diff --git a/.github/workflows/.tests-python.yml b/.github/workflows/.tests-python.yml index a4c578fd8..ab9dfce98 100644 --- a/.github/workflows/.tests-python.yml +++ b/.github/workflows/.tests-python.yml @@ -13,6 +13,7 @@ env: EMAIL_URL: "smtp://none:none@0.0.0.0:1025" FLASK_APP: fittrackee/__main__.py SENDER_EMAIL: fittrackee@example.com + UI_URL: https://0.0.0.0:5000 jobs: python: diff --git a/Makefile b/Makefile index b19c37597..b25f736f0 100644 --- a/Makefile +++ b/Makefile @@ -92,8 +92,12 @@ docker-serve-client: docker compose -f docker-compose-dev.yml exec fittrackee_client $(NPM) dev docker-set-admin: + # deprecated command docker compose -f docker-compose-dev.yml exec fittrackee ftcli users update $(USERNAME) --set-admin true +docker-set-role: + docker compose -f docker-compose-dev.yml exec fittrackee ftcli users update $(USERNAME) --set-role $(ROLE) + docker-shell: docker compose -f docker-compose-dev.yml exec fittrackee docker/shell.sh @@ -239,10 +243,6 @@ serve-python-dev: echo 'Running on https://$(HOST):$(PORT)' $(FLASK) run --with-threads -h $(HOST) -p $(PORT) --cert=adhoc -set-admin: - echo "Deprecated command, will be removed in a next version. Use 'user-set-admin' instead." - $(FTCLI) users update $(USERNAME) --set-admin true - test-all: test-client test-python test-e2e: @@ -252,6 +252,11 @@ test-e2e-client: E2E_ARGS=client $(PYTEST) e2e --driver firefox $(PYTEST_ARGS) test-python: + # for tests parallelization: 4 workers max. + # make test-python PYTEST_ARGS="-p no:warnings -n auto --maxprocesses=4" + $(PYTEST) fittrackee $(PYTEST_ARGS) + +test-python-cov: # for tests parallelization: 4 workers max. # make test-python PYTEST_ARGS="-p no:warnings -n auto --maxprocesses=4" $(PYTEST) fittrackee --cov-config .coveragerc --cov=fittrackee --cov-report term-missing $(PYTEST_ARGS) @@ -282,7 +287,11 @@ user-reset-password: ADMIN := true user-set-admin: + # deprecated command $(FTCLI) users update $(USERNAME) --set-admin $(ADMIN) +user-set-role: + $(FTCLI) users update $(USERNAME) --set-role $(ROLE) + user-update-email: $(FTCLI) users update $(USERNAME) --update-email $(EMAIL) diff --git a/README.md b/README.md index 65e90ea2c..752e6cbac 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Map data from [OpenStreetMap](https://www.openstreetmap.org). **Under heavy development (some features may be unstable).** (see [issues](https://github.com/SamR1/FitTrackee/issues) and [documentation](https://samr1.github.io/FitTrackee) for more info.) -![FitTrackee Dashboard Screenshot](https://samr1.github.io/FitTrackee/en/_images/fittrackee_screenshot-01.png) +![FitTrackee Dashboard Screenshot](https://samr1.github.io/FitTrackee/en/_images/dashboard.png) ## Translations diff --git a/docs/en/_images/administration-menu.png b/docs/en/_images/administration-menu.png new file mode 100644 index 000000000..f37628b3b Binary files /dev/null and b/docs/en/_images/administration-menu.png differ diff --git a/docs/en/_images/dashboard.png b/docs/en/_images/dashboard.png new file mode 100644 index 000000000..9b39669e0 Binary files /dev/null and b/docs/en/_images/dashboard.png differ diff --git a/docs/en/_images/equipment-detail.png b/docs/en/_images/equipment-detail.png new file mode 100644 index 000000000..c670ea06b Binary files /dev/null and b/docs/en/_images/equipment-detail.png differ diff --git a/docs/en/_images/equipments-list.png b/docs/en/_images/equipments-list.png new file mode 100644 index 000000000..d9575b364 Binary files /dev/null and b/docs/en/_images/equipments-list.png differ diff --git a/docs/en/_images/fittrackee_screenshot-01.png b/docs/en/_images/fittrackee_screenshot-01.png deleted file mode 100644 index 64b285df2..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-01.png and /dev/null differ diff --git a/docs/en/_images/fittrackee_screenshot-02.png b/docs/en/_images/fittrackee_screenshot-02.png deleted file mode 100644 index 62063c1af..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-02.png and /dev/null differ diff --git a/docs/en/_images/fittrackee_screenshot-03.png b/docs/en/_images/fittrackee_screenshot-03.png deleted file mode 100644 index a01d55838..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-03.png and /dev/null differ diff --git a/docs/en/_images/fittrackee_screenshot-04.png b/docs/en/_images/fittrackee_screenshot-04.png deleted file mode 100644 index 891ddeaca..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-04.png and /dev/null differ diff --git a/docs/en/_images/fittrackee_screenshot-05.png b/docs/en/_images/fittrackee_screenshot-05.png deleted file mode 100644 index e875f94c9..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-05.png and /dev/null differ diff --git a/docs/en/_images/fittrackee_screenshot-06.png b/docs/en/_images/fittrackee_screenshot-06.png deleted file mode 100644 index 28b6394c5..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-06.png and /dev/null differ diff --git a/docs/en/_images/fittrackee_screenshot-07.png b/docs/en/_images/fittrackee_screenshot-07.png deleted file mode 100644 index aac958342..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-07.png and /dev/null differ diff --git a/docs/en/_images/fittrackee_screenshot-08.png b/docs/en/_images/fittrackee_screenshot-08.png deleted file mode 100644 index 6d496cda6..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-08.png and /dev/null differ diff --git a/docs/en/_images/fittrackee_screenshot-09.png b/docs/en/_images/fittrackee_screenshot-09.png deleted file mode 100644 index 980512de0..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-09.png and /dev/null differ diff --git a/docs/en/_images/fittrackee_screenshot-10.png b/docs/en/_images/fittrackee_screenshot-10.png deleted file mode 100644 index 94ca28350..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-10.png and /dev/null differ diff --git a/docs/en/_images/fittrackee_screenshot-11.png b/docs/en/_images/fittrackee_screenshot-11.png deleted file mode 100644 index 01c18fab1..000000000 Binary files a/docs/en/_images/fittrackee_screenshot-11.png and /dev/null differ diff --git a/docs/en/_images/notifications.png b/docs/en/_images/notifications.png new file mode 100644 index 000000000..0bdb63646 Binary files /dev/null and b/docs/en/_images/notifications.png differ diff --git a/docs/en/_images/oauth2-app-authorization.png b/docs/en/_images/oauth2-app-authorization.png new file mode 100644 index 000000000..43d4ef484 Binary files /dev/null and b/docs/en/_images/oauth2-app-authorization.png differ diff --git a/docs/en/_images/oauth2-client-creation.png b/docs/en/_images/oauth2-client-creation.png new file mode 100644 index 000000000..899f0755c Binary files /dev/null and b/docs/en/_images/oauth2-client-creation.png differ diff --git a/docs/en/_images/sports-administration.png b/docs/en/_images/sports-administration.png new file mode 100644 index 000000000..5b812ed01 Binary files /dev/null and b/docs/en/_images/sports-administration.png differ diff --git a/docs/en/_images/statistics-by-sport.png b/docs/en/_images/statistics-by-sport.png new file mode 100644 index 000000000..ee61232d3 Binary files /dev/null and b/docs/en/_images/statistics-by-sport.png differ diff --git a/docs/en/_images/statistics-by-time-period.png b/docs/en/_images/statistics-by-time-period.png new file mode 100644 index 000000000..fec1e2471 Binary files /dev/null and b/docs/en/_images/statistics-by-time-period.png differ diff --git a/docs/en/_images/users-directory.png b/docs/en/_images/users-directory.png new file mode 100644 index 000000000..e8aa07e3c Binary files /dev/null and b/docs/en/_images/users-directory.png differ diff --git a/docs/en/_images/workout-detail.png b/docs/en/_images/workout-detail.png new file mode 100644 index 000000000..cba7da0d9 Binary files /dev/null and b/docs/en/_images/workout-detail.png differ diff --git a/docs/en/_images/workouts-list.png b/docs/en/_images/workouts-list.png new file mode 100644 index 000000000..1f69bef21 Binary files /dev/null and b/docs/en/_images/workouts-list.png differ diff --git a/docs/en/_sources/api/auth.rst.txt b/docs/en/_sources/api/auth.rst.txt index e4500df10..2d6b1ef44 100644 --- a/docs/en/_sources/api/auth.rst.txt +++ b/docs/en/_sources/api/auth.rst.txt @@ -22,4 +22,9 @@ Authentication and account auth.accept_privacy_policy, auth.get_user_data_export, auth.request_user_data_export, - auth.download_data_export \ No newline at end of file + auth.download_data_export, + auth.get_blocked_users, + auth.get_user_suspension, + auth.appeal_user_suspension, + auth.get_user_sanction, + auth.appeal_user_sanction \ No newline at end of file diff --git a/docs/en/_sources/api/comments.rst.txt b/docs/en/_sources/api/comments.rst.txt new file mode 100644 index 000000000..f1d3862da --- /dev/null +++ b/docs/en/_sources/api/comments.rst.txt @@ -0,0 +1,14 @@ +Comments +######## + +.. autoflask:: fittrackee:create_app() + :endpoints: + comments.get_workout_comments, + comments.get_workout_comment, + comments.post_workout_comment, + comments.update_workout_comment, + comments.like_comment, + comments.undo_comment_like, + comments.appeal_comment_suspension, + comments.delete_workout_comment + diff --git a/docs/en/_sources/api/follow_requests.rst.txt b/docs/en/_sources/api/follow_requests.rst.txt new file mode 100644 index 000000000..532e98ea9 --- /dev/null +++ b/docs/en/_sources/api/follow_requests.rst.txt @@ -0,0 +1,8 @@ +Follow requests +############### + +.. autoflask:: fittrackee:create_app() + :endpoints: + follow_requests.get_follow_requests, + follow_requests.accept_follow_request, + follow_requests.reject_follow_request \ No newline at end of file diff --git a/docs/en/_sources/api/index.rst.txt b/docs/en/_sources/api/index.rst.txt index e4c1ef0ff..88102ea04 100644 --- a/docs/en/_sources/api/index.rst.txt +++ b/docs/en/_sources/api/index.rst.txt @@ -7,11 +7,16 @@ API documentation auth configuration + comments equipments equipment_types + follow_requests oauth2 + notifications records + reports sports stats + timeline users workouts diff --git a/docs/en/_sources/api/notifications.rst.txt b/docs/en/_sources/api/notifications.rst.txt new file mode 100644 index 000000000..35d07846d --- /dev/null +++ b/docs/en/_sources/api/notifications.rst.txt @@ -0,0 +1,10 @@ +Notifications +############# + +.. autoflask:: fittrackee:create_app() + :endpoints: + notifications.get_auth_user_notifications, + notifications.update_user_notifications, + notifications.get_status, + notifications.mark_all_as_read, + notifications.get_notification_types \ No newline at end of file diff --git a/docs/en/_sources/api/reports.rst.txt b/docs/en/_sources/api/reports.rst.txt new file mode 100644 index 000000000..3c9adcb37 --- /dev/null +++ b/docs/en/_sources/api/reports.rst.txt @@ -0,0 +1,14 @@ +Reports +######## + +.. autoflask:: fittrackee:create_app() + :endpoints: + reports.get_reports, + reports.get_report, + reports.get_unresolved_reports_status, + reports.create_report, + reports.update_report, + reports.create_action, + reports.process_appeal + + diff --git a/docs/en/_sources/api/timeline.rst.txt b/docs/en/_sources/api/timeline.rst.txt new file mode 100644 index 000000000..88baa077b --- /dev/null +++ b/docs/en/_sources/api/timeline.rst.txt @@ -0,0 +1,6 @@ +Timeline +######## + +.. autoflask:: fittrackee:create_app() + :endpoints: + timeline.get_user_timeline diff --git a/docs/en/_sources/api/users.rst.txt b/docs/en/_sources/api/users.rst.txt index c06c6c140..f3ae5ae31 100644 --- a/docs/en/_sources/api/users.rst.txt +++ b/docs/en/_sources/api/users.rst.txt @@ -7,4 +7,11 @@ Users users.get_single_user, users.get_picture, users.update_user, - users.delete_user + users.delete_user, + users.follow_user, + users.unfollow_user, + users.get_followers, + users.get_following, + users.block_user, + users.unblock_user, + users.get_user_sanctions diff --git a/docs/en/_sources/api/workouts.rst.txt b/docs/en/_sources/api/workouts.rst.txt index a07264864..8c15e90c2 100644 --- a/docs/en/_sources/api/workouts.rst.txt +++ b/docs/en/_sources/api/workouts.rst.txt @@ -15,4 +15,7 @@ Workouts workouts.post_workout, workouts.post_workout_no_gpx, workouts.update_workout, - workouts.delete_workout + workouts.delete_workout, + workouts.like_workout, + workouts.undo_workout_like, + workouts.appeal_workout_suspension diff --git a/docs/en/_sources/cli.rst.txt b/docs/en/_sources/cli.rst.txt index 175e897bc..274b93b83 100644 --- a/docs/en/_sources/cli.rst.txt +++ b/docs/en/_sources/cli.rst.txt @@ -97,6 +97,7 @@ Remove blacklisted tokens expired for more than provided number of days. ``ftcli users create`` """""""""""""""""""""" .. versionadded:: 0.7.15 +.. versionchanged:: 0.8.4 User preference for interface language is added. Create a user account. @@ -104,9 +105,6 @@ Create a user account. - the newly created account is already active. - the CLI allows to create users when registration is disabled. -.. versionchanged:: 0.8.4 - -User preference for interface language is added. .. cssclass:: table-bordered .. list-table:: @@ -147,8 +145,9 @@ Can be used if redis is not set (no dramatiq workers running). ``ftcli users update`` """""""""""""""""""""" .. versionadded:: 0.6.5 +.. versionchanged:: 0.9.0 Add ``--set-role`` option. ``--set-admin`` is now deprecated. -Modify a user account (admin rights, active status, email and password). +Modify a user account (role, active status, email and password). .. cssclass:: table-bordered .. list-table:: @@ -160,7 +159,9 @@ Modify a user account (admin rights, active status, email and password). * - ``USERNAME`` - Username. * - ``--set-admin BOOLEAN`` - - Add/remove admin rights (when adding admin rights, it also activates user account if not active). + - [DEPRECATED] Add/remove admin rights (when adding admin rights, it also activates user account if not active). + * - ``--set-role ROLE`` + - Set user role (when setting 'moderator', 'admin' and 'owner' role, it also activates user account if not active). * - ``--activate`` - Activate user account. * - ``--reset-password`` diff --git a/docs/en/_sources/features.rst.txt b/docs/en/_sources/features.rst.txt index 2c04e5e3c..471628e65 100644 --- a/docs/en/_sources/features.rst.txt +++ b/docs/en/_sources/features.rst.txt @@ -3,7 +3,7 @@ Features | **FitTrackee** allows you to store and display **gpx** files and some statistics from your **outdoor** activities. | Equipments can be associated with workouts. -| For now, this app is kind of a single-user application. Even if several users can register, a user can only view his own workouts. +| If registration is enabled, multiple users can register and interact with other users (comments, likes). Workouts and comments are visible to other users according to visibility levels. Gpx files are stored in an upload directory (**without encryption**). @@ -11,37 +11,43 @@ With the default configuration, `Open Street Map Workouts -^^^^^^^^ +======== + +Sports +------ + - 18 sports are supported: - - Cycling (Sport) - - Cycling (Transport) - - Cycling (Trekking) (*new in 0.7.27*) - - Cycling (Virtual) (*new in 0.7.3*) - - Hiking - - Mountain Biking - - Mountain Biking (Electric) (*new in 0.5.0*) - - Mountaineering (*new in 0.7.9*) - - Open Water Swimming (*new in 0.7.20*) - - Paragliding (*new in 0.7.19*) - - Rowing (*new in 0.5.0*) - - Running - - Skiing (Alpine) (*new in 0.5.0*) - - Skiing (Cross Country) (*new in 0.5.0*) - - Snowshoes (*new in 0.5.2*) - - Swimrun (*new in 0.8.7*) - - Trail (*new in 0.5.0*) - - Walking + + - Cycling (Sport) + - Cycling (Transport) + - Cycling (Trekking) (*new in 0.7.27*) + - Cycling (Virtual) (*new in 0.7.3*) + - Hiking + - Mountain Biking + - Mountain Biking (Electric) (*new in 0.5.0*) + - Mountaineering (*new in 0.7.9*) + - Open Water Swimming (*new in 0.7.20*) + - Paragliding (*new in 0.7.19*) + - Rowing (*new in 0.5.0*) + - Running + - Skiing (Alpine) (*new in 0.5.0*) + - Skiing (Cross Country) (*new in 0.5.0*) + - Snowshoes (*new in 0.5.2*) + - Swimrun (*new in 0.8.7*) + - Trail (*new in 0.5.0*) + - Walking - (*new in 0.5.0*) Stopped speed threshold used by `gpxpy `_ is not the default one for the following sports (0.1 km/h instead of 1 km/h): - - Hiking - - Mountaineering - - Open Water Swimming - - Paragliding - - Skiing (Cross Country) - - Snowshoes - - Swimrun - - Trail - - Walking + + - Hiking + - Mountaineering + - Open Water Swimming + - Paragliding + - Skiing (Cross Country) + - Snowshoes + - Swimrun + - Trail + - Walking .. note:: It can be overridden in user preferences. @@ -50,8 +56,14 @@ Workouts | Except the stopped speed threshold, all sports are analyzed in the same way (no specificity taken into account for the moment). | Swimrun is displayed as a single activity with no difference between segments for now. -- Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts. -- Workout creation by uploading manually a gpx file or a zip archive containing a limited number of gpx files (related data are stored in database in metric system). +Workouts +-------- + +- A workout can be created by: + + - uploading manually a gpx file or a zip archive containing a limited number of gpx files, + - | or entering date, time, duration and distance (without gpx file). + | Ascent and descent can also be provided (*new in 0.7.10*). .. warning:: | Only **gpx** files with time and elevation are supported (otherwise, errors may occur on upload). @@ -59,89 +71,204 @@ Workouts .. note:: | Calculated values may differ from values calculated by the application that originally generated the gpx files, in particular the maximum speed. +.. note:: + | Related data are stored in database in metric system. + .. note:: | For now, **FitTrackee** has no importer, but some `third-party tools `__ allow you to import workouts. - | If the name is present in the gpx file (````), it is used as the workout title. Otherwise, a title is generated from the sport and workout date. | User can add title while uploading gpx file (*new in 0.8.10*). -- | The user can add description (*new in 0.8.9*) and note. - -.. note:: - | This "Description" field is longer than the "Notes" field and will have the same visibility as the workout in a next version (see `#125 `__). The "Notes" field will remain private. - +- | The user can add description (*new in 0.8.9*) and private notes. + | A limited Markdown syntax can be used (*new in 0.9.0*). - If present and no description is provided by the user, the description from the gpx file (````) is used as the workout description (*new in 0.8.10*). -- | A workout can even be created without gpx (the user must enter date, time, duration and distance). - | Ascent and descent can also be provided (*new in 0.7.10*). - | A workout with a gpx file can be displayed with map and charts (speed and elevation (if the gpx file contains elevation data, *updated in 0.7.20*)). | Controls allow full screen view and position reset (*new in 0.5.5*). - | If **Visual Crossing** (*new in 0.7.11*) API key is provided, weather is displayed in workout detail. Data source is displayed in **About** page. | Wind is displayed, with an arrow indicating the direction (a tooltip can be displayed with the direction that the wind is coming **from**) (*new in 0.5.5*). -- An `equipment `__ can be associated with a workout (*new in 0.8.0*). For now, only one equipment can be associated. +- | An `equipment `__ can be associated with a workout (*new in 0.8.0*). For now, only one equipment can be associated. + | Equipment is only visible to workout owner. - Segments can be displayed. -- Workout gpx file can be downloaded (*new in 0.5.1*) -- Workout edition and deletion. -- User statistics, by time period (week, month, year) and sport: - - totals: - - total distance - - total duration - - total workouts - - total ascent (*new in 0.5.0*) - - total descent (*new in 0.5.0*) - - averages: - - average speed (*new in 0.5.1*) - - average distance (*new in 0.8.5*) - - average duration (*new in 0.8.5*) - - average workouts (*new in 0.8.5*) - - average ascent (*new in 0.8.5*) - - average descent (*new in 0.8.5*) -- User statistics by sport (*new in 0.8.5*): - - total workouts - - distance (total and average) - - duration (total and average) - - average speed - - ascent (total and average) - - descent (total and average) - - records +- Records associated with the workout are displayed. .. note:: - | There is a limit on the number of workouts used to calculate statistics to avoid performance issues. The value can be set in administration. - | If the limit is reached, the number of workouts used is displayed. - | The total number of workouts for a given sport is not affected by this limit. + Records may differ from records displayed by the application that originally generated the gpx files. + +- Visibility level can be set separately for workout data and map and analysis data (*new in 0.9.0*): + + - private: only owner can see the workout, + - followers only: only owner and followers can see the workout, + - public: anyone can see the workout even unauthenticated users. + +.. note:: + | A workout with a gpx file whose visibility for map and analysis data does not allow them to be viewed appears as a workout without a gpx file. + +.. note:: + | Default visibility is private. All workouts created before **FitTrackee** 0.9.0 are private. + +.. important:: + | Please keep in mind that the server operating team or the moderation team may view content with restricted visibility. + +- Workout can be edited: + + - sport + - title + - equipment + - description (*new in 0.8.9*) + - private notes + - workout visibility (*new in 0.9.0*) + - map and analysis visibility (*new in 0.9.0*) + - date (only workouts without gpx) + - duration (only workouts without gpx) + - distance (only workouts without gpx) + - ascent and descent (only workouts without gpx) (*new in 0.7.10*) + +- Workout gpx file can be downloaded (*new in 0.5.1*). +- Workout can be deleted. +- Workouts list. + + - The user can filter workouts on: + + - date + - sports (only sports with workouts are displayed in sport dropdown) + - equipment (only equipments with workouts are displayed in equipment dropdown) (*new in 0.8.0*) + - title (*new in 0.7.15*) + - description (*new in 0.8.9*) + - notes (*new in 0.8.0*) + - distance + - duration + - average speed + - maximum speed + + - Workouts can be sorted by: + + - date + - distance + - duration + - average speed + +- A user can report a workout that violates instance rules. This will send a notification to moderators and administrators. + +Interactions +============ + +.. versionadded:: 0.9.0 + +Users +----- +- | Users directory. + | A user can configure visibility in directory in the user preferences. + | This has an impact on username completion when writing comments (only profiles visible in users directory or followed users are suggested). + +.. note:: + A user profile remains accessible via its URL. + +- | User can send follow request to others users. + | Follow request can be approved or rejected. +- | In order to hide unwanted content, a user can block another user. + | Blocking users hides their workouts on timeline and comments. Notifications from blocked users are not displayed. + | Blocked users cannot see workouts and comments from users who have blocked them, or follow them (if they followed them, they are forced to unfollow them). +- A user can report a user profile that violates instance rules. This will send a notification to moderators and administrators. + +Comments +-------- + +- | Depending on visibility, a user can comment on a workout. + | A limited Markdown syntax can be used. +- The visibility levels for comment are: + + - private: only author and mentioned users can see the comment, + - followers only: only author, followers and mentioned users can see the comment, + - public: anyone can see the comment even unauthenticated users. + +.. important:: + | Please keep in mind that the server operating team or the moderation team may view content with restricted visibility. + +- Comment text can be modified (visibility level cannot be changed). +- A user can report a comment that violates instance rules. This will send a notification to moderators and administrators. + +Likes +----- + +- Depending on visibility, a user can like or "unlike" a workout or a comment. + +Notifications +------------- + +- Notifications are sent for the following event: + + - follow request and follow + - like on comment or workout + - comment on workout + - mention on comment + - suspension or warning (an email is also sent if email sending is enabled) + - suspension or warning lifting (an email is also sent if email sending is enabled) + +- Users with moderation rights can also receive notifications on: + + - report creation + - appeal on suspension or warning + +- Users with administration rights can also receive notifications on user creation. +- Users can mark notifications as read or unread. + +Dashboard +========= + +- A dashboard displays: + + - a graph with monthly statistics + - a monthly calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts. + - user records by sports: -- User records by sports: - average speed - farthest distance - highest ascent (*new in 0.6.11*, can be hidden, see user preferences) - longest duration - maximum speed -.. note:: - Records may differ from records displayed by the application that originally generated the gpx files. + - a timeline with workouts visible to user -- Workouts list. - - The user can filter workouts on: - - date - - sports (only sports with workouts are displayed in sport dropdown) - - equipment (only equipments with workouts are displayed in equipment dropdown) (*new in 0.8.0*) - - title (*new in 0.7.15*) - - description (*new in 0.8.9*) - - notes (*new in 0.8.0*) - - distance - - duration - - average speed - - maximum speed - - Workouts can be sorted by: - - date - - distance - - duration - - average speed +Statistics +========== -.. note:: - For now, only the owner of the workout can see it. +- User statistics, by time period (week, month, year) and sport: + - totals: + + - total distance + - total duration + - total workouts + - total ascent (*new in 0.5.0*) + - total descent (*new in 0.5.0*) + + - averages: + + - average speed (*new in 0.5.1*) + - average distance (*new in 0.8.5*) + - average duration (*new in 0.8.5*) + - average workouts (*new in 0.8.5*) + - average ascent (*new in 0.8.5*) + - average descent (*new in 0.8.5*) + +- User statistics by sport (*new in 0.8.5*): + + - total workouts + - distance (total and average) + - duration (total and average) + - average speed + - ascent (total and average) + - descent (total and average) + - records + +.. note:: + | There is a limit on the number of workouts used to calculate statistics to avoid performance issues. The value can be set in administration. + | If the limit is reached, the number of workouts used is displayed. + | The total number of workouts for a given sport is not affected by this limit. Account & preferences -^^^^^^^^^^^^^^^^^^^^^ +===================== + - A user can create, update and deleted his account. - The user must agree to the privacy policy to register. If a more recent policy is available, a message is displayed on the dashboard to review the new version (*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*). @@ -154,6 +281,8 @@ Account & preferences - A user can reset his password (*new in 0.3.0*) - A user can change his email address (*new in 0.6.0*) - A user can set language, timezone and first day of week. +- A user can set follow requests approval: manually (default) or automatically. (*new in 0.9.0*) +- A user can set profile visibility in users directory: hidden (default) or displayed (*new in 0.9.0*) - A user can set the interface theme (light, dark or according to browser preferences) (*new in 0.7.27*). - A user can choose between metric system and imperial system for distance, elevation and speed display (*new in 0.5.0*) - A user can choose to display or hide ascent records and total on Dashboard (*new in 0.6.11*) @@ -164,11 +293,13 @@ Account & preferences .. note:: Changing this preference will only affect next file uploads. +- A user can set default visibility for workout data and map and analysis (*new in 0.9.0*). - A user can set sport preferences (*new in 0.5.0*): - - change sport color (used for sport image and charts) - - can override stopped speed threshold (for next uploaded gpx files) - - disable/enable a sport - - define default `equipments `__ (*new in 0.8.0*). + + - change sport color (used for sport image and charts) + - can override stopped speed threshold (for next uploaded gpx files) + - disable/enable a sport + - define default `equipments `__ (*new in 0.8.0*). .. note:: | If a sport is disabled by an administrator, it can not be enabled by a user. In this case, it will only appear in preferences if the user has workouts and only sport color can be changed. @@ -181,24 +312,32 @@ Account & preferences .. note:: For now, it's not possible to import these files into another **FitTrackee** instance. +- A user can display blocked users (*new in 0.9.0*). +- A user can view follow requests to approve or reject (*new in 0.9.0*). +- A user can view received sanctions and appeal (*new in 0.9.0*). + Equipments -^^^^^^^^^^ -(*new in 0.8.0*) +========== + +.. versionadded:: 0.8.0 - A user can create equipments that can be associated with workouts. - The following equipment types are available, depending on the sport: - - Shoes: Hiking, Mountaineering, Running, Trail and Walking, - - Bike: Cycling (Sport, Transport, Trekking), Mountain Biking and Mountain Biking (Electric), - - Bike Trainer: Cycling (Virtual), - - Kayak/Boat: Rowing, - - Skis: Skiing (Alpine and Cross Country), - - Snowshoes: Snowshoes. + + - Shoes: Hiking, Mountaineering, Running, Trail and Walking, + - Bike: Cycling (Sport, Transport, Trekking), Mountain Biking and Mountain Biking (Electric), + - Bike Trainer: Cycling (Virtual), + - Kayak/Boat: Rowing, + - Skis: Skiing (Alpine and Cross Country), + - Snowshoes: Snowshoes. + - Equipment is visible only to its owner. - For now only, only one piece of equipment can be associated with a workout. - Following totals are displayed for each piece of equipment: - - total distance - - total duration - - total workouts + + - total distance + - total duration + - total workouts .. note:: | In case of an incorrect total (although this should not happen), it is possible to recalculate totals. @@ -215,19 +354,24 @@ Equipments | An equipment type can be deactivated by an administrator. OAuth Apps -^^^^^^^^^^ -(*new in 0.7.0*) +=========== + +.. versionadded:: 0.7.0 - A user can create `clients `__ for third-party applications. Administration -^^^^^^^^^^^^^^ -(*new in 0.3.0*) +============== + +.. versionadded:: 0.3.0 Application -""""""""""" +----------- -**Configuration** +- Only users if administration rights can access application administration. + +Configuration +~~~~~~~~~~~~~ The following parameters can be set: @@ -244,17 +388,18 @@ The following parameters can be set: .. note:: If email sending is disabled, a warning is displayed. -**About** +About +~~~~~ -(*new in 0.7.13*) +.. versionadded:: 0.7.13 -| It is possible displayed additional information that may be useful to users in **About** page. +| It is possible displayed additional information that may be useful to users in **About** page (like instance rules). | Markdown syntax can be used. +Privacy policy +~~~~~~~~~~~~~~ -**Privacy policy** - -(*new in 0.7.13*) +.. versionadded:: 0.7.13 | A default privacy policy is available (originally adapted from the `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 it. @@ -263,30 +408,93 @@ The following parameters can be set: Only the default privacy policy is translated (if the translation is available). Users -""""" +----- + +.. versionchanged:: 0.9.0 Add moderator and owner role + +- Only users with administration rights can access users administration. +- Roles: + + - user + + - no moderation or administration rights + + - moderator (*new in 0.9.0*): + + - can only access moderation entry in administration + - can see reports + - perform report actions + + - administrator + + - has moderator rights (*new in 0.9.0*) + - can access all entries in administration: + + - application + - moderation + - equipment types + - sports + - users + + - owner (*new in 0.9.0*) : + + - has admin rights + - role can not be modified by other administrator/owner on application + +.. note:: + + Roles defined prior to version 0.9.0 remain unchanged. - display and filter users list - edit a user to: - - add/remove administration rights + - update role (*updated in 0.9.0*). A user with owner role can not be modified by other users. Owner role can only be assigned or removed with **FitTrackee** CLI. - 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 +Moderation +---------- + +.. versionadded:: 0.9.0 + +- Only users with administration or moderation rights can access moderation. +- Display and filter reports list. +- Manage a report: + + - add a comment + - send a warning + - suspend or reactive workout or comment + - suspend or reactive user account + - mark report as resolved or unresolved + +.. note:: + Report content is visible regardless the visibility level. + +- A user can appeal suspension or warning. +- Suspended user can only access his account, appeal the account suspension, request and data export or delete his account. His sessions and comments are no longer visible. + Equipment Types -""""""""""""""" -- enable or disable an equipment type in order to match disabled sports (a equipment type can be disabled even if equipment with this type exists) (*new in 0.8.0*) +--------------- + +.. versionadded:: 0.8.0 + +- Only users with administration rights can access equipment types administration. +- enable or disable an equipment type in order to match disabled sports (a equipment type can be disabled even if equipment with this type exists) (*new in 0.8.0*). Sports -"""""" -- enable or disable a sport (a sport can be disabled even if workout with this sport exists) +------ +- Only users with administration rights can access sports administration. +- Enable or disable a sport (a sport can be disabled even if workout with this sport exists). Translations -^^^^^^^^^^^^ +============ + FitTrackee is available in the following languages (which can be saved in the user preferences): - English @@ -310,48 +518,67 @@ Application translations status on `Weblate `__ for more information) -.. figure:: _images/fittrackee_screenshot-01.png +.. figure:: _images/dashboard.png :alt: FitTrackee Dashboard diff --git a/docs/en/_sources/installation.rst.txt b/docs/en/_sources/installation.rst.txt index 33a2cc03e..c370fb57f 100644 --- a/docs/en/_sources/installation.rst.txt +++ b/docs/en/_sources/installation.rst.txt @@ -265,6 +265,9 @@ deployment method. Emails ~~~~~~ .. versionadded:: 0.3.0 +.. versionchanged:: 0.5.3 Credentials and port can be omitted +.. versionchanged:: 0.6.5 Disable email sending +.. versionchanged:: 0.7.24 Handle special characters in password To send emails, a valid ``EMAIL_URL`` must be provided: @@ -272,15 +275,16 @@ To send emails, a valid ``EMAIL_URL`` must be provided: - with SSL: ``smtp://username:password@smtp.example.com:465/?ssl=True`` - with STARTTLS: ``smtp://username:password@smtp.example.com:587/?tls=True`` +Credentials can be omitted: ``smtp://smtp.example.com:25``. +If ``:`` is omitted, the port defaults to 25. + +Password can be encoded if it contains special characters. +For instance with password ``passwordwith@and&and?``, the encoded password will be: ``passwordwith%40and%26and%3F``. + .. warning:: | If the email URL is invalid, the application may not start. | Sending emails with Office365 may not work if SMTP auth is disabled. -.. versionchanged:: 0.5.3 - -| Credentials can be omitted: ``smtp://smtp.example.com:25``. -| If ``:`` is omitted, the port defaults to 25. - .. warning:: | Since 0.6.0, newly created accounts must be confirmed (an email with confirmation instructions is sent after registration). @@ -291,22 +295,21 @@ Emails sent by FitTrackee are: - email change (to old and new email addresses) - password change - notification when a data export archive is ready to download (*new in 0.7.13*) +- suspension and warning (*new in 0.9.0*) +- suspension and warning lifting (*new in 0.9.0*) +- rejected appeal (*new in 0.9.0*) -.. 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). +On 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 `__ is available to activate account, modify email and password and handle data export requests. -.. versionchanged:: 0.7.24 - -Password can be encoded if it contains special characters. -For instance with password ``passwordwith@and&and?``, the encoded password will be: ``passwordwith%40and%26and%3F``. - Map tile server ~~~~~~~~~~~~~~~ .. versionadded:: 0.4.0 +.. versionchanged:: 0.6.10 Handle tile server subdomains +.. versionchanged:: 0.7.23 Default tile server (**OpenStreetMap**) no longer requires subdomains Default tile server is now **OpenStreetMap**'s standard tile layer (if environment variables are not initialized). The tile server can be changed by updating ``TILE_SERVER_URL`` and ``MAP_ATTRIBUTION`` variables (`list of tile servers `__). @@ -319,9 +322,6 @@ To keep using **ThunderForest Outdoors**, the configuration is: .. note:: | Check the terms of service of tile provider for map attribution. - -.. versionchanged:: 0.6.10 - Since the tile server can be used for static map generation, some servers require a subdomain. For instance, to set OSM France tile server, the expected values are: @@ -332,9 +332,7 @@ For instance, to set OSM France tile server, the expected values are: The subdomain will be chosen randomly. -.. versionadded:: 0.7.23 - -The default URL is updated: **OpenStreetMap**'s tile server no longer requires subdomains. +The default tile server (**OpenStreetMap**) no longer requires subdomains. API rate limits @@ -375,20 +373,20 @@ API rate limits Weather data ~~~~~~~~~~~~ -.. versionchanged:: 0.7.11 +.. versionchanged:: 0.7.11 Add Visual Crossing to weather providers +.. versionchanged:: 0.7.15 Remove Darksky from weather providers The following weather data providers are supported by **FitTrackee**: - `Visual Crossing `__ (**note**: historical data are provided on hourly period) -To configure a weather provider, set the following environment variables: - -- ``WEATHER_API_KEY``: the key to the corresponding weather provider +.. note:: + **DarkSky** support is discontinued, since the service shut down on March 31, 2023. -.. versionchanged:: 0.7.15 +To configure a weather provider, set the following environment variables: -**DarkSky** support is discontinued, since the service shut down on March 31, 2023. +- ``WEATHER_API_KEY``: the key to the corresponding weather provider Installation @@ -456,11 +454,11 @@ For instance, copy and update ``.env`` file from ``.env.example`` and source the - Open http://localhost:5000 and register -- To set admin rights to the newly created account, use the following command line: +- To set owner role to the newly created account, use the following command line: .. code:: bash - $ ftcli users update --set-admin true + $ ftcli users update --set-role owner .. note:: If the user account is inactive, it activates it. @@ -514,11 +512,11 @@ Dev environment - Open http://localhost:3000 and register -- To set admin rights to the newly created account, use the following command line: +- To set owner role to the newly created account, use the following command line: .. code:: bash - $ make user-set-admin USERNAME= + $ make user-set-role USERNAME= ROLE=owner .. note:: If the user account is inactive, it activates it. @@ -565,11 +563,11 @@ Production environment - Open http://localhost:5000 and register -- To set admin rights to the newly created account, use the following command line: +- To set owner role to the newly created account, use the following command line: .. code:: bash - $ make user-set-admin USERNAME= + $ make user-set-role USERNAME= ROLE=owner .. note:: If the user account is inactive, it activates it. @@ -749,10 +747,10 @@ Examples: WantedBy=multi-user.target -.. note:: +.. seealso:: To handle large files, a higher value for `timeout `__ can be set. -.. note:: +.. seealso:: More information on deployment with Gunicorn in its `documentation `__. - for task queue workers: ``fittrackee_workers.service`` @@ -827,7 +825,7 @@ Examples: } } -.. note:: +.. seealso:: If needed, update configuration to handle larger files (see `client_max_body_size `_). @@ -857,11 +855,11 @@ For **evaluation** purposes, docker files are available, installing **FitTrackee Open http://localhost:8025 to access `MailHog interface `_ (email testing tool) -- To set admin rights to the newly created account, use the following command line: +- To set owner role to the newly created account, use the following command line: .. code:: bash - $ make docker-set-admin USERNAME= + $ make docker-set-role USERNAME= ROLE=owner .. note:: If the user account is inactive, it activates it. diff --git a/docs/en/_sources/oauth.rst.txt b/docs/en/_sources/oauth.rst.txt index e9b4d8c10..bf5531cfc 100644 --- a/docs/en/_sources/oauth.rst.txt +++ b/docs/en/_sources/oauth.rst.txt @@ -1,6 +1,7 @@ OAuth 2.0 ######### -(*new in 0.7.0*) + +.. versionadded:: 0.7.0 FitTrackee provides a REST API (see `documentation `__) whose most endpoints require authentication/authorization. @@ -28,12 +29,18 @@ The following scopes are available: - ``application:write``: grants write access to application configuration (only for users with administration rights), - ``equipments:read``: grants read access to equipments endpoints (*new in 0.8.0*), - ``equipments:write``: grants write access to equipments endpoints (*new in 0.8.0*), +- ``follow:read``: grants read access to follow requests and followers endpoints (*new in 0.9.0*), +- ``follow:write``: grants write access to requests and followers endpoints (*new in 0.9.0*), +- ``notifications:read``: grants read access to notifications endpoints (*new in 0.9.0*), +- ``notifications:write``: grants write access to notifications endpoints (*new in 0.9.0*), - ``profile:read``: grants read access to auth endpoints, - ``profile:write``: grants write access to auth endpoints, +- ``reports:read``: grants read access to reports endpoints (*new in 0.9.0*), +- ``reports:write``: grants write access to reports endpoints (*new in 0.9.0*), - ``users:read``: grants read access to users endpoints, - ``users:write``: grants write access to users endpoints, -- ``workouts:read``: grants read access to workouts-related endpoints, -- ``workouts:write``: grants write access to workouts-related endpoints. +- ``workouts:read``: grants read access to workouts and comments endpoints (*changed in 0.9.0*), +- ``workouts:write``: grants write access to workouts and comments endpoints (*changed in 0.9.0*). Flow @@ -41,7 +48,7 @@ Flow - The user creates an App (client) on FitTrackee for a third-party application. - .. figure:: _images/fittrackee_screenshot-07.png + .. figure:: _images/oauth2-client-creation.png :alt: OAuth2 client creation on FitTrackee | After registration, the client id and secret are shown. @@ -49,7 +56,7 @@ Flow - | The 3rd-party app needs to redirect to FitTrackee, in order for the user to authorize the 3rd-party app to access user data on FitTrackee. - .. figure:: _images/fittrackee_screenshot-08.png + .. figure:: _images/oauth2-app-authorization.png :alt: App authorization on FitTrackee | The authorization URL is ``https:///profile/apps/authorize``. @@ -72,7 +79,7 @@ Flow | ``https:///profile/apps/authorize?response_type=code&client_id=&scope=profile%3Aread+workouts%3Awrite&state=&code_challenge=&code_challenge_method=S256`` -- | After the authorization, FitTrackee redirects to the 3rd-party app, so the 3rd-party app can get the authorization code from the redirect URL and then fetches an access token with the client id and secret (endpoint `/api/oauth/token `_). +- | After the authorization, FitTrackee redirects to the 3rd-party app, so the 3rd-party app can get the authorization code from the redirect URL and then fetches an access token with the client id and secret (endpoint `/api/oauth/token `_). | Example of a redirect URL: | ``https://example.com/callback?code=&state=`` diff --git a/docs/en/api/auth.html b/docs/en/api/auth.html index cbaca2c28..20e8d88ea 100644 --- a/docs/en/api/auth.html +++ b/docs/en/api/auth.html @@ -215,12 +215,17 @@
  • API documentation @@ -481,6 +486,7 @@

    Authentication and account GET /api/auth/profile

    Get authenticated user info (profile, account, preferences).

    +

    Suspended user can access this endpoint.

    Scope: profile:read

    Example request:

    GET /api/auth/profile HTTP/1.1
    @@ -503,11 +509,16 @@ 

    Authentication and account "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "followers": 0, + "following": 0, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -571,7 +582,8 @@

    Authentication and account "use_dark_mode": null, "use_raw_gpx_speed": false, "username": "sam", - "weekm": false + "weekm": false, + "workouts_visibility": "private" }, "status": "success" } @@ -601,6 +613,7 @@

    Authentication and account POST /api/auth/profile/edit

    Edit authenticated user profile.

    +

    Suspended user can access this endpoint.

    Scope: profile:write

    Example request:

    POST /api/auth/profile/edit HTTP/1.1
    @@ -623,11 +636,16 @@ 

    Authentication and account "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "followers": 0, + "following": 0, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -692,6 +710,7 @@

    Authentication and account "use_raw_gpx_speed": false, "username": "sam" "weekm": true, + "workouts_visibility": "private" }, "message": "user profile updated", "status": "success" @@ -746,6 +765,7 @@

    Authentication and accountprofile:write

    Example request:

    POST /api/auth/profile/edit/preferences HTTP/1.1
    @@ -768,11 +788,16 @@ 

    Authentication and account "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "followers": 0, + "following": 0, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "followers_only", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -837,6 +862,7 @@

    Authentication and account "use_raw_gpx_speed": true, "username": "sam" "weekm": true, + "workouts_visibility": "public" }, "message": "user preferences updated", "status": "success" @@ -848,14 +874,22 @@

    Authentication and account
    • date_format (string) – the format used to display dates in the app

    • display_ascent (boolean) – display highest ascent records and total

    • +
    • hide_profile_in_users_directory (boolean) – if true, user does not +appear in users directory

    • imperial_units (boolean) – display distance in imperial units

    • language (string) – language preferences

    • +
    • map_visibility (string) – workout map visibility +(public, followers_only, private)

    • +
    • manually_approves_followers (boolean) – if false, follow requests +are automatically approved

    • start_elevation_at_zero (boolean) – do elevation plots start at zero?

    • timezone (string) – user time zone

    • -
    • use_dark_mode (boolean) – Display interface with dark mode if true. -If null, it uses browser preferences.

    • +
    • use_dark_mode (boolean) – Display interface with dark mode if true. +If null, it uses browser preferences.

    • use_raw_gpx_speed (boolean) – Use unfiltered gpx to calculate speeds

    • weekm (boolean) – does week start on Monday?

    • +
    • workouts_visibility (string) – user workouts visibility +(public, followers_only, private)

    Request Headers:
    @@ -948,6 +982,10 @@

    Authentication and accountequipment with id <equipment_id> is inactive

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundsport does not exist

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -990,6 +1028,10 @@

    Authentication and accountinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundsport does not exist

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -1001,6 +1043,7 @@

    Authentication and account POST /api/auth/picture

    Update authenticated user picture.

    +

    Suspended user can access this endpoint.

    Scope: profile:write

    Example request:

    POST /api/auth/picture HTTP/1.1
    @@ -1055,6 +1098,7 @@ 

    Authentication and account DELETE /api/auth/picture

    Delete authenticated user picture.

    +

    Suspended user can access this endpoint.

    Scope: profile:write

    Example request:

    DELETE /api/auth/picture HTTP/1.1
    @@ -1137,6 +1181,7 @@ 

    Authentication and accountprofile:write

    Example request:

    PATCH /api/auth/profile/edit/account HTTP/1.1
    @@ -1159,11 +1204,14 @@ 

    Authentication and account "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "followers_only", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -1228,6 +1276,7 @@

    Authentication and account "use_raw_gpx_speed": false, "username": "sam" "weekm": true, + "workouts_visibility": "private" }, "message": "user account updated", "status": "success" @@ -1349,6 +1398,7 @@

    Authentication and accountPOST /api/auth/logout

    User logout. If a valid token is provided, it will be blacklisted.

    +

    Suspended user can access this endpoint.

    Example request:

    POST /api/auth/logout HTTP/1.1
     Content-Type: application/json
    @@ -1404,8 +1454,9 @@ 

    Authentication and account POST /api/auth/account/privacy-policy

    The authenticated user accepts the privacy policy.

    +

    Suspended user can access this endpoint.

    Example request:

    -
    POST /auth/account/privacy-policy HTTP/1.1
    +
    POST /api/auth/account/privacy-policy HTTP/1.1
     Content-Type: application/json
     
    @@ -1455,8 +1506,9 @@

    Authentication and accountin_progress, successful and errored)

  • file name and size (in bytes) when export is successful

  • +

    Suspended user can access this endpoint.

    Example request:

    -
    GET /auth/account/export HTTP/1.1
    +
    GET /api/auth/account/export HTTP/1.1
     Content-Type: application/json
     
    @@ -1514,8 +1566,9 @@

    Authentication and account POST /api/auth/account/export/request

    Request a data export for authenticated user.

    +

    Suspended user can access this endpoint.

    Example request:

    -
    POST /auth/account/export/request HTTP/1.1
    +
    POST /api/auth/account/export/request HTTP/1.1
     Content-Type: application/json
     
    @@ -1563,9 +1616,10 @@

    Authentication and account
    GET /api/auth/account/export/(string: file_name)
    -

    Download a data export archive

    +

    Download a data export archive.

    +

    Suspended user can access this endpoint.

    Example request:

    -
    GET /auth/account/export/download/archive_rgjsR3fHr5Yp.zip HTTP/1.1
    +
    GET /api/auth/account/export/download/archive_rgjsR3fHr5Yp.zip HTTP/1.1
     Content-Type: application/json
     
    @@ -1600,6 +1654,342 @@

    Authentication and account +
    +GET /api/auth/blocked-users
    +

    Get blocked users by authenticated user

    +

    Scope: profile:read

    +

    Example requests:

    +
      +
    • without parameters:

    • +
    +
    GET /api/auth/blocked-users HTTP/1.1
    +
    +
    +
      +
    • with parameters:

    • +
    +
    GET /api/auth/blocked-users?page=1
    +  HTTP/1.1
    +
    +
    +

    Example responses:

    +
      +
    • with blocked users:

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "blocked_users": [
    +      {
    +        "blocked": true,
    +        "created_at": "Sun, 01 Dec 2024 17:27:49 GMT",
    +        "followers": 0,
    +        "following": 0,
    +        "follows": "false",
    +        "is_followed_by": "false",
    +        "nb_workouts": 1,
    +        "picture": false,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      }
    +    ],
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 1,
    +      "total": 1
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • no blocked users:

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "blocked_users": [],
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 0,
    +      "total": 0
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Query Parameters:
    +
      +
    • page (integer) – page if using pagination (default: 1)

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/auth/account/suspension
    +

    Get suspension if exists for authenticated user.

    +

    Suspended user can access this endpoint.

    +

    Scope: profile:read

    +

    Example request:

    +
    GET /api/auth/account/suspension HTTP/1.1
    +
    +
    +

    Example responses:

    +
      +
    • suspension exists:

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "status": "success",
    +    "user_suspension": {
    +      "action_type": "user_suspension",
    +      "appeal": null,
    +      "comment": null,
    +      "created_at": "Wed, 04 Dec 2024 10:45:13 GMT",
    +      "id": "mmy3qPL3vcFuKJGfFBnCJV",
    +      "reason": "<SUSPENSION REASON>",
    +      "workout": null
    +    }
    +  }
    +
    +
    +
      +
    • no suspension:

    • +
    +
    HTTP/1.1 404 NOT FOUND
    +Content-Type: application/json
    +
    +  {
    +    "status": "not found",
    +    "message": "user account is not suspended"
    +  }
    +
    +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 404 Not Founduser account is not suspended

    • +
    +
    +
    +
    + +
    +
    +POST /api/auth/account/suspension/appeal
    +

    Appeal suspension for authenticated user.

    +

    Suspended user can access this endpoint.

    +

    Scope: profile:write

    +

    Example request:

    +
    POST /api/auth/account/suspension/appeal HTTP/1.1
    +
    +
    +

    Example response:

    +
    HTTP/1.1 201 CREATED
    +Content-Type: application/json
    +
    +  {
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Request Headers:
    +
    +
    +
    Request JSON Object:
    +
      +
    • text (string) – text explaining appeal

    • +
    +
    +
    Status Codes:
    +
    +
    +
    +
    + +
    +
    +GET /api/auth/account/sanctions/(string: action_short_id)
    +

    Get sanction for authenticated user.

    +

    Suspended user can access this endpoint.

    +

    Scope: profile:read

    +

    Example request:

    +
    GET /api/auth/account/sanctions/mmy3qPL3vcFuKJGfFBnCJV HTTP/1.1
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 SUCCESS
    +Content-Type: application/json
    +
    +  {
    +    "sanction": {
    +      "action_type": "user_suspension",
    +      "appeal": {
    +        "approved": null,
    +        "created_at": "Wed, 04 Dec 2024 10:49:00 GMT",
    +        "id": "7pDujhCVHyA4hv29JZQNgg",
    +        "reason": null,
    +        "text": "<APPEAL TEXT>",
    +        "updated_at": null
    +      },
    +      "comment": null,
    +      "created_at": "Wed, 04 Dec 2024 10:45:13 GMT",
    +      "id": "mmy3qPL3vcFuKJGfFBnCJV",
    +      "reason": "<SANCTION REASON>",
    +      "workout": null
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • action_short_id (string) – suspension id

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 404 Not Foundno sanction found

    • +
    +
    +
    +
    + +
    +
    +POST /api/auth/account/sanctions/(string: action_short_id)/appeal
    +

    Appeal a sanction

    +

    Scope: profile:write

    +

    Example request:

    +
    POST /api/auth/account/sanctions/6dxczvMrhkAR72shUz9Pwd/appeal HTTP/1.1
    +
    +
    +

    Example response:

    +
    HTTP/1.1 201 CREATED
    +Content-Type: application/json
    +
    +  {
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • action_short_id (string) – sanction id

    • +
    +
    +
    Request JSON Object:
    +
      +
    • text (string) – text explaining appeal

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
    +
    +
    +
    + diff --git a/docs/en/api/comments.html b/docs/en/api/comments.html new file mode 100644 index 000000000..488fbb93b --- /dev/null +++ b/docs/en/api/comments.html @@ -0,0 +1,925 @@ + + + + + + + + + Comments - FitTrackee 0.8.12 documentation + + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    +
    + + + + + Back to top + +
    + +
    + +
    + +
    +
    +
    +

    Comments

    +
    +
    +GET /api/workouts/(string: workout_short_id)/comments
    +

    Get workout comments.

    +

    It returns only comments visible to authenticated user and only public +comments when no authentication provided.

    +

    Scope: workouts:read

    +

    Example request:

    +
    GET /api/workouts/2oRDfncv6vpRkfp3yrCYHt/comments HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "comments": [
    +        {
    +          "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +          "id": "WJgTwtqFpnPrHYAK5eX9Pw",
    +          "liked": false,
    +          "likes_count": 0,
    +          "mentions": [],
    +          "modification_date": null,
    +          "suspended_at": null,
    +          "text": "Great!",
    +          "text_html": "Great!",
    +          "text_visibility": "private",
    +          "user": {
    +            "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +            "followers": 3,
    +            "following": 2,
    +            "nb_workouts": 10,
    +            "picture": true,
    +            "role": "user",
    +            "suspended_at": null,
    +            "username": "Sam"
    +          },
    +          "workout_id": "2oRDfncv6vpRkfp3yrCYHt"
    +        }
    +      ]
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • workout_short_id (string) – workout short id

    • +
    +
    +
    Request Headers:
    +
      +
    • Authorization – OAuth 2.0 Bearer Token for comments with +private and followers_only visibility

    • +
    +
    +
    Status Codes:
    +
    +
    +
    +
    + +
    +
    +GET /api/comments/(string: comment_short_id)
    +

    Get comment.

    +

    Scope: workouts:read

    +

    Example request:

    +
    GET /api/workouts/2oRDfncv6vpRkfp3yrCYHt/comment HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "comment": {
    +      "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +      "id": "T2zeeUXvuy3PLA8MeeUFyk",
    +      "liked": false,
    +      "likes_count": 0,
    +      "mentions": [],
    +      "modification_date": null,
    +      "suspended_at": null,
    +      "text": "Nice!",
    +      "text_html": "Nice!",
    +      "text_visibility": "private",
    +      "user": {
    +        "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +        "followers": 3,
    +        "following": 2,
    +        "nb_workouts": 10,
    +        "picture": true,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "workout_id": null
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • comment_short_id (string) – comment short id

    • +
    +
    +
    Request Headers:
    +
      +
    • Authorization – OAuth 2.0 Bearer Token for comment with +private and followers_only visibility

    • +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundworkout comment not found

    • +
    +
    +
    +
    + +
    +
    +POST /api/workouts/(string: workout_short_id)/comments
    +

    Post a comment.

    +

    Scope: workouts:write

    +

    Example request:

    +
    POST /api/workouts/2oRDfncv6vpRkfp3yrCYHt HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "comment": {
    +      "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +      "id": "WJgTwtqFpnPrHYAK5eX9Pw",
    +      "liked": false,
    +      "likes_count": 0,
    +      "mentions": [],
    +      "modification_date": null,
    +      "suspended_at": null,
    +      "text": "Great!",
    +      "text_html": "Great!",
    +      "text_visibility": "private",
    +      "user": {
    +        "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +        "followers": 3,
    +        "following": 2,
    +        "nb_workouts": 10,
    +        "picture": true,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "workout_id": "2oRDfncv6vpRkfp3yrCYHt"
    +    },
    +    "status": "created"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • workout_short_id (string) – workout short id

    • +
    +
    +
    Request JSON Object:
    +
      +
    • text (string) – comment content

    • +
    • text_visibility (string) – visibility level (public, +followers_only, private)

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
    +
    +
    +
    + +
    +
    +PATCH /api/comments/(string: comment_short_id)
    +

    Update comment text.

    +

    Scope: workouts:write

    +

    Example request:

    +
    PATCH /api/workouts/WJgTwtqFpnPrHYAK5eX9Pw HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "comment": {
    +      "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +      "id": "WJgTwtqFpnPrHYAK5eX9Pw",
    +      "liked": false,
    +      "likes_count": 0,
    +      "mentions": [],
    +      "modification_date": null,
    +      "suspended_at": null,
    +      "text": "Great!",
    +      "text_html": "Great!",
    +      "text_visibility": "private",
    +      "user": {
    +        "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +        "followers": 3,
    +        "following": 2,
    +        "nb_workouts": 10,
    +        "picture": true,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "workout_id": "2oRDfncv6vpRkfp3yrCYHt"
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • comment_short_id (string) – comment short id

    • +
    +
    +
    Request JSON Object:
    +
      +
    • text (string) – comment content

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
    +
    +
    +
    + +
    +
    +POST /api/comments/(string: comment_short_id)/like
    +

    Add a “like” to a comment.

    +

    Scope: workouts:write

    +

    Example request:

    +
    POST /api/comments/WJgTwtqFpnPrHYAK5eX9Pw/like HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "comment": {
    +      "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +      "id": "WJgTwtqFpnPrHYAK5eX9Pw",
    +      "liked": true,
    +      "likes_count": 1,
    +      "mentions": [],
    +      "modification_date": null,
    +      "suspended_at": null,
    +      "text": "Great!",
    +      "text_html": "Great!",
    +      "text_visibility": "private",
    +      "user": {
    +        "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +        "followers": 3,
    +        "following": 2,
    +        "nb_workouts": 10,
    +        "picture": true,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "workout_id": "2oRDfncv6vpRkfp3yrCYHt"
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • comment_short_id (string) – comment short id

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundcomment not found

    • +
    +
    +
    +
    + +
    +
    +POST /api/comments/(string: comment_short_id)/like/undo
    +

    Remove a comment “like”.

    +

    Scope: workouts:write

    +

    Example request:

    +
    POST /api/comments/2oRDfncv6vpRkfp3yrCYHt/like/undo HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "comment": {
    +      "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +      "id": "WJgTwtqFpnPrHYAK5eX9Pw",
    +      "liked": false,
    +      "likes_count": 0,
    +      "mentions": [],
    +      "modification_date": null,
    +      "suspended_at": null,
    +      "text": "Great!",
    +      "text_html": "Great!",
    +      "text_visibility": "private",
    +      "user": {
    +        "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +        "followers": 3,
    +        "following": 2,
    +        "nb_workouts": 10,
    +        "picture": true,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "workout_id": "2oRDfncv6vpRkfp3yrCYHt"
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • comment_short_id (string) – comment short id

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundcomment not found

    • +
    +
    +
    +
    + +
    +
    +POST /api/comments/(string: comment_short_id)/suspension/appeal
    +

    Appeal comment suspension.

    +

    Only comment author can appeal the suspension.

    +

    Scope: workouts:write

    +

    Example request:

    +
    POST /api/comments/WJgTwtqFpnPrHYAK5eX9Pw/suspension/appeal HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 201 CREATED
    +Content-Type: application/json
    +
    +  {
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • comment_short_id (string) – comment short id

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 201 Created – appeal created

    • +
    • 400 Bad Request

        +
      • no text provided

      • +
      • you can appeal only once

      • +
      • workout comment is not suspended

      • +
      • workout comment has no suspension

      • +
      +

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundcomment not found

    • +
    • 500 Internal Server Errorerror, please try again or contact the administrator

    • +
    +
    +
    +
    + +
    +
    +DELETE /api/comments/(string: comment_short_id)
    +

    Delete workout comment.

    +

    Scope: workouts:write

    +

    Example request:

    +
    DELETE /api/comments/MzydiCYYfktG3gga2x8AfU HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 204 NO CONTENT
    +Content-Type: application/json
    +
    +
    +
    +
    Parameters:
    +
      +
    • comment_short_id (string) – comment short id

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + + + + + + + \ No newline at end of file diff --git a/docs/en/api/configuration.html b/docs/en/api/configuration.html index aeccfe3c2..028ec788d 100644 --- a/docs/en/api/configuration.html +++ b/docs/en/api/configuration.html @@ -3,7 +3,7 @@ - + Configuration - FitTrackee 0.8.12 documentation @@ -215,12 +215,17 @@
  • API documentation @@ -331,8 +336,8 @@

    Configuration PATCH /api/config

    Update Application configuration.

    -

    Authenticated user must be an admin.

    Scope: application:write

    +

    Minimum role: Administrator

    Example request:

    GET /api/config HTTP/1.1
     Content-Type: application/json
    @@ -404,7 +409,11 @@ 

    Configurationvalid email must be provided for admin contact

  • -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 500 Internal Server Errorerror when updating configuration

  • @@ -446,12 +455,12 @@

    Configuration - +
    Next
    -
    Equipments
    +
    Comments
    diff --git a/docs/en/api/equipment_types.html b/docs/en/api/equipment_types.html index 9830c06fe..9b3d49e46 100644 --- a/docs/en/api/equipment_types.html +++ b/docs/en/api/equipment_types.html @@ -3,7 +3,7 @@ - + Equipment Types - FitTrackee 0.8.12 documentation @@ -215,12 +215,17 @@
  • API documentation @@ -286,7 +291,8 @@

    Equipment Types
    GET /api/equipment-types
    -

    Get all types of equipment

    +

    Get all types of equipment.

    +

    Suspended user can access this endpoint.

    Scope: equipments:read

    Example request:

    GET /api/equipment-types HTTP/1.1
    @@ -497,7 +503,11 @@ 

    Equipment Typesinvalid token, please log in again

  • -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundequipment_type not found

  • @@ -508,8 +518,8 @@

    Equipment Types PATCH /api/equipment-types/(int: equipment_type_id)

    Update a type of equipment to (de)activate it.

    -

    Authenticated user must be an admin.

    Scope: equipments:write

    +

    Minimum role: Administrator

    Example request:

    PATCH /api/equipment-types/2 HTTP/1.1
     Content-Type: application/json
    @@ -563,7 +573,11 @@ 

    Equipment Typesinvalid token, please log in again

    -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundequipment_type not found

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -578,12 +592,12 @@

    Equipment Types - +
    Next
    -
    OAuth2
    +
    Follow requests
    diff --git a/docs/en/api/equipments.html b/docs/en/api/equipments.html index a108688f4..5b346f515 100644 --- a/docs/en/api/equipments.html +++ b/docs/en/api/equipments.html @@ -3,7 +3,7 @@ - + Equipments - FitTrackee 0.8.12 documentation @@ -215,12 +215,17 @@
  • API documentation @@ -288,6 +293,7 @@

    EquipmentsGET /api/equipments

    Get all user equipments. Only the equipment owner can see his equipment.

    +

    Suspended user can access this endpoint.

    Scope: equipments:read

    Example request:

    GET /api/equipments HTTP/1.1
    @@ -370,7 +376,10 @@ 

    Equipmentsinvalid token, please log in again

  • -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    +

  • @@ -381,6 +390,7 @@

    EquipmentsGET /api/equipments/(string: equipment_short_id)

    Get an equipment item. Only the equipment owner can see his equipment.

    +

    Suspended user can access this endpoint.

    Scope: equipments:read

    Example request:

    GET /api/equipments/2UkrViYShoAkg8qSUKnUS4 HTTP/1.1
    @@ -455,7 +465,10 @@ 

    Equipmentsinvalid token, please log in again

    -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    +

  • 404 Not Foundequipment not found

  • @@ -543,7 +556,11 @@

    Equipmentsinvalid token, please log in again

    -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundequipment not found

  • 500 Internal Server ErrorError during equipment save

  • @@ -658,7 +675,11 @@

    Equipmentsinvalid token, please log in again

    -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundequipment not found

  • 500 Internal Server ErrorError during equipment update

  • @@ -722,7 +743,11 @@

    Equipmentsinvalid token, please log in again

    -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundequipment not found

  • 500 Internal Server ErrorError during equipment save

  • @@ -780,6 +805,7 @@

    Equipments403 Forbidden
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • you cannot delete equipment that has workouts associated with it without 'force' parameter

    @@ -807,14 +833,14 @@

    Equipments - +
    Previous
    -
    Configuration
    +
    Comments
    diff --git a/docs/en/api/follow_requests.html b/docs/en/api/follow_requests.html new file mode 100644 index 000000000..7944587b0 --- /dev/null +++ b/docs/en/api/follow_requests.html @@ -0,0 +1,557 @@ + + + + + + + + + Follow requests - FitTrackee 0.8.12 documentation + + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    +
    + + + + + Back to top + +
    + +
    + +
    + +
    +
    +
    +

    Follow requests

    +
    +
    +GET /api/follow-requests
    +

    Get follow requests to process, received by authenticated user.

    +

    Scope: follow:read

    +

    Example requests:

    +
      +
    • without parameters

    • +
    +
    GET /api/follow-requests/ HTTP/1.1
    +
    +
    +
      +
    • with some query parameters

    • +
    +
    GET /api/follow-requests?page=1&order=desc  HTTP/1.1
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "data": {
    +    "follow_requests": [
    +      {
    +        "admin": false,
    +        "bio": null,
    +        "birth_date": null,
    +        "created_at": "Thu, 02 Dec 2021 17:50:48 GMT",
    +        "first_name": null,
    +        "followers": 1,
    +        "following": 1,
    +        "last_name": null,
    +        "location": null,
    +        "nb_sports": 0,
    +        "nb_workouts": 0,
    +        "picture": false,
    +        "records": [],
    +        "sports_list": [],
    +        "total_distance": 0.0,
    +        "total_duration": "0:00:00",
    +        "username": "Sam"
    +      }
    +    ]
    +  },
    +  "pagination": {
    +    "has_next": false,
    +    "has_prev": false,
    +    "page": 1,
    +    "pages": 1,
    +    "total": 1
    +  },
    +  "status": "success"
    +}
    +
    +
    +
    +
    Query Parameters:
    +
      +
    • page (integer) – page if using pagination (default: 1)

    • +
    • per_page (integer) – number of follow requests per page +(default: 10, max: 50)

    • +
    • order (string) – sorting order (default: asc)

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
    +
    +
    +
    + +
    +
    +POST /api/follow-requests/(user_name)/accept
    +

    Accept a follow request from user.

    +

    Scope: follow:write

    +

    Example requests:

    +
    POST /api/follow-requests/Sam/accept HTTP/1.1
    +
    +
    +

    Example responses:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success",
    +  "message": "Follow request from user 'Sam' is accepted.",
    +}
    +
    +
    +
    +
    Parameters:
    +
      +
    • user_name (string) – user name

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OK – success

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 400 Bad Request

        +
      • Follow request from user 'user_name' already accepted.

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      • Follow request does not exist.

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +POST /api/follow-requests/(user_name)/reject
    +

    Reject a follow request from user.

    +

    Scope: follow:write

    +

    Example requests:

    +
    POST /api/follow-requests/Sam/reject HTTP/1.1
    +
    +
    +

    Example responses:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success",
    +  "message": "Follow request from user 'Sam' is rejected.",
    +}
    +
    +
    +
    +
    Parameters:
    +
      +
    • user_name (string) – user name

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OK – success

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 400 Bad Request

        +
      • Follow request from user 'user_name' already rejected.

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      • Follow request does not exist.

      • +
      +

    • +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + + + + + + + \ No newline at end of file diff --git a/docs/en/api/index.html b/docs/en/api/index.html index bddef026f..649d7d283 100644 --- a/docs/en/api/index.html +++ b/docs/en/api/index.html @@ -215,12 +215,17 @@
  • API documentation @@ -288,12 +293,17 @@

    API documentationAuthentication and account

  • Configuration
  • +
  • Comments
  • Equipments
  • Equipment Types
  • +
  • Follow requests
  • OAuth2
  • +
  • Notifications
  • Records
  • +
  • Reports
  • Sports
  • Statistics
  • +
  • Timeline
  • Users
  • Workouts
  • diff --git a/docs/en/api/notifications.html b/docs/en/api/notifications.html new file mode 100644 index 000000000..daa5b5bb4 --- /dev/null +++ b/docs/en/api/notifications.html @@ -0,0 +1,691 @@ + + + + + + + + + Notifications - FitTrackee 0.8.12 documentation + + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    +
    + + + + + Back to top + +
    + +
    + +
    + +
    +
    +
    +

    Notifications

    +
    +
    +GET /api/notifications
    +

    Get authenticated user notifications.

    +

    Scope: notifications:read

    +

    Example requests:

    +
      +
    • without parameters:

    • +
    +
    GET /api/notifications HTTP/1.1
    +
    +
    +
      +
    • with some query parameters:

    • +
    +
    GET /api/notifications?page=2&status=unread  HTTP/1.1
    +
    +
    +

    Example responses:

    +
      +
    • returning at least one notification:

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "notifications": [
    +      {
    +        "created_at": "Wed, 04 Dec 2024 10:06:35 GMT",
    +        "from": {
    +          "created_at": "Wed, 04 Dec 2024 09:07:08 GMT",
    +          "followers": 0,
    +          "following": 0,
    +          "follows": "pending",
    +          "is_followed_by": "false",
    +          "nb_workouts": 0,
    +          "picture": true,
    +          "role": "admin",
    +          "suspended_at": null,
    +          "username": "admin"
    +        },
    +        "id": 22,
    +        "marked_as_read": false,
    +        "type": "follow_request"
    +      }
    +    ],
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 1,
    +      "total": 1
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • returning no notifications

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "notifications": [],
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 0,
    +      "total": 0
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Query Parameters:
    +
      +
    • page (integer) – page if using pagination (default: 1)

    • +
    • order (string) – sorting order: asc, desc (default: desc)

    • +
    • status (string) – notification read status (read, unread)

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +PATCH /api/notifications/(int: notification_id)
    +

    Update authenticated user notification read status.

    +

    Scope: notifications:write

    +

    Example request:

    +
    PATCH /api/notifications/22 HTTP/1.1
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    + {
    +    "notification": {
    +      "created_at": "Wed, 04 Dec 2024 10:06:35 GMT",
    +      "from": {
    +        "created_at": "Wed, 04 Dec 2024 09:07:08 GMT",
    +        "followers": 0,
    +        "following": 0,
    +        "follows": "pending",
    +        "is_followed_by": "false",
    +        "nb_workouts": 0,
    +        "picture": true,
    +        "role": "admin",
    +        "suspended_at": null,
    +        "username": "admin"
    +      },
    +      "id": 22,
    +      "marked_as_read": true,
    +      "type": "follow_request"
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • notification_id (string) – notification id

    • +
    +
    +
    Request JSON Object:
    +
      +
    • read_status (boolean) – notification read status

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • notification not found

      • +
      +

    • +
    • 500 Internal Server Error

        +
      • error, please try again or contact the administrator

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/notifications/unread
    +

    Get if unread notifications exist for authenticated user.

    +

    Scope: notifications:read

    +

    Example request:

    +
    GET /api/notifications/unread HTTP/1.1
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    + {
    +    "status": "success",
    +    "unread": false
    +  }
    +
    +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +POST /api/notifications/mark-all-as-read
    +

    Mark all authenticated user notifications as read.

    +

    Scope: notifications:write

    +

    Example request:

    +
    POST /api/notifications/mark-all-as-read HTTP/1.1
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    + {
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Request JSON Object:
    +
      +
    • type (boolean) – notification type (optional)

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 500 Internal Server Error

        +
      • error, please try again or contact the administrator

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/notifications/types
    +

    Get types of notifications received by authenticated user.

    +

    Scope: notifications:read

    +

    Example requests:

    +
      +
    • without parameters:

    • +
    +
    GET /api/notifications/types HTTP/1.1
    +
    +
    +
      +
    • with query parameter:

    • +
    +
    GET /api/notifications/types?status=unread  HTTP/1.1
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    + {
    +    "notification_types": [
    +      "mention"
    +    ],
    +    "status": "success"
    + }
    +
    +
    +
    +
    Query Parameters:
    +
      +
    • status (string) – notification read status (read, unread)

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + + + + + + + \ No newline at end of file diff --git a/docs/en/api/oauth2.html b/docs/en/api/oauth2.html index 36c034ec5..635a0409c 100644 --- a/docs/en/api/oauth2.html +++ b/docs/en/api/oauth2.html @@ -3,7 +3,7 @@ - + OAuth2 - FitTrackee 0.8.12 documentation @@ -215,12 +215,17 @@
  • API documentation @@ -290,6 +295,7 @@

    OAuth2

    This endpoint is only accessible by FitTrackee client (first-party application).

    +

    Suspended user can access this endpoint.

    Example request:

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • @@ -433,6 +443,7 @@

    OAuth2

    Get an OAuth2 client (app) by ‘client_id’.

    This endpoint is only accessible by FitTrackee client (first-party application).

    +

    Suspended user can access this endpoint.

    Example request:

    @@ -828,23 +846,23 @@

    OAuth2

    @@ -405,23 +414,23 @@

    Records

    @@ -531,6 +541,7 @@

    Sports

    Update a sport.

    Authenticated user must be an admin.

    Scope: workouts:write

    +

    Minimum role: Administrator

    Example request:

    PATCH /api/sports/1 HTTP/1.1
     Content-Type: application/json
    @@ -594,14 +605,21 @@ 

    SportsStatus Codes:
    @@ -625,14 +643,14 @@

    Sports - +
    Previous
    -
    Records
    +
    Reports
    diff --git a/docs/en/api/stats.html b/docs/en/api/stats.html index 23c2baf81..6de1e2826 100644 --- a/docs/en/api/stats.html +++ b/docs/en/api/stats.html @@ -3,7 +3,7 @@ - + Statistics - FitTrackee 0.8.12 documentation @@ -215,12 +215,17 @@
  • API documentation @@ -446,7 +451,14 @@

    Statisticsinvalid token, please log in again

  • -
  • 404 Not Founduser does not exist

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • +
  • 404 Not Found

      +
    • user does not exist

    • +
    +

  • @@ -594,6 +606,10 @@

    Statisticsinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Found

    • user does not exist

    • sport does not exist

    • @@ -609,6 +625,7 @@

      StatisticsGET /api/stats/all

      Get all application statistics.

      Scope: workouts:read

      +

      Minimum role: Moderator

      Example requests:

      GET /api/stats/all HTTP/1.1
       
      @@ -643,7 +660,11 @@

      Statisticsinvalid token, please log in again

  • -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • @@ -656,12 +677,12 @@

    Statistics - +
    Next
    -
    Users
    +
    Timeline
    diff --git a/docs/en/api/timeline.html b/docs/en/api/timeline.html new file mode 100644 index 000000000..58958449f --- /dev/null +++ b/docs/en/api/timeline.html @@ -0,0 +1,509 @@ + + + + + + + + + Timeline - FitTrackee 0.8.12 documentation + + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    +
    + + + + + Back to top + +
    + +
    + +
    + +
    +
    +
    +

    Timeline

    +
    +
    +GET /api/timeline
    +

    Get workouts visible to authenticated user.

    +

    Scope: workouts:read

    +

    Example requests:

    +
      +
    • without parameters:

    • +
    +
    GET /api/timeline HTTP/1.1
    +
    +
    +
      +
    • with some query parameters:

    • +
    +
    GET /api/timeline?page=2  HTTP/1.1
    +
    +
    +

    Example responses:

    +
      +
    • returning at least one workout:

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "workouts": [
    +        {
    +          "ascent": null,
    +          "ave_speed": 10.0,
    +          "bounds": [],
    +          "creation_date": "Sun, 14 Jul 2019 13:51:01 GMT",
    +          "descent": null,
    +          "description": null,
    +          "distance": 10.0,
    +          "duration": "0:17:04",
    +          "equipments": [],
    +          "id": "kjxavSTUrJvoAh2wvCeGEF",
    +          "map": null,
    +          "max_alt": null,
    +          "max_speed": 10.0,
    +          "min_alt": null,
    +          "modification_date": null,
    +          "moving": "0:17:04",
    +          "next_workout": 3,
    +          "notes": null,
    +          "pauses": null,
    +          "previous_workout": null,
    +          "records": [
    +            {
    +              "id": 4,
    +              "record_type": "MS",
    +              "sport_id": 1,
    +              "user": "admin",
    +              "value": 10.0,
    +              "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT",
    +              "workout_id": "kjxavSTUrJvoAh2wvCeGEF"
    +            },
    +            {
    +              "id": 13,
    +              "record_type": "HA",
    +              "sport_id": 1,
    +              "user": "Sam",
    +              "value": 43.97,
    +              "workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
    +              "workout_id": "hvYBqYBRa7wwXpaStWR4V2"
    +            },
    +            {
    +              "id": 3,
    +              "record_type": "LD",
    +              "sport_id": 1,
    +              "user": "admin",
    +              "value": "0:17:04",
    +              "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT",
    +              "workout_id": "kjxavSTUrJvoAh2wvCeGEF"
    +            },
    +            {
    +              "id": 2,
    +              "record_type": "FD",
    +              "sport_id": 1,
    +              "user": "admin",
    +              "value": 10.0,
    +              "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT",
    +              "workout_id": "kjxavSTUrJvoAh2wvCeGEF"
    +            },
    +            {
    +              "id": 1,
    +              "record_type": "AS",
    +              "sport_id": 1,
    +              "user": "admin",
    +              "value": 10.0,
    +              "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT",
    +              "workout_id": "kjxavSTUrJvoAh2wvCeGEF"
    +            }
    +          ],
    +          "segments": [],
    +          "sport_id": 1,
    +          "title": null,
    +          "user": "admin",
    +          "weather_end": null,
    +          "weather_start": null,
    +          "with_gpx": false,
    +          "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT"
    +        }
    +      ]
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • returning no workouts

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +      "data": {
    +          "workouts": []
    +      },
    +      "status": "success"
    +  }
    +
    +
    +
    +
    Query Parameters:
    +
      +
    • page (integer) – page if using pagination (default: 1)

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 500 Internal Server Errorerror, please try again or contact the administrator

    • +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + + + + + + + \ No newline at end of file diff --git a/docs/en/api/users.html b/docs/en/api/users.html index 5f1ab9beb..dfb165eef 100644 --- a/docs/en/api/users.html +++ b/docs/en/api/users.html @@ -3,7 +3,7 @@ - + Users - FitTrackee 0.8.12 documentation @@ -215,12 +215,17 @@
  • API documentation @@ -286,8 +291,8 @@

    Users
    GET /api/users
    -

    Get all users (regardless their account status), if authenticated user -has admin rights.

    +

    Get all users. +If authenticated user has admin rights, users email is returned.

    It returns user preferences only for authenticated user.

    Scope: users:read

    Example request:

    @@ -319,11 +324,13 @@

    Users "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "admin@example.com", "first_name": null, - "is_admin": true, - "imperial_units": false, - "language": "en", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -379,11 +386,10 @@

    Users 4, 6 ], - "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", "username": "admin", - "weekm": false + "workouts_visibility": "private" }, { "admin": false, @@ -392,19 +398,22 @@

    Users "created_at": "Sat, 20 Jul 2019 11:27:03 GMT", "email": "sam@example.com", "first_name": null, - "is_admin": false, - "language": "fr", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 0, "nb_workouts": 0, "picture": false, "records": [], "sports_list": [], - "timezone": "Europe/Paris", "total_distance": 0, "total_duration": "0:00:00", - "username": "sam" + "username": "sam", + "workouts_visibility": "private" } ] }, @@ -420,8 +429,19 @@

    Users
  • q (string) – query on user name

  • order (string) – sorting order: asc, desc (default: asc)

  • order_by (string) – sorting criteria: username, created_at, -workouts_count, admin, is_active +workouts_count, role, is_active (default: username)

  • +
  • with_following (boolean) – returns hidden users followed by user if +true

  • +
  • with_hidden_users (boolean) – returns hidden users if true (only if +authenticated user has administration rights - for users +administration)

  • +
  • with_inactive (boolean) – returns inactive users if true (only if +authenticated user has administration rights - for users +administration)

  • +
  • with_suspended (boolean) – returns suspended users if true (only if +authenticated user has administration rights - for users +administration)

  • Request Headers:
    @@ -438,6 +458,10 @@

    Users
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • @@ -446,16 +470,20 @@

    Users
    GET /api/users/(user_name)
    -

    Get single user details. Only user with admin rights can get other users -details.

    +

    Get single user details. +If a user is authenticated, it returns relationships. +If authenticated user has admin rights, user email is returned.

    It returns user preferences only for authenticated user.

    -

    Scope: users:read

    +

    Scope: users:read for Oauth 2.0 client

    Example request:

    GET /api/users/admin HTTP/1.1
     Content-Type: application/json
     

    Example response:

    +
      +
    • when a user is authenticated:

    • +
    HTTP/1.1 200 OK
     Content-Type: application/json
     
    @@ -468,11 +496,13 @@ 

    Users "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "admin@example.com", "first_name": null, - "imperial_units": false, - "is_admin": true, - "language": "en", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -528,10 +558,42 @@

    Users 4, 6 ], - "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", - "username": "admin" + "username": "admin", + "workouts_visibility": "private" + } + ], + "status": "success" +} +

    +
    +
      +
    • when no authentication:

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "data": [
    +    {
    +      "admin": true,
    +      "bio": null,
    +      "birth_date": null,
    +      "created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
    +      "email": "admin@example.com",
    +      "first_name": null,
    +      "followers": 0,
    +      "following": 0,
    +      "follows": "false",
    +      "is_followed_by": "false",
    +      "last_name": null,
    +      "location": null,
    +      "map_visibility": "private",
    +      "nb_workouts": 6,
    +      "picture": false,
    +      "username": "admin",
    +      "workouts_visibility": "private"
         }
       ],
       "status": "success"
    @@ -546,7 +608,7 @@ 

    Users

    Request Headers:
    Status Codes:
    @@ -558,6 +620,10 @@

    Users
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Found

    • user does not exist

    @@ -610,9 +676,10 @@

    Users if sending enabled)

  • update user email (and send email to new user email, if sending enabled)

  • activate account for an inactive user

  • +
  • deactivate account after report.

  • -

    Only user with admin rights can modify another user.

    Scope: users:write

    +

    Minimum role: Administrator

    Example request:

    PATCH /api/users/<user_name> HTTP/1.1
     Content-Type: application/json
    @@ -631,11 +698,13 @@ 

    Users "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "admin@example.com", "first_name": null, - "imperial_units": false, - "is_active": true, - "language": "en", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", "last_name": null, "location": null, + "map_visibility": "private", "nb_workouts": 6, "nb_sports": 3, "picture": false, @@ -691,10 +760,10 @@

    Users 4, 6 ], - "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", - "username": "admin" + "username": "admin", + "workouts_visibility": "private" } ], "status": "success" @@ -709,8 +778,9 @@

    Users
    Request JSON Object:
      -
    • activate (boolean) – activate user account

    • -
    • admin (boolean) – does the user have administrator rights

    • +
    • activate (boolean) – (de-)activate user account

    • +
    • role (boolean) – user role (user, admin, moderator). +owner can only be set via CLI.

    • new_email (boolean) – new user email

    • reset_password (boolean) – reset user password

    @@ -725,6 +795,7 @@

    Users
  • 200 OKsuccess

  • 400 Bad Request

    • invalid payload

    • +
    • invalid role

    • valid email must be provided

    • new email must be different than current email

    @@ -735,8 +806,15 @@

    Users
  • invalid token, please log in again

  • -
  • 403 Forbiddenyou do not have permissions

  • -
  • 404 Not Founduser does not exist

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • +
  • 404 Not Found

      +
    • user does not exist

    • +
    +

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -748,8 +826,10 @@

    Users DELETE /api/users/(user_name)

    Delete a user account.

    A user can only delete his own account.

    -

    An admin can delete all accounts except his account if he’s the only -one admin.

    +

    A user with admin rights can delete all accounts except his account if +he is the only user with admin rights. +Only owner can delete his own account.

    +

    Suspended user can access this endpoint.

    Scope: users:write

    Example request:

    DELETE /api/users/john_doe HTTP/1.1
    @@ -786,13 +866,642 @@ 

    Users
  • you can not delete your account, no other user has admin rights

  • -
  • 404 Not Founduser does not exist

  • +
  • 404 Not Found

      +
    • user does not exist

    • +
    +

  • +
  • 500 Internal Server Errorerror, please try again or contact the administrator

  • + +

    +

    + + +
    +
    +POST /api/users/(user_name)/follow
    +

    Send a follow request to a user.

    +

    Scope: follow:write

    +

    Example request:

    +
    POST /api/users/john_doe/follow HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success",
    +  "message": "Follow request to user 'john_doe' is sent.",
    +}
    +
    +
    +
    +
    Parameters:
    +
      +
    • user_name (string) – user name

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OK – success

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      +

    • +
    • 500 Internal Server Errorerror, please try again or contact the administrator

    • +
    +
    +
    +
    + +
    +
    +POST /api/users/(user_name)/unfollow
    +

    Unfollow a user.

    +

    Scope: follow:write

    +

    Example request:

    +
    POST /api/users/john_doe/unfollow HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success",
    +  "message": "Undo for a follow request to user 'john_doe' is sent.",
    +}
    +
    +
    +
    +
    Parameters:
    +
      +
    • user_name (string) – user name

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OK – success

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      +

    • 500 Internal Server Errorerror, please try again or contact the administrator

    +
    +
    +GET /api/users/(user_name)/followers
    +

    Get user followers. +If the authenticated user has admin rights, it returns following users with +additional field ‘email’

    +

    Scope: follow:read

    +

    Example request:

    +
      +
    • without parameters

    • +
    +
    GET /api/users/sam/followers HTTP/1.1
    +Content-Type: application/json
    +
    +
    +
      +
    • with page parameter

    • +
    +
    GET /api/users/sam/followers?page=1 HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "data": {
    +    "followers": [
    +      {
    +        "admin": false,
    +        "bio": null,
    +        "birth_date": null,
    +        "created_at": "Thu, 02 Dec 2021 17:50:48 GMT",
    +        "first_name": null,
    +        "followers": 1,
    +        "following": 1,
    +        "follows": "true",
    +        "is_followed_by": "false",
    +        "last_name": null,
    +        "location": null,
    +        "map_visibility": "followers_only",
    +        "nb_sports": 0,
    +        "nb_workouts": 0,
    +        "picture": false,
    +        "records": [],
    +        "sports_list": [],
    +        "total_distance": 0.0,
    +        "total_duration": "0:00:00",
    +        "username": "JohnDoe",
    +        "workouts_visibility": "followers_only"
    +      }
    +    ]
    +  },
    +  "pagination": {
    +    "has_next": false,
    +    "has_prev": false,
    +    "page": 1,
    +    "pages": 1,
    +    "total": 1
    +  },
    +  "status": "success"
    +}
    +
    +
    +
    +
    Parameters:
    +
      +
    • user_name (string) – user name

    • +
    +
    +
    Query Parameters:
    +
      +
    • page (integer) – page if using pagination (default: 1)

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OK – success

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/users/(user_name)/following
    +

    Get user following. +If the authenticate user has admin rights, it returns following users with +additional field ‘email’

    +

    Scope: follow:read

    +

    Example request:

    +
      +
    • without parameters

    • +
    +
    GET /api/users/sam/following HTTP/1.1
    +Content-Type: application/json
    +
    +
    +
      +
    • with page parameter

    • +
    +
    GET /api/users/sam/following?page=1 HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "data": {
    +    "following": [
    +      {
    +        "admin": false,
    +        "bio": null,
    +        "birth_date": null,
    +        "created_at": "Thu, 02 Dec 2021 17:50:48 GMT",
    +        "first_name": null,
    +        "followers": 1,
    +        "following": 1,
    +        "follows": "false",
    +        "is_followed_by": "true",
    +        "last_name": null,
    +        "location": null,
    +        "map_visibility": "followers_only",
    +        "nb_sports": 0,
    +        "nb_workouts": 0,
    +        "picture": false,
    +        "records": [],
    +        "sports_list": [],
    +        "total_distance": 0.0,
    +        "total_duration": "0:00:00",
    +        "username": "JohnDoe",
    +        "workouts_visibility": "followers_only"
    +      }
    +    ]
    +  },
    +  "pagination": {
    +    "has_next": false,
    +    "has_prev": false,
    +    "page": 1,
    +    "pages": 1,
    +    "total": 1
    +  },
    +  "status": "success"
    +}
    +
    +
    +
    +
    Parameters:
    +
      +
    • user_name (string) – user name

    • +
    +
    +
    Query Parameters:
    +
      +
    • page (integer) – page if using pagination (default: 1)

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OK – success

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +POST /api/users/(user_name)/block
    +

    Block a user

    +

    Scope: users:write

    +

    Example request:

    +
    GET /api/users/sam/block HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success"
    +}
    +
    +
    +
    +
    Parameters:
    +
      +
    • user_name (string) – user name

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OK – success

    • +
    • 400 Bad Request

        +
      • invalid payload

      • +
      +

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user not found

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +POST /api/users/(user_name)/unblock
    +

    Unblock a user

    +

    Scope: users:write

    +

    Example request:

    +
    GET /api/users/sam/unblock HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success"
    +}
    +
    +
    +
    +
    Parameters:
    +
      +
    • user_name (string) – user name

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OK – success

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user not found

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/users/(user_name)/sanctions
    +

    Get user sanctions.

    +

    It returns sanctions only if: +- user name is authenticated user username +- user has moderation rights.

    +

    Suspended user can access this endpoint.

    +

    Scope: users:read

    +

    Example requests:

    +
      +
    • without parameters:

    • +
    +
    GET /api/users/Sam/sanctions HTTP/1.1
    +
    +
    +
      +
    • with parameters:

    • +
    +
    GET /api/users/Sam/sanctions?page=2 HTTP/1.1
    +
    +
    +

    Example responses:

    +
      +
    • if sanctions exist (response with moderation rights)

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "sanctions": [
    +        {
    +          "action_type": "workout_suspension",
    +          "appeal": {
    +            "approved": null,
    +            "created_at": "Wed, 04 Dec 2024 11:00:04 GMT",
    +            "id": "2ULe2hWhSnYCS2VHbsikB9",
    +            "moderator": null,
    +            "reason": null,
    +            "text": "<APPEAL TEXT>",
    +            "updated_at": null,
    +            "user": {
    +              "blocked": false,
    +              "created_at": "Wed, 04 Dec 2024 09:07:06 GMT",
    +              "email": "sam@example.com",
    +              "followers": 0,
    +              "following": 0,
    +              "follows": false,
    +              "is_active": true,
    +              "is_followed_by": false,
    +              "nb_workouts": 1,
    +              "picture": false,
    +              "role": "user",
    +              "suspended_at": null,
    +              "username": "Sam"
    +            }
    +          },
    +          "created_at": "Wed, 04 Dec 2024 10:59:45 GMT",
    +          "id": "6dxczvMrhkAR72shUz9Pwd",
    +          "moderator": {
    +            "blocked": false,
    +            "created_at": "Wed, 01 Mar 2023 12:31:17 GMT",
    +            "email": "admin@example.com",
    +            "followers": 0,
    +            "following": 0,
    +            "follows": "false",
    +            "is_active": true,
    +            "is_followed_by": "false",
    +            "nb_workouts": 0,
    +            "picture": true,
    +            "role": "admin",
    +            "suspended_at": null,
    +            "username": "admin"
    +          },
    +          "reason": "<SUSPENSION REASON>",
    +          "report_id": 2,
    +          "user": {
    +            "blocked": false,
    +            "created_at": "Sun, 01 Dec 2024 17:27:49 GMT",
    +            "email": "sam@example.com",
    +            "followers": 0,
    +            "following": 0,
    +            "follows": "false",
    +            "is_active": true,
    +            "is_followed_by": "false",
    +            "nb_workouts": 1,
    +            "picture": false,
    +            "role": "user",
    +            "suspended_at": null,
    +            "username": "Sam"
    +          }
    +        }
    +      ]
    +    },
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 1,
    +      "total": 1
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • if sanctions exist (response for authenticated user)

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "sanctions": [
    +        {
    +          "action_type": "workout_suspension",
    +          "appeal": {
    +            "approved": null,
    +            "created_at": "Wed, 04 Dec 2024 16:50:55 GMT",
    +            "id": "kcj6hdGQqPKaaKQmfQj8Jv",
    +            "reason": null,
    +            "text": "<APPEAL TEXT>",
    +            "updated_at": null
    +          },
    +          "created_at": "Wed, 04 Dec 2024 16:50:44 GMT",
    +          "id": "6nvxvAyoh9Zkr8RMXhu54T",
    +          "reason": "<SUSPENSION REASON>"
    +        }
    +      ]
    +    },
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 1,
    +      "total": 1
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • no sanctions

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "sanctions": []
    +    },
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 0,
    +      "total": 0
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • user_name (string) – user name

    • +
    +
    +
    Query Parameters:
    +
      +
    • page (integer) – page if using pagination (default: 1)

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OK – success

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      +

    • +
    • 404 Not Found

        +
      • user not found

      • +
      +

    • +
    +
    +
    +
    + @@ -809,14 +1518,14 @@

    Users

    - +
    Previous
    -
    Statistics
    +
    Timeline
    diff --git a/docs/en/api/workouts.html b/docs/en/api/workouts.html index 0ddba3a46..c62bb0c7f 100644 --- a/docs/en/api/workouts.html +++ b/docs/en/api/workouts.html @@ -215,12 +215,17 @@
  • API documentation @@ -322,7 +327,10 @@

    Workouts "duration": "0:17:04", "equipments": [], "id": "kjxavSTUrJvoAh2wvCeGEF", + "liked": false, + "likes_count": 0, "map": null, + "map_visibility": "private", "max_alt": null, "max_speed": 10.0, "min_alt": null, @@ -381,12 +389,24 @@

    Workouts ], "segments": [], "sport_id": 1, + "suspended": false, + "suspended_at": null, "title": null, - "user": "admin", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "weather_end": null, "weather_start": null, "with_gpx": false, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_visibility": "private" } ] }, @@ -431,8 +451,8 @@

    Workouts
  • order_by (string) – sorting criteria: ave_speed, distance, duration, workout_date (default: workout_date)

  • -
  • equipment_id (string) – equipment id (if ‘none’, only workouts without -equipments will be returned)

  • +
  • equipment_id (string) – equipment id (if none, only workouts +without equipments will be returned)

  • notes (string) – any part (or all) of the workout notes, notes matching is case-insensitive

  • description (string) – any part of the workout description; @@ -453,6 +473,10 @@

    Workouts
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -463,7 +487,6 @@

    Workouts
    GET /api/workouts/(string: workout_short_id)

    Get a workout.

    -

    Scope: workouts:read

    Example request:

    Request Headers:
      -
    • Authorization – OAuth 2.0 Bearer Token

    • +
    • Authorization – OAuth 2.0 Bearer Token for workout with +private or followers_only visibility

    Status Codes:
    @@ -549,7 +588,11 @@

    Workouts
  • invalid token, please log in again

  • -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundworkout not found

  • @@ -560,7 +603,6 @@

    Workouts
    GET /api/workouts/(string: workout_short_id)/gpx

    Get gpx file for a workout displayed on map with Leaflet.

    -

    Scope: workouts:read

    Example request:

    GET /api/workouts/kjxavSTUrJvoAh2wvCeGEF/gpx HTTP/1.1
     Content-Type: application/json
    @@ -587,7 +629,8 @@ 

    Workouts

    Request Headers:
      -
    • Authorization – OAuth 2.0 Bearer Token

    • +
    • Authorization – OAuth 2.0 Bearer Token for workout with +private or followers_only map visibility

    Status Codes:
    @@ -599,6 +642,11 @@

    Workouts
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Found

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Found

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundworkout not found

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -758,9 +816,8 @@

    Workouts
    GET /api/workouts/(string: workout_short_id)/gpx/segment/(int: segment_id)

    Get gpx file for a workout segment displayed on map with Leaflet.

    -

    Scope: workouts:read

    Example request:

    -
    GET /api/workouts/kjxavSTUrJvoAh2wvCeGEF/gpx/segment/0 HTTP/1.1
    +
    GET /api/workouts/kjxavSTUrJvoAh2wvCeGEF/gpx/segment/1 HTTP/1.1
     Content-Type: application/json
     
    @@ -786,7 +843,8 @@

    Workouts

    Request Headers:
      -
    • Authorization – OAuth 2.0 Bearer Token

    • +
    • Authorization – OAuth 2.0 Bearer Token for workout with +private or followers_only map visibility

    Status Codes:
    @@ -799,6 +857,11 @@

    Workouts
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundworkout not found

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -835,6 +898,10 @@

    Workouts
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundmap does not exist

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -897,6 +964,10 @@

    Workouts
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Found

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 413 Request Entity Too Largeerror during picture update: file size exceeds 1.0MB

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -1079,8 +1198,12 @@

    Workouts "description": null, "distance": 10.0, "duration": "0:17:04", + "id": "Kd5wyhwLtVozw6o3AU5M4J", + "liked": false, + "likes_count": 0, "equipments": [], "map": null, + "map_visibility": "private", "max_alt": null, "max_speed": 10.0, "min_alt": null, @@ -1130,13 +1253,25 @@

    Workouts ], "segments": [], "sport_id": 1, + "suspended": false, + "suspended_at": null, "title": null, - "user": "admin", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "uuid": "kjxavSTUrJvoAh2wvCeGEF" "weather_end": null, "weather_start": null, "with_gpx": false, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_visibility": "private" } ] }, @@ -1191,6 +1326,10 @@

    Workouts
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -1203,7 +1342,7 @@

    Workouts

    Update a workout.

    Scope: workouts:write

    Example request:

    -
    PATCH /api/workouts/1 HTTP/1.1
    +
    PATCH /api/workouts/2oRDfncv6vpRkfp3yrCYHt HTTP/1.1
     Content-Type: application/json
     
    @@ -1224,7 +1363,11 @@

    Workouts "distance": 10.0, "duration": "0:17:04", "equipments": [], + "id": "2oRDfncv6vpRkfp3yrCYHt", + "liked": false, + "likes_count": 0, "map": null, + "map_visibility": "private", "max_alt": null, "max_speed": 10.0, "min_alt": null, @@ -1274,13 +1417,25 @@

    Workouts ], "segments": [], "sport_id": 1, + "suspended": false, + "suspended_at": null, "title": null, - "user": "admin", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "uuid": "kjxavSTUrJvoAh2wvCeGEF" "weather_end": null, "weather_start": null, "with_gpx": false, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_visibility": "private" } ] }, @@ -1306,18 +1461,22 @@

    Workouts (only for workout without gpx)

  • duration (integer) – workout duration in seconds (only for workout without gpx)

  • +
  • equipment_ids (array of strings) – the id of the equipment to associate with this workout (any existing +equipment for this workout will be replaced). +Note: for now only one equipment can be associated. +If an empty array, equipment for this workout will be removed.

  • +
  • map_visibility (string) – map and analysis data visibility +(private, followers_only or public)

  • notes (string) – notes (max length: 500 characters, otherwise they will be truncated)

  • sport_id (integer) – workout sport id

  • title (string) – workout title (max length: 255 characters, otherwise it will be truncated)

  • -
  • equipment_ids (array of strings) – the id of the equipment to associate with this workout (any existing -equipment for this workout will be replaced). -Note: for now only one equipment can be associated. -If an empty array, equipment for this workout will be removed.

  • workout_date (string) – workout date in user timezone (format: %Y-%m-%d %H:%M) (only for workout without gpx)

  • +
  • workout_visibility (string) – workout visibility (private, +followers_only or public)

  • Request Headers:
    @@ -1343,6 +1502,10 @@

    Workouts
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundworkout not found

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -1385,6 +1548,263 @@

    Workouts
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • +
  • 404 Not Foundworkout not found

  • +
  • 500 Internal Server Errorerror, please try again or contact the administrator

  • + + + + + +
    +
    +POST /api/workouts/(string: workout_short_id)/like
    +

    Add a “like” to a workout.

    +

    Scope: workouts:write

    +

    Example request:

    +
    POST /api/workouts/HgzYFXgvWKCEpdq3vYk67q/like HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "workouts": [
    +        {
    +          "ascent": 231.208,
    +          "ave_speed": 13.12,
    +          "bounds": [],
    +          "creation_date": "Wed, 04 Dec 2024 09:18:26 GMT",
    +          "descent": 234.208,
    +          "description": null,
    +          "distance": 23.41,
    +          "duration": "3:32:27",
    +          "equipments": [],
    +          "id": "HgzYFXgvWKCEpdq3vYk67q",
    +          "liked": true,
    +          "likes_count": 1,
    +          "map": null,
    +          "map_visibility": "private",
    +          "max_alt": 104.44,
    +          "max_speed": 25.59,
    +          "min_alt": 19.0,
    +          "modification_date": "Wed, 04 Dec 2024 16:45:14 GMT",
    +          "moving": "1:47:04",
    +          "next_workout": null,
    +          "notes": null,
    +          "pauses": "1:23:51",
    +          "previous_workout": null,
    +          "records": [],
    +          "segments": [],
    +          "sport_id": 1,
    +          "suspended": false,
    +          "title": "Cycling (Sport) - 2016-04-26 16:42:27",
    +          "user": {
    +            "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +            "followers": 0,
    +            "following": 0,
    +            "nb_workouts": 1,
    +            "picture": false,
    +            "role": "user",
    +            "suspended_at": null,
    +            "username": "Sam"
    +          },
    +          "weather_end": null,
    +          "weather_start": null,
    +          "with_gpx": false,
    +          "workout_date": "Tue, 26 Apr 2016 14:42:27 GMT",
    +          "workout_visibility": "public"
    +        }
    +      ]
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • workout_short_id (string) – workout short id

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundcomment not found

    • +
    +
    +
    +
    + +
    +
    +POST /api/workouts/(string: workout_short_id)/like/undo
    +

    Remove workout “like”.

    +

    Scope: workouts:write

    +

    Example request:

    +
    POST /api/workouts/HgzYFXgvWKCEpdq3vYk67q/like/undo HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "workouts": [
    +        {
    +          "ascent": 231.208,
    +          "ave_speed": 13.12,
    +          "bounds": [],
    +          "creation_date": "Wed, 04 Dec 2024 09:18:26 GMT",
    +          "descent": 234.208,
    +          "description": null,
    +          "distance": 23.41,
    +          "duration": "3:32:27",
    +          "equipments": [],
    +          "id": "HgzYFXgvWKCEpdq3vYk67q",
    +          "liked": false,
    +          "likes_count": 0,
    +          "map": null,
    +          "map_visibility": "private",
    +          "max_alt": 104.44,
    +          "max_speed": 25.59,
    +          "min_alt": 19.0,
    +          "modification_date": "Wed, 04 Dec 2024 16:45:14 GMT",
    +          "moving": "1:47:04",
    +          "next_workout": null,
    +          "notes": null,
    +          "pauses": "1:23:51",
    +          "previous_workout": null,
    +          "records": [],
    +          "segments": [],
    +          "sport_id": 1,
    +          "suspended": false,
    +          "title": "Cycling (Sport) - 2016-04-26 16:42:27",
    +          "user": {
    +            "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +            "followers": 0,
    +            "following": 0,
    +            "nb_workouts": 1,
    +            "picture": false,
    +            "role": "user",
    +            "suspended_at": null,
    +            "username": "Sam"
    +          },
    +          "weather_end": null,
    +          "weather_start": null,
    +          "with_gpx": false,
    +          "workout_date": "Tue, 26 Apr 2016 14:42:27 GMT",
    +          "workout_visibility": "public"
    +        }
    +      ]
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • workout_short_id (string) – workout short id

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundcomment not found

    • +
    +
    +
    +
    + +
    +
    +POST /api/workouts/(string: workout_short_id)/suspension/appeal
    +

    Appeal workout suspension.

    +

    Only workout author can appeal the suspension.

    +

    Scope: workouts:write

    +

    Example request:

    +
    POST /api/workouts/2oRDfncv6vpRkfp3yrCYHt/suspension/appeal HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Example response:

    +
    HTTP/1.1 201 CREATED
    +Content-Type: application/json
    +
    +  {
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Parameters:
    +
      +
    • workout_short_id (string) – workout short id

    • +
    +
    +
    Request Headers:
    +
    +
    +
    Status Codes:
    +
    diff --git a/docs/en/changelog.html b/docs/en/changelog.html index f938d7e43..2dcb43c9e 100644 --- a/docs/en/changelog.html +++ b/docs/en/changelog.html @@ -215,12 +215,17 @@
  • API documentation diff --git a/docs/en/cli.html b/docs/en/cli.html index 4e3c64d94..f3e4d9b00 100644 --- a/docs/en/cli.html +++ b/docs/en/cli.html @@ -215,12 +215,17 @@
  • API documentation @@ -400,6 +405,9 @@

    ftcli

    Added in version 0.7.15.

  • +
    +

    Changed in version 0.8.4: User preference for interface language is added.

    +

    Create a user account.

    Note

    @@ -408,10 +416,6 @@

    ftcli

    the CLI allows to create users when registration is disabled.

    -
    -

    Changed in version 0.8.4.

    -
    -

    User preference for interface language is added.

    @@ -471,7 +475,10 @@

    ftcli

    Added in version 0.6.5.

    -

    Modify a user account (admin rights, active status, email and password).

    +
    +

    Changed in version 0.9.0: Add --set-role option. --set-admin is now deprecated.

    +
    +

    Modify a user account (role, active status, email and password).

    @@ -488,15 +495,18 @@

    ftcli

    Username.

    - + + + + - + - + - + diff --git a/docs/en/features.html b/docs/en/features.html index 2d2eddde3..d88a73bea 100644 --- a/docs/en/features.html +++ b/docs/en/features.html @@ -215,12 +215,17 @@
  • API documentation @@ -286,15 +291,17 @@

    Features
    FitTrackee allows you to store and display gpx files and some statistics from your outdoor activities.
    Equipments can be associated with workouts.
    -
    For now, this app is kind of a single-user application. Even if several users can register, a user can only view his own workouts.
    +
    If registration is enabled, multiple users can register and interact with other users (comments, likes). Workouts and comments are visible to other users according to visibility levels.

    Gpx files are stored in an upload directory (without encryption).

    With the default configuration, Open Street Map is used as tile server in Workout detail and for static map generation.

    Workouts

    +
    +

    Sports

    -
      -
    • Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts.

    • -
    • Workout creation by uploading manually a gpx file or a zip archive containing a limited number of gpx files (related data are stored in database in metric system).

    • +
    +
    +

    Workouts

    +
      +
    • A workout can be created by:

      +
        +
      • uploading manually a gpx file or a zip archive containing a limited number of gpx files,

      • +
      • +
        or entering date, time, duration and distance (without gpx file).
        +
        Ascent and descent can also be provided (new in 0.7.10).
        +
        +
      • +
      +

    Warning

    @@ -363,6 +377,12 @@

    Workouts

    Note

    +
    Related data are stored in database in metric system.
    +
    +
    +
    @@ -373,52 +393,236 @@

    Workouts

  • -
    The user can add description (new in 0.8.9) and note.
    +
    The user can add description (new in 0.8.9) and private notes.
    +
    A limited Markdown syntax can be used (new in 0.9.0).
    +
    +
  • +
  • If present and no description is provided by the user, the description from the gpx file (<desc></desc>) is used as the workout description (new in 0.8.10).

  • +
  • +
    A workout with a gpx file can be displayed with map and charts (speed and elevation (if the gpx file contains elevation data, updated in 0.7.20)).
    +
    Controls allow full screen view and position reset (new in 0.5.5).
    +
    +
  • +
  • +
    If Visual Crossing (new in 0.7.11) API key is provided, weather is displayed in workout detail. Data source is displayed in About page.
    +
    Wind is displayed, with an arrow indicating the direction (a tooltip can be displayed with the direction that the wind is coming from) (new in 0.5.5).
    +
    +
  • +
  • +
    An equipment can be associated with a workout (new in 0.8.0). For now, only one equipment can be associated.
    +
    Equipment is only visible to workout owner.
  • +
  • Segments can be displayed.

  • +
  • Records associated with the workout are displayed.

  • Note

    +

    Records may differ from records displayed by the application that originally generated the gpx files.

    +
    +
      +
    • Visibility level can be set separately for workout data and map and analysis data (new in 0.9.0):

      +
        +
      • private: only owner can see the workout,

      • +
      • followers only: only owner and followers can see the workout,

      • +
      • public: anyone can see the workout even unauthenticated users.

      • +
      +
    • +
    +
    +

    Note

    +
    +
    A workout with a gpx file whose visibility for map and analysis data does not allow them to be viewed appears as a workout without a gpx file.
    +
    +
    +
    +

    Note

    -
    This “Description” field is longer than the “Notes” field and will have the same visibility as the workout in a next version (see #125). The “Notes” field will remain private.
    +
    Default visibility is private. All workouts created before FitTrackee 0.9.0 are private.
    +
    +

    Important

    +
    +
    Please keep in mind that the server operating team or the moderation team may view content with restricted visibility.
    +
    +
    +
      +
    • Workout can be edited:

      +
        +
      • sport

      • +
      • title

      • +
      • equipment

      • +
      • description (new in 0.8.9)

      • +
      • private notes

      • +
      • workout visibility (new in 0.9.0)

      • +
      • map and analysis visibility (new in 0.9.0)

      • +
      • date (only workouts without gpx)

      • +
      • duration (only workouts without gpx)

      • +
      • distance (only workouts without gpx)

      • +
      • ascent and descent (only workouts without gpx) (new in 0.7.10)

      • +
      +
    • +
    • Workout gpx file can be downloaded (new in 0.5.1).

    • +
    • Workout can be deleted.

    • +
    • Workouts list.

      +
        +
      • The user can filter workouts on:

        +
          +
        • date

        • +
        • sports (only sports with workouts are displayed in sport dropdown)

        • +
        • equipment (only equipments with workouts are displayed in equipment dropdown) (new in 0.8.0)

        • +
        • title (new in 0.7.15)

        • +
        • description (new in 0.8.9)

        • +
        • notes (new in 0.8.0)

        • +
        • distance

        • +
        • duration

        • +
        • average speed

        • +
        • maximum speed

        • +
        +
      • +
      • Workouts can be sorted by:

        +
          +
        • date

        • +
        • distance

        • +
        • duration

        • +
        • average speed

        • +
        +
      • +
      +
    • +
    • A user can report a workout that violates instance rules. This will send a notification to moderators and administrators.

    • +
    + + +
    +

    Interactions

    +
    +

    Added in version 0.9.0.

    +
    +
    +

    Users

      -
    • If present and no description is provided by the user, the description from the gpx file (<desc></desc>) is used as the workout description (new in 0.8.10).

    • -
      A workout can even be created without gpx (the user must enter date, time, duration and distance).
      -
      Ascent and descent can also be provided (new in 0.7.10).
      +
      Users directory.
      +
      A user can configure visibility in directory in the user preferences.
      +
      This has an impact on username completion when writing comments (only profiles visible in users directory or followed users are suggested).
    • +
    +
    +

    Note

    +

    A user profile remains accessible via its URL.

    +
    +
    • -
      A workout with a gpx file can be displayed with map and charts (speed and elevation (if the gpx file contains elevation data, updated in 0.7.20)).
      -
      Controls allow full screen view and position reset (new in 0.5.5).
      +
      User can send follow request to others users.
      +
      Follow request can be approved or rejected.
    • -
      If Visual Crossing (new in 0.7.11) API key is provided, weather is displayed in workout detail. Data source is displayed in About page.
      -
      Wind is displayed, with an arrow indicating the direction (a tooltip can be displayed with the direction that the wind is coming from) (new in 0.5.5).
      +
      In order to hide unwanted content, a user can block another user.
      +
      Blocking users hides their workouts on timeline and comments. Notifications from blocked users are not displayed.
      +
      Blocked users cannot see workouts and comments from users who have blocked them, or follow them (if they followed them, they are forced to unfollow them).
    • -
    • An equipment can be associated with a workout (new in 0.8.0). For now, only one equipment can be associated.

    • -
    • Segments can be displayed.

    • -
    • Workout gpx file can be downloaded (new in 0.5.1)

    • -
    • Workout edition and deletion.

    • -
    • -
      User statistics, by time period (week, month, year) and sport:
        -
      • -
        totals:
          +
        • A user can report a user profile that violates instance rules. This will send a notification to moderators and administrators.

        • +
        +
    +
    +

    Comments

    +
      +
    • +
      Depending on visibility, a user can comment on a workout.
      +
      A limited Markdown syntax can be used.
      +
      +
    • +
    • The visibility levels for comment are:

      +
        +
      • private: only author and mentioned users can see the comment,

      • +
      • followers only: only author, followers and mentioned users can see the comment,

      • +
      • public: anyone can see the comment even unauthenticated users.

      • +
      +
    • +
    +
    +

    Important

    +
    +
    Please keep in mind that the server operating team or the moderation team may view content with restricted visibility.
    +
    +
    +
      +
    • Comment text can be modified (visibility level cannot be changed).

    • +
    • A user can report a comment that violates instance rules. This will send a notification to moderators and administrators.

    • +
    +
    +
    +

    Likes

    +
      +
    • Depending on visibility, a user can like or “unlike” a workout or a comment.

    • +
    +
    +
    +

    Notifications

    +
      +
    • Notifications are sent for the following event:

      +
        +
      • follow request and follow

      • +
      • like on comment or workout

      • +
      • comment on workout

      • +
      • mention on comment

      • +
      • suspension or warning (an email is also sent if email sending is enabled)

      • +
      • suspension or warning lifting (an email is also sent if email sending is enabled)

      • +
      +
    • +
    • Users with moderation rights can also receive notifications on:

      +
        +
      • report creation

      • +
      • appeal on suspension or warning

      • +
      +
    • +
    • Users with administration rights can also receive notifications on user creation.

    • +
    • Users can mark notifications as read or unread.

    • +
    +
    +
    +
    +

    Dashboard

    +
      +
    • A dashboard displays:

      +
        +
      • a graph with monthly statistics

      • +
      • a monthly calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts.

      • +
      • user records by sports:

        +
          +
        • average speed

        • +
        • farthest distance

        • +
        • highest ascent (new in 0.6.11, can be hidden, see user preferences)

        • +
        • longest duration

        • +
        • maximum speed

        • +
        +
      • +
      • a timeline with workouts visible to user

      • +
      +
    • +
    +
    +
    +

    Statistics

    + -
      -
    • -
      User records by sports:
        -
      • average speed

      • -
      • farthest distance

      • -
      • highest ascent (new in 0.6.11, can be hidden, see user preferences)

      • -
      • longest duration

      • -
      • maximum speed

      • -
      -
      -
      -
    • -
    -
    -

    Note

    -

    Records may differ from records displayed by the application that originally generated the gpx files.

    -
    -
      -
    • -
      Workouts list.
        -
      • -
        The user can filter workouts on:
          -
        • date

        • -
        • sports (only sports with workouts are displayed in sport dropdown)

        • -
        • equipment (only equipments with workouts are displayed in equipment dropdown) (new in 0.8.0)

        • -
        • title (new in 0.7.15)

        • -
        • description (new in 0.8.9)

        • -
        • notes (new in 0.8.0)

        • -
        • distance

        • -
        • duration

        • -
        • average speed

        • -
        • maximum speed

        • -
        -
        -
        -
      • -
      • -
        Workouts can be sorted by:
          -
        • date

        • -
        • distance

        • -
        • duration

        • -
        • average speed

        • -
        -
        -
        -
      • -
      -
      -
      -
    • -
    -
    -

    Note

    -

    For now, only the owner of the workout can see it.

    -

    Account & preferences

    @@ -528,6 +671,8 @@

    Account & preferences

    A user can reset his password (new in 0.3.0)

  • A user can change his email address (new in 0.6.0)

  • A user can set language, timezone and first day of week.

  • +
  • A user can set follow requests approval: manually (default) or automatically. (new in 0.9.0)

  • +
  • A user can set profile visibility in users directory: hidden (default) or displayed (new in 0.9.0)

  • A user can set the interface theme (light, dark or according to browser preferences) (new in 0.7.27).

  • A user can choose between metric system and imperial system for distance, elevation and speed display (new in 0.5.0)

  • A user can choose to display or hide ascent records and total on Dashboard (new in 0.6.11)

  • @@ -540,15 +685,14 @@

    Account & preferencesChanging this preference will only affect next file uploads.

    +
      +
    • A user can display blocked users (new in 0.9.0).

    • +
    • A user can view follow requests to approve or reject (new in 0.9.0).

    • +
    • A user can view received sanctions and appeal (new in 0.9.0).

    • +

    Equipments

    -

    (new in 0.8.0)

    +
    +

    Added in version 0.8.0.

    +
    @@ -628,17 +775,25 @@

    Equipments

    OAuth Apps

    -

    (new in 0.7.0)

    +
    +

    Added in version 0.7.0.

    +
    • A user can create clients for third-party applications.

    Administration

    -

    (new in 0.3.0)

    +
    +

    Added in version 0.3.0.

    +

    Application

    -

    Configuration

    +
      +
    • Only users if administration rights can access application administration.

    • +
    +
    +

    Configuration

    The following parameters can be set:

    +
    +

    About

    +
    +

    Added in version 0.7.13.

    +
    -
    It is possible displayed additional information that may be useful to users in About page.
    +
    It is possible displayed additional information that may be useful to users in About page (like instance rules).
    Markdown syntax can be used.
    -

    Privacy policy

    -

    (new in 0.7.13)

    +
    +
    +

    Privacy policy

    +
    +

    Added in version 0.7.13.

    +
    A default privacy policy is available (originally adapted from the 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 it.
    @@ -673,13 +836,60 @@

    Application -

    Users

    +

    +
    +

    Users

    +
    +

    Changed in version 0.9.0: Add moderator and owner role

    +
    +
      +
    • Only users with administration rights can access users administration.

    • +
    • Roles:

      +
        +
      • user

        +
          +
        • no moderation or administration rights

        • +
        +
      • +
      • moderator (new in 0.9.0):

        +
          +
        • can only access moderation entry in administration

        • +
        • can see reports

        • +
        • perform report actions

        • +
        +
      • +
      • administrator

        +
          +
        • has moderator rights (new in 0.9.0)

        • +
        • can access all entries in administration:

          +
            +
          • application

          • +
          • moderation

          • +
          • equipment types

          • +
          • sports

          • +
          • users

          • +
          +
        • +
        +
      • +
      • owner (new in 0.9.0) :

        +
          +
        • has admin rights

        • +
        • role can not be modified by other administrator/owner on application

        • +
        +
      • +
      +
    • +
    +
    +

    Note

    +

    Roles defined prior to version 0.9.0 remain unchanged.

    +
    • display and filter users list

    • edit a user to:

        -
      • add/remove administration rights

      • +
      • update role (updated in 0.9.0). A user with owner role can not be modified by other users. Owner role can only be assigned or removed with FitTrackee CLI.

      • 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.

      • @@ -688,16 +898,48 @@

        Users
      • delete a user

    +
    +

    Moderation

    +
    +

    Added in version 0.9.0.

    +
    +
      +
    • Only users with administration or moderation rights can access moderation.

    • +
    • Display and filter reports list.

    • +
    • Manage a report:

      +
        +
      • add a comment

      • +
      • send a warning

      • +
      • suspend or reactive workout or comment

      • +
      • suspend or reactive user account

      • +
      • mark report as resolved or unresolved

      • +
      +
    • +
    +
    +

    Note

    +

    Report content is visible regardless the visibility level.

    +
    +
      +
    • A user can appeal suspension or warning.

    • +
    • Suspended user can only access his account, appeal the account suspension, request and data export or delete his account. His sessions and comments are no longer visible.

    • +
    +

    Equipment Types

    +
    +

    Added in version 0.8.0.

    +
      -
    • enable or disable an equipment type in order to match disabled sports (a equipment type can be disabled even if equipment with this type exists) (new in 0.8.0)

    • +
    • Only users with administration rights can access equipment types administration.

    • +
    • enable or disable an equipment type in order to match disabled sports (a equipment type can be disabled even if equipment with this type exists) (new in 0.8.0).

    -
    -

    Sports

    +
    +

    Sports

      -
    • enable or disable a sport (a sport can be disabled even if workout with this sport exists)

    • +
    • Only users with administration rights can access sports administration.

    • +
    • Enable or disable a sport (a sport can be disabled even if workout with this sport exists).

    @@ -727,49 +969,61 @@

    Translations

    Screenshots

    -
    -

    Dashboard

    +
    +

    Dashboard

    -FitTrackee Dashboard +FitTrackee Dashboard

    Workout detail

    -FitTrackee Workout +FitTrackee Workout

    Workouts list

    -FitTrackee Workouts +FitTrackee Workouts
    -
    -

    Statistics

    +
    +

    Statistics

    -FitTrackee Statistics +FitTrackee Statistics
    -FitTrackee Sport Statistics +FitTrackee Sport Statistics
    -
    -

    Equipments

    +
    +

    Equipments

    -FitTrackee Equipments +FitTrackee Equipments
    -FitTrackee Equipment detail +FitTrackee Equipment Detail
    -
    -

    Administration

    +
    +

    Notifications

    -FitTrackee Administration +FitTrackee Notifications
    +
    +
    +

    Users directory

    -FitTrackee Sports Administration +FitTrackee Users Directory +
    +
    +
    +

    Administration

    +
    +FitTrackee Administration +
    +
    +FitTrackee Sports Administration
    @@ -835,25 +1089,46 @@

    Administration

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -${t} -`}tablecell(t){const n=this.parser.parseInline(t.tokens),a=t.header?"th":"td";return(t.align?`<${a} align="${t.align}">`:`<${a}>`)+n+` -`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${ca(t,!0)}`}br(t){return"
    "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:n,tokens:a}){const s=this.parser.parseInline(a),r=NT(t);if(r===null)return s;t=r;let i='",i}image({href:t,title:n,text:a}){const s=NT(t);if(s===null)return ca(a);t=s;let r=`${a}{const c=o[u].flat(1/0);a=a.concat(this.walkTokens(c,n))}):o.tokens&&(a=a.concat(this.walkTokens(o.tokens,n)))}}return a}use(...t){const n=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(a=>{const s={...a};if(s.async=this.defaults.async||s.async||!1,a.extensions&&(a.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){const i=n.renderers[r.name];i?n.renderers[r.name]=function(...o){let u=r.renderer.apply(this,o);return u===!1&&(u=i.apply(this,o)),u}:n.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const i=n[r.level];i?i.unshift(r.tokenizer):n[r.level]=[r.tokenizer],r.start&&(r.level==="block"?n.startBlock?n.startBlock.push(r.start):n.startBlock=[r.start]:r.level==="inline"&&(n.startInline?n.startInline.push(r.start):n.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(n.childTokens[r.name]=r.childTokens)}),s.extensions=n),a.renderer){const r=this.defaults.renderer||new Uu(this.defaults);for(const i in a.renderer){if(!(i in r))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;const o=i,u=a.renderer[o],c=r[o];r[o]=(...l)=>{let E=u.apply(r,l);return E===!1&&(E=c.apply(r,l)),E||""}}s.renderer=r}if(a.tokenizer){const r=this.defaults.tokenizer||new ku(this.defaults);for(const i in a.tokenizer){if(!(i in r))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;const o=i,u=a.tokenizer[o],c=r[o];r[o]=(...l)=>{let E=u.apply(r,l);return E===!1&&(E=c.apply(r,l)),E}}s.tokenizer=r}if(a.hooks){const r=this.defaults.hooks||new li;for(const i in a.hooks){if(!(i in r))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;const o=i,u=a.hooks[o],c=r[o];li.passThroughHooks.has(i)?r[o]=l=>{if(this.defaults.async)return Promise.resolve(u.call(r,l)).then(d=>c.call(r,d));const E=u.call(r,l);return c.call(r,E)}:r[o]=(...l)=>{let E=u.apply(r,l);return E===!1&&(E=c.apply(r,l)),E}}s.hooks=r}if(a.walkTokens){const r=this.defaults.walkTokens,i=a.walkTokens;s.walkTokens=function(o){let u=[];return u.push(i.call(this,o)),r&&(u=u.concat(r.call(this,o))),u}}this.defaults={...this.defaults,...s}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,n){return Wn.lex(t,n??this.defaults)}parser(t,n){return zn.parse(t,n??this.defaults)}parseMarkdown(t){return(a,s)=>{const r={...s},i={...this.defaults,...r},o=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&r.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof a>"u"||a===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof a!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(a)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=t);const u=i.hooks?i.hooks.provideLexer():t?Wn.lex:Wn.lexInline,c=i.hooks?i.hooks.provideParser():t?zn.parse:zn.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(a):a).then(l=>u(l,i)).then(l=>i.hooks?i.hooks.processAllTokens(l):l).then(l=>i.walkTokens?Promise.all(this.walkTokens(l,i.walkTokens)).then(()=>l):l).then(l=>c(l,i)).then(l=>i.hooks?i.hooks.postprocess(l):l).catch(o);try{i.hooks&&(a=i.hooks.preprocess(a));let l=u(a,i);i.hooks&&(l=i.hooks.processAllTokens(l)),i.walkTokens&&this.walkTokens(l,i.walkTokens);let E=c(l,i);return i.hooks&&(E=i.hooks.postprocess(E)),E}catch(l){return o(l)}}}onError(t,n){return a=>{if(a.message+=` -Please report this to https://github.com/markedjs/marked.`,t){const s="

    An error occurred:

    "+ca(a.message+"",!0)+"
    ";return n?Promise.resolve(s):s}if(n)return Promise.reject(a);throw a}}}const Us=new XFe;function pt(e,t){return Us.parse(e,t)}pt.options=pt.setOptions=function(e){return Us.setOptions(e),pt.defaults=Us.defaults,OA(pt.defaults),pt};pt.getDefaults=op;pt.defaults=zs;pt.use=function(...e){return Us.use(...e),pt.defaults=Us.defaults,OA(pt.defaults),pt};pt.walkTokens=function(e,t){return Us.walkTokens(e,t)};pt.parseInline=Us.parseInline;pt.Parser=zn;pt.parser=zn.parse;pt.Renderer=Uu;pt.TextRenderer=fp;pt.Lexer=Wn;pt.lexer=Wn.lex;pt.Tokenizer=ku;pt.Hooks=li;pt.parse=pt;pt.options;pt.setOptions;pt.use;pt.walkTokens;pt.parseInline;zn.parse;Wn.lex;var qn={},Kn={},ic={},jn={},po={},CT;function QFe(){return CT||(CT=1,Object.defineProperty(po,"__esModule",{value:!0}),po.default=new Uint16Array('ᵁ<Õıʊҝջאٵ۞ޢߖࠏ੊ઑඡ๭༉༦჊ረዡᐕᒝᓃᓟᔥ\0\0\0\0\0\0ᕫᛍᦍᰒᷝ὾⁠↰⊍⏀⏻⑂⠤⤒ⴈ⹈⿎〖㊺㘹㞬㣾㨨㩱㫠㬮ࠀEMabcfglmnoprstu\\bfms„‹•˜¦³¹ÈÏlig耻Æ䃆P耻&䀦cute耻Á䃁reve;䄂Āiyx}rc耻Â䃂;䐐r;쀀𝔄rave耻À䃀pha;䎑acr;䄀d;橓Āgp¡on;䄄f;쀀𝔸plyFunction;恡ing耻Å䃅Ācs¾Ãr;쀀𝒜ign;扔ilde耻Ã䃃ml耻Ä䃄ЀaceforsuåûþėĜĢħĪĀcrêòkslash;或Ŷöø;櫧ed;挆y;䐑ƀcrtąċĔause;戵noullis;愬a;䎒r;쀀𝔅pf;쀀𝔹eve;䋘còēmpeq;扎܀HOacdefhilorsuōőŖƀƞƢƵƷƺǜȕɳɸɾcy;䐧PY耻©䂩ƀcpyŝŢźute;䄆Ā;iŧŨ拒talDifferentialD;慅leys;愭ȀaeioƉƎƔƘron;䄌dil耻Ç䃇rc;䄈nint;戰ot;䄊ĀdnƧƭilla;䂸terDot;䂷òſi;䎧rcleȀDMPTLJNjǑǖot;抙inus;抖lus;投imes;抗oĀcsǢǸkwiseContourIntegral;戲eCurlyĀDQȃȏoubleQuote;思uote;怙ȀlnpuȞȨɇɕonĀ;eȥȦ户;橴ƀgitȯȶȺruent;扡nt;戯ourIntegral;戮ĀfrɌɎ;愂oduct;成nterClockwiseContourIntegral;戳oss;樯cr;쀀𝒞pĀ;Cʄʅ拓ap;才րDJSZacefiosʠʬʰʴʸˋ˗ˡ˦̳ҍĀ;oŹʥtrahd;椑cy;䐂cy;䐅cy;䐏ƀgrsʿ˄ˇger;怡r;憡hv;櫤Āayː˕ron;䄎;䐔lĀ;t˝˞戇a;䎔r;쀀𝔇Āaf˫̧Ācm˰̢riticalȀADGT̖̜̀̆cute;䂴oŴ̋̍;䋙bleAcute;䋝rave;䁠ilde;䋜ond;拄ferentialD;慆Ѱ̽\0\0\0͔͂\0Ѕf;쀀𝔻ƀ;DE͈͉͍䂨ot;惜qual;扐blèCDLRUVͣͲ΂ϏϢϸontourIntegraìȹoɴ͹\0\0ͻ»͉nArrow;懓Āeo·ΤftƀARTΐΖΡrrow;懐ightArrow;懔eåˊngĀLRΫτeftĀARγιrrow;柸ightArrow;柺ightArrow;柹ightĀATϘϞrrow;懒ee;抨pɁϩ\0\0ϯrrow;懑ownArrow;懕erticalBar;戥ǹABLRTaВЪаўѿͼrrowƀ;BUНОТ憓ar;椓pArrow;懵reve;䌑eft˒к\0ц\0ѐightVector;楐eeVector;楞ectorĀ;Bљњ憽ar;楖ightǔѧ\0ѱeeVector;楟ectorĀ;BѺѻ懁ar;楗eeĀ;A҆҇护rrow;憧ĀctҒҗr;쀀𝒟rok;䄐ࠀNTacdfglmopqstuxҽӀӄӋӞӢӧӮӵԡԯԶՒ՝ՠեG;䅊H耻Ð䃐cute耻É䃉ƀaiyӒӗӜron;䄚rc耻Ê䃊;䐭ot;䄖r;쀀𝔈rave耻È䃈ement;戈ĀapӺӾcr;䄒tyɓԆ\0\0ԒmallSquare;旻erySmallSquare;斫ĀgpԦԪon;䄘f;쀀𝔼silon;䎕uĀaiԼՉlĀ;TՂՃ橵ilde;扂librium;懌Āci՗՚r;愰m;橳a;䎗ml耻Ë䃋Āipժկsts;戃onentialE;慇ʀcfiosօֈ֍ֲ׌y;䐤r;쀀𝔉lledɓ֗\0\0֣mallSquare;旼erySmallSquare;斪Ͱֺ\0ֿ\0\0ׄf;쀀𝔽All;戀riertrf;愱cò׋؀JTabcdfgorstר׬ׯ׺؀ؒؖ؛؝أ٬ٲcy;䐃耻>䀾mmaĀ;d׷׸䎓;䏜reve;䄞ƀeiy؇،ؐdil;䄢rc;䄜;䐓ot;䄠r;쀀𝔊;拙pf;쀀𝔾eater̀EFGLSTصلَٖٛ٦qualĀ;Lؾؿ扥ess;招ullEqual;执reater;檢ess;扷lantEqual;橾ilde;扳cr;쀀𝒢;扫ЀAacfiosuڅڋږڛڞڪھۊRDcy;䐪Āctڐڔek;䋇;䁞irc;䄤r;愌lbertSpace;愋ǰگ\0ڲf;愍izontalLine;攀Āctۃۅòکrok;䄦mpńېۘownHumðįqual;扏܀EJOacdfgmnostuۺ۾܃܇܎ܚܞܡܨ݄ݸދޏޕcy;䐕lig;䄲cy;䐁cute耻Í䃍Āiyܓܘrc耻Î䃎;䐘ot;䄰r;愑rave耻Ì䃌ƀ;apܠܯܿĀcgܴܷr;䄪inaryI;慈lieóϝǴ݉\0ݢĀ;eݍݎ戬Āgrݓݘral;戫section;拂isibleĀCTݬݲomma;恣imes;恢ƀgptݿރވon;䄮f;쀀𝕀a;䎙cr;愐ilde;䄨ǫޚ\0ޞcy;䐆l耻Ï䃏ʀcfosuެ޷޼߂ߐĀiyޱ޵rc;䄴;䐙r;쀀𝔍pf;쀀𝕁ǣ߇\0ߌr;쀀𝒥rcy;䐈kcy;䐄΀HJacfosߤߨ߽߬߱ࠂࠈcy;䐥cy;䐌ppa;䎚Āey߶߻dil;䄶;䐚r;쀀𝔎pf;쀀𝕂cr;쀀𝒦րJTaceflmostࠥࠩࠬࡐࡣ঳সে্਷ੇcy;䐉耻<䀼ʀcmnpr࠷࠼ࡁࡄࡍute;䄹bda;䎛g;柪lacetrf;愒r;憞ƀaeyࡗ࡜ࡡron;䄽dil;䄻;䐛Āfsࡨ॰tԀACDFRTUVarࡾࢩࢱࣦ࣠ࣼयज़ΐ४Ānrࢃ࢏gleBracket;柨rowƀ;BR࢙࢚࢞憐ar;懤ightArrow;懆eiling;挈oǵࢷ\0ࣃbleBracket;柦nǔࣈ\0࣒eeVector;楡ectorĀ;Bࣛࣜ懃ar;楙loor;挊ightĀAV࣯ࣵrrow;憔ector;楎Āerँगeƀ;AVउऊऐ抣rrow;憤ector;楚iangleƀ;BEतथऩ抲ar;槏qual;抴pƀDTVषूौownVector;楑eeVector;楠ectorĀ;Bॖॗ憿ar;楘ectorĀ;B॥०憼ar;楒ightáΜs̀EFGLSTॾঋকঝঢভqualGreater;拚ullEqual;扦reater;扶ess;檡lantEqual;橽ilde;扲r;쀀𝔏Ā;eঽা拘ftarrow;懚idot;䄿ƀnpw৔ਖਛgȀLRlr৞৷ਂਐeftĀAR০৬rrow;柵ightArrow;柷ightArrow;柶eftĀarγਊightáοightáϊf;쀀𝕃erĀLRਢਬeftArrow;憙ightArrow;憘ƀchtਾੀੂòࡌ;憰rok;䅁;扪Ѐacefiosuਗ਼੝੠੷੼અઋ઎p;椅y;䐜Ādl੥੯iumSpace;恟lintrf;愳r;쀀𝔐nusPlus;戓pf;쀀𝕄cò੶;䎜ҀJacefostuણધભીଔଙඑ඗ඞcy;䐊cute;䅃ƀaey઴હાron;䅇dil;䅅;䐝ƀgswે૰଎ativeƀMTV૓૟૨ediumSpace;怋hiĀcn૦૘ë૙eryThiî૙tedĀGL૸ଆreaterGreateòٳessLesóੈLine;䀊r;쀀𝔑ȀBnptଢନଷ଺reak;恠BreakingSpace;䂠f;愕ڀ;CDEGHLNPRSTV୕ୖ୪୼஡௫ఄ౞಄ದ೘ൡඅ櫬Āou୛୤ngruent;扢pCap;扭oubleVerticalBar;戦ƀlqxஃஊ஛ement;戉ualĀ;Tஒஓ扠ilde;쀀≂̸ists;戄reater΀;EFGLSTஶஷ஽௉௓௘௥扯qual;扱ullEqual;쀀≧̸reater;쀀≫̸ess;批lantEqual;쀀⩾̸ilde;扵umpń௲௽ownHump;쀀≎̸qual;쀀≏̸eĀfsఊధtTriangleƀ;BEచఛడ拪ar;쀀⧏̸qual;括s̀;EGLSTవశ఼ౄోౘ扮qual;扰reater;扸ess;쀀≪̸lantEqual;쀀⩽̸ilde;扴estedĀGL౨౹reaterGreater;쀀⪢̸essLess;쀀⪡̸recedesƀ;ESಒಓಛ技qual;쀀⪯̸lantEqual;拠ĀeiಫಹverseElement;戌ghtTriangleƀ;BEೋೌ೒拫ar;쀀⧐̸qual;拭ĀquೝഌuareSuĀbp೨೹setĀ;E೰ೳ쀀⊏̸qual;拢ersetĀ;Eഃആ쀀⊐̸qual;拣ƀbcpഓതൎsetĀ;Eഛഞ쀀⊂⃒qual;抈ceedsȀ;ESTലള഻െ抁qual;쀀⪰̸lantEqual;拡ilde;쀀≿̸ersetĀ;E൘൛쀀⊃⃒qual;抉ildeȀ;EFT൮൯൵ൿ扁qual;扄ullEqual;扇ilde;扉erticalBar;戤cr;쀀𝒩ilde耻Ñ䃑;䎝܀Eacdfgmoprstuvලෂ෉෕ෛ෠෧෼ขภยา฿ไlig;䅒cute耻Ó䃓Āiy෎ීrc耻Ô䃔;䐞blac;䅐r;쀀𝔒rave耻Ò䃒ƀaei෮ෲ෶cr;䅌ga;䎩cron;䎟pf;쀀𝕆enCurlyĀDQฎบoubleQuote;怜uote;怘;橔Āclวฬr;쀀𝒪ash耻Ø䃘iŬื฼de耻Õ䃕es;樷ml耻Ö䃖erĀBP๋๠Āar๐๓r;怾acĀek๚๜;揞et;掴arenthesis;揜Ҁacfhilors๿ງຊຏຒດຝະ໼rtialD;戂y;䐟r;쀀𝔓i;䎦;䎠usMinus;䂱Āipຢອncareplanåڝf;愙Ȁ;eio຺ູ໠໤檻cedesȀ;EST່້໏໚扺qual;檯lantEqual;扼ilde;找me;怳Ādp໩໮uct;戏ortionĀ;aȥ໹l;戝Āci༁༆r;쀀𝒫;䎨ȀUfos༑༖༛༟OT耻"䀢r;쀀𝔔pf;愚cr;쀀𝒬؀BEacefhiorsu༾གྷཇའཱིྦྷྪྭ႖ႩႴႾarr;椐G耻®䂮ƀcnrཎནབute;䅔g;柫rĀ;tཛྷཝ憠l;椖ƀaeyཧཬཱron;䅘dil;䅖;䐠Ā;vླྀཹ愜erseĀEUྂྙĀlq྇ྎement;戋uilibrium;懋pEquilibrium;楯r»ཹo;䎡ghtЀACDFTUVa࿁࿫࿳ဢဨၛႇϘĀnr࿆࿒gleBracket;柩rowƀ;BL࿜࿝࿡憒ar;懥eftArrow;懄eiling;按oǵ࿹\0စbleBracket;柧nǔည\0နeeVector;楝ectorĀ;Bဝသ懂ar;楕loor;挋Āerိ၃eƀ;AVဵံြ抢rrow;憦ector;楛iangleƀ;BEၐၑၕ抳ar;槐qual;抵pƀDTVၣၮၸownVector;楏eeVector;楜ectorĀ;Bႂႃ憾ar;楔ectorĀ;B႑႒懀ar;楓Āpuႛ႞f;愝ndImplies;楰ightarrow;懛ĀchႹႼr;愛;憱leDelayed;槴ڀHOacfhimoqstuფჱჷჽᄙᄞᅑᅖᅡᅧᆵᆻᆿĀCcჩხHcy;䐩y;䐨FTcy;䐬cute;䅚ʀ;aeiyᄈᄉᄎᄓᄗ檼ron;䅠dil;䅞rc;䅜;䐡r;쀀𝔖ortȀDLRUᄪᄴᄾᅉownArrow»ОeftArrow»࢚ightArrow»࿝pArrow;憑gma;䎣allCircle;战pf;쀀𝕊ɲᅭ\0\0ᅰt;戚areȀ;ISUᅻᅼᆉᆯ斡ntersection;抓uĀbpᆏᆞsetĀ;Eᆗᆘ抏qual;抑ersetĀ;Eᆨᆩ抐qual;抒nion;抔cr;쀀𝒮ar;拆ȀbcmpᇈᇛሉላĀ;sᇍᇎ拐etĀ;Eᇍᇕqual;抆ĀchᇠህeedsȀ;ESTᇭᇮᇴᇿ扻qual;檰lantEqual;扽ilde;承Tháྌ;我ƀ;esሒሓሣ拑rsetĀ;Eሜም抃qual;抇et»ሓրHRSacfhiorsሾቄ቉ቕ቞ቱቶኟዂወዑORN耻Þ䃞ADE;愢ĀHc቎ቒcy;䐋y;䐦Ābuቚቜ;䀉;䎤ƀaeyብቪቯron;䅤dil;䅢;䐢r;쀀𝔗Āeiቻ኉Dzኀ\0ኇefore;戴a;䎘Ācn኎ኘkSpace;쀀  Space;怉ldeȀ;EFTካኬኲኼ戼qual;扃ullEqual;扅ilde;扈pf;쀀𝕋ipleDot;惛Āctዖዛr;쀀𝒯rok;䅦ૡዷጎጚጦ\0ጬጱ\0\0\0\0\0ጸጽ፷ᎅ\0᏿ᐄᐊᐐĀcrዻጁute耻Ú䃚rĀ;oጇገ憟cir;楉rǣጓ\0጖y;䐎ve;䅬Āiyጞጣrc耻Û䃛;䐣blac;䅰r;쀀𝔘rave耻Ù䃙acr;䅪Ādiፁ፩erĀBPፈ፝Āarፍፐr;䁟acĀekፗፙ;揟et;掵arenthesis;揝onĀ;P፰፱拃lus;抎Āgp፻፿on;䅲f;쀀𝕌ЀADETadps᎕ᎮᎸᏄϨᏒᏗᏳrrowƀ;BDᅐᎠᎤar;椒ownArrow;懅ownArrow;憕quilibrium;楮eeĀ;AᏋᏌ报rrow;憥ownáϳerĀLRᏞᏨeftArrow;憖ightArrow;憗iĀ;lᏹᏺ䏒on;䎥ing;䅮cr;쀀𝒰ilde;䅨ml耻Ü䃜ҀDbcdefosvᐧᐬᐰᐳᐾᒅᒊᒐᒖash;披ar;櫫y;䐒ashĀ;lᐻᐼ抩;櫦Āerᑃᑅ;拁ƀbtyᑌᑐᑺar;怖Ā;iᑏᑕcalȀBLSTᑡᑥᑪᑴar;戣ine;䁼eparator;杘ilde;所ThinSpace;怊r;쀀𝔙pf;쀀𝕍cr;쀀𝒱dash;抪ʀcefosᒧᒬᒱᒶᒼirc;䅴dge;拀r;쀀𝔚pf;쀀𝕎cr;쀀𝒲Ȁfiosᓋᓐᓒᓘr;쀀𝔛;䎞pf;쀀𝕏cr;쀀𝒳ҀAIUacfosuᓱᓵᓹᓽᔄᔏᔔᔚᔠcy;䐯cy;䐇cy;䐮cute耻Ý䃝Āiyᔉᔍrc;䅶;䐫r;쀀𝔜pf;쀀𝕐cr;쀀𝒴ml;䅸ЀHacdefosᔵᔹᔿᕋᕏᕝᕠᕤcy;䐖cute;䅹Āayᕄᕉron;䅽;䐗ot;䅻Dzᕔ\0ᕛoWidtè૙a;䎖r;愨pf;愤cr;쀀𝒵௡ᖃᖊᖐ\0ᖰᖶᖿ\0\0\0\0ᗆᗛᗫᙟ᙭\0ᚕ᚛ᚲᚹ\0ᚾcute耻á䃡reve;䄃̀;Ediuyᖜᖝᖡᖣᖨᖭ戾;쀀∾̳;房rc耻â䃢te肻´̆;䐰lig耻æ䃦Ā;r²ᖺ;쀀𝔞rave耻à䃠ĀepᗊᗖĀfpᗏᗔsym;愵èᗓha;䎱ĀapᗟcĀclᗤᗧr;䄁g;樿ɤᗰ\0\0ᘊʀ;adsvᗺᗻᗿᘁᘇ戧nd;橕;橜lope;橘;橚΀;elmrszᘘᘙᘛᘞᘿᙏᙙ戠;榤e»ᘙsdĀ;aᘥᘦ戡ѡᘰᘲᘴᘶᘸᘺᘼᘾ;榨;榩;榪;榫;榬;榭;榮;榯tĀ;vᙅᙆ戟bĀ;dᙌᙍ抾;榝Āptᙔᙗh;戢»¹arr;捼Āgpᙣᙧon;䄅f;쀀𝕒΀;Eaeiop዁ᙻᙽᚂᚄᚇᚊ;橰cir;橯;扊d;手s;䀧roxĀ;e዁ᚒñᚃing耻å䃥ƀctyᚡᚦᚨr;쀀𝒶;䀪mpĀ;e዁ᚯñʈilde耻ã䃣ml耻ä䃤Āciᛂᛈoninôɲnt;樑ࠀNabcdefiklnoprsu᛭ᛱᜰ᜼ᝃᝈ᝸᝽០៦ᠹᡐᜍ᤽᥈ᥰot;櫭Ācrᛶ᜞kȀcepsᜀᜅᜍᜓong;扌psilon;䏶rime;怵imĀ;e᜚᜛戽q;拍Ŷᜢᜦee;抽edĀ;gᜬᜭ挅e»ᜭrkĀ;t፜᜷brk;掶Āoyᜁᝁ;䐱quo;怞ʀcmprtᝓ᝛ᝡᝤᝨausĀ;eĊĉptyv;榰séᜌnoõēƀahwᝯ᝱ᝳ;䎲;愶een;扬r;쀀𝔟g΀costuvwឍឝឳេ៕៛៞ƀaiuបពរðݠrc;旯p»፱ƀdptឤឨឭot;樀lus;樁imes;樂ɱឹ\0\0ើcup;樆ar;昅riangleĀdu៍្own;施p;斳plus;樄eåᑄåᒭarow;植ƀako៭ᠦᠵĀcn៲ᠣkƀlst៺֫᠂ozenge;槫riangleȀ;dlr᠒᠓᠘᠝斴own;斾eft;旂ight;斸k;搣Ʊᠫ\0ᠳƲᠯ\0ᠱ;斒;斑4;斓ck;斈ĀeoᠾᡍĀ;qᡃᡆ쀀=⃥uiv;쀀≡⃥t;挐Ȁptwxᡙᡞᡧᡬf;쀀𝕓Ā;tᏋᡣom»Ꮜtie;拈؀DHUVbdhmptuvᢅᢖᢪᢻᣗᣛᣬ᣿ᤅᤊᤐᤡȀLRlrᢎᢐᢒᢔ;敗;敔;敖;敓ʀ;DUduᢡᢢᢤᢦᢨ敐;敦;敩;敤;敧ȀLRlrᢳᢵᢷᢹ;敝;敚;敜;教΀;HLRhlrᣊᣋᣍᣏᣑᣓᣕ救;敬;散;敠;敫;敢;敟ox;槉ȀLRlrᣤᣦᣨᣪ;敕;敒;攐;攌ʀ;DUduڽ᣷᣹᣻᣽;敥;敨;攬;攴inus;抟lus;択imes;抠ȀLRlrᤙᤛᤝ᤟;敛;敘;攘;攔΀;HLRhlrᤰᤱᤳᤵᤷ᤻᤹攂;敪;敡;敞;攼;攤;攜Āevģ᥂bar耻¦䂦Ȁceioᥑᥖᥚᥠr;쀀𝒷mi;恏mĀ;e᜚᜜lƀ;bhᥨᥩᥫ䁜;槅sub;柈Ŭᥴ᥾lĀ;e᥹᥺怢t»᥺pƀ;Eeįᦅᦇ;檮Ā;qۜۛೡᦧ\0᧨ᨑᨕᨲ\0ᨷᩐ\0\0᪴\0\0᫁\0\0ᬡᬮ᭍᭒\0᯽\0ᰌƀcpr᦭ᦲ᧝ute;䄇̀;abcdsᦿᧀᧄ᧊᧕᧙戩nd;橄rcup;橉Āau᧏᧒p;橋p;橇ot;橀;쀀∩︀Āeo᧢᧥t;恁îړȀaeiu᧰᧻ᨁᨅǰ᧵\0᧸s;橍on;䄍dil耻ç䃧rc;䄉psĀ;sᨌᨍ橌m;橐ot;䄋ƀdmnᨛᨠᨦil肻¸ƭptyv;榲t脀¢;eᨭᨮ䂢räƲr;쀀𝔠ƀceiᨽᩀᩍy;䑇ckĀ;mᩇᩈ朓ark»ᩈ;䏇r΀;Ecefms᩟᩠ᩢᩫ᪤᪪᪮旋;槃ƀ;elᩩᩪᩭ䋆q;扗eɡᩴ\0\0᪈rrowĀlr᩼᪁eft;憺ight;憻ʀRSacd᪒᪔᪖᪚᪟»ཇ;擈st;抛irc;抚ash;抝nint;樐id;櫯cir;槂ubsĀ;u᪻᪼晣it»᪼ˬ᫇᫔᫺\0ᬊonĀ;eᫍᫎ䀺Ā;qÇÆɭ᫙\0\0᫢aĀ;t᫞᫟䀬;䁀ƀ;fl᫨᫩᫫戁îᅠeĀmx᫱᫶ent»᫩eóɍǧ᫾\0ᬇĀ;dኻᬂot;橭nôɆƀfryᬐᬔᬗ;쀀𝕔oäɔ脀©;sŕᬝr;愗Āaoᬥᬩrr;憵ss;朗Ācuᬲᬷr;쀀𝒸Ābpᬼ᭄Ā;eᭁᭂ櫏;櫑Ā;eᭉᭊ櫐;櫒dot;拯΀delprvw᭠᭬᭷ᮂᮬᯔ᯹arrĀlr᭨᭪;椸;椵ɰ᭲\0\0᭵r;拞c;拟arrĀ;p᭿ᮀ憶;椽̀;bcdosᮏᮐᮖᮡᮥᮨ截rcap;橈Āauᮛᮞp;橆p;橊ot;抍r;橅;쀀∪︀Ȁalrv᮵ᮿᯞᯣrrĀ;mᮼᮽ憷;椼yƀevwᯇᯔᯘqɰᯎ\0\0ᯒreã᭳uã᭵ee;拎edge;拏en耻¤䂤earrowĀlrᯮ᯳eft»ᮀight»ᮽeäᯝĀciᰁᰇoninôǷnt;戱lcty;挭ঀAHabcdefhijlorstuwz᰸᰻᰿ᱝᱩᱵᲊᲞᲬᲷ᳻᳿ᴍᵻᶑᶫᶻ᷆᷍rò΁ar;楥Ȁglrs᱈ᱍ᱒᱔ger;怠eth;愸òᄳhĀ;vᱚᱛ怐»ऊūᱡᱧarow;椏aã̕Āayᱮᱳron;䄏;䐴ƀ;ao̲ᱼᲄĀgrʿᲁr;懊tseq;橷ƀglmᲑᲔᲘ耻°䂰ta;䎴ptyv;榱ĀirᲣᲨsht;楿;쀀𝔡arĀlrᲳᲵ»ࣜ»သʀaegsv᳂͸᳖᳜᳠mƀ;oș᳊᳔ndĀ;ș᳑uit;晦amma;䏝in;拲ƀ;io᳧᳨᳸䃷de脀÷;o᳧ᳰntimes;拇nø᳷cy;䑒cɯᴆ\0\0ᴊrn;挞op;挍ʀlptuwᴘᴝᴢᵉᵕlar;䀤f;쀀𝕕ʀ;emps̋ᴭᴷᴽᵂqĀ;d͒ᴳot;扑inus;戸lus;戔quare;抡blebarwedgåúnƀadhᄮᵝᵧownarrowóᲃarpoonĀlrᵲᵶefôᲴighôᲶŢᵿᶅkaro÷གɯᶊ\0\0ᶎrn;挟op;挌ƀcotᶘᶣᶦĀryᶝᶡ;쀀𝒹;䑕l;槶rok;䄑Ādrᶰᶴot;拱iĀ;fᶺ᠖斿Āah᷀᷃ròЩaòྦangle;榦Āci᷒ᷕy;䑟grarr;柿ऀDacdefglmnopqrstuxḁḉḙḸոḼṉṡṾấắẽỡἪἷὄ὎὚ĀDoḆᴴoôᲉĀcsḎḔute耻é䃩ter;橮ȀaioyḢḧḱḶron;䄛rĀ;cḭḮ扖耻ê䃪lon;払;䑍ot;䄗ĀDrṁṅot;扒;쀀𝔢ƀ;rsṐṑṗ檚ave耻è䃨Ā;dṜṝ檖ot;檘Ȁ;ilsṪṫṲṴ檙nters;揧;愓Ā;dṹṺ檕ot;檗ƀapsẅẉẗcr;䄓tyƀ;svẒẓẕ戅et»ẓpĀ1;ẝẤijạả;怄;怅怃ĀgsẪẬ;䅋p;怂ĀgpẴẸon;䄙f;쀀𝕖ƀalsỄỎỒrĀ;sỊị拕l;槣us;橱iƀ;lvỚớở䎵on»ớ;䏵ȀcsuvỪỳἋἣĀioữḱrc»Ḯɩỹ\0\0ỻíՈantĀglἂἆtr»ṝess»Ṻƀaeiἒ἖Ἒls;䀽st;扟vĀ;DȵἠD;橸parsl;槥ĀDaἯἳot;打rr;楱ƀcdiἾὁỸr;愯oô͒ĀahὉὋ;䎷耻ð䃰Āmrὓὗl耻ë䃫o;悬ƀcipὡὤὧl;䀡sôծĀeoὬὴctatioîՙnentialåչৡᾒ\0ᾞ\0ᾡᾧ\0\0ῆῌ\0ΐ\0ῦῪ \0 ⁚llingdotseñṄy;䑄male;晀ƀilrᾭᾳ῁lig;耀ffiɩᾹ\0\0᾽g;耀ffig;耀ffl;쀀𝔣lig;耀filig;쀀fjƀaltῙ῜ῡt;晭ig;耀flns;斱of;䆒ǰ΅\0ῳf;쀀𝕗ĀakֿῷĀ;vῼ´拔;櫙artint;樍Āao‌⁕Ācs‑⁒ႉ‸⁅⁈\0⁐β•‥‧‪‬\0‮耻½䂽;慓耻¼䂼;慕;慙;慛Ƴ‴\0‶;慔;慖ʴ‾⁁\0\0⁃耻¾䂾;慗;慜5;慘ƶ⁌\0⁎;慚;慝8;慞l;恄wn;挢cr;쀀𝒻ࢀEabcdefgijlnorstv₂₉₟₥₰₴⃰⃵⃺⃿℃ℒℸ̗ℾ⅒↞Ā;lٍ₇;檌ƀcmpₐₕ₝ute;䇵maĀ;dₜ᳚䎳;檆reve;䄟Āiy₪₮rc;䄝;䐳ot;䄡Ȁ;lqsؾق₽⃉ƀ;qsؾٌ⃄lanô٥Ȁ;cdl٥⃒⃥⃕c;檩otĀ;o⃜⃝檀Ā;l⃢⃣檂;檄Ā;e⃪⃭쀀⋛︀s;檔r;쀀𝔤Ā;gٳ؛mel;愷cy;䑓Ȁ;Eajٚℌℎℐ;檒;檥;檤ȀEaesℛℝ℩ℴ;扩pĀ;p℣ℤ檊rox»ℤĀ;q℮ℯ檈Ā;q℮ℛim;拧pf;쀀𝕘Āci⅃ⅆr;愊mƀ;el٫ⅎ⅐;檎;檐茀>;cdlqr׮ⅠⅪⅮⅳⅹĀciⅥⅧ;檧r;橺ot;拗Par;榕uest;橼ʀadelsↄⅪ←ٖ↛ǰ↉\0↎proø₞r;楸qĀlqؿ↖lesó₈ií٫Āen↣↭rtneqq;쀀≩︀Å↪ԀAabcefkosy⇄⇇⇱⇵⇺∘∝∯≨≽ròΠȀilmr⇐⇔⇗⇛rsðᒄf»․ilôکĀdr⇠⇤cy;䑊ƀ;cwࣴ⇫⇯ir;楈;憭ar;意irc;䄥ƀalr∁∎∓rtsĀ;u∉∊晥it»∊lip;怦con;抹r;쀀𝔥sĀew∣∩arow;椥arow;椦ʀamopr∺∾≃≞≣rr;懿tht;戻kĀlr≉≓eftarrow;憩ightarrow;憪f;쀀𝕙bar;怕ƀclt≯≴≸r;쀀𝒽asè⇴rok;䄧Ābp⊂⊇ull;恃hen»ᱛૡ⊣\0⊪\0⊸⋅⋎\0⋕⋳\0\0⋸⌢⍧⍢⍿\0⎆⎪⎴cute耻í䃭ƀ;iyݱ⊰⊵rc耻î䃮;䐸Ācx⊼⊿y;䐵cl耻¡䂡ĀfrΟ⋉;쀀𝔦rave耻ì䃬Ȁ;inoܾ⋝⋩⋮Āin⋢⋦nt;樌t;戭fin;槜ta;愩lig;䄳ƀaop⋾⌚⌝ƀcgt⌅⌈⌗r;䄫ƀelpܟ⌏⌓inåގarôܠh;䄱f;抷ed;䆵ʀ;cfotӴ⌬⌱⌽⍁are;愅inĀ;t⌸⌹戞ie;槝doô⌙ʀ;celpݗ⍌⍐⍛⍡al;抺Āgr⍕⍙eróᕣã⍍arhk;樗rod;樼Ȁcgpt⍯⍲⍶⍻y;䑑on;䄯f;쀀𝕚a;䎹uest耻¿䂿Āci⎊⎏r;쀀𝒾nʀ;EdsvӴ⎛⎝⎡ӳ;拹ot;拵Ā;v⎦⎧拴;拳Ā;iݷ⎮lde;䄩ǫ⎸\0⎼cy;䑖l耻ï䃯̀cfmosu⏌⏗⏜⏡⏧⏵Āiy⏑⏕rc;䄵;䐹r;쀀𝔧ath;䈷pf;쀀𝕛ǣ⏬\0⏱r;쀀𝒿rcy;䑘kcy;䑔Ѐacfghjos␋␖␢␧␭␱␵␻ppaĀ;v␓␔䎺;䏰Āey␛␠dil;䄷;䐺r;쀀𝔨reen;䄸cy;䑅cy;䑜pf;쀀𝕜cr;쀀𝓀஀ABEHabcdefghjlmnoprstuv⑰⒁⒆⒍⒑┎┽╚▀♎♞♥♹♽⚚⚲⛘❝❨➋⟀⠁⠒ƀart⑷⑺⑼rò৆òΕail;椛arr;椎Ā;gঔ⒋;檋ar;楢ॣ⒥\0⒪\0⒱\0\0\0\0\0⒵Ⓔ\0ⓆⓈⓍ\0⓹ute;䄺mptyv;榴raîࡌbda;䎻gƀ;dlࢎⓁⓃ;榑åࢎ;檅uo耻«䂫rЀ;bfhlpst࢙ⓞⓦⓩ⓫⓮⓱⓵Ā;f࢝ⓣs;椟s;椝ë≒p;憫l;椹im;楳l;憢ƀ;ae⓿─┄檫il;椙Ā;s┉┊檭;쀀⪭︀ƀabr┕┙┝rr;椌rk;杲Āak┢┬cĀek┨┪;䁻;䁛Āes┱┳;榋lĀdu┹┻;榏;榍Ȁaeuy╆╋╖╘ron;䄾Ādi═╔il;䄼ìࢰâ┩;䐻Ȁcqrs╣╦╭╽a;椶uoĀ;rนᝆĀdu╲╷har;楧shar;楋h;憲ʀ;fgqs▋▌উ◳◿扤tʀahlrt▘▤▷◂◨rrowĀ;t࢙□aé⓶arpoonĀdu▯▴own»њp»०eftarrows;懇ightƀahs◍◖◞rrowĀ;sࣴࢧarpoonó྘quigarro÷⇰hreetimes;拋ƀ;qs▋ও◺lanôবʀ;cdgsব☊☍☝☨c;檨otĀ;o☔☕橿Ā;r☚☛檁;檃Ā;e☢☥쀀⋚︀s;檓ʀadegs☳☹☽♉♋pproøⓆot;拖qĀgq♃♅ôউgtò⒌ôছiíলƀilr♕࣡♚sht;楼;쀀𝔩Ā;Eজ♣;檑š♩♶rĀdu▲♮Ā;l॥♳;楪lk;斄cy;䑙ʀ;achtੈ⚈⚋⚑⚖rò◁orneòᴈard;楫ri;旺Āio⚟⚤dot;䅀ustĀ;a⚬⚭掰che»⚭ȀEaes⚻⚽⛉⛔;扨pĀ;p⛃⛄檉rox»⛄Ā;q⛎⛏檇Ā;q⛎⚻im;拦Ѐabnoptwz⛩⛴⛷✚✯❁❇❐Ānr⛮⛱g;柬r;懽rëࣁgƀlmr⛿✍✔eftĀar০✇ightá৲apsto;柼ightá৽parrowĀlr✥✩efô⓭ight;憬ƀafl✶✹✽r;榅;쀀𝕝us;樭imes;樴š❋❏st;戗áፎƀ;ef❗❘᠀旊nge»❘arĀ;l❤❥䀨t;榓ʀachmt❳❶❼➅➇ròࢨorneòᶌarĀ;d྘➃;業;怎ri;抿̀achiqt➘➝ੀ➢➮➻quo;怹r;쀀𝓁mƀ;egল➪➬;檍;檏Ābu┪➳oĀ;rฟ➹;怚rok;䅂萀<;cdhilqrࠫ⟒☹⟜⟠⟥⟪⟰Āci⟗⟙;檦r;橹reå◲mes;拉arr;楶uest;橻ĀPi⟵⟹ar;榖ƀ;ef⠀भ᠛旃rĀdu⠇⠍shar;楊har;楦Āen⠗⠡rtneqq;쀀≨︀Å⠞܀Dacdefhilnopsu⡀⡅⢂⢎⢓⢠⢥⢨⣚⣢⣤ઃ⣳⤂Dot;戺Ȁclpr⡎⡒⡣⡽r耻¯䂯Āet⡗⡙;時Ā;e⡞⡟朠se»⡟Ā;sျ⡨toȀ;dluျ⡳⡷⡻owîҌefôएðᏑker;斮Āoy⢇⢌mma;権;䐼ash;怔asuredangle»ᘦr;쀀𝔪o;愧ƀcdn⢯⢴⣉ro耻µ䂵Ȁ;acdᑤ⢽⣀⣄sôᚧir;櫰ot肻·Ƶusƀ;bd⣒ᤃ⣓戒Ā;uᴼ⣘;横ţ⣞⣡p;櫛ò−ðઁĀdp⣩⣮els;抧f;쀀𝕞Āct⣸⣽r;쀀𝓂pos»ᖝƀ;lm⤉⤊⤍䎼timap;抸ఀGLRVabcdefghijlmoprstuvw⥂⥓⥾⦉⦘⧚⧩⨕⨚⩘⩝⪃⪕⪤⪨⬄⬇⭄⭿⮮ⰴⱧⱼ⳩Āgt⥇⥋;쀀⋙̸Ā;v⥐௏쀀≫⃒ƀelt⥚⥲⥶ftĀar⥡⥧rrow;懍ightarrow;懎;쀀⋘̸Ā;v⥻ే쀀≪⃒ightarrow;懏ĀDd⦎⦓ash;抯ash;抮ʀbcnpt⦣⦧⦬⦱⧌la»˞ute;䅄g;쀀∠⃒ʀ;Eiop඄⦼⧀⧅⧈;쀀⩰̸d;쀀≋̸s;䅉roø඄urĀ;a⧓⧔普lĀ;s⧓ସdz⧟\0⧣p肻 ଷmpĀ;e௹ఀʀaeouy⧴⧾⨃⨐⨓ǰ⧹\0⧻;橃on;䅈dil;䅆ngĀ;dൾ⨊ot;쀀⩭̸p;橂;䐽ash;怓΀;Aadqsxஒ⨩⨭⨻⩁⩅⩐rr;懗rĀhr⨳⨶k;椤Ā;oᏲᏰot;쀀≐̸uiöୣĀei⩊⩎ar;椨í஘istĀ;s஠டr;쀀𝔫ȀEest௅⩦⩹⩼ƀ;qs஼⩭௡ƀ;qs஼௅⩴lanô௢ií௪Ā;rஶ⪁»ஷƀAap⪊⪍⪑rò⥱rr;憮ar;櫲ƀ;svྍ⪜ྌĀ;d⪡⪢拼;拺cy;䑚΀AEadest⪷⪺⪾⫂⫅⫶⫹rò⥦;쀀≦̸rr;憚r;急Ȁ;fqs఻⫎⫣⫯tĀar⫔⫙rro÷⫁ightarro÷⪐ƀ;qs఻⪺⫪lanôౕĀ;sౕ⫴»శiíౝĀ;rవ⫾iĀ;eచథiäඐĀpt⬌⬑f;쀀𝕟膀¬;in⬙⬚⬶䂬nȀ;Edvஉ⬤⬨⬮;쀀⋹̸ot;쀀⋵̸ǡஉ⬳⬵;拷;拶iĀ;vಸ⬼ǡಸ⭁⭃;拾;拽ƀaor⭋⭣⭩rȀ;ast୻⭕⭚⭟lleì୻l;쀀⫽⃥;쀀∂̸lint;樔ƀ;ceಒ⭰⭳uåಥĀ;cಘ⭸Ā;eಒ⭽ñಘȀAait⮈⮋⮝⮧rò⦈rrƀ;cw⮔⮕⮙憛;쀀⤳̸;쀀↝̸ghtarrow»⮕riĀ;eೋೖ΀chimpqu⮽⯍⯙⬄୸⯤⯯Ȁ;cerല⯆ഷ⯉uå൅;쀀𝓃ortɭ⬅\0\0⯖ará⭖mĀ;e൮⯟Ā;q൴൳suĀbp⯫⯭å೸åഋƀbcp⯶ⰑⰙȀ;Ees⯿ⰀഢⰄ抄;쀀⫅̸etĀ;eഛⰋqĀ;qണⰀcĀ;eലⰗñസȀ;EesⰢⰣൟⰧ抅;쀀⫆̸etĀ;e൘ⰮqĀ;qൠⰣȀgilrⰽⰿⱅⱇìௗlde耻ñ䃱çృiangleĀlrⱒⱜeftĀ;eచⱚñదightĀ;eೋⱥñ೗Ā;mⱬⱭ䎽ƀ;esⱴⱵⱹ䀣ro;愖p;怇ҀDHadgilrsⲏⲔⲙⲞⲣⲰⲶⳓⳣash;抭arr;椄p;쀀≍⃒ash;抬ĀetⲨⲬ;쀀≥⃒;쀀>⃒nfin;槞ƀAetⲽⳁⳅrr;椂;쀀≤⃒Ā;rⳊⳍ쀀<⃒ie;쀀⊴⃒ĀAtⳘⳜrr;椃rie;쀀⊵⃒im;쀀∼⃒ƀAan⳰⳴ⴂrr;懖rĀhr⳺⳽k;椣Ā;oᏧᏥear;椧ቓ᪕\0\0\0\0\0\0\0\0\0\0\0\0\0ⴭ\0ⴸⵈⵠⵥ⵲ⶄᬇ\0\0ⶍⶫ\0ⷈⷎ\0ⷜ⸙⸫⸾⹃Ācsⴱ᪗ute耻ó䃳ĀiyⴼⵅrĀ;c᪞ⵂ耻ô䃴;䐾ʀabios᪠ⵒⵗLjⵚlac;䅑v;樸old;榼lig;䅓Ācr⵩⵭ir;榿;쀀𝔬ͯ⵹\0\0⵼\0ⶂn;䋛ave耻ò䃲;槁Ābmⶈ෴ar;榵Ȁacitⶕ⶘ⶥⶨrò᪀Āir⶝ⶠr;榾oss;榻nå๒;槀ƀaeiⶱⶵⶹcr;䅍ga;䏉ƀcdnⷀⷅǍron;䎿;榶pf;쀀𝕠ƀaelⷔ⷗ǒr;榷rp;榹΀;adiosvⷪⷫⷮ⸈⸍⸐⸖戨rò᪆Ȁ;efmⷷⷸ⸂⸅橝rĀ;oⷾⷿ愴f»ⷿ耻ª䂪耻º䂺gof;抶r;橖lope;橗;橛ƀclo⸟⸡⸧ò⸁ash耻ø䃸l;折iŬⸯ⸴de耻õ䃵esĀ;aǛ⸺s;樶ml耻ö䃶bar;挽ૡ⹞\0⹽\0⺀⺝\0⺢⺹\0\0⻋ຜ\0⼓\0\0⼫⾼\0⿈rȀ;astЃ⹧⹲຅脀¶;l⹭⹮䂶leìЃɩ⹸\0\0⹻m;櫳;櫽y;䐿rʀcimpt⺋⺏⺓ᡥ⺗nt;䀥od;䀮il;怰enk;怱r;쀀𝔭ƀimo⺨⺰⺴Ā;v⺭⺮䏆;䏕maô੶ne;明ƀ;tv⺿⻀⻈䏀chfork»´;䏖Āau⻏⻟nĀck⻕⻝kĀ;h⇴⻛;愎ö⇴sҀ;abcdemst⻳⻴ᤈ⻹⻽⼄⼆⼊⼎䀫cir;樣ir;樢Āouᵀ⼂;樥;橲n肻±ຝim;樦wo;樧ƀipu⼙⼠⼥ntint;樕f;쀀𝕡nd耻£䂣Ԁ;Eaceinosu່⼿⽁⽄⽇⾁⾉⾒⽾⾶;檳p;檷uå໙Ā;c໎⽌̀;acens່⽙⽟⽦⽨⽾pproø⽃urlyeñ໙ñ໎ƀaes⽯⽶⽺pprox;檹qq;檵im;拨iíໟmeĀ;s⾈ຮ怲ƀEas⽸⾐⽺ð⽵ƀdfp໬⾙⾯ƀals⾠⾥⾪lar;挮ine;挒urf;挓Ā;t໻⾴ï໻rel;抰Āci⿀⿅r;쀀𝓅;䏈ncsp;怈̀fiopsu⿚⋢⿟⿥⿫⿱r;쀀𝔮pf;쀀𝕢rime;恗cr;쀀𝓆ƀaeo⿸〉〓tĀei⿾々rnionóڰnt;樖stĀ;e【】䀿ñἙô༔઀ABHabcdefhilmnoprstux぀けさすムㄎㄫㅇㅢㅲㆎ㈆㈕㈤㈩㉘㉮㉲㊐㊰㊷ƀartぇおがròႳòϝail;検aròᱥar;楤΀cdenqrtとふへみわゔヌĀeuねぱ;쀀∽̱te;䅕iãᅮmptyv;榳gȀ;del࿑らるろ;榒;榥å࿑uo耻»䂻rր;abcfhlpstw࿜ガクシスゼゾダッデナp;極Ā;f࿠ゴs;椠;椳s;椞ë≝ð✮l;楅im;楴l;憣;憝Āaiパフil;椚oĀ;nホボ戶aló༞ƀabrョリヮrò៥rk;杳ĀakンヽcĀekヹ・;䁽;䁝Āes㄂㄄;榌lĀduㄊㄌ;榎;榐Ȁaeuyㄗㄜㄧㄩron;䅙Ādiㄡㄥil;䅗ì࿲âヺ;䑀Ȁclqsㄴㄷㄽㅄa;椷dhar;楩uoĀ;rȎȍh;憳ƀacgㅎㅟངlȀ;ipsླྀㅘㅛႜnåႻarôྩt;断ƀilrㅩဣㅮsht;楽;쀀𝔯ĀaoㅷㆆrĀduㅽㅿ»ѻĀ;l႑ㆄ;楬Ā;vㆋㆌ䏁;䏱ƀgns㆕ㇹㇼht̀ahlrstㆤㆰ㇂㇘㇤㇮rrowĀ;t࿜ㆭaéトarpoonĀduㆻㆿowîㅾp»႒eftĀah㇊㇐rrowó࿪arpoonóՑightarrows;應quigarro÷ニhreetimes;拌g;䋚ingdotseñἲƀahm㈍㈐㈓rò࿪aòՑ;怏oustĀ;a㈞㈟掱che»㈟mid;櫮Ȁabpt㈲㈽㉀㉒Ānr㈷㈺g;柭r;懾rëဃƀafl㉇㉊㉎r;榆;쀀𝕣us;樮imes;樵Āap㉝㉧rĀ;g㉣㉤䀩t;榔olint;樒arò㇣Ȁachq㉻㊀Ⴜ㊅quo;怺r;쀀𝓇Ābu・㊊oĀ;rȔȓƀhir㊗㊛㊠reåㇸmes;拊iȀ;efl㊪ၙᠡ㊫方tri;槎luhar;楨;愞ൡ㋕㋛㋟㌬㌸㍱\0㍺㎤\0\0㏬㏰\0㐨㑈㑚㒭㒱㓊㓱\0㘖\0\0㘳cute;䅛quï➺Ԁ;Eaceinpsyᇭ㋳㋵㋿㌂㌋㌏㌟㌦㌩;檴ǰ㋺\0㋼;檸on;䅡uåᇾĀ;dᇳ㌇il;䅟rc;䅝ƀEas㌖㌘㌛;檶p;檺im;择olint;樓iíሄ;䑁otƀ;be㌴ᵇ㌵担;橦΀Aacmstx㍆㍊㍗㍛㍞㍣㍭rr;懘rĀhr㍐㍒ë∨Ā;oਸ਼਴t耻§䂧i;䀻war;椩mĀin㍩ðnuóñt;朶rĀ;o㍶⁕쀀𝔰Ȁacoy㎂㎆㎑㎠rp;景Āhy㎋㎏cy;䑉;䑈rtɭ㎙\0\0㎜iäᑤaraì⹯耻­䂭Āgm㎨㎴maƀ;fv㎱㎲㎲䏃;䏂Ѐ;deglnprካ㏅㏉㏎㏖㏞㏡㏦ot;橪Ā;q኱ኰĀ;E㏓㏔檞;檠Ā;E㏛㏜檝;檟e;扆lus;樤arr;楲aròᄽȀaeit㏸㐈㐏㐗Āls㏽㐄lsetmé㍪hp;樳parsl;槤Ādlᑣ㐔e;挣Ā;e㐜㐝檪Ā;s㐢㐣檬;쀀⪬︀ƀflp㐮㐳㑂tcy;䑌Ā;b㐸㐹䀯Ā;a㐾㐿槄r;挿f;쀀𝕤aĀdr㑍ЂesĀ;u㑔㑕晠it»㑕ƀcsu㑠㑹㒟Āau㑥㑯pĀ;sᆈ㑫;쀀⊓︀pĀ;sᆴ㑵;쀀⊔︀uĀbp㑿㒏ƀ;esᆗᆜ㒆etĀ;eᆗ㒍ñᆝƀ;esᆨᆭ㒖etĀ;eᆨ㒝ñᆮƀ;afᅻ㒦ְrť㒫ֱ»ᅼaròᅈȀcemt㒹㒾㓂㓅r;쀀𝓈tmîñiì㐕aræᆾĀar㓎㓕rĀ;f㓔ឿ昆Āan㓚㓭ightĀep㓣㓪psiloîỠhé⺯s»⡒ʀbcmnp㓻㕞ሉ㖋㖎Ҁ;Edemnprs㔎㔏㔑㔕㔞㔣㔬㔱㔶抂;櫅ot;檽Ā;dᇚ㔚ot;櫃ult;櫁ĀEe㔨㔪;櫋;把lus;檿arr;楹ƀeiu㔽㕒㕕tƀ;en㔎㕅㕋qĀ;qᇚ㔏eqĀ;q㔫㔨m;櫇Ābp㕚㕜;櫕;櫓c̀;acensᇭ㕬㕲㕹㕻㌦pproø㋺urlyeñᇾñᇳƀaes㖂㖈㌛pproø㌚qñ㌗g;晪ڀ123;Edehlmnps㖩㖬㖯ሜ㖲㖴㗀㗉㗕㗚㗟㗨㗭耻¹䂹耻²䂲耻³䂳;櫆Āos㖹㖼t;檾ub;櫘Ā;dሢ㗅ot;櫄sĀou㗏㗒l;柉b;櫗arr;楻ult;櫂ĀEe㗤㗦;櫌;抋lus;櫀ƀeiu㗴㘉㘌tƀ;enሜ㗼㘂qĀ;qሢ㖲eqĀ;q㗧㗤m;櫈Ābp㘑㘓;櫔;櫖ƀAan㘜㘠㘭rr;懙rĀhr㘦㘨ë∮Ā;oਫ਩war;椪lig耻ß䃟௡㙑㙝㙠ዎ㙳㙹\0㙾㛂\0\0\0\0\0㛛㜃\0㜉㝬\0\0\0㞇ɲ㙖\0\0㙛get;挖;䏄rë๟ƀaey㙦㙫㙰ron;䅥dil;䅣;䑂lrec;挕r;쀀𝔱Ȁeiko㚆㚝㚵㚼Dz㚋\0㚑eĀ4fኄኁaƀ;sv㚘㚙㚛䎸ym;䏑Ācn㚢㚲kĀas㚨㚮pproø዁im»ኬsðኞĀas㚺㚮ð዁rn耻þ䃾Ǭ̟㛆⋧es膀×;bd㛏㛐㛘䃗Ā;aᤏ㛕r;樱;樰ƀeps㛡㛣㜀á⩍Ȁ;bcf҆㛬㛰㛴ot;挶ir;櫱Ā;o㛹㛼쀀𝕥rk;櫚á㍢rime;怴ƀaip㜏㜒㝤dåቈ΀adempst㜡㝍㝀㝑㝗㝜㝟ngleʀ;dlqr㜰㜱㜶㝀㝂斵own»ᶻeftĀ;e⠀㜾ñम;扜ightĀ;e㊪㝋ñၚot;旬inus;樺lus;樹b;槍ime;樻ezium;揢ƀcht㝲㝽㞁Āry㝷㝻;쀀𝓉;䑆cy;䑛rok;䅧Āio㞋㞎xô᝷headĀlr㞗㞠eftarro÷ࡏightarrow»ཝऀAHabcdfghlmoprstuw㟐㟓㟗㟤㟰㟼㠎㠜㠣㠴㡑㡝㡫㢩㣌㣒㣪㣶ròϭar;楣Ācr㟜㟢ute耻ú䃺òᅐrǣ㟪\0㟭y;䑞ve;䅭Āiy㟵㟺rc耻û䃻;䑃ƀabh㠃㠆㠋ròᎭlac;䅱aòᏃĀir㠓㠘sht;楾;쀀𝔲rave耻ù䃹š㠧㠱rĀlr㠬㠮»ॗ»ႃlk;斀Āct㠹㡍ɯ㠿\0\0㡊rnĀ;e㡅㡆挜r»㡆op;挏ri;旸Āal㡖㡚cr;䅫肻¨͉Āgp㡢㡦on;䅳f;쀀𝕦̀adhlsuᅋ㡸㡽፲㢑㢠ownáᎳarpoonĀlr㢈㢌efô㠭ighô㠯iƀ;hl㢙㢚㢜䏅»ᏺon»㢚parrows;懈ƀcit㢰㣄㣈ɯ㢶\0\0㣁rnĀ;e㢼㢽挝r»㢽op;挎ng;䅯ri;旹cr;쀀𝓊ƀdir㣙㣝㣢ot;拰lde;䅩iĀ;f㜰㣨»᠓Āam㣯㣲rò㢨l耻ü䃼angle;榧ހABDacdeflnoprsz㤜㤟㤩㤭㦵㦸㦽㧟㧤㧨㧳㧹㧽㨁㨠ròϷarĀ;v㤦㤧櫨;櫩asèϡĀnr㤲㤷grt;榜΀eknprst㓣㥆㥋㥒㥝㥤㦖appá␕othinçẖƀhir㓫⻈㥙opô⾵Ā;hᎷ㥢ïㆍĀiu㥩㥭gmá㎳Ābp㥲㦄setneqĀ;q㥽㦀쀀⊊︀;쀀⫋︀setneqĀ;q㦏㦒쀀⊋︀;쀀⫌︀Āhr㦛㦟etá㚜iangleĀlr㦪㦯eft»थight»ၑy;䐲ash»ံƀelr㧄㧒㧗ƀ;beⷪ㧋㧏ar;抻q;扚lip;拮Ābt㧜ᑨaòᑩr;쀀𝔳tré㦮suĀbp㧯㧱»ജ»൙pf;쀀𝕧roð໻tré㦴Ācu㨆㨋r;쀀𝓋Ābp㨐㨘nĀEe㦀㨖»㥾nĀEe㦒㨞»㦐igzag;榚΀cefoprs㨶㨻㩖㩛㩔㩡㩪irc;䅵Ādi㩀㩑Ābg㩅㩉ar;機eĀ;qᗺ㩏;扙erp;愘r;쀀𝔴pf;쀀𝕨Ā;eᑹ㩦atèᑹcr;쀀𝓌ૣណ㪇\0㪋\0㪐㪛\0\0㪝㪨㪫㪯\0\0㫃㫎\0㫘ៜ៟tré៑r;쀀𝔵ĀAa㪔㪗ròσrò৶;䎾ĀAa㪡㪤ròθrò৫að✓is;拻ƀdptឤ㪵㪾Āfl㪺ឩ;쀀𝕩imåឲĀAa㫇㫊ròώròਁĀcq㫒ីr;쀀𝓍Āpt៖㫜ré។Ѐacefiosu㫰㫽㬈㬌㬑㬕㬛㬡cĀuy㫶㫻te耻ý䃽;䑏Āiy㬂㬆rc;䅷;䑋n耻¥䂥r;쀀𝔶cy;䑗pf;쀀𝕪cr;쀀𝓎Ācm㬦㬩y;䑎l耻ÿ䃿Ԁacdefhiosw㭂㭈㭔㭘㭤㭩㭭㭴㭺㮀cute;䅺Āay㭍㭒ron;䅾;䐷ot;䅼Āet㭝㭡træᕟa;䎶r;쀀𝔷cy;䐶grarr;懝pf;쀀𝕫cr;쀀𝓏Ājn㮅㮇;怍j;怌'.split("").map(function(e){return e.charCodeAt(0)}))),po}var fo={},DT;function ZFe(){return DT||(DT=1,Object.defineProperty(fo,"__esModule",{value:!0}),fo.default=new Uint16Array("Ȁaglq \x1Bɭ\0\0p;䀦os;䀧t;䀾t;䀼uot;䀢".split("").map(function(e){return e.charCodeAt(0)}))),fo}var oc={},PT;function LT(){return PT||(PT=1,function(e){var t;Object.defineProperty(e,"__esModule",{value:!0}),e.replaceCodePoint=e.fromCodePoint=void 0;var n=new Map([[0,65533],[128,8364],[130,8218],[131,402],[132,8222],[133,8230],[134,8224],[135,8225],[136,710],[137,8240],[138,352],[139,8249],[140,338],[142,381],[145,8216],[146,8217],[147,8220],[148,8221],[149,8226],[150,8211],[151,8212],[152,732],[153,8482],[154,353],[155,8250],[156,339],[158,382],[159,376]]);e.fromCodePoint=(t=String.fromCodePoint)!==null&&t!==void 0?t:function(r){var i="";return r>65535&&(r-=65536,i+=String.fromCharCode(r>>>10&1023|55296),r=56320|r&1023),i+=String.fromCharCode(r),i};function a(r){var i;return r>=55296&&r<=57343||r>1114111?65533:(i=n.get(r))!==null&&i!==void 0?i:r}e.replaceCodePoint=a;function s(r){return(0,e.fromCodePoint)(a(r))}e.default=s}(oc)),oc}var yT;function wu(){return yT||(yT=1,function(e){var t=jn&&jn.__createBinding||(Object.create?function($,W,Y,te){te===void 0&&(te=Y);var K=Object.getOwnPropertyDescriptor(W,Y);(!K||("get"in K?!W.__esModule:K.writable||K.configurable))&&(K={enumerable:!0,get:function(){return W[Y]}}),Object.defineProperty($,te,K)}:function($,W,Y,te){te===void 0&&(te=Y),$[te]=W[Y]}),n=jn&&jn.__setModuleDefault||(Object.create?function($,W){Object.defineProperty($,"default",{enumerable:!0,value:W})}:function($,W){$.default=W}),a=jn&&jn.__importStar||function($){if($&&$.__esModule)return $;var W={};if($!=null)for(var Y in $)Y!=="default"&&Object.prototype.hasOwnProperty.call($,Y)&&t(W,$,Y);return n(W,$),W},s=jn&&jn.__importDefault||function($){return $&&$.__esModule?$:{default:$}};Object.defineProperty(e,"__esModule",{value:!0}),e.decodeXML=e.decodeHTMLStrict=e.decodeHTMLAttribute=e.decodeHTML=e.determineBranch=e.EntityDecoder=e.DecodingMode=e.BinTrieFlags=e.fromCodePoint=e.replaceCodePoint=e.decodeCodePoint=e.xmlDecodeTree=e.htmlDecodeTree=void 0;var r=s(QFe());e.htmlDecodeTree=r.default;var i=s(ZFe());e.xmlDecodeTree=i.default;var o=a(LT());e.decodeCodePoint=o.default;var u=LT();Object.defineProperty(e,"replaceCodePoint",{enumerable:!0,get:function(){return u.replaceCodePoint}}),Object.defineProperty(e,"fromCodePoint",{enumerable:!0,get:function(){return u.fromCodePoint}});var c;(function($){$[$.NUM=35]="NUM",$[$.SEMI=59]="SEMI",$[$.EQUALS=61]="EQUALS",$[$.ZERO=48]="ZERO",$[$.NINE=57]="NINE",$[$.LOWER_A=97]="LOWER_A",$[$.LOWER_F=102]="LOWER_F",$[$.LOWER_X=120]="LOWER_X",$[$.LOWER_Z=122]="LOWER_Z",$[$.UPPER_A=65]="UPPER_A",$[$.UPPER_F=70]="UPPER_F",$[$.UPPER_Z=90]="UPPER_Z"})(c||(c={}));var l=32,E;(function($){$[$.VALUE_LENGTH=49152]="VALUE_LENGTH",$[$.BRANCH_LENGTH=16256]="BRANCH_LENGTH",$[$.JUMP_TABLE=127]="JUMP_TABLE"})(E=e.BinTrieFlags||(e.BinTrieFlags={}));function d($){return $>=c.ZERO&&$<=c.NINE}function p($){return $>=c.UPPER_A&&$<=c.UPPER_F||$>=c.LOWER_A&&$<=c.LOWER_F}function T($){return $>=c.UPPER_A&&$<=c.UPPER_Z||$>=c.LOWER_A&&$<=c.LOWER_Z||d($)}function A($){return $===c.EQUALS||T($)}var I;(function($){$[$.EntityStart=0]="EntityStart",$[$.NumericStart=1]="NumericStart",$[$.NumericDecimal=2]="NumericDecimal",$[$.NumericHex=3]="NumericHex",$[$.NamedEntity=4]="NamedEntity"})(I||(I={}));var m;(function($){$[$.Legacy=0]="Legacy",$[$.Strict=1]="Strict",$[$.Attribute=2]="Attribute"})(m=e.DecodingMode||(e.DecodingMode={}));var S=function(){function $(W,Y,te){this.decodeTree=W,this.emitCodePoint=Y,this.errors=te,this.state=I.EntityStart,this.consumed=1,this.result=0,this.treeIndex=0,this.excess=1,this.decodeMode=m.Strict}return $.prototype.startEntity=function(W){this.decodeMode=W,this.state=I.EntityStart,this.result=0,this.treeIndex=0,this.excess=1,this.consumed=1},$.prototype.write=function(W,Y){switch(this.state){case I.EntityStart:return W.charCodeAt(Y)===c.NUM?(this.state=I.NumericStart,this.consumed+=1,this.stateNumericStart(W,Y+1)):(this.state=I.NamedEntity,this.stateNamedEntity(W,Y));case I.NumericStart:return this.stateNumericStart(W,Y);case I.NumericDecimal:return this.stateNumericDecimal(W,Y);case I.NumericHex:return this.stateNumericHex(W,Y);case I.NamedEntity:return this.stateNamedEntity(W,Y)}},$.prototype.stateNumericStart=function(W,Y){return Y>=W.length?-1:(W.charCodeAt(Y)|l)===c.LOWER_X?(this.state=I.NumericHex,this.consumed+=1,this.stateNumericHex(W,Y+1)):(this.state=I.NumericDecimal,this.stateNumericDecimal(W,Y))},$.prototype.addToNumericResult=function(W,Y,te,K){if(Y!==te){var Se=te-Y;this.result=this.result*Math.pow(K,Se)+parseInt(W.substr(Y,Se),K),this.consumed+=Se}},$.prototype.stateNumericHex=function(W,Y){for(var te=Y;Y>14;Y>14,Se!==0){if(me===c.SEMI)return this.emitNamedEntityData(this.treeIndex,Se,this.consumed+this.excess);this.decodeMode!==m.Strict&&(this.result=this.treeIndex,this.consumed+=this.excess,this.excess=0)}}return-1},$.prototype.emitNotTerminatedNamedEntity=function(){var W,Y=this,te=Y.result,K=Y.decodeTree,Se=(K[te]&E.VALUE_LENGTH)>>14;return this.emitNamedEntityData(te,Se,this.consumed),(W=this.errors)===null||W===void 0||W.missingSemicolonAfterCharacterReference(),this.consumed},$.prototype.emitNamedEntityData=function(W,Y,te){var K=this.decodeTree;return this.emitCodePoint(Y===1?K[W]&~E.VALUE_LENGTH:K[W+1],te),Y===3&&this.emitCodePoint(K[W+2],te),te},$.prototype.end=function(){var W;switch(this.state){case I.NamedEntity:return this.result!==0&&(this.decodeMode!==m.Attribute||this.result===this.treeIndex)?this.emitNotTerminatedNamedEntity():0;case I.NumericDecimal:return this.emitNumericEntity(0,2);case I.NumericHex:return this.emitNumericEntity(0,3);case I.NumericStart:return(W=this.errors)===null||W===void 0||W.absenceOfDigitsInNumericCharacterReference(this.consumed),0;case I.EntityStart:return 0}},$}();e.EntityDecoder=S;function h($){var W="",Y=new S($,function(te){return W+=(0,o.fromCodePoint)(te)});return function(K,Se){for(var me=0,ge=0;(ge=K.indexOf("&",ge))>=0;){W+=K.slice(me,ge),Y.startEntity(Se);var J=Y.write(K,ge+1);if(J<0){me=ge+Y.end();break}me=ge+J,ge=J===0?me+1:me}var Ne=W+K.slice(me);return W="",Ne}}function _($,W,Y,te){var K=(W&E.BRANCH_LENGTH)>>7,Se=W&E.JUMP_TABLE;if(K===0)return Se!==0&&te===Se?Y:-1;if(Se){var me=te-Se;return me<0||me>=K?-1:$[Y+me]-1}for(var ge=Y,J=ge+K-1;ge<=J;){var Ne=ge+J>>>1,st=$[Ne];if(stte)J=Ne-1;else return $[Ne+K]}return-1}e.determineBranch=_;var O=h(r.default),b=h(i.default);function v($,W){return W===void 0&&(W=m.Legacy),O($,W)}e.decodeHTML=v;function D($){return O($,m.Attribute)}e.decodeHTMLAttribute=D;function L($){return O($,m.Strict)}e.decodeHTMLStrict=L;function U($){return b($,m.Strict)}e.decodeXML=U}(jn)),jn}var $T;function PA(){return $T||($T=1,function(e){Object.defineProperty(e,"__esModule",{value:!0}),e.QuoteType=void 0;var t=wu(),n;(function(d){d[d.Tab=9]="Tab",d[d.NewLine=10]="NewLine",d[d.FormFeed=12]="FormFeed",d[d.CarriageReturn=13]="CarriageReturn",d[d.Space=32]="Space",d[d.ExclamationMark=33]="ExclamationMark",d[d.Number=35]="Number",d[d.Amp=38]="Amp",d[d.SingleQuote=39]="SingleQuote",d[d.DoubleQuote=34]="DoubleQuote",d[d.Dash=45]="Dash",d[d.Slash=47]="Slash",d[d.Zero=48]="Zero",d[d.Nine=57]="Nine",d[d.Semi=59]="Semi",d[d.Lt=60]="Lt",d[d.Eq=61]="Eq",d[d.Gt=62]="Gt",d[d.Questionmark=63]="Questionmark",d[d.UpperA=65]="UpperA",d[d.LowerA=97]="LowerA",d[d.UpperF=70]="UpperF",d[d.LowerF=102]="LowerF",d[d.UpperZ=90]="UpperZ",d[d.LowerZ=122]="LowerZ",d[d.LowerX=120]="LowerX",d[d.OpeningSquareBracket=91]="OpeningSquareBracket"})(n||(n={}));var a;(function(d){d[d.Text=1]="Text",d[d.BeforeTagName=2]="BeforeTagName",d[d.InTagName=3]="InTagName",d[d.InSelfClosingTag=4]="InSelfClosingTag",d[d.BeforeClosingTagName=5]="BeforeClosingTagName",d[d.InClosingTagName=6]="InClosingTagName",d[d.AfterClosingTagName=7]="AfterClosingTagName",d[d.BeforeAttributeName=8]="BeforeAttributeName",d[d.InAttributeName=9]="InAttributeName",d[d.AfterAttributeName=10]="AfterAttributeName",d[d.BeforeAttributeValue=11]="BeforeAttributeValue",d[d.InAttributeValueDq=12]="InAttributeValueDq",d[d.InAttributeValueSq=13]="InAttributeValueSq",d[d.InAttributeValueNq=14]="InAttributeValueNq",d[d.BeforeDeclaration=15]="BeforeDeclaration",d[d.InDeclaration=16]="InDeclaration",d[d.InProcessingInstruction=17]="InProcessingInstruction",d[d.BeforeComment=18]="BeforeComment",d[d.CDATASequence=19]="CDATASequence",d[d.InSpecialComment=20]="InSpecialComment",d[d.InCommentLike=21]="InCommentLike",d[d.BeforeSpecialS=22]="BeforeSpecialS",d[d.SpecialStartSequence=23]="SpecialStartSequence",d[d.InSpecialTag=24]="InSpecialTag",d[d.BeforeEntity=25]="BeforeEntity",d[d.BeforeNumericEntity=26]="BeforeNumericEntity",d[d.InNamedEntity=27]="InNamedEntity",d[d.InNumericEntity=28]="InNumericEntity",d[d.InHexEntity=29]="InHexEntity"})(a||(a={}));function s(d){return d===n.Space||d===n.NewLine||d===n.Tab||d===n.FormFeed||d===n.CarriageReturn}function r(d){return d===n.Slash||d===n.Gt||s(d)}function i(d){return d>=n.Zero&&d<=n.Nine}function o(d){return d>=n.LowerA&&d<=n.LowerZ||d>=n.UpperA&&d<=n.UpperZ}function u(d){return d>=n.UpperA&&d<=n.UpperF||d>=n.LowerA&&d<=n.LowerF}var c;(function(d){d[d.NoValue=0]="NoValue",d[d.Unquoted=1]="Unquoted",d[d.Single=2]="Single",d[d.Double=3]="Double"})(c=e.QuoteType||(e.QuoteType={}));var l={Cdata:new Uint8Array([67,68,65,84,65,91]),CdataEnd:new Uint8Array([93,93,62]),CommentEnd:new Uint8Array([45,45,62]),ScriptEnd:new Uint8Array([60,47,115,99,114,105,112,116]),StyleEnd:new Uint8Array([60,47,115,116,121,108,101]),TitleEnd:new Uint8Array([60,47,116,105,116,108,101])},E=function(){function d(p,T){var A=p.xmlMode,I=A===void 0?!1:A,m=p.decodeEntities,S=m===void 0?!0:m;this.cbs=T,this.state=a.Text,this.buffer="",this.sectionStart=0,this.index=0,this.baseState=a.Text,this.isSpecial=!1,this.running=!0,this.offset=0,this.currentSequence=void 0,this.sequenceIndex=0,this.trieIndex=0,this.trieCurrent=0,this.entityResult=0,this.entityExcess=0,this.xmlMode=I,this.decodeEntities=S,this.entityTrie=I?t.xmlDecodeTree:t.htmlDecodeTree}return d.prototype.reset=function(){this.state=a.Text,this.buffer="",this.sectionStart=0,this.index=0,this.baseState=a.Text,this.currentSequence=void 0,this.running=!0,this.offset=0},d.prototype.write=function(p){this.offset+=this.buffer.length,this.buffer=p,this.parse()},d.prototype.end=function(){this.running&&this.finish()},d.prototype.pause=function(){this.running=!1},d.prototype.resume=function(){this.running=!0,this.indexthis.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=a.BeforeTagName,this.sectionStart=this.index):this.decodeEntities&&p===n.Amp&&(this.state=a.BeforeEntity)},d.prototype.stateSpecialStartSequence=function(p){var T=this.sequenceIndex===this.currentSequence.length,A=T?r(p):(p|32)===this.currentSequence[this.sequenceIndex];if(!A)this.isSpecial=!1;else if(!T){this.sequenceIndex++;return}this.sequenceIndex=0,this.state=a.InTagName,this.stateInTagName(p)},d.prototype.stateInSpecialTag=function(p){if(this.sequenceIndex===this.currentSequence.length){if(p===n.Gt||s(p)){var T=this.index-this.currentSequence.length;if(this.sectionStart>14)-1;if(!this.allowLegacyEntity()&&p!==n.Semi)this.trieIndex+=A;else{var I=this.index-this.entityExcess+1;I>this.sectionStart&&this.emitPartial(this.sectionStart,I),this.entityResult=this.trieIndex,this.trieIndex+=A,this.entityExcess=0,this.sectionStart=this.index+1,A===0&&this.emitNamedEntity()}}},d.prototype.emitNamedEntity=function(){if(this.state=this.baseState,this.entityResult!==0){var p=(this.entityTrie[this.entityResult]&t.BinTrieFlags.VALUE_LENGTH)>>14;switch(p){case 1:{this.emitCodePoint(this.entityTrie[this.entityResult]&~t.BinTrieFlags.VALUE_LENGTH);break}case 2:{this.emitCodePoint(this.entityTrie[this.entityResult+1]);break}case 3:this.emitCodePoint(this.entityTrie[this.entityResult+1]),this.emitCodePoint(this.entityTrie[this.entityResult+2])}}},d.prototype.stateBeforeNumericEntity=function(p){(p|32)===n.LowerX?(this.entityExcess++,this.state=a.InHexEntity):(this.state=a.InNumericEntity,this.stateInNumericEntity(p))},d.prototype.emitNumericEntity=function(p){var T=this.index-this.entityExcess-1,A=T+2+ +(this.state===a.InHexEntity);A!==this.index&&(T>this.sectionStart&&this.emitPartial(this.sectionStart,T),this.sectionStart=this.index+Number(p),this.emitCodePoint((0,t.replaceCodePoint)(this.entityResult))),this.state=this.baseState},d.prototype.stateInNumericEntity=function(p){p===n.Semi?this.emitNumericEntity(!0):i(p)?(this.entityResult=this.entityResult*10+(p-n.Zero),this.entityExcess++):(this.allowLegacyEntity()?this.emitNumericEntity(!1):this.state=this.baseState,this.index--)},d.prototype.stateInHexEntity=function(p){p===n.Semi?this.emitNumericEntity(!0):i(p)?(this.entityResult=this.entityResult*16+(p-n.Zero),this.entityExcess++):u(p)?(this.entityResult=this.entityResult*16+((p|32)-n.LowerA+10),this.entityExcess++):(this.allowLegacyEntity()?this.emitNumericEntity(!1):this.state=this.baseState,this.index--)},d.prototype.allowLegacyEntity=function(){return!this.xmlMode&&(this.baseState===a.Text||this.baseState===a.InSpecialTag)},d.prototype.cleanup=function(){this.running&&this.sectionStart!==this.index&&(this.state===a.Text||this.state===a.InSpecialTag&&this.sequenceIndex===0?(this.cbs.ontext(this.sectionStart,this.index),this.sectionStart=this.index):(this.state===a.InAttributeValueDq||this.state===a.InAttributeValueSq||this.state===a.InAttributeValueNq)&&(this.cbs.onattribdata(this.sectionStart,this.index),this.sectionStart=this.index))},d.prototype.shouldContinue=function(){return this.index0&&b.has(this.stack[this.stack.length-1]);){var v=this.stack.pop();(h=(S=this.cbs).onclosetag)===null||h===void 0||h.call(S,v,!0)}this.isVoidElement(m)||(this.stack.push(m),d.has(m)?this.foreignContext.push(!0):p.has(m)&&this.foreignContext.push(!1)),(O=(_=this.cbs).onopentagname)===null||O===void 0||O.call(_,m),this.cbs.onopentag&&(this.attribs={})},I.prototype.endOpenTag=function(m){var S,h;this.startIndex=this.openTagStart,this.attribs&&((h=(S=this.cbs).onopentag)===null||h===void 0||h.call(S,this.tagname,this.attribs,m),this.attribs=null),this.cbs.onclosetag&&this.isVoidElement(this.tagname)&&this.cbs.onclosetag(this.tagname,!0),this.tagname=""},I.prototype.onopentagend=function(m){this.endIndex=m,this.endOpenTag(!1),this.startIndex=m+1},I.prototype.onclosetag=function(m,S){var h,_,O,b,v,D;this.endIndex=S;var L=this.getSlice(m,S);if(this.lowerCaseTagNames&&(L=L.toLowerCase()),(d.has(L)||p.has(L))&&this.foreignContext.pop(),this.isVoidElement(L))!this.options.xmlMode&&L==="br"&&((_=(h=this.cbs).onopentagname)===null||_===void 0||_.call(h,"br"),(b=(O=this.cbs).onopentag)===null||b===void 0||b.call(O,"br",{},!0),(D=(v=this.cbs).onclosetag)===null||D===void 0||D.call(v,"br",!1));else{var U=this.stack.lastIndexOf(L);if(U!==-1)if(this.cbs.onclosetag)for(var $=this.stack.length-U;$--;)this.cbs.onclosetag(this.stack.pop(),$!==0);else this.stack.length=U;else!this.options.xmlMode&&L==="p"&&(this.emitOpenTag("p"),this.closeCurrentTag(!0))}this.startIndex=S+1},I.prototype.onselfclosingtag=function(m){this.endIndex=m,this.options.xmlMode||this.options.recognizeSelfClosing||this.foreignContext[this.foreignContext.length-1]?(this.closeCurrentTag(!1),this.startIndex=m+1):this.onopentagend(m)},I.prototype.closeCurrentTag=function(m){var S,h,_=this.tagname;this.endOpenTag(m),this.stack[this.stack.length-1]===_&&((h=(S=this.cbs).onclosetag)===null||h===void 0||h.call(S,_,!m),this.stack.pop())},I.prototype.onattribname=function(m,S){this.startIndex=m;var h=this.getSlice(m,S);this.attribname=this.lowerCaseAttributeNames?h.toLowerCase():h},I.prototype.onattribdata=function(m,S){this.attribvalue+=this.getSlice(m,S)},I.prototype.onattribentity=function(m){this.attribvalue+=(0,s.fromCodePoint)(m)},I.prototype.onattribend=function(m,S){var h,_;this.endIndex=S,(_=(h=this.cbs).onattribute)===null||_===void 0||_.call(h,this.attribname,this.attribvalue,m===a.QuoteType.Double?'"':m===a.QuoteType.Single?"'":m===a.QuoteType.NoValue?void 0:null),this.attribs&&!Object.prototype.hasOwnProperty.call(this.attribs,this.attribname)&&(this.attribs[this.attribname]=this.attribvalue),this.attribvalue=""},I.prototype.getInstructionName=function(m){var S=m.search(T),h=S<0?m:m.substr(0,S);return this.lowerCaseTagNames&&(h=h.toLowerCase()),h},I.prototype.ondeclaration=function(m,S){this.endIndex=S;var h=this.getSlice(m,S);if(this.cbs.onprocessinginstruction){var _=this.getInstructionName(h);this.cbs.onprocessinginstruction("!".concat(_),"!".concat(h))}this.startIndex=S+1},I.prototype.onprocessinginstruction=function(m,S){this.endIndex=S;var h=this.getSlice(m,S);if(this.cbs.onprocessinginstruction){var _=this.getInstructionName(h);this.cbs.onprocessinginstruction("?".concat(_),"?".concat(h))}this.startIndex=S+1},I.prototype.oncomment=function(m,S,h){var _,O,b,v;this.endIndex=S,(O=(_=this.cbs).oncomment)===null||O===void 0||O.call(_,this.getSlice(m,S-h)),(v=(b=this.cbs).oncommentend)===null||v===void 0||v.call(b),this.startIndex=S+1},I.prototype.oncdata=function(m,S,h){var _,O,b,v,D,L,U,$,W,Y;this.endIndex=S;var te=this.getSlice(m,S-h);this.options.xmlMode||this.options.recognizeCDATA?((O=(_=this.cbs).oncdatastart)===null||O===void 0||O.call(_),(v=(b=this.cbs).ontext)===null||v===void 0||v.call(b,te),(L=(D=this.cbs).oncdataend)===null||L===void 0||L.call(D)):(($=(U=this.cbs).oncomment)===null||$===void 0||$.call(U,"[CDATA[".concat(te,"]]")),(Y=(W=this.cbs).oncommentend)===null||Y===void 0||Y.call(W)),this.startIndex=S+1},I.prototype.onend=function(){var m,S;if(this.cbs.onclosetag){this.endIndex=this.startIndex;for(var h=this.stack.length;h>0;this.cbs.onclosetag(this.stack[--h],!0));}(S=(m=this.cbs).onend)===null||S===void 0||S.call(m)},I.prototype.reset=function(){var m,S,h,_;(S=(m=this.cbs).onreset)===null||S===void 0||S.call(m),this.tokenizer.reset(),this.tagname="",this.attribname="",this.attribs=null,this.stack.length=0,this.startIndex=0,this.endIndex=0,(_=(h=this.cbs).onparserinit)===null||_===void 0||_.call(h,this),this.buffers.length=0,this.bufferOffset=0,this.writeIndex=0,this.ended=!1},I.prototype.parseComplete=function(m){this.reset(),this.end(m)},I.prototype.getSlice=function(m,S){for(;m-this.bufferOffset>=this.buffers[0].length;)this.shiftBuffer();for(var h=this.buffers[0].slice(m-this.bufferOffset,S-this.bufferOffset);S-this.bufferOffset>this.buffers[0].length;)this.shiftBuffer(),h+=this.buffers[0].slice(0,S-this.bufferOffset);return h},I.prototype.shiftBuffer=function(){this.bufferOffset+=this.buffers[0].length,this.writeIndex--,this.buffers.shift()},I.prototype.write=function(m){var S,h;if(this.ended){(h=(S=this.cbs).onerror)===null||h===void 0||h.call(S,new Error(".write() after done!"));return}this.buffers.push(m),this.tokenizer.running&&(this.tokenizer.write(m),this.writeIndex++)},I.prototype.end=function(m){var S,h;if(this.ended){(h=(S=this.cbs).onerror)===null||h===void 0||h.call(S,new Error(".end() after done!"));return}m&&this.write(m),this.ended=!0,this.tokenizer.end()},I.prototype.pause=function(){this.tokenizer.pause()},I.prototype.resume=function(){for(this.tokenizer.resume();this.tokenizer.running&&this.writeIndex0?this.children[this.children.length-1]:null},enumerable:!1,configurable:!0}),Object.defineProperty(b.prototype,"childNodes",{get:function(){return this.children},set:function(v){this.children=v},enumerable:!1,configurable:!0}),b}(a);je.NodeWithChildren=u;var c=function(O){e(b,O);function b(){var v=O!==null&&O.apply(this,arguments)||this;return v.type=n.ElementType.CDATA,v}return Object.defineProperty(b.prototype,"nodeType",{get:function(){return 4},enumerable:!1,configurable:!0}),b}(u);je.CDATA=c;var l=function(O){e(b,O);function b(){var v=O!==null&&O.apply(this,arguments)||this;return v.type=n.ElementType.Root,v}return Object.defineProperty(b.prototype,"nodeType",{get:function(){return 9},enumerable:!1,configurable:!0}),b}(u);je.Document=l;var E=function(O){e(b,O);function b(v,D,L,U){L===void 0&&(L=[]),U===void 0&&(U=v==="script"?n.ElementType.Script:v==="style"?n.ElementType.Style:n.ElementType.Tag);var $=O.call(this,L)||this;return $.name=v,$.attribs=D,$.type=U,$}return Object.defineProperty(b.prototype,"nodeType",{get:function(){return 1},enumerable:!1,configurable:!0}),Object.defineProperty(b.prototype,"tagName",{get:function(){return this.name},set:function(v){this.name=v},enumerable:!1,configurable:!0}),Object.defineProperty(b.prototype,"attributes",{get:function(){var v=this;return Object.keys(this.attribs).map(function(D){var L,U;return{name:D,value:v.attribs[D],namespace:(L=v["x-attribsNamespace"])===null||L===void 0?void 0:L[D],prefix:(U=v["x-attribsPrefix"])===null||U===void 0?void 0:U[D]}})},enumerable:!1,configurable:!0}),b}(u);je.Element=E;function d(O){return(0,n.isTag)(O)}je.isTag=d;function p(O){return O.type===n.ElementType.CDATA}je.isCDATA=p;function T(O){return O.type===n.ElementType.Text}je.isText=T;function A(O){return O.type===n.ElementType.Comment}je.isComment=A;function I(O){return O.type===n.ElementType.Directive}je.isDirective=I;function m(O){return O.type===n.ElementType.Root}je.isDocument=m;function S(O){return Object.prototype.hasOwnProperty.call(O,"children")}je.hasChildren=S;function h(O,b){b===void 0&&(b=!1);var v;if(T(O))v=new r(O.data);else if(A(O))v=new i(O.data);else if(d(O)){var D=b?_(O.children):[],L=new E(O.name,t({},O.attribs),D);D.forEach(function(Y){return Y.parent=L}),O.namespace!=null&&(L.namespace=O.namespace),O["x-attribsNamespace"]&&(L["x-attribsNamespace"]=t({},O["x-attribsNamespace"])),O["x-attribsPrefix"]&&(L["x-attribsPrefix"]=t({},O["x-attribsPrefix"])),v=L}else if(p(O)){var D=b?_(O.children):[],U=new c(D);D.forEach(function(te){return te.parent=U}),v=U}else if(m(O)){var D=b?_(O.children):[],$=new l(D);D.forEach(function(te){return te.parent=$}),O["x-mode"]&&($["x-mode"]=O["x-mode"]),v=$}else if(I(O)){var W=new o(O.name,O.data);O["x-name"]!=null&&(W["x-name"]=O["x-name"],W["x-publicId"]=O["x-publicId"],W["x-systemId"]=O["x-systemId"]),v=W}else throw new Error("Not implemented yet: ".concat(O.type));return v.startIndex=O.startIndex,v.endIndex=O.endIndex,O.sourceCodeLocation!=null&&(v.sourceCodeLocation=O.sourceCodeLocation),v}je.cloneNode=h;function _(O){for(var b=O.map(function(D){return h(D,!0)}),v=1;v$\x80-\uFFFF]/g;var t=new Map([[34,"""],[38,"&"],[39,"'"],[60,"<"],[62,">"]]);e.getCodePoint=String.prototype.codePointAt!=null?function(s,r){return s.codePointAt(r)}:function(s,r){return(s.charCodeAt(r)&64512)===55296?(s.charCodeAt(r)-55296)*1024+s.charCodeAt(r+1)-56320+65536:s.charCodeAt(r)};function n(s){for(var r="",i=0,o;(o=e.xmlReplacer.exec(s))!==null;){var u=o.index,c=s.charCodeAt(u),l=t.get(c);l!==void 0?(r+=s.substring(i,u)+l,i=u+1):(r+="".concat(s.substring(i,u),"&#x").concat((0,e.getCodePoint)(s,u).toString(16),";"),i=e.xmlReplacer.lastIndex+=+((c&64512)===55296))}return r+s.substr(i)}e.encodeXML=n,e.escape=n;function a(s,r){return function(o){for(var u,c=0,l="";u=s.exec(o);)c!==u.index&&(l+=o.substring(c,u.index)),l+=r.get(u[0].charCodeAt(0)),c=u.index+1;return l+o.substring(c)}}e.escapeUTF8=a(/[&<>'"]/g,t),e.escapeAttribute=a(/["&\u00A0]/g,new Map([[34,"""],[38,"&"],[160," "]])),e.escapeText=a(/[&<>\u00A0]/g,new Map([[38,"&"],[60,"<"],[62,">"],[160," "]]))}(cc)),cc}var BT;function GT(){if(BT)return va;BT=1;var e=va&&va.__importDefault||function(o){return o&&o.__esModule?o:{default:o}};Object.defineProperty(va,"__esModule",{value:!0}),va.encodeNonAsciiHTML=va.encodeHTML=void 0;var t=e(JFe()),n=Qd(),a=/[\t\n!-,./:-@[-`\f{-}$\x80-\uFFFF]/g;function s(o){return i(a,o)}va.encodeHTML=s;function r(o){return i(n.xmlReplacer,o)}va.encodeNonAsciiHTML=r;function i(o,u){for(var c="",l=0,E;(E=o.exec(u))!==null;){var d=E.index;c+=u.substring(l,d);var p=u.charCodeAt(d),T=t.default.get(p);if(typeof T=="object"){if(d+10&&(v+=E(_.children,O)),(O.xmlMode||!l.has(_.name))&&(v+=""))),v}function I(_){return"<".concat(_.data,">")}function m(_,O){var b,v=_.data||"";return((b=O.encodeEntities)!==null&&b!==void 0?b:O.decodeEntities)!==!1&&!(!O.xmlMode&&_.parent&&o.has(_.parent.name))&&(v=O.xmlMode||O.encodeEntities!=="utf8"?(0,r.encodeXML)(v):(0,r.escapeText)(v)),v}function S(_){return"")}function h(_){return"")}return _n}var KT;function LA(){if(KT)return nn;KT=1;var e=nn&&nn.__importDefault||function(c){return c&&c.__esModule?c:{default:c}};Object.defineProperty(nn,"__esModule",{value:!0}),nn.innerText=nn.textContent=nn.getText=nn.getInnerHTML=nn.getOuterHTML=void 0;var t=ss(),n=e(nBe()),a=Yi();function s(c,l){return(0,n.default)(c,l)}nn.getOuterHTML=s;function r(c,l){return(0,t.hasChildren)(c)?c.children.map(function(E){return s(E,l)}).join(""):""}nn.getInnerHTML=r;function i(c){return Array.isArray(c)?c.map(i).join(""):(0,t.isTag)(c)?c.name==="br"?` -`:i(c.children):(0,t.isCDATA)(c)?i(c.children):(0,t.isText)(c)?c.data:""}nn.getText=i;function o(c){return Array.isArray(c)?c.map(o).join(""):(0,t.hasChildren)(c)&&!(0,t.isComment)(c)?o(c.children):(0,t.isText)(c)?c.data:""}nn.textContent=o;function u(c){return Array.isArray(c)?c.map(u).join(""):(0,t.hasChildren)(c)&&(c.type===a.ElementType.Tag||(0,t.isCDATA)(c))?u(c.children):(0,t.isText)(c)?c.data:""}return nn.innerText=u,nn}var Ft={},jT;function aBe(){if(jT)return Ft;jT=1,Object.defineProperty(Ft,"__esModule",{value:!0}),Ft.prevElementSibling=Ft.nextElementSibling=Ft.getName=Ft.hasAttrib=Ft.getAttributeValue=Ft.getSiblings=Ft.getParent=Ft.getChildren=void 0;var e=ss();function t(c){return(0,e.hasChildren)(c)?c.children:[]}Ft.getChildren=t;function n(c){return c.parent||null}Ft.getParent=n;function a(c){var l,E,d=n(c);if(d!=null)return t(d);for(var p=[c],T=c.prev,A=c.next;T!=null;)p.unshift(T),l=T,T=l.prev;for(;A!=null;)p.push(A),E=A,A=E.next;return p}Ft.getSiblings=a;function s(c,l){var E;return(E=c.attribs)===null||E===void 0?void 0:E[l]}Ft.getAttributeValue=s;function r(c,l){return c.attribs!=null&&Object.prototype.hasOwnProperty.call(c.attribs,l)&&c.attribs[l]!=null}Ft.hasAttrib=r;function i(c){return c.name}Ft.getName=i;function o(c){for(var l,E=c.next;E!==null&&!(0,e.isTag)(E);)l=E,E=l.next;return E}Ft.nextElementSibling=o;function u(c){for(var l,E=c.prev;E!==null&&!(0,e.isTag)(E);)l=E,E=l.prev;return E}return Ft.prevElementSibling=u,Ft}var an={},YT;function sBe(){if(YT)return an;YT=1,Object.defineProperty(an,"__esModule",{value:!0}),an.prepend=an.prependChild=an.append=an.appendChild=an.replaceElement=an.removeElement=void 0;function e(i){if(i.prev&&(i.prev.next=i.next),i.next&&(i.next.prev=i.prev),i.parent){var o=i.parent.children,u=o.lastIndexOf(i);u>=0&&o.splice(u,1)}i.next=null,i.prev=null,i.parent=null}an.removeElement=e;function t(i,o){var u=o.prev=i.prev;u&&(u.next=o);var c=o.next=i.next;c&&(c.prev=o);var l=o.parent=i.parent;if(l){var E=l.children;E[E.lastIndexOf(i)]=o,i.parent=null}}an.replaceElement=t;function n(i,o){if(e(o),o.next=null,o.parent=i,i.children.push(o)>1){var u=i.children[i.children.length-2];u.next=o,o.prev=u}else o.prev=null}an.appendChild=n;function a(i,o){e(o);var u=i.parent,c=i.next;if(o.next=c,o.prev=i,i.next=o,o.parent=u,c){if(c.prev=o,u){var l=u.children;l.splice(l.lastIndexOf(c),0,o)}}else u&&u.children.push(o)}an.append=a;function s(i,o){if(e(o),o.parent=i,o.prev=null,i.children.unshift(o)!==1){var u=i.children[1];u.prev=o,o.next=u}else o.next=null}an.prependChild=s;function r(i,o){e(o);var u=i.parent;if(u){var c=u.children;c.splice(c.indexOf(i),0,o)}i.prev&&(i.prev.next=o),o.parent=u,o.prev=i.prev,o.next=i,i.prev=o}return an.prepend=r,an}var sn={},XT;function yA(){if(XT)return sn;XT=1,Object.defineProperty(sn,"__esModule",{value:!0}),sn.findAll=sn.existsOne=sn.findOne=sn.findOneChild=sn.find=sn.filter=void 0;var e=ss();function t(o,u,c,l){return c===void 0&&(c=!0),l===void 0&&(l=1/0),n(o,Array.isArray(u)?u:[u],c,l)}sn.filter=t;function n(o,u,c,l){for(var E=[],d=[u],p=[0];;){if(p[0]>=d[0].length){if(p.length===1)return E;d.shift(),p.shift();continue}var T=d[0][p[0]++];if(o(T)&&(E.push(T),--l<=0))return E;c&&(0,e.hasChildren)(T)&&T.children.length>0&&(p.unshift(0),d.unshift(T.children))}}sn.find=n;function a(o,u){return u.find(o)}sn.findOneChild=a;function s(o,u,c){c===void 0&&(c=!0);for(var l=null,E=0;E0&&(l=s(o,d.children,!0));else continue}return l}sn.findOne=s;function r(o,u){return u.some(function(c){return(0,e.isTag)(c)&&(o(c)||r(o,c.children))})}sn.existsOne=r;function i(o,u){for(var c=[],l=[u],E=[0];;){if(E[0]>=l[0].length){if(l.length===1)return c;l.shift(),E.shift();continue}var d=l[0][E[0]++];(0,e.isTag)(d)&&(o(d)&&c.push(d),d.children.length>0&&(E.unshift(0),l.unshift(d.children)))}}return sn.findAll=i,sn}var bn={},QT;function $A(){if(QT)return bn;QT=1,Object.defineProperty(bn,"__esModule",{value:!0}),bn.getElementsByTagType=bn.getElementsByTagName=bn.getElementById=bn.getElements=bn.testElement=void 0;var e=ss(),t=yA(),n={tag_name:function(E){return typeof E=="function"?function(d){return(0,e.isTag)(d)&&E(d.name)}:E==="*"?e.isTag:function(d){return(0,e.isTag)(d)&&d.name===E}},tag_type:function(E){return typeof E=="function"?function(d){return E(d.type)}:function(d){return d.type===E}},tag_contains:function(E){return typeof E=="function"?function(d){return(0,e.isText)(d)&&E(d.data)}:function(d){return(0,e.isText)(d)&&d.data===E}}};function a(E,d){return typeof d=="function"?function(p){return(0,e.isTag)(p)&&d(p.attribs[E])}:function(p){return(0,e.isTag)(p)&&p.attribs[E]===d}}function s(E,d){return function(p){return E(p)||d(p)}}function r(E){var d=Object.keys(E).map(function(p){var T=E[p];return Object.prototype.hasOwnProperty.call(n,p)?n[p](T):a(p,T)});return d.length===0?null:d.reduce(s)}function i(E,d){var p=r(E);return p?p(d):!0}bn.testElement=i;function o(E,d,p,T){T===void 0&&(T=1/0);var A=r(E);return A?(0,t.filter)(A,d,p,T):[]}bn.getElements=o;function u(E,d,p){return p===void 0&&(p=!0),Array.isArray(d)||(d=[d]),(0,t.findOne)(a("id",E),d,p)}bn.getElementById=u;function c(E,d,p,T){return p===void 0&&(p=!0),T===void 0&&(T=1/0),(0,t.filter)(n.tag_name(E),d,p,T)}bn.getElementsByTagName=c;function l(E,d,p,T){return p===void 0&&(p=!0),T===void 0&&(T=1/0),(0,t.filter)(n.tag_type(E),d,p,T)}return bn.getElementsByTagType=l,bn}var dc={},ZT;function rBe(){return ZT||(ZT=1,function(e){Object.defineProperty(e,"__esModule",{value:!0}),e.uniqueSort=e.compareDocumentPosition=e.DocumentPosition=e.removeSubsets=void 0;var t=ss();function n(i){for(var o=i.length;--o>=0;){var u=i[o];if(o>0&&i.lastIndexOf(u,o-1)>=0){i.splice(o,1);continue}for(var c=u.parent;c;c=c.parent)if(i.includes(c)){i.splice(o,1);break}}return i}e.removeSubsets=n;var a;(function(i){i[i.DISCONNECTED=1]="DISCONNECTED",i[i.PRECEDING=2]="PRECEDING",i[i.FOLLOWING=4]="FOLLOWING",i[i.CONTAINS=8]="CONTAINS",i[i.CONTAINED_BY=16]="CONTAINED_BY"})(a=e.DocumentPosition||(e.DocumentPosition={}));function s(i,o){var u=[],c=[];if(i===o)return 0;for(var l=(0,t.hasChildren)(i)?i:i.parent;l;)u.unshift(l),l=l.parent;for(l=(0,t.hasChildren)(o)?o:o.parent;l;)c.unshift(l),l=l.parent;for(var E=Math.min(u.length,c.length),d=0;dT.indexOf(I)?p===o?a.FOLLOWING|a.CONTAINED_BY:a.FOLLOWING:p===i?a.PRECEDING|a.CONTAINS:a.PRECEDING}e.compareDocumentPosition=s;function r(i){return i=i.filter(function(o,u,c){return!c.includes(o,u+1)}),i.sort(function(o,u){var c=s(o,u);return c&a.PRECEDING?-1:c&a.FOLLOWING?1:0}),i}e.uniqueSort=r}(dc)),dc}var Gr={},JT;function iBe(){if(JT)return Gr;JT=1,Object.defineProperty(Gr,"__esModule",{value:!0}),Gr.getFeed=void 0;var e=LA(),t=$A();function n(d){var p=u(E,d);return p?p.name==="feed"?a(p):s(p):null}Gr.getFeed=n;function a(d){var p,T=d.children,A={type:"atom",items:(0,t.getElementsByTagName)("entry",T).map(function(S){var h,_=S.children,O={media:o(_)};l(O,"id","id",_),l(O,"title","title",_);var b=(h=u("link",_))===null||h===void 0?void 0:h.attribs.href;b&&(O.link=b);var v=c("summary",_)||c("content",_);v&&(O.description=v);var D=c("updated",_);return D&&(O.pubDate=new Date(D)),O})};l(A,"id","id",T),l(A,"title","title",T);var I=(p=u("link",T))===null||p===void 0?void 0:p.attribs.href;I&&(A.link=I),l(A,"description","subtitle",T);var m=c("updated",T);return m&&(A.updated=new Date(m)),l(A,"author","email",T,!0),A}function s(d){var p,T,A=(T=(p=u("channel",d.children))===null||p===void 0?void 0:p.children)!==null&&T!==void 0?T:[],I={type:d.name.substr(0,3),id:"",items:(0,t.getElementsByTagName)("item",d.children).map(function(S){var h=S.children,_={media:o(h)};l(_,"id","guid",h),l(_,"title","title",h),l(_,"link","link",h),l(_,"description","description",h);var O=c("pubDate",h)||c("dc:date",h);return O&&(_.pubDate=new Date(O)),_})};l(I,"title","title",A),l(I,"link","link",A),l(I,"description","description",A);var m=c("lastBuildDate",A);return m&&(I.updated=new Date(m)),l(I,"author","managingEditor",A,!0),I}var r=["url","type","lang"],i=["fileSize","bitrate","framerate","samplingrate","channels","duration","height","width"];function o(d){return(0,t.getElementsByTagName)("media:content",d).map(function(p){for(var T=p.attribs,A={medium:T.medium,isDefault:!!T.isDefault},I=0,m=r;I{if(typeof e!="string")throw new TypeError("Expected a string");return e.replace(/[|\\{}()[\]^$+*?.]/g,"\\$&").replace(/-/g,"\\x2d")}),pc}var To={},a_;function lBe(){if(a_)return To;a_=1,Object.defineProperty(To,"__esModule",{value:!0});/*! - * is-plain-object - * - * Copyright (c) 2014-2017, Jon Schlinkert. - * Released under the MIT License. - */function e(n){return Object.prototype.toString.call(n)==="[object Object]"}function t(n){var a,s;return e(n)===!1?!1:(a=n.constructor,a===void 0?!0:(s=a.prototype,!(e(s)===!1||s.hasOwnProperty("isPrototypeOf")===!1)))}return To.isPlainObject=t,To}var fc,s_;function cBe(){if(s_)return fc;s_=1;var e=function(S){return t(S)&&!n(S)};function t(m){return!!m&&typeof m=="object"}function n(m){var S=Object.prototype.toString.call(m);return S==="[object RegExp]"||S==="[object Date]"||r(m)}var a=typeof Symbol=="function"&&Symbol.for,s=a?Symbol.for("react.element"):60103;function r(m){return m.$$typeof===s}function i(m){return Array.isArray(m)?[]:{}}function o(m,S){return S.clone!==!1&&S.isMergeableObject(m)?A(i(m),m,S):m}function u(m,S,h){return m.concat(S).map(function(_){return o(_,h)})}function c(m,S){if(!S.customMerge)return A;var h=S.customMerge(m);return typeof h=="function"?h:A}function l(m){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(m).filter(function(S){return Object.propertyIsEnumerable.call(m,S)}):[]}function E(m){return Object.keys(m).concat(l(m))}function d(m,S){try{return S in m}catch{return!1}}function p(m,S){return d(m,S)&&!(Object.hasOwnProperty.call(m,S)&&Object.propertyIsEnumerable.call(m,S))}function T(m,S,h){var _={};return h.isMergeableObject(m)&&E(m).forEach(function(O){_[O]=o(m[O],h)}),E(S).forEach(function(O){p(m,O)||(d(m,O)&&h.isMergeableObject(S[O])?_[O]=c(O,h)(m[O],S[O],h):_[O]=o(S[O],h))}),_}function A(m,S,h){h=h||{},h.arrayMerge=h.arrayMerge||u,h.isMergeableObject=h.isMergeableObject||e,h.cloneUnlessOtherwiseSpecified=o;var _=Array.isArray(S),O=Array.isArray(m),b=_===O;return b?_?h.arrayMerge(m,S,h):T(m,S,h):o(S,h)}A.all=function(S,h){if(!Array.isArray(S))throw new Error("first argument should be an array");return S.reduce(function(_,O){return A(_,O,h)},{})};var I=A;return fc=I,fc}var xo={exports:{}},dBe=xo.exports,r_;function EBe(){return r_||(r_=1,function(e){(function(t,n){e.exports?e.exports=n():t.parseSrcset=n()})(dBe,function(){return function(t){function n(_){return _===" "||_===" "||_===` -`||_==="\f"||_==="\r"}function a(_){var O,b=_.exec(t.substring(I));if(b)return O=b[0],I+=O.length,O}for(var s=t.length,r=/^[ \t\n\r\u000c]+/,i=/^[, \t\n\r\u000c]+/,o=/^[^ \t\n\r\u000c]+/,u=/[,]+$/,c=/^\d+$/,l=/^-?(?:[0-9]+|[0-9]*\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/,E,d,p,T,A,I=0,m=[];;){if(a(i),I>=s)return m;E=a(o),d=[],E.slice(-1)===","?(E=E.replace(u,""),h()):S()}function S(){for(a(r),p="",T="in descriptor";;){if(A=t.charAt(I),T==="in descriptor")if(n(A))p&&(d.push(p),p="",T="after descriptor");else if(A===","){I+=1,p&&d.push(p),h();return}else if(A==="(")p=p+A,T="in parens";else if(A===""){p&&d.push(p),h();return}else p=p+A;else if(T==="in parens")if(A===")")p=p+A,T="in descriptor";else if(A===""){d.push(p),h();return}else p=p+A;else if(T==="after descriptor"&&!n(A))if(A===""){h();return}else T="in descriptor",I-=1;I+=1}}function h(){var _=!1,O,b,v,D,L={},U,$,W,Y,te;for(D=0;D",typeof this.line<"u"&&(this.message+=":"+this.line+":"+this.column),this.message+=": "+this.reason}showSourceCode(s){if(!this.source)return"";let r=this.source;s==null&&(s=e.isColorSupported);let i=p=>p,o=p=>p,u=p=>p;if(s){let{bold:p,gray:T,red:A}=e.createColors(!0);o=I=>p(A(I)),i=I=>T(I),t&&(u=I=>t(I))}let c=r.split(/\r?\n/),l=Math.max(this.line-3,0),E=Math.min(this.line+2,c.length),d=String(E).length;return c.slice(l,E).map((p,T)=>{let A=l+1+T,I=" "+(" "+A).slice(-d)+" | ";if(A===this.line){if(p.length>160){let S=20,h=Math.max(0,this.column-S),_=Math.max(this.column+S,this.endColumn+S),O=p.slice(h,_),b=i(I.replace(/\d/g," "))+p.slice(0,Math.min(this.column-1,S-1)).replace(/[^\t]/g," ");return o(">")+i(I)+u(O)+` - `+b+o("^")}let m=i(I.replace(/\d/g," "))+p.slice(0,this.column-1).replace(/[^\t]/g," ");return o(">")+i(I)+u(p)+` - `+m+o("^")}return" "+i(I)+u(p)}).join(` -`)}toString(){let s=this.showSourceCode();return s&&(s=` - -`+s+` -`),this.name+": "+this.message+s}}return mc=n,n.default=n,mc}var Tc,u_;function kA(){if(u_)return Tc;u_=1;const e={after:` -`,beforeClose:` -`,beforeComment:` -`,beforeDecl:` -`,beforeOpen:" ",beforeRule:` -`,colon:": ",commentLeft:" ",commentRight:" ",emptyBody:"",indent:" ",semicolon:!1};function t(a){return a[0].toUpperCase()+a.slice(1)}class n{constructor(s){this.builder=s}atrule(s,r){let i="@"+s.name,o=s.params?this.rawValue(s,"params"):"";if(typeof s.raws.afterName<"u"?i+=s.raws.afterName:o&&(i+=" "),s.nodes)this.block(s,i+o);else{let u=(s.raws.between||"")+(r?";":"");this.builder(i+o+u,s)}}beforeAfter(s,r){let i;s.type==="decl"?i=this.raw(s,null,"beforeDecl"):s.type==="comment"?i=this.raw(s,null,"beforeComment"):r==="before"?i=this.raw(s,null,"beforeRule"):i=this.raw(s,null,"beforeClose");let o=s.parent,u=0;for(;o&&o.type!=="root";)u+=1,o=o.parent;if(i.includes(` -`)){let c=this.raw(s,null,"indent");if(c.length)for(let l=0;l0&&s.nodes[r].type==="comment";)r-=1;let i=this.raw(s,"semicolon");for(let o=0;o{if(o=E.raws[r],typeof o<"u")return!1})}return typeof o>"u"&&(o=e[i]),c.rawCache[i]=o,o}rawBeforeClose(s){let r;return s.walk(i=>{if(i.nodes&&i.nodes.length>0&&typeof i.raws.after<"u")return r=i.raws.after,r.includes(` -`)&&(r=r.replace(/[^\n]+$/,"")),!1}),r&&(r=r.replace(/\S/g,"")),r}rawBeforeComment(s,r){let i;return s.walkComments(o=>{if(typeof o.raws.before<"u")return i=o.raws.before,i.includes(` -`)&&(i=i.replace(/[^\n]+$/,"")),!1}),typeof i>"u"?i=this.raw(r,null,"beforeDecl"):i&&(i=i.replace(/\S/g,"")),i}rawBeforeDecl(s,r){let i;return s.walkDecls(o=>{if(typeof o.raws.before<"u")return i=o.raws.before,i.includes(` -`)&&(i=i.replace(/[^\n]+$/,"")),!1}),typeof i>"u"?i=this.raw(r,null,"beforeRule"):i&&(i=i.replace(/\S/g,"")),i}rawBeforeOpen(s){let r;return s.walk(i=>{if(i.type!=="decl"&&(r=i.raws.between,typeof r<"u"))return!1}),r}rawBeforeRule(s){let r;return s.walk(i=>{if(i.nodes&&(i.parent!==s||s.first!==i)&&typeof i.raws.before<"u")return r=i.raws.before,r.includes(` -`)&&(r=r.replace(/[^\n]+$/,"")),!1}),r&&(r=r.replace(/\S/g,"")),r}rawColon(s){let r;return s.walkDecls(i=>{if(typeof i.raws.between<"u")return r=i.raws.between.replace(/[^\s:]/g,""),!1}),r}rawEmptyBody(s){let r;return s.walk(i=>{if(i.nodes&&i.nodes.length===0&&(r=i.raws.after,typeof r<"u"))return!1}),r}rawIndent(s){if(s.raws.indent)return s.raws.indent;let r;return s.walk(i=>{let o=i.parent;if(o&&o!==s&&o.parent&&o.parent===s&&typeof i.raws.before<"u"){let u=i.raws.before.split(` -`);return r=u[u.length-1],r=r.replace(/\S/g,""),!1}}),r}rawSemicolon(s){let r;return s.walk(i=>{if(i.nodes&&i.nodes.length&&i.last.type==="decl"&&(r=i.raws.semicolon,typeof r<"u"))return!1}),r}rawValue(s,r){let i=s[r],o=s.raws[r];return o&&o.value===i?o.raw:i}root(s){this.body(s),s.raws.after&&this.builder(s.raws.after)}rule(s){this.block(s,this.rawValue(s,"selector")),s.raws.ownSemicolon&&this.builder(s.raws.ownSemicolon,s,"end")}stringify(s,r){if(!this[s.type])throw new Error("Unknown AST node type "+s.type+". Maybe you need to change PostCSS stringifier.");this[s.type](s,r)}}return Tc=n,n.default=n,Tc}var _c,l_;function Tl(){if(l_)return _c;l_=1;let e=kA();function t(n,a){new e(a).stringify(n)}return _c=t,t.default=t,_c}var ho={},c_;function Tp(){return c_||(c_=1,ho.isClean=Symbol("isClean"),ho.my=Symbol("my")),ho}var hc,d_;function _l(){if(d_)return hc;d_=1;let e=mp(),t=kA(),n=Tl(),{isClean:a,my:s}=Tp();function r(u,c){let l=new u.constructor;for(let E in u){if(!Object.prototype.hasOwnProperty.call(u,E)||E==="proxyCache")continue;let d=u[E],p=typeof d;E==="parent"&&p==="object"?c&&(l[E]=c):E==="source"?l[E]=d:Array.isArray(d)?l[E]=d.map(T=>r(T,l)):(p==="object"&&d!==null&&(d=r(d)),l[E]=d)}return l}function i(u,c){if(c&&typeof c.offset<"u")return c.offset;let l=1,E=1,d=0;for(let p=0;pc.root().toProxy():c[l]},set(c,l,E){return c[l]===E||(c[l]=E,(l==="prop"||l==="value"||l==="name"||l==="params"||l==="important"||l==="text")&&c.markDirty()),!0}}}markClean(){this[a]=!0}markDirty(){if(this[a]){this[a]=!1;let c=this;for(;c=c.parent;)c[a]=!1}}next(){if(!this.parent)return;let c=this.parent.index(this);return this.parent.nodes[c+1]}positionBy(c){let l=this.source.start;if(c.index)l=this.positionInside(c.index);else if(c.word){let d=this.source.input.css.slice(i(this.source.input.css,this.source.start),i(this.source.input.css,this.source.end)).indexOf(c.word);d!==-1&&(l=this.positionInside(d))}return l}positionInside(c){let l=this.source.start.column,E=this.source.start.line,d=i(this.source.input.css,this.source.start),p=d+c;for(let T=d;Ttypeof I=="object"&&I.toJSON?I.toJSON(null,l):I);else if(typeof A=="object"&&A.toJSON)E[T]=A.toJSON(null,l);else if(T==="source"){let I=l.get(A.input);I==null&&(I=p,l.set(A.input,p),p++),E[T]={end:A.end,inputId:I,start:A.start}}else E[T]=A}return d&&(E.inputs=[...l.keys()].map(T=>T.toJSON())),E}toProxy(){return this.proxyCache||(this.proxyCache=new Proxy(this,this.getProxyProcessor())),this.proxyCache}toString(c=n){c.stringify&&(c=c.stringify);let l="";return c(this,E=>{l+=E}),l}warn(c,l,E){let d={node:this};for(let p in E)d[p]=E[p];return c.warn(l,d)}get proxyOf(){return this}}return hc=o,o.default=o,hc}var Sc,E_;function hl(){if(E_)return Sc;E_=1;let e=_l();class t extends e{constructor(a){super(a),this.type="comment"}}return Sc=t,t.default=t,Sc}var Ac,p_;function Sl(){if(p_)return Ac;p_=1;let e=_l();class t extends e{constructor(a){a&&typeof a.value<"u"&&typeof a.value!="string"&&(a={...a,value:String(a.value)}),super(a),this.type="decl"}get variable(){return this.prop.startsWith("--")||this.prop[0]==="$"}}return Ac=t,t.default=t,Ac}var Oc,f_;function xs(){if(f_)return Oc;f_=1;let e=hl(),t=Sl(),n=_l(),{isClean:a,my:s}=Tp(),r,i,o,u;function c(d){return d.map(p=>(p.nodes&&(p.nodes=c(p.nodes)),delete p.source,p))}function l(d){if(d[a]=!1,d.proxyOf.nodes)for(let p of d.proxyOf.nodes)l(p)}class E extends n{append(...p){for(let T of p){let A=this.normalize(T,this.last);for(let I of A)this.proxyOf.nodes.push(I)}return this.markDirty(),this}cleanRaws(p){if(super.cleanRaws(p),this.nodes)for(let T of this.nodes)T.cleanRaws(p)}each(p){if(!this.proxyOf.nodes)return;let T=this.getIterator(),A,I;for(;this.indexes[T]p[T](...A.map(I=>typeof I=="function"?(m,S)=>I(m.toProxy(),S):I)):T==="every"||T==="some"?A=>p[T]((I,...m)=>A(I.toProxy(),...m)):T==="root"?()=>p.root().toProxy():T==="nodes"?p.nodes.map(A=>A.toProxy()):T==="first"||T==="last"?p[T].toProxy():p[T]:p[T]},set(p,T,A){return p[T]===A||(p[T]=A,(T==="name"||T==="params"||T==="selector")&&p.markDirty()),!0}}}index(p){return typeof p=="number"?p:(p.proxyOf&&(p=p.proxyOf),this.proxyOf.nodes.indexOf(p))}insertAfter(p,T){let A=this.index(p),I=this.normalize(T,this.proxyOf.nodes[A]).reverse();A=this.index(p);for(let S of I)this.proxyOf.nodes.splice(A+1,0,S);let m;for(let S in this.indexes)m=this.indexes[S],A"u")p=[];else if(Array.isArray(p)){p=p.slice(0);for(let I of p)I.parent&&I.parent.removeChild(I,"ignore")}else if(p.type==="root"&&this.type!=="document"){p=p.nodes.slice(0);for(let I of p)I.parent&&I.parent.removeChild(I,"ignore")}else if(p.type)p=[p];else if(p.prop){if(typeof p.value>"u")throw new Error("Value field is missed in node creation");typeof p.value!="string"&&(p.value=String(p.value)),p=[new t(p)]}else if(p.selector||p.selectors)p=[new u(p)];else if(p.name)p=[new r(p)];else if(p.text)p=[new e(p)];else throw new Error("Unknown node type in node creation");return p.map(I=>(I[s]||E.rebuild(I),I=I.proxyOf,I.parent&&I.parent.removeChild(I),I[a]&&l(I),I.raws||(I.raws={}),typeof I.raws.before>"u"&&T&&typeof T.raws.before<"u"&&(I.raws.before=T.raws.before.replace(/\S/g,"")),I.parent=this.proxyOf,I))}prepend(...p){p=p.reverse();for(let T of p){let A=this.normalize(T,this.first,"prepend").reverse();for(let I of A)this.proxyOf.nodes.unshift(I);for(let I in this.indexes)this.indexes[I]=this.indexes[I]+A.length}return this.markDirty(),this}push(p){return p.parent=this,this.proxyOf.nodes.push(p),this}removeAll(){for(let p of this.proxyOf.nodes)p.parent=void 0;return this.proxyOf.nodes=[],this.markDirty(),this}removeChild(p){p=this.index(p),this.proxyOf.nodes[p].parent=void 0,this.proxyOf.nodes.splice(p,1);let T;for(let A in this.indexes)T=this.indexes[A],T>=p&&(this.indexes[A]=T-1);return this.markDirty(),this}replaceValues(p,T,A){return A||(A=T,T={}),this.walkDecls(I=>{T.props&&!T.props.includes(I.prop)||T.fast&&!I.value.includes(T.fast)||(I.value=I.value.replace(p,A))}),this.markDirty(),this}some(p){return this.nodes.some(p)}walk(p){return this.each((T,A)=>{let I;try{I=p(T,A)}catch(m){throw T.addToError(m)}return I!==!1&&T.walk&&(I=T.walk(p)),I})}walkAtRules(p,T){return T?p instanceof RegExp?this.walk((A,I)=>{if(A.type==="atrule"&&p.test(A.name))return T(A,I)}):this.walk((A,I)=>{if(A.type==="atrule"&&A.name===p)return T(A,I)}):(T=p,this.walk((A,I)=>{if(A.type==="atrule")return T(A,I)}))}walkComments(p){return this.walk((T,A)=>{if(T.type==="comment")return p(T,A)})}walkDecls(p,T){return T?p instanceof RegExp?this.walk((A,I)=>{if(A.type==="decl"&&p.test(A.prop))return T(A,I)}):this.walk((A,I)=>{if(A.type==="decl"&&A.prop===p)return T(A,I)}):(T=p,this.walk((A,I)=>{if(A.type==="decl")return T(A,I)}))}walkRules(p,T){return T?p instanceof RegExp?this.walk((A,I)=>{if(A.type==="rule"&&p.test(A.selector))return T(A,I)}):this.walk((A,I)=>{if(A.type==="rule"&&A.selector===p)return T(A,I)}):(T=p,this.walk((A,I)=>{if(A.type==="rule")return T(A,I)}))}get first(){if(this.proxyOf.nodes)return this.proxyOf.nodes[0]}get last(){if(this.proxyOf.nodes)return this.proxyOf.nodes[this.proxyOf.nodes.length-1]}}return E.registerParse=d=>{i=d},E.registerRule=d=>{u=d},E.registerAtRule=d=>{r=d},E.registerRoot=d=>{o=d},Oc=E,E.default=E,E.rebuild=d=>{d.type==="atrule"?Object.setPrototypeOf(d,r.prototype):d.type==="rule"?Object.setPrototypeOf(d,u.prototype):d.type==="decl"?Object.setPrototypeOf(d,t.prototype):d.type==="comment"?Object.setPrototypeOf(d,e.prototype):d.type==="root"&&Object.setPrototypeOf(d,o.prototype),d[s]=!0,d.nodes&&d.nodes.forEach(p=>{E.rebuild(p)})},Oc}var gc,m_;function _p(){if(m_)return gc;m_=1;let e=xs();class t extends e{constructor(a){super(a),this.type="atrule"}append(...a){return this.proxyOf.nodes||(this.nodes=[]),super.append(...a)}prepend(...a){return this.proxyOf.nodes||(this.nodes=[]),super.prepend(...a)}}return gc=t,t.default=t,e.registerAtRule(t),gc}var Ic,T_;function hp(){if(T_)return Ic;T_=1;let e=xs(),t,n;class a extends e{constructor(r){super({type:"document",...r}),this.nodes||(this.nodes=[])}toResult(r={}){return new t(new n,this,r).stringify()}}return a.registerLazyResult=s=>{t=s},a.registerProcessor=s=>{n=s},Ic=a,a.default=a,Ic}var Rc,__;function TBe(){if(__)return Rc;__=1;let e="useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";return Rc={nanoid:(a=21)=>{let s="",r=a|0;for(;r--;)s+=e[Math.random()*64|0];return s},customAlphabet:(a,s=21)=>(r=s)=>{let i="",o=r|0;for(;o--;)i+=a[Math.random()*a.length|0];return i}},Rc}var Nc,h_;function UA(){if(h_)return Nc;h_=1;let{existsSync:e,readFileSync:t}=Xn,{dirname:n,join:a}=Xn,{SourceMapConsumer:s,SourceMapGenerator:r}=Xn;function i(u){return Buffer?Buffer.from(u,"base64").toString():window.atob(u)}class o{constructor(c,l){if(l.map===!1)return;this.loadAnnotation(c),this.inline=this.startWith(this.annotation,"data:");let E=l.map?l.map.prev:void 0,d=this.loadMap(l.from,E);!this.mapFile&&l.from&&(this.mapFile=l.from),this.mapFile&&(this.root=n(this.mapFile)),d&&(this.text=d)}consumer(){return this.consumerCache||(this.consumerCache=new s(this.text)),this.consumerCache}decodeInline(c){let l=/^data:application\/json;charset=utf-?8;base64,/,E=/^data:application\/json;base64,/,d=/^data:application\/json;charset=utf-?8,/,p=/^data:application\/json,/,T=c.match(d)||c.match(p);if(T)return decodeURIComponent(c.substr(T[0].length));let A=c.match(l)||c.match(E);if(A)return i(c.substr(A[0].length));let I=c.match(/data:application\/json;([^,]+),/)[1];throw new Error("Unsupported source map encoding "+I)}getAnnotationURL(c){return c.replace(/^\/\*\s*# sourceMappingURL=/,"").trim()}isMap(c){return typeof c!="object"?!1:typeof c.mappings=="string"||typeof c._mappings=="string"||Array.isArray(c.sections)}loadAnnotation(c){let l=c.match(/\/\*\s*# sourceMappingURL=/g);if(!l)return;let E=c.lastIndexOf(l.pop()),d=c.indexOf("*/",E);E>-1&&d>-1&&(this.annotation=this.getAnnotationURL(c.substring(E,d)))}loadFile(c){if(this.root=n(c),e(c))return this.mapFile=c,t(c,"utf-8").toString().trim()}loadMap(c,l){if(l===!1)return!1;if(l){if(typeof l=="string")return l;if(typeof l=="function"){let E=l(c);if(E){let d=this.loadFile(E);if(!d)throw new Error("Unable to load previous source map: "+E.toString());return d}}else{if(l instanceof s)return r.fromSourceMap(l).toString();if(l instanceof r)return l.toString();if(this.isMap(l))return JSON.stringify(l);throw new Error("Unsupported previous source map format: "+l.toString())}}else{if(this.inline)return this.decodeInline(this.annotation);if(this.annotation){let E=this.annotation;return c&&(E=a(n(c),E)),this.loadFile(E)}}}startWith(c,l){return c?c.substr(0,l.length)===l:!1}withContent(){return!!(this.consumer().sourcesContent&&this.consumer().sourcesContent.length>0)}}return Nc=o,o.default=o,Nc}var vc,S_;function Al(){if(S_)return vc;S_=1;let{nanoid:e}=TBe(),{isAbsolute:t,resolve:n}=Xn,{SourceMapConsumer:a,SourceMapGenerator:s}=Xn,{fileURLToPath:r,pathToFileURL:i}=Xn,o=mp(),u=UA(),c=Xn,l=Symbol("fromOffsetCache"),E=!!(a&&s),d=!!(n&&t);class p{constructor(A,I={}){if(A===null||typeof A>"u"||typeof A=="object"&&!A.toString)throw new Error(`PostCSS received ${A} instead of CSS string`);if(this.css=A.toString(),this.css[0]==="\uFEFF"||this.css[0]==="￾"?(this.hasBOM=!0,this.css=this.css.slice(1)):this.hasBOM=!1,I.from&&(!d||/^\w+:\/\//.test(I.from)||t(I.from)?this.file=I.from:this.file=n(I.from)),d&&E){let m=new u(this.css,I);if(m.text){this.map=m;let S=m.consumer().file;!this.file&&S&&(this.file=this.mapResolve(S))}}this.file||(this.id=""),this.map&&(this.map.file=this.from)}error(A,I,m,S={}){let h,_,O;if(I&&typeof I=="object"){let v=I,D=m;if(typeof v.offset=="number"){let L=this.fromOffset(v.offset);I=L.line,m=L.col}else I=v.line,m=v.column;if(typeof D.offset=="number"){let L=this.fromOffset(D.offset);_=L.line,h=L.col}else _=D.line,h=D.column}else if(!m){let v=this.fromOffset(I);I=v.line,m=v.col}let b=this.origin(I,m,_,h);return b?O=new o(A,b.endLine===void 0?b.line:{column:b.column,line:b.line},b.endLine===void 0?b.column:{column:b.endColumn,line:b.endLine},b.source,b.file,S.plugin):O=new o(A,_===void 0?I:{column:m,line:I},_===void 0?m:{column:h,line:_},this.css,this.file,S.plugin),O.input={column:m,endColumn:h,endLine:_,line:I,source:this.css},this.file&&(i&&(O.input.url=i(this.file).toString()),O.input.file=this.file),O}fromOffset(A){let I,m;if(this[l])m=this[l];else{let h=this.css.split(` -`);m=new Array(h.length);let _=0;for(let O=0,b=h.length;O=I)S=m.length-1;else{let h=m.length-2,_;for(;S>1),A=m[_+1])S=_+1;else{S=_;break}}return{col:A-m[S]+1,line:S+1}}mapResolve(A){return/^\w+:\/\//.test(A)?A:n(this.map.consumer().sourceRoot||this.map.root||".",A)}origin(A,I,m,S){if(!this.map)return!1;let h=this.map.consumer(),_=h.originalPositionFor({column:I,line:A});if(!_.source)return!1;let O;typeof m=="number"&&(O=h.originalPositionFor({column:S,line:m}));let b;t(_.source)?b=i(_.source):b=new URL(_.source,this.map.consumer().sourceRoot||i(this.map.mapFile));let v={column:_.column,endColumn:O&&O.column,endLine:O&&O.line,line:_.line,url:b.toString()};if(b.protocol==="file:")if(r)v.file=r(b);else throw new Error("file: protocol is not available in this PostCSS build");let D=h.sourceContentFor(_.source);return D&&(v.source=D),v}toJSON(){let A={};for(let I of["hasBOM","css","file","id"])this[I]!=null&&(A[I]=this[I]);return this.map&&(A.map={...this.map},A.map.consumerCache&&(A.map.consumerCache=void 0)),A}get from(){return this.file||this.id}}return vc=p,p.default=p,c&&c.registerInput&&c.registerInput(p),vc}var bc,A_;function Xi(){if(A_)return bc;A_=1;let e=xs(),t,n;class a extends e{constructor(r){super(r),this.type="root",this.nodes||(this.nodes=[])}normalize(r,i,o){let u=super.normalize(r);if(i){if(o==="prepend")this.nodes.length>1?i.raws.before=this.nodes[1].raws.before:delete i.raws.before;else if(this.first!==i)for(let c of u)c.raws.before=i.raws.before}return u}removeChild(r,i){let o=this.index(r);return!i&&o===0&&this.nodes.length>1&&(this.nodes[1].raws.before=this.nodes[o].raws.before),super.removeChild(r)}toResult(r={}){return new t(new n,this,r).stringify()}}return a.registerLazyResult=s=>{t=s},a.registerProcessor=s=>{n=s},bc=a,a.default=a,e.registerRoot(a),bc}var Cc,O_;function wA(){if(O_)return Cc;O_=1;let e={comma(t){return e.split(t,[","],!0)},space(t){let n=[" ",` -`," "];return e.split(t,n)},split(t,n,a){let s=[],r="",i=!1,o=0,u=!1,c="",l=!1;for(let E of t)l?l=!1:E==="\\"?l=!0:u?E===c&&(u=!1):E==='"'||E==="'"?(u=!0,c=E):E==="("?o+=1:E===")"?o>0&&(o-=1):o===0&&n.includes(E)&&(i=!0),i?(r!==""&&s.push(r.trim()),r="",i=!1):r+=E;return(a||r!=="")&&s.push(r.trim()),s}};return Cc=e,e.default=e,Cc}var Dc,g_;function Sp(){if(g_)return Dc;g_=1;let e=xs(),t=wA();class n extends e{constructor(s){super(s),this.type="rule",this.nodes||(this.nodes=[])}get selectors(){return t.comma(this.selector)}set selectors(s){let r=this.selector?this.selector.match(/,\s*/):null,i=r?r[0]:","+this.raw("between","beforeOpen");this.selector=s.join(i)}}return Dc=n,n.default=n,e.registerRule(n),Dc}var Pc,I_;function _Be(){if(I_)return Pc;I_=1;let e=_p(),t=hl(),n=Sl(),a=Al(),s=UA(),r=Xi(),i=Sp();function o(u,c){if(Array.isArray(u))return u.map(d=>o(d));let{inputs:l,...E}=u;if(l){c=[];for(let d of l){let p={...d,__proto__:a.prototype};p.map&&(p.map={...p.map,__proto__:s.prototype}),c.push(p)}}if(E.nodes&&(E.nodes=u.nodes.map(d=>o(d,c))),E.source){let{inputId:d,...p}=E.source;E.source=p,d!=null&&(E.source.input=c[d])}if(E.type==="root")return new r(E);if(E.type==="decl")return new n(E);if(E.type==="rule")return new i(E);if(E.type==="comment")return new t(E);if(E.type==="atrule")return new e(E);throw new Error("Unknown node type: "+u.type)}return Pc=o,o.default=o,Pc}var Lc,R_;function MA(){if(R_)return Lc;R_=1;let{dirname:e,relative:t,resolve:n,sep:a}=Xn,{SourceMapConsumer:s,SourceMapGenerator:r}=Xn,{pathToFileURL:i}=Xn,o=Al(),u=!!(s&&r),c=!!(e&&n&&t&&a);class l{constructor(d,p,T,A){this.stringify=d,this.mapOpts=T.map||{},this.root=p,this.opts=T,this.css=A,this.originalCSS=A,this.usesFileUrls=!this.mapOpts.from&&this.mapOpts.absolute,this.memoizedFileURLs=new Map,this.memoizedPaths=new Map,this.memoizedURLs=new Map}addAnnotation(){let d;this.isInline()?d="data:application/json;base64,"+this.toBase64(this.map.toString()):typeof this.mapOpts.annotation=="string"?d=this.mapOpts.annotation:typeof this.mapOpts.annotation=="function"?d=this.mapOpts.annotation(this.opts.to,this.root):d=this.outputFile()+".map";let p=` -`;this.css.includes(`\r -`)&&(p=`\r -`),this.css+=p+"/*# sourceMappingURL="+d+" */"}applyPrevMaps(){for(let d of this.previous()){let p=this.toUrl(this.path(d.file)),T=d.root||e(d.file),A;this.mapOpts.sourcesContent===!1?(A=new s(d.text),A.sourcesContent&&(A.sourcesContent=null)):A=d.consumer(),this.map.applySourceMap(A,p,this.toUrl(this.path(T)))}}clearAnnotation(){if(this.mapOpts.annotation!==!1)if(this.root){let d;for(let p=this.root.nodes.length-1;p>=0;p--)d=this.root.nodes[p],d.type==="comment"&&d.text.startsWith("# sourceMappingURL=")&&this.root.removeChild(p)}else this.css&&(this.css=this.css.replace(/\n*\/\*#[\S\s]*?\*\/$/gm,""))}generate(){if(this.clearAnnotation(),c&&u&&this.isMap())return this.generateMap();{let d="";return this.stringify(this.root,p=>{d+=p}),[d]}}generateMap(){if(this.root)this.generateString();else if(this.previous().length===1){let d=this.previous()[0].consumer();d.file=this.outputFile(),this.map=r.fromSourceMap(d,{ignoreInvalidMapping:!0})}else this.map=new r({file:this.outputFile(),ignoreInvalidMapping:!0}),this.map.addMapping({generated:{column:0,line:1},original:{column:0,line:1},source:this.opts.from?this.toUrl(this.path(this.opts.from)):""});return this.isSourcesContent()&&this.setSourcesContent(),this.root&&this.previous().length>0&&this.applyPrevMaps(),this.isAnnotation()&&this.addAnnotation(),this.isInline()?[this.css]:[this.css,this.map]}generateString(){this.css="",this.map=new r({file:this.outputFile(),ignoreInvalidMapping:!0});let d=1,p=1,T="",A={generated:{column:0,line:0},original:{column:0,line:0},source:""},I,m;this.stringify(this.root,(S,h,_)=>{if(this.css+=S,h&&_!=="end"&&(A.generated.line=d,A.generated.column=p-1,h.source&&h.source.start?(A.source=this.sourcePath(h),A.original.line=h.source.start.line,A.original.column=h.source.start.column-1,this.map.addMapping(A)):(A.source=T,A.original.line=1,A.original.column=0,this.map.addMapping(A))),m=S.match(/\n/g),m?(d+=m.length,I=S.lastIndexOf(` -`),p=S.length-I):p+=S.length,h&&_!=="start"){let O=h.parent||{raws:{}};(!(h.type==="decl"||h.type==="atrule"&&!h.nodes)||h!==O.last||O.raws.semicolon)&&(h.source&&h.source.end?(A.source=this.sourcePath(h),A.original.line=h.source.end.line,A.original.column=h.source.end.column-1,A.generated.line=d,A.generated.column=p-2,this.map.addMapping(A)):(A.source=T,A.original.line=1,A.original.column=0,A.generated.line=d,A.generated.column=p-1,this.map.addMapping(A)))}})}isAnnotation(){return this.isInline()?!0:typeof this.mapOpts.annotation<"u"?this.mapOpts.annotation:this.previous().length?this.previous().some(d=>d.annotation):!0}isInline(){if(typeof this.mapOpts.inline<"u")return this.mapOpts.inline;let d=this.mapOpts.annotation;return typeof d<"u"&&d!==!0?!1:this.previous().length?this.previous().some(p=>p.inline):!0}isMap(){return typeof this.opts.map<"u"?!!this.opts.map:this.previous().length>0}isSourcesContent(){return typeof this.mapOpts.sourcesContent<"u"?this.mapOpts.sourcesContent:this.previous().length?this.previous().some(d=>d.withContent()):!0}outputFile(){return this.opts.to?this.path(this.opts.to):this.opts.from?this.path(this.opts.from):"to.css"}path(d){if(this.mapOpts.absolute||d.charCodeAt(0)===60||/^\w+:\/\//.test(d))return d;let p=this.memoizedPaths.get(d);if(p)return p;let T=this.opts.to?e(this.opts.to):".";typeof this.mapOpts.annotation=="string"&&(T=e(n(T,this.mapOpts.annotation)));let A=t(T,d);return this.memoizedPaths.set(d,A),A}previous(){if(!this.previousMaps)if(this.previousMaps=[],this.root)this.root.walk(d=>{if(d.source&&d.source.input.map){let p=d.source.input.map;this.previousMaps.includes(p)||this.previousMaps.push(p)}});else{let d=new o(this.originalCSS,this.opts);d.map&&this.previousMaps.push(d.map)}return this.previousMaps}setSourcesContent(){let d={};if(this.root)this.root.walk(p=>{if(p.source){let T=p.source.input.from;if(T&&!d[T]){d[T]=!0;let A=this.usesFileUrls?this.toFileUrl(T):this.toUrl(this.path(T));this.map.setSourceContent(A,p.source.input.css)}}});else if(this.css){let p=this.opts.from?this.toUrl(this.path(this.opts.from)):"";this.map.setSourceContent(p,this.css)}}sourcePath(d){return this.mapOpts.from?this.toUrl(this.mapOpts.from):this.usesFileUrls?this.toFileUrl(d.source.input.from):this.toUrl(this.path(d.source.input.from))}toBase64(d){return Buffer?Buffer.from(d).toString("base64"):window.btoa(unescape(encodeURIComponent(d)))}toFileUrl(d){let p=this.memoizedFileURLs.get(d);if(p)return p;if(i){let T=i(d).toString();return this.memoizedFileURLs.set(d,T),T}else throw new Error("`map.absolute` option is not available in this PostCSS build")}toUrl(d){let p=this.memoizedURLs.get(d);if(p)return p;a==="\\"&&(d=d.replace(/\\/g,"/"));let T=encodeURI(d).replace(/[#?]/g,encodeURIComponent);return this.memoizedURLs.set(d,T),T}}return Lc=l,Lc}var yc,N_;function hBe(){if(N_)return yc;N_=1;const e=39,t=34,n=92,a=47,s=10,r=32,i=12,o=9,u=13,c=91,l=93,E=40,d=41,p=123,T=125,A=59,I=42,m=58,S=64,h=/[\t\n\f\r "#'()/;[\\\]{}]/g,_=/[\t\n\f\r !"#'():;@[\\\]{}]|\/(?=\*)/g,O=/.[\r\n"'(/\\]/,b=/[\da-f]/i;return yc=function(D,L={}){let U=D.css.valueOf(),$=L.ignoreErrors,W,Y,te,K,Se,me,ge,J,Ne,st,Ot=U.length,Le=0,Dt=[],Ge=[];function Ut(){return Le}function re(Z){throw D.error("Unclosed "+Z,Le)}function Re(){return Ge.length===0&&Le>=Ot}function Ae(Z){if(Ge.length)return Ge.pop();if(Le>=Ot)return;let de=Z?Z.ignoreUnclosed:!1;switch(W=U.charCodeAt(Le),W){case s:case r:case o:case u:case i:{K=Le;do K+=1,W=U.charCodeAt(K);while(W===r||W===s||W===o||W===u||W===i);me=["space",U.slice(Le,K)],Le=K-1;break}case c:case l:case p:case T:case m:case A:case d:{let P=String.fromCharCode(W);me=[P,P,Le];break}case E:{if(st=Dt.length?Dt.pop()[1]:"",Ne=U.charCodeAt(Le+1),st==="url"&&Ne!==e&&Ne!==t&&Ne!==r&&Ne!==s&&Ne!==o&&Ne!==i&&Ne!==u){K=Le;do{if(ge=!1,K=U.indexOf(")",K+1),K===-1)if($||de){K=Le;break}else re("bracket");for(J=K;U.charCodeAt(J-1)===n;)J-=1,ge=!ge}while(ge);me=["brackets",U.slice(Le,K+1),Le,K],Le=K}else K=U.indexOf(")",Le+1),Y=U.slice(Le,K+1),K===-1||O.test(Y)?me=["(","(",Le]:(me=["brackets",Y,Le,K],Le=K);break}case e:case t:{Se=W===e?"'":'"',K=Le;do{if(ge=!1,K=U.indexOf(Se,K+1),K===-1)if($||de){K=Le+1;break}else re("string");for(J=K;U.charCodeAt(J-1)===n;)J-=1,ge=!ge}while(ge);me=["string",U.slice(Le,K+1),Le,K],Le=K;break}case S:{h.lastIndex=Le+1,h.test(U),h.lastIndex===0?K=U.length-1:K=h.lastIndex-2,me=["at-word",U.slice(Le,K+1),Le,K],Le=K;break}case n:{for(K=Le,te=!0;U.charCodeAt(K+1)===n;)K+=1,te=!te;if(W=U.charCodeAt(K+1),te&&W!==a&&W!==r&&W!==s&&W!==o&&W!==u&&W!==i&&(K+=1,b.test(U.charAt(K)))){for(;b.test(U.charAt(K+1));)K+=1;U.charCodeAt(K+1)===r&&(K+=1)}me=["word",U.slice(Le,K+1),Le,K],Le=K;break}default:{W===a&&U.charCodeAt(Le+1)===I?(K=U.indexOf("*/",Le+2)+1,K===0&&($||de?K=U.length:re("comment")),me=["comment",U.slice(Le,K+1),Le,K],Le=K):(_.lastIndex=Le+1,_.test(U),_.lastIndex===0?K=U.length-1:K=_.lastIndex-2,me=["word",U.slice(Le,K+1),Le,K],Dt.push(me),Le=K);break}}return Le++,me}function Pe(Z){Ge.push(Z)}return{back:Pe,endOfFile:Re,nextToken:Ae,position:Ut}},yc}var $c,v_;function SBe(){if(v_)return $c;v_=1;let e=_p(),t=hl(),n=Sl(),a=Xi(),s=Sp(),r=hBe();const i={empty:!0,space:!0};function o(c){for(let l=c.length-1;l>=0;l--){let E=c[l],d=E[3]||E[2];if(d)return d}}class u{constructor(l){this.input=l,this.root=new a,this.current=this.root,this.spaces="",this.semicolon=!1,this.createTokenizer(),this.root.source={input:l,start:{column:1,line:1,offset:0}}}atrule(l){let E=new e;E.name=l[1].slice(1),E.name===""&&this.unnamedAtrule(E,l),this.init(E,l[2]);let d,p,T,A=!1,I=!1,m=[],S=[];for(;!this.tokenizer.endOfFile();){if(l=this.tokenizer.nextToken(),d=l[0],d==="("||d==="["?S.push(d==="("?")":"]"):d==="{"&&S.length>0?S.push("}"):d===S[S.length-1]&&S.pop(),S.length===0)if(d===";"){E.source.end=this.getPosition(l[2]),E.source.end.offset++,this.semicolon=!0;break}else if(d==="{"){I=!0;break}else if(d==="}"){if(m.length>0){for(T=m.length-1,p=m[T];p&&p[0]==="space";)p=m[--T];p&&(E.source.end=this.getPosition(p[3]||p[2]),E.source.end.offset++)}this.end(l);break}else m.push(l);else m.push(l);if(this.tokenizer.endOfFile()){A=!0;break}}E.raws.between=this.spacesAndCommentsFromEnd(m),m.length?(E.raws.afterName=this.spacesAndCommentsFromStart(m),this.raw(E,"params",m),A&&(l=m[m.length-1],E.source.end=this.getPosition(l[3]||l[2]),E.source.end.offset++,this.spaces=E.raws.between,E.raws.between="")):(E.raws.afterName="",E.params=""),I&&(E.nodes=[],this.current=E)}checkMissedSemicolon(l){let E=this.colon(l);if(E===!1)return;let d=0,p;for(let T=E-1;T>=0&&(p=l[T],!(p[0]!=="space"&&(d+=1,d===2)));T--);throw this.input.error("Missed semicolon",p[0]==="word"?p[3]+1:p[2])}colon(l){let E=0,d,p,T;for(let[A,I]of l.entries()){if(p=I,T=p[0],T==="("&&(E+=1),T===")"&&(E-=1),E===0&&T===":")if(!d)this.doubleColon(p);else{if(d[0]==="word"&&d[1]==="progid")continue;return A}d=p}return!1}comment(l){let E=new t;this.init(E,l[2]),E.source.end=this.getPosition(l[3]||l[2]),E.source.end.offset++;let d=l[1].slice(2,-2);if(/^\s*$/.test(d))E.text="",E.raws.left=d,E.raws.right="";else{let p=d.match(/^(\s*)([^]*\S)(\s*)$/);E.text=p[2],E.raws.left=p[1],E.raws.right=p[3]}}createTokenizer(){this.tokenizer=r(this.input)}decl(l,E){let d=new n;this.init(d,l[0][2]);let p=l[l.length-1];for(p[0]===";"&&(this.semicolon=!0,l.pop()),d.source.end=this.getPosition(p[3]||p[2]||o(l)),d.source.end.offset++;l[0][0]!=="word";)l.length===1&&this.unknownWord(l),d.raws.before+=l.shift()[1];for(d.source.start=this.getPosition(l[0][2]),d.prop="";l.length;){let S=l[0][0];if(S===":"||S==="space"||S==="comment")break;d.prop+=l.shift()[1]}d.raws.between="";let T;for(;l.length;)if(T=l.shift(),T[0]===":"){d.raws.between+=T[1];break}else T[0]==="word"&&/\w/.test(T[1])&&this.unknownWord([T]),d.raws.between+=T[1];(d.prop[0]==="_"||d.prop[0]==="*")&&(d.raws.before+=d.prop[0],d.prop=d.prop.slice(1));let A=[],I;for(;l.length&&(I=l[0][0],!(I!=="space"&&I!=="comment"));)A.push(l.shift());this.precheckMissedSemicolon(l);for(let S=l.length-1;S>=0;S--){if(T=l[S],T[1].toLowerCase()==="!important"){d.important=!0;let h=this.stringFrom(l,S);h=this.spacesFromEnd(l)+h,h!==" !important"&&(d.raws.important=h);break}else if(T[1].toLowerCase()==="important"){let h=l.slice(0),_="";for(let O=S;O>0;O--){let b=h[O][0];if(_.trim().startsWith("!")&&b!=="space")break;_=h.pop()[1]+_}_.trim().startsWith("!")&&(d.important=!0,d.raws.important=_,l=h)}if(T[0]!=="space"&&T[0]!=="comment")break}l.some(S=>S[0]!=="space"&&S[0]!=="comment")&&(d.raws.between+=A.map(S=>S[1]).join(""),A=[]),this.raw(d,"value",A.concat(l),E),d.value.includes(":")&&!E&&this.checkMissedSemicolon(l)}doubleColon(l){throw this.input.error("Double colon",{offset:l[2]},{offset:l[2]+l[1].length})}emptyRule(l){let E=new s;this.init(E,l[2]),E.selector="",E.raws.between="",this.current=E}end(l){this.current.nodes&&this.current.nodes.length&&(this.current.raws.semicolon=this.semicolon),this.semicolon=!1,this.current.raws.after=(this.current.raws.after||"")+this.spaces,this.spaces="",this.current.parent?(this.current.source.end=this.getPosition(l[2]),this.current.source.end.offset++,this.current=this.current.parent):this.unexpectedClose(l)}endFile(){this.current.parent&&this.unclosedBlock(),this.current.nodes&&this.current.nodes.length&&(this.current.raws.semicolon=this.semicolon),this.current.raws.after=(this.current.raws.after||"")+this.spaces,this.root.source.end=this.getPosition(this.tokenizer.position())}freeSemicolon(l){if(this.spaces+=l[1],this.current.nodes){let E=this.current.nodes[this.current.nodes.length-1];E&&E.type==="rule"&&!E.raws.ownSemicolon&&(E.raws.ownSemicolon=this.spaces,this.spaces="")}}getPosition(l){let E=this.input.fromOffset(l);return{column:E.col,line:E.line,offset:l}}init(l,E){this.current.push(l),l.source={input:this.input,start:this.getPosition(E)},l.raws.before=this.spaces,this.spaces="",l.type!=="comment"&&(this.semicolon=!1)}other(l){let E=!1,d=null,p=!1,T=null,A=[],I=l[1].startsWith("--"),m=[],S=l;for(;S;){if(d=S[0],m.push(S),d==="("||d==="[")T||(T=S),A.push(d==="("?")":"]");else if(I&&p&&d==="{")T||(T=S),A.push("}");else if(A.length===0)if(d===";")if(p){this.decl(m,I);return}else break;else if(d==="{"){this.rule(m);return}else if(d==="}"){this.tokenizer.back(m.pop()),E=!0;break}else d===":"&&(p=!0);else d===A[A.length-1]&&(A.pop(),A.length===0&&(T=null));S=this.tokenizer.nextToken()}if(this.tokenizer.endOfFile()&&(E=!0),A.length>0&&this.unclosedBracket(T),E&&p){if(!I)for(;m.length&&(S=m[m.length-1][0],!(S!=="space"&&S!=="comment"));)this.tokenizer.back(m.pop());this.decl(m,I)}else this.unknownWord(m)}parse(){let l;for(;!this.tokenizer.endOfFile();)switch(l=this.tokenizer.nextToken(),l[0]){case"space":this.spaces+=l[1];break;case";":this.freeSemicolon(l);break;case"}":this.end(l);break;case"comment":this.comment(l);break;case"at-word":this.atrule(l);break;case"{":this.emptyRule(l);break;default:this.other(l);break}this.endFile()}precheckMissedSemicolon(){}raw(l,E,d,p){let T,A,I=d.length,m="",S=!0,h,_;for(let O=0;Ob+v[1],"");l.raws[E]={raw:O,value:m}}l[E]=m}rule(l){l.pop();let E=new s;this.init(E,l[0][2]),E.raws.between=this.spacesAndCommentsFromEnd(l),this.raw(E,"selector",l),this.current=E}spacesAndCommentsFromEnd(l){let E,d="";for(;l.length&&(E=l[l.length-1][0],!(E!=="space"&&E!=="comment"));)d=l.pop()[1]+d;return d}spacesAndCommentsFromStart(l){let E,d="";for(;l.length&&(E=l[0][0],!(E!=="space"&&E!=="comment"));)d+=l.shift()[1];return d}spacesFromEnd(l){let E,d="";for(;l.length&&(E=l[l.length-1][0],E==="space");)d=l.pop()[1]+d;return d}stringFrom(l,E){let d="";for(let p=E;pa.type==="warning")}get content(){return this.css}}return wc=t,t.default=t,wc}var Mc,P_;function zA(){if(P_)return Mc;P_=1;let e=xs(),t=hp(),n=MA(),a=Ap(),s=Op(),r=Xi(),i=Tl(),{isClean:o,my:u}=Tp();const c={atrule:"AtRule",comment:"Comment",decl:"Declaration",document:"Document",root:"Root",rule:"Rule"},l={AtRule:!0,AtRuleExit:!0,Comment:!0,CommentExit:!0,Declaration:!0,DeclarationExit:!0,Document:!0,DocumentExit:!0,Once:!0,OnceExit:!0,postcssPlugin:!0,prepare:!0,Root:!0,RootExit:!0,Rule:!0,RuleExit:!0},E={Once:!0,postcssPlugin:!0,prepare:!0},d=0;function p(h){return typeof h=="object"&&typeof h.then=="function"}function T(h){let _=!1,O=c[h.type];return h.type==="decl"?_=h.prop.toLowerCase():h.type==="atrule"&&(_=h.name.toLowerCase()),_&&h.append?[O,O+"-"+_,d,O+"Exit",O+"Exit-"+_]:_?[O,O+"-"+_,O+"Exit",O+"Exit-"+_]:h.append?[O,d,O+"Exit"]:[O,O+"Exit"]}function A(h){let _;return h.type==="document"?_=["Document",d,"DocumentExit"]:h.type==="root"?_=["Root",d,"RootExit"]:_=T(h),{eventIndex:0,events:_,iterator:0,node:h,visitorIndex:0,visitors:[]}}function I(h){return h[o]=!1,h.nodes&&h.nodes.forEach(_=>I(_)),h}let m={};class S{constructor(_,O,b){this.stringified=!1,this.processed=!1;let v;if(typeof O=="object"&&O!==null&&(O.type==="root"||O.type==="document"))v=I(O);else if(O instanceof S||O instanceof s)v=I(O.root),O.map&&(typeof b.map>"u"&&(b.map={}),b.map.inline||(b.map.inline=!1),b.map.prev=O.map);else{let D=a;b.syntax&&(D=b.syntax.parse),b.parser&&(D=b.parser),D.parse&&(D=D.parse);try{v=D(O,b)}catch(L){this.processed=!0,this.error=L}v&&!v[u]&&e.rebuild(v)}this.result=new s(_,v,b),this.helpers={...m,postcss:m,result:this.result},this.plugins=this.processor.plugins.map(D=>typeof D=="object"&&D.prepare?{...D,...D.prepare(this.result)}:D)}async(){return this.error?Promise.reject(this.error):this.processed?Promise.resolve(this.result):(this.processing||(this.processing=this.runAsync()),this.processing)}catch(_){return this.async().catch(_)}finally(_){return this.async().then(_,_)}getAsyncError(){throw new Error("Use process(css).then(cb) to work with async plugins")}handleError(_,O){let b=this.result.lastPlugin;try{O&&O.addToError(_),this.error=_,_.name==="CssSyntaxError"&&!_.plugin?(_.plugin=b.postcssPlugin,_.setMessage()):b.postcssVersion}catch(v){console&&console.error&&console.error(v)}return _}prepareVisitors(){this.listeners={};let _=(O,b,v)=>{this.listeners[b]||(this.listeners[b]=[]),this.listeners[b].push([O,v])};for(let O of this.plugins)if(typeof O=="object")for(let b in O){if(!l[b]&&/^[A-Z]/.test(b))throw new Error(`Unknown event ${b} in ${O.postcssPlugin}. Try to update PostCSS (${this.processor.version} now).`);if(!E[b])if(typeof O[b]=="object")for(let v in O[b])v==="*"?_(O,b,O[b][v]):_(O,b+"-"+v.toLowerCase(),O[b][v]);else typeof O[b]=="function"&&_(O,b,O[b])}this.hasListener=Object.keys(this.listeners).length>0}async runAsync(){this.plugin=0;for(let _=0;_0;){let b=this.visitTick(O);if(p(b))try{await b}catch(v){let D=O[O.length-1].node;throw this.handleError(v,D)}}}if(this.listeners.OnceExit)for(let[O,b]of this.listeners.OnceExit){this.result.lastPlugin=O;try{if(_.type==="document"){let v=_.nodes.map(D=>b(D,this.helpers));await Promise.all(v)}else await b(_,this.helpers)}catch(v){throw this.handleError(v)}}}return this.processed=!0,this.stringify()}runOnRoot(_){this.result.lastPlugin=_;try{if(typeof _=="object"&&_.Once){if(this.result.root.type==="document"){let O=this.result.root.nodes.map(b=>_.Once(b,this.helpers));return p(O[0])?Promise.all(O):O}return _.Once(this.result.root,this.helpers)}else if(typeof _=="function")return _(this.result.root,this.result)}catch(O){throw this.handleError(O)}}stringify(){if(this.error)throw this.error;if(this.stringified)return this.result;this.stringified=!0,this.sync();let _=this.result.opts,O=i;_.syntax&&(O=_.syntax.stringify),_.stringifier&&(O=_.stringifier),O.stringify&&(O=O.stringify);let v=new n(O,this.result.root,this.result.opts).generate();return this.result.css=v[0],this.result.map=v[1],this.result}sync(){if(this.error)throw this.error;if(this.processed)return this.result;if(this.processed=!0,this.processing)throw this.getAsyncError();for(let _ of this.plugins){let O=this.runOnRoot(_);if(p(O))throw this.getAsyncError()}if(this.prepareVisitors(),this.hasListener){let _=this.result.root;for(;!_[o];)_[o]=!0,this.walkSync(_);if(this.listeners.OnceExit)if(_.type==="document")for(let O of _.nodes)this.visitSync(this.listeners.OnceExit,O);else this.visitSync(this.listeners.OnceExit,_)}return this.result}then(_,O){return this.async().then(_,O)}toString(){return this.css}visitSync(_,O){for(let[b,v]of _){this.result.lastPlugin=b;let D;try{D=v(O,this.helpers)}catch(L){throw this.handleError(L,O.proxyOf)}if(O.type!=="root"&&O.type!=="document"&&!O.parent)return!0;if(p(D))throw this.getAsyncError()}}visitTick(_){let O=_[_.length-1],{node:b,visitors:v}=O;if(b.type!=="root"&&b.type!=="document"&&!b.parent){_.pop();return}if(v.length>0&&O.visitorIndex{v[o]||this.walkSync(v)});else{let v=this.listeners[b];if(v&&this.visitSync(v,_.toProxy()))return}}warnings(){return this.sync().warnings()}get content(){return this.stringify().content}get css(){return this.stringify().css}get map(){return this.stringify().map}get messages(){return this.sync().messages}get opts(){return this.result.opts}get processor(){return this.result.processor}get root(){return this.sync().root}get[Symbol.toStringTag](){return"LazyResult"}}return S.registerPostcss=h=>{m=h},Mc=S,S.default=S,r.registerLazyResult(S),t.registerLazyResult(S),Mc}var Wc,L_;function ABe(){if(L_)return Wc;L_=1;let e=MA(),t=Ap();const n=Op();let a=Tl();class s{constructor(i,o,u){o=o.toString(),this.stringified=!1,this._processor=i,this._css=o,this._opts=u,this._map=void 0;let c,l=a;this.result=new n(this._processor,c,this._opts),this.result.css=o;let E=this;Object.defineProperty(this.result,"root",{get(){return E.root}});let d=new e(l,c,this._opts,o);if(d.isMap()){let[p,T]=d.generate();p&&(this.result.css=p),T&&(this.result.map=T)}else d.clearAnnotation(),this.result.css=d.css}async(){return this.error?Promise.reject(this.error):Promise.resolve(this.result)}catch(i){return this.async().catch(i)}finally(i){return this.async().then(i,i)}sync(){if(this.error)throw this.error;return this.result}then(i,o){return this.async().then(i,o)}toString(){return this._css}warnings(){return[]}get content(){return this.result.css}get css(){return this.result.css}get map(){return this.result.map}get messages(){return[]}get opts(){return this.result.opts}get processor(){return this.result.processor}get root(){if(this._root)return this._root;let i,o=t;try{i=o(this._css,this._opts)}catch(u){this.error=u}if(this.error)throw this.error;return this._root=i,i}get[Symbol.toStringTag](){return"NoWorkResult"}}return Wc=s,s.default=s,Wc}var zc,y_;function OBe(){if(y_)return zc;y_=1;let e=hp(),t=zA(),n=ABe(),a=Xi();class s{constructor(i=[]){this.version="8.4.49",this.plugins=this.normalize(i)}normalize(i){let o=[];for(let u of i)if(u.postcss===!0?u=u():u.postcss&&(u=u.postcss),typeof u=="object"&&Array.isArray(u.plugins))o=o.concat(u.plugins);else if(typeof u=="object"&&u.postcssPlugin)o.push(u);else if(typeof u=="function")o.push(u);else if(!(typeof u=="object"&&(u.parse||u.stringify)))throw new Error(u+" is not a PostCSS plugin");return o}process(i,o={}){return!this.plugins.length&&!o.parser&&!o.stringifier&&!o.syntax?new n(this,i,o):new t(this,i,o)}use(i){return this.plugins=this.plugins.concat(this.normalize([i])),this}}return zc=s,s.default=s,a.registerProcessor(s),e.registerProcessor(s),zc}var xc,$_;function gBe(){if($_)return xc;$_=1;var e={};let t=_p(),n=hl(),a=xs(),s=mp(),r=Sl(),i=hp(),o=_Be(),u=Al(),c=zA(),l=wA(),E=_l(),d=Ap(),p=OBe(),T=Op(),A=Xi(),I=Sp(),m=Tl(),S=WA();function h(..._){return _.length===1&&Array.isArray(_[0])&&(_=_[0]),new p(_)}return h.plugin=function(O,b){let v=!1;function D(...U){console&&console.warn&&!v&&(v=!0,console.warn(O+`: postcss.plugin was deprecated. Migration guide: -https://evilmartians.com/chronicles/postcss-8-plugin-migration`),e.LANG&&e.LANG.startsWith("cn")&&console.warn(O+`: 里面 postcss.plugin 被弃用. 迁移指南: -https://www.w3ctech.com/topic/2226`));let $=b(...U);return $.postcssPlugin=O,$.postcssVersion=new p().version,$}let L;return Object.defineProperty(D,"postcss",{get(){return L||(L=D()),L}}),D.process=function(U,$,W){return h([D(W)]).process(U,$)},D},h.stringify=m,h.parse=d,h.fromJSON=o,h.list=l,h.comment=_=>new n(_),h.atRule=_=>new t(_),h.decl=_=>new r(_),h.rule=_=>new I(_),h.root=_=>new A(_),h.document=_=>new i(_),h.CssSyntaxError=s,h.Declaration=r,h.Container=a,h.Processor=p,h.Document=i,h.Comment=n,h.Warning=S,h.AtRule=t,h.Result=T,h.Input=u,h.Rule=I,h.Root=A,h.Node=E,c.registerPostcss(h),xc=h,h.default=h,xc}var Fc,k_;function IBe(){if(k_)return Fc;k_=1;const e=oBe(),t=uBe(),{isPlainObject:n}=lBe(),a=cBe(),s=EBe(),{parse:r}=gBe(),i=["img","audio","video","picture","svg","object","map","iframe","embed"],o=["script","style"];function u(I,m){I&&Object.keys(I).forEach(function(S){m(I[S],S)})}function c(I,m){return{}.hasOwnProperty.call(I,m)}function l(I,m){const S=[];return u(I,function(h){m(h)&&S.push(h)}),S}function E(I){for(const m in I)if(c(I,m))return!1;return!0}function d(I){return I.map(function(m){if(!m.url)throw new Error("URL missing");return m.url+(m.w?` ${m.w}w`:"")+(m.h?` ${m.h}h`:"")+(m.d?` ${m.d}x`:"")}).join(", ")}Fc=T;const p=/^[^\0\t\n\f\r /<=>]+$/;function T(I,m,S){if(I==null)return"";typeof I=="number"&&(I=I.toString());let h="",_="";function O(Z,de){const P=this;this.tag=Z,this.attribs=de||{},this.tagPosition=h.length,this.text="",this.mediaChildren=[],this.updateParentNodeText=function(){if(Se.length){const k=Se[Se.length-1];k.text+=P.text}},this.updateParentNodeMediaChildren=function(){Se.length&&i.includes(this.tag)&&Se[Se.length-1].mediaChildren.push(this.tag)}}m=Object.assign({},T.defaults,m),m.parser=Object.assign({},A,m.parser);const b=function(Z){return m.allowedTags===!1||(m.allowedTags||[]).indexOf(Z)>-1};o.forEach(function(Z){b(Z)&&!m.allowVulnerableTags&&console.warn(` - -⚠️ Your \`allowedTags\` option includes, \`${Z}\`, which is inherently -vulnerable to XSS attacks. Please remove it from \`allowedTags\`. -Or, to disable this warning, add the \`allowVulnerableTags\` option -and ensure you are accounting for this risk. - -`)});const v=m.nonTextTags||["script","style","textarea","option"];let D,L;m.allowedAttributes&&(D={},L={},u(m.allowedAttributes,function(Z,de){D[de]=[];const P=[];Z.forEach(function(k){typeof k=="string"&&k.indexOf("*")>=0?P.push(t(k).replace(/\\\*/g,".*")):D[de].push(k)}),P.length&&(L[de]=new RegExp("^("+P.join("|")+")$"))}));const U={},$={},W={};u(m.allowedClasses,function(Z,de){if(D&&(c(D,de)||(D[de]=[]),D[de].push("class")),U[de]=Z,Array.isArray(Z)){const P=[];U[de]=[],W[de]=[],Z.forEach(function(k){typeof k=="string"&&k.indexOf("*")>=0?P.push(t(k).replace(/\\\*/g,".*")):k instanceof RegExp?W[de].push(k):U[de].push(k)}),P.length&&($[de]=new RegExp("^("+P.join("|")+")$"))}});const Y={};let te;u(m.transformTags,function(Z,de){let P;typeof Z=="function"?P=Z:typeof Z=="string"&&(P=T.simpleTransform(Z)),de==="*"?te=P:Y[de]=P});let K,Se,me,ge,J,Ne,st=!1;Le();const Ot=new e.Parser({onopentag:function(Z,de){if(m.enforceHtmlBoundary&&Z==="html"&&Le(),J){Ne++;return}const P=new O(Z,de);Se.push(P);let k=!1;const H=!!P.text;let oe;if(c(Y,Z)&&(oe=Y[Z](Z,de),P.attribs=de=oe.attribs,oe.text!==void 0&&(P.innerText=oe.text),Z!==oe.tagName&&(P.name=Z=oe.tagName,ge[K]=oe.tagName)),te&&(oe=te(Z,de),P.attribs=de=oe.attribs,Z!==oe.tagName&&(P.name=Z=oe.tagName,ge[K]=oe.tagName)),(!b(Z)||m.disallowedTagsMode==="recursiveEscape"&&!E(me)||m.nestingLimit!=null&&K>=m.nestingLimit)&&(k=!0,me[K]=!0,(m.disallowedTagsMode==="discard"||m.disallowedTagsMode==="completelyDiscard")&&v.indexOf(Z)!==-1&&(J=!0,Ne=1),me[K]=!0),K++,k){if(m.disallowedTagsMode==="discard"||m.disallowedTagsMode==="completelyDiscard")return;_=h,h=""}h+="<"+Z,Z==="script"&&(m.allowedScriptHostnames||m.allowedScriptDomains)&&(P.innerText=""),(!D||c(D,Z)||D["*"])&&u(de,function(Q,y){if(!p.test(y)){delete P.attribs[y];return}if(Q===""&&!m.allowedEmptyAttributes.includes(y)&&(m.nonBooleanAttributes.includes(y)||m.nonBooleanAttributes.includes("*"))){delete P.attribs[y];return}let w=!1;if(!D||c(D,Z)&&D[Z].indexOf(y)!==-1||D["*"]&&D["*"].indexOf(y)!==-1||c(L,Z)&&L[Z].test(y)||L["*"]&&L["*"].test(y))w=!0;else if(D&&D[Z]){for(const B of D[Z])if(n(B)&&B.name&&B.name===y){w=!0;let q="";if(B.multiple===!0){const se=Q.split(" ");for(const ce of se)B.values.indexOf(ce)!==-1&&(q===""?q=ce:q+=" "+ce)}else B.values.indexOf(Q)>=0&&(q=Q);Q=q}}if(w){if(m.allowedSchemesAppliedToAttributes.indexOf(y)!==-1&&Ge(Z,Q)){delete P.attribs[y];return}if(Z==="script"&&y==="src"){let B=!0;try{const q=Ut(Q);if(m.allowedScriptHostnames||m.allowedScriptDomains){const se=(m.allowedScriptHostnames||[]).find(function(F){return F===q.url.hostname}),ce=(m.allowedScriptDomains||[]).find(function(F){return q.url.hostname===F||q.url.hostname.endsWith(`.${F}`)});B=se||ce}}catch{B=!1}if(!B){delete P.attribs[y];return}}if(Z==="iframe"&&y==="src"){let B=!0;try{const q=Ut(Q);if(q.isRelativeUrl)B=c(m,"allowIframeRelativeUrls")?m.allowIframeRelativeUrls:!m.allowedIframeHostnames&&!m.allowedIframeDomains;else if(m.allowedIframeHostnames||m.allowedIframeDomains){const se=(m.allowedIframeHostnames||[]).find(function(F){return F===q.url.hostname}),ce=(m.allowedIframeDomains||[]).find(function(F){return q.url.hostname===F||q.url.hostname.endsWith(`.${F}`)});B=se||ce}}catch{B=!1}if(!B){delete P.attribs[y];return}}if(y==="srcset")try{let B=s(Q);if(B.forEach(function(q){Ge("srcset",q.url)&&(q.evil=!0)}),B=l(B,function(q){return!q.evil}),B.length)Q=d(l(B,function(q){return!q.evil})),P.attribs[y]=Q;else{delete P.attribs[y];return}}catch{delete P.attribs[y];return}if(y==="class"){const B=U[Z],q=U["*"],se=$[Z],ce=W[Z],F=W["*"],X=$["*"],Ie=[se,X].concat(ce,F).filter(function(ye){return ye});if(B&&q?Q=Pe(Q,a(B,q),Ie):Q=Pe(Q,B||q,Ie),!Q.length){delete P.attribs[y];return}}if(y==="style"){if(m.parseStyleAttributes)try{const B=r(Z+" {"+Q+"}",{map:!1}),q=re(B,m.allowedStyles);if(Q=Re(q),Q.length===0){delete P.attribs[y];return}}catch{typeof window<"u"&&console.warn('Failed to parse "'+Z+" {"+Q+`}", If you're running this in a browser, we recommend to disable style parsing: options.parseStyleAttributes: false, since this only works in a node environment due to a postcss dependency, More info: https://github.com/apostrophecms/sanitize-html/issues/547`),delete P.attribs[y];return}else if(m.allowedStyles)throw new Error("allowedStyles option cannot be used together with parseStyleAttributes: false.")}h+=" "+y,Q&&Q.length?h+='="'+Dt(Q,!0)+'"':m.allowedEmptyAttributes.includes(y)&&(h+='=""')}else delete P.attribs[y]}),m.selfClosing.indexOf(Z)!==-1?h+=" />":(h+=">",P.innerText&&!H&&!m.textFilter&&(h+=Dt(P.innerText),st=!0)),k&&(h=_+Dt(h),_="")},ontext:function(Z){if(J)return;const de=Se[Se.length-1];let P;if(de&&(P=de.tag,Z=de.innerText!==void 0?de.innerText:Z),m.disallowedTagsMode==="completelyDiscard"&&!b(P))Z="";else if((m.disallowedTagsMode==="discard"||m.disallowedTagsMode==="completelyDiscard")&&(P==="script"||P==="style"))h+=Z;else{const k=Dt(Z,!1);m.textFilter&&!st?h+=m.textFilter(k,P):st||(h+=k)}if(Se.length){const k=Se[Se.length-1];k.text+=Z}},onclosetag:function(Z,de){if(J)if(Ne--,!Ne)J=!1;else return;const P=Se.pop();if(!P)return;if(P.tag!==Z){Se.push(P);return}J=m.enforceHtmlBoundary?Z==="html":!1,K--;const k=me[K];if(k){if(delete me[K],m.disallowedTagsMode==="discard"||m.disallowedTagsMode==="completelyDiscard"){P.updateParentNodeText();return}_=h,h=""}if(ge[K]&&(Z=ge[K],delete ge[K]),m.exclusiveFilter&&m.exclusiveFilter(P)){h=h.substr(0,P.tagPosition);return}if(P.updateParentNodeMediaChildren(),P.updateParentNodeText(),m.selfClosing.indexOf(Z)!==-1||de&&!b(Z)&&["escape","recursiveEscape"].indexOf(m.disallowedTagsMode)>=0){k&&(h=_,_="");return}h+="",k&&(h=_+Dt(h),_=""),st=!1}},m.parser);return Ot.write(I),Ot.end(),h;function Le(){h="",K=0,Se=[],me={},ge={},J=!1,Ne=0}function Dt(Z,de){return typeof Z!="string"&&(Z=Z+""),m.parser.decodeEntities&&(Z=Z.replace(/&/g,"&").replace(//g,">"),de&&(Z=Z.replace(/"/g,"""))),Z=Z.replace(/&(?![a-zA-Z0-9#]{1,20};)/g,"&").replace(//g,">"),de&&(Z=Z.replace(/"/g,""")),Z}function Ge(Z,de){for(de=de.replace(/[\x00-\x20]+/g,"");;){const H=de.indexOf("",H+4);if(oe===-1)break;de=de.substring(0,H)+de.substring(oe+3)}const P=de.match(/^([a-zA-Z][a-zA-Z0-9.\-+]*):/);if(!P)return de.match(/^[/\\]{2}/)?!m.allowProtocolRelative:!1;const k=P[1].toLowerCase();return c(m.allowedSchemesByTag,Z)?m.allowedSchemesByTag[Z].indexOf(k)===-1:!m.allowedSchemes||m.allowedSchemes.indexOf(k)===-1}function Ut(Z){if(Z=Z.replace(/^(\w+:)?\s*[\\/]\s*[\\/]/,"$1//"),Z.startsWith("relative:"))throw new Error("relative: exploit attempt");let de="relative://relative-site";for(let H=0;H<100;H++)de+=`/${H}`;const P=new URL(Z,de);return{isRelativeUrl:P&&P.hostname==="relative-site"&&P.protocol==="relative:",url:P}}function re(Z,de){if(!de)return Z;const P=Z.nodes[0];let k;return de[P.selector]&&de["*"]?k=a(de[P.selector],de["*"]):k=de[P.selector]||de["*"],k&&(Z.nodes[0].nodes=P.nodes.reduce(Ae(k),[])),Z}function Re(Z){return Z.nodes[0].nodes.reduce(function(de,P){return de.push(`${P.prop}:${P.value}${P.important?" !important":""}`),de},[]).join(";")}function Ae(Z){return function(de,P){return c(Z,P.prop)&&Z[P.prop].some(function(H){return H.test(P.value)})&&de.push(P),de}}function Pe(Z,de,P){return de?(Z=Z.split(/\s+/),Z.filter(function(k){return de.indexOf(k)!==-1||P.some(function(H){return H.test(k)})}).join(" ")):Z}}const A={decodeEntities:!0};return T.defaults={allowedTags:["address","article","aside","footer","header","h1","h2","h3","h4","h5","h6","hgroup","main","nav","section","blockquote","dd","div","dl","dt","figcaption","figure","hr","li","main","ol","p","pre","ul","a","abbr","b","bdi","bdo","br","cite","code","data","dfn","em","i","kbd","mark","q","rb","rp","rt","rtc","ruby","s","samp","small","span","strong","sub","sup","time","u","var","wbr","caption","col","colgroup","table","tbody","td","tfoot","th","thead","tr"],nonBooleanAttributes:["abbr","accept","accept-charset","accesskey","action","allow","alt","as","autocapitalize","autocomplete","blocking","charset","cite","class","color","cols","colspan","content","contenteditable","coords","crossorigin","data","datetime","decoding","dir","dirname","download","draggable","enctype","enterkeyhint","fetchpriority","for","form","formaction","formenctype","formmethod","formtarget","headers","height","hidden","high","href","hreflang","http-equiv","id","imagesizes","imagesrcset","inputmode","integrity","is","itemid","itemprop","itemref","itemtype","kind","label","lang","list","loading","low","max","maxlength","media","method","min","minlength","name","nonce","optimum","pattern","ping","placeholder","popover","popovertarget","popovertargetaction","poster","preload","referrerpolicy","rel","rows","rowspan","sandbox","scope","shape","size","sizes","slot","span","spellcheck","src","srcdoc","srclang","srcset","start","step","style","tabindex","target","title","translate","type","usemap","value","width","wrap","onauxclick","onafterprint","onbeforematch","onbeforeprint","onbeforeunload","onbeforetoggle","onblur","oncancel","oncanplay","oncanplaythrough","onchange","onclick","onclose","oncontextlost","oncontextmenu","oncontextrestored","oncopy","oncuechange","oncut","ondblclick","ondrag","ondragend","ondragenter","ondragleave","ondragover","ondragstart","ondrop","ondurationchange","onemptied","onended","onerror","onfocus","onformdata","onhashchange","oninput","oninvalid","onkeydown","onkeypress","onkeyup","onlanguagechange","onload","onloadeddata","onloadedmetadata","onloadstart","onmessage","onmessageerror","onmousedown","onmouseenter","onmouseleave","onmousemove","onmouseout","onmouseover","onmouseup","onoffline","ononline","onpagehide","onpageshow","onpaste","onpause","onplay","onplaying","onpopstate","onprogress","onratechange","onreset","onresize","onrejectionhandled","onscroll","onscrollend","onsecuritypolicyviolation","onseeked","onseeking","onselect","onslotchange","onstalled","onstorage","onsubmit","onsuspend","ontimeupdate","ontoggle","onunhandledrejection","onunload","onvolumechange","onwaiting","onwheel"],disallowedTagsMode:"discard",allowedAttributes:{a:["href","name","target"],img:["src","srcset","alt","title","width","height","loading"]},allowedEmptyAttributes:["alt"],selfClosing:["img","br","hr","area","base","basefont","input","link","meta"],allowedSchemes:["http","https","ftp","mailto","tel"],allowedSchemesByTag:{},allowedSchemesAppliedToAttributes:["href","src","cite"],allowProtocolRelative:!0,enforceHtmlBoundary:!1,parseStyleAttributes:!0},T.simpleTransform=function(I,m,S){return S=S===void 0?!0:S,m=m||{},function(h,_){let O;if(S)for(O in m)_[O]=m[O];else _=m;return{tagName:I,attribs:_}}},Fc}var RBe=IBe();const xA=uE(RBe),NBe=e=>xA(mFe(e,{target:"_blank"}),{allowedTags:["a"],disallowedTagsMode:"escape"}),Pi=e=>{const t=pt.parse(e,{breaks:!0});return xA(t)},vBe={id:"admin-app",class:"admin-card"},bBe={for:"admin_contact"},CBe=["value"],DBe=["disabled"],PBe={for:"max_users"},LBe=["disabled"],yBe={class:"admin-help"},$Be={class:"info-box"},kBe={for:"max_single_file_size"},UBe=["disabled"],wBe={for:"max_zip_file_size"},MBe=["disabled"],WBe={for:"gpx_limit_import"},zBe=["disabled"],xBe={for:"stats_workouts_limit"},FBe=["disabled"],BBe={class:"admin-help"},GBe={class:"info-box"},HBe={class:"about-label",for:"about"},VBe={class:"textarea-description"},qBe=["innerHTML"],KBe={class:"privacy-policy-label",for:"privacy_policy"},jBe={class:"textarea-description"},YBe=["innerHTML"],XBe={key:5,class:"form-buttons"},QBe={class:"confirm",type:"submit"},ZBe={key:6,class:"form-buttons"},JBe=ae({__name:"AdminApplication",props:{appConfig:{},edition:{type:Boolean,default:!1}},setup(e){const t=e,{edition:n}=he(t),a=Ue(),s=_a(),r=Kt({admin_contact:"",max_users:0,max_single_file_size:0,max_zip_file_size:0,gpx_limit_import:0,about:"",privacy_policy:"",stats_workouts_limit:0}),i=z(()=>a.getters[V.GETTERS.ERROR_MESSAGES]);ft(()=>{t.appConfig&&o(t.appConfig)});function o(l){Object.keys(r).map(E=>{["max_single_file_size","max_zip_file_size"].includes(E)?r[E]=xxe(l[E]):["about","privacy_policy"].includes(E)?r[E]=l[E]!==null?l[E]:"":r[E]=l[E]})}function u(){o(t.appConfig),a.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),s.push("/admin/application")}function c(){const l=Object.assign({},r);l.max_single_file_size*=1048576,l.max_zip_file_size*=1048576,a.dispatch(V.ACTIONS.UPDATE_APPLICATION_CONFIG,l)}return(l,E)=>{const d=ie("ErrorMessage"),p=ie("Card");return N(),C("div",vBe,[x(p,null,{title:le(()=>[G(R(l.$t("admin.APP_CONFIG.TITLE")),1)]),content:le(()=>[f("form",{class:"admin-form",onSubmit:De(c,["prevent"])},[f("label",bBe,[G(R(l.$t("admin.APP_CONFIG.ADMIN_CONTACT"))+": ",1),!g(n)&&!r.admin_contact?(N(),C("input",{key:0,class:"no-contact",value:l.$t("admin.APP_CONFIG.NO_CONTACT_EMAIL"),disabled:""},null,8,CBe)):ke((N(),C("input",{key:1,id:"admin_contact",name:"admin_contact",type:"email","onUpdate:modelValue":E[0]||(E[0]=T=>r.admin_contact=T),disabled:!g(n)},null,8,DBe)),[[et,r.admin_contact]])]),f("label",PBe,[G(R(l.$t("admin.APP_CONFIG.MAX_USERS_LABEL"))+": ",1),ke(f("input",{id:"max_users",name:"max_users",type:"number",min:"0","onUpdate:modelValue":E[1]||(E[1]=T=>r.max_users=T),disabled:!g(n)},null,8,LBe),[[et,r.max_users]])]),f("div",yBe,[f("span",$Be,[E[10]||(E[10]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(l.$t("admin.APP_CONFIG.MAX_USERS_HELP")),1)])]),f("label",kBe,[G(R(l.$t("admin.APP_CONFIG.SINGLE_UPLOAD_MAX_SIZE_LABEL"))+": ",1),ke(f("input",{id:"max_single_file_size",name:"max_single_file_size",type:"number",step:"0.1",min:"0","onUpdate:modelValue":E[2]||(E[2]=T=>r.max_single_file_size=T),disabled:!g(n)},null,8,UBe),[[et,r.max_single_file_size]])]),f("label",wBe,[G(R(l.$t("admin.APP_CONFIG.ZIP_UPLOAD_MAX_SIZE_LABEL"))+": ",1),ke(f("input",{id:"max_zip_file_size",name:"max_zip_file_size",type:"number",step:"0.1",min:"0","onUpdate:modelValue":E[3]||(E[3]=T=>r.max_zip_file_size=T),disabled:!g(n)},null,8,MBe),[[et,r.max_zip_file_size]])]),f("label",WBe,[G(R(l.$t("admin.APP_CONFIG.MAX_FILES_IN_ZIP_LABEL"))+": ",1),ke(f("input",{id:"gpx_limit_import",name:"gpx_limit_import",type:"number",min:"0","onUpdate:modelValue":E[4]||(E[4]=T=>r.gpx_limit_import=T),disabled:!g(n)},null,8,zBe),[[et,r.gpx_limit_import]])]),f("label",xBe,[G(R(l.$t("admin.APP_CONFIG.STATS_WORKOUTS_LIMIT_LABEL"))+": ",1),ke(f("input",{id:"stats_workouts_limit",name:"stats_workouts_limit",type:"number",min:"0","onUpdate:modelValue":E[5]||(E[5]=T=>r.stats_workouts_limit=T),disabled:!g(n)},null,8,FBe),[[et,r.stats_workouts_limit]])]),f("div",BBe,[f("span",GBe,[E[11]||(E[11]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(l.$t("admin.APP_CONFIG.STATS_WORKOUTS_LIMIT_HELP")),1)])]),f("label",HBe,R(l.$t("admin.ABOUT.TEXT"))+": ",1),f("span",VBe,R(l.$t("admin.ABOUT.DESCRIPTION")),1),g(n)?ke((N(),C("textarea",{key:0,id:"about",name:"about",rows:"10","onUpdate:modelValue":E[6]||(E[6]=T=>r.about=T)},null,512)),[[et,r.about]]):(N(),C("div",{key:1,innerHTML:r.about?g(Pi)(r.about):l.$t("admin.NO_TEXT_ENTERED"),class:"textarea-content"},null,8,qBe)),f("label",KBe,R(Be(l.$t("privacy_policy.TITLE")))+": ",1),f("span",jBe,R(l.$t("admin.PRIVACY_POLICY_DESCRIPTION")),1),g(n)?ke((N(),C("textarea",{key:2,id:"privacy_policy",name:"privacy_policy",rows:"20","onUpdate:modelValue":E[7]||(E[7]=T=>r.privacy_policy=T)},null,512)),[[et,r.privacy_policy]]):(N(),C("div",{key:3,innerHTML:r.privacy_policy?g(Pi)(r.privacy_policy):l.$t("admin.NO_TEXT_ENTERED"),class:"textarea-content"},null,8,YBe)),i.value?(N(),j(d,{key:4,message:i.value},null,8,["message"])):M("",!0),g(n)?(N(),C("div",XBe,[f("button",QBe,R(l.$t("buttons.SUBMIT")),1),f("button",{class:"cancel",onClick:De(u,["prevent"])},R(l.$t("buttons.CANCEL")),1)])):(N(),C("div",ZBe,[f("button",{class:"confirm",onClick:E[8]||(E[8]=De(T=>l.$router.push("/admin/application/edit"),["prevent"]))},R(l.$t("buttons.EDIT")),1),f("button",{class:"cancel",onClick:E[9]||(E[9]=De(T=>l.$router.push("/admin"),["prevent"]))},R(l.$t("admin.BACK_TO_ADMIN")),1)]))],32)]),_:1})])}}}),U_=ue(JBe,[["__scopeId","data-v-e3ac3fa7"]]),eGe=(e,t)=>{const n=e.translatedLabel.toLowerCase(),a=t.translatedLabel.toLowerCase();return n>a?1:ne.map(n=>({...n,translatedLabel:t(`equipment_types.${n.label}.LABEL`)})).sort(eGe),gp=(e,t)=>{const n=e.label.toLowerCase(),a=t.label.toLowerCase();return n>a?1:na?e.filter(r=>BA[r.equipment_type.label].includes(a.label)).filter(r=>n=="all"?!0:n=="withIncludedIds"&&s.includes(r.id)||r.is_active).map(r=>({...r,label:r.is_active?r.label:`${r.label} (${t("common.INACTIVE")})`})).sort(gp):[],tGe={id:"admin-equipment-types",class:"admin-card"},nGe={class:"responsive-table"},aGe={class:"text-left"},sGe={class:"text-left equipment-type-action"},rGe={class:"text-center"},iGe={class:"cell-heading"},oGe={class:"equipment-type-label"},uGe={class:"cell-heading"},lGe={class:"text-center"},cGe={class:"cell-heading"},dGe={class:"equipment-type-action"},EGe={class:"cell-heading"},pGe={class:"action-button"},fGe=["onClick"],mGe={key:0,class:"has-equipments"},TGe=ae({__name:"AdminEquipmentTypes",setup(e){const{t}=Ct(),n=Ue(),a=z(()=>FA(n.getters[We.GETTERS.EQUIPMENT_TYPES],t)),s=z(()=>n.getters[V.GETTERS.ERROR_MESSAGES]);ft(()=>r());function r(){n.dispatch(We.ACTIONS.GET_EQUIPMENT_TYPES)}function i(o,u){n.dispatch(We.ACTIONS.UPDATE_EQUIPMENT_TYPE,{id:o,isActive:u})}return(o,u)=>{const c=ie("EquipmentTypeImage"),l=ie("ErrorMessage"),E=ie("Card");return N(),C("div",tGe,[x(E,null,{title:le(()=>[G(R(o.$t("admin.EQUIPMENT_TYPES.TITLE")),1)]),content:le(()=>[f("button",{class:"top-button",onClick:u[0]||(u[0]=De(d=>o.$router.push("/admin"),["prevent"]))},R(o.$t("admin.BACK_TO_ADMIN")),1),f("div",nGe,[f("table",null,[f("thead",null,[f("tr",null,[u[2]||(u[2]=f("th",null,"#",-1)),f("th",null,R(o.$t("admin.EQUIPMENT_TYPES.TABLE.IMAGE")),1),f("th",aGe,R(o.$t("admin.EQUIPMENT_TYPES.TABLE.LABEL")),1),f("th",null,R(o.$t("admin.EQUIPMENT_TYPES.TABLE.ACTIVE")),1),f("th",sGe,R(o.$t("admin.ACTION")),1)])]),f("tbody",null,[(N(!0),C(_e,null,$e(a.value,d=>(N(),C("tr",{key:d.id},[f("td",rGe,[u[3]||(u[3]=f("span",{class:"cell-heading"},"id",-1)),G(" "+R(d.id),1)]),f("td",null,[f("span",iGe,R(o.$t("admin.EQUIPMENT_TYPES.TABLE.IMAGE")),1),x(c,{title:d.translatedLabel,"equipment-type-label":d.label},null,8,["title","equipment-type-label"])]),f("td",oGe,[f("span",uGe,R(o.$t("admin.EQUIPMENT_TYPES.TABLE.LABEL")),1),G(" "+R(d.translatedLabel),1)]),f("td",lGe,[f("span",cGe,R(o.$t("admin.EQUIPMENT_TYPES.TABLE.ACTIVE")),1),f("i",{class:Te(`fa fa${d.is_active?"-check":""}`),"aria-hidden":"true"},null,2)]),f("td",dGe,[f("span",EGe,R(o.$t("admin.ACTION")),1),f("div",pGe,[f("button",{class:Te({danger:d.is_active}),onClick:p=>i(d.id,!d.is_active)},R(o.$t(`buttons.${d.is_active?"DIS":"EN"}ABLE`)),11,fGe),d.has_equipments?(N(),C("span",mGe,[u[4]||(u[4]=f("i",{class:"fa fa-warning","aria-hidden":"true"},null,-1)),G(" "+R(o.$t("admin.EQUIPMENT_TYPES.TABLE.HAS_EQUIPMENTS")),1)])):M("",!0)])])]))),128))])]),s.value?(N(),j(l,{key:0,message:s.value},null,8,["message"])):M("",!0),f("button",{onClick:u[1]||(u[1]=De(d=>o.$router.push("/admin"),["prevent"]))},R(o.$t("admin.BACK_TO_ADMIN")),1)])]),_:1})])}}}),_Ge=ue(TGe,[["__scopeId","data-v-d329b43d"]]),hGe={class:"stat-card"},SGe={class:"stat-content box"},AGe={class:"stat-icon"},OGe={class:"stat-details"},gGe={class:"stat-huge"},IGe={class:"stat"},$a=ae({__name:"StatCard",props:{icon:{},text:{},value:{}},setup(e){const t=e,{icon:n,text:a,value:s}=he(t);return(r,i)=>(N(),C("div",hGe,[f("div",SGe,[f("div",AGe,[f("i",{class:Te(["fa",`fa-${g(n)}`])},null,2)]),f("div",OGe,[f("div",gGe,R(g(s)),1),f("div",IGe,R(g(a)),1)])])]))}}),RGe={id:"user-stats"},NGe=ae({__name:"AppStatsCards",props:{appStatistics:{}},setup(e){const t=e,{appStatistics:n}=he(t),a=z(()=>uA(n.value.uploads_dir_size));return(s,r)=>(N(),C("div",RGe,[x($a,{icon:"users",value:g(n).users,text:s.$t("admin.USER",g(n).users)},null,8,["value","text"]),x($a,{icon:"tags",value:g(n).sports,text:s.$t("workouts.SPORT",g(n).sports)},null,8,["value","text"]),x($a,{icon:"calendar",value:g(n).workouts,text:s.$t("workouts.WORKOUT",g(n).workouts)},null,8,["value","text"]),x($a,{icon:"folder-open",value:a.value.size,text:a.value.suffix},null,8,["value","text"])]))}}),vGe={id:"admin-menu",class:"center-card"},bGe={class:"admin-menu description-list"},CGe={class:"application-config-details"},DGe={class:"registration-status"},PGe={key:0,class:"email-sending-status"},LGe=ae({__name:"AdminMenu",props:{appConfig:{},appStatistics:{default:()=>({})}},setup(e){const t=e,{appConfig:n,appStatistics:a}=he(t);return mt(()=>{const s=document.getElementById("adminLink");s&&s.focus()}),(s,r)=>{const i=ie("router-link");return N(),C("div",vGe,[x(yS,null,{title:le(()=>[G(R(s.$t("admin.ADMINISTRATION")),1)]),content:le(()=>[x(NGe,{appStatistics:g(a)},null,8,["appStatistics"]),f("div",bGe,[f("dl",null,[f("dt",null,[x(i,{id:"adminLink",to:"/admin/application"},{default:le(()=>[G(R(s.$t("admin.APPLICATION")),1)]),_:1})]),f("dd",CGe,[G(R(s.$t("admin.UPDATE_APPLICATION_DESCRIPTION")),1),r[1]||(r[1]=f("br",null,null,-1)),f("span",DGe,R(s.$t(`admin.REGISTRATION_${g(n).is_registration_enabled?"ENABLED":"DISABLED"}`)),1),g(n).is_email_sending_enabled?M("",!0):(N(),C("span",PGe,[r[0]||(r[0]=f("i",{class:"fa fa-exclamation-triangle","aria-hidden":"true"},null,-1)),G(" "+R(s.$t("admin.EMAIL_SENDING_DISABLED")),1)]))]),f("dt",null,[x(i,{to:"/admin/equipment-types"},{default:le(()=>[G(R(Be(s.$t("equipments.EQUIPMENT_TYPE",0))),1)]),_:1})]),f("dd",null,R(s.$t("admin.ENABLE_DISABLE_EQUIPMENT_TYPES")),1),f("dt",null,[x(i,{to:"/admin/sports"},{default:le(()=>[G(R(Be(s.$t("workouts.SPORT",0))),1)]),_:1})]),f("dd",null,R(s.$t("admin.ENABLE_DISABLE_SPORTS")),1),f("dt",null,[x(i,{to:"/admin/users"},{default:le(()=>[G(R(Be(s.$t("admin.USER",0))),1)]),_:1})]),f("dd",null,R(s.$t("admin.ADMIN_RIGHTS_DELETE_USER_ACCOUNT")),1)])])]),_:1})])}}}),yGe=ue(LGe,[["__scopeId","data-v-69570181"]]),Ip={"Cycling (Sport)":"#4c9792","Cycling (Trekking)":"#a8af88","Cycling (Transport)":"#88af98","Cycling (Virtual)":"#64a360",Hiking:"#bb757c","Mountain Biking":"#d4b371","Mountain Biking (Electric)":"#fc9d6f",Mountaineering:"#48b3b7","Open Water Swimming":"#4058a4",Paragliding:"#c23c50",Rowing:"#fcce72",Running:"#835b83","Skiing (Alpine)":"#67a4bd","Skiing (Cross Country)":"#9498d0",Snowshoes:"#5780a8",Swimrun:"#3d9fc9",Trail:"#09a98a",Walking:"#838383"},$Ge=e=>{const t={};return e.map(n=>t[n.id]=n.color?n.color:Ip[n.label]),t},kGe=(e,t)=>{const n=e.translatedLabel.toLowerCase(),a=t.translatedLabel.toLowerCase();return n>a?1:ne.filter(s=>n==="all"?!0:a.includes(s.id)||s[n]).map(s=>({...s,translatedLabel:t(`sports.${s.label}.LABEL`)})).sort(kGe),Rp=(e,t)=>t.filter(n=>n.id===e.sport_id).map(n=>n.label)[0],Np=(e,t)=>t.filter(n=>n.id===e.sport_id).map(n=>n.color)[0],UGe={id:"admin-sports",class:"admin-card"},wGe={class:"responsive-table"},MGe={class:"text-left"},WGe={class:"text-left sport-action"},zGe={class:"text-center"},xGe={class:"cell-heading"},FGe={class:"sport-label"},BGe={class:"cell-heading"},GGe={class:"text-center"},HGe={class:"cell-heading"},VGe={class:"sport-action"},qGe={class:"cell-heading"},KGe={class:"action-button"},jGe=["onClick"],YGe={key:0,class:"has-workouts"},XGe=ae({__name:"AdminSports",setup(e){const{t}=Ct(),n=Ue(),a=z(()=>Hn(n.getters[vt.GETTERS.SPORTS],t)),s=z(()=>n.getters[V.GETTERS.ERROR_MESSAGES]);ft(()=>n.dispatch(vt.ACTIONS.GET_SPORTS,!0));function r(i,o){n.dispatch(vt.ACTIONS.UPDATE_SPORTS,{id:i,isActive:o})}return(i,o)=>{const u=ie("SportImage"),c=ie("ErrorMessage"),l=ie("Card");return N(),C("div",UGe,[x(l,null,{title:le(()=>[G(R(i.$t("admin.SPORTS.TITLE")),1)]),content:le(()=>[f("button",{class:"top-button",onClick:o[0]||(o[0]=De(E=>i.$router.push("/admin"),["prevent"]))},R(i.$t("admin.BACK_TO_ADMIN")),1),f("div",wGe,[f("table",null,[f("thead",null,[f("tr",null,[o[2]||(o[2]=f("th",null,"#",-1)),f("th",null,R(i.$t("admin.SPORTS.TABLE.IMAGE")),1),f("th",MGe,R(i.$t("admin.SPORTS.TABLE.LABEL")),1),f("th",null,R(i.$t("admin.SPORTS.TABLE.ACTIVE")),1),f("th",WGe,R(i.$t("admin.ACTION")),1)])]),f("tbody",null,[(N(!0),C(_e,null,$e(a.value,E=>(N(),C("tr",{key:E.id},[f("td",zGe,[o[3]||(o[3]=f("span",{class:"cell-heading"},"id",-1)),G(" "+R(E.id),1)]),f("td",null,[f("span",xGe,R(i.$t("admin.SPORTS.TABLE.IMAGE")),1),x(u,{title:E.translatedLabel,"sport-label":E.label,color:E.color},null,8,["title","sport-label","color"])]),f("td",FGe,[f("span",BGe,R(i.$t("admin.SPORTS.TABLE.LABEL")),1),G(" "+R(E.translatedLabel),1)]),f("td",GGe,[f("span",HGe,R(i.$t("admin.SPORTS.TABLE.ACTIVE")),1),f("i",{class:Te(`fa fa${E.is_active?"-check":""}`),"aria-hidden":"true"},null,2)]),f("td",VGe,[f("span",qGe,R(i.$t("admin.ACTION")),1),f("div",KGe,[f("button",{class:Te({danger:E.is_active}),onClick:d=>r(E.id,!E.is_active)},R(i.$t(`buttons.${E.is_active?"DIS":"EN"}ABLE`)),11,jGe),E.has_workouts?(N(),C("span",YGe,[o[4]||(o[4]=f("i",{class:"fa fa-warning","aria-hidden":"true"},null,-1)),G(" "+R(i.$t("admin.SPORTS.TABLE.HAS_WORKOUTS")),1)])):M("",!0)])])]))),128))])]),s.value?(N(),j(c,{key:0,message:s.value},null,8,["message"])):M("",!0),f("button",{onClick:o[1]||(o[1]=De(E=>i.$router.push("/admin"),["prevent"]))},R(i.$t("admin.BACK_TO_ADMIN")),1)])]),_:1})])}}}),QGe=ue(XGe,[["__scopeId","data-v-56f637e8"]]),ZGe={class:"table-selects"},JGe=["value"],eHe=["value"],tHe=["value"],nHe=["value"],aHe=["value"],sHe=["value"],rHe=ae({__name:"FilterSelects",props:{order_by:{},query:{},sort:{},message:{}},emits:["updateSelect"],setup(e,{emit:t}){const n=e,a=t,{order_by:s,query:r,sort:i,message:o}=he(n),u=[10,25,50,100];function c(l){a("updateSelect",l.target.id,l.target.value)}return(l,E)=>(N(),C("div",ZGe,[f("label",null,[G(R(l.$t("common.SELECTS.ORDER_BY.LABEL"))+": ",1),f("select",{name:"order_by",id:"order_by",value:g(r).order_by,onChange:c},[(N(!0),C(_e,null,$e(g(s),d=>(N(),C("option",{value:d,key:d},R(l.$t(`${g(o)}.${d.toUpperCase()}`)),9,eHe))),128))],40,JGe)]),f("label",null,[G(R(l.$t("common.SELECTS.ORDER.LABEL"))+": ",1),f("select",{name:"order",id:"order",value:g(r).order,onChange:c},[(N(!0),C(_e,null,$e(g(i),d=>(N(),C("option",{value:d,key:d},R(l.$t(`common.SELECTS.ORDER.${d.toUpperCase()}`)),9,nHe))),128))],40,tHe)]),f("label",null,[G(R(l.$t("common.SELECTS.PER_PAGE.LABEL"))+": ",1),f("select",{name:"per_page",id:"per_page",value:g(r).per_page,onChange:c},[(N(),C(_e,null,$e(u,d=>f("option",{value:d,key:d},R(d),9,sHe)),64))],40,aHe)])]))}}),HA=ue(rHe,[["__scopeId","data-v-fc86ab3c"]]),vp=["asc","desc"],VA=1,iHe=10,Zd=(e,t)=>e&&typeof e=="string"&&+e>0?+e:t,w_=(e,t,n)=>e&&typeof e=="string"&&t.includes(e)?e:n,Jd=(e,t,n,a)=>{const r=(a||{}).defaultSort||"asc",i={};return i.page=Zd(e.page,VA),i.per_page=Zd(e.per_page,iHe),i.order=w_(e.order,vp,r),i.order_by=w_(e.order_by,t,n),typeof e.q=="string"?i.q=e.q:delete i.q,typeof e.notes=="string"?i.notes=e.notes:delete i.notes,typeof e.description=="string"?i.description=e.description:delete i.description,i},oHe=["equipment_id","from","to","ave_speed_from","ave_speed_to","max_speed_from","max_speed_to","distance_from","distance_to","duration_from","duration_to","sport_id","title"],Ks=(e,t=1)=>Array.from({length:e-t+1},(n,a)=>t+a),uHe=(e,t)=>{if(e<0)return[];if(e<9)return Ks(e);let n=[1,2];return t<4?n=n.concat([3,4,5]):t<6?n=n.concat(Ks(t+2,3)):(n=n.concat(["..."]),t=e-2&&+n[n.length-1]{const u=ie("router-link");return N(),C("nav",lHe,[f("ul",cHe,[f("li",{class:Te(["page-prev",{disabled:!g(n).has_prev}])},[x(u,{class:"page-link",to:{path:g(a),query:r(g(n).page,-1)},disabled:!g(n).has_prev,tabindex:g(n).has_prev?0:-1},{default:le(({navigate:c})=>[Lt(i.$slots,"default",{onClick:l=>g(n).has_next?c:null},()=>[G(R(i.$t("common.PREVIOUS"))+" ",1),o[0]||(o[0]=f("i",{class:"fa fa-chevron-left","aria-hidden":"true"},null,-1))],!0)]),_:3},8,["to","disabled","tabindex"])],2),(N(!0),C(_e,null,$e(g(uHe)(g(n).pages,g(n).page),c=>(N(),C("li",{key:c,class:Te(["page",{active:c===g(n).page}])},[c==="..."?(N(),C("span",dHe," ... ")):(N(),j(u,{key:1,class:"page-link",to:{path:g(a),query:r(+c)}},{default:le(()=>[G(R(c),1)]),_:2},1032,["to"]))],2))),128)),f("li",{class:Te(["page-next",{disabled:!g(n).has_next}])},[x(u,{class:"page-link",to:{path:g(a),query:r(g(n).page,1)},disabled:!g(n).has_next,tabindex:g(n).has_next?0:-1},{default:le(({navigate:c})=>[Lt(i.$slots,"default",{onClick:l=>g(n).has_next?c:null},()=>[G(R(i.$t("common.NEXT"))+" ",1),o[1]||(o[1]=f("i",{class:"fa fa-chevron-right","aria-hidden":"true"},null,-1))],!0)]),_:3},8,["to","disabled","tabindex"])],2)])])}}}),Mu=ue(EHe,[["__scopeId","data-v-f1388e09"]]),pHe={class:"users-filters"},fHe={class:"search-username"},mHe=["placeholder"],THe=ae({__name:"UsersNameFilter",emits:["filterOnUsername"],setup(e,{emit:t}){const n=bt(),a=pe(n.query.q?n.query.q:""),s=t;function r(){a.value!==""&&s("filterOnUsername",a)}function i(){a.value="",s("filterOnUsername",a.value)}return(o,u)=>(N(),C("div",pHe,[f("div",fHe,[ke(f("input",{id:"username",name:"username","onUpdate:modelValue":u[0]||(u[0]=c=>a.value=c),onKeyup:Ye(r,["enter"]),placeholder:o.$t("user.FILTER_ON_USERNAME")},null,40,mHe),[[et,a.value,void 0,{trim:!0}]]),a.value!==""?(N(),C("i",{key:0,class:"fa fa-times","aria-hidden":"true",onClick:i})):M("",!0)]),f("i",{class:Te(["fa fa-search",{"fa-disabled":a.value===""}]),"aria-hidden":"true",onClick:r},null,2)]))}}),_He=ue(THe,[["__scopeId","data-v-553040c7"]]);var So={exports:{}},M_;function hHe(){return M_||(M_=1,function(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.default=n;function n(a){if(a===null||a===!0||a===!1)return NaN;var s=Number(a);return isNaN(s)?s:s<0?Math.ceil(s):Math.floor(s)}e.exports=t.default}(So,So.exports)),So.exports}var Ao={exports:{}},W_;function SHe(){return W_||(W_=1,function(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.default=n;function n(a){var s=new Date(Date.UTC(a.getFullYear(),a.getMonth(),a.getDate(),a.getHours(),a.getMinutes(),a.getSeconds(),a.getMilliseconds()));return s.setUTCFullYear(a.getFullYear()),a.getTime()-s.getTime()}e.exports=t.default}(Ao,Ao.exports)),Ao.exports}function AHe(e,t){var n=RHe(t);return n.formatToParts?gHe(n,e):IHe(n,e)}var OHe={year:0,month:1,day:2,hour:3,minute:4,second:5};function gHe(e,t){try{for(var n=e.formatToParts(t),a=[],s=0;s=0&&(a[r]=parseInt(n[s].value,10))}return a}catch(i){if(i instanceof RangeError)return[NaN];throw i}}function IHe(e,t){var n=e.format(t),a=/(\d+)\/(\d+)\/(\d+),? (\d+):(\d+):(\d+)/.exec(n);return[a[3],a[1],a[2],a[4],a[5],a[6]]}var Bc={};function RHe(e){if(!Bc[e]){var t=new Intl.DateTimeFormat("en-US",{hourCycle:"h23",timeZone:"America/New_York",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"}).format(new Date("2014-06-25T04:00:00.123Z")),n=t==="06/25/2014, 00:00:00"||t==="‎06‎/‎25‎/‎2014‎ ‎00‎:‎00‎:‎00";Bc[e]=n?new Intl.DateTimeFormat("en-US",{hourCycle:"h23",timeZone:e,year:"numeric",month:"numeric",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"}):new Intl.DateTimeFormat("en-US",{hour12:!1,timeZone:e,year:"numeric",month:"numeric",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"})}return Bc[e]}function qA(e,t,n,a,s,r,i){var o=new Date(0);return o.setUTCFullYear(e,t,n),o.setUTCHours(a,s,r,i),o}var z_=36e5,NHe=6e4,Gc={timezone:/([Z+-].*)$/,timezoneZ:/^(Z)$/,timezoneHH:/^([+-]\d{2})$/,timezoneHHMM:/^([+-])(\d{2}):?(\d{2})$/};function KA(e,t,n){var a,s;if(!e||(a=Gc.timezoneZ.exec(e),a))return 0;var r;if(a=Gc.timezoneHH.exec(e),a)return r=parseInt(a[1],10),x_(r)?-(r*z_):NaN;if(a=Gc.timezoneHHMM.exec(e),a){r=parseInt(a[2],10);var i=parseInt(a[3],10);return x_(r,i)?(s=Math.abs(r)*z_+i*NHe,a[1]==="+"?-s:s):NaN}if(CHe(e)){t=new Date(t||Date.now());var o=n?t:vHe(t),u=eE(o,e),c=n?u:bHe(t,u,e);return-c}return NaN}function vHe(e){return qA(e.getFullYear(),e.getMonth(),e.getDate(),e.getHours(),e.getMinutes(),e.getSeconds(),e.getMilliseconds())}function eE(e,t){var n=AHe(e,t),a=qA(n[0],n[1]-1,n[2],n[3]%24,n[4],n[5],0).getTime(),s=e.getTime(),r=s%1e3;return s-=r>=0?r:1e3+r,a-s}function bHe(e,t,n){var a=e.getTime(),s=a-t,r=eE(new Date(s),n);if(t===r)return t;s-=r-t;var i=eE(new Date(s),n);return r===i?r:Math.max(r,i)}function x_(e,t){return-23<=e&&e<=23&&(t==null||0<=t&&t<=59)}var F_={};function CHe(e){if(F_[e])return!0;try{return new Intl.DateTimeFormat(void 0,{timeZone:e}),F_[e]=!0,!0}catch{return!1}}var DHe=hHe();const PHe=uE(DHe);var LHe=SHe();const B_=uE(LHe);var yHe=/(Z|[+-]\d{2}(?::?\d{2})?| UTC| [a-zA-Z]+\/[a-zA-Z_]+(?:\/[a-zA-Z_]+)?)$/,Hc=36e5,G_=6e4,$He=2,dn={dateTimePattern:/^([0-9W+-]+)(T| )(.*)/,datePattern:/^([0-9W+-]+)(.*)/,plainTime:/:/,YY:/^(\d{2})$/,YYY:[/^([+-]\d{2})$/,/^([+-]\d{3})$/,/^([+-]\d{4})$/],YYYY:/^(\d{4})/,YYYYY:[/^([+-]\d{4})/,/^([+-]\d{5})/,/^([+-]\d{6})/],MM:/^-(\d{2})$/,DDD:/^-?(\d{3})$/,MMDD:/^-?(\d{2})-?(\d{2})$/,Www:/^-?W(\d{2})$/,WwwD:/^-?W(\d{2})-?(\d{1})$/,HH:/^(\d{2}([.,]\d*)?)$/,HHMM:/^(\d{2}):?(\d{2}([.,]\d*)?)$/,HHMMSS:/^(\d{2}):?(\d{2}):?(\d{2}([.,]\d*)?)$/,timeZone:yHe};function kHe(e,t){if(arguments.length<1)throw new TypeError("1 argument required, but only "+arguments.length+" present");if(e===null)return new Date(NaN);var n={},a=n.additionalDigits==null?$He:PHe(n.additionalDigits);if(a!==2&&a!==1&&a!==0)throw new RangeError("additionalDigits must be 0, 1 or 2");if(e instanceof Date||typeof e=="object"&&Object.prototype.toString.call(e)==="[object Date]")return new Date(e.getTime());if(typeof e=="number"||Object.prototype.toString.call(e)==="[object Number]")return new Date(e);if(!(typeof e=="string"||Object.prototype.toString.call(e)==="[object String]"))return new Date(NaN);var s=UHe(e),r=wHe(s.date,a),i=r.year,o=r.restDateString,u=MHe(o,i);if(isNaN(u))return new Date(NaN);if(u){var c=u.getTime(),l=0,E;if(s.time&&(l=WHe(s.time),isNaN(l)))return new Date(NaN);if(s.timeZone||n.timeZone){if(E=KA(s.timeZone||n.timeZone,new Date(c+l)),isNaN(E))return new Date(NaN)}else E=B_(new Date(c+l)),E=B_(new Date(c+l+E));return new Date(c+l+E)}else return new Date(NaN)}function UHe(e){var t={},n=dn.dateTimePattern.exec(e),a;if(n?(t.date=n[1],a=n[3]):(n=dn.datePattern.exec(e),n?(t.date=n[1],a=n[2]):(t.date=null,a=e)),a){var s=dn.timeZone.exec(a);s?(t.time=a.replace(s[1],""),t.timeZone=s[1].trim()):t.time=a}return t}function wHe(e,t){var n=dn.YYY[t],a=dn.YYYYY[t],s;if(s=dn.YYYY.exec(e)||a.exec(e),s){var r=s[1];return{year:parseInt(r,10),restDateString:e.slice(r.length)}}if(s=dn.YY.exec(e)||n.exec(e),s){var i=s[1];return{year:parseInt(i,10)*100,restDateString:e.slice(i.length)}}return{year:null}}function MHe(e,t){if(t===null)return null;var n,a,s,r;if(e.length===0)return a=new Date(0),a.setUTCFullYear(t),a;if(n=dn.MM.exec(e),n)return a=new Date(0),s=parseInt(n[1],10)-1,V_(t,s)?(a.setUTCFullYear(t,s),a):new Date(NaN);if(n=dn.DDD.exec(e),n){a=new Date(0);var i=parseInt(n[1],10);return FHe(t,i)?(a.setUTCFullYear(t,0,i),a):new Date(NaN)}if(n=dn.MMDD.exec(e),n){a=new Date(0),s=parseInt(n[1],10)-1;var o=parseInt(n[2],10);return V_(t,s,o)?(a.setUTCFullYear(t,s,o),a):new Date(NaN)}if(n=dn.Www.exec(e),n)return r=parseInt(n[1],10)-1,q_(t,r)?H_(t,r):new Date(NaN);if(n=dn.WwwD.exec(e),n){r=parseInt(n[1],10)-1;var u=parseInt(n[2],10)-1;return q_(t,r,u)?H_(t,r,u):new Date(NaN)}return null}function WHe(e){var t,n,a;if(t=dn.HH.exec(e),t)return n=parseFloat(t[1].replace(",",".")),Vc(n)?n%24*Hc:NaN;if(t=dn.HHMM.exec(e),t)return n=parseInt(t[1],10),a=parseFloat(t[2].replace(",",".")),Vc(n,a)?n%24*Hc+a*G_:NaN;if(t=dn.HHMMSS.exec(e),t){n=parseInt(t[1],10),a=parseInt(t[2],10);var s=parseFloat(t[3].replace(",","."));return Vc(n,a,s)?n%24*Hc+a*G_+s*1e3:NaN}return null}function H_(e,t,n){t=t||0,n=n||0;var a=new Date(0);a.setUTCFullYear(e,0,4);var s=a.getUTCDay()||7,r=t*7+n+1-s;return a.setUTCDate(a.getUTCDate()+r),a}var zHe=[31,28,31,30,31,30,31,31,30,31,30,31],xHe=[31,29,31,30,31,30,31,31,30,31,30,31];function jA(e){return e%400===0||e%4===0&&e%100!==0}function V_(e,t,n){if(t<0||t>11)return!1;if(n!=null){if(n<1)return!1;var a=jA(e);if(a&&n>xHe[t]||!a&&n>zHe[t])return!1}return!0}function FHe(e,t){if(t<1)return!1;var n=jA(e);return!(n&&t>366||!n&&t>365)}function q_(e,t,n){return!(t<0||t>52||n!=null&&(n<0||n>6))}function Vc(e,t,n){return!(e!=null&&(e<0||e>=25)||t!=null&&(t<0||t>=60)||n!=null&&(n<0||n>=60))}function BHe(e,t,n){var a=kHe(e,n),s=KA(t,a,!0),r=new Date(a.getTime()-s),i=new Date(0);return i.setFullYear(r.getUTCFullYear(),r.getUTCMonth(),r.getUTCDate()),i.setHours(r.getUTCHours(),r.getUTCMinutes(),r.getUTCSeconds(),r.getUTCMilliseconds()),i}const{locale:Wu}=Nr.global,GHe=(e,t,n)=>{switch(e){case"week":return ol(t,{weekStartsOn:n?1:0});case"year":return wE(t);case"month":return qi(t);default:throw new Error(`Invalid duration, expected: "week", "month", "year", got: "${e}"`)}},HHe=(e,t)=>{switch(e){case"week":return Hi(t,7);case"year":return nu(t,1);case"month":return dr(t,1);default:throw new Error(`Invalid duration, expected: "week", "month", "year", got: "${e}"`)}},Ol=(e,t)=>BHe(new Date(e),t),K_=(e,t)=>{const n=qi(e),a=Vi(e),s=t?1:0;return{start:ol(n,{weekStartsOn:s}),end:ME(a,{weekStartsOn:s})}},YA=(e,t=null,n=null)=>(t||(t="yyyy/MM/dd"),t=ds(t,Wu.value),n||(n="HH:mm"),{workout_date:mn(e,t,{locale:ks[Wu.value]}),workout_time:mn(e,n)}),VHe=["MM/dd/yyyy","dd/MM/yyyy","yyyy-MM-dd","date_string"],XA={bg:"d MMMM yyyy",cs:"d. MMM yyyy",de:"do MMM yyyy",en:"MMM. do, yyyy",es:"d MMM yyyy",eu:"yyyy MMM. d",fr:"d MMM yyyy",gl:"d MMM yyyy",it:"d MMM yyyy",nb:"do MMM yyyy",nl:"d MMM yyyy",pl:"d MMM yyyy",pt:"d MMM yyyy",ru:"d MMMM yyyy"},ds=(e,t)=>e==="date_string"?XA[t]:e,Vn=(e,t,n,a=!0,s=null,r=!1)=>{s||(s=Wu.value);const i=a?r?" HH:mm:ss":" HH:mm":"";return mn(Ol(e,t),`${ds(n,s)}${i}`,{locale:ks[s]})},qHe=(e,t,n=null)=>{const a=n||Wu.value,s=[];return VHe.map(r=>{const i=ds(r,a);s.push({label:`${i} - ${Vn(e,t,i,!1,a)}`,value:r})}),s},KHe={id:"admin-users",class:"admin-card"},jHe={key:0,class:"no-users"},YHe={key:1,class:"responsive-table"},XHe={class:"left-text"},QHe={class:"left-text"},ZHe={class:"left-text"},JHe={class:"cell-heading"},eVe={class:"cell-heading"},tVe={class:"cell-heading"},nVe={class:"cell-heading"},aVe={class:"text-center"},sVe={class:"cell-heading"},rVe={class:"text-center"},iVe={class:"cell-heading"},oVe={class:"text-center"},uVe={class:"cell-heading"},lVe={class:"text-center"},cVe={class:"cell-heading"},dVe=["disabled","onClick"],j_="created_at",EVe=ae({__name:"AdminUsers",setup(e){const t=Ue(),n=bt(),a=_a(),s=["is_active","admin","created_at","username","workouts_count"];let r=Kt(Jd(n.query,s,j_));const i=z(()=>t.getters[ee.GETTERS.AUTH_USER_PROFILE]),o=z(()=>t.getters[Fe.GETTERS.USERS]),u=z(()=>t.getters[Fe.GETTERS.USERS_PAGINATION]),c=z(()=>t.getters[V.GETTERS.ERROR_MESSAGES]);ft(()=>l(r));function l(T){t.dispatch(Fe.ACTIONS.GET_USERS,T)}function E(T){p("q",T.value)}function d(T,A){t.dispatch(Fe.ACTIONS.UPDATE_USER,{username:T,admin:A})}function p(T,A){r[T]=A,T==="per_page"&&(r.page=1),a.push({path:"/admin/users",query:r})}return ct(()=>{t.dispatch(Fe.ACTIONS.EMPTY_USERS)}),Me(()=>n.query,T=>{r=Jd(T,s,j_,{query:r}),l(r)}),(T,A)=>{const I=ie("router-link"),m=ie("ErrorMessage"),S=ie("Card");return N(),C("div",KHe,[x(S,null,{title:le(()=>[G(R(Be(T.$t("admin.USER",0))),1)]),content:le(()=>[f("button",{class:"top-button",onClick:A[0]||(A[0]=De(h=>T.$router.push("/admin"),["prevent"]))},R(T.$t("admin.BACK_TO_ADMIN")),1),x(_He,{onFilterOnUsername:E}),x(HA,{sort:g(vp),order_by:s,query:g(r),message:"admin.USERS.SELECTS.ORDER_BY",onUpdateSelect:p},null,8,["sort","query"]),o.value.length===0?(N(),C("div",jHe,R(T.$t("user.NO_USERS_FOUND")),1)):(N(),C("div",YHe,[f("table",null,[f("thead",null,[f("tr",null,[A[2]||(A[2]=f("th",null,"#",-1)),f("th",XHe,R(T.$t("user.USERNAME")),1),f("th",QHe,R(T.$t("user.EMAIL")),1),f("th",ZHe,R(T.$t("user.PROFILE.REGISTRATION_DATE")),1),f("th",null,R(Be(T.$t("workouts.WORKOUT",0))),1),f("th",null,R(T.$t("admin.ACTIVE")),1),f("th",null,R(T.$t("user.ADMIN")),1),f("th",null,R(T.$t("admin.ACTION")),1)])]),f("tbody",null,[(N(!0),C(_e,null,$e(o.value,h=>(N(),C("tr",{key:h.username},[f("td",null,[f("span",JHe,R(T.$t("user.PROFILE.PICTURE")),1),x(Bi,{user:h},null,8,["user"])]),f("td",null,[f("span",eVe,R(T.$t("user.USERNAME")),1),x(I,{to:`/admin/users/${h.username}`},{default:le(()=>[G(R(h.username),1)]),_:2},1032,["to"])]),f("td",null,[f("span",tVe,R(T.$t("user.EMAIL")),1),G(" "+R(h.email),1)]),f("td",null,[f("span",nVe,R(T.$t("user.PROFILE.REGISTRATION_DATE")),1),f("time",null,R(g(Vn)(h.created_at,i.value.timezone,i.value.date_format)),1)]),f("td",aVe,[f("span",sVe,R(Be(T.$t("workouts.WORKOUT",0))),1),G(" "+R(h.nb_workouts),1)]),f("td",rVe,[f("span",iVe,R(T.$t("admin.ACTIVE")),1),f("i",{class:Te(`fa fa${h.is_active?"-check":""}-square-o`),"aria-hidden":"true"},null,2)]),f("td",oVe,[f("span",uVe,R(T.$t("user.ADMIN")),1),f("i",{class:Te(`fa fa${h.admin?"-check":""}-square-o`),"aria-hidden":"true"},null,2)]),f("td",lVe,[f("span",cVe,R(T.$t("admin.ACTION")),1),f("button",{class:Te({danger:h.admin}),disabled:h.username===i.value.username,onClick:_=>d(h.username,!h.admin)},R(T.$t(`admin.USERS.TABLE.${h.admin?"REMOVE":"ADD"}_ADMIN_RIGHTS`)),11,dVe)])]))),128))])]),u.value.page?(N(),j(Mu,{key:0,path:"/admin/users",pagination:u.value,query:g(r)},null,8,["pagination","query"])):M("",!0),c.value?(N(),j(m,{key:1,message:c.value},null,8,["message"])):M("",!0),f("button",{onClick:A[1]||(A[1]=De(h=>T.$router.push("/admin"),["prevent"]))},R(T.$t("admin.BACK_TO_ADMIN")),1)]))]),_:1})])}}}),pVe=ue(EVe,[["__scopeId","data-v-dc27c3d6"]]),fVe={class:"box user-header"},mVe={class:"user-details"},TVe={class:"user-name"},_Ve={class:"user-stats"},hVe={class:"user-stat"},SVe={class:"stat-number"},AVe={class:"stat-label"},OVe={class:"user-stat"},gVe={class:"stat-label"},IVe={class:"user-stat hide-small"},RVe={class:"stat-number"},NVe={class:"stat-label"},vVe=ae({__name:"UserHeader",props:{user:{}},setup(e){const t=e,{user:n}=he(t),a=Ue(),s=z(()=>a.getters[ee.GETTERS.AUTH_USER_PROFILE]);return(r,i)=>{const o=ie("Distance");return N(),C("div",fVe,[x(Bi,{user:g(n)},null,8,["user"]),f("div",mVe,[f("div",TVe,R(g(n).username),1),f("div",_Ve,[f("div",hVe,[f("span",SVe,R(g(n).nb_workouts),1),f("span",AVe,R(r.$t("workouts.WORKOUT",g(n).nb_workouts)),1)]),f("div",OVe,[x(o,{distance:g(n).total_distance,unitFrom:"km",digits:0,displayUnit:!1,useImperialUnits:s.value.imperial_units},null,8,["distance","useImperialUnits"]),f("span",gVe,R(s.value.imperial_units?"miles":"km"),1)]),f("div",IVe,[f("span",RVe,R(g(n).nb_sports),1),f("span",NVe,R(r.$t("workouts.SPORT",g(n).nb_sports)),1)])])])])}}}),QA=ue(vVe,[["__scopeId","data-v-3abb1646"]]),bVe={class:"profile-tabs"},CVe={class:"profile-tabs-links"},ZA=ae({__name:"UserProfileTabs",props:{tabs:{},selectedTab:{},edition:{type:Boolean}},setup(e){const t=e,{tabs:n,selectedTab:a}=he(t);mt(()=>{const r=document.getElementById(`tab-${n.value[0]}`);r&&r.focus()});function s(r){switch(r){case"ACCOUNT":case"PICTURE":case"PRIVACY-POLICY":return`/profile/edit/${r.toLocaleLowerCase()}`;case"APPS":case"EQUIPMENTS":case"PREFERENCES":case"SPORTS":return`/profile${t.edition?"/edit":""}/${r.toLocaleLowerCase()}`;default:case"PROFILE":return`/profile${t.edition?"/edit":""}`}}return(r,i)=>{const o=ie("router-link");return N(),C("div",bVe,[f("div",CVe,[(N(!0),C(_e,null,$e(g(n),u=>(N(),j(o,{class:Te(["profile-tab",{selected:u===g(a)}]),to:s(u),key:u},{default:le(()=>[G(R(r.$t(`user.PROFILE.TABS.${u}`)),1)]),_:2},1032,["class","to"]))),128))])])}}}),DVe={id:"user-profile"},PVe={class:"box"},LVe=ae({__name:"index",props:{user:{},tab:{}},setup(e){const t=e,{user:n,tab:a}=he(t),s=["PROFILE","PREFERENCES","SPORTS","EQUIPMENTS","APPS"];return(r,i)=>{const o=ie("router-view");return N(),C("div",DVe,[x(QA,{user:g(n)},null,8,["user"]),f("div",PVe,[x(ZA,{tabs:s,selectedTab:g(a),edition:!1},null,8,["selectedTab"]),x(o,{user:g(n)},null,8,["user"])])])}}}),yVe=ue(LVe,[["__scopeId","data-v-ab81f074"]]),$Ve={id:"user-infos",class:"description-list"},kVe={key:1,class:"info-box success-message"},UVe={key:4,class:"email-form form-box"},wVe={class:"form-items",for:"email"},MVe={class:"form-items",for:"email"},WVe={class:"form-buttons"},zVe={class:"confirm",type:"submit"},xVe={key:5},FVe={key:0},BVe={class:"user-bio"},GVe={key:0,class:"profile-buttons"},HVe={key:1,class:"profile-buttons"},VVe=ae({__name:"UserInfos",props:{user:{},fromAdmin:{type:Boolean,default:!1}},setup(e){const t=e,n=Ue(),{user:a,fromAdmin:s}=he(t),r=z(()=>n.getters[V.GETTERS.LANGUAGE]),i=z(()=>n.getters[ee.GETTERS.AUTH_USER_PROFILE]),o=z(()=>t.user.created_at?Vn(t.user.created_at,i.value.timezone,i.value.date_format):""),u=z(()=>t.user.birth_date?mn(new Date(t.user.birth_date),`${ds(i.value.date_format,r.value)}`,{locale:ks[r.value]}):""),c=z(()=>n.getters[Fe.GETTERS.USERS_IS_SUCCESS]),l=z(()=>n.getters[V.GETTERS.ERROR_MESSAGES]),E=z(()=>n.getters[V.GETTERS.APP_CONFIG]),d=pe(""),p=pe(!1),T=pe(!1),A=pe(""),I=pe("");function m(L){d.value=L,L!==""&&n.commit(Fe.MUTATIONS.UPDATE_IS_SUCCESS,!1)}function S(L){n.dispatch(Fe.ACTIONS.DELETE_USER_ACCOUNT,{username:L})}function h(L){I.value="password-reset",n.dispatch(Fe.ACTIONS.UPDATE_USER,{username:L,resetPassword:!0})}function _(L){n.dispatch(Fe.ACTIONS.UPDATE_USER,{username:L,activate:!0})}function O(){D(),A.value=a.value.email_to_confirm?a.value.email_to_confirm:"",T.value=!0,I.value="email-update"}function b(){A.value="",T.value=!1}function v(L){n.dispatch(Fe.ACTIONS.UPDATE_USER,{username:L,new_email:A.value})}function D(){n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),n.commit(Fe.MUTATIONS.UPDATE_IS_SUCCESS,!1),I.value=""}return ct(()=>D()),Me(()=>c.value,L=>{L&&(m(""),b())}),(L,U)=>{const $=ie("Modal"),W=ie("AlertMessage"),Y=ie("ErrorMessage");return N(),C("div",$Ve,[d.value?(N(),j($,{key:0,title:L.$t("common.CONFIRMATION"),message:d.value==="delete"?"admin.CONFIRM_USER_ACCOUNT_DELETION":"admin.CONFIRM_USER_PASSWORD_RESET",strongMessage:g(a).username,onConfirmAction:U[0]||(U[0]=te=>d.value==="delete"?S(g(a).username):h(g(a).username)),onCancelAction:U[1]||(U[1]=te=>m("")),onKeydown:U[2]||(U[2]=Ye(te=>m(""),["esc"]))},null,8,["title","message","strongMessage"])):M("",!0),c.value?(N(),C("div",kVe,R(L.$t(`admin.${I.value==="password-reset"?"PASSWORD_RESET":"USER_EMAIL_UPDATE"}_SUCCESSFUL`)),1)):M("",!0),g(a).is_active?M("",!0):(N(),j(W,{key:2,message:"user.THIS_USER_ACCOUNT_IS_INACTIVE"})),l.value?(N(),j(Y,{key:3,message:l.value},null,8,["message"])):M("",!0),T.value?(N(),C("div",UVe,[f("form",{class:Te({errors:p.value}),onSubmit:U[5]||(U[5]=De(te=>v(g(a).username),["prevent"]))},[f("label",wVe,[G(R(L.$t("admin.CURRENT_EMAIL"))+" ",1),ke(f("input",{id:"email",type:"email","onUpdate:modelValue":U[3]||(U[3]=te=>g(a).email=te),disabled:""},null,512),[[et,g(a).email]])]),f("label",MVe,[G(R(L.$t("admin.NEW_EMAIL"))+"* ",1),ke(f("input",{id:"new-email",type:"email",required:"","onUpdate:modelValue":U[4]||(U[4]=te=>A.value=te)},null,512),[[et,A.value]])]),f("div",WVe,[f("button",zVe,R(L.$t("buttons.SUBMIT")),1),f("button",{class:"cancel",onClick:De(b,["prevent"])},R(L.$t("buttons.CANCEL")),1)])],34)])):(N(),C("div",xVe,[f("dl",null,[f("dt",null,R(L.$t("user.PROFILE.REGISTRATION_DATE"))+":",1),f("dd",null,[f("time",null,R(o.value),1)]),f("dt",null,R(L.$t("user.PROFILE.FIRST_NAME"))+":",1),f("dd",null,R(g(a).first_name),1),f("dt",null,R(L.$t("user.PROFILE.LAST_NAME"))+":",1),f("dd",null,R(g(a).last_name),1),f("dt",null,R(L.$t("user.PROFILE.BIRTH_DATE"))+":",1),f("dd",null,[u.value?(N(),C("time",FVe,R(u.value),1)):M("",!0)]),f("dt",null,R(L.$t("user.PROFILE.LOCATION"))+":",1),f("dd",null,R(g(a).location),1),f("dt",null,R(L.$t("user.PROFILE.BIO"))+":",1),f("dd",BVe,R(g(a).bio),1)]),g(s)?(N(),C("div",GVe,[i.value.username!==g(a).username?(N(),C("button",{key:0,class:"danger",onClick:U[6]||(U[6]=De(te=>m("delete"),["prevent"]))},R(L.$t("admin.DELETE_USER")),1)):M("",!0),g(a).is_active?M("",!0):(N(),C("button",{key:1,onClick:U[7]||(U[7]=De(te=>_(g(a).username),["prevent"]))},R(L.$t("admin.ACTIVATE_USER_ACCOUNT")),1)),i.value.username!==g(a).username?(N(),C("button",{key:2,onClick:De(O,["prevent"])},R(L.$t("admin.UPDATE_USER_EMAIL")),1)):M("",!0),i.value.username!==g(a).username&&E.value.is_email_sending_enabled?(N(),C("button",{key:3,onClick:U[8]||(U[8]=De(te=>m("reset"),["prevent"]))},R(L.$t("admin.RESET_USER_PASSWORD")),1)):M("",!0),f("button",{onClick:U[9]||(U[9]=te=>L.$router.go(-1))},R(L.$t("buttons.BACK")),1)])):(N(),C("div",HVe,[f("button",{onClick:U[10]||(U[10]=te=>L.$router.push("/profile/edit"))},R(L.$t("user.PROFILE.EDIT")),1),f("button",{onClick:U[11]||(U[11]=te=>L.$router.push("/"))},R(L.$t("common.HOME")),1)]))]))])}}}),JA=ue(VVe,[["__scopeId","data-v-01368a7e"]]),qVe={id:"user-preferences",class:"description-list"},KVe={class:"preferences-section"},jVe={class:"preferences-section"},YVe={class:"info-box raw-speed-help"},XVe={class:"profile-buttons"},QVe=ae({__name:"UserPreferences",props:{user:{}},setup(e){const t=e,n=Ue(),a=z(()=>n.getters[V.GETTERS.LANGUAGE]),s=z(()=>t.user.language&&t.user.language in or?or[t.user.language]:or.en),r=z(()=>t.user.weekm?"MONDAY":"SUNDAY"),i=z(()=>t.user.timezone?t.user.timezone:"Europe/Paris"),o=z(()=>t.user.date_format?t.user.date_format:"MM/dd/yyyy"),u=z(()=>t.user.display_ascent?"DISPLAYED":"HIDDEN"),c=z(()=>t.user.use_dark_mode===!0?"DARK":t.user.use_dark_mode===!1?"LIGHT":"DEFAULT");return(l,E)=>(N(),C("div",qVe,[f("div",KVe,R(l.$t("user.PROFILE.INTERFACE")),1),f("dl",null,[f("dt",null,R(l.$t("user.PROFILE.LANGUAGE"))+":",1),f("dd",null,R(s.value),1),f("dt",null,R(l.$t("user.PROFILE.THEME_MODE.LABEL"))+":",1),f("dd",null,R(l.$t(`user.PROFILE.THEME_MODE.VALUES.${c.value}`)),1),f("dt",null,R(l.$t("user.PROFILE.TIMEZONE"))+":",1),f("dd",null,R(i.value),1),f("dt",null,R(l.$t("user.PROFILE.DATE_FORMAT"))+":",1),f("dd",null,R(g(ds)(o.value,a.value)),1),f("dt",null,R(l.$t("user.PROFILE.FIRST_DAY_OF_WEEK"))+":",1),f("dd",null,R(l.$t(`user.PROFILE.${r.value}`)),1)]),f("div",jVe,R(l.$t("workouts.WORKOUT",0)),1),f("dl",null,[f("dt",null,R(l.$t("user.PROFILE.UNITS.LABEL"))+":",1),f("dd",null,R(l.$t(`user.PROFILE.UNITS.${l.user.imperial_units?"IMPERIAL":"METRIC"}`)),1),f("dt",null,R(l.$t("user.PROFILE.ASCENT_DATA"))+":",1),f("dd",null,R(l.$t(`common.${u.value}`)),1),f("dt",null,R(l.$t("user.PROFILE.ELEVATION_CHART_START.LABEL"))+":",1),f("dd",null,R(l.$t(`user.PROFILE.ELEVATION_CHART_START.${l.user.start_elevation_at_zero?"ZERO":"MIN_ALT"}`)),1),f("dt",null,R(l.$t("user.PROFILE.USE_RAW_GPX_SPEED.LABEL"))+":",1),f("dd",null,R(l.$t(`user.PROFILE.USE_RAW_GPX_SPEED.${l.user.use_raw_gpx_speed?"RAW_SPEED":"FILTERED_SPEED"}`)),1),f("div",YVe,[f("span",null,[E[2]||(E[2]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(l.$t("user.PROFILE.USE_RAW_GPX_SPEED.HELP")),1)])])]),f("div",XVe,[f("button",{onClick:E[0]||(E[0]=d=>l.$router.push("/profile/edit/preferences"))},R(l.$t("user.PROFILE.EDIT_PREFERENCES")),1),f("button",{onClick:E[1]||(E[1]=d=>l.$router.push("/"))},R(l.$t("common.HOME")),1)])]))}}),ZVe=ue(QVe,[["__scopeId","data-v-e641e7e8"]]),JVe={id:"user-profile-edition",class:"center-card"},eqe=ae({__name:"index",props:{user:{},tab:{}},setup(e){const t=e,{user:n,tab:a}=he(t),s=["PROFILE","ACCOUNT","PICTURE","PREFERENCES","SPORTS","EQUIPMENTS","PRIVACY-POLICY"];return(r,i)=>{const o=ie("router-view"),u=ie("Card");return N(),C("div",JVe,[x(u,null,{title:le(()=>[G(R(r.$t(`user.PROFILE.${g(a)}_EDITION`)),1)]),content:le(()=>[x(ZA,{tabs:s,selectedTab:g(a),edition:!0},null,8,["selectedTab"]),x(o,{user:g(n)},null,8,["user"])]),_:1})])}}}),ci=new Map,tqe=e=>{const{method:t,url:n,params:a={},data:s={}}=e;return[t,n,JSON.stringify(a),JSON.stringify(s)].join("")},Sr=e=>{const t=tqe(e);if(ci.has(t)){const n=ci.get(t)||{};n==null||n.abort(),ci.delete(t)}return t},Qa=Wt.create({baseURL:Fi()});Qa.interceptors.request.use(e=>{const t=new AbortController;e.signal=t.signal;const n=Sr(e);return ci.set(n,t),e},e=>Promise.reject(e));Qa.interceptors.response.use(e=>(Sr(e.config),e),e=>(e.message!=="canceled"&&e.response&&Sr(e.response.config),Promise.reject(e)));const eO=(e,t)=>{e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.delete(`users/${t.username}`).then(n=>{n.status===204?t.fromAdmin?it.push("/admin/users"):e.dispatch(ee.ACTIONS.LOGOUT).then(()=>it.push("/")):Ee(e,null)}).catch(n=>Ee(e,n))},nqe={[Fe.ACTIONS.EMPTY_USER](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Fe.MUTATIONS.UPDATE_USER,{})},[Fe.ACTIONS.EMPTY_USERS](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Fe.MUTATIONS.UPDATE_USERS,[]),e.commit(Fe.MUTATIONS.UPDATE_USERS_PAGINATION,{})},[Fe.ACTIONS.GET_USER](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Fe.MUTATIONS.UPDATE_USERS_LOADING,!0),ze.get(`users/${t}`).then(n=>{n.data.status==="success"?e.commit(Fe.MUTATIONS.UPDATE_USER,n.data.data.users[0]):Ee(e,null)}).catch(n=>Ee(e,n)).finally(()=>e.commit(Fe.MUTATIONS.UPDATE_USERS_LOADING,!1))},[Fe.ACTIONS.GET_USERS](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Fe.MUTATIONS.UPDATE_USERS_LOADING,!0),ze.get("users",{params:t}).then(n=>{n.data.status==="success"?(e.commit(Fe.MUTATIONS.UPDATE_USERS,n.data.data.users),e.commit(Fe.MUTATIONS.UPDATE_USERS_PAGINATION,n.data.pagination)):Ee(e,null)}).catch(n=>Ee(e,n)).finally(()=>e.commit(Fe.MUTATIONS.UPDATE_USERS_LOADING,!1))},[Fe.ACTIONS.UPDATE_USER](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Fe.MUTATIONS.UPDATE_IS_SUCCESS,!1);const n={};t.admin!==void 0&&(n.admin=t.admin),t.resetPassword&&(n.reset_password=t.resetPassword),t.activate&&(n.activate=t.activate),t.new_email!==void 0&&(n.new_email=t.new_email),ze.patch(`users/${t.username}`,n).then(a=>{a.data.status==="success"?(e.commit(Fe.MUTATIONS.UPDATE_USER_IN_USERS,a.data.data.users[0]),(t.resetPassword||t.new_email)&&e.commit(Fe.MUTATIONS.UPDATE_IS_SUCCESS,!0),(t.activate||t.new_email)&&e.commit(Fe.MUTATIONS.UPDATE_USER,a.data.data.users[0])):Ee(e,null)}).catch(a=>Ee(e,a)).finally(()=>e.commit(Fe.MUTATIONS.UPDATE_USERS_LOADING,!1))},[Fe.ACTIONS.DELETE_USER_ACCOUNT](e,t){eO(e,{username:t.username,fromAdmin:!0})}},Oo=e=>{localStorage.removeItem("authToken"),e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(yt.MUTATIONS.EMPTY_USER_STATS),e.commit(yt.MUTATIONS.EMPTY_USER_SPORT_STATS),e.commit(ee.MUTATIONS.CLEAR_AUTH_USER_TOKEN),e.commit(Fe.MUTATIONS.UPDATE_USERS,[]),e.commit(Oe.MUTATIONS.EMPTY_WORKOUTS),e.commit(Oe.MUTATIONS.EMPTY_WORKOUT),it.push("/login")},aqe={[ee.ACTIONS.CHECK_AUTH_USER](e){window.localStorage.authToken&&!e.getters[ee.GETTERS.IS_AUTHENTICATED]&&(e.commit(ee.MUTATIONS.UPDATE_AUTH_TOKEN,window.localStorage.authToken),e.dispatch(ee.ACTIONS.GET_USER_PROFILE,!0)),!window.localStorage.authToken&&e.getters[ee.GETTERS.IS_AUTHENTICATED]&&Oo(e)},[ee.ACTIONS.CONFIRM_ACCOUNT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),Qa.post("auth/account/confirm",{token:t.token}).then(n=>{if(n.data.status==="success"){const a=n.data.auth_token;window.localStorage.setItem("authToken",a),e.commit(ee.MUTATIONS.UPDATE_AUTH_TOKEN,a),e.dispatch(ee.ACTIONS.GET_USER_PROFILE).then(()=>it.push("/"))}else Ee(e,null)}).catch(n=>{Ee(e,n)})},[ee.ACTIONS.CONFIRM_EMAIL](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.UPDATE_IS_SUCCESS,!1),Qa.post("/auth/email/update",{token:t.token}).then(n=>{n.data.status==="success"?(e.commit(ee.MUTATIONS.UPDATE_IS_SUCCESS,!0),t.refreshUser&&e.dispatch(ee.ACTIONS.GET_USER_PROFILE).then(()=>it.push("/profile/edit/account")),it.push("/profile/edit/account")):Ee(e,null)}).catch(n=>{Ee(e,n)})},[ee.ACTIONS.GET_USER_PROFILE](e,t=!1){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get("auth/profile").then(n=>{if(n.data.status==="success"){const a=e.getters[ee.GETTERS.IS_PROFILE_NOT_LOADED];e.commit(ee.MUTATIONS.UPDATE_AUTH_USER_PROFILE,n.data.data),n.data.data.accepted_privacy_policy||e.dispatch(V.ACTIONS.GET_APPLICATION_PRIVACY_POLICY),(a||t)&&(n.data.data.language&&e.dispatch(V.ACTIONS.UPDATE_APPLICATION_LANGUAGE,n.data.data.language),e.commit(V.MUTATIONS.UPDATE_DARK_MODE,n.data.data.use_dark_mode)),e.dispatch(vt.ACTIONS.GET_SPORTS),e.dispatch(We.ACTIONS.GET_EQUIPMENTS),e.dispatch(We.ACTIONS.GET_EQUIPMENT_TYPES)}else Ee(e,null),Oo(e)}).catch(n=>{n.message!=="canceled"&&(Ee(e,n),Oo(e))})},[ee.ACTIONS.LOGIN_OR_REGISTER](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.UPDATE_IS_REGISTRATION_SUCCESS,!1),Qa.post(`/auth/${t.actionType}`,t.formData).then(n=>{if(n.data.status==="success")if(t.actionType==="login"){const a=n.data.auth_token;window.localStorage.setItem("authToken",a),e.commit(ee.MUTATIONS.UPDATE_AUTH_TOKEN,a),e.dispatch(ee.ACTIONS.GET_USER_PROFILE,!0).then(()=>it.push(typeof t.redirectUrl=="string"?t.redirectUrl:"/"))}else it.push("/login").then(()=>e.commit(ee.MUTATIONS.UPDATE_IS_REGISTRATION_SUCCESS,!0));else Ee(e,null)}).catch(n=>Ee(e,n))},[ee.ACTIONS.LOGOUT](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.post("auth/logout").then(t=>{t.data.status==="success"?Oo(e):Ee(e,null)}).catch(t=>Ee(e,t))},[ee.ACTIONS.UPDATE_USER_PROFILE](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!0),ze.post("auth/profile/edit",t).then(n=>{n.data.status==="success"?(e.commit(ee.MUTATIONS.UPDATE_AUTH_USER_PROFILE,n.data.data),it.push("/profile")):Ee(e,null)}).catch(n=>Ee(e,n)).finally(()=>e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!1))},[ee.ACTIONS.UPDATE_USER_ACCOUNT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!0),e.commit(ee.MUTATIONS.UPDATE_IS_SUCCESS,!1),ze.patch("auth/profile/edit/account",t).then(n=>{n.data.status==="success"?(e.commit(ee.MUTATIONS.UPDATE_AUTH_USER_PROFILE,n.data.data),e.commit(ee.MUTATIONS.UPDATE_IS_SUCCESS,!0)):Ee(e,null)}).catch(n=>Ee(e,n)).finally(()=>e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!1))},[ee.ACTIONS.UPDATE_USER_PREFERENCES](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!0),ze.post("auth/profile/edit/preferences",t).then(n=>{n.data.status==="success"?(e.commit(ee.MUTATIONS.UPDATE_AUTH_USER_PROFILE,n.data.data),e.commit(V.MUTATIONS.UPDATE_DARK_MODE,n.data.data.use_dark_mode),e.dispatch(V.ACTIONS.UPDATE_APPLICATION_LANGUAGE,n.data.data.language).then(()=>it.push("/profile/preferences"))):Ee(e,null)}).catch(n=>Ee(e,n)).finally(()=>e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!1))},[ee.ACTIONS.RESET_USER_SPORT_PREFERENCES](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!0),ze.delete(`auth/profile/reset/sports/${t.sportId}`).then(n=>{n.status===204?(e.dispatch(vt.ACTIONS.GET_SPORTS),t.fromSport&&it.push(`/profile/sports/${t.sportId}`)):Ee(e,null)}).catch(n=>{Ee(e,n),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!1)})},[ee.ACTIONS.UPDATE_USER_SPORT_PREFERENCES](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!0);const{fromSport:n,...a}=t;ze.post("auth/profile/edit/sports",a).then(s=>{s.data.status==="success"?(e.dispatch(vt.ACTIONS.GET_SPORTS),n&&it.push(`/profile/sports/${a.sport_id}`)):Ee(e,null)}).catch(s=>{Ee(e,s),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!1)})},[ee.ACTIONS.UPDATE_USER_PICTURE](e,t){if(e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!0),!t.picture)throw new Error("No file part");const n=new FormData;n.append("file",t.picture),ze.post("auth/picture",n,{headers:{"content-type":"multipart/form-data"}}).then(a=>{a.data.status==="success"?e.dispatch(ee.ACTIONS.GET_USER_PROFILE).then(()=>it.push("/profile")):Ee(e,null)}).catch(a=>Ee(e,a)).finally(()=>e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!1))},[ee.ACTIONS.DELETE_ACCOUNT](e,t){eO(e,t)},[ee.ACTIONS.DELETE_PICTURE](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!0),ze.delete("auth/picture").then(t=>{t.status===204?e.dispatch(ee.ACTIONS.GET_USER_PROFILE).then(()=>it.push("/profile")):Ee(e,null)}).catch(t=>Ee(e,t)).finally(()=>e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!1))},[ee.ACTIONS.SEND_PASSWORD_RESET_REQUEST](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),Qa.post("auth/password/reset-request",t).then(n=>{n.data.status==="success"?it.push("/password-reset/sent"):Ee(e,null)}).catch(n=>Ee(e,n))},[ee.ACTIONS.RESEND_ACCOUNT_CONFIRMATION_EMAIL](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),Qa.post("auth/account/resend-confirmation",t).then(n=>{n.data.status==="success"?it.push("/account-confirmation/email-sent"):Ee(e,null)}).catch(n=>Ee(e,n))},[ee.ACTIONS.RESET_USER_PASSWORD](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),Qa.post("auth/password/update",t).then(n=>{n.data.status==="success"?it.push("/password-reset/password-updated"):Ee(e,null)}).catch(n=>Ee(e,n))},[ee.ACTIONS.ACCEPT_PRIVACY_POLICY](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.post("auth/account/privacy-policy",{accepted_policy:t}).then(n=>{n.data.status==="success"?e.dispatch(ee.ACTIONS.GET_USER_PROFILE).then(()=>it.push("/profile")):Ee(e,null)}).catch(n=>Ee(e,n))},[ee.ACTIONS.REQUEST_DATA_EXPORT](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.post("auth/account/export/request").then(t=>{t.data.status==="success"?e.commit(ee.MUTATIONS.SET_EXPORT_REQUEST,t.data.request):Ee(e,null)}).catch(t=>Ee(e,t))},[ee.ACTIONS.GET_REQUEST_DATA_EXPORT](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get("auth/account/export").then(t=>{t.data.status==="success"?e.commit(ee.MUTATIONS.SET_EXPORT_REQUEST,t.data.request):Ee(e,null)}).catch(t=>Ee(e,t))}},sqe={[ee.GETTERS.AUTH_TOKEN]:e=>e.authToken,[ee.GETTERS.AUTH_USER_PROFILE]:e=>e.authUserProfile,[ee.GETTERS.EXPORT_REQUEST]:e=>e.exportRequest,[ee.GETTERS.IS_AUTHENTICATED]:e=>e.authToken!==null,[ee.GETTERS.IS_ADMIN]:e=>e.authUserProfile&&e.authUserProfile.admin,[ee.GETTERS.IS_REGISTRATION_SUCCESS]:e=>e.isRegistrationSuccess,[ee.GETTERS.IS_SUCCESS]:e=>e.isSuccess,[ee.GETTERS.USER_LOADING]:e=>e.loading,[ee.GETTERS.IS_PROFILE_NOT_LOADED]:e=>e.authUserProfile.username===void 0},rqe={[ee.MUTATIONS.CLEAR_AUTH_USER_TOKEN](e){e.authToken=null,e.authUserProfile={}},[ee.MUTATIONS.UPDATE_AUTH_TOKEN](e,t){e.authToken=t},[ee.MUTATIONS.UPDATE_AUTH_USER_PROFILE](e,t){e.authUserProfile=t},[ee.MUTATIONS.UPDATE_IS_REGISTRATION_SUCCESS](e,t){e.isRegistrationSuccess=t},[ee.MUTATIONS.UPDATE_IS_SUCCESS](e,t){e.isSuccess=t},[ee.MUTATIONS.UPDATE_USER_LOADING](e,t){e.loading=t},[ee.MUTATIONS.SET_EXPORT_REQUEST](e,t){e.exportRequest=t}},iqe={authToken:null,authUserProfile:{},isSuccess:!1,isRegistrationSuccess:!1,loading:!1,exportRequest:null},oqe={state:iqe,actions:aqe,getters:sqe,mutations:rqe},uqe={[We.ACTIONS.ADD_EQUIPMENT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.post("equipments",{description:t.description,equipment_type_id:t.equipmentTypeId,label:t.label,default_for_sport_ids:t.defaultForSportIds}).then(n=>{if(n.data.status==="created"){if(n.data.data.equipments.length>0){const a=n.data.data.equipments[0];e.commit(We.MUTATIONS.ADD_EQUIPMENT,a),it.push(`/profile/equipments/${a.id}`)}e.dispatch(vt.ACTIONS.GET_SPORTS),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!1)}else Ee(e,null)}).catch(n=>Ee(e,n))},[We.ACTIONS.DELETE_EQUIPMENT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.delete(`equipments/${t.id}${t.force?"?force":""}`).then(()=>{e.commit(We.MUTATIONS.REMOVE_EQUIPMENT,t.id),e.dispatch(vt.ACTIONS.GET_SPORTS),it.push("/profile/equipments")}).catch(n=>Ee(e,n))},[We.ACTIONS.GET_EQUIPMENT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get(`equipments/${t}`).then(n=>{n.data.status==="success"?n.data.data.equipments.length>0&&e.commit(We.MUTATIONS.UPDATE_EQUIPMENT,n.data.data.equipments[0]):Ee(e,null)}).catch(n=>Ee(e,n))},[We.ACTIONS.GET_EQUIPMENTS](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get("equipments").then(t=>{t.data.status==="success"?e.commit(We.MUTATIONS.SET_EQUIPMENTS,t.data.data.equipments):Ee(e,null)}).catch(t=>Ee(e,t))},[We.ACTIONS.GET_EQUIPMENT_TYPES](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get("equipment-types").then(t=>{t.data.status==="success"?(e.commit(We.MUTATIONS.SET_EQUIPMENT_TYPES,t.data.data.equipment_types),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!1)):Ee(e,null)}).catch(t=>Ee(e,t))},[We.ACTIONS.REFRESH_EQUIPMENT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(We.MUTATIONS.SET_LOADING,!0),ze.post(`equipments/${t}/refresh`).then(n=>{n.data.status==="success"?n.data.data.equipments.length>0&&(e.commit(We.MUTATIONS.UPDATE_EQUIPMENT,n.data.data.equipments[0]),it.push(`/profile/equipments/${t}`)):Ee(e,null)}).catch(n=>Ee(e,n)).finally(()=>e.commit(We.MUTATIONS.SET_LOADING,!1))},[We.ACTIONS.UPDATE_EQUIPMENT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(We.MUTATIONS.SET_LOADING,!0),ze.patch(`equipments/${t.id}`,{description:t.description,equipment_type_id:t.equipmentTypeId,is_active:t.isActive,label:t.label,default_for_sport_ids:t.defaultForSportIds}).then(n=>{n.data.status==="success"?n.data.data.equipments.length>0&&(e.commit(We.MUTATIONS.UPDATE_EQUIPMENT,n.data.data.equipments[0]),e.dispatch(vt.ACTIONS.GET_SPORTS),it.push(`/profile/equipments/${t.id}`)):Ee(e,null)}).catch(n=>Ee(e,n)).finally(()=>e.commit(We.MUTATIONS.SET_LOADING,!1))},[We.ACTIONS.UPDATE_EQUIPMENT_TYPE](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(We.MUTATIONS.SET_LOADING,!0),ze.patch(`equipment-types/${t.id}`,{is_active:t.isActive}).then(n=>{n.data.status==="success"?e.dispatch(We.ACTIONS.GET_EQUIPMENT_TYPES):Ee(e,null)}).catch(n=>Ee(e,n)).finally(()=>e.commit(We.MUTATIONS.SET_LOADING,!1))}},lqe={[We.GETTERS.EQUIPMENTS]:e=>e.equipments,[We.GETTERS.EQUIPMENT_TYPES]:e=>e.equipmentTypes,[We.GETTERS.LOADING]:e=>e.loading},cqe={[We.MUTATIONS.ADD_EQUIPMENT](e,t){e.equipments.push(t)},[We.MUTATIONS.REMOVE_EQUIPMENT](e,t){e.equipments=e.equipments.filter(n=>n.id!=t)},[We.MUTATIONS.SET_EQUIPMENTS](e,t){e.equipments=t},[We.MUTATIONS.SET_EQUIPMENT_TYPES](e,t){e.equipmentTypes=t},[We.MUTATIONS.SET_LOADING](e,t){e.loading=t},[We.MUTATIONS.UPDATE_EQUIPMENT](e,t){const n=e.equipments.findIndex(a=>a.id===t.id);n!==-1&&(e.equipments[n]=t)}},dqe={equipments:[],equipmentTypes:[],loading:!1},Eqe={state:dqe,actions:uqe,getters:lqe,mutations:cqe},Y_=(e,t)=>{e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get(t).then(n=>{n.data.status==="success"?e.commit(Ze.MUTATIONS.SET_CLIENT,n.data.data.client):Ee(e,null)}).catch(n=>Ee(e,n))},pqe={[Ze.ACTIONS.AUTHORIZE_CLIENT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES);const n=new FormData;n.set("client_id",t.client_id),n.set("response_type",t.response_type),n.set("scope",t.scope),n.set("confirm","true"),t.state&&n.set("state",t.state),t.code_challenge&&n.set("code_challenge",t.code_challenge),t.code_challenge_method&&n.set("code_challenge_method",t.code_challenge_method),ze.post("oauth/authorize",n,{headers:{"Content-Type":"multipart/form-data"}}).then(a=>{a.status==200&&a.data.redirect_url?window.location.href=a.data.redirect_url:Ee(e,null)}).catch(a=>Ee(e,a))},[Ze.ACTIONS.CREATE_CLIENT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.post("oauth/apps",t).then(n=>{n.data.status==="created"?(e.commit(Ze.MUTATIONS.SET_CLIENT,n.data.data.client),it.push(`/profile/apps/${n.data.data.client.id}/created`)):Ee(e,null)}).catch(n=>Ee(e,n))},[Ze.ACTIONS.DELETE_CLIENT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.delete(`oauth/apps/${t}`).then(n=>{n.status===204?e.dispatch(Ze.ACTIONS.GET_CLIENTS).then(()=>it.push("/profile/apps")):Ee(e,null)}).catch(n=>Ee(e,n))},[Ze.ACTIONS.GET_CLIENT_BY_CLIENT_ID](e,t){Y_(e,`oauth/apps/${t}`)},[Ze.ACTIONS.GET_CLIENT_BY_ID](e,t){Y_(e,`oauth/apps/${t}/by_id`)},[Ze.ACTIONS.GET_CLIENTS](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get("oauth/apps",{params:t}).then(n=>{n.data.status==="success"?(e.commit(Ze.MUTATIONS.SET_CLIENTS,n.data.data.clients),e.commit(Ze.MUTATIONS.SET_CLIENTS_PAGINATION,n.data.pagination)):Ee(e,null)}).catch(n=>Ee(e,n))},[Ze.ACTIONS.REVOKE_ALL_TOKENS](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Ze.MUTATIONS.SET_REVOCATION_SUCCESSFUL,!1),ze.post(`oauth/apps/${t}/revoke`).then(n=>{n.data.status==="success"?e.commit(Ze.MUTATIONS.SET_REVOCATION_SUCCESSFUL,!0):Ee(e,null)}).catch(n=>Ee(e,n))}},fqe={[Ze.GETTERS.CLIENT]:e=>e.client,[Ze.GETTERS.CLIENTS]:e=>e.clients,[Ze.GETTERS.CLIENTS_PAGINATION]:e=>e.pagination,[Ze.GETTERS.REVOCATION_SUCCESSFUL]:e=>e.revocationSuccessful},mqe={[Ze.MUTATIONS.SET_CLIENT](e,t){e.client=t},[Ze.MUTATIONS.EMPTY_CLIENT](e){e.client={}},[Ze.MUTATIONS.SET_CLIENTS](e,t){e.clients=t},[Ze.MUTATIONS.SET_CLIENTS_PAGINATION](e,t){e.pagination=t},[Ze.MUTATIONS.SET_REVOCATION_SUCCESSFUL](e,t){e.revocationSuccessful=t}},Tqe={client:{},clients:[],pagination:{},revocationSuccessful:!1},_qe={state:Tqe,actions:pqe,getters:fqe,mutations:mqe},{locale:hqe}=Nr.global,Sqe={[V.ACTIONS.GET_APPLICATION_CONFIG](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(V.MUTATIONS.UPDATE_APPLICATION_LOADING,!0),ze.get("config").then(t=>{t.data.status==="success"?e.commit(V.MUTATIONS.UPDATE_APPLICATION_CONFIG,t.data.data):Ee(e,null)}).catch(t=>Ee(e,t)).finally(()=>e.commit(V.MUTATIONS.UPDATE_APPLICATION_LOADING,!1))},[V.ACTIONS.GET_APPLICATION_STATS](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get("stats/all").then(t=>{t.data.status==="success"?e.commit(V.MUTATIONS.UPDATE_APPLICATION_STATS,t.data.data):Ee(e,null)}).catch(t=>Ee(e,t))},[V.ACTIONS.GET_APPLICATION_PRIVACY_POLICY](e){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get("config").then(t=>{t.data.status==="success"?e.commit(V.MUTATIONS.UPDATE_APPLICATION_PRIVACY_POLICY,t.data.data):Ee(e,null)}).catch(t=>Ee(e,t))},[V.ACTIONS.UPDATE_APPLICATION_CONFIG](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.patch("config",t).then(n=>{n.data.status==="success"?(e.commit(V.MUTATIONS.UPDATE_APPLICATION_CONFIG,n.data.data),it.push("/admin/application")):Ee(e,null)}).catch(n=>Ee(e,n))},[V.ACTIONS.UPDATE_APPLICATION_LANGUAGE](e,t){var n;(n=document.querySelector("html"))==null||n.setAttribute("lang",t),e.commit(V.MUTATIONS.UPDATE_LANG,t),hqe.value=t}},Aqe={[V.GETTERS.APP_CONFIG]:e=>e.application.config,[V.GETTERS.APP_LOADING]:e=>e.appLoading,[V.GETTERS.APP_STATS]:e=>e.application.statistics,[V.GETTERS.DARK_MODE]:e=>e.darkMode,[V.GETTERS.ERROR_MESSAGES]:e=>e.errorMessages,[V.GETTERS.LANGUAGE]:e=>e.language,[V.GETTERS.LOCALE]:e=>e.locale},Oqe={[V.MUTATIONS.EMPTY_ERROR_MESSAGES](e){e.errorMessages=null},[V.MUTATIONS.SET_ERROR_MESSAGES](e,t){e.errorMessages=t},[V.MUTATIONS.UPDATE_APPLICATION_CONFIG](e,t){e.application.config=t},[V.MUTATIONS.UPDATE_APPLICATION_LOADING](e,t){e.appLoading=t},[V.MUTATIONS.UPDATE_APPLICATION_PRIVACY_POLICY](e,t){e.application.config.privacy_policy=t.privacy_policy,e.application.config.privacy_policy_date=t.privacy_policy_date},[V.MUTATIONS.UPDATE_APPLICATION_STATS](e,t){e.application.statistics=t},[V.MUTATIONS.UPDATE_LANG](e,t){t in ks?(e.language=t,e.locale=ks[t]):(e.language="en",e.locale=Ki)},[V.MUTATIONS.UPDATE_DARK_MODE](e,t){e.darkMode=t}},gqe={root:!0,language:"en",locale:Ki,errorMessages:null,application:{statistics:{sports:0,uploads_dir_size:0,users:0,workouts:0}},appLoading:!1,darkMode:null},Iqe={[vt.ACTIONS.GET_SPORTS](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get(`sports${t?"?check_workouts=true":""}`).then(n=>{n.data.status==="success"?(e.commit(vt.MUTATIONS.SET_SPORTS,n.data.data.sports),e.commit(ee.MUTATIONS.UPDATE_USER_LOADING,!1)):Ee(e,null)}).catch(n=>Ee(e,n))},[vt.ACTIONS.UPDATE_SPORTS](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.patch(`sports/${t.id}`,{is_active:t.isActive}).then(n=>{n.data.status==="success"?e.dispatch(vt.ACTIONS.GET_SPORTS):Ee(e,null)}).catch(n=>Ee(e,n))}},Rqe={[vt.GETTERS.SPORTS]:e=>e.sports},Nqe={[vt.MUTATIONS.SET_SPORTS](e,t){e.sports=t}},vqe={sports:[]},bqe={state:vqe,actions:Iqe,getters:Rqe,mutations:Nqe},Cqe={[yt.ACTIONS.GET_USER_STATS](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get(`stats/${t.username}/by_time`,{params:t.params}).then(n=>{n.data.status==="success"?e.commit(yt.MUTATIONS.UPDATE_USER_STATS,n.data.data.statistics):Ee(e,null)}).catch(n=>Ee(e,n))},[yt.ACTIONS.GET_USER_SPORT_STATS](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(yt.MUTATIONS.UPDATE_STATS_LOADING,!0),ze.get(`stats/${t.username}/by_sport`,{params:{sport_id:t.sportId}}).then(n=>{n.data.status==="success"?(e.commit(yt.MUTATIONS.UPDATE_USER_SPORT_STATS,n.data.data.statistics),e.commit(yt.MUTATIONS.UPDATE_TOTAL_WORKOUTS,n.data.data.total_workouts)):Ee(e,null)}).catch(n=>Ee(e,n)).finally(()=>e.commit(yt.MUTATIONS.UPDATE_STATS_LOADING,!1))}},Dqe={[yt.GETTERS.USER_SPORT_STATS]:e=>e.sportStatistics,[yt.GETTERS.USER_STATS]:e=>e.statistics,[yt.GETTERS.STATS_LOADING]:e=>e.loading,[yt.GETTERS.TOTAL_WORKOUTS]:e=>e.totalWorkouts},Pqe={[yt.MUTATIONS.UPDATE_USER_STATS](e,t){e.statistics=t},[yt.MUTATIONS.EMPTY_USER_STATS](e){e.statistics={}},[yt.MUTATIONS.EMPTY_USER_SPORT_STATS](e){e.sportStatistics={},e.totalWorkouts=0},[yt.MUTATIONS.UPDATE_USER_SPORT_STATS](e,t){e.sportStatistics=t},[yt.MUTATIONS.UPDATE_STATS_LOADING](e,t){e.loading=t},[yt.MUTATIONS.UPDATE_TOTAL_WORKOUTS](e,t){e.totalWorkouts=t}},Lqe={statistics:{},sportStatistics:{},totalWorkouts:0,loading:!1},yqe={state:Lqe,actions:Cqe,getters:Dqe,mutations:Pqe},$qe={[Fe.GETTERS.USER]:e=>e.user,[Fe.GETTERS.USERS]:e=>e.users,[Fe.GETTERS.USERS_IS_SUCCESS]:e=>e.isSuccess,[Fe.GETTERS.USERS_LOADING]:e=>e.loading,[Fe.GETTERS.USERS_PAGINATION]:e=>e.pagination},kqe={[Fe.MUTATIONS.UPDATE_USER](e,t){e.user=t},[Fe.MUTATIONS.UPDATE_USER_IN_USERS](e,t){e.users=e.users.map(n=>n.username===t.username?t:n)},[Fe.MUTATIONS.UPDATE_USERS](e,t){e.users=t},[Fe.MUTATIONS.UPDATE_USERS_LOADING](e,t){e.loading=t},[Fe.MUTATIONS.UPDATE_USERS_PAGINATION](e,t){e.pagination=t},[Fe.MUTATIONS.UPDATE_IS_SUCCESS](e,t){e.isSuccess=t}},Uqe={user:{},users:[],loading:!1,isSuccess:!1,pagination:{}},wqe={state:Uqe,actions:nqe,getters:$qe,mutations:kqe},go=(e,t,n)=>{e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),ze.get("workouts",{params:t}).then(a=>{a.data.status==="success"?(e.commit(Oe.MUTATIONS[n],a.data.data.workouts),n===Ns.SET_USER_WORKOUTS&&e.commit(Oe.MUTATIONS.SET_WORKOUTS_PAGINATION,a.data.pagination)):Ee(e,null)}).catch(a=>Ee(e,a))},Mqe={[Oe.ACTIONS.GET_CALENDAR_WORKOUTS](e,t){e.commit(Oe.MUTATIONS.EMPTY_CALENDAR_WORKOUTS),go(e,t,Ns.SET_CALENDAR_WORKOUTS)},[Oe.ACTIONS.GET_USER_WORKOUTS](e,t){go(e,t,Ns.SET_USER_WORKOUTS)},[Oe.ACTIONS.GET_TIMELINE_WORKOUTS](e,t){go(e,t,Ns.SET_TIMELINE_WORKOUTS)},[Oe.ACTIONS.GET_MORE_TIMELINE_WORKOUTS](e,t){go(e,t,Ns.ADD_TIMELINE_WORKOUTS)},[Oe.ACTIONS.GET_WORKOUT_DATA](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Oe.MUTATIONS.SET_WORKOUT_LOADING,!0);const n=t.segmentId?`/segment/${t.segmentId}`:"";ze.get(`workouts/${t.workoutId}`).then(a=>{const s=a.data.data.workouts[0];if(a.data.status==="success"){if(t.segmentId&&(s.segments.length===0||!s.segments[+t.segmentId-1]))throw new Error("WORKOUT_NOT_FOUND");e.commit(Oe.MUTATIONS.SET_WORKOUT,a.data.data.workouts[0]),a.data.data.workouts[0].with_gpx&&(ze.get(`workouts/${t.workoutId}/chart_data${n}`).then(r=>{r.data.status==="success"&&e.commit(Oe.MUTATIONS.SET_WORKOUT_CHART_DATA,r.data.data.chart_data)}),ze.get(`workouts/${t.workoutId}/gpx${n}`).then(r=>{r.data.status==="success"&&e.commit(Oe.MUTATIONS.SET_WORKOUT_GPX,r.data.data.gpx)}))}else e.commit(Oe.MUTATIONS.EMPTY_WORKOUT),Ee(e,null)}).catch(a=>{e.commit(Oe.MUTATIONS.EMPTY_WORKOUT),Ee(e,a)}).finally(()=>e.commit(Oe.MUTATIONS.SET_WORKOUT_LOADING,!1))},[Oe.ACTIONS.DELETE_WORKOUT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Oe.MUTATIONS.SET_WORKOUT_LOADING,!0),ze.delete(`workouts/${t.workoutId}`).then(()=>{e.commit(Oe.MUTATIONS.EMPTY_WORKOUT),e.dispatch(ee.ACTIONS.GET_USER_PROFILE),it.push("/")}).catch(n=>{Ee(e,n)}).finally(()=>e.commit(Oe.MUTATIONS.SET_WORKOUT_LOADING,!1))},[Oe.ACTIONS.EDIT_WORKOUT](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Oe.MUTATIONS.SET_WORKOUT_LOADING,!0),ze.patch(`workouts/${t.workoutId}`,t.data).then(()=>{e.dispatch(ee.ACTIONS.GET_USER_PROFILE),e.dispatch(Oe.ACTIONS.GET_WORKOUT_DATA,{workoutId:t.workoutId}).then(()=>{it.push({name:"Workout",params:{workoutId:t.workoutId}})})}).catch(n=>{Ee(e,n)}).finally(()=>e.commit(Oe.MUTATIONS.SET_WORKOUT_LOADING,!1))},[Oe.ACTIONS.EDIT_WORKOUT_CONTENT](e,t){e.commit(Oe.MUTATIONS.SET_WORKOUT_CONTENT_LOADING,!0),e.commit(Oe.MUTATIONS.SET_WORKOUT_CONTENT_TYPE,t.contentType),e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES);const n={[t.contentType==="NOTES"?"notes":"description"]:t.content};ze.patch(`workouts/${t.workoutId}`,n).then(a=>{const s=a.data.data.workouts[0];e.commit(Oe.MUTATIONS.SET_WORKOUT_CONTENT,s)}).catch(a=>{Ee(e,a)}).finally(()=>e.commit(Oe.MUTATIONS.SET_WORKOUT_CONTENT_LOADING,!1))},[Oe.ACTIONS.ADD_WORKOUT](e,t){if(e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Oe.MUTATIONS.SET_WORKOUT_LOADING,!0),!t.file)throw new Error("No file part");const n=t.notes.replace(/"/g,'\\"'),a=t.description.replace(/"/g,'\\"'),s=t.title.replace(/"/g,'\\"'),r=new FormData;r.append("file",t.file),r.append("data",`{"sport_id": ${t.sport_id}, "notes": "${n}", "description": "${a}", "title": "${s}", "equipment_ids": [${t.equipment_ids.map(i=>`"${i}"`).join(",")}]}`),ze.post("workouts",r,{headers:{"content-type":"multipart/form-data"}}).then(i=>{if(i.data.status==="created"){e.dispatch(ee.ACTIONS.GET_USER_PROFILE);const o=i.data.data.workouts[0];it.push(i.data.data.workouts.length===1?`/workouts/${o.id}`:"/")}}).catch(i=>{Ee(e,i)}).finally(()=>e.commit(Oe.MUTATIONS.SET_WORKOUT_LOADING,!1))},[Oe.ACTIONS.ADD_WORKOUT_WITHOUT_GPX](e,t){e.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Oe.MUTATIONS.SET_WORKOUT_LOADING,!0),ze.post("workouts/no_gpx",t).then(n=>{if(n.data.status==="created"){e.dispatch(ee.ACTIONS.GET_USER_PROFILE);const a=n.data.data.workouts[0];it.push(`/workouts/${a.id}`)}}).catch(n=>{Ee(e,n)}).finally(()=>e.commit(Oe.MUTATIONS.SET_WORKOUT_LOADING,!1))}},Wqe={[Oe.GETTERS.CALENDAR_WORKOUTS]:e=>e.calendar_workouts,[Oe.GETTERS.TIMELINE_WORKOUTS]:e=>e.timeline_workouts,[Oe.GETTERS.USER_WORKOUTS]:e=>e.user_workouts,[Oe.GETTERS.WORKOUT_CONTENT_EDITION]:e=>e.workoutContent,[Oe.GETTERS.WORKOUT_DATA]:e=>e.workoutData,[Oe.GETTERS.WORKOUTS_PAGINATION]:e=>e.pagination},zqe={[Oe.MUTATIONS.ADD_TIMELINE_WORKOUTS](e,t){e.timeline_workouts=e.timeline_workouts.concat(t)},[Oe.MUTATIONS.SET_CALENDAR_WORKOUTS](e,t){e.calendar_workouts=t},[Oe.MUTATIONS.SET_TIMELINE_WORKOUTS](e,t){e.timeline_workouts=t},[Oe.MUTATIONS.SET_USER_WORKOUTS](e,t){e.user_workouts=t},[Oe.MUTATIONS.SET_WORKOUTS_PAGINATION](e,t){e.pagination=t},[Oe.MUTATIONS.SET_WORKOUT](e,t){e.workoutData.workout=t},[Oe.MUTATIONS.SET_WORKOUT_CHART_DATA](e,t){e.workoutData.chartData=t},[Oe.MUTATIONS.SET_WORKOUT_GPX](e,t){e.workoutData.gpx=t},[Oe.MUTATIONS.SET_WORKOUT_LOADING](e,t){e.workoutData.loading=t},[Oe.MUTATIONS.SET_WORKOUT_CONTENT](e,t){e.workoutData.workout=t},[Oe.MUTATIONS.SET_WORKOUT_CONTENT_LOADING](e,t){e.workoutContent.loading=t},[Oe.MUTATIONS.SET_WORKOUT_CONTENT_TYPE](e,t){e.workoutContent.contentType=t},[Oe.MUTATIONS.EMPTY_CALENDAR_WORKOUTS](e){e.calendar_workouts=[]},[Oe.MUTATIONS.EMPTY_WORKOUTS](e){e.calendar_workouts=[],e.user_workouts=[],e.timeline_workouts=[]},[Oe.MUTATIONS.EMPTY_WORKOUT](e){e.workoutData={gpx:"",loading:!1,workout:{},chartData:[]}}},xqe={calendar_workouts:[],timeline_workouts:[],pagination:{},user_workouts:[],workoutData:{gpx:"",loading:!1,workout:{},chartData:[]},workoutContent:{loading:!1,contentType:""}},Fqe={state:xqe,actions:Mqe,getters:Wqe,mutations:zqe},Bqe={authUserModule:oqe,equipmentModule:Eqe,oAuthModule:_qe,sportsModule:bqe,statsModule:yqe,usersModule:wqe,workoutsModule:Fqe},Gqe={state:gqe,actions:Sqe,getters:Aqe,mutations:Oqe,modules:Bqe},di=x2(Gqe),ze=Wt.create({baseURL:Fi()});ze.interceptors.request.use(e=>{const t=new AbortController;e.signal=t.signal;const n=Sr(e);ci.set(n,t);const a=di.getters[ee.GETTERS.AUTH_TOKEN];if(a){const s=`Bearer ${a}`;e.headers&&e.headers.Authorization!==s&&(e.headers.Authorization=`Bearer ${a}`)}return e},e=>Promise.reject(e));ze.interceptors.response.use(e=>(Sr(e.config),e),e=>(e.message!=="canceled"&&e.response&&Sr(e.response.config),Promise.reject(e)));const tE=(e,t)=>e.push.apply(e,t),Ar=e=>e.sort((t,n)=>t.i-n.i||t.j-n.j),X_=e=>{const t={};let n=1;return e.forEach(a=>{t[a]=n,n+=1}),t};var Hqe={4:[[1,2],[2,3]],5:[[1,3],[2,3],[2,4]],6:[[1,2],[2,4],[4,5]],7:[[1,3],[2,3],[4,5],[4,6]],8:[[2,4],[4,6]]};const Q_=2050,Z_=1e3,Vqe=Hqe,qqe=10,Kqe=1e4,tO=10,nO=50,aO=20,sO=/^[A-Z\xbf-\xdf][^A-Z\xbf-\xdf]+$/,jqe=/^[^A-Z\xbf-\xdf]+[A-Z\xbf-\xdf]$/,Yqe=/^[A-Z\xbf-\xdf]+$/,rO=/^[^a-z\xdf-\xff]+$/,Xqe=/^[a-z\xdf-\xff]+$/,Qqe=/^[^A-Z\xbf-\xdf]+$/,Zqe=/[a-z\xdf-\xff]/,Jqe=/[A-Z\xbf-\xdf]/,eKe=/[^A-Za-z\xbf-\xdf]/gi,tKe=/^\d+$/,bp=new Date().getFullYear(),nKe={recentYear:/19\d\d|200\d|201\d|202\d/g},iO=[" ",",",";",":","|","/","\\","_",".","-"],aKe=iO.length;class sKe{match({password:t}){const n=[...this.getMatchesWithoutSeparator(t),...this.getMatchesWithSeparator(t)],a=this.filterNoise(n);return Ar(a)}getMatchesWithSeparator(t){const n=[],a=/^(\d{1,4})([\s/\\_.-])(\d{1,2})\2(\d{1,4})$/;for(let s=0;s<=Math.abs(t.length-6);s+=1)for(let r=s+5;r<=s+9&&!(r>=t.length);r+=1){const i=t.slice(s,+r+1||9e9),o=a.exec(i);if(o!=null){const u=this.mapIntegersToDayMonthYear([parseInt(o[1],10),parseInt(o[3],10),parseInt(o[4],10)]);u!=null&&n.push({pattern:"date",token:i,i:s,j:r,separator:o[2],year:u.year,month:u.month,day:u.day})}}return n}getMatchesWithoutSeparator(t){const n=[],a=/^\d{4,8}$/,s=r=>Math.abs(r.year-bp);for(let r=0;r<=Math.abs(t.length-4);r+=1)for(let i=r+3;i<=r+7&&!(i>=t.length);i+=1){const o=t.slice(r,+i+1||9e9);if(a.exec(o)){const u=[],c=o.length;if(Vqe[c].forEach(([E,d])=>{const p=this.mapIntegersToDayMonthYear([parseInt(o.slice(0,E),10),parseInt(o.slice(E,d),10),parseInt(o.slice(d),10)]);p!=null&&u.push(p)}),u.length>0){let E=u[0],d=s(u[0]);u.slice(1).forEach(p=>{const T=s(p);T{let a=!1;const s=t.length;for(let r=0;r=n.j){a=!0;break}}return!a})}mapIntegersToDayMonthYear(t){if(t[1]>31||t[1]<=0)return null;let n=0,a=0,s=0;for(let r=0,i=t.length;r99&&oQ_)return null;o>31&&(a+=1),o>12&&(n+=1),o<=0&&(s+=1)}return a>=2||n===3||s>=2?null:this.getDayMonth(t)}getDayMonth(t){const n=[[t[2],t.slice(0,2)],[t[0],t.slice(1,3)]],a=n.length;for(let s=0;s=1&&r<=31&&i>=1&&i<=12)return{day:r,month:i}}return null}twoToFourDigitYear(t){return t>99?t:t>50?t+1900:t+2e3}}const Da=new Uint32Array(65536),rKe=(e,t)=>{const n=e.length,a=t.length,s=1<{const n=t.length,a=e.length,s=[],r=[],i=Math.ceil(n/32),o=Math.ceil(a/32);for(let T=0;T>>S&1,O=s[S/32|0]>>>S&1,b=h|T,v=((h|O)&A)+A^A|h|O;let D=T|~(v|A),L=A&v;D>>>31^_&&(r[S/32|0]^=1<>>31^O&&(s[S/32|0]^=1<>>T&1,m=s[T/32|0]>>>T&1,S=A|c,h=((A|m)&l)+l^l|A|m;let _=c|~(h|l),O=l&h;p+=_>>>a-1&1,p-=O>>>a-1&1,_>>>31^I&&(r[T/32|0]^=1<>>31^m&&(s[T/32|0]^=1<{if(e.length{const a=e.length<=t.length,s=e.length<=n;return a||s?Math.ceil(e.length/4):n},lKe=(e,t,n)=>{let a=0;const s=Object.keys(t).find(r=>{const i=uKe(e,r,n);if(Math.abs(e.length-r.length)>i)return!1;const o=oKe(e,r),u=o<=i;return u&&(a=o),u});return s?{levenshteinDistance:a,levenshteinDistanceEntry:s}:{}};var J_={a:["4","@"],b:["8"],c:["(","{","[","<"],d:["6","|)"],e:["3"],f:["#"],g:["6","9","&"],h:["#","|-|"],i:["1","!","|"],k:["<","|<"],l:["!","1","|","7"],m:["^^","nn","2n","/\\\\/\\\\"],n:["//"],o:["0","()"],q:["9"],u:["|_|"],s:["$","5"],t:["+","7"],v:["<",">","/"],w:["^/","uu","vv","2u","2v","\\\\/\\\\/"],x:["%","><"],z:["2"]},qc={warnings:{straightRow:"straightRow",keyPattern:"keyPattern",simpleRepeat:"simpleRepeat",extendedRepeat:"extendedRepeat",sequences:"sequences",recentYears:"recentYears",dates:"dates",topTen:"topTen",topHundred:"topHundred",common:"common",similarToCommon:"similarToCommon",wordByItself:"wordByItself",namesByThemselves:"namesByThemselves",commonNames:"commonNames",userInputs:"userInputs",pwned:"pwned"},suggestions:{l33t:"l33t",reverseWords:"reverseWords",allUppercase:"allUppercase",capitalization:"capitalization",dates:"dates",recentYears:"recentYears",associatedYears:"associatedYears",sequences:"sequences",repeated:"repeated",longerKeyboardPattern:"longerKeyboardPattern",anotherWord:"anotherWord",useWords:"useWords",noNeed:"noNeed",pwned:"pwned"},timeEstimation:{ltSecond:"ltSecond",second:"second",seconds:"seconds",minute:"minute",minutes:"minutes",hour:"hour",hours:"hours",day:"day",days:"days",month:"month",months:"months",year:"year",years:"years",centuries:"centuries"}};class Li{constructor(t=[]){this.parents=t,this.children=new Map}addSub(t,...n){const a=t.charAt(0);this.children.has(a)||this.children.set(a,new Li([...this.parents,a]));let s=this.children.get(a);for(let r=1;r(Object.entries(e).forEach(([n,a])=>{a.forEach(s=>{t.addSub(s,n)})}),t);class cKe{constructor(){this.matchers={},this.l33tTable=J_,this.trieNodeRoot=e0(J_,new Li),this.dictionary={userInputs:[]},this.rankedDictionaries={},this.rankedDictionariesMaxWordSize={},this.translations=qc,this.graphs={},this.useLevenshteinDistance=!1,this.levenshteinThreshold=2,this.l33tMaxSubstitutions=100,this.maxLength=256,this.setRankedDictionaries()}setOptions(t={}){t.l33tTable&&(this.l33tTable=t.l33tTable,this.trieNodeRoot=e0(t.l33tTable,new Li)),t.dictionary&&(this.dictionary=t.dictionary,this.setRankedDictionaries()),t.translations&&this.setTranslations(t.translations),t.graphs&&(this.graphs=t.graphs),t.useLevenshteinDistance!==void 0&&(this.useLevenshteinDistance=t.useLevenshteinDistance),t.levenshteinThreshold!==void 0&&(this.levenshteinThreshold=t.levenshteinThreshold),t.l33tMaxSubstitutions!==void 0&&(this.l33tMaxSubstitutions=t.l33tMaxSubstitutions),t.maxLength!==void 0&&(this.maxLength=t.maxLength)}setTranslations(t){if(this.checkCustomTranslations(t))this.translations=t;else throw new Error("Invalid translations object fallback to keys")}checkCustomTranslations(t){let n=!0;return Object.keys(qc).forEach(a=>{if(a in t){const s=a;Object.keys(qc[s]).forEach(r=>{r in t[s]||(n=!1)})}else n=!1}),n}setRankedDictionaries(){const t={},n={};Object.keys(this.dictionary).forEach(a=>{t[a]=X_(this.dictionary[a]),n[a]=this.getRankedDictionariesMaxWordSize(this.dictionary[a])}),this.rankedDictionaries=t,this.rankedDictionariesMaxWordSize=n}getRankedDictionariesMaxWordSize(t){const n=t.map(a=>typeof a!="string"?a.toString().length:a.length);return n.length===0?0:n.reduce((a,s)=>Math.max(a,s),-1/0)}buildSanitizedRankedDictionary(t){const n=[];return t.forEach(a=>{const s=typeof a;(s==="string"||s==="number"||s==="boolean")&&n.push(a.toString().toLowerCase())}),X_(n)}extendUserInputsDictionary(t){this.dictionary.userInputs||(this.dictionary.userInputs=[]);const n=[...this.dictionary.userInputs,...t];this.rankedDictionaries.userInputs=this.buildSanitizedRankedDictionary(n),this.rankedDictionariesMaxWordSize.userInputs=this.getRankedDictionariesMaxWordSize(n)}addMatcher(t,n){this.matchers[t]?console.info(`Matcher ${t} already exists`):this.matchers[t]=n}}const xe=new cKe;class dKe{constructor(t){this.defaultMatch=t}match({password:t}){const n=t.split("").reverse().join("");return this.defaultMatch({password:n}).map(a=>({...a,token:a.token.split("").reverse().join(""),reversed:!0,i:t.length-1-a.j,j:t.length-1-a.i}))}}class EKe{constructor({substr:t,limit:n,trieRoot:a}){this.buffer=[],this.finalPasswords=[],this.substr=t,this.limit=n,this.trieRoot=a}getAllPossibleSubsAtIndex(t){const n=[];let a=this.trieRoot;for(let s=t;s=this.limit)return;if(a===this.substr.length){t===n&&this.finalPasswords.push({password:this.buffer.join(""),changes:r});return}const u=[...this.getAllPossibleSubsAtIndex(a)];let c=!1;for(let l=a+u.length-1;l>=a;l-=1){const E=u[l-a];if(E.isTerminal()){if(i===E.parents.join("")&&o>=3)continue;c=!0;const d=E.subs;for(const p of d){this.buffer.push(p);const T=r.concat({i:s,letter:p,substitution:E.parents.join("")});if(this.helper({onlyFullSub:t,isFullSub:n,index:l+1,subIndex:s+p.length,changes:T,lastSubLetter:E.parents.join(""),consecutiveSubCount:i===E.parents.join("")?o+1:1}),this.buffer.pop(),this.finalPasswords.length>=this.limit)return}}}if(!t||!c){const l=this.substr.charAt(a);this.buffer.push(l),this.helper({onlyFullSub:t,isFullSub:n&&!c,index:a+1,subIndex:s+1,changes:r,lastSubLetter:i,consecutiveSubCount:o}),this.buffer.pop()}}getAll(){return this.helper({onlyFullSub:!0,isFullSub:!0,index:0,subIndex:0,changes:[],lastSubLetter:void 0,consecutiveSubCount:0}),this.helper({onlyFullSub:!1,isFullSub:!0,index:0,subIndex:0,changes:[],lastSubLetter:void 0,consecutiveSubCount:0}),this.finalPasswords}}const pKe=(e,t,n)=>new EKe({substr:e,limit:t,trieRoot:n}).getAll(),fKe=(e,t,n)=>{const s=e.changes.filter(c=>c.ic-l.letter.length+l.substitution.length,t),r=e.changes.filter(c=>c.i>=t&&c.i<=n),i=r.reduce((c,l)=>c-l.letter.length+l.substitution.length,n-t+s),o=[],u=[];return r.forEach(c=>{o.findIndex(E=>E.letter===c.letter&&E.substitution===c.substitution)<0&&(o.push({letter:c.letter,substitution:c.substitution}),u.push(`${c.substitution} -> ${c.letter}`))}),{i:s,j:i,subs:o,subDisplay:u.join(", ")}};class mKe{constructor(t){this.defaultMatch=t}isAlreadyIncluded(t,n){return t.some(a=>Object.entries(a).every(([s,r])=>s==="subs"||r===n[s]))}match({password:t}){const n=[],a=pKe(t,xe.l33tMaxSubstitutions,xe.trieNodeRoot);let s=!1,r=!0;return a.forEach(i=>{if(s)return;const o=this.defaultMatch({password:i.password,useLevenshtein:r});r=!1,o.forEach(u=>{s||(s=u.i===0&&u.j===t.length-1);const c=fKe(i,u.i,u.j),l=t.slice(c.i,+c.j+1||9e9),E={...u,l33t:!0,token:l,...c},d=this.isAlreadyIncluded(n,E);l.toLowerCase()!==u.matchedWord&&!d&&n.push(E)})}),n.filter(i=>i.token.length>1)}}class TKe{constructor(){this.l33t=new mKe(this.defaultMatch),this.reverse=new dKe(this.defaultMatch)}match({password:t}){const n=[...this.defaultMatch({password:t}),...this.reverse.match({password:t}),...this.l33t.match({password:t})];return Ar(n)}defaultMatch({password:t,useLevenshtein:n=!0}){const a=[],s=t.length,r=t.toLowerCase();return Object.keys(xe.rankedDictionaries).forEach(i=>{const o=xe.rankedDictionaries[i],u=xe.rankedDictionariesMaxWordSize[i],c=Math.min(u,s);for(let l=0;l{const r=n[s];r.lastIndex=0;let i;for(;i=r.exec(t);)if(i){const o=i[0];a.push({pattern:"regex",token:o,i:i.index,j:i.index+i[0].length-1,regexName:s,regexMatch:i})}}),Ar(a)}}var Fs={nCk(e,t){let n=e;if(t>n)return 0;if(t===0)return 1;let a=1;for(let s=1;s<=t;s+=1)a*=n,a/=s,n-=1;return a},log10(e){return e===0?0:Math.log(e)/Math.log(10)},log2(e){return Math.log(e)/Math.log(2)},factorial(e){let t=1;for(let n=2;n<=e;n+=1)t*=n;return t}},hKe=({token:e})=>{let t=qqe**e.length;t===Number.POSITIVE_INFINITY&&(t=Number.MAX_VALUE);let n;return e.length===1?n=tO+1:n=nO+1,Math.max(t,n)},SKe=({year:e,separator:t})=>{let a=Math.max(Math.abs(e-bp),aO)*365;return t&&(a*=4),a};const AKe=e=>{const t=e.split(""),n=t.filter(i=>i.match(Jqe)).length,a=t.filter(i=>i.match(Zqe)).length;let s=0;const r=Math.min(n,a);for(let i=1;i<=r;i+=1)s+=Fs.nCk(n+a,i);return s};var OKe=e=>{const t=e.replace(eKe,"");if(t.match(Qqe)||t.toLowerCase()===t)return 1;const n=[sO,jqe,rO],a=n.length;for(let s=0;s{let n=0,a=e.indexOf(t);for(;a>=0;)n+=1,a=e.indexOf(t,a+t.length);return n},gKe=({sub:e,token:t})=>{const n=t.toLowerCase(),a=t0(n,e.substitution),s=t0(n,e.letter);return{subbedCount:a,unsubbedCount:s}};var IKe=({l33t:e,subs:t,token:n})=>{if(!e)return 1;let a=1;return t.forEach(s=>{const{subbedCount:r,unsubbedCount:i}=gKe({sub:s,token:n});if(r===0||i===0)a*=2;else{const o=Math.min(i,r);let u=0;for(let c=1;c<=o;c+=1)u+=Fs.nCk(i+r,c);a*=u}}),a},RKe=({rank:e,reversed:t,l33t:n,subs:a,token:s,dictionaryName:r})=>{const i=e,o=OKe(s),u=IKe({l33t:n,subs:a,token:s}),c=t&&2||1;let l;return r==="diceware"?l=6**5/2:l=i*o*u*c,{baseGuesses:i,uppercaseVariations:o,l33tVariations:u,calculation:l}},NKe=({regexName:e,regexMatch:t,token:n})=>{const a={alphaLower:26,alphaUpper:26,alpha:52,alphanumeric:62,digits:10,symbols:33};if(e in a)return a[e]**n.length;switch(e){case"recentYear":return Math.max(Math.abs(parseInt(t[0],10)-bp),aO)}return 0},vKe=({baseGuesses:e,repeatCount:t})=>e*t,bKe=({token:e,ascending:t})=>{const n=e.charAt(0);let a=0;return["a","A","z","Z","0","1","9"].includes(n)?a=4:n.match(/\d/)?a=10:a=26,t||(a*=2),a*e.length};const CKe=e=>{let t=0;return Object.keys(e).forEach(n=>{const a=e[n];t+=a.filter(s=>!!s).length}),t/=Object.entries(e).length,t},DKe=({token:e,graph:t,turns:n})=>{const a=Object.keys(xe.graphs[t]).length,s=CKe(xe.graphs[t]);let r=0;const i=e.length;for(let o=2;o<=i;o+=1){const u=Math.min(n,o-1);for(let c=1;c<=u;c+=1)r+=Fs.nCk(o-1,c-1)*a*s**c}return r};var PKe=({graph:e,token:t,shiftedCount:n,turns:a})=>{let s=DKe({token:t,graph:e,turns:a});if(n){const r=t.length-n;if(n===0||r===0)s*=2;else{let i=0;for(let o=1;o<=Math.min(n,r);o+=1)i+=Fs.nCk(n+r,o);s*=i}}return Math.round(s)},LKe=()=>aKe;const yKe=(e,t)=>{let n=1;return e.token.lengthn0[e]?n0[e](t):xe.matchers[e]&&"scoring"in xe.matchers[e]?xe.matchers[e].scoring(t):0;var kKe=(e,t)=>{const n={};if("guesses"in e&&e.guesses!=null)return e;const a=yKe(e,t),s=$Ke(e.pattern,e);let r=0;typeof s=="number"?r=s:e.pattern==="dictionary"&&(r=s.calculation,n.baseGuesses=s.baseGuesses,n.uppercaseVariations=s.uppercaseVariations,n.l33tVariations=s.l33tVariations);const i=Math.max(r,a);return{...e,...n,guesses:i,guessesLog10:Fs.log10(i)}};const Cn={password:"",optimal:{},excludeAdditive:!1,separatorRegex:void 0,fillArray(e,t){const n=[];for(let a=0;a1&&(s*=this.optimal.pi[a.i-1][t-1]);let r=Fs.factorial(t)*s;this.excludeAdditive||(r+=Kqe**(t-1));let i=!1;Object.keys(this.optimal.g[n]).forEach(o=>{const u=this.optimal.g[n][o];parseInt(o,10)<=t&&u<=r&&(i=!0)}),i||(this.optimal.g[n][t]=r,this.optimal.m[n][t]=a,this.optimal.pi[n][t]=s)},bruteforceUpdate(e){let t=this.makeBruteforceMatch(0,e);this.update(t,1);for(let n=1;n<=e;n+=1){t=this.makeBruteforceMatch(n,e);const a=this.optimal.m[n-1];Object.keys(a).forEach(s=>{a[s].pattern!=="bruteforce"&&this.update(t,parseInt(s,10)+1)})}},unwind(e){const t=[];let n=e-1,a=0,s=1/0;const r=this.optimal.g[n];for(r&&Object.keys(r).forEach(i=>{const o=r[i];o=0;){const i=this.optimal.m[n][a];t.unshift(i),n=i.i-1,a-=1}return t}};var nE={mostGuessableMatchSequence(e,t,n=!1){Cn.password=e,Cn.excludeAdditive=n;const a=e.length;let s=Cn.fillArray(a,"array");t.forEach(u=>{s[u.j].push(u)}),s=s.map(u=>u.sort((c,l)=>c.i-l.i)),Cn.optimal={m:Cn.fillArray(a,"object"),pi:Cn.fillArray(a,"object"),g:Cn.fillArray(a,"object")};for(let u=0;u{c.i>0?Object.keys(Cn.optimal.m[c.i-1]).forEach(l=>{Cn.update(c,parseInt(l,10)+1)}):Cn.update(c,1)}),Cn.bruteforceUpdate(u);const r=Cn.unwind(a),i=r.length,o=this.getGuesses(e,i);return{password:e,guesses:o,guessesLog10:Fs.log10(o),sequence:r}},getGuesses(e,t){const n=e.length;let a=0;return e.length===0?a=1:a=Cn.optimal.g[n-1][t],a}};class UKe{match({password:t,omniMatch:n}){const a=[];let s=0;for(;si instanceof Promise)?Promise.all(a):a}normalizeMatch(t,n,a,s){const r={pattern:"repeat",i:a.index,j:n,token:a[0],baseToken:t,baseGuesses:0,repeatCount:a[0].length/t.length};return s instanceof Promise?s.then(i=>({...r,baseGuesses:i})):{...r,baseGuesses:s}}getGreedyMatch(t,n){const a=/(.+)\1+/g;return a.lastIndex=n,a.exec(t)}getLazyMatch(t,n){const a=/(.+?)\1+/g;return a.lastIndex=n,a.exec(t)}setMatchToken(t,n){const a=/^(.+?)\1+$/;let s,r="";if(n&&t[0].length>n[0].length){s=t;const i=a.exec(s[0]);i&&(r=i[1])}else s=n,s&&(r=s[1]);return{match:s,baseToken:r}}getBaseGuesses(t,n){const a=n.match(t);return a instanceof Promise?a.then(r=>nE.mostGuessableMatchSequence(t,r).guesses):nE.mostGuessableMatchSequence(t,a).guesses}}class wKe{constructor(){this.MAX_DELTA=5}match({password:t}){const n=[];if(t.length===1)return[];let a=0,s=null;const r=t.length;for(let i=1;i1||Math.abs(a)===1){const i=Math.abs(a);if(i>0&&i<=this.MAX_DELTA){const o=s.slice(t,+n+1||9e9),{sequenceName:u,sequenceSpace:c}=this.getSequence(o);return r.push({pattern:"sequence",i:t,j:n,token:s.slice(t,+n+1||9e9),sequenceName:u,sequenceSpace:c,ascending:a>0})}}return null}getSequence(t){let n="unicode",a=26;return Xqe.test(t)?(n="lower",a=26):Yqe.test(t)?(n="upper",a=26):tKe.test(t)&&(n="digits",a=10),{sequenceName:n,sequenceSpace:a}}}class MKe{constructor(){this.SHIFTED_RX=/[~!@#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?]/}match({password:t}){const n=[];return Object.keys(xe.graphs).forEach(a=>{const s=xe.graphs[a];tE(n,this.helper(t,s,a))}),Ar(n)}checkIfShifted(t,n,a){return!t.includes("keypad")&&this.SHIFTED_RX.test(n.charAt(a))?1:0}helper(t,n,a){let s;const r=[];let i=0;const o=t.length;for(;i2&&r.push({pattern:"spatial",i,j:u-1,token:t.slice(i,u),graph:a,turns:l,shiftedCount:s}),i=u;break}}}return r}}const WKe=new RegExp(`[${iO.join("")}]`);class zu{static getMostUsedSeparatorChar(t){const n=[...t.split("").filter(s=>WKe.test(s)).reduce((s,r)=>{const i=s.get(r);return i?s.set(r,i+1):s.set(r,1),s},new Map).entries()].sort(([s,r],[i,o])=>o-r);if(!n.length)return;const a=n[0];if(!(a[1]<2))return a[0]}static getSeparatorRegex(t){return new RegExp(`([^${t} -])(${t})(?!${t})`,"g")}match({password:t}){const n=[];if(t.length===0)return n;const a=zu.getMostUsedSeparatorChar(t);if(a===void 0)return n;const s=zu.getSeparatorRegex(a);for(const r of t.matchAll(s)){if(r.index===void 0)continue;const i=r.index+1;n.push({pattern:"separator",token:a,i,j:i})}return n}}class zKe{constructor(){this.matchers={date:sKe,dictionary:TKe,regex:_Ke,repeat:UKe,sequence:wKe,spatial:MKe,separator:zu}}match(t){const n=[],a=[];return[...Object.keys(this.matchers),...Object.keys(xe.matchers)].forEach(r=>{if(!this.matchers[r]&&!xe.matchers[r])return;const i=this.matchers[r]?this.matchers[r]:xe.matchers[r].Matching,u=new i().match({password:t,omniMatch:this});u instanceof Promise?(u.then(c=>{tE(n,c)}),a.push(u)):tE(n,u)}),a.length>0?new Promise((r,i)=>{Promise.all(a).then(()=>{r(Ar(n))}).catch(o=>{i(o)})}):Ar(n)}}const oO=1,uO=oO*60,lO=uO*60,cO=lO*24,dO=cO*31,EO=dO*12,xKe=EO*100,Kc={second:oO,minute:uO,hour:lO,day:cO,month:dO,year:EO,century:xKe};class FKe{translate(t,n){let a=t;n!==void 0&&n!==1&&(a+="s");const{timeEstimation:s}=xe.translations;return s[a].replace("{base}",`${n}`)}estimateAttackTimes(t){const n={onlineThrottling100PerHour:t/.027777777777777776,onlineNoThrottling10PerSecond:t/10,offlineSlowHashing1e4PerSecond:t/1e4,offlineFastHashing1e10PerSecond:t/1e10},a={onlineThrottling100PerHour:"",onlineNoThrottling10PerSecond:"",offlineSlowHashing1e4PerSecond:"",offlineFastHashing1e10PerSecond:""};return Object.keys(n).forEach(s=>{const r=n[s];a[s]=this.displayTime(r)}),{crackTimesSeconds:n,crackTimesDisplay:a,score:this.guessesToScore(t)}}guessesToScore(t){return t<1005?0:t<1000005?1:t<100000005?2:t<1e10+5?3:4}displayTime(t){let n="centuries",a;const s=Object.keys(Kc),r=s.findIndex(i=>t-1&&(n=s[r-1],r!==0?a=Math.round(t/Kc[n]):n="ltSecond"),this.translate(n,a)}}var BKe=()=>null,GKe=()=>({warning:xe.translations.warnings.dates,suggestions:[xe.translations.suggestions.dates]});const HKe=(e,t)=>{let n=null;return t&&!e.l33t&&!e.reversed?e.rank<=10?n=xe.translations.warnings.topTen:e.rank<=100?n=xe.translations.warnings.topHundred:n=xe.translations.warnings.common:e.guessesLog10<=4&&(n=xe.translations.warnings.similarToCommon),n},VKe=(e,t)=>{let n=null;return t&&(n=xe.translations.warnings.wordByItself),n},qKe=(e,t)=>t?xe.translations.warnings.namesByThemselves:xe.translations.warnings.commonNames,KKe=(e,t)=>{let n=null;const a=e.dictionaryName,s=a==="lastnames"||a.toLowerCase().includes("firstnames");return a==="passwords"?n=HKe(e,t):a.includes("wikipedia")?n=VKe(e,t):s?n=qKe(e,t):a==="userInputs"&&(n=xe.translations.warnings.userInputs),n};var jKe=(e,t)=>{const n=KKe(e,t),a=[],s=e.token;return s.match(sO)?a.push(xe.translations.suggestions.capitalization):s.match(rO)&&s.toLowerCase()!==s&&a.push(xe.translations.suggestions.allUppercase),e.reversed&&e.token.length>=4&&a.push(xe.translations.suggestions.reverseWords),e.l33t&&a.push(xe.translations.suggestions.l33t),{warning:n,suggestions:a}},YKe=e=>e.regexName==="recentYear"?{warning:xe.translations.warnings.recentYears,suggestions:[xe.translations.suggestions.recentYears,xe.translations.suggestions.associatedYears]}:{warning:null,suggestions:[]},XKe=e=>{let t=xe.translations.warnings.extendedRepeat;return e.baseToken.length===1&&(t=xe.translations.warnings.simpleRepeat),{warning:t,suggestions:[xe.translations.suggestions.repeated]}},QKe=()=>({warning:xe.translations.warnings.sequences,suggestions:[xe.translations.suggestions.sequences]}),ZKe=e=>{let t=xe.translations.warnings.keyPattern;return e.turns===1&&(t=xe.translations.warnings.straightRow),{warning:t,suggestions:[xe.translations.suggestions.longerKeyboardPattern]}},JKe=()=>null;const a0={warning:null,suggestions:[]};class eje{constructor(){this.matchers={bruteforce:BKe,date:GKe,dictionary:jKe,regex:YKe,repeat:XKe,sequence:QKe,spatial:ZKe,separator:JKe},this.defaultFeedback={warning:null,suggestions:[]},this.setDefaultSuggestions()}setDefaultSuggestions(){this.defaultFeedback.suggestions.push(xe.translations.suggestions.useWords,xe.translations.suggestions.noNeed)}getFeedback(t,n){if(n.length===0)return this.defaultFeedback;if(t>2)return a0;const a=xe.translations.suggestions.anotherWord,s=this.getLongestMatch(n);let r=this.getMatchFeedback(s,n.length===1);return r!=null?r.suggestions.unshift(a):r={warning:null,suggestions:[a]},r}getLongestMatch(t){let n=t[0];return t.slice(1).forEach(s=>{s.token.length>n.token.length&&(n=s)}),n}getMatchFeedback(t,n){return this.matchers[t.pattern]?this.matchers[t.pattern](t,n):xe.matchers[t.pattern]&&"feedback"in xe.matchers[t.pattern]?xe.matchers[t.pattern].feedback(t,n):a0}}const pO=()=>new Date().getTime(),tje=(e,t,n)=>{const a=new eje,s=new FKe,r=nE.mostGuessableMatchSequence(t,e),i=pO()-n,o=s.estimateAttackTimes(r.guesses);return{calcTime:i,...r,...o,feedback:a.getFeedback(o.score,r.sequence)}},nje=(e,t)=>new zKe().match(e),aje=(e,t)=>{const n=pO(),a=nje(e);if(a instanceof Promise)throw new Error("You are using a Promised matcher, please use `zxcvbnAsync` for it.");return tje(a,e,n)},sje="modulepreload",rje=function(e){return"/"+e},s0={},wt=function(t,n,a){let s=Promise.resolve();if(n&&n.length>0){document.getElementsByTagName("link");const i=document.querySelector("meta[property=csp-nonce]"),o=(i==null?void 0:i.nonce)||(i==null?void 0:i.getAttribute("nonce"));s=Promise.allSettled(n.map(u=>{if(u=rje(u),u in s0)return;s0[u]=!0;const c=u.endsWith(".css"),l=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${l}`))return;const E=document.createElement("link");if(E.rel=c?"stylesheet":sje,c||(E.as="script"),E.crossOrigin="",E.href=u,o&&E.setAttribute("nonce",o),document.head.appendChild(E),c)return new Promise((d,p)=>{E.addEventListener("load",d),E.addEventListener("error",()=>p(new Error(`Unable to preload CSS for ${u}`)))})}))}function r(i){const o=new Event("vite:preloadError",{cancelable:!0});if(o.payload=i,window.dispatchEvent(o),!o.defaultPrevented)throw i}return s.then(i=>{for(const o of i||[])o.status==="rejected"&&r(o.reason);return t().catch(r)})},ije=async e=>{switch(e){case"fr":return await wt(()=>import("./password.fr-LQIeIoMk.js"),[]);case"de":return await wt(()=>import("./password.de-SDMVbHi1.js"),[]);case"it":return await wt(()=>import("./password.it-CReO5S7F.js"),[]);case"es":return await wt(()=>import("./password.es-es-DLU3Rh6X.js"),[]);case"pl":return await wt(()=>import("./password.pl-T3z7Kg0O.js"),[]);case"cs":return await wt(()=>import("./password.cs-CLn3Tyh5.js"),[]);default:return await wt(()=>import("./password.en-BDtqNyGO.js"),[])}},r0=async e=>{const t=await wt(()=>import("./password.common-bdamX4EN.js"),[]),n=await ije(e),a={graphs:t.adjacencyGraphs,dictionary:{...t.dictionary,...n.dictionary}};xe.setOptions(a)},oje=e=>{switch(e){case 2:return"AVERAGE";case 3:return"GOOD";case 4:return"STRONG";default:return"WEAK"}},uje={class:"password-strength"},lje={for:"password-strength",class:"visually-hidden"},cje=["value"],dje={key:0,class:"password-strength-details"},Eje={class:"password-strength-value"},pje={key:0,class:"info-box"},fje={class:"password-feedback"},mje=ae({__name:"PasswordStength",props:{password:{}},setup(e){const t=e,{password:n}=he(t),a=Ue(),s=z(()=>a.getters[V.GETTERS.LANGUAGE]),r=z(()=>a.getters[ee.GETTERS.IS_SUCCESS]),i=pe(0),o=pe(""),u=pe([]),c=pe("0% 100%");ft(async()=>await r0(s.value));function l(E){const d=aje(E);i.value=d.score,o.value=oje(i.value),u.value=d.feedback.suggestions,c.value=i.value*100/4+"% 100%"}return Me(()=>s.value,async E=>{await r0(E)}),Me(()=>n.value,async E=>{r.value?o.value="":l(E)}),(E,d)=>(N(),C("div",uje,[f("label",lje,R(E.$t("user.PASSWORD_STRENGTH.LABEL")),1),f("input",{id:"password-strength",class:Te(["password-slider",`strength-${i.value}`]),style:wa({backgroundSize:c.value}),type:"range",value:i.value,min:"0",max:"4",step:"1",tabindex:-1,autocomplete:"off"},null,14,cje),o.value?(N(),C("div",dje,[f("span",Eje,R(E.$t("user.PASSWORD_STRENGTH.LABEL"))+": "+R(E.$t(`user.PASSWORD_STRENGTH.${o.value}`)),1),u.value.length>0?(N(),C("div",pje,[f("ul",fje,[(N(!0),C(_e,null,$e(u.value,p=>(N(),C("li",{key:p},R(E.$t(`user.PASSWORD_STRENGTH.SUGGESTIONS.${p}`)),1))),128))])])):M("",!0)])):M("",!0)]))}}),Tje=ue(mje,[["__scopeId","data-v-338d49ea"]]),_je={class:"password-input"},hje=["id","disabled","placeholder","required","type","autocomplete"],Sje={class:"show-password"},Aje={key:0,class:"form-info"},Oje=ae({__name:"PasswordInput",props:{checkStrength:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},id:{default:"password"},password:{default:""},placeholder:{},required:{type:Boolean,default:!1},autocomplete:{}},emits:["updatePassword","passwordError"],setup(e,{emit:t}){const n=e,{autocomplete:a,checkStrength:s,disabled:r,id:i,password:o,placeholder:u,required:c}=he(n),l=pe(!1),E=pe(""),d=t;function p(){l.value=!l.value}function T(I){d("updatePassword",I.target.value)}function A(){d("passwordError")}return Me(()=>o.value,I=>{I===""&&(E.value="",l.value=!1)}),(I,m)=>(N(),C("div",_je,[ke(f("input",{id:g(i),disabled:g(r),placeholder:g(u),required:g(c),type:l.value?"text":"password","onUpdate:modelValue":m[0]||(m[0]=S=>E.value=S),minlength:"8",onInput:T,onInvalid:A,autocomplete:g(a)},null,40,hje),[[yR,E.value]]),f("div",Sje,[f("button",{class:"transparent",onClick:De(p,["prevent"]),type:"button"},[G(R(I.$t(`user.${l.value?"HIDE":"SHOW"}_PASSWORD`))+" ",1),f("i",{class:Te(["fa",`fa-eye${l.value?"-slash":""}`]),"aria-hidden":"true"},null,2)])]),g(s)?(N(),C("div",Aje,[m[1]||(m[1]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(I.$t("user.PASSWORD_INFO")),1)])):M("",!0),g(s)?(N(),j(Tje,{key:1,password:E.value},null,8,["password"])):M("",!0)]))}}),aE=ue(Oje,[["__scopeId","data-v-0caa3bff"]]),gje={id:"user-infos-edition"},Ije={class:"profile-form form-box"},Rje={key:1,class:"info-box success-message"},Nje={class:"form-items",for:"email"},vje=["disabled"],bje={class:"form-items",for:"password-field"},Cje={class:"form-items",for:"new-password-field"},Dje={class:"form-buttons"},Pje={class:"confirm",type:"submit"},Lje={class:"data-export"},yje={class:"info-box"},$je={key:0,class:"data-export-archive"},kje={key:1},Uje={key:2},wje=ae({__name:"UserAccountEdition",props:{user:{}},setup(e){const t=e,{user:n}=he(t),a=Ue(),s=Kt({email:"",password:"",new_password:""}),r=z(()=>a.getters[ee.GETTERS.USER_LOADING]),i=z(()=>a.getters[V.GETTERS.APP_CONFIG]),o=z(()=>a.getters[ee.GETTERS.IS_SUCCESS]),u=pe(!1),c=z(()=>a.getters[V.GETTERS.ERROR_MESSAGES]),l=pe(!1),E=pe(!1),d=z(()=>a.getters[ee.GETTERS.EXPORT_REQUEST]),p=z(()=>h()),T=pe(!1);mt(()=>{t.user&&(a.dispatch(ee.ACTIONS.GET_REQUEST_DATA_EXPORT),I(t.user))});function A(){l.value=!0}function I(U){s.email=U.email}function m(U){s.password=U}function S(U){s.new_password=U}function h(){return d.value?Vn(d.value.created_at,n.value.timezone,n.value.date_format,!0,null,!0):null}function _(){return p.value?n3(new Date(p.value),r3(new Date,1)):!0}function O(){const U={email:s.email,password:s.password};s.new_password&&(U.new_password=s.new_password),u.value=s.email!==n.value.email,a.dispatch(ee.ACTIONS.UPDATE_USER_ACCOUNT,U)}function b(U){E.value=U}function v(U){a.dispatch(ee.ACTIONS.DELETE_ACCOUNT,{username:U})}function D(){a.dispatch(ee.ACTIONS.REQUEST_DATA_EXPORT)}async function L(U){T.value=!0,await ze.get(`/auth/account/export/${U}`,{responseType:"blob"}).then($=>{const W=window.URL.createObjectURL(new Blob([$.data],{type:"application/zip"})),Y=document.createElement("a");Y.href=W,Y.setAttribute("download",U),document.body.appendChild(Y),Y.click()}).finally(()=>T.value=!1)}return ct(()=>{a.commit(ee.MUTATIONS.UPDATE_IS_SUCCESS,!1),a.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)}),Me(()=>o.value,async U=>{U&&(m(""),S(""),I(n.value),l.value=!1)}),Me(()=>n.value.email,async()=>{I(n.value)}),(U,$)=>{const W=ie("Modal"),Y=ie("ErrorMessage");return N(),C("div",gje,[E.value?(N(),j(W,{key:0,title:U.$t("common.CONFIRMATION"),message:U.$t("user.CONFIRM_ACCOUNT_DELETION"),onConfirmAction:$[0]||($[0]=te=>v(g(n).username)),onCancelAction:$[1]||($[1]=te=>b(!1)),onKeydown:$[2]||($[2]=Ye(te=>b(!1),["esc"]))},null,8,["title","message"])):M("",!0),f("div",Ije,[c.value?(N(),j(Y,{key:0,message:c.value},null,8,["message"])):M("",!0),o.value?(N(),C("div",Rje,R(U.$t(`user.PROFILE.SUCCESSFUL_${u.value&&i.value.is_email_sending_enabled?"EMAIL_":""}UPDATE`)),1)):M("",!0),f("form",{class:Te({errors:l.value}),onSubmit:De(O,["prevent"])},[f("label",Nje,[G(R(U.$t("user.EMAIL"))+"* ",1),ke(f("input",{id:"email","onUpdate:modelValue":$[3]||($[3]=te=>s.email=te),disabled:r.value,required:!0,onInvalid:A,autocomplete:"email"},null,40,vje),[[et,s.email]])]),f("label",bje,[G(R(U.$t("user.CURRENT_PASSWORD"))+"* ",1),x(aE,{id:"password-field",disabled:r.value,password:s.password,required:!0,onUpdatePassword:m,onPasswordError:A,autocomplete:"current-password"},null,8,["disabled","password"])]),f("label",Cje,[G(R(U.$t("user.NEW_PASSWORD"))+" ",1),x(aE,{id:"new-password-field",disabled:r.value,checkStrength:!0,password:s.new_password,isSuccess:!1,onUpdatePassword:S,onPasswordError:A,autocomplete:"new-password"},null,8,["disabled","password"])]),f("div",Dje,[f("button",Pje,R(U.$t("buttons.SUBMIT")),1),f("button",{class:"cancel",onClick:$[4]||($[4]=De(te=>U.$router.push("/profile"),["prevent"]))},R(U.$t("buttons.CANCEL")),1),f("button",{class:"danger",onClick:$[5]||($[5]=De(te=>b(!0),["prevent"]))},R(U.$t("buttons.DELETE_MY_ACCOUNT")),1),_()?(N(),C("button",{key:0,class:"confirm",onClick:De(D,["prevent"])},R(U.$t("buttons.REQUEST_DATA_EXPORT")),1)):M("",!0)])],34),f("div",Lje,[f("span",yje,[$[7]||($[7]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(U.$t("user.EXPORT_REQUEST.ONLY_ONE_EXPORT_PER_DAY")),1)]),d.value?(N(),C("div",$je,[G(R(U.$t("user.EXPORT_REQUEST.DATA_EXPORT"))+" ("+R(p.value)+"): ",1),d.value.status==="successful"?(N(),C("span",{key:0,class:"archive-link",onClick:$[6]||($[6]=De(te=>L(d.value.file_name),["prevent"]))},[$[8]||($[8]=f("i",{class:"fa fa-download","aria-hidden":"true"},null,-1)),G(" "+R(U.$t("user.EXPORT_REQUEST.DOWNLOAD_ARCHIVE"))+" ("+R(g(ru)(d.value.file_size))+") ",1)])):(N(),C("span",kje,R(U.$t(`user.EXPORT_REQUEST.STATUS.${d.value.status}`)),1)),T.value?(N(),C("span",Uje,[G(R(U.$t("user.EXPORT_REQUEST.GENERATING_LINK"))+" ",1),$[9]||($[9]=f("i",{class:"fa fa-spinner fa-pulse","aria-hidden":"true"},null,-1))])):M("",!0)])):M("",!0)])])])}}}),Mje=ue(wje,[["__scopeId","data-v-d6bbef04"]]),Wje={id:"user-infos-edition"},zje={class:"profile-form form-box"},xje={class:"form-items",for:"registrationDate"},Fje=["value"],Bje={class:"form-items",for:"first_name"},Gje=["disabled"],Hje={class:"form-items",for:"last_name"},Vje={class:"form-items",for:"birth_date"},qje=["disabled"],Kje={class:"form-items",for:"location"},jje=["disabled"],Yje={class:"form-items"},Xje={class:"form-buttons"},Qje={class:"confirm",type:"submit"},Zje=ae({__name:"UserInfosEdition",props:{user:{}},setup(e){const t=e,n=Ue(),a=Kt({first_name:"",last_name:"",birth_date:"",location:"",bio:""}),s=z(()=>t.user.created_at?Vn(t.user.created_at,t.user.timezone,t.user.date_format):""),r=z(()=>n.getters[ee.GETTERS.USER_LOADING]),i=z(()=>n.getters[V.GETTERS.ERROR_MESSAGES]);mt(()=>{t.user&&o(t.user)});function o(l){a.first_name=l.first_name?l.first_name:"",a.last_name=l.last_name?l.last_name:"",a.birth_date=l.birth_date?mn(new Date(l.birth_date),"yyyy-MM-dd"):"",a.location=l.location?l.location:"",a.bio=l.bio?l.bio:""}function u(l){a.bio=l}function c(){n.dispatch(ee.ACTIONS.UPDATE_USER_PROFILE,a)}return ct(()=>{n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)}),(l,E)=>{const d=ie("ErrorMessage"),p=ie("CustomTextArea");return N(),C("div",Wje,[f("div",zje,[i.value?(N(),j(d,{key:0,message:i.value},null,8,["message"])):M("",!0),f("form",{onSubmit:De(c,["prevent"])},[f("label",xje,[G(R(l.$t("user.PROFILE.REGISTRATION_DATE"))+" ",1),f("input",{id:"registrationDate",value:s.value,disabled:""},null,8,Fje)]),f("label",Bje,[G(R(l.$t("user.PROFILE.FIRST_NAME"))+" ",1),ke(f("input",{id:"first_name","onUpdate:modelValue":E[0]||(E[0]=T=>a.first_name=T),disabled:r.value},null,8,Gje),[[et,a.first_name]])]),f("label",Hje,[G(R(l.$t("user.PROFILE.LAST_NAME"))+" ",1),ke(f("input",{id:"last_name","onUpdate:modelValue":E[1]||(E[1]=T=>a.last_name=T)},null,512),[[et,a.last_name]])]),f("label",Vje,[G(R(l.$t("user.PROFILE.BIRTH_DATE"))+" ",1),ke(f("input",{id:"birth_date",type:"date",class:"birth-date","onUpdate:modelValue":E[2]||(E[2]=T=>a.birth_date=T),disabled:r.value},null,8,qje),[[et,a.birth_date]])]),f("label",Kje,[G(R(l.$t("user.PROFILE.LOCATION"))+" ",1),ke(f("input",{id:"location","onUpdate:modelValue":E[3]||(E[3]=T=>a.location=T),disabled:r.value},null,8,jje),[[et,a.location]])]),f("label",Yje,[G(R(l.$t("user.PROFILE.BIO"))+" ",1),x(p,{name:"bio",charLimit:200,input:a.bio,disabled:r.value,onUpdateValue:u},null,8,["input","disabled"])]),f("div",Xje,[f("button",Qje,R(l.$t("buttons.SUBMIT")),1),f("button",{class:"cancel",onClick:E[4]||(E[4]=De(T=>l.$router.push("/profile"),["prevent"]))},R(l.$t("buttons.CANCEL")),1)])],32)])])}}}),Jje=ue(Zje,[["__scopeId","data-v-d124143f"]]),eYe={id:"user-picture-edition"},tYe={class:"user-picture-form"},nYe={class:"picture-help"},aYe={class:"info-box"},sYe={class:"picture-buttons"},rYe=["disabled"],iYe=ae({__name:"UserPictureEdition",props:{user:{}},setup(e){const t=e,n=Ue(),{user:a}=he(t),s=z(()=>n.getters[V.GETTERS.ERROR_MESSAGES]),r=z(()=>n.getters[V.GETTERS.APP_CONFIG]),i=r.value.max_single_file_size?ru(r.value.max_single_file_size):"",o=pe(null);function u(){n.dispatch(ee.ACTIONS.DELETE_PICTURE)}function c(E){E.target.files!==null&&(o.value=E.target.files[0])}function l(){o.value&&n.dispatch(ee.ACTIONS.UPDATE_USER_PICTURE,{picture:o.value})}return ct(()=>{n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)}),(E,d)=>{const p=ie("ErrorMessage");return N(),C("div",eYe,[f("div",tYe,[s.value?(N(),j(p,{key:0,message:s.value},null,8,["message"])):M("",!0),x(Bi,{user:g(a)},null,8,["user"]),f("form",{onSubmit:De(l,["prevent"])},[f("input",{type:"file",name:"picture",accept:".png,.jpg,.gif",onInput:c},null,32),f("div",nYe,[f("span",aYe,[d[1]||(d[1]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(E.$t("workouts.MAX_SIZE"))+": "+R(g(i)),1)])]),f("div",sYe,[f("button",{type:"submit",disabled:!o.value},R(E.$t("user.PROFILE.PICTURE_UPDATE")),9,rYe),g(a).picture?(N(),C("button",{key:0,class:"danger",onClick:u},R(E.$t("user.PROFILE.PICTURE_REMOVE")),1)):M("",!0),f("button",{class:"cancel",onClick:d[0]||(d[0]=T=>E.$router.push("/profile"))},R(E.$t("user.PROFILE.BACK_TO_PROFILE")),1)])],32)])])}}}),oYe=ue(iYe,[["__scopeId","data-v-92649ccc"]]),i0=["Africa/Abidjan","Africa/Accra","Africa/Algiers","Africa/Bissau","Africa/Cairo","Africa/Casablanca","Africa/Ceuta","Africa/El_Aaiun","Africa/Johannesburg","Africa/Juba","Africa/Khartoum","Africa/Lagos","Africa/Maputo","Africa/Monrovia","Africa/Nairobi","Africa/Ndjamena","Africa/Sao_Tome","Africa/Tripoli","Africa/Tunis","Africa/Windhoek","America/Adak","America/Anchorage","America/Araguaina","America/Argentina/Buenos_Aires","America/Argentina/Catamarca","America/Argentina/Cordoba","America/Argentina/Jujuy","America/Argentina/La_Rioja","America/Argentina/Mendoza","America/Argentina/Rio_Gallegos","America/Argentina/Salta","America/Argentina/San_Juan","America/Argentina/San_Luis","America/Argentina/Tucuman","America/Argentina/Ushuaia","America/Asuncion","America/Atikokan","America/Bahia","America/Bahia_Banderas","America/Barbados","America/Belem","America/Belize","America/Blanc-Sablon","America/Boa_Vista","America/Bogota","America/Boise","America/Cambridge_Bay","America/Campo_Grande","America/Cancun","America/Caracas","America/Cayenne","America/Chicago","America/Chihuahua","America/Costa_Rica","America/Creston","America/Cuiaba","America/Curacao","America/Danmarkshavn","America/Dawson","America/Dawson_Creek","America/Denver","America/Detroit","America/Edmonton","America/Eirunepe","America/El_Salvador","America/Fortaleza","America/Fort_Nelson","America/Glace_Bay","America/Godthab","America/Goose_Bay","America/Grand_Turk","America/Guatemala","America/Guayaquil","America/Guyana","America/Halifax","America/Havana","America/Hermosillo","America/Indiana/Indianapolis","America/Indiana/Knox","America/Indiana/Marengo","America/Indiana/Petersburg","America/Indiana/Tell_City","America/Indiana/Vevay","America/Indiana/Vincennes","America/Indiana/Winamac","America/Inuvik","America/Iqaluit","America/Jamaica","America/Juneau","America/Kentucky/Louisville","America/Kentucky/Monticello","America/La_Paz","America/Lima","America/Los_Angeles","America/Maceio","America/Managua","America/Manaus","America/Martinique","America/Matamoros","America/Mazatlan","America/Menominee","America/Merida","America/Metlakatla","America/Mexico_City","America/Miquelon","America/Moncton","America/Monterrey","America/Montevideo","America/Nassau","America/New_York","America/Nipigon","America/Nome","America/Noronha","America/North_Dakota/Beulah","America/North_Dakota/Center","America/North_Dakota/New_Salem","America/Ojinaga","America/Panama","America/Pangnirtung","America/Paramaribo","America/Phoenix","America/Port-au-Prince","America/Port_of_Spain","America/Porto_Velho","America/Puerto_Rico","America/Punta_Arenas","America/Rainy_River","America/Rankin_Inlet","America/Recife","America/Regina","America/Resolute","America/Rio_Branco","America/Santarem","America/Santiago","America/Santo_Domingo","America/Sao_Paulo","America/Scoresbysund","America/Sitka","America/St_Johns","America/Swift_Current","America/Tegucigalpa","America/Thule","America/Thunder_Bay","America/Tijuana","America/Toronto","America/Vancouver","America/Whitehorse","America/Winnipeg","America/Yakutat","America/Yellowknife","Antarctica/Casey","Antarctica/Davis","Antarctica/DumontDUrville","Antarctica/Macquarie","Antarctica/Mawson","Antarctica/Palmer","Antarctica/Rothera","Antarctica/Syowa","Antarctica/Troll","Antarctica/Vostok","Asia/Almaty","Asia/Amman","Asia/Anadyr","Asia/Aqtau","Asia/Aqtobe","Asia/Ashgabat","Asia/Atyrau","Asia/Baghdad","Asia/Baku","Asia/Bangkok","Asia/Barnaul","Asia/Beirut","Asia/Bishkek","Asia/Brunei","Asia/Chita","Asia/Choibalsan","Asia/Colombo","Asia/Damascus","Asia/Dhaka","Asia/Dili","Asia/Dubai","Asia/Dushanbe","Asia/Famagusta","Asia/Gaza","Asia/Hebron","Asia/Ho_Chi_Minh","Asia/Hong_Kong","Asia/Hovd","Asia/Irkutsk","Asia/Jakarta","Asia/Jayapura","Asia/Jerusalem","Asia/Kabul","Asia/Kamchatka","Asia/Karachi","Asia/Kathmandu","Asia/Khandyga","Asia/Kolkata","Asia/Krasnoyarsk","Asia/Kuala_Lumpur","Asia/Kuching","Asia/Macau","Asia/Magadan","Asia/Makassar","Asia/Manila","Asia/Nicosia","Asia/Novokuznetsk","Asia/Novosibirsk","Asia/Omsk","Asia/Oral","Asia/Pontianak","Asia/Pyongyang","Asia/Qatar","Asia/Qostanay","Asia/Qyzylorda","Asia/Riyadh","Asia/Sakhalin","Asia/Samarkand","Asia/Seoul","Asia/Shanghai","Asia/Singapore","Asia/Srednekolymsk","Asia/Taipei","Asia/Tashkent","Asia/Tbilisi","Asia/Tehran","Asia/Thimphu","Asia/Tokyo","Asia/Tomsk","Asia/Ulaanbaatar","Asia/Urumqi","Asia/Ust-Nera","Asia/Vladivostok","Asia/Yakutsk","Asia/Yangon","Asia/Yekaterinburg","Asia/Yerevan","Atlantic/Azores","Atlantic/Bermuda","Atlantic/Canary","Atlantic/Cape_Verde","Atlantic/Faroe","Atlantic/Madeira","Atlantic/Reykjavik","Atlantic/South_Georgia","Atlantic/Stanley","Australia/Adelaide","Australia/Brisbane","Australia/Broken_Hill","Australia/Currie","Australia/Darwin","Australia/Eucla","Australia/Hobart","Australia/Lindeman","Australia/Lord_Howe","Australia/Melbourne","Australia/Perth","Australia/Sydney","Europe/Amsterdam","Europe/Andorra","Europe/Astrakhan","Europe/Athens","Europe/Belgrade","Europe/Berlin","Europe/Brussels","Europe/Bucharest","Europe/Budapest","Europe/Chisinau","Europe/Copenhagen","Europe/Dublin","Europe/Gibraltar","Europe/Helsinki","Europe/Istanbul","Europe/Kaliningrad","Europe/Kiev","Europe/Kirov","Europe/Lisbon","Europe/London","Europe/Luxembourg","Europe/Madrid","Europe/Malta","Europe/Minsk","Europe/Monaco","Europe/Moscow","Europe/Oslo","Europe/Paris","Europe/Prague","Europe/Riga","Europe/Rome","Europe/Samara","Europe/Saratov","Europe/Simferopol","Europe/Sofia","Europe/Stockholm","Europe/Tallinn","Europe/Tirane","Europe/Ulyanovsk","Europe/Uzhgorod","Europe/Vienna","Europe/Vilnius","Europe/Volgograd","Europe/Warsaw","Europe/Zaporozhye","Europe/Zurich","Indian/Chagos","Indian/Christmas","Indian/Cocos","Indian/Kerguelen","Indian/Mahe","Indian/Maldives","Indian/Mauritius","Indian/Reunion","Pacific/Apia","Pacific/Auckland","Pacific/Bougainville","Pacific/Chatham","Pacific/Chuuk","Pacific/Easter","Pacific/Efate","Pacific/Enderbury","Pacific/Fakaofo","Pacific/Fiji","Pacific/Funafuti","Pacific/Galapagos","Pacific/Gambier","Pacific/Guadalcanal","Pacific/Guam","Pacific/Honolulu","Pacific/Kiritimati","Pacific/Kosrae","Pacific/Kwajalein","Pacific/Majuro","Pacific/Marquesas","Pacific/Nauru","Pacific/Niue","Pacific/Norfolk","Pacific/Noumea","Pacific/Pago_Pago","Pacific/Palau","Pacific/Pitcairn","Pacific/Pohnpei","Pacific/Port_Moresby","Pacific/Rarotonga","Pacific/Tahiti","Pacific/Tarawa","Pacific/Tongatapu","Pacific/Wake","Pacific/Wallis"],uYe={id:"tz-dropdown"},lYe=["value","disabled","aria-expanded"],cYe=["aria-label"],dYe=["id","onClick","onMouseover","autofocus"],EYe=ae({__name:"TimezoneDropdown",props:{input:{},disabled:{type:Boolean,default:!1}},emits:["updateTimezone"],setup(e,{emit:t}){const n=e,a=t,{input:s,disabled:r}=he(n),i=pe(s.value),o=pe(!1),u=pe(0),c=z(()=>s.value?i0.filter(_=>l(_)):i0);function l(_){return _.toLowerCase().match(i.value.toLowerCase())}function E(_){u.value=_}function d(_){c.value.length>_&&(i.value=c.value[_],a("updateTimezone",i.value),o.value=!1)}function p(_){_.preventDefault(),c.value.length>0&&d(u.value)}function T(_){_.preventDefault(),o.value=!0,i.value=_.target.value.trim()}function A(){d(u.value)}function I(_){const O=document.getElementById(`tz-dropdown-item-${_}`);O&&(O.focus(),O.scrollIntoView({behavior:"smooth",block:"nearest"}))}function m(){o.value=!0,u.value=u.value===null?0:u.value+=1,u.value>=c.value.length&&(u.value=0),I(u.value)}function S(){o.value=!0,u.value=u.value===null?c.value.length-1:u.value-=1,u.value<=-1&&(u.value=c.value.length-1),I(u.value)}function h(){o.value&&(o.value=!1,i.value=s.value)}return Me(()=>n.input,_=>{i.value=_}),(_,O)=>(N(),C("div",uYe,[f("input",{class:"tz-dropdown-input",id:"timezone",name:"timezone",value:i.value,disabled:g(r),required:"",role:"combobox","aria-autocomplete":"list","aria-controls":"tz-dropdown-list","aria-expanded":o.value,onKeydown:[O[0]||(O[0]=Ye(b=>h(),["esc"])),Ye(p,["enter"]),O[2]||(O[2]=Ye(b=>m(),["down"])),O[3]||(O[3]=Ye(b=>S(),["up"]))],onInput:T,onBlur:O[1]||(O[1]=b=>A())},null,40,lYe),o.value?(N(),C("ul",{key:0,class:"tz-dropdown-list",id:"tz-dropdown-list",role:"listbox",tabindex:"-1","aria-label":_.$t("user.PROFILE.TIMEZONE",0)},[(N(!0),C(_e,null,$e(c.value,(b,v)=>(N(),C("li",{key:b,id:`tz-dropdown-item-${v}`,class:Te(["tz-dropdown-item",{focus:v===u.value}]),onClick:D=>d(v),onMouseover:D=>E(v),autofocus:v===u.value,role:"option"},R(b),43,dYe))),128))],8,cYe)):M("",!0)]))}}),pYe=ue(EYe,[["__scopeId","data-v-3d9b6b6f"]]),fYe={id:"user-preferences-edition"},mYe={class:"profile-form form-box"},TYe={class:"preferences-section"},_Ye={class:"form-items"},hYe=["disabled"],SYe=["value"],AYe={class:"form-items"},OYe=["disabled"],gYe=["value"],IYe={class:"form-items"},RYe={class:"form-items"},NYe=["disabled"],vYe=["value"],bYe={class:"form-items form-checkboxes"},CYe={class:"checkboxes-label"},DYe={class:"checkboxes"},PYe=["id","name","checked","disabled","onInput"],LYe={class:"checkbox-label"},yYe={class:"preferences-section"},$Ye={class:"form-items form-checkboxes"},kYe={class:"checkboxes-label"},UYe={class:"checkboxes"},wYe=["id","name","checked","disabled","onInput"],MYe={class:"checkbox-label"},WYe={class:"form-items form-checkboxes"},zYe={class:"checkboxes-label"},xYe={class:"checkboxes"},FYe=["id","name","checked","disabled","onInput"],BYe={class:"checkbox-label"},GYe={class:"form-items form-checkboxes"},HYe={class:"checkboxes-label"},VYe={class:"checkboxes"},qYe=["id","name","checked","disabled","onInput"],KYe={class:"checkbox-label"},jYe={class:"form-items form-checkboxes"},YYe={class:"checkboxes-label"},XYe={class:"checkboxes"},QYe=["id","name","checked","disabled","onInput"],ZYe={class:"checkbox-label"},JYe={class:"info-box raw-speed-help"},eXe={class:"form-buttons"},tXe={class:"confirm",type:"submit"},nXe=ae({__name:"UserPreferencesEdition",props:{user:{}},setup(e){const t=e,n=Ue(),a=Kt({display_ascent:!0,imperial_units:!1,language:"en",timezone:"Europe/Paris",date_format:"dd/MM/yyyy",weekm:!1,start_elevation_at_zero:!1,use_raw_gpx_speed:!1,use_dark_mode:!1}),s=[{label:"SUNDAY",value:!1},{label:"MONDAY",value:!0}],r=[{label:"METRIC",value:!1},{label:"IMPERIAL",value:!0}],i=[{label:"DISPLAYED",value:!0},{label:"HIDDEN",value:!1}],o=[{label:"ZERO",value:!0},{label:"MIN_ALT",value:!1}],u=[{label:"FILTERED_SPEED",value:!1},{label:"RAW_SPEED",value:!0}],c=[{label:"DARK",value:!0},{label:"DEFAULT",value:null},{label:"LIGHT",value:!1}],l=z(()=>n.getters[ee.GETTERS.USER_LOADING]),E=z(()=>n.getters[V.GETTERS.ERROR_MESSAGES]),d=z(()=>qHe(new Date().toUTCString(),t.user.timezone,a.language));mt(()=>{t.user&&p(t.user)});function p(O){a.display_ascent=O.display_ascent,a.start_elevation_at_zero=O.start_elevation_at_zero?O.start_elevation_at_zero:!1,a.use_raw_gpx_speed=O.use_raw_gpx_speed?O.use_raw_gpx_speed:!1,a.imperial_units=O.imperial_units?O.imperial_units:!1,a.language=O.language&&O.language in or?O.language:"en",a.timezone=O.timezone?O.timezone:"Europe/Paris",a.date_format=O.date_format?O.date_format:"dd/MM/yyyy",a.weekm=O.weekm?O.weekm:!1,a.use_dark_mode=O.use_dark_mode}function T(){n.dispatch(ee.ACTIONS.UPDATE_USER_PREFERENCES,a)}function A(O){a.timezone=O}function I(O){a.start_elevation_at_zero=O}function m(O){a.use_raw_gpx_speed=O}function S(O){a.display_ascent=O}function h(O){a.imperial_units=O}function _(O){a.weekm=O}return ct(()=>{n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)}),(O,b)=>{const v=ie("ErrorMessage");return N(),C("div",fYe,[f("div",mYe,[E.value?(N(),j(v,{key:0,message:E.value},null,8,["message"])):M("",!0),f("form",{onSubmit:De(T,["prevent"])},[f("div",TYe,R(O.$t("user.PROFILE.INTERFACE")),1),f("label",_Ye,[G(R(O.$t("user.PROFILE.LANGUAGE"))+" ",1),ke(f("select",{id:"language","onUpdate:modelValue":b[0]||(b[0]=D=>a.language=D),disabled:l.value},[(N(!0),C(_e,null,$e(g(vd),D=>(N(),C("option",{value:D.value,key:D.value},R(D.label),9,SYe))),128))],8,hYe),[[Ta,a.language]])]),f("label",AYe,[G(R(O.$t("user.PROFILE.THEME_MODE.LABEL"))+" ",1),ke(f("select",{id:"use_dark_mode","onUpdate:modelValue":b[1]||(b[1]=D=>a.use_dark_mode=D),disabled:l.value},[(N(),C(_e,null,$e(c,D=>f("option",{value:D.value,key:D.label},R(O.$t(`user.PROFILE.THEME_MODE.VALUES.${D.label}`)),9,gYe)),64))],8,OYe),[[Ta,a.use_dark_mode]])]),f("label",IYe,[G(R(O.$t("user.PROFILE.TIMEZONE"))+" ",1),x(pYe,{input:a.timezone,disabled:l.value,onUpdateTimezone:A},null,8,["input","disabled"])]),f("label",RYe,[G(R(O.$t("user.PROFILE.DATE_FORMAT"))+" ",1),ke(f("select",{id:"date_format","onUpdate:modelValue":b[2]||(b[2]=D=>a.date_format=D),disabled:l.value},[(N(!0),C(_e,null,$e(d.value,D=>(N(),C("option",{value:D.value,key:D.value},R(D.label),9,vYe))),128))],8,NYe),[[Ta,a.date_format]])]),f("div",bYe,[f("span",CYe,R(O.$t("user.PROFILE.FIRST_DAY_OF_WEEK")),1),f("div",DYe,[(N(),C(_e,null,$e(s,D=>f("label",{key:D.label},[f("input",{type:"radio",id:D.label,name:D.label,checked:D.value===a.weekm,disabled:l.value,onInput:L=>_(D.value)},null,40,PYe),f("span",LYe,R(O.$t(`user.PROFILE.${D.label}`)),1)])),64))])]),f("div",yYe,R(O.$t("workouts.WORKOUT",0)),1),f("div",$Ye,[f("span",kYe,R(O.$t("user.PROFILE.UNITS.LABEL")),1),f("div",UYe,[(N(),C(_e,null,$e(r,D=>f("label",{key:D.label},[f("input",{type:"radio",id:D.label,name:D.label,checked:D.value===a.imperial_units,disabled:l.value,onInput:L=>h(D.value)},null,40,wYe),f("span",MYe,R(O.$t(`user.PROFILE.UNITS.${D.label}`)),1)])),64))])]),f("div",WYe,[f("span",zYe,R(O.$t("user.PROFILE.ASCENT_DATA")),1),f("div",xYe,[(N(),C(_e,null,$e(i,D=>f("label",{key:D.label},[f("input",{type:"radio",id:D.label,name:D.label,checked:D.value===a.display_ascent,disabled:l.value,onInput:L=>S(D.value)},null,40,FYe),f("span",BYe,R(O.$t(`common.${D.label}`)),1)])),64))])]),f("div",GYe,[f("span",HYe,R(O.$t("user.PROFILE.ELEVATION_CHART_START.LABEL")),1),f("div",VYe,[(N(),C(_e,null,$e(o,D=>f("label",{key:D.label},[f("input",{type:"radio",id:D.label,name:D.label,checked:D.value===a.start_elevation_at_zero,disabled:l.value,onInput:L=>I(D.value)},null,40,qYe),f("span",KYe,R(O.$t(`user.PROFILE.ELEVATION_CHART_START.${D.label}`)),1)])),64))])]),f("div",jYe,[f("span",YYe,R(O.$t("user.PROFILE.USE_RAW_GPX_SPEED.LABEL")),1),f("div",XYe,[(N(),C(_e,null,$e(u,D=>f("label",{key:D.label},[f("input",{type:"radio",id:D.label,name:D.label,checked:D.value===a.use_raw_gpx_speed,disabled:l.value,onInput:L=>m(D.value)},null,40,QYe),f("span",ZYe,R(O.$t(`user.PROFILE.USE_RAW_GPX_SPEED.${D.label}`)),1)])),64))]),f("div",JYe,[f("span",null,[b[4]||(b[4]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(O.$t("user.PROFILE.USE_RAW_GPX_SPEED.HELP")),1)])])]),f("div",eXe,[f("button",tXe,R(O.$t("buttons.SUBMIT")),1),f("button",{class:"cancel",onClick:b[3]||(b[3]=De(D=>O.$router.push("/profile/preferences"),["prevent"]))},R(O.$t("buttons.CANCEL")),1)])],32)])])}}}),aXe=ue(nXe,[["__scopeId","data-v-3130c876"]]),sXe={class:"privacy-policy-text"},rXe={class:"last-update"},iXe=["innerHTML"],oXe=["innerHTML"],uXe="Sun, 26 Feb 2023 17:00:00 GMT",lXe=ae({__name:"PrivacyPolicy",setup(e){const t=Ue(),n=z(()=>t.getters[V.GETTERS.APP_CONFIG]),a=z(()=>t.getters[V.GETTERS.LANGUAGE]),s=z(()=>t.getters[ee.GETTERS.AUTH_USER_PROFILE]),r=z(()=>l()),i=z(()=>c()),o=z(()=>E()),u=["DATA_COLLECTED","INFORMATION_USAGE","INFORMATION_PROTECTION","INFORMATION_DISCLOSURE","SITE_USAGE_BY_CHILDREN","YOUR_CONSENT","ACCOUNT_DELETION","CHANGES_TO_OUR_PRIVACY_POLICY"];function c(){return s.value.timezone?s.value.timezone:Intl.DateTimeFormat().resolvedOptions().timeZone?Intl.DateTimeFormat().resolvedOptions().timeZone:"Europe/Paris"}function l(){return XA[a.value]}function E(){return Vn(n.value.privacy_policy&&n.value.privacy_policy_date?`${n.value.privacy_policy_date}`:uXe,i.value,r.value,!1)}return(d,p)=>(N(),C("div",sXe,[f("h1",null,R(Be(d.$t("privacy_policy.TITLE"))),1),f("p",rXe,[G(R(d.$t("privacy_policy.LAST_UPDATE"))+": ",1),f("time",null,R(o.value),1)]),n.value.privacy_policy?(N(),C("div",{key:0,innerHTML:g(Pi)(n.value.privacy_policy)},null,8,iXe)):(N(),C(_e,{key:1},$e(u,T=>(N(),C(_e,{key:T},[f("h2",null,R(d.$t(`privacy_policy.CONTENT.${T}.TITLE`)),1),f("p",{innerHTML:g(Pi)(d.$t(`privacy_policy.CONTENT.${T}.CONTENT`))},null,8,oXe)],64))),64))]))}}),fO=ue(lXe,[["__scopeId","data-v-178c1981"]]),cXe={id:"user-privacy-policy"},dXe={key:1},EXe={class:"policy-content"},pXe={for:"accepted_policy",class:"accepted_policy"},fXe={class:"form-buttons"},mXe={class:"confirm",type:"submit"},TXe=ae({__name:"UserPrivacyPolicyValidation",props:{user:{}},setup(e){const t=e,{user:n}=he(t),a=Ue(),s=z(()=>a.getters[V.GETTERS.ERROR_MESSAGES]),r=pe(!1),i=pe(!1);function o(){a.dispatch(ee.ACTIONS.ACCEPT_PRIVACY_POLICY,r.value)}function u(){i.value=!0}return ct(()=>{a.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)}),(c,l)=>{const E=ie("ErrorMessage"),d=ie("router-link"),p=ie("i18n-t");return N(),C("div",cXe,[s.value?(N(),j(E,{key:0,message:s.value},null,8,["message"])):M("",!0),g(n).accepted_privacy_policy?(N(),C("div",dXe,[f("p",null,[x(p,{keypath:"user.YOU_HAVE_ACCEPTED_PRIVACY_POLICY"},{default:le(()=>[x(d,{to:"/privacy-policy"},{default:le(()=>[G(R(c.$t("privacy_policy.TITLE")),1)]),_:1})]),_:1})]),f("button",{class:"cancel",onClick:l[0]||(l[0]=T=>c.$router.push("/profile"))},R(c.$t("user.PROFILE.BACK_TO_PROFILE")),1)])):(N(),C("form",{key:2,class:Te({errors:i.value}),onSubmit:l[3]||(l[3]=De(T=>o(),["prevent"]))},[f("div",EXe,[x(fO)]),f("label",pXe,[ke(f("input",{type:"checkbox",id:"accepted_policy",required:"","onUpdate:modelValue":l[1]||(l[1]=T=>r.value=T),onInvalid:u},null,544),[[Qu,r.value]]),f("span",null,[x(p,{keypath:"user.READ_AND_ACCEPT_PRIVACY_POLICY"},{default:le(()=>[G(R(c.$t("privacy_policy.TITLE")),1)]),_:1})])]),x(d,{to:"/profile/edit/account"},{default:le(()=>[G(R(c.$t("user.I_WANT_TO_DELETE_MY_ACCOUNT")),1)]),_:1}),f("div",fXe,[f("button",mXe,R(c.$t("buttons.SUBMIT")),1),f("button",{class:"cancel",onClick:l[2]||(l[2]=T=>c.$router.push("/profile"))},R(c.$t("user.PROFILE.BACK_TO_PROFILE")),1)])],34))])}}}),_Xe=ue(TXe,[["__scopeId","data-v-a7ac61ac"]]),hXe=["equipments:read","equipments:write","profile:read","profile:write","users:read","users:write","workouts:read","workouts:write"],SXe=["application:write"],AXe={id:"new-oauth2-app"},OXe={id:"new-oauth2-title"},gXe={id:"apps-form"},IXe={class:"form-items"},RXe={class:"form-item"},NXe={for:"app-name"},vXe={class:"form-item"},bXe={for:"app-description"},CXe={class:"form-item"},DXe={for:"app-url"},PXe={class:"form-item"},LXe={for:"app-redirect-uri"},yXe={class:"form-item-scope"},$Xe={class:"form-item-scope-label"},kXe={class:"scope-label"},UXe=["name","checked","onChange"],wXe=["innerHTML"],MXe={class:"form-buttons"},WXe=["disabled"],zXe=ae({__name:"AddUserApp",props:{authUser:{}},setup(e){const t=e,n=Ue(),a=Kt({client_name:"",client_uri:"",client_description:"",description:"",redirect_uri:""}),s=Kt([]),r=z(()=>c(t.authUser,SXe,hXe));function i(){const l={client_name:a.client_name,client_description:a.client_description,client_uri:a.client_uri,redirect_uris:[a.redirect_uri],scope:s.sort().join(" ")};n.dispatch(Ze.ACTIONS.CREATE_CLIENT,l)}function o(l){a.client_description=l}function u(l){const E=s.indexOf(l);E>-1?s.splice(E,1):s.push(l)}function c(l,E,d){const p=[...d];return l.admin&&p.push(...E),p.sort()}return(l,E)=>{const d=ie("CustomTextArea");return N(),C("div",AXe,[f("h1",OXe,R(l.$t("oauth2.ADD_A_NEW_APP")),1),f("div",gXe,[f("form",{onSubmit:De(i,["prevent"])},[f("div",IXe,[f("div",RXe,[f("label",NXe,R(l.$t("oauth2.APP.NAME"))+"*",1),ke(f("input",{id:"app-name",type:"text",required:"","onUpdate:modelValue":E[0]||(E[0]=p=>a.client_name=p)},null,512),[[et,a.client_name]])]),f("div",vXe,[f("label",bXe,R(l.$t("oauth2.APP.DESCRIPTION")),1),x(d,{name:"app-description",charLimit:200,input:a.description,onUpdateValue:o},null,8,["input"])]),f("div",CXe,[f("label",DXe,R(l.$t("oauth2.APP.URL"))+"*",1),ke(f("input",{id:"app-url",type:"text",required:"","onUpdate:modelValue":E[1]||(E[1]=p=>a.client_uri=p)},null,512),[[et,a.client_uri]])]),f("div",PXe,[f("label",LXe,R(l.$t("oauth2.APP.REDIRECT_URL"))+"* ",1),ke(f("input",{id:"app-redirect-uri",type:"text",required:"","onUpdate:modelValue":E[2]||(E[2]=p=>a.redirect_uri=p)},null,512),[[et,a.redirect_uri]])]),f("div",yXe,[f("div",$Xe,R(l.$t("oauth2.APP.SCOPE.LABEL"))+"* ",1),(N(!0),C(_e,null,$e(r.value,p=>(N(),C("div",{class:"form-item-scope-checkboxes",key:p},[f("label",kXe,[f("input",{type:"checkbox",name:p,checked:s.includes(p),onChange:T=>u(p)},null,40,UXe),f("code",null,R(p),1)]),f("p",{class:"scope-description",innerHTML:l.$t(`oauth2.APP.SCOPE.${p}_DESCRIPTION`)},null,8,wXe)]))),128))])]),f("div",MXe,[f("button",{class:"confirm",type:"submit",disabled:s.length===0},R(l.$t("buttons.SUBMIT")),9,WXe),f("button",{class:"cancel",onClick:E[3]||(E[3]=De(()=>l.$router.push("/profile/apps"),["prevent"]))},R(l.$t("buttons.CANCEL")),1)])],32)])])}}}),xXe=ue(zXe,[["__scopeId","data-v-e2284e06"]]),FXe={id:"authorize-oauth2-app"},BXe={key:0},GXe={id:"authorize-oauth2-title"},HXe={class:"oauth2-access description-list"},VXe={class:"client-scope"},qXe=["innerHTML"],KXe={class:"authorize-oauth2-buttons"},jXe={key:1},YXe={class:"no-app"},XXe=ae({__name:"AuthorizeUserApp",setup(e){const t=bt(),n=Ue(),a=z(()=>n.getters[Ze.GETTERS.CLIENT]),s=z(()=>n.getters[V.GETTERS.ERROR_MESSAGES]);ft(()=>r());function r(){t.query.client_id&&typeof t.query.client_id=="string"&&n.dispatch(Ze.ACTIONS.GET_CLIENT_BY_CLIENT_ID,t.query.client_id)}function i(){n.dispatch(Ze.ACTIONS.AUTHORIZE_CLIENT,{client_id:`${t.query.client_id}`,redirect_uri:`${t.query.redirect_uri}`,response_type:`${t.query.response_type}`,scope:`${t.query.scope}`,state:`${t.query.state?t.query.state:""}`,code_challenge:`${t.query.code_challenge?t.query.code_challenge:""}`,code_challenge_method:`${t.query.code_challenge_method?t.query.code_challenge_method:""}`})}return(o,u)=>{const c=ie("router-link"),l=ie("i18n-t"),E=ie("ErrorMessage");return N(),C("div",FXe,[a.value.client_id?(N(),C("div",BXe,[f("h1",GXe,[x(l,{keypath:"oauth2.AUTHORIZE_APP"},{default:le(()=>[x(c,{to:{name:"UserApp",params:{id:a.value.id}}},{default:le(()=>[G(R(a.value.name),1)]),_:1},8,["to"])]),_:1})]),s.value?(N(),j(E,{key:0,message:s.value},null,8,["message"])):M("",!0),f("div",HXe,[f("p",null,R(o.$t("oauth2.APP_REQUESTING_ACCESS")),1),f("dl",null,[(N(!0),C(_e,null,$e(a.value.scope.split(" "),d=>(N(),C(_e,{key:d},[f("dt",VXe,[f("code",null,R(d),1)]),f("dd",{innerHTML:o.$t(`oauth2.APP.SCOPE.${d}_DESCRIPTION`)},null,8,qXe)],64))),128))]),f("div",KXe,[f("button",{class:"danger",onClick:i},R(o.$t("buttons.AUTHORIZE")),1),f("button",{class:"cancel",onClick:u[0]||(u[0]=d=>o.$router.push("/profile/apps"))},R(o.$t("buttons.CANCEL")),1)])])])):(N(),C("div",jXe,[f("p",YXe,R(o.$t("oauth2.NO_APP")),1),f("button",{onClick:u[1]||(u[1]=d=>o.$router.push("/profile/apps"))},R(o.$t("buttons.BACK")),1)]))])}}}),QXe=ue(XXe,[["__scopeId","data-v-77d2c8f0"]]),ZXe={id:"oauth2-apps"},JXe=ae({__name:"index",props:{user:{}},setup(e){const t=e,n=Ue(),{user:a}=he(t);return ct(()=>{n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),n.commit(Ze.MUTATIONS.SET_CLIENTS,[])}),(s,r)=>{const i=ie("router-view");return N(),C("div",ZXe,[x(i,{authUser:g(a)},null,8,["authUser"])])}}}),eQe={id:"oauth2-app",class:"description-list"},tQe={key:1},nQe={key:0,class:"info-box success-message"},aQe=["title"],sQe={key:0},rQe={key:1,class:"app-secret"},iQe=["title"],oQe={class:"client-scopes"},uQe={class:"app-buttons"},lQe={key:2},cQe={class:"no-app"},dQe=ae({__name:"UserApp",props:{authUser:{},afterCreation:{type:Boolean,default:!1}},setup(e){const t=e,n=bt(),a=Ue(),{afterCreation:s,authUser:r}=he(t),i=z(()=>a.getters[Ze.GETTERS.CLIENT]),o=z(()=>a.getters[Ze.GETTERS.REVOCATION_SUCCESSFUL]),u=pe(!1),c=pe(""),l=pe(!1),E=pe(!1),d=pe(!1);ft(()=>{p(),navigator.clipboard&&(d.value=!0)});function p(){!s.value&&n.params.id&&typeof n.params.id=="string"&&a.dispatch(Ze.ACTIONS.GET_CLIENT_BY_ID,+n.params.id)}function T(h){c.value=h?"oauth2.APP_DELETION_CONFIRMATION":"oauth2.TOKENS_REVOCATION_CONFIRMATION",A(!0)}function A(h){u.value=h,h||(c.value="")}function I(h){c.value==="oauth2.APP_DELETION_CONFIRMATION"?a.dispatch(Ze.ACTIONS.DELETE_CLIENT,h):a.dispatch(Ze.ACTIONS.REVOKE_ALL_TOKENS,h)}function m(){navigator.clipboard.writeText(i.value.client_id),l.value=!0,E.value=!1,setTimeout(()=>{l.value=!1},3e3)}function S(){i.value.client_secret&&(navigator.clipboard.writeText(i.value.client_secret),E.value=!0,l.value=!1,setTimeout(()=>{E.value=!1},3e3))}return ct(()=>{a.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),a.commit(Ze.MUTATIONS.EMPTY_CLIENT),a.commit(Ze.MUTATIONS.SET_REVOCATION_SUCCESSFUL,!1)}),Me(()=>o.value,h=>{h&&A(!1)}),(h,_)=>{const O=ie("Modal");return N(),C("div",eQe,[u.value?(N(),j(O,{key:0,title:h.$t("common.CONFIRMATION"),message:h.$t(c.value),onConfirmAction:_[0]||(_[0]=b=>I(i.value.id)),onCancelAction:_[1]||(_[1]=b=>A(!1)),onKeydown:_[2]||(_[2]=Ye(b=>A(!1),["esc"]))},null,8,["title","message"])):M("",!0),i.value&&i.value.client_id?(N(),C("div",tQe,[g(s)||o.value?(N(),C("div",nQe,R(h.$t(g(s)?"oauth2.APP_CREATED_SUCCESSFULLY":"oauth2.TOKENS_REVOKED")),1)):M("",!0),f("dl",null,[f("dt",null,R(h.$t("oauth2.APP.CLIENT_ID"))+":",1),f("dd",null,[G(R(i.value.client_id)+" ",1),g(s)&&d.value?(N(),C("i",{key:0,class:Te(`fa fa-${l.value?"check":"copy"}`),"aria-hidden":"true",title:h.$t("oauth2.COPY_TO_CLIPBOARD"),onClick:m},null,10,aQe)):M("",!0)]),g(s)&&i.value.client_secret?(N(),C("dt",sQe,R(h.$t("oauth2.APP.CLIENT_SECRET"))+": ",1)):M("",!0),g(s)&&i.value.client_secret?(N(),C("dd",rQe,[G(R(i.value.client_secret)+" ",1),d.value?(N(),C("i",{key:0,class:Te(`fa fa-${E.value?"check":"copy"}`),"aria-hidden":"true",title:h.$t("oauth2.COPY_TO_CLIPBOARD"),onClick:S},null,10,iQe)):M("",!0)])):M("",!0),f("dt",null,R(Be(h.$t("oauth2.APP.ISSUE_AT")))+":",1),f("dd",null,[f("time",null,R(g(Vn)(i.value.issued_at,g(r).timezone,g(r).date_format)),1)]),f("dt",null,R(h.$t("oauth2.APP.NAME"))+":",1),f("dd",null,R(i.value.name),1),f("dt",null,R(h.$t("oauth2.APP.DESCRIPTION"))+":",1),f("dd",{class:Te({"no-description":!i.value.client_description})},R(i.value.client_description?i.value.client_description:h.$t("oauth2.NO_DESCRIPTION")),3),f("dt",null,R(h.$t("oauth2.APP.URL"))+":",1),f("dd",null,R(i.value.website),1),f("dt",null,R(h.$t("oauth2.APP.REDIRECT_URL"))+":",1),f("dd",null,R(i.value.redirect_uris.length>0?i.value.redirect_uris[0]:""),1),f("dt",null,R(h.$t("oauth2.APP.SCOPE.LABEL"))+":",1),f("dd",oQe,[(N(!0),C(_e,null,$e(i.value.scope.split(" "),b=>(N(),C("span",{class:"client-scope",key:b},[f("code",null,R(b),1)]))),128))])]),f("div",uQe,[f("button",{class:"danger",onClick:_[3]||(_[3]=b=>T(!1))},R(h.$t("oauth2.REVOKE_ALL_TOKENS")),1),f("button",{class:"danger",onClick:_[4]||(_[4]=b=>T(!0))},R(h.$t("oauth2.DELETE_APP")),1),f("button",{onClick:_[5]||(_[5]=b=>h.$router.push("/profile/apps"))},R(h.$t("buttons.BACK")),1)])])):(N(),C("div",lQe,[f("p",cQe,R(h.$t("oauth2.NO_APP")),1),f("button",{onClick:_[6]||(_[6]=b=>h.$router.push("/profile/apps"))},R(h.$t("buttons.BACK")),1)]))])}}}),o0=ue(dQe,[["__scopeId","data-v-3a7d0f4a"]]),EQe={id:"oauth2-apps-list"},pQe={class:"apps-list"},fQe={key:0},mQe={class:"app-issued-at"},TQe={key:1,class:"no-apps"},_Qe={class:"app-list-buttons"},hQe=ae({__name:"UserAppsList",props:{authUser:{}},setup(e){const t=e,n=Ue(),a=bt(),{authUser:s}=he(t),r=z(()=>n.getters[Ze.GETTERS.CLIENTS]),i=z(()=>n.getters[Ze.GETTERS.CLIENTS_PAGINATION]);let o=u(a.query);ft(()=>{c(o)});function u(l){const E={};return l.page&&(E.page=Zd(l.page,VA)),E}function c(l){n.dispatch(Ze.ACTIONS.GET_CLIENTS,l)}return Me(()=>a.query,async l=>{o=u(l),c(o)}),(l,E)=>{const d=ie("router-link");return N(),C("div",EQe,[f("p",pQe,R(l.$t("oauth2.APPS_LIST")),1),r.value.length>0?(N(),C("ul",fQe,[(N(!0),C(_e,null,$e(r.value,p=>(N(),C("li",{key:p.client_id},[x(d,{to:{name:"UserApp",params:{id:p.id}}},{default:le(()=>[G(R(p.name),1)]),_:2},1032,["to"]),f("span",mQe,[G(R(l.$t("oauth2.APP.ISSUE_AT"))+" ",1),f("time",null,R(g(Vn)(p.issued_at,g(s).timezone,g(s).date_format)),1)])]))),128))])):(N(),C("div",TQe,R(l.$t("oauth2.NO_APPS")),1)),r.value.length>0?(N(),j(Mu,{key:2,pagination:i.value,path:"/profile/apps",query:g(o)},null,8,["pagination","query"])):M("",!0),f("div",_Qe,[f("button",{onClick:E[0]||(E[0]=p=>l.$router.push("/profile/apps/new"))},R(l.$t("oauth2.NEW_APP")),1),f("button",{onClick:E[1]||(E[1]=p=>l.$router.push("/"))},R(l.$t("common.HOME")),1)])])}}}),SQe=ue(hQe,[["__scopeId","data-v-064a87b7"]]);function jc(e){return e===0?!1:Array.isArray(e)&&e.length===0?!0:!e}function AQe(e){return(...t)=>!e(...t)}function OQe(e,t){return e===void 0&&(e="undefined"),e===null&&(e="null"),e===!1&&(e="false"),e.toString().toLowerCase().indexOf(t.trim())!==-1}function mO(e,t,n,a){return t?e.filter(s=>OQe(a(s,n),t)).sort((s,r)=>a(s,n).length-a(r,n).length):e}function gQe(e){return e.filter(t=>!t.$isLabel)}function Yc(e,t){return n=>n.reduce((a,s)=>s[e]&&s[e].length?(a.push({$groupLabel:s[t],$isLabel:!0}),a.concat(s[e])):a,[])}function IQe(e,t,n,a,s){return r=>r.map(i=>{if(!i[n])return console.warn("Options passed to vue-multiselect do not contain groups, despite the config."),[];const o=mO(i[n],e,t,s);return o.length?{[a]:i[a],[n]:o}:[]})}const u0=(...e)=>t=>e.reduce((n,a)=>a(n),t);var RQe={data(){return{search:"",isOpen:!1,preferredOpenDirection:"below",optimizedHeight:this.maxHeight}},props:{internalSearch:{type:Boolean,default:!0},options:{type:Array,required:!0},multiple:{type:Boolean,default:!1},trackBy:{type:String},label:{type:String},searchable:{type:Boolean,default:!0},clearOnSelect:{type:Boolean,default:!0},hideSelected:{type:Boolean,default:!1},placeholder:{type:String,default:"Select option"},allowEmpty:{type:Boolean,default:!0},resetAfter:{type:Boolean,default:!1},closeOnSelect:{type:Boolean,default:!0},customLabel:{type:Function,default(e,t){return jc(e)?"":t?e[t]:e}},taggable:{type:Boolean,default:!1},tagPlaceholder:{type:String,default:"Press enter to create a tag"},tagPosition:{type:String,default:"top"},max:{type:[Number,Boolean],default:!1},id:{default:null},optionsLimit:{type:Number,default:1e3},groupValues:{type:String},groupLabel:{type:String},groupSelect:{type:Boolean,default:!1},blockKeys:{type:Array,default(){return[]}},preserveSearch:{type:Boolean,default:!1},preselectFirst:{type:Boolean,default:!1},preventAutofocus:{type:Boolean,default:!1}},mounted(){!this.multiple&&this.max&&console.warn("[Vue-Multiselect warn]: Max prop should not be used when prop Multiple equals false."),this.preselectFirst&&!this.internalValue.length&&this.options.length&&this.select(this.filteredOptions[0])},computed:{internalValue(){return this.modelValue||this.modelValue===0?Array.isArray(this.modelValue)?this.modelValue:[this.modelValue]:[]},filteredOptions(){const e=this.search||"",t=e.toLowerCase().trim();let n=this.options.concat();return this.internalSearch?n=this.groupValues?this.filterAndFlat(n,t,this.label):mO(n,t,this.label,this.customLabel):n=this.groupValues?Yc(this.groupValues,this.groupLabel)(n):n,n=this.hideSelected?n.filter(AQe(this.isSelected)):n,this.taggable&&t.length&&!this.isExistingOption(t)&&(this.tagPosition==="bottom"?n.push({isTag:!0,label:e}):n.unshift({isTag:!0,label:e})),n.slice(0,this.optionsLimit)},valueKeys(){return this.trackBy?this.internalValue.map(e=>e[this.trackBy]):this.internalValue},optionKeys(){return(this.groupValues?this.flatAndStrip(this.options):this.options).map(t=>this.customLabel(t,this.label).toString().toLowerCase())},currentOptionLabel(){return this.multiple?this.searchable?"":this.placeholder:this.internalValue.length?this.getOptionLabel(this.internalValue[0]):this.searchable?"":this.placeholder}},watch:{internalValue:{handler(){this.resetAfter&&this.internalValue.length&&(this.search="",this.$emit("update:modelValue",this.multiple?[]:null))},deep:!0},search(){this.$emit("search-change",this.search)}},emits:["open","search-change","close","select","update:modelValue","remove","tag"],methods:{getValue(){return this.multiple?this.internalValue:this.internalValue.length===0?null:this.internalValue[0]},filterAndFlat(e,t,n){return u0(IQe(t,n,this.groupValues,this.groupLabel,this.customLabel),Yc(this.groupValues,this.groupLabel))(e)},flatAndStrip(e){return u0(Yc(this.groupValues,this.groupLabel),gQe)(e)},updateSearch(e){this.search=e},isExistingOption(e){return this.options?this.optionKeys.indexOf(e)>-1:!1},isSelected(e){const t=this.trackBy?e[this.trackBy]:e;return this.valueKeys.indexOf(t)>-1},isOptionDisabled(e){return!!e.$isDisabled},getOptionLabel(e){if(jc(e))return"";if(e.isTag)return e.label;if(e.$isLabel)return e.$groupLabel;const t=this.customLabel(e,this.label);return jc(t)?"":t},select(e,t){if(e.$isLabel&&this.groupSelect){this.selectGroup(e);return}if(!(this.blockKeys.indexOf(t)!==-1||this.disabled||e.$isDisabled||e.$isLabel)&&!(this.max&&this.multiple&&this.internalValue.length===this.max)&&!(t==="Tab"&&!this.pointerDirty)){if(e.isTag)this.$emit("tag",e.label,this.id),this.search="",this.closeOnSelect&&!this.multiple&&this.deactivate();else{if(this.isSelected(e)){t!=="Tab"&&this.removeElement(e);return}this.multiple?this.$emit("update:modelValue",this.internalValue.concat([e])):this.$emit("update:modelValue",e),this.$emit("select",e,this.id),this.clearOnSelect&&(this.search="")}this.closeOnSelect&&this.deactivate()}},selectGroup(e){const t=this.options.find(n=>n[this.groupLabel]===e.$groupLabel);if(t){if(this.wholeGroupSelected(t)){this.$emit("remove",t[this.groupValues],this.id);const n=this.trackBy?t[this.groupValues].map(s=>s[this.trackBy]):t[this.groupValues],a=this.internalValue.filter(s=>n.indexOf(this.trackBy?s[this.trackBy]:s)===-1);this.$emit("update:modelValue",a)}else{let n=t[this.groupValues].filter(a=>!(this.isOptionDisabled(a)||this.isSelected(a)));this.max&&n.splice(this.max-this.internalValue.length),this.$emit("select",n,this.id),this.$emit("update:modelValue",this.internalValue.concat(n))}this.closeOnSelect&&this.deactivate()}},wholeGroupSelected(e){return e[this.groupValues].every(t=>this.isSelected(t)||this.isOptionDisabled(t))},wholeGroupDisabled(e){return e[this.groupValues].every(this.isOptionDisabled)},removeElement(e,t=!0){if(this.disabled||e.$isDisabled)return;if(!this.allowEmpty&&this.internalValue.length<=1){this.deactivate();return}const n=typeof e=="object"?this.valueKeys.indexOf(e[this.trackBy]):this.valueKeys.indexOf(e);if(this.multiple){const a=this.internalValue.slice(0,n).concat(this.internalValue.slice(n+1));this.$emit("update:modelValue",a)}else this.$emit("update:modelValue",null);this.$emit("remove",e,this.id),this.closeOnSelect&&t&&this.deactivate()},removeLastElement(){this.blockKeys.indexOf("Delete")===-1&&this.search.length===0&&Array.isArray(this.internalValue)&&this.internalValue.length&&this.removeElement(this.internalValue[this.internalValue.length-1],!1)},activate(){this.isOpen||this.disabled||(this.adjustPosition(),this.groupValues&&this.pointer===0&&this.filteredOptions.length&&(this.pointer=1),this.isOpen=!0,this.searchable?(this.preserveSearch||(this.search=""),this.preventAutofocus||this.$nextTick(()=>this.$refs.search&&this.$refs.search.focus())):this.preventAutofocus||typeof this.$el<"u"&&this.$el.focus(),this.$emit("open",this.id))},deactivate(){this.isOpen&&(this.isOpen=!1,this.searchable?this.$refs.search!==null&&typeof this.$refs.search<"u"&&this.$refs.search.blur():typeof this.$el<"u"&&this.$el.blur(),this.preserveSearch||(this.search=""),this.$emit("close",this.getValue(),this.id))},toggle(){this.isOpen?this.deactivate():this.activate()},adjustPosition(){if(typeof window>"u")return;const e=this.$el.getBoundingClientRect().top,t=window.innerHeight-this.$el.getBoundingClientRect().bottom;t>this.maxHeight||t>e||this.openDirection==="below"||this.openDirection==="bottom"?(this.preferredOpenDirection="below",this.optimizedHeight=Math.min(t-40,this.maxHeight)):(this.preferredOpenDirection="above",this.optimizedHeight=Math.min(e-40,this.maxHeight))}}},NQe={data(){return{pointer:0,pointerDirty:!1}},props:{showPointer:{type:Boolean,default:!0},optionHeight:{type:Number,default:40}},computed:{pointerPosition(){return this.pointer*this.optionHeight},visibleElements(){return this.optimizedHeight/this.optionHeight}},watch:{filteredOptions(){this.pointerAdjust()},isOpen(){this.pointerDirty=!1},pointer(){this.$refs.search&&this.$refs.search.setAttribute("aria-activedescendant",this.id+"-"+this.pointer.toString())}},methods:{optionHighlight(e,t){return{"multiselect__option--highlight":e===this.pointer&&this.showPointer,"multiselect__option--selected":this.isSelected(t)}},groupHighlight(e,t){if(!this.groupSelect)return["multiselect__option--disabled",{"multiselect__option--group":t.$isLabel}];const n=this.options.find(a=>a[this.groupLabel]===t.$groupLabel);return n&&!this.wholeGroupDisabled(n)?["multiselect__option--group",{"multiselect__option--highlight":e===this.pointer&&this.showPointer},{"multiselect__option--group-selected":this.wholeGroupSelected(n)}]:"multiselect__option--disabled"},addPointerElement({key:e}="Enter"){this.filteredOptions.length>0&&this.select(this.filteredOptions[this.pointer],e),this.pointerReset()},pointerForward(){this.pointer0?(this.pointer--,this.$refs.list.scrollTop>=this.pointerPosition&&(this.$refs.list.scrollTop=this.pointerPosition),this.filteredOptions[this.pointer]&&this.filteredOptions[this.pointer].$isLabel&&!this.groupSelect&&this.pointerBackward()):this.filteredOptions[this.pointer]&&this.filteredOptions[0].$isLabel&&!this.groupSelect&&this.pointerForward(),this.pointerDirty=!0},pointerReset(){this.closeOnSelect&&(this.pointer=0,this.$refs.list&&(this.$refs.list.scrollTop=0))},pointerAdjust(){this.pointer>=this.filteredOptions.length-1&&(this.pointer=this.filteredOptions.length?this.filteredOptions.length-1:0),this.filteredOptions.length>0&&this.filteredOptions[this.pointer].$isLabel&&!this.groupSelect&&this.pointerForward()},pointerSet(e){this.pointer=e,this.pointerDirty=!0}}},TO={name:"vue-multiselect",mixins:[RQe,NQe],compatConfig:{MODE:3,ATTR_ENUMERATED_COERCION:!1},props:{name:{type:String,default:""},modelValue:{type:null,default(){return[]}},selectLabel:{type:String,default:"Press enter to select"},selectGroupLabel:{type:String,default:"Press enter to select group"},selectedLabel:{type:String,default:"Selected"},deselectLabel:{type:String,default:"Press enter to remove"},deselectGroupLabel:{type:String,default:"Press enter to deselect group"},showLabels:{type:Boolean,default:!0},limit:{type:Number,default:99999},maxHeight:{type:Number,default:300},limitText:{type:Function,default:e=>`and ${e} more`},loading:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},spellcheck:{type:Boolean,default:!1},openDirection:{type:String,default:""},showNoOptions:{type:Boolean,default:!0},showNoResults:{type:Boolean,default:!0},tabindex:{type:Number,default:0},required:{type:Boolean,default:!1}},computed:{hasOptionGroup(){return this.groupValues&&this.groupLabel&&this.groupSelect},isSingleLabelVisible(){return(this.singleValue||this.singleValue===0)&&(!this.isOpen||!this.searchable)&&!this.visibleValues.length},isPlaceholderVisible(){return!this.internalValue.length&&(!this.searchable||!this.isOpen)},visibleValues(){return this.multiple?this.internalValue.slice(0,this.limit):[]},singleValue(){return this.internalValue[0]},deselectLabelText(){return this.showLabels?this.deselectLabel:""},deselectGroupLabelText(){return this.showLabels?this.deselectGroupLabel:""},selectLabelText(){return this.showLabels?this.selectLabel:""},selectGroupLabelText(){return this.showLabels?this.selectGroupLabel:""},selectedLabelText(){return this.showLabels?this.selectedLabel:""},inputStyle(){return this.searchable||this.multiple&&this.modelValue&&this.modelValue.length?this.isOpen?{width:"100%"}:{width:"0",position:"absolute",padding:"0"}:""},contentStyle(){return this.options.length?{display:"inline-block"}:{display:"block"}},isAbove(){return this.openDirection==="above"||this.openDirection==="top"?!0:this.openDirection==="below"||this.openDirection==="bottom"?!1:this.preferredOpenDirection==="above"},showSearchInput(){return this.searchable&&(this.hasSingleSelectedSlot&&(this.visibleSingleValue||this.visibleSingleValue===0)?this.isOpen:!0)}}};const vQe={ref:"tags",class:"multiselect__tags"},bQe={class:"multiselect__tags-wrap"},CQe={class:"multiselect__spinner"},DQe={key:0},PQe={class:"multiselect__option"},LQe={class:"multiselect__option"},yQe=G("No elements found. Consider changing the search query."),$Qe={class:"multiselect__option"},kQe=G("List is empty.");function UQe(e,t,n,a,s,r){return N(),j("div",{tabindex:e.searchable?-1:n.tabindex,class:[{"multiselect--active":e.isOpen,"multiselect--disabled":n.disabled,"multiselect--above":r.isAbove,"multiselect--has-options-group":r.hasOptionGroup},"multiselect"],onFocus:t[14]||(t[14]=i=>e.activate()),onBlur:t[15]||(t[15]=i=>e.searchable?!1:e.deactivate()),onKeydown:[t[16]||(t[16]=Ye(De(i=>e.pointerForward(),["self","prevent"]),["down"])),t[17]||(t[17]=Ye(De(i=>e.pointerBackward(),["self","prevent"]),["up"]))],onKeypress:t[18]||(t[18]=Ye(De(i=>e.addPointerElement(i),["stop","self"]),["enter","tab"])),onKeyup:t[19]||(t[19]=Ye(i=>e.deactivate(),["esc"])),role:"combobox","aria-owns":"listbox-"+e.id},[Lt(e.$slots,"caret",{toggle:e.toggle},()=>[x("div",{onMousedown:t[1]||(t[1]=De(i=>e.toggle(),["prevent","stop"])),class:"multiselect__select"},null,32)]),Lt(e.$slots,"clear",{search:e.search}),x("div",vQe,[Lt(e.$slots,"selection",{search:e.search,remove:e.removeElement,values:r.visibleValues,isOpen:e.isOpen},()=>[ke(x("div",bQe,[(N(!0),j(_e,null,$e(r.visibleValues,(i,o)=>Lt(e.$slots,"tag",{option:i,search:e.search,remove:e.removeElement},()=>[(N(),j("span",{class:"multiselect__tag",key:o},[x("span",{textContent:R(e.getOptionLabel(i))},null,8,["textContent"]),x("i",{tabindex:"1",onKeypress:Ye(De(u=>e.removeElement(i),["prevent"]),["enter"]),onMousedown:De(u=>e.removeElement(i),["prevent"]),class:"multiselect__tag-icon"},null,40,["onKeypress","onMousedown"])]))])),256))],512),[[yr,r.visibleValues.length>0]]),e.internalValue&&e.internalValue.length>n.limit?Lt(e.$slots,"limit",{key:0},()=>[x("strong",{class:"multiselect__strong",textContent:R(n.limitText(e.internalValue.length-n.limit))},null,8,["textContent"])]):M("v-if",!0)]),x(df,{name:"multiselect__loading"},{default:le(()=>[Lt(e.$slots,"loading",{},()=>[ke(x("div",CQe,null,512),[[yr,n.loading]])])]),_:3}),e.searchable?(N(),j("input",{key:0,ref:"search",name:n.name,id:e.id,type:"text",autocomplete:"off",spellcheck:n.spellcheck,placeholder:e.placeholder,required:n.required,style:r.inputStyle,value:e.search,disabled:n.disabled,tabindex:n.tabindex,onInput:t[2]||(t[2]=i=>e.updateSearch(i.target.value)),onFocus:t[3]||(t[3]=De(i=>e.activate(),["prevent"])),onBlur:t[4]||(t[4]=De(i=>e.deactivate(),["prevent"])),onKeyup:t[5]||(t[5]=Ye(i=>e.deactivate(),["esc"])),onKeydown:[t[6]||(t[6]=Ye(De(i=>e.pointerForward(),["prevent"]),["down"])),t[7]||(t[7]=Ye(De(i=>e.pointerBackward(),["prevent"]),["up"])),t[9]||(t[9]=Ye(De(i=>e.removeLastElement(),["stop"]),["delete"]))],onKeypress:t[8]||(t[8]=Ye(De(i=>e.addPointerElement(i),["prevent","stop","self"]),["enter"])),class:"multiselect__input","aria-controls":"listbox-"+e.id},null,44,["name","id","spellcheck","placeholder","required","value","disabled","tabindex","aria-controls"])):M("v-if",!0),r.isSingleLabelVisible?(N(),j("span",{key:1,class:"multiselect__single",onMousedown:t[10]||(t[10]=De((...i)=>e.toggle&&e.toggle(...i),["prevent"]))},[Lt(e.$slots,"singleLabel",{option:r.singleValue},()=>[G(R(e.currentOptionLabel),1)])],32)):M("v-if",!0),r.isPlaceholderVisible?(N(),j("span",{key:2,class:"multiselect__placeholder",onMousedown:t[11]||(t[11]=De((...i)=>e.toggle&&e.toggle(...i),["prevent"]))},[Lt(e.$slots,"placeholder",{},()=>[G(R(e.placeholder),1)])],32)):M("v-if",!0)],512),x(df,{name:"multiselect"},{default:le(()=>[ke(x("div",{class:"multiselect__content-wrapper",onFocus:t[12]||(t[12]=(...i)=>e.activate&&e.activate(...i)),tabindex:"-1",onMousedown:t[13]||(t[13]=De(()=>{},["prevent"])),style:{maxHeight:e.optimizedHeight+"px"},ref:"list"},[x("ul",{class:"multiselect__content",style:r.contentStyle,role:"listbox",id:"listbox-"+e.id,"aria-multiselectable":e.multiple},[Lt(e.$slots,"beforeList"),e.multiple&&e.max===e.internalValue.length?(N(),j("li",DQe,[x("span",PQe,[Lt(e.$slots,"maxElements",{},()=>[G("Maximum of "+R(e.max)+" options selected. First remove a selected option to select another.",1)])])])):M("v-if",!0),!e.max||e.internalValue.length(N(),j("li",{class:"multiselect__element",key:o,"aria-selected":e.isSelected(i),id:e.id+"-"+o,role:i&&(i.$isLabel||i.$isDisabled)?null:"option"},[i&&(i.$isLabel||i.$isDisabled)?M("v-if",!0):(N(),j("span",{key:0,class:[e.optionHighlight(o,i),"multiselect__option"],onClick:De(u=>e.select(i),["stop"]),onMouseenter:De(u=>e.pointerSet(o),["self"]),"data-select":i&&i.isTag?e.tagPlaceholder:r.selectLabelText,"data-selected":r.selectedLabelText,"data-deselect":r.deselectLabelText},[Lt(e.$slots,"option",{option:i,search:e.search,index:o},()=>[x("span",null,R(e.getOptionLabel(i)),1)])],42,["onClick","onMouseenter","data-select","data-selected","data-deselect"])),i&&(i.$isLabel||i.$isDisabled)?(N(),j("span",{key:1,"data-select":e.groupSelect&&r.selectGroupLabelText,"data-deselect":e.groupSelect&&r.deselectGroupLabelText,class:[e.groupHighlight(o,i),"multiselect__option"],onMouseenter:De(u=>e.groupSelect&&e.pointerSet(o),["self"]),onMousedown:De(u=>e.selectGroup(i),["prevent"])},[Lt(e.$slots,"option",{option:i,search:e.search,index:o},()=>[x("span",null,R(e.getOptionLabel(i)),1)])],42,["data-select","data-deselect","onMouseenter","onMousedown"])):M("v-if",!0)],8,["aria-selected","id","role"]))),128)):M("v-if",!0),ke(x("li",null,[x("span",LQe,[Lt(e.$slots,"noResult",{search:e.search},()=>[yQe])])],512),[[yr,n.showNoResults&&e.filteredOptions.length===0&&e.search&&!n.loading]]),ke(x("li",null,[x("span",$Qe,[Lt(e.$slots,"noOptions",{},()=>[kQe])])],512),[[yr,n.showNoOptions&&(e.options.length===0||r.hasOptionGroup===!0&&e.filteredOptions.length===0)&&!e.search&&!n.loading]]),Lt(e.$slots,"afterList")],12,["id","aria-multiselectable"])],36),[[yr,e.isOpen]])]),_:3})],42,["tabindex","aria-owns"])}TO.render=UQe;const wQe=ae({__name:"SportsMultiSelect",props:{sports:{},name:{},equipmentSports:{default:()=>[]},disabled:{type:Boolean,default:!1}},emits:["updatedValues"],setup(e,{emit:t}){const n=e,a=t,{equipmentSports:s,name:r,sports:i}=he(n),o=pe([]);ft(()=>{s.value&&(o.value=s.value)});function u(c){a("updatedValues",c.map(l=>l.id))}return Me(()=>s.value,async c=>{o.value=c,u(c)}),(c,l)=>g(i)?(N(),j(g(TO),{key:0,placeholder:"",id:g(r),name:g(r),disabled:c.disabled,modelValue:o.value,"onUpdate:modelValue":[l[0]||(l[0]=E=>o.value=E),u],multiple:!0,options:g(i),taggable:!0,label:"translatedLabel","track-by":"id",selectLabel:c.$t("workouts.MULTISELECT.selectLabel"),selectedLabel:c.$t("workouts.MULTISELECT.selectedLabel"),deselectLabel:c.$t("workouts.MULTISELECT.deselectLabel")},null,8,["id","name","disabled","modelValue","options","selectLabel","selectedLabel","deselectLabel"])):M("",!0)}}),MQe=ue(wQe,[["__scopeId","data-v-ea940e1c"]]),WQe={id:"new-equipment"},zQe={key:0,id:"new-equipment-title"},xQe={id:"equipment-form"},FQe={class:"form-items"},BQe={class:"form-item"},GQe={for:"equipment-label"},HQe={class:"equipment-label-help"},VQe={class:"info-box"},qQe={class:"form-item"},KQe={for:"equipment-type-id"},jQe=["value"],YQe={key:0,class:"equipment-warning"},XQe={class:"info-box"},QQe={class:"form-item"},ZQe={for:"equipment-description"},JQe={key:1,class:"form-item-checkbox"},eZe={for:"equipment-active"},tZe={class:"form-item"},nZe={for:"equipment-sports"},aZe={class:"form-buttons"},sZe=["disabled"],rZe=["disabled"],iZe=ae({__name:"EquipmentEdition",props:{equipments:{},translatedEquipmentTypes:{}},setup(e){const t=e,n=Ue(),a=bt(),{t:s}=Ct(),{equipments:r,translatedEquipmentTypes:i}=he(t),o=z(()=>n.getters[We.GETTERS.LOADING]),u=z(()=>m(r.value)),c=z(()=>n.getters[V.GETTERS.ERROR_MESSAGES]),l=Kt({id:"",label:"",description:"",equipmentTypeId:0,isActive:!0,defaultForSportIds:[]}),E=z(()=>Hn(n.getters[vt.GETTERS.SPORTS],s)),d=z(()=>i.value.filter(D=>D.id===l.equipmentTypeId)),p=z(()=>d.value.length>0?E.value.filter(D=>BA[d.value[0].label].includes(D.label)):[]),T=pe([]),A=z(()=>i.value.filter(D=>{var L;return D.is_active||((L=u.value)==null?void 0:L.equipment_type.id)===D.id})),I=pe(!1);mt(()=>{var L;const D=document.getElementById("equipment-label");D==null||D.focus(),a.params.id&&a.params.id&&(L=u.value)!=null&&L.id&&h(u.value)});function m(D){if(!a.params.id)return null;const L=D.filter(U=>a.params.id?U.id===a.params.id:null);return L.length===0?null:L[0]}function S(D){T.value=Hn(E.value,s,"all").filter(L=>D.default_for_sport_ids.includes(L.id))}function h(D){l.id=D.id,l.label=D.label,l.description=D.description?D.description:"",l.equipmentTypeId=D.equipment_type.id,l.isActive=D.is_active,S(D)}function _(){n.dispatch(We.ACTIONS[l.id?"UPDATE_EQUIPMENT":"ADD_EQUIPMENT"],l)}function O(D){l.description=D}function b(){I.value=!0}function v(D){l.defaultForSportIds=D}return ct(()=>{n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)}),Me(()=>u.value,D=>{a.params.id&&(D!=null&&D.id)&&h(D)}),Me(()=>l.equipmentTypeId,D=>{u.value&&D===u.value.equipment_type.id?S(u.value):T.value=[]}),(D,L)=>{var W,Y;const U=ie("CustomTextArea"),$=ie("ErrorMessage");return N(),C("div",WQe,[l.id?M("",!0):(N(),C("h1",zQe,R(D.$t("equipments.ADD_A_NEW_EQUIPMENT")),1)),f("div",xQe,[f("form",{class:Te({errors:I.value}),onSubmit:De(_,["prevent"])},[f("div",FQe,[f("div",BQe,[f("label",GQe,R(Be(D.$t("common.LABEL")))+"* ",1),ke(f("input",{id:"equipment-label",maxlength:"50",type:"text",required:"",onInvalid:b,"onUpdate:modelValue":L[0]||(L[0]=te=>l.label=te)},null,544),[[et,l.label]]),f("div",HQe,[f("span",VQe,[L[4]||(L[4]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(D.$t("equipments.50_CHARACTERS_MAX")),1)])])]),f("div",qQe,[f("label",KQe,R(Be(D.$t("equipments.EQUIPMENT_TYPE")))+"* ",1),ke(f("select",{id:"equipment-type-id",required:"",onInvalid:b,"onUpdate:modelValue":L[1]||(L[1]=te=>l.equipmentTypeId=te)},[(N(!0),C(_e,null,$e(A.value,te=>(N(),C("option",{value:te.id,key:te.id},R(te.translatedLabel)+" "+R(te.is_active?"":`(${D.$t("common.INACTIVE")})`),9,jQe))),128))],544),[[Ta,l.equipmentTypeId]])]),(W=u.value)!=null&&W.workouts_count&&l.equipmentTypeId!==((Y=u.value)==null?void 0:Y.equipment_type.id)?(N(),C("div",YQe,[f("span",XQe,[L[5]||(L[5]=f("i",{class:"fa fa-exclamation-triangle warning","aria-hidden":"true"},null,-1)),G(" "+R(D.$t("equipments.ALL_WORKOUTS_ASSOCIATIONS_REMOVED")),1)])])):M("",!0),f("div",QQe,[f("label",ZQe,R(D.$t("common.DESCRIPTION")),1),x(U,{name:"equipment-description",charLimit:200,input:l.description,onUpdateValue:O},null,8,["input"])]),l.id?(N(),C("div",JQe,[f("label",eZe,R(Be(D.$t("common.ACTIVE"))),1),ke(f("input",{id:"equipment-active",name:"equipment-active",type:"checkbox","onUpdate:modelValue":L[2]||(L[2]=te=>l.isActive=te)},null,512),[[Qu,l.isActive]])])):M("",!0),f("div",tZe,[f("label",nZe,R(Be(D.$t("equipments.DEFAULT_FOR_SPORTS",0))),1),x(MQe,{sports:p.value,name:"equipment-sports",equipmentSports:T.value,disabled:!l.equipmentTypeId,onUpdatedValues:v},null,8,["sports","equipmentSports","disabled"])])]),c.value?(N(),j($,{key:0,message:c.value},null,8,["message"])):M("",!0),f("div",aZe,[f("button",{class:"confirm",type:"submit",disabled:o.value},R(D.$t("buttons.SUBMIT")),9,sZe),f("button",{class:"cancel",disabled:o.value,onClick:L[3]||(L[3]=De(()=>{var te;return D.$router.push((te=u.value)!=null&&te.id?g(a).query.fromEdition?"/profile/edit/equipments":`/profile/equipments/${u.value.id}`:"/profile/equipments")},["prevent"]))},R(D.$t("buttons.CANCEL")),9,rZe)])],34)])])}}}),l0=ue(iZe,[["__scopeId","data-v-28e798a0"]]),oZe={key:0,id:"user-equipments"},c0=ae({__name:"index",props:{user:{},isEdition:{type:Boolean}},setup(e){const t=e,n=Ue(),{t:a}=Ct(),{user:s}=he(t),r=bt(),i=z(()=>n.getters[We.GETTERS.EQUIPMENTS]),o=z(()=>n.getters[We.GETTERS.EQUIPMENT_TYPES]),u=z(()=>FA(o.value,a));return ft(()=>{n.dispatch(We.ACTIONS.GET_EQUIPMENT_TYPES),n.dispatch(We.ACTIONS.GET_EQUIPMENTS)}),ct(()=>{n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)}),Me(()=>r.name,c=>{c==="UserEquipmentsList"&&n.dispatch(We.ACTIONS.GET_EQUIPMENTS)}),(c,l)=>{const E=ie("router-view");return u.value?(N(),C("div",oZe,[x(E,{authUser:g(s),equipments:i.value,translatedEquipmentTypes:u.value,isEdition:c.isEdition},null,8,["authUser","equipments","translatedEquipmentTypes","isEdition"])])):M("",!0)}}}),uZe=(e,t=!1)=>{let n="0";t&&(n=String(Math.floor(e/86400)),e%=86400);const a=String(Math.floor(e/3600)).padStart(2,"0");e%=3600;const s=String(Math.floor(e/60)).padStart(2,"0"),r=String(e%60).padStart(2,"0");return t?`${n==="0"?"":`${n}d `}${a==="00"?"":`${a}h `}${s}m ${r}s`:`${a==="00"?"":`${a}:`}${s}:${r}`},Cp=(e,t)=>{const n=e.match(/day/g)?e.split(", ")[1]:e;return{days:e.match(/day/g)?`${e.split(" ")[0]} ${e.match(/days/g)?t("common.DAY",2):t("common.DAY",1)}`:`0 ${t("common.DAY",2)},`,duration:`${n.split(":")[0]}h ${n.split(":")[1]}min`}},sE=(e,t)=>{if(e.match(/day/g)){const n=Cp(e,t);return`${n.days}, ${n.duration}`}return e},lZe={key:0,id:"user-equipment",class:"description-list"},cZe={class:"equipment-type"},dZe={key:0,class:"equipment-description"},EZe={key:1,class:"no-description"},pZe={class:"duration-detail"},fZe={class:"sports-list"},mZe={class:"equipment-buttons"},TZe=["disabled"],_Ze=["disabled"],hZe=["disabled"],SZe=["disabled"],AZe={key:1},OZe={class:"no-equipment"},gZe=["disabled"],IZe=ae({__name:"UserEquipment",props:{authUser:{},equipments:{}},setup(e){const t=e,n=Ue(),a=bt(),{t:s}=Ct(),{authUser:r,equipments:i}=he(t),o=It("sportColors"),u=z(()=>n.getters[We.GETTERS.LOADING]),c=z(()=>p(i.value)),l=z(()=>n.getters[vt.GETTERS.SPORTS]),E=z(()=>Hn(l.value,s,"all",r.value.sports_list).filter(m=>{var S;return c.value?(S=c.value)==null?void 0:S.default_for_sport_ids.includes(m.id):!1})),d=pe(!1);ft(()=>{n.dispatch(We.ACTIONS.GET_EQUIPMENTS)});function p(m){if(!a.params.id)return null;const S=m.filter(h=>a.params.id?h.id===a.params.id:null);return S.length===0?null:S[0]}function T(m){d.value=m}function A(){var m,S;if((m=c.value)!=null&&m.id){const h={id:c.value.id};((S=c.value)==null?void 0:S.workouts_count)>0&&(h.force=!0),n.dispatch(We.ACTIONS.DELETE_EQUIPMENT,h)}}function I(m){n.dispatch(We.ACTIONS.REFRESH_EQUIPMENT,m)}return ct(()=>{n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)}),(m,S)=>{const h=ie("Modal"),_=ie("EquipmentTypeImage"),O=ie("router-link"),b=ie("Distance"),v=ie("SportImage");return c.value?(N(),C("div",lZe,[d.value?(N(),j(h,{key:0,title:m.$t("common.CONFIRMATION"),message:"user.PROFILE.EQUIPMENTS.CONFIRM_EQUIPMENT_DELETION",strongMessage:c.value.label,warning:c.value.workouts_count>0?m.$t("user.PROFILE.EQUIPMENTS.EQUIPMENT_ASSOCIATED_WITH_WORKOUTS"):"",onConfirmAction:A,onCancelAction:S[0]||(S[0]=D=>T(!1)),onKeydown:S[1]||(S[1]=Ye(D=>T(!1),["esc"]))},null,8,["title","strongMessage","warning"])):M("",!0),f("dl",null,[f("dt",null,R(Be(m.$t("common.LABEL"))),1),f("dd",null,R(c.value.label),1),f("dt",null,R(Be(m.$t("equipments.EQUIPMENT_TYPE"))),1),f("dd",cZe,[x(_,{title:m.$t(`equipment_types.${c.value.equipment_type.label}.LABEL`),"equipment-type-label":c.value.equipment_type.label},null,8,["title","equipment-type-label"]),f("span",null,R(m.$t(`equipment_types.${c.value.equipment_type.label}.LABEL`))+" "+R(c.value.equipment_type.is_active?"":`(${m.$t("common.INACTIVE")})`),1)]),f("dt",null,R(m.$t("common.DESCRIPTION")),1),f("dd",null,[c.value.description?(N(),C("span",dZe,R(c.value.description),1)):(N(),C("span",EZe,R(m.$t("common.NO_DESCRIPTION")),1))]),f("dt",null,R(Be(m.$t("workouts.WORKOUT",0))),1),f("dd",null,[c.value.workouts_count?(N(),j(O,{key:0,to:`/workouts?equipment_id=${c.value.id}`},{default:le(()=>[G(R(c.value.workouts_count),1)]),_:1},8,["to"])):(N(),C(_e,{key:1},[G(R(c.value.workouts_count),1)],64))]),f("dt",null,R(Be(m.$t("workouts.TOTAL_DISTANCE",0))),1),f("dd",null,[x(b,{distance:c.value.total_distance,unitFrom:"km",digits:2,displayUnit:!1,useImperialUnits:g(r).imperial_units},null,8,["distance","useImperialUnits"]),f("span",null,R(g(r).imperial_units?"miles":"km"),1)]),f("dt",null,R(Be(m.$t("workouts.TOTAL_DURATION",0))),1),f("dd",null,[G(R(g(sE)(c.value.total_moving,m.$t))+" ",1),c.value.total_duration!==c.value.total_moving?(N(),C(_e,{key:0},[S[7]||(S[7]=G(" (")),f("span",pZe,R(m.$t("common.TOTAL_DURATION_WITH_PAUSES"))+": ",1),G(" "+R(g(sE)(c.value.total_duration,m.$t))+") ",1)],64)):M("",!0)]),f("dt",null,R(Be(m.$t("common.ACTIVE",0))),1),f("dd",null,[f("i",{class:Te(`fa fa-${c.value.is_active?"check-":""}square-o`),"aria-hidden":"true"},null,2)]),c.value.default_for_sport_ids.length>0?(N(),C(_e,{key:0},[f("dt",null,R(Be(m.$t("equipments.DEFAULT_FOR_SPORTS",0))),1),f("dd",fZe,[(N(!0),C(_e,null,$e(E.value,D=>(N(),C("span",{class:Te(["sport-badge",{inactive:!D.is_active_for_user}]),key:D.label},[x(v,{title:D.translatedLabel,"sport-label":D.label,color:D.color?D.color:g(o)[D.label]},null,8,["title","sport-label","color"]),x(O,{to:`/profile/sports/${D.id}?fromEquipmentId=${c.value.id}`},{default:le(()=>[G(R(D.translatedLabel)+" "+R(D.is_active_for_user?"":`(${m.$t("common.INACTIVE")})`),1)]),_:2},1032,["to"])],2))),128))])],64)):M("",!0)]),f("div",mZe,[f("button",{onClick:S[2]||(S[2]=D=>m.$router.push(`/profile/edit/equipments/${c.value.id}`)),disabled:u.value},R(m.$t("buttons.EDIT")),9,TZe),f("button",{disabled:u.value,onClick:S[3]||(S[3]=D=>I(c.value.id))},R(m.$t("buttons.REFRESH_TOTALS")),9,_Ze),f("button",{class:"danger",onClick:S[4]||(S[4]=D=>d.value=!0),disabled:u.value},R(m.$t("buttons.DELETE")),9,hZe),f("button",{disabled:u.value,onClick:S[5]||(S[5]=D=>m.$router.push(g(a).query.fromWorkoutId?`/workouts/${g(a).query.fromWorkoutId}`:g(a).query.fromSportId?`/profile/sports/${g(a).query.fromSportId}`:"/profile/equipments"))},R(m.$t("buttons.BACK")),9,SZe)])])):(N(),C("div",AZe,[f("p",OZe,R(m.$t("equipments.NO_EQUIPMENT")),1),f("button",{onClick:S[6]||(S[6]=D=>m.$router.push("/profile/equipments")),disabled:u.value},R(m.$t("buttons.BACK")),9,gZe)]))}}}),RZe=ue(IZe,[["__scopeId","data-v-bb9ee5a6"]]),NZe={id:"user-equipments-list"},vZe={key:0,class:"mobile-display"},bZe={key:1,class:"equipments-list"},CZe={key:3},DZe={class:"responsive-table"},PZe={class:"text-left"},LZe={class:"text-left"},yZe={class:"text-left"},$Ze={class:"text-left"},kZe={key:0},UZe={class:"equipment-label"},wZe={class:"cell-heading"},MZe={class:"column"},WZe={class:"cell-heading"},zZe={class:"column"},xZe={class:"cell-heading"},FZe={class:"active"},BZe={class:"cell-heading"},GZe={key:0,class:"action-buttons"},HZe={class:"cell-heading"},VZe=["onClick"],qZe={class:"equipments-list-buttons"},KZe=ae({__name:"UserEquipmentsList",props:{equipments:{},translatedEquipmentTypes:{},authUser:{},isEdition:{type:Boolean}},setup(e){const t=e,{authUser:n,isEdition:a,equipments:s,translatedEquipmentTypes:r}=he(t),i=z(()=>o(s.value));function o(u){const c={};return u.map(l=>{l.equipment_type.id in c?c[l.equipment_type.id].push(l):c[l.equipment_type.id]=[l]}),c}return(u,c)=>{const l=ie("EquipmentTypeImage"),E=ie("router-link"),d=ie("Distance");return N(),C("div",NZe,[g(s).length>0?(N(),C("div",vZe,[g(a)?M("",!0):(N(),C("button",{key:0,onClick:c[0]||(c[0]=p=>u.$router.push("/profile/edit/equipments"))},R(u.$t("equipments.EDIT_EQUIPMENTS")),1)),g(a)?M("",!0):(N(),C("button",{key:1,onClick:c[1]||(c[1]=p=>u.$router.push("/profile/equipments/new"))},R(u.$t("equipments.NEW_EQUIPMENT")),1)),g(a)?(N(),C("button",{key:2,onClick:c[2]||(c[2]=p=>u.$router.push("/profile/equipments"))},R(u.$t("buttons.BACK")),1)):(N(),C("button",{key:3,onClick:c[3]||(c[3]=p=>u.$router.push("/"))},R(u.$t("common.HOME")),1))])):M("",!0),g(a)?M("",!0):(N(),C("h1",bZe,R(u.$t("user.PROFILE.EQUIPMENTS.YOUR_EQUIPMENTS")),1)),g(s).length===0?(N(),C("p",{key:2,class:Te(["no-equipments",{edition:g(a)}])},R(u.$t("equipments.NO_EQUIPMENTS")),3)):(N(),C("div",CZe,[(N(!0),C(_e,null,$e(g(r),p=>(N(),C(_e,{key:p.label},[i.value[p.id]?(N(),C(_e,{key:0},[f("h2",null,[x(l,{title:p.translatedLabel,"equipment-type-label":p.label},null,8,["title","equipment-type-label"]),G(" "+R(p.translatedLabel)+" "+R(p.is_active?"":`(${u.$t("common.INACTIVE")})`),1)]),f("div",DZe,[f("table",null,[f("thead",null,[f("tr",null,[f("th",PZe,R(u.$t("common.LABEL")),1),f("th",LZe,R(u.$t("workouts.WORKOUT",0)),1),f("th",yZe,R(Be(u.$t("workouts.TOTAL_DISTANCE"))),1),f("th",$Ze,R(u.$t("common.ACTIVE")),1),g(a)?(N(),C("th",kZe,R(u.$t("common.ACTION")),1)):M("",!0),c[8]||(c[8]=f("th",null,null,-1))])]),f("tbody",null,[(N(!0),C(_e,null,$e(i.value[p.id].sort(g(gp)),T=>(N(),C("tr",{key:T.label},[f("td",UZe,[f("span",wZe,R(u.$t("common.LABEL")),1),x(E,{to:{name:"Equipment",params:{id:T.id}}},{default:le(()=>[G(R(T.label),1)]),_:2},1032,["to"])]),f("td",MZe,[f("span",WZe,R(u.$t("workouts.WORKOUT",0)),1),T.workouts_count?(N(),j(E,{key:0,to:`/workouts?equipment_id=${T.id}`},{default:le(()=>[G(R(T.workouts_count),1)]),_:2},1032,["to"])):(N(),C(_e,{key:1},[G(R(T.workouts_count),1)],64))]),f("td",zZe,[f("span",xZe,R(u.$t("workouts.TOTAL_DISTANCE",0)),1),x(d,{distance:T.total_distance,unitFrom:"km",digits:2,displayUnit:!1,useImperialUnits:g(n).imperial_units},null,8,["distance","useImperialUnits"]),f("span",null,R(g(n).imperial_units?"miles":"km"),1)]),f("td",FZe,[f("span",BZe,R(u.$t("common.ACTIVE")),1),f("i",{class:Te(`fa fa${T.is_active?"-check":""}`),"aria-hidden":"true"},null,2)]),g(a)?(N(),C("td",GZe,[f("span",HZe,R(u.$t("user.PROFILE.SPORT.ACTION")),1),f("button",{onClick:A=>u.$router.push(`/profile/edit/equipments/${T.id}${g(a)?"?fromEdition=true":""}`)},R(u.$t("buttons.EDIT")),9,VZe)])):M("",!0)]))),128))])])])],64)):M("",!0)],64))),128))])),f("div",qZe,[!g(a)&&g(s).length>0?(N(),C("button",{key:0,onClick:c[4]||(c[4]=p=>u.$router.push("/profile/edit/equipments"))},R(u.$t("equipments.EDIT_EQUIPMENTS")),1)):M("",!0),g(a)?M("",!0):(N(),C("button",{key:1,onClick:c[5]||(c[5]=p=>u.$router.push("/profile/equipments/new"))},R(u.$t("equipments.NEW_EQUIPMENT")),1)),g(a)?(N(),C("button",{key:2,onClick:c[6]||(c[6]=p=>u.$router.push("/profile/equipments"))},R(u.$t("buttons.BACK")),1)):(N(),C("button",{key:3,onClick:c[7]||(c[7]=p=>u.$router.push("/"))},R(u.$t("common.HOME")),1))])])}}}),d0=ue(KZe,[["__scopeId","data-v-b2876f25"]]),jZe={id:"users-sports"},E0=ae({__name:"index",props:{user:{},isEdition:{type:Boolean}},setup(e){const t=e,n=Ue(),{t:a}=Ct(),{user:s,isEdition:r}=he(t),i=z(()=>n.getters[vt.GETTERS.SPORTS]),o=z(()=>Hn(i.value,a,"is_active",s.value.sports_list));return ct(()=>{n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),n.commit(Ze.MUTATIONS.SET_CLIENTS,[])}),(u,c)=>{const l=ie("router-view");return N(),C("div",jZe,[x(l,{authUser:g(s),isEdition:g(r),translatedSports:o.value},null,8,["authUser","isEdition","translatedSports"])])}}}),YZe=ae({__name:"EquipmentBadge",props:{equipment:{},workoutId:{},sportId:{}},setup(e){const t=e,{equipment:n,sportId:a,workoutId:s}=he(t);return(r,i)=>{var c;const o=ie("EquipmentTypeImage"),u=ie("router-link");return N(),j(u,{class:Te(["equipment-badge",{inactive:!g(n).is_active}]),to:{name:"Equipment",params:{id:g(n).id},query:{fromWorkoutId:g(s),fromSportId:(c=g(a))==null?void 0:c.toString()}}},{default:le(()=>[x(o,{title:r.$t(`equipment_types.${g(n).equipment_type.label}.LABEL`),"equipment-type-label":g(n).equipment_type.label},null,8,["title","equipment-type-label"]),f("span",null,R(g(n).label)+" "+R(g(n).is_active?"":`(${r.$t("common.INACTIVE")})`),1)]),_:1},8,["class","to"])}}}),_O=ue(YZe,[["__scopeId","data-v-84285cae"]]);function Dp(){const e=Ue(),t=z(()=>e.getters[V.GETTERS.ERROR_MESSAGES]),n=z(()=>e.getters[ee.GETTERS.USER_LOADING]),a="#838383",s=It("sportColors"),r=pe(!1),i=pe(""),o=Kt({sport_id:0,color:null,is_active:!0,stopped_speed_threshold:1,fromSport:!1});function u(d){o.is_active=d.target.checked}function c(d){r.value=d}function l(d){const p={...o};p.stopped_speed_threshold=d.imperial_units?Vt(o.stopped_speed_threshold,"mi","km",2):o.stopped_speed_threshold,e.dispatch(ee.ACTIONS.UPDATE_USER_SPORT_PREFERENCES,p)}function E(d,p=!1){e.dispatch(ee.ACTIONS.RESET_USER_SPORT_PREFERENCES,{sportId:d,fromSport:p})}return{defaultColor:a,defaultEquipmentId:i,displayModal:r,errorMessages:t,loading:n,sportColors:s,sportPayload:o,resetSport:E,updateDisplayModal:c,updateIsActive:u,updateSport:l}}const XZe={key:0,id:"user-sport",class:"description-list"},QZe={class:"sport-equipments"},ZZe={key:0,class:"no-equipments"},JZe={class:"sport-buttons"},eJe=["disabled"],tJe={key:1},nJe={class:"no-sport"},aJe=ae({__name:"UserSport",props:{authUser:{},translatedSports:{}},setup(e){const t=e,n=Ue(),a=bt(),{translatedSports:s}=he(t),{displayModal:r,errorMessages:i,loading:o,sportColors:u,resetSport:c,updateDisplayModal:l}=Dp(),E=z(()=>d(s.value));function d(p){if(!a.params.id)return null;const T=p.filter(A=>a.params.id?A.id===+a.params.id:null);return T.length===0?null:T[0]}return ct(()=>{n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)}),Me(()=>o.value,p=>{!p&&!i.value&&l(!1)}),(p,T)=>{const A=ie("Modal"),I=ie("SportImage"),m=ie("Distance");return E.value?(N(),C("div",XZe,[g(r)?(N(),j(A,{key:0,title:p.$t("common.CONFIRMATION"),message:p.$t(`user.PROFILE.SPORT.CONFIRM_SPORT_RESET${E.value.default_equipments.length>0?"_WITH_EQUIPMENTS":""}`),onConfirmAction:T[0]||(T[0]=S=>g(c)(E.value.id,!0)),onCancelAction:T[1]||(T[1]=S=>g(l)(!1)),onKeydown:T[2]||(T[2]=Ye(S=>g(l)(!1),["esc"]))},null,8,["title","message"])):M("",!0),f("dl",null,[f("dt",null,R(Be(p.$t("workouts.SPORT",1))),1),f("dd",null,R(E.value.translatedLabel),1),f("dt",null,R(Be(p.$t("user.PROFILE.SPORT.COLOR"))),1),f("dd",null,[x(I,{title:E.value.translatedLabel,"sport-label":E.value.label,color:E.value.color?E.value.color:g(u)[E.value.label]},null,8,["title","sport-label","color"])]),f("dt",null,R(Be(p.$t("workouts.WORKOUT",0))),1),f("dd",null,[f("i",{class:Te(`fa fa-${p.authUser.sports_list.includes(E.value.id)?"check-":""}square-o`),"aria-hidden":"true"},null,2)]),f("dt",null,R(Be(p.$t("user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD"))),1),f("dd",null,[x(m,{distance:E.value.stopped_speed_threshold,unitFrom:"km",speed:!0,useImperialUnits:p.authUser.imperial_units},null,8,["distance","useImperialUnits"])]),f("dt",null,R(Be(p.$t("common.ACTIVE",0))),1),f("dd",null,[f("i",{class:Te(`fa fa-${E.value.is_active_for_user?"check-":""}square-o`),"aria-hidden":"true"},null,2)]),f("dt",null,R(p.$t("user.PROFILE.SPORT.DEFAULT_EQUIPMENTS",1)),1),f("dd",QZe,[(N(!0),C(_e,null,$e(E.value.default_equipments,S=>(N(),j(_O,{equipment:S,"sport-id":E.value.id,key:S.label},null,8,["equipment","sport-id"]))),128)),E.value.default_equipments.length===0?(N(),C("div",ZZe,R(p.$t("equipments.NO_EQUIPMENTS")),1)):M("",!0)])]),f("div",JZe,[f("button",{onClick:T[3]||(T[3]=S=>p.$router.push(`/profile/edit/sports/${E.value.id}`))},R(p.$t("buttons.EDIT")),1),f("button",{disabled:g(o),class:"danger",onClick:T[4]||(T[4]=De(S=>g(l)(!0),["prevent"]))},R(p.$t("buttons.RESET")),9,eJe),f("button",{onClick:T[5]||(T[5]=S=>p.$router.push(g(a).query.fromEquipmentId?`/profile/equipments/${g(a).query.fromEquipmentId}`:"/profile/sports"))},R(p.$t("buttons.BACK")),1)])])):(N(),C("div",tJe,[f("p",nJe,R(p.$t("user.NO_SPORT_FOUND")),1),f("button",{onClick:T[6]||(T[6]=S=>p.$router.push("/profile/sports"))},R(p.$t("buttons.BACK")),1)]))}}}),sJe=ue(aJe,[["__scopeId","data-v-1211593f"]]),rJe={key:0,id:"sport-edition"},iJe={class:"form-items"},oJe={class:"form-item"},uJe={for:"sport-label"},lJe={class:"form-item"},cJe={for:"sport-color"},dJe=["disabled"],EJe={class:"form-item"},pJe={for:"sport-threshold"},fJe=["disabled"],mJe={class:"form-item-checkbox"},TJe={for:"equipment-active"},_Je=["checked","disabled"],hJe={class:"form-item"},SJe={for:"sport-default-equipment"},AJe=["disabled"],OJe={value:""},gJe=["value"],IJe={class:"form-buttons"},RJe=["disabled"],NJe=["disabled"],vJe=ae({__name:"UserSportEdition",props:{authUser:{},translatedSports:{}},setup(e){const t=e,{t:n}=Ct(),a=Ue(),s=bt(),{authUser:r,translatedSports:i}=he(t),{defaultColor:o,defaultEquipmentId:u,errorMessages:c,loading:l,sportColors:E,sportPayload:d,updateIsActive:p,updateSport:T}=Dp(),A=z(()=>h(i.value)),I=z(()=>a.getters[We.GETTERS.EQUIPMENTS]),m=z(()=>I.value&&A.value?GA(I.value,n,"withIncludedIds",A.value,A.value.default_equipments.map(v=>v.id)):[]),S=pe(!1);mt(()=>{var D;const v=document.getElementById("sport-color");v==null||v.focus(),s.params.id&&s.params.id&&(D=A.value)!=null&&D.id&&_(A.value,!0)});function h(v){if(!s.params.id)return null;const D=v.filter(L=>s.params.id?L.id===+s.params.id:null);return D.length===0?null:D[0]}function _(v,D=!1){v!==null&&(d.sport_id=v.id,d.color=v.color?v.color:E?E[v.label]:o,d.is_active=v.is_active_for_user,d.stopped_speed_threshold=+`${r.value.imperial_units?Vt(v.stopped_speed_threshold,"km","mi",2):parseFloat(v.stopped_speed_threshold.toFixed(2))}`,d.fromSport=!0,D&&(u.value=v.default_equipments.length>0?v.default_equipments[0].id:""))}function O(){d.default_equipment_ids=u.value?[u.value]:[],T(r.value)}function b(){S.value=!0}return Me(()=>A.value,v=>{s.params.id&&(v!=null&&v.id)&&_(v,!0)}),(v,D)=>{const L=ie("ErrorMessage");return A.value?(N(),C("div",rJe,[f("form",{class:Te({errors:S.value}),onSubmit:De(O,["prevent"])},[f("div",iJe,[f("div",oJe,[f("label",uJe,R(Be(v.$t("workouts.SPORT",1))),1),G(" "+R(A.value.translatedLabel),1)]),f("div",lJe,[f("label",cJe,R(Be(v.$t("user.PROFILE.SPORT.COLOR"))),1),ke(f("input",{id:"sport-color",name:"sport-color",class:"sport-color",type:"color",required:"","onUpdate:modelValue":D[0]||(D[0]=U=>g(d).color=U),disabled:g(l),onInvalid:b},null,40,dJe),[[et,g(d).color]])]),f("div",EJe,[f("label",pJe,R(Be(v.$t("user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD")))+" ("+R(`${g(r).imperial_units?"mi":"km"}/h`)+")* ",1),ke(f("input",{id:"sport-threshold",name:"sport-threshold",class:"threshold-input",type:"number",min:"0",step:"0.1",required:"","onUpdate:modelValue":D[1]||(D[1]=U=>g(d).stopped_speed_threshold=U),disabled:g(l),onInvalid:b},null,40,fJe),[[et,g(d).stopped_speed_threshold]])]),f("div",mJe,[f("label",TJe,R(Be(v.$t("common.ACTIVE"))),1),f("input",{id:"equipment-active",name:"equipment-active",type:"checkbox",checked:A.value.is_active_for_user,onChange:D[2]||(D[2]=(...U)=>g(p)&&g(p)(...U)),disabled:g(l)},null,40,_Je)]),f("div",hJe,[f("label",SJe,R(v.$t("user.PROFILE.SPORT.DEFAULT_EQUIPMENTS",1)),1),ke(f("select",{id:"sport-default-equipment",onInvalid:b,disabled:g(l),"onUpdate:modelValue":D[3]||(D[3]=U=>Bt(u)?u.value=U:null)},[f("option",OJe,R(v.$t("equipments.NO_EQUIPMENTS")),1),(N(!0),C(_e,null,$e(m.value,U=>(N(),C("option",{value:U.id,key:U.id},R(U.label),9,gJe))),128))],40,AJe),[[Ta,g(u)]])])]),g(c)?(N(),j(L,{key:0,message:g(c)},null,8,["message"])):M("",!0),f("div",IJe,[f("button",{class:"confirm",type:"submit",disabled:g(l)},R(v.$t("buttons.SUBMIT")),9,RJe),f("button",{class:"cancel",onClick:D[4]||(D[4]=De(()=>{var U;return v.$router.push(`/profile/sports/${(U=A.value)==null?void 0:U.id}`)},["prevent"])),disabled:g(l)},R(v.$t("buttons.CANCEL")),9,NJe)])],34)])):M("",!0)}}}),bJe=ue(vJe,[["__scopeId","data-v-7f3cff18"]]),CJe={id:"user-sport-preferences"},DJe={key:1,class:"responsive-table"},PJe={class:"mobile-display"},LJe={key:0,class:"profile-buttons mobile-display"},yJe={key:1,class:"profile-buttons"},$Je={class:"text-left"},kJe={class:"threshold"},UJe={key:0},wJe={class:"cell-heading"},MJe={class:"cell-heading"},WJe={key:2,class:"disabled-message"},zJe={key:3,class:"fa fa-refresh fa-spin fa-fw"},xJe={class:"cell-heading"},FJe={class:"cell-heading"},BJe={class:"cell-heading"},GJe=["checked"],HJe={class:"cell-heading"},VJe={key:1},qJe={key:0,class:"action-buttons"},KJe={class:"cell-heading"},jJe=["onClick"],YJe={key:1,class:"edition-buttons"},XJe=["disabled"],QJe=["disabled"],ZJe=["disabled"],JJe={key:0,class:"profile-buttons"},eet={key:1,class:"profile-buttons"},tet=ae({__name:"UserSportPreferences",props:{authUser:{},translatedSports:{},isEdition:{type:Boolean}},setup(e){const t=e,n=Ue(),{authUser:a,isEdition:s,translatedSports:r}=he(t),{defaultColor:i,displayModal:o,errorMessages:u,loading:c,sportColors:l,sportPayload:E,resetSport:d,updateDisplayModal:p,updateIsActive:T,updateSport:A}=Dp(),I=pe(!1);function m(_){_!==null?(E.sport_id=_.id,E.color=_.color?_.color:l?l[_.label]:i,E.is_active=_.is_active_for_user,E.stopped_speed_threshold=+`${a.value.imperial_units?Vt(_.stopped_speed_threshold,"km","mi",2):parseFloat(_.stopped_speed_threshold.toFixed(2))}`,I.value=_.default_equipments.length>0):h()}function S(_){return E.sport_id===_}function h(){E.sport_id=0,E.color=null,E.is_active=!0,E.stopped_speed_threshold=1,I.value=!1,n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)}return Me(()=>c.value,_=>{!_&&!u.value&&(h(),p(!1))}),(_,O)=>{const b=ie("Modal"),v=ie("SportImage"),D=ie("router-link"),L=ie("ErrorMessage"),U=ie("Distance");return N(),C("div",CJe,[g(o)?(N(),j(b,{key:0,title:_.$t("common.CONFIRMATION"),message:_.$t(`user.PROFILE.SPORT.CONFIRM_SPORT_RESET${I.value?"_WITH_EQUIPMENTS":""}`),onConfirmAction:O[0]||(O[0]=$=>g(d)(g(E).sport_id)),onCancelAction:O[1]||(O[1]=$=>g(p)(!1)),onKeydown:O[2]||(O[2]=Ye($=>g(p)(!1),["esc"]))},null,8,["title","message"])):M("",!0),g(r).length>0?(N(),C("div",DJe,[f("div",PJe,[g(s)?(N(),C("div",LJe,[f("button",{class:"cancel",onClick:O[3]||(O[3]=De($=>_.$router.push("/profile/sports"),["prevent"]))},R(_.$t("buttons.BACK")),1)])):(N(),C("div",yJe,[f("button",{onClick:O[4]||(O[4]=$=>_.$router.push("/profile/edit/sports"))},R(_.$t("user.PROFILE.EDIT_SPORTS_PREFERENCES")),1),f("button",{onClick:O[5]||(O[5]=$=>_.$router.push("/"))},R(_.$t("common.HOME")),1)]))]),f("table",null,[f("thead",null,[f("tr",null,[f("th",null,R(_.$t("user.PROFILE.SPORT.COLOR")),1),f("th",$Je,R(_.$t("workouts.SPORT",0)),1),f("th",null,R(_.$t("workouts.WORKOUT",0)),1),f("th",null,R(_.$t("equipments.EQUIPMENT",0)),1),f("th",null,R(_.$t("user.PROFILE.SPORT.IS_ACTIVE")),1),f("th",null,[f("div",kJe,[f("span",null,R(_.$t("user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD")),1),f("span",null," ("+R(`${g(a).imperial_units?"mi":"km"}/h`)+") ",1)])]),g(s)?(N(),C("th",UJe,R(_.$t("user.PROFILE.SPORT.ACTION")),1)):M("",!0)])]),f("tbody",null,[(N(!0),C(_e,null,$e(g(r),$=>(N(),C("tr",{key:$.id},[f("td",null,[f("span",wJe,R(_.$t("user.PROFILE.SPORT.COLOR")),1),S($.id)?ke((N(),C("input",{key:0,class:"sport-color",type:"color","onUpdate:modelValue":O[6]||(O[6]=W=>g(E).color=W)},null,512)),[[et,g(E).color]]):(N(),j(v,{key:1,title:$.translatedLabel,"sport-label":$.label,color:$.color?$.color:g(l)[$.label]},null,8,["title","sport-label","color"]))]),f("td",{class:Te(["sport-label",{"disabled-sport":!$.is_active}])},[f("span",MJe,R(_.$t("user.PROFILE.SPORT.LABEL")),1),S($.id)?(N(),C(_e,{key:0},[G(R($.translatedLabel),1)],64)):(N(),j(D,{key:1,to:`/profile/sports/${$.id}`},{default:le(()=>[G(R($.translatedLabel),1)]),_:2},1032,["to"])),$.is_active?M("",!0):(N(),C("span",WJe," ("+R(_.$t("user.PROFILE.SPORT.DISABLED_BY_ADMIN"))+") ",1)),g(c)&&S($.id)?(N(),C("i",zJe)):M("",!0),g(u)&&g(E).sport_id===$.id?(N(),j(L,{key:4,message:g(u)},null,8,["message"])):M("",!0)],2),f("td",{class:Te(["text-center",{"disabled-sport":!$.is_active}])},[f("span",xJe,R(_.$t("workouts.WORKOUT",0)),1),f("i",{class:Te(`fa fa${g(a).sports_list.includes($.id)?"-check":""}`),"aria-hidden":"true"},null,2)],2),f("td",{class:Te(["text-center",{"disabled-sport":!$.is_active}])},[f("span",FJe,R(_.$t("equipments.EQUIPMENT",0)),1),f("i",{class:Te(`fa fa${$.default_equipments.length>0?"-check":""}`),"aria-hidden":"true"},null,2)],2),f("td",{class:Te(["text-center",{"disabled-sport":!$.is_active}])},[f("span",BJe,R(_.$t("user.PROFILE.SPORT.IS_ACTIVE")),1),S($.id)&&$.is_active?(N(),C("input",{key:0,type:"checkbox",checked:$.is_active_for_user,onChange:O[7]||(O[7]=(...W)=>g(T)&&g(T)(...W))},null,40,GJe)):(N(),C("i",{key:1,class:Te(`fa fa${$.is_active_for_user?"-check":""}`),"aria-hidden":"true"},null,2))],2),f("td",{class:Te(["text-center",{"disabled-sport":!$.is_active}])},[f("span",HJe,R(_.$t("user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD"))+" "+R(`${g(a).imperial_units?"mi":"km"}/h`),1),S($.id)&&$.is_active?ke((N(),C("input",{key:0,class:"threshold-input",type:"number",min:"0",step:"0.1","onUpdate:modelValue":O[8]||(O[8]=W=>g(E).stopped_speed_threshold=W)},null,512)),[[et,g(E).stopped_speed_threshold]]):(N(),C("span",VJe,[x(U,{distance:$.stopped_speed_threshold,unitFrom:"km",speed:!0,useImperialUnits:g(a).imperial_units,displayUnit:!1},null,8,["distance","useImperialUnits"])]))],2),g(s)?(N(),C("td",qJe,[f("span",KJe,R(_.$t("user.PROFILE.SPORT.ACTION")),1),g(E).sport_id===0?(N(),C("button",{key:0,onClick:W=>m($)},R(_.$t("buttons.EDIT")),9,jJe)):M("",!0),S($.id)?(N(),C("div",YJe,[f("button",{disabled:g(c),onClick:O[9]||(O[9]=De(W=>g(A)(g(a)),["prevent"]))},R(_.$t("buttons.SUBMIT")),9,XJe),f("button",{disabled:g(c),class:"warning",onClick:O[10]||(O[10]=De(W=>g(p)(!0),["prevent"]))},R(_.$t("buttons.RESET")),9,QJe),f("button",{disabled:g(c),onClick:O[11]||(O[11]=W=>m(null))},R(_.$t("buttons.CANCEL")),9,ZJe)])):M("",!0)])):M("",!0)]))),128))])]),g(s)?(N(),C("div",JJe,[f("button",{class:"cancel",onClick:O[12]||(O[12]=De($=>_.$router.push("/profile/sports"),["prevent"]))},R(_.$t("buttons.BACK")),1)])):(N(),C("div",eet,[f("button",{onClick:O[13]||(O[13]=$=>_.$router.push("/profile/edit/sports"))},R(_.$t("user.PROFILE.EDIT_SPORTS_PREFERENCES")),1),f("button",{onClick:O[14]||(O[14]=$=>_.$router.push("/"))},R(_.$t("common.HOME")),1)]))])):M("",!0)])}}}),p0=ue(tet,[["__scopeId","data-v-6c042f49"]]),net={class:"about-text"},aet=["innerHTML"],set=["href"],ret={href:"https://github.com/SamR1/FitTrackee",target:"_blank",rel:"noopener noreferrer"},iet={key:0},oet=["href"],uet={key:1},cet=["href"],det={class:"about-instance"},Eet=["innerHTML"],pet=ae({__name:"About",setup(e){const t=Ue(),n=z(()=>t.getters[V.GETTERS.APP_CONFIG]),a=z(()=>i()),s=z(()=>t.getters[V.GETTERS.LANGUAGE]),r=z(()=>o());function i(){const u={};return n.value.weather_provider==="visualcrossing"&&(u.name="Visual Crossing",u.url="https://www.visualcrossing.com"),u}function o(){let u="https://samr1.github.io/FitTrackee/";return s.value==="fr"&&(u+="fr/"),u}return(u,c)=>{const l=ie("i18n-t");return N(),C("div",net,[f("div",null,[f("p",{class:"error-message",innerHTML:u.$t("about.FITTRACKEE_DESCRIPTION")},null,8,aet),f("p",null,[c[0]||(c[0]=f("i",{class:"fa fa-book fa-padding","aria-hidden":"true"},null,-1)),f("a",{class:"documentation-link",href:r.value,target:"_blank",rel:"noopener noreferrer"},R(Be(u.$t("common.DOCUMENTATION"))),9,set)]),f("p",null,[c[1]||(c[1]=f("i",{class:"fa fa-github fa-padding","aria-hidden":"true"},null,-1)),f("a",ret,R(u.$t("about.SOURCE_CODE")),1)]),f("p",null,[c[3]||(c[3]=f("i",{class:"fa fa-balance-scale fa-padding","aria-hidden":"true"},null,-1)),x(l,{keypath:"about.FITTRACKEE_LICENSE"},{default:le(()=>c[2]||(c[2]=[f("a",{href:"https://choosealicense.com/licenses/agpl-3.0/",target:"_blank",rel:"noopener noreferrer"},"AGPLv3",-1)])),_:1})]),n.value.admin_contact?(N(),C("div",iet,[c[4]||(c[4]=f("i",{class:"fa fa-envelope-o fa-padding","aria-hidden":"true"},null,-1)),f("a",{href:`mailto:${n.value.admin_contact}`},R(u.$t("about.CONTACT_ADMIN")),9,oet)])):M("",!0),a.value&&a.value.name?(N(),C("div",uet,[G(R(u.$t("about.WEATHER_DATA_FROM"))+" ",1),f("a",{href:a.value.url,target:"_blank",rel:"nofollow noopener"},R(a.value.name),9,cet)])):M("",!0),n.value.about?(N(),C(_e,{key:2},[f("p",det,R(u.$t("about.ABOUT_THIS_INSTANCE")),1),f("div",{innerHTML:g(Pi)(n.value.about)},null,8,Eet)],64)):M("",!0)])])}}}),fet=ue(pet,[["__scopeId","data-v-ed135ec0"]]),met={},Tet={id:"bike"};function _et(e,t){return N(),C("div",Tet,t[0]||(t[0]=[f("img",{class:"bike-img",src:"/img/bike.svg",alt:"mountain bike"},null,-1)]))}const hO=ue(met,[["render",_et],["__scopeId","data-v-dc181e30"]]),het={id:"about",class:"view"},Aet={class:"container"},Oet={class:"container-sub"},get={class:"container-sub about-details"},Iet=ae({__name:"AboutView",setup(e){return(t,n)=>(N(),C("div",het,[f("div",Aet,[f("div",Oet,[x(hO)]),f("div",get,[x(fet)])])]))}}),Ret=ue(Iet,[["__scopeId","data-v-ef9c7198"]]),Net={id:"error"},vet={class:"error-content"},bet=ae({__name:"Error",props:{title:{},message:{},buttonText:{},path:{default:"/"}},setup(e){const t=e,{buttonText:n,title:a,message:s,path:r}=he(t);return(i,o)=>(N(),C("div",Net,[f("div",vet,[f("h1",null,R(g(a)),1),f("p",null,R(g(s)),1),g(n)?(N(),C("button",{key:0,onClick:o[0]||(o[0]=u=>i.$router.push(g(r))),class:"upper"},R(g(n)),1)):M("",!0)])]))}}),Cet=ue(bet,[["__scopeId","data-v-48ec856d"]]),Pp=ae({__name:"NotFound",props:{target:{default:"PAGE"}},setup(e){const t=e,{target:n}=he(t),a=pe(),s=pe(!1);mt(()=>r());function r(){a.value=setTimeout(()=>{s.value=!0},500)}return ct(()=>{a.value&&clearTimeout(a.value)}),(i,o)=>s.value?(N(),j(Cet,{key:0,title:"404",message:i.$t(`error.NOT_FOUND.${g(n)}`),"button-text":i.$t("common.HOME")},null,8,["message","button-text"])):M("",!0)}}),Det={id:"admin",class:"view"},Pet={key:0,class:"container"},Let=ae({__name:"AdminView",setup(e){const t=Ue(),n=z(()=>t.getters[V.GETTERS.APP_CONFIG]),a=z(()=>t.getters[V.GETTERS.APP_STATS]),s=z(()=>t.getters[ee.GETTERS.IS_ADMIN]),r=z(()=>t.getters[ee.GETTERS.USER_LOADING]);return ft(()=>t.dispatch(V.ACTIONS.GET_APPLICATION_STATS)),(i,o)=>{const u=ie("router-view");return N(),C("div",Det,[r.value?M("",!0):(N(),C("div",Pet,[s.value?(N(),j(u,{key:0,appConfig:n.value,appStatistics:a.value},null,8,["appConfig","appStatistics"])):(N(),j(Pp,{key:1})),o[0]||(o[0]=f("div",{id:"bottom"},null,-1))]))])}}}),yet=ue(Let,[["__scopeId","data-v-5eee0876"]]),SO="/img/workouts/mountains.svg",$et=["alt"],AO=ae({__name:"StaticMap",props:{workout:{},displayHover:{type:Boolean,default:!1}},setup(e){const t=e,{displayHover:n}=he(t),a=`${Fi()}workouts/map/${t.workout.map}`;return(s,r)=>{const i=ie("router-link");return N(),C("div",{class:Te(["static-map",{"display-hover":g(n)}])},[g(n)?(N(),C("img",{key:0,src:a,alt:s.$t("workouts.WORKOUT_MAP")},null,8,$et)):(N(),j(i,{key:1,class:"bg-map-image",to:{name:"Workout",params:{workoutId:s.workout.id}},style:wa({backgroundImage:`url(${a})`}),"aria-label":s.$t("workouts.WORKOUT_MAP")},null,8,["to","style","aria-label"])),r[0]||(r[0]=f("div",{class:"map-attribution"},[f("a",{class:"map-attribution-text",href:"https://www.openstreetmap.org/copyright",target:"_blank",rel:"noopener noreferrer"}," © OpenStreetMap ")],-1))],2)}}}),ket={class:"timeline-workout"},Uet={class:"box"},wet={class:"workout-user-date"},Met={class:"workout-user"},Wet=["datetime","title"],zet={class:"workout-map"},xet={class:"no-map"},Fet={class:"img"},Bet={class:"data"},Get={key:0},Het={class:"data"},Vet={key:0,class:"data elevation"},qet=["alt"],Ket={class:"data-values"},jet={key:1,class:"data altitude"},Yet={class:"data-values"},Xet=ae({__name:"WorkoutCard",props:{user:{},useImperialUnits:{type:Boolean},workout:{default:()=>({})},sport:{default:()=>({})}},setup(e){const t=e,n=Ue(),{user:a,workout:s,sport:r,useImperialUnits:i}=he(t),o=z(()=>n.getters[V.GETTERS.LOCALE]),u=z(()=>Vn(s.value.workout_date,a.value.timezone,a.value.date_format));function c(E){return E.with_gpx&&E.min_alt!==null&&E.max_alt!==null}function l(E){return c(E)&&E.ascent!==null&&E.descent!==null}return(E,d)=>{var I;const p=ie("router-link"),T=ie("SportImage"),A=ie("Distance");return N(),C("div",ket,[f("div",Uet,[f("div",wet,[f("div",Met,[x(Bi,{user:g(a)},null,8,["user"]),g(a).username?(N(),j(p,{key:0,class:"workout-user-name",to:{name:"User",params:{username:g(a).username}}},{default:le(()=>[G(R(g(a).username),1)]),_:1},8,["to"])):M("",!0)]),g(s).id?(N(),j(p,{key:0,class:"workout-title",to:{name:"Workout",params:{workoutId:g(s).id}}},{default:le(()=>[G(R(g(s).title),1)]),_:1},8,["to"])):M("",!0),g(s).workout_date&&g(a)?(N(),C("time",{key:1,class:"workout-date",datetime:u.value,title:u.value},R(g(t3)(new Date(g(s).workout_date),new Date,{addSuffix:!0,locale:o.value})),9,Wet)):M("",!0)]),f("div",zet,[g(s).with_gpx?(N(),j(AO,{key:0,workout:g(s)},null,8,["workout"])):g(s).id?(N(),j(p,{key:1,to:{name:"Workout",params:{workoutId:g(s).id}}},{default:le(()=>[f("div",xet,R(E.$t("workouts.NO_MAP")),1)]),_:1},8,["to"])):M("",!0)]),f("div",{class:Te(["workout-data",{"without-elevation":!c(g(s))}]),onClick:d[0]||(d[0]=m=>g(s).id?E.$router.push({name:"Workout",params:{workoutId:g(s).id}}):null)},[f("div",Fet,[(I=g(r))!=null&&I.label?(N(),j(T,{key:0,"sport-label":g(r).label,color:g(r).color},null,8,["sport-label","color"])):M("",!0)]),f("div",Bet,[d[1]||(d[1]=f("i",{class:"fa fa-clock-o","aria-hidden":"true"},null,-1)),g(s)?(N(),C("span",Get,R(g(s).moving),1)):M("",!0)]),f("div",Het,[d[2]||(d[2]=f("i",{class:"fa fa-road","aria-hidden":"true"},null,-1)),g(s).id?(N(),j(A,{key:0,distance:g(s).distance,digits:3,unitFrom:"km",useImperialUnits:g(i)},null,8,["distance","useImperialUnits"])):M("",!0)]),c(g(s))?(N(),C("div",Vet,[f("img",{class:"mountains",src:SO,alt:E.$t("workouts.ELEVATION")},null,8,qet),f("div",Ket,[g(s).id?(N(),j(A,{key:0,distance:g(s).min_alt,unitFrom:"m",displayUnit:!1,useImperialUnits:g(i)},null,8,["distance","useImperialUnits"])):M("",!0),d[3]||(d[3]=G("/ ")),g(s).id?(N(),j(A,{key:1,distance:g(s).max_alt,unitFrom:"m",useImperialUnits:g(i)},null,8,["distance","useImperialUnits"])):M("",!0)])])):M("",!0),l(g(s))?(N(),C("div",jet,[d[6]||(d[6]=f("i",{class:"fa fa-location-arrow","aria-hidden":"true"},null,-1)),f("div",Yet,[d[4]||(d[4]=G(" +")),g(s).id?(N(),j(A,{key:0,distance:g(s).ascent,unitFrom:"m",displayUnit:!1,useImperialUnits:g(i)},null,8,["distance","useImperialUnits"])):M("",!0),d[5]||(d[5]=G("/- ")),g(s).id?(N(),j(A,{key:1,distance:g(s).descent,unitFrom:"m",useImperialUnits:g(i)},null,8,["distance","useImperialUnits"])):M("",!0)])])):M("",!0)],2)])])}}}),f0=ue(Xet,[["__scopeId","data-v-ef89664d"]]),Qet={},Zet={class:"no-workouts box"};function Jet(e,t){const n=ie("router-link");return N(),C("div",Zet,[f("div",null,[G(R(e.$t("workouts.NO_WORKOUTS"))+" ",1),x(n,{to:"/workouts/add"},{default:le(()=>[G(R(e.$t("workouts.UPLOAD_FIRST_WORKOUT")),1)]),_:1})])])}const Lp=ue(Qet,[["render",Jet],["__scopeId","data-v-b0c91cc6"]]),es={ligthMode:{text:"#666",line:"rgba(0, 0, 0, 0.1)"},darkMode:{text:"#a1a1a1",line:"#3f3f3f"}},ett=(e,t,n,a=!1)=>{const s={speed:{label:t("workouts.SPEED"),backgroundColor:["transparent"],borderColor:[a?"#5f5c97":"#8884d8"],borderWidth:2,data:[],yAxisID:"ySpeed"},elevation:{label:t("workouts.ELEVATION"),backgroundColor:[a?"#303030":"#e5e5e5"],borderColor:[a?"#222222":"#cccccc"],borderWidth:1,fill:!0,data:[],yAxisID:"yElevation"}},r=[],i=[],o=[];return e.map(u=>{r.push(Mo("km",u.distance,n)),i.push(u.duration),s.speed.data.push(Mo("km",u.speed,n)),u.elevation!==void 0&&s.elevation.data.push(Mo("m",u.elevation,n)),o.push({latitude:u.latitude,longitude:u.longitude})}),{distance_labels:r,duration_labels:i,datasets:s,coordinates:o}},ttt=e=>{const t=e.length;if(t===0)return{};const n={};return e.map(a=>{n[a.sport_id]||(n[a.sport_id]={count:0,percentage:0}),n[a.sport_id].count+=1,n[a.sport_id].percentage=n[a.sport_id].count/t}),n},yi={order:"desc",order_by:"workout_date"},ntt={id:"timeline"},att={class:"section-title"},stt={key:0},rtt={key:1},itt={key:1,class:"more-workouts"},Io=5,ott=ae({__name:"Timeline",props:{sports:{},user:{}},setup(e){const t=e,n=Ue(),{sports:a,user:s}=he(t),r=pe(1),i=t.user.nb_workouts>=Io?Io:t.user.nb_workouts;ft(()=>c());const o=z(()=>n.getters[Oe.GETTERS.TIMELINE_WORKOUTS]),u=z(()=>o.value.length>0?o.value[o.value.length-1].previous_workout!==null:!1);function c(){n.dispatch(Oe.ACTIONS.GET_TIMELINE_WORKOUTS,{page:r.value,per_page:Io,...yi})}function l(){r.value+=1,n.dispatch(Oe.ACTIONS.GET_MORE_TIMELINE_WORKOUTS,{page:r.value,per_page:Io,...yi})}return(E,d)=>(N(),C("div",ntt,[f("div",att,R(E.$t("workouts.LATEST_WORKOUTS")),1),g(s).nb_workouts>0&&o.value.length===0?(N(),C("div",stt,[(N(!0),C(_e,null,$e([...Array(g(i)).keys()],p=>(N(),j(f0,{user:g(s),useImperialUnits:g(s).imperial_units,key:p},null,8,["user","useImperialUnits"]))),128))])):(N(),C("div",rtt,[(N(!0),C(_e,null,$e(o.value,p=>(N(),j(f0,{workout:p,sport:o.value.length>0?g(a).filter(T=>T.id===p.sport_id)[0]:null,user:g(s),useImperialUnits:g(s).imperial_units,key:p.id},null,8,["workout","sport","user","useImperialUnits"]))),128)),o.value.length===0?(N(),j(Lp,{key:0})):M("",!0),u.value?(N(),C("div",itt,[f("button",{onClick:l},R(E.$t("workouts.LOAD_MORE_WORKOUT")),1)])):M("",!0)]))]))}}),utt=ue(ott,[["__scopeId","data-v-e0964959"]]),ltt=["title"],OO=ae({__name:"CalendarWorkout",props:{displayHARecord:{type:Boolean},workout:{},sportLabel:{},sportColor:{}},setup(e){const t=e,{displayHARecord:n,workout:a,sportLabel:s,sportColor:r}=he(t);return(i,o)=>{const u=ie("SportImage"),c=ie("router-link");return N(),j(c,{class:"calendar-workout",to:{name:"Workout",params:{workoutId:g(a).id}}},{default:le(()=>[x(u,{"sport-label":g(s),title:g(a).title,color:g(r)},null,8,["sport-label","title","color"]),f("sup",null,[g(a).records.length>0?(N(),C("i",{key:0,class:"fa fa-trophy custom-fa-small","aria-hidden":"true",title:g(a).records.filter(l=>g(n)?!0:l.record_type!=="HA").map(l=>` ${i.$t(`workouts.RECORD_${l.record_type}`)}`)[0]},null,8,ltt)):M("",!0)])]),_:1},8,["to"])}}}),ctt={class:"donut-chart"},dtt={height:"34",width:"34",viewBox:"0 0 34 34"},Ett=["stroke","stroke-dashoffset","transform"],m0=16,T0=16,_0=14,ptt=ae({__name:"DonutChart",props:{colors:{},datasets:{}},setup(e){const t=e,{colors:n,datasets:a}=he(t);let s=-90;const r=2*Math.PI*_0;function i(u,c){return c-u*c}function o(u,c){const l=`rotate(${s}, ${m0}, ${T0})`;return s=c*360+s,l}return(u,c)=>(N(),C("div",ctt,[(N(),C("svg",dtt,[(N(!0),C(_e,null,$e(Object.entries(g(a)),(l,E)=>(N(),C("g",{key:E},[f("circle",{cx:m0,cy:T0,r:_0,fill:"transparent",stroke:g(n)[+l[0]],"stroke-dashoffset":i(l[1].percentage,r),"stroke-dasharray":r,"stroke-width":"3","stroke-opacity":"0.8",transform:o(E,l[1].percentage)},null,8,Ett)]))),128))]))]))}}),ftt={class:"calendar-workouts-chart"},mtt=["id"],Ttt={class:"workouts-count"},_tt={key:0,class:"workouts-pane"},htt=["id"],Stt=ae({__name:"CalendarWorkoutsChart",props:{colors:{},datasets:{},sports:{},workouts:{},displayHARecord:{type:Boolean},index:{}},setup(e){const t=e;let n=0;const{colors:a,datasets:s,index:r,sports:i,workouts:o}=he(t),u=pe(!0);function c(){const d=document.getElementById(`workouts-pane-${r.value}`);return d!=null&&d.children&&(d==null?void 0:d.children.length)>0?d:null}async function l(d){var T;d.preventDefault(),d.stopPropagation(),u.value=!u.value,await fn();const p=c();u.value?(T=document.getElementById(`workouts-donut-${r.value}`))==null||T.focus():(p==null?void 0:p.children[0]).focus()}function E(d){if(!u.value){if(!u.value&&(d.key==="Tab"||d.keyCode===9)){d.preventDefault(),d.stopPropagation();const p=c();p&&(d.shiftKey?(n-=1,n<0&&(n=p.children.length-1)):(n+=1,n>=p.children.length&&(n=0)),p.children[n].focus())}d.key==="Escape"&&l(d)}}return mt(()=>{document.addEventListener("keydown",E)}),ct(()=>{document.removeEventListener("keydown",E)}),(d,p)=>{const T=AI("click-outside");return N(),C("div",ftt,[f("button",{class:"workouts-chart transparent",id:`workouts-donut-${g(r)}`,onClick:l},[f("div",Ttt,R(g(o).length),1),x(ptt,{datasets:g(s),colors:g(a)},null,8,["datasets","colors"])],8,mtt),u.value?M("",!0):(N(),C("div",_tt,[ke((N(),C("div",{class:"more-workouts",id:`workouts-pane-${g(r)}`},[f("button",{class:"calendar-more-close transparent",onClick:l},p[0]||(p[0]=[f("i",{class:"fa fa-times","aria-hidden":"true"},null,-1)])),(N(!0),C(_e,null,$e(g(o),(A,I)=>(N(),j(OO,{key:I,displayHARecord:d.displayHARecord,workout:A,sportLabel:g(Rp)(A,g(i)),sportColor:g(Np)(A,g(i))},null,8,["displayHARecord","workout","sportLabel","sportColor"]))),128))],8,htt)),[[T,l]])]))])}}}),h0=ue(Stt,[["__scopeId","data-v-796e8c43"]]),Att={class:"calendar-workouts"},Ott={class:"desktop-display"},gtt={key:0,class:"workouts-display"},Itt={key:1,class:"donut-display"},Rtt={class:"mobile-display"},Ntt={key:0,class:"donut-display"},S0=6,vtt=ae({__name:"CalendarWorkouts",props:{displayHARecord:{type:Boolean},workouts:{},sports:{},index:{}},setup(e){const t=e,{displayHARecord:n,index:a,sports:s,workouts:r}=he(t),i=z(()=>ttt(t.workouts)),o=z(()=>$Ge(t.sports));return(u,c)=>(N(),C("div",Att,[f("div",Ott,[g(r).length<=S0?(N(),C("div",gtt,[(N(!0),C(_e,null,$e(g(r).slice(0,S0),(l,E)=>(N(),j(OO,{key:E,displayHARecord:g(n),workout:l,sportLabel:g(Rp)(l,g(s)),sportColor:g(Np)(l,g(s))},null,8,["displayHARecord","workout","sportLabel","sportColor"]))),128))])):(N(),C("div",Itt,[x(h0,{workouts:g(r),sports:g(s),datasets:i.value,colors:o.value,displayHARecord:g(n),index:g(a)},null,8,["workouts","sports","datasets","colors","displayHARecord","index"])]))]),f("div",Rtt,[g(r).length>0?(N(),C("div",Ntt,[x(h0,{workouts:g(r),sports:g(s),datasets:i.value,colors:o.value,displayHARecord:g(n),index:g(a)},null,8,["workouts","sports","datasets","colors","displayHARecord","index"])])):M("",!0)])]))}}),btt={class:"calendar-cells"},Ctt={class:"calendar-cell-day"},Dtt=ae({__name:"CalendarCells",props:{currentDay:{},displayHARecord:{type:Boolean},endDate:{},sports:{},startDate:{},timezone:{},weekStartingMonday:{type:Boolean},workouts:{}},setup(e){const t=e,{currentDay:n,displayHARecord:a,endDate:s,sports:r,startDate:i,timezone:o,weekStartingMonday:u,workouts:c}=he(t),l=pe([]);mt(()=>E());function E(){l.value=[];let T=i.value;for(;T<=s.value;){const A=[];for(let I=0;I<7;I++)A.push(T),T=Hi(T,1);l.value.push(A)}}function d(T){return u.value?[5,6].includes(T):[0,6].includes(T)}function p(T,A){return A?A.filter(I=>NS(Ol(I.workout_date,o.value),T)).reverse():[]}return Me(()=>t.currentDay,()=>E()),(T,A)=>(N(),C("div",btt,[(N(!0),C(_e,null,$e(l.value,(I,m)=>(N(),C("div",{class:"calendar-row",key:m},[(N(!0),C(_e,null,$e(I,(S,h)=>(N(),C("div",{class:Te(["calendar-cell",{"disabled-cell":!g(a3)(S,g(n)),"week-end":d(h),today:g(s3)(S)}]),key:h},[x(vtt,{workouts:p(S,g(c)),sports:g(r),displayHARecord:g(a),index:h},null,8,["workouts","sports","displayHARecord","index"]),f("div",Ctt,R(g(mn)(S,"d")),1)],2))),128))]))),128))]))}}),Ptt={class:"calendar-days"},Ltt=ae({__name:"CalendarDays",props:{startDate:{},localeOptions:{}},setup(e){const t=e,n=[];for(let a=0;a<7;a++)n.push(Hi(t.startDate,a));return(a,s)=>(N(),C("div",Ptt,[(N(),C(_e,null,$e(n,(r,i)=>f("div",{class:"calendar-day",key:i},R(g(mn)(r,a.localeOptions.code==="eu"?"EEEEEE.":"EEE",{locale:a.localeOptions})),1)),64))]))}}),ytt={class:"calendar-header"},$tt=["aria-label"],ktt={class:"calendar-month"},Utt=["aria-label"],wtt=ae({__name:"CalendarHeader",props:{day:{},localeOptions:{}},emits:["displayNextMonth","displayPreviousMonth"],setup(e,{emit:t}){const n=e,a=t,{day:s,localeOptions:r}=he(n);return(i,o)=>(N(),C("div",ytt,[f("button",{class:"calendar-arrow calendar-arrow-left transparent",onClick:o[0]||(o[0]=u=>a("displayPreviousMonth")),"aria-label":i.$t("common.PREVIOUS")},o[2]||(o[2]=[f("i",{class:"fa fa-chevron-left","aria-hidden":"true"},null,-1)]),8,$tt),f("div",ktt,[f("span",null,R(g(mn)(g(s),"MMM yyyy",{locale:g(r)})),1)]),f("button",{class:"calendar-arrow calendar-arrow-right transparent",onClick:o[1]||(o[1]=u=>a("displayNextMonth")),"aria-label":i.$t("common.NEXT")},o[3]||(o[3]=[f("i",{class:"fa fa-chevron-right","aria-hidden":"true"},null,-1)]),8,Utt)]))}}),Mtt={id:"user-calendar"},Wtt={class:"calendar-card box"},A0="yyyy-MM-dd",ztt=ae({__name:"index",props:{sports:{},user:{}},setup(e){const t=e,n=Ue(),{sports:a,user:s}=he(t),r=pe(new Date),i=pe(K_(r.value,s.value.weekm)),o=z(()=>n.getters[Oe.GETTERS.CALENDAR_WORKOUTS]),u=z(()=>n.getters[V.GETTERS.LOCALE]);ft(()=>c());function c(){i.value=K_(r.value,t.user.weekm);const d={from:mn(i.value.start,A0),to:mn(i.value.end,A0),page:1,per_page:100,...yi};n.dispatch(Oe.ACTIONS.GET_CALENDAR_WORKOUTS,d)}function l(){r.value=dr(r.value,1),c()}function E(){r.value=Oi(r.value,1),c()}return(d,p)=>(N(),C("div",Mtt,[f("div",Wtt,[x(wtt,{day:r.value,"locale-options":u.value,onDisplayNextMonth:l,onDisplayPreviousMonth:E},null,8,["day","locale-options"]),x(Ltt,{"start-date":i.value.start,"locale-options":u.value},null,8,["start-date","locale-options"]),x(Dtt,{currentDay:r.value,displayHARecord:g(s).display_ascent,"end-date":i.value.end,sports:g(a),"start-date":i.value.start,timezone:g(s).timezone,workouts:o.value,weekStartingMonday:g(s).weekm},null,8,["currentDay","displayHARecord","end-date","sports","start-date","timezone","workouts","weekStartingMonday"])])]))}}),gO={data:{type:Object,required:!0},options:{type:Object,default:()=>({})},plugins:{type:Array,default:()=>[]},datasetIdKey:{type:String,default:"label"},updateMode:{type:String,default:void 0}},xtt={ariaLabel:{type:String},ariaDescribedby:{type:String}},Ftt={type:{type:String,required:!0},destroyDelay:{type:Number,default:0},...gO,...xtt},Btt=Zh[0]==="2"?(e,t)=>Object.assign(e,{attrs:t}):(e,t)=>Object.assign(e,t);function js(e){return ki(e)?tt(e):e}function Gtt(e){let t=arguments.length>1&&arguments[1]!==void 0?arguments[1]:e;return ki(t)?new Proxy(e,{}):e}function Htt(e,t){const n=e.options;n&&t&&Object.assign(n,t)}function IO(e,t){e.labels=t}function RO(e,t,n){const a=[];e.datasets=t.map(s=>{const r=e.datasets.find(i=>i[n]===s[n]);return!r||!s.data||a.includes(r)?{...s}:(a.push(r),Object.assign(r,s),r)})}function Vtt(e,t){const n={labels:[],datasets:[]};return IO(n,e.labels),RO(n,e.datasets,t),n}const qtt=ae({props:Ftt,setup(e,t){let{expose:n,slots:a}=t;const s=pe(null),r=Vu(null);n({chart:r});const i=()=>{if(!s.value)return;const{type:c,data:l,options:E,plugins:d,datasetIdKey:p}=e,T=Vtt(l,p),A=Gtt(T,l);r.value=new oE(s.value,{type:c,data:A,options:{...E},plugins:d})},o=()=>{const c=tt(r.value);c&&(e.destroyDelay>0?setTimeout(()=>{c.destroy(),r.value=null},e.destroyDelay):(c.destroy(),r.value=null))},u=c=>{c.update(e.updateMode)};return mt(i),ct(o),Me([()=>e.options,()=>e.data],(c,l)=>{let[E,d]=c,[p,T]=l;const A=tt(r.value);if(!A)return;let I=!1;if(E){const m=js(E),S=js(p);m&&m!==S&&(Htt(A,m),I=!0)}if(d){const m=js(d.labels),S=js(T.labels),h=js(d.datasets),_=js(T.datasets);m!==S&&(IO(A.config.data,m),I=!0),h&&h!==_&&(RO(A.config.data,h,e.datasetIdKey),I=!0)}I&&fn(()=>{u(A)})},{deep:!0}),()=>An("canvas",{role:"img",ariaLabel:e.ariaLabel,ariaDescribedby:e.ariaDescribedby,ref:s},[An("p",{},[a.default?a.default():""])])}});function NO(e,t){return oE.register(t),ae({props:gO,setup(n,a){let{expose:s}=a;const r=Vu(null),i=o=>{r.value=o==null?void 0:o.chart};return s({chart:r}),()=>An(qtt,Btt({ref:i},{type:e,...n}))}})}const Ktt=NO("bar",M0),jtt=NO("line",W0),Hr=(e,t,n,a=!0,s="km")=>{const r=n?Sn[s].defaultTarget:s;switch(e){case"average_speed":return`${t.toFixed(2)} ${r}/h`;case"average_duration":case"total_duration":return uZe(t,a);case"average_distance":case"average_ascent":case"average_descent":case"total_distance":case"total_ascent":case"total_descent":return`${t.toFixed(2)} ${r}`;default:return t.toString()}},Ytt=ae({__name:"Chart",props:{datasets:{},labels:{},displayedData:{},displayedSportIds:{},fullStats:{type:Boolean},useImperialUnits:{type:Boolean},label:{}},setup(e){const t=e,{datasets:n,labels:a,displayedData:s,displayedSportIds:r,fullStats:i,useImperialUnits:o}=he(t),u=Gi(),{t:c}=Ct(),l=z(()=>u.getters[V.GETTERS.DARK_MODE]),E=z(()=>rl(l.value)),d=z(()=>({color:E.value?es.darkMode.line:es.ligthMode.line})),p=z(()=>({color:E.value?es.darkMode.text:es.ligthMode.text})),T=z(()=>s.value!=="average_workouts"&&s.value.startsWith("average")),A=z(()=>({labels:a.value,datasets:JSON.parse(JSON.stringify(n.value))})),I=z(()=>({responsive:!0,maintainAspectRatio:!1,animation:!1,layout:{padding:{top:i.value?40:22}},scales:{x:{stacked:!0,grid:{drawOnChartArea:!1,...d.value},border:{...d.value},ticks:{...p.value}},y:{stacked:!s.value.startsWith("average"),grid:{drawOnChartArea:!1,...d.value},border:{...d.value},ticks:{maxTicksLimit:6,callback:function(_){return Hr(s.value,+_,o.value,!1,h(s.value))},...p.value},afterFit:function(_){_.width=i.value?90:60}}},plugins:{datalabels:{anchor:"end",align:"end",color:function(_){return T.value&&_.dataset.backgroundColor?_.dataset.backgroundColor[0]:p.value.color},rotation:function(_){return i.value&&_.chart.chartArea.width<580?310:0},display:function(_){return i.value&&_.chart.chartArea.width<300?!1:T.value?r.value.length==1?"auto":!1:!0},formatter:function(_,O){if(s.value.startsWith("average"))return Hr(s.value,_,o.value,!1);{const b=O.chart.data.datasets.map(v=>v.data[O.dataIndex]).reduce((v,D)=>S(v,D),0);return O.datasetIndex===r.value.length-1&&b>0?Hr(s.value,b,o.value,!1,h(s.value)):null}}},legend:{display:!1},tooltip:{interaction:{intersect:!0,mode:"index",position:T.value?"nearest":"average"},filter:function(_){return _.formattedValue!=="0"},callbacks:{label:function(_){let O=s.value==="average_workouts"?c("workouts.WORKOUT",0):c(`sports.${_.dataset.label}.LABEL`)||"";return O&&(O+=": "),_.parsed.y!==null&&(O+=Hr(s.value,_.parsed.y,o.value,!0,h(s.value))),O},footer:function(_){if(s.value.startsWith("average"))return"";let O=0;return _.map(b=>{O+=b.parsed.y}),`${c("common.TOTAL")}: `+Hr(s.value,O,o.value,!0,h(s.value))}}}}}));function m(_){return isNaN(_)?0:+_}function S(_,O){return m(_)+m(O)}function h(_){return _.includes("scent")?"m":"km"}return(_,O)=>(N(),C("div",{class:Te(["bar-chart",{minimal:!g(i)}])},[x(g(Ktt),{data:A.value,options:I.value,"aria-label":_.label},null,8,["data","options","aria-label"])],2))}}),Xtt=ue(Ytt,[["__scopeId","data-v-893ee0af"]]),{locale:xu}=Nr.global,vO={week:{api:"yyyy-MM-dd",chart:"MM/dd/yyyy"},month:{api:"yyyy-MM",chart:"MM/yyyy"},year:{api:"yyyy",chart:"yyyy"}},Qtt=["average_ascent","average_descent","average_distance","average_duration","average_speed","total_workouts","total_duration","total_distance","total_ascent","total_descent"],Ztt=(e,t)=>{const n=[];for(let a=GHe(e.duration,e.start,t);a<=e.end;a=HHe(e.duration,a))n.push(a);return n},la=(e,t,n=!1)=>{const a={label:e,backgroundColor:[t],data:[]};return n?(a.type="line",a.borderColor=[t],a.spanGaps=!0):a.type="bar",a},Jtt=e=>{const t={average_ascent:[],average_descent:[],average_distance:[],average_duration:[],average_speed:[],average_workouts:[],total_workouts:[],total_distance:[],total_duration:[],total_ascent:[],total_descent:[]};return e.map(n=>{const a=n.color?n.color:Ip[n.label];t.average_ascent.push(la(n.label,a,!0)),t.average_descent.push(la(n.label,a,!0)),t.average_distance.push(la(n.label,a,!0)),t.average_duration.push(la(n.label,a,!0)),t.average_speed.push(la(n.label,a,!0)),t.total_workouts.push(la(n.label,a)),t.total_distance.push(la(n.label,a)),t.total_duration.push(la(n.label,a)),t.total_ascent.push(la(n.label,a)),t.total_descent.push(la(n.label,a))}),t},ent=(e,t,n)=>{switch(e){case"average_speed":case"total_distance":case"total_ascent":case"total_descent":case"average_distance":case"average_ascent":case"average_descent":return Mo(["average_speed","total_distance","average_distance"].includes(e)?"km":"m",t,n);default:case"total_workouts":case"total_duration":case"average_duration":return t}},rE=(e,t,n,a)=>mn(e,t==="week"?ds(n,xu.value):a,{locale:ks[xu.value]}),tnt=(e,t,n,a,s,r,i)=>{const o=Ztt(e,t),u=vO[e.duration],c=n.filter(p=>a.includes(p.id)),l=[],E=Jtt(c),d={};return c.map(p=>d[p.label]=p.id),o.map(p=>{const T=mn(p,u.api),A=rE(p,e.duration,i,u.chart);mn(p,e.duration==="week"?ds(i,xu.value):u.chart,{locale:ks[xu.value]}),l.push(A),Qtt.map(I=>{E[I].map(m=>{m.data.push(T in s&&d[m.label]in s[T]?ent(I,s[T][d[m.label]][I],r):I.startsWith("average")?null:0)})})}),{labels:l,datasets:E}},nnt=(e,t,n,a)=>{const s=n?1:0,r=t==="year"?wE(Rd(e,9)):t==="week"?ol(Oi(e,2),{weekStartsOn:s}):qi(Oi(e,11)),i=t==="year"?vS(e):t==="week"?ME(e,{weekStartsOn:s}):Vi(e);return{duration:t,end:i,start:r,statsType:a}},ant=(e,t,n)=>{const{duration:a,start:s,end:r}=e,i=n?1:0;return{duration:a,end:a==="year"?vS(t?Rd(r,1):nu(r,1)):a==="week"?ME(t?mm(r,1):Id(r,1),{weekStartsOn:i}):Vi(t?Oi(r,1):dr(r,1)),start:a==="year"?wE(t?Rd(s,1):nu(s,1)):a==="week"?ol(t?mm(s,1):Id(s,1),{weekStartsOn:i}):qi(t?Oi(s,1):dr(s,1)),statsType:e.statsType}},O0=e=>{const t=e.reduce((a,s)=>(a||0)+(s||0),0);return+(e.length?(t||0)/e.length:0).toFixed(1)},snt=(e,t)=>{const n=e.label.toLowerCase(),a=t.label.toLowerCase();return n>a?1:n{const n=[],a={label:"workouts_average",backgroundColor:[],data:[]};let s=[];const r=e.map(i=>(i.label=t(`sports.${i.label}.LABEL`),i)).sort(snt);for(const i of r)a.data.push(O0(i.data)),a.backgroundColor.push(i.backgroundColor[0]),n.push(i.label),s.length>0?s=s.map((o,u)=>o+(i.data[u]||0)):s=i.data.map(o=>o||0);return{labels:n,datasets:{workouts_average:[a]},workoutsAverage:O0(s)}},int={class:"stats-chart"},ont={key:0},unt={key:1},lnt={class:"chart-radio"},cnt=["value","checked","disabled"],dnt=["value","checked","disabled"],Ent=["value","checked","disabled"],pnt={key:0},fnt=["checked","disabled"],mnt={key:1},Tnt=["value","checked","disabled"],_nt={key:2},hnt=["value","checked","disabled"],Snt={class:"workouts-average"},Ant={key:0,class:"info-box"},Ont=ae({__name:"index",props:{sports:{},user:{},chartParams:{},displayedSportIds:{default:()=>[]},fullStats:{type:Boolean,default:!1},hideChartIfNoData:{type:Boolean,default:!1},isDisabled:{type:Boolean,default:!1},selectedTimeFrame:{default:null}},setup(e){const t=e,{sports:n,user:a,chartParams:s,displayedSportIds:r,fullStats:i,hideChartIfNoData:o,isDisabled:u}=he(t),c=Ue(),{t:l}=Ct(),E=pe("total_distance"),d=z(()=>c.getters[yt.GETTERS.USER_STATS]),p=z(()=>vO[s.value.duration].chart),T=z(()=>rE(s.value.start,s.value.duration,a.value.date_format,p.value)),A=z(()=>rE(s.value.end,s.value.duration,a.value.date_format,p.value)),I=z(()=>tnt(s.value,a.value.weekm,n.value,r.value,d.value,a.value.imperial_units,a.value.date_format)),m=z(()=>I.value.datasets[E.value]),S=z(()=>I.value.labels),h=z(()=>Object.keys(d.value).length===0),_=z(()=>s.value.statsType),O=z(()=>rnt(I.value.datasets.total_workouts,l));ft(()=>b(D(s.value,a.value)));function b(L){c.dispatch(yt.ACTIONS.GET_USER_STATS,{username:a.value.username,params:L})}function v(L){E.value=L.target.value}function D(L,U){return{from:mn(L.start,"yyyy-MM-dd"),to:mn(L.end,"yyyy-MM-dd"),time:L.duration==="week"?`week${U.weekm?"m":""}`:L.duration,type:_.value}}return Me(()=>s.value,async L=>{b(D(L,a.value))}),Me(()=>_.value,async L=>{E.value=L==="total"&&E.value==="average_speed"?"total_distance":`${_.value}_${E.value.split("_")[1]}`}),(L,U)=>(N(),C("div",int,[g(o)&&h.value?(N(),C("div",ont,R(L.$t("workouts.NO_WORKOUTS")),1)):(N(),C("div",unt,[f("div",lnt,[f("label",null,[f("input",{type:"radio",name:"value_type",value:`${_.value}_distance`,checked:E.value===`${_.value}_distance`,disabled:g(u),onClick:v},null,8,cnt),G(" "+R(L.$t("workouts.DISTANCE")),1)]),f("label",null,[f("input",{type:"radio",name:"value_type",value:`${_.value}_duration`,checked:E.value===`${_.value}_duration`,disabled:g(u),onClick:v},null,8,dnt),G(" "+R(L.$t("workouts.DURATION")),1)]),f("label",null,[f("input",{type:"radio",name:"value_type",value:`${_.value}_workouts`,checked:E.value===`${_.value}_workouts`,disabled:g(u),onClick:v},null,8,Ent),G(" "+R(L.$t("workouts.WORKOUT",2)),1)]),g(i)&&_.value==="average"?(N(),C("label",pnt,[f("input",{type:"radio",name:"value_type",value:"average_speed",checked:E.value==="average_speed",disabled:g(u),onClick:v},null,8,fnt),G(" "+R(L.$t("workouts.SPEED")),1)])):M("",!0),g(i)?(N(),C("label",mnt,[f("input",{type:"radio",name:"value_type",value:`${_.value}_ascent`,checked:E.value===`${_.value}_ascent`,disabled:g(u),onClick:v},null,8,Tnt),G(" "+R(L.$t("workouts.ASCENT")),1)])):M("",!0),g(i)?(N(),C("label",_nt,[f("input",{type:"radio",name:"value_type",value:`${_.value}_descent`,checked:E.value===`${_.value}_descent`,disabled:g(u),onClick:v},null,8,hnt),G(" "+R(L.$t("workouts.DESCENT")),1)])):M("",!0)]),S.value.length>0||O.value.labels.length>0?(N(),j(Xtt,{key:0,datasets:E.value==="average_workouts"?O.value.datasets.workouts_average:m.value,labels:E.value==="average_workouts"?O.value.labels:S.value,displayedData:E.value,displayedSportIds:g(r),fullStats:g(i),useImperialUnits:g(a).imperial_units,label:L.$t(`statistics.STATISTICS_CHARTS.${g(s).duration}`)+` (${T.value} - ${A.value})`},null,8,["datasets","labels","displayedData","displayedSportIds","fullStats","useImperialUnits","label"])):M("",!0),f("div",Snt,[E.value==="average_workouts"&&L.selectedTimeFrame?(N(),C("div",Ant,[U[0]||(U[0]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(L.$t("statistics.DATES"))+": "+R(T.value)+" - "+R(A.value)+", "+R(L.$t("statistics.WORKOUTS_AVERAGE"))+": "+R(O.value.workoutsAverage)+"/"+R(L.$t(`statistics.TIME_FRAMES.${L.selectedTimeFrame}`)),1)])):M("",!0)])]))]))}}),bO=ue(Ont,[["__scopeId","data-v-0473fee9"]]),gnt={class:"user-month-stats"},Int=ae({__name:"UserMonthStats",props:{sports:{},user:{}},setup(e){const t=e,{sports:n,user:a}=he(t),s=new Date,r={duration:"week",start:qi(s),end:Vi(s),statsType:"total"},i=n.value.map(o=>o.id);return(o,u)=>{const c=ie("Card");return N(),C("div",gnt,[x(c,null,{title:le(()=>[G(R(o.$t("dashboard.THIS_MONTH")),1)]),content:le(()=>[x(bO,{sports:g(n),user:g(a),"chart-params":r,"displayed-sport-ids":g(i),"hide-chart-if-no-data":!0},null,8,["sports","user","displayed-sport-ids"])]),_:1})])}}}),Rnt=ue(Int,[["__scopeId","data-v-3131940a"]]),Nnt={class:"record"},vnt={class:"record-type"},bnt={class:"record-value"},Cnt={class:"record-date"},Dnt=ae({__name:"SportRecordsTable",props:{record:{}},setup(e){const t=e,{record:n}=he(t);return(a,s)=>{const r=ie("router-link");return N(),C("div",Nnt,[f("span",vnt,R(Be(g(n).label)),1),f("span",bnt,R(g(n).value),1),f("span",Cnt,[x(r,{to:{name:"Workout",params:{workoutId:g(n).workout_id}}},{default:le(()=>[f("time",null,R(g(n).workout_date),1)]),_:1},8,["to"])])])}}}),CO=ue(Dnt,[["__scopeId","data-v-fce46986"]]),{locale:Pnt}=Nr.global,Lnt=(e,t,n,a)=>{const s="km",r=n?Sn[s].defaultTarget:s,i="m",o=n?Sn[i].defaultTarget:i;let u;switch(e.record_type){case"AS":case"MS":u=`${Vt(+e.value,s,r,2)} ${r}/h`;break;case"FD":u=`${Vt(+e.value,s,r,3)} ${r}`;break;case"HA":u=`${Vt(+e.value,i,o,2)} ${o}`;break;case"LD":u=e.value;break;default:throw new Error(`Invalid record type, expected: "AS", "FD", "HA", "LD", "MD", got: "${e.record_type}"`)}return{id:e.id,record_type:e.record_type,sport_id:e.sport_id,value:u,user:e.user,workout_date:Vn(e.workout_date,t,a,!1),workout_id:e.workout_id}},DO=(e,t)=>{const n=e.label.toLowerCase(),a=t.label.toLowerCase();return n>a?1:n(r=ds(r,Pnt.value),e.filter(o=>s?!0:o.record_type!=="HA").reduce((o,u)=>{const c=t.find(l=>l.id===u.sport_id);return c&&c.label&&(i===null||c.id===i)&&(o[c.translatedLabel]===void 0&&(o[c.translatedLabel]={label:c.label,color:c.color,records:[]}),o[c.translatedLabel].records.push(Lnt(u,n,a,r))),o},{})),ynt={class:"records-card"},$nt=ae({__name:"RecordsCard",props:{records:{},sportTranslatedLabel:{}},setup(e){const t=e,{records:n,sportTranslatedLabel:a}=he(t),s=Ue(),{t:r}=Ct(),i=z(()=>s.getters[V.GETTERS.LANGUAGE]),o=z(()=>i.value==="bg");function u(c){const l=[];return c.map(E=>{l.push({...E,label:r(`workouts.RECORD_${E.record_type}`)})}),l.sort(DO)}return(c,l)=>{const E=ie("SportImage"),d=ie("Card");return N(),C("div",ynt,[x(d,null,{title:le(()=>[x(E,{"sport-label":g(n).label,color:g(n).color},null,8,["sport-label","color"]),G(" "+R(g(a)),1)]),content:le(()=>[(N(!0),C(_e,null,$e(u(g(n).records),p=>(N(),j(CO,{class:Te({"max-width":o.value}),record:p,key:p.id},null,8,["class","record"]))),128))]),_:1})])}}}),knt=ue($nt,[["__scopeId","data-v-97e7ddaa"]]),Unt={class:"user-records-section"},wnt={class:"section-title"},Mnt={class:"user-records"},Wnt={key:0,class:"no-records"},znt=ae({__name:"index",props:{sports:{},user:{}},setup(e){const t=e,{t:n}=Ct(),a=z(()=>PO(t.user.records,Hn(t.sports,n),t.user.timezone,t.user.imperial_units,t.user.display_ascent,t.user.date_format));return(s,r)=>(N(),C("div",Unt,[f("div",wnt,[r[0]||(r[0]=f("i",{class:"fa fa-trophy custom-fa-small","aria-hidden":"true"},null,-1)),G(" "+R(s.$t("workouts.RECORD",2)),1)]),f("div",Mnt,[Object.keys(a.value).length===0?(N(),C("div",Wnt,R(s.$t("workouts.NO_RECORDS")),1)):M("",!0),(N(!0),C(_e,null,$e(Object.keys(a.value).sort(),i=>(N(),j(knt,{sportTranslatedLabel:i,records:a.value[i],key:i,useImperialUnits:s.user.imperial_units},null,8,["sportTranslatedLabel","records","useImperialUnits"]))),128))])]))}}),xnt=ue(znt,[["__scopeId","data-v-fff33919"]]),Fnt={id:"user-stats"},Xc="km",Qc="m",Bnt=ae({__name:"index",props:{user:{}},setup(e){const t=e,{t:n}=Ct(),{user:a}=he(t),s=z(()=>Cp(a.value.total_duration,n)),r=a.value.imperial_units?Sn[Xc].defaultTarget:Xc,i=z(()=>a.value.imperial_units?Vt(a.value.total_distance,Xc,r,2):parseFloat(a.value.total_distance.toFixed(2))),o=a.value.imperial_units?Sn[Qc].defaultTarget:Qc,u=z(()=>a.value.imperial_units?Vt(a.value.total_ascent,Qc,o,2):parseFloat(a.value.total_ascent.toFixed(2)));return(c,l)=>(N(),C("div",Fnt,[x($a,{icon:"calendar",value:g(a).nb_workouts,text:c.$t("workouts.WORKOUT",g(a).nb_workouts)},null,8,["value","text"]),x($a,{icon:"road",value:i.value,text:g(r)==="mi"?"miles":g(r)},null,8,["value","text"]),g(a).display_ascent?(N(),j($a,{key:0,icon:"location-arrow",value:u.value,text:g(o)==="ft"?"feet":g(o)},null,8,["value","text"])):M("",!0),x($a,{icon:"clock-o",value:s.value.days,text:s.value.duration},null,8,["value","text"]),g(a).display_ascent?M("",!0):(N(),j($a,{key:1,icon:"tags",value:g(a).nb_sports,text:c.$t("workouts.SPORT",g(a).nb_sports)},null,8,["value","text"]))]))}}),Gnt={},Hnt={class:"privacy-policy-message"};function Vnt(e,t){const n=ie("router-link"),a=ie("i18n-t");return N(),C("div",Hnt,[f("span",null,[x(a,{keypath:"user.LAST_PRIVACY_POLICY_TO_VALIDATE"},{default:le(()=>[x(n,{to:"/profile/edit/privacy-policy",class:"policy-link"},{default:le(()=>[G(R(e.$t("user.REVIEW")),1)]),_:1})]),_:1})])])}const qnt=ue(Gnt,[["render",Vnt],["__scopeId","data-v-1b250692"]]),Knt={key:0,id:"dashboard",class:"view"},jnt={class:"container mobile-menu"},Ynt={class:"box"},Xnt={key:0,class:"container privacy-policy-message"},Qnt={class:"container"},Znt={class:"container dashboard-container"},Jnt={class:"left-container dashboard-sub-container"},eat={class:"right-container dashboard-sub-container"},tat={key:1,class:"app-loading"},nat=ae({__name:"Dashboard",setup(e){const t=Ue(),n=z(()=>t.getters[ee.GETTERS.AUTH_USER_PROFILE]),a=z(()=>t.getters[vt.GETTERS.SPORTS]),s=pe("calendar");ft(()=>t.dispatch(ee.ACTIONS.GET_USER_PROFILE));function r(i){s.value=i}return(i,o)=>{const u=ie("Loader");return n.value.username&&a.value.length>0?(N(),C("div",Knt,[f("div",jnt,[f("div",Ynt,[f("div",{class:Te(["mobile-menu-item",{"is-selected":s.value==="calendar"}]),onClick:o[0]||(o[0]=c=>r("calendar"))},o[4]||(o[4]=[f("i",{class:"fa fa-calendar","aria-hidden":"true"},null,-1)]),2),f("div",{class:Te(["mobile-menu-item",{"is-selected":s.value==="chart"}]),onClick:o[1]||(o[1]=c=>r("chart"))},o[5]||(o[5]=[f("i",{class:"fa fa-bar-chart","aria-hidden":"true"},null,-1)]),2),f("div",{class:Te(["mobile-menu-item",{"is-selected":s.value==="timeline"}]),onClick:o[2]||(o[2]=c=>r("timeline"))},o[6]||(o[6]=[f("i",{class:"fa fa-map-o","aria-hidden":"true"},null,-1)]),2),f("div",{class:Te(["mobile-menu-item",{"is-selected":s.value==="records"}]),onClick:o[3]||(o[3]=c=>r("records"))},o[7]||(o[7]=[f("i",{class:"fa fa-trophy","aria-hidden":"true"},null,-1)]),2)])]),n.value.accepted_privacy_policy?M("",!0):(N(),C("div",Xnt,[x(qnt)])),f("div",Qnt,[x(Bnt,{user:n.value},null,8,["user"])]),f("div",Znt,[f("div",Jnt,[x(Rnt,{sports:a.value,user:n.value,class:Te({"is-hidden":s.value!=="chart"})},null,8,["sports","user","class"]),x(xnt,{sports:a.value,user:n.value,class:Te({"is-hidden":s.value!=="records"})},null,8,["sports","user","class"])]),f("div",eat,[x(ztt,{sports:a.value,user:n.value,class:Te({"is-hidden":s.value!=="calendar"})},null,8,["sports","user","class"]),x(utt,{sports:a.value,user:n.value,class:Te({"is-hidden":s.value!=="timeline"})},null,8,["sports","user","class"])])]),o[8]||(o[8]=f("div",{id:"bottom"},null,-1))])):(N(),C("div",tat,[x(u)]))}}}),aat=ue(nat,[["__scopeId","data-v-6e13c66c"]]),sat={class:"not-found view"},rat=ae({__name:"NotFoundView",setup(e){return(t,n)=>(N(),C("div",sat,[x(Pp)]))}}),iat={id:"privacy-policy",class:"view"},oat={class:"container"},uat=ae({__name:"PrivacyPolicyView",setup(e){const t=Ue();return ft(()=>{t.dispatch(V.ACTIONS.GET_APPLICATION_PRIVACY_POLICY)}),(n,a)=>(N(),C("div",iat,[f("div",oat,[x(fO)]),a[0]||(a[0]=f("div",{id:"bottom"},null,-1))]))}}),lat={class:"chart-menu"},cat=["disabled","aria-label"],dat={class:"time-frames custom-checkboxes-group"},Eat={class:"time-frames-checkboxes custom-checkboxes"},pat=["id","name","checked","onInput","disabled"],fat=["id","tabindex","onKeydown"],mat=["disabled","aria-label"],Tat={class:"stats-type"},_at={class:"stats-type-radio"},hat=["checked","disabled"],Sat=["checked","disabled"],Aat=ae({__name:"StatsMenu",props:{isDisabled:{type:Boolean}},emits:["arrowClick","statsTypeUpdate","timeFrameUpdate"],setup(e,{emit:t}){const n=e,{isDisabled:a}=he(n),s=t,r=pe("month"),i=["week","month","year"],o=pe("total");function u(l){r.value=l,s("timeFrameUpdate",l)}function c(l){o.value=l.target.value,s("statsTypeUpdate",o.value)}return(l,E)=>(N(),C(_e,null,[f("div",lat,[f("button",{class:"chart-arrow transparent",onClick:E[0]||(E[0]=d=>s("arrowClick",!0)),onKeydown:E[1]||(E[1]=Ye(d=>s("arrowClick",!0),["enter"])),disabled:g(a),"aria-label":l.$t("common.PREVIOUS")},E[4]||(E[4]=[f("i",{class:"fa fa-chevron-left","aria-hidden":"true"},null,-1)]),40,cat),f("div",dat,[f("div",Eat,[(N(),C(_e,null,$e(i,d=>f("div",{class:"time-frame custom-checkbox",key:d},[f("label",null,[f("input",{type:"radio",id:d,name:d,checked:r.value===d,onInput:p=>u(d),disabled:g(a)},null,40,pat),f("span",{id:`frame-${d}`,tabindex:g(a)?-1:0,role:"button",onKeydown:Ye(p=>u(d),["enter"])},R(l.$t(`statistics.TIME_FRAMES.${d}`)),41,fat)])])),64))])]),f("button",{class:"chart-arrow transparent",onClick:E[2]||(E[2]=d=>s("arrowClick",!1)),onKeydown:E[3]||(E[3]=Ye(d=>s("arrowClick",!1),["enter"])),disabled:g(a),"aria-label":l.$t("common.NEXT")},E[5]||(E[5]=[f("i",{class:"fa fa-chevron-right","aria-hidden":"true"},null,-1)]),40,mat)]),f("div",Tat,[f("div",_at,[f("label",null,[f("input",{type:"radio",name:"stats_type",value:"total",checked:o.value==="total",disabled:g(a),onClick:c},null,8,hat),G(" "+R(l.$t("common.TOTAL")),1)]),f("label",null,[f("input",{type:"radio",name:"stats_type",value:"average",checked:o.value==="average",disabled:g(a),onClick:c},null,8,Sat),G(" "+R(l.$t("statistics.AVERAGE")),1)])])])],64))}}),Oat=ue(Aat,[["__scopeId","data-v-3fa0b6ca"]]),gat={class:"sports-menu"},Iat=["id","name","checked","onInput","onKeyup"],Rat={class:"sport-label"},Nat=ae({__name:"StatsSportsMenu",props:{userSports:{},selectedSportIds:{default:()=>[]}},emits:["selectedSportIdsUpdate"],setup(e,{emit:t}){const n=e,a=t,{t:s}=Ct(),r=It("sportColors"),{selectedSportIds:i}=he(n),o=z(()=>Hn(n.userSports,s));function u(c){a("selectedSportIdsUpdate",c)}return(c,l)=>{const E=ie("SportImage");return N(),C("div",gat,[(N(!0),C(_e,null,$e(o.value,d=>(N(),C("label",{type:"checkbox",key:d.id,style:wa({color:d.color?d.color:g(r)[d.label]})},[f("input",{type:"checkbox",id:`${d.id}`,name:d.label,checked:g(i).includes(d.id),onInput:p=>u(d.id),onKeyup:Ye(De(p=>u(d.id),["prevent"]),["space"])},null,40,Iat),x(E,{"sport-label":d.label,color:d.color},null,8,["sport-label","color"]),f("span",Rat,R(d.translatedLabel),1)],4))),128))])}}}),vat={key:0,id:"user-statistics"},bat=ae({__name:"index",props:{sports:{},user:{},isDisabled:{type:Boolean}},setup(e){const t=e,{t:n}=Ct(),{sports:a,user:s}=he(t),r=pe("month"),i=pe("total"),o=pe(d(r.value,i.value)),u=z(()=>Hn(t.sports,n)),c=pe(T(a.value));function l(I){r.value=I,o.value=d(I,i.value)}function E(I){i.value=I,o.value=d(r.value,I)}function d(I,m){return nnt(new Date,I,t.user.weekm,m)}function p(I){o.value=ant(o.value,I,t.user.weekm)}function T(I){return I.map(m=>m.id)}function A(I){c.value.includes(I)?c.value=c.value.filter(m=>m!==I):c.value.push(I)}return Me(()=>t.sports,I=>{c.value=T(I)}),(I,m)=>u.value?(N(),C("div",vat,[x(Oat,{onStatsTypeUpdate:E,onTimeFrameUpdate:l,onArrowClick:p,isDisabled:I.isDisabled},null,8,["isDisabled"]),x(bO,{sports:g(a),user:g(s),chartParams:o.value,"displayed-sport-ids":c.value,fullStats:!0,isDisabled:I.isDisabled,selectedTimeFrame:r.value},null,8,["sports","user","chartParams","displayed-sport-ids","isDisabled","selectedTimeFrame"]),x(Nat,{"selected-sport-ids":c.value,"user-sports":g(a),onSelectedSportIdsUpdate:A},null,8,["selected-sport-ids","user-sports"])])):M("",!0)}}),Cat=ue(bat,[["__scopeId","data-v-ff5da6bd"]]),Dat={class:"sport-stat-card"},Pat={class:"stat-content"},Lat={class:"stat-icon"},yat={class:"stat-details"},$at={class:"stat-label"},kat={class:"stat-values"},Uat={key:0,class:"fa fa-refresh fa-spin fa-fw"},wat={key:1,class:"stat-huge"},Mat={key:2,class:"stat"},Wat={key:0,class:"stat-average"},zat={key:0},Ys=ae({__name:"SportStatCard",props:{icon:{},text:{default:""},totalValue:{},label:{},loading:{type:Boolean}},setup(e){const t=e,{icon:n,loading:a,text:s,totalValue:r}=he(t);return(i,o)=>(N(),C("div",Dat,[f("div",Pat,[f("div",Lat,[f("i",{class:Te(["fa",`fa-${g(n)}`])},null,2)]),f("div",yat,[f("div",$at,R(i.label),1),f("div",kat,[g(a)?(N(),C("i",Uat)):(N(),C("span",wat,R(g(r)?g(r):""),1)),g(s)?(N(),C("span",Mat,R(g(s)),1)):M("",!0)]),["calendar","tachometer"].includes(g(n))?M("",!0):(N(),C("div",Wat,[g(a)?(N(),C("div",zat,o[0]||(o[0]=[f("i",{class:"fa fa-refresh fa-spin fa-fw"},null,-1)]))):Lt(i.$slots,"average",{key:1})]))])])]))}}),xat={id:"sport-statistics"},Fat={for:"sport"},Bat=["value"],Gat={key:0,class:"sport-statistics"},Hat={class:"sport-img-label"},Vat={class:"sport-label"},qat={class:"label"},Kat={class:"statistics"},jat={key:0,class:"statistics-workouts-count"},Yat={key:1,class:"statistics-workouts-count"},Xat={class:"statistics"},Qat={class:"records"},Zat={class:"label"},Jat=ae({__name:"SportStatistics",props:{sports:{},authUser:{}},setup(e){const t=e,n=bt(),a=_a(),s=Ue(),{t:r}=Ct(),{authUser:i,sports:o}=he(t),u=z(()=>Hn(o.value,r,"all")),c=u.value.map(v=>v.id),l=pe(c[0]),E=z(()=>PO(i.value.records,u.value,i.value.timezone,i.value.imperial_units,i.value.display_ascent,i.value.date_format,l.value)),d=z(()=>u.value.find(v=>v.id===l.value)),p=z(()=>s.getters.USER_SPORT_STATS[l.value]),T=z(()=>s.getters.TOTAL_WORKOUTS),A=i.value.imperial_units?Sn.km.defaultTarget:"km",I=i.value.imperial_units?Sn.m.defaultTarget:"m",m=z(()=>s.getters.STATS_LOADING),S=z(()=>p.value?Cp(p.value.total_duration,r):{days:"",duration:""});ft(()=>_());function h(v,D){if(v===void 0)return"";const L=i.value.imperial_units?Sn[D].defaultTarget:D;return i.value.imperial_units?Vt(v,D,L,2):v}function _(){l.value=n.query.sport_id&&c.includes(+n.query.sport_id)?+n.query.sport_id:c[0],s.dispatch(yt.ACTIONS.GET_USER_SPORT_STATS,{username:i.value.username,sportId:l.value})}function O(v){var L,U;const D=[];return(L=d.value)!=null&&L.translatedLabel&&v[(U=d.value)==null?void 0:U.translatedLabel].records.map($=>{D.push({...$,label:r(`workouts.RECORD_${$.record_type}`)})}),D.sort(DO)}function b(v){a.push({path:"/statistics",query:{chart:"by_sport",sport_id:v.target.value}})}return Me(()=>n.query,()=>{_()}),(v,D)=>{var $,W,Y,te,K,Se;const L=ie("SportImage"),U=ie("Distance");return N(),C("div",xat,[f("label",Fat,R(v.$t("workouts.SPORT",1))+": ",1),ke(f("select",{id:"sport","onUpdate:modelValue":D[0]||(D[0]=me=>l.value=me),onChange:b},[(N(!0),C(_e,null,$e(u.value,me=>(N(),C("option",{value:me.id,key:me.id},R(me.translatedLabel),9,Bat))),128))],544),[[Ta,l.value]]),d.value?(N(),C("div",Gat,[f("div",Hat,[x(L,{"sport-label":d.value.label,color:d.value.color},null,8,["sport-label","color"]),f("div",Vat,R(d.value.translatedLabel),1)]),f("div",null,[f("div",qat,[D[1]||(D[1]=f("i",{class:"fa fa-line-chart custom-fa-small","aria-hidden":"true"},null,-1)),G(" "+R(v.$t("statistics.STATISTICS",0)),1)]),f("div",Kat,[x(Ys,{icon:"calendar",loading:m.value,"total-value":T.value,label:v.$t("workouts.WORKOUT",0)},null,8,["loading","total-value","label"])]),p.value&&p.value.total_workouts[f("div",null,R(v.$t("statistics.AVERAGE"))+":",1),p.value?(N(),j(U,{key:0,distance:p.value.average_distance,unitFrom:"km",useImperialUnits:g(i).imperial_units},null,8,["distance","useImperialUnits"])):M("",!0)]),_:1},8,["loading","total-value","text","label"]),x(Ys,{icon:"clock-o",loading:m.value,"total-value":S.value.days,text:S.value.duration,label:v.$t("workouts.DURATION")},{average:le(()=>[f("div",null,R(v.$t("statistics.AVERAGE"))+":",1),f("span",null,R(p.value?g(sE)(p.value.average_duration,v.$t):""),1)]),_:1},8,["loading","total-value","text","label"]),x(Ys,{icon:"tachometer",loading:m.value,"total-value":h((W=p.value)==null?void 0:W.average_speed,"km"),text:`${g(A)}/h`,label:v.$t("workouts.AVE_SPEED")},null,8,["loading","total-value","text","label"]),((Y=p.value)==null?void 0:Y.total_ascent)!==null?(N(),j(Ys,{key:0,icon:"location-arrow",loading:m.value,"total-value":h((te=p.value)==null?void 0:te.total_ascent,"m"),text:g(I),label:v.$t("workouts.ASCENT")},{average:le(()=>[f("div",null,R(v.$t("statistics.AVERAGE"))+":",1),p.value?(N(),j(U,{key:0,distance:p.value.average_ascent,unitFrom:"m",useImperialUnits:g(i).imperial_units},null,8,["distance","useImperialUnits"])):M("",!0)]),_:1},8,["loading","total-value","text","label"])):M("",!0),((K=p.value)==null?void 0:K.total_descent)!==null?(N(),j(Ys,{key:1,icon:"location-arrow fa-rotate-90",loading:m.value,"total-value":h((Se=p.value)==null?void 0:Se.total_descent,"m"),text:g(I),label:v.$t("workouts.DESCENT")},{average:le(()=>[f("div",null,R(v.$t("statistics.AVERAGE"))+":",1),p.value?(N(),j(U,{key:0,distance:p.value.average_descent,unitFrom:"m",useImperialUnits:g(i).imperial_units},null,8,["distance","useImperialUnits"])):M("",!0)]),_:1},8,["loading","total-value","text","label"])):M("",!0)])]),f("div",Qat,[f("div",Zat,[D[2]||(D[2]=f("i",{class:"fa fa-trophy custom-fa-small","aria-hidden":"true"},null,-1)),G(" "+R(v.$t("workouts.RECORD",0)),1)]),f("div",null,[(N(!0),C(_e,null,$e(O(E.value),me=>(N(),j(CO,{record:me,key:me.id},null,8,["record"]))),128))])])])):M("",!0)])}}}),est=ue(Jat,[["__scopeId","data-v-2e2b2caa"]]),tst={id:"statistics",class:"view"},nst={key:0,class:"container"},ast=["value"],sst=ae({__name:"StatisticsView",setup(e){const t=bt(),n=_a(),a=Ue(),s=z(()=>a.getters[ee.GETTERS.AUTH_USER_PROFILE]),r=z(()=>a.getters[vt.GETTERS.SPORTS].filter(l=>s.value.sports_list.includes(l.id))),i=z(()=>s.value.nb_workouts===0),o=["by_time","by_sport"],u=pe("by_time");ft(()=>{u.value=t.query.chart&&o.includes(t.query.chart)?t.query.chart:"by_time"}),mt(()=>{if(!i.value){const l=document.getElementById("stats-type");l==null||l.focus()}});function c(l){n.push({path:"/statistics",query:{chart:l.target.value}})}return(l,E)=>{const d=ie("Card");return N(),C("div",tst,[s.value.username?(N(),C("div",nst,[x(d,null,{title:le(()=>[G(R(l.$t("statistics.STATISTICS"))+" ",1),r.value.length>0?ke((N(),C("select",{key:0,class:"stats-types",name:"stats-type",id:"stats-type","onUpdate:modelValue":E[0]||(E[0]=p=>u.value=p),onChange:c},[(N(),C(_e,null,$e(o,p=>f("option",{value:p,key:p},R(l.$t(`statistics.STATISTICS_TYPES.${p}`)),9,ast)),64))],544)),[[Ta,u.value]]):M("",!0)]),content:le(()=>[l.$route.query.chart!=="by_sport"?(N(),j(Cat,{key:0,class:Te({"stats-disabled":i.value}),user:s.value,sports:r.value,isDisabled:i.value},null,8,["class","user","sports","isDisabled"])):r.value.length>0?(N(),j(est,{key:1,sports:r.value,authUser:s.value},null,8,["sports","authUser"])):M("",!0)]),_:1}),s.value.nb_workouts===0?(N(),j(Lp,{key:0})):M("",!0)])):M("",!0)])}}}),rst=ue(sst,[["__scopeId","data-v-b57d20e5"]]),ist={name:"EmailSent"},ost={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 345.834 345.834",style:{"enable-background":"new 0 0 345.834 345.834"},"xml:space":"preserve"};function ust(e,t,n,a,s,r){return N(),C("svg",ost,t[0]||(t[0]=[f("g",null,[f("path",{d:`M339.798,260.429c0.13-0.026,0.257-0.061,0.385-0.094c0.109-0.028,0.219-0.051,0.326-0.084 - c0.125-0.038,0.247-0.085,0.369-0.129c0.108-0.039,0.217-0.074,0.324-0.119c0.115-0.048,0.226-0.104,0.338-0.157 - c0.109-0.052,0.22-0.1,0.327-0.158c0.107-0.057,0.208-0.122,0.312-0.184c0.107-0.064,0.215-0.124,0.319-0.194 - c0.111-0.074,0.214-0.156,0.321-0.236c0.09-0.067,0.182-0.13,0.27-0.202c0.162-0.133,0.316-0.275,0.466-0.421 - c0.027-0.026,0.056-0.048,0.083-0.075c0.028-0.028,0.052-0.059,0.079-0.088c0.144-0.148,0.284-0.3,0.416-0.46 - c0.077-0.094,0.144-0.192,0.216-0.289c0.074-0.1,0.152-0.197,0.221-0.301c0.074-0.111,0.139-0.226,0.207-0.34 - c0.057-0.096,0.118-0.19,0.171-0.289c0.062-0.115,0.114-0.234,0.169-0.351c0.049-0.104,0.101-0.207,0.146-0.314 - c0.048-0.115,0.086-0.232,0.128-0.349c0.041-0.114,0.085-0.227,0.12-0.343c0.036-0.118,0.062-0.238,0.092-0.358 - c0.029-0.118,0.063-0.234,0.086-0.353c0.028-0.141,0.045-0.283,0.065-0.425c0.014-0.1,0.033-0.199,0.043-0.3 - c0.025-0.249,0.038-0.498,0.038-0.748V92.76c0-4.143-3.357-7.5-7.5-7.5h-236.25c-0.066,0-0.13,0.008-0.196,0.01 - c-0.143,0.004-0.285,0.01-0.427,0.022c-0.113,0.009-0.225,0.022-0.337,0.037c-0.128,0.016-0.255,0.035-0.382,0.058 - c-0.119,0.021-0.237,0.046-0.354,0.073c-0.119,0.028-0.238,0.058-0.356,0.092c-0.117,0.033-0.232,0.069-0.346,0.107 - c-0.117,0.04-0.234,0.082-0.349,0.128c-0.109,0.043-0.216,0.087-0.322,0.135c-0.118,0.053-0.235,0.11-0.351,0.169 - c-0.099,0.051-0.196,0.103-0.292,0.158c-0.116,0.066-0.23,0.136-0.343,0.208c-0.093,0.06-0.184,0.122-0.274,0.185 - c-0.106,0.075-0.211,0.153-0.314,0.235c-0.094,0.075-0.186,0.152-0.277,0.231c-0.09,0.079-0.179,0.158-0.266,0.242 - c-0.099,0.095-0.194,0.194-0.288,0.294c-0.047,0.05-0.097,0.094-0.142,0.145c-0.027,0.03-0.048,0.063-0.074,0.093 - c-0.094,0.109-0.182,0.223-0.27,0.338c-0.064,0.084-0.13,0.168-0.19,0.254c-0.078,0.112-0.15,0.227-0.222,0.343 - c-0.059,0.095-0.12,0.189-0.174,0.286c-0.063,0.112-0.118,0.227-0.175,0.342c-0.052,0.105-0.106,0.21-0.153,0.317 - c-0.049,0.113-0.092,0.23-0.135,0.345c-0.043,0.113-0.087,0.225-0.124,0.339c-0.037,0.115-0.067,0.232-0.099,0.349 - c-0.032,0.12-0.066,0.239-0.093,0.36c-0.025,0.113-0.042,0.228-0.062,0.342c-0.022,0.13-0.044,0.26-0.06,0.39 - c-0.013,0.108-0.019,0.218-0.027,0.328c-0.01,0.14-0.019,0.28-0.021,0.421c-0.001,0.041-0.006,0.081-0.006,0.122v46.252 - c0,4.143,3.357,7.5,7.5,7.5s7.5-3.357,7.5-7.5v-29.595l66.681,59.037c-0.348,0.245-0.683,0.516-0.995,0.827l-65.687,65.687v-49.288 - c0-4.143-3.357-7.5-7.5-7.5s-7.5,3.357-7.5,7.5v9.164h-38.75c-4.143,0-7.5,3.357-7.5,7.5s3.357,7.5,7.5,7.5h38.75v43.231 - c0,4.143,3.357,7.5,7.5,7.5h236.25c0.247,0,0.494-0.013,0.74-0.037c0.115-0.011,0.226-0.033,0.339-0.049 - C339.542,260.469,339.67,260.454,339.798,260.429z M330.834,234.967l-65.688-65.687c-0.042-0.042-0.087-0.077-0.13-0.117 - l49.383-41.897c3.158-2.68,3.546-7.412,0.866-10.571c-2.678-3.157-7.41-3.547-10.571-0.866l-84.381,71.59l-98.444-87.158h208.965 - V234.967z M185.878,179.888c0.535-0.535,0.969-1.131,1.308-1.765l28.051,24.835c1.418,1.255,3.194,1.885,4.972,1.885 - c1.726,0,3.451-0.593,4.853-1.781l28.587-24.254c0.26,0.38,0.553,0.743,0.89,1.08l65.687,65.687H120.191L185.878,179.888z`}),f("path",{d:`M7.5,170.676h126.667c4.143,0,7.5-3.357,7.5-7.5s-3.357-7.5-7.5-7.5H7.5c-4.143,0-7.5,3.357-7.5,7.5 - S3.357,170.676,7.5,170.676z`}),f("path",{d:`M20.625,129.345H77.5c4.143,0,7.5-3.357,7.5-7.5s-3.357-7.5-7.5-7.5H20.625c-4.143,0-7.5,3.357-7.5,7.5 - S16.482,129.345,20.625,129.345z`}),f("path",{d:"M62.5,226.51h-55c-4.143,0-7.5,3.357-7.5,7.5s3.357,7.5,7.5,7.5h55c4.143,0,7.5-3.357,7.5-7.5S66.643,226.51,62.5,226.51z"})],-1)]))}const LO=ue(ist,[["render",ust]]),lst={id:"user-form"},cst={key:2,class:"info-box success-message"},dst={class:"form-items"},Est={key:0,for:"username"},pst=["disabled"],fst={key:2,class:"form-info"},mst={key:3,for:"email"},Tst=["disabled"],_st={key:5,class:"form-info"},hst={key:6,for:"password"},Sst={key:8,for:"accepted_policy",class:"accepted_policy"},Ast=["disabled"],Ost=["disabled"],gst={key:3},Ist={key:0},Rst={key:4},Nst={class:"account"},vst={key:5},bst=ae({__name:"UserAuthForm",props:{action:{},token:{default:""}},setup(e){const t=e,n=bt(),a=Ue(),{action:s}=he(t),r=Kt({username:"",email:"",password:"",accepted_policy:!1}),i=z(()=>A(t.action)),o=z(()=>a.getters[V.GETTERS.ERROR_MESSAGES]),u=z(()=>a.getters[ee.GETTERS.IS_REGISTRATION_SUCCESS]),c=z(()=>a.getters[ee.GETTERS.IS_SUCCESS]),l=z(()=>a.getters[V.GETTERS.APP_CONFIG]),E=z(()=>a.getters[V.GETTERS.LANGUAGE]),d=z(()=>t.action==="register"&&!l.value.is_registration_enabled),p=z(()=>["reset-request","account-confirmation-resend"].includes(t.action)&&!l.value.is_email_sending_enabled),T=pe(!1);function A(_){switch(_){case"reset-request":case"reset":return"buttons.SUBMIT";default:return`buttons.${t.action.toUpperCase()}`}}function I(){T.value=!0}function m(_){r.password=_}function S(_){switch(_){case"reset":return t.token?a.dispatch(ee.ACTIONS.RESET_USER_PASSWORD,{password:r.password,token:t.token}):a.commit(V.MUTATIONS.SET_ERROR_MESSAGES,"user.INVALID_TOKEN");case"reset-request":return a.dispatch(ee.ACTIONS.SEND_PASSWORD_RESET_REQUEST,{email:r.email});case"account-confirmation-resend":return a.dispatch(ee.ACTIONS.RESEND_ACCOUNT_CONFIRMATION_EMAIL,{email:r.email});default:r.language=E.value,a.dispatch(ee.ACTIONS.LOGIN_OR_REGISTER,{actionType:_,formData:r,redirectUrl:n.query.from})}}function h(){r.username="",r.email="",r.password="",r.accepted_policy=!1}return ct(()=>a.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)),Me(()=>n.path,async()=>{a.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),a.commit(ee.MUTATIONS.UPDATE_IS_SUCCESS,!1),a.commit(ee.MUTATIONS.UPDATE_IS_REGISTRATION_SUCCESS,!1),T.value=!1,h()}),(_,O)=>{const b=ie("AlertMessage"),v=ie("router-link"),D=ie("i18n-t"),L=ie("ErrorMessage");return N(),C("div",{id:"user-auth-form",class:Te(`${["reset","reset-request"].includes(g(s))?g(s):"user-form"}`)},[f("div",lst,[f("div",{class:Te(["form-box",{disabled:d.value}])},[d.value?(N(),j(b,{key:0,message:"user.REGISTER_DISABLED"})):M("",!0),p.value?(N(),j(b,{key:1,message:"admin.EMAIL_SENDING_DISABLED"})):M("",!0),c.value||u.value?(N(),C("div",cst,R(_.$t(`user.PROFILE.SUCCESSFUL_${u.value?`REGISTRATION${l.value.is_email_sending_enabled?"_WITH_EMAIL":""}`:"UPDATE"}`)),1)):M("",!0),f("form",{class:Te({errors:T.value}),onSubmit:O[3]||(O[3]=De(U=>S(g(s)),["prevent"]))},[f("div",dst,[g(s)==="register"?(N(),C("label",Est,R(_.$t("user.USERNAME",0)),1)):M("",!0),g(s)==="register"?ke((N(),C("input",{key:1,id:"username",disabled:d.value,required:"",pattern:"[a-zA-Z0-9_]+",minlength:"3",maxlength:"30",onInvalid:I,"onUpdate:modelValue":O[0]||(O[0]=U=>r.username=U),autocomplete:"username"},null,40,pst)),[[et,r.username]]):M("",!0),g(s)==="register"?(N(),C("div",fst,[O[4]||(O[4]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(_.$t("user.USERNAME_INFO")),1)])):M("",!0),g(s)!=="reset"?(N(),C("label",mst,R(_.$t("user.EMAIL",0)),1)):M("",!0),g(s)!=="reset"?ke((N(),C("input",{key:4,id:"email",disabled:d.value||p.value,required:"",onInvalid:I,type:"email","onUpdate:modelValue":O[1]||(O[1]=U=>r.email=U),autocomplete:"email"},null,40,Tst)),[[et,r.email]]):M("",!0),["reset-request","register","account-confirmation-resend"].includes(g(s))?(N(),C("div",_st,[O[5]||(O[5]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(_.$t("user.EMAIL_INFO")),1)])):M("",!0),["account-confirmation-resend","reset-request"].includes(g(s))?M("",!0):(N(),C("label",hst,R(_.$t(`user.${g(s)==="reset"?"ENTER_PASSWORD":"PASSWORD"}`)),1)),["account-confirmation-resend","reset-request"].includes(g(s))?M("",!0):(N(),j(aE,{key:7,id:"password",disabled:d.value,required:!0,password:r.password,checkStrength:["reset","register"].includes(g(s)),onUpdatePassword:m,onPasswordError:I,autocomplete:"current-password"},null,8,["disabled","password","checkStrength"])),g(s)==="register"?(N(),C("label",Sst,[ke(f("input",{type:"checkbox",id:"accepted_policy",disabled:d.value,required:"",onInvalid:I,"onUpdate:modelValue":O[2]||(O[2]=U=>r.accepted_policy=U)},null,40,Ast),[[Qu,r.accepted_policy]]),f("span",null,[x(D,{keypath:"user.READ_AND_ACCEPT_PRIVACY_POLICY"},{default:le(()=>[x(v,{to:"/privacy-policy",target:"_blank"},{default:le(()=>[G(R(_.$t("privacy_policy.TITLE")),1)]),_:1})]),_:1})])])):M("",!0)]),f("button",{type:"submit",disabled:d.value||p.value},R(_.$t(i.value)),9,Ost)],34),g(s)==="login"?(N(),C("div",gst,[x(v,{class:"links",to:"/register"},{default:le(()=>[G(R(_.$t("user.REGISTER")),1)]),_:1}),l.value.is_email_sending_enabled?(N(),C("span",Ist,"-")):M("",!0),l.value.is_email_sending_enabled?(N(),j(v,{key:1,class:"links",to:"/password-reset/request"},{default:le(()=>[G(R(_.$t("user.PASSWORD_FORGOTTEN")),1)]),_:1})):M("",!0)])):M("",!0),g(s)==="register"?(N(),C("div",Rst,[f("span",Nst,R(_.$t("user.ALREADY_HAVE_ACCOUNT")),1),x(v,{class:"links",to:"/login"},{default:le(()=>[G(R(_.$t("user.LOGIN")),1)]),_:1})])):M("",!0),["login","register"].includes(g(s))&&l.value.is_email_sending_enabled?(N(),C("div",vst,[x(v,{class:"links",to:"/account-confirmation/resend"},{default:le(()=>[G(R(_.$t("user.ACCOUNT_CONFIRMATION_NOT_RECEIVED")),1)]),_:1})])):M("",!0),o.value?(N(),j(L,{key:6,message:o.value},null,8,["message"])):M("",!0)],2)])],2)}}}),yp=ue(bst,[["__scopeId","data-v-1d52bb01"]]),Cst={id:"account-confirmation-email",class:"center-card with-margin"},Dst={key:0,class:"email-sent"},Pst={class:"email-sent-message"},Lst={key:1},yst=ae({__name:"AccountConfirmationEmail",props:{action:{}},setup(e){const t=e,{action:n}=he(t);return(a,s)=>{const r=ie("Card");return N(),C("div",Cst,[g(n)==="email-sent"?(N(),C("div",Dst,[x(LO),f("div",Pst,R(a.$t("user.ACCOUNT_CONFIRMATION_SENT")),1)])):(N(),C("div",Lst,[x(r,null,{title:le(()=>[G(R(a.$t("user.RESENT_ACCOUNT_CONFIRMATION")),1)]),content:le(()=>[x(yp,{action:g(n)},null,8,["action"])]),_:1})]))])}}}),$st=ue(yst,[["__scopeId","data-v-b0299010"]]),kst={id:"account-confirmation",class:"view"},Ust={class:"container"},wst=ae({__name:"AccountConfirmationResendView",props:{action:{}},setup(e){const t=e,{action:n}=he(t);return(a,s)=>(N(),C("div",kst,[f("div",Ust,[x($st,{action:g(n)},null,8,["action"])])]))}}),g0=ue(wst,[["__scopeId","data-v-9a9c1644"]]),Mst={key:0,id:"account-confirmation",class:"center-card with-margin"},Wst={class:"error-message"},zst=ae({__name:"AccountConfirmationView",setup(e){const t=bt(),n=_a(),a=Ue(),s=z(()=>a.getters[V.GETTERS.ERROR_MESSAGES]),r=z(()=>t.query.token);ft(()=>i());function i(){r.value?a.dispatch(ee.ACTIONS.CONFIRM_ACCOUNT,{token:r.value}):n.push("/")}return ct(()=>a.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)),(o,u)=>{const c=ie("router-link");return s.value?(N(),C("div",Mst,[x(FE),f("p",Wst,[f("span",null,R(o.$t("error.SOMETHING_WRONG"))+".",1),x(c,{class:"links",to:"/account-confirmation/resend"},{default:le(()=>[G(R(o.$t("buttons.ACCOUNT-CONFIRMATION-RESEND"))+"? ",1)]),_:1})])])):M("",!0)}}}),xst=ue(zst,[["__scopeId","data-v-1b343aed"]]),Fst={key:0,id:"email-update",class:"center-card with-margin"},Bst={class:"error-message"},Gst=ae({__name:"EmailUpdateView",setup(e){const t=bt(),n=_a(),a=Ue(),s=z(()=>a.getters[ee.GETTERS.AUTH_USER_PROFILE]),r=z(()=>a.getters[ee.GETTERS.IS_AUTHENTICATED]),i=z(()=>a.getters[V.GETTERS.ERROR_MESSAGES]),o=z(()=>t.query.token);ft(()=>u());function u(){o.value?a.dispatch(ee.ACTIONS.CONFIRM_EMAIL,{token:o.value,refreshUser:r.value}):n.push("/")}return ct(()=>a.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)),Me(()=>i.value,c=>{s.value.username&&c&&n.push("/")}),(c,l)=>{const E=ie("router-link"),d=ie("i18n-t");return i.value&&!s.value.username?(N(),C("div",Fst,[x(FE),f("p",Bst,[f("span",null,R(c.$t("error.SOMETHING_WRONG"))+".",1),f("span",null,[x(d,{keypath:"user.PROFILE.ERRORED_EMAIL_UPDATE"},{default:le(()=>[x(E,{to:"/login"},{default:le(()=>[G(R(c.$t("user.LOG_IN")),1)]),_:1})]),_:1})])])])):M("",!0)}}}),Hst=ue(Gst,[["__scopeId","data-v-8b516209"]]),Vst={id:"loginOrRegister",class:"view"},qst={class:"container"},Kst={class:"container-sub"},jst={class:"container-sub"},Yst=ae({__name:"LoginOrRegister",props:{action:{}},setup(e){const t=e,{action:n}=he(t);return(a,s)=>(N(),C("div",Vst,[f("div",qst,[f("div",Kst,[x(hO)]),f("div",jst,[x(yp,{action:g(n)},null,8,["action"])])])]))}}),I0=ue(Yst,[["__scopeId","data-v-84d61340"]]),Xst={name:"Password"},Qst={version:"1.1",id:"Layer_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 512.001 512.001",style:{"enable-background":"new 0 0 512.001 512.001"},"xml:space":"preserve"};function Zst(e,t,n,a,s,r){return N(),C("svg",Qst,t[0]||(t[0]=[gn(``,7)]))}const Jst=ue(Xst,[["render",Zst]]),ert={id:"password-action-done",class:"center-card with-margin"},trt={class:"password-message"},nrt={key:0},art=ae({__name:"PasswordActionDone",props:{action:{}},setup(e){const t=e,{action:n}=he(t);return(a,s)=>{const r=ie("router-link"),i=ie("i18n-t");return N(),C("div",ert,[g(n)==="request-sent"?(N(),j(LO,{key:0})):(N(),j(Jst,{key:1})),f("div",trt,[g(n)==="request-sent"?(N(),C("span",nrt,R(a.$t("user.PASSWORD_SENT_EMAIL_TEXT")),1)):(N(),j(i,{key:1,keypath:"user.PASSWORD_UPDATED"},{default:le(()=>[x(r,{to:"/login"},{default:le(()=>[G(R(a.$t("common.HERE")),1)]),_:1})]),_:1}))])])}}}),srt=ue(art,[["__scopeId","data-v-ee1004fc"]]),rrt={id:"password-reset-request",class:"center-card with-margin"},irt=ae({__name:"PasswordResetForm",props:{action:{},token:{default:""}},setup(e){const t=e,{action:n,token:a}=he(t);return(s,r)=>{const i=ie("Card");return N(),C("div",rrt,[x(i,null,{title:le(()=>[G(R(s.$t("user.RESET_PASSWORD")),1)]),content:le(()=>[x(yp,{action:g(n),token:g(a)},null,8,["action","token"])]),_:1})])}}}),ort=ue(irt,[["__scopeId","data-v-97f01ba1"]]),urt={id:"password-reset",class:"view"},lrt={class:"container"},crt=ae({__name:"PasswordResetView",props:{action:{}},setup(e){const t=e,n=bt(),a=_a(),{action:s}=he(t),r=z(()=>n.query.token);return ft(()=>{t.action==="reset"&&!r.value&&a.push("/")}),(i,o)=>(N(),C("div",urt,[f("div",lrt,[g(s).startsWith("reset")?(N(),j(ort,{key:0,action:g(s),token:r.value},null,8,["action","token"])):(N(),j(srt,{key:1,action:g(s)},null,8,["action"]))])]))}}),Ro=ue(crt,[["__scopeId","data-v-5cbe9029"]]),drt={key:0,id:"profile",class:"view"},Ert=ae({__name:"ProfileView",setup(e){const t=Ue(),n=z(()=>t.getters[ee.GETTERS.AUTH_USER_PROFILE]);return(a,s)=>{const r=ie("router-view");return n.value.username?(N(),C("div",drt,[x(r,{user:n.value},null,8,["user"]),s[0]||(s[0]=f("div",{id:"bottom"},null,-1))])):M("",!0)}}}),prt=ue(Ert,[["__scopeId","data-v-af92ad3a"]]),frt={key:0,id:"user",class:"view"},mrt={class:"box"},Trt=ae({__name:"UserView",props:{fromAdmin:{type:Boolean}},setup(e){const t=e,{fromAdmin:n}=he(t),a=bt(),s=Ue(),r=z(()=>s.getters[Fe.GETTERS.USER]);return ft(()=>{a.params.username&&typeof a.params.username=="string"&&s.dispatch(Fe.ACTIONS.GET_USER,a.params.username)}),wi(()=>{s.dispatch(Fe.ACTIONS.EMPTY_USER)}),(i,o)=>r.value.username?(N(),C("div",frt,[x(QA,{user:r.value},null,8,["user"]),f("div",mrt,[x(JA,{user:r.value,"from-admin":g(n)},null,8,["user","from-admin"])])])):M("",!0)}}),R0=ue(Trt,[["__scopeId","data-v-1b7a0b4f"]]),_rt={id:"workout-form"},hrt={class:"form-items"},Srt={key:0,class:"form-item-radio"},Art=["checked","disabled"],Ort={for:"withGpx"},grt=["checked","disabled"],Irt={for:"withoutGpx"},Rrt={class:"form-item"},Nrt={for:"sport"},vrt=["disabled"],brt=["value"],Crt={key:1,class:"form-item"},Drt={for:"gpxFile"},Prt=["disabled"],Lrt={class:"files-help info-box"},yrt={class:"form-item"},$rt={for:"title"},krt=["required","disabled"],Urt={key:0,class:"field-help"},wrt={class:"info-box"},Mrt={key:2},Wrt={class:"workout-date-duration"},zrt={class:"form-item"},xrt={class:"workout-date-time"},Frt=["disabled"],Brt=["disabled"],Grt={class:"form-item"},Hrt={for:"workout-duration-hour",class:"visually-hidden"},Vrt=["disabled"],qrt={for:"workout-duration-minutes",class:"visually-hidden"},Krt=["disabled"],jrt={for:"workout-duration-seconds",class:"visually-hidden"},Yrt=["disabled"],Xrt={class:"workout-data"},Qrt={class:"form-item"},Zrt=["disabled"],Jrt={class:"form-item"},eit=["disabled"],tit={class:"form-item"},nit=["disabled"],ait={key:3,class:"form-item"},sit={for:"workout-equipment"},rit=["disabled"],iit={value:""},oit=["value"],uit={key:4,class:"form-item"},lit={for:"description"},cit={key:0,class:"field-help"},dit={class:"info-box"},Eit={key:5,class:"form-item"},pit={for:"notes"},fit={key:1},mit={key:2,class:"form-buttons"},Tit=["disabled"],_it=ae({__name:"WorkoutEdition",props:{authUser:{},sports:{},isCreation:{type:Boolean,default:!1},loading:{type:Boolean,default:!1},workout:{default:()=>({})}},setup(e){const t=e,{t:n}=Ct(),a=Ue(),s=_a(),{authUser:r,workout:i,isCreation:o,loading:u}=he(t),c=z(()=>Hn(t.sports,n,"is_active_for_user",i.value.id?[i.value.sport_id]:[])),l=z(()=>a.getters[V.GETTERS.APP_CONFIG]),E=l.value.max_single_file_size?ru(l.value.max_single_file_size):"",d=l.value.gpx_limit_import,p=l.value.max_zip_file_size?ru(l.value.max_zip_file_size):"",T=z(()=>a.getters[V.GETTERS.ERROR_MESSAGES]),A=Kt({sport_id:"",title:"",notes:"",workoutDate:"",workoutTime:"",workoutDurationHour:"",workoutDurationMinutes:"",workoutDurationSeconds:"",workoutDistance:"",workoutAscent:"",workoutDescent:"",equipment_id:"",description:""}),I=pe(i.value.id?i.value.with_gpx:o.value);let m=null;const S=pe(!1),h=pe([]),_=z(()=>a.getters[We.GETTERS.EQUIPMENTS]),O=z(()=>A.sport_id?c.value.filter(J=>J.id===+A.sport_id)[0]:null),b=z(()=>_.value?GA(_.value,n,o.value?"is_active":"withIncludedIds",O.value,o.value?[]:i.value.equipments.map(J=>J.id)):[]);mt(()=>{let J;t.workout.id?($(t.workout),J=document.getElementById("sport")):J=document.getElementById("withGpx"),J&&J.focus()});function v(J){A.notes=J}function D(J){A.description=J}function L(){I.value=!I.value,S.value=!1}function U(J){J.target.files&&(m=J.target.files[0])}function $(J){if(A.sport_id=`${J.sport_id}`,A.title=J.title,A.description=J.description,A.notes=J.notes,A.equipment_id=J.equipments.length>0?`${J.equipments[0].id}`:"",!J.with_gpx){const Ne=YA(Ol(J.workout_date,t.authUser.timezone),"yyyy-MM-dd"),st=J.duration.split(":");A.workoutDistance=`${r.value.imperial_units?Vt(J.distance,"km","mi",3):parseFloat(J.distance.toFixed(3))}`,A.workoutDate=Ne.workout_date,A.workoutTime=Ne.workout_time,A.workoutDurationHour=st[0],A.workoutDurationMinutes=st[1],A.workoutDurationSeconds=st[2],A.workoutAscent=J.ascent===null?"":`${r.value.imperial_units?Vt(J.ascent,"m","ft",2):parseFloat(J.ascent.toFixed(2))}`,A.workoutDescent=J.descent===null?"":`${r.value.imperial_units?Vt(J.descent,"m","ft",2):parseFloat(J.descent.toFixed(2))}`}}function W(){return h.value.includes("workouts.INVALID_DISTANCE")}function Y(){return h.value.includes("workouts.INVALID_DURATION")}function te(){return h.value.includes("workouts.INVALID_ASCENT_OR_DESCENT")}function K(J){h.value=[],J.duration=+A.workoutDurationHour*3600+ +A.workoutDurationMinutes*60+ +A.workoutDurationSeconds,J.duration<=0&&h.value.push("workouts.INVALID_DURATION"),J.distance=r.value.imperial_units?Vt(+A.workoutDistance,"mi","km",3):+A.workoutDistance,J.distance<=0&&h.value.push("workouts.INVALID_DISTANCE"),J.workout_date=`${A.workoutDate} ${A.workoutTime}`,J.ascent=A.workoutAscent===""?null:r.value.imperial_units?Vt(+A.workoutAscent,"ft","m",3):+A.workoutAscent,J.descent=A.workoutDescent===""?null:r.value.imperial_units?Vt(+A.workoutDescent,"ft","m",3):+A.workoutDescent,(J.ascent!==null&&J.descent===null||J.ascent===null&&J.descent!==null)&&h.value.push("workouts.INVALID_ASCENT_OR_DESCENT")}function Se(){const J={sport_id:+A.sport_id,description:A.description,notes:A.notes,equipment_ids:A.equipment_id&&b.value.find(Ne=>Ne.id===A.equipment_id)?[A.equipment_id]:[],title:A.title};if(t.workout.id)t.workout.with_gpx||K(J),h.value.length>0?a.commit(V.MUTATIONS.SET_ERROR_MESSAGES,h.value):a.dispatch(Oe.ACTIONS.EDIT_WORKOUT,{workoutId:t.workout.id,data:J});else if(I.value){if(!m){a.commit(V.MUTATIONS.SET_ERROR_MESSAGES,"workouts.NO_FILE_PROVIDED");return}J.file=m,a.dispatch(Oe.ACTIONS.ADD_WORKOUT,J)}else K(J),h.value.length>0?a.commit(V.MUTATIONS.SET_ERROR_MESSAGES,h.value):a.dispatch(Oe.ACTIONS.ADD_WORKOUT_WITHOUT_GPX,J)}function me(){t.workout.id?s.push({name:"Workout",params:{workoutId:t.workout.id}}):s.go(-1)}function ge(){S.value=!0}return ct(()=>a.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES)),Me(()=>t.workout,async(J,Ne)=>{J!==Ne&&J&&J.id&&$(J)}),Me(()=>O.value,J=>{o.value&&(A.equipment_id=J!=null&&J.default_equipments&&(J==null?void 0:J.default_equipments.length)>0?`${J.default_equipments[0].id}`:"")}),(J,Ne)=>{const st=ie("CustomTextArea"),Ot=ie("ErrorMessage"),Le=ie("Loader"),Dt=ie("Card");return N(),C("div",{id:"workout-edition",class:Te(["center-card",{"center-form":g(i)&&g(i).with_gpx,"with-margin":!g(o)}])},[x(Dt,null,{title:le(()=>[G(R(J.$t(`workouts.${g(o)?"ADD":"EDIT"}_WORKOUT`)),1)]),content:le(()=>[f("div",_rt,[f("form",{class:Te({errors:S.value}),onSubmit:De(Se,["prevent"])},[f("div",hrt,[g(o)?(N(),C("div",Srt,[f("div",null,[f("input",{id:"withGpx",type:"radio",checked:I.value,disabled:g(u),onClick:L},null,8,Art),f("label",Ort,R(J.$t("workouts.WITH_GPX")),1)]),f("div",null,[f("input",{id:"withoutGpx",type:"radio",checked:!I.value,disabled:g(u),onClick:L},null,8,grt),f("label",Irt,R(J.$t("workouts.WITHOUT_GPX")),1)])])):M("",!0),f("div",Rrt,[f("label",Nrt,R(J.$t("workouts.SPORT",1))+"*: ",1),ke(f("select",{id:"sport",required:"",onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[0]||(Ne[0]=Ge=>A.sport_id=Ge)},[(N(!0),C(_e,null,$e(c.value,Ge=>(N(),C("option",{value:Ge.id,key:Ge.id},R(Ge.translatedLabel),9,brt))),128))],40,vrt),[[Ta,A.sport_id]])]),g(o)&&I.value?(N(),C("div",Crt,[f("label",Drt,R(J.$t("workouts.GPX_FILE"))+" "+R(J.$t("workouts.ZIP_ARCHIVE_DESCRIPTION"))+"*: ",1),f("input",{id:"gpxFile",name:"gpxFile",type:"file",accept:".gpx, .zip",disabled:g(u),required:"",onInvalid:ge,onInput:U},null,40,Prt),f("div",Lrt,[f("div",null,[f("strong",null,R(J.$t("workouts.GPX_FILE"))+":",1),f("ul",null,[f("li",null,R(J.$t("workouts.MAX_SIZE"))+": "+R(g(E)),1)])]),f("div",null,[f("strong",null,R(J.$t("workouts.ZIP_ARCHIVE"))+":",1),f("ul",null,[f("li",null,R(J.$t("workouts.NO_FOLDER")),1),f("li",null,R(J.$t("workouts.MAX_FILES"))+": "+R(g(d)),1),f("li",null,R(J.$t("workouts.MAX_SIZE"))+": "+R(g(p)),1)])])])])):M("",!0),f("div",yrt,[f("label",$rt,R(J.$t("workouts.TITLE"))+R(g(o)?"":"*")+": ",1),ke(f("input",{id:"title",name:"title",type:"text",required:!g(o),onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[1]||(Ne[1]=Ge=>A.title=Ge),maxlength:"255"},null,40,krt),[[et,A.title]]),I.value&&g(o)?(N(),C("div",Urt,[f("span",wrt,[Ne[11]||(Ne[11]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(J.$t("workouts.TITLE_FIELD_HELP")),1)])])):M("",!0)]),I.value?M("",!0):(N(),C("div",Mrt,[f("div",Wrt,[f("div",zrt,[f("label",null,R(J.$t("workouts.WORKOUT_DATE"))+"*:",1),f("div",xrt,[ke(f("input",{id:"workout-date",name:"workout-date",type:"date",required:"",onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[2]||(Ne[2]=Ge=>A.workoutDate=Ge)},null,40,Frt),[[et,A.workoutDate]]),ke(f("input",{id:"workout-time",name:"workout-time",class:"workout-time",type:"time",required:"",onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[3]||(Ne[3]=Ge=>A.workoutTime=Ge)},null,40,Brt),[[et,A.workoutTime]])])]),f("div",Grt,[f("label",null,R(J.$t("workouts.DURATION"))+"*:",1),f("div",null,[f("label",Hrt,R(J.$t("common.HOURS",0)),1),ke(f("input",{id:"workout-duration-hour",name:"workout-duration-hour",class:Te(["workout-duration",{errored:Y()}]),type:"text",placeholder:"HH",minlength:"1",maxlength:"2",pattern:"^([0-1]?[0-9]|2[0-3])$",required:"",onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[4]||(Ne[4]=Ge=>A.workoutDurationHour=Ge)},null,42,Vrt),[[et,A.workoutDurationHour]]),Ne[12]||(Ne[12]=G(" : ")),f("label",qrt,R(J.$t("common.MINUTES",0)),1),ke(f("input",{id:"workout-duration-minutes",name:"workout-duration-minutes",class:Te(["workout-duration",{errored:Y()}]),type:"text",pattern:"^([0-5][0-9])$",minlength:"2",maxlength:"2",placeholder:"MM",required:"",onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[5]||(Ne[5]=Ge=>A.workoutDurationMinutes=Ge)},null,42,Krt),[[et,A.workoutDurationMinutes]]),Ne[13]||(Ne[13]=G(" : ")),f("label",jrt,R(J.$t("common.SECONDS",0)),1),ke(f("input",{id:"workout-duration-seconds",name:"workout-duration-seconds",class:Te(["workout-duration",{errored:Y()}]),type:"text",pattern:"^([0-5][0-9])$",minlength:"2",maxlength:"2",placeholder:"SS",required:"",onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[6]||(Ne[6]=Ge=>A.workoutDurationSeconds=Ge)},null,42,Yrt),[[et,A.workoutDurationSeconds]])])])]),f("div",Xrt,[f("div",Qrt,[f("label",null,R(J.$t("workouts.DISTANCE"))+" ("+R(g(r).imperial_units?"mi":"km")+")*: ",1),ke(f("input",{class:Te({errored:W()}),name:"workout-distance",type:"number",min:"0",step:"0.001",required:"",onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[7]||(Ne[7]=Ge=>A.workoutDistance=Ge)},null,42,Zrt),[[et,A.workoutDistance]])]),f("div",Jrt,[f("label",null,R(J.$t("workouts.ASCENT"))+" ("+R(g(r).imperial_units?"ft":"m")+"): ",1),ke(f("input",{class:Te({errored:te()}),name:"workout-ascent",type:"number",min:"0",step:"0.01",onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[8]||(Ne[8]=Ge=>A.workoutAscent=Ge)},null,42,eit),[[et,A.workoutAscent]])]),f("div",tit,[f("label",null,R(J.$t("workouts.DESCENT"))+" ("+R(g(r).imperial_units?"ft":"m")+"): ",1),ke(f("input",{class:Te({errored:te()}),name:"workout-descent",type:"number",min:"0",step:"0.01",onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[9]||(Ne[9]=Ge=>A.workoutDescent=Ge)},null,42,nit),[[et,A.workoutDescent]])])])])),_.value?(N(),C("div",ait,[f("label",sit,R(J.$t("equipments.EQUIPMENT",1))+": ",1),ke(f("select",{id:"workout-equipment",onInvalid:ge,disabled:g(u),"onUpdate:modelValue":Ne[10]||(Ne[10]=Ge=>A.equipment_id=Ge)},[f("option",iit,R(J.$t("equipments.NO_EQUIPMENTS")),1),(N(!0),C(_e,null,$e(b.value,Ge=>(N(),C("option",{value:Ge.id,key:Ge.id},R(Ge.label),9,oit))),128))],40,rit),[[Ta,A.equipment_id]])])):M("",!0),g(o)?(N(),C("div",uit,[f("label",lit,R(J.$t("workouts.DESCRIPTION"))+": ",1),x(st,{name:"description",input:A.description,disabled:g(u),charLimit:1e4,rows:5,onUpdateValue:D},null,8,["input","disabled"]),I.value&&g(o)?(N(),C("div",cit,[f("span",dit,[Ne[14]||(Ne[14]=f("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),G(" "+R(J.$t("workouts.DESCRIPTION_FIELD_HELP")),1)])])):M("",!0)])):M("",!0),g(o)?(N(),C("div",Eit,[f("label",pit,R(J.$t("workouts.NOTES"))+": ",1),x(st,{name:"notes",input:A.notes,disabled:g(u),onUpdateValue:v},null,8,["input","disabled"])])):M("",!0)]),T.value?(N(),j(Ot,{key:0,message:T.value},null,8,["message"])):M("",!0),g(u)?(N(),C("div",fit,[x(Le)])):(N(),C("div",mit,[f("button",{class:"confirm",type:"submit",disabled:g(u)},R(J.$t("buttons.SUBMIT")),9,Tit),f("button",{class:"cancel",onClick:De(me,["prevent"])},R(J.$t("buttons.CANCEL")),1)]))],34)])]),_:1})],2)}}}),yO=ue(_it,[["__scopeId","data-v-ea44f353"]]),hit={id:"add-workout",class:"view"},Sit={class:"container"},Ait=ae({__name:"AddWorkout",setup(e){const t=Ue(),n=z(()=>t.getters[vt.GETTERS.SPORTS]),a=z(()=>t.getters[ee.GETTERS.AUTH_USER_PROFILE]),s=z(()=>t.getters[Oe.GETTERS.WORKOUT_DATA]);return(r,i)=>(N(),C("div",hit,[f("div",Sit,[x(yO,{authUser:a.value,sports:n.value,isCreation:!0,loading:s.value.loading},null,8,["authUser","sports","loading"])])]))}}),Oit={id:"edit-workout",class:"view"},git={class:"container"},Iit=ae({__name:"EditWorkout",setup(e){const t=bt(),n=Ue(),a=z(()=>n.getters[ee.GETTERS.AUTH_USER_PROFILE]),s=z(()=>n.getters[vt.GETTERS.SPORTS]),r=z(()=>n.getters[Oe.GETTERS.WORKOUT_DATA]);return ft(()=>{n.dispatch(Oe.ACTIONS.GET_WORKOUT_DATA,{workoutId:t.params.workoutId})}),Me(()=>t.params.workoutId,async i=>{i||n.commit(Oe.MUTATIONS.EMPTY_WORKOUT)}),(i,o)=>(N(),C("div",Oit,[f("div",git,[r.value.workout.id?(N(),j(yO,{key:0,authUser:a.value,sports:s.value,workout:r.value.workout,loading:r.value.loading},null,8,["authUser","sports","workout","loading"])):M("",!0)])]))}}),Rit={id:"workout-card-title"},Nit=["disabled","title"],vit={class:"workout-card-title"},bit={class:"workout-title-date"},Cit={key:0,class:"workout-title"},Dit=["aria-label"],Pit=["aria-label"],Lit=["aria-label"],yit={key:1,class:"workout-title"},$it={class:"workout-segment"},kit={class:"workout-date"},Uit=["datetime"],wit={class:"workout-link"},Mit=["disabled","title"],Wit=ae({__name:"WorkoutCardTitle",props:{sport:{},workoutObject:{}},emits:["displayModal"],setup(e,{emit:t}){const n=e,a=t,{sport:s,workoutObject:r}=he(n);async function i(u){await ze.get(`workouts/${u}/gpx/download`,{responseType:"blob"}).then(c=>{const l=window.URL.createObjectURL(new Blob([c.data],{type:"application/gpx+xml"})),E=document.createElement("a");E.href=l,E.setAttribute("download",`${u}.gpx`),document.body.appendChild(E),E.click()})}function o(){a("displayModal",!0)}return(u,c)=>{const l=ie("SportImage"),E=ie("router-link");return N(),C("div",Rit,[f("button",{class:Te(["workout-previous workout-arrow transparent",{inactive:!g(r).previousUrl}]),disabled:!g(r).previousUrl,title:g(r).previousUrl?u.$t(`workouts.PREVIOUS_${g(r).type}`):u.$t(`workouts.NO_PREVIOUS_${g(r).type}`),onClick:c[0]||(c[0]=d=>g(r).previousUrl?u.$router.push(g(r).previousUrl):null)},c[4]||(c[4]=[f("i",{class:"fa fa-chevron-left","aria-hidden":"true"},null,-1)]),10,Nit),f("div",vit,[x(l,{"sport-label":g(s).label,color:g(s).color},null,8,["sport-label","color"]),f("div",bit,[g(r).type==="WORKOUT"?(N(),C("div",Cit,[f("span",null,R(g(r).title),1),f("button",{class:"transparent icon-button",onClick:c[1]||(c[1]=d=>u.$router.push({name:"EditWorkout",params:{workoutId:g(r).workoutId}})),"aria-label":u.$t("workouts.EDIT_WORKOUT")},c[5]||(c[5]=[f("i",{class:"fa fa-edit","aria-hidden":"true"},null,-1)]),8,Dit),g(r).with_gpx?(N(),C("button",{key:0,class:"transparent icon-button",onClick:c[2]||(c[2]=De(d=>i(g(r).workoutId),["prevent"])),"aria-label":u.$t("workouts.DOWNLOAD_WORKOUT")},c[6]||(c[6]=[f("i",{class:"fa fa-download","aria-hidden":"true"},null,-1)]),8,Pit)):M("",!0),f("button",{id:"delete-workout-button",class:"transparent icon-button",onClick:De(o,["prevent"]),"aria-label":u.$t("workouts.DELETE_WORKOUT")},c[7]||(c[7]=[f("i",{class:"fa fa-trash","aria-hidden":"true"},null,-1)]),8,Lit)])):g(r).segmentId!==null?(N(),C("div",yit,[G(R(g(r).title)+" ",1),f("span",$it,[c[8]||(c[8]=G(" — ")),c[9]||(c[9]=f("i",{class:"fa fa-map-marker","aria-hidden":"true"},null,-1)),G(" "+R(u.$t("workouts.SEGMENT"))+" "+R(g(r).segmentId+1),1)])])):M("",!0),f("div",kit,[f("time",{datetime:g(r).workoutFullDate},R(g(r).workoutDate)+" - "+R(g(r).workoutTime),9,Uit),f("span",wit,[g(r).type==="SEGMENT"?(N(),j(E,{key:0,to:{name:"Workout",params:{workoutId:g(r).workoutId}}},{default:le(()=>[G(" > "+R(u.$t("workouts.BACK_TO_WORKOUT")),1)]),_:1},8,["to"])):M("",!0)])])])]),f("button",{class:Te(["workout-next workout-arrow transparent",{inactive:!g(r).nextUrl}]),disabled:!g(r).nextUrl,title:g(r).nextUrl?u.$t(`workouts.NEXT_${g(r).type}`):u.$t(`workouts.NO_NEXT_${g(r).type}`),onClick:c[3]||(c[3]=d=>g(r).nextUrl?u.$router.push(g(r).nextUrl):null)},c[10]||(c[10]=[f("i",{class:"fa fa-chevron-right","aria-hidden":"true"},null,-1)]),10,Mit)])}}}),zit=ue(Wit,[["__scopeId","data-v-ba7fce77"]]),xit={key:0,class:"workout-record"},Fit=ae({__name:"WorkoutRecord",props:{recordType:{},workoutObject:{}},setup(e){const t=e,{recordType:n,workoutObject:a}=he(t);return(s,r)=>g(a).records&&g(a).records.find(i=>i.record_type===g(n))?(N(),C("span",xit,r[0]||(r[0]=[f("sup",null,[f("i",{class:"fa fa-trophy","aria-hidden":"true"})],-1)]))):M("",!0)}}),Vr=ue(Fit,[["__scopeId","data-v-1b247cbe"]]),Bit="/img/weather/temperature.svg",Git="/img/weather/pour-rain.svg",Hit="/img/weather/breeze.svg",Vit=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"],qit=e=>{const t=Math.floor(e/22.5+.5);return Vit[t%16]},Kit={class:"wind"},jit={class:"wind-bearing"},Yit=["title"],Xit=ae({__name:"WeatherWind",props:{weather:{},useImperialUnits:{type:Boolean}},setup(e){const t=e,{useImperialUnits:n,weather:a}=he(t),{t:s}=Ct();function r(i){return s(`workouts.WEATHER.WIND_DIRECTIONS.${qit(i)}`)}return(i,o)=>(N(),C("div",Kit,[G(R(g(D6)(g(a).wind,g(n)))+" ",1),f("div",jit,[g(a).windBearing?(N(),C("i",{key:0,class:"fa fa-long-arrow-down",style:wa({transform:`rotate(${g(a).windBearing}deg)`}),"aria-hidden":"true",title:r(g(a).windBearing)},null,12,Yit)):M("",!0)])]))}}),N0=ue(Xit,[["__scopeId","data-v-e1f7f9cc"]]),Qit={key:0,id:"workout-weather"},Zit={class:"weather-table"},Jit={class:"weather-th"},eot=["src","alt","title"],tot={class:"weather-th"},not=["src","alt","title"],aot=["alt","title"],sot=["alt","title"],rot=["alt","title"],iot=ae({__name:"WorkoutWeather",props:{workoutObject:{},useImperialUnits:{type:Boolean}},setup(e){const t=e,{useImperialUnits:n,workoutObject:a}=he(t);return(s,r)=>g(a).weatherStart&&g(a).weatherEnd?(N(),C("div",Qit,[f("table",Zit,[f("thead",null,[f("tr",null,[r[0]||(r[0]=f("th",null,null,-1)),f("th",null,[f("div",Jit,[G(R(s.$t("workouts.START"))+" ",1),f("img",{class:"weather-img",src:`/img/weather/${g(a).weatherStart.icon}.svg`,alt:s.$t(`workouts.WEATHER.DARK_SKY.${g(a).weatherStart.icon}`),title:s.$t(`workouts.WEATHER.DARK_SKY.${g(a).weatherStart.icon}`)},null,8,eot)])]),f("th",null,[f("div",tot,[G(R(s.$t("workouts.END"))+" ",1),f("img",{class:"weather-img",src:`/img/weather/${g(a).weatherEnd.icon}.svg`,alt:s.$t(`workouts.WEATHER.DARK_SKY.${g(a).weatherEnd.icon}`),title:s.$t(`workouts.WEATHER.DARK_SKY.${g(a).weatherEnd.icon}`)},null,8,not)])])])]),f("tbody",null,[f("tr",null,[f("td",null,[f("img",{class:"weather-img weather-img-small",src:Bit,alt:s.$t("workouts.WEATHER.TEMPERATURE"),title:s.$t("workouts.WEATHER.TEMPERATURE")},null,8,aot)]),f("td",null,R(g(Am)(g(a).weatherStart.temperature,g(n))),1),f("td",null,R(g(Am)(g(a).weatherEnd.temperature,g(n))),1)]),f("tr",null,[f("td",null,[f("img",{class:"weather-img weather-img-small",src:Git,alt:s.$t("workouts.WEATHER.HUMIDITY"),title:s.$t("workouts.WEATHER.HUMIDITY")},null,8,sot)]),f("td",null,R(Number(g(a).weatherStart.humidity*100).toFixed(1))+"% ",1),f("td",null,R(Number(g(a).weatherEnd.humidity*100).toFixed(1))+"% ",1)]),f("tr",null,[f("td",null,[f("img",{class:"weather-img weather-img-small",src:Hit,alt:s.$t("workouts.WEATHER.WIND"),title:s.$t("workouts.WEATHER.WIND")},null,8,rot)]),f("td",null,[x(N0,{weather:g(a).weatherStart,useImperialUnits:g(n)},null,8,["weather","useImperialUnits"])]),f("td",null,[x(N0,{weather:g(a).weatherEnd,useImperialUnits:g(n)},null,8,["weather","useImperialUnits"])])])])])])):M("",!0)}}),oot=ue(iot,[["__scopeId","data-v-be2fffb0"]]),uot={id:"workout-info"},lot={class:"workout-data"},cot={class:"label"},dot={class:"value"},Eot={key:0},pot={class:"value"},fot={class:"value"},mot={key:0,class:"workout-data"},Tot={class:"label"},_ot={key:1,class:"workout-data"},hot={class:"label"},Sot={class:"label"},Aot={key:2,class:"workout-data"},Oot=["alt"],got={class:"label"},Iot={class:"label"},Rot={key:3,class:"workout-data"},Not={class:"label"},vot={class:"label"},bot=ae({__name:"WorkoutData",props:{workoutObject:{},useImperialUnits:{type:Boolean},displayHARecord:{type:Boolean}},setup(e){const t=e,{displayHARecord:n,workoutObject:a,useImperialUnits:s}=he(t),r=z(()=>t.workoutObject.pauses!=="0:00:00"&&t.workoutObject.pauses!==null);return(i,o)=>{const u=ie("Distance");return N(),C("div",uot,[f("div",lot,[o[0]||(o[0]=f("i",{class:"fa fa-clock-o","aria-hidden":"true"},null,-1)),f("span",cot,R(i.$t("workouts.DURATION")),1),o[1]||(o[1]=G(": ")),f("span",dot,R(g(a).moving),1),x(Vr,{workoutObject:g(a),recordType:"LD"},null,8,["workoutObject"]),r.value?(N(),C("div",Eot,[G(" ("+R(i.$t("workouts.PAUSES"))+": ",1),f("span",pot,R(g(a).pauses),1),G(" - "+R(i.$t("workouts.TOTAL_DURATION"))+": ",1),f("span",fot,R(g(a).duration)+")",1)])):M("",!0)]),g(a).distance!==null?(N(),C("div",mot,[o[2]||(o[2]=f("i",{class:"fa fa-road","aria-hidden":"true"},null,-1)),f("span",Tot,R(i.$t("workouts.DISTANCE")),1),o[3]||(o[3]=G(": ")),x(u,{distance:g(a).distance,digits:3,unitFrom:"km",strong:!0,useImperialUnits:g(s)},null,8,["distance","useImperialUnits"]),x(Vr,{workoutObject:g(a),recordType:"FD"},null,8,["workoutObject"])])):M("",!0),g(a).aveSpeed!==null&&g(a).maxSpeed!==null?(N(),C("div",_ot,[o[4]||(o[4]=f("i",{class:"fa fa-tachometer","aria-hidden":"true"},null,-1)),f("span",hot,R(i.$t("workouts.AVERAGE_SPEED")),1),o[5]||(o[5]=G(": ")),x(u,{distance:g(a).aveSpeed,unitFrom:"km",speed:!0,strong:!0,useImperialUnits:g(s)},null,8,["distance","useImperialUnits"]),x(Vr,{workoutObject:g(a),recordType:"AS"},null,8,["workoutObject"]),o[6]||(o[6]=f("br",null,null,-1)),f("span",Sot,R(i.$t("workouts.MAX_SPEED")),1),o[7]||(o[7]=G(": ")),x(u,{distance:g(a).maxSpeed,unitFrom:"km",speed:!0,strong:!0,useImperialUnits:g(s)},null,8,["distance","useImperialUnits"]),x(Vr,{workoutObject:g(a),recordType:"MS"},null,8,["workoutObject"])])):M("",!0),g(a).maxAlt!==null&&g(a).minAlt!==null?(N(),C("div",Aot,[f("img",{class:"mountains",src:SO,alt:i.$t("workouts.ELEVATION")},null,8,Oot),f("span",got,R(i.$t("workouts.MIN_ALTITUDE")),1),o[8]||(o[8]=G(": ")),x(u,{distance:g(a).minAlt,unitFrom:"m",strong:!0,useImperialUnits:g(s)},null,8,["distance","useImperialUnits"]),o[9]||(o[9]=f("br",null,null,-1)),f("span",Iot,R(i.$t("workouts.MAX_ALTITUDE")),1),o[10]||(o[10]=G(": ")),x(u,{distance:g(a).maxAlt,unitFrom:"m",strong:!0,useImperialUnits:g(s)},null,8,["distance","useImperialUnits"])])):M("",!0),g(a).ascent!==null&&g(a).descent!==null?(N(),C("div",Rot,[o[11]||(o[11]=f("i",{class:"fa fa-location-arrow","aria-hidden":"true"},null,-1)),f("span",Not,R(i.$t("workouts.ASCENT")),1),o[12]||(o[12]=G(": ")),x(u,{distance:g(a).ascent,unitFrom:"m",strong:!0,useImperialUnits:g(s)},null,8,["distance","useImperialUnits"]),g(n)?(N(),j(Vr,{key:0,workoutObject:g(a),recordType:"HA"},null,8,["workoutObject"])):M("",!0),o[13]||(o[13]=f("br",null,null,-1)),f("span",vot,R(i.$t("workouts.DESCENT")),1),o[14]||(o[14]=G(": ")),x(u,{distance:g(a).descent,unitFrom:"m",strong:!0,useImperialUnits:g(s)},null,8,["distance","useImperialUnits"])])):M("",!0),x(oot,{workoutObject:g(a),useImperialUnits:g(s)},null,8,["workoutObject","useImperialUnits"])])}}}),Cot=ue(bot,[["__scopeId","data-v-9341b3e8"]]);function ur(e,t){return Array.from(e.getElementsByTagName(t))}function vr(e){return e==null||e.normalize(),(e==null?void 0:e.textContent)||""}function ws(e,t,n){const a=e.getElementsByTagName(t),s=a.length?a[0]:null;return s&&n&&n(s),s}function Dot(e,t,n){const a={};if(!e)return a;const s=e.getElementsByTagName(t),r=s.length?s[0]:null;return r&&n?n(r,a):a}function $O(e,t,n){const a=vr(ws(e,t));return a&&n?n(a)||{}:{}}function v0(e,t,n){const a=Number.parseFloat(vr(ws(e,t)));if(!Number.isNaN(a))return a&&n?n(a)||{}:{}}function Pot(e,t,n){const a=Number.parseFloat(vr(ws(e,t)));if(!Number.isNaN(a))return n&&n(a),a}function iE(e,t){const n={};for(const a of t)$O(e,a,s=>{n[a]=s});return n}function Lot(e){return(e==null?void 0:e.nodeType)===1}function kO(e){let t=[];if(e===null)return t;for(const n of Array.from(e.childNodes)){if(!Lot(n))continue;const a=yot(n.nodeName);if(a==="gpxtpx:TrackPointExtension")t=t.concat(kO(n));else{const s=vr(n);t.push([a,$ot(s)])}}return t}function yot(e){return["heart","gpxtpx:hr","hr"].includes(e)?"heart":e}function $ot(e){const t=Number.parseFloat(e);return Number.isNaN(t)?e:t}function UO(e){const t=[Number.parseFloat(e.getAttribute("lon")||""),Number.parseFloat(e.getAttribute("lat")||"")];if(Number.isNaN(t[0])||Number.isNaN(t[1]))return null;Pot(e,"ele",a=>{t.push(a)});const n=ws(e,"time");return{coordinates:t,time:n?vr(n):null,extendedValues:kO(ws(e,"extensions"))}}function wO(e){return Dot(e,"line",t=>Object.assign({},$O(t,"color",a=>({stroke:`#${a}`})),v0(t,"opacity",a=>({"stroke-opacity":a})),v0(t,"width",a=>({"stroke-width":a*96/25.4}))))}function $p(e,t){var s;const n=iE(t,["name","cmt","desc","type","time","keywords"]);for(const[r,i]of e)for(const o of Array.from(t.getElementsByTagNameNS(i,"*")))n[o.tagName.replace(":","_")]=(s=vr(o))==null?void 0:s.trim();const a=ur(t,"link");return a.length&&(n.links=a.map(r=>Object.assign({href:r.getAttribute("href")},iE(r,["text","type"])))),n}function MO(e,t){const n=ur(e,t),a=[],s=[],r={};for(let i=0;i1,o=Object.assign({_gpxType:"trk"},$p(e,t),wO(ws(t,"extensions")),s.length?{coordinateProperties:{times:i?s:s[0]}}:{});for(const c of r){a.push(c.line),o.coordinateProperties||(o.coordinateProperties={});const l=o.coordinateProperties,E=Object.entries(c.extendedValues);for(let d=0;dnew Array(A.line.length).fill(null))),l[p][d]=T):l[p]=T}}return{type:"Feature",properties:o,geometry:i?{type:"MultiLineString",coordinates:a}:{type:"LineString",coordinates:a[0]}}}function wot(e,t){const n=Object.assign($p(e,t),iE(t,["sym"])),a=UO(t);return a?{type:"Feature",properties:n,geometry:{type:"Point",coordinates:a.coordinates}}:null}function*Mot(e){var r,i;const t="gpxx",n="http://www.garmin.com/xmlschemas/GpxExtensions/v3",a=[[t,n]],s=(r=e.getElementsByTagName("gpx")[0])==null?void 0:r.attributes;if(s)for(const o of Array.from(s))(i=o.name)!=null&&i.startsWith("xmlns:")&&o.value!==n&&a.push([o.name,o.value]);for(const o of ur(e,"trk")){const u=Uot(a,o);u&&(yield u)}for(const o of ur(e,"rte")){const u=kot(a,o);u&&(yield u)}for(const o of ur(e,"wpt")){const u=wot(a,o);u&&(yield u)}}function Wot(e){return{type:"FeatureCollection",features:Array.from(Mot(e))}}const b0=(e,t)=>{for(const n of Object.keys(t))e.on(n,t[n])},WO=e=>{for(const t of Object.keys(e)){const n=e[t];n&&Za(n.cancel)&&n.cancel()}},zot=e=>!e||typeof e.charAt!="function"?e:e.charAt(0).toUpperCase()+e.slice(1),Za=e=>typeof e=="function",za=(e,t,n)=>{for(const a in n){const s="set"+zot(a);e[s]?Me(()=>n[a],(r,i)=>{e[s](r,i)}):t[s]&&Me(()=>n[a],r=>{t[s](r)})}},ha=(e,t,n={})=>{const a={...n};for(const s in e){const r=t[s],i=e[s];r&&(r&&r.custom===!0||i!==void 0&&(a[s]=i))}return a},Bs=e=>{const t={},n={};for(const a in e)if(a.startsWith("on")&&!a.startsWith("onUpdate")&&a!=="onReady"){const s=a.slice(2).toLocaleLowerCase();t[s]=e[a]}else n[a]=e[a];return{listeners:t,attrs:n}},xot=async e=>{const t=await Promise.all([wt(()=>import("./maps-oY9oTtTW.js").then(n=>n.m),__vite__mapDeps([0,1])),wt(()=>import("./maps-oY9oTtTW.js").then(n=>n.b),__vite__mapDeps([0,1])),wt(()=>import("./maps-oY9oTtTW.js").then(n=>n.c),__vite__mapDeps([0,1]))]);delete e.Default.prototype._getIconUrl,e.Default.mergeOptions({iconRetinaUrl:t[0].default,iconUrl:t[1].default,shadowUrl:t[2].default})},No=e=>{const t=pe((...a)=>console.warn(`Method ${e} has been invoked without being replaced`)),n=(...a)=>t.value(...a);return n.wrapped=t,En(e,n),n},vo=(e,t)=>e.wrapped.value=t,Zn=typeof self=="object"&&self.self===self&&self||typeof global=="object"&&global.global===global&&global||globalThis,Bn=e=>{const t=It(e);if(t===void 0)throw new Error(`Attempt to inject ${e.description} before it was provided.`);return t},xa=Symbol("useGlobalLeaflet"),Es=Symbol("addLayer"),kp=Symbol("removeLayer"),zO=Symbol("registerControl"),xO=Symbol("registerLayerControl"),FO=Symbol("canSetParentHtml"),BO=Symbol("setParentHtml"),GO=Symbol("setIcon"),Fot=Symbol("bindPopup"),Bot=Symbol("bindTooltip"),Got=Symbol("unbindPopup"),Hot=Symbol("unbindTooltip"),Qi={options:{type:Object,default:()=>({}),custom:!0}},gl=e=>({options:e.options,methods:{}}),br={...Qi,pane:{type:String},attribution:{type:String},name:{type:String,custom:!0},layerType:{type:String,custom:!0},visible:{type:Boolean,custom:!0,default:!0}},Up=(e,t,n)=>{const a=Bn(Es),s=Bn(kp),{options:r,methods:i}=gl(e),o=ha(e,br,r),u=()=>a({leafletObject:t.value}),c=()=>s({leafletObject:t.value}),l={...i,setAttribution(E){c(),t.value.options.attribution=E,e.visible&&u()},setName(){c(),e.visible&&u()},setLayerType(){c(),e.visible&&u()},setVisible(E){t.value&&(E?u():c())},bindPopup(E){if(!t.value||!Za(t.value.bindPopup)){console.warn("Attempt to bind popup before bindPopup method available on layer.");return}t.value.bindPopup(E)},bindTooltip(E){if(!t.value||!Za(t.value.bindTooltip)){console.warn("Attempt to bind tooltip before bindTooltip method available on layer.");return}t.value.bindTooltip(E)},unbindTooltip(){t.value&&(Za(t.value.closeTooltip)&&t.value.closeTooltip(),Za(t.value.unbindTooltip)&&t.value.unbindTooltip())},unbindPopup(){t.value&&(Za(t.value.closePopup)&&t.value.closePopup(),Za(t.value.unbindPopup)&&t.value.unbindPopup())},updateVisibleProp(E){n.emit("update:visible",E)}};return En(Fot,l.bindPopup),En(Bot,l.bindTooltip),En(Got,l.unbindPopup),En(Hot,l.unbindTooltip),ct(()=>{l.unbindPopup(),l.unbindTooltip(),c()}),{options:o,methods:l}},wp=(e,t)=>{if(e&&t.default)return An("div",{style:{display:"none"}},t.default())},Vot={...br,interactive:{type:Boolean,default:void 0},bubblingMouseEvents:{type:Boolean,default:void 0}},HO={...Vot,stroke:{type:Boolean,default:void 0},color:{type:String},weight:{type:Number},opacity:{type:Number},lineCap:{type:String},lineJoin:{type:String},dashArray:{type:String},dashOffset:{type:String},fill:{type:Boolean,default:void 0},fillColor:{type:String},fillOpacity:{type:Number},fillRule:{type:String},className:{type:String}},qot={...HO,radius:{type:Number},latLng:{type:[Object,Array],required:!0,custom:!0}};({...qot});const Cr={...Qi,position:{type:String}},VO=(e,t)=>{const{options:n,methods:a}=gl(e),s=ha(e,Cr,n),r={...a,setPosition(i){t.value&&t.value.setPosition(i)}};return ct(()=>{t.value&&t.value.remove()}),{options:s,methods:r}},Kot=e=>e.default?An("div",{ref:"root"},e.default()):null,C0=ae({name:"LControl",props:{...Cr,disableClickPropagation:{type:Boolean,custom:!0,default:!0},disableScrollPropagation:{type:Boolean,custom:!0,default:!1}},setup(e,t){const n=pe(),a=pe(),s=It(xa),r=Bn(zO),{options:i,methods:o}=VO(e,n);return mt(async()=>{const{Control:u,DomEvent:c}=s?Zn.L:await wt(()=>import("./maps-oY9oTtTW.js").then(E=>E.d),__vite__mapDeps([0,1])),l=u.extend({onAdd(){return a.value}});n.value=Ma(new l(i)),za(o,n.value,e),r({leafletObject:n.value}),e.disableClickPropagation&&a.value&&c.disableClickPropagation(a.value),e.disableScrollPropagation&&a.value&&c.disableScrollPropagation(a.value),fn(()=>t.emit("ready",n.value))}),{root:a,leafletObject:n}},render(){return Kot(this.$slots)}});({...Cr});const qO={...Cr,collapsed:{type:Boolean,default:void 0},autoZIndex:{type:Boolean,default:void 0},hideSingleBase:{type:Boolean,default:void 0},sortLayers:{type:Boolean,default:void 0},sortFunction:{type:Function}},jot=(e,t)=>{const{options:n}=VO(e,t);return{options:ha(e,qO,n),methods:{addLayer(a){a.layerType==="base"?t.value.addBaseLayer(a.leafletObject,a.name):a.layerType==="overlay"&&t.value.addOverlay(a.leafletObject,a.name)},removeLayer(a){t.value.removeLayer(a.leafletObject)}}}},Yot=ae({name:"LControlLayers",props:qO,setup(e,t){const n=pe(),a=It(xa),s=Bn(xO),{options:r,methods:i}=jot(e,n);return mt(async()=>{const{control:o}=a?Zn.L:await wt(()=>import("./maps-oY9oTtTW.js").then(u=>u.d),__vite__mapDeps([0,1]));n.value=Ma(o.layers(void 0,void 0,r)),za(i,n.value,e),s({...e,...i,leafletObject:n.value}),fn(()=>t.emit("ready",n.value))}),{leafletObject:n}},render(){return null}});({...Cr});({...Cr});const Il={...br},KO=(e,t,n)=>{const{options:a,methods:s}=Up(e,t,n),r=ha(e,Il,a),i={...s,addLayer(o){t.value.addLayer(o.leafletObject)},removeLayer(o){t.value.removeLayer(o.leafletObject)}};return En(Es,i.addLayer),En(kp,i.removeLayer),{options:r,methods:i}};({...Il});const jO={...Il,geojson:{type:[Object,Array],custom:!0},optionsStyle:{type:Function,custom:!0}},Xot=(e,t,n)=>{const{options:a,methods:s}=KO(e,t,n),r=ha(e,jO,a);Object.prototype.hasOwnProperty.call(e,"optionsStyle")&&(r.style=e.optionsStyle);const i={...s,setGeojson(o){t.value.clearLayers(),t.value.addData(o)},setOptionsStyle(o){t.value.setStyle(o)},getGeoJSONData(){return t.value.toGeoJSON()},getBounds(){return t.value.getBounds()}};return{options:r,methods:i}},Qot=ae({props:jO,setup(e,t){const n=pe(),a=pe(!1),s=It(xa),r=Bn(Es),{methods:i,options:o}=Xot(e,n,t);return mt(async()=>{const{geoJSON:u}=s?Zn.L:await wt(()=>import("./maps-oY9oTtTW.js").then(l=>l.d),__vite__mapDeps([0,1]));n.value=Ma(u(e.geojson,o));const{listeners:c}=Bs(t.attrs);n.value.on(c),za(i,n.value,e),r({...e,...i,leafletObject:n.value}),a.value=!0,fn(()=>t.emit("ready",n.value))}),{ready:a,leafletObject:n}},render(){return wp(this.ready,this.$slots)}}),Mp={...br,opacity:{type:Number},zIndex:{type:Number},tileSize:{type:[Number,Array,Object]},noWrap:{type:Boolean,default:void 0},minZoom:{type:Number},maxZoom:{type:Number},className:{type:String}},YO=(e,t,n)=>{const{options:a,methods:s}=Up(e,t,n),r=ha(e,Mp,a),i={...s,setTileComponent(){var o;(o=t.value)==null||o.redraw()}};return ct(()=>{t.value.off()}),{options:r,methods:i}},Zot=(e,t,n,a)=>e.extend({initialize(s){this.tileComponents={},this.on("tileunload",this._unloadTile),n.setOptions(this,s)},createTile(s){const r=this._tileCoordsToKey(s);this.tileComponents[r]=t.create("div");const i=An({setup:a,props:["coords"]},{coords:s});return WR(i,this.tileComponents[r]),this.tileComponents[r]},_unloadTile(s){const r=this._tileCoordsToKey(s.coords);this.tileComponents[r]&&(this.tileComponents[r].innerHTML="",this.tileComponents[r]=void 0)}});({...Mp});const D0={iconUrl:{type:String},iconRetinaUrl:{type:String},iconSize:{type:[Object,Array]},iconAnchor:{type:[Object,Array]},popupAnchor:{type:[Object,Array]},tooltipAnchor:{type:[Object,Array]},shadowUrl:{type:String},shadowRetinaUrl:{type:String},shadowSize:{type:[Object,Array]},shadowAnchor:{type:[Object,Array]},bgPos:{type:[Object,Array]},className:{type:String}},Jot=ae({name:"LIcon",props:{...D0,...Qi},setup(e,t){const n=pe(),a=It(xa),s=Bn(FO),r=Bn(BO),i=Bn(GO);let o,u,c,l,E;const d=(I,m,S)=>{const h=I&&I.innerHTML;if(!m){S&&E&&s()&&r(h);return}const{listeners:_}=Bs(t.attrs);E&&u(E,_);const{options:O}=gl(e),b=ha(e,D0,O);h&&(b.html=h),E=b.html?c(b):l(b),o(E,_),i(E)},p=()=>{fn(()=>d(n.value,!0,!1))},T=()=>{fn(()=>d(n.value,!1,!0))},A={setIconUrl:p,setIconRetinaUrl:p,setIconSize:p,setIconAnchor:p,setPopupAnchor:p,setTooltipAnchor:p,setShadowUrl:p,setShadowRetinaUrl:p,setShadowAnchor:p,setBgPos:p,setClassName:p,setHtml:p};return mt(async()=>{const{DomEvent:I,divIcon:m,icon:S}=a?Zn.L:await wt(()=>import("./maps-oY9oTtTW.js").then(h=>h.d),__vite__mapDeps([0,1]));o=I.on,u=I.off,c=m,l=S,za(A,{},e),new MutationObserver(T).observe(n.value,{attributes:!0,childList:!0,characterData:!0,subtree:!0}),p()}),{root:n}},render(){const e=this.$slots.default?this.$slots.default():void 0;return An("div",{ref:"root"},e)}});({...br});const eut=ae({props:Il,setup(e,t){const n=pe(),a=pe(!1),s=It(xa),r=Bn(Es),{methods:i}=KO(e,n,t);return mt(async()=>{const{layerGroup:o}=s?Zn.L:await wt(()=>import("./maps-oY9oTtTW.js").then(c=>c.d),__vite__mapDeps([0,1]));n.value=Ma(o(void 0,e.options));const{listeners:u}=Bs(t.attrs);n.value.on(u),za(i,n.value,e),r({...e,...i,leafletObject:n.value}),a.value=!0,fn(()=>t.emit("ready",n.value))}),{ready:a,leafletObject:n}},render(){return wp(this.ready,this.$slots)}});function XO(e,t,n){var a,s,r;t===void 0&&(t=50),n===void 0&&(n={});var i=(a=n.isImmediate)!=null&&a,o=(s=n.callback)!=null&&s,u=n.maxWait,c=Date.now(),l=[];function E(){if(u!==void 0){var p=Date.now()-c;if(p+t>=u)return u-p}return t}var d=function(){var p=[].slice.call(arguments),T=this;return new Promise(function(A,I){var m=i&&r===void 0;if(r!==void 0&&clearTimeout(r),r=setTimeout(function(){if(r=void 0,c=Date.now(),!i){var h=e.apply(T,p);o&&o(h),l.forEach(function(_){return(0,_.resolve)(h)}),l=[]}},E()),m){var S=e.apply(T,p);return o&&o(S),A(S)}l.push({resolve:A,reject:I})})};return d.cancel=function(p){r!==void 0&&clearTimeout(r),l.forEach(function(T){return(0,T.reject)(p)}),l=[]},d}const P0={...Qi,center:{type:[Object,Array]},bounds:{type:[Array,Object]},maxBounds:{type:[Array,Object]},zoom:{type:Number},minZoom:{type:Number},maxZoom:{type:Number},paddingBottomRight:{type:[Object,Array]},paddingTopLeft:{type:Object},padding:{type:Object},worldCopyJump:{type:Boolean,default:void 0},crs:{type:[String,Object]},maxBoundsViscosity:{type:Number},inertia:{type:Boolean,default:void 0},inertiaDeceleration:{type:Number},inertiaMaxSpeed:{type:Number},easeLinearity:{type:Number},zoomAnimation:{type:Boolean,default:void 0},zoomAnimationThreshold:{type:Number},fadeAnimation:{type:Boolean,default:void 0},markerZoomAnimation:{type:Boolean,default:void 0},noBlockingAnimations:{type:Boolean,default:void 0},useGlobalLeaflet:{type:Boolean,default:!0,custom:!0}},tut=ae({inheritAttrs:!1,emits:["ready","update:zoom","update:center","update:bounds"],props:P0,setup(e,t){const n=pe(),a=Kt({ready:!1,layersToAdd:[],layersInControl:[]}),{options:s}=gl(e),r=ha(e,P0,s),{listeners:i,attrs:o}=Bs(t.attrs),u=No(Es),c=No(kp),l=No(zO),E=No(xO);En(xa,e.useGlobalLeaflet);const d=z(()=>{const m={};return e.noBlockingAnimations&&(m.animate=!1),m}),p=z(()=>{const m=d.value;return e.padding&&(m.padding=e.padding),e.paddingTopLeft&&(m.paddingTopLeft=e.paddingTopLeft),e.paddingBottomRight&&(m.paddingBottomRight=e.paddingBottomRight),m}),T={moveend:XO(m=>{a.leafletRef&&(t.emit("update:zoom",a.leafletRef.getZoom()),t.emit("update:center",a.leafletRef.getCenter()),t.emit("update:bounds",a.leafletRef.getBounds()))}),overlayadd(m){const S=a.layersInControl.find(h=>h.name===m.name);S&&S.updateVisibleProp(!0)},overlayremove(m){const S=a.layersInControl.find(h=>h.name===m.name);S&&S.updateVisibleProp(!1)}};mt(async()=>{e.useGlobalLeaflet&&(Zn.L=Zn.L||await wt(()=>import("./maps-oY9oTtTW.js").then(L=>L.l),__vite__mapDeps([0,1])));const{map:m,CRS:S,Icon:h,latLngBounds:_,latLng:O,stamp:b}=e.useGlobalLeaflet?Zn.L:await wt(()=>import("./maps-oY9oTtTW.js").then(L=>L.d),__vite__mapDeps([0,1]));try{r.beforeMapMount&&await r.beforeMapMount()}catch(L){console.error(`The following error occurred running the provided beforeMapMount hook ${L.message}`)}await xot(h);const v=typeof r.crs=="string"?S[r.crs]:r.crs;r.crs=v||S.EPSG3857;const D={addLayer(L){L.layerType!==void 0&&(a.layerControl===void 0?a.layersToAdd.push(L):a.layersInControl.find(U=>b(U.leafletObject)===b(L.leafletObject))||(a.layerControl.addLayer(L),a.layersInControl.push(L))),L.visible!==!1&&a.leafletRef.addLayer(L.leafletObject)},removeLayer(L){L.layerType!==void 0&&(a.layerControl===void 0?a.layersToAdd=a.layersToAdd.filter(U=>U.name!==L.name):(a.layerControl.removeLayer(L.leafletObject),a.layersInControl=a.layersInControl.filter(U=>b(U.leafletObject)!==b(L.leafletObject)))),a.leafletRef.removeLayer(L.leafletObject)},registerLayerControl(L){a.layerControl=L,a.layersToAdd.forEach(U=>{a.layerControl.addLayer(U)}),a.layersToAdd=[],l(L)},registerControl(L){a.leafletRef.addControl(L.leafletObject)},setZoom(L){const U=a.leafletRef.getZoom();L!==U&&a.leafletRef.setZoom(L,d.value)},setCrs(L){const U=a.leafletRef.getBounds();a.leafletRef.options.crs=L,a.leafletRef.fitBounds(U,{animate:!1,padding:[0,0]})},fitBounds(L){a.leafletRef.fitBounds(L,p.value)},setBounds(L){if(!L)return;const U=_(L);U.isValid()&&!(a.lastSetBounds||a.leafletRef.getBounds()).equals(U,0)&&(a.lastSetBounds=U,a.leafletRef.fitBounds(U))},setCenter(L){if(L==null)return;const U=O(L),$=a.lastSetCenter||a.leafletRef.getCenter();($.lat!==U.lat||$.lng!==U.lng)&&(a.lastSetCenter=U,a.leafletRef.panTo(U,d.value))}};vo(u,D.addLayer),vo(c,D.removeLayer),vo(l,D.registerControl),vo(E,D.registerLayerControl),a.leafletRef=Ma(m(n.value,r)),za(D,a.leafletRef,e),b0(a.leafletRef,T),b0(a.leafletRef,i),a.ready=!0,fn(()=>t.emit("ready",a.leafletRef))}),wi(()=>{WO(T),a.leafletRef&&(a.leafletRef.off(),a.leafletRef.remove())});const A=z(()=>a.leafletRef),I=z(()=>a.ready);return{root:n,ready:I,leafletObject:A,attrs:o}},render({attrs:e}){return e.style||(e.style={}),e.style.width||(e.style.width="100%"),e.style.height||(e.style.height="100%"),An("div",{...e,ref:"root"},this.ready&&this.$slots.default?this.$slots.default():{})}}),nut=["Symbol(Comment)","Symbol(Text)"],aut=["LTooltip","LPopup"],QO={...br,draggable:{type:Boolean,default:void 0},icon:{type:[Object]},zIndexOffset:{type:Number},latLng:{type:[Object,Array],custom:!0,required:!0}},sut=(e,t,n)=>{const{options:a,methods:s}=Up(e,t,n),r=ha(e,QO,a),i={...s,setDraggable(o){t.value.dragging&&(o?t.value.dragging.enable():t.value.dragging.disable())},latLngSync(o){n.emit("update:latLng",o.latlng),n.emit("update:lat-lng",o.latlng)},setLatLng(o){if(o!=null&&t.value){const u=t.value.getLatLng();(!u||!u.equals(o))&&t.value.setLatLng(o)}}};return{options:r,methods:i}},rut=(e,t)=>{const n=t.slots.default&&t.slots.default();return n&&n.length&&n.some(iut)};function iut(e){return!(nut.includes(e.type.toString())||aut.includes(e.type.name))}const ZO=ae({name:"LMarker",props:QO,setup(e,t){const n=pe(),a=pe(!1),s=It(xa),r=Bn(Es);En(FO,()=>{var c;return!!((c=n.value)!=null&&c.getElement())}),En(BO,c=>{var l,E;const d=Za((l=n.value)==null?void 0:l.getElement)&&((E=n.value)==null?void 0:E.getElement());d&&(d.innerHTML=c)}),En(GO,c=>{var l;return((l=n.value)==null?void 0:l.setIcon)&&n.value.setIcon(c)});const{options:i,methods:o}=sut(e,n,t),u={moveHandler:XO(o.latLngSync)};return mt(async()=>{const{marker:c,divIcon:l}=s?Zn.L:await wt(()=>import("./maps-oY9oTtTW.js").then(d=>d.d),__vite__mapDeps([0,1]));rut(i,t)&&(i.icon=l({className:""})),n.value=Ma(c(e.latLng,i));const{listeners:E}=Bs(t.attrs);n.value.on(E),n.value.on("move",u.moveHandler),za(o,n.value,e),r({...e,...o,leafletObject:n.value}),a.value=!0,fn(()=>t.emit("ready",n.value))}),wi(()=>WO(u)),{ready:a,leafletObject:n}},render(){return wp(this.ready,this.$slots)}}),out={...HO,smoothFactor:{type:Number},noClip:{type:Boolean,default:void 0},latLngs:{type:Array,required:!0,custom:!0}},L0={...out},JO={...Qi,content:{type:String,default:null}};({...JO});({...L0,latLngs:{...L0.latLngs}});const Wp={...Mp,tms:{type:Boolean,default:void 0},subdomains:{type:[String,Array],validator:e=>typeof e=="string"?!0:Array.isArray(e)?e.every(t=>typeof t=="string"):!1},detectRetina:{type:Boolean,default:void 0},url:{type:String,required:!0,custom:!0}},uut=(e,t,n)=>{const{options:a,methods:s}=YO(e,t,n),r=ha(e,Wp,a),i={...s};return{options:r,methods:i}},lut=ae({props:Wp,setup(e,t){const n=pe(),a=It(xa),s=Bn(Es),{options:r,methods:i}=uut(e,n,t);return mt(async()=>{const{tileLayer:o}=a?Zn.L:await wt(()=>import("./maps-oY9oTtTW.js").then(c=>c.d),__vite__mapDeps([0,1]));n.value=Ma(o(e.url,r));const{listeners:u}=Bs(t.attrs);n.value.on(u),za(i,n.value,e),s({...e,...i,leafletObject:n.value}),fn(()=>t.emit("ready",n.value))}),{leafletObject:n}},render(){return null}});({...JO});({...Wp});const y0=ae({__name:"CustomMarker",props:{markerCoordinates:{},isStart:{type:Boolean}},setup(e){const t=e,{isStart:n,markerCoordinates:a}=he(t);return(s,r)=>g(a).latitude?(N(),j(g(ZO),{key:0,"lat-lng":[g(a).latitude,g(a).longitude]},{default:le(()=>[x(g(Jot),{"icon-url":`/img/workouts/${g(n)?"start":"finish"}.svg`,iconSize:[15,15]},null,8,["icon-url"])]),_:1},8,["lat-lng"])):M("",!0)}}),cut={id:"workout-map"},dut={key:0,class:"leaflet-container"},Eut={key:1},put={key:1,class:"no-map"},fut=ae({__name:"index",props:{workoutData:{},markerCoordinates:{default:()=>({})}},setup(e){const t=e,n=Ue(),{workoutData:a,markerCoordinates:s}=he(t),r=pe(null),i=z(()=>I()),o=z(()=>n.getters[V.GETTERS.APP_CONFIG]),u=z(()=>T(i)),c=z(()=>t.workoutData&&t.workoutData.gpx?p(t.workoutData.gpx):{}),l=z(()=>t.workoutData&&t.workoutData.chartData.length>0?{latitude:t.workoutData.chartData[0].latitude,longitude:t.workoutData.chartData[0].longitude}:{}),E=z(()=>t.workoutData&&t.workoutData.chartData.length>0?{latitude:t.workoutData.chartData[t.workoutData.chartData.length-1].latitude,longitude:t.workoutData.chartData[t.workoutData.chartData.length-1].longitude}:{}),d=pe(!1);function p(h){if(!h||h!=="")try{return{jsonData:Wot(new DOMParser().parseFromString(h,"text/xml"))}}catch{return console.error("Invalid gpx content"),{}}return{}}function T(h){return[(h.value[0][0]+h.value[1][0])/2,(h.value[0][1]+h.value[1][1])/2]}function A(h){var _,O;(_=r.value)!=null&&_.leafletObject&&((O=r.value)==null||O.leafletObject.fitBounds(h))}function I(){return t.workoutData?[[t.workoutData.workout.bounds[0],t.workoutData.workout.bounds[1]],[t.workoutData.workout.bounds[2],t.workoutData.workout.bounds[3]]]:[]}function m(){var h;(h=r.value)==null||h.leafletObject.fitBounds(I())}function S(){d.value=!d.value,d.value||setTimeout(()=>{m()},100)}return(h,_)=>{const O=ie("VFullscreen");return N(),C("div",cut,[g(a).loading?(N(),C("div",dut)):(N(),C("div",Eut,[g(a).workout.with_gpx?(N(),j(O,{key:0,modelValue:d.value,"onUpdate:modelValue":_[1]||(_[1]=b=>d.value=b)},{default:le(()=>[f("div",{class:Te(["leaflet-container",{"fullscreen-map":d.value}])},[c.value.jsonData&&u.value&&i.value.length===2?(N(),j(g(tut),{key:0,zoom:13,maxZoom:19,center:u.value,bounds:i.value,zoomAnimation:!1,ref_key:"workoutMap",ref:r,onReady:_[0]||(_[0]=b=>A(i.value)),"use-global-leaflet":!1,class:"map","aria-label":h.$t("workouts.WORKOUT_MAP")},{default:le(()=>[x(g(Yot)),x(g(C0),{position:"topleft",class:"map-control",tabindex:"0",role:"button","aria-label":h.$t("workouts.RESET_ZOOM"),onClick:m},{default:le(()=>_[2]||(_[2]=[f("i",{class:"fa fa-refresh","aria-hidden":"true"},null,-1)])),_:1},8,["aria-label"]),x(g(C0),{position:"topleft",class:"map-control",tabindex:"0",role:"button","aria-label":h.$t(`workouts.${d.value?"EXIT":"VIEW"}_FULLSCREEN`),onClick:S},{default:le(()=>[f("i",{class:Te(`fa fa-${d.value?"compress":"arrows-alt"}`),"aria-hidden":"true"},null,2)]),_:1},8,["aria-label"]),x(g(lut),{url:`${g(Fi)()}workouts/map_tile/{s}/{z}/{x}/{y}.png`,attribution:o.value.map_attribution,bounds:i.value},null,8,["url","attribution","bounds"]),x(g(Qot),{geojson:c.value.jsonData},null,8,["geojson"]),g(s).latitude?(N(),j(g(ZO),{key:0,"lat-lng":[g(s).latitude,g(s).longitude]},null,8,["lat-lng"])):M("",!0),x(g(eut),{name:h.$t("workouts.START_AND_FINISH"),"layer-type":"overlay"},{default:le(()=>[l.value.latitude?(N(),j(y0,{key:0,markerCoordinates:l.value,isStart:!0},null,8,["markerCoordinates"])):M("",!0),E.value.latitude?(N(),j(y0,{key:1,markerCoordinates:E.value,isStart:!1},null,8,["markerCoordinates"])):M("",!0)]),_:1},8,["name"])]),_:1},8,["center","bounds","aria-label"])):M("",!0)],2)]),_:1},8,["modelValue"])):(N(),C("div",put,R(h.$t("workouts.NO_MAP")),1))]))])}}}),mut=ue(fut,[["__scopeId","data-v-02ef686a"]]),Tut={class:"workout-detail"},_ut={class:"workout-map-data"},hut={key:0,class:"workout-equipments"},Sut=ae({__name:"index",props:{authUser:{},displaySegment:{type:Boolean},sports:{},workoutData:{},markerCoordinates:{default:()=>({})}},setup(e){const t=e,n=bt(),a=Ue(),{authUser:s,markerCoordinates:r,workoutData:i}=he(t),o=z(()=>t.workoutData.workout),u=pe(n.params.workoutId?+n.params.segmentId:null),c=z(()=>o.value.segments.length>0&&u.value?o.value.segments[+u.value-1]:null),l=pe(!1),E=z(()=>t.sports?t.sports.find(h=>h.id===t.workoutData.workout.sport_id):{}),d=z(()=>T(o.value,c.value));function p(h,_,O){const b=_&&O&&O!==1?`/workouts/${h.id}/segment/${O-1}`:!_&&h.previous_workout?`/workouts/${h.previous_workout}`:null,v=_&&O&&On.params.segmentId,async h=>{u.value=h?+h:null,S()}),Me(()=>n.params.workoutId,async h=>{h&&(l.value=!1,S())}),(h,_)=>{const O=ie("Modal"),b=ie("Card");return N(),C("div",Tut,[l.value?(N(),j(O,{key:0,title:h.$t("common.CONFIRMATION"),message:h.$t("workouts.WORKOUT_DELETION_CONFIRMATION"),onConfirmAction:_[0]||(_[0]=v=>m(d.value.workoutId)),onCancelAction:I,onKeydown:Ye(I,["esc"])},null,8,["title","message"])):M("",!0),x(b,null,{title:le(()=>[E.value?(N(),j(zit,{key:0,sport:E.value,workoutObject:d.value,onDisplayModal:_[1]||(_[1]=v=>A(!0))},null,8,["sport","workoutObject"])):M("",!0)]),content:le(()=>[f("div",_ut,[x(mut,{workoutData:g(i),markerCoordinates:g(r)},null,8,["workoutData","markerCoordinates"]),x(Cot,{workoutObject:d.value,useImperialUnits:g(s).imperial_units,displayHARecord:g(s).display_ascent},null,8,["workoutObject","useImperialUnits","displayHARecord"])]),d.value.equipments?(N(),C("div",hut,[(N(!0),C(_e,null,$e(d.value.equipments,v=>(N(),j(_O,{equipment:v,"workout-id":d.value.workoutId,key:v.label},null,8,["equipment","workout-id"]))),128))])):M("",!0)]),_:1})])}}}),Aut=ue(Sut,[["__scopeId","data-v-203972ae"]]),Out=e=>{const t=document.getElementById(e);if(t){let n=t.querySelector("ul");return n||(n=document.createElement("ul"),t.appendChild(n)),n}throw new Error("No legend container")},gut={id:"htmlLegend",afterUpdate(e,t,n){var r,i,o,u,c,l;const a=Out(n.containerID);for(;a.firstChild;)a.firstChild.remove();((o=(i=(r=e.options.plugins)==null?void 0:r.legend)==null?void 0:i.labels)!=null&&o.generateLabels?(l=(c=(u=e.options.plugins)==null?void 0:u.legend)==null?void 0:c.labels)==null?void 0:l.generateLabels(e):[]).forEach(E=>{var m,S,h;if(!((h=(S=(m=e.config.options)==null?void 0:m.scales)==null?void 0:S.yElevation)!=null&&h.display)&&E.datasetIndex===1)return;const d=document.createElement("li");d.onclick=_=>{_.preventDefault(),E.datasetIndex!==void 0&&(e.setDatasetVisibility(E.datasetIndex,!e.isDatasetVisible(E.datasetIndex)),e.update())};const p=document.createElement("input");p&&(p.type="checkbox",p.id=E.text,p.checked=!E.hidden);const T=document.createElement("label");T.htmlFor=p.id;const A=document.createTextNode(E.text);T.appendChild(A);const I=document.createElement("span");I&&(I.style.background=String(E.fillStyle),I.style.borderColor=String(E.strokeStyle)),T.appendChild(I),d.appendChild(p),d.appendChild(T),a.appendChild(d)})}},Iut={id:"workout-chart"},Rut={class:"chart-radio"},Nut=["checked"],vut=["checked"],but={class:"line-chart"},Cut={class:"chart-info"},Dut={class:"no-data-cleaning"},Put={key:0,class:"elevation-start"},Lut=["checked"],yut=ae({__name:"index",props:{authUser:{},workoutData:{}},emits:["getCoordinates"],setup(e,{emit:t}){const n=e,a=t,s=Gi(),{t:r}=Ct(),{authUser:i,workoutData:o}=he(n),u=z(()=>s.getters[V.GETTERS.DARK_MODE]),c=z(()=>rl(u.value)),l=pe(!0),E=pe(i.value.start_elevation_at_zero),d=z(()=>ett(o.value.chartData,r,i.value.imperial_units,c.value)),p=z(()=>d.value&&d.value.datasets.elevation.data.length>0),T=U("km"),A=U("m"),I=z(()=>({labels:l.value?d.value.distance_labels:d.value.duration_labels,datasets:JSON.parse(JSON.stringify([d.value.datasets.speed,d.value.datasets.elevation]))})),m=z(()=>d.value.coordinates),S=z(()=>({color:c.value?es.darkMode.line:es.ligthMode.line})),h=z(()=>({color:c.value?es.darkMode.text:es.ligthMode.text})),_=z(()=>({responsive:!0,maintainAspectRatio:!1,animation:!1,layout:{padding:{top:22}},scales:{x:{grid:{drawOnChartArea:!1,...S.value},border:{...S.value},ticks:{count:10,callback:function($){return l.value?Number($).toFixed(2):v($)},...h.value},type:"linear",bounds:"data",title:{display:!0,text:l.value?r("workouts.DISTANCE")+` (${T})`:r("workouts.DURATION"),...h.value}},ySpeed:{grid:{drawOnChartArea:!1,...S.value},border:{...S.value},position:"left",title:{display:!0,text:r("workouts.SPEED")+` (${T}/h)`,...h.value},ticks:{...h.value}},yElevation:{beginAtZero:E.value,display:p.value,grid:{drawOnChartArea:!1,...S.value},border:{...S.value},position:"right",title:{display:!0,text:r("workouts.ELEVATION")+` (${A})`,...h.value},ticks:{...h.value}}},elements:{point:{pointStyle:"circle",pointRadius:0}},plugins:{datalabels:{display:!1},tooltip:{interaction:{intersect:!1,mode:"index"},callbacks:{label:function($){const W=` ${$.dataset.label}: ${$.formattedValue}`;return $.dataset.yAxisID==="yElevation"?W+` ${A}`:W+` ${T}/h`},title:function($){return $.length>0&&D(m.value[$[0].dataIndex]),$.length===0?"":l.value?`${r("workouts.DISTANCE")}: ${$[0].label} ${T}`:`${r("workouts.DURATION")}: ${v($[0].label.replace(",",""))}`}}},legend:{display:!1},htmlLegend:{containerID:"chart-legend",displayElevation:p.value}}})),O=[gut];function b(){l.value=!l.value}function v($){return new Date(+$*1e3).toISOString().substr(11,8)}function D($){a("getCoordinates",$)}function L(){D({latitude:null,longitude:null})}function U($){return n.authUser.imperial_units?Sn[$].defaultTarget:$}return($,W)=>{const Y=ie("Card");return N(),C("div",Iut,[x(Y,null,{title:le(()=>[G(R($.$t("workouts.ANALYSIS")),1)]),content:le(()=>[f("div",Rut,[f("label",null,[f("input",{type:"radio",name:"distance",checked:l.value,onClick:b},null,8,Nut),G(" "+R($.$t("workouts.DISTANCE")),1)]),f("label",null,[f("input",{type:"radio",name:"duration",checked:!l.value,onClick:b},null,8,vut),G(" "+R($.$t("workouts.DURATION")),1)])]),W[1]||(W[1]=f("div",{id:"chart-legend"},null,-1)),f("div",but,[x(g(jtt),{data:I.value,options:_.value,plugins:O,onMouseleave:L,"aria-label":$.$t("workouts.WORKOUT_CHART")},null,8,["data","options","aria-label"])]),f("div",Cut,[f("div",Dut,R($.$t("workouts.NO_DATA_CLEANING")),1),p.value?(N(),C("div",Put,[f("label",null,[f("input",{type:"checkbox",checked:E.value,onClick:W[0]||(W[0]=te=>E.value=!E.value)},null,8,Lut),G(" "+R($.$t("workouts.START_ELEVATION_AT_ZERO")),1)])])):M("",!0)])]),_:1})])}}}),$ut=ue(yut,[["__scopeId","data-v-727b605c"]]),kut={id:"workout-content"},Uut=["aria-label"],wut={key:0,class:"fa fa-edit","aria-hidden":"true"},Mut=["for"],Wut={class:"form-buttons"},zut=["disabled"],xut={key:0,class:"edition-loading"},Fut=["innerHTML"],Zc=1e3,But=ae({__name:"WorkoutContent",props:{content:{default:()=>""},contentType:{},workoutId:{}},setup(e){const t=e,n=Ue(),{content:a,contentType:s,workoutId:r}=he(t),i=z(()=>a.value!==null&&a.value.length>Zc),o=z(()=>n.getters[Oe.GETTERS.WORKOUT_CONTENT_EDITION]),u=z(()=>o.value.loading&&o.value.contentType===s.value),c=pe(!1),l=z(()=>c.value?a.value:T(a.value)),E=pe(!1),d=pe(""),p=z(()=>o.value.contentType===s.value?n.getters[V.GETTERS.ERROR_MESSAGES]:null);function T(h){return h===null||h.length<=Zc?h:h.slice(0,Zc-10)+"…"}function A(){n.commit(V.MUTATIONS.EMPTY_ERROR_MESSAGES),E.value=!0,d.value=a.value?a.value:""}function I(h){d.value=h}function m(){E.value=!1,d.value=a.value?a.value:""}function S(){n.dispatch(Oe.ACTIONS.EDIT_WORKOUT_CONTENT,{workoutId:r.value,content:d.value,contentType:s.value})}return Me(()=>u.value,h=>{h||(E.value=!1)}),(h,_)=>{const O=ie("CustomTextArea"),b=ie("ErrorMessage"),v=ie("Card");return N(),C("div",kut,[x(v,null,{title:le(()=>[G(R(h.$t(`workouts.${g(s)}`))+" ",1),f("button",{class:"transparent icon-button","aria-label":h.$t("buttons.EDIT"),onClick:A},[E.value?M("",!0):(N(),C("i",wut))],8,Uut)]),content:le(()=>[E.value?(N(),C("form",{key:0,onSubmit:De(S,["prevent"])},[f("label",{for:g(s).toLowerCase(),class:"visually-hidden"},R(h.$t(`workouts.${g(s)}`)),9,Mut),x(O,{name:g(s).toLowerCase(),input:g(a),disabled:u.value,charLimit:g(s)==="NOTES"?500:1e4,rows:g(s)==="NOTES"?2:5,onUpdateValue:I},null,8,["name","input","disabled","charLimit","rows"]),f("div",Wut,[f("button",{class:"confirm",type:"submit",disabled:u.value},R(h.$t("buttons.SUBMIT")),9,zut),f("button",{class:"cancel",onClick:De(m,["prevent"])},R(h.$t("buttons.CANCEL")),1),u.value?(N(),C("div",xut,_[1]||(_[1]=[f("div",null,[f("i",{class:"fa fa-spinner fa-pulse","aria-hidden":"true"})],-1)]))):M("",!0)])],32)):(N(),C(_e,{key:1},[f("span",{class:Te(["workout-content",{notes:g(s)==="NOTES"||!g(a)}]),innerHTML:l.value&&l.value!==""?g(NBe)(l.value):h.$t(`workouts.NO_${g(s)}`)},null,10,Fut),i.value?(N(),C("button",{key:0,class:"read-more transparent",onClick:_[0]||(_[0]=D=>c.value=!c.value)},[f("i",{class:Te(`fa fa-caret-${c.value?"up":"down"}`),"aria-hidden":"true"},null,2),G(" "+R(h.$t(`buttons.${c.value?"HIDE":"READ_MORE"}`)),1)])):M("",!0)],64)),p.value?(N(),j(b,{key:2,message:p.value},null,8,["message"])):M("",!0)]),_:1})])}}}),$0=ue(But,[["__scopeId","data-v-53e6d9a6"]]),Gut={id:"workout-segments"},Hut=ae({__name:"WorkoutSegments",props:{segments:{},useImperialUnits:{type:Boolean}},setup(e){const t=e,{segments:n,useImperialUnits:a}=he(t);return(s,r)=>{const i=ie("router-link"),o=ie("Distance"),u=ie("Card");return N(),C("div",Gut,[x(u,null,{title:le(()=>[G(R(s.$t("workouts.SEGMENT",2)),1)]),content:le(()=>[f("ul",null,[(N(!0),C(_e,null,$e(g(n),(c,l)=>(N(),C("li",{key:c.segment_id},[x(i,{to:{name:"WorkoutSegment",params:{workoutId:c.workout_id,segmentId:l+1}}},{default:le(()=>[G(R(s.$t("workouts.SEGMENT",1))+" "+R(l+1),1)]),_:2},1032,["to"]),G(" ("+R(s.$t("workouts.DISTANCE"))+": ",1),x(o,{distance:c.distance,unitFrom:"km",useImperialUnits:g(a)},null,8,["distance","useImperialUnits"]),G(", "+R(s.$t("workouts.DURATION"))+": "+R(c.duration)+") ",1)]))),128))])]),_:1})])}}}),Vut=ue(Hut,[["__scopeId","data-v-15725c61"]]),qut={id:"workout",class:"view"},Kut={class:"container"},jut={key:0,class:"workout-container"},Yut={key:0},Xut={key:1},Qut=ae({__name:"Workout",props:{displaySegment:{type:Boolean}},setup(e){const t=e,n=bt(),a=Ue(),{displaySegment:s}=he(t),r=z(()=>a.getters[Oe.GETTERS.WORKOUT_DATA]),i=z(()=>a.getters[ee.GETTERS.AUTH_USER_PROFILE]),o=z(()=>a.getters[vt.GETTERS.SPORTS]),u=pe({latitude:null,longitude:null});ft(()=>{const l={workoutId:n.params.workoutId};t.displaySegment&&(l.segmentId=n.params.segmentId),a.dispatch(Oe.ACTIONS.GET_WORKOUT_DATA,l)}),ct(()=>{a.commit(Oe.MUTATIONS.EMPTY_WORKOUT)});function c(l){u.value={latitude:l.latitude,longitude:l.longitude}}return Me(()=>n.params.workoutId,async l=>{l&&a.dispatch(Oe.ACTIONS.GET_WORKOUT_DATA,{workoutId:l})}),Me(()=>n.params.segmentId,async l=>{if(n.params.workoutId){const E={workoutId:n.params.workoutId};l&&(E.segmentId=l),a.dispatch(Oe.ACTIONS.GET_WORKOUT_DATA,E)}}),(l,E)=>(N(),C("div",qut,[f("div",Kut,[o.value.length>0?(N(),C("div",jut,[r.value.workout.id?(N(),C("div",Yut,[x(Aut,{workoutData:r.value,sports:o.value,authUser:i.value,markerCoordinates:u.value,displaySegment:g(s)},null,8,["workoutData","sports","authUser","markerCoordinates","displaySegment"]),r.value.workout.with_gpx&&r.value.chartData.length>0?(N(),j($ut,{key:0,workoutData:r.value,authUser:i.value,displaySegment:g(s),onGetCoordinates:c},null,8,["workoutData","authUser","displaySegment"])):M("",!0),g(s)?M("",!0):(N(),j($0,{key:1,"workout-id":r.value.workout.id,"content-type":"DESCRIPTION",content:r.value.workout.description,loading:r.value.loading},null,8,["workout-id","content","loading"])),!g(s)&&r.value.workout.segments.length>1?(N(),j(Vut,{key:2,segments:r.value.workout.segments,useImperialUnits:i.value.imperial_units},null,8,["segments","useImperialUnits"])):M("",!0),g(s)?M("",!0):(N(),j($0,{key:3,"workout-id":r.value.workout.id,"content-type":"NOTES",content:r.value.workout.notes,loading:r.value.loading},null,8,["workout-id","content","loading"])),E[0]||(E[0]=f("div",{id:"bottom"},null,-1))])):(N(),C("div",Xut,[r.value.loading?M("",!0):(N(),j(Pp,{key:0,target:"WORKOUT"}))]))])):M("",!0)])]))}}),k0=ue(Qut,[["__scopeId","data-v-8c74ce88"]]),Zut={class:"workouts-filters"},Jut={class:"box"},elt={class:"form-all-items"},tlt={class:"form-items-group"},nlt={class:"form-item"},alt={for:"from"},slt=["value"],rlt={class:"form-item"},ilt={for:"to"},olt=["value"],ult={class:"form-item"},llt={for:"sport_id"},clt=["value"],dlt=["value"],Elt={class:"form-item form-item-equipment"},plt=["value"],flt={key:0,value:"",disabled:"",selected:""},mlt={value:"none"},Tlt=["label"],_lt=["value"],hlt={class:"form-items-group"},Slt={class:"form-item form-item-text"},Alt={for:"title"},Olt={class:"form-inputs-group"},glt=["value"],Ilt={class:"form-item form-item-text"},Rlt={for:"notes"},Nlt={class:"form-inputs-group"},vlt=["value"],blt={class:"form-item form-item-text"},Clt={for:"notes"},Dlt={class:"form-inputs-group"},Plt=["value"],Llt={class:"form-items-group"},ylt={class:"form-item"},$lt={class:"form-inputs-group"},klt=["value"],Ult=["value"],wlt={class:"form-item"},Mlt={class:"form-inputs-group"},Wlt=["value"],zlt=["value"],xlt={class:"form-items-group"},Flt={class:"form-item"},Blt={class:"form-inputs-group"},Glt=["value"],Hlt=["value"],Vlt={class:"form-item"},qlt={class:"form-inputs-group"},Klt=["value"],jlt=["value"],Ylt={class:"form-button"},Xlt=ae({__name:"WorkoutsFilters",props:{authUser:{},sports:{}},emits:["filter"],setup(e,{emit:t}){const n=e,a=t,{t:s}=Ct(),r=Gi(),i=bt(),o=_a(),{authUser:u}=he(n),c=u.value.imperial_units?Sn.km.defaultTarget:"km",l=z(()=>Hn(n.sports,s)),E=z(()=>I(r.getters[We.GETTERS.EQUIPMENTS]));let d=Object.assign({},i.query);mt(()=>{const m=document.getElementById("from");m&&m.focus()});function p(m){const S=m.target.name,h=m.target.value;h===""?delete d[S]:d[S]=h}function T(){a("filter"),"page"in d&&(d.page="1"),o.push({path:"/workouts",query:d})}function A(){a("filter"),o.push({path:"/workouts",query:{}})}function I(m){const S={};return m.filter(h=>h.workouts_count>0).map(h=>{const _=s(`equipment_types.${h.equipment_type.label}.LABEL`);_ in S?S[_].push(h):S[_]=[h]}),S}return Me(()=>i.query,m=>{d=Object.assign({},m)}),(m,S)=>(N(),C("div",Zut,[f("div",Jut,[f("form",{onSubmit:S[0]||(S[0]=De(()=>{},["prevent"])),class:"form"},[f("div",elt,[f("div",tlt,[f("div",nlt,[f("label",alt,R(m.$t("workouts.FROM"))+": ",1),f("input",{id:"from",name:"from",type:"date",value:m.$route.query.from,onChange:p},null,40,slt)]),f("div",rlt,[f("label",ilt,R(m.$t("workouts.TO"))+": ",1),f("input",{id:"to",name:"to",type:"date",value:m.$route.query.to,onChange:p},null,40,olt)]),f("div",ult,[f("label",llt,R(m.$t("workouts.SPORT",1))+":",1),f("select",{id:"sport_id",name:"sport_id",value:m.$route.query.sport_id,onChange:p,onKeyup:Ye(T,["enter"])},[S[1]||(S[1]=f("option",{value:""},null,-1)),(N(!0),C(_e,null,$e(l.value.filter(h=>g(u).sports_list.includes(h.id)),h=>(N(),C("option",{value:h.id,key:h.id},R(h.translatedLabel),9,dlt))),128))],40,clt)]),f("div",Elt,[f("label",null,R(m.$t("equipments.EQUIPMENT",1))+":",1),f("select",{name:"equipment_id",value:m.$route.query.equipment_id,onChange:p,onKeyup:Ye(T,["enter"])},[S[3]||(S[3]=f("option",{value:""},null,-1)),Object.keys(E.value).length==0?(N(),C("option",flt,R(m.$t("equipments.NO_EQUIPMENTS")),1)):M("",!0),Object.keys(E.value).length>0?(N(),C(_e,{key:1},[f("option",mlt,R(m.$t("equipments.WITHOUT_EQUIPMENTS")),1),S[2]||(S[2]=f("option",{disabled:""},"---",-1))],64)):M("",!0),(N(!0),C(_e,null,$e(Object.keys(E.value).sort(),h=>(N(),C("optgroup",{label:h,key:h},[(N(!0),C(_e,null,$e(E.value[h].sort(g(gp)),_=>(N(),C("option",{value:_.id,key:_.id},R(_.label),9,_lt))),128))],8,Tlt))),128))],40,plt)])]),f("div",hlt,[f("div",Slt,[f("label",Alt,R(m.$t("workouts.TITLE",1))+":",1),f("div",Olt,[f("input",{id:"title",class:"text",name:"title",value:m.$route.query.title,onChange:p,placeholder:"",type:"text",onKeyup:Ye(T,["enter"])},null,40,glt)])]),f("div",Ilt,[f("label",Rlt,R(m.$t("workouts.DESCRIPTION"))+":",1),f("div",Nlt,[f("input",{id:"description",class:"text",name:"description",value:m.$route.query.description,onChange:p,placeholder:"",type:"text",onKeyup:Ye(T,["enter"])},null,40,vlt)])]),f("div",blt,[f("label",Clt,R(m.$t("workouts.NOTES"))+":",1),f("div",Dlt,[f("input",{id:"notes",class:"text",name:"notes",value:m.$route.query.notes,onChange:p,placeholder:"",type:"text",onKeyup:Ye(T,["enter"])},null,40,Plt)])])]),f("div",Llt,[f("div",ylt,[f("label",null,R(m.$t("workouts.DISTANCE"))+" ("+R(g(c))+"): ",1),f("div",$lt,[f("input",{name:"distance_from",type:"number",min:"0",step:"0.1",value:m.$route.query.distance_from,onChange:p,onKeyup:Ye(T,["enter"])},null,40,klt),f("span",null,R(m.$t("workouts.TO")),1),f("input",{name:"distance_to",type:"number",min:"0",step:"0.1",value:m.$route.query.distance_to,onChange:p,onKeyup:Ye(T,["enter"])},null,40,Ult)])]),f("div",wlt,[f("label",null,R(m.$t("workouts.DURATION"))+": ",1),f("div",Mlt,[f("input",{name:"duration_from",value:m.$route.query.duration_from,onChange:p,pattern:"^([0-9]*[0-9]):([0-5][0-9])$",placeholder:"hh:mm",type:"text",onKeyup:Ye(T,["enter"])},null,40,Wlt),f("span",null,R(m.$t("workouts.TO")),1),f("input",{name:"duration_to",value:m.$route.query.duration_to,onChange:p,pattern:"^([0-9]*[0-9]):([0-5][0-9])$",placeholder:"hh:mm",type:"text",onKeyup:Ye(T,["enter"])},null,40,zlt)])])]),f("div",xlt,[f("div",Flt,[f("label",null,R(m.$t("workouts.AVE_SPEED"))+" ("+R(g(c))+"/h): ",1),f("div",Blt,[f("input",{min:"0",name:"ave_speed_from",value:m.$route.query.ave_speed_from,onChange:p,step:"0.1",type:"number",onKeyup:Ye(T,["enter"])},null,40,Glt),f("span",null,R(m.$t("workouts.TO")),1),f("input",{min:"0",name:"ave_speed_to",value:m.$route.query.ave_speed_to,onChange:p,step:"0.1",type:"number",onKeyup:Ye(T,["enter"])},null,40,Hlt)])]),f("div",Vlt,[f("label",null,R(m.$t("workouts.MAX_SPEED"))+" ("+R(g(c))+"/h): ",1),f("div",qlt,[f("input",{min:"0",name:"max_speed_from",value:m.$route.query.max_speed_from,onChange:p,step:"0.1",type:"number",onKeyup:Ye(T,["enter"])},null,40,Klt),f("span",null,R(m.$t("workouts.TO")),1),f("input",{min:"0",name:"max_speed_to",value:m.$route.query.max_speed_to,onChange:p,step:"0.1",type:"number",onKeyup:Ye(T,["enter"])},null,40,jlt)])])])]),f("div",Ylt,[f("button",{type:"submit",class:"confirm",onClick:T},R(m.$t("buttons.FILTER")),1),f("button",{class:"confirm",onClick:A},R(m.$t("buttons.CLEAR_FILTER")),1)])],32)])]))}}),Qlt=ue(Xlt,[["__scopeId","data-v-a380e198"]]),Zlt={class:"workouts-list"},Jlt={class:"total"},ect={class:"total-label"},tct={key:0},nct={key:0,class:"workouts-table responsive-table"},act={class:"sport-col"},sct={class:"cell-heading"},rct=["onMouseover"],ict={class:"cell-heading"},oct={key:0,class:"fa fa-map-o","aria-hidden":"true"},uct={class:"title"},lct={class:"workout-date"},cct={class:"cell-heading"},dct={class:"text-right"},Ect={class:"cell-heading"},pct={class:"text-right"},fct={class:"cell-heading"},mct={class:"text-right"},Tct={class:"cell-heading"},_ct={class:"text-right"},hct={class:"cell-heading"},Sct={class:"text-right"},Act={class:"cell-heading"},Oct={class:"text-right"},gct={class:"cell-heading"},Ict=ae({__name:"WorkoutsList",props:{user:{},sports:{}},setup(e){const t=e,n=Ue(),a=bt(),s=_a(),{user:r,sports:i}=he(t),o=["ave_speed","distance","duration","workout_date"],u=z(()=>n.getters[Oe.GETTERS.USER_WORKOUTS]),c=z(()=>n.getters[Oe.GETTERS.WORKOUTS_PAGINATION]),l=z(()=>n.getters[V.GETTERS.LANGUAGE]);let E=A(a.query);const d=pe(null);ft(()=>{p(E),n.dispatch(We.ACTIONS.GET_EQUIPMENTS)});function p(S){n.dispatch(Oe.ACTIONS.GET_USER_WORKOUTS,r.value.imperial_units?I(S):S)}function T(S,h){const _=Object.assign({},a.query);_[S]=h,S==="per_page"&&(_.page="1"),E=A(_),s.push({path:"/workouts",query:E})}function A(S){const h=Jd(S,o,yi.order_by,{defaultSort:yi.order});return Object.keys(S).filter(_=>oHe.includes(_)).map(_=>{typeof S[_]=="string"&&(h[_]=S[_])}),h}function I(S){const h={...S};return Object.entries(h).map(_=>{_[0].match("speed|distance")&&_[1]&&(h[_[0]]=Vt(+_[1],"mi","km"))}),h}function m(S){d.value=S}return Me(()=>a.query,async S=>{E=A(S),p(E)}),(S,h)=>{const _=ie("SportImage"),O=ie("router-link"),b=ie("Distance");return N(),C("div",Zlt,[f("div",{class:Te(["box",{"empty-table":u.value.length===0}])},[f("div",Jlt,[f("span",ect,R(S.$t("common.TOTAL").toLowerCase())+": ",1),c.value.total?(N(),C("span",tct,R(c.value.total)+" "+R(S.$t("workouts.WORKOUT",c.value.total)),1)):M("",!0)]),x(HA,{sort:g(vp),order_by:o,query:g(E),message:"workouts",onUpdateSelect:T},null,8,["sort","query"]),u.value.length>0?(N(),C("div",nct,[x(Mu,{class:"top-pagination",pagination:c.value,path:"/workouts",query:g(E)},null,8,["pagination","query"]),f("table",null,[f("thead",{class:Te({smaller:l.value==="de"})},[f("tr",null,[h[1]||(h[1]=f("th",{class:"sport-col"},null,-1)),f("th",null,R(Be(S.$t("workouts.WORKOUT",1))),1),f("th",null,R(Be(S.$t("workouts.DATE"))),1),f("th",null,R(Be(S.$t("workouts.DISTANCE"))),1),f("th",null,R(Be(S.$t("workouts.DURATION"))),1),f("th",null,R(Be(S.$t("workouts.AVE_SPEED"))),1),f("th",null,R(Be(S.$t("workouts.MAX_SPEED"))),1),f("th",null,R(Be(S.$t("workouts.ASCENT"))),1),f("th",null,R(Be(S.$t("workouts.DESCENT"))),1)])],2),f("tbody",null,[(N(!0),C(_e,null,$e(u.value,v=>(N(),C("tr",{key:v.id},[f("td",act,[f("span",sct,R(S.$t("workouts.SPORT",1)),1),g(i).length>0?(N(),j(_,{key:0,title:g(i).filter(D=>D.id===v.sport_id)[0].translatedLabel,"sport-label":g(Rp)(v,g(i)),color:g(Np)(v,g(i))},null,8,["title","sport-label","color"])):M("",!0)]),f("td",{class:"workout-title",onMouseover:D=>m(v.id),onMouseleave:h[0]||(h[0]=D=>m(null))},[f("span",ict,R(Be(S.$t("workouts.WORKOUT",1))),1),x(O,{class:"nav-item",to:{name:"Workout",params:{workoutId:v.id}}},{default:le(()=>[v.with_gpx?(N(),C("i",oct)):M("",!0),f("span",uct,R(v.title),1)]),_:2},1032,["to"]),v.with_gpx&&d.value===v.id?(N(),j(AO,{key:0,workout:v,"display-hover":!0},null,8,["workout"])):M("",!0)],40,rct),f("td",lct,[f("span",cct,R(S.$t("workouts.DATE")),1),f("time",null,R(g(Vn)(v.workout_date,g(r).timezone,g(r).date_format)),1)]),f("td",dct,[f("span",Ect,R(S.$t("workouts.DISTANCE")),1),v.distance!==null?(N(),j(b,{key:0,distance:v.distance,unitFrom:"km",useImperialUnits:g(r).imperial_units},null,8,["distance","useImperialUnits"])):M("",!0)]),f("td",pct,[f("span",fct,R(S.$t("workouts.DURATION")),1),G(" "+R(v.moving),1)]),f("td",mct,[f("span",Tct,R(S.$t("workouts.AVE_SPEED")),1),v.ave_speed!==null?(N(),j(b,{key:0,distance:v.ave_speed,unitFrom:"km",speed:!0,useImperialUnits:g(r).imperial_units},null,8,["distance","useImperialUnits"])):M("",!0)]),f("td",_ct,[f("span",hct,R(S.$t("workouts.MAX_SPEED")),1),v.max_speed!==null?(N(),j(b,{key:0,distance:v.max_speed,unitFrom:"km",speed:!0,useImperialUnits:g(r).imperial_units},null,8,["distance","useImperialUnits"])):M("",!0)]),f("td",Sct,[f("span",Act,R(S.$t("workouts.ASCENT")),1),v.ascent!==null?(N(),j(b,{key:0,distance:v.ascent,unitFrom:"m",useImperialUnits:g(r).imperial_units},null,8,["distance","useImperialUnits"])):M("",!0)]),f("td",Oct,[f("span",gct,R(S.$t("workouts.DESCENT")),1),v.descent!==null?(N(),j(b,{key:0,distance:v.descent,unitFrom:"m",useImperialUnits:g(r).imperial_units},null,8,["distance","useImperialUnits"])):M("",!0)])]))),128))])]),x(Mu,{pagination:c.value,path:"/workouts",query:g(E)},null,8,["pagination","query"])])):M("",!0)],2),u.value.length===0?(N(),j(Lp,{key:0})):M("",!0),h[2]||(h[2]=f("div",{id:"bottom"},null,-1))])}}}),Rct=ue(Ict,[["__scopeId","data-v-f1b11684"]]),Nct={key:0,id:"workouts",class:"view"},vct={class:"container workouts-container"},bct={class:"display-filters"},Cct={class:"list-container"},Dct=ae({__name:"WorkoutsView",setup(e){const{t}=Ct(),n=Ue(),a=z(()=>n.getters[ee.GETTERS.AUTH_USER_PROFILE]),s=z(()=>n.getters[vt.GETTERS.SPORTS]),r=z(()=>Hn(s.value,t)),i=pe(!0);function o(){i.value=!i.value}return(u,c)=>a.value.username?(N(),C("div",Nct,[f("div",vct,[f("div",{class:Te(["filters-container",{hidden:i.value}])},[x(Qlt,{sports:r.value,authUser:a.value,onFilter:o},null,8,["sports","authUser"])],2),f("div",bct,[f("div",{onClick:o},[f("i",{class:Te(`fa fa-caret-${i.value?"down":"up"}`),"aria-hidden":"true"},null,2),f("span",null,R(u.$t(`workouts.${i.value?"DISPLAY":"HIDE"}_FILTERS`)),1)])]),f("div",Cct,[x(Rct,{user:a.value,sports:r.value},null,8,["user","sports"])])])])):M("",!0)}}),Pct=ue(Dct,[["__scopeId","data-v-3fa59b88"]]),{t:U0}=Nr.global,w0=e=>{const t=/(\/profile)(\/edit)*(\/*)/,n=e.replace(t,"").toUpperCase();return n===""?"PROFILE":n.split("/")[0].toUpperCase()},Lct=[{path:"/",name:"Dashboard",component:aat,meta:{title:"dashboard.DASHBOARD"}},{path:"/login",name:"Login",component:I0,props:{action:"login"},meta:{title:"user.LOGIN",withoutAuth:!0}},{path:"/register",name:"Register",component:I0,props:{action:"register"},meta:{title:"user.REGISTER",withoutAuth:!0}},{path:"/account-confirmation",name:"AccountConfirmation",component:xst,meta:{title:"user.ACCOUNT_CONFIRMATION",withoutAuth:!0}},{path:"/account-confirmation/resend",name:"AccountConfirmationResend",component:g0,props:{action:"account-confirmation-resend"},meta:{title:"buttons.ACCOUNT-CONFIRMATION-RESEND",withoutAuth:!0}},{path:"/account-confirmation/email-sent",name:"AccountConfirmationEmailSend",component:g0,props:{action:"email-sent"},meta:{title:"buttons.ACCOUNT-CONFIRMATION-RESEND",withoutAuth:!0}},{path:"/password-reset/sent",name:"PasswordEmailSent",component:Ro,props:{action:"request-sent"},meta:{title:"user.PASSWORD_RESET",withoutAuth:!0}},{path:"/password-reset/request",name:"PasswordResetRequest",component:Ro,props:{action:"reset-request"},meta:{title:"user.PASSWORD_RESET",withoutAuth:!0}},{path:"/password-reset/password-updated",name:"PasswordUpdated",component:Ro,props:{action:"password-updated"},meta:{title:"user.PASSWORD_RESET",withoutAuth:!0}},{path:"/password-reset",name:"PasswordReset",component:Ro,props:{action:"reset"},meta:{title:"user.PASSWORD_RESET",withoutAuth:!0}},{path:"/email-update",name:"EmailUpdate",component:Hst,meta:{title:"user.EMAIL_UPDATE",withoutChecks:!0}},{path:"/profile",name:"Profile",component:prt,children:[{path:"",name:"UserProfile",component:yVe,props:e=>({tab:w0(e.path)}),children:[{path:"",name:"UserInfos",component:JA,meta:{title:"user.PROFILE.TABS.PROFILE"}},{path:"preferences",name:"UserPreferences",component:ZVe,meta:{title:"user.PROFILE.TABS.PREFERENCES"}},{path:"sports",name:"UserSports",component:E0,props:{isEdition:!1},meta:{title:"user.PROFILE.TABS.SPORTS"},children:[{path:"",name:"UserSportPreferences",component:p0,meta:{title:"user.PROFILE.TABS.SPORTS"}},{path:":id",name:"UserSport",component:sJe,meta:{title:"user.PROFILE.TABS.SPORTS"}}]},{path:"apps",name:"UserApps",component:JXe,children:[{path:"",name:"UserAppsList",component:SQe,meta:{title:"user.PROFILE.TABS.APPS"}},{path:":id",name:"UserApp",component:o0,meta:{title:"user.PROFILE.TABS.APPS"}},{path:":id/created",name:"CreatedUserApp",component:o0,props:{afterCreation:!0},meta:{title:"user.PROFILE.TABS.APPS"}},{path:"new",name:"AddUserApp",component:xXe,meta:{title:"user.PROFILE.TABS.APPS"}},{path:"authorize",name:"AuthorizeUserApp",component:QXe,meta:{title:"user.PROFILE.TABS.APPS"}}]},{path:"equipments",name:"UserEquipments",component:c0,props:{isEdition:!1},children:[{path:"",name:"UserEquipmentsList",component:d0,meta:{title:"user.PROFILE.TABS.EQUIPMENTS"}},{path:"new",name:"AddEquipment",component:l0,meta:{title:"user.PROFILE.TABS.EQUIPMENTS"}},{path:":id",name:"Equipment",component:RZe,meta:{title:"user.PROFILE.TABS.EQUIPMENTS"}}]}]},{path:"edit",name:"UserProfileEdition",component:eqe,props:e=>({tab:w0(e.path)}),children:[{path:"",name:"UserInfosEdition",component:Jje,meta:{title:"user.PROFILE.EDIT"}},{path:"account",name:"UserAccountEdition",component:Mje,meta:{title:"user.PROFILE.ACCOUNT_EDITION"}},{path:"picture",name:"UserPictureEdition",component:oYe,meta:{title:"user.PROFILE.PICTURE_EDITION"}},{path:"preferences",name:"UserPreferencesEdition",component:aXe,meta:{title:"user.PROFILE.EDIT_PREFERENCES"}},{path:"sports",name:"UserSportsEdition",component:E0,props:{isEdition:!0},meta:{title:"user.PROFILE.EDIT_SPORTS_PREFERENCES"},children:[{path:"",name:"UserSportPreferencesEdition",component:p0,meta:{title:"user.PROFILE.TABS.SPORTS"}},{path:":id",name:"UserSportEdition",component:bJe,meta:{title:"user.PROFILE.TABS.SPORTS"}}]},{path:"equipments",name:"UserEquipmentsEdition",component:c0,props:{isEdition:!0},children:[{path:"",name:"UserEquipmentsListEdition",component:d0,meta:{title:"user.PROFILE.TABS.EQUIPMENTS"}},{path:":id",name:"EquipmentEdition",component:l0,meta:{title:"user.PROFILE.TABS.EQUIPMENTS"}}]},{path:"privacy-policy",name:"UserPrivacyPolicy",component:_Xe,meta:{title:"user.PROFILE.PRIVACY-POLICY_EDITION"}}]}]},{path:"/statistics",name:"Statistics",component:rst,meta:{title:"statistics.STATISTICS"}},{path:"/users/:username",name:"User",component:R0,meta:{title:"administration.USER"}},{path:"/workouts",name:"Workouts",component:Pct,meta:{title:"workouts.WORKOUT",count:0}},{path:"/workouts/:workoutId",name:"Workout",component:k0,props:{displaySegment:!1},meta:{title:"workouts.WORKOUT"}},{path:"/workouts/:workoutId/edit",name:"EditWorkout",component:Iit,meta:{title:"workouts.EDIT_WORKOUT"}},{path:"/workouts/:workoutId/segment/:segmentId",name:"WorkoutSegment",component:k0,props:{displaySegment:!0},meta:{title:"workouts.SEGMENT",count:0}},{path:"/workouts/add",name:"AddWorkout",component:Ait,meta:{title:"workouts.ADD_WORKOUT"}},{path:"/admin",name:"Administration",component:yet,children:[{path:"",name:"AdministrationMenu",component:yGe,meta:{title:"admin.ADMINISTRATION"}},{path:"application",name:"ApplicationAdministration",component:U_,meta:{title:"admin.APP_CONFIG.TITLE"}},{path:"application/edit",name:"ApplicationAdministrationEdition",component:U_,props:{edition:!0},meta:{title:"admin.APPLICATION"}},{path:"equipment-types",name:"EquipmentTypeAdministration",component:_Ge,meta:{title:"admin.EQUIPMENT_TYPES.TITLE"}},{path:"sports",name:"SportsAdministration",component:QGe,meta:{title:"admin.SPORTS.TITLE"}},{path:"users/:username",name:"UserFromAdmin",component:R0,props:{fromAdmin:!0},meta:{title:"admin.USER",count:1}},{path:"users",name:"UsersAdministration",component:pVe,meta:{title:"admin.USERS.TITLE"}}]},{path:"/about",name:"About",component:Ret,meta:{title:"common.ABOUT",withoutChecks:!0}},{path:"/privacy-policy",name:"PrivacyPolicy",component:uat,meta:{title:"privacy_policy.TITLE",withoutChecks:!0}},{path:"/:pathMatch(.*)*",name:"not-found",component:rat,meta:{title:"error.NOT_FOUND.PAGE"}}],it=$U({history:uU("/"),routes:Lct});it.beforeEach((e,t,n)=>{if("title"in e.meta){const a=typeof e.meta.title=="string"?e.meta.title:"",s=a?typeof e.meta.count=="number"?U0(a,+e.meta.count):U0(a):"";window.document.title=`FitTrackee${a?` - ${Be(s)}`:""}`}di.dispatch(ee.ACTIONS.CHECK_AUTH_USER).then(()=>{if(e.meta.withoutChecks)return n();if(di.getters[ee.GETTERS.IS_AUTHENTICATED]&&e.meta.withoutAuth)return n("/");if(!di.getters[ee.GETTERS.IS_AUTHENTICATED]&&!e.meta.withoutAuth){const a=e.path==="/"?{path:"/login"}:{path:"/login",query:{from:e.fullPath}};n(a)}else n()}).catch(a=>{console.error(a),n()})});oE.register(ag,sg,rg,ig,og,ug,lg,M0,cg,W0,dg,Eg);const eg=zR(p6).provide("sportColors",Ip).use(Nr).use(di).use(it).use(Nv,{name:"VFullscreen"}).directive("click-outside",Q5);X5.forEach(e=>{eg.component(e.name,e.target)});eg.mount("#app"); diff --git a/fittrackee/dist/static/index-jrrO8wNh.js b/fittrackee/dist/static/index-jrrO8wNh.js new file mode 100644 index 000000000..24b9be177 --- /dev/null +++ b/fittrackee/dist/static/index-jrrO8wNh.js @@ -0,0 +1,694 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["static/maps-DkiRMund.js","static/css/maps-CIGW-MKW.css"])))=>i.map(i=>d[i]); +var zI=Object.defineProperty;var xI=(e,t,n)=>t in e?zI(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var Nt=(e,t,n)=>xI(e,typeof t!="symbol"?t+"":t,n);import{C as gE,B as l0,L as c0,a as BI,b as GI,P as VI,p as HI,c as KI,d as qI,i as jI,e as YI,f as XI,g as QI}from"./charts-BDOr5tL2.js";import{g as RE,a as ZI}from"./maps-DkiRMund.js";(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))a(s);new MutationObserver(s=>{for(const o of s)if(o.type==="childList")for(const i of o.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&a(i)}).observe(document,{childList:!0,subtree:!0});function n(s){const o={};return s.integrity&&(o.integrity=s.integrity),s.referrerPolicy&&(o.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?o.credentials="include":s.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function a(s){if(s.ep)return;s.ep=!0;const o=n(s);fetch(s.href,o)}})();/** +* @vue/shared v3.5.13 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**//*! #__NO_SIDE_EFFECTS__ */function NE(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const vt={},lo=[],Na=()=>{},JI=()=>!1,el=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),vE=e=>e.startsWith("onUpdate:"),Ht=Object.assign,bE=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},eg=Object.prototype.hasOwnProperty,ht=(e,t)=>eg.call(e,t),ze=Array.isArray,co=e=>ji(e)==="[object Map]",$o=e=>ji(e)==="[object Set]",lm=e=>ji(e)==="[object Date]",qe=e=>typeof e=="function",zt=e=>typeof e=="string",ra=e=>typeof e=="symbol",bt=e=>e!==null&&typeof e=="object",d0=e=>(bt(e)||qe(e))&&qe(e.then)&&qe(e.catch),E0=Object.prototype.toString,ji=e=>E0.call(e),tg=e=>ji(e).slice(8,-1),p0=e=>ji(e)==="[object Object]",CE=e=>zt(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,ri=NE(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),tl=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},ng=/-(\w)/g,Jn=tl(e=>e.replace(ng,(t,n)=>n?n.toUpperCase():"")),ag=/\B([A-Z])/g,ps=tl(e=>e.replace(ag,"-$1").toLowerCase()),Fe=tl(e=>e.charAt(0).toUpperCase()+e.slice(1)),zl=tl(e=>e?`on${Fe(e)}`:""),ls=(e,t)=>!Object.is(e,t),zr=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:a,value:n})},Jr=e=>{const t=parseFloat(e);return isNaN(t)?e:t},sg=e=>{const t=zt(e)?Number(e):NaN;return isNaN(t)?e:t};let cm;const nl=()=>cm||(cm=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function Va(e){if(ze(e)){const t={};for(let n=0;n{if(n){const a=n.split(ig);a.length>1&&(t[a[0].trim()]=a[1].trim())}}),t}function he(e){let t="";if(zt(e))t=e;else if(ze(e))for(let n=0;nWs(n,t))}const _0=e=>!!(e&&e.__v_isRef===!0),A=e=>zt(e)?e:e==null?"":ze(e)||bt(e)&&(e.toString===E0||!qe(e.toString))?_0(e)?A(e.value):JSON.stringify(e,f0,2):String(e),f0=(e,t)=>_0(t)?f0(e,t.value):co(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[a,s],o)=>(n[xl(a,o)+" =>"]=s,n),{})}:$o(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>xl(n))}:ra(t)?xl(t):bt(t)&&!ze(t)&&!p0(t)?String(t):t,xl=(e,t="")=>{var n;return ra(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/** +* @vue/reactivity v3.5.13 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Fn;class h0{constructor(t=!1){this.detached=t,this._active=!0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.parent=Fn,!t&&Fn&&(this.index=(Fn.scopes||(Fn.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t0)return;if(li){let t=li;for(li=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;ui;){let t=ui;for(ui=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(a){e||(e=a)}t=n}}if(e)throw e}function g0(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function R0(e){let t,n=e.depsTail,a=n;for(;a;){const s=a.prevDep;a.version===-1?(a===n&&(n=s),yE(a),pg(a)):t=a,a.dep.activeLink=a.prevActiveLink,a.prevActiveLink=void 0,a=s}e.deps=t,e.depsTail=n}function _d(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(N0(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function N0(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===vi))return;e.globalVersion=vi;const t=e.dep;if(e.flags|=2,t.version>0&&!e.isSSR&&e.deps&&!_d(e)){e.flags&=-3;return}const n=Ct,a=oa;Ct=e,oa=!0;try{g0(e);const s=e.fn(e._value);(t.version===0||ls(s,e._value))&&(e._value=s,t.version++)}catch(s){throw t.version++,s}finally{Ct=n,oa=a,R0(e),e.flags&=-3}}function yE(e,t=!1){const{dep:n,prevSub:a,nextSub:s}=e;if(a&&(a.nextSub=s,e.prevSub=void 0),s&&(s.prevSub=a,e.nextSub=void 0),n.subs===e&&(n.subs=a,!a&&n.computed)){n.computed.flags&=-5;for(let o=n.computed.deps;o;o=o.nextDep)yE(o,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function pg(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let oa=!0;const v0=[];function ms(){v0.push(oa),oa=!1}function Ts(){const e=v0.pop();oa=e===void 0?!0:e}function dm(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=Ct;Ct=void 0;try{t()}finally{Ct=n}}}let vi=0;class mg{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class $E{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0}track(t){if(!Ct||!oa||Ct===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==Ct)n=this.activeLink=new mg(Ct,this),Ct.deps?(n.prevDep=Ct.depsTail,Ct.depsTail.nextDep=n,Ct.depsTail=n):Ct.deps=Ct.depsTail=n,b0(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const a=n.nextDep;a.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=a),n.prevDep=Ct.depsTail,n.nextDep=void 0,Ct.depsTail.nextDep=n,Ct.depsTail=n,Ct.deps===n&&(Ct.deps=a)}return n}trigger(t){this.version++,vi++,this.notify(t)}notify(t){DE();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{LE()}}}function b0(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let a=t.deps;a;a=a.nextDep)b0(a)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const eu=new WeakMap,ws=Symbol(""),fd=Symbol(""),bi=Symbol("");function sn(e,t,n){if(oa&&Ct){let a=eu.get(e);a||eu.set(e,a=new Map);let s=a.get(n);s||(a.set(n,s=new $E),s.map=a,s.key=n),s.track()}}function Wa(e,t,n,a,s,o){const i=eu.get(e);if(!i){vi++;return}const r=u=>{u&&u.trigger()};if(DE(),t==="clear")i.forEach(r);else{const u=ze(e),l=u&&CE(n);if(u&&n==="length"){const d=Number(a);i.forEach((E,c)=>{(c==="length"||c===bi||!ra(c)&&c>=d)&&r(E)})}else switch((n!==void 0||i.has(void 0))&&r(i.get(n)),l&&r(i.get(bi)),t){case"add":u?l&&r(i.get("length")):(r(i.get(ws)),co(e)&&r(i.get(fd)));break;case"delete":u||(r(i.get(ws)),co(e)&&r(i.get(fd)));break;case"set":co(e)&&r(i.get(ws));break}}LE()}function Tg(e,t){const n=eu.get(e);return n&&n.get(t)}function Zs(e){const t=ut(e);return t===e?t:(sn(t,"iterate",bi),Xn(e)?t:t.map(on))}function al(e){return sn(e=ut(e),"iterate",bi),e}const _g={__proto__:null,[Symbol.iterator](){return Gl(this,Symbol.iterator,on)},concat(...e){return Zs(this).concat(...e.map(t=>ze(t)?Zs(t):t))},entries(){return Gl(this,"entries",e=>(e[1]=on(e[1]),e))},every(e,t){return ba(this,"every",e,t,void 0,arguments)},filter(e,t){return ba(this,"filter",e,t,n=>n.map(on),arguments)},find(e,t){return ba(this,"find",e,t,on,arguments)},findIndex(e,t){return ba(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return ba(this,"findLast",e,t,on,arguments)},findLastIndex(e,t){return ba(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return ba(this,"forEach",e,t,void 0,arguments)},includes(...e){return Vl(this,"includes",e)},indexOf(...e){return Vl(this,"indexOf",e)},join(e){return Zs(this).join(e)},lastIndexOf(...e){return Vl(this,"lastIndexOf",e)},map(e,t){return ba(this,"map",e,t,void 0,arguments)},pop(){return Go(this,"pop")},push(...e){return Go(this,"push",e)},reduce(e,...t){return Em(this,"reduce",e,t)},reduceRight(e,...t){return Em(this,"reduceRight",e,t)},shift(){return Go(this,"shift")},some(e,t){return ba(this,"some",e,t,void 0,arguments)},splice(...e){return Go(this,"splice",e)},toReversed(){return Zs(this).toReversed()},toSorted(e){return Zs(this).toSorted(e)},toSpliced(...e){return Zs(this).toSpliced(...e)},unshift(...e){return Go(this,"unshift",e)},values(){return Gl(this,"values",on)}};function Gl(e,t,n){const a=al(e),s=a[t]();return a!==e&&!Xn(e)&&(s._next=s.next,s.next=()=>{const o=s._next();return o.value&&(o.value=n(o.value)),o}),s}const fg=Array.prototype;function ba(e,t,n,a,s,o){const i=al(e),r=i!==e&&!Xn(e),u=i[t];if(u!==fg[t]){const E=u.apply(e,o);return r?on(E):E}let l=n;i!==e&&(r?l=function(E,c){return n.call(this,on(E),c,e)}:n.length>2&&(l=function(E,c){return n.call(this,E,c,e)}));const d=u.call(i,l,a);return r&&s?s(d):d}function Em(e,t,n,a){const s=al(e);let o=n;return s!==e&&(Xn(e)?n.length>3&&(o=function(i,r,u){return n.call(this,i,r,u,e)}):o=function(i,r,u){return n.call(this,i,on(r),u,e)}),s[t](o,...a)}function Vl(e,t,n){const a=ut(e);sn(a,"iterate",bi);const s=a[t](...n);return(s===-1||s===!1)&&Yi(n[0])?(n[0]=ut(n[0]),a[t](...n)):s}function Go(e,t,n=[]){ms(),DE();const a=ut(e)[t].apply(e,n);return LE(),Ts(),a}const hg=NE("__proto__,__v_isRef,__isVue"),C0=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(ra));function Sg(e){ra(e)||(e=String(e));const t=ut(this);return sn(t,"has",e),t.hasOwnProperty(e)}class P0{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,a){if(n==="__v_skip")return t.__v_skip;const s=this._isReadonly,o=this._isShallow;if(n==="__v_isReactive")return!s;if(n==="__v_isReadonly")return s;if(n==="__v_isShallow")return o;if(n==="__v_raw")return a===(s?o?Pg:$0:o?y0:L0).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(a)?t:void 0;const i=ze(t);if(!s){let u;if(i&&(u=_g[n]))return u;if(n==="hasOwnProperty")return Sg}const r=Reflect.get(t,n,qt(t)?t:a);return(ra(n)?C0.has(n):hg(n))||(s||sn(t,"get",n),o)?r:qt(r)?i&&CE(n)?r:r.value:bt(r)?s?k0(r):kt(r):r}}class D0 extends P0{constructor(t=!1){super(!1,t)}set(t,n,a,s){let o=t[n];if(!this._isShallow){const u=Fs(o);if(!Xn(a)&&!Fs(a)&&(o=ut(o),a=ut(a)),!ze(t)&&qt(o)&&!qt(a))return u?!1:(o.value=a,!0)}const i=ze(t)&&CE(n)?Number(n)e,dr=e=>Reflect.getPrototypeOf(e);function Rg(e,t,n){return function(...a){const s=this.__v_raw,o=ut(s),i=co(o),r=e==="entries"||e===Symbol.iterator&&i,u=e==="keys"&&i,l=s[e](...a),d=n?hd:t?Sd:on;return!t&&sn(o,"iterate",u?fd:ws),{next(){const{value:E,done:c}=l.next();return c?{value:E,done:c}:{value:r?[d(E[0]),d(E[1])]:d(E),done:c}},[Symbol.iterator](){return this}}}}function Er(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Ng(e,t){const n={get(s){const o=this.__v_raw,i=ut(o),r=ut(s);e||(ls(s,r)&&sn(i,"get",s),sn(i,"get",r));const{has:u}=dr(i),l=t?hd:e?Sd:on;if(u.call(i,s))return l(o.get(s));if(u.call(i,r))return l(o.get(r));o!==i&&o.get(s)},get size(){const s=this.__v_raw;return!e&&sn(ut(s),"iterate",ws),Reflect.get(s,"size",s)},has(s){const o=this.__v_raw,i=ut(o),r=ut(s);return e||(ls(s,r)&&sn(i,"has",s),sn(i,"has",r)),s===r?o.has(s):o.has(s)||o.has(r)},forEach(s,o){const i=this,r=i.__v_raw,u=ut(r),l=t?hd:e?Sd:on;return!e&&sn(u,"iterate",ws),r.forEach((d,E)=>s.call(o,l(d),l(E),i))}};return Ht(n,e?{add:Er("add"),set:Er("set"),delete:Er("delete"),clear:Er("clear")}:{add(s){!t&&!Xn(s)&&!Fs(s)&&(s=ut(s));const o=ut(this);return dr(o).has.call(o,s)||(o.add(s),Wa(o,"add",s,s)),this},set(s,o){!t&&!Xn(o)&&!Fs(o)&&(o=ut(o));const i=ut(this),{has:r,get:u}=dr(i);let l=r.call(i,s);l||(s=ut(s),l=r.call(i,s));const d=u.call(i,s);return i.set(s,o),l?ls(o,d)&&Wa(i,"set",s,o):Wa(i,"add",s,o),this},delete(s){const o=ut(this),{has:i,get:r}=dr(o);let u=i.call(o,s);u||(s=ut(s),u=i.call(o,s)),r&&r.call(o,s);const l=o.delete(s);return u&&Wa(o,"delete",s,void 0),l},clear(){const s=ut(this),o=s.size!==0,i=s.clear();return o&&Wa(s,"clear",void 0,void 0),i}}),["keys","values","entries",Symbol.iterator].forEach(s=>{n[s]=Rg(s,e,t)}),n}function UE(e,t){const n=Ng(e,t);return(a,s,o)=>s==="__v_isReactive"?!e:s==="__v_isReadonly"?e:s==="__v_raw"?a:Reflect.get(ht(n,s)&&s in a?n:a,s,o)}const vg={get:UE(!1,!1)},bg={get:UE(!1,!0)},Cg={get:UE(!0,!1)};const L0=new WeakMap,y0=new WeakMap,$0=new WeakMap,Pg=new WeakMap;function Dg(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Lg(e){return e.__v_skip||!Object.isExtensible(e)?0:Dg(tg(e))}function kt(e){return Fs(e)?e:kE(e,!1,Og,vg,L0)}function U0(e){return kE(e,!1,gg,bg,y0)}function k0(e){return kE(e,!0,Ig,Cg,$0)}function kE(e,t,n,a,s){if(!bt(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;const o=s.get(e);if(o)return o;const i=Lg(e);if(i===0)return e;const r=new Proxy(e,i===2?a:n);return s.set(e,r),r}function Eo(e){return Fs(e)?Eo(e.__v_raw):!!(e&&e.__v_isReactive)}function Fs(e){return!!(e&&e.__v_isReadonly)}function Xn(e){return!!(e&&e.__v_isShallow)}function Yi(e){return e?!!e.__v_raw:!1}function ut(e){const t=e&&e.__v_raw;return t?ut(t):e}function Ha(e){return!ht(e,"__v_skip")&&Object.isExtensible(e)&&m0(e,"__v_skip",!0),e}const on=e=>bt(e)?kt(e):e,Sd=e=>bt(e)?k0(e):e;function qt(e){return e?e.__v_isRef===!0:!1}function Se(e){return w0(e,!1)}function sl(e){return w0(e,!0)}function w0(e,t){return qt(e)?e:new yg(e,t)}class yg{constructor(t,n){this.dep=new $E,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:ut(t),this._value=n?t:on(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,a=this.__v_isShallow||Xn(t)||Fs(t);t=a?t:ut(t),ls(t,n)&&(this._rawValue=t,this._value=a?t:on(t),this.dep.trigger())}}function T(e){return qt(e)?e.value:e}const $g={get:(e,t,n)=>t==="__v_raw"?e:T(Reflect.get(e,t,n)),set:(e,t,n,a)=>{const s=e[t];return qt(s)&&!qt(n)?(s.value=n,!0):Reflect.set(e,t,n,a)}};function M0(e){return Eo(e)?e:new Proxy(e,$g)}function _e(e){const t=ze(e)?new Array(e.length):{};for(const n in e)t[n]=kg(e,n);return t}class Ug{constructor(t,n,a){this._object=t,this._key=n,this._defaultValue=a,this.__v_isRef=!0,this._value=void 0}get value(){const t=this._object[this._key];return this._value=t===void 0?this._defaultValue:t}set value(t){this._object[this._key]=t}get dep(){return Tg(ut(this._object),this._key)}}function kg(e,t,n){const a=e[t];return qt(a)?a:new Ug(e,t,n)}class wg{constructor(t,n,a){this.fn=t,this.setter=n,this._value=void 0,this.dep=new $E(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=vi-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=a}notify(){if(this.flags|=16,!(this.flags&8)&&Ct!==this)return I0(this,!0),!0}get value(){const t=this.dep.track();return N0(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Mg(e,t,n=!1){let a,s;return qe(e)?a=e:(a=e.get,s=e.set),new wg(a,s,n)}const pr={},tu=new WeakMap;let Ps;function Wg(e,t=!1,n=Ps){if(n){let a=tu.get(n);a||tu.set(n,a=[]),a.push(e)}}function Fg(e,t,n=vt){const{immediate:a,deep:s,once:o,scheduler:i,augmentJob:r,call:u}=n,l=I=>s?I:Xn(I)||s===!1||s===0?Fa(I,1):Fa(I);let d,E,c,m,_=!1,h=!1;if(qt(e)?(E=()=>e.value,_=Xn(e)):Eo(e)?(E=()=>l(e),_=!0):ze(e)?(h=!0,_=e.some(I=>Eo(I)||Xn(I)),E=()=>e.map(I=>{if(qt(I))return I.value;if(Eo(I))return l(I);if(qe(I))return u?u(I,2):I()})):qe(e)?t?E=u?()=>u(e,2):e:E=()=>{if(c){ms();try{c()}finally{Ts()}}const I=Ps;Ps=d;try{return u?u(e,3,[m]):e(m)}finally{Ps=I}}:E=Na,t&&s){const I=E,N=s===!0?1/0:s;E=()=>Fa(I(),N)}const O=Eg(),S=()=>{d.stop(),O&&O.active&&bE(O.effects,d)};if(o&&t){const I=t;t=(...N)=>{I(...N),S()}}let R=h?new Array(e.length).fill(pr):pr;const g=I=>{if(!(!(d.flags&1)||!d.dirty&&!I))if(t){const N=d.run();if(s||_||(h?N.some((b,C)=>ls(b,R[C])):ls(N,R))){c&&c();const b=Ps;Ps=d;try{const C=[N,R===pr?void 0:h&&R[0]===pr?[]:R,m];u?u(t,3,C):t(...C),R=N}finally{Ps=b}}}else d.run()};return r&&r(g),d=new A0(E),d.scheduler=i?()=>i(g,!1):g,m=I=>Wg(I,!1,d),c=d.onStop=()=>{const I=tu.get(d);if(I){if(u)u(I,4);else for(const N of I)N();tu.delete(d)}},t?a?g(!0):R=d.run():i?i(g.bind(null,!0),!0):d.run(),S.pause=d.pause.bind(d),S.resume=d.resume.bind(d),S.stop=S,S}function Fa(e,t=1/0,n){if(t<=0||!bt(e)||e.__v_skip||(n=n||new Set,n.has(e)))return e;if(n.add(e),t--,qt(e))Fa(e.value,t,n);else if(ze(e))for(let a=0;a{Fa(a,t,n)});else if(p0(e)){for(const a in e)Fa(e[a],t,n);for(const a of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,a)&&Fa(e[a],t,n)}return e}/** +* @vue/runtime-core v3.5.13 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function Xi(e,t,n,a){try{return a?e(...a):e()}catch(s){ol(s,t,n)}}function ua(e,t,n,a){if(qe(e)){const s=Xi(e,t,n,a);return s&&d0(s)&&s.catch(o=>{ol(o,t,n)}),s}if(ze(e)){const s=[];for(let o=0;o>>1,s=fn[a],o=Ci(s);o=Ci(n)?fn.push(e):fn.splice(xg(t),0,e),e.flags|=1,F0()}}function F0(){nu||(nu=W0.then(x0))}function Bg(e){ze(e)?po.push(...e):es&&e.id===-1?es.splice(oo+1,0,e):e.flags&1||(po.push(e),e.flags|=1),F0()}function pm(e,t,n=Oa+1){for(;nCi(n)-Ci(a));if(po.length=0,es){es.push(...t);return}for(es=t,oo=0;ooe.id==null?e.flags&2?-1:1/0:e.id;function x0(e){try{for(Oa=0;Oa{a._d&&vm(-1);const o=au(t);let i;try{i=e(...s)}finally{au(o),a._d&&vm(1)}return i};return a._n=!0,a._c=!0,a._d=!0,a}function Me(e,t){if(Qt===null)return e;const n=ll(Qt),a=e.dirs||(e.dirs=[]);for(let s=0;se.__isTeleport,ci=e=>e&&(e.disabled||e.disabled===""),mm=e=>e&&(e.defer||e.defer===""),Tm=e=>typeof SVGElement<"u"&&e instanceof SVGElement,_m=e=>typeof MathMLElement=="function"&&e instanceof MathMLElement,Ad=(e,t)=>{const n=e&&e.to;return zt(n)?t?t(n):null:n},H0={name:"Teleport",__isTeleport:!0,process(e,t,n,a,s,o,i,r,u,l){const{mc:d,pc:E,pbc:c,o:{insert:m,querySelector:_,createText:h,createComment:O}}=l,S=ci(t.props);let{shapeFlag:R,children:g,dynamicChildren:I}=t;if(e==null){const N=t.el=h(""),b=t.anchor=h("");m(N,n,a),m(b,n,a);const C=(P,$)=>{R&16&&(s&&s.isCE&&(s.ce._teleportTarget=P),d(g,P,$,s,o,i,r,u))},k=()=>{const P=t.target=Ad(t.props,_),$=K0(P,t,h,m);P&&(i!=="svg"&&Tm(P)?i="svg":i!=="mathml"&&_m(P)&&(i="mathml"),S||(C(P,$),xr(t,!1)))};S&&(C(n,b),xr(t,!0)),mm(t.props)?_n(()=>{k(),t.el.__isMounted=!0},o):k()}else{if(mm(t.props)&&!e.el.__isMounted){_n(()=>{H0.process(e,t,n,a,s,o,i,r,u,l),delete e.el.__isMounted},o);return}t.el=e.el,t.targetStart=e.targetStart;const N=t.anchor=e.anchor,b=t.target=e.target,C=t.targetAnchor=e.targetAnchor,k=ci(e.props),P=k?n:b,$=k?N:C;if(i==="svg"||Tm(b)?i="svg":(i==="mathml"||_m(b))&&(i="mathml"),I?(c(e.dynamicChildren,I,P,s,o,i,r),FE(e,t,!0)):u||E(e,t,P,$,s,o,i,r,!1),S)k?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):mr(t,n,N,l,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const y=t.target=Ad(t.props,_);y&&mr(t,y,null,l,0)}else k&&mr(t,b,C,l,1);xr(t,S)}},remove(e,t,n,{um:a,o:{remove:s}},o){const{shapeFlag:i,children:r,anchor:u,targetStart:l,targetAnchor:d,target:E,props:c}=e;if(E&&(s(l),s(d)),o&&s(u),i&16){const m=o||!ci(c);for(let _=0;_{e.isMounted=!0}),Qi(()=>{e.isUnmounting=!0}),e}const Vn=[Function,Array],q0={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:Vn,onEnter:Vn,onAfterEnter:Vn,onEnterCancelled:Vn,onBeforeLeave:Vn,onLeave:Vn,onAfterLeave:Vn,onLeaveCancelled:Vn,onBeforeAppear:Vn,onAppear:Vn,onAfterAppear:Vn,onAppearCancelled:Vn},j0=e=>{const t=e.subTree;return t.component?j0(t.component):t},Kg={name:"BaseTransition",props:q0,setup(e,{slots:t}){const n=Ao(),a=Hg();return()=>{const s=t.default&&Q0(t.default(),!0);if(!s||!s.length)return;const o=Y0(s),i=ut(e),{mode:r}=i;if(a.isLeaving)return Hl(o);const u=fm(o);if(!u)return Hl(o);let l=Od(u,i,a,n,E=>l=E);u.type!==hn&&Pi(u,l);let d=n.subTree&&fm(n.subTree);if(d&&d.type!==hn&&!Ls(u,d)&&j0(n).type!==hn){let E=Od(d,i,a,n);if(Pi(d,E),r==="out-in"&&u.type!==hn)return a.isLeaving=!0,E.afterLeave=()=>{a.isLeaving=!1,n.job.flags&8||n.update(),delete E.afterLeave,d=void 0},Hl(o);r==="in-out"&&u.type!==hn?E.delayLeave=(c,m,_)=>{const h=X0(a,d);h[String(d.key)]=d,c[ts]=()=>{m(),c[ts]=void 0,delete l.delayedLeave,d=void 0},l.delayedLeave=()=>{_(),delete l.delayedLeave,d=void 0}}:d=void 0}else d&&(d=void 0);return o}}};function Y0(e){let t=e[0];if(e.length>1){for(const n of e)if(n.type!==hn){t=n;break}}return t}const qg=Kg;function X0(e,t){const{leavingVNodes:n}=e;let a=n.get(t.type);return a||(a=Object.create(null),n.set(t.type,a)),a}function Od(e,t,n,a,s){const{appear:o,mode:i,persisted:r=!1,onBeforeEnter:u,onEnter:l,onAfterEnter:d,onEnterCancelled:E,onBeforeLeave:c,onLeave:m,onAfterLeave:_,onLeaveCancelled:h,onBeforeAppear:O,onAppear:S,onAfterAppear:R,onAppearCancelled:g}=t,I=String(e.key),N=X0(n,e),b=(P,$)=>{P&&ua(P,a,9,$)},C=(P,$)=>{const y=$[1];b(P,$),ze(P)?P.every(z=>z.length<=1)&&y():P.length<=1&&y()},k={mode:i,persisted:r,beforeEnter(P){let $=u;if(!n.isMounted)if(o)$=O||u;else return;P[ts]&&P[ts](!0);const y=N[I];y&&Ls(e,y)&&y.el[ts]&&y.el[ts](),b($,[P])},enter(P){let $=l,y=d,z=E;if(!n.isMounted)if(o)$=S||l,y=R||d,z=g||E;else return;let Z=!1;const Ae=P[Tr]=J=>{Z||(Z=!0,J?b(z,[P]):b(y,[P]),k.delayedLeave&&k.delayedLeave(),P[Tr]=void 0)};$?C($,[P,Ae]):Ae()},leave(P,$){const y=String(e.key);if(P[Tr]&&P[Tr](!0),n.isUnmounting)return $();b(c,[P]);let z=!1;const Z=P[ts]=Ae=>{z||(z=!0,$(),Ae?b(h,[P]):b(_,[P]),P[ts]=void 0,N[y]===e&&delete N[y])};N[y]=e,m?C(m,[P,Z]):Z()},clone(P){const $=Od(P,t,n,a,s);return s&&s($),$}};return k}function Hl(e){if(il(e))return e=cs(e),e.children=null,e}function fm(e){if(!il(e))return V0(e.type)&&e.children?Y0(e.children):e;const{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&qe(n.default))return n.default()}}function Pi(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Pi(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Q0(e,t=!1,n){let a=[],s=0;for(let o=0;o1)for(let o=0;osu(_,t&&(ze(t)?t[h]:t),n,a,s));return}if(mo(a)&&!s){a.shapeFlag&512&&a.type.__asyncResolved&&a.component.subTree.component&&su(e,t,n,a.component.subTree);return}const o=a.shapeFlag&4?ll(a.component):a.el,i=s?null:o,{i:r,r:u}=e,l=t&&t.r,d=r.refs===vt?r.refs={}:r.refs,E=r.setupState,c=ut(E),m=E===vt?()=>!1:_=>ht(c,_);if(l!=null&&l!==u&&(zt(l)?(d[l]=null,m(l)&&(E[l]=null)):qt(l)&&(l.value=null)),qe(u))Xi(u,r,12,[i,d]);else{const _=zt(u),h=qt(u);if(_||h){const O=()=>{if(e.f){const S=_?m(u)?E[u]:d[u]:u.value;s?ze(S)&&bE(S,o):ze(S)?S.includes(o)||S.push(o):_?(d[u]=[o],m(u)&&(E[u]=d[u])):(u.value=[o],e.k&&(d[e.k]=u.value))}else _?(d[u]=i,m(u)&&(E[u]=i)):h&&(u.value=i,e.k&&(d[e.k]=i))};i?(O.id=-1,_n(O,n)):O()}}}nl().requestIdleCallback;nl().cancelIdleCallback;const mo=e=>!!e.type.__asyncLoader,il=e=>e.type.__isKeepAlive;function jg(e,t){J0(e,"a",t)}function Yg(e,t){J0(e,"da",t)}function J0(e,t,n=en){const a=e.__wdc||(e.__wdc=()=>{let s=n;for(;s;){if(s.isDeactivated)return;s=s.parent}return e()});if(rl(t,a,n),n){let s=n.parent;for(;s&&s.parent;)il(s.parent.vnode)&&Xg(a,t,n,s),s=s.parent}}function Xg(e,t,n,a){const s=rl(t,e,a,!0);Et(()=>{bE(a[t],s)},n)}function rl(e,t,n=en,a=!1){if(n){const s=n[e]||(n[e]=[]),o=t.__weh||(t.__weh=(...i)=>{ms();const r=Ji(n),u=ua(t,n,e,i);return r(),Ts(),u});return a?s.unshift(o):s.push(o),o}}const Ka=e=>(t,n=en)=>{(!yi||e==="sp")&&rl(e,(...a)=>t(...a),n)},tt=Ka("bm"),Tt=Ka("m"),Qg=Ka("bu"),Zg=Ka("u"),Qi=Ka("bum"),Et=Ka("um"),Jg=Ka("sp"),eR=Ka("rtg"),tR=Ka("rtc");function nR(e,t=en){rl("ec",e,t)}const eS="components",aR="directives";function q(e,t){return tS(eS,e,!0,t)||e}const sR=Symbol.for("v-ndc");function oR(e){return tS(aR,e)}function tS(e,t,n=!0,a=!1){const s=Qt||en;if(s){const o=s.type;if(e===eS){const r=VR(o,!1);if(r&&(r===t||r===Jn(t)||r===Fe(Jn(t))))return o}const i=hm(s[e]||o[e],t)||hm(s.appContext[e],t);return!i&&a?o:i}}function hm(e,t){return e&&(e[t]||e[Jn(t)]||e[Fe(Jn(t))])}function be(e,t,n,a){let s;const o=n,i=ze(e);if(i||zt(e)){const r=i&&Eo(e);let u=!1;r&&(u=!Xn(e),e=al(e)),s=new Array(e.length);for(let l=0,d=e.length;lt(r,u,void 0,o));else{const r=Object.keys(e);s=new Array(r.length);for(let u=0,l=r.length;u{const o=a.fn(...s);return o&&(o.key=a.key),o}:a.fn)}return e}function Pt(e,t,n={},a,s){if(Qt.ce||Qt.parent&&mo(Qt.parent)&&Qt.parent.ce)return t!=="default"&&(n.name=t),f(),B(le,null,[w("slot",n,a&&a())],64);let o=e[t];o&&o._c&&(o._d=!1),f();const i=o&&nS(o(n)),r=n.key||i&&i.key,u=B(le,{key:(r&&!ra(r)?r:`_${t}`)+(!i&&a?"_fb":"")},i||(a?a():[]),i&&e._===1?64:-2);return!s&&u.scopeId&&(u.slotScopeIds=[u.scopeId+"-s"]),o&&o._c&&(o._d=!0),u}function nS(e){return e.some(t=>Li(t)?!(t.type===hn||t.type===le&&!nS(t.children)):!0)?e:null}const Id=e=>e?IS(e)?ll(e):Id(e.parent):null,di=Ht(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Id(e.parent),$root:e=>Id(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>ME(e),$forceUpdate:e=>e.f||(e.f=()=>{wE(e.update)}),$nextTick:e=>e.n||(e.n=un.bind(e.proxy)),$watch:e=>vR.bind(e)}),Kl=(e,t)=>e!==vt&&!e.__isScriptSetup&&ht(e,t),iR={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:a,data:s,props:o,accessCache:i,type:r,appContext:u}=e;let l;if(t[0]!=="$"){const m=i[t];if(m!==void 0)switch(m){case 1:return a[t];case 2:return s[t];case 4:return n[t];case 3:return o[t]}else{if(Kl(a,t))return i[t]=1,a[t];if(s!==vt&&ht(s,t))return i[t]=2,s[t];if((l=e.propsOptions[0])&&ht(l,t))return i[t]=3,o[t];if(n!==vt&&ht(n,t))return i[t]=4,n[t];gd&&(i[t]=0)}}const d=di[t];let E,c;if(d)return t==="$attrs"&&sn(e.attrs,"get",""),d(e);if((E=r.__cssModules)&&(E=E[t]))return E;if(n!==vt&&ht(n,t))return i[t]=4,n[t];if(c=u.config.globalProperties,ht(c,t))return c[t]},set({_:e},t,n){const{data:a,setupState:s,ctx:o}=e;return Kl(s,t)?(s[t]=n,!0):a!==vt&&ht(a,t)?(a[t]=n,!0):ht(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(o[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:a,appContext:s,propsOptions:o}},i){let r;return!!n[i]||e!==vt&&ht(e,i)||Kl(t,i)||(r=o[0])&&ht(r,i)||ht(a,i)||ht(di,i)||ht(s.config.globalProperties,i)},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:ht(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Sm(e){return ze(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let gd=!0;function rR(e){const t=ME(e),n=e.proxy,a=e.ctx;gd=!1,t.beforeCreate&&Am(t.beforeCreate,e,"bc");const{data:s,computed:o,methods:i,watch:r,provide:u,inject:l,created:d,beforeMount:E,mounted:c,beforeUpdate:m,updated:_,activated:h,deactivated:O,beforeDestroy:S,beforeUnmount:R,destroyed:g,unmounted:I,render:N,renderTracked:b,renderTriggered:C,errorCaptured:k,serverPrefetch:P,expose:$,inheritAttrs:y,components:z,directives:Z,filters:Ae}=t;if(l&&uR(l,a,null),i)for(const Te in i){const De=i[Te];qe(De)&&(a[Te]=De.bind(n))}if(s){const Te=s.call(n,n);bt(Te)&&(e.data=kt(Te))}if(gd=!0,o)for(const Te in o){const De=o[Te],Ve=qe(De)?De.bind(n,n):qe(De.get)?De.get.bind(n,n):Na,xe=!qe(De)&&qe(De.set)?De.set.bind(n):Na,ot=F({get:Ve,set:xe});Object.defineProperty(a,Te,{enumerable:!0,configurable:!0,get:()=>ot.value,set:re=>ot.value=re})}if(r)for(const Te in r)aS(r[Te],a,n,Te);if(u){const Te=qe(u)?u.call(n):u;Reflect.ownKeys(Te).forEach(De=>{On(De,Te[De])})}d&&Am(d,e,"c");function ce(Te,De){ze(De)?De.forEach(Ve=>Te(Ve.bind(n))):De&&Te(De.bind(n))}if(ce(tt,E),ce(Tt,c),ce(Qg,m),ce(Zg,_),ce(jg,h),ce(Yg,O),ce(nR,k),ce(tR,b),ce(eR,C),ce(Qi,R),ce(Et,I),ce(Jg,P),ze($))if($.length){const Te=e.exposed||(e.exposed={});$.forEach(De=>{Object.defineProperty(Te,De,{get:()=>n[De],set:Ve=>n[De]=Ve})})}else e.exposed||(e.exposed={});N&&e.render===Na&&(e.render=N),y!=null&&(e.inheritAttrs=y),z&&(e.components=z),Z&&(e.directives=Z),P&&Z0(e)}function uR(e,t,n=Na){ze(e)&&(e=Rd(e));for(const a in e){const s=e[a];let o;bt(s)?"default"in s?o=Ut(s.from||a,s.default,!0):o=Ut(s.from||a):o=Ut(s),qt(o)?Object.defineProperty(t,a,{enumerable:!0,configurable:!0,get:()=>o.value,set:i=>o.value=i}):t[a]=o}}function Am(e,t,n){ua(ze(e)?e.map(a=>a.bind(t.proxy)):e.bind(t.proxy),t,n)}function aS(e,t,n,a){let s=a.includes(".")?_S(n,a):()=>n[a];if(zt(e)){const o=t[e];qe(o)&&Le(s,o)}else if(qe(e))Le(s,e.bind(n));else if(bt(e))if(ze(e))e.forEach(o=>aS(o,t,n,a));else{const o=qe(e.handler)?e.handler.bind(n):t[e.handler];qe(o)&&Le(s,o,e)}}function ME(e){const t=e.type,{mixins:n,extends:a}=t,{mixins:s,optionsCache:o,config:{optionMergeStrategies:i}}=e.appContext,r=o.get(t);let u;return r?u=r:!s.length&&!n&&!a?u=t:(u={},s.length&&s.forEach(l=>ou(u,l,i,!0)),ou(u,t,i)),bt(t)&&o.set(t,u),u}function ou(e,t,n,a=!1){const{mixins:s,extends:o}=t;o&&ou(e,o,n,!0),s&&s.forEach(i=>ou(e,i,n,!0));for(const i in t)if(!(a&&i==="expose")){const r=lR[i]||n&&n[i];e[i]=r?r(e[i],t[i]):t[i]}return e}const lR={data:Om,props:Im,emits:Im,methods:oi,computed:oi,beforeCreate:mn,created:mn,beforeMount:mn,mounted:mn,beforeUpdate:mn,updated:mn,beforeDestroy:mn,beforeUnmount:mn,destroyed:mn,unmounted:mn,activated:mn,deactivated:mn,errorCaptured:mn,serverPrefetch:mn,components:oi,directives:oi,watch:dR,provide:Om,inject:cR};function Om(e,t){return t?e?function(){return Ht(qe(e)?e.call(this,this):e,qe(t)?t.call(this,this):t)}:t:e}function cR(e,t){return oi(Rd(e),Rd(t))}function Rd(e){if(ze(e)){const t={};for(let n=0;n1)return n&&qe(t)?t.call(a&&a.proxy):t}}const oS={},iS=()=>Object.create(oS),rS=e=>Object.getPrototypeOf(e)===oS;function mR(e,t,n,a=!1){const s={},o=iS();e.propsDefaults=Object.create(null),uS(e,t,s,o);for(const i in e.propsOptions[0])i in s||(s[i]=void 0);n?e.props=a?s:U0(s):e.type.props?e.props=s:e.props=o,e.attrs=o}function TR(e,t,n,a){const{props:s,attrs:o,vnode:{patchFlag:i}}=e,r=ut(s),[u]=e.propsOptions;let l=!1;if((a||i>0)&&!(i&16)){if(i&8){const d=e.vnode.dynamicProps;for(let E=0;E{u=!0;const[c,m]=lS(E,t,!0);Ht(i,c),m&&r.push(...m)};!n&&t.mixins.length&&t.mixins.forEach(d),e.extends&&d(e.extends),e.mixins&&e.mixins.forEach(d)}if(!o&&!u)return bt(e)&&a.set(e,lo),lo;if(ze(o))for(let d=0;de[0]==="_"||e==="$stable",WE=e=>ze(e)?e.map(Ra):[Ra(e)],fR=(e,t,n)=>{if(t._n)return t;const a=X((...s)=>WE(t(...s)),n);return a._c=!1,a},dS=(e,t,n)=>{const a=e._ctx;for(const s in e){if(cS(s))continue;const o=e[s];if(qe(o))t[s]=fR(s,o,a);else if(o!=null){const i=WE(o);t[s]=()=>i}}},ES=(e,t)=>{const n=WE(t);e.slots.default=()=>n},pS=(e,t,n)=>{for(const a in t)(n||a!=="_")&&(e[a]=t[a])},hR=(e,t,n)=>{const a=e.slots=iS();if(e.vnode.shapeFlag&32){const s=t._;s?(pS(a,t,n),n&&m0(a,"_",s,!0)):dS(t,a)}else t&&ES(e,t)},SR=(e,t,n)=>{const{vnode:a,slots:s}=e;let o=!0,i=vt;if(a.shapeFlag&32){const r=t._;r?n&&r===1?o=!1:pS(s,t,n):(o=!t.$stable,dS(t,s)),i=t}else t&&(ES(e,t),i={default:1});if(o)for(const r in s)!cS(r)&&i[r]==null&&delete s[r]},_n=$R;function AR(e){return OR(e)}function OR(e,t){const n=nl();n.__VUE__=!0;const{insert:a,remove:s,patchProp:o,createElement:i,createText:r,createComment:u,setText:l,setElementText:d,parentNode:E,nextSibling:c,setScopeId:m=Na,insertStaticContent:_}=e,h=(U,M,Y,pe=null,oe=null,L=null,W=void 0,G=null,j=!!M.dynamicChildren)=>{if(U===M)return;U&&!Ls(U,M)&&(pe=de(U),re(U,oe,L,!0),U=null),M.patchFlag===-2&&(j=!1,M.dynamicChildren=null);const{type:Ee,ref:ge,shapeFlag:V}=M;switch(Ee){case Zi:O(U,M,Y,pe);break;case hn:S(U,M,Y,pe);break;case Br:U==null&&R(M,Y,pe,W);break;case le:z(U,M,Y,pe,oe,L,W,G,j);break;default:V&1?N(U,M,Y,pe,oe,L,W,G,j):V&6?Z(U,M,Y,pe,oe,L,W,G,j):(V&64||V&128)&&Ee.process(U,M,Y,pe,oe,L,W,G,j,Ce)}ge!=null&&oe&&su(ge,U&&U.ref,L,M||U,!M)},O=(U,M,Y,pe)=>{if(U==null)a(M.el=r(M.children),Y,pe);else{const oe=M.el=U.el;M.children!==U.children&&l(oe,M.children)}},S=(U,M,Y,pe)=>{U==null?a(M.el=u(M.children||""),Y,pe):M.el=U.el},R=(U,M,Y,pe)=>{[U.el,U.anchor]=_(U.children,M,Y,pe,U.el,U.anchor)},g=({el:U,anchor:M},Y,pe)=>{let oe;for(;U&&U!==M;)oe=c(U),a(U,Y,pe),U=oe;a(M,Y,pe)},I=({el:U,anchor:M})=>{let Y;for(;U&&U!==M;)Y=c(U),s(U),U=Y;s(M)},N=(U,M,Y,pe,oe,L,W,G,j)=>{M.type==="svg"?W="svg":M.type==="math"&&(W="mathml"),U==null?b(M,Y,pe,oe,L,W,G,j):P(U,M,oe,L,W,G,j)},b=(U,M,Y,pe,oe,L,W,G)=>{let j,Ee;const{props:ge,shapeFlag:V,transition:ie,dirs:Pe}=U;if(j=U.el=i(U.type,L,ge&&ge.is,ge),V&8?d(j,U.children):V&16&&k(U.children,j,null,pe,oe,ql(U,L),W,G),Pe&&Os(U,null,pe,"created"),C(j,U,U.scopeId,W,pe),ge){for(const lt in ge)lt!=="value"&&!ri(lt)&&o(j,lt,null,ge[lt],L,pe);"value"in ge&&o(j,"value",null,ge.value,L),(Ee=ge.onVnodeBeforeMount)&&Ta(Ee,pe,U)}Pe&&Os(U,null,pe,"beforeMount");const We=IR(oe,ie);We&&ie.beforeEnter(j),a(j,M,Y),((Ee=ge&&ge.onVnodeMounted)||We||Pe)&&_n(()=>{Ee&&Ta(Ee,pe,U),We&&ie.enter(j),Pe&&Os(U,null,pe,"mounted")},oe)},C=(U,M,Y,pe,oe)=>{if(Y&&m(U,Y),pe)for(let L=0;L{for(let Ee=j;Ee{const G=M.el=U.el;let{patchFlag:j,dynamicChildren:Ee,dirs:ge}=M;j|=U.patchFlag&16;const V=U.props||vt,ie=M.props||vt;let Pe;if(Y&&Is(Y,!1),(Pe=ie.onVnodeBeforeUpdate)&&Ta(Pe,Y,M,U),ge&&Os(M,U,Y,"beforeUpdate"),Y&&Is(Y,!0),(V.innerHTML&&ie.innerHTML==null||V.textContent&&ie.textContent==null)&&d(G,""),Ee?$(U.dynamicChildren,Ee,G,Y,pe,ql(M,oe),L):W||De(U,M,G,null,Y,pe,ql(M,oe),L,!1),j>0){if(j&16)y(G,V,ie,Y,oe);else if(j&2&&V.class!==ie.class&&o(G,"class",null,ie.class,oe),j&4&&o(G,"style",V.style,ie.style,oe),j&8){const We=M.dynamicProps;for(let lt=0;lt{Pe&&Ta(Pe,Y,M,U),ge&&Os(M,U,Y,"updated")},pe)},$=(U,M,Y,pe,oe,L,W)=>{for(let G=0;G{if(M!==Y){if(M!==vt)for(const L in M)!ri(L)&&!(L in Y)&&o(U,L,M[L],null,oe,pe);for(const L in Y){if(ri(L))continue;const W=Y[L],G=M[L];W!==G&&L!=="value"&&o(U,L,G,W,oe,pe)}"value"in Y&&o(U,"value",M.value,Y.value,oe)}},z=(U,M,Y,pe,oe,L,W,G,j)=>{const Ee=M.el=U?U.el:r(""),ge=M.anchor=U?U.anchor:r("");let{patchFlag:V,dynamicChildren:ie,slotScopeIds:Pe}=M;Pe&&(G=G?G.concat(Pe):Pe),U==null?(a(Ee,Y,pe),a(ge,Y,pe),k(M.children||[],Y,ge,oe,L,W,G,j)):V>0&&V&64&&ie&&U.dynamicChildren?($(U.dynamicChildren,ie,Y,oe,L,W,G),(M.key!=null||oe&&M===oe.subTree)&&FE(U,M,!0)):De(U,M,Y,ge,oe,L,W,G,j)},Z=(U,M,Y,pe,oe,L,W,G,j)=>{M.slotScopeIds=G,U==null?M.shapeFlag&512?oe.ctx.activate(M,Y,pe,W,j):Ae(M,Y,pe,oe,L,W,j):J(U,M,j)},Ae=(U,M,Y,pe,oe,L,W)=>{const G=U.component=FR(U,pe,oe);if(il(U)&&(G.ctx.renderer=Ce),zR(G,!1,W),G.asyncDep){if(oe&&oe.registerDep(G,ce,W),!U.el){const j=G.subTree=w(hn);S(null,j,M,Y)}}else ce(G,U,M,Y,oe,L,W)},J=(U,M,Y)=>{const pe=M.component=U.component;if(LR(U,M,Y))if(pe.asyncDep&&!pe.asyncResolved){Te(pe,M,Y);return}else pe.next=M,pe.update();else M.el=U.el,pe.vnode=M},ce=(U,M,Y,pe,oe,L,W)=>{const G=()=>{if(U.isMounted){let{next:V,bu:ie,u:Pe,parent:We,vnode:lt}=U;{const Un=mS(U);if(Un){V&&(V.el=lt.el,Te(U,V,W)),Un.asyncDep.then(()=>{U.isUnmounted||G()});return}}let ct=V,Vt;Is(U,!1),V?(V.el=lt.el,Te(U,V,W)):V=lt,ie&&zr(ie),(Vt=V.props&&V.props.onVnodeBeforeUpdate)&&Ta(Vt,We,V,lt),Is(U,!0);const Yt=jl(U),Gn=U.subTree;U.subTree=Yt,h(Gn,Yt,E(Gn.el),de(Gn),U,oe,L),V.el=Yt.el,ct===null&&yR(U,Yt.el),Pe&&_n(Pe,oe),(Vt=V.props&&V.props.onVnodeUpdated)&&_n(()=>Ta(Vt,We,V,lt),oe)}else{let V;const{el:ie,props:Pe}=M,{bm:We,m:lt,parent:ct,root:Vt,type:Yt}=U,Gn=mo(M);if(Is(U,!1),We&&zr(We),!Gn&&(V=Pe&&Pe.onVnodeBeforeMount)&&Ta(V,ct,M),Is(U,!0),ie&&Ie){const Un=()=>{U.subTree=jl(U),Ie(ie,U.subTree,U,oe,null)};Gn&&Yt.__asyncHydrate?Yt.__asyncHydrate(ie,U,Un):Un()}else{Vt.ce&&Vt.ce._injectChildStyle(Yt);const Un=U.subTree=jl(U);h(null,Un,Y,pe,U,oe,L),M.el=Un.el}if(lt&&_n(lt,oe),!Gn&&(V=Pe&&Pe.onVnodeMounted)){const Un=M;_n(()=>Ta(V,ct,Un),oe)}(M.shapeFlag&256||ct&&mo(ct.vnode)&&ct.vnode.shapeFlag&256)&&U.a&&_n(U.a,oe),U.isMounted=!0,M=Y=pe=null}};U.scope.on();const j=U.effect=new A0(G);U.scope.off();const Ee=U.update=j.run.bind(j),ge=U.job=j.runIfDirty.bind(j);ge.i=U,ge.id=U.uid,j.scheduler=()=>wE(ge),Is(U,!0),Ee()},Te=(U,M,Y)=>{M.component=U;const pe=U.vnode.props;U.vnode=M,U.next=null,TR(U,M.props,pe,Y),SR(U,M.children,Y),ms(),pm(U),Ts()},De=(U,M,Y,pe,oe,L,W,G,j=!1)=>{const Ee=U&&U.children,ge=U?U.shapeFlag:0,V=M.children,{patchFlag:ie,shapeFlag:Pe}=M;if(ie>0){if(ie&128){xe(Ee,V,Y,pe,oe,L,W,G,j);return}else if(ie&256){Ve(Ee,V,Y,pe,oe,L,W,G,j);return}}Pe&8?(ge&16&&It(Ee,oe,L),V!==Ee&&d(Y,V)):ge&16?Pe&16?xe(Ee,V,Y,pe,oe,L,W,G,j):It(Ee,oe,L,!0):(ge&8&&d(Y,""),Pe&16&&k(V,Y,pe,oe,L,W,G,j))},Ve=(U,M,Y,pe,oe,L,W,G,j)=>{U=U||lo,M=M||lo;const Ee=U.length,ge=M.length,V=Math.min(Ee,ge);let ie;for(ie=0;iege?It(U,oe,L,!0,!1,V):k(M,Y,pe,oe,L,W,G,j,V)},xe=(U,M,Y,pe,oe,L,W,G,j)=>{let Ee=0;const ge=M.length;let V=U.length-1,ie=ge-1;for(;Ee<=V&&Ee<=ie;){const Pe=U[Ee],We=M[Ee]=j?ns(M[Ee]):Ra(M[Ee]);if(Ls(Pe,We))h(Pe,We,Y,null,oe,L,W,G,j);else break;Ee++}for(;Ee<=V&&Ee<=ie;){const Pe=U[V],We=M[ie]=j?ns(M[ie]):Ra(M[ie]);if(Ls(Pe,We))h(Pe,We,Y,null,oe,L,W,G,j);else break;V--,ie--}if(Ee>V){if(Ee<=ie){const Pe=ie+1,We=Peie)for(;Ee<=V;)re(U[Ee],oe,L,!0),Ee++;else{const Pe=Ee,We=Ee,lt=new Map;for(Ee=We;Ee<=ie;Ee++){const kn=M[Ee]=j?ns(M[Ee]):Ra(M[Ee]);kn.key!=null&<.set(kn.key,Ee)}let ct,Vt=0;const Yt=ie-We+1;let Gn=!1,Un=0;const Bo=new Array(Yt);for(Ee=0;Ee=Yt){re(kn,oe,L,!0);continue}let ma;if(kn.key!=null)ma=lt.get(kn.key);else for(ct=We;ct<=ie;ct++)if(Bo[ct-We]===0&&Ls(kn,M[ct])){ma=ct;break}ma===void 0?re(kn,oe,L,!0):(Bo[ma-We]=Ee+1,ma>=Un?Un=ma:Gn=!0,h(kn,M[ma],Y,null,oe,L,W,G,j),Vt++)}const rm=Gn?gR(Bo):lo;for(ct=rm.length-1,Ee=Yt-1;Ee>=0;Ee--){const kn=We+Ee,ma=M[kn],um=kn+1{const{el:L,type:W,transition:G,children:j,shapeFlag:Ee}=U;if(Ee&6){ot(U.component.subTree,M,Y,pe);return}if(Ee&128){U.suspense.move(M,Y,pe);return}if(Ee&64){W.move(U,M,Y,Ce);return}if(W===le){a(L,M,Y);for(let V=0;VG.enter(L),oe);else{const{leave:V,delayLeave:ie,afterLeave:Pe}=G,We=()=>a(L,M,Y),lt=()=>{V(L,()=>{We(),Pe&&Pe()})};ie?ie(L,We,lt):lt()}else a(L,M,Y)},re=(U,M,Y,pe=!1,oe=!1)=>{const{type:L,props:W,ref:G,children:j,dynamicChildren:Ee,shapeFlag:ge,patchFlag:V,dirs:ie,cacheIndex:Pe}=U;if(V===-2&&(oe=!1),G!=null&&su(G,null,Y,U,!0),Pe!=null&&(M.renderCache[Pe]=void 0),ge&256){M.ctx.deactivate(U);return}const We=ge&1&&ie,lt=!mo(U);let ct;if(lt&&(ct=W&&W.onVnodeBeforeUnmount)&&Ta(ct,M,U),ge&6)wt(U.component,Y,pe);else{if(ge&128){U.suspense.unmount(Y,pe);return}We&&Os(U,null,M,"beforeUnmount"),ge&64?U.type.remove(U,M,Y,Ce,pe):Ee&&!Ee.hasOnce&&(L!==le||V>0&&V&64)?It(Ee,M,Y,!1,!0):(L===le&&V&384||!oe&&ge&16)&&It(j,M,Y),pe&&Oe(U)}(lt&&(ct=W&&W.onVnodeUnmounted)||We)&&_n(()=>{ct&&Ta(ct,M,U),We&&Os(U,null,M,"unmounted")},Y)},Oe=U=>{const{type:M,el:Y,anchor:pe,transition:oe}=U;if(M===le){pt(Y,pe);return}if(M===Br){I(U);return}const L=()=>{s(Y),oe&&!oe.persisted&&oe.afterLeave&&oe.afterLeave()};if(U.shapeFlag&1&&oe&&!oe.persisted){const{leave:W,delayLeave:G}=oe,j=()=>W(Y,L);G?G(U.el,L,j):j()}else L()},pt=(U,M)=>{let Y;for(;U!==M;)Y=c(U),s(U),U=Y;s(M)},wt=(U,M,Y)=>{const{bum:pe,scope:oe,job:L,subTree:W,um:G,m:j,a:Ee}=U;Rm(j),Rm(Ee),pe&&zr(pe),oe.stop(),L&&(L.flags|=8,re(W,U,M,Y)),G&&_n(G,M),_n(()=>{U.isUnmounted=!0},M),M&&M.pendingBranch&&!M.isUnmounted&&U.asyncDep&&!U.asyncResolved&&U.suspenseId===M.pendingId&&(M.deps--,M.deps===0&&M.resolve())},It=(U,M,Y,pe=!1,oe=!1,L=0)=>{for(let W=L;W{if(U.shapeFlag&6)return de(U.component.subTree);if(U.shapeFlag&128)return U.suspense.next();const M=c(U.anchor||U.el),Y=M&&M[G0];return Y?c(Y):M};let H=!1;const fe=(U,M,Y)=>{U==null?M._vnode&&re(M._vnode,null,null,!0):h(M._vnode||null,U,M,null,null,null,Y),M._vnode=U,H||(H=!0,pm(),z0(),H=!1)},Ce={p:h,um:re,m:ot,r:Oe,mt:Ae,mc:k,pc:De,pbc:$,n:de,o:e};let ae,Ie;return{render:fe,hydrate:ae,createApp:pR(fe,ae)}}function ql({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function Is({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function IR(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function FE(e,t,n=!1){const a=e.children,s=t.children;if(ze(a)&&ze(s))for(let o=0;o>1,e[n[r]]0&&(t[a]=n[o-1]),n[o]=a)}}for(o=n.length,i=n[o-1];o-- >0;)n[o]=i,i=t[i];return n}function mS(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:mS(t)}function Rm(e){if(e)for(let t=0;tUt(RR);function Le(e,t,n){return TS(e,t,n)}function TS(e,t,n=vt){const{immediate:a,deep:s,flush:o,once:i}=n,r=Ht({},n),u=t&&a||!t&&o!=="post";let l;if(yi){if(o==="sync"){const m=NR();l=m.__watcherHandles||(m.__watcherHandles=[])}else if(!u){const m=()=>{};return m.stop=Na,m.resume=Na,m.pause=Na,m}}const d=en;r.call=(m,_,h)=>ua(m,d,_,h);let E=!1;o==="post"?r.scheduler=m=>{_n(m,d&&d.suspense)}:o!=="sync"&&(E=!0,r.scheduler=(m,_)=>{_?m():wE(m)}),r.augmentJob=m=>{t&&(m.flags|=4),E&&(m.flags|=2,d&&(m.id=d.uid,m.i=d))};const c=Fg(e,t,r);return yi&&(l?l.push(c):u&&c()),c}function vR(e,t,n){const a=this.proxy,s=zt(e)?e.includes(".")?_S(a,e):()=>a[e]:e.bind(a,a);let o;qe(t)?o=t:(o=t.handler,n=t);const i=Ji(this),r=TS(s,o.bind(a),n);return i(),r}function _S(e,t){const n=t.split(".");return()=>{let a=e;for(let s=0;st==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Jn(t)}Modifiers`]||e[`${ps(t)}Modifiers`];function CR(e,t,...n){if(e.isUnmounted)return;const a=e.vnode.props||vt;let s=n;const o=t.startsWith("update:"),i=o&&bR(a,t.slice(7));i&&(i.trim&&(s=n.map(d=>zt(d)?d.trim():d)),i.number&&(s=n.map(Jr)));let r,u=a[r=zl(t)]||a[r=zl(Jn(t))];!u&&o&&(u=a[r=zl(ps(t))]),u&&ua(u,e,6,s);const l=a[r+"Once"];if(l){if(!e.emitted)e.emitted={};else if(e.emitted[r])return;e.emitted[r]=!0,ua(l,e,6,s)}}function fS(e,t,n=!1){const a=t.emitsCache,s=a.get(e);if(s!==void 0)return s;const o=e.emits;let i={},r=!1;if(!qe(e)){const u=l=>{const d=fS(l,t,!0);d&&(r=!0,Ht(i,d))};!n&&t.mixins.length&&t.mixins.forEach(u),e.extends&&u(e.extends),e.mixins&&e.mixins.forEach(u)}return!o&&!r?(bt(e)&&a.set(e,null),null):(ze(o)?o.forEach(u=>i[u]=null):Ht(i,o),bt(e)&&a.set(e,i),i)}function ul(e,t){return!e||!el(t)?!1:(t=t.slice(2).replace(/Once$/,""),ht(e,t[0].toLowerCase()+t.slice(1))||ht(e,ps(t))||ht(e,t))}function jl(e){const{type:t,vnode:n,proxy:a,withProxy:s,propsOptions:[o],slots:i,attrs:r,emit:u,render:l,renderCache:d,props:E,data:c,setupState:m,ctx:_,inheritAttrs:h}=e,O=au(e);let S,R;try{if(n.shapeFlag&4){const I=s||a,N=I;S=Ra(l.call(N,I,d,E,m,c,_)),R=r}else{const I=t;S=Ra(I.length>1?I(E,{attrs:r,slots:i,emit:u}):I(E,null)),R=t.props?r:PR(r)}}catch(I){Ei.length=0,ol(I,e,1),S=w(hn)}let g=S;if(R&&h!==!1){const I=Object.keys(R),{shapeFlag:N}=g;I.length&&N&7&&(o&&I.some(vE)&&(R=DR(R,o)),g=cs(g,R,!1,!0))}return n.dirs&&(g=cs(g,null,!1,!0),g.dirs=g.dirs?g.dirs.concat(n.dirs):n.dirs),n.transition&&Pi(g,n.transition),S=g,au(O),S}const PR=e=>{let t;for(const n in e)(n==="class"||n==="style"||el(n))&&((t||(t={}))[n]=e[n]);return t},DR=(e,t)=>{const n={};for(const a in e)(!vE(a)||!(a.slice(9)in t))&&(n[a]=e[a]);return n};function LR(e,t,n){const{props:a,children:s,component:o}=e,{props:i,children:r,patchFlag:u}=t,l=o.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&u>=0){if(u&1024)return!0;if(u&16)return a?Nm(a,i,l):!!i;if(u&8){const d=t.dynamicProps;for(let E=0;Ee.__isSuspense;function $R(e,t){t&&t.pendingBranch?ze(e)?t.effects.push(...e):t.effects.push(e):Bg(e)}const le=Symbol.for("v-fgt"),Zi=Symbol.for("v-txt"),hn=Symbol.for("v-cmt"),Br=Symbol.for("v-stc"),Ei=[];let xn=null;function f(e=!1){Ei.push(xn=e?null:[])}function UR(){Ei.pop(),xn=Ei[Ei.length-1]||null}let Di=1;function vm(e,t=!1){Di+=e,e<0&&xn&&t&&(xn.hasOnce=!0)}function SS(e){return e.dynamicChildren=Di>0?xn||lo:null,UR(),Di>0&&xn&&xn.push(e),e}function v(e,t,n,a,s,o){return SS(p(e,t,n,a,s,o,!0))}function B(e,t,n,a,s){return SS(w(e,t,n,a,s,!0))}function Li(e){return e?e.__v_isVNode===!0:!1}function Ls(e,t){return e.type===t.type&&e.key===t.key}const AS=({key:e})=>e??null,Gr=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?zt(e)||qt(e)||qe(e)?{i:Qt,r:e,k:t,f:!!n}:e:null);function p(e,t=null,n=null,a=0,s=null,o=e===le?0:1,i=!1,r=!1){const u={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&AS(t),ref:t&&Gr(t),scopeId:B0,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:o,patchFlag:a,dynamicProps:s,dynamicChildren:null,appContext:null,ctx:Qt};return r?(zE(u,n),o&128&&e.normalize(u)):n&&(u.shapeFlag|=zt(n)?8:16),Di>0&&!i&&xn&&(u.patchFlag>0||o&6)&&u.patchFlag!==32&&xn.push(u),u}const w=kR;function kR(e,t=null,n=null,a=0,s=null,o=!1){if((!e||e===sR)&&(e=hn),Li(e)){const r=cs(e,t,!0);return n&&zE(r,n),Di>0&&!o&&xn&&(r.shapeFlag&6?xn[xn.indexOf(e)]=r:xn.push(r)),r.patchFlag=-2,r}if(HR(e)&&(e=e.__vccOpts),t){t=wR(t);let{class:r,style:u}=t;r&&!zt(r)&&(t.class=he(r)),bt(u)&&(Yi(u)&&!ze(u)&&(u=Ht({},u)),t.style=Va(u))}const i=zt(e)?1:hS(e)?128:V0(e)?64:bt(e)?4:qe(e)?2:0;return p(e,t,n,a,s,i,o,!0)}function wR(e){return e?Yi(e)||rS(e)?Ht({},e):e:null}function cs(e,t,n=!1,a=!1){const{props:s,ref:o,patchFlag:i,children:r,transition:u}=e,l=t?OS(s||{},t):s,d={__v_isVNode:!0,__v_skip:!0,type:e.type,props:l,key:l&&AS(l),ref:t&&t.ref?n&&o?ze(o)?o.concat(Gr(t)):[o,Gr(t)]:Gr(t):o,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:r,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==le?i===-1?16:i|16:i,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:u,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&cs(e.ssContent),ssFallback:e.ssFallback&&cs(e.ssFallback),el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return u&&a&&Pi(d,u.clone(d)),d}function x(e=" ",t=0){return w(Zi,null,e,t)}function Dn(e,t){const n=w(Br,null,e);return n.staticCount=t,n}function D(e="",t=!1){return t?(f(),B(hn,null,e)):w(hn,null,e)}function Ra(e){return e==null||typeof e=="boolean"?w(hn):ze(e)?w(le,null,e.slice()):Li(e)?ns(e):w(Zi,null,String(e))}function ns(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:cs(e)}function zE(e,t){let n=0;const{shapeFlag:a}=e;if(t==null)t=null;else if(ze(t))n=16;else if(typeof t=="object")if(a&65){const s=t.default;s&&(s._c&&(s._d=!1),zE(e,s()),s._c&&(s._d=!0));return}else{n=32;const s=t._;!s&&!rS(t)?t._ctx=Qt:s===3&&Qt&&(Qt.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else qe(t)?(t={default:t,_ctx:Qt},n=32):(t=String(t),a&64?(n=16,t=[x(t)]):n=8);e.children=t,e.shapeFlag|=n}function OS(...e){const t={};for(let n=0;nen||Qt;let iu,vd;{const e=nl(),t=(n,a)=>{let s;return(s=e[n])||(s=e[n]=[]),s.push(a),o=>{s.length>1?s.forEach(i=>i(o)):s[0](o)}};iu=t("__VUE_INSTANCE_SETTERS__",n=>en=n),vd=t("__VUE_SSR_SETTERS__",n=>yi=n)}const Ji=e=>{const t=en;return iu(e),e.scope.on(),()=>{e.scope.off(),iu(t)}},bm=()=>{en&&en.scope.off(),iu(null)};function IS(e){return e.vnode.shapeFlag&4}let yi=!1;function zR(e,t=!1,n=!1){t&&vd(t);const{props:a,children:s}=e.vnode,o=IS(e);mR(e,a,o,t),hR(e,s,n);const i=o?xR(e,t):void 0;return t&&vd(!1),i}function xR(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,iR);const{setup:a}=n;if(a){ms();const s=e.setupContext=a.length>1?GR(e):null,o=Ji(e),i=Xi(a,e,0,[e.props,s]),r=d0(i);if(Ts(),o(),(r||e.sp)&&!mo(e)&&Z0(e),r){if(i.then(bm,bm),t)return i.then(u=>{Cm(e,u,t)}).catch(u=>{ol(u,e,0)});e.asyncDep=i}else Cm(e,i,t)}else gS(e,t)}function Cm(e,t,n){qe(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:bt(t)&&(e.setupState=M0(t)),gS(e,n)}let Pm;function gS(e,t,n){const a=e.type;if(!e.render){if(!t&&Pm&&!a.render){const s=a.template||ME(e).template;if(s){const{isCustomElement:o,compilerOptions:i}=e.appContext.config,{delimiters:r,compilerOptions:u}=a,l=Ht(Ht({isCustomElement:o,delimiters:r},i),u);a.render=Pm(s,l)}}e.render=a.render||Na}{const s=Ji(e);ms();try{rR(e)}finally{Ts(),s()}}}const BR={get(e,t){return sn(e,"get",""),e[t]}};function GR(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,BR),slots:e.slots,emit:e.emit,expose:t}}function ll(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(M0(Ha(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in di)return di[n](e)},has(t,n){return n in t||n in di}})):e.proxy}function VR(e,t=!0){return qe(e)?e.displayName||e.name:e.name||t&&e.__name}function HR(e){return qe(e)&&"__vccOpts"in e}const F=(e,t)=>Mg(e,t,yi);function Cn(e,t,n){const a=arguments.length;return a===2?bt(t)&&!ze(t)?Li(t)?w(e,null,[t]):w(e,t):w(e,null,t):(a>3?n=Array.prototype.slice.call(arguments,2):a===3&&Li(n)&&(n=[n]),w(e,t,n))}const RS="3.5.13";/** +* @vue/runtime-dom v3.5.13 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let bd;const Dm=typeof window<"u"&&window.trustedTypes;if(Dm)try{bd=Dm.createPolicy("vue",{createHTML:e=>e})}catch{}const NS=bd?e=>bd.createHTML(e):e=>e,KR="http://www.w3.org/2000/svg",qR="http://www.w3.org/1998/Math/MathML",wa=typeof document<"u"?document:null,Lm=wa&&wa.createElement("template"),jR={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,a)=>{const s=t==="svg"?wa.createElementNS(KR,e):t==="mathml"?wa.createElementNS(qR,e):n?wa.createElement(e,{is:n}):wa.createElement(e);return e==="select"&&a&&a.multiple!=null&&s.setAttribute("multiple",a.multiple),s},createText:e=>wa.createTextNode(e),createComment:e=>wa.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>wa.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,a,s,o){const i=n?n.previousSibling:t.lastChild;if(s&&(s===o||s.nextSibling))for(;t.insertBefore(s.cloneNode(!0),n),!(s===o||!(s=s.nextSibling)););else{Lm.innerHTML=NS(a==="svg"?`${e}`:a==="mathml"?`${e}`:e);const r=Lm.content;if(a==="svg"||a==="mathml"){const u=r.firstChild;for(;u.firstChild;)r.appendChild(u.firstChild);r.removeChild(u)}t.insertBefore(r,n)}return[i?i.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Ya="transition",Vo="animation",$i=Symbol("_vtc"),vS={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},YR=Ht({},q0,vS),XR=e=>(e.displayName="Transition",e.props=YR,e),ym=XR((e,{slots:t})=>Cn(qg,QR(e),t)),gs=(e,t=[])=>{ze(e)?e.forEach(n=>n(...t)):e&&e(...t)},$m=e=>e?ze(e)?e.some(t=>t.length>1):e.length>1:!1;function QR(e){const t={};for(const z in e)z in vS||(t[z]=e[z]);if(e.css===!1)return t;const{name:n="v",type:a,duration:s,enterFromClass:o=`${n}-enter-from`,enterActiveClass:i=`${n}-enter-active`,enterToClass:r=`${n}-enter-to`,appearFromClass:u=o,appearActiveClass:l=i,appearToClass:d=r,leaveFromClass:E=`${n}-leave-from`,leaveActiveClass:c=`${n}-leave-active`,leaveToClass:m=`${n}-leave-to`}=e,_=ZR(s),h=_&&_[0],O=_&&_[1],{onBeforeEnter:S,onEnter:R,onEnterCancelled:g,onLeave:I,onLeaveCancelled:N,onBeforeAppear:b=S,onAppear:C=R,onAppearCancelled:k=g}=t,P=(z,Z,Ae,J)=>{z._enterCancelled=J,Rs(z,Z?d:r),Rs(z,Z?l:i),Ae&&Ae()},$=(z,Z)=>{z._isLeaving=!1,Rs(z,E),Rs(z,m),Rs(z,c),Z&&Z()},y=z=>(Z,Ae)=>{const J=z?C:R,ce=()=>P(Z,z,Ae);gs(J,[Z,ce]),Um(()=>{Rs(Z,z?u:o),Ca(Z,z?d:r),$m(J)||km(Z,a,h,ce)})};return Ht(t,{onBeforeEnter(z){gs(S,[z]),Ca(z,o),Ca(z,i)},onBeforeAppear(z){gs(b,[z]),Ca(z,u),Ca(z,l)},onEnter:y(!1),onAppear:y(!0),onLeave(z,Z){z._isLeaving=!0;const Ae=()=>$(z,Z);Ca(z,E),z._enterCancelled?(Ca(z,c),Wm()):(Wm(),Ca(z,c)),Um(()=>{z._isLeaving&&(Rs(z,E),Ca(z,m),$m(I)||km(z,a,O,Ae))}),gs(I,[z,Ae])},onEnterCancelled(z){P(z,!1,void 0,!0),gs(g,[z])},onAppearCancelled(z){P(z,!0,void 0,!0),gs(k,[z])},onLeaveCancelled(z){$(z),gs(N,[z])}})}function ZR(e){if(e==null)return null;if(bt(e))return[Yl(e.enter),Yl(e.leave)];{const t=Yl(e);return[t,t]}}function Yl(e){return sg(e)}function Ca(e,t){t.split(/\s+/).forEach(n=>n&&e.classList.add(n)),(e[$i]||(e[$i]=new Set)).add(t)}function Rs(e,t){t.split(/\s+/).forEach(a=>a&&e.classList.remove(a));const n=e[$i];n&&(n.delete(t),n.size||(e[$i]=void 0))}function Um(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let JR=0;function km(e,t,n,a){const s=e._endId=++JR,o=()=>{s===e._endId&&a()};if(n!=null)return setTimeout(o,n);const{type:i,timeout:r,propCount:u}=eN(e,t);if(!i)return a();const l=i+"end";let d=0;const E=()=>{e.removeEventListener(l,c),o()},c=m=>{m.target===e&&++d>=u&&E()};setTimeout(()=>{d(n[_]||"").split(", "),s=a(`${Ya}Delay`),o=a(`${Ya}Duration`),i=wm(s,o),r=a(`${Vo}Delay`),u=a(`${Vo}Duration`),l=wm(r,u);let d=null,E=0,c=0;t===Ya?i>0&&(d=Ya,E=i,c=o.length):t===Vo?l>0&&(d=Vo,E=l,c=u.length):(E=Math.max(i,l),d=E>0?i>l?Ya:Vo:null,c=d?d===Ya?o.length:u.length:0);const m=d===Ya&&/\b(transform|all)(,|$)/.test(a(`${Ya}Property`).toString());return{type:d,timeout:E,propCount:c,hasTransform:m}}function wm(e,t){for(;e.lengthMm(n)+Mm(e[a])))}function Mm(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function Wm(){return document.body.offsetHeight}function tN(e,t,n){const a=e[$i];a&&(t=(t?[t,...a]:[...a]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const ru=Symbol("_vod"),bS=Symbol("_vsh"),Ho={beforeMount(e,{value:t},{transition:n}){e[ru]=e.style.display==="none"?"":e.style.display,n&&t?n.beforeEnter(e):Ko(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:a}){!t!=!n&&(a?t?(a.beforeEnter(e),Ko(e,!0),a.enter(e)):a.leave(e,()=>{Ko(e,!1)}):Ko(e,t))},beforeUnmount(e,{value:t}){Ko(e,t)}};function Ko(e,t){e.style.display=t?e[ru]:"none",e[bS]=!t}const nN=Symbol(""),aN=/(^|;)\s*display\s*:/;function sN(e,t,n){const a=e.style,s=zt(n);let o=!1;if(n&&!s){if(t)if(zt(t))for(const i of t.split(";")){const r=i.slice(0,i.indexOf(":")).trim();n[r]==null&&Vr(a,r,"")}else for(const i in t)n[i]==null&&Vr(a,i,"");for(const i in n)i==="display"&&(o=!0),Vr(a,i,n[i])}else if(s){if(t!==n){const i=a[nN];i&&(n+=";"+i),a.cssText=n,o=aN.test(n)}}else t&&e.removeAttribute("style");ru in e&&(e[ru]=o?a.display:"",e[bS]&&(a.display="none"))}const Fm=/\s*!important$/;function Vr(e,t,n){if(ze(n))n.forEach(a=>Vr(e,t,a));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const a=oN(e,t);Fm.test(n)?e.setProperty(ps(a),n.replace(Fm,""),"important"):e[a]=n}}const zm=["Webkit","Moz","ms"],Xl={};function oN(e,t){const n=Xl[t];if(n)return n;let a=Jn(t);if(a!=="filter"&&a in e)return Xl[t]=a;a=Fe(a);for(let s=0;sQl||(lN.then(()=>Ql=0),Ql=Date.now());function dN(e,t){const n=a=>{if(!a._vts)a._vts=Date.now();else if(a._vts<=n.attached)return;ua(EN(a,n.value),t,5,[a])};return n.value=e,n.attached=cN(),n}function EN(e,t){if(ze(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(a=>s=>!s._stopped&&a&&a(s))}else return t}const Km=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,pN=(e,t,n,a,s,o)=>{const i=s==="svg";t==="class"?tN(e,a,i):t==="style"?sN(e,n,a):el(t)?vE(t)||rN(e,t,n,a,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):mN(e,t,a,i))?(Gm(e,t,a),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Bm(e,t,a,i,o,t!=="value")):e._isVueCE&&(/[A-Z]/.test(t)||!zt(a))?Gm(e,Jn(t),a,o,t):(t==="true-value"?e._trueValue=a:t==="false-value"&&(e._falseValue=a),Bm(e,t,a,i))};function mN(e,t,n,a){if(a)return!!(t==="innerHTML"||t==="textContent"||t in e&&Km(t)&&qe(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const s=e.tagName;if(s==="IMG"||s==="VIDEO"||s==="CANVAS"||s==="SOURCE")return!1}return Km(t)&&zt(n)?!1:t in e}const ds=e=>{const t=e.props["onUpdate:modelValue"]||!1;return ze(t)?n=>zr(t,n):t};function TN(e){e.target.composing=!0}function qm(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Qn=Symbol("_assign"),st={created(e,{modifiers:{lazy:t,trim:n,number:a}},s){e[Qn]=ds(s);const o=a||s.props&&s.props.type==="number";za(e,t?"change":"input",i=>{if(i.target.composing)return;let r=e.value;n&&(r=r.trim()),o&&(r=Jr(r)),e[Qn](r)}),n&&za(e,"change",()=>{e.value=e.value.trim()}),t||(za(e,"compositionstart",TN),za(e,"compositionend",qm),za(e,"change",qm))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:a,trim:s,number:o}},i){if(e[Qn]=ds(i),e.composing)return;const r=(o||e.type==="number")&&!/^0\d/.test(e.value)?Jr(e.value):e.value,u=t??"";r!==u&&(document.activeElement===e&&e.type!=="range"&&(a&&t===n||s&&e.value.trim()===u)||(e.value=u))}},cl={deep:!0,created(e,t,n){e[Qn]=ds(n),za(e,"change",()=>{const a=e._modelValue,s=Oo(e),o=e.checked,i=e[Qn];if(ze(a)){const r=PE(a,s),u=r!==-1;if(o&&!u)i(a.concat(s));else if(!o&&u){const l=[...a];l.splice(r,1),i(l)}}else if($o(a)){const r=new Set(a);o?r.add(s):r.delete(s),i(r)}else i(CS(e,o))})},mounted:jm,beforeUpdate(e,t,n){e[Qn]=ds(n),jm(e,t,n)}};function jm(e,{value:t,oldValue:n},a){e._modelValue=t;let s;if(ze(t))s=PE(t,a.props.value)>-1;else if($o(t))s=t.has(a.props.value);else{if(t===n)return;s=Ws(t,CS(e,!0))}e.checked!==s&&(e.checked=s)}const _N={created(e,{value:t},n){e.checked=Ws(t,n.props.value),e[Qn]=ds(n),za(e,"change",()=>{e[Qn](Oo(e))})},beforeUpdate(e,{value:t,oldValue:n},a){e[Qn]=ds(a),t!==n&&(e.checked=Ws(t,a.props.value))}},Sn={deep:!0,created(e,{value:t,modifiers:{number:n}},a){const s=$o(t);za(e,"change",()=>{const o=Array.prototype.filter.call(e.options,i=>i.selected).map(i=>n?Jr(Oo(i)):Oo(i));e[Qn](e.multiple?s?new Set(o):o:o[0]),e._assigning=!0,un(()=>{e._assigning=!1})}),e[Qn]=ds(a)},mounted(e,{value:t}){Ym(e,t)},beforeUpdate(e,t,n){e[Qn]=ds(n)},updated(e,{value:t}){e._assigning||Ym(e,t)}};function Ym(e,t){const n=e.multiple,a=ze(t);if(!(n&&!a&&!$o(t))){for(let s=0,o=e.options.length;sString(l)===String(r)):i.selected=PE(t,r)>-1}else i.selected=t.has(r);else if(Ws(Oo(i),t)){e.selectedIndex!==s&&(e.selectedIndex=s);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Oo(e){return"_value"in e?e._value:e.value}function CS(e,t){const n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}const fN={created(e,t,n){_r(e,t,n,null,"created")},mounted(e,t,n){_r(e,t,n,null,"mounted")},beforeUpdate(e,t,n,a){_r(e,t,n,a,"beforeUpdate")},updated(e,t,n,a){_r(e,t,n,a,"updated")}};function hN(e,t){switch(e){case"SELECT":return Sn;case"TEXTAREA":return st;default:switch(t){case"checkbox":return cl;case"radio":return _N;default:return st}}}function _r(e,t,n,a,s){const i=hN(e.tagName,n.props&&n.props.type)[s];i&&i(e,t,n,a)}const SN=["ctrl","shift","alt","meta"],AN={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>SN.some(n=>e[`${n}Key`]&&!t.includes(n))},Ne=(e,t)=>{const n=e._withMods||(e._withMods={}),a=t.join(".");return n[a]||(n[a]=(s,...o)=>{for(let i=0;i{const n=e._withKeys||(e._withKeys={}),a=t.join(".");return n[a]||(n[a]=s=>{if(!("key"in s))return;const o=ps(s.key);if(t.some(i=>i===o||ON[i]===o))return e(s)})},IN=Ht({patchProp:pN},jR);let Xm;function PS(){return Xm||(Xm=AR(IN))}const gN=(...e)=>{PS().render(...e)},RN=(...e)=>{const t=PS().createApp(...e),{mount:n}=t;return t.mount=a=>{const s=vN(a);if(!s)return;const o=t._component;!qe(o)&&!o.render&&!o.template&&(o.template=s.innerHTML),s.nodeType===1&&(s.textContent="");const i=n(s,!1,NN(s));return s instanceof Element&&(s.removeAttribute("v-cloak"),s.setAttribute("data-v-app","")),i},t};function NN(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function vN(e){return zt(e)?document.querySelector(e):e}var bN=Object.defineProperty,CN=Object.defineProperties,PN=Object.getOwnPropertyDescriptors,uu=Object.getOwnPropertySymbols,DS=Object.prototype.hasOwnProperty,LS=Object.prototype.propertyIsEnumerable,Qm=(e,t,n)=>t in e?bN(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,fr=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{},yS=function(e){return e},DN=function(e,t,n){switch(n.length){case 0:return e.call(t);case 1:return e.call(t,n[0]);case 2:return e.call(t,n[0],n[1]);case 3:return e.call(t,n[0],n[1],n[2])}return e.apply(t,n)},Zm=Math.max,LN=function(e,t,n){return t=Zm(t===void 0?e.length-1:t,0),function(){for(var a=arguments,s=-1,o=Zm(a.length-t,0),i=Array(o);++s0){if(++t>=800)return arguments[0]}else t=0;return e.apply(void 0,arguments)}}(iv),lv=yS,cv=LN,dv=uv,Ev=function(e,t){return dv(cv(e,t,lv),e+"")},wS=function(e,t){return e===t||e!=e&&t!=t},MS=function(e){return typeof e=="number"&&e>-1&&e%1==0&&e<=9007199254740991},pv=kS,mv=MS,WS=function(e){return e!=null&&mv(e.length)&&!pv(e)},Tv=/^(?:0|[1-9]\d*)$/,FS=function(e,t){var n=typeof e;return!!(t=t??9007199254740991)&&(n=="number"||n!="symbol"&&Tv.test(e))&&e>-1&&e%1==0&&e2?t[2]:void 0;for(s&&tb(t[0],t[1],s)&&(a=1);++ne.pageOnly||!Pa.isEnabled),o=F(()=>(s.value||e.teleport)&&a.isFullscreen?{position:"fixed",left:"0",top:"0",width:"100%",height:"100%"}:void 0);function i(){t("change",a.isFullscreen),t("update:modelValue",a.isFullscreen),t("update:fullscreen",a.isFullscreen)}function r(){Pa.isFullscreen||Pa.off("change",r),a.isFullscreen=Pa.isFullscreen,i()}function u(c){c.key==="Escape"&&d()}function l(){s.value?(a.isFullscreen=!0,i(),document.removeEventListener("keyup",u),document.addEventListener("keyup",u)):(Pa.off("change",r),Pa.on("change",r),Pa.request(e.teleport?document.body:n.value))}function d(){a.isFullscreen&&(s.value?(a.isFullscreen=!1,i(),document.removeEventListener("keyup",u)):Pa.exit())}return Le(()=>e.fullscreen,c=>{c!==a.isFullscreen&&(c?l():d())}),Le(()=>e.modelValue,c=>{c!==a.isFullscreen&&(c?l():d())}),E=((c,m)=>{for(var _ in m||(m={}))DS.call(m,_)&&Qm(c,_,m[_]);if(uu)for(var _ of uu(m))LS.call(m,_)&&Qm(c,_,m[_]);return c})({wrapper:n,wrapperStyle:o},_e(a)),CN(E,PN({toggle:function(c){c===void 0?a.isFullscreen?d():l():c?l():d()},request:l,exit:d,shadeClick:function(c){c.target===n.value&&e.exitOnClickWrapper&&d()}}));var E}});VS.render=function(e,t,n,a,s,o){return f(),B(Vg,{to:"body",disabled:!e.teleport||!e.fullscreen},[w("div",OS({ref:"wrapper"},e.$attrs,{style:e.wrapperStyle,class:{[e.fullscreenClass]:e.isFullscreen},onClick:t[1]||(t[1]=i=>e.shadeClick(i))}),[Pt(e.$slots,"default")],16)],8,["disabled"])};const ob=typeof window<"u"&&window!==null;(function(){if(ob&&"IntersectionObserver"in window&&"IntersectionObserverEntry"in window&&"intersectionRatio"in window.IntersectionObserverEntry.prototype)return"isIntersecting"in window.IntersectionObserverEntry.prototype||Object.defineProperty(window.IntersectionObserverEntry.prototype,"isIntersecting",{get(){return this.intersectionRatio>0}}),!0})();const ib=Object.prototype.propertyIsEnumerable,cT=Object.getOwnPropertySymbols;function Hr(e){return typeof e=="function"||toString.call(e)==="[object Object]"}function rb(e){return e!=="__proto__"&&e!=="constructor"&&e!=="prototype"}function ub(e,...t){if(!Hr(e))throw new TypeError("expected the first argument to be an object");if(t.length===0||typeof Symbol!="function"||typeof cT!="function")return e;for(const n of t){const a=cT(n);for(const s of a)ib.call(n,s)&&(e[s]=n[s])}return e}function VE(e,...t){let n=0;var a;for((typeof(a=e)=="object"?a===null:typeof a!="function")&&(e=t[n++]),e||(e={});n{a.key==="Escape"&&(document.removeEventListener("keyup",n),this.exit())};return this.isFullscreen=!0,this.element=e,document.removeEventListener("keyup",n),document.addEventListener("keyup",n),this.options.callback&&this.options.callback(this.isFullscreen),Promise.resolve()}{const n=()=>{_a.isFullscreen||(_a.off("change",n),ET(this)),this.isFullscreen=_a.isFullscreen,this.options.teleport?this.element=e||null:this.element=_a.element,this.options.callback&&this.options.callback(_a.isFullscreen)};return _a.on("change",n),_a.request(this.options.teleport?document.body:e)}},exit(){return this.isFullscreen?this.options.pageOnly?(ET(this),this.isFullscreen=!1,this.element=null,this.options.callback&&this.options.callback(this.isFullscreen),Promise.resolve()):_a.exit():Promise.resolve()}},lb=(e,t,n)=>{const a=()=>{let s;const o={teleport:t.modifiers.teleport,pageOnly:t.modifiers.pageOnly};if(t.value)if(typeof t.value=="string")s=t.value;else{const i=t.value,{target:r}=i,u=((l,d)=>{var E={};for(var c in l)DS.call(l,c)&&d.indexOf(c)<0&&(E[c]=l[c]);if(l!=null&&uu)for(var c of uu(l))d.indexOf(c)<0&&LS.call(l,c)&&(E[c]=l[c]);return E})(i,["target"]);s=r,VE(o,u)}typeof s=="string"&&(s=document.querySelector(s)),KS.toggle(s,o)};e._onClickFullScreen&&e.removeEventListener("click",e._onClickFullScreen),e.addEventListener("click",a),e._onClickFullScreen=a};var cb={install(e,{name:t="fullscreen"}={}){e.config.globalProperties[`$${t}`]=KS,e.component(t,sb(VS,{name:t})),e.directive(t,lb)}};const db={id:"footer"},Eb={class:"footer-items"},pb={class:"footer-item"},mb={class:"footer-item"},Tb={key:0,class:"footer-item bullet"},_b={key:1,class:"footer-item"},fb=["href"],hb={class:"footer-item"},Sb=Q({__name:"Footer",props:{version:{},adminContact:{}},setup(e){const t=e,{adminContact:n,version:a}=_e(t);return(s,o)=>{const i=q("router-link");return f(),v("footer",db,[p("div",Eb,[p("div",pb,[o[0]||(o[0]=p("strong",null,"FitTrackee",-1)),x(" v"+A(T(a)),1)]),o[1]||(o[1]=p("div",{class:"footer-item bullet"},"•",-1)),p("div",mb,[w(i,{to:"/about"},{default:X(()=>[x(A(s.$t("common.ABOUT")),1)]),_:1})]),T(n)?(f(),v("div",Tb,"•")):D("",!0),T(n)?(f(),v("div",_b,[p("a",{href:`mailto:${T(n)}`},A(s.$t("common.CONTACT")),9,fb)])):D("",!0),o[2]||(o[2]=p("div",{class:"footer-item bullet"},"•",-1)),p("div",hb,[w(i,{to:"/privacy-policy"},{default:X(()=>[x(A(s.$t("privacy_policy.TITLE")),1)]),_:1})])])])}}}),se=(e,t)=>{const n=e.__vccOpts||e;for(const[a,s]of t)n[a]=s;return n},Ab=se(Sb,[["__scopeId","data-v-e9aea8ab"]]),Ob="/img/weather/clear-day.svg";function qS(e,t){return function(){return e.apply(t,arguments)}}const{toString:Ib}=Object.prototype,{getPrototypeOf:HE}=Object,El=(e=>t=>{const n=Ib.call(t);return e[n]||(e[n]=n.slice(8,-1).toLowerCase())})(Object.create(null)),Ea=e=>(e=e.toLowerCase(),t=>El(t)===e),pl=e=>t=>typeof t===e,{isArray:Uo}=Array,Ui=pl("undefined");function gb(e){return e!==null&&!Ui(e)&&e.constructor!==null&&!Ui(e.constructor)&&Bn(e.constructor.isBuffer)&&e.constructor.isBuffer(e)}const jS=Ea("ArrayBuffer");function Rb(e){let t;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?t=ArrayBuffer.isView(e):t=e&&e.buffer&&jS(e.buffer),t}const Nb=pl("string"),Bn=pl("function"),YS=pl("number"),ml=e=>e!==null&&typeof e=="object",vb=e=>e===!0||e===!1,Kr=e=>{if(El(e)!=="object")return!1;const t=HE(e);return(t===null||t===Object.prototype||Object.getPrototypeOf(t)===null)&&!(Symbol.toStringTag in e)&&!(Symbol.iterator in e)},bb=Ea("Date"),Cb=Ea("File"),Pb=Ea("Blob"),Db=Ea("FileList"),Lb=e=>ml(e)&&Bn(e.pipe),yb=e=>{let t;return e&&(typeof FormData=="function"&&e instanceof FormData||Bn(e.append)&&((t=El(e))==="formdata"||t==="object"&&Bn(e.toString)&&e.toString()==="[object FormData]"))},$b=Ea("URLSearchParams"),[Ub,kb,wb,Mb]=["ReadableStream","Request","Response","Headers"].map(Ea),Wb=e=>e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function er(e,t,{allOwnKeys:n=!1}={}){if(e===null||typeof e>"u")return;let a,s;if(typeof e!="object"&&(e=[e]),Uo(e))for(a=0,s=e.length;a0;)if(s=n[a],t===s.toLowerCase())return s;return null}const ys=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,QS=e=>!Ui(e)&&e!==ys;function Ld(){const{caseless:e}=QS(this)&&this||{},t={},n=(a,s)=>{const o=e&&XS(t,s)||s;Kr(t[o])&&Kr(a)?t[o]=Ld(t[o],a):Kr(a)?t[o]=Ld({},a):Uo(a)?t[o]=a.slice():t[o]=a};for(let a=0,s=arguments.length;a(er(t,(s,o)=>{n&&Bn(s)?e[o]=qS(s,n):e[o]=s},{allOwnKeys:a}),e),zb=e=>(e.charCodeAt(0)===65279&&(e=e.slice(1)),e),xb=(e,t,n,a)=>{e.prototype=Object.create(t.prototype,a),e.prototype.constructor=e,Object.defineProperty(e,"super",{value:t.prototype}),n&&Object.assign(e.prototype,n)},Bb=(e,t,n,a)=>{let s,o,i;const r={};if(t=t||{},e==null)return t;do{for(s=Object.getOwnPropertyNames(e),o=s.length;o-- >0;)i=s[o],(!a||a(i,e,t))&&!r[i]&&(t[i]=e[i],r[i]=!0);e=n!==!1&&HE(e)}while(e&&(!n||n(e,t))&&e!==Object.prototype);return t},Gb=(e,t,n)=>{e=String(e),(n===void 0||n>e.length)&&(n=e.length),n-=t.length;const a=e.indexOf(t,n);return a!==-1&&a===n},Vb=e=>{if(!e)return null;if(Uo(e))return e;let t=e.length;if(!YS(t))return null;const n=new Array(t);for(;t-- >0;)n[t]=e[t];return n},Hb=(e=>t=>e&&t instanceof e)(typeof Uint8Array<"u"&&HE(Uint8Array)),Kb=(e,t)=>{const a=(e&&e[Symbol.iterator]).call(e);let s;for(;(s=a.next())&&!s.done;){const o=s.value;t.call(e,o[0],o[1])}},qb=(e,t)=>{let n;const a=[];for(;(n=e.exec(t))!==null;)a.push(n);return a},jb=Ea("HTMLFormElement"),Yb=e=>e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(n,a,s){return a.toUpperCase()+s}),pT=(({hasOwnProperty:e})=>(t,n)=>e.call(t,n))(Object.prototype),Xb=Ea("RegExp"),ZS=(e,t)=>{const n=Object.getOwnPropertyDescriptors(e),a={};er(n,(s,o)=>{let i;(i=t(s,o,e))!==!1&&(a[o]=i||s)}),Object.defineProperties(e,a)},Qb=e=>{ZS(e,(t,n)=>{if(Bn(e)&&["arguments","caller","callee"].indexOf(n)!==-1)return!1;const a=e[n];if(Bn(a)){if(t.enumerable=!1,"writable"in t){t.writable=!1;return}t.set||(t.set=()=>{throw Error("Can not rewrite read-only method '"+n+"'")})}})},Zb=(e,t)=>{const n={},a=s=>{s.forEach(o=>{n[o]=!0})};return Uo(e)?a(e):a(String(e).split(t)),n},Jb=()=>{},eC=(e,t)=>e!=null&&Number.isFinite(e=+e)?e:t,ac="abcdefghijklmnopqrstuvwxyz",mT="0123456789",JS={DIGIT:mT,ALPHA:ac,ALPHA_DIGIT:ac+ac.toUpperCase()+mT},tC=(e=16,t=JS.ALPHA_DIGIT)=>{let n="";const{length:a}=t;for(;e--;)n+=t[Math.random()*a|0];return n};function nC(e){return!!(e&&Bn(e.append)&&e[Symbol.toStringTag]==="FormData"&&e[Symbol.iterator])}const aC=e=>{const t=new Array(10),n=(a,s)=>{if(ml(a)){if(t.indexOf(a)>=0)return;if(!("toJSON"in a)){t[s]=a;const o=Uo(a)?[]:{};return er(a,(i,r)=>{const u=n(i,s+1);!Ui(u)&&(o[r]=u)}),t[s]=void 0,o}}return a};return n(e,0)},sC=Ea("AsyncFunction"),oC=e=>e&&(ml(e)||Bn(e))&&Bn(e.then)&&Bn(e.catch),eA=((e,t)=>e?setImmediate:t?((n,a)=>(ys.addEventListener("message",({source:s,data:o})=>{s===ys&&o===n&&a.length&&a.shift()()},!1),s=>{a.push(s),ys.postMessage(n,"*")}))(`axios@${Math.random()}`,[]):n=>setTimeout(n))(typeof setImmediate=="function",Bn(ys.postMessage)),iC=typeof queueMicrotask<"u"?queueMicrotask.bind(ys):typeof process<"u"&&process.nextTick||eA,ue={isArray:Uo,isArrayBuffer:jS,isBuffer:gb,isFormData:yb,isArrayBufferView:Rb,isString:Nb,isNumber:YS,isBoolean:vb,isObject:ml,isPlainObject:Kr,isReadableStream:Ub,isRequest:kb,isResponse:wb,isHeaders:Mb,isUndefined:Ui,isDate:bb,isFile:Cb,isBlob:Pb,isRegExp:Xb,isFunction:Bn,isStream:Lb,isURLSearchParams:$b,isTypedArray:Hb,isFileList:Db,forEach:er,merge:Ld,extend:Fb,trim:Wb,stripBOM:zb,inherits:xb,toFlatObject:Bb,kindOf:El,kindOfTest:Ea,endsWith:Gb,toArray:Vb,forEachEntry:Kb,matchAll:qb,isHTMLForm:jb,hasOwnProperty:pT,hasOwnProp:pT,reduceDescriptors:ZS,freezeMethods:Qb,toObjectSet:Zb,toCamelCase:Yb,noop:Jb,toFiniteNumber:eC,findKey:XS,global:ys,isContextDefined:QS,ALPHABET:JS,generateString:tC,isSpecCompliantForm:nC,toJSONObject:aC,isAsyncFn:sC,isThenable:oC,setImmediate:eA,asap:iC};function Xe(e,t,n,a,s){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack,this.message=e,this.name="AxiosError",t&&(this.code=t),n&&(this.config=n),a&&(this.request=a),s&&(this.response=s,this.status=s.status?s.status:null)}ue.inherits(Xe,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:ue.toJSONObject(this.config),code:this.code,status:this.status}}});const tA=Xe.prototype,nA={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach(e=>{nA[e]={value:e}});Object.defineProperties(Xe,nA);Object.defineProperty(tA,"isAxiosError",{value:!0});Xe.from=(e,t,n,a,s,o)=>{const i=Object.create(tA);return ue.toFlatObject(e,i,function(u){return u!==Error.prototype},r=>r!=="isAxiosError"),Xe.call(i,e.message,t,n,a,s),i.cause=e,i.name=e.name,o&&Object.assign(i,o),i};const rC=null;function yd(e){return ue.isPlainObject(e)||ue.isArray(e)}function aA(e){return ue.endsWith(e,"[]")?e.slice(0,-2):e}function TT(e,t,n){return e?e.concat(t).map(function(s,o){return s=aA(s),!n&&o?"["+s+"]":s}).join(n?".":""):t}function uC(e){return ue.isArray(e)&&!e.some(yd)}const lC=ue.toFlatObject(ue,{},null,function(t){return/^is[A-Z]/.test(t)});function Tl(e,t,n){if(!ue.isObject(e))throw new TypeError("target must be an object");t=t||new FormData,n=ue.toFlatObject(n,{metaTokens:!0,dots:!1,indexes:!1},!1,function(h,O){return!ue.isUndefined(O[h])});const a=n.metaTokens,s=n.visitor||d,o=n.dots,i=n.indexes,u=(n.Blob||typeof Blob<"u"&&Blob)&&ue.isSpecCompliantForm(t);if(!ue.isFunction(s))throw new TypeError("visitor must be a function");function l(_){if(_===null)return"";if(ue.isDate(_))return _.toISOString();if(!u&&ue.isBlob(_))throw new Xe("Blob is not supported. Use a Buffer instead.");return ue.isArrayBuffer(_)||ue.isTypedArray(_)?u&&typeof Blob=="function"?new Blob([_]):Buffer.from(_):_}function d(_,h,O){let S=_;if(_&&!O&&typeof _=="object"){if(ue.endsWith(h,"{}"))h=a?h:h.slice(0,-2),_=JSON.stringify(_);else if(ue.isArray(_)&&uC(_)||(ue.isFileList(_)||ue.endsWith(h,"[]"))&&(S=ue.toArray(_)))return h=aA(h),S.forEach(function(g,I){!(ue.isUndefined(g)||g===null)&&t.append(i===!0?TT([h],I,o):i===null?h:h+"[]",l(g))}),!1}return yd(_)?!0:(t.append(TT(O,h,o),l(_)),!1)}const E=[],c=Object.assign(lC,{defaultVisitor:d,convertValue:l,isVisitable:yd});function m(_,h){if(!ue.isUndefined(_)){if(E.indexOf(_)!==-1)throw Error("Circular reference detected in "+h.join("."));E.push(_),ue.forEach(_,function(S,R){(!(ue.isUndefined(S)||S===null)&&s.call(t,S,ue.isString(R)?R.trim():R,h,c))===!0&&m(S,h?h.concat(R):[R])}),E.pop()}}if(!ue.isObject(e))throw new TypeError("data must be an object");return m(e),t}function _T(e){const t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,function(a){return t[a]})}function KE(e,t){this._pairs=[],e&&Tl(e,this,t)}const sA=KE.prototype;sA.append=function(t,n){this._pairs.push([t,n])};sA.toString=function(t){const n=t?function(a){return t.call(this,a,_T)}:_T;return this._pairs.map(function(s){return n(s[0])+"="+n(s[1])},"").join("&")};function cC(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}function oA(e,t,n){if(!t)return e;const a=n&&n.encode||cC;ue.isFunction(n)&&(n={serialize:n});const s=n&&n.serialize;let o;if(s?o=s(t,n):o=ue.isURLSearchParams(t)?t.toString():new KE(t,n).toString(a),o){const i=e.indexOf("#");i!==-1&&(e=e.slice(0,i)),e+=(e.indexOf("?")===-1?"?":"&")+o}return e}class fT{constructor(){this.handlers=[]}use(t,n,a){return this.handlers.push({fulfilled:t,rejected:n,synchronous:a?a.synchronous:!1,runWhen:a?a.runWhen:null}),this.handlers.length-1}eject(t){this.handlers[t]&&(this.handlers[t]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(t){ue.forEach(this.handlers,function(a){a!==null&&t(a)})}}const iA={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},dC=typeof URLSearchParams<"u"?URLSearchParams:KE,EC=typeof FormData<"u"?FormData:null,pC=typeof Blob<"u"?Blob:null,mC={isBrowser:!0,classes:{URLSearchParams:dC,FormData:EC,Blob:pC},protocols:["http","https","file","blob","url","data"]},qE=typeof window<"u"&&typeof document<"u",$d=typeof navigator=="object"&&navigator||void 0,TC=qE&&(!$d||["ReactNative","NativeScript","NS"].indexOf($d.product)<0),_C=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",fC=qE&&window.location.href||"http://localhost",hC=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:qE,hasStandardBrowserEnv:TC,hasStandardBrowserWebWorkerEnv:_C,navigator:$d,origin:fC},Symbol.toStringTag,{value:"Module"})),rn={...hC,...mC};function SC(e,t){return Tl(e,new rn.classes.URLSearchParams,Object.assign({visitor:function(n,a,s,o){return rn.isNode&&ue.isBuffer(n)?(this.append(a,n.toString("base64")),!1):o.defaultVisitor.apply(this,arguments)}},t))}function AC(e){return ue.matchAll(/\w+|\[(\w*)]/g,e).map(t=>t[0]==="[]"?"":t[1]||t[0])}function OC(e){const t={},n=Object.keys(e);let a;const s=n.length;let o;for(a=0;a=n.length;return i=!i&&ue.isArray(s)?s.length:i,u?(ue.hasOwnProp(s,i)?s[i]=[s[i],a]:s[i]=a,!r):((!s[i]||!ue.isObject(s[i]))&&(s[i]=[]),t(n,a,s[i],o)&&ue.isArray(s[i])&&(s[i]=OC(s[i])),!r)}if(ue.isFormData(e)&&ue.isFunction(e.entries)){const n={};return ue.forEachEntry(e,(a,s)=>{t(AC(a),s,n,0)}),n}return null}function IC(e,t,n){if(ue.isString(e))try{return(t||JSON.parse)(e),ue.trim(e)}catch(a){if(a.name!=="SyntaxError")throw a}return(0,JSON.stringify)(e)}const tr={transitional:iA,adapter:["xhr","http","fetch"],transformRequest:[function(t,n){const a=n.getContentType()||"",s=a.indexOf("application/json")>-1,o=ue.isObject(t);if(o&&ue.isHTMLForm(t)&&(t=new FormData(t)),ue.isFormData(t))return s?JSON.stringify(rA(t)):t;if(ue.isArrayBuffer(t)||ue.isBuffer(t)||ue.isStream(t)||ue.isFile(t)||ue.isBlob(t)||ue.isReadableStream(t))return t;if(ue.isArrayBufferView(t))return t.buffer;if(ue.isURLSearchParams(t))return n.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),t.toString();let r;if(o){if(a.indexOf("application/x-www-form-urlencoded")>-1)return SC(t,this.formSerializer).toString();if((r=ue.isFileList(t))||a.indexOf("multipart/form-data")>-1){const u=this.env&&this.env.FormData;return Tl(r?{"files[]":t}:t,u&&new u,this.formSerializer)}}return o||s?(n.setContentType("application/json",!1),IC(t)):t}],transformResponse:[function(t){const n=this.transitional||tr.transitional,a=n&&n.forcedJSONParsing,s=this.responseType==="json";if(ue.isResponse(t)||ue.isReadableStream(t))return t;if(t&&ue.isString(t)&&(a&&!this.responseType||s)){const i=!(n&&n.silentJSONParsing)&&s;try{return JSON.parse(t)}catch(r){if(i)throw r.name==="SyntaxError"?Xe.from(r,Xe.ERR_BAD_RESPONSE,this,null,this.response):r}}return t}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:rn.classes.FormData,Blob:rn.classes.Blob},validateStatus:function(t){return t>=200&&t<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};ue.forEach(["delete","get","head","post","put","patch"],e=>{tr.headers[e]={}});const gC=ue.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),RC=e=>{const t={};let n,a,s;return e&&e.split(` +`).forEach(function(i){s=i.indexOf(":"),n=i.substring(0,s).trim().toLowerCase(),a=i.substring(s+1).trim(),!(!n||t[n]&&gC[n])&&(n==="set-cookie"?t[n]?t[n].push(a):t[n]=[a]:t[n]=t[n]?t[n]+", "+a:a)}),t},hT=Symbol("internals");function Yo(e){return e&&String(e).trim().toLowerCase()}function qr(e){return e===!1||e==null?e:ue.isArray(e)?e.map(qr):String(e)}function NC(e){const t=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let a;for(;a=n.exec(e);)t[a[1]]=a[2];return t}const vC=e=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim());function sc(e,t,n,a,s){if(ue.isFunction(a))return a.call(this,t,n);if(s&&(t=n),!!ue.isString(t)){if(ue.isString(a))return t.indexOf(a)!==-1;if(ue.isRegExp(a))return a.test(t)}}function bC(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(t,n,a)=>n.toUpperCase()+a)}function CC(e,t){const n=ue.toCamelCase(" "+t);["get","set","has"].forEach(a=>{Object.defineProperty(e,a+n,{value:function(s,o,i){return this[a].call(this,t,s,o,i)},configurable:!0})})}class vn{constructor(t){t&&this.set(t)}set(t,n,a){const s=this;function o(r,u,l){const d=Yo(u);if(!d)throw new Error("header name must be a non-empty string");const E=ue.findKey(s,d);(!E||s[E]===void 0||l===!0||l===void 0&&s[E]!==!1)&&(s[E||u]=qr(r))}const i=(r,u)=>ue.forEach(r,(l,d)=>o(l,d,u));if(ue.isPlainObject(t)||t instanceof this.constructor)i(t,n);else if(ue.isString(t)&&(t=t.trim())&&!vC(t))i(RC(t),n);else if(ue.isHeaders(t))for(const[r,u]of t.entries())o(u,r,a);else t!=null&&o(n,t,a);return this}get(t,n){if(t=Yo(t),t){const a=ue.findKey(this,t);if(a){const s=this[a];if(!n)return s;if(n===!0)return NC(s);if(ue.isFunction(n))return n.call(this,s,a);if(ue.isRegExp(n))return n.exec(s);throw new TypeError("parser must be boolean|regexp|function")}}}has(t,n){if(t=Yo(t),t){const a=ue.findKey(this,t);return!!(a&&this[a]!==void 0&&(!n||sc(this,this[a],a,n)))}return!1}delete(t,n){const a=this;let s=!1;function o(i){if(i=Yo(i),i){const r=ue.findKey(a,i);r&&(!n||sc(a,a[r],r,n))&&(delete a[r],s=!0)}}return ue.isArray(t)?t.forEach(o):o(t),s}clear(t){const n=Object.keys(this);let a=n.length,s=!1;for(;a--;){const o=n[a];(!t||sc(this,this[o],o,t,!0))&&(delete this[o],s=!0)}return s}normalize(t){const n=this,a={};return ue.forEach(this,(s,o)=>{const i=ue.findKey(a,o);if(i){n[i]=qr(s),delete n[o];return}const r=t?bC(o):String(o).trim();r!==o&&delete n[o],n[r]=qr(s),a[r]=!0}),this}concat(...t){return this.constructor.concat(this,...t)}toJSON(t){const n=Object.create(null);return ue.forEach(this,(a,s)=>{a!=null&&a!==!1&&(n[s]=t&&ue.isArray(a)?a.join(", "):a)}),n}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([t,n])=>t+": "+n).join(` +`)}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(t){return t instanceof this?t:new this(t)}static concat(t,...n){const a=new this(t);return n.forEach(s=>a.set(s)),a}static accessor(t){const a=(this[hT]=this[hT]={accessors:{}}).accessors,s=this.prototype;function o(i){const r=Yo(i);a[r]||(CC(s,i),a[r]=!0)}return ue.isArray(t)?t.forEach(o):o(t),this}}vn.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);ue.reduceDescriptors(vn.prototype,({value:e},t)=>{let n=t[0].toUpperCase()+t.slice(1);return{get:()=>e,set(a){this[n]=a}}});ue.freezeMethods(vn);function oc(e,t){const n=this||tr,a=t||n,s=vn.from(a.headers);let o=a.data;return ue.forEach(e,function(r){o=r.call(n,o,s.normalize(),t?t.status:void 0)}),s.normalize(),o}function uA(e){return!!(e&&e.__CANCEL__)}function ko(e,t,n){Xe.call(this,e??"canceled",Xe.ERR_CANCELED,t,n),this.name="CanceledError"}ue.inherits(ko,Xe,{__CANCEL__:!0});function lA(e,t,n){const a=n.config.validateStatus;!n.status||!a||a(n.status)?e(n):t(new Xe("Request failed with status code "+n.status,[Xe.ERR_BAD_REQUEST,Xe.ERR_BAD_RESPONSE][Math.floor(n.status/100)-4],n.config,n.request,n))}function PC(e){const t=/^([-+\w]{1,25})(:?\/\/|:)/.exec(e);return t&&t[1]||""}function DC(e,t){e=e||10;const n=new Array(e),a=new Array(e);let s=0,o=0,i;return t=t!==void 0?t:1e3,function(u){const l=Date.now(),d=a[o];i||(i=l),n[s]=u,a[s]=l;let E=o,c=0;for(;E!==s;)c+=n[E++],E=E%e;if(s=(s+1)%e,s===o&&(o=(o+1)%e),l-i{n=d,s=null,o&&(clearTimeout(o),o=null),e.apply(null,l)};return[(...l)=>{const d=Date.now(),E=d-n;E>=a?i(l,d):(s=l,o||(o=setTimeout(()=>{o=null,i(s)},a-E)))},()=>s&&i(s)]}const cu=(e,t,n=3)=>{let a=0;const s=DC(50,250);return LC(o=>{const i=o.loaded,r=o.lengthComputable?o.total:void 0,u=i-a,l=s(u),d=i<=r;a=i;const E={loaded:i,total:r,progress:r?i/r:void 0,bytes:u,rate:l||void 0,estimated:l&&r&&d?(r-i)/l:void 0,event:o,lengthComputable:r!=null,[t?"download":"upload"]:!0};e(E)},n)},ST=(e,t)=>{const n=e!=null;return[a=>t[0]({lengthComputable:n,total:e,loaded:a}),t[1]]},AT=e=>(...t)=>ue.asap(()=>e(...t)),yC=rn.hasStandardBrowserEnv?((e,t)=>n=>(n=new URL(n,rn.origin),e.protocol===n.protocol&&e.host===n.host&&(t||e.port===n.port)))(new URL(rn.origin),rn.navigator&&/(msie|trident)/i.test(rn.navigator.userAgent)):()=>!0,$C=rn.hasStandardBrowserEnv?{write(e,t,n,a,s,o){const i=[e+"="+encodeURIComponent(t)];ue.isNumber(n)&&i.push("expires="+new Date(n).toGMTString()),ue.isString(a)&&i.push("path="+a),ue.isString(s)&&i.push("domain="+s),o===!0&&i.push("secure"),document.cookie=i.join("; ")},read(e){const t=document.cookie.match(new RegExp("(^|;\\s*)("+e+")=([^;]*)"));return t?decodeURIComponent(t[3]):null},remove(e){this.write(e,"",Date.now()-864e5)}}:{write(){},read(){return null},remove(){}};function UC(e){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(e)}function kC(e,t){return t?e.replace(/\/?\/$/,"")+"/"+t.replace(/^\/+/,""):e}function cA(e,t){return e&&!UC(t)?kC(e,t):t}const OT=e=>e instanceof vn?{...e}:e;function zs(e,t){t=t||{};const n={};function a(l,d,E,c){return ue.isPlainObject(l)&&ue.isPlainObject(d)?ue.merge.call({caseless:c},l,d):ue.isPlainObject(d)?ue.merge({},d):ue.isArray(d)?d.slice():d}function s(l,d,E,c){if(ue.isUndefined(d)){if(!ue.isUndefined(l))return a(void 0,l,E,c)}else return a(l,d,E,c)}function o(l,d){if(!ue.isUndefined(d))return a(void 0,d)}function i(l,d){if(ue.isUndefined(d)){if(!ue.isUndefined(l))return a(void 0,l)}else return a(void 0,d)}function r(l,d,E){if(E in t)return a(l,d);if(E in e)return a(void 0,l)}const u={url:o,method:o,data:o,baseURL:i,transformRequest:i,transformResponse:i,paramsSerializer:i,timeout:i,timeoutMessage:i,withCredentials:i,withXSRFToken:i,adapter:i,responseType:i,xsrfCookieName:i,xsrfHeaderName:i,onUploadProgress:i,onDownloadProgress:i,decompress:i,maxContentLength:i,maxBodyLength:i,beforeRedirect:i,transport:i,httpAgent:i,httpsAgent:i,cancelToken:i,socketPath:i,responseEncoding:i,validateStatus:r,headers:(l,d,E)=>s(OT(l),OT(d),E,!0)};return ue.forEach(Object.keys(Object.assign({},e,t)),function(d){const E=u[d]||s,c=E(e[d],t[d],d);ue.isUndefined(c)&&E!==r||(n[d]=c)}),n}const dA=e=>{const t=zs({},e);let{data:n,withXSRFToken:a,xsrfHeaderName:s,xsrfCookieName:o,headers:i,auth:r}=t;t.headers=i=vn.from(i),t.url=oA(cA(t.baseURL,t.url),e.params,e.paramsSerializer),r&&i.set("Authorization","Basic "+btoa((r.username||"")+":"+(r.password?unescape(encodeURIComponent(r.password)):"")));let u;if(ue.isFormData(n)){if(rn.hasStandardBrowserEnv||rn.hasStandardBrowserWebWorkerEnv)i.setContentType(void 0);else if((u=i.getContentType())!==!1){const[l,...d]=u?u.split(";").map(E=>E.trim()).filter(Boolean):[];i.setContentType([l||"multipart/form-data",...d].join("; "))}}if(rn.hasStandardBrowserEnv&&(a&&ue.isFunction(a)&&(a=a(t)),a||a!==!1&&yC(t.url))){const l=s&&o&&$C.read(o);l&&i.set(s,l)}return t},wC=typeof XMLHttpRequest<"u",MC=wC&&function(e){return new Promise(function(n,a){const s=dA(e);let o=s.data;const i=vn.from(s.headers).normalize();let{responseType:r,onUploadProgress:u,onDownloadProgress:l}=s,d,E,c,m,_;function h(){m&&m(),_&&_(),s.cancelToken&&s.cancelToken.unsubscribe(d),s.signal&&s.signal.removeEventListener("abort",d)}let O=new XMLHttpRequest;O.open(s.method.toUpperCase(),s.url,!0),O.timeout=s.timeout;function S(){if(!O)return;const g=vn.from("getAllResponseHeaders"in O&&O.getAllResponseHeaders()),N={data:!r||r==="text"||r==="json"?O.responseText:O.response,status:O.status,statusText:O.statusText,headers:g,config:e,request:O};lA(function(C){n(C),h()},function(C){a(C),h()},N),O=null}"onloadend"in O?O.onloadend=S:O.onreadystatechange=function(){!O||O.readyState!==4||O.status===0&&!(O.responseURL&&O.responseURL.indexOf("file:")===0)||setTimeout(S)},O.onabort=function(){O&&(a(new Xe("Request aborted",Xe.ECONNABORTED,e,O)),O=null)},O.onerror=function(){a(new Xe("Network Error",Xe.ERR_NETWORK,e,O)),O=null},O.ontimeout=function(){let I=s.timeout?"timeout of "+s.timeout+"ms exceeded":"timeout exceeded";const N=s.transitional||iA;s.timeoutErrorMessage&&(I=s.timeoutErrorMessage),a(new Xe(I,N.clarifyTimeoutError?Xe.ETIMEDOUT:Xe.ECONNABORTED,e,O)),O=null},o===void 0&&i.setContentType(null),"setRequestHeader"in O&&ue.forEach(i.toJSON(),function(I,N){O.setRequestHeader(N,I)}),ue.isUndefined(s.withCredentials)||(O.withCredentials=!!s.withCredentials),r&&r!=="json"&&(O.responseType=s.responseType),l&&([c,_]=cu(l,!0),O.addEventListener("progress",c)),u&&O.upload&&([E,m]=cu(u),O.upload.addEventListener("progress",E),O.upload.addEventListener("loadend",m)),(s.cancelToken||s.signal)&&(d=g=>{O&&(a(!g||g.type?new ko(null,e,O):g),O.abort(),O=null)},s.cancelToken&&s.cancelToken.subscribe(d),s.signal&&(s.signal.aborted?d():s.signal.addEventListener("abort",d)));const R=PC(s.url);if(R&&rn.protocols.indexOf(R)===-1){a(new Xe("Unsupported protocol "+R+":",Xe.ERR_BAD_REQUEST,e));return}O.send(o||null)})},WC=(e,t)=>{const{length:n}=e=e?e.filter(Boolean):[];if(t||n){let a=new AbortController,s;const o=function(l){if(!s){s=!0,r();const d=l instanceof Error?l:this.reason;a.abort(d instanceof Xe?d:new ko(d instanceof Error?d.message:d))}};let i=t&&setTimeout(()=>{i=null,o(new Xe(`timeout ${t} of ms exceeded`,Xe.ETIMEDOUT))},t);const r=()=>{e&&(i&&clearTimeout(i),i=null,e.forEach(l=>{l.unsubscribe?l.unsubscribe(o):l.removeEventListener("abort",o)}),e=null)};e.forEach(l=>l.addEventListener("abort",o));const{signal:u}=a;return u.unsubscribe=()=>ue.asap(r),u}},FC=function*(e,t){let n=e.byteLength;if(n{const s=zC(e,t);let o=0,i,r=u=>{i||(i=!0,a&&a(u))};return new ReadableStream({async pull(u){try{const{done:l,value:d}=await s.next();if(l){r(),u.close();return}let E=d.byteLength;if(n){let c=o+=E;n(c)}u.enqueue(new Uint8Array(d))}catch(l){throw r(l),l}},cancel(u){return r(u),s.return()}},{highWaterMark:2})},_l=typeof fetch=="function"&&typeof Request=="function"&&typeof Response=="function",EA=_l&&typeof ReadableStream=="function",BC=_l&&(typeof TextEncoder=="function"?(e=>t=>e.encode(t))(new TextEncoder):async e=>new Uint8Array(await new Response(e).arrayBuffer())),pA=(e,...t)=>{try{return!!e(...t)}catch{return!1}},GC=EA&&pA(()=>{let e=!1;const t=new Request(rn.origin,{body:new ReadableStream,method:"POST",get duplex(){return e=!0,"half"}}).headers.has("Content-Type");return e&&!t}),gT=64*1024,Ud=EA&&pA(()=>ue.isReadableStream(new Response("").body)),du={stream:Ud&&(e=>e.body)};_l&&(e=>{["text","arrayBuffer","blob","formData","stream"].forEach(t=>{!du[t]&&(du[t]=ue.isFunction(e[t])?n=>n[t]():(n,a)=>{throw new Xe(`Response type '${t}' is not supported`,Xe.ERR_NOT_SUPPORT,a)})})})(new Response);const VC=async e=>{if(e==null)return 0;if(ue.isBlob(e))return e.size;if(ue.isSpecCompliantForm(e))return(await new Request(rn.origin,{method:"POST",body:e}).arrayBuffer()).byteLength;if(ue.isArrayBufferView(e)||ue.isArrayBuffer(e))return e.byteLength;if(ue.isURLSearchParams(e)&&(e=e+""),ue.isString(e))return(await BC(e)).byteLength},HC=async(e,t)=>{const n=ue.toFiniteNumber(e.getContentLength());return n??VC(t)},KC=_l&&(async e=>{let{url:t,method:n,data:a,signal:s,cancelToken:o,timeout:i,onDownloadProgress:r,onUploadProgress:u,responseType:l,headers:d,withCredentials:E="same-origin",fetchOptions:c}=dA(e);l=l?(l+"").toLowerCase():"text";let m=WC([s,o&&o.toAbortSignal()],i),_;const h=m&&m.unsubscribe&&(()=>{m.unsubscribe()});let O;try{if(u&&GC&&n!=="get"&&n!=="head"&&(O=await HC(d,a))!==0){let N=new Request(t,{method:"POST",body:a,duplex:"half"}),b;if(ue.isFormData(a)&&(b=N.headers.get("content-type"))&&d.setContentType(b),N.body){const[C,k]=ST(O,cu(AT(u)));a=IT(N.body,gT,C,k)}}ue.isString(E)||(E=E?"include":"omit");const S="credentials"in Request.prototype;_=new Request(t,{...c,signal:m,method:n.toUpperCase(),headers:d.normalize().toJSON(),body:a,duplex:"half",credentials:S?E:void 0});let R=await fetch(_);const g=Ud&&(l==="stream"||l==="response");if(Ud&&(r||g&&h)){const N={};["status","statusText","headers"].forEach(P=>{N[P]=R[P]});const b=ue.toFiniteNumber(R.headers.get("content-length")),[C,k]=r&&ST(b,cu(AT(r),!0))||[];R=new Response(IT(R.body,gT,C,()=>{k&&k(),h&&h()}),N)}l=l||"text";let I=await du[ue.findKey(du,l)||"text"](R,e);return!g&&h&&h(),await new Promise((N,b)=>{lA(N,b,{data:I,headers:vn.from(R.headers),status:R.status,statusText:R.statusText,config:e,request:_})})}catch(S){throw h&&h(),S&&S.name==="TypeError"&&/fetch/i.test(S.message)?Object.assign(new Xe("Network Error",Xe.ERR_NETWORK,e,_),{cause:S.cause||S}):Xe.from(S,S&&S.code,e,_)}}),kd={http:rC,xhr:MC,fetch:KC};ue.forEach(kd,(e,t)=>{if(e){try{Object.defineProperty(e,"name",{value:t})}catch{}Object.defineProperty(e,"adapterName",{value:t})}});const RT=e=>`- ${e}`,qC=e=>ue.isFunction(e)||e===null||e===!1,mA={getAdapter:e=>{e=ue.isArray(e)?e:[e];const{length:t}=e;let n,a;const s={};for(let o=0;o`adapter ${r} `+(u===!1?"is not supported by the environment":"is not available in the build"));let i=t?o.length>1?`since : +`+o.map(RT).join(` +`):" "+RT(o[0]):"as no adapter specified";throw new Xe("There is no suitable adapter to dispatch the request "+i,"ERR_NOT_SUPPORT")}return a},adapters:kd};function ic(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new ko(null,e)}function NT(e){return ic(e),e.headers=vn.from(e.headers),e.data=oc.call(e,e.transformRequest),["post","put","patch"].indexOf(e.method)!==-1&&e.headers.setContentType("application/x-www-form-urlencoded",!1),mA.getAdapter(e.adapter||tr.adapter)(e).then(function(a){return ic(e),a.data=oc.call(e,e.transformResponse,a),a.headers=vn.from(a.headers),a},function(a){return uA(a)||(ic(e),a&&a.response&&(a.response.data=oc.call(e,e.transformResponse,a.response),a.response.headers=vn.from(a.response.headers))),Promise.reject(a)})}const TA="1.7.9",fl={};["object","boolean","number","function","string","symbol"].forEach((e,t)=>{fl[e]=function(a){return typeof a===e||"a"+(t<1?"n ":" ")+e}});const vT={};fl.transitional=function(t,n,a){function s(o,i){return"[Axios v"+TA+"] Transitional option '"+o+"'"+i+(a?". "+a:"")}return(o,i,r)=>{if(t===!1)throw new Xe(s(i," has been removed"+(n?" in "+n:"")),Xe.ERR_DEPRECATED);return n&&!vT[i]&&(vT[i]=!0,console.warn(s(i," has been deprecated since v"+n+" and will be removed in the near future"))),t?t(o,i,r):!0}};fl.spelling=function(t){return(n,a)=>(console.warn(`${a} is likely a misspelling of ${t}`),!0)};function jC(e,t,n){if(typeof e!="object")throw new Xe("options must be an object",Xe.ERR_BAD_OPTION_VALUE);const a=Object.keys(e);let s=a.length;for(;s-- >0;){const o=a[s],i=t[o];if(i){const r=e[o],u=r===void 0||i(r,o,e);if(u!==!0)throw new Xe("option "+o+" must be "+u,Xe.ERR_BAD_OPTION_VALUE);continue}if(n!==!0)throw new Xe("Unknown option "+o,Xe.ERR_BAD_OPTION)}}const jr={assertOptions:jC,validators:fl},fa=jr.validators;class Ms{constructor(t){this.defaults=t,this.interceptors={request:new fT,response:new fT}}async request(t,n){try{return await this._request(t,n)}catch(a){if(a instanceof Error){let s={};Error.captureStackTrace?Error.captureStackTrace(s):s=new Error;const o=s.stack?s.stack.replace(/^.+\n/,""):"";try{a.stack?o&&!String(a.stack).endsWith(o.replace(/^.+\n.+\n/,""))&&(a.stack+=` +`+o):a.stack=o}catch{}}throw a}}_request(t,n){typeof t=="string"?(n=n||{},n.url=t):n=t||{},n=zs(this.defaults,n);const{transitional:a,paramsSerializer:s,headers:o}=n;a!==void 0&&jr.assertOptions(a,{silentJSONParsing:fa.transitional(fa.boolean),forcedJSONParsing:fa.transitional(fa.boolean),clarifyTimeoutError:fa.transitional(fa.boolean)},!1),s!=null&&(ue.isFunction(s)?n.paramsSerializer={serialize:s}:jr.assertOptions(s,{encode:fa.function,serialize:fa.function},!0)),jr.assertOptions(n,{baseUrl:fa.spelling("baseURL"),withXsrfToken:fa.spelling("withXSRFToken")},!0),n.method=(n.method||this.defaults.method||"get").toLowerCase();let i=o&&ue.merge(o.common,o[n.method]);o&&ue.forEach(["delete","get","head","post","put","patch","common"],_=>{delete o[_]}),n.headers=vn.concat(i,o);const r=[];let u=!0;this.interceptors.request.forEach(function(h){typeof h.runWhen=="function"&&h.runWhen(n)===!1||(u=u&&h.synchronous,r.unshift(h.fulfilled,h.rejected))});const l=[];this.interceptors.response.forEach(function(h){l.push(h.fulfilled,h.rejected)});let d,E=0,c;if(!u){const _=[NT.bind(this),void 0];for(_.unshift.apply(_,r),_.push.apply(_,l),c=_.length,d=Promise.resolve(n);E{if(!a._listeners)return;let o=a._listeners.length;for(;o-- >0;)a._listeners[o](s);a._listeners=null}),this.promise.then=s=>{let o;const i=new Promise(r=>{a.subscribe(r),o=r}).then(s);return i.cancel=function(){a.unsubscribe(o)},i},t(function(o,i,r){a.reason||(a.reason=new ko(o,i,r),n(a.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(t){if(this.reason){t(this.reason);return}this._listeners?this._listeners.push(t):this._listeners=[t]}unsubscribe(t){if(!this._listeners)return;const n=this._listeners.indexOf(t);n!==-1&&this._listeners.splice(n,1)}toAbortSignal(){const t=new AbortController,n=a=>{t.abort(a)};return this.subscribe(n),t.signal.unsubscribe=()=>this.unsubscribe(n),t.signal}static source(){let t;return{token:new jE(function(s){t=s}),cancel:t}}}function YC(e){return function(n){return e.apply(null,n)}}function XC(e){return ue.isObject(e)&&e.isAxiosError===!0}const wd={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511};Object.entries(wd).forEach(([e,t])=>{wd[t]=e});function _A(e){const t=new Ms(e),n=qS(Ms.prototype.request,t);return ue.extend(n,Ms.prototype,t,{allOwnKeys:!0}),ue.extend(n,t,null,{allOwnKeys:!0}),n.create=function(s){return _A(zs(e,s))},n}const Gt=_A(tr);Gt.Axios=Ms;Gt.CanceledError=ko;Gt.CancelToken=jE;Gt.isCancel=uA;Gt.VERSION=TA;Gt.toFormData=Tl;Gt.AxiosError=Xe;Gt.Cancel=Gt.CanceledError;Gt.all=function(t){return Promise.all(t)};Gt.spread=YC;Gt.isAxiosError=XC;Gt.mergeConfig=zs;Gt.AxiosHeaders=vn;Gt.formToJSON=e=>rA(ue.isHTMLForm(e)?new FormData(e):e);Gt.getAdapter=mA.getAdapter;Gt.HttpStatusCode=wd;Gt.default=Gt;var fA=(e=>(e.ACCEPT_PRIVACY_POLICY="ACCEPT_PRIVACY_POLICY",e.APPEAL="APPEAL",e.CHECK_AUTH_USER="CHECK_AUTH_USER",e.CONFIRM_ACCOUNT="CONFIRM_ACCOUNT",e.CONFIRM_EMAIL="CONFIRM_EMAIL",e.DELETE_ACCOUNT="DELETE_ACCOUNT",e.DELETE_PICTURE="DELETE_PICTURE",e.GET_ACCOUNT_SUSPENSION="GET_ACCOUNT_SUSPENSION",e.GET_BLOCKED_USERS="GET_BLOCKED_USERS",e.GET_FOLLOW_REQUESTS="GET_FOLLOW_REQUESTS",e.GET_REQUEST_DATA_EXPORT="GET_REQUEST_DATA_EXPORT",e.GET_USER_PROFILE="GET_USER_PROFILE",e.GET_USER_SANCTION="GET_USER_SANCTION",e.LOGIN_OR_REGISTER="LOGIN_OR_REGISTER",e.LOGOUT="LOGOUT",e.REQUEST_DATA_EXPORT="REQUEST_DATA_EXPORT",e.RESEND_ACCOUNT_CONFIRMATION_EMAIL="RESEND_ACCOUNT_CONFIRMATION_EMAIL",e.RESET_USER_PASSWORD="RESET_USER_PASSWORD",e.RESET_USER_SPORT_PREFERENCES="RESET_USER_SPORT_PREFERENCES",e.SEND_PASSWORD_RESET_REQUEST="SEND_PASSWORD_RESET_REQUEST",e.UPDATE_FOLLOW_REQUESTS="UPDATE_FOLLOW_REQUESTS",e.UPDATE_USER_ACCOUNT="UPDATE_USER_ACCOUNT",e.UPDATE_USER_PICTURE="UPDATE_USER_PICTURE",e.UPDATE_USER_PROFILE="UPDATE_USER_PROFILE",e.UPDATE_USER_PREFERENCES="UPDATE_USER_PREFERENCES",e.UPDATE_USER_SPORT_PREFERENCES="UPDATE_USER_SPORT_PREFERENCES",e))(fA||{}),hA=(e=>(e.ACCOUNT_SUSPENSION="ACCOUNT_SUSPENSION",e.AUTH_TOKEN="AUTH_TOKEN",e.AUTH_USER_PROFILE="AUTH_USER_PROFILE",e.BLOCKED_USERS="BLOCKED_USERS",e.EXPORT_REQUEST="EXPORT_REQUEST",e.FOLLOW_REQUESTS="FOLLOW_REQUESTS",e.HAS_ADMIN_RIGHTS="HAS_ADMIN_RIGHTS",e.HAS_MODERATOR_RIGHTS="HAS_MODERATOR_RIGHTS",e.HAS_OWNER_RIGHTS="HAS_OWNER_RIGHTS",e.IS_AUTHENTICATED="IS_AUTHENTICATED",e.IS_PROFILE_NOT_LOADED="IS_PROFILE_NOT_LOADED",e.IS_SUCCESS="IS_SUCCESS",e.IS_SUSPENDED="IS_SUSPENDED",e.IS_REGISTRATION_SUCCESS="IS_REGISTRATION_SUCCESS",e.IS_PROFILE_LOADED="IS_PROFILE_LOADED",e.USER_LOADING="USER_LOADING",e.USER_SANCTION="USER_SANCTION",e))(hA||{}),SA=(e=>(e.CLEAR_AUTH_USER_TOKEN="CLEAR_AUTH_USER_TOKEN",e.SET_EXPORT_REQUEST="SET_EXPORT_REQUEST",e.SET_ACCOUNT_SUSPENSION="SET_ACCOUNT_SUSPENSION",e.SET_USER_SANCTION="SET_USER_SANCTION",e.UPDATE_AUTH_TOKEN="UPDATE_AUTH_TOKEN",e.UPDATE_AUTH_USER_PROFILE="UPDATE_AUTH_USER_PROFILE",e.UPDATE_BLOCKED_USERS="UPDATE_BLOCKED_USERS",e.UPDATE_FOLLOW_REQUESTS="UPDATE_FOLLOW_REQUESTS",e.UPDATE_IS_SUCCESS="UPDATE_USER_IS_SUCCESS",e.UPDATE_IS_REGISTRATION_SUCCESS="UPDATE_IS_REGISTRATION_SUCCESS",e.UPDATE_USER_LOADING="UPDATE_USER_LOADING",e))(SA||{}),AA=(e=>(e.ADD_EQUIPMENT="ADD_EQUIPMENT",e.DELETE_EQUIPMENT="DELETE_EQUIPMENT",e.GET_EQUIPMENT="GET_EQUIPMENT",e.GET_EQUIPMENT_TYPES="GET_EQUIPMENT_TYPES",e.GET_EQUIPMENTS="GET_EQUIPMENTS",e.REFRESH_EQUIPMENT="REFRESH_EQUIPMENT",e.UPDATE_EQUIPMENT="UPDATE_EQUIPMENT",e.UPDATE_EQUIPMENT_TYPE="UPDATE_EQUIPMENT_TYPE",e))(AA||{}),OA=(e=>(e.EQUIPMENT="EQUIPMENT",e.EQUIPMENTS="EQUIPMENTS",e.EQUIPMENT_TYPES="EQUIPMENT_TYPES",e.LOADING="LOADING",e))(OA||{}),IA=(e=>(e.ADD_EQUIPMENT="ADD_EQUIPMENT",e.REMOVE_EQUIPMENT="REMOVE_EQUIPMENT",e.SET_EQUIPMENTS="SET_EQUIPMENTS",e.SET_EQUIPMENT_TYPES="SET_EQUIPMENT_TYPES",e.SET_LOADING="SET_LOADING",e.UPDATE_EQUIPMENT="UPDATE_EQUIPMENT",e))(IA||{}),gA=(e=>(e.GET_UNREAD_STATUS="GET_UNREAD_STATUS",e.GET_NOTIFICATION_TYPES="GET_NOTIFICATION_TYPES",e.GET_NOTIFICATIONS="GET_NOTIFICATIONS",e.MARK_ALL_AS_READ="MARK_ALL_AS_READ",e.UPDATE_STATUS="UPDATE_STATUS",e))(gA||{}),RA=(e=>(e.NOTIFICATIONS="NOTIFICATIONS",e.PAGINATION="PAGINATION",e.UNREAD_STATUS="UNREAD_STATUS",e.TYPES="TYPES",e))(RA||{}),NA=(e=>(e.UPDATE_NOTIFICATIONS="UPDATE_NOTIFICATIONS",e.UPDATE_PAGINATION="UPDATE_PAGINATION",e.UPDATE_TYPES="UPDATE_TYPES",e.UPDATE_UNREAD_STATUS="UPDATE_UNREAD_STATUS",e.EMPTY_NOTIFICATIONS="EMPTY_NOTIFICATIONS",e))(NA||{}),vA=(e=>(e.AUTHORIZE_CLIENT="AUTHORIZE_CLIENT",e.CREATE_CLIENT="CREATE_CLIENT",e.DELETE_CLIENT="DELETE_CLIENT",e.GET_CLIENTS="GET_CLIENTS",e.GET_CLIENT_BY_CLIENT_ID="GET_CLIENT_BY_CLIENT_ID",e.GET_CLIENT_BY_ID="GET_CLIENT_BY_ID",e.REVOKE_ALL_TOKENS="REVOKE_ALL_TOKENS",e))(vA||{}),bA=(e=>(e.CLIENT="CLIENT",e.CLIENTS="CLIENTS",e.CLIENTS_PAGINATION="CLIENTS_PAGINATION",e.REVOCATION_SUCCESSFUL="REVOCATION_SUCCESSFUL",e))(bA||{}),CA=(e=>(e.EMPTY_CLIENT="EMPTY_CLIENT",e.SET_CLIENT="SET_CLIENT",e.SET_CLIENTS="SET_CLIENTS",e.SET_CLIENTS_PAGINATION="SET_CLIENTS_PAGINATION",e.SET_REVOCATION_SUCCESSFUL="SET_REVOCATION_SUCCESSFUL",e))(CA||{}),PA=(e=>(e.EMPTY_REPORTS="EMPTY_REPORTS",e.GET_REPORT="GET_REPORT",e.GET_REPORTS="GET_REPORTS",e.GET_UNRESOLVED_REPORTS_STATUS="GET_UNRESOLVED_REPORTS_STATUS",e.PROCESS_APPEAL="PROCESS_APPEAL",e.SUBMIT_ADMIN_ACTION="SUBMIT_ADMIN_ACTION",e.SUBMIT_REPORT="SUBMIT_REPORT",e.UPDATE_REPORT="UPDATE_REPORT",e))(PA||{}),DA=(e=>(e.REPORT="REPORT",e.REPORT_STATUS="REPORT_STATUS",e.REPORT_LOADING="REPORT_LOADING",e.REPORT_UPDATE_LOADING="REPORT_UPDATE_LOADING",e.REPORTS="REPORTS",e.REPORTS_PAGINATION="REPORTS_PAGINATION",e.UNRESOLVED_REPORTS_STATUS="UNRESOLVED_REPORTS_STATUS",e))(DA||{}),LA=(e=>(e.EMPTY_REPORT="EMPTY_REPORT",e.SET_REPORT="SET_REPORT",e.SET_REPORT_LOADING="SET_REPORT_LOADING",e.SET_REPORT_STATUS="SET_REPORT_STATUS",e.SET_REPORT_UPDATE_LOADING="SET_REPORT_UPDATE_LOADING",e.SET_REPORTS="SET_REPORTS",e.SET_REPORTS_PAGINATION="SET_REPORTS_PAGINATION",e.SET_UNRESOLVED_REPORTS_STATUS="SET_UNRESOLVED_REPORTS_STATUS",e))(LA||{}),yA=(e=>(e.GET_APPLICATION_CONFIG="GET_APPLICATION_CONFIG",e.GET_APPLICATION_PRIVACY_POLICY="GET_APPLICATION_PRIVACY_POLICY",e.GET_APPLICATION_STATS="GET_APPLICATION_STATS",e.UPDATE_APPLICATION_CONFIG="UPDATE_APPLICATION_CONFIG",e.UPDATE_APPLICATION_LANGUAGE="UPDATE_APPLICATION_LANGUAGE",e))(yA||{}),$A=(e=>(e.APP_CONFIG="APP_CONFIG",e.APP_LOADING="APP_LOADING",e.APP_STATS="APP_STATS",e.DARK_MODE="DARK_MODE",e.ERROR_MESSAGES="ERROR_MESSAGES",e.LANGUAGE="LANGUAGE",e.LOCALE="LOCALE",e.DISPLAY_OPTIONS="DISPLAY_OPTIONS",e))($A||{}),UA=(e=>(e.EMPTY_ERROR_MESSAGES="EMPTY_ERROR_MESSAGES",e.SET_ERROR_MESSAGES="SET_ERROR_MESSAGES",e.UPDATE_APPLICATION_CONFIG="UPDATE_APPLICATION_CONFIG",e.UPDATE_APPLICATION_LOADING="UPDATE_APPLICATION_LOADING",e.UPDATE_APPLICATION_PRIVACY_POLICY="UPDATE_APPLICATION_PRIVACY_POLICY",e.UPDATE_APPLICATION_STATS="UPDATE_APPLICATION_STATS",e.UPDATE_DARK_MODE="UPDATE_DARK_MODE",e.UPDATE_LANG="UPDATE_LANG",e.UPDATE_DISPLAY_OPTIONS="UPDATE_DISPLAY_OPTIONS",e))(UA||{}),kA=(e=>(e.GET_SPORTS="GET_SPORTS",e.UPDATE_SPORTS="UPDATE_SPORTS",e))(kA||{}),wA=(e=>(e.SPORTS="SPORTS",e))(wA||{}),MA=(e=>(e.SET_SPORTS="SET_SPORTS",e))(MA||{}),WA=(e=>(e.GET_USER_SPORT_STATS="GET_USER_SPORT_STATS",e.GET_USER_STATS="GET_USER_STATS",e))(WA||{}),FA=(e=>(e.USER_SPORT_STATS="USER_SPORT_STATS",e.USER_STATS="USER_STATS",e.STATS_LOADING="STATS_LOADING",e.TOTAL_WORKOUTS="TOTAL_WORKOUTS",e))(FA||{}),zA=(e=>(e.EMPTY_USER_SPORT_STATS="EMPTY_USER_SPORT_STATS",e.EMPTY_USER_STATS="EMPTY_USER_STATS",e.UPDATE_USER_SPORT_STATS="UPDATE_USER_SPORT_STATS",e.UPDATE_USER_STATS="UPDATE_USER_STATS",e.UPDATE_STATS_LOADING="UPDATE_STATS_LOADING",e.UPDATE_TOTAL_WORKOUTS="UPDATE_TOTAL_WORKOUTS",e))(zA||{}),xA=(e=>(e.EMPTY_USER="EMPTY_USER",e.EMPTY_USERS="EMPTY_USERS",e.GET_USER="GET_USER",e.GET_USER_SANCTIONS="GET_USER_SANCTIONS",e.GET_USERS="GET_USERS",e.GET_USERS_FOR_ADMIN="GET_USERS_FOR_ADMIN",e.UPDATE_USER="UPDATE_USER",e.DELETE_USER_ACCOUNT="DELETE_USER_ACCOUNT",e.UPDATE_RELATIONSHIP="UPDATE_RELATIONSHIP",e.GET_RELATIONSHIPS="GET_RELATIONSHIPS",e.EMPTY_RELATIONSHIPS="EMPTY_RELATIONSHIPS",e))(xA||{}),BA=(e=>(e.USER="USER",e.USER_SANCTIONS="USER_SANCTIONS",e.USER_SANCTIONS_LOADING="USER_SANCTIONS_LOADING",e.USER_SANCTIONS_PAGINATION="USER_SANCTIONS_PAGINATION",e.USER_CURRENT_REPORTING="USER_CURRENT_REPORTING",e.USER_RELATIONSHIPS="USER_RELATIONSHIPS",e.USERS="USERS",e.USERS_IS_SUCCESS="USERS_IS_SUCCESS",e.USERS_LOADING="USERS_LOADING",e.USERS_PAGINATION="USERS_PAGINATION",e))(BA||{}),GA=(e=>(e.UPDATE_USER="UPDATE_USER",e.UPDATE_USER_SANCTIONS="UPDATE_USER_SANCTIONS",e.UPDATE_USER_SANCTIONS_LOADING="UPDATE_USER_SANCTIONS_LOADING",e.UPDATE_USER_SANCTIONS_PAGINATION="UPDATE_USER_SANCTIONS_PAGINATION",e.UPDATE_USER_CURRENT_REPORTING="UPDATE_USER_CURRENT_REPORTING",e.UPDATE_USER_IN_USERS="UPDATE_USER_IN_USERS",e.UPDATE_USER_IN_RELATIONSHIPS="UPDATE_USER_IN_RELATIONSHIPS",e.UPDATE_USER_RELATIONSHIPS="UPDATE_USER_RELATIONSHIPS",e.UPDATE_USERS="UPDATE_USERS",e.UPDATE_USERS_LOADING="UPDATE_USERS_LOADING",e.UPDATE_USERS_PAGINATION="UPDATE_USERS_PAGINATION",e.UPDATE_IS_SUCCESS="UPDATE_IS_SUCCESS",e))(GA||{}),VA=(e=>(e.ADD_COMMENT="ADD_COMMENT",e.ADD_WORKOUT="ADD_WORKOUT",e.ADD_WORKOUT_WITHOUT_GPX="ADD_WORKOUT_WITHOUT_GPX",e.DELETE_WORKOUT="DELETE_WORKOUT",e.DELETE_WORKOUT_COMMENT="DELETE_WORKOUT_COMMENT",e.EDIT_WORKOUT="EDIT_WORKOUT",e.EDIT_WORKOUT_COMMENT="EDIT_WORKOUT_COMMENT",e.EDIT_WORKOUT_CONTENT="EDIT_WORKOUT_CONTENT",e.GET_CALENDAR_WORKOUTS="GET_CALENDAR_WORKOUTS",e.GET_USER_WORKOUTS="GET_USER_WORKOUTS",e.GET_TIMELINE_WORKOUTS="GET_TIMELINE_WORKOUTS",e.GET_MORE_TIMELINE_WORKOUTS="GET_MORE_TIMELINE_WORKOUTS",e.GET_WORKOUT_DATA="GET_WORKOUT_DATA",e.GET_WORKOUT_COMMENT="GET_WORKOUT_COMMENT",e.GET_WORKOUT_COMMENTS="GET_WORKOUT_COMMENTS",e.LIKE_COMMENT="LIKE_COMMENT",e.LIKE_WORKOUT="LIKE_WORKOUT",e.MAKE_APPEAL="MAKE_COMMENT_APPEAL",e.UNDO_LIKE_COMMENT="UNDO_LIKE_COMMENT",e.UNDO_LIKE_WORKOUT="UNDO_LIKE_WORKOUT",e))(VA||{}),HA=(e=>(e.APPEAL_LOADING="APPEAL_LOADING",e.CALENDAR_WORKOUTS="CALENDAR_WORKOUTS",e.CURRENT_REPORTING="CURRENT_REPORTING",e.SUCCESS="SUCCESS",e.TIMELINE_WORKOUTS="TIMELINE_WORKOUTS",e.USER_WORKOUTS="USER_WORKOUTS",e.WORKOUT_CONTENT_EDITION="WORKOUT_CONTENT_EDITION",e.WORKOUT_DATA="WORKOUT_DATA",e.WORKOUTS_PAGINATION="WORKOUTS_PAGINATION",e))(HA||{}),$s=(e=>(e.ADD_TIMELINE_WORKOUTS="ADD_TIMELINE_WORKOUTS",e.EMPTY_WORKOUTS="EMPTY_WORKOUTS",e.EMPTY_CALENDAR_WORKOUTS="EMPTY_CALENDAR_WORKOUTS",e.EMPTY_WORKOUT="EMPTY_WORKOUT",e.SET_APPEAL_LOADING="SET_APPEAL_LOADING",e.SET_CALENDAR_WORKOUTS="SET_CALENDAR_WORKOUTS",e.SET_TIMELINE_WORKOUTS="SET_TIMELINE_WORKOUTS",e.SET_USER_WORKOUTS="SET_USER_WORKOUTS",e.SET_WORKOUT="SET_WORKOUT",e.SET_WORKOUT_GPX="SET_WORKOUT_GPX",e.SET_WORKOUT_CHART_DATA="SET_WORKOUT_CHART_DATA",e.SET_WORKOUT_CONTENT="SET_WORKOUT_CONTENT",e.SET_WORKOUT_CONTENT_LOADING="SET_WORKOUT_CONTENT_LOADING",e.SET_WORKOUT_CONTENT_TYPE="SET_WORKOUT_CONTENT_TYPE",e.SET_WORKOUT_LOADING="SET_WORKOUT_LOADING",e.SET_WORKOUTS_PAGINATION="SET_WORKOUTS_PAGINATION",e.ADD_WORKOUT_COMMENT="ADD_WORKOUT_COMMENT",e.SET_WORKOUT_COMMENTS="SET_WORKOUT_COMMENTS",e.SET_COMMENT_LOADING="SET_COMMENT_LOADING",e.SET_CURRENT_COMMENT_EDITION="SET_CURRENT_COMMENT_EDITION",e.SET_CURRENT_REPORTING="SET_CURRENT_REPORTING",e.SET_SUCCESS="SET_SUCCESS",e))($s||{});const te={ACTIONS:yA,GETTERS:$A,MUTATIONS:UA},Zt={ACTIONS:kA,GETTERS:wA,MUTATIONS:MA},Wt={ACTIONS:WA,GETTERS:FA,MUTATIONS:zA},K={ACTIONS:fA,GETTERS:hA,MUTATIONS:SA},nt={ACTIONS:vA,GETTERS:bA,MUTATIONS:CA},dt={ACTIONS:gA,GETTERS:RA,MUTATIONS:NA},ye={ACTIONS:PA,GETTERS:DA,MUTATIONS:LA},me={ACTIONS:xA,GETTERS:BA,MUTATIONS:GA},ee={ACTIONS:VA,GETTERS:HA,MUTATIONS:$s},Be={ACTIONS:AA,GETTERS:OA,MUTATIONS:IA},nr=()=>"/api/",ne=(e,t,n="UNKNOWN")=>{var i;if(t&&t.message==="canceled")return;const a=t!=null&&t.response&&t.response.data?t.response.data:null;if(((i=t==null?void 0:t.response)==null?void 0:i.status)===401&&(a==null?void 0:a.error)==="invalid_token"){localStorage.removeItem("authToken"),e.dispatch(K.ACTIONS.CHECK_AUTH_USER);return}const s=QC(t,e),o=s?"":t?t.response?t.response.status===413?"file size is greater than the allowed size":a!=null&&a.message?a.message:n:t.message?t.message:n:n;e.commit(te.MUTATIONS.SET_ERROR_MESSAGES,s||(o.includes(` +`)?o.split(` +`).filter(r=>r!=="").map(r=>`api.ERROR.${r}`):`api.ERROR.${o}`))},QC=(e,t)=>{var n;if((n=e==null?void 0:e.response)!=null&&n.data){const a={...e.response.data};if("equipment_id"in a){const s=t.getters[Be.GETTERS.EQUIPMENTS].filter(o=>o.id===a.equipment_id);return{equipmentId:a.equipment_id,equipmentLabel:s.length===0?null:s[0].label,status:a.status}}}return null},ZC={class:"user-picture"},JC=["alt","src"],e2={key:1,class:"no-picture"},Jt=Q({__name:"UserPicture",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),a=F(()=>n.value.picture?`${nr()}users/${n.value.username}/picture?${Date.now()}`:"");return(s,o)=>(f(),v("div",ZC,[a.value!==""?(f(),v("img",{key:0,class:"profile-user-img",alt:s.$t("user.USER_PICTURE"),src:a.value},null,8,JC)):(f(),v("div",e2,o[0]||(o[0]=[p("i",{class:"fa fa-user-circle-o","aria-hidden":"true"},null,-1)])))]))}});function t2(){return KA().__VUE_DEVTOOLS_GLOBAL_HOOK__}function KA(){return typeof navigator<"u"&&typeof window<"u"?window:typeof globalThis<"u"?globalThis:{}}const n2=typeof Proxy=="function",a2="devtools-plugin:setup",s2="plugin:settings:set";let Js,Md;function o2(){var e;return Js!==void 0||(typeof window<"u"&&window.performance?(Js=!0,Md=window.performance):typeof globalThis<"u"&&(!((e=globalThis.perf_hooks)===null||e===void 0)&&e.performance)?(Js=!0,Md=globalThis.perf_hooks.performance):Js=!1),Js}function i2(){return o2()?Md.now():Date.now()}class r2{constructor(t,n){this.target=null,this.targetQueue=[],this.onQueue=[],this.plugin=t,this.hook=n;const a={};if(t.settings)for(const i in t.settings){const r=t.settings[i];a[i]=r.defaultValue}const s=`__vue-devtools-plugin-settings__${t.id}`;let o=Object.assign({},a);try{const i=localStorage.getItem(s),r=JSON.parse(i);Object.assign(o,r)}catch{}this.fallbacks={getSettings(){return o},setSettings(i){try{localStorage.setItem(s,JSON.stringify(i))}catch{}o=i},now(){return i2()}},n&&n.on(s2,(i,r)=>{i===this.plugin.id&&this.fallbacks.setSettings(r)}),this.proxiedOn=new Proxy({},{get:(i,r)=>this.target?this.target.on[r]:(...u)=>{this.onQueue.push({method:r,args:u})}}),this.proxiedTarget=new Proxy({},{get:(i,r)=>this.target?this.target[r]:r==="on"?this.proxiedOn:Object.keys(this.fallbacks).includes(r)?(...u)=>(this.targetQueue.push({method:r,args:u,resolve:()=>{}}),this.fallbacks[r](...u)):(...u)=>new Promise(l=>{this.targetQueue.push({method:r,args:u,resolve:l})})})}async setRealTarget(t){this.target=t;for(const n of this.onQueue)this.target.on[n.method](...n.args);for(const n of this.targetQueue)n.resolve(await this.target[n.method](...n.args))}}function u2(e,t){const n=e,a=KA(),s=t2(),o=n2&&n.enableEarlyProxy;if(s&&(a.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__||!o))s.emit(a2,e,t);else{const i=o?new r2(n,s):null;(a.__VUE_DEVTOOLS_PLUGINS__=a.__VUE_DEVTOOLS_PLUGINS__||[]).push({pluginDescriptor:n,setupFn:t,proxy:i}),i&&t(i.proxiedTarget)}}/*! + * vuex v4.1.0 + * (c) 2022 Evan You + * @license MIT + */var qA="store";function jA(e){return e===void 0&&(e=null),Ut(e!==null?e:qA)}function wo(e,t){Object.keys(e).forEach(function(n){return t(e[n],n)})}function l2(e){return e!==null&&typeof e=="object"}function c2(e){return e&&typeof e.then=="function"}function d2(e,t){return function(){return e(t)}}function YA(e,t,n){return t.indexOf(e)<0&&(n&&n.prepend?t.unshift(e):t.push(e)),function(){var a=t.indexOf(e);a>-1&&t.splice(a,1)}}function XA(e,t){e._actions=Object.create(null),e._mutations=Object.create(null),e._wrappedGetters=Object.create(null),e._modulesNamespaceMap=Object.create(null);var n=e.state;hl(e,n,[],e._modules.root,!0),YE(e,n,t)}function YE(e,t,n){var a=e._state,s=e._scope;e.getters={},e._makeLocalGettersCache=Object.create(null);var o=e._wrappedGetters,i={},r={},u=S0(!0);u.run(function(){wo(o,function(l,d){i[d]=d2(l,e),r[d]=F(function(){return i[d]()}),Object.defineProperty(e.getters,d,{get:function(){return r[d].value},enumerable:!0})})}),e._state=kt({data:t}),e._scope=u,e.strict&&_2(e),a&&n&&e._withCommit(function(){a.data=null}),s&&s.stop()}function hl(e,t,n,a,s){var o=!n.length,i=e._modules.getNamespace(n);if(a.namespaced&&(e._modulesNamespaceMap[i],e._modulesNamespaceMap[i]=a),!o&&!s){var r=XE(t,n.slice(0,-1)),u=n[n.length-1];e._withCommit(function(){r[u]=a.state})}var l=a.context=E2(e,i,n);a.forEachMutation(function(d,E){var c=i+E;p2(e,c,d,l)}),a.forEachAction(function(d,E){var c=d.root?E:i+E,m=d.handler||d;m2(e,c,m,l)}),a.forEachGetter(function(d,E){var c=i+E;T2(e,c,d,l)}),a.forEachChild(function(d,E){hl(e,t,n.concat(E),d,s)})}function E2(e,t,n){var a=t==="",s={dispatch:a?e.dispatch:function(o,i,r){var u=Eu(o,i,r),l=u.payload,d=u.options,E=u.type;return(!d||!d.root)&&(E=t+E),e.dispatch(E,l)},commit:a?e.commit:function(o,i,r){var u=Eu(o,i,r),l=u.payload,d=u.options,E=u.type;(!d||!d.root)&&(E=t+E),e.commit(E,l,d)}};return Object.defineProperties(s,{getters:{get:a?function(){return e.getters}:function(){return QA(e,t)}},state:{get:function(){return XE(e.state,n)}}}),s}function QA(e,t){if(!e._makeLocalGettersCache[t]){var n={},a=t.length;Object.keys(e.getters).forEach(function(s){if(s.slice(0,a)===t){var o=s.slice(a);Object.defineProperty(n,o,{get:function(){return e.getters[s]},enumerable:!0})}}),e._makeLocalGettersCache[t]=n}return e._makeLocalGettersCache[t]}function p2(e,t,n,a){var s=e._mutations[t]||(e._mutations[t]=[]);s.push(function(i){n.call(e,a.state,i)})}function m2(e,t,n,a){var s=e._actions[t]||(e._actions[t]=[]);s.push(function(i){var r=n.call(e,{dispatch:a.dispatch,commit:a.commit,getters:a.getters,state:a.state,rootGetters:e.getters,rootState:e.state},i);return c2(r)||(r=Promise.resolve(r)),e._devtoolHook?r.catch(function(u){throw e._devtoolHook.emit("vuex:error",u),u}):r})}function T2(e,t,n,a){e._wrappedGetters[t]||(e._wrappedGetters[t]=function(o){return n(a.state,a.getters,o.state,o.getters)})}function _2(e){Le(function(){return e._state.data},function(){},{deep:!0,flush:"sync"})}function XE(e,t){return t.reduce(function(n,a){return n[a]},e)}function Eu(e,t,n){return l2(e)&&e.type&&(n=t,t=e,e=e.type),{type:e,payload:t,options:n}}var f2="vuex bindings",bT="vuex:mutations",rc="vuex:actions",eo="vuex",h2=0;function S2(e,t){u2({id:"org.vuejs.vuex",app:e,label:"Vuex",homepage:"https://next.vuex.vuejs.org/",logo:"https://vuejs.org/images/icons/favicon-96x96.png",packageName:"vuex",componentStateTypes:[f2]},function(n){n.addTimelineLayer({id:bT,label:"Vuex Mutations",color:CT}),n.addTimelineLayer({id:rc,label:"Vuex Actions",color:CT}),n.addInspector({id:eo,label:"Vuex",icon:"storage",treeFilterPlaceholder:"Filter stores..."}),n.on.getInspectorTree(function(a){if(a.app===e&&a.inspectorId===eo)if(a.filter){var s=[];t1(s,t._modules.root,a.filter,""),a.rootNodes=s}else a.rootNodes=[e1(t._modules.root,"")]}),n.on.getInspectorState(function(a){if(a.app===e&&a.inspectorId===eo){var s=a.nodeId;QA(t,s),a.state=I2(R2(t._modules,s),s==="root"?t.getters:t._makeLocalGettersCache,s)}}),n.on.editInspectorState(function(a){if(a.app===e&&a.inspectorId===eo){var s=a.nodeId,o=a.path;s!=="root"&&(o=s.split("/").filter(Boolean).concat(o)),t._withCommit(function(){a.set(t._state.data,o,a.state.value)})}}),t.subscribe(function(a,s){var o={};a.payload&&(o.payload=a.payload),o.state=s,n.notifyComponentUpdate(),n.sendInspectorTree(eo),n.sendInspectorState(eo),n.addTimelineEvent({layerId:bT,event:{time:Date.now(),title:a.type,data:o}})}),t.subscribeAction({before:function(a,s){var o={};a.payload&&(o.payload=a.payload),a._id=h2++,a._time=Date.now(),o.state=s,n.addTimelineEvent({layerId:rc,event:{time:a._time,title:a.type,groupId:a._id,subtitle:"start",data:o}})},after:function(a,s){var o={},i=Date.now()-a._time;o.duration={_custom:{type:"duration",display:i+"ms",tooltip:"Action duration",value:i}},a.payload&&(o.payload=a.payload),o.state=s,n.addTimelineEvent({layerId:rc,event:{time:Date.now(),title:a.type,groupId:a._id,subtitle:"end",data:o}})}})})}var CT=8702998,A2=6710886,O2=16777215,ZA={label:"namespaced",textColor:O2,backgroundColor:A2};function JA(e){return e&&e!=="root"?e.split("/").slice(-2,-1)[0]:"Root"}function e1(e,t){return{id:t||"root",label:JA(t),tags:e.namespaced?[ZA]:[],children:Object.keys(e._children).map(function(n){return e1(e._children[n],t+n+"/")})}}function t1(e,t,n,a){a.includes(n)&&e.push({id:a||"root",label:a.endsWith("/")?a.slice(0,a.length-1):a||"Root",tags:t.namespaced?[ZA]:[]}),Object.keys(t._children).forEach(function(s){t1(e,t._children[s],n,a+s+"/")})}function I2(e,t,n){t=n==="root"?t:t[n];var a=Object.keys(t),s={state:Object.keys(e.state).map(function(i){return{key:i,editable:!0,value:e.state[i]}})};if(a.length){var o=g2(t);s.getters=Object.keys(o).map(function(i){return{key:i.endsWith("/")?JA(i):i,editable:!1,value:Wd(function(){return o[i]})}})}return s}function g2(e){var t={};return Object.keys(e).forEach(function(n){var a=n.split("/");if(a.length>1){var s=t,o=a.pop();a.forEach(function(i){s[i]||(s[i]={_custom:{value:{},display:i,tooltip:"Module",abstract:!0}}),s=s[i]._custom.value}),s[o]=Wd(function(){return e[n]})}else t[n]=Wd(function(){return e[n]})}),t}function R2(e,t){var n=t.split("/").filter(function(a){return a});return n.reduce(function(a,s,o){var i=a[s];if(!i)throw new Error('Missing module "'+s+'" for path "'+t+'".');return o===n.length-1?i:i._children},t==="root"?e:e.root._children)}function Wd(e){try{return e()}catch(t){return t}}var pa=function(t,n){this.runtime=n,this._children=Object.create(null),this._rawModule=t;var a=t.state;this.state=(typeof a=="function"?a():a)||{}},n1={namespaced:{configurable:!0}};n1.namespaced.get=function(){return!!this._rawModule.namespaced};pa.prototype.addChild=function(t,n){this._children[t]=n};pa.prototype.removeChild=function(t){delete this._children[t]};pa.prototype.getChild=function(t){return this._children[t]};pa.prototype.hasChild=function(t){return t in this._children};pa.prototype.update=function(t){this._rawModule.namespaced=t.namespaced,t.actions&&(this._rawModule.actions=t.actions),t.mutations&&(this._rawModule.mutations=t.mutations),t.getters&&(this._rawModule.getters=t.getters)};pa.prototype.forEachChild=function(t){wo(this._children,t)};pa.prototype.forEachGetter=function(t){this._rawModule.getters&&wo(this._rawModule.getters,t)};pa.prototype.forEachAction=function(t){this._rawModule.actions&&wo(this._rawModule.actions,t)};pa.prototype.forEachMutation=function(t){this._rawModule.mutations&&wo(this._rawModule.mutations,t)};Object.defineProperties(pa.prototype,n1);var Ks=function(t){this.register([],t,!1)};Ks.prototype.get=function(t){return t.reduce(function(n,a){return n.getChild(a)},this.root)};Ks.prototype.getNamespace=function(t){var n=this.root;return t.reduce(function(a,s){return n=n.getChild(s),a+(n.namespaced?s+"/":"")},"")};Ks.prototype.update=function(t){a1([],this.root,t)};Ks.prototype.register=function(t,n,a){var s=this;a===void 0&&(a=!0);var o=new pa(n,a);if(t.length===0)this.root=o;else{var i=this.get(t.slice(0,-1));i.addChild(t[t.length-1],o)}n.modules&&wo(n.modules,function(r,u){s.register(t.concat(u),r,a)})};Ks.prototype.unregister=function(t){var n=this.get(t.slice(0,-1)),a=t[t.length-1],s=n.getChild(a);s&&s.runtime&&n.removeChild(a)};Ks.prototype.isRegistered=function(t){var n=this.get(t.slice(0,-1)),a=t[t.length-1];return n?n.hasChild(a):!1};function a1(e,t,n){if(t.update(n),n.modules)for(var a in n.modules){if(!t.getChild(a))return;a1(e.concat(a),t.getChild(a),n.modules[a])}}function N2(e){return new Ln(e)}var Ln=function(t){var n=this;t===void 0&&(t={});var a=t.plugins;a===void 0&&(a=[]);var s=t.strict;s===void 0&&(s=!1);var o=t.devtools;this._committing=!1,this._actions=Object.create(null),this._actionSubscribers=[],this._mutations=Object.create(null),this._wrappedGetters=Object.create(null),this._modules=new Ks(t),this._modulesNamespaceMap=Object.create(null),this._subscribers=[],this._makeLocalGettersCache=Object.create(null),this._scope=null,this._devtools=o;var i=this,r=this,u=r.dispatch,l=r.commit;this.dispatch=function(c,m){return u.call(i,c,m)},this.commit=function(c,m,_){return l.call(i,c,m,_)},this.strict=s;var d=this._modules.root.state;hl(this,d,[],this._modules.root),YE(this,d),a.forEach(function(E){return E(n)})},QE={state:{configurable:!0}};Ln.prototype.install=function(t,n){t.provide(n||qA,this),t.config.globalProperties.$store=this;var a=this._devtools!==void 0?this._devtools:!1;a&&S2(t,this)};QE.state.get=function(){return this._state.data};QE.state.set=function(e){};Ln.prototype.commit=function(t,n,a){var s=this,o=Eu(t,n,a),i=o.type,r=o.payload,u={type:i,payload:r},l=this._mutations[i];l&&(this._withCommit(function(){l.forEach(function(E){E(r)})}),this._subscribers.slice().forEach(function(d){return d(u,s.state)}))};Ln.prototype.dispatch=function(t,n){var a=this,s=Eu(t,n),o=s.type,i=s.payload,r={type:o,payload:i},u=this._actions[o];if(u){try{this._actionSubscribers.slice().filter(function(d){return d.before}).forEach(function(d){return d.before(r,a.state)})}catch{}var l=u.length>1?Promise.all(u.map(function(d){return d(i)})):u[0](i);return new Promise(function(d,E){l.then(function(c){try{a._actionSubscribers.filter(function(m){return m.after}).forEach(function(m){return m.after(r,a.state)})}catch{}d(c)},function(c){try{a._actionSubscribers.filter(function(m){return m.error}).forEach(function(m){return m.error(r,a.state,c)})}catch{}E(c)})})}};Ln.prototype.subscribe=function(t,n){return YA(t,this._subscribers,n)};Ln.prototype.subscribeAction=function(t,n){var a=typeof t=="function"?{before:t}:t;return YA(a,this._actionSubscribers,n)};Ln.prototype.watch=function(t,n,a){var s=this;return Le(function(){return t(s.state,s.getters)},n,Object.assign({},a))};Ln.prototype.replaceState=function(t){var n=this;this._withCommit(function(){n._state.data=t})};Ln.prototype.registerModule=function(t,n,a){a===void 0&&(a={}),typeof t=="string"&&(t=[t]),this._modules.register(t,n),hl(this,this.state,t,this._modules.get(t),a.preserveState),YE(this,this.state)};Ln.prototype.unregisterModule=function(t){var n=this;typeof t=="string"&&(t=[t]),this._modules.unregister(t),this._withCommit(function(){var a=XE(n.state,t.slice(0,-1));delete a[t[t.length-1]]}),XA(this)};Ln.prototype.hasModule=function(t){return typeof t=="string"&&(t=[t]),this._modules.isRegistered(t)};Ln.prototype.hotUpdate=function(t){this._modules.update(t),XA(this,!0)};Ln.prototype._withCommit=function(t){var n=this._committing;this._committing=!0,t(),this._committing=n};Object.defineProperties(Ln.prototype,QE);function $e(){return jA()}function He(){const e=$e(),t=F(()=>e.getters[te.GETTERS.LANGUAGE]),n=F(()=>e.getters[te.GETTERS.APP_CONFIG]),a=F(()=>e.getters[te.GETTERS.APP_LOADING]),s=F(()=>e.getters[te.GETTERS.DISPLAY_OPTIONS]),o=F(()=>e.getters[te.GETTERS.DARK_MODE]),i=F(()=>l()),r=F(()=>e.getters[te.GETTERS.ERROR_MESSAGES]),u=F(()=>e.getters[te.GETTERS.LOCALE]);function l(){return o.value===null&&window.matchMedia("(prefers-color-scheme: dark)").matches?!0:o.value===!0}return{appConfig:n,appLanguage:t,appLoading:a,darkMode:o,darkTheme:i,displayOptions:s,errorMessages:r,locale:u}}/*! + * vue-router v4.5.0 + * (c) 2024 Eduardo San Martin Morote + * @license MIT + */const io=typeof document<"u";function s1(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function v2(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&s1(e.default)}const ft=Object.assign;function uc(e,t){const n={};for(const a in t){const s=t[a];n[a]=la(s)?s.map(e):e(s)}return n}const pi=()=>{},la=Array.isArray,o1=/#/g,b2=/&/g,C2=/\//g,P2=/=/g,D2=/\?/g,i1=/\+/g,L2=/%5B/g,y2=/%5D/g,r1=/%5E/g,$2=/%60/g,u1=/%7B/g,U2=/%7C/g,l1=/%7D/g,k2=/%20/g;function ZE(e){return encodeURI(""+e).replace(U2,"|").replace(L2,"[").replace(y2,"]")}function w2(e){return ZE(e).replace(u1,"{").replace(l1,"}").replace(r1,"^")}function Fd(e){return ZE(e).replace(i1,"%2B").replace(k2,"+").replace(o1,"%23").replace(b2,"%26").replace($2,"`").replace(u1,"{").replace(l1,"}").replace(r1,"^")}function M2(e){return Fd(e).replace(P2,"%3D")}function W2(e){return ZE(e).replace(o1,"%23").replace(D2,"%3F")}function F2(e){return e==null?"":W2(e).replace(C2,"%2F")}function ki(e){try{return decodeURIComponent(""+e)}catch{}return""+e}const z2=/\/$/,x2=e=>e.replace(z2,"");function lc(e,t,n="/"){let a,s={},o="",i="";const r=t.indexOf("#");let u=t.indexOf("?");return r=0&&(u=-1),u>-1&&(a=t.slice(0,u),o=t.slice(u+1,r>-1?r:t.length),s=e(o)),r>-1&&(a=a||t.slice(0,r),i=t.slice(r,t.length)),a=H2(a??t,n),{fullPath:a+(o&&"?")+o+i,path:a,query:s,hash:ki(i)}}function B2(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function PT(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function G2(e,t,n){const a=t.matched.length-1,s=n.matched.length-1;return a>-1&&a===s&&Io(t.matched[a],n.matched[s])&&c1(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function Io(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function c1(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(const n in e)if(!V2(e[n],t[n]))return!1;return!0}function V2(e,t){return la(e)?DT(e,t):la(t)?DT(t,e):e===t}function DT(e,t){return la(t)?e.length===t.length&&e.every((n,a)=>n===t[a]):e.length===1&&e[0]===t}function H2(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),a=e.split("/"),s=a[a.length-1];(s===".."||s===".")&&a.push("");let o=n.length-1,i,r;for(i=0;i1&&o--;else break;return n.slice(0,o).join("/")+"/"+a.slice(i).join("/")}const Xa={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};var wi;(function(e){e.pop="pop",e.push="push"})(wi||(wi={}));var mi;(function(e){e.back="back",e.forward="forward",e.unknown=""})(mi||(mi={}));function K2(e){if(!e)if(io){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),x2(e)}const q2=/^[^#]+#/;function j2(e,t){return e.replace(q2,"#")+t}function Y2(e,t){const n=document.documentElement.getBoundingClientRect(),a=e.getBoundingClientRect();return{behavior:t.behavior,left:a.left-n.left-(t.left||0),top:a.top-n.top-(t.top||0)}}const Sl=()=>({left:window.scrollX,top:window.scrollY});function X2(e){let t;if("el"in e){const n=e.el,a=typeof n=="string"&&n.startsWith("#"),s=typeof n=="string"?a?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!s)return;t=Y2(s,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function LT(e,t){return(history.state?history.state.position-t:-1)+e}const zd=new Map;function Q2(e,t){zd.set(e,t)}function Z2(e){const t=zd.get(e);return zd.delete(e),t}let J2=()=>location.protocol+"//"+location.host;function d1(e,t){const{pathname:n,search:a,hash:s}=t,o=e.indexOf("#");if(o>-1){let r=s.includes(e.slice(o))?e.slice(o).length:1,u=s.slice(r);return u[0]!=="/"&&(u="/"+u),PT(u,"")}return PT(n,e)+a+s}function eP(e,t,n,a){let s=[],o=[],i=null;const r=({state:c})=>{const m=d1(e,location),_=n.value,h=t.value;let O=0;if(c){if(n.value=m,t.value=c,i&&i===_){i=null;return}O=h?c.position-h.position:0}else a(m);s.forEach(S=>{S(n.value,_,{delta:O,type:wi.pop,direction:O?O>0?mi.forward:mi.back:mi.unknown})})};function u(){i=n.value}function l(c){s.push(c);const m=()=>{const _=s.indexOf(c);_>-1&&s.splice(_,1)};return o.push(m),m}function d(){const{history:c}=window;c.state&&c.replaceState(ft({},c.state,{scroll:Sl()}),"")}function E(){for(const c of o)c();o=[],window.removeEventListener("popstate",r),window.removeEventListener("beforeunload",d)}return window.addEventListener("popstate",r),window.addEventListener("beforeunload",d,{passive:!0}),{pauseListeners:u,listen:l,destroy:E}}function yT(e,t,n,a=!1,s=!1){return{back:e,current:t,forward:n,replaced:a,position:window.history.length,scroll:s?Sl():null}}function tP(e){const{history:t,location:n}=window,a={value:d1(e,n)},s={value:t.state};s.value||o(a.value,{back:null,current:a.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function o(u,l,d){const E=e.indexOf("#"),c=E>-1?(n.host&&document.querySelector("base")?e:e.slice(E))+u:J2()+e+u;try{t[d?"replaceState":"pushState"](l,"",c),s.value=l}catch(m){console.error(m),n[d?"replace":"assign"](c)}}function i(u,l){const d=ft({},t.state,yT(s.value.back,u,s.value.forward,!0),l,{position:s.value.position});o(u,d,!0),a.value=u}function r(u,l){const d=ft({},s.value,t.state,{forward:u,scroll:Sl()});o(d.current,d,!0);const E=ft({},yT(a.value,u,null),{position:d.position+1},l);o(u,E,!1),a.value=u}return{location:a,state:s,push:r,replace:i}}function nP(e){e=K2(e);const t=tP(e),n=eP(e,t.state,t.location,t.replace);function a(o,i=!0){i||n.pauseListeners(),history.go(o)}const s=ft({location:"",base:e,go:a,createHref:j2.bind(null,e)},t,n);return Object.defineProperty(s,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(s,"state",{enumerable:!0,get:()=>t.state.value}),s}function aP(e){return typeof e=="string"||e&&typeof e=="object"}function E1(e){return typeof e=="string"||typeof e=="symbol"}const p1=Symbol("");var $T;(function(e){e[e.aborted=4]="aborted",e[e.cancelled=8]="cancelled",e[e.duplicated=16]="duplicated"})($T||($T={}));function go(e,t){return ft(new Error,{type:e,[p1]:!0},t)}function Da(e,t){return e instanceof Error&&p1 in e&&(t==null||!!(e.type&t))}const UT="[^/]+?",sP={sensitive:!1,strict:!1,start:!0,end:!0},oP=/[.+*?^${}()[\]/\\]/g;function iP(e,t){const n=ft({},sP,t),a=[];let s=n.start?"^":"";const o=[];for(const l of e){const d=l.length?[]:[90];n.strict&&!l.length&&(s+="/");for(let E=0;Et.length?t.length===1&&t[0]===80?1:-1:0}function m1(e,t){let n=0;const a=e.score,s=t.score;for(;n0&&t[t.length-1]<0}const uP={type:0,value:""},lP=/[a-zA-Z0-9_]/;function cP(e){if(!e)return[[]];if(e==="/")return[[uP]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(m){throw new Error(`ERR (${n})/"${l}": ${m}`)}let n=0,a=n;const s=[];let o;function i(){o&&s.push(o),o=[]}let r=0,u,l="",d="";function E(){l&&(n===0?o.push({type:0,value:l}):n===1||n===2||n===3?(o.length>1&&(u==="*"||u==="+")&&t(`A repeatable param (${l}) must be alone in its segment. eg: '/:ids+.`),o.push({type:1,value:l,regexp:d,repeatable:u==="*"||u==="+",optional:u==="*"||u==="?"})):t("Invalid state to consume buffer"),l="")}function c(){l+=u}for(;r{i(g)}:pi}function i(E){if(E1(E)){const c=a.get(E);c&&(a.delete(E),n.splice(n.indexOf(c),1),c.children.forEach(i),c.alias.forEach(i))}else{const c=n.indexOf(E);c>-1&&(n.splice(c,1),E.record.name&&a.delete(E.record.name),E.children.forEach(i),E.alias.forEach(i))}}function r(){return n}function u(E){const c=TP(E,n);n.splice(c,0,E),E.record.name&&!WT(E)&&a.set(E.record.name,E)}function l(E,c){let m,_={},h,O;if("name"in E&&E.name){if(m=a.get(E.name),!m)throw go(1,{location:E});O=m.record.name,_=ft(wT(c.params,m.keys.filter(g=>!g.optional).concat(m.parent?m.parent.keys.filter(g=>g.optional):[]).map(g=>g.name)),E.params&&wT(E.params,m.keys.map(g=>g.name))),h=m.stringify(_)}else if(E.path!=null)h=E.path,m=n.find(g=>g.re.test(h)),m&&(_=m.parse(h),O=m.record.name);else{if(m=c.name?a.get(c.name):n.find(g=>g.re.test(c.path)),!m)throw go(1,{location:E,currentLocation:c});O=m.record.name,_=ft({},c.params,E.params),h=m.stringify(_)}const S=[];let R=m;for(;R;)S.unshift(R.record),R=R.parent;return{name:O,path:h,params:_,matched:S,meta:mP(S)}}e.forEach(E=>o(E));function d(){n.length=0,a.clear()}return{addRoute:o,resolve:l,removeRoute:i,clearRoutes:d,getRoutes:r,getRecordMatcher:s}}function wT(e,t){const n={};for(const a of t)a in e&&(n[a]=e[a]);return n}function MT(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:pP(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,"mods",{value:{}}),t}function pP(e){const t={},n=e.props||!1;if("component"in e)t.default=n;else for(const a in e.components)t[a]=typeof n=="object"?n[a]:n;return t}function WT(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function mP(e){return e.reduce((t,n)=>ft(t,n.meta),{})}function FT(e,t){const n={};for(const a in e)n[a]=a in t?t[a]:e[a];return n}function TP(e,t){let n=0,a=t.length;for(;n!==a;){const o=n+a>>1;m1(e,t[o])<0?a=o:n=o+1}const s=_P(e);return s&&(a=t.lastIndexOf(s,a-1)),a}function _P(e){let t=e;for(;t=t.parent;)if(T1(t)&&m1(e,t)===0)return t}function T1({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function fP(e){const t={};if(e===""||e==="?")return t;const a=(e[0]==="?"?e.slice(1):e).split("&");for(let s=0;so&&Fd(o)):[a&&Fd(a)]).forEach(o=>{o!==void 0&&(t+=(t.length?"&":"")+n,o!=null&&(t+="="+o))})}return t}function hP(e){const t={};for(const n in e){const a=e[n];a!==void 0&&(t[n]=la(a)?a.map(s=>s==null?null:""+s):a==null?a:""+a)}return t}const SP=Symbol(""),xT=Symbol(""),Al=Symbol(""),JE=Symbol(""),xd=Symbol("");function Xo(){let e=[];function t(a){return e.push(a),()=>{const s=e.indexOf(a);s>-1&&e.splice(s,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function as(e,t,n,a,s,o=i=>i()){const i=a&&(a.enterCallbacks[s]=a.enterCallbacks[s]||[]);return()=>new Promise((r,u)=>{const l=c=>{c===!1?u(go(4,{from:n,to:t})):c instanceof Error?u(c):aP(c)?u(go(2,{from:t,to:c})):(i&&a.enterCallbacks[s]===i&&typeof c=="function"&&i.push(c),r())},d=o(()=>e.call(a&&a.instances[s],t,n,l));let E=Promise.resolve(d);e.length<3&&(E=E.then(l)),E.catch(c=>u(c))})}function cc(e,t,n,a,s=o=>o()){const o=[];for(const i of e)for(const r in i.components){let u=i.components[r];if(!(t!=="beforeRouteEnter"&&!i.instances[r]))if(s1(u)){const d=(u.__vccOpts||u)[t];d&&o.push(as(d,n,a,i,r,s))}else{let l=u();o.push(()=>l.then(d=>{if(!d)throw new Error(`Couldn't resolve component "${r}" at "${i.path}"`);const E=v2(d)?d.default:d;i.mods[r]=d,i.components[r]=E;const m=(E.__vccOpts||E)[t];return m&&as(m,n,a,i,r,s)()}))}}return o}function BT(e){const t=Ut(Al),n=Ut(JE),a=F(()=>{const u=T(e.to);return t.resolve(u)}),s=F(()=>{const{matched:u}=a.value,{length:l}=u,d=u[l-1],E=n.matched;if(!d||!E.length)return-1;const c=E.findIndex(Io.bind(null,d));if(c>-1)return c;const m=GT(u[l-2]);return l>1&>(d)===m&&E[E.length-1].path!==m?E.findIndex(Io.bind(null,u[l-2])):c}),o=F(()=>s.value>-1&&RP(n.params,a.value.params)),i=F(()=>s.value>-1&&s.value===n.matched.length-1&&c1(n.params,a.value.params));function r(u={}){if(gP(u)){const l=t[T(e.replace)?"replace":"push"](T(e.to)).catch(pi);return e.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>l),l}return Promise.resolve()}return{route:a,href:F(()=>a.value.href),isActive:o,isExactActive:i,navigate:r}}function AP(e){return e.length===1?e[0]:e}const OP=Q({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"}},useLink:BT,setup(e,{slots:t}){const n=kt(BT(e)),{options:a}=Ut(Al),s=F(()=>({[VT(e.activeClass,a.linkActiveClass,"router-link-active")]:n.isActive,[VT(e.exactActiveClass,a.linkExactActiveClass,"router-link-exact-active")]:n.isExactActive}));return()=>{const o=t.default&&AP(t.default(n));return e.custom?o:Cn("a",{"aria-current":n.isExactActive?e.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:s.value},o)}}}),IP=OP;function gP(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function RP(e,t){for(const n in t){const a=t[n],s=e[n];if(typeof a=="string"){if(a!==s)return!1}else if(!la(s)||s.length!==a.length||a.some((o,i)=>o!==s[i]))return!1}return!0}function GT(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const VT=(e,t,n)=>e??t??n,NP=Q({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:n}){const a=Ut(xd),s=F(()=>e.route||a.value),o=Ut(xT,0),i=F(()=>{let l=T(o);const{matched:d}=s.value;let E;for(;(E=d[l])&&!E.components;)l++;return l}),r=F(()=>s.value.matched[i.value]);On(xT,F(()=>i.value+1)),On(SP,r),On(xd,s);const u=Se();return Le(()=>[u.value,r.value,e.name],([l,d,E],[c,m,_])=>{d&&(d.instances[E]=l,m&&m!==d&&l&&l===c&&(d.leaveGuards.size||(d.leaveGuards=m.leaveGuards),d.updateGuards.size||(d.updateGuards=m.updateGuards))),l&&d&&(!m||!Io(d,m)||!c)&&(d.enterCallbacks[E]||[]).forEach(h=>h(l))},{flush:"post"}),()=>{const l=s.value,d=e.name,E=r.value,c=E&&E.components[d];if(!c)return HT(n.default,{Component:c,route:l});const m=E.props[d],_=m?m===!0?l.params:typeof m=="function"?m(l):m:null,O=Cn(c,ft({},_,t,{onVnodeUnmounted:S=>{S.component.isUnmounted&&(E.instances[d]=null)},ref:u}));return HT(n.default,{Component:O,route:l})||O}}});function HT(e,t){if(!e)return null;const n=e(t);return n.length===1?n[0]:n}const vP=NP;function bP(e){const t=EP(e.routes,e),n=e.parseQuery||fP,a=e.stringifyQuery||zT,s=e.history,o=Xo(),i=Xo(),r=Xo(),u=sl(Xa);let l=Xa;io&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const d=uc.bind(null,de=>""+de),E=uc.bind(null,F2),c=uc.bind(null,ki);function m(de,H){let fe,Ce;return E1(de)?(fe=t.getRecordMatcher(de),Ce=H):Ce=de,t.addRoute(Ce,fe)}function _(de){const H=t.getRecordMatcher(de);H&&t.removeRoute(H)}function h(){return t.getRoutes().map(de=>de.record)}function O(de){return!!t.getRecordMatcher(de)}function S(de,H){if(H=ft({},H||u.value),typeof de=="string"){const M=lc(n,de,H.path),Y=t.resolve({path:M.path},H),pe=s.createHref(M.fullPath);return ft(M,Y,{params:c(Y.params),hash:ki(M.hash),redirectedFrom:void 0,href:pe})}let fe;if(de.path!=null)fe=ft({},de,{path:lc(n,de.path,H.path).path});else{const M=ft({},de.params);for(const Y in M)M[Y]==null&&delete M[Y];fe=ft({},de,{params:E(M)}),H.params=E(H.params)}const Ce=t.resolve(fe,H),ae=de.hash||"";Ce.params=d(c(Ce.params));const Ie=B2(a,ft({},de,{hash:w2(ae),path:Ce.path})),U=s.createHref(Ie);return ft({fullPath:Ie,hash:ae,query:a===zT?hP(de.query):de.query||{}},Ce,{redirectedFrom:void 0,href:U})}function R(de){return typeof de=="string"?lc(n,de,u.value.path):ft({},de)}function g(de,H){if(l!==de)return go(8,{from:H,to:de})}function I(de){return C(de)}function N(de){return I(ft(R(de),{replace:!0}))}function b(de){const H=de.matched[de.matched.length-1];if(H&&H.redirect){const{redirect:fe}=H;let Ce=typeof fe=="function"?fe(de):fe;return typeof Ce=="string"&&(Ce=Ce.includes("?")||Ce.includes("#")?Ce=R(Ce):{path:Ce},Ce.params={}),ft({query:de.query,hash:de.hash,params:Ce.path!=null?{}:de.params},Ce)}}function C(de,H){const fe=l=S(de),Ce=u.value,ae=de.state,Ie=de.force,U=de.replace===!0,M=b(fe);if(M)return C(ft(R(M),{state:typeof M=="object"?ft({},ae,M.state):ae,force:Ie,replace:U}),H||fe);const Y=fe;Y.redirectedFrom=H;let pe;return!Ie&&G2(a,Ce,fe)&&(pe=go(16,{to:Y,from:Ce}),ot(Ce,Ce,!0,!1)),(pe?Promise.resolve(pe):$(Y,Ce)).catch(oe=>Da(oe)?Da(oe,2)?oe:xe(oe):De(oe,Y,Ce)).then(oe=>{if(oe){if(Da(oe,2))return C(ft({replace:U},R(oe.to),{state:typeof oe.to=="object"?ft({},ae,oe.to.state):ae,force:Ie}),H||Y)}else oe=z(Y,Ce,!0,U,ae);return y(Y,Ce,oe),oe})}function k(de,H){const fe=g(de,H);return fe?Promise.reject(fe):Promise.resolve()}function P(de){const H=pt.values().next().value;return H&&typeof H.runWithContext=="function"?H.runWithContext(de):de()}function $(de,H){let fe;const[Ce,ae,Ie]=CP(de,H);fe=cc(Ce.reverse(),"beforeRouteLeave",de,H);for(const M of Ce)M.leaveGuards.forEach(Y=>{fe.push(as(Y,de,H))});const U=k.bind(null,de,H);return fe.push(U),It(fe).then(()=>{fe=[];for(const M of o.list())fe.push(as(M,de,H));return fe.push(U),It(fe)}).then(()=>{fe=cc(ae,"beforeRouteUpdate",de,H);for(const M of ae)M.updateGuards.forEach(Y=>{fe.push(as(Y,de,H))});return fe.push(U),It(fe)}).then(()=>{fe=[];for(const M of Ie)if(M.beforeEnter)if(la(M.beforeEnter))for(const Y of M.beforeEnter)fe.push(as(Y,de,H));else fe.push(as(M.beforeEnter,de,H));return fe.push(U),It(fe)}).then(()=>(de.matched.forEach(M=>M.enterCallbacks={}),fe=cc(Ie,"beforeRouteEnter",de,H,P),fe.push(U),It(fe))).then(()=>{fe=[];for(const M of i.list())fe.push(as(M,de,H));return fe.push(U),It(fe)}).catch(M=>Da(M,8)?M:Promise.reject(M))}function y(de,H,fe){r.list().forEach(Ce=>P(()=>Ce(de,H,fe)))}function z(de,H,fe,Ce,ae){const Ie=g(de,H);if(Ie)return Ie;const U=H===Xa,M=io?history.state:{};fe&&(Ce||U?s.replace(de.fullPath,ft({scroll:U&&M&&M.scroll},ae)):s.push(de.fullPath,ae)),u.value=de,ot(de,H,fe,U),xe()}let Z;function Ae(){Z||(Z=s.listen((de,H,fe)=>{if(!wt.listening)return;const Ce=S(de),ae=b(Ce);if(ae){C(ft(ae,{replace:!0,force:!0}),Ce).catch(pi);return}l=Ce;const Ie=u.value;io&&Q2(LT(Ie.fullPath,fe.delta),Sl()),$(Ce,Ie).catch(U=>Da(U,12)?U:Da(U,2)?(C(ft(R(U.to),{force:!0}),Ce).then(M=>{Da(M,20)&&!fe.delta&&fe.type===wi.pop&&s.go(-1,!1)}).catch(pi),Promise.reject()):(fe.delta&&s.go(-fe.delta,!1),De(U,Ce,Ie))).then(U=>{U=U||z(Ce,Ie,!1),U&&(fe.delta&&!Da(U,8)?s.go(-fe.delta,!1):fe.type===wi.pop&&Da(U,20)&&s.go(-1,!1)),y(Ce,Ie,U)}).catch(pi)}))}let J=Xo(),ce=Xo(),Te;function De(de,H,fe){xe(de);const Ce=ce.list();return Ce.length?Ce.forEach(ae=>ae(de,H,fe)):console.error(de),Promise.reject(de)}function Ve(){return Te&&u.value!==Xa?Promise.resolve():new Promise((de,H)=>{J.add([de,H])})}function xe(de){return Te||(Te=!de,Ae(),J.list().forEach(([H,fe])=>de?fe(de):H()),J.reset()),de}function ot(de,H,fe,Ce){const{scrollBehavior:ae}=e;if(!io||!ae)return Promise.resolve();const Ie=!fe&&Z2(LT(de.fullPath,0))||(Ce||!fe)&&history.state&&history.state.scroll||null;return un().then(()=>ae(de,H,Ie)).then(U=>U&&X2(U)).catch(U=>De(U,de,H))}const re=de=>s.go(de);let Oe;const pt=new Set,wt={currentRoute:u,listening:!0,addRoute:m,removeRoute:_,clearRoutes:t.clearRoutes,hasRoute:O,getRoutes:h,resolve:S,options:e,push:I,replace:N,go:re,back:()=>re(-1),forward:()=>re(1),beforeEach:o.add,beforeResolve:i.add,afterEach:r.add,onError:ce.add,isReady:Ve,install(de){const H=this;de.component("RouterLink",IP),de.component("RouterView",vP),de.config.globalProperties.$router=H,Object.defineProperty(de.config.globalProperties,"$route",{enumerable:!0,get:()=>T(u)}),io&&!Oe&&u.value===Xa&&(Oe=!0,I(s.location).catch(ae=>{}));const fe={};for(const ae in Xa)Object.defineProperty(fe,ae,{get:()=>u.value[ae],enumerable:!0});de.provide(Al,H),de.provide(JE,U0(fe)),de.provide(xd,u);const Ce=de.unmount;pt.add(de),de.unmount=function(){pt.delete(de),pt.size<1&&(l=Xa,Z&&Z(),Z=null,u.value=Xa,Oe=!1,Te=!1),Ce()}}};function It(de){return de.reduce((H,fe)=>H.then(()=>P(fe)),Promise.resolve())}return wt}function CP(e,t){const n=[],a=[],s=[],o=Math.max(t.matched.length,e.matched.length);for(let i=0;iIo(l,r))?a.push(r):n.push(r));const u=e.matched[i];u&&(t.matched.find(l=>Io(l,u))||s.push(u))}return[n,a,s]}function yn(){return Ut(Al)}function it(e){return Ut(JE)}function pu(e){"@babel/helpers - typeof";return pu=typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?function(t){return typeof t}:function(t){return t&&typeof Symbol=="function"&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},pu(e)}function tn(e){if(e===null||e===!0||e===!1)return NaN;var t=Number(e);return isNaN(t)?t:t<0?Math.ceil(t):Math.floor(t)}function Ye(e,t){if(t.length1?"s":"")+" required, but only "+t.length+" present")}function et(e){Ye(1,arguments);var t=Object.prototype.toString.call(e);return e instanceof Date||pu(e)==="object"&&t==="[object Date]"?new Date(e.getTime()):typeof e=="number"||t==="[object Number]"?new Date(e):((typeof e=="string"||t==="[object String]")&&typeof console<"u"&&(console.warn("Starting with v2.0.0-beta.1 date-fns doesn't accept strings as date arguments. Please use `parseISO` to parse strings. See: https://github.com/date-fns/date-fns/blob/master/docs/upgradeGuide.md#string-arguments"),console.warn(new Error().stack)),new Date(NaN))}function ar(e,t){Ye(2,arguments);var n=et(e),a=tn(t);return isNaN(a)?new Date(NaN):(a&&n.setDate(n.getDate()+a),n)}function Ro(e,t){Ye(2,arguments);var n=et(e),a=tn(t);if(isNaN(a))return new Date(NaN);if(!a)return n;var s=n.getDate(),o=new Date(n.getTime());o.setMonth(n.getMonth()+a+1,0);var i=o.getDate();return s>=i?o:(n.setFullYear(o.getFullYear(),o.getMonth(),s),n)}function PP(e,t){Ye(2,arguments);var n=et(e).getTime(),a=tn(t);return new Date(n+a)}var DP={};function qs(){return DP}function Ol(e,t){var n,a,s,o,i,r,u,l;Ye(1,arguments);var d=qs(),E=tn((n=(a=(s=(o=t==null?void 0:t.weekStartsOn)!==null&&o!==void 0?o:t==null||(i=t.locale)===null||i===void 0||(r=i.options)===null||r===void 0?void 0:r.weekStartsOn)!==null&&s!==void 0?s:d.weekStartsOn)!==null&&a!==void 0?a:(u=d.locale)===null||u===void 0||(l=u.options)===null||l===void 0?void 0:l.weekStartsOn)!==null&&n!==void 0?n:0);if(!(E>=0&&E<=6))throw new RangeError("weekStartsOn must be between 0 and 6 inclusively");var c=et(e),m=c.getDay(),_=(m0?1:s}function _1(e,t){Ye(2,arguments);var n=KT(e),a=KT(t);return n.getTime()===a.getTime()}function LP(e){return Ye(1,arguments),e instanceof Date||pu(e)==="object"&&Object.prototype.toString.call(e)==="[object Date]"}function yP(e){if(Ye(1,arguments),!LP(e)&&typeof e!="number")return!1;var t=et(e);return!isNaN(Number(t))}function $P(e,t){Ye(2,arguments);var n=et(e),a=et(t),s=n.getFullYear()-a.getFullYear(),o=n.getMonth()-a.getMonth();return s*12+o}function UP(e,t){return Ye(2,arguments),et(e).getTime()-et(t).getTime()}var qT={ceil:Math.ceil,round:Math.round,floor:Math.floor,trunc:function(t){return t<0?Math.ceil(t):Math.floor(t)}},kP="trunc";function wP(e){return e?qT[e]:qT[kP]}function MP(e){Ye(1,arguments);var t=et(e);return t.setHours(23,59,59,999),t}function sr(e){Ye(1,arguments);var t=et(e),n=t.getMonth();return t.setFullYear(t.getFullYear(),n+1,0),t.setHours(23,59,59,999),t}function WP(e){Ye(1,arguments);var t=et(e);return MP(t).getTime()===sr(t).getTime()}function FP(e,t){Ye(2,arguments);var n=et(e),a=et(t),s=Ti(n,a),o=Math.abs($P(n,a)),i;if(o<1)i=0;else{n.getMonth()===1&&n.getDate()>27&&n.setDate(30),n.setMonth(n.getMonth()-s*o);var r=Ti(n,a)===-s;WP(et(e))&&o===1&&Ti(e,a)===1&&(r=!1),i=s*(o-Number(r))}return i===0?0:i}function zP(e,t,n){Ye(2,arguments);var a=UP(e,t)/1e3;return wP(void 0)(a)}function or(e){Ye(1,arguments);var t=et(e);return t.setDate(1),t.setHours(0,0,0,0),t}function f1(e){Ye(1,arguments);var t=et(e),n=t.getFullYear();return t.setFullYear(n+1,0,0),t.setHours(23,59,59,999),t}function ep(e){Ye(1,arguments);var t=et(e),n=new Date(0);return n.setFullYear(t.getFullYear(),0,1),n.setHours(0,0,0,0),n}function tp(e,t){var n,a,s,o,i,r,u,l;Ye(1,arguments);var d=qs(),E=tn((n=(a=(s=(o=t==null?void 0:t.weekStartsOn)!==null&&o!==void 0?o:t==null||(i=t.locale)===null||i===void 0||(r=i.options)===null||r===void 0?void 0:r.weekStartsOn)!==null&&s!==void 0?s:d.weekStartsOn)!==null&&a!==void 0?a:(u=d.locale)===null||u===void 0||(l=u.options)===null||l===void 0?void 0:l.weekStartsOn)!==null&&n!==void 0?n:0);if(!(E>=0&&E<=6))throw new RangeError("weekStartsOn must be between 0 and 6 inclusively");var c=et(e),m=c.getDay(),_=(m=s.getTime()?n+1:t.getTime()>=i.getTime()?n:n-1}function VP(e){Ye(1,arguments);var t=h1(e),n=new Date(0);n.setUTCFullYear(t,0,4),n.setUTCHours(0,0,0,0);var a=Tu(n);return a}var HP=6048e5;function KP(e){Ye(1,arguments);var t=et(e),n=Tu(t).getTime()-VP(t).getTime();return Math.round(n/HP)+1}function No(e,t){var n,a,s,o,i,r,u,l;Ye(1,arguments);var d=qs(),E=tn((n=(a=(s=(o=t==null?void 0:t.weekStartsOn)!==null&&o!==void 0?o:t==null||(i=t.locale)===null||i===void 0||(r=i.options)===null||r===void 0?void 0:r.weekStartsOn)!==null&&s!==void 0?s:d.weekStartsOn)!==null&&a!==void 0?a:(u=d.locale)===null||u===void 0||(l=u.options)===null||l===void 0?void 0:l.weekStartsOn)!==null&&n!==void 0?n:0);if(!(E>=0&&E<=6))throw new RangeError("weekStartsOn must be between 0 and 6 inclusively");var c=et(e),m=c.getUTCDay(),_=(m=1&&m<=7))throw new RangeError("firstWeekContainsDate must be between 1 and 7 inclusively");var _=new Date(0);_.setUTCFullYear(E+1,0,m),_.setUTCHours(0,0,0,0);var h=No(_,t),O=new Date(0);O.setUTCFullYear(E,0,m),O.setUTCHours(0,0,0,0);var S=No(O,t);return d.getTime()>=h.getTime()?E+1:d.getTime()>=S.getTime()?E:E-1}function qP(e,t){var n,a,s,o,i,r,u,l;Ye(1,arguments);var d=qs(),E=tn((n=(a=(s=(o=t==null?void 0:t.firstWeekContainsDate)!==null&&o!==void 0?o:t==null||(i=t.locale)===null||i===void 0||(r=i.options)===null||r===void 0?void 0:r.firstWeekContainsDate)!==null&&s!==void 0?s:d.firstWeekContainsDate)!==null&&a!==void 0?a:(u=d.locale)===null||u===void 0||(l=u.options)===null||l===void 0?void 0:l.firstWeekContainsDate)!==null&&n!==void 0?n:1),c=S1(e,t),m=new Date(0);m.setUTCFullYear(c,0,E),m.setUTCHours(0,0,0,0);var _=No(m,t);return _}var jP=6048e5;function YP(e,t){Ye(1,arguments);var n=et(e),a=No(n,t).getTime()-qP(n,t).getTime();return Math.round(a/jP)+1}function At(e,t){for(var n=e<0?"-":"",a=Math.abs(e).toString();a.length0?a:1-a;return At(n==="yy"?s%100:s,n.length)},M:function(t,n){var a=t.getUTCMonth();return n==="M"?String(a+1):At(a+1,2)},d:function(t,n){return At(t.getUTCDate(),n.length)},a:function(t,n){var a=t.getUTCHours()/12>=1?"pm":"am";switch(n){case"a":case"aa":return a.toUpperCase();case"aaa":return a;case"aaaaa":return a[0];case"aaaa":default:return a==="am"?"a.m.":"p.m."}},h:function(t,n){return At(t.getUTCHours()%12||12,n.length)},H:function(t,n){return At(t.getUTCHours(),n.length)},m:function(t,n){return At(t.getUTCMinutes(),n.length)},s:function(t,n){return At(t.getUTCSeconds(),n.length)},S:function(t,n){var a=n.length,s=t.getUTCMilliseconds(),o=Math.floor(s*Math.pow(10,a-3));return At(o,n.length)}},to={am:"am",pm:"pm",midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},XP={G:function(t,n,a){var s=t.getUTCFullYear()>0?1:0;switch(n){case"G":case"GG":case"GGG":return a.era(s,{width:"abbreviated"});case"GGGGG":return a.era(s,{width:"narrow"});case"GGGG":default:return a.era(s,{width:"wide"})}},y:function(t,n,a){if(n==="yo"){var s=t.getUTCFullYear(),o=s>0?s:1-s;return a.ordinalNumber(o,{unit:"year"})}return Qa.y(t,n)},Y:function(t,n,a,s){var o=S1(t,s),i=o>0?o:1-o;if(n==="YY"){var r=i%100;return At(r,2)}return n==="Yo"?a.ordinalNumber(i,{unit:"year"}):At(i,n.length)},R:function(t,n){var a=h1(t);return At(a,n.length)},u:function(t,n){var a=t.getUTCFullYear();return At(a,n.length)},Q:function(t,n,a){var s=Math.ceil((t.getUTCMonth()+1)/3);switch(n){case"Q":return String(s);case"QQ":return At(s,2);case"Qo":return a.ordinalNumber(s,{unit:"quarter"});case"QQQ":return a.quarter(s,{width:"abbreviated",context:"formatting"});case"QQQQQ":return a.quarter(s,{width:"narrow",context:"formatting"});case"QQQQ":default:return a.quarter(s,{width:"wide",context:"formatting"})}},q:function(t,n,a){var s=Math.ceil((t.getUTCMonth()+1)/3);switch(n){case"q":return String(s);case"qq":return At(s,2);case"qo":return a.ordinalNumber(s,{unit:"quarter"});case"qqq":return a.quarter(s,{width:"abbreviated",context:"standalone"});case"qqqqq":return a.quarter(s,{width:"narrow",context:"standalone"});case"qqqq":default:return a.quarter(s,{width:"wide",context:"standalone"})}},M:function(t,n,a){var s=t.getUTCMonth();switch(n){case"M":case"MM":return Qa.M(t,n);case"Mo":return a.ordinalNumber(s+1,{unit:"month"});case"MMM":return a.month(s,{width:"abbreviated",context:"formatting"});case"MMMMM":return a.month(s,{width:"narrow",context:"formatting"});case"MMMM":default:return a.month(s,{width:"wide",context:"formatting"})}},L:function(t,n,a){var s=t.getUTCMonth();switch(n){case"L":return String(s+1);case"LL":return At(s+1,2);case"Lo":return a.ordinalNumber(s+1,{unit:"month"});case"LLL":return a.month(s,{width:"abbreviated",context:"standalone"});case"LLLLL":return a.month(s,{width:"narrow",context:"standalone"});case"LLLL":default:return a.month(s,{width:"wide",context:"standalone"})}},w:function(t,n,a,s){var o=YP(t,s);return n==="wo"?a.ordinalNumber(o,{unit:"week"}):At(o,n.length)},I:function(t,n,a){var s=KP(t);return n==="Io"?a.ordinalNumber(s,{unit:"week"}):At(s,n.length)},d:function(t,n,a){return n==="do"?a.ordinalNumber(t.getUTCDate(),{unit:"date"}):Qa.d(t,n)},D:function(t,n,a){var s=GP(t);return n==="Do"?a.ordinalNumber(s,{unit:"dayOfYear"}):At(s,n.length)},E:function(t,n,a){var s=t.getUTCDay();switch(n){case"E":case"EE":case"EEE":return a.day(s,{width:"abbreviated",context:"formatting"});case"EEEEE":return a.day(s,{width:"narrow",context:"formatting"});case"EEEEEE":return a.day(s,{width:"short",context:"formatting"});case"EEEE":default:return a.day(s,{width:"wide",context:"formatting"})}},e:function(t,n,a,s){var o=t.getUTCDay(),i=(o-s.weekStartsOn+8)%7||7;switch(n){case"e":return String(i);case"ee":return At(i,2);case"eo":return a.ordinalNumber(i,{unit:"day"});case"eee":return a.day(o,{width:"abbreviated",context:"formatting"});case"eeeee":return a.day(o,{width:"narrow",context:"formatting"});case"eeeeee":return a.day(o,{width:"short",context:"formatting"});case"eeee":default:return a.day(o,{width:"wide",context:"formatting"})}},c:function(t,n,a,s){var o=t.getUTCDay(),i=(o-s.weekStartsOn+8)%7||7;switch(n){case"c":return String(i);case"cc":return At(i,n.length);case"co":return a.ordinalNumber(i,{unit:"day"});case"ccc":return a.day(o,{width:"abbreviated",context:"standalone"});case"ccccc":return a.day(o,{width:"narrow",context:"standalone"});case"cccccc":return a.day(o,{width:"short",context:"standalone"});case"cccc":default:return a.day(o,{width:"wide",context:"standalone"})}},i:function(t,n,a){var s=t.getUTCDay(),o=s===0?7:s;switch(n){case"i":return String(o);case"ii":return At(o,n.length);case"io":return a.ordinalNumber(o,{unit:"day"});case"iii":return a.day(s,{width:"abbreviated",context:"formatting"});case"iiiii":return a.day(s,{width:"narrow",context:"formatting"});case"iiiiii":return a.day(s,{width:"short",context:"formatting"});case"iiii":default:return a.day(s,{width:"wide",context:"formatting"})}},a:function(t,n,a){var s=t.getUTCHours(),o=s/12>=1?"pm":"am";switch(n){case"a":case"aa":return a.dayPeriod(o,{width:"abbreviated",context:"formatting"});case"aaa":return a.dayPeriod(o,{width:"abbreviated",context:"formatting"}).toLowerCase();case"aaaaa":return a.dayPeriod(o,{width:"narrow",context:"formatting"});case"aaaa":default:return a.dayPeriod(o,{width:"wide",context:"formatting"})}},b:function(t,n,a){var s=t.getUTCHours(),o;switch(s===12?o=to.noon:s===0?o=to.midnight:o=s/12>=1?"pm":"am",n){case"b":case"bb":return a.dayPeriod(o,{width:"abbreviated",context:"formatting"});case"bbb":return a.dayPeriod(o,{width:"abbreviated",context:"formatting"}).toLowerCase();case"bbbbb":return a.dayPeriod(o,{width:"narrow",context:"formatting"});case"bbbb":default:return a.dayPeriod(o,{width:"wide",context:"formatting"})}},B:function(t,n,a){var s=t.getUTCHours(),o;switch(s>=17?o=to.evening:s>=12?o=to.afternoon:s>=4?o=to.morning:o=to.night,n){case"B":case"BB":case"BBB":return a.dayPeriod(o,{width:"abbreviated",context:"formatting"});case"BBBBB":return a.dayPeriod(o,{width:"narrow",context:"formatting"});case"BBBB":default:return a.dayPeriod(o,{width:"wide",context:"formatting"})}},h:function(t,n,a){if(n==="ho"){var s=t.getUTCHours()%12;return s===0&&(s=12),a.ordinalNumber(s,{unit:"hour"})}return Qa.h(t,n)},H:function(t,n,a){return n==="Ho"?a.ordinalNumber(t.getUTCHours(),{unit:"hour"}):Qa.H(t,n)},K:function(t,n,a){var s=t.getUTCHours()%12;return n==="Ko"?a.ordinalNumber(s,{unit:"hour"}):At(s,n.length)},k:function(t,n,a){var s=t.getUTCHours();return s===0&&(s=24),n==="ko"?a.ordinalNumber(s,{unit:"hour"}):At(s,n.length)},m:function(t,n,a){return n==="mo"?a.ordinalNumber(t.getUTCMinutes(),{unit:"minute"}):Qa.m(t,n)},s:function(t,n,a){return n==="so"?a.ordinalNumber(t.getUTCSeconds(),{unit:"second"}):Qa.s(t,n)},S:function(t,n){return Qa.S(t,n)},X:function(t,n,a,s){var o=s._originalDate||t,i=o.getTimezoneOffset();if(i===0)return"Z";switch(n){case"X":return YT(i);case"XXXX":case"XX":return Ds(i);case"XXXXX":case"XXX":default:return Ds(i,":")}},x:function(t,n,a,s){var o=s._originalDate||t,i=o.getTimezoneOffset();switch(n){case"x":return YT(i);case"xxxx":case"xx":return Ds(i);case"xxxxx":case"xxx":default:return Ds(i,":")}},O:function(t,n,a,s){var o=s._originalDate||t,i=o.getTimezoneOffset();switch(n){case"O":case"OO":case"OOO":return"GMT"+jT(i,":");case"OOOO":default:return"GMT"+Ds(i,":")}},z:function(t,n,a,s){var o=s._originalDate||t,i=o.getTimezoneOffset();switch(n){case"z":case"zz":case"zzz":return"GMT"+jT(i,":");case"zzzz":default:return"GMT"+Ds(i,":")}},t:function(t,n,a,s){var o=s._originalDate||t,i=Math.floor(o.getTime()/1e3);return At(i,n.length)},T:function(t,n,a,s){var o=s._originalDate||t,i=o.getTime();return At(i,n.length)}};function jT(e,t){var n=e>0?"-":"+",a=Math.abs(e),s=Math.floor(a/60),o=a%60;if(o===0)return n+String(s);var i=t;return n+String(s)+i+At(o,2)}function YT(e,t){if(e%60===0){var n=e>0?"-":"+";return n+At(Math.abs(e)/60,2)}return Ds(e,t)}function Ds(e,t){var n=t||"",a=e>0?"-":"+",s=Math.abs(e),o=At(Math.floor(s/60),2),i=At(s%60,2);return a+o+n+i}var XT=function(t,n){switch(t){case"P":return n.date({width:"short"});case"PP":return n.date({width:"medium"});case"PPP":return n.date({width:"long"});case"PPPP":default:return n.date({width:"full"})}},A1=function(t,n){switch(t){case"p":return n.time({width:"short"});case"pp":return n.time({width:"medium"});case"ppp":return n.time({width:"long"});case"pppp":default:return n.time({width:"full"})}},QP=function(t,n){var a=t.match(/(P+)(p+)?/)||[],s=a[1],o=a[2];if(!o)return XT(t,n);var i;switch(s){case"P":i=n.dateTime({width:"short"});break;case"PP":i=n.dateTime({width:"medium"});break;case"PPP":i=n.dateTime({width:"long"});break;case"PPPP":default:i=n.dateTime({width:"full"});break}return i.replace("{{date}}",XT(s,n)).replace("{{time}}",A1(o,n))},ZP={p:A1,P:QP},JP=["D","DD"],eD=["YY","YYYY"];function tD(e){return JP.indexOf(e)!==-1}function nD(e){return eD.indexOf(e)!==-1}function QT(e,t,n){if(e==="YYYY")throw new RangeError("Use `yyyy` instead of `YYYY` (in `".concat(t,"`) for formatting years to the input `").concat(n,"`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md"));if(e==="YY")throw new RangeError("Use `yy` instead of `YY` (in `".concat(t,"`) for formatting years to the input `").concat(n,"`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md"));if(e==="D")throw new RangeError("Use `d` instead of `D` (in `".concat(t,"`) for formatting days of the month to the input `").concat(n,"`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md"));if(e==="DD")throw new RangeError("Use `dd` instead of `DD` (in `".concat(t,"`) for formatting days of the month to the input `").concat(n,"`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md"))}var aD={lessThanXSeconds:{one:"less than a second",other:"less than {{count}} seconds"},xSeconds:{one:"1 second",other:"{{count}} seconds"},halfAMinute:"half a minute",lessThanXMinutes:{one:"less than a minute",other:"less than {{count}} minutes"},xMinutes:{one:"1 minute",other:"{{count}} minutes"},aboutXHours:{one:"about 1 hour",other:"about {{count}} hours"},xHours:{one:"1 hour",other:"{{count}} hours"},xDays:{one:"1 day",other:"{{count}} days"},aboutXWeeks:{one:"about 1 week",other:"about {{count}} weeks"},xWeeks:{one:"1 week",other:"{{count}} weeks"},aboutXMonths:{one:"about 1 month",other:"about {{count}} months"},xMonths:{one:"1 month",other:"{{count}} months"},aboutXYears:{one:"about 1 year",other:"about {{count}} years"},xYears:{one:"1 year",other:"{{count}} years"},overXYears:{one:"over 1 year",other:"over {{count}} years"},almostXYears:{one:"almost 1 year",other:"almost {{count}} years"}},sD=function(t,n,a){var s,o=aD[t];return typeof o=="string"?s=o:n===1?s=o.one:s=o.other.replace("{{count}}",n.toString()),a!=null&&a.addSuffix?a.comparison&&a.comparison>0?"in "+s:s+" ago":s};function Qe(e){return function(){var t=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},n=t.width?String(t.width):e.defaultWidth,a=e.formats[n]||e.formats[e.defaultWidth];return a}}var oD={full:"EEEE, MMMM do, y",long:"MMMM do, y",medium:"MMM d, y",short:"MM/dd/yyyy"},iD={full:"h:mm:ss a zzzz",long:"h:mm:ss a z",medium:"h:mm:ss a",short:"h:mm a"},rD={full:"{{date}} 'at' {{time}}",long:"{{date}} 'at' {{time}}",medium:"{{date}}, {{time}}",short:"{{date}}, {{time}}"},uD={date:Qe({formats:oD,defaultWidth:"full"}),time:Qe({formats:iD,defaultWidth:"full"}),dateTime:Qe({formats:rD,defaultWidth:"full"})},lD={lastWeek:"'last' eeee 'at' p",yesterday:"'yesterday at' p",today:"'today at' p",tomorrow:"'tomorrow at' p",nextWeek:"eeee 'at' p",other:"P"},cD=function(t,n,a,s){return lD[t]};function ke(e){return function(t,n){var a=n!=null&&n.context?String(n.context):"standalone",s;if(a==="formatting"&&e.formattingValues){var o=e.defaultFormattingWidth||e.defaultWidth,i=n!=null&&n.width?String(n.width):o;s=e.formattingValues[i]||e.formattingValues[o]}else{var r=e.defaultWidth,u=n!=null&&n.width?String(n.width):e.defaultWidth;s=e.values[u]||e.values[r]}var l=e.argumentCallback?e.argumentCallback(t):t;return s[l]}}var dD={narrow:["B","A"],abbreviated:["BC","AD"],wide:["Before Christ","Anno Domini"]},ED={narrow:["1","2","3","4"],abbreviated:["Q1","Q2","Q3","Q4"],wide:["1st quarter","2nd quarter","3rd quarter","4th quarter"]},pD={narrow:["J","F","M","A","M","J","J","A","S","O","N","D"],abbreviated:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],wide:["January","February","March","April","May","June","July","August","September","October","November","December"]},mD={narrow:["S","M","T","W","T","F","S"],short:["Su","Mo","Tu","We","Th","Fr","Sa"],abbreviated:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],wide:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},TD={narrow:{am:"a",pm:"p",midnight:"mi",noon:"n",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},abbreviated:{am:"AM",pm:"PM",midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},wide:{am:"a.m.",pm:"p.m.",midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"}},_D={narrow:{am:"a",pm:"p",midnight:"mi",noon:"n",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"},abbreviated:{am:"AM",pm:"PM",midnight:"midnight",noon:"noon",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"},wide:{am:"a.m.",pm:"p.m.",midnight:"midnight",noon:"noon",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"}},fD=function(t,n){var a=Number(t),s=a%100;if(s>20||s<10)switch(s%10){case 1:return a+"st";case 2:return a+"nd";case 3:return a+"rd"}return a+"th"},hD={ordinalNumber:fD,era:ke({values:dD,defaultWidth:"wide"}),quarter:ke({values:ED,defaultWidth:"wide",argumentCallback:function(t){return t-1}}),month:ke({values:pD,defaultWidth:"wide"}),day:ke({values:mD,defaultWidth:"wide"}),dayPeriod:ke({values:TD,defaultWidth:"wide",formattingValues:_D,defaultFormattingWidth:"wide"})};function we(e){return function(t){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},a=n.width,s=a&&e.matchPatterns[a]||e.matchPatterns[e.defaultMatchWidth],o=t.match(s);if(!o)return null;var i=o[0],r=a&&e.parsePatterns[a]||e.parsePatterns[e.defaultParseWidth],u=Array.isArray(r)?AD(r,function(E){return E.test(i)}):SD(r,function(E){return E.test(i)}),l;l=e.valueCallback?e.valueCallback(u):u,l=n.valueCallback?n.valueCallback(l):l;var d=t.slice(i.length);return{value:l,rest:d}}}function SD(e,t){for(var n in e)if(e.hasOwnProperty(n)&&t(e[n]))return n}function AD(e,t){for(var n=0;n1&&arguments[1]!==void 0?arguments[1]:{},a=t.match(e.matchPattern);if(!a)return null;var s=a[0],o=t.match(e.parsePattern);if(!o)return null;var i=e.valueCallback?e.valueCallback(o[0]):o[0];i=n.valueCallback?n.valueCallback(i):i;var r=t.slice(s.length);return{value:i,rest:r}}}var OD=/^(\d+)(th|st|nd|rd)?/i,ID=/\d+/i,gD={narrow:/^(b|a)/i,abbreviated:/^(b\.?\s?c\.?|b\.?\s?c\.?\s?e\.?|a\.?\s?d\.?|c\.?\s?e\.?)/i,wide:/^(before christ|before common era|anno domini|common era)/i},RD={any:[/^b/i,/^(a|c)/i]},ND={narrow:/^[1234]/i,abbreviated:/^q[1234]/i,wide:/^[1234](th|st|nd|rd)? quarter/i},vD={any:[/1/i,/2/i,/3/i,/4/i]},bD={narrow:/^[jfmasond]/i,abbreviated:/^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i,wide:/^(january|february|march|april|may|june|july|august|september|october|november|december)/i},CD={narrow:[/^j/i,/^f/i,/^m/i,/^a/i,/^m/i,/^j/i,/^j/i,/^a/i,/^s/i,/^o/i,/^n/i,/^d/i],any:[/^ja/i,/^f/i,/^mar/i,/^ap/i,/^may/i,/^jun/i,/^jul/i,/^au/i,/^s/i,/^o/i,/^n/i,/^d/i]},PD={narrow:/^[smtwf]/i,short:/^(su|mo|tu|we|th|fr|sa)/i,abbreviated:/^(sun|mon|tue|wed|thu|fri|sat)/i,wide:/^(sunday|monday|tuesday|wednesday|thursday|friday|saturday)/i},DD={narrow:[/^s/i,/^m/i,/^t/i,/^w/i,/^t/i,/^f/i,/^s/i],any:[/^su/i,/^m/i,/^tu/i,/^w/i,/^th/i,/^f/i,/^sa/i]},LD={narrow:/^(a|p|mi|n|(in the|at) (morning|afternoon|evening|night))/i,any:/^([ap]\.?\s?m\.?|midnight|noon|(in the|at) (morning|afternoon|evening|night))/i},yD={any:{am:/^a/i,pm:/^p/i,midnight:/^mi/i,noon:/^no/i,morning:/morning/i,afternoon:/afternoon/i,evening:/evening/i,night:/night/i}},$D={ordinalNumber:$n({matchPattern:OD,parsePattern:ID,valueCallback:function(t){return parseInt(t,10)}}),era:we({matchPatterns:gD,defaultMatchWidth:"wide",parsePatterns:RD,defaultParseWidth:"any"}),quarter:we({matchPatterns:ND,defaultMatchWidth:"wide",parsePatterns:vD,defaultParseWidth:"any",valueCallback:function(t){return t+1}}),month:we({matchPatterns:bD,defaultMatchWidth:"wide",parsePatterns:CD,defaultParseWidth:"any"}),day:we({matchPatterns:PD,defaultMatchWidth:"wide",parsePatterns:DD,defaultParseWidth:"any"}),dayPeriod:we({matchPatterns:LD,defaultMatchWidth:"any",parsePatterns:yD,defaultParseWidth:"any"})},ir={code:"en-US",formatDistance:sD,formatLong:uD,formatRelative:cD,localize:hD,match:$D,options:{weekStartsOn:0,firstWeekContainsDate:1}},UD=/[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g,kD=/P+p+|P+|p+|''|'(''|[^'])+('|$)|./g,wD=/^'([^]*?)'?$/,MD=/''/g,WD=/[a-zA-Z]/;function gn(e,t,n){var a,s,o,i,r,u,l,d,E,c,m,_,h,O,S,R,g,I;Ye(2,arguments);var N=String(t),b=qs(),C=(a=(s=n==null?void 0:n.locale)!==null&&s!==void 0?s:b.locale)!==null&&a!==void 0?a:ir,k=tn((o=(i=(r=(u=n==null?void 0:n.firstWeekContainsDate)!==null&&u!==void 0?u:n==null||(l=n.locale)===null||l===void 0||(d=l.options)===null||d===void 0?void 0:d.firstWeekContainsDate)!==null&&r!==void 0?r:b.firstWeekContainsDate)!==null&&i!==void 0?i:(E=b.locale)===null||E===void 0||(c=E.options)===null||c===void 0?void 0:c.firstWeekContainsDate)!==null&&o!==void 0?o:1);if(!(k>=1&&k<=7))throw new RangeError("firstWeekContainsDate must be between 1 and 7 inclusively");var P=tn((m=(_=(h=(O=n==null?void 0:n.weekStartsOn)!==null&&O!==void 0?O:n==null||(S=n.locale)===null||S===void 0||(R=S.options)===null||R===void 0?void 0:R.weekStartsOn)!==null&&h!==void 0?h:b.weekStartsOn)!==null&&_!==void 0?_:(g=b.locale)===null||g===void 0||(I=g.options)===null||I===void 0?void 0:I.weekStartsOn)!==null&&m!==void 0?m:0);if(!(P>=0&&P<=6))throw new RangeError("weekStartsOn must be between 0 and 6 inclusively");if(!C.localize)throw new RangeError("locale must contain localize property");if(!C.formatLong)throw new RangeError("locale must contain formatLong property");var $=et(e);if(!yP($))throw new RangeError("Invalid time value");var y=Bd($),z=xP($,y),Z={firstWeekContainsDate:k,weekStartsOn:P,locale:C,_originalDate:$},Ae=N.match(kD).map(function(J){var ce=J[0];if(ce==="p"||ce==="P"){var Te=ZP[ce];return Te(J,C.formatLong)}return J}).join("").match(UD).map(function(J){if(J==="''")return"'";var ce=J[0];if(ce==="'")return FD(J);var Te=XP[ce];if(Te)return!(n!=null&&n.useAdditionalWeekYearTokens)&&nD(J)&&QT(J,t,String(e)),!(n!=null&&n.useAdditionalDayOfYearTokens)&&tD(J)&&QT(J,t,String(e)),Te(z,J,C.localize,Z);if(ce.match(WD))throw new RangeError("Format string contains an unescaped latin alphabet character `"+ce+"`");return J}).join("");return Ae}function FD(e){var t=e.match(wD);return t?t[1].replace(MD,"'"):e}function O1(e,t){if(e==null)throw new TypeError("assign requires that input parameter not be null or undefined");for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&(e[n]=t[n]);return e}function zD(e){return O1({},e)}var ZT=1440,xD=2520,dc=43200,BD=86400;function xs(e,t,n){var a,s;Ye(2,arguments);var o=qs(),i=(a=(s=n==null?void 0:n.locale)!==null&&s!==void 0?s:o.locale)!==null&&a!==void 0?a:ir;if(!i.formatDistance)throw new RangeError("locale must contain formatDistance property");var r=Ti(e,t);if(isNaN(r))throw new RangeError("Invalid time value");var u=O1(zD(n),{addSuffix:!!(n!=null&&n.addSuffix),comparison:r}),l,d;r>0?(l=et(t),d=et(e)):(l=et(e),d=et(t));var E=zP(d,l),c=(Bd(d)-Bd(l))/1e3,m=Math.round((E-c)/60),_;if(m<2)return n!=null&&n.includeSeconds?E<5?i.formatDistance("lessThanXSeconds",5,u):E<10?i.formatDistance("lessThanXSeconds",10,u):E<20?i.formatDistance("lessThanXSeconds",20,u):E<40?i.formatDistance("halfAMinute",0,u):E<60?i.formatDistance("lessThanXMinutes",1,u):i.formatDistance("xMinutes",1,u):m===0?i.formatDistance("lessThanXMinutes",1,u):i.formatDistance("xMinutes",m,u);if(m<45)return i.formatDistance("xMinutes",m,u);if(m<90)return i.formatDistance("aboutXHours",1,u);if(m=0&&(a[o]=parseInt(n[s].value,10))}return a}catch(i){if(i instanceof RangeError)return[NaN];throw i}}function ZD(e,t){var n=e.format(t),a=/(\d+)\/(\d+)\/(\d+),? (\d+):(\d+):(\d+)/.exec(n);return[a[3],a[1],a[2],a[4],a[5],a[6]]}var Ec={};function JD(e){if(!Ec[e]){var t=new Intl.DateTimeFormat("en-US",{hourCycle:"h23",timeZone:"America/New_York",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"}).format(new Date("2014-06-25T04:00:00.123Z")),n=t==="06/25/2014, 00:00:00"||t==="‎06‎/‎25‎/‎2014‎ ‎00‎:‎00‎:‎00";Ec[e]=n?new Intl.DateTimeFormat("en-US",{hourCycle:"h23",timeZone:e,year:"numeric",month:"numeric",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"}):new Intl.DateTimeFormat("en-US",{hour12:!1,timeZone:e,year:"numeric",month:"numeric",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit"})}return Ec[e]}function I1(e,t,n,a,s,o,i){var r=new Date(0);return r.setUTCFullYear(e,t,n),r.setUTCHours(a,s,o,i),r}var n_=36e5,eL=6e4,pc={timezone:/([Z+-].*)$/,timezoneZ:/^(Z)$/,timezoneHH:/^([+-]\d{2})$/,timezoneHHMM:/^([+-])(\d{2}):?(\d{2})$/};function g1(e,t,n){var a,s;if(!e||(a=pc.timezoneZ.exec(e),a))return 0;var o;if(a=pc.timezoneHH.exec(e),a)return o=parseInt(a[1],10),a_(o)?-(o*n_):NaN;if(a=pc.timezoneHHMM.exec(e),a){o=parseInt(a[2],10);var i=parseInt(a[3],10);return a_(o,i)?(s=Math.abs(o)*n_+i*eL,a[1]==="+"?-s:s):NaN}if(aL(e)){t=new Date(t||Date.now());var r=n?t:tL(t),u=Hd(r,e),l=n?u:nL(t,u,e);return-l}return NaN}function tL(e){return I1(e.getFullYear(),e.getMonth(),e.getDate(),e.getHours(),e.getMinutes(),e.getSeconds(),e.getMilliseconds())}function Hd(e,t){var n=YD(e,t),a=I1(n[0],n[1]-1,n[2],n[3]%24,n[4],n[5],0).getTime(),s=e.getTime(),o=s%1e3;return s-=o>=0?o:1e3+o,a-s}function nL(e,t,n){var a=e.getTime(),s=a-t,o=Hd(new Date(s),n);if(t===o)return t;s-=o-t;var i=Hd(new Date(s),n);return o===i?o:Math.max(o,i)}function a_(e,t){return-23<=e&&e<=23&&(t==null||0<=t&&t<=59)}var s_={};function aL(e){if(s_[e])return!0;try{return new Intl.DateTimeFormat(void 0,{timeZone:e}),s_[e]=!0,!0}catch{return!1}}var sL=qD();const oL=RE(sL);var iL=jD();const o_=RE(iL);var rL=/(Z|[+-]\d{2}(?::?\d{2})?| UTC| [a-zA-Z]+\/[a-zA-Z_]+(?:\/[a-zA-Z_]+)?)$/,mc=36e5,i_=6e4,uL=2,An={dateTimePattern:/^([0-9W+-]+)(T| )(.*)/,datePattern:/^([0-9W+-]+)(.*)/,plainTime:/:/,YY:/^(\d{2})$/,YYY:[/^([+-]\d{2})$/,/^([+-]\d{3})$/,/^([+-]\d{4})$/],YYYY:/^(\d{4})/,YYYYY:[/^([+-]\d{4})/,/^([+-]\d{5})/,/^([+-]\d{6})/],MM:/^-(\d{2})$/,DDD:/^-?(\d{3})$/,MMDD:/^-?(\d{2})-?(\d{2})$/,Www:/^-?W(\d{2})$/,WwwD:/^-?W(\d{2})-?(\d{1})$/,HH:/^(\d{2}([.,]\d*)?)$/,HHMM:/^(\d{2}):?(\d{2}([.,]\d*)?)$/,HHMMSS:/^(\d{2}):?(\d{2}):?(\d{2}([.,]\d*)?)$/,timeZone:rL};function lL(e,t){if(arguments.length<1)throw new TypeError("1 argument required, but only "+arguments.length+" present");if(e===null)return new Date(NaN);var n={},a=n.additionalDigits==null?uL:oL(n.additionalDigits);if(a!==2&&a!==1&&a!==0)throw new RangeError("additionalDigits must be 0, 1 or 2");if(e instanceof Date||typeof e=="object"&&Object.prototype.toString.call(e)==="[object Date]")return new Date(e.getTime());if(typeof e=="number"||Object.prototype.toString.call(e)==="[object Number]")return new Date(e);if(!(typeof e=="string"||Object.prototype.toString.call(e)==="[object String]"))return new Date(NaN);var s=cL(e),o=dL(s.date,a),i=o.year,r=o.restDateString,u=EL(r,i);if(isNaN(u))return new Date(NaN);if(u){var l=u.getTime(),d=0,E;if(s.time&&(d=pL(s.time),isNaN(d)))return new Date(NaN);if(s.timeZone||n.timeZone){if(E=g1(s.timeZone||n.timeZone,new Date(l+d)),isNaN(E))return new Date(NaN)}else E=o_(new Date(l+d)),E=o_(new Date(l+d+E));return new Date(l+d+E)}else return new Date(NaN)}function cL(e){var t={},n=An.dateTimePattern.exec(e),a;if(n?(t.date=n[1],a=n[3]):(n=An.datePattern.exec(e),n?(t.date=n[1],a=n[2]):(t.date=null,a=e)),a){var s=An.timeZone.exec(a);s?(t.time=a.replace(s[1],""),t.timeZone=s[1].trim()):t.time=a}return t}function dL(e,t){var n=An.YYY[t],a=An.YYYYY[t],s;if(s=An.YYYY.exec(e)||a.exec(e),s){var o=s[1];return{year:parseInt(o,10),restDateString:e.slice(o.length)}}if(s=An.YY.exec(e)||n.exec(e),s){var i=s[1];return{year:parseInt(i,10)*100,restDateString:e.slice(i.length)}}return{year:null}}function EL(e,t){if(t===null)return null;var n,a,s,o;if(e.length===0)return a=new Date(0),a.setUTCFullYear(t),a;if(n=An.MM.exec(e),n)return a=new Date(0),s=parseInt(n[1],10)-1,u_(t,s)?(a.setUTCFullYear(t,s),a):new Date(NaN);if(n=An.DDD.exec(e),n){a=new Date(0);var i=parseInt(n[1],10);return _L(t,i)?(a.setUTCFullYear(t,0,i),a):new Date(NaN)}if(n=An.MMDD.exec(e),n){a=new Date(0),s=parseInt(n[1],10)-1;var r=parseInt(n[2],10);return u_(t,s,r)?(a.setUTCFullYear(t,s,r),a):new Date(NaN)}if(n=An.Www.exec(e),n)return o=parseInt(n[1],10)-1,l_(t,o)?r_(t,o):new Date(NaN);if(n=An.WwwD.exec(e),n){o=parseInt(n[1],10)-1;var u=parseInt(n[2],10)-1;return l_(t,o,u)?r_(t,o,u):new Date(NaN)}return null}function pL(e){var t,n,a;if(t=An.HH.exec(e),t)return n=parseFloat(t[1].replace(",",".")),Tc(n)?n%24*mc:NaN;if(t=An.HHMM.exec(e),t)return n=parseInt(t[1],10),a=parseFloat(t[2].replace(",",".")),Tc(n,a)?n%24*mc+a*i_:NaN;if(t=An.HHMMSS.exec(e),t){n=parseInt(t[1],10),a=parseInt(t[2],10);var s=parseFloat(t[3].replace(",","."));return Tc(n,a,s)?n%24*mc+a*i_+s*1e3:NaN}return null}function r_(e,t,n){t=t||0,n=n||0;var a=new Date(0);a.setUTCFullYear(e,0,4);var s=a.getUTCDay()||7,o=t*7+n+1-s;return a.setUTCDate(a.getUTCDate()+o),a}var mL=[31,28,31,30,31,30,31,31,30,31,30,31],TL=[31,29,31,30,31,30,31,31,30,31,30,31];function R1(e){return e%400===0||e%4===0&&e%100!==0}function u_(e,t,n){if(t<0||t>11)return!1;if(n!=null){if(n<1)return!1;var a=R1(e);if(a&&n>TL[t]||!a&&n>mL[t])return!1}return!0}function _L(e,t){if(t<1)return!1;var n=R1(e);return!(n&&t>366||!n&&t>365)}function l_(e,t,n){return!(t<0||t>52||n!=null&&(n<0||n>6))}function Tc(e,t,n){return!(e!=null&&(e<0||e>=25)||t!=null&&(t<0||t>=60)||n!=null&&(n<0||n>=60))}function fL(e,t,n){var a=lL(e,n),s=g1(t,a,!0),o=new Date(a.getTime()-s),i=new Date(0);return i.setFullYear(o.getUTCFullYear(),o.getUTCMonth(),o.getUTCDate()),i.setHours(o.getUTCHours(),o.getUTCMinutes(),o.getUTCSeconds(),o.getUTCMilliseconds()),i}/*! + * shared v10.0.5 + * (c) 2024 kazuya kawaguchi + * Released under the MIT License. + */const _u=typeof window<"u",_s=(e,t=!1)=>t?Symbol.for(e):Symbol(e),hL=(e,t,n)=>SL({l:e,k:t,s:n}),SL=e=>JSON.stringify(e).replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029").replace(/\u0027/g,"\\u0027"),Bt=e=>typeof e=="number"&&isFinite(e),AL=e=>np(e)==="[object Date]",vo=e=>np(e)==="[object RegExp]",Il=e=>at(e)&&Object.keys(e).length===0,jt=Object.assign,OL=Object.create,gt=(e=null)=>OL(e);let c_;const Us=()=>c_||(c_=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:gt());function d_(e){return e.replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}const IL=Object.prototype.hasOwnProperty;function aa(e,t){return IL.call(e,t)}const Ft=Array.isArray,Lt=e=>typeof e=="function",Ue=e=>typeof e=="string",mt=e=>typeof e=="boolean",_t=e=>e!==null&&typeof e=="object",gL=e=>_t(e)&&Lt(e.then)&&Lt(e.catch),N1=Object.prototype.toString,np=e=>N1.call(e),at=e=>np(e)==="[object Object]",RL=e=>e==null?"":Ft(e)||at(e)&&e.toString===N1?JSON.stringify(e,null,2):String(e);function ap(e,t=""){return e.reduce((n,a,s)=>s===0?n+a:n+t+a,"")}function NL(e,t){typeof console<"u"&&(console.warn("[intlify] "+e),t&&console.warn(t.stack))}const Ar=e=>!_t(e)||Ft(e);function Yr(e,t){if(Ar(e)||Ar(t))throw new Error("Invalid value");const n=[{src:e,des:t}];for(;n.length;){const{src:a,des:s}=n.pop();Object.keys(a).forEach(o=>{o!=="__proto__"&&(_t(a[o])&&!_t(s[o])&&(s[o]=Array.isArray(a[o])?[]:gt()),Ar(s[o])||Ar(a[o])?s[o]=a[o]:n.push({src:a[o],des:s[o]}))})}}/*! + * message-compiler v10.0.5 + * (c) 2024 kazuya kawaguchi + * Released under the MIT License. + */function vL(e,t,n){return{line:e,column:t,offset:n}}function Kd(e,t,n){return{start:e,end:t}}const Ot={EXPECTED_TOKEN:1,INVALID_TOKEN_IN_PLACEHOLDER:2,UNTERMINATED_SINGLE_QUOTE_IN_PLACEHOLDER:3,UNKNOWN_ESCAPE_SEQUENCE:4,INVALID_UNICODE_ESCAPE_SEQUENCE:5,UNBALANCED_CLOSING_BRACE:6,UNTERMINATED_CLOSING_BRACE:7,EMPTY_PLACEHOLDER:8,NOT_ALLOW_NEST_PLACEHOLDER:9,INVALID_LINKED_FORMAT:10,MUST_HAVE_MESSAGES_IN_PLURAL:11,UNEXPECTED_EMPTY_LINKED_MODIFIER:12,UNEXPECTED_EMPTY_LINKED_KEY:13,UNEXPECTED_LEXICAL_ANALYSIS:14,UNHANDLED_CODEGEN_NODE_TYPE:15,UNHANDLED_MINIFIER_NODE_TYPE:16},bL=17;function gl(e,t,n={}){const{domain:a,messages:s,args:o}=n,i=e,r=new SyntaxError(String(i));return r.code=e,t&&(r.location=t),r.domain=a,r}function CL(e){throw e}const La=" ",PL="\r",Tn=` +`,DL="\u2028",LL="\u2029";function yL(e){const t=e;let n=0,a=1,s=1,o=0;const i=C=>t[C]===PL&&t[C+1]===Tn,r=C=>t[C]===Tn,u=C=>t[C]===LL,l=C=>t[C]===DL,d=C=>i(C)||r(C)||u(C)||l(C),E=()=>n,c=()=>a,m=()=>s,_=()=>o,h=C=>i(C)||u(C)||l(C)?Tn:t[C],O=()=>h(n),S=()=>h(n+o);function R(){return o=0,d(n)&&(a++,s=0),i(n)&&n++,n++,s++,t[n]}function g(){return i(n+o)&&o++,o++,t[n+o]}function I(){n=0,a=1,s=1,o=0}function N(C=0){o=C}function b(){const C=n+o;for(;C!==n;)R();o=0}return{index:E,line:c,column:m,peekOffset:_,charAt:h,currentChar:O,currentPeek:S,next:R,peek:g,reset:I,resetPeek:N,skipToPeek:b}}const Za=void 0,$L=".",E_="'",UL="tokenizer";function kL(e,t={}){const n=t.location!==!1,a=yL(e),s=()=>a.index(),o=()=>vL(a.line(),a.column(),a.index()),i=o(),r=s(),u={currentType:13,offset:r,startLoc:i,endLoc:i,lastType:13,lastOffset:r,lastStartLoc:i,lastEndLoc:i,braceNest:0,inLinked:!1,text:""},l=()=>u,{onError:d}=t;function E(L,W,G,...j){const Ee=l();if(W.column+=G,W.offset+=G,d){const ge=n?Kd(Ee.startLoc,W):null,V=gl(L,ge,{domain:UL,args:j});d(V)}}function c(L,W,G){L.endLoc=o(),L.currentType=W;const j={type:W};return n&&(j.loc=Kd(L.startLoc,L.endLoc)),G!=null&&(j.value=G),j}const m=L=>c(L,13);function _(L,W){return L.currentChar()===W?(L.next(),W):(E(Ot.EXPECTED_TOKEN,o(),0,W),"")}function h(L){let W="";for(;L.currentPeek()===La||L.currentPeek()===Tn;)W+=L.currentPeek(),L.peek();return W}function O(L){const W=h(L);return L.skipToPeek(),W}function S(L){if(L===Za)return!1;const W=L.charCodeAt(0);return W>=97&&W<=122||W>=65&&W<=90||W===95}function R(L){if(L===Za)return!1;const W=L.charCodeAt(0);return W>=48&&W<=57}function g(L,W){const{currentType:G}=W;if(G!==2)return!1;h(L);const j=S(L.currentPeek());return L.resetPeek(),j}function I(L,W){const{currentType:G}=W;if(G!==2)return!1;h(L);const j=L.currentPeek()==="-"?L.peek():L.currentPeek(),Ee=R(j);return L.resetPeek(),Ee}function N(L,W){const{currentType:G}=W;if(G!==2)return!1;h(L);const j=L.currentPeek()===E_;return L.resetPeek(),j}function b(L,W){const{currentType:G}=W;if(G!==7)return!1;h(L);const j=L.currentPeek()===".";return L.resetPeek(),j}function C(L,W){const{currentType:G}=W;if(G!==8)return!1;h(L);const j=S(L.currentPeek());return L.resetPeek(),j}function k(L,W){const{currentType:G}=W;if(!(G===7||G===11))return!1;h(L);const j=L.currentPeek()===":";return L.resetPeek(),j}function P(L,W){const{currentType:G}=W;if(G!==9)return!1;const j=()=>{const ge=L.currentPeek();return ge==="{"?S(L.peek()):ge==="@"||ge==="|"||ge===":"||ge==="."||ge===La||!ge?!1:ge===Tn?(L.peek(),j()):y(L,!1)},Ee=j();return L.resetPeek(),Ee}function $(L){h(L);const W=L.currentPeek()==="|";return L.resetPeek(),W}function y(L,W=!0){const G=(Ee=!1,ge="")=>{const V=L.currentPeek();return V==="{"||V==="@"||!V?Ee:V==="|"?!(ge===La||ge===Tn):V===La?(L.peek(),G(!0,La)):V===Tn?(L.peek(),G(!0,Tn)):!0},j=G();return W&&L.resetPeek(),j}function z(L,W){const G=L.currentChar();return G===Za?Za:W(G)?(L.next(),G):null}function Z(L){const W=L.charCodeAt(0);return W>=97&&W<=122||W>=65&&W<=90||W>=48&&W<=57||W===95||W===36}function Ae(L){return z(L,Z)}function J(L){const W=L.charCodeAt(0);return W>=97&&W<=122||W>=65&&W<=90||W>=48&&W<=57||W===95||W===36||W===45}function ce(L){return z(L,J)}function Te(L){const W=L.charCodeAt(0);return W>=48&&W<=57}function De(L){return z(L,Te)}function Ve(L){const W=L.charCodeAt(0);return W>=48&&W<=57||W>=65&&W<=70||W>=97&&W<=102}function xe(L){return z(L,Ve)}function ot(L){let W="",G="";for(;W=De(L);)G+=W;return G}function re(L){let W="";for(;;){const G=L.currentChar();if(G==="{"||G==="}"||G==="@"||G==="|"||!G)break;if(G===La||G===Tn)if(y(L))W+=G,L.next();else{if($(L))break;W+=G,L.next()}else W+=G,L.next()}return W}function Oe(L){O(L);let W="",G="";for(;W=ce(L);)G+=W;return L.currentChar()===Za&&E(Ot.UNTERMINATED_CLOSING_BRACE,o(),0),G}function pt(L){O(L);let W="";return L.currentChar()==="-"?(L.next(),W+=`-${ot(L)}`):W+=ot(L),L.currentChar()===Za&&E(Ot.UNTERMINATED_CLOSING_BRACE,o(),0),W}function wt(L){return L!==E_&&L!==Tn}function It(L){O(L),_(L,"'");let W="",G="";for(;W=z(L,wt);)W==="\\"?G+=de(L):G+=W;const j=L.currentChar();return j===Tn||j===Za?(E(Ot.UNTERMINATED_SINGLE_QUOTE_IN_PLACEHOLDER,o(),0),j===Tn&&(L.next(),_(L,"'")),G):(_(L,"'"),G)}function de(L){const W=L.currentChar();switch(W){case"\\":case"'":return L.next(),`\\${W}`;case"u":return H(L,W,4);case"U":return H(L,W,6);default:return E(Ot.UNKNOWN_ESCAPE_SEQUENCE,o(),0,W),""}}function H(L,W,G){_(L,W);let j="";for(let Ee=0;Ee{const j=L.currentChar();return j==="{"||j==="@"||j==="|"||j==="("||j===")"||!j||j===La?G:(G+=j,L.next(),W(G))};return W("")}function U(L){O(L);const W=_(L,"|");return O(L),W}function M(L,W){let G=null;switch(L.currentChar()){case"{":return W.braceNest>=1&&E(Ot.NOT_ALLOW_NEST_PLACEHOLDER,o(),0),L.next(),G=c(W,2,"{"),O(L),W.braceNest++,G;case"}":return W.braceNest>0&&W.currentType===2&&E(Ot.EMPTY_PLACEHOLDER,o(),0),L.next(),G=c(W,3,"}"),W.braceNest--,W.braceNest>0&&O(L),W.inLinked&&W.braceNest===0&&(W.inLinked=!1),G;case"@":return W.braceNest>0&&E(Ot.UNTERMINATED_CLOSING_BRACE,o(),0),G=Y(L,W)||m(W),W.braceNest=0,G;default:{let Ee=!0,ge=!0,V=!0;if($(L))return W.braceNest>0&&E(Ot.UNTERMINATED_CLOSING_BRACE,o(),0),G=c(W,1,U(L)),W.braceNest=0,W.inLinked=!1,G;if(W.braceNest>0&&(W.currentType===4||W.currentType===5||W.currentType===6))return E(Ot.UNTERMINATED_CLOSING_BRACE,o(),0),W.braceNest=0,pe(L,W);if(Ee=g(L,W))return G=c(W,4,Oe(L)),O(L),G;if(ge=I(L,W))return G=c(W,5,pt(L)),O(L),G;if(V=N(L,W))return G=c(W,6,It(L)),O(L),G;if(!Ee&&!ge&&!V)return G=c(W,12,Ce(L)),E(Ot.INVALID_TOKEN_IN_PLACEHOLDER,o(),0,G.value),O(L),G;break}}return G}function Y(L,W){const{currentType:G}=W;let j=null;const Ee=L.currentChar();switch((G===7||G===8||G===11||G===9)&&(Ee===Tn||Ee===La)&&E(Ot.INVALID_LINKED_FORMAT,o(),0),Ee){case"@":return L.next(),j=c(W,7,"@"),W.inLinked=!0,j;case".":return O(L),L.next(),c(W,8,".");case":":return O(L),L.next(),c(W,9,":");default:return $(L)?(j=c(W,1,U(L)),W.braceNest=0,W.inLinked=!1,j):b(L,W)||k(L,W)?(O(L),Y(L,W)):C(L,W)?(O(L),c(W,11,ae(L))):P(L,W)?(O(L),Ee==="{"?M(L,W)||j:c(W,10,Ie(L))):(G===7&&E(Ot.INVALID_LINKED_FORMAT,o(),0),W.braceNest=0,W.inLinked=!1,pe(L,W))}}function pe(L,W){let G={type:13};if(W.braceNest>0)return M(L,W)||m(W);if(W.inLinked)return Y(L,W)||m(W);switch(L.currentChar()){case"{":return M(L,W)||m(W);case"}":return E(Ot.UNBALANCED_CLOSING_BRACE,o(),0),L.next(),c(W,3,"}");case"@":return Y(L,W)||m(W);default:{if($(L))return G=c(W,1,U(L)),W.braceNest=0,W.inLinked=!1,G;if(y(L))return c(W,0,re(L));break}}return G}function oe(){const{currentType:L,offset:W,startLoc:G,endLoc:j}=u;return u.lastType=L,u.lastOffset=W,u.lastStartLoc=G,u.lastEndLoc=j,u.offset=s(),u.startLoc=o(),a.currentChar()===Za?c(u,13):pe(a,u)}return{nextToken:oe,currentOffset:s,currentPosition:o,context:l}}const wL="parser",ML=/(?:\\\\|\\'|\\u([0-9a-fA-F]{4})|\\U([0-9a-fA-F]{6}))/g;function WL(e,t,n){switch(e){case"\\\\":return"\\";case"\\'":return"'";default:{const a=parseInt(t||n,16);return a<=55295||a>=57344?String.fromCodePoint(a):"�"}}}function FL(e={}){const t=e.location!==!1,{onError:n}=e;function a(S,R,g,I,...N){const b=S.currentPosition();if(b.offset+=I,b.column+=I,n){const C=t?Kd(g,b):null,k=gl(R,C,{domain:wL,args:N});n(k)}}function s(S,R,g){const I={type:S};return t&&(I.start=R,I.end=R,I.loc={start:g,end:g}),I}function o(S,R,g,I){t&&(S.end=R,S.loc&&(S.loc.end=g))}function i(S,R){const g=S.context(),I=s(3,g.offset,g.startLoc);return I.value=R,o(I,S.currentOffset(),S.currentPosition()),I}function r(S,R){const g=S.context(),{lastOffset:I,lastStartLoc:N}=g,b=s(5,I,N);return b.index=parseInt(R,10),S.nextToken(),o(b,S.currentOffset(),S.currentPosition()),b}function u(S,R){const g=S.context(),{lastOffset:I,lastStartLoc:N}=g,b=s(4,I,N);return b.key=R,S.nextToken(),o(b,S.currentOffset(),S.currentPosition()),b}function l(S,R){const g=S.context(),{lastOffset:I,lastStartLoc:N}=g,b=s(9,I,N);return b.value=R.replace(ML,WL),S.nextToken(),o(b,S.currentOffset(),S.currentPosition()),b}function d(S){const R=S.nextToken(),g=S.context(),{lastOffset:I,lastStartLoc:N}=g,b=s(8,I,N);return R.type!==11?(a(S,Ot.UNEXPECTED_EMPTY_LINKED_MODIFIER,g.lastStartLoc,0),b.value="",o(b,I,N),{nextConsumeToken:R,node:b}):(R.value==null&&a(S,Ot.UNEXPECTED_LEXICAL_ANALYSIS,g.lastStartLoc,0,ha(R)),b.value=R.value||"",o(b,S.currentOffset(),S.currentPosition()),{node:b})}function E(S,R){const g=S.context(),I=s(7,g.offset,g.startLoc);return I.value=R,o(I,S.currentOffset(),S.currentPosition()),I}function c(S){const R=S.context(),g=s(6,R.offset,R.startLoc);let I=S.nextToken();if(I.type===8){const N=d(S);g.modifier=N.node,I=N.nextConsumeToken||S.nextToken()}switch(I.type!==9&&a(S,Ot.UNEXPECTED_LEXICAL_ANALYSIS,R.lastStartLoc,0,ha(I)),I=S.nextToken(),I.type===2&&(I=S.nextToken()),I.type){case 10:I.value==null&&a(S,Ot.UNEXPECTED_LEXICAL_ANALYSIS,R.lastStartLoc,0,ha(I)),g.key=E(S,I.value||"");break;case 4:I.value==null&&a(S,Ot.UNEXPECTED_LEXICAL_ANALYSIS,R.lastStartLoc,0,ha(I)),g.key=u(S,I.value||"");break;case 5:I.value==null&&a(S,Ot.UNEXPECTED_LEXICAL_ANALYSIS,R.lastStartLoc,0,ha(I)),g.key=r(S,I.value||"");break;case 6:I.value==null&&a(S,Ot.UNEXPECTED_LEXICAL_ANALYSIS,R.lastStartLoc,0,ha(I)),g.key=l(S,I.value||"");break;default:{a(S,Ot.UNEXPECTED_EMPTY_LINKED_KEY,R.lastStartLoc,0);const N=S.context(),b=s(7,N.offset,N.startLoc);return b.value="",o(b,N.offset,N.startLoc),g.key=b,o(g,N.offset,N.startLoc),{nextConsumeToken:I,node:g}}}return o(g,S.currentOffset(),S.currentPosition()),{node:g}}function m(S){const R=S.context(),g=R.currentType===1?S.currentOffset():R.offset,I=R.currentType===1?R.endLoc:R.startLoc,N=s(2,g,I);N.items=[];let b=null;do{const P=b||S.nextToken();switch(b=null,P.type){case 0:P.value==null&&a(S,Ot.UNEXPECTED_LEXICAL_ANALYSIS,R.lastStartLoc,0,ha(P)),N.items.push(i(S,P.value||""));break;case 5:P.value==null&&a(S,Ot.UNEXPECTED_LEXICAL_ANALYSIS,R.lastStartLoc,0,ha(P)),N.items.push(r(S,P.value||""));break;case 4:P.value==null&&a(S,Ot.UNEXPECTED_LEXICAL_ANALYSIS,R.lastStartLoc,0,ha(P)),N.items.push(u(S,P.value||""));break;case 6:P.value==null&&a(S,Ot.UNEXPECTED_LEXICAL_ANALYSIS,R.lastStartLoc,0,ha(P)),N.items.push(l(S,P.value||""));break;case 7:{const $=c(S);N.items.push($.node),b=$.nextConsumeToken||null;break}}}while(R.currentType!==13&&R.currentType!==1);const C=R.currentType===1?R.lastOffset:S.currentOffset(),k=R.currentType===1?R.lastEndLoc:S.currentPosition();return o(N,C,k),N}function _(S,R,g,I){const N=S.context();let b=I.items.length===0;const C=s(1,R,g);C.cases=[],C.cases.push(I);do{const k=m(S);b||(b=k.items.length===0),C.cases.push(k)}while(N.currentType!==13);return b&&a(S,Ot.MUST_HAVE_MESSAGES_IN_PLURAL,g,0),o(C,S.currentOffset(),S.currentPosition()),C}function h(S){const R=S.context(),{offset:g,startLoc:I}=R,N=m(S);return R.currentType===13?N:_(S,g,I,N)}function O(S){const R=kL(S,jt({},e)),g=R.context(),I=s(0,g.offset,g.startLoc);return t&&I.loc&&(I.loc.source=S),I.body=h(R),e.onCacheKey&&(I.cacheKey=e.onCacheKey(S)),g.currentType!==13&&a(R,Ot.UNEXPECTED_LEXICAL_ANALYSIS,g.lastStartLoc,0,S[g.offset]||""),o(I,R.currentOffset(),R.currentPosition()),I}return{parse:O}}function ha(e){if(e.type===13)return"EOF";const t=(e.value||"").replace(/\r?\n/gu,"\\n");return t.length>10?t.slice(0,9)+"…":t}function zL(e,t={}){const n={ast:e,helpers:new Set};return{context:()=>n,helper:o=>(n.helpers.add(o),o)}}function p_(e,t){for(let n=0;nm_(n)),e}function m_(e){if(e.items.length===1){const t=e.items[0];(t.type===3||t.type===9)&&(e.static=t.value,delete t.value)}else{const t=[];for(let n=0;nr;function l(O,S){r.code+=O}function d(O,S=!0){const R=S?s:"";l(o?R+" ".repeat(O):R)}function E(O=!0){const S=++r.indentLevel;O&&d(S)}function c(O=!0){const S=--r.indentLevel;O&&d(S)}function m(){d(r.indentLevel)}return{context:u,push:l,indent:E,deindent:c,newline:m,helper:O=>`_${O}`,needIndent:()=>r.needIndent}}function VL(e,t){const{helper:n}=e;e.push(`${n("linked")}(`),bo(e,t.key),t.modifier?(e.push(", "),bo(e,t.modifier),e.push(", _type")):e.push(", undefined, _type"),e.push(")")}function HL(e,t){const{helper:n,needIndent:a}=e;e.push(`${n("normalize")}([`),e.indent(a());const s=t.items.length;for(let o=0;o1){e.push(`${n("plural")}([`),e.indent(a());const s=t.cases.length;for(let o=0;o{const n=Ue(t.mode)?t.mode:"normal",a=Ue(t.filename)?t.filename:"message.intl",s=!!t.sourceMap,o=t.breakLineCode!=null?t.breakLineCode:n==="arrow"?";":` +`,i=t.needIndent?t.needIndent:n!=="arrow",r=e.helpers||[],u=GL(e,{mode:n,filename:a,sourceMap:s,breakLineCode:o,needIndent:i});u.push(n==="normal"?"function __msg__ (ctx) {":"(ctx) => {"),u.indent(i),r.length>0&&(u.push(`const { ${ap(r.map(E=>`${E}: _${E}`),", ")} } = ctx`),u.newline()),u.push("return "),bo(u,e),u.deindent(i),u.push("}"),delete e.helpers;const{code:l,map:d}=u.context();return{ast:e,code:l,map:d?d.toJSON():void 0}};function YL(e,t={}){const n=jt({},t),a=!!n.jit,s=!!n.minify,o=n.optimize==null?!0:n.optimize,r=FL(n).parse(e);return a?(o&&BL(r),s&&ro(r),{ast:r,code:""}):(xL(r,n),jL(r,n))}/*! + * core-base v10.0.5 + * (c) 2024 kazuya kawaguchi + * Released under the MIT License. + */function XL(){typeof __INTLIFY_PROD_DEVTOOLS__!="boolean"&&(Us().__INTLIFY_PROD_DEVTOOLS__=!1),typeof __INTLIFY_DROP_MESSAGE_COMPILER__!="boolean"&&(Us().__INTLIFY_DROP_MESSAGE_COMPILER__=!1)}function _c(e){return n=>QL(n,e)}function QL(e,t){const n=JL(t);if(n==null)throw Wi(0);if(op(n)===1){const o=ty(n);return e.plural(o.reduce((i,r)=>[...i,T_(e,r)],[]))}else return T_(e,n)}const ZL=["b","body"];function JL(e){return fs(e,ZL)}const ey=["c","cases"];function ty(e){return fs(e,ey,[])}function T_(e,t){const n=ay(t);if(n!=null)return e.type==="text"?n:e.normalize([n]);{const a=oy(t).reduce((s,o)=>[...s,qd(e,o)],[]);return e.normalize(a)}}const ny=["s","static"];function ay(e){return fs(e,ny)}const sy=["i","items"];function oy(e){return fs(e,sy,[])}function qd(e,t){const n=op(t);switch(n){case 3:return Or(t,n);case 9:return Or(t,n);case 4:{const a=t;if(aa(a,"k")&&a.k)return e.interpolate(e.named(a.k));if(aa(a,"key")&&a.key)return e.interpolate(e.named(a.key));throw Wi(n)}case 5:{const a=t;if(aa(a,"i")&&Bt(a.i))return e.interpolate(e.list(a.i));if(aa(a,"index")&&Bt(a.index))return e.interpolate(e.list(a.index));throw Wi(n)}case 6:{const a=t,s=ly(a),o=dy(a);return e.linked(qd(e,o),s?qd(e,s):void 0,e.type)}case 7:return Or(t,n);case 8:return Or(t,n);default:throw new Error(`unhandled node on format message part: ${n}`)}}const iy=["t","type"];function op(e){return fs(e,iy)}const ry=["v","value"];function Or(e,t){const n=fs(e,ry);if(n)return n;throw Wi(t)}const uy=["m","modifier"];function ly(e){return fs(e,uy)}const cy=["k","key"];function dy(e){const t=fs(e,cy);if(t)return t;throw Wi(6)}function fs(e,t,n){for(let a=0;ae;let Ir=gt();function Co(e){return _t(e)&&op(e)===0&&(aa(e,"b")||aa(e,"body"))}function py(e,t={}){let n=!1;const a=t.onError||CL;return t.onError=s=>{n=!0,a(s)},{...YL(e,t),detectError:n}}function my(e,t){if(!__INTLIFY_DROP_MESSAGE_COMPILER__&&Ue(e)){mt(t.warnHtmlMessage)&&t.warnHtmlMessage;const a=(t.onCacheKey||Ey)(e),s=Ir[a];if(s)return s;const{ast:o,detectError:i}=py(e,{...t,location:!1,jit:!0}),r=_c(o);return i?r:Ir[a]=r}else{const n=e.cacheKey;if(n){const a=Ir[n];return a||(Ir[n]=_c(e))}else return _c(e)}}let Fi=null;function Ty(e){Fi=e}function _y(e,t,n){Fi&&Fi.emit("i18n:init",{timestamp:Date.now(),i18n:e,version:t,meta:n})}const fy=hy("function:translate");function hy(e){return t=>Fi&&Fi.emit(e,t)}const Ba={INVALID_ARGUMENT:bL,INVALID_DATE_ARGUMENT:18,INVALID_ISO_DATE_ARGUMENT:19,NOT_SUPPORT_NON_STRING_MESSAGE:20,NOT_SUPPORT_LOCALE_PROMISE_VALUE:21,NOT_SUPPORT_LOCALE_ASYNC_FUNCTION:22,NOT_SUPPORT_LOCALE_TYPE:23},Sy=24;function Ga(e){return gl(e,null,void 0)}function ip(e,t){return t.locale!=null?__(t.locale):__(e.locale)}let fc;function __(e){if(Ue(e))return e;if(Lt(e)){if(e.resolvedOnce&&fc!=null)return fc;if(e.constructor.name==="Function"){const t=e();if(gL(t))throw Ga(Ba.NOT_SUPPORT_LOCALE_PROMISE_VALUE);return fc=t}else throw Ga(Ba.NOT_SUPPORT_LOCALE_ASYNC_FUNCTION)}else throw Ga(Ba.NOT_SUPPORT_LOCALE_TYPE)}function Ay(e,t,n){return[...new Set([n,...Ft(t)?t:_t(t)?Object.keys(t):Ue(t)?[t]:[n]])]}function v1(e,t,n){const a=Ue(n)?n:zi,s=e;s.__localeChainCache||(s.__localeChainCache=new Map);let o=s.__localeChainCache.get(a);if(!o){o=[];let i=[n];for(;Ft(i);)i=f_(o,i,t);const r=Ft(t)||!at(t)?t:t.default?t.default:null;i=Ue(r)?[r]:r,Ft(i)&&f_(o,i,!1),s.__localeChainCache.set(a,o)}return o}function f_(e,t,n){let a=!0;for(let s=0;s{i===void 0?i=r:i+=r},c[1]=()=>{i!==void 0&&(t.push(i),i=void 0)},c[2]=()=>{c[0](),s++},c[3]=()=>{if(s>0)s--,a=4,c[0]();else{if(s=0,i===void 0||(i=by(i),i===!1))return!1;c[1]()}};function m(){const _=e[n+1];if(a===5&&_==="'"||a===6&&_==='"')return n++,r="\\"+_,c[0](),!0}for(;a!==null;)if(n++,o=e[n],!(o==="\\"&&m())){if(u=vy(o),E=hs[a],l=E[u]||E.l||8,l===8||(a=l[0],l[1]!==void 0&&(d=c[l[1]],d&&(r=o,d()===!1))))return;if(a===7)return t}}const h_=new Map;function Py(e,t){return _t(e)?e[t]:null}function Dy(e,t){if(!_t(e))return null;let n=h_.get(t);if(n||(n=Cy(t),n&&h_.set(t,n)),!n)return null;const a=n.length;let s=e,o=0;for(;o`${e.charAt(0).toLocaleUpperCase()}${e.substr(1)}`;function yy(){return{upper:(e,t)=>t==="text"&&Ue(e)?e.toUpperCase():t==="vnode"&&_t(e)&&"__v_isVNode"in e?e.children.toUpperCase():e,lower:(e,t)=>t==="text"&&Ue(e)?e.toLowerCase():t==="vnode"&&_t(e)&&"__v_isVNode"in e?e.children.toLowerCase():e,capitalize:(e,t)=>t==="text"&&Ue(e)?A_(e):t==="vnode"&&_t(e)&&"__v_isVNode"in e?A_(e.children):e}}let b1;function $y(e){b1=e}let C1;function Uy(e){C1=e}let P1;function ky(e){P1=e}let D1=null;const wy=e=>{D1=e},My=()=>D1;let L1=null;const O_=e=>{L1=e},Wy=()=>L1;let I_=0;function Fy(e={}){const t=Lt(e.onWarn)?e.onWarn:NL,n=Ue(e.version)?e.version:Ly,a=Ue(e.locale)||Lt(e.locale)?e.locale:zi,s=Lt(a)?zi:a,o=Ft(e.fallbackLocale)||at(e.fallbackLocale)||Ue(e.fallbackLocale)||e.fallbackLocale===!1?e.fallbackLocale:s,i=at(e.messages)?e.messages:hc(s),r=at(e.datetimeFormats)?e.datetimeFormats:hc(s),u=at(e.numberFormats)?e.numberFormats:hc(s),l=jt(gt(),e.modifiers,yy()),d=e.pluralRules||gt(),E=Lt(e.missing)?e.missing:null,c=mt(e.missingWarn)||vo(e.missingWarn)?e.missingWarn:!0,m=mt(e.fallbackWarn)||vo(e.fallbackWarn)?e.fallbackWarn:!0,_=!!e.fallbackFormat,h=!!e.unresolving,O=Lt(e.postTranslation)?e.postTranslation:null,S=at(e.processor)?e.processor:null,R=mt(e.warnHtmlMessage)?e.warnHtmlMessage:!0,g=!!e.escapeParameter,I=Lt(e.messageCompiler)?e.messageCompiler:b1,N=Lt(e.messageResolver)?e.messageResolver:C1||Py,b=Lt(e.localeFallbacker)?e.localeFallbacker:P1||Ay,C=_t(e.fallbackContext)?e.fallbackContext:void 0,k=e,P=_t(k.__datetimeFormatters)?k.__datetimeFormatters:new Map,$=_t(k.__numberFormatters)?k.__numberFormatters:new Map,y=_t(k.__meta)?k.__meta:{};I_++;const z={version:n,cid:I_,locale:a,fallbackLocale:o,messages:i,modifiers:l,pluralRules:d,missing:E,missingWarn:c,fallbackWarn:m,fallbackFormat:_,unresolving:h,postTranslation:O,processor:S,warnHtmlMessage:R,escapeParameter:g,messageCompiler:I,messageResolver:N,localeFallbacker:b,fallbackContext:C,onWarn:t,__meta:y};return z.datetimeFormats=r,z.numberFormats=u,z.__datetimeFormatters=P,z.__numberFormatters=$,__INTLIFY_PROD_DEVTOOLS__&&_y(z,n,y),z}const hc=e=>({[e]:gt()});function rp(e,t,n,a,s){const{missing:o,onWarn:i}=e;if(o!==null){const r=o(e,n,t,s);return Ue(r)?r:t}else return t}function Qo(e,t,n){const a=e;a.__localeChainCache=new Map,e.localeFallbacker(e,n,t)}function zy(e,t){return e===t?!1:e.split("-")[0]===t.split("-")[0]}function xy(e,t){const n=t.indexOf(e);if(n===-1)return!1;for(let a=n+1;a{y1.includes(u)?i[u]=n[u]:o[u]=n[u]}),Ue(a)?o.locale=a:at(a)&&(i=a),at(s)&&(i=s),[o.key||"",r,o,i]}function R_(e,t,n){const a=e;for(const s in n){const o=`${t}__${s}`;a.__datetimeFormatters.has(o)&&a.__datetimeFormatters.delete(o)}}function N_(e,...t){const{numberFormats:n,unresolving:a,fallbackLocale:s,onWarn:o,localeFallbacker:i}=e,{__numberFormatters:r}=e,[u,l,d,E]=Yd(...t),c=mt(d.missingWarn)?d.missingWarn:e.missingWarn;mt(d.fallbackWarn)?d.fallbackWarn:e.fallbackWarn;const m=!!d.part,_=ip(e,d),h=i(e,s,_);if(!Ue(u)||u==="")return new Intl.NumberFormat(_,E).format(l);let O={},S,R=null;const g="number format";for(let b=0;b{$1.includes(u)?i[u]=n[u]:o[u]=n[u]}),Ue(a)?o.locale=a:at(a)&&(i=a),at(s)&&(i=s),[o.key||"",r,o,i]}function v_(e,t,n){const a=e;for(const s in n){const o=`${t}__${s}`;a.__numberFormatters.has(o)&&a.__numberFormatters.delete(o)}}const By=e=>e,Gy=e=>"",Vy="text",Hy=e=>e.length===0?"":ap(e),Ky=RL;function b_(e,t){return e=Math.abs(e),t===2?e?e>1?1:0:1:e?Math.min(e,2):0}function qy(e){const t=Bt(e.pluralIndex)?e.pluralIndex:-1;return e.named&&(Bt(e.named.count)||Bt(e.named.n))?Bt(e.named.count)?e.named.count:Bt(e.named.n)?e.named.n:t:t}function jy(e,t){t.count||(t.count=e),t.n||(t.n=e)}function Yy(e={}){const t=e.locale,n=qy(e),a=_t(e.pluralRules)&&Ue(t)&&Lt(e.pluralRules[t])?e.pluralRules[t]:b_,s=_t(e.pluralRules)&&Ue(t)&&Lt(e.pluralRules[t])?b_:void 0,o=S=>S[a(n,S.length,s)],i=e.list||[],r=S=>i[S],u=e.named||gt();Bt(e.pluralIndex)&&jy(n,u);const l=S=>u[S];function d(S,R){const g=Lt(e.messages)?e.messages(S,!!R):_t(e.messages)?e.messages[S]:!1;return g||(e.parent?e.parent.message(S):Gy)}const E=S=>e.modifiers?e.modifiers[S]:By,c=at(e.processor)&&Lt(e.processor.normalize)?e.processor.normalize:Hy,m=at(e.processor)&&Lt(e.processor.interpolate)?e.processor.interpolate:Ky,_=at(e.processor)&&Ue(e.processor.type)?e.processor.type:Vy,O={list:r,named:l,plural:o,linked:(S,...R)=>{const[g,I]=R;let N="text",b="";R.length===1?_t(g)?(b=g.modifier||b,N=g.type||N):Ue(g)&&(b=g||b):R.length===2&&(Ue(g)&&(b=g||b),Ue(I)&&(N=I||N));const C=d(S,!0)(O),k=N==="vnode"&&Ft(C)&&b?C[0]:C;return b?E(b)(k,N):k},message:d,type:_,interpolate:m,normalize:c,values:jt(gt(),i,u)};return O}const C_=()=>"",qn=e=>Lt(e);function P_(e,...t){const{fallbackFormat:n,postTranslation:a,unresolving:s,messageCompiler:o,fallbackLocale:i,messages:r}=e,[u,l]=Xd(...t),d=mt(l.missingWarn)?l.missingWarn:e.missingWarn,E=mt(l.fallbackWarn)?l.fallbackWarn:e.fallbackWarn,c=mt(l.escapeParameter)?l.escapeParameter:e.escapeParameter,m=!!l.resolvedMessage,_=Ue(l.default)||mt(l.default)?mt(l.default)?o?u:()=>u:l.default:n?o?u:()=>u:null,h=n||_!=null&&(Ue(_)||Lt(_)),O=ip(e,l);c&&Xy(l);let[S,R,g]=m?[u,O,r[O]||gt()]:U1(e,u,O,i,E,d),I=S,N=u;if(!m&&!(Ue(I)||Co(I)||qn(I))&&h&&(I=_,N=I),!m&&(!(Ue(I)||Co(I)||qn(I))||!Ue(R)))return s?Rl:u;let b=!1;const C=()=>{b=!0},k=qn(I)?I:k1(e,u,R,I,N,C);if(b)return I;const P=Jy(e,R,g,l),$=Yy(P),y=Qy(e,k,$),z=a?a(y,u):y;if(__INTLIFY_PROD_DEVTOOLS__){const Z={timestamp:Date.now(),key:Ue(u)?u:qn(I)?I.key:"",locale:R||(qn(I)?I.locale:""),format:Ue(I)?I:qn(I)?I.source:"",message:z};Z.meta=jt({},e.__meta,My()||{}),fy(Z)}return z}function Xy(e){Ft(e.list)?e.list=e.list.map(t=>Ue(t)?d_(t):t):_t(e.named)&&Object.keys(e.named).forEach(t=>{Ue(e.named[t])&&(e.named[t]=d_(e.named[t]))})}function U1(e,t,n,a,s,o){const{messages:i,onWarn:r,messageResolver:u,localeFallbacker:l}=e,d=l(e,a,n);let E=gt(),c,m=null;const _="translate";for(let h=0;ha;return l.locale=n,l.key=t,l}const u=i(a,Zy(e,n,s,a,r,o));return u.locale=n,u.key=t,u.source=a,u}function Qy(e,t,n){return t(n)}function Xd(...e){const[t,n,a]=e,s=gt();if(!Ue(t)&&!Bt(t)&&!qn(t)&&!Co(t))throw Ga(Ba.INVALID_ARGUMENT);const o=Bt(t)?String(t):(qn(t),t);return Bt(n)?s.plural=n:Ue(n)?s.default=n:at(n)&&!Il(n)?s.named=n:Ft(n)&&(s.list=n),Bt(a)?s.plural=a:Ue(a)?s.default=a:at(a)&&jt(s,a),[o,s]}function Zy(e,t,n,a,s,o){return{locale:t,key:n,warnHtmlMessage:s,onError:i=>{throw o&&o(i),i},onCacheKey:i=>hL(t,n,i)}}function Jy(e,t,n,a){const{modifiers:s,pluralRules:o,messageResolver:i,fallbackLocale:r,fallbackWarn:u,missingWarn:l,fallbackContext:d}=e,c={locale:t,modifiers:s,pluralRules:o,messages:(m,_)=>{let h=i(n,m);if(h==null&&(d||_)){const[,,O]=U1(d||e,m,t,r,u,l);h=i(O,m)}if(Ue(h)||Co(h)){let O=!1;const R=k1(e,m,t,h,m,()=>{O=!0});return O?C_:R}else return qn(h)?h:C_}};return e.processor&&(c.processor=e.processor),a.list&&(c.list=a.list),a.named&&(c.named=a.named),Bt(a.plural)&&(c.pluralIndex=a.plural),c}XL();/*! + * vue-i18n v10.0.5 + * (c) 2024 kazuya kawaguchi + * Released under the MIT License. + */const e3="10.0.5";function t3(){typeof __VUE_I18N_FULL_INSTALL__!="boolean"&&(Us().__VUE_I18N_FULL_INSTALL__=!0),typeof __VUE_I18N_LEGACY_API__!="boolean"&&(Us().__VUE_I18N_LEGACY_API__=!0),typeof __INTLIFY_DROP_MESSAGE_COMPILER__!="boolean"&&(Us().__INTLIFY_DROP_MESSAGE_COMPILER__=!1),typeof __INTLIFY_PROD_DEVTOOLS__!="boolean"&&(Us().__INTLIFY_PROD_DEVTOOLS__=!1)}const Rn={UNEXPECTED_RETURN_TYPE:Sy,INVALID_ARGUMENT:25,MUST_BE_CALL_SETUP_TOP:26,NOT_INSTALLED:27,REQUIRED_VALUE:28,INVALID_VALUE:29,CANNOT_SETUP_VUE_DEVTOOLS_PLUGIN:30,NOT_INSTALLED_WITH_PROVIDE:31,UNEXPECTED_ERROR:32,NOT_COMPATIBLE_LEGACY_VUE_I18N:33,NOT_AVAILABLE_COMPOSITION_IN_LEGACY:34};function Pn(e,...t){return gl(e,null,void 0)}const Qd=_s("__translateVNode"),Zd=_s("__datetimeParts"),Jd=_s("__numberParts"),w1=_s("__setPluralRules"),M1=_s("__injectWithOption"),eE=_s("__dispose");function xi(e){if(!_t(e))return e;for(const t in e)if(aa(e,t))if(!t.includes("."))_t(e[t])&&xi(e[t]);else{const n=t.split("."),a=n.length-1;let s=e,o=!1;for(let i=0;i{if("locale"in r&&"resource"in r){const{locale:u,resource:l}=r;u?(i[u]=i[u]||gt(),Yr(l,i[u])):Yr(l,i)}else Ue(r)&&Yr(JSON.parse(r),i)}),s==null&&o)for(const r in i)aa(i,r)&&xi(i[r]);return i}function W1(e){return e.type}function F1(e,t,n){let a=_t(t.messages)?t.messages:gt();"__i18nGlobal"in n&&(a=up(e.locale.value,{messages:a,__i18n:n.__i18nGlobal}));const s=Object.keys(a);s.length&&s.forEach(o=>{e.mergeLocaleMessage(o,a[o])});{if(_t(t.datetimeFormats)){const o=Object.keys(t.datetimeFormats);o.length&&o.forEach(i=>{e.mergeDateTimeFormat(i,t.datetimeFormats[i])})}if(_t(t.numberFormats)){const o=Object.keys(t.numberFormats);o.length&&o.forEach(i=>{e.mergeNumberFormat(i,t.numberFormats[i])})}}}function D_(e){return w(Zi,null,e,0)}const L_="__INTLIFY_META__",y_=()=>[],n3=()=>!1;let $_=0;function U_(e){return(t,n,a,s)=>e(n,a,Ao()||void 0,s)}const a3=()=>{const e=Ao();let t=null;return e&&(t=W1(e)[L_])?{[L_]:t}:null};function lp(e={}){const{__root:t,__injectWithOption:n}=e,a=t===void 0,s=e.flatJson,o=_u?Se:sl;let i=mt(e.inheritLocale)?e.inheritLocale:!0;const r=o(t&&i?t.locale.value:Ue(e.locale)?e.locale:zi),u=o(t&&i?t.fallbackLocale.value:Ue(e.fallbackLocale)||Ft(e.fallbackLocale)||at(e.fallbackLocale)||e.fallbackLocale===!1?e.fallbackLocale:r.value),l=o(up(r.value,e)),d=o(at(e.datetimeFormats)?e.datetimeFormats:{[r.value]:{}}),E=o(at(e.numberFormats)?e.numberFormats:{[r.value]:{}});let c=t?t.missingWarn:mt(e.missingWarn)||vo(e.missingWarn)?e.missingWarn:!0,m=t?t.fallbackWarn:mt(e.fallbackWarn)||vo(e.fallbackWarn)?e.fallbackWarn:!0,_=t?t.fallbackRoot:mt(e.fallbackRoot)?e.fallbackRoot:!0,h=!!e.fallbackFormat,O=Lt(e.missing)?e.missing:null,S=Lt(e.missing)?U_(e.missing):null,R=Lt(e.postTranslation)?e.postTranslation:null,g=t?t.warnHtmlMessage:mt(e.warnHtmlMessage)?e.warnHtmlMessage:!0,I=!!e.escapeParameter;const N=t?t.modifiers:at(e.modifiers)?e.modifiers:{};let b=e.pluralRules||t&&t.pluralRules,C;C=(()=>{a&&O_(null);const V={version:e3,locale:r.value,fallbackLocale:u.value,messages:l.value,modifiers:N,pluralRules:b,missing:S===null?void 0:S,missingWarn:c,fallbackWarn:m,fallbackFormat:h,unresolving:!0,postTranslation:R===null?void 0:R,warnHtmlMessage:g,escapeParameter:I,messageResolver:e.messageResolver,messageCompiler:e.messageCompiler,__meta:{framework:"vue"}};V.datetimeFormats=d.value,V.numberFormats=E.value,V.__datetimeFormatters=at(C)?C.__datetimeFormatters:void 0,V.__numberFormatters=at(C)?C.__numberFormatters:void 0;const ie=Fy(V);return a&&O_(ie),ie})(),Qo(C,r.value,u.value);function P(){return[r.value,u.value,l.value,d.value,E.value]}const $=F({get:()=>r.value,set:V=>{r.value=V,C.locale=r.value}}),y=F({get:()=>u.value,set:V=>{u.value=V,C.fallbackLocale=u.value,Qo(C,r.value,V)}}),z=F(()=>l.value),Z=F(()=>d.value),Ae=F(()=>E.value);function J(){return Lt(R)?R:null}function ce(V){R=V,C.postTranslation=V}function Te(){return O}function De(V){V!==null&&(S=U_(V)),O=V,C.missing=S}const Ve=(V,ie,Pe,We,lt,ct)=>{P();let Vt;try{__INTLIFY_PROD_DEVTOOLS__,a||(C.fallbackContext=t?Wy():void 0),Vt=V(C)}finally{__INTLIFY_PROD_DEVTOOLS__,a||(C.fallbackContext=void 0)}if(Pe!=="translate exists"&&Bt(Vt)&&Vt===Rl||Pe==="translate exists"&&!Vt){const[Yt,Gn]=ie();return t&&_?We(t):lt(Yt)}else{if(ct(Vt))return Vt;throw Pn(Rn.UNEXPECTED_RETURN_TYPE)}};function xe(...V){return Ve(ie=>Reflect.apply(P_,null,[ie,...V]),()=>Xd(...V),"translate",ie=>Reflect.apply(ie.t,ie,[...V]),ie=>ie,ie=>Ue(ie))}function ot(...V){const[ie,Pe,We]=V;if(We&&!_t(We))throw Pn(Rn.INVALID_ARGUMENT);return xe(ie,Pe,jt({resolvedMessage:!0},We||{}))}function re(...V){return Ve(ie=>Reflect.apply(g_,null,[ie,...V]),()=>jd(...V),"datetime format",ie=>Reflect.apply(ie.d,ie,[...V]),()=>S_,ie=>Ue(ie))}function Oe(...V){return Ve(ie=>Reflect.apply(N_,null,[ie,...V]),()=>Yd(...V),"number format",ie=>Reflect.apply(ie.n,ie,[...V]),()=>S_,ie=>Ue(ie))}function pt(V){return V.map(ie=>Ue(ie)||Bt(ie)||mt(ie)?D_(String(ie)):ie)}const It={normalize:pt,interpolate:V=>V,type:"vnode"};function de(...V){return Ve(ie=>{let Pe;const We=ie;try{We.processor=It,Pe=Reflect.apply(P_,null,[We,...V])}finally{We.processor=null}return Pe},()=>Xd(...V),"translate",ie=>ie[Qd](...V),ie=>[D_(ie)],ie=>Ft(ie))}function H(...V){return Ve(ie=>Reflect.apply(N_,null,[ie,...V]),()=>Yd(...V),"number format",ie=>ie[Jd](...V),y_,ie=>Ue(ie)||Ft(ie))}function fe(...V){return Ve(ie=>Reflect.apply(g_,null,[ie,...V]),()=>jd(...V),"datetime format",ie=>ie[Zd](...V),y_,ie=>Ue(ie)||Ft(ie))}function Ce(V){b=V,C.pluralRules=b}function ae(V,ie){return Ve(()=>{if(!V)return!1;const Pe=Ue(ie)?ie:r.value,We=M(Pe),lt=C.messageResolver(We,V);return Co(lt)||qn(lt)||Ue(lt)},()=>[V],"translate exists",Pe=>Reflect.apply(Pe.te,Pe,[V,ie]),n3,Pe=>mt(Pe))}function Ie(V){let ie=null;const Pe=v1(C,u.value,r.value);for(let We=0;We{i&&(r.value=V,C.locale=V,Qo(C,r.value,u.value))}),Le(t.fallbackLocale,V=>{i&&(u.value=V,C.fallbackLocale=V,Qo(C,r.value,u.value))}));const ge={id:$_,locale:$,fallbackLocale:y,get inheritLocale(){return i},set inheritLocale(V){i=V,V&&t&&(r.value=t.locale.value,u.value=t.fallbackLocale.value,Qo(C,r.value,u.value))},get availableLocales(){return Object.keys(l.value).sort()},messages:z,get modifiers(){return N},get pluralRules(){return b||{}},get isGlobal(){return a},get missingWarn(){return c},set missingWarn(V){c=V,C.missingWarn=c},get fallbackWarn(){return m},set fallbackWarn(V){m=V,C.fallbackWarn=m},get fallbackRoot(){return _},set fallbackRoot(V){_=V},get fallbackFormat(){return h},set fallbackFormat(V){h=V,C.fallbackFormat=h},get warnHtmlMessage(){return g},set warnHtmlMessage(V){g=V,C.warnHtmlMessage=V},get escapeParameter(){return I},set escapeParameter(V){I=V,C.escapeParameter=V},t:xe,getLocaleMessage:M,setLocaleMessage:Y,mergeLocaleMessage:pe,getPostTranslationHandler:J,setPostTranslationHandler:ce,getMissingHandler:Te,setMissingHandler:De,[w1]:Ce};return ge.datetimeFormats=Z,ge.numberFormats=Ae,ge.rt=ot,ge.te=ae,ge.tm=U,ge.d=re,ge.n=Oe,ge.getDateTimeFormat=oe,ge.setDateTimeFormat=L,ge.mergeDateTimeFormat=W,ge.getNumberFormat=G,ge.setNumberFormat=j,ge.mergeNumberFormat=Ee,ge[M1]=n,ge[Qd]=de,ge[Zd]=fe,ge[Jd]=H,ge}function s3(e){const t=Ue(e.locale)?e.locale:zi,n=Ue(e.fallbackLocale)||Ft(e.fallbackLocale)||at(e.fallbackLocale)||e.fallbackLocale===!1?e.fallbackLocale:t,a=Lt(e.missing)?e.missing:void 0,s=mt(e.silentTranslationWarn)||vo(e.silentTranslationWarn)?!e.silentTranslationWarn:!0,o=mt(e.silentFallbackWarn)||vo(e.silentFallbackWarn)?!e.silentFallbackWarn:!0,i=mt(e.fallbackRoot)?e.fallbackRoot:!0,r=!!e.formatFallbackMessages,u=at(e.modifiers)?e.modifiers:{},l=e.pluralizationRules,d=Lt(e.postTranslation)?e.postTranslation:void 0,E=Ue(e.warnHtmlInMessage)?e.warnHtmlInMessage!=="off":!0,c=!!e.escapeParameterHtml,m=mt(e.sync)?e.sync:!0;let _=e.messages;if(at(e.sharedMessages)){const N=e.sharedMessages;_=Object.keys(N).reduce((C,k)=>{const P=C[k]||(C[k]={});return jt(P,N[k]),C},_||{})}const{__i18n:h,__root:O,__injectWithOption:S}=e,R=e.datetimeFormats,g=e.numberFormats,I=e.flatJson;return{locale:t,fallbackLocale:n,messages:_,flatJson:I,datetimeFormats:R,numberFormats:g,missing:a,missingWarn:s,fallbackWarn:o,fallbackRoot:i,fallbackFormat:r,modifiers:u,pluralRules:l,postTranslation:d,warnHtmlMessage:E,escapeParameter:c,messageResolver:e.messageResolver,inheritLocale:m,__i18n:h,__root:O,__injectWithOption:S}}function tE(e={}){const t=lp(s3(e)),{__extender:n}=e,a={id:t.id,get locale(){return t.locale.value},set locale(s){t.locale.value=s},get fallbackLocale(){return t.fallbackLocale.value},set fallbackLocale(s){t.fallbackLocale.value=s},get messages(){return t.messages.value},get datetimeFormats(){return t.datetimeFormats.value},get numberFormats(){return t.numberFormats.value},get availableLocales(){return t.availableLocales},get missing(){return t.getMissingHandler()},set missing(s){t.setMissingHandler(s)},get silentTranslationWarn(){return mt(t.missingWarn)?!t.missingWarn:t.missingWarn},set silentTranslationWarn(s){t.missingWarn=mt(s)?!s:s},get silentFallbackWarn(){return mt(t.fallbackWarn)?!t.fallbackWarn:t.fallbackWarn},set silentFallbackWarn(s){t.fallbackWarn=mt(s)?!s:s},get modifiers(){return t.modifiers},get formatFallbackMessages(){return t.fallbackFormat},set formatFallbackMessages(s){t.fallbackFormat=s},get postTranslation(){return t.getPostTranslationHandler()},set postTranslation(s){t.setPostTranslationHandler(s)},get sync(){return t.inheritLocale},set sync(s){t.inheritLocale=s},get warnHtmlInMessage(){return t.warnHtmlMessage?"warn":"off"},set warnHtmlInMessage(s){t.warnHtmlMessage=s!=="off"},get escapeParameterHtml(){return t.escapeParameter},set escapeParameterHtml(s){t.escapeParameter=s},get pluralizationRules(){return t.pluralRules||{}},__composer:t,t(...s){return Reflect.apply(t.t,t,[...s])},rt(...s){return Reflect.apply(t.rt,t,[...s])},tc(...s){const[o,i,r]=s,u={plural:1};let l=null,d=null;if(!Ue(o))throw Pn(Rn.INVALID_ARGUMENT);const E=o;return Ue(i)?u.locale=i:Bt(i)?u.plural=i:Ft(i)?l=i:at(i)&&(d=i),Ue(r)?u.locale=r:Ft(r)?l=r:at(r)&&(d=r),Reflect.apply(t.t,t,[E,l||d||{},u])},te(s,o){return t.te(s,o)},tm(s){return t.tm(s)},getLocaleMessage(s){return t.getLocaleMessage(s)},setLocaleMessage(s,o){t.setLocaleMessage(s,o)},mergeLocaleMessage(s,o){t.mergeLocaleMessage(s,o)},d(...s){return Reflect.apply(t.d,t,[...s])},getDateTimeFormat(s){return t.getDateTimeFormat(s)},setDateTimeFormat(s,o){t.setDateTimeFormat(s,o)},mergeDateTimeFormat(s,o){t.mergeDateTimeFormat(s,o)},n(...s){return Reflect.apply(t.n,t,[...s])},getNumberFormat(s){return t.getNumberFormat(s)},setNumberFormat(s,o){t.setNumberFormat(s,o)},mergeNumberFormat(s,o){t.mergeNumberFormat(s,o)}};return a.__extender=n,a}function o3(e,t,n){return{beforeCreate(){const a=Ao();if(!a)throw Pn(Rn.UNEXPECTED_ERROR);const s=this.$options;if(s.i18n){const o=s.i18n;if(s.__i18n&&(o.__i18n=s.__i18n),o.__root=t,this===this.$root)this.$i18n=k_(e,o);else{o.__injectWithOption=!0,o.__extender=n.__vueI18nExtend,this.$i18n=tE(o);const i=this.$i18n;i.__extender&&(i.__disposer=i.__extender(this.$i18n))}}else if(s.__i18n)if(this===this.$root)this.$i18n=k_(e,s);else{this.$i18n=tE({__i18n:s.__i18n,__injectWithOption:!0,__extender:n.__vueI18nExtend,__root:t});const o=this.$i18n;o.__extender&&(o.__disposer=o.__extender(this.$i18n))}else this.$i18n=e;s.__i18nGlobal&&F1(t,s,s),this.$t=(...o)=>this.$i18n.t(...o),this.$rt=(...o)=>this.$i18n.rt(...o),this.$tc=(...o)=>this.$i18n.tc(...o),this.$te=(o,i)=>this.$i18n.te(o,i),this.$d=(...o)=>this.$i18n.d(...o),this.$n=(...o)=>this.$i18n.n(...o),this.$tm=o=>this.$i18n.tm(o),n.__setInstance(a,this.$i18n)},mounted(){},unmounted(){const a=Ao();if(!a)throw Pn(Rn.UNEXPECTED_ERROR);const s=this.$i18n;delete this.$t,delete this.$rt,delete this.$tc,delete this.$te,delete this.$d,delete this.$n,delete this.$tm,s.__disposer&&(s.__disposer(),delete s.__disposer,delete s.__extender),n.__deleteInstance(a),delete this.$i18n}}}function k_(e,t){e.locale=t.locale||e.locale,e.fallbackLocale=t.fallbackLocale||e.fallbackLocale,e.missing=t.missing||e.missing,e.silentTranslationWarn=t.silentTranslationWarn||e.silentFallbackWarn,e.silentFallbackWarn=t.silentFallbackWarn||e.silentFallbackWarn,e.formatFallbackMessages=t.formatFallbackMessages||e.formatFallbackMessages,e.postTranslation=t.postTranslation||e.postTranslation,e.warnHtmlInMessage=t.warnHtmlInMessage||e.warnHtmlInMessage,e.escapeParameterHtml=t.escapeParameterHtml||e.escapeParameterHtml,e.sync=t.sync||e.sync,e.__composer[w1](t.pluralizationRules||e.pluralizationRules);const n=up(e.locale,{messages:t.messages,__i18n:t.__i18n});return Object.keys(n).forEach(a=>e.mergeLocaleMessage(a,n[a])),t.datetimeFormats&&Object.keys(t.datetimeFormats).forEach(a=>e.mergeDateTimeFormat(a,t.datetimeFormats[a])),t.numberFormats&&Object.keys(t.numberFormats).forEach(a=>e.mergeNumberFormat(a,t.numberFormats[a])),e}const cp={tag:{type:[String,Object]},locale:{type:String},scope:{type:String,validator:e=>e==="parent"||e==="global",default:"parent"},i18n:{type:Object}};function i3({slots:e},t){return t.length===1&&t[0]==="default"?(e.default?e.default():[]).reduce((a,s)=>[...a,...s.type===le?s.children:[s]],[]):t.reduce((n,a)=>{const s=e[a];return s&&(n[a]=s()),n},gt())}function z1(){return le}const r3=Q({name:"i18n-t",props:jt({keypath:{type:String,required:!0},plural:{type:[Number,String],validator:e=>Bt(e)||!isNaN(e)}},cp),setup(e,t){const{slots:n,attrs:a}=t,s=e.i18n||yt({useScope:e.scope,__useComponent:!0});return()=>{const o=Object.keys(n).filter(E=>E!=="_"),i=gt();e.locale&&(i.locale=e.locale),e.plural!==void 0&&(i.plural=Ue(e.plural)?+e.plural:e.plural);const r=i3(t,o),u=s[Qd](e.keypath,r,i),l=jt(gt(),a),d=Ue(e.tag)||_t(e.tag)?e.tag:z1();return Cn(d,l,u)}}}),w_=r3;function u3(e){return Ft(e)&&!Ue(e[0])}function x1(e,t,n,a){const{slots:s,attrs:o}=t;return()=>{const i={part:!0};let r=gt();e.locale&&(i.locale=e.locale),Ue(e.format)?i.key=e.format:_t(e.format)&&(Ue(e.format.key)&&(i.key=e.format.key),r=Object.keys(e.format).reduce((c,m)=>n.includes(m)?jt(gt(),c,{[m]:e.format[m]}):c,gt()));const u=a(e.value,i,r);let l=[i.key];Ft(u)?l=u.map((c,m)=>{const _=s[c.type],h=_?_({[c.type]:c.value,index:m,parts:u}):[c.value];return u3(h)&&(h[0].key=`${c.type}-${m}`),h}):Ue(u)&&(l=[u]);const d=jt(gt(),o),E=Ue(e.tag)||_t(e.tag)?e.tag:z1();return Cn(E,d,l)}}const l3=Q({name:"i18n-n",props:jt({value:{type:Number,required:!0},format:{type:[String,Object]}},cp),setup(e,t){const n=e.i18n||yt({useScope:e.scope,__useComponent:!0});return x1(e,t,$1,(...a)=>n[Jd](...a))}}),M_=l3,c3=Q({name:"i18n-d",props:jt({value:{type:[Number,Date],required:!0},format:{type:[String,Object]}},cp),setup(e,t){const n=e.i18n||yt({useScope:e.scope,__useComponent:!0});return x1(e,t,y1,(...a)=>n[Zd](...a))}}),W_=c3;function d3(e,t){const n=e;if(e.mode==="composition")return n.__getInstance(t)||e.global;{const a=n.__getInstance(t);return a!=null?a.__composer:e.global.__composer}}function E3(e){const t=i=>{const{instance:r,value:u}=i;if(!r||!r.$)throw Pn(Rn.UNEXPECTED_ERROR);const l=d3(e,r.$),d=F_(u);return[Reflect.apply(l.t,l,[...z_(d)]),l]};return{created:(i,r)=>{const[u,l]=t(r);_u&&e.global===l&&(i.__i18nWatcher=Le(l.locale,()=>{r.instance&&r.instance.$forceUpdate()})),i.__composer=l,i.textContent=u},unmounted:i=>{_u&&i.__i18nWatcher&&(i.__i18nWatcher(),i.__i18nWatcher=void 0,delete i.__i18nWatcher),i.__composer&&(i.__composer=void 0,delete i.__composer)},beforeUpdate:(i,{value:r})=>{if(i.__composer){const u=i.__composer,l=F_(r);i.textContent=Reflect.apply(u.t,u,[...z_(l)])}},getSSRProps:i=>{const[r]=t(i);return{textContent:r}}}}function F_(e){if(Ue(e))return{path:e};if(at(e)){if(!("path"in e))throw Pn(Rn.REQUIRED_VALUE,"path");return e}else throw Pn(Rn.INVALID_VALUE)}function z_(e){const{path:t,locale:n,args:a,choice:s,plural:o}=e,i={},r=a||{};return Ue(n)&&(i.locale=n),Bt(s)&&(i.plural=s),Bt(o)&&(i.plural=o),[t,r,i]}function p3(e,t,...n){const a=at(n[0])?n[0]:{};(mt(a.globalInstall)?a.globalInstall:!0)&&([w_.name,"I18nT"].forEach(o=>e.component(o,w_)),[M_.name,"I18nN"].forEach(o=>e.component(o,M_)),[W_.name,"I18nD"].forEach(o=>e.component(o,W_))),e.directive("t",E3(t))}const m3=_s("global-vue-i18n");function T3(e={},t){const n=__VUE_I18N_LEGACY_API__&&mt(e.legacy)?e.legacy:__VUE_I18N_LEGACY_API__,a=mt(e.globalInjection)?e.globalInjection:!0,s=new Map,[o,i]=_3(e,n),r=_s("");function u(c){return s.get(c)||null}function l(c,m){s.set(c,m)}function d(c){s.delete(c)}const E={get mode(){return __VUE_I18N_LEGACY_API__&&n?"legacy":"composition"},async install(c,...m){if(c.__VUE_I18N_SYMBOL__=r,c.provide(c.__VUE_I18N_SYMBOL__,E),at(m[0])){const O=m[0];E.__composerExtend=O.__composerExtend,E.__vueI18nExtend=O.__vueI18nExtend}let _=null;!n&&a&&(_=R3(c,E.global)),__VUE_I18N_FULL_INSTALL__&&p3(c,E,...m),__VUE_I18N_LEGACY_API__&&n&&c.mixin(o3(i,i.__composer,E));const h=c.unmount;c.unmount=()=>{_&&_(),E.dispose(),h()}},get global(){return i},dispose(){o.stop()},__instances:s,__getInstance:u,__setInstance:l,__deleteInstance:d};return E}function yt(e={}){const t=Ao();if(t==null)throw Pn(Rn.MUST_BE_CALL_SETUP_TOP);if(!t.isCE&&t.appContext.app!=null&&!t.appContext.app.__VUE_I18N_SYMBOL__)throw Pn(Rn.NOT_INSTALLED);const n=f3(t),a=S3(n),s=W1(t),o=h3(e,s);if(o==="global")return F1(a,e,s),a;if(o==="parent"){let u=A3(n,t,e.__useComponent);return u==null&&(u=a),u}const i=n;let r=i.__getInstance(t);if(r==null){const u=jt({},e);"__i18n"in s&&(u.__i18n=s.__i18n),a&&(u.__root=a),r=lp(u),i.__composerExtend&&(r[eE]=i.__composerExtend(r)),I3(i,t,r),i.__setInstance(t,r)}return r}function _3(e,t,n){const a=S0(),s=__VUE_I18N_LEGACY_API__&&t?a.run(()=>tE(e)):a.run(()=>lp(e));if(s==null)throw Pn(Rn.UNEXPECTED_ERROR);return[a,s]}function f3(e){const t=Ut(e.isCE?m3:e.appContext.app.__VUE_I18N_SYMBOL__);if(!t)throw Pn(e.isCE?Rn.NOT_INSTALLED_WITH_PROVIDE:Rn.UNEXPECTED_ERROR);return t}function h3(e,t){return Il(e)?"__i18n"in t?"local":"global":e.useScope?e.useScope:"local"}function S3(e){return e.mode==="composition"?e.global:e.global.__composer}function A3(e,t,n=!1){let a=null;const s=t.root;let o=O3(t,n);for(;o!=null;){const i=e;if(e.mode==="composition")a=i.__getInstance(o);else if(__VUE_I18N_LEGACY_API__){const r=i.__getInstance(o);r!=null&&(a=r.__composer,n&&a&&!a[M1]&&(a=null))}if(a!=null||s===o)break;o=o.parent}return a}function O3(e,t=!1){return e==null?null:t&&e.vnode.ctx||e.parent}function I3(e,t,n){Tt(()=>{},t),Et(()=>{const a=n;e.__deleteInstance(t);const s=a[eE];s&&(s(),delete a[eE])},t)}const g3=["locale","fallbackLocale","availableLocales"],x_=["t","rt","d","n","tm","te"];function R3(e,t){const n=Object.create(null);return g3.forEach(s=>{const o=Object.getOwnPropertyDescriptor(t,s);if(!o)throw Pn(Rn.UNEXPECTED_ERROR);const i=qt(o.value)?{get(){return o.value.value},set(r){o.value.value=r}}:{get(){return o.get&&o.get()}};Object.defineProperty(n,s,i)}),e.config.globalProperties.$i18n=n,x_.forEach(s=>{const o=Object.getOwnPropertyDescriptor(t,s);if(!o||!o.value)throw Pn(Rn.UNEXPECTED_ERROR);Object.defineProperty(e.config.globalProperties,`$${s}`,o)}),()=>{delete e.config.globalProperties.$i18n,x_.forEach(s=>{delete e.config.globalProperties[`$${s}`]})}}t3();$y(my);Uy(Dy);ky(v1);if(__INTLIFY_PROD_DEVTOOLS__){const e=Us();e.__INTLIFY__=!0,Ty(e.__INTLIFY_DEVTOOLS_GLOBAL_HOOK__)}const N3="Относно тази инстанция",v3="Връзка с администратор",b3="FitTrackee е self-hosted тракер за активности.",C3="под {0} лиценз ",P3="Сорс код",D3="Данни за времето от:",L3={ABOUT_THIS_INSTANCE:N3,CONTACT_ADMIN:v3,FITTRACKEE_DESCRIPTION:b3,FITTRACKEE_LICENSE:C3,SOURCE_CODE:P3,WEATHER_DATA_FROM:D3},y3={DESCRIPTION:"Всякаква допълнителна информация, която може да бъде полезна за вашите потребители. Може да се използва Markdown синтаксис.",TEXT:"Детайлна информация за инстанцията"},$3="Действие",U3="Активиране на акаунт",k3="Активен",w3="Администриране",M3="Администрация",W3="Добавяне/премахване на администраторски права, изтриване на потребителски акаунт.",F3="Приложение",z3={ADMIN_CONTACT:"Администраторски имейл за контакт",MAX_FILES_IN_ZIP_LABEL:"Макс. файлове в zip архив",MAX_USERS_HELP:"0 - без лимит за регистрация.",MAX_USERS_LABEL:"Макс. брой активни потребители",NO_CONTACT_EMAIL:"без имейл за връзка",SINGLE_UPLOAD_MAX_SIZE_LABEL:"Макс. големина на качените файлове (в Mb)",STATS_WORKOUTS_LIMIT_HELP:"0 - без лимит на броя тренировки.",STATS_WORKOUTS_LIMIT_LABEL:"Макс. тренировки за спортни статистики",TITLE:"Конфигурация на приложението",ZIP_UPLOAD_MAX_SIZE_LABEL:"Макс. размер на zip архива (в Mb)"},x3="Обратно към администрациата",B3="Сигурни ли сте, че искате да изтриете {0} акаунтa? Всички данни ще бъдат изтрити, и няма да могат да бъдат възстановени.",G3="Сигурни ли сте, че искате да зададете нова парола за {0}?",V3="Текущ имейл",H3="Изтрий потребител",K3="Изпращането на имейли е забранено.",q3="Позволи/забрани видеве оборудвания.",j3="Позволи/забрани спортове.",Y3={TABLE:{ACTIVE:"Активно",HAS_EQUIPMENTS:"оборудването съществува",IMAGE:"Изображение",LABEL:"Етикет"},TITLE:"Администриране на оборудването"},X3="Нов имейл",Q3="Не е въведен текст",Z3="Паролата бе нулирана.",J3="Добавете своя собствена политика за поверителност или оставете празно, за да използвате тази по подразбиране. Може да се използва Markdown синтаксис.",e$="Регистрациите са забранени.",t$="Регистрациите са позволени.",n$="Нулиране на парола",a$={TABLE:{ACTIVE:"Активен",HAS_WORKOUTS:"съществуват тренировки",IMAGE:"Изображение",LABEL:"Етикет"},TITLE:"Администриране на спортове"},s$="Актуализиране на конфигурацията на приложението.",o$="Актуализиране на имейл",i$="потребител | потребители",r$={SELECTS:{ORDER_BY:{ADMIN:"административен статус",CREATED_AT:"регистриран на",IS_ACTIVE:"статус на акаунт",USERNAME:"потребителско име",WORKOUTS_COUNT:"брой тренировки"}},TITLE:"Администриране - Потребители"},u$="Имейл адресът бе променен.",l$={ABOUT:y3,ACTION:$3,ACTIVATE_USER_ACCOUNT:U3,ACTIVE:k3,ADMIN:w3,ADMINISTRATION:M3,ADMIN_RIGHTS_DELETE_USER_ACCOUNT:W3,APPLICATION:F3,APP_CONFIG:z3,BACK_TO_ADMIN:x3,CONFIRM_USER_ACCOUNT_DELETION:B3,CONFIRM_USER_PASSWORD_RESET:G3,CURRENT_EMAIL:V3,DELETE_USER:H3,EMAIL_SENDING_DISABLED:K3,ENABLE_DISABLE_EQUIPMENT_TYPES:q3,ENABLE_DISABLE_SPORTS:j3,EQUIPMENT_TYPES:Y3,NEW_EMAIL:X3,NO_TEXT_ENTERED:Q3,PASSWORD_RESET_SUCCESSFUL:Z3,PRIVACY_POLICY_DESCRIPTION:J3,REGISTRATION_DISABLED:e$,REGISTRATION_ENABLED:t$,RESET_USER_PASSWORD:n$,SPORTS:a$,UPDATE_APPLICATION_DESCRIPTION:s$,UPDATE_USER_EMAIL:o$,USER:i$,USERS:r$,USER_EMAIL_UPDATE_SUCCESSFUL:u$},c$={"
    /i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},GGe=/^(?:[ \t]*(?:\n|$))+/,VGe=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,HGe=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,rr=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,KGe=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,aO=/(?:[*+-]|\d{1,9}[.)])/,sO=Rt(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,aO).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),Np=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,qGe=/^[^\n]+/,vp=/(?!\s*\])(?:\\.|[^\[\]\\])+/,jGe=Rt(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",vp).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),YGe=Rt(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,aO).getRegex(),Cl="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",bp=/|$))/,XGe=Rt("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",bp).replace("tag",Cl).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),oO=Rt(Np).replace("hr",rr).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Cl).getRegex(),QGe=Rt(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",oO).getRegex(),Cp={blockquote:QGe,code:VGe,def:jGe,fences:HGe,heading:KGe,hr:rr,html:XGe,lheading:sO,list:YGe,newline:GGe,paragraph:oO,table:Ii,text:qGe},ef=Rt("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",rr).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Cl).getRegex(),ZGe={...Cp,table:ef,paragraph:Rt(Np).replace("hr",rr).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",ef).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Cl).getRegex()},JGe={...Cp,html:Rt(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",bp).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:Ii,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:Rt(Np).replace("hr",rr).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",sO).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},iO=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,eVe=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,rO=/^( {2,}|\\)\n(?!\s*$)/,tVe=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,sVe=Rt(/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,"u").replace(/punct/g,Pl).getRegex(),oVe=Rt("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)","gu").replace(/notPunctSpace/g,uO).replace(/punctSpace/g,Pp).replace(/punct/g,Pl).getRegex(),iVe=Rt("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,uO).replace(/punctSpace/g,Pp).replace(/punct/g,Pl).getRegex(),rVe=Rt(/\\(punct)/,"gu").replace(/punct/g,Pl).getRegex(),uVe=Rt(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),lVe=Rt(bp).replace("(?:-->|$)","-->").getRegex(),cVe=Rt("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",lVe).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Hu=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,dVe=Rt(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",Hu).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),lO=Rt(/^!?\[(label)\]\[(ref)\]/).replace("label",Hu).replace("ref",vp).getRegex(),cO=Rt(/^!?\[(ref)\](?:\[\])?/).replace("ref",vp).getRegex(),EVe=Rt("reflink|nolink(?!\\()","g").replace("reflink",lO).replace("nolink",cO).getRegex(),Dp={_backpedal:Ii,anyPunctuation:rVe,autolink:uVe,blockSkip:aVe,br:rO,code:eVe,del:Ii,emStrongLDelim:sVe,emStrongRDelimAst:oVe,emStrongRDelimUnd:iVe,escape:iO,link:dVe,nolink:cO,punctuation:nVe,reflink:lO,reflinkSearch:EVe,tag:cVe,text:tVe,url:Ii},pVe={...Dp,link:Rt(/^!?\[(label)\]\((.*?)\)/).replace("label",Hu).getRegex(),reflink:Rt(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Hu).getRegex()},mE={...Dp,escape:Rt(iO).replace("])","~|])").getRegex(),url:Rt(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},tf=e=>TVe[e];function Aa(e,t){if(t){if(In.escapeTest.test(e))return e.replace(In.escapeReplace,tf)}else if(In.escapeTestNoEncode.test(e))return e.replace(In.escapeReplaceNoEncode,tf);return e}function nf(e){try{e=encodeURI(e).replace(In.percentDecode,"%")}catch{return null}return e}function af(e,t){var o;const n=e.replace(In.findPipe,(i,r,u)=>{let l=!1,d=r;for(;--d>=0&&u[d]==="\\";)l=!l;return l?"|":" |"}),a=n.split(In.splitPipe);let s=0;if(a[0].trim()||a.shift(),a.length>0&&!((o=a.at(-1))!=null&&o.trim())&&a.pop(),t)if(a.length>t)a.splice(t);else for(;a.length{const i=o.match(n.other.beginningSpace);if(i===null)return o;const[r]=i;return r.length>=s.length?o.slice(s.length):o}).join(` +`)}class Ku{constructor(t){Nt(this,"options");Nt(this,"rules");Nt(this,"lexer");this.options=t||js}space(t){const n=this.rules.block.newline.exec(t);if(n&&n[0].length>0)return{type:"space",raw:n[0]}}code(t){const n=this.rules.block.code.exec(t);if(n){const a=n[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:n[0],codeBlockStyle:"indented",text:this.options.pedantic?a:ti(a,` +`)}}}fences(t){const n=this.rules.block.fences.exec(t);if(n){const a=n[0],s=fVe(a,n[3]||"",this.rules);return{type:"code",raw:a,lang:n[2]?n[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):n[2],text:s}}}heading(t){const n=this.rules.block.heading.exec(t);if(n){let a=n[2].trim();if(this.rules.other.endingHash.test(a)){const s=ti(a,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(a=s.trim())}return{type:"heading",raw:n[0],depth:n[1].length,text:a,tokens:this.lexer.inline(a)}}}hr(t){const n=this.rules.block.hr.exec(t);if(n)return{type:"hr",raw:ti(n[0],` +`)}}blockquote(t){const n=this.rules.block.blockquote.exec(t);if(n){let a=ti(n[0],` +`).split(` +`),s="",o="";const i=[];for(;a.length>0;){let r=!1;const u=[];let l;for(l=0;l1,o={type:"list",raw:"",ordered:s,start:s?+a.slice(0,-1):"",loose:!1,items:[]};a=s?`\\d{1,9}\\${a.slice(-1)}`:`\\${a}`,this.options.pedantic&&(a=s?a:"[*+-]");const i=this.rules.other.listItemRegex(a);let r=!1;for(;t;){let l=!1,d="",E="";if(!(n=i.exec(t))||this.rules.block.hr.test(t))break;d=n[0],t=t.substring(d.length);let c=n[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,R=>" ".repeat(3*R.length)),m=t.split(` +`,1)[0],_=!c.trim(),h=0;if(this.options.pedantic?(h=2,E=c.trimStart()):_?h=n[1].length+1:(h=n[2].search(this.rules.other.nonSpaceChar),h=h>4?1:h,E=c.slice(h),h+=n[1].length),_&&this.rules.other.blankLine.test(m)&&(d+=m+` +`,t=t.substring(m.length+1),l=!0),!l){const R=this.rules.other.nextBulletRegex(h),g=this.rules.other.hrRegex(h),I=this.rules.other.fencesBeginRegex(h),N=this.rules.other.headingBeginRegex(h),b=this.rules.other.htmlBeginRegex(h);for(;t;){const C=t.split(` +`,1)[0];let k;if(m=C,this.options.pedantic?(m=m.replace(this.rules.other.listReplaceNesting," "),k=m):k=m.replace(this.rules.other.tabCharGlobal," "),I.test(m)||N.test(m)||b.test(m)||R.test(m)||g.test(m))break;if(k.search(this.rules.other.nonSpaceChar)>=h||!m.trim())E+=` +`+k.slice(h);else{if(_||c.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||I.test(c)||N.test(c)||g.test(c))break;E+=` +`+m}!_&&!m.trim()&&(_=!0),d+=C+` +`,t=t.substring(C.length+1),c=k.slice(h)}}o.loose||(r?o.loose=!0:this.rules.other.doubleBlankLine.test(d)&&(r=!0));let O=null,S;this.options.gfm&&(O=this.rules.other.listIsTask.exec(E),O&&(S=O[0]!=="[ ] ",E=E.replace(this.rules.other.listReplaceTask,""))),o.items.push({type:"list_item",raw:d,task:!!O,checked:S,loose:!1,text:E,tokens:[]}),o.raw+=d}const u=o.items.at(-1);u&&(u.raw=u.raw.trimEnd(),u.text=u.text.trimEnd()),o.raw=o.raw.trimEnd();for(let l=0;lc.type==="space"),E=d.length>0&&d.some(c=>this.rules.other.anyLine.test(c.raw));o.loose=E}if(o.loose)for(let l=0;l({text:l,tokens:this.lexer.inline(l),header:!1,align:i.align[d]})));return i}}lheading(t){const n=this.rules.block.lheading.exec(t);if(n)return{type:"heading",raw:n[0],depth:n[2].charAt(0)==="="?1:2,text:n[1],tokens:this.lexer.inline(n[1])}}paragraph(t){const n=this.rules.block.paragraph.exec(t);if(n){const a=n[1].charAt(n[1].length-1)===` +`?n[1].slice(0,-1):n[1];return{type:"paragraph",raw:n[0],text:a,tokens:this.lexer.inline(a)}}}text(t){const n=this.rules.block.text.exec(t);if(n)return{type:"text",raw:n[0],text:n[0],tokens:this.lexer.inline(n[0])}}escape(t){const n=this.rules.inline.escape.exec(t);if(n)return{type:"escape",raw:n[0],text:n[1]}}tag(t){const n=this.rules.inline.tag.exec(t);if(n)return!this.lexer.state.inLink&&this.rules.other.startATag.test(n[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(n[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(n[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(n[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:n[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:n[0]}}link(t){const n=this.rules.inline.link.exec(t);if(n){const a=n[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(a)){if(!this.rules.other.endAngleBracket.test(a))return;const i=ti(a.slice(0,-1),"\\");if((a.length-i.length)%2===0)return}else{const i=_Ve(n[2],"()");if(i>-1){const u=(n[0].indexOf("!")===0?5:4)+n[1].length+i;n[2]=n[2].substring(0,i),n[0]=n[0].substring(0,u).trim(),n[3]=""}}let s=n[2],o="";if(this.options.pedantic){const i=this.rules.other.pedanticHrefTitle.exec(s);i&&(s=i[1],o=i[3])}else o=n[3]?n[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(a)?s=s.slice(1):s=s.slice(1,-1)),sf(n,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:o&&o.replace(this.rules.inline.anyPunctuation,"$1")},n[0],this.lexer,this.rules)}}reflink(t,n){let a;if((a=this.rules.inline.reflink.exec(t))||(a=this.rules.inline.nolink.exec(t))){const s=(a[2]||a[1]).replace(this.rules.other.multipleSpaceGlobal," "),o=n[s.toLowerCase()];if(!o){const i=a[0].charAt(0);return{type:"text",raw:i,text:i}}return sf(a,o,a[0],this.lexer,this.rules)}}emStrong(t,n,a=""){let s=this.rules.inline.emStrongLDelim.exec(t);if(!s||s[3]&&a.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!a||this.rules.inline.punctuation.exec(a)){const i=[...s[0]].length-1;let r,u,l=i,d=0;const E=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(E.lastIndex=0,n=n.slice(-1*t.length+i);(s=E.exec(n))!=null;){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(u=[...r].length,s[3]||s[4]){l+=u;continue}else if((s[5]||s[6])&&i%3&&!((i+u)%3)){d+=u;continue}if(l-=u,l>0)continue;u=Math.min(u,u+l+d);const c=[...s[0]][0].length,m=t.slice(0,i+s.index+c+u);if(Math.min(i,u)%2){const h=m.slice(1,-1);return{type:"em",raw:m,text:h,tokens:this.lexer.inlineTokens(h)}}const _=m.slice(2,-2);return{type:"strong",raw:m,text:_,tokens:this.lexer.inlineTokens(_)}}}}codespan(t){const n=this.rules.inline.code.exec(t);if(n){let a=n[2].replace(this.rules.other.newLineCharGlobal," ");const s=this.rules.other.nonSpaceChar.test(a),o=this.rules.other.startingSpaceChar.test(a)&&this.rules.other.endingSpaceChar.test(a);return s&&o&&(a=a.substring(1,a.length-1)),{type:"codespan",raw:n[0],text:a}}}br(t){const n=this.rules.inline.br.exec(t);if(n)return{type:"br",raw:n[0]}}del(t){const n=this.rules.inline.del.exec(t);if(n)return{type:"del",raw:n[0],text:n[2],tokens:this.lexer.inlineTokens(n[2])}}autolink(t){const n=this.rules.inline.autolink.exec(t);if(n){let a,s;return n[2]==="@"?(a=n[1],s="mailto:"+a):(a=n[1],s=a),{type:"link",raw:n[0],text:a,href:s,tokens:[{type:"text",raw:a,text:a}]}}}url(t){var a;let n;if(n=this.rules.inline.url.exec(t)){let s,o;if(n[2]==="@")s=n[0],o="mailto:"+s;else{let i;do i=n[0],n[0]=((a=this.rules.inline._backpedal.exec(n[0]))==null?void 0:a[0])??"";while(i!==n[0]);s=n[0],n[1]==="www."?o="http://"+n[0]:o=n[0]}return{type:"link",raw:n[0],text:s,href:o,tokens:[{type:"text",raw:s,text:s}]}}}inlineText(t){const n=this.rules.inline.text.exec(t);if(n){const a=this.lexer.state.inRawBlock;return{type:"text",raw:n[0],text:n[0],escaped:a}}}}class jn{constructor(t){Nt(this,"tokens");Nt(this,"options");Nt(this,"state");Nt(this,"tokenizer");Nt(this,"inlineQueue");this.tokens=[],this.tokens.links=Object.create(null),this.options=t||js,this.options.tokenizer=this.options.tokenizer||new Ku,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:In,block:vr.normal,inline:ei.normal};this.options.pedantic?(n.block=vr.pedantic,n.inline=ei.pedantic):this.options.gfm&&(n.block=vr.gfm,this.options.breaks?n.inline=ei.breaks:n.inline=ei.gfm),this.tokenizer.rules=n}static get rules(){return{block:vr,inline:ei}}static lex(t,n){return new jn(n).lex(t)}static lexInline(t,n){return new jn(n).inlineTokens(t)}lex(t){t=t.replace(In.carriageReturn,` +`),this.blockTokens(t,this.tokens);for(let n=0;n(r=l.call({lexer:this},t,n))?(t=t.substring(r.raw.length),n.push(r),!0):!1))continue;if(r=this.tokenizer.space(t)){t=t.substring(r.raw.length);const l=n.at(-1);r.raw.length===1&&l!==void 0?l.raw+=` +`:n.push(r);continue}if(r=this.tokenizer.code(t)){t=t.substring(r.raw.length);const l=n.at(-1);(l==null?void 0:l.type)==="paragraph"||(l==null?void 0:l.type)==="text"?(l.raw+=` +`+r.raw,l.text+=` +`+r.text,this.inlineQueue.at(-1).src=l.text):n.push(r);continue}if(r=this.tokenizer.fences(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.heading(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.hr(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.blockquote(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.list(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.html(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.def(t)){t=t.substring(r.raw.length);const l=n.at(-1);(l==null?void 0:l.type)==="paragraph"||(l==null?void 0:l.type)==="text"?(l.raw+=` +`+r.raw,l.text+=` +`+r.raw,this.inlineQueue.at(-1).src=l.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title});continue}if(r=this.tokenizer.table(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.lheading(t)){t=t.substring(r.raw.length),n.push(r);continue}let u=t;if((i=this.options.extensions)!=null&&i.startBlock){let l=1/0;const d=t.slice(1);let E;this.options.extensions.startBlock.forEach(c=>{E=c.call({lexer:this},d),typeof E=="number"&&E>=0&&(l=Math.min(l,E))}),l<1/0&&l>=0&&(u=t.substring(0,l+1))}if(this.state.top&&(r=this.tokenizer.paragraph(u))){const l=n.at(-1);a&&(l==null?void 0:l.type)==="paragraph"?(l.raw+=` +`+r.raw,l.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=l.text):n.push(r),a=u.length!==t.length,t=t.substring(r.raw.length);continue}if(r=this.tokenizer.text(t)){t=t.substring(r.raw.length);const l=n.at(-1);(l==null?void 0:l.type)==="text"?(l.raw+=` +`+r.raw,l.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=l.text):n.push(r);continue}if(t){const l="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(l);break}else throw new Error(l)}}return this.state.top=!0,n}inline(t,n=[]){return this.inlineQueue.push({src:t,tokens:n}),n}inlineTokens(t,n=[]){var r,u,l;let a=t,s=null;if(this.tokens.links){const d=Object.keys(this.tokens.links);if(d.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(a))!=null;)d.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.blockSkip.exec(a))!=null;)a=a.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(a))!=null;)a=a.slice(0,s.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let o=!1,i="";for(;t;){o||(i=""),o=!1;let d;if((u=(r=this.options.extensions)==null?void 0:r.inline)!=null&&u.some(c=>(d=c.call({lexer:this},t,n))?(t=t.substring(d.raw.length),n.push(d),!0):!1))continue;if(d=this.tokenizer.escape(t)){t=t.substring(d.raw.length),n.push(d);continue}if(d=this.tokenizer.tag(t)){t=t.substring(d.raw.length),n.push(d);continue}if(d=this.tokenizer.link(t)){t=t.substring(d.raw.length),n.push(d);continue}if(d=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(d.raw.length);const c=n.at(-1);d.type==="text"&&(c==null?void 0:c.type)==="text"?(c.raw+=d.raw,c.text+=d.text):n.push(d);continue}if(d=this.tokenizer.emStrong(t,a,i)){t=t.substring(d.raw.length),n.push(d);continue}if(d=this.tokenizer.codespan(t)){t=t.substring(d.raw.length),n.push(d);continue}if(d=this.tokenizer.br(t)){t=t.substring(d.raw.length),n.push(d);continue}if(d=this.tokenizer.del(t)){t=t.substring(d.raw.length),n.push(d);continue}if(d=this.tokenizer.autolink(t)){t=t.substring(d.raw.length),n.push(d);continue}if(!this.state.inLink&&(d=this.tokenizer.url(t))){t=t.substring(d.raw.length),n.push(d);continue}let E=t;if((l=this.options.extensions)!=null&&l.startInline){let c=1/0;const m=t.slice(1);let _;this.options.extensions.startInline.forEach(h=>{_=h.call({lexer:this},m),typeof _=="number"&&_>=0&&(c=Math.min(c,_))}),c<1/0&&c>=0&&(E=t.substring(0,c+1))}if(d=this.tokenizer.inlineText(E)){t=t.substring(d.raw.length),d.raw.slice(-1)!=="_"&&(i=d.raw.slice(-1)),o=!0;const c=n.at(-1);(c==null?void 0:c.type)==="text"?(c.raw+=d.raw,c.text+=d.text):n.push(d);continue}if(t){const c="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return n}}class qu{constructor(t){Nt(this,"options");Nt(this,"parser");this.options=t||js}space(t){return""}code({text:t,lang:n,escaped:a}){var i;const s=(i=(n||"").match(In.notSpaceStart))==null?void 0:i[0],o=t.replace(In.endingNewline,"")+` +`;return s?'
    '+(a?o:Aa(o,!0))+`
    +`:"
    "+(a?o:Aa(o,!0))+`
    +`}blockquote({tokens:t}){return`
    +${this.parser.parse(t)}
    +`}html({text:t}){return t}heading({tokens:t,depth:n}){return`${this.parser.parseInline(t)} +`}hr(t){return`
    +`}list(t){const n=t.ordered,a=t.start;let s="";for(let r=0;r +`+s+" +`}listitem(t){var a;let n="";if(t.task){const s=this.checkbox({checked:!!t.checked});t.loose?((a=t.tokens[0])==null?void 0:a.type)==="paragraph"?(t.tokens[0].text=s+" "+t.tokens[0].text,t.tokens[0].tokens&&t.tokens[0].tokens.length>0&&t.tokens[0].tokens[0].type==="text"&&(t.tokens[0].tokens[0].text=s+" "+Aa(t.tokens[0].tokens[0].text),t.tokens[0].tokens[0].escaped=!0)):t.tokens.unshift({type:"text",raw:s+" ",text:s+" ",escaped:!0}):n+=s+" "}return n+=this.parser.parse(t.tokens,!!t.loose),`
  • ${n}
  • +`}checkbox({checked:t}){return"'}paragraph({tokens:t}){return`

    ${this.parser.parseInline(t)}

    +`}table(t){let n="",a="";for(let o=0;o${s}
    `),`

    --set-admin BOOLEAN

    Add/remove admin rights (when adding admin rights, it also activates user account if not active).

    [DEPRECATED] Add/remove admin rights (when adding admin rights, it also activates user account if not active).

    --set-role ROLE

    Set user role (when setting ‘moderator’, ‘admin’ and ‘owner’ role, it also activates user account if not active).

    --activate

    --activate

    Activate user account.

    --reset-password

    --reset-password

    Reset user password (a new password will be displayed).

    --update-email EMAIL

    --update-email EMAIL

    Update user email.

    + GET /api/auth/account/sanctions/(string:action_short_id) +
    + GET /api/auth/account/suspension +
    + GET /api/auth/blocked-users +
    GET /api/auth/profile
    + GET /api/comments/(string:comment_short_id) +
    @@ -333,6 +362,30 @@

    HTTP Routing Table

    GET /api/equipments/(string:equipment_short_id)
    + GET /api/follow-requests +
    + GET /api/notifications +
    + GET /api/notifications/types +
    + GET /api/notifications/unread +
    @@ -363,6 +416,24 @@

    HTTP Routing Table

    GET /api/records
    + GET /api/reports +
    + GET /api/reports/(int:report_id) +
    + GET /api/reports/unresolved +
    @@ -393,6 +464,12 @@

    HTTP Routing Table

    GET /api/stats/all
    + GET /api/timeline +
    @@ -405,12 +482,30 @@

    HTTP Routing Table

    GET /api/users/(user_name)
    + GET /api/users/(user_name)/followers +
    + GET /api/users/(user_name)/following +
    GET /api/users/(user_name)/picture
    + GET /api/users/(user_name)/sanctions +
    @@ -435,6 +530,12 @@

    HTTP Routing Table

    GET /api/workouts/(string:workout_short_id)/chart_data/segment/(int:segment_id)
    + GET /api/workouts/(string:workout_short_id)/comments +
    @@ -489,6 +590,18 @@

    HTTP Routing Table

    POST /api/auth/account/resend-confirmation
    + POST /api/auth/account/sanctions/(string:action_short_id)/appeal +
    + POST /api/auth/account/suspension/appeal +
    @@ -549,6 +662,24 @@

    HTTP Routing Table

    POST /api/auth/register
    + POST /api/comments/(string:comment_short_id)/like +
    + POST /api/comments/(string:comment_short_id)/like/undo +
    + POST /api/comments/(string:comment_short_id)/suspension/appeal +
    @@ -561,6 +692,24 @@

    HTTP Routing Table

    POST /api/equipments/(string:equipment_short_id)/refresh
    + POST /api/follow-requests/(user_name)/accept +
    + POST /api/follow-requests/(user_name)/reject +
    + POST /api/notifications/mark-all-as-read +
    @@ -591,12 +740,72 @@

    HTTP Routing Table

    POST /api/oauth/token
    + POST /api/reports +
    + POST /api/reports/(int:report_id)/actions +
    + POST /api/users/(user_name)/block +
    + POST /api/users/(user_name)/follow +
    + POST /api/users/(user_name)/unblock +
    + POST /api/users/(user_name)/unfollow +
    POST /api/workouts
    + POST /api/workouts/(string:workout_short_id)/comments +
    + POST /api/workouts/(string:workout_short_id)/like +
    + POST /api/workouts/(string:workout_short_id)/like/undo +
    + POST /api/workouts/(string:workout_short_id)/suspension/appeal +
    @@ -615,6 +824,12 @@

    HTTP Routing Table

    DELETE /api/auth/profile/reset/sports/(sport_id)
    + DELETE /api/comments/(string:comment_short_id) +
    @@ -639,12 +854,24 @@

    HTTP Routing Table

    DELETE /api/workouts/(string:workout_short_id)
    + PATCH /api/appeals/(string:appeal_id) +
    PATCH /api/auth/profile/edit/account
    + PATCH /api/comments/(string:comment_short_id) +
    @@ -663,6 +890,18 @@

    HTTP Routing Table

    PATCH /api/equipments/(string:equipment_short_id)
    + PATCH /api/notifications/(int:notification_id) +
    + PATCH /api/reports/(int:report_id) +
    diff --git a/docs/en/index.html b/docs/en/index.html index 89117647e..5db20778c 100644 --- a/docs/en/index.html +++ b/docs/en/index.html @@ -215,12 +215,17 @@
  • API documentation @@ -303,7 +308,7 @@

    FitTrackee(see issues for more information)
    -FitTrackee Dashboard +FitTrackee Dashboard
    diff --git a/docs/en/installation.html b/docs/en/installation.html index f59a5b361..b277e016f 100644 --- a/docs/en/installation.html +++ b/docs/en/installation.html @@ -215,12 +215,17 @@
  • API documentation @@ -686,12 +691,25 @@

    Emails

    Added in version 0.3.0.

    +
    +

    Changed in version 0.5.3: Credentials and port can be omitted

    +
    +
    +

    Changed in version 0.6.5: Disable email sending

    +
    +
    +

    Changed in version 0.7.24: Handle special characters in password

    +

    To send emails, a valid EMAIL_URL must be provided:

    • with an unencrypted SMTP server: smtp://username:password@smtp.example.com:25

    • with SSL: smtp://username:password@smtp.example.com:465/?ssl=True

    • with STARTTLS: smtp://username:password@smtp.example.com:587/?tls=True

    +

    Credentials can be omitted: smtp://smtp.example.com:25. +If :<port> is omitted, the port defaults to 25.

    +

    Password can be encoded if it contains special characters. +For instance with password passwordwith@and&and?, the encoded password will be: passwordwith%40and%26and%3F.

    -
    -

    Changed in version 0.5.3.

    -
    -
    -
    Credentials can be omitted: smtp://smtp.example.com:25.
    -
    If :<port> is omitted, the port defaults to 25.
    -

  • password change

  • notification when a data export archive is ready to download (new in 0.7.13)

  • +
  • suspension and warning (new in 0.9.0)

  • +
  • suspension and warning lifting (new in 0.9.0)

  • +
  • rejected appeal (new in 0.9.0)

  • -
    -

    Changed in version 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).

    +

    On 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 is available to activate account, modify email and password and handle data export requests.

    -
    -

    Changed in version 0.7.24.

    -
    -

    Password can be encoded if it contains special characters. -For instance with password passwordwith@and&and?, the encoded password will be: passwordwith%40and%26and%3F.

    Map tile server

    Added in version 0.4.0.

    +
    +

    Changed in version 0.6.10: Handle tile server subdomains

    +
    +
    +

    Changed in version 0.7.23: Default tile server (OpenStreetMap) no longer requires subdomains

    +

    Default tile server is now OpenStreetMap’s standard tile layer (if environment variables are not initialized). The tile server can be changed by updating TILE_SERVER_URL and MAP_ATTRIBUTION variables (list of tile servers).

    To keep using ThunderForest Outdoors, the configuration is:

    @@ -749,9 +761,6 @@

    Map tile serverCheck the terms of service of tile provider for map attribution. -
    -

    Changed in version 0.6.10.

    -

    Since the tile server can be used for static map generation, some servers require a subdomain.

    For instance, to set OSM France tile server, the expected values are:

    The subdomain will be chosen randomly.

    -
    -

    Added in version 0.7.23.

    -
    -

    The default URL is updated: OpenStreetMap’s tile server no longer requires subdomains.

    +

    The default tile server (OpenStreetMap) no longer requires subdomains.

    API rate limits

    @@ -807,20 +813,23 @@

    API rate limits

    Weather data

    -

    Changed in version 0.7.11.

    +

    Changed in version 0.7.11: Add Visual Crossing to weather providers

    +
    +
    +

    Changed in version 0.7.15: Remove Darksky from weather providers

    The following weather data providers are supported by FitTrackee:

    +
    +

    Note

    +

    DarkSky support is discontinued, since the service shut down on March 31, 2023.

    +

    To configure a weather provider, set the following environment variables:

    • WEATHER_API_KEY: the key to the corresponding weather provider

    -
    -

    Changed in version 0.7.15.

    -
    -

    DarkSky support is discontinued, since the service shut down on March 31, 2023.

    Installation

    @@ -894,9 +903,9 @@

    From PyPI
  • Open http://localhost:5000 and register

  • -
  • To set admin rights to the newly created account, use the following command line:

  • +
  • To set owner role to the newly created account, use the following command line:

  • -
    $ ftcli users update <username> --set-admin true
    +
    $ ftcli users update <username> --set-role owner
     
    @@ -955,9 +964,9 @@

    Dev environment
  • Open http://localhost:3000 and register

  • -
  • To set admin rights to the newly created account, use the following command line:

  • +
  • To set owner role to the newly created account, use the following command line:

  • -
    $ make user-set-admin USERNAME=<username>
    +
    $ make user-set-role USERNAME=<username> ROLE=owner
     
    @@ -1009,9 +1018,9 @@

    Production environment
    • Open http://localhost:5000 and register

    • -
    • To set admin rights to the newly created account, use the following command line:

    • +
    • To set owner role to the newly created account, use the following command line:

    -
    $ make user-set-admin USERNAME=<username>
    +
    $ make user-set-role USERNAME=<username> ROLE=owner
     
    @@ -1196,12 +1205,12 @@

    DeploymentWantedBy=multi-user.target

    -
    -

    Note

    +
    -
    -

    Note

    +
    +

    See also

    More information on deployment with Gunicorn in its documentation.

    @@ -1307,9 +1316,9 @@

    Installation

    Open http://localhost:8025 to access MailHog interface (email testing tool)

      -
    • To set admin rights to the newly created account, use the following command line:

    • +
    • To set owner role to the newly created account, use the following command line:

    -
    $ make docker-set-admin USERNAME=<username>
    +
    $ make docker-set-role USERNAME=<username> ROLE=owner
     
    diff --git a/docs/en/oauth.html b/docs/en/oauth.html index 0d5b2f2e8..1bdd65c3b 100644 --- a/docs/en/oauth.html +++ b/docs/en/oauth.html @@ -215,12 +215,17 @@
  • API documentation @@ -283,7 +288,9 @@
  • equipments:read: grants read access to equipments endpoints (new in 0.8.0),

  • equipments:write: grants write access to equipments endpoints (new in 0.8.0),

  • +
  • follow:read: grants read access to follow requests and followers endpoints (new in 0.9.0),

  • +
  • follow:write: grants write access to requests and followers endpoints (new in 0.9.0),

  • +
  • notifications:read: grants read access to notifications endpoints (new in 0.9.0),

  • +
  • notifications:write: grants write access to notifications endpoints (new in 0.9.0),

  • profile:read: grants read access to auth endpoints,

  • profile:write: grants write access to auth endpoints,

  • +
  • reports:read: grants read access to reports endpoints (new in 0.9.0),

  • +
  • reports:write: grants write access to reports endpoints (new in 0.9.0),

  • users:read: grants read access to users endpoints,

  • users:write: grants write access to users endpoints,

  • -
  • workouts:read: grants read access to workouts-related endpoints,

  • -
  • workouts:write: grants write access to workouts-related endpoints.

  • +
  • workouts:read: grants read access to workouts and comments endpoints (changed in 0.9.0),

  • +
  • workouts:write: grants write access to workouts and comments endpoints (changed in 0.9.0).

  • @@ -320,7 +333,7 @@

    Flow
  • The user creates an App (client) on FitTrackee for a third-party application.

    -OAuth2 client creation on FitTrackee +OAuth2 client creation on FitTrackee
    After registration, the client id and secret are shown.
    @@ -331,7 +344,7 @@

    FlowThe 3rd-party app needs to redirect to FitTrackee, in order for the user to authorize the 3rd-party app to access user data on FitTrackee.

  • -App authorization on FitTrackee +App authorization on FitTrackee
    The authorization URL is https://<FITTRACKEE_HOST>/profile/apps/authorize.
    @@ -361,7 +374,7 @@

    Flow

  • -
    After the authorization, FitTrackee redirects to the 3rd-party app, so the 3rd-party app can get the authorization code from the redirect URL and then fetches an access token with the client id and secret (endpoint /api/oauth/token).
    +
    After the authorization, FitTrackee redirects to the 3rd-party app, so the 3rd-party app can get the authorization code from the redirect URL and then fetches an access token with the client id and secret (endpoint /api/oauth/token).
    Example of a redirect URL:
    https://example.com/callback?code=<AUTHORIZATION_CODE>&state=<STATE>
    diff --git a/docs/en/objects.inv b/docs/en/objects.inv index 7a9de6b09..ca9e41605 100644 Binary files a/docs/en/objects.inv and b/docs/en/objects.inv differ diff --git a/docs/en/search.html b/docs/en/search.html index 12244898d..b43376968 100644 --- a/docs/en/search.html +++ b/docs/en/search.html @@ -216,12 +216,17 @@
  • API documentation diff --git a/docs/en/searchindex.js b/docs/en/searchindex.js index 12a28abff..09ae66eeb 100644 --- a/docs/en/searchindex.js +++ b/docs/en/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles": {"API documentation": [[4, null]], "API rate limits": [[15, "api-rate-limits"]], "Account & preferences": [[13, "account-preferences"]], "Administration": [[13, "administration"], [13, "id2"]], "Administrator": [[18, null]], "Application": [[13, "application"]], "Authentication and account": [[0, null]], "Bugs Fixed": [[11, "bugs-fixed"], [11, "id5"], [11, "id8"], [11, "id11"], [11, "id16"], [11, "id22"], [11, "id27"], [11, "id31"], [11, "id34"], [11, "id36"], [11, "id40"], [11, "id44"], [11, "id48"], [11, "id51"], [11, "id54"], [11, "id57"], [11, "id58"], [11, "id61"], [11, "id63"], [11, "id65"], [11, "id68"], [11, "id71"], [11, "id79"], [11, "id82"], [11, "id85"], [11, "id88"], [11, "id100"], [11, "id105"], [11, "id107"], [11, "id111"], [11, "id114"], [11, "id117"], [11, "id119"], [11, "id122"], [11, "id125"], [11, "id127"], [11, "id130"], [11, "id133"], [11, "id136"], [11, "id141"], [11, "id143"], [11, "id145"], [11, "id147"], [11, "id150"], [11, "id152"], [11, "id158"], [11, "id161"], [11, "id163"], [11, "id165"], [11, "id172"], [11, "id177"], [11, "id179"], [11, "id181"], [11, "id184"], [11, "id186"], [11, "id188"], [11, "id192"], [11, "id202"], [11, "id205"], [11, "id207"], [11, "id210"], [11, "id217"]], "Change log": [[11, null]], "Command line interface": [[12, null]], "Configuration": [[1, null]], "Dashboard": [[13, "dashboard"]], "Database": [[12, "database"]], "Deployment": [[15, "deployment"]], "Dev environment": [[15, "dev-environment"], [15, "id5"]], "Development": [[15, "development"]], "Docker": [[15, "docker"]], "Documentation": [[11, "documentation"], [11, "id75"], [11, "id109"]], "Emails": [[15, "emails"]], "Endpoints:": [[4, null]], "Environment variables": [[15, "environment-variables"]], "Equipment Types": [[2, null], [13, "equipment-types"]], "Equipments": [[3, null], [13, "equipments"], [13, "id1"]], "Failed to upload or download files": [[18, "failed-to-upload-or-download-files"]], "Features": [[11, "features"], [11, "id129"], [11, "id139"], [11, "id149"], [13, null]], "Features and enhancements": [[11, "features-and-enhancements"], [11, "id4"], [11, "id10"], [11, "id15"], [11, "id19"], [11, "id26"], [11, "id30"], [11, "id39"], [11, "id43"], [11, "id47"], [11, "id50"], [11, "id67"], [11, "id70"], [11, "id78"], [11, "id81"], [11, "id87"], [11, "id92"], [11, "id94"], [11, "id96"], [11, "id99"], [11, "id110"], [11, "id116"]], "FitTrackee": [[14, null]], "FitTrackee fails to start": [[18, "fittrackee-fails-to-start"]], "Flow": [[16, "flow"]], "From PyPI": [[15, "from-pypi"], [15, "id3"]], "From sources": [[15, "from-sources"], [15, "id4"]], "Import tools": [[17, "import-tools"]], "Installation": [[15, null], [15, "id2"], [15, "id6"]], "Installation scripts": [[17, "installation-scripts"]], "Issues Closed": [[11, "issues-closed"], [11, "id121"], [11, "id124"], [11, "id128"], [11, "id132"], [11, "id135"], [11, "id138"], [11, "id140"], [11, "id144"], [11, "id146"], [11, "id148"], [11, "id153"], [11, "id157"], [11, "id160"], [11, "id162"], [11, "id164"], [11, "id166"], [11, "id168"], [11, "id170"], [11, "id175"], [11, "id178"], [11, "id180"], [11, "id183"], [11, "id185"], [11, "id187"], [11, "id190"], [11, "id194"], [11, "id196"], [11, "id198"], [11, "id201"], [11, "id203"], [11, "id206"], [11, "id208"], [11, "id212"], [11, "id215"], [11, "id218"]], "Main dependencies": [[15, "main-dependencies"]], "Map images are not displayed but map is shown in Workout detail": [[18, "map-images-are-not-displayed-but-map-is-shown-in-workout-detail"]], "Map tile server": [[15, "map-tile-server"]], "Misc": [[11, "misc"], [11, "id1"], [11, "id3"], [11, "id7"], [11, "id14"], [11, "id18"], [11, "id21"], [11, "id25"], [11, "id29"], [11, "id33"], [11, "id38"], [11, "id42"], [11, "id46"], [11, "id53"], [11, "id56"], [11, "id60"], [11, "id62"], [11, "id66"], [11, "id73"], [11, "id76"], [11, "id84"], [11, "id91"], [11, "id102"], [11, "id104"], [11, "id120"], [11, "id134"], [11, "id137"], [11, "id154"], [11, "id156"], [11, "id173"], [11, "id182"], [11, "id189"], [11, "id193"], [11, "id200"], [11, "id211"], [11, "id214"]], "New Features": [[11, "new-features"], [11, "id167"], [11, "id169"], [11, "id171"], [11, "id176"], [11, "id191"], [11, "id195"], [11, "id197"], [11, "id199"], [11, "id204"], [11, "id209"], [11, "id213"], [11, "id216"], [11, "id219"]], "NixOS": [[15, "nixos"]], "OAuth 2.0": [[16, null]], "OAuth Apps": [[13, "oauth-apps"]], "OAuth2": [[5, null], [12, "oauth2"]], "Prerequisites": [[15, "prerequisites"]], "Prod environment": [[15, "prod-environment"]], "Production environment": [[15, "production-environment"]], "Pull Requests": [[11, "pull-requests"], [11, "id123"], [11, "id126"], [11, "id142"], [11, "id151"], [11, "id155"], [11, "id159"], [11, "id174"]], "Records": [[6, null]], "Resources": [[16, "resources"]], "Scopes": [[16, "scopes"]], "Screenshots": [[13, "screenshots"]], "Security": [[11, "security"]], "Sports": [[7, null], [13, "sports"]], "Statistics": [[8, null], [13, "statistics"]], "Table of contents": [[14, "table-of-contents"]], "Third-party tools": [[17, null]], "Translations": [[11, "translations"], [11, "id2"], [11, "id6"], [11, "id9"], [11, "id12"], [11, "id13"], [11, "id17"], [11, "id20"], [11, "id23"], [11, "id24"], [11, "id28"], [11, "id32"], [11, "id35"], [11, "id37"], [11, "id41"], [11, "id45"], [11, "id49"], [11, "id52"], [11, "id55"], [11, "id59"], [11, "id64"], [11, "id69"], [11, "id72"], [11, "id74"], [11, "id77"], [11, "id80"], [11, "id83"], [11, "id86"], [11, "id89"], [11, "id90"], [11, "id93"], [11, "id95"], [11, "id97"], [11, "id98"], [11, "id101"], [11, "id103"], [11, "id106"], [11, "id108"], [11, "id112"], [11, "id113"], [11, "id115"], [11, "id118"], [11, "id131"], [13, "translations"]], "Troubleshooting": [[19, null]], "Upgrade": [[15, "upgrade"]], "Users": [[9, null], [12, "users"], [13, "users"]], "Version 0.1.0 - First release \ud83c\udf89 (2018-07-04)": [[11, "version-0-1-0-first-release-2018-07-04"]], "Version 0.1.1 - Fix and improvements (2019/02/07)": [[11, "version-0-1-1-fix-and-improvements-2019-02-07"]], "Version 0.2.0 - Statistics (2019/07/07)": [[11, "version-0-2-0-statistics-2019-07-07"]], "Version 0.2.1 - Fix and improvements (2019/09/01)": [[11, "version-0-2-1-fix-and-improvements-2019-09-01"]], "Version 0.2.2 - Statistics fix (2019/09/23)": [[11, "version-0-2-2-statistics-fix-2019-09-23"]], "Version 0.2.3 - FitTrackee available in French (2019/12/29)": [[11, "version-0-2-3-fittrackee-available-in-french-2019-12-29"]], "Version 0.2.4 - Minor fix (2020/01/30)": [[11, "version-0-2-4-minor-fix-2020-01-30"]], "Version 0.2.5 - Fix and improvements (2020/01/31)": [[11, "version-0-2-5-fix-and-improvements-2020-01-31"]], "Version 0.3.0 - Administration (2020/07/15)": [[11, "version-0-3-0-administration-2020-07-15"]], "Version 0.4.0 - FitTrackee on PyPI (2020/09/19)": [[11, "version-0-4-0-fittrackee-on-pypi-2020-09-19"]], "Version 0.4.1 (2020/12/31)": [[11, "version-0-4-1-2020-12-31"]], "Version 0.4.2 (2021/01/03)": [[11, "version-0-4-2-2021-01-03"]], "Version 0.4.3 (2021/01/10)": [[11, "version-0-4-3-2021-01-10"]], "Version 0.4.4 (2021/01/31)": [[11, "version-0-4-4-2021-01-31"]], "Version 0.4.5 (2021/02/17)": [[11, "version-0-4-5-2021-02-17"]], "Version 0.4.6 (2021/02/21)": [[11, "version-0-4-6-2021-02-21"]], "Version 0.4.7 (2021/04/07)": [[11, "version-0-4-7-2021-04-07"]], "Version 0.4.8 (2021/07/03)": [[11, "version-0-4-8-2021-07-03"]], "Version 0.4.9 (2021/07/16)": [[11, "version-0-4-9-2021-07-16"]], "Version 0.5.0 (2021/11/14)": [[11, "version-0-5-0-2021-11-14"]], "Version 0.5.1 (2021/11/30)": [[11, "version-0-5-1-2021-11-30"]], "Version 0.5.2 (2021/12/19)": [[11, "version-0-5-2-2021-12-19"]], "Version 0.5.3 (2022/01/01)": [[11, "version-0-5-3-2022-01-01"]], "Version 0.5.4 (2022/01/01)": [[11, "version-0-5-4-2022-01-01"]], "Version 0.5.5 (2022/01/19)": [[11, "version-0-5-5-2022-01-19"]], "Version 0.5.6 (2022/02/05)": [[11, "version-0-5-6-2022-02-05"]], "Version 0.5.7 (2022/02/13)": [[11, "version-0-5-7-2022-02-13"]], "Version 0.6.0 (2022/03/27)": [[11, "version-0-6-0-2022-03-27"]], "Version 0.6.1 (2022/03/27)": [[11, "version-0-6-1-2022-03-27"]], "Version 0.6.10 (2022/07/13)": [[11, "version-0-6-10-2022-07-13"]], "Version 0.6.11 (2022/07/27)": [[11, "version-0-6-11-2022-07-27"]], "Version 0.6.12 (2022/09/14)": [[11, "version-0-6-12-2022-09-14"]], "Version 0.6.2 (2022/04/03)": [[11, "version-0-6-2-2022-04-03"]], "Version 0.6.3 (2022/04/09)": [[11, "version-0-6-3-2022-04-09"]], "Version 0.6.4 (2022/04/23)": [[11, "version-0-6-4-2022-04-23"]], "Version 0.6.5 (2022/04/24)": [[11, "version-0-6-5-2022-04-24"]], "Version 0.6.6 (2022/05/29)": [[11, "version-0-6-6-2022-05-29"]], "Version 0.6.7 (2022/06/11)": [[11, "version-0-6-7-2022-06-11"]], "Version 0.6.8 (2022/06/22)": [[11, "version-0-6-8-2022-06-22"]], "Version 0.6.9 (2022/07/03)": [[11, "version-0-6-9-2022-07-03"]], "Version 0.7.0 (2022/09/19)": [[11, "version-0-7-0-2022-09-19"]], "Version 0.7.1 (2022/09/21)": [[11, "version-0-7-1-2022-09-21"]], "Version 0.7.10 (2022/12/21)": [[11, "version-0-7-10-2022-12-21"]], "Version 0.7.11 (2022/12/31)": [[11, "version-0-7-11-2022-12-31"]], "Version 0.7.12 (2023/02/16)": [[11, "version-0-7-12-2023-02-16"]], "Version 0.7.13 (2023/03/05)": [[11, "version-0-7-13-2023-03-05"]], "Version 0.7.14 (2023/03/08)": [[11, "version-0-7-14-2023-03-08"]], "Version 0.7.15 (2023/04/12)": [[11, "version-0-7-15-2023-04-12"]], "Version 0.7.16 (2023/05/29)": [[11, "version-0-7-16-2023-05-29"]], "Version 0.7.17 (2023/06/03)": [[11, "version-0-7-17-2023-06-03"]], "Version 0.7.18 (2023/06/25)": [[11, "version-0-7-18-2023-06-25"]], "Version 0.7.19 (2023/07/15)": [[11, "version-0-7-19-2023-07-15"]], "Version 0.7.2 (2022/09/21)": [[11, "version-0-7-2-2022-09-21"]], "Version 0.7.20 (2023/07/22)": [[11, "version-0-7-20-2023-07-22"]], "Version 0.7.21 (2023/07/30)": [[11, "version-0-7-21-2023-07-30"]], "Version 0.7.22 (2023/08/23)": [[11, "version-0-7-22-2023-08-23"]], "Version 0.7.23 (2023/09/14)": [[11, "version-0-7-23-2023-09-14"]], "Version 0.7.24 (2023/10/04)": [[11, "version-0-7-24-2023-10-04"]], "Version 0.7.25 (2023/10/08)": [[11, "version-0-7-25-2023-10-08"]], "Version 0.7.26 (2023/11/19)": [[11, "version-0-7-26-2023-11-19"]], "Version 0.7.27 (2023/12/20)": [[11, "version-0-7-27-2023-12-20"]], "Version 0.7.28 (2023/12/23)": [[11, "version-0-7-28-2023-12-23"]], "Version 0.7.29 (2024/01/06)": [[11, "version-0-7-29-2024-01-06"]], "Version 0.7.3 (2022/11/01)": [[11, "version-0-7-3-2022-11-01"]], "Version 0.7.30 (2024/02/04)": [[11, "version-0-7-30-2024-02-04"]], "Version 0.7.31 (2024/02/10)": [[11, "version-0-7-31-2024-02-10"]], "Version 0.7.32 (2024/03/10)": [[11, "version-0-7-32-2024-03-10"]], "Version 0.7.4 (2022/11/05)": [[11, "version-0-7-4-2022-11-05"]], "Version 0.7.5 (2022/11/09)": [[11, "version-0-7-5-2022-11-09"]], "Version 0.7.6 (2022/11/09)": [[11, "version-0-7-6-2022-11-09"]], "Version 0.7.7 (2022/11/27)": [[11, "version-0-7-7-2022-11-27"]], "Version 0.7.8 (2022/11/30)": [[11, "version-0-7-8-2022-11-30"]], "Version 0.7.9 (2022/12/11)": [[11, "version-0-7-9-2022-12-11"]], "Version 0.8.0 (2024/04/21)": [[11, "version-0-8-0-2024-04-21"]], "Version 0.8.1 (2024/05/01)": [[11, "version-0-8-1-2024-05-01"]], "Version 0.8.10 (2024/10/09)": [[11, "version-0-8-10-2024-10-09"]], "Version 0.8.11 (2024/10/30)": [[11, "version-0-8-11-2024-10-30"]], "Version 0.8.12 (2024/11/17)": [[11, "version-0-8-12-2024-11-17"]], "Version 0.8.2 (2024/05/08)": [[11, "version-0-8-2-2024-05-08"]], "Version 0.8.3 (2024/05/09)": [[11, "version-0-8-3-2024-05-09"]], "Version 0.8.4 (2024/05/22)": [[11, "version-0-8-4-2024-05-22"]], "Version 0.8.5 (2024/06/29)": [[11, "version-0-8-5-2024-06-29"]], "Version 0.8.6 (2024/08/03)": [[11, "version-0-8-6-2024-08-03"]], "Version 0.8.7 (2024/08/25)": [[11, "version-0-8-7-2024-08-25"]], "Version 0.8.8 (2024/09/01)": [[11, "version-0-8-8-2024-09-01"]], "Version 0.8.9 (2024/09/21)": [[11, "version-0-8-9-2024-09-21"]], "Weather data": [[15, "weather-data"]], "Workout detail": [[13, "workout-detail"]], "Workouts": [[10, null], [13, "workouts"]], "Workouts list": [[13, "workouts-list"]], "Yunohost": [[15, "yunohost"]], "ftcli db drop": [[12, "ftcli-db-drop"]], "ftcli db upgrade": [[12, "ftcli-db-upgrade"]], "ftcli oauth2 clean": [[12, "ftcli-oauth2-clean"]], "ftcli users clean_archives": [[12, "ftcli-users-clean-archives"]], "ftcli users clean_tokens": [[12, "ftcli-users-clean-tokens"]], "ftcli users create": [[12, "ftcli-users-create"]], "ftcli users export_archives": [[12, "ftcli-users-export-archives"]], "ftcli users update": [[12, "ftcli-users-update"]]}, "docnames": ["api/auth", "api/configuration", "api/equipment_types", "api/equipments", "api/index", "api/oauth2", "api/records", "api/sports", "api/stats", "api/users", "api/workouts", "changelog", "cli", "features", "index", "installation", "oauth", "third_party_tools", "troubleshooting/administrator", "troubleshooting/index"], "envversion": {"sphinx": 62, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["api/auth.rst", "api/configuration.rst", "api/equipment_types.rst", "api/equipments.rst", "api/index.rst", "api/oauth2.rst", "api/records.rst", "api/sports.rst", "api/stats.rst", "api/users.rst", "api/workouts.rst", "changelog.md", "cli.rst", "features.rst", "index.rst", "installation.rst", "oauth.rst", "third_party_tools.rst", "troubleshooting/administrator.rst", "troubleshooting/index.rst"], "indexentries": {"api_rate_limits": [[15, "envvar-API_RATE_LIMITS", false]], "app_log": [[15, "envvar-APP_LOG", false]], "app_secret_key": [[15, "envvar-APP_SECRET_KEY", false]], "app_settings": [[15, "envvar-APP_SETTINGS", false]], "app_workers": [[15, "envvar-APP_WORKERS", false]], "database_disable_pooling": [[15, "envvar-DATABASE_DISABLE_POOLING", false]], "database_url": [[15, "envvar-DATABASE_URL", false]], "default_staticmap": [[15, "envvar-DEFAULT_STATICMAP", false]], "email_url": [[15, "envvar-EMAIL_URL", false]], "environment variable": [[15, "envvar-API_RATE_LIMITS", false], [15, "envvar-APP_LOG", false], [15, "envvar-APP_SECRET_KEY", false], [15, "envvar-APP_SETTINGS", false], [15, "envvar-APP_WORKERS", false], [15, "envvar-DATABASE_DISABLE_POOLING", false], [15, "envvar-DATABASE_URL", false], [15, "envvar-DEFAULT_STATICMAP", false], [15, "envvar-EMAIL_URL", false], [15, "envvar-FLASK_APP", false], [15, "envvar-HOST", false], [15, "envvar-MAP_ATTRIBUTION", false], [15, "envvar-PORT", false], [15, "envvar-REDIS_URL", false], [15, "envvar-SENDER_EMAIL", false], [15, "envvar-STATICMAP_SUBDOMAINS", false], [15, "envvar-TILE_SERVER_URL", false], [15, "envvar-UI_URL", false], [15, "envvar-UPLOAD_FOLDER", false], [15, "envvar-VITE_APP_API_URL", false], [15, "envvar-WEATHER_API_KEY", false], [15, "envvar-WEATHER_API_PROVIDER", false], [15, "envvar-WORKERS_PROCESSES", false]], "flask_app": [[15, "envvar-FLASK_APP", false]], "host": [[15, "envvar-HOST", false]], "map_attribution": [[15, "envvar-MAP_ATTRIBUTION", false]], "port": [[15, "envvar-PORT", false]], "redis_url": [[15, "envvar-REDIS_URL", false]], "sender_email": [[15, "envvar-SENDER_EMAIL", false]], "staticmap_subdomains": [[15, "envvar-STATICMAP_SUBDOMAINS", false]], "tile_server_url": [[15, "envvar-TILE_SERVER_URL", false]], "ui_url": [[15, "envvar-UI_URL", false]], "upload_folder": [[15, "envvar-UPLOAD_FOLDER", false]], "vite_app_api_url": [[15, "envvar-VITE_APP_API_URL", false]], "weather_api_key": [[15, "envvar-WEATHER_API_KEY", false]], "weather_api_provider \ud83c\udd95": [[15, "envvar-WEATHER_API_PROVIDER", false]], "workers_processes": [[15, "envvar-WORKERS_PROCESSES", false]]}, "objects": {"": [[0, 0, 1, "post--api-auth-account-confirm", "/api/auth/account/confirm"], [0, 1, 1, "get--api-auth-account-export", "/api/auth/account/export"], [0, 1, 1, "get--api-auth-account-export-(string-file_name)", "/api/auth/account/export/(string:file_name)"], [0, 0, 1, "post--api-auth-account-export-request", "/api/auth/account/export/request"], [0, 0, 1, "post--api-auth-account-privacy-policy", "/api/auth/account/privacy-policy"], [0, 0, 1, "post--api-auth-account-resend-confirmation", "/api/auth/account/resend-confirmation"], [0, 0, 1, "post--api-auth-email-update", "/api/auth/email/update"], [0, 0, 1, "post--api-auth-login", "/api/auth/login"], [0, 0, 1, "post--api-auth-logout", "/api/auth/logout"], [0, 0, 1, "post--api-auth-password-reset-request", "/api/auth/password/reset-request"], [0, 0, 1, "post--api-auth-password-update", "/api/auth/password/update"], [0, 2, 1, "delete--api-auth-picture", "/api/auth/picture"], [0, 0, 1, "post--api-auth-picture", "/api/auth/picture"], [0, 1, 1, "get--api-auth-profile", "/api/auth/profile"], [0, 0, 1, "post--api-auth-profile-edit", "/api/auth/profile/edit"], [0, 3, 1, "patch--api-auth-profile-edit-account", "/api/auth/profile/edit/account"], [0, 0, 1, "post--api-auth-profile-edit-preferences", "/api/auth/profile/edit/preferences"], [0, 0, 1, "post--api-auth-profile-edit-sports", "/api/auth/profile/edit/sports"], [0, 2, 1, "delete--api-auth-profile-reset-sports-(sport_id)", "/api/auth/profile/reset/sports/(sport_id)"], [0, 0, 1, "post--api-auth-register", "/api/auth/register"], [1, 1, 1, "get--api-config", "/api/config"], [1, 3, 1, "patch--api-config", "/api/config"], [2, 1, 1, "get--api-equipment-types", "/api/equipment-types"], [2, 1, 1, "get--api-equipment-types-(int-equipment_type_id)", "/api/equipment-types/(int:equipment_type_id)"], [2, 3, 1, "patch--api-equipment-types-(int-equipment_type_id)", "/api/equipment-types/(int:equipment_type_id)"], [3, 1, 1, "get--api-equipments", "/api/equipments"], [3, 0, 1, "post--api-equipments", "/api/equipments"], [3, 2, 1, "delete--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [3, 1, 1, "get--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [3, 3, 1, "patch--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [3, 0, 1, "post--api-equipments-(string-equipment_short_id)-refresh", "/api/equipments/(string:equipment_short_id)/refresh"], [5, 1, 1, "get--api-oauth-apps", "/api/oauth/apps"], [5, 0, 1, "post--api-oauth-apps", "/api/oauth/apps"], [5, 2, 1, "delete--api-oauth-apps-(int-client_id)", "/api/oauth/apps/(int:client_id)"], [5, 1, 1, "get--api-oauth-apps-(int-client_id)-by_id", "/api/oauth/apps/(int:client_id)/by_id"], [5, 0, 1, "post--api-oauth-apps-(int-client_id)-revoke", "/api/oauth/apps/(int:client_id)/revoke"], [5, 1, 1, "get--api-oauth-apps-(string-client_client_id)", "/api/oauth/apps/(string:client_client_id)"], [5, 0, 1, "post--api-oauth-authorize", "/api/oauth/authorize"], [5, 0, 1, "post--api-oauth-revoke", "/api/oauth/revoke"], [5, 0, 1, "post--api-oauth-token", "/api/oauth/token"], [1, 1, 1, "get--api-ping", "/api/ping"], [6, 1, 1, "get--api-records", "/api/records"], [7, 1, 1, "get--api-sports", "/api/sports"], [7, 1, 1, "get--api-sports-(int-sport_id)", "/api/sports/(int:sport_id)"], [7, 3, 1, "patch--api-sports-(int-sport_id)", "/api/sports/(int:sport_id)"], [8, 1, 1, "get--api-stats-(user_name)-by_sport", "/api/stats/(user_name)/by_sport"], [8, 1, 1, "get--api-stats-(user_name)-by_time", "/api/stats/(user_name)/by_time"], [8, 1, 1, "get--api-stats-all", "/api/stats/all"], [9, 1, 1, "get--api-users", "/api/users"], [9, 2, 1, "delete--api-users-(user_name)", "/api/users/(user_name)"], [9, 1, 1, "get--api-users-(user_name)", "/api/users/(user_name)"], [9, 3, 1, "patch--api-users-(user_name)", "/api/users/(user_name)"], [9, 1, 1, "get--api-users-(user_name)-picture", "/api/users/(user_name)/picture"], [10, 1, 1, "get--api-workouts", "/api/workouts"], [10, 0, 1, "post--api-workouts", "/api/workouts"], [10, 2, 1, "delete--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [10, 3, 1, "patch--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)-chart_data", "/api/workouts/(string:workout_short_id)/chart_data"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)-chart_data-segment-(int-segment_id)", "/api/workouts/(string:workout_short_id)/chart_data/segment/(int:segment_id)"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)-gpx", "/api/workouts/(string:workout_short_id)/gpx"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)-gpx-download", "/api/workouts/(string:workout_short_id)/gpx/download"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)-gpx-segment-(int-segment_id)", "/api/workouts/(string:workout_short_id)/gpx/segment/(int:segment_id)"], [10, 1, 1, "get--api-workouts-map-(map_id)", "/api/workouts/map/(map_id)"], [10, 0, 1, "post--api-workouts-no_gpx", "/api/workouts/no_gpx"], [15, 4, 1, "-", "API_RATE_LIMITS"], [15, 4, 1, "-", "APP_LOG"], [15, 4, 1, "-", "APP_SECRET_KEY"], [15, 4, 1, "-", "APP_SETTINGS"], [15, 4, 1, "-", "APP_WORKERS"], [15, 4, 1, "-", "DATABASE_DISABLE_POOLING"], [15, 4, 1, "-", "DATABASE_URL"], [15, 4, 1, "-", "DEFAULT_STATICMAP"], [15, 4, 1, "-", "EMAIL_URL"], [15, 4, 1, "-", "FLASK_APP"], [15, 4, 1, "-", "HOST"], [15, 4, 1, "-", "MAP_ATTRIBUTION"], [15, 4, 1, "-", "PORT"], [15, 4, 1, "-", "REDIS_URL"], [15, 4, 1, "-", "SENDER_EMAIL"], [15, 4, 1, "-", "STATICMAP_SUBDOMAINS"], [15, 4, 1, "-", "TILE_SERVER_URL"], [15, 4, 1, "-", "UI_URL"], [15, 4, 1, "-", "UPLOAD_FOLDER"], [15, 4, 1, "-", "VITE_APP_API_URL"], [15, 4, 1, "-", "WEATHER_API_KEY"], [15, 4, 1, "envvar-WEATHER_API_PROVIDER", "WEATHER_API_PROVIDER \ud83c\udd95"], [15, 4, 1, "-", "WORKERS_PROCESSES"]], "/api/workouts/map_tile/(s)/(z)/(x)/(y)": [[10, 1, 1, "get--api-workouts-map_tile-(s)-(z)-(x)-(y).png", "png"]]}, "objnames": {"0": ["http", "post", "HTTP post"], "1": ["http", "get", "HTTP get"], "2": ["http", "delete", "HTTP delete"], "3": ["http", "patch", "HTTP patch"], "4": ["std", "envvar", "environment variable"]}, "objtypes": {"0": "http:post", "1": "http:get", "2": "http:delete", "3": "http:patch", "4": "std:envvar"}, "terms": {"": [3, 9, 10, 11, 13, 15], "0": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15], "00": [0, 3, 6, 9, 10], "000": 13, "000000": 0, "01": [0, 6, 8, 9, 10], "02": 10, "03": [9, 10], "04": 10, "06": [3, 5, 8], "0667062": 5, "06ba975": 11, "07": [0, 6, 9, 10], "075aeb9": 11, "08": [0, 3, 6, 9, 10], "09": [0, 9], "0mb": [0, 10], "1": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 12, 13, 15], "10": [0, 1, 3, 6, 9, 10, 13, 15], "100": [10, 11, 13], "1000": 8, "10000": [1, 10], "101": [8, 11], "104": 11, "1048576": 1, "10485760": 1, "106": 11, "109": 11, "10mb": 13, "11": [0, 6, 9, 13, 15], "112": 11, "113": 11, "115": 11, "116": 11, "12": [0, 1, 6, 9, 10, 15, 17], "121": 11, "123": 11, "1232004": 10, "12341": 8, "1234538": 10, "125": [11, 13], "1267": 8, "127": [11, 15], "129": 11, "13": [0, 6, 9, 10, 12, 13, 15], "131": 11, "134": 11, "135": 11, "1375986": 11, "138": 11, "14": [0, 5, 9, 10], "140": 11, "145": 11, "146": 11, "149": 11, "15": [8, 10, 12, 13, 15], "150": 8, "151": 11, "152": 11, "155": 11, "156": [8, 11], "1563529507772": 10, "16": [8, 10, 13], "160": 11, "161": 11, "162": 11, "1658660147": 5, "167": 11, "169": 11, "17": [0, 10], "171": 11, "173": 11, "175": 11, "177": 11, "178": [8, 11], "18": [0, 6, 9, 10, 13, 15], "180": 11, "19": 13, "190": 11, "191": 11, "192": 11, "193": 11, "195": 11, "196": 11, "197": 11, "1m": 15, "1mb": [13, 15], "2": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 13, 14, 15], "20": [9, 13], "200": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 15], "201": [3, 10, 11], "2017": [8, 10], "2018": [8, 10], "2019": [0, 6, 8, 9, 10], "2022": 5, "2023": [0, 3, 15], "203": 8, "204": [0, 3, 5, 9, 10], "208": 11, "209": 11, "21": 3, "210": 11, "212": 11, "213": 11, "22": 10, "223": 11, "224": 11, "225": 11, "23": 15, "230": 11, "231": 11, "232": 11, "236": 11, "237": 11, "239": 11, "24": 15, "241": 11, "242": 11, "244": 11, "246": 11, "247": 11, "25": 15, "250": 11, "252": 11, "255": 10, "257": 11, "258": 11, "259": 11, "26": 15, "260": 11, "261": 11, "264": 11, "265": 11, "266": 11, "26and": 15, "27": [0, 5, 9, 13], "270": 11, "271": 11, "273": 11, "274": 11, "275": 11, "278": 11, "279": [10, 11], "28": 3, "280": [10, 11], "282": [8, 11], "287": 11, "289": 11, "29": 3, "290": 11, "2930": 10, "294": 11, "297": 11, "2bcff2e": 11, "2e1ee2c": 11, "2ukrviyshoakg8qsuknus4": 3, "3": [0, 2, 7, 8, 9, 10, 13, 15], "30": [0, 8], "300": 15, "3000": 15, "301": [11, 15], "304": 11, "305": 11, "307": 11, "308": 11, "31": [0, 10, 13, 15], "310": 11, "314": 11, "315": 11, "318": 11, "319": 11, "320": 11, "323": 11, "328": 11, "329": 11, "33": [8, 11], "3320": 8, "333": 11, "338": 11, "34": 11, "34614d5": 11, "35": [0, 11], "350": 11, "351": 11, "352": 11, "354": 11, "356": 11, "357": 11, "358": 11, "359": 11, "36": 11, "365": 11, "366": 11, "367": 11, "369": 11, "37": 11, "370": 11, "371": 11, "374": 11, "375": 11, "376": 11, "377": 11, "380": 11, "3821e37": 11, "384": 11, "386": 11, "388": 11, "39": 10, "390": 11, "391": 11, "393": 11, "394": 11, "395": 11, "397": 11, "398": 11, "399": 11, "3aread": 16, "3awrit": 16, "3b6fa25": 11, "3c8d9c2": 11, "3f": 15, "3rd": 16, "4": [0, 2, 7, 8, 9, 10, 12, 13, 15], "40": 11, "400": [0, 1, 2, 3, 5, 7, 8, 9, 10, 11], "401": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11], "402": 11, "403": [0, 1, 2, 3, 7, 8, 9, 10], "404": [0, 2, 3, 5, 7, 8, 9, 10, 11], "406": 11, "407": 11, "409": 11, "40and": 15, "41": 11, "410": 11, "4109": 10, "411": 11, "413": [0, 10], "415": 11, "416": 11, "417": 11, "418": 11, "42": 11, "421": 11, "422": 11, "426": 11, "427": 11, "428": 11, "43": [0, 6, 9, 10, 11], "431": 11, "433": 11, "436": 11, "438": 11, "44": [10, 11], "441": 11, "443": 15, "444": 11, "449": 11, "45": 10, "450": 11, "455": 11, "456": 11, "46": [8, 11], "464": 11, "465": 15, "468": 11, "469": 11, "47": [8, 10, 11], "471": 11, "472": 11, "473": 11, "474": 11, "475": 11, "476": 11, "477": 11, "478": 11, "479": 11, "48": 8, "481": 11, "482": 11, "484": 11, "488": 11, "489": 11, "490": 11, "494": 11, "495": 11, "496": 11, "499": 11, "4c3fc34": 11, "5": [2, 5, 7, 8, 9, 10, 12, 13, 15], "50": [0, 3, 8, 9, 11, 15], "500": [0, 1, 2, 3, 7, 9, 10, 11], "5000": 15, "502": 11, "504": 11, "506": 11, "507": 11, "5078118": 10, "5079733": 10, "508": 11, "51": 10, "510": 11, "511": 11, "512": 11, "51758b4": 11, "52": 11, "521": 11, "524": 11, "526": 11, "527": 11, "528": 11, "53": [5, 11], "530": 11, "531": 11, "532": 11, "533": 11, "534": 11, "536": 11, "537": 11, "538": 11, "54": 11, "540": 11, "542": 11, "543": 11, "5432": 15, "544": 11, "545": 11, "546": 11, "550": 11, "551": 11, "555": 11, "556": 11, "557": 11, "558": 11, "56": 11, "563": 11, "564": 11, "565": 11, "566": 11, "57": [10, 11], "571": 11, "575": 11, "58": [0, 9, 11], "582": 11, "583": 11, "587": [11, 15], "588": 11, "59": [8, 11], "590": 11, "591": 11, "592": 11, "593": 11, "595": 11, "598": 11, "6": [0, 2, 3, 7, 9, 10, 12, 13, 15], "60": 11, "600": 11, "603": 11, "604": 11, "607": 11, "608": 11, "609": 11, "60e164d": 11, "61": 11, "610": 11, "612": 11, "613": 8, "614": 11, "616": 11, "617": 11, "618": 11, "62": 11, "620": 11, "621": 11, "622": 11, "624": 11, "625": 11, "626": 11, "628": 11, "629": 11, "63": 10, "631": 11, "633": 11, "634": 11, "635": 11, "636": 11, "637": 11, "639": [11, 12], "64": 11, "640": 11, "645": 11, "651": 11, "652": 11, "66": 11, "67": [0, 8, 9], "6e215aa": 11, "7": [10, 12, 13, 15, 16], "70": 11, "71": 11, "72": 11, "720": 0, "73": 11, "7380": 10, "74": 11, "75": 11, "7641": 8, "78": 8, "79": 11, "8": [0, 1, 10, 12, 13, 15, 16], "80": [11, 15], "8025": 15, "81": 11, "82": 11, "83": 11, "84": 11, "85": 11, "864000": 5, "87": 11, "877fa0f": 11, "88": 11, "89": 11, "895": [0, 9], "8aa4cff": 11, "9": [0, 6, 9, 13, 15], "90": 11, "91": 11, "92": 11, "924": 0, "93": 11, "95": [8, 11], "97": [0, 6, 9, 10, 11], "98": 11, "99": [8, 11], "9960": 8, "A": [3, 9, 11, 12, 13, 15, 18], "AS": [0, 6, 9, 10], "And": 11, "BY": 15, "For": [8, 10, 13, 15, 16], "If": [0, 3, 5, 8, 10, 12, 13, 15, 16], "In": [11, 13], "It": [0, 9, 11, 13, 14, 16], "NO": [0, 3, 5, 9, 10], "NOT": [2, 3, 5, 7, 10], "No": [0, 3, 5, 9, 10, 11, 14], "Not": [0, 2, 3, 5, 7, 8, 9, 10], "OF": 15, "On": [13, 15], "One": 15, "The": [0, 3, 10, 11, 13, 15, 16, 18], "There": [13, 15], "To": [11, 15, 16], "WITH": 15, "With": [13, 15], "_": [0, 11], "__main__": 15, "_blank": 15, "a458f5f": 11, "aaron": 16, "abil": 11, "about": [1, 11, 13, 15, 16], "absolut": [15, 18], "accept": 0, "accepted_polici": 0, "accepted_privacy_polici": 0, "access": [0, 5, 8, 11, 15, 16], "access_token": 5, "accord": 13, "account": [4, 9, 11, 12, 15, 17], "action": 11, "activ": [0, 2, 3, 7, 9, 11, 12, 13, 14, 15], "ad": [0, 3, 10, 11, 12, 13, 15], "adapt": [13, 15], "add": [9, 11, 12, 13, 14], "addit": [11, 13, 15], "address": [0, 13, 15], "admin": [0, 1, 2, 6, 7, 8, 9, 10, 11, 12, 13, 15], "admin_contact": 1, "administr": [0, 1, 2, 3, 7, 9, 10, 16, 19], "aff4d68": 11, "affect": [11, 13], "after": [0, 5, 11, 13, 15, 16], "again": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "agplv3": 11, "agre": [0, 11, 13], "alert": 11, "all": [2, 3, 5, 6, 7, 8, 9, 10, 11, 13, 15], "allow": [0, 1, 3, 10, 11, 12, 13, 14, 15, 16], "along": 10, "alphanumer": [0, 11], "alpin": 13, "alreadi": [0, 3, 11, 12], "also": [11, 12, 13, 14, 15], "altern": 11, "although": 13, "altitud": [11, 13], "alwai": 15, "among": 11, "an": [0, 1, 2, 3, 5, 7, 9, 10, 11, 13, 15, 16, 18], "analyz": 13, "android": 14, "ani": [3, 10, 15], "anoth": [0, 3, 9, 13, 15], "antialia": 11, "anymor": 11, "apach": 14, "api": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 13, 14, 16], "api_rate_limit": 15, "apikei": 15, "app": [0, 5, 11, 14, 15, 16], "app_log": 15, "app_secret_kei": 15, "app_set": 15, "app_work": 15, "appear": 13, "appli": 12, "applic": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 14, 15, 16], "application_directori": 15, "ar": [0, 3, 5, 6, 8, 10, 11, 13, 14, 15, 16, 17, 19], "archiv": [0, 1, 11, 12, 13, 15], "archive_rgjsr3fhr5yp": 0, "archive_rgjsr3fht295ywnqr5yp": 0, "archlinux": 15, "area": 11, "arg": [12, 15], "argument": [3, 12], "arrai": [0, 3, 5, 10], "arrow": [11, 13], "asc": [9, 10], "ascent": [0, 6, 10, 11, 13], "asset": 15, "associ": [0, 3, 5, 10, 11, 13], "astridx": 11, "attribut": [11, 15], "auth": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 15, 16], "auth_token": 0, "authent": [1, 2, 4, 5, 6, 7, 8, 9, 10, 16], "authlib": [5, 15, 16], "author": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 15, 16], "authorization_cod": [5, 16], "autoescap": 11, "automat": 13, "avail": [0, 6, 12, 13, 15, 16, 17], "ave_spe": 10, "ave_speed_from": 10, "ave_speed_to": 10, "averag": [6, 8, 10, 11, 13], "average_asc": 8, "average_desc": 8, "average_dist": 8, "average_dur": 8, "average_spe": 8, "avoid": [11, 13], "awesom": 15, "axi": [10, 11, 13], "b": 15, "b1536fc": 11, "b29ed7a": 11, "b748459": 11, "b862a77": 11, "back": 12, "background": 11, "backup": 15, "backward": 15, "bad": [0, 1, 2, 3, 5, 7, 8, 9, 10], "base": 15, "basqu": [11, 13], "bcc568e": 11, "bearer": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "becom": 11, "been": [11, 14], "befor": [11, 13, 15], "begin": 15, "behind": 16, "being": 13, "below": [11, 15], "better": [11, 16], "between": [3, 11, 13], "bike": [2, 3, 7, 10, 11, 13], "bin": 15, "bio": [0, 9], "biographi": 0, "birth": [0, 11], "birth_dat": [0, 9], "bjornclauw": 11, "black": 11, "blacklist": [0, 12], "boat": 13, "bodi": [11, 15, 16], "bokm\u00e5l": [11, 13], "boolean": [0, 1, 2, 3, 5, 7, 9, 12], "boosterl": 11, "bound": 10, "brief": 3, "browser": [0, 11, 13], "build": [11, 15], "bulgarian": [11, 13], "button": 11, "by_id": 5, "by_sport": 8, "by_tim": 8, "byakurau": 11, "byte": 0, "c": [10, 15], "c88a515": 11, "calcul": [0, 8, 11, 13], "calendar": [11, 13], "callback": [5, 16], "can": [0, 3, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18], "cannot": [3, 11, 13], "card": 11, "cart": 15, "case": [3, 10, 13, 15], "cb9d02f": 11, "cc": 15, "cc3fe1c": 11, "cc4287e": 11, "cd": 15, "challeng": [5, 16], "chang": [0, 3, 12, 13, 14, 15], "changelog": 15, "charact": [0, 3, 10, 11, 15], "chart": [10, 11, 13, 15], "chart_data": 10, "check": [1, 7, 11, 15, 18], "check_workout": 7, "checkbox": 11, "choos": [11, 13], "chosen": 15, "ci": [11, 15], "cleanup": 11, "clear": 15, "cli": [11, 12, 13, 15], "click": 11, "clickabl": 11, "client": [0, 5, 11, 13, 15, 16], "client_client_id": 5, "client_descript": 5, "client_id": [5, 16], "client_max_body_s": [15, 18], "client_nam": 5, "client_secret": 5, "client_uri": 5, "clone": 15, "code": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 16], "code_challeng": [5, 16], "code_challenge_method": [5, 16], "code_verifi": 5, "color": [0, 7, 11, 13], "com": [0, 1, 5, 9, 11, 15, 16], "come": 13, "comma": [11, 15], "command": [11, 13, 14, 15], "complet": [0, 11], "compos": 15, "comradekingu": 11, "config": [1, 11, 15, 18], "configur": [4, 11, 13, 15, 16], "confirm": [0, 5, 11, 13, 15], "confusedalex": 11, "contact": [0, 1, 2, 3, 7, 9, 10, 13], "contain": [11, 13, 15], "content": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11], "contribut": 11, "contributor": [1, 11, 15, 17], "control": [11, 13, 15], "coordin": 15, "copi": [1, 15], "copyright": [1, 15], "core": 11, "correctli": 11, "correspond": [0, 15], "could": 11, "countri": 13, "cp": 15, "creat": [0, 3, 5, 10, 11, 13, 15, 16], "create_app": 15, "created_at": [0, 9], "creation": [0, 11, 13], "creation_d": [3, 10], "creativecommon": 15, "credenti": [0, 15], "criteria": [9, 10], "critic": 18, "cross": [5, 13, 15, 16], "current": [0, 3, 9, 11], "custom": [11, 13, 15], "cycl": [7, 11, 13], "czech": [11, 13], "d": [0, 8, 10], "dai": [11, 12, 13, 15], "danielsiersleben": 11, "dark": [0, 11, 13], "darkski": [11, 15], "dashboard": 11, "data": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 13, 14, 16], "databas": [11, 13, 15, 18], "database_disable_pool": 15, "database_url": [11, 15, 18], "date": [0, 8, 10, 11, 13], "date_format": 0, "date_str": 0, "davidhenrythoreau": 11, "db": 15, "dd": 0, "de": [0, 2, 15], "deactiv": 13, "debian": [15, 17], "default": [0, 3, 5, 8, 9, 10, 11, 13, 15], "default_equipment_id": 0, "default_for_sport_id": 3, "default_staticmap": [11, 15], "defin": [11, 13], "definit": 11, "delet": [0, 3, 5, 9, 10, 11, 12, 13], "depend": [11, 13], "deploy": 11, "deprec": 11, "desc": [9, 10, 13], "descent": [10, 11, 13], "describ": 15, "descript": [3, 5, 10, 11, 12, 13, 15], "detail": [9, 11, 15, 19], "detect": 11, "dev": 11, "develop": [11, 12, 14], "diagnost": 15, "dialect": 18, "differ": [9, 11, 13], "direct": [11, 13], "directli": 15, "directori": [11, 13, 15], "disabl": [0, 11, 12, 13, 15], "discontinu": 15, "discours": 13, "displai": [0, 10, 11, 12, 13, 14, 15, 16, 19], "display_asc": 0, "distanc": [0, 6, 10, 11, 13], "distance_from": 10, "distance_to": 10, "dkm": 11, "do": [0, 1, 2, 3, 7, 8, 9, 10, 15], "docker": 11, "document": [14, 15, 16, 18], "doe": [0, 3, 8, 9, 10, 11], "don": 0, "dotenv": 11, "dotlambda": 11, "doubl": 10, "down": [11, 15], "download": [0, 10, 11, 13, 15, 19], "dperruso": 11, "dramatiq": [11, 12, 15], "drop": 11, "dropdown": [11, 13], "due": 11, "durat": [6, 10, 11, 13], "duration_from": 10, "duration_to": 10, "dure": [0, 3, 10], "dutch": [11, 13], "e": 0, "e2": 11, "ea0ac99": 11, "each": 13, "easi": 11, "edit": [0, 11, 13], "electr": 13, "elev": [0, 10, 11, 13, 15], "els": 15, "email": [0, 1, 9, 11, 12, 13, 18], "email_to_confirm": 0, "email_url": [15, 18], "empti": [10, 11, 12, 15], "en": [0, 9, 12], "enabl": [0, 1, 9, 13, 15], "encod": [11, 15], "encount": 15, "encrypt": 13, "end": [8, 10], "endpoint": [0, 1, 5, 11, 15, 16], "engin": [11, 15, 18], "english": [0, 11, 12, 13], "enter": [11, 13], "entiti": [0, 10], "entri": [11, 15], "entrypoint": 11, "enumer": 15, "env": [11, 15], "environ": [11, 12, 18], "equal": 1, "equip": [0, 4, 10, 11, 16], "equipment_id": [0, 10], "equipment_short_id": 3, "equipment_typ": [2, 3], "equipment_type_id": [2, 3], "equipment_type_label": 3, "erral": 11, "error": [0, 1, 2, 3, 5, 7, 9, 10, 11, 13, 15, 18], "escap": 10, "estim": 11, "eu": 11, "europ": [0, 9], "evalu": [11, 15], "even": [11, 13], "ewm": 11, "exampl": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 14, 15, 16], "exc": 18, "exce": [0, 3, 10, 11], "exceed": 11, "except": [9, 13, 15, 18], "exchang": 16, "exclud": 13, "execstart": 15, "execut": 11, "exhaust": 14, "exist": [0, 3, 8, 9, 10, 11, 13, 14, 15], "exit": [12, 15], "expect": 15, "expir": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 12], "expires_at": 5, "expires_in": 5, "export": [0, 11, 12, 13, 14, 15], "extens": [0, 10, 15], "extrem": 13, "f2aec30": 11, "f96dcef": 11, "fa33f4d996844a5c73ecd1ae24456ab8": 10, "fail": [11, 19], "fall": 12, "fallback": 0, "fals": [0, 1, 2, 5, 7, 9, 10, 15], "farthest": [6, 11, 13], "fb10602": 11, "fd": [0, 6, 9, 10], "featur": [14, 15], "fetch": [13, 16], "field": [11, 13], "file": [0, 1, 10, 11, 12, 13, 14, 15, 17, 19], "file_nam": 0, "file_s": 0, "filenam": 0, "filter": [11, 13], "finish": 11, "first": [0, 3, 5, 13, 16], "first_nam": [0, 9], "fit": [15, 17], "fitotrack": 14, "fittracke": [3, 5, 12, 13, 15, 16, 17, 19], "fittrackee_cli": 15, "fittrackee_host": 16, "fittrackee_instal": 17, "fittrackee_work": [11, 15], "fittrackee_ynh": 15, "fix": 15, "flake8": 11, "flask": [11, 15], "flask_app": 15, "flaticon": 15, "float": [0, 10], "fmstrat": 11, "folder": 15, "follow": [6, 13, 15, 16, 17, 18], "fond": 15, "footer": 11, "forbidden": [0, 1, 2, 3, 7, 8, 9, 10], "forc": 3, "forgeri": [5, 16], "fork": 15, "form": [0, 5, 10, 11], "format": [0, 8, 10, 11, 13], "forward": [15, 16], "found": [0, 2, 3, 5, 7, 8, 9, 10, 11], "fr": [0, 9, 15], "frame": 8, "franc": 15, "freepik": 15, "french": 13, "fri": 10, "from": [3, 5, 8, 10, 11, 13, 14, 16, 17], "ft": 11, "ftcli": 15, "full": [11, 13], "fullchain": 15, "fullscreen": 11, "furo": 11, "galician": [11, 13], "gallegonovato": 11, "garmin": 17, "gener": [5, 11, 12, 13, 15, 16], "german": [11, 13, 15], "get": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 16], "gif": 0, "git": 15, "github": 15, "given": [0, 5, 8, 13], "gl": 0, "gmt": [0, 3, 5, 6, 9, 10], "gnu": 11, "gorgobacka": 11, "gp": [11, 13], "gpl": 14, "gpx": [0, 5, 10, 11, 13, 14, 15, 17], "gpx_limit_import": 1, "gpxpy": [0, 11, 13, 15], "grai": 11, "grammar": 11, "grant": [5, 16], "grant_typ": 5, "graph": 11, "greater": [1, 11], "guid": 15, "gunicorn": [15, 18], "gz": 15, "gzip": 0, "h": [10, 13], "ha": [0, 3, 6, 7, 9, 10, 11, 13, 14], "handl": [0, 11, 13, 15, 18], "happen": 13, "has_equip": 2, "has_next": 5, "has_prev": 5, "has_workout": 7, "have": [1, 2, 3, 7, 8, 9, 10, 11, 13], "he": 9, "header": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 16], "health": 1, "heavi": [14, 15], "help": [12, 15], "hexadecim": 0, "hi": [0, 3, 9, 11, 13], "hidden": [11, 13], "hide": [11, 13], "higher": 15, "highest": [0, 6, 13], "hike": [7, 11, 13], "histor": 15, "home": 15, "host": [11, 15], "hour": 15, "hourli": 15, "how": 15, "href": [1, 15], "html": 11, "http": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 15, 16], "http2": 15, "hvybqybra7wwxpastwr4v2": [0, 6, 9, 10], "i": [0, 1, 3, 5, 10, 11, 12, 13, 14, 15, 16, 19], "i18n": 11, "icon": [11, 15], "id": [0, 2, 3, 5, 6, 7, 8, 9, 10, 11, 16], "imag": [0, 9, 10, 11, 13, 15, 19], "imperi": [0, 11, 13], "imperial_unit": [0, 9], "implement": [11, 16], "import": [5, 11, 13, 15], "in_progress": 0, "inact": [0, 3, 9, 10, 13, 15], "includ": 11, "incompat": 15, "incomplet": 12, "inconsist": 11, "incorrect": [3, 11, 13], "increas": 18, "index": 10, "indic": 13, "individu": 13, "info": [0, 11, 13], "inform": [0, 1, 11, 13, 14, 15], "init": 11, "initi": [11, 15], "initialis": 11, "input": 11, "insensit": [10, 15], "insid": 15, "instal": [11, 14], "instanc": [1, 5, 11, 13, 15, 16], "instead": [11, 13], "instruct": [0, 11, 13, 15], "int": [0, 2, 3, 5, 7, 10], "integ": [1, 2, 3, 5, 7, 8, 9, 10], "interact": 16, "interceptor": 11, "interfac": [0, 11, 13, 14, 15], "intern": [0, 1, 2, 3, 7, 9, 10], "introduc": 11, "invalid": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 15], "invalidemailurlschem": 18, "ip": 15, "is_act": [0, 2, 3, 7, 9], "is_active_for_us": 7, "is_admin": 9, "is_email_sending_en": 1, "is_registration_en": 1, "iso": 12, "isort": 11, "issu": [5, 13, 14, 15], "issued_at": 5, "italian": [11, 13], "item": 3, "its": [13, 15], "j": [10, 11, 15], "jan": 10, "jat255": 11, "javascript": [11, 15], "jderuit": 11, "jinja": 11, "jmlich": 11, "john_do": 9, "jpeg": 9, "jpg": 0, "json": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 13], "jul": [0, 6, 9, 10], "juli": 5, "jwt": 15, "kayak": 13, "kayak_boat": 2, "keep": [11, 14, 15], "kei": [11, 13, 15], "keyboard": 11, "kind": 13, "kjxavsturjvoah2wvcegef": 10, "km": [10, 13], "koen": 11, "komoot": 15, "label": [2, 3, 7, 11, 13], "lang": [0, 11, 12], "languag": [0, 9, 11, 12, 13], "larg": [0, 10, 13, 15], "larger": [15, 18], "last": [0, 11, 15], "last_nam": [0, 9], "lastli": 11, "latitud": 10, "launch": 11, "lavoi": 11, "layer": [11, 15], "layout": 11, "ld": [0, 6, 9, 10], "leaflet": [10, 15], "least": 10, "legal": 15, "legitim": 11, "length": 10, "less": [3, 11], "let": 11, "letter": 12, "librari": [5, 11, 15, 16], "licenc": 15, "licens": [11, 14, 15], "light": 13, "like": [13, 15], "limit": [3, 11, 13], "line": [13, 14, 15], "link": [11, 15], "lint": 15, "linux": 15, "list": [5, 11, 14, 15], "listen": 15, "ll": 15, "load": [11, 18], "loader": 11, "local": [0, 11, 14, 15], "localhost": [11, 15], "locat": [0, 9, 15], "lock": 13, "log": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 13, 14, 15, 18], "logfil": 15, "login": [0, 11], "logo": 15, "logout": [0, 11], "longer": [3, 11, 13, 15, 18], "longest": [6, 11, 13], "longitud": 10, "lower": 11, "lukasitali": 11, "m": [0, 6, 8, 9, 10], "made": 15, "mai": [11, 13, 14, 15], "mailhog": 15, "mainten": 15, "major": 11, "make": [11, 15], "makefil": 15, "malform": 0, "manag": [11, 12, 15], "mandatori": [3, 5, 8, 10, 11, 12, 15, 16], "manual": 13, "map": [10, 11, 13, 14, 19], "map_attribut": [1, 15], "map_id": 10, "map_til": 10, "mar": [0, 3], "mara21": 11, "march": [11, 15], "mariusz": 11, "mariuz": 11, "markdown": [11, 13], "marker": 11, "match": [0, 10, 11, 13], "max": [1, 9, 10, 11, 12], "max_alt": 10, "max_single_file_s": 1, "max_spe": 10, "max_speed_from": 10, "max_speed_to": 10, "max_us": 1, "max_zip_file_s": 1, "maxim": 10, "maximum": [6, 10, 11, 12, 13], "md": 11, "measur": 11, "mention": 15, "menu": 11, "messag": [0, 1, 5, 10, 11, 12, 13, 15], "method": [5, 15, 16], "metric": 13, "mi": 11, "microsecond": 11, "migrat": [11, 12, 15], "min": 11, "min_alt": 10, "minim": [10, 16], "minimum": [11, 13], "minut": 15, "miss": [0, 11], "mm": 0, "mmm": 0, "mobil": [11, 14], "modal": 11, "mode": [0, 11], "model": 11, "modifi": [0, 9, 11, 12, 15], "modification_d": 10, "modul": [11, 15], "moment": 13, "mon": 10, "mondai": [0, 8, 13], "mondstern": 11, "month": [8, 11, 13], "more": [11, 12, 13, 14, 15], "morn": 10, "most": 16, "mountain": [7, 11, 13], "mous": 11, "move": [10, 11], "movement": 11, "multi": 15, "multipart": [0, 5, 10], "multipl": 11, "must": [0, 1, 2, 3, 5, 7, 9, 10, 11, 13, 15, 16, 18], "mv": 15, "my": 3, "mynixo": 15, "n": 0, "name": [0, 5, 9, 11, 13, 15], "nano": 15, "navig": 11, "nb": 0, "nb_sport": [0, 9], "nb_workout": [0, 9], "nbsp": 15, "necessari": [13, 15], "nederland": 11, "need": [11, 13, 15, 16], "net": 15, "netinstal": 17, "network": 15, "new": [0, 3, 9, 12, 13, 15, 16], "new_email": 9, "new_password": 0, "newli": [0, 12, 15], "next": [11, 13], "next_workout": 10, "nginx": [11, 13, 15, 16, 18], "nixpkg": 15, "nl": 0, "no_gpx": 10, "node": 15, "nofollow": 15, "non": [2, 7, 14], "none": 10, "noopen": 15, "noreferr": 15, "norwegian": [11, 13], "nosuchmoduleerror": 18, "notat": 15, "note": [0, 10, 11, 13, 15], "notif": 15, "now": [0, 8, 10, 11, 13, 15], "null": [0, 1, 3, 7, 9, 10, 11], "number": [1, 9, 10, 11, 12, 13, 15], "nuv9cy8vqonrqkhtz5pqaq2zw7msh0mornpjr14amswd6f6i": 5, "o": 15, "o22a27s2abpuoxjbxv3ujdox": 5, "oauth": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 14, 15], "oauth2": [4, 16], "oauthlib": 16, "object": [0, 1, 2, 3, 7, 9, 10], "occur": 13, "office365": 15, "offset": 11, "ok": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "old": 15, "omit": 15, "ondrejzivni": 11, "one": [0, 3, 9, 10, 13], "ongo": 0, "onli": [0, 3, 5, 8, 9, 10, 11, 12, 13, 15, 16], "open": [11, 13, 14, 15], "openstreetmap": [1, 11, 15], "opentrack": 14, "oper": 15, "option": [3, 5, 11, 12, 15, 16], "order": [9, 10, 11, 13, 16], "order_bi": [9, 10], "org": [1, 15], "origin": 13, "osm": 15, "osmfr": 15, "other": [0, 3, 9, 15], "otherwis": [10, 13, 15], "ou": 11, "out": 0, "outdoor": [11, 13, 14, 15], "over": 11, "overlap": 11, "overrid": 13, "overridden": 13, "overwrit": 15, "own": [3, 9, 13, 14], "owner": [3, 11, 13, 15], "packag": [11, 15], "paf38": 11, "page": [5, 9, 10, 11, 13], "pagin": [5, 9, 10], "par": [11, 15], "par_pag": 9, "paraglid": [11, 13], "parallel": 11, "paramet": [0, 2, 3, 5, 7, 8, 9, 10, 11, 13, 15, 16], "parecki": 16, "pari": [0, 9], "pars": [11, 15], "part": [0, 10], "parti": [5, 13, 14, 16], "partial": 11, "particular": 13, "pass": 15, "password": [0, 9, 11, 12, 13, 15], "passwordwith": 15, "patch": [0, 1, 2, 3, 7, 9, 10], "path": [15, 18], "paus": [10, 11], "payload": [0, 1, 2, 5, 7, 9, 10], "pem": 15, "per": [9, 10, 11, 15], "per_pag": [9, 10], "perform": 13, "perhap": 3, "period": [8, 13, 15], "permiss": [1, 2, 3, 7, 8, 9, 10], "pg_dump": 15, "pictur": [0, 9, 10, 11], "piec": [3, 13], "pil": 11, "ping": 1, "pip": 15, "pipenv": 11, "pkce": [5, 16], "pleas": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11], "plot": 0, "plugin": 18, "pluja": 11, "png": [0, 10, 15], "poetri": [11, 15], "point": [11, 15], "polici": [0, 1, 11, 13], "polish": [11, 13], "pong": 1, "pool": 15, "port": 15, "portugues": [11, 13], "posit": [11, 13], "possibl": [11, 13, 14, 15], "post": [0, 3, 5, 10, 16], "postgr": [11, 18], "postgresql": [11, 15, 18], "postgresql10": 11, "pr": 11, "pre": 11, "prefer": [0, 9, 11, 12], "prepar": 11, "present": [11, 13], "prevent": [5, 15, 16], "previous": 11, "previous_workout": 10, "privaci": [0, 1, 11, 13], "privacy_polici": 1, "privacy_policy_d": 1, "privat": [11, 13], "privileg": 15, "privkei": 15, "problem": 11, "process": [0, 11, 12, 15], "product": 11, "productionconfig": 15, "profil": [0, 5, 11, 16], "project": 15, "proto": [15, 16], "provid": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 18], "proxi": [15, 16], "proxy_add_x_forwarded_for": 15, "proxy_pass": 15, "proxy_redirect": 15, "proxy_set_head": [15, 16], "pull": 15, "purpos": [11, 15], "pwd": 15, "py": 15, "python": [11, 15, 16], "q": 9, "qrj7by6h2iyjsv8sersfgv": 3, "queri": [3, 5, 7, 8, 9, 10], "queue": 15, "quot": 10, "qwerty287": 11, "r": 15, "random": 12, "randomli": 15, "rate": 11, "reach": 13, "read": [0, 2, 3, 5, 6, 7, 8, 9, 10, 11, 16], "readi": 15, "readm": 11, "real": 15, "reason": 0, "rebuild": 11, "recalcul": 13, "recent": 13, "recommend": [5, 15, 16], "record": [0, 4, 9, 10, 11, 13], "record_typ": [0, 6, 9, 10], "redi": [11, 12, 15], "redirect": [5, 11, 16], "redirect_uri": 5, "redis_url": 15, "reduc": 11, "refacto": 11, "refactor": 11, "refresh": [3, 5, 11], "refresh_token": 5, "regardless": 9, "regist": [0, 1, 11, 13, 15], "registr": [0, 1, 11, 12, 13, 15, 16], "rel": 15, "relat": [12, 13, 15, 16], "releas": [13, 15], "relev": 11, "remain": [11, 13], "remote_addr": 15, "remov": [3, 9, 10, 11, 12, 13], "renam": 11, "replac": [10, 11, 15], "repo": 15, "report": 11, "repositori": 15, "request": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 12, 13, 15, 16], "request_uri": 15, "requir": [0, 3, 11, 15, 16], "requisit": 11, "resend": 0, "resent": 0, "reset": [0, 9, 11, 12, 13, 15], "reset_password": 9, "resolut": 11, "respons": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11], "response_typ": [5, 16], "rest": 16, "restart": 15, "restartsec": 15, "result": 11, "return": [0, 5, 6, 8, 9, 10, 11, 15], "review": [11, 13], "revok": [0, 5], "rework": 11, "ride": 11, "right": [9, 11, 12, 13, 15, 16], "roehv64thcg28wcewzhrnvlusoduvw8nvnhkcml57": 5, "rout": [11, 15], "row": 13, "ruff": 11, "run": [7, 11, 12, 13, 15, 16], "runner": 14, "russian": [11, 13], "s256": [5, 16], "sa": 15, "sam": [0, 6, 9, 10], "same": [3, 11, 13], "samr1": 15, "sanit": 11, "sat": 9, "save": [3, 11, 13], "schema": 15, "scheme": [15, 16], "scope": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "screen": [11, 13], "script": 11, "search": 11, "second": [3, 10], "secret": [5, 15, 16], "section": 11, "secur": 16, "see": [3, 11, 13, 14, 15, 16, 18], "seem": 11, "segment": [10, 11, 13], "segment_id": 10, "select": [0, 10, 13], "semant": 15, "send": [0, 9, 11, 13, 15], "sender": 15, "sender_email": 15, "sent": [11, 13, 15, 16], "separ": [15, 16], "serv": [11, 15], "server": [0, 1, 2, 3, 7, 9, 10, 11, 13, 14], "server_nam": 15, "servic": [11, 15], "session": 16, "set": [3, 11, 12, 13, 15, 16, 18], "sever": [11, 13, 14, 15], "sh": 17, "shell": 15, "shoe": [2, 3, 13], "short": [3, 10], "should": [11, 13, 15], "show": [11, 12, 15], "shown": [11, 16, 19], "shura0": 11, "shut": [11, 15], "side": 11, "signatur": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "sikmir": 11, "simontb": 11, "simpl": 15, "simplest": 15, "simplifi": [11, 16], "sinc": [11, 15, 16], "singl": [1, 9, 11, 13, 15], "site": [5, 16], "size": [0, 1, 10, 11, 13, 15], "ski": [2, 13], "skylan0916": 11, "slothj": 11, "slow": 11, "small": 11, "smtp": [11, 15], "snowsho": [2, 11, 13], "so": [15, 16], "some": [3, 9, 10, 11, 13, 14, 15, 16], "sorri": 0, "sort": [9, 10, 13], "sou": 15, "sourc": 13, "space": 16, "spanish": [11, 13], "spawn": 15, "special": [11, 15], "specif": [13, 15], "specifi": 11, "speed": [0, 6, 10, 11, 13, 15], "spinner": 11, "sport": [0, 1, 3, 4, 8, 10, 11, 15], "sport_id": [0, 3, 6, 7, 8, 9, 10], "sport_label": 3, "sports_list": [0, 9], "sql": 15, "sqlalchemi": [11, 15, 18], "ssl": 15, "ssl_certif": 15, "ssl_certificate_kei": 15, "standard": [11, 15], "standarderror": 15, "standardoutput": 15, "start": [0, 8, 10, 11, 13, 15, 19], "start_elevation_at_zero": 0, "startlimitintervalsec": 15, "starttl": 15, "stat": [8, 11], "state": [5, 16], "static": [11, 13, 15], "staticmap": 15, "staticmap_subdomain": [11, 15], "statist": [1, 4], "stats_workouts_limit": 1, "statu": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13], "step": 15, "sticki": 11, "still": [11, 13, 14, 15], "stop": [0, 11, 13, 15], "stopped_speed_threshold": [0, 7], "store": [13, 14, 16], "strategi": 15, "strava": 17, "street": [13, 14], "strength": 11, "string": [0, 1, 3, 5, 7, 8, 9, 10, 15, 16], "strong": 15, "subdomain": [10, 11, 15], "subject": 15, "success": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "successfulli": 0, "suitabl": 15, "sun": [0, 6, 9, 10], "sundai": [8, 10, 13], "suppli": 3, "support": [0, 5, 11, 12, 13, 15, 16, 18], "swim": [11, 13], "swimrun": [11, 13], "switch": 11, "synchron": 17, "syntax": 13, "syslog": 15, "syslogidentifi": 15, "system": [13, 15], "systemd": 15, "t": [0, 11, 18], "tab": 16, "tabl": 11, "taken": [0, 13], "tar": 15, "target": 15, "task": 15, "templat": 11, "term": [11, 15], "test": [11, 15], "textarea": 11, "than": [1, 3, 9, 11, 12, 13], "thank": [11, 15, 17], "thei": [10, 15, 16], "them": 14, "theme": [11, 13], "thi": [0, 3, 5, 10, 11, 12, 13, 14, 15], "third": [13, 14, 16], "those": 3, "thovi98": 11, "threshold": [0, 11, 13], "thu": 5, "thunderforest": [11, 15], "tile": [10, 11, 13], "tile_server_url": 15, "time": [0, 8, 10, 11, 13], "timeout": [15, 18], "timezon": [0, 9, 10, 11, 13], "titl": [10, 11, 13], "tl": [11, 15], "token": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 16], "token_typ": 5, "too": [0, 10], "tool": [11, 13, 14, 15], "tooltip": [11, 13], "total": [0, 3, 5, 8, 11, 13], "total_asc": [0, 8], "total_dist": [0, 3, 8, 9], "total_dur": [0, 3, 8, 9], "total_mov": 3, "total_workout": 8, "track": [11, 14], "tracke": 15, "trail": [11, 13], "trainer": [2, 13], "transport": [7, 11, 13], "traxi": 11, "trekk": [11, 13], "troubleshoot": 14, "true": [0, 1, 2, 3, 5, 7, 9, 11, 15], "truncat": 10, "try": [0, 2, 3, 7, 9, 10], "tue": 3, "two": 12, "type": [0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 15], "typescript": 15, "typo": 11, "u": 15, "uberspac": 15, "ubuntu": 15, "ui": 11, "ui_url": 15, "unauthor": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "uncom": 15, "under": [14, 15], "underscor": 0, "unencrypt": 15, "unfilt": 0, "uniqu": [5, 16], "unit": [0, 11, 15], "unless": 3, "unstabl": [14, 15], "up": [11, 13, 14], "updat": [0, 1, 2, 3, 7, 9, 10, 11, 13, 15], "upgrad": 11, "upload": [1, 11, 12, 13, 15, 17, 19], "upload_fold": [15, 18], "uploads_dir_s": 8, "uri": 11, "url": [0, 5, 11, 15, 16, 18], "urtzai": 11, "us": [0, 3, 5, 9, 10, 11, 12, 13, 14, 15, 16], "usag": [12, 15], "use_dark_mod": 0, "use_raw_gpx_spe": 0, "user": [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 15, 16], "user_id": [0, 3], "user_nam": [8, 9], "usernam": [0, 8, 9, 11, 12, 15], "util": 15, "uuid": [10, 11], "v0": [11, 15], "v3": 14, "valid": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 15, 18], "valu": [0, 3, 5, 6, 9, 10, 11, 13, 15, 16, 18], "variabl": [11, 18], "variou": 11, "venv": 15, "verifi": [5, 16], "version": [1, 12, 13, 15], "via": [13, 15], "view": [11, 13, 15], "virtual": [11, 13], "virtualenv": [11, 15], "visibl": [11, 13], "visual": [13, 15], "visualcross": 11, "vite_app_api_url": 15, "voodoopt": 11, "vue": [11, 15], "vue3": 15, "vue_app_api_url": 15, "vuex": 15, "w": 11, "wa": [0, 3, 11, 13, 15], "wai": [13, 15], "walk": [7, 11, 13], "want": 15, "wantedbi": 15, "warn": [11, 13], "water": [11, 13], "weather": [11, 13], "weather_api": 15, "weather_api_kei": 15, "weather_api_provid": [11, 15], "weather_end": 10, "weather_provid": 1, "weather_start": 10, "web": [0, 14, 15, 16], "weblat": [11, 13], "websit": 5, "wed": 0, "week": [0, 8, 11, 13], "weekend": 11, "weekm": [0, 8, 9], "were": 11, "wget": 15, "when": [0, 1, 10, 11, 12, 13, 15], "where": 15, "whether": 3, "which": [0, 13], "while": 13, "white": 11, "whose": 16, "wind": [11, 13], "window": 15, "with_gpx": 10, "without": [3, 5, 8, 9, 10, 11, 13, 14], "work": 15, "worker": [11, 12, 15, 18], "workers_process": 15, "workflow": 11, "workingdirectori": 15, "workout": [0, 1, 3, 4, 5, 6, 7, 8, 11, 14, 16, 17, 19], "workout_d": [0, 6, 9, 10], "workout_id": [0, 6, 9, 10], "workout_short_id": 10, "workouts_count": [3, 9], "write": [0, 1, 2, 3, 5, 7, 9, 10, 16], "written": 15, "www": [1, 15], "x": [0, 10, 11, 15, 16], "xmgz": 11, "xml": 10, "xxxx": 15, "xzf": 15, "y": [0, 8, 10, 15], "yarn": 15, "year": [8, 13], "yet": [11, 14], "you": [0, 1, 2, 3, 7, 8, 9, 10, 11, 13, 14, 15], "your": [9, 13, 14, 15], "yyyi": 0, "z": [10, 15], "zero": [0, 11, 13], "zip": [0, 1, 10, 11, 13], "zone": 0, "zoom": 10}, "titles": ["Authentication and account", "Configuration", "Equipment Types", "Equipments", "API documentation", "OAuth2", "Records", "Sports", "Statistics", "Users", "Workouts", "Change log", "Command line interface", "Features", "FitTrackee", "Installation", "OAuth 2.0", "Third-party tools", "Administrator", "Troubleshooting"], "titleterms": {"0": [11, 16], "01": 11, "02": 11, "03": 11, "04": 11, "05": 11, "06": 11, "07": 11, "08": 11, "09": 11, "1": 11, "10": 11, "11": 11, "12": 11, "13": 11, "14": 11, "15": 11, "16": 11, "17": 11, "18": 11, "19": 11, "2": [11, 16], "20": 11, "2018": 11, "2019": 11, "2020": 11, "2021": 11, "2022": 11, "2023": 11, "2024": 11, "21": 11, "22": 11, "23": 11, "24": 11, "25": 11, "26": 11, "27": 11, "28": 11, "29": 11, "3": 11, "30": 11, "31": 11, "32": 11, "4": 11, "5": 11, "6": 11, "7": 11, "8": 11, "9": 11, "account": [0, 13], "administr": [11, 13, 18], "api": [4, 15], "app": 13, "applic": 13, "ar": 18, "authent": 0, "avail": 11, "bug": 11, "chang": 11, "clean": 12, "clean_arch": 12, "clean_token": 12, "close": 11, "command": 12, "configur": 1, "content": 14, "creat": 12, "dashboard": 13, "data": 15, "databas": 12, "db": 12, "depend": 15, "deploy": 15, "detail": [13, 18], "dev": 15, "develop": 15, "displai": 18, "docker": 15, "document": [4, 11], "download": 18, "drop": 12, "email": 15, "endpoint": 4, "enhanc": 11, "environ": 15, "equip": [2, 3, 13], "export_arch": 12, "fail": 18, "featur": [11, 13], "file": 18, "first": 11, "fittracke": [11, 14, 18], "fix": 11, "flow": 16, "french": 11, "from": 15, "ftcli": 12, "i": 18, "imag": 18, "import": 17, "improv": 11, "instal": [15, 17], "interfac": 12, "issu": 11, "limit": 15, "line": 12, "list": 13, "log": 11, "main": 15, "map": [15, 18], "minor": 11, "misc": 11, "new": 11, "nixo": 15, "oauth": [13, 16], "oauth2": [5, 12], "parti": 17, "prefer": 13, "prerequisit": 15, "prod": 15, "product": 15, "pull": 11, "pypi": [11, 15], "rate": 15, "record": 6, "releas": 11, "request": 11, "resourc": 16, "scope": 16, "screenshot": 13, "script": 17, "secur": 11, "server": 15, "shown": 18, "sourc": 15, "sport": [7, 13], "start": 18, "statist": [8, 11, 13], "tabl": 14, "third": 17, "tile": 15, "tool": 17, "translat": [11, 13], "troubleshoot": 19, "type": [2, 13], "updat": 12, "upgrad": [12, 15], "upload": 18, "user": [9, 12, 13], "variabl": 15, "version": 11, "weather": 15, "workout": [10, 13, 18], "yunohost": 15}}) \ No newline at end of file +Search.setIndex({"alltitles": {"API documentation": [[6, null]], "API rate limits": [[20, "api-rate-limits"]], "About": [[18, "about"]], "Account & preferences": [[18, "account-preferences"]], "Administration": [[18, "administration"], [18, "id8"]], "Administrator": [[23, null]], "Application": [[18, "application"]], "Authentication and account": [[0, null]], "Bugs Fixed": [[16, "bugs-fixed"], [16, "id5"], [16, "id8"], [16, "id11"], [16, "id16"], [16, "id22"], [16, "id27"], [16, "id31"], [16, "id34"], [16, "id36"], [16, "id40"], [16, "id44"], [16, "id48"], [16, "id51"], [16, "id54"], [16, "id57"], [16, "id58"], [16, "id61"], [16, "id63"], [16, "id65"], [16, "id68"], [16, "id71"], [16, "id79"], [16, "id82"], [16, "id85"], [16, "id88"], [16, "id100"], [16, "id105"], [16, "id107"], [16, "id111"], [16, "id114"], [16, "id117"], [16, "id119"], [16, "id122"], [16, "id125"], [16, "id127"], [16, "id130"], [16, "id133"], [16, "id136"], [16, "id141"], [16, "id143"], [16, "id145"], [16, "id147"], [16, "id150"], [16, "id152"], [16, "id158"], [16, "id161"], [16, "id163"], [16, "id165"], [16, "id172"], [16, "id177"], [16, "id179"], [16, "id181"], [16, "id184"], [16, "id186"], [16, "id188"], [16, "id192"], [16, "id202"], [16, "id205"], [16, "id207"], [16, "id210"], [16, "id217"]], "Change log": [[16, null]], "Command line interface": [[17, null]], "Comments": [[1, null], [18, "comments"]], "Configuration": [[2, null], [18, "configuration"]], "Dashboard": [[18, "dashboard"], [18, "id4"]], "Database": [[17, "database"]], "Deployment": [[20, "deployment"]], "Dev environment": [[20, "dev-environment"], [20, "id5"]], "Development": [[20, "development"]], "Docker": [[20, "docker"]], "Documentation": [[16, "documentation"], [16, "id75"], [16, "id109"]], "Emails": [[20, "emails"]], "Endpoints:": [[6, null]], "Environment variables": [[20, "environment-variables"]], "Equipment Types": [[3, null], [18, "equipment-types"]], "Equipments": [[4, null], [18, "equipments"], [18, "id6"]], "Failed to upload or download files": [[23, "failed-to-upload-or-download-files"]], "Features": [[16, "features"], [16, "id129"], [16, "id139"], [16, "id149"], [18, null]], "Features and enhancements": [[16, "features-and-enhancements"], [16, "id4"], [16, "id10"], [16, "id15"], [16, "id19"], [16, "id26"], [16, "id30"], [16, "id39"], [16, "id43"], [16, "id47"], [16, "id50"], [16, "id67"], [16, "id70"], [16, "id78"], [16, "id81"], [16, "id87"], [16, "id92"], [16, "id94"], [16, "id96"], [16, "id99"], [16, "id110"], [16, "id116"]], "FitTrackee": [[19, null]], "FitTrackee fails to start": [[23, "fittrackee-fails-to-start"]], "Flow": [[21, "flow"]], "Follow requests": [[5, null]], "From PyPI": [[20, "from-pypi"], [20, "id3"]], "From sources": [[20, "from-sources"], [20, "id4"]], "Import tools": [[22, "import-tools"]], "Installation": [[20, null], [20, "id2"], [20, "id6"]], "Installation scripts": [[22, "installation-scripts"]], "Interactions": [[18, "interactions"]], "Issues Closed": [[16, "issues-closed"], [16, "id121"], [16, "id124"], [16, "id128"], [16, "id132"], [16, "id135"], [16, "id138"], [16, "id140"], [16, "id144"], [16, "id146"], [16, "id148"], [16, "id153"], [16, "id157"], [16, "id160"], [16, "id162"], [16, "id164"], [16, "id166"], [16, "id168"], [16, "id170"], [16, "id175"], [16, "id178"], [16, "id180"], [16, "id183"], [16, "id185"], [16, "id187"], [16, "id190"], [16, "id194"], [16, "id196"], [16, "id198"], [16, "id201"], [16, "id203"], [16, "id206"], [16, "id208"], [16, "id212"], [16, "id215"], [16, "id218"]], "Likes": [[18, "likes"]], "Main dependencies": [[20, "main-dependencies"]], "Map images are not displayed but map is shown in Workout detail": [[23, "map-images-are-not-displayed-but-map-is-shown-in-workout-detail"]], "Map tile server": [[20, "map-tile-server"]], "Misc": [[16, "misc"], [16, "id1"], [16, "id3"], [16, "id7"], [16, "id14"], [16, "id18"], [16, "id21"], [16, "id25"], [16, "id29"], [16, "id33"], [16, "id38"], [16, "id42"], [16, "id46"], [16, "id53"], [16, "id56"], [16, "id60"], [16, "id62"], [16, "id66"], [16, "id73"], [16, "id76"], [16, "id84"], [16, "id91"], [16, "id102"], [16, "id104"], [16, "id120"], [16, "id134"], [16, "id137"], [16, "id154"], [16, "id156"], [16, "id173"], [16, "id182"], [16, "id189"], [16, "id193"], [16, "id200"], [16, "id211"], [16, "id214"]], "Moderation": [[18, "moderation"]], "New Features": [[16, "new-features"], [16, "id167"], [16, "id169"], [16, "id171"], [16, "id176"], [16, "id191"], [16, "id195"], [16, "id197"], [16, "id199"], [16, "id204"], [16, "id209"], [16, "id213"], [16, "id216"], [16, "id219"]], "NixOS": [[20, "nixos"]], "Notifications": [[7, null], [18, "notifications"], [18, "id7"]], "OAuth 2.0": [[21, null]], "OAuth Apps": [[18, "oauth-apps"]], "OAuth2": [[8, null], [17, "oauth2"]], "Prerequisites": [[20, "prerequisites"]], "Privacy policy": [[18, "privacy-policy"]], "Prod environment": [[20, "prod-environment"]], "Production environment": [[20, "production-environment"]], "Pull Requests": [[16, "pull-requests"], [16, "id123"], [16, "id126"], [16, "id142"], [16, "id151"], [16, "id155"], [16, "id159"], [16, "id174"]], "Records": [[9, null]], "Reports": [[10, null]], "Resources": [[21, "resources"]], "Scopes": [[21, "scopes"]], "Screenshots": [[18, "screenshots"]], "Security": [[16, "security"]], "Sports": [[11, null], [18, "sports"], [18, "id3"]], "Statistics": [[12, null], [18, "statistics"], [18, "id5"]], "Table of contents": [[19, "table-of-contents"]], "Third-party tools": [[22, null]], "Timeline": [[13, null]], "Translations": [[16, "translations"], [16, "id2"], [16, "id6"], [16, "id9"], [16, "id12"], [16, "id13"], [16, "id17"], [16, "id20"], [16, "id23"], [16, "id24"], [16, "id28"], [16, "id32"], [16, "id35"], [16, "id37"], [16, "id41"], [16, "id45"], [16, "id49"], [16, "id52"], [16, "id55"], [16, "id59"], [16, "id64"], [16, "id69"], [16, "id72"], [16, "id74"], [16, "id77"], [16, "id80"], [16, "id83"], [16, "id86"], [16, "id89"], [16, "id90"], [16, "id93"], [16, "id95"], [16, "id97"], [16, "id98"], [16, "id101"], [16, "id103"], [16, "id106"], [16, "id108"], [16, "id112"], [16, "id113"], [16, "id115"], [16, "id118"], [16, "id131"], [18, "translations"]], "Troubleshooting": [[24, null]], "Upgrade": [[20, "upgrade"]], "Users": [[14, null], [17, "users"], [18, "users"], [18, "id2"]], "Users directory": [[18, "users-directory"]], "Version 0.1.0 - First release \ud83c\udf89 (2018-07-04)": [[16, "version-0-1-0-first-release-2018-07-04"]], "Version 0.1.1 - Fix and improvements (2019/02/07)": [[16, "version-0-1-1-fix-and-improvements-2019-02-07"]], "Version 0.2.0 - Statistics (2019/07/07)": [[16, "version-0-2-0-statistics-2019-07-07"]], "Version 0.2.1 - Fix and improvements (2019/09/01)": [[16, "version-0-2-1-fix-and-improvements-2019-09-01"]], "Version 0.2.2 - Statistics fix (2019/09/23)": [[16, "version-0-2-2-statistics-fix-2019-09-23"]], "Version 0.2.3 - FitTrackee available in French (2019/12/29)": [[16, "version-0-2-3-fittrackee-available-in-french-2019-12-29"]], "Version 0.2.4 - Minor fix (2020/01/30)": [[16, "version-0-2-4-minor-fix-2020-01-30"]], "Version 0.2.5 - Fix and improvements (2020/01/31)": [[16, "version-0-2-5-fix-and-improvements-2020-01-31"]], "Version 0.3.0 - Administration (2020/07/15)": [[16, "version-0-3-0-administration-2020-07-15"]], "Version 0.4.0 - FitTrackee on PyPI (2020/09/19)": [[16, "version-0-4-0-fittrackee-on-pypi-2020-09-19"]], "Version 0.4.1 (2020/12/31)": [[16, "version-0-4-1-2020-12-31"]], "Version 0.4.2 (2021/01/03)": [[16, "version-0-4-2-2021-01-03"]], "Version 0.4.3 (2021/01/10)": [[16, "version-0-4-3-2021-01-10"]], "Version 0.4.4 (2021/01/31)": [[16, "version-0-4-4-2021-01-31"]], "Version 0.4.5 (2021/02/17)": [[16, "version-0-4-5-2021-02-17"]], "Version 0.4.6 (2021/02/21)": [[16, "version-0-4-6-2021-02-21"]], "Version 0.4.7 (2021/04/07)": [[16, "version-0-4-7-2021-04-07"]], "Version 0.4.8 (2021/07/03)": [[16, "version-0-4-8-2021-07-03"]], "Version 0.4.9 (2021/07/16)": [[16, "version-0-4-9-2021-07-16"]], "Version 0.5.0 (2021/11/14)": [[16, "version-0-5-0-2021-11-14"]], "Version 0.5.1 (2021/11/30)": [[16, "version-0-5-1-2021-11-30"]], "Version 0.5.2 (2021/12/19)": [[16, "version-0-5-2-2021-12-19"]], "Version 0.5.3 (2022/01/01)": [[16, "version-0-5-3-2022-01-01"]], "Version 0.5.4 (2022/01/01)": [[16, "version-0-5-4-2022-01-01"]], "Version 0.5.5 (2022/01/19)": [[16, "version-0-5-5-2022-01-19"]], "Version 0.5.6 (2022/02/05)": [[16, "version-0-5-6-2022-02-05"]], "Version 0.5.7 (2022/02/13)": [[16, "version-0-5-7-2022-02-13"]], "Version 0.6.0 (2022/03/27)": [[16, "version-0-6-0-2022-03-27"]], "Version 0.6.1 (2022/03/27)": [[16, "version-0-6-1-2022-03-27"]], "Version 0.6.10 (2022/07/13)": [[16, "version-0-6-10-2022-07-13"]], "Version 0.6.11 (2022/07/27)": [[16, "version-0-6-11-2022-07-27"]], "Version 0.6.12 (2022/09/14)": [[16, "version-0-6-12-2022-09-14"]], "Version 0.6.2 (2022/04/03)": [[16, "version-0-6-2-2022-04-03"]], "Version 0.6.3 (2022/04/09)": [[16, "version-0-6-3-2022-04-09"]], "Version 0.6.4 (2022/04/23)": [[16, "version-0-6-4-2022-04-23"]], "Version 0.6.5 (2022/04/24)": [[16, "version-0-6-5-2022-04-24"]], "Version 0.6.6 (2022/05/29)": [[16, "version-0-6-6-2022-05-29"]], "Version 0.6.7 (2022/06/11)": [[16, "version-0-6-7-2022-06-11"]], "Version 0.6.8 (2022/06/22)": [[16, "version-0-6-8-2022-06-22"]], "Version 0.6.9 (2022/07/03)": [[16, "version-0-6-9-2022-07-03"]], "Version 0.7.0 (2022/09/19)": [[16, "version-0-7-0-2022-09-19"]], "Version 0.7.1 (2022/09/21)": [[16, "version-0-7-1-2022-09-21"]], "Version 0.7.10 (2022/12/21)": [[16, "version-0-7-10-2022-12-21"]], "Version 0.7.11 (2022/12/31)": [[16, "version-0-7-11-2022-12-31"]], "Version 0.7.12 (2023/02/16)": [[16, "version-0-7-12-2023-02-16"]], "Version 0.7.13 (2023/03/05)": [[16, "version-0-7-13-2023-03-05"]], "Version 0.7.14 (2023/03/08)": [[16, "version-0-7-14-2023-03-08"]], "Version 0.7.15 (2023/04/12)": [[16, "version-0-7-15-2023-04-12"]], "Version 0.7.16 (2023/05/29)": [[16, "version-0-7-16-2023-05-29"]], "Version 0.7.17 (2023/06/03)": [[16, "version-0-7-17-2023-06-03"]], "Version 0.7.18 (2023/06/25)": [[16, "version-0-7-18-2023-06-25"]], "Version 0.7.19 (2023/07/15)": [[16, "version-0-7-19-2023-07-15"]], "Version 0.7.2 (2022/09/21)": [[16, "version-0-7-2-2022-09-21"]], "Version 0.7.20 (2023/07/22)": [[16, "version-0-7-20-2023-07-22"]], "Version 0.7.21 (2023/07/30)": [[16, "version-0-7-21-2023-07-30"]], "Version 0.7.22 (2023/08/23)": [[16, "version-0-7-22-2023-08-23"]], "Version 0.7.23 (2023/09/14)": [[16, "version-0-7-23-2023-09-14"]], "Version 0.7.24 (2023/10/04)": [[16, "version-0-7-24-2023-10-04"]], "Version 0.7.25 (2023/10/08)": [[16, "version-0-7-25-2023-10-08"]], "Version 0.7.26 (2023/11/19)": [[16, "version-0-7-26-2023-11-19"]], "Version 0.7.27 (2023/12/20)": [[16, "version-0-7-27-2023-12-20"]], "Version 0.7.28 (2023/12/23)": [[16, "version-0-7-28-2023-12-23"]], "Version 0.7.29 (2024/01/06)": [[16, "version-0-7-29-2024-01-06"]], "Version 0.7.3 (2022/11/01)": [[16, "version-0-7-3-2022-11-01"]], "Version 0.7.30 (2024/02/04)": [[16, "version-0-7-30-2024-02-04"]], "Version 0.7.31 (2024/02/10)": [[16, "version-0-7-31-2024-02-10"]], "Version 0.7.32 (2024/03/10)": [[16, "version-0-7-32-2024-03-10"]], "Version 0.7.4 (2022/11/05)": [[16, "version-0-7-4-2022-11-05"]], "Version 0.7.5 (2022/11/09)": [[16, "version-0-7-5-2022-11-09"]], "Version 0.7.6 (2022/11/09)": [[16, "version-0-7-6-2022-11-09"]], "Version 0.7.7 (2022/11/27)": [[16, "version-0-7-7-2022-11-27"]], "Version 0.7.8 (2022/11/30)": [[16, "version-0-7-8-2022-11-30"]], "Version 0.7.9 (2022/12/11)": [[16, "version-0-7-9-2022-12-11"]], "Version 0.8.0 (2024/04/21)": [[16, "version-0-8-0-2024-04-21"]], "Version 0.8.1 (2024/05/01)": [[16, "version-0-8-1-2024-05-01"]], "Version 0.8.10 (2024/10/09)": [[16, "version-0-8-10-2024-10-09"]], "Version 0.8.11 (2024/10/30)": [[16, "version-0-8-11-2024-10-30"]], "Version 0.8.12 (2024/11/17)": [[16, "version-0-8-12-2024-11-17"]], "Version 0.8.2 (2024/05/08)": [[16, "version-0-8-2-2024-05-08"]], "Version 0.8.3 (2024/05/09)": [[16, "version-0-8-3-2024-05-09"]], "Version 0.8.4 (2024/05/22)": [[16, "version-0-8-4-2024-05-22"]], "Version 0.8.5 (2024/06/29)": [[16, "version-0-8-5-2024-06-29"]], "Version 0.8.6 (2024/08/03)": [[16, "version-0-8-6-2024-08-03"]], "Version 0.8.7 (2024/08/25)": [[16, "version-0-8-7-2024-08-25"]], "Version 0.8.8 (2024/09/01)": [[16, "version-0-8-8-2024-09-01"]], "Version 0.8.9 (2024/09/21)": [[16, "version-0-8-9-2024-09-21"]], "Weather data": [[20, "weather-data"]], "Workout detail": [[18, "workout-detail"]], "Workouts": [[15, null], [18, "workouts"], [18, "id1"]], "Workouts list": [[18, "workouts-list"]], "Yunohost": [[20, "yunohost"]], "ftcli db drop": [[17, "ftcli-db-drop"]], "ftcli db upgrade": [[17, "ftcli-db-upgrade"]], "ftcli oauth2 clean": [[17, "ftcli-oauth2-clean"]], "ftcli users clean_archives": [[17, "ftcli-users-clean-archives"]], "ftcli users clean_tokens": [[17, "ftcli-users-clean-tokens"]], "ftcli users create": [[17, "ftcli-users-create"]], "ftcli users export_archives": [[17, "ftcli-users-export-archives"]], "ftcli users update": [[17, "ftcli-users-update"]]}, "docnames": ["api/auth", "api/comments", "api/configuration", "api/equipment_types", "api/equipments", "api/follow_requests", "api/index", "api/notifications", "api/oauth2", "api/records", "api/reports", "api/sports", "api/stats", "api/timeline", "api/users", "api/workouts", "changelog", "cli", "features", "index", "installation", "oauth", "third_party_tools", "troubleshooting/administrator", "troubleshooting/index"], "envversion": {"sphinx": 62, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["api/auth.rst", "api/comments.rst", "api/configuration.rst", "api/equipment_types.rst", "api/equipments.rst", "api/follow_requests.rst", "api/index.rst", "api/notifications.rst", "api/oauth2.rst", "api/records.rst", "api/reports.rst", "api/sports.rst", "api/stats.rst", "api/timeline.rst", "api/users.rst", "api/workouts.rst", "changelog.md", "cli.rst", "features.rst", "index.rst", "installation.rst", "oauth.rst", "third_party_tools.rst", "troubleshooting/administrator.rst", "troubleshooting/index.rst"], "indexentries": {"api_rate_limits": [[20, "envvar-API_RATE_LIMITS", false]], "app_log": [[20, "envvar-APP_LOG", false]], "app_secret_key": [[20, "envvar-APP_SECRET_KEY", false]], "app_settings": [[20, "envvar-APP_SETTINGS", false]], "app_workers": [[20, "envvar-APP_WORKERS", false]], "database_disable_pooling": [[20, "envvar-DATABASE_DISABLE_POOLING", false]], "database_url": [[20, "envvar-DATABASE_URL", false]], "default_staticmap": [[20, "envvar-DEFAULT_STATICMAP", false]], "email_url": [[20, "envvar-EMAIL_URL", false]], "environment variable": [[20, "envvar-API_RATE_LIMITS", false], [20, "envvar-APP_LOG", false], [20, "envvar-APP_SECRET_KEY", false], [20, "envvar-APP_SETTINGS", false], [20, "envvar-APP_WORKERS", false], [20, "envvar-DATABASE_DISABLE_POOLING", false], [20, "envvar-DATABASE_URL", false], [20, "envvar-DEFAULT_STATICMAP", false], [20, "envvar-EMAIL_URL", false], [20, "envvar-FLASK_APP", false], [20, "envvar-HOST", false], [20, "envvar-MAP_ATTRIBUTION", false], [20, "envvar-PORT", false], [20, "envvar-REDIS_URL", false], [20, "envvar-SENDER_EMAIL", false], [20, "envvar-STATICMAP_SUBDOMAINS", false], [20, "envvar-TILE_SERVER_URL", false], [20, "envvar-UI_URL", false], [20, "envvar-UPLOAD_FOLDER", false], [20, "envvar-VITE_APP_API_URL", false], [20, "envvar-WEATHER_API_KEY", false], [20, "envvar-WEATHER_API_PROVIDER", false], [20, "envvar-WORKERS_PROCESSES", false]], "flask_app": [[20, "envvar-FLASK_APP", false]], "host": [[20, "envvar-HOST", false]], "map_attribution": [[20, "envvar-MAP_ATTRIBUTION", false]], "port": [[20, "envvar-PORT", false]], "redis_url": [[20, "envvar-REDIS_URL", false]], "sender_email": [[20, "envvar-SENDER_EMAIL", false]], "staticmap_subdomains": [[20, "envvar-STATICMAP_SUBDOMAINS", false]], "tile_server_url": [[20, "envvar-TILE_SERVER_URL", false]], "ui_url": [[20, "envvar-UI_URL", false]], "upload_folder": [[20, "envvar-UPLOAD_FOLDER", false]], "vite_app_api_url": [[20, "envvar-VITE_APP_API_URL", false]], "weather_api_key": [[20, "envvar-WEATHER_API_KEY", false]], "weather_api_provider \ud83c\udd95": [[20, "envvar-WEATHER_API_PROVIDER", false]], "workers_processes": [[20, "envvar-WORKERS_PROCESSES", false]]}, "objects": {"": [[10, 0, 1, "patch--api-appeals-(string-appeal_id)", "/api/appeals/(string:appeal_id)"], [0, 1, 1, "post--api-auth-account-confirm", "/api/auth/account/confirm"], [0, 2, 1, "get--api-auth-account-export", "/api/auth/account/export"], [0, 2, 1, "get--api-auth-account-export-(string-file_name)", "/api/auth/account/export/(string:file_name)"], [0, 1, 1, "post--api-auth-account-export-request", "/api/auth/account/export/request"], [0, 1, 1, "post--api-auth-account-privacy-policy", "/api/auth/account/privacy-policy"], [0, 1, 1, "post--api-auth-account-resend-confirmation", "/api/auth/account/resend-confirmation"], [0, 2, 1, "get--api-auth-account-sanctions-(string-action_short_id)", "/api/auth/account/sanctions/(string:action_short_id)"], [0, 1, 1, "post--api-auth-account-sanctions-(string-action_short_id)-appeal", "/api/auth/account/sanctions/(string:action_short_id)/appeal"], [0, 2, 1, "get--api-auth-account-suspension", "/api/auth/account/suspension"], [0, 1, 1, "post--api-auth-account-suspension-appeal", "/api/auth/account/suspension/appeal"], [0, 2, 1, "get--api-auth-blocked-users", "/api/auth/blocked-users"], [0, 1, 1, "post--api-auth-email-update", "/api/auth/email/update"], [0, 1, 1, "post--api-auth-login", "/api/auth/login"], [0, 1, 1, "post--api-auth-logout", "/api/auth/logout"], [0, 1, 1, "post--api-auth-password-reset-request", "/api/auth/password/reset-request"], [0, 1, 1, "post--api-auth-password-update", "/api/auth/password/update"], [0, 3, 1, "delete--api-auth-picture", "/api/auth/picture"], [0, 1, 1, "post--api-auth-picture", "/api/auth/picture"], [0, 2, 1, "get--api-auth-profile", "/api/auth/profile"], [0, 1, 1, "post--api-auth-profile-edit", "/api/auth/profile/edit"], [0, 0, 1, "patch--api-auth-profile-edit-account", "/api/auth/profile/edit/account"], [0, 1, 1, "post--api-auth-profile-edit-preferences", "/api/auth/profile/edit/preferences"], [0, 1, 1, "post--api-auth-profile-edit-sports", "/api/auth/profile/edit/sports"], [0, 3, 1, "delete--api-auth-profile-reset-sports-(sport_id)", "/api/auth/profile/reset/sports/(sport_id)"], [0, 1, 1, "post--api-auth-register", "/api/auth/register"], [1, 3, 1, "delete--api-comments-(string-comment_short_id)", "/api/comments/(string:comment_short_id)"], [1, 2, 1, "get--api-comments-(string-comment_short_id)", "/api/comments/(string:comment_short_id)"], [1, 0, 1, "patch--api-comments-(string-comment_short_id)", "/api/comments/(string:comment_short_id)"], [1, 1, 1, "post--api-comments-(string-comment_short_id)-like", "/api/comments/(string:comment_short_id)/like"], [1, 1, 1, "post--api-comments-(string-comment_short_id)-like-undo", "/api/comments/(string:comment_short_id)/like/undo"], [1, 1, 1, "post--api-comments-(string-comment_short_id)-suspension-appeal", "/api/comments/(string:comment_short_id)/suspension/appeal"], [2, 2, 1, "get--api-config", "/api/config"], [2, 0, 1, "patch--api-config", "/api/config"], [3, 2, 1, "get--api-equipment-types", "/api/equipment-types"], [3, 2, 1, "get--api-equipment-types-(int-equipment_type_id)", "/api/equipment-types/(int:equipment_type_id)"], [3, 0, 1, "patch--api-equipment-types-(int-equipment_type_id)", "/api/equipment-types/(int:equipment_type_id)"], [4, 2, 1, "get--api-equipments", "/api/equipments"], [4, 1, 1, "post--api-equipments", "/api/equipments"], [4, 3, 1, "delete--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [4, 2, 1, "get--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [4, 0, 1, "patch--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [4, 1, 1, "post--api-equipments-(string-equipment_short_id)-refresh", "/api/equipments/(string:equipment_short_id)/refresh"], [5, 2, 1, "get--api-follow-requests", "/api/follow-requests"], [5, 1, 1, "post--api-follow-requests-(user_name)-accept", "/api/follow-requests/(user_name)/accept"], [5, 1, 1, "post--api-follow-requests-(user_name)-reject", "/api/follow-requests/(user_name)/reject"], [7, 2, 1, "get--api-notifications", "/api/notifications"], [7, 0, 1, "patch--api-notifications-(int-notification_id)", "/api/notifications/(int:notification_id)"], [7, 1, 1, "post--api-notifications-mark-all-as-read", "/api/notifications/mark-all-as-read"], [7, 2, 1, "get--api-notifications-types", "/api/notifications/types"], [7, 2, 1, "get--api-notifications-unread", "/api/notifications/unread"], [8, 2, 1, "get--api-oauth-apps", "/api/oauth/apps"], [8, 1, 1, "post--api-oauth-apps", "/api/oauth/apps"], [8, 3, 1, "delete--api-oauth-apps-(int-client_id)", "/api/oauth/apps/(int:client_id)"], [8, 2, 1, "get--api-oauth-apps-(int-client_id)-by_id", "/api/oauth/apps/(int:client_id)/by_id"], [8, 1, 1, "post--api-oauth-apps-(int-client_id)-revoke", "/api/oauth/apps/(int:client_id)/revoke"], [8, 2, 1, "get--api-oauth-apps-(string-client_client_id)", "/api/oauth/apps/(string:client_client_id)"], [8, 1, 1, "post--api-oauth-authorize", "/api/oauth/authorize"], [8, 1, 1, "post--api-oauth-revoke", "/api/oauth/revoke"], [8, 1, 1, "post--api-oauth-token", "/api/oauth/token"], [2, 2, 1, "get--api-ping", "/api/ping"], [9, 2, 1, "get--api-records", "/api/records"], [10, 2, 1, "get--api-reports", "/api/reports"], [10, 1, 1, "post--api-reports", "/api/reports"], [10, 2, 1, "get--api-reports-(int-report_id)", "/api/reports/(int:report_id)"], [10, 0, 1, "patch--api-reports-(int-report_id)", "/api/reports/(int:report_id)"], [10, 1, 1, "post--api-reports-(int-report_id)-actions", "/api/reports/(int:report_id)/actions"], [10, 2, 1, "get--api-reports-unresolved", "/api/reports/unresolved"], [11, 2, 1, "get--api-sports", "/api/sports"], [11, 2, 1, "get--api-sports-(int-sport_id)", "/api/sports/(int:sport_id)"], [11, 0, 1, "patch--api-sports-(int-sport_id)", "/api/sports/(int:sport_id)"], [12, 2, 1, "get--api-stats-(user_name)-by_sport", "/api/stats/(user_name)/by_sport"], [12, 2, 1, "get--api-stats-(user_name)-by_time", "/api/stats/(user_name)/by_time"], [12, 2, 1, "get--api-stats-all", "/api/stats/all"], [13, 2, 1, "get--api-timeline", "/api/timeline"], [14, 2, 1, "get--api-users", "/api/users"], [14, 3, 1, "delete--api-users-(user_name)", "/api/users/(user_name)"], [14, 2, 1, "get--api-users-(user_name)", "/api/users/(user_name)"], [14, 0, 1, "patch--api-users-(user_name)", "/api/users/(user_name)"], [14, 1, 1, "post--api-users-(user_name)-block", "/api/users/(user_name)/block"], [14, 1, 1, "post--api-users-(user_name)-follow", "/api/users/(user_name)/follow"], [14, 2, 1, "get--api-users-(user_name)-followers", "/api/users/(user_name)/followers"], [14, 2, 1, "get--api-users-(user_name)-following", "/api/users/(user_name)/following"], [14, 2, 1, "get--api-users-(user_name)-picture", "/api/users/(user_name)/picture"], [14, 2, 1, "get--api-users-(user_name)-sanctions", "/api/users/(user_name)/sanctions"], [14, 1, 1, "post--api-users-(user_name)-unblock", "/api/users/(user_name)/unblock"], [14, 1, 1, "post--api-users-(user_name)-unfollow", "/api/users/(user_name)/unfollow"], [15, 2, 1, "get--api-workouts", "/api/workouts"], [15, 1, 1, "post--api-workouts", "/api/workouts"], [15, 3, 1, "delete--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [15, 0, 1, "patch--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)-chart_data", "/api/workouts/(string:workout_short_id)/chart_data"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)-chart_data-segment-(int-segment_id)", "/api/workouts/(string:workout_short_id)/chart_data/segment/(int:segment_id)"], [1, 2, 1, "get--api-workouts-(string-workout_short_id)-comments", "/api/workouts/(string:workout_short_id)/comments"], [1, 1, 1, "post--api-workouts-(string-workout_short_id)-comments", "/api/workouts/(string:workout_short_id)/comments"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)-gpx", "/api/workouts/(string:workout_short_id)/gpx"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)-gpx-download", "/api/workouts/(string:workout_short_id)/gpx/download"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)-gpx-segment-(int-segment_id)", "/api/workouts/(string:workout_short_id)/gpx/segment/(int:segment_id)"], [15, 1, 1, "post--api-workouts-(string-workout_short_id)-like", "/api/workouts/(string:workout_short_id)/like"], [15, 1, 1, "post--api-workouts-(string-workout_short_id)-like-undo", "/api/workouts/(string:workout_short_id)/like/undo"], [15, 1, 1, "post--api-workouts-(string-workout_short_id)-suspension-appeal", "/api/workouts/(string:workout_short_id)/suspension/appeal"], [15, 2, 1, "get--api-workouts-map-(map_id)", "/api/workouts/map/(map_id)"], [15, 1, 1, "post--api-workouts-no_gpx", "/api/workouts/no_gpx"], [20, 4, 1, "-", "API_RATE_LIMITS"], [20, 4, 1, "-", "APP_LOG"], [20, 4, 1, "-", "APP_SECRET_KEY"], [20, 4, 1, "-", "APP_SETTINGS"], [20, 4, 1, "-", "APP_WORKERS"], [20, 4, 1, "-", "DATABASE_DISABLE_POOLING"], [20, 4, 1, "-", "DATABASE_URL"], [20, 4, 1, "-", "DEFAULT_STATICMAP"], [20, 4, 1, "-", "EMAIL_URL"], [20, 4, 1, "-", "FLASK_APP"], [20, 4, 1, "-", "HOST"], [20, 4, 1, "-", "MAP_ATTRIBUTION"], [20, 4, 1, "-", "PORT"], [20, 4, 1, "-", "REDIS_URL"], [20, 4, 1, "-", "SENDER_EMAIL"], [20, 4, 1, "-", "STATICMAP_SUBDOMAINS"], [20, 4, 1, "-", "TILE_SERVER_URL"], [20, 4, 1, "-", "UI_URL"], [20, 4, 1, "-", "UPLOAD_FOLDER"], [20, 4, 1, "-", "VITE_APP_API_URL"], [20, 4, 1, "-", "WEATHER_API_KEY"], [20, 4, 1, "envvar-WEATHER_API_PROVIDER", "WEATHER_API_PROVIDER \ud83c\udd95"], [20, 4, 1, "-", "WORKERS_PROCESSES"]], "/api/workouts/map_tile/(s)/(z)/(x)/(y)": [[15, 2, 1, "get--api-workouts-map_tile-(s)-(z)-(x)-(y).png", "png"]]}, "objnames": {"0": ["http", "patch", "HTTP patch"], "1": ["http", "post", "HTTP post"], "2": ["http", "get", "HTTP get"], "3": ["http", "delete", "HTTP delete"], "4": ["std", "envvar", "environment variable"]}, "objtypes": {"0": "http:patch", "1": "http:post", "2": "http:get", "3": "http:delete", "4": "std:envvar"}, "terms": {"": [4, 15, 16, 18, 20], "0": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20], "00": [0, 4, 5, 9, 13, 14, 15], "000": 18, "000000": 0, "01": [0, 1, 9, 10, 12, 13, 14, 15], "02": [5, 14, 15], "03": [14, 15], "04": [0, 7, 10, 13, 14, 15], "06": [4, 7, 8, 12, 14], "0667062": 8, "06ba975": 16, "07": [0, 7, 9, 13, 14, 15], "075aeb9": 16, "08": [0, 4, 7, 9, 13, 14, 15], "09": [0, 7, 10, 14, 15], "0mb": [0, 15], "1": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 20], "10": [0, 1, 2, 4, 5, 7, 9, 13, 14, 15, 18, 20], "100": [15, 16, 18], "1000": 12, "10000": [2, 15], "101": [12, 16], "104": [15, 16], "1048576": 2, "10485760": 2, "106": 16, "109": 16, "10mb": 18, "11": [0, 9, 14, 15, 18, 20], "112": 16, "113": 16, "115": 16, "116": 16, "12": [0, 2, 9, 10, 14, 15, 20, 22], "121": 16, "123": 16, "1232004": 15, "12341": 12, "1234538": 15, "125": 16, "1267": 12, "127": [16, 20], "129": 16, "13": [0, 1, 9, 13, 14, 15, 17, 18, 20], "131": 16, "134": 16, "135": 16, "1375986": 16, "138": 16, "14": [0, 1, 8, 13, 14, 15], "140": 16, "145": 16, "146": 16, "149": 16, "15": [12, 15, 17, 18, 20], "150": 12, "151": 16, "152": 16, "155": 16, "156": [12, 16], "1563529507772": 15, "158": 15, "16": [1, 12, 14, 15, 18], "160": 16, "161": 16, "162": 16, "1658660147": 8, "167": 16, "169": 16, "17": [0, 5, 10, 13, 14, 15], "171": 16, "173": 16, "175": 16, "177": 16, "178": [12, 16], "18": [0, 9, 10, 14, 15, 18, 20], "180": 16, "19": [15, 18], "190": 16, "191": 16, "192": 16, "193": 16, "195": 16, "196": 16, "197": 16, "1m": 20, "1mb": [18, 20], "2": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20], "20": [14, 15, 18], "200": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20], "201": [0, 1, 4, 10, 15, 16], "2016": 15, "2017": [12, 15], "2018": [12, 13, 15], "2019": [0, 9, 12, 13, 14, 15], "2021": [5, 14], "2022": 8, "2023": [0, 4, 14, 20], "2024": [0, 1, 7, 10, 14, 15], "203": 12, "204": [0, 1, 4, 8, 14, 15], "208": [15, 16], "209": 16, "21": [4, 10], "210": 16, "212": 16, "213": 16, "22": [7, 15], "223": 16, "224": 16, "225": 16, "23": [15, 20], "230": 16, "231": [15, 16], "232": 16, "234": 15, "236": 16, "237": 16, "239": 16, "24": [1, 15, 20], "241": 16, "242": 16, "244": 16, "246": 16, "247": 16, "25": [10, 15, 20], "250": 16, "252": 16, "255": 15, "257": 16, "258": 16, "259": 16, "26": [15, 20], "260": 16, "261": 16, "264": 16, "265": 16, "266": 16, "26and": 20, "27": [0, 8, 10, 14, 15, 18], "270": 16, "271": 16, "273": 16, "274": 16, "275": 16, "278": 16, "279": [15, 16], "28": 4, "280": [15, 16], "282": [12, 16], "287": 16, "289": 16, "29": [4, 10], "290": 16, "2930": 15, "294": 16, "297": 16, "2bcff2e": 16, "2e1ee2c": 16, "2ordfncv6vprkfp3yrcyht": [1, 15], "2ukrviyshoakg8qsuknus4": 4, "2ule2hwhsnycs2vhbsikb9": 14, "3": [0, 1, 3, 11, 12, 13, 14, 15, 18, 20], "30": [0, 10, 12, 15], "300": 20, "3000": 20, "301": [16, 20], "304": 16, "305": 16, "307": 16, "308": 16, "31": [0, 14, 15, 18, 20], "310": 16, "314": 16, "315": 16, "318": 16, "319": 16, "32": 15, "320": 16, "323": 16, "328": 16, "329": 16, "33": [12, 16], "3320": 12, "333": 16, "338": 16, "34": [1, 16], "34614d5": 16, "35": [0, 7, 15, 16], "350": 16, "351": 16, "352": 16, "354": 16, "356": 16, "357": 16, "358": 16, "359": 16, "36": 16, "365": 16, "366": 16, "367": 16, "369": 16, "37": 16, "370": 16, "371": 16, "374": 16, "375": 16, "376": 16, "377": 16, "38": 10, "380": 16, "3821e37": 16, "384": 16, "386": 16, "388": 16, "39": 15, "390": 16, "391": 16, "393": 16, "394": 16, "395": 16, "397": 16, "398": 16, "399": 16, "3aread": 21, "3awrit": 21, "3b6fa25": 16, "3c8d9c2": 16, "3f": 20, "3rd": 21, "4": [0, 3, 11, 12, 13, 14, 15, 17, 18, 20], "40": 16, "400": [0, 1, 2, 3, 4, 5, 8, 10, 11, 12, 14, 15, 16], "401": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], "402": 16, "403": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "404": [0, 1, 3, 4, 5, 7, 8, 10, 11, 12, 14, 15, 16], "406": 16, "407": 16, "409": 16, "40and": 20, "41": [15, 16], "410": 16, "4109": 15, "411": 16, "413": [0, 15], "415": 16, "416": 16, "417": 16, "418": 16, "42": [15, 16], "421": 16, "422": 16, "426": 16, "427": [15, 16], "428": 16, "43": [0, 9, 13, 14, 15, 16], "431": 16, "433": 16, "435": 15, "436": 16, "438": 16, "44": [14, 15, 16], "441": 16, "443": 20, "444": 16, "449": 16, "45": [0, 1, 14, 15], "450": 16, "455": 16, "456": 16, "46": [12, 16], "464": 16, "465": 20, "468": 16, "469": 16, "47": [12, 15, 16], "471": 16, "472": 16, "473": 16, "474": 16, "475": 16, "476": 16, "477": 16, "478": [15, 16], "479": 16, "48": [5, 12, 14], "481": 16, "482": 16, "484": 16, "488": 16, "489": 16, "49": [0, 10, 14], "490": 16, "494": 16, "495": 16, "496": 16, "499": [15, 16], "4c3fc34": 16, "5": [3, 8, 11, 12, 14, 15, 17, 18, 20], "50": [0, 4, 5, 12, 14, 16, 20], "500": [0, 1, 2, 3, 4, 5, 7, 10, 11, 13, 14, 15, 16], "5000": 20, "502": 16, "504": 16, "506": 16, "507": 16, "5078118": 15, "5079733": 15, "508": 16, "51": [13, 15], "510": 16, "511": 16, "512": 16, "517587": 15, "51758b4": 16, "52": [1, 15, 16], "521": 16, "524": 16, "526": 16, "527": 16, "528": 16, "53": [8, 16], "530": 16, "531": 16, "532": 16, "533": 16, "534": 16, "536": 16, "537": 16, "538": 16, "54": 16, "540": 16, "542": 16, "543": 16, "5432": 20, "544": 16, "545": 16, "546": 16, "55": [14, 15], "550": 16, "551": 16, "555": 16, "556": 16, "557": 16, "558": 16, "56": [10, 16], "560627": 15, "563": 16, "564": 16, "565": 16, "566": 16, "57": [15, 16], "571": 16, "575": 16, "58": [0, 14, 16], "582": 16, "583": 16, "587": [16, 20], "588": 16, "59": [12, 14, 15, 16], "590": 16, "591": 16, "592": 16, "593": 16, "595": 16, "598": 16, "6": [0, 3, 4, 11, 14, 15, 17, 18, 20], "60": 16, "600": 16, "603": 16, "604": 16, "607": 16, "608": 16, "609": 16, "60e164d": 16, "61": 16, "610": 16, "612": 16, "613": 12, "614": 16, "616": 16, "617": 16, "618": 16, "62": 16, "620": 16, "621": [15, 16], "622": 16, "624": 16, "625": 16, "626": 16, "628": 16, "629": 16, "63": 15, "631": 16, "633": 16, "634": 16, "635": 16, "636": 16, "637": 16, "639": [16, 17], "64": 16, "640": 16, "645": 16, "651": 16, "652": 16, "66": 16, "67": [0, 12, 14], "6dxczvmrhkar72shuz9pwd": [0, 14], "6e215aa": 16, "6nvxvayoh9zkr8rmxhu54t": 14, "7": [15, 17, 18, 20, 21], "70": 16, "71": 16, "72": 16, "720": 0, "73": 16, "7380": 15, "74": 16, "75": 16, "7641": 12, "78": 12, "79": 16, "7pdujhcvhya4hv29jzqngg": 0, "8": [0, 2, 15, 17, 18, 20, 21], "80": [16, 20], "8025": 20, "81": 16, "82": 16, "83": 16, "84": 16, "85": 16, "864000": 8, "87": 16, "877fa0f": 16, "88": 16, "89": 16, "895": [0, 14], "8aa4cff": 16, "9": [0, 9, 14, 15, 17, 18, 20, 21], "90": 16, "91": 16, "92": 16, "924": 0, "93": 16, "93706": 15, "95": [12, 16], "97": [0, 9, 13, 14, 15, 16], "98": 16, "981933": 15, "99": [12, 16], "9960": 12, "A": [4, 14, 16, 17, 18, 20, 23], "AS": [0, 9, 13, 14, 15], "And": 16, "BY": 20, "For": [12, 15, 18, 20, 21], "If": [0, 4, 8, 12, 14, 15, 17, 18, 20, 21], "In": [16, 18], "It": [0, 1, 14, 16, 18, 19, 21], "NO": [0, 1, 4, 8, 14, 15], "NOT": [0, 3, 4, 8, 11, 15], "No": [0, 1, 4, 8, 14, 15, 16, 19], "Not": [0, 1, 3, 4, 5, 7, 8, 10, 11, 12, 14, 15], "OF": 20, "On": [18, 20], "One": 20, "The": [0, 4, 15, 16, 18, 20, 21, 23], "There": [18, 20], "To": [16, 20, 21], "WITH": 20, "With": [18, 20], "_": [0, 16], "__main__": 20, "_blank": 20, "a458f5f": 16, "aaron": 21, "abil": 16, "about": [2, 16, 20, 21], "absolut": [20, 23], "ac075ec36dc25dcc20c270d2005f0398": 15, "accept": [0, 5], "accepted_polici": 0, "accepted_privacy_polici": 0, "access": [0, 3, 4, 8, 11, 12, 14, 16, 18, 20, 21], "access_token": 8, "accord": 18, "account": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 20, 22], "action": [10, 16, 18], "action_short_id": 0, "action_typ": [0, 10, 14], "activ": [0, 3, 4, 11, 14, 16, 17, 18, 19, 20], "ad": [0, 4, 15, 16, 17, 18, 20, 21], "adapt": [18, 20], "add": [1, 14, 15, 16, 17, 18, 19, 20], "addit": [14, 16, 18, 20], "address": [0, 18, 20], "admin": [0, 2, 3, 5, 7, 9, 11, 12, 13, 14, 15, 16, 17, 18], "admin_contact": 2, "administr": [0, 1, 2, 3, 4, 7, 11, 13, 14, 15, 21, 24], "aff4d68": 16, "affect": [10, 16, 18], "after": [0, 8, 14, 16, 18, 20, 21], "again": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "agplv3": 16, "agre": [0, 16, 18], "alert": 16, "all": [3, 4, 7, 8, 9, 11, 12, 14, 15, 16, 18, 20], "allow": [0, 2, 4, 15, 16, 17, 18, 19, 20, 21], "along": 15, "alphanumer": [0, 16], "alpin": 18, "alreadi": [0, 4, 5, 10, 16, 17], "also": [16, 17, 18, 19, 20], "altern": 16, "although": 18, "altitud": [16, 18], "alwai": 20, "among": 16, "an": [0, 4, 8, 11, 14, 15, 16, 18, 20, 21, 23], "analysi": [15, 18], "analyz": 18, "android": 19, "ani": [4, 15, 20], "anoth": [0, 4, 18, 20], "antialia": 16, "anymor": 16, "anyon": 18, "apach": 19, "api": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21], "api_rate_limit": 20, "apikei": 20, "app": [0, 8, 16, 19, 20, 21], "app_log": 20, "app_secret_kei": 20, "app_set": 20, "app_work": 20, "appeal": [0, 1, 10, 14, 15, 18, 20], "appeal_id": 10, "appear": [0, 18], "appli": 17, "applic": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 19, 20, 21], "application_directori": 20, "approv": [0, 10, 14, 18], "apr": 15, "ar": [0, 4, 8, 9, 12, 15, 16, 18, 19, 20, 21, 22, 24], "archiv": [0, 2, 16, 17, 18, 20], "archive_rgjsr3fhr5yp": 0, "archive_rgjsr3fht295ywnqr5yp": 0, "archlinux": 20, "area": 16, "arg": [17, 20], "argument": [4, 17], "arrai": [0, 4, 8, 15], "arrow": [16, 18], "asc": [5, 7, 10, 14, 15], "ascent": [0, 9, 13, 15, 16, 18], "asset": 20, "assign": 18, "associ": [0, 4, 8, 15, 16, 18], "astridx": 16, "attribut": [16, 20], "auth": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 21], "auth_token": 0, "authent": [1, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 21], "authlib": [8, 20, 21], "author": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 20, 21], "authorization_cod": [8, 21], "autoescap": 16, "automat": [0, 18], "avail": [0, 9, 17, 18, 20, 21, 22], "ave_spe": [13, 15], "ave_speed_from": 15, "ave_speed_to": 15, "averag": [9, 12, 15, 16, 18], "average_asc": 12, "average_desc": 12, "average_dist": 12, "average_dur": 12, "average_spe": 12, "avoid": [16, 18], "awesom": 20, "axi": [15, 16, 18], "b": 20, "b1536fc": 16, "b29ed7a": 16, "b748459": 16, "b862a77": 16, "back": 17, "background": 16, "backup": 20, "backward": 20, "bad": [0, 1, 2, 3, 4, 5, 8, 10, 11, 12, 14, 15], "base": 20, "basqu": [16, 18], "bcc568e": 16, "bearer": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "becom": 16, "been": [10, 16, 19], "befor": [16, 18, 20], "begin": 20, "behind": 21, "being": 18, "below": [16, 20], "better": [16, 21], "between": [4, 16, 18], "bike": [3, 4, 11, 15, 16, 18], "bin": 20, "bio": [0, 5, 14], "biographi": 0, "birth": [0, 16], "birth_dat": [0, 5, 14], "bjornclauw": 16, "black": 16, "blacklist": [0, 17], "block": [0, 10, 14, 18], "blocked_us": 0, "boat": 18, "bodi": [16, 20, 21], "bokm\u00e5l": [16, 18], "boolean": [0, 2, 3, 4, 7, 8, 10, 11, 14, 17], "boosterl": 16, "bound": [13, 15], "brief": 4, "browser": [0, 16, 18], "build": [16, 20], "bulgarian": [16, 18], "button": 16, "by_id": 8, "by_sport": 12, "by_tim": 12, "byakurau": 16, "byte": 0, "c": [15, 20], "c88a515": 16, "calcul": [0, 12, 16, 18], "calendar": [16, 18], "callback": [8, 21], "can": [0, 1, 3, 4, 8, 10, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 23], "cannot": [4, 16, 18], "card": 16, "cart": 20, "case": [4, 15, 18, 20], "cb9d02f": 16, "cc": 20, "cc3fe1c": 16, "cc4287e": 16, "cd": 20, "challeng": [8, 21], "chang": [0, 4, 17, 18, 19, 20, 21], "changelog": 20, "charact": [0, 4, 15, 16, 20], "chart": [15, 16, 18, 20], "chart_data": 15, "check": [2, 11, 16, 20, 23], "check_workout": 11, "checkbox": 16, "choos": [16, 18], "chosen": 20, "ci": [16, 20], "cleanup": 16, "clear": 20, "cli": [14, 16, 17, 18, 20], "click": 16, "clickabl": 16, "client": [0, 8, 14, 16, 18, 20, 21], "client_client_id": 8, "client_descript": 8, "client_id": [8, 21], "client_max_body_s": [20, 23], "client_nam": 8, "client_secret": 8, "client_uri": 8, "clone": 20, "code": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 21], "code_challeng": [8, 21], "code_challenge_method": [8, 21], "code_verifi": 8, "color": [0, 11, 16, 18], "com": [0, 2, 8, 10, 14, 16, 20, 21], "come": 18, "comma": [16, 20], "command": [16, 18, 19, 20], "comment": [0, 6, 10, 15, 21], "comment_id": 10, "comment_short_id": 1, "comment_suspens": 10, "comment_unsuspens": 10, "complet": [0, 16, 18], "compos": 20, "comradekingu": 16, "config": [2, 16, 20, 23], "configur": [6, 16, 20, 21], "confirm": [0, 8, 16, 18, 20], "confusedalex": 16, "contact": [0, 1, 2, 3, 4, 7, 11, 13, 14, 15, 18], "contain": [16, 18, 20], "content": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18], "contribut": 16, "contributor": [2, 16, 20, 22], "control": [16, 18, 20], "coordin": 20, "copi": [2, 20], "copyright": [2, 20], "core": 16, "correctli": 16, "correspond": [0, 20], "could": 16, "countri": 18, "cp": 20, "creat": [0, 1, 4, 8, 10, 15, 16, 18, 20, 21], "create_app": 20, "created_at": [0, 1, 5, 7, 10, 14, 15], "creation": [0, 16, 18], "creation_d": [4, 13, 15], "creativecommon": 20, "credenti": [0, 20], "criteria": [10, 14, 15], "critic": 23, "cross": [8, 18, 20, 21], "current": [0, 4, 14, 16], "custom": [16, 18, 20], "cycl": [11, 15, 16, 18], "czech": [16, 18], "d": [0, 12, 15], "dai": [16, 17, 18, 20], "dan": 15, "danielsiersleben": 16, "dark": [0, 16, 18], "darkski": [16, 20], "dashboard": 16, "data": [0, 1, 2, 3, 4, 5, 8, 9, 11, 12, 13, 14, 15, 16, 18, 19, 21], "databas": [16, 18, 20, 23], "database_disable_pool": 20, "database_url": [16, 20, 23], "date": [0, 12, 15, 16, 18], "date_format": 0, "date_str": 0, "davidhenrythoreau": 16, "db": 20, "dd": 0, "de": [0, 3, 14, 20], "deactiv": [14, 18], "debian": [20, 22], "dec": [0, 1, 5, 7, 10, 14, 15], "default": [0, 4, 5, 7, 8, 10, 12, 13, 14, 15, 16, 18, 20], "default_equipment_id": 0, "default_for_sport_id": 4, "default_staticmap": [16, 20], "defin": [16, 18], "definit": 16, "delet": [0, 1, 4, 8, 14, 15, 16, 17, 18], "depend": [16, 18], "deploy": 16, "deprec": [16, 17], "desc": [5, 7, 10, 14, 15, 18], "descent": [13, 15, 16, 18], "describ": [10, 20], "descript": [4, 8, 13, 15, 16, 17, 18, 20], "detail": [14, 16, 20, 24], "detect": 16, "dev": 16, "develop": [16, 17, 19], "diagnost": 20, "dialect": 23, "differ": [14, 16, 18], "direct": [16, 18], "directli": 20, "directori": [0, 16, 20], "disabl": [0, 16, 17, 18, 20], "discontinu": 20, "discours": 18, "displai": [0, 15, 16, 17, 18, 19, 20, 21, 24], "display_asc": 0, "distanc": [0, 9, 13, 15, 16, 18], "distance_from": 15, "distance_to": 15, "dkm": 16, "do": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20], "docker": 16, "document": [19, 20, 21, 23], "doe": [0, 4, 5, 12, 14, 15, 16, 18], "don": 0, "dotenv": 16, "dotlambda": 16, "doubl": 15, "down": [16, 20], "download": [0, 15, 16, 18, 20, 24], "dperruso": 16, "dramatiq": [16, 17, 20], "drop": 16, "dropdown": [16, 18], "due": 16, "durat": [9, 13, 15, 16, 18], "duration_from": 15, "duration_to": 15, "dure": [0, 1, 4, 10, 15], "dutch": [16, 18], "e": 0, "e2": 16, "ea0ac99": 16, "each": 18, "easi": 16, "edit": [0, 16, 18], "electr": 18, "elev": [0, 15, 16, 18, 20], "els": 20, "email": [0, 2, 10, 14, 16, 17, 18, 23], "email_to_confirm": 0, "email_url": [20, 23], "empti": [15, 16, 17, 20], "en": [0, 17], "enabl": [0, 2, 14, 18, 20], "encod": [16, 20], "encount": 20, "encrypt": 18, "end": [12, 15], "endpoint": [0, 2, 3, 4, 8, 11, 14, 16, 20, 21], "engin": [16, 20, 23], "english": [0, 16, 17, 18], "enter": [16, 18], "entiti": [0, 15], "entri": [16, 18, 20], "entrypoint": 16, "enumer": 20, "env": [16, 20], "environ": [16, 17, 23], "equal": 2, "equip": [0, 6, 13, 15, 16, 21], "equipment_id": [0, 15], "equipment_short_id": 4, "equipment_typ": [3, 4], "equipment_type_id": [3, 4], "equipment_type_label": 4, "erral": 16, "error": [0, 1, 2, 3, 4, 5, 7, 8, 10, 11, 13, 14, 15, 16, 18, 20, 23], "escap": 15, "estim": 16, "eu": 16, "europ": 0, "evalu": [16, 20], "even": [16, 18], "event": 18, "ewm": 16, "exampl": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 19, 20, 21], "exc": 23, "exce": [0, 4, 15, 16], "exceed": 16, "except": [14, 18, 20, 23], "exchang": 21, "exclud": 18, "execstart": 20, "execut": 16, "exhaust": 19, "exist": [0, 4, 5, 7, 10, 12, 14, 15, 16, 18, 19, 20], "exit": [17, 20], "expect": [10, 20], "expir": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17], "expires_at": 8, "expires_in": 8, "explain": [0, 10], "export": [0, 16, 17, 18, 19, 20], "extens": [0, 15, 20], "extrem": 18, "f2aec30": 16, "f96dcef": 16, "fa33f4d996844a5c73ecd1ae24456ab8": 15, "fail": [16, 24], "fall": 17, "fallback": 0, "fals": [0, 1, 2, 3, 5, 7, 8, 10, 11, 13, 14, 15, 20], "farthest": [9, 16, 18], "fb10602": 16, "fd": [0, 9, 13, 14, 15], "featur": [19, 20], "fetch": [18, 21], "field": [14, 16], "file": [0, 2, 15, 16, 17, 18, 19, 20, 22, 24], "file_nam": 0, "file_s": 0, "filenam": 0, "filter": [10, 16, 18], "finish": 16, "first": [0, 4, 8, 18, 21], "first_nam": [0, 5, 14], "fit": [20, 22], "fitotrack": 19, "fittracke": [4, 8, 17, 18, 20, 21, 22, 24], "fittrackee_cli": 20, "fittrackee_host": 21, "fittrackee_instal": 22, "fittrackee_work": [16, 20], "fittrackee_ynh": 20, "fix": 20, "flake8": 16, "flask": [16, 20], "flask_app": 20, "flaticon": 20, "float": [0, 15], "fmstrat": 16, "folder": 20, "follow": [0, 1, 6, 7, 9, 10, 14, 15, 18, 20, 21, 22, 23], "follow_request": [5, 7], "followers_onli": [0, 1, 14, 15], "fond": 20, "footer": 16, "forbidden": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "forc": [4, 18], "forgeri": [8, 21], "fork": 20, "form": [0, 8, 15, 16], "format": [0, 12, 15, 16, 18], "forward": [20, 21], "found": [0, 1, 3, 4, 5, 7, 8, 10, 11, 12, 14, 15, 16], "fr": [0, 20], "frame": 12, "franc": 20, "freepik": 20, "french": 18, "fri": 15, "from": [4, 5, 7, 8, 12, 15, 16, 18, 19, 21, 22], "ft": 16, "ftcli": 20, "full": [16, 18], "fullchain": 20, "fullscreen": 16, "furo": 16, "galician": [16, 18], "gallegonovato": 16, "gard": 15, "garmin": 22, "gener": [8, 16, 17, 18, 20, 21], "german": [16, 18, 20], "get": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 21], "gif": 0, "git": 20, "github": 20, "given": [0, 8, 12, 18], "gl": 0, "gmt": [0, 1, 4, 5, 7, 8, 9, 10, 13, 14, 15], "gnu": 16, "gorgobacka": 16, "gp": [16, 18], "gpl": 19, "gpx": [0, 8, 15, 16, 18, 19, 20, 22], "gpx_limit_import": 2, "gpxpy": [0, 16, 18, 20], "grai": 16, "grammar": 16, "grant": [8, 21], "grant_typ": 8, "graph": [16, 18], "great": 1, "greater": [2, 16], "guid": 20, "gunicorn": [20, 23], "gz": 20, "gzip": 0, "h": [15, 18], "ha": [0, 1, 4, 9, 10, 11, 13, 14, 15, 16, 18, 19], "handl": [0, 16, 18, 20, 23], "happen": 18, "has_equip": 3, "has_next": [0, 5, 7, 8, 10, 14], "has_prev": [0, 5, 7, 8, 10, 14], "has_workout": 11, "have": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18], "he": 14, "header": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 21], "health": 2, "heavi": [19, 20], "help": [17, 20], "hexadecim": 0, "hgzyfxgvwkcepdq3vyk67q": 15, "hi": [0, 4, 14, 16, 18], "hidden": [14, 16, 18], "hide": [16, 18], "hide_profile_in_users_directori": 0, "higher": 20, "highest": [0, 9, 18], "hike": [11, 16, 18], "histor": 20, "home": 20, "host": [16, 20], "hour": 20, "hourli": 20, "how": 20, "href": [2, 20], "html": 16, "http": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 21], "http2": 20, "hv9kwvdtbhhyfvml7phovq": 10, "hvybqybra7wwxpastwr4v2": [0, 9, 13, 14, 15], "i": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 24], "i18n": 16, "icon": [16, 20], "id": [0, 1, 3, 4, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 21], "imag": [0, 14, 15, 16, 18, 20, 24], "impact": 18, "imperi": [0, 16, 18], "imperial_unit": 0, "implement": [16, 21], "import": [8, 16, 18, 20], "in_progress": 0, "inact": [0, 4, 14, 15, 18, 20], "includ": 16, "incompat": 20, "incomplet": 17, "inconsist": 16, "incorrect": [4, 16, 18], "increas": 23, "index": 15, "indic": 18, "individu": 18, "info": [0, 16, 18], "inform": [0, 2, 16, 18, 19, 20], "init": 16, "initi": [16, 20], "initialis": 16, "input": 16, "insensit": [15, 20], "insid": 20, "instal": [16, 19], "instanc": [2, 8, 16, 18, 20, 21], "instead": [16, 18], "instruct": [0, 16, 18, 20], "int": [0, 3, 4, 7, 8, 10, 11, 15], "integ": [0, 2, 3, 4, 5, 7, 8, 10, 11, 12, 13, 14, 15], "interact": 21, "interceptor": 16, "interfac": [0, 16, 18, 19, 20], "intern": [0, 1, 2, 3, 4, 5, 7, 10, 11, 13, 14, 15], "introduc": 16, "invalid": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20], "invalidemailurlschem": 23, "ip": 20, "is_act": [0, 3, 4, 10, 11, 14], "is_active_for_us": 11, "is_email_sending_en": 2, "is_followed_bi": [0, 7, 10, 14], "is_registration_en": 2, "is_reported_user_warn": 10, "iso": 17, "isort": 16, "issu": [8, 18, 19, 20], "issued_at": 8, "italian": [16, 18], "item": 4, "its": [18, 20], "j": [15, 16, 20], "jan": [13, 15], "jat255": 16, "javascript": [16, 20], "jderuit": 16, "jinja": 16, "jmlich": 16, "john_do": 14, "johndo": 14, "jpeg": 14, "jpg": 0, "json": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18], "jul": [0, 9, 13, 14, 15], "juli": 8, "jwt": 20, "kayak": 18, "kayak_boat": 3, "kcj6hdgqqpkaakqmfqj8jv": 14, "kd5wyhwltvozw6o3au5m4j": 15, "keep": [16, 18, 19, 20], "kei": [16, 18, 20], "keyboard": 16, "kjxavsturjvoah2wvcegef": [13, 15], "km": [15, 18], "koen": 16, "komoot": 20, "label": [3, 4, 11, 16, 18], "lang": [0, 16, 17], "languag": [0, 16, 17, 18], "larg": [0, 15, 18, 20], "larger": [20, 23], "last": [0, 16, 20], "last_nam": [0, 5, 14], "lastli": 16, "latitud": 15, "launch": 16, "lavoi": 16, "layer": [16, 20], "layout": 16, "ld": [0, 9, 13, 14, 15], "le": 15, "leaflet": [15, 20], "least": [7, 10, 13, 15], "legal": 20, "legitim": 16, "length": 15, "less": [4, 16], "let": 16, "letter": 17, "level": [1, 18], "librari": [8, 16, 20, 21], "licenc": 20, "licens": [16, 19, 20], "lift": [18, 20], "light": 18, "like": [1, 15, 20], "likes_count": [1, 15], "limit": [4, 16, 18], "line": [18, 19, 20], "link": [16, 20], "lint": 20, "linux": 20, "list": [8, 16, 19, 20], "listen": 20, "ll": 20, "load": [16, 23], "loader": 16, "local": [0, 16, 19, 20], "localhost": [16, 20], "locat": [0, 5, 14, 20], "lock": 18, "log": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20, 23], "logfil": 20, "login": [0, 16], "logo": 20, "logout": [0, 16], "longer": [4, 16, 18, 20, 23], "longest": [9, 16, 18], "longitud": 15, "lower": 16, "lukasitali": 16, "m": [0, 9, 12, 13, 14, 15], "made": 20, "mai": [16, 18, 19, 20], "mailhog": 20, "mainten": 20, "major": 16, "make": [16, 20], "makefil": 20, "malform": 0, "manag": [16, 17, 18, 20], "mandatori": [4, 8, 10, 12, 15, 16, 17, 20, 21], "manual": 18, "manually_approves_follow": 0, "map": [0, 13, 15, 16, 18, 19, 24], "map_attribut": [2, 20], "map_id": 15, "map_til": 15, "map_vis": [0, 14, 15], "mar": [0, 4, 14], "mara21": 16, "march": [16, 20], "mariusz": 16, "mariuz": 16, "mark": [7, 18], "markdown": [16, 18], "marked_as_read": 7, "marker": 16, "match": [0, 15, 16, 18], "max": [2, 5, 14, 15, 16, 17], "max_alt": [13, 15], "max_single_file_s": 2, "max_spe": [13, 15], "max_speed_from": 15, "max_speed_to": 15, "max_us": 2, "max_zip_file_s": 2, "maxim": 15, "maximum": [9, 15, 16, 17, 18], "md": 16, "measur": 16, "mention": [1, 7, 18, 20], "menu": 16, "messag": [0, 2, 5, 8, 14, 15, 16, 17, 18, 20], "method": [8, 20, 21], "metric": 18, "mi": 16, "microsecond": 16, "migrat": [16, 17, 20], "min": 16, "min_alt": [13, 15], "mind": 18, "minim": [15, 21], "minimum": [2, 3, 10, 11, 12, 14, 16, 18], "minut": 20, "miss": [0, 16], "mm": 0, "mmm": 0, "mmy3qpl3vcfukjgffbncjv": 0, "mobil": [16, 19], "modal": 16, "mode": [0, 16], "model": 16, "moder": [10, 12, 14, 17], "modifi": [0, 16, 17, 18, 20], "modification_d": [1, 13, 15], "modul": [16, 20], "moment": 18, "mon": [13, 15], "mondai": [0, 12, 18], "mondstern": 16, "month": [12, 16, 18], "monthli": 18, "more": [16, 17, 18, 19, 20], "morn": 15, "most": 21, "mountain": [11, 16, 18], "mous": 16, "move": [13, 15, 16], "movement": 16, "multi": 20, "multipart": [0, 8, 15], "multipl": [16, 18], "must": [0, 2, 4, 8, 11, 14, 15, 16, 18, 20, 21, 23], "mv": 20, "my": 4, "mynixo": 20, "mzydicyyfktg3gga2x8afu": 1, "n": 0, "name": [0, 5, 8, 14, 16, 18, 20], "nano": 20, "navig": 16, "nb": 0, "nb_sport": [0, 5, 14], "nb_workout": [0, 1, 5, 7, 10, 14, 15], "nbsp": 20, "necessari": [18, 20], "nederland": 16, "need": [16, 18, 20, 21], "net": 20, "netinstal": 22, "network": 20, "new": [0, 4, 14, 17, 18, 20, 21], "new_email": 14, "new_password": 0, "newli": [0, 17, 20], "next": [16, 18], "next_workout": [13, 15], "nginx": [16, 18, 20, 21, 23], "nice": 1, "nixpkg": 20, "nl": 0, "no_gpx": 15, "node": 20, "nofollow": 20, "non": [3, 11, 19], "none": 15, "noopen": 20, "noreferr": 20, "norwegian": [16, 18], "nosuchmoduleerror": 23, "notat": 20, "note": [0, 10, 13, 15, 16, 18, 20], "notif": [6, 20, 21], "notification_id": 7, "notification_typ": 7, "nov": [1, 15], "now": [0, 12, 15, 16, 17, 18, 20], "null": [0, 1, 2, 4, 5, 7, 10, 11, 13, 14, 15, 16], "number": [2, 5, 14, 15, 16, 17, 18, 20], "nuv9cy8vqonrqkhtz5pqaq2zw7msh0mornpjr14amswd6f6i": 8, "o": 20, "o22a27s2abpuoxjbxv3ujdox": 8, "oauth": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 19, 20], "oauth2": [6, 21], "oauthlib": 21, "object": [0, 1, 2, 3, 4, 7, 10, 11, 14, 15], "object_id": 10, "object_typ": 10, "occur": 18, "office365": 20, "offset": 16, "ok": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "old": 20, "omit": 20, "onc": [0, 1, 15], "ondrejzivni": 16, "one": [0, 4, 7, 10, 13, 15, 18], "ongo": 0, "onli": [0, 1, 4, 8, 12, 14, 15, 16, 17, 18, 20, 21], "open": [16, 18, 19, 20], "openstreetmap": [2, 16, 20], "opentrack": 19, "oper": [18, 20], "option": [4, 7, 8, 16, 17, 20, 21], "order": [5, 7, 10, 14, 15, 16, 18, 21], "order_bi": [10, 14, 15], "org": [2, 20], "origin": 18, "osm": 20, "osmfr": 20, "other": [0, 4, 14, 18, 20], "otherwis": [15, 18, 20], "ou": 16, "out": 0, "outdoor": [16, 18, 19, 20], "over": 16, "overlap": 16, "overrid": 18, "overridden": 18, "overwrit": 20, "own": [4, 10, 14, 19], "owner": [4, 14, 16, 17, 18, 20], "packag": [16, 20], "paf38": 16, "page": [0, 5, 7, 8, 10, 13, 14, 15, 16, 18], "pagin": [0, 5, 7, 8, 10, 13, 14, 15], "par": [16, 20], "par_pag": 14, "paraglid": [16, 18], "parallel": 16, "paramet": [0, 1, 3, 4, 5, 7, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21], "parecki": 21, "pari": 0, "pars": [16, 20], "part": [0, 15], "parti": [8, 18, 19, 21], "partial": 16, "particular": 18, "pass": 20, "password": [0, 14, 16, 17, 18, 20], "passwordwith": 20, "patch": [0, 1, 2, 3, 4, 7, 10, 11, 14, 15], "path": [20, 23], "paus": [13, 15, 16], "payload": [0, 1, 2, 3, 8, 10, 11, 14, 15], "pem": 20, "pend": 7, "per": [5, 14, 15, 16, 20], "per_pag": [5, 14, 15], "perform": 18, "perhap": 4, "period": [12, 18, 20], "permiss": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "pg_dump": 20, "pictur": [0, 1, 5, 7, 10, 14, 15, 16], "piec": [4, 18], "pil": 16, "ping": 2, "pip": 20, "pipenv": 16, "pkce": [8, 21], "pleas": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18], "plot": 0, "plugin": 23, "pluja": 16, "png": [0, 15, 20], "poetri": [16, 20], "point": [16, 20], "polici": [0, 2, 16], "polish": [16, 18], "pong": 2, "pool": 20, "port": 20, "portugues": [16, 18], "posit": [16, 18], "possibl": [16, 18, 19, 20], "post": [0, 1, 4, 5, 7, 8, 10, 14, 15, 21], "postgr": [16, 23], "postgresql": [16, 20, 23], "postgresql10": 16, "pr": 16, "pre": 16, "prefer": [0, 14, 16, 17], "prepar": 16, "present": [16, 18], "prevent": [8, 20, 21], "previous": 16, "previous_workout": [13, 15], "prior": 18, "privaci": [0, 2, 16], "privacy_polici": 2, "privacy_policy_d": 2, "privat": [0, 1, 14, 15, 16, 18], "privileg": 20, "privkei": 20, "problem": 16, "process": [0, 5, 10, 16, 17, 20], "product": 16, "productionconfig": 20, "profil": [0, 8, 10, 16, 18, 21], "project": 20, "proto": [20, 21], "provid": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 21, 23], "proxi": [20, 21], "proxy_add_x_forwarded_for": 20, "proxy_pass": 20, "proxy_redirect": 20, "proxy_set_head": [20, 21], "psjeexbjz2jjnqctcpxvvf": 15, "public": [0, 1, 15, 18], "pull": 20, "purpos": [16, 20], "pwd": 20, "py": 20, "python": [16, 20, 21], "q": 14, "qrj7by6h2iyjsv8sersfgv": 4, "queri": [0, 4, 5, 7, 8, 10, 11, 12, 13, 14, 15], "queue": 20, "quot": 15, "qwerty287": 16, "r": 20, "random": 17, "randomli": 20, "rate": 16, "reach": 18, "reactiv": [10, 18], "read": [0, 1, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 21], "read_statu": 7, "readi": 20, "readm": 16, "real": 20, "reason": [0, 10, 14], "rebuild": 16, "recalcul": 18, "receiv": [5, 7, 18], "recent": 18, "recommend": [8, 20, 21], "record": [0, 5, 6, 13, 14, 15, 16, 18], "record_typ": [0, 9, 13, 14, 15], "redi": [16, 17, 20], "redirect": [8, 16, 21], "redirect_uri": 8, "redis_url": 20, "reduc": 16, "refacto": 16, "refactor": 16, "refresh": [4, 8, 16], "refresh_token": 8, "regardless": [14, 18], "regist": [0, 2, 16, 18, 20], "registr": [0, 2, 16, 17, 18, 20, 21], "reject": [5, 10, 18, 20], "rel": 20, "relat": [17, 18, 20], "relationship": 14, "releas": [18, 20], "relev": 16, "remain": [16, 18], "remote_addr": 20, "remov": [1, 4, 14, 15, 16, 17, 18, 20], "renam": 16, "replac": [15, 16, 20], "repo": 20, "report": [6, 14, 16, 18, 21], "report_act": 10, "report_id": [10, 14], "reported_bi": 10, "reported_com": 10, "reported_us": 10, "reported_workout": 10, "repositori": 20, "request": [0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 20, 21], "request_uri": 20, "requir": [0, 4, 16, 20, 21], "requisit": 16, "resend": 0, "resent": 0, "reset": [0, 14, 16, 17, 18, 20], "reset_password": 14, "resolut": 16, "resolv": [10, 18], "resolved_at": 10, "resolved_bi": 10, "respons": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], "response_typ": [8, 21], "rest": 21, "restart": 20, "restartsec": 20, "restrict": 18, "result": 16, "return": [0, 1, 7, 8, 9, 10, 12, 13, 14, 15, 16, 20], "review": [16, 18], "revok": [0, 8], "rework": 16, "ride": 16, "right": [14, 16, 17, 18, 21], "roehv64thcg28wcewzhrnvlusoduvw8nvnhkcml57": 8, "role": [0, 1, 2, 3, 7, 10, 11, 12, 14, 15, 17, 18, 20], "rout": [16, 20], "row": 18, "ruff": 16, "rule": 18, "run": [11, 16, 17, 18, 20, 21], "runner": 19, "russian": [16, 18], "s256": [8, 21], "sa": 20, "sam": [0, 1, 5, 9, 10, 13, 14, 15], "same": [4, 16, 18], "samr1": 20, "sanction": [0, 14, 18], "sanit": 16, "sat": 14, "save": [1, 4, 10, 16, 18], "schema": 20, "scheme": [20, 21], "scope": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "screen": [16, 18], "script": 16, "search": 16, "second": [4, 15], "secret": [8, 20, 21], "section": 16, "secur": 21, "see": [4, 16, 18, 19, 20, 21, 23], "seem": 16, "segment": [13, 15, 16, 18], "segment_id": 15, "select": [0, 15, 18], "semant": 20, "send": [0, 14, 16, 18, 20], "sender": 20, "sender_email": 20, "sent": [14, 16, 18, 20, 21], "separ": [18, 20, 21], "serv": [16, 20], "server": [0, 1, 2, 3, 4, 5, 7, 10, 11, 13, 14, 15, 16, 18, 19], "server_nam": 20, "servic": [16, 20], "session": [18, 21], "set": [4, 14, 16, 17, 18, 20, 21, 23], "sever": [16, 19, 20], "sh": 22, "shell": 20, "shoe": [3, 4, 18], "short": [1, 4, 15], "should": [16, 18, 20], "show": [16, 17, 20], "shown": [16, 21, 24], "shura0": 16, "shut": [16, 20], "side": 16, "signatur": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "sikmir": 16, "simontb": 16, "simpl": 20, "simplest": 20, "simplifi": [16, 21], "sinc": [16, 20, 21], "singl": [2, 14, 16, 18, 20], "site": [8, 21], "size": [0, 2, 15, 16, 18, 20], "ski": [3, 18], "skylan0916": 16, "slothj": 16, "slow": 16, "small": 16, "smtp": [16, 20], "snowsho": [3, 16, 18], "so": [20, 21], "some": [4, 5, 7, 10, 13, 14, 15, 16, 18, 19, 20, 21], "sorri": 0, "sort": [5, 7, 10, 14, 15, 18], "sou": 20, "sourc": 18, "space": 21, "spanish": [16, 18], "spawn": 20, "special": [16, 20], "specif": [18, 20], "specifi": 16, "speed": [0, 9, 15, 16, 18, 20], "spinner": 16, "sport": [0, 2, 4, 6, 12, 15, 16, 20], "sport_id": [0, 4, 9, 11, 12, 13, 14, 15], "sport_label": 4, "sports_list": [0, 5, 14], "sql": 20, "sqlalchemi": [16, 20, 23], "ssl": 20, "ssl_certif": 20, "ssl_certificate_kei": 20, "standard": [16, 20], "standarderror": 20, "standardoutput": 20, "start": [0, 12, 15, 16, 18, 20, 24], "start_elevation_at_zero": 0, "startlimitintervalsec": 20, "starttl": 20, "stat": [12, 16], "state": [8, 21], "static": [16, 18, 20], "staticmap": 20, "staticmap_subdomain": [16, 20], "statist": [2, 6], "stats_workouts_limit": 2, "statu": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], "step": 20, "sticki": 16, "still": [16, 18, 19, 20], "stop": [0, 16, 18, 20], "stopped_speed_threshold": [0, 11], "store": [18, 19, 21], "strategi": 20, "strava": 22, "street": [18, 19], "strength": 16, "string": [0, 1, 2, 4, 5, 7, 8, 10, 11, 12, 14, 15, 20, 21], "strong": 20, "subdomain": [15, 16, 20], "subject": 20, "success": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "successfulli": 0, "suggest": 18, "suitabl": 20, "sun": [0, 1, 9, 10, 13, 14, 15], "sundai": [12, 15, 18], "suppli": 4, "support": [0, 8, 16, 17, 18, 20, 21, 23], "suspend": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18], "suspended_at": [0, 1, 7, 10, 14, 15], "suspens": [0, 1, 14, 15, 18, 20], "swim": [16, 18], "swimrun": [16, 18], "switch": 16, "synchron": 22, "syntax": 18, "syslog": 20, "syslogidentifi": 20, "system": [18, 20], "systemd": 20, "t": [0, 16, 23], "t2zeeuxvuy3pla8meeufyk": 1, "tab": 21, "tabl": 16, "taken": [0, 18], "tar": 20, "target": 20, "task": 20, "team": 18, "templat": 16, "term": [16, 20], "test": [16, 20], "text": [0, 1, 10, 14, 15, 18], "text_html": 1, "text_vis": 1, "textarea": 16, "than": [2, 4, 14, 16, 17], "thank": [16, 20, 22], "thei": [15, 18, 20, 21], "them": [18, 19], "theme": [16, 18], "thi": [0, 3, 4, 8, 11, 14, 15, 16, 17, 18, 19, 20], "third": [18, 19, 21], "those": 4, "thovi98": 16, "threshold": [0, 16, 18], "thu": [5, 8, 14], "thunderforest": [16, 20], "tile": [15, 16, 18], "tile_server_url": 20, "time": [0, 12, 15, 16, 18], "timelin": [6, 18], "timeout": [20, 23], "timezon": [0, 15, 16, 18], "titl": [13, 15, 16, 18], "tl": [16, 20], "token": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 21], "token_typ": 8, "too": [0, 15], "tool": [16, 18, 19, 20], "tooltip": [16, 18], "total": [0, 4, 5, 7, 8, 10, 12, 14, 16, 18], "total_asc": [0, 12], "total_dist": [0, 4, 5, 12, 14], "total_dur": [0, 4, 5, 12, 14], "total_mov": 4, "total_workout": 12, "track": [16, 19], "tracke": 20, "trail": [16, 18], "trainer": [3, 18], "transport": [11, 16, 18], "traxi": 16, "trekk": [16, 18], "troubleshoot": 19, "true": [0, 1, 2, 3, 4, 7, 8, 10, 11, 14, 15, 16, 20], "truncat": 15, "try": [0, 1, 3, 4, 7, 11, 13, 14, 15], "tue": [4, 15], "two": 17, "type": [0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20], "typescript": 20, "typo": 16, "u": 20, "uberspac": 20, "ubuntu": 20, "ui": 16, "ui_url": 20, "unauthent": 18, "unauthor": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "unblock": 14, "unchang": 18, "uncom": 20, "under": [19, 20], "underscor": 0, "undo": [1, 14, 15], "unencrypt": 20, "unfilt": 0, "unfollow": [14, 18], "uniqu": [8, 21], "unit": [0, 16, 20], "unless": 4, "unlik": 18, "unread": [7, 18], "unresolv": [10, 18], "unstabl": [19, 20], "unwant": 18, "up": [16, 18, 19], "updat": [0, 1, 2, 3, 4, 7, 10, 11, 14, 15, 16, 18, 20], "updated_at": [0, 10, 14], "upgrad": 16, "upload": [2, 16, 17, 18, 20, 22, 24], "upload_fold": [20, 23], "uploads_dir_s": 12, "uri": 16, "url": [0, 8, 16, 18, 20, 21, 23], "urtzai": 16, "us": [0, 4, 5, 7, 8, 10, 13, 14, 15, 16, 17, 18, 19, 20, 21], "usag": [17, 20], "use_dark_mod": 0, "use_raw_gpx_spe": 0, "user": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 20, 21], "user_id": [0, 4], "user_nam": [5, 12, 14], "user_suspens": [0, 10], "user_unsuspens": 10, "user_warn": 10, "usernam": [0, 1, 5, 7, 10, 12, 14, 15, 16, 17, 18, 20], "util": 20, "uuid": [15, 16], "v0": [16, 20], "v3": 19, "valid": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20, 23], "valu": [0, 4, 8, 9, 10, 13, 14, 15, 16, 18, 20, 21, 23], "variabl": [16, 23], "variou": 16, "venv": 20, "verifi": [8, 21], "version": [2, 17, 18, 20, 21], "via": [14, 18, 20], "view": [16, 18, 20], "violat": 18, "virtual": [16, 18], "virtualenv": [16, 20], "visibl": [0, 1, 13, 15, 16, 18], "visual": [18, 20], "visualcross": 16, "vite_app_api_url": 20, "voodoopt": 16, "vtt": 15, "vue": [16, 20], "vue3": 20, "vue_app_api_url": 20, "vuex": 20, "w": 16, "wa": [0, 4, 10, 16, 18, 20], "wai": [18, 20], "walk": [11, 16, 18], "want": 20, "wantedbi": 20, "warn": [16, 18, 20], "water": [16, 18], "weather": [16, 18], "weather_api": 20, "weather_api_kei": 20, "weather_api_provid": [16, 20], "weather_end": [13, 15], "weather_provid": 2, "weather_start": [13, 15], "web": [0, 19, 20, 21], "weblat": [16, 18], "websit": 8, "wed": [0, 7, 10, 14, 15], "week": [0, 12, 16, 18], "weekend": 16, "weekm": [0, 12], "were": 16, "wget": 20, "when": [0, 1, 2, 14, 15, 16, 17, 18, 20], "where": 20, "whether": 4, "which": [0, 18], "while": 18, "white": 16, "who": 18, "whose": [18, 21], "why": 10, "wind": [16, 18], "window": 20, "with_follow": 14, "with_gpx": [13, 15], "with_hidden_us": 14, "with_inact": 14, "with_suspend": 14, "without": [0, 4, 5, 7, 8, 10, 12, 13, 14, 15, 16, 18, 19], "wjgtwtqfpnprhyak5ex9pw": 1, "work": 20, "worker": [16, 17, 20, 23], "workers_process": 20, "workflow": 16, "workingdirectori": 20, "workout": [0, 1, 2, 4, 6, 8, 9, 10, 11, 12, 13, 16, 19, 21, 22, 24], "workout_d": [0, 9, 13, 14, 15], "workout_id": [0, 1, 9, 10, 13, 14, 15], "workout_short_id": [1, 15], "workout_suspens": [10, 14], "workout_unsuspens": 10, "workout_vis": 15, "workouts_count": [4, 14], "workouts_vis": [0, 14], "write": [0, 1, 2, 3, 4, 5, 7, 8, 10, 11, 14, 15, 18, 21], "written": 20, "www": [2, 20], "x": [0, 15, 16, 20, 21], "xmgz": 16, "xml": 15, "xxxx": 20, "xzf": 20, "y": [0, 12, 15, 20], "yarn": 20, "year": [12, 18], "yet": [16, 19], "you": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20], "your": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20], "yyyi": 0, "z": [15, 20], "z2ze5qzrnmvmndejpphask": 10, "zero": [0, 16, 18], "zip": [0, 2, 15, 16, 18], "zone": 0, "zoom": 15}, "titles": ["Authentication and account", "Comments", "Configuration", "Equipment Types", "Equipments", "Follow requests", "API documentation", "Notifications", "OAuth2", "Records", "Reports", "Sports", "Statistics", "Timeline", "Users", "Workouts", "Change log", "Command line interface", "Features", "FitTrackee", "Installation", "OAuth 2.0", "Third-party tools", "Administrator", "Troubleshooting"], "titleterms": {"0": [16, 21], "01": 16, "02": 16, "03": 16, "04": 16, "05": 16, "06": 16, "07": 16, "08": 16, "09": 16, "1": 16, "10": 16, "11": 16, "12": 16, "13": 16, "14": 16, "15": 16, "16": 16, "17": 16, "18": 16, "19": 16, "2": [16, 21], "20": 16, "2018": 16, "2019": 16, "2020": 16, "2021": 16, "2022": 16, "2023": 16, "2024": 16, "21": 16, "22": 16, "23": 16, "24": 16, "25": 16, "26": 16, "27": 16, "28": 16, "29": 16, "3": 16, "30": 16, "31": 16, "32": 16, "4": 16, "5": 16, "6": 16, "7": 16, "8": 16, "9": 16, "about": 18, "account": [0, 18], "administr": [16, 18, 23], "api": [6, 20], "app": 18, "applic": 18, "ar": 23, "authent": 0, "avail": 16, "bug": 16, "chang": 16, "clean": 17, "clean_arch": 17, "clean_token": 17, "close": 16, "command": 17, "comment": [1, 18], "configur": [2, 18], "content": 19, "creat": 17, "dashboard": 18, "data": 20, "databas": 17, "db": 17, "depend": 20, "deploy": 20, "detail": [18, 23], "dev": 20, "develop": 20, "directori": 18, "displai": 23, "docker": 20, "document": [6, 16], "download": 23, "drop": 17, "email": 20, "endpoint": 6, "enhanc": 16, "environ": 20, "equip": [3, 4, 18], "export_arch": 17, "fail": 23, "featur": [16, 18], "file": 23, "first": 16, "fittracke": [16, 19, 23], "fix": 16, "flow": 21, "follow": 5, "french": 16, "from": 20, "ftcli": 17, "i": 23, "imag": 23, "import": 22, "improv": 16, "instal": [20, 22], "interact": 18, "interfac": 17, "issu": 16, "like": 18, "limit": 20, "line": 17, "list": 18, "log": 16, "main": 20, "map": [20, 23], "minor": 16, "misc": 16, "moder": 18, "new": 16, "nixo": 20, "notif": [7, 18], "oauth": [18, 21], "oauth2": [8, 17], "parti": 22, "polici": 18, "prefer": 18, "prerequisit": 20, "privaci": 18, "prod": 20, "product": 20, "pull": 16, "pypi": [16, 20], "rate": 20, "record": 9, "releas": 16, "report": 10, "request": [5, 16], "resourc": 21, "scope": 21, "screenshot": 18, "script": 22, "secur": 16, "server": 20, "shown": 23, "sourc": 20, "sport": [11, 18], "start": 23, "statist": [12, 16, 18], "tabl": 19, "third": 22, "tile": 20, "timelin": 13, "tool": 22, "translat": [16, 18], "troubleshoot": 24, "type": [3, 18], "updat": 17, "upgrad": [17, 20], "upload": 23, "user": [14, 17, 18], "variabl": 20, "version": 16, "weather": 20, "workout": [15, 18, 23], "yunohost": 20}}) \ No newline at end of file diff --git a/docs/en/third_party_tools.html b/docs/en/third_party_tools.html index 9951eea26..e6a3f5152 100644 --- a/docs/en/third_party_tools.html +++ b/docs/en/third_party_tools.html @@ -215,12 +215,17 @@
  • API documentation diff --git a/docs/en/troubleshooting/administrator.html b/docs/en/troubleshooting/administrator.html index ccef39011..a63e77ec4 100644 --- a/docs/en/troubleshooting/administrator.html +++ b/docs/en/troubleshooting/administrator.html @@ -215,12 +215,17 @@
  • API documentation diff --git a/docs/en/troubleshooting/index.html b/docs/en/troubleshooting/index.html index 2b14393f8..f950c2a87 100644 --- a/docs/en/troubleshooting/index.html +++ b/docs/en/troubleshooting/index.html @@ -215,12 +215,17 @@
  • API documentation diff --git a/docs/fr/_images/administration-menu.png b/docs/fr/_images/administration-menu.png new file mode 100644 index 000000000..f37628b3b Binary files /dev/null and b/docs/fr/_images/administration-menu.png differ diff --git a/docs/fr/_images/dashboard.png b/docs/fr/_images/dashboard.png new file mode 100644 index 000000000..9b39669e0 Binary files /dev/null and b/docs/fr/_images/dashboard.png differ diff --git a/docs/fr/_images/equipment-detail.png b/docs/fr/_images/equipment-detail.png new file mode 100644 index 000000000..c670ea06b Binary files /dev/null and b/docs/fr/_images/equipment-detail.png differ diff --git a/docs/fr/_images/equipments-list.png b/docs/fr/_images/equipments-list.png new file mode 100644 index 000000000..d9575b364 Binary files /dev/null and b/docs/fr/_images/equipments-list.png differ diff --git a/docs/fr/_images/fittrackee_screenshot-01.png b/docs/fr/_images/fittrackee_screenshot-01.png deleted file mode 100644 index 64b285df2..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-01.png and /dev/null differ diff --git a/docs/fr/_images/fittrackee_screenshot-02.png b/docs/fr/_images/fittrackee_screenshot-02.png deleted file mode 100644 index 62063c1af..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-02.png and /dev/null differ diff --git a/docs/fr/_images/fittrackee_screenshot-03.png b/docs/fr/_images/fittrackee_screenshot-03.png deleted file mode 100644 index a01d55838..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-03.png and /dev/null differ diff --git a/docs/fr/_images/fittrackee_screenshot-04.png b/docs/fr/_images/fittrackee_screenshot-04.png deleted file mode 100644 index 891ddeaca..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-04.png and /dev/null differ diff --git a/docs/fr/_images/fittrackee_screenshot-05.png b/docs/fr/_images/fittrackee_screenshot-05.png deleted file mode 100644 index e875f94c9..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-05.png and /dev/null differ diff --git a/docs/fr/_images/fittrackee_screenshot-06.png b/docs/fr/_images/fittrackee_screenshot-06.png deleted file mode 100644 index 28b6394c5..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-06.png and /dev/null differ diff --git a/docs/fr/_images/fittrackee_screenshot-07.png b/docs/fr/_images/fittrackee_screenshot-07.png deleted file mode 100644 index aac958342..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-07.png and /dev/null differ diff --git a/docs/fr/_images/fittrackee_screenshot-08.png b/docs/fr/_images/fittrackee_screenshot-08.png deleted file mode 100644 index 6d496cda6..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-08.png and /dev/null differ diff --git a/docs/fr/_images/fittrackee_screenshot-09.png b/docs/fr/_images/fittrackee_screenshot-09.png deleted file mode 100644 index 980512de0..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-09.png and /dev/null differ diff --git a/docs/fr/_images/fittrackee_screenshot-10.png b/docs/fr/_images/fittrackee_screenshot-10.png deleted file mode 100644 index 94ca28350..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-10.png and /dev/null differ diff --git a/docs/fr/_images/fittrackee_screenshot-11.png b/docs/fr/_images/fittrackee_screenshot-11.png deleted file mode 100644 index 01c18fab1..000000000 Binary files a/docs/fr/_images/fittrackee_screenshot-11.png and /dev/null differ diff --git a/docs/fr/_images/notifications.png b/docs/fr/_images/notifications.png new file mode 100644 index 000000000..0bdb63646 Binary files /dev/null and b/docs/fr/_images/notifications.png differ diff --git a/docs/fr/_images/oauth2-app-authorization.png b/docs/fr/_images/oauth2-app-authorization.png new file mode 100644 index 000000000..43d4ef484 Binary files /dev/null and b/docs/fr/_images/oauth2-app-authorization.png differ diff --git a/docs/fr/_images/oauth2-client-creation.png b/docs/fr/_images/oauth2-client-creation.png new file mode 100644 index 000000000..899f0755c Binary files /dev/null and b/docs/fr/_images/oauth2-client-creation.png differ diff --git a/docs/fr/_images/sports-administration.png b/docs/fr/_images/sports-administration.png new file mode 100644 index 000000000..5b812ed01 Binary files /dev/null and b/docs/fr/_images/sports-administration.png differ diff --git a/docs/fr/_images/statistics-by-sport.png b/docs/fr/_images/statistics-by-sport.png new file mode 100644 index 000000000..ee61232d3 Binary files /dev/null and b/docs/fr/_images/statistics-by-sport.png differ diff --git a/docs/fr/_images/statistics-by-time-period.png b/docs/fr/_images/statistics-by-time-period.png new file mode 100644 index 000000000..fec1e2471 Binary files /dev/null and b/docs/fr/_images/statistics-by-time-period.png differ diff --git a/docs/fr/_images/users-directory.png b/docs/fr/_images/users-directory.png new file mode 100644 index 000000000..e8aa07e3c Binary files /dev/null and b/docs/fr/_images/users-directory.png differ diff --git a/docs/fr/_images/workout-detail.png b/docs/fr/_images/workout-detail.png new file mode 100644 index 000000000..cba7da0d9 Binary files /dev/null and b/docs/fr/_images/workout-detail.png differ diff --git a/docs/fr/_images/workouts-list.png b/docs/fr/_images/workouts-list.png new file mode 100644 index 000000000..1f69bef21 Binary files /dev/null and b/docs/fr/_images/workouts-list.png differ diff --git a/docs/fr/_sources/api/auth.rst.txt b/docs/fr/_sources/api/auth.rst.txt index e4500df10..2d6b1ef44 100644 --- a/docs/fr/_sources/api/auth.rst.txt +++ b/docs/fr/_sources/api/auth.rst.txt @@ -22,4 +22,9 @@ Authentication and account auth.accept_privacy_policy, auth.get_user_data_export, auth.request_user_data_export, - auth.download_data_export \ No newline at end of file + auth.download_data_export, + auth.get_blocked_users, + auth.get_user_suspension, + auth.appeal_user_suspension, + auth.get_user_sanction, + auth.appeal_user_sanction \ No newline at end of file diff --git a/docs/fr/_sources/api/comments.rst.txt b/docs/fr/_sources/api/comments.rst.txt new file mode 100644 index 000000000..f1d3862da --- /dev/null +++ b/docs/fr/_sources/api/comments.rst.txt @@ -0,0 +1,14 @@ +Comments +######## + +.. autoflask:: fittrackee:create_app() + :endpoints: + comments.get_workout_comments, + comments.get_workout_comment, + comments.post_workout_comment, + comments.update_workout_comment, + comments.like_comment, + comments.undo_comment_like, + comments.appeal_comment_suspension, + comments.delete_workout_comment + diff --git a/docs/fr/_sources/api/follow_requests.rst.txt b/docs/fr/_sources/api/follow_requests.rst.txt new file mode 100644 index 000000000..532e98ea9 --- /dev/null +++ b/docs/fr/_sources/api/follow_requests.rst.txt @@ -0,0 +1,8 @@ +Follow requests +############### + +.. autoflask:: fittrackee:create_app() + :endpoints: + follow_requests.get_follow_requests, + follow_requests.accept_follow_request, + follow_requests.reject_follow_request \ No newline at end of file diff --git a/docs/fr/_sources/api/index.rst.txt b/docs/fr/_sources/api/index.rst.txt index e4c1ef0ff..88102ea04 100644 --- a/docs/fr/_sources/api/index.rst.txt +++ b/docs/fr/_sources/api/index.rst.txt @@ -7,11 +7,16 @@ API documentation auth configuration + comments equipments equipment_types + follow_requests oauth2 + notifications records + reports sports stats + timeline users workouts diff --git a/docs/fr/_sources/api/notifications.rst.txt b/docs/fr/_sources/api/notifications.rst.txt new file mode 100644 index 000000000..35d07846d --- /dev/null +++ b/docs/fr/_sources/api/notifications.rst.txt @@ -0,0 +1,10 @@ +Notifications +############# + +.. autoflask:: fittrackee:create_app() + :endpoints: + notifications.get_auth_user_notifications, + notifications.update_user_notifications, + notifications.get_status, + notifications.mark_all_as_read, + notifications.get_notification_types \ No newline at end of file diff --git a/docs/fr/_sources/api/reports.rst.txt b/docs/fr/_sources/api/reports.rst.txt new file mode 100644 index 000000000..3c9adcb37 --- /dev/null +++ b/docs/fr/_sources/api/reports.rst.txt @@ -0,0 +1,14 @@ +Reports +######## + +.. autoflask:: fittrackee:create_app() + :endpoints: + reports.get_reports, + reports.get_report, + reports.get_unresolved_reports_status, + reports.create_report, + reports.update_report, + reports.create_action, + reports.process_appeal + + diff --git a/docs/fr/_sources/api/timeline.rst.txt b/docs/fr/_sources/api/timeline.rst.txt new file mode 100644 index 000000000..88baa077b --- /dev/null +++ b/docs/fr/_sources/api/timeline.rst.txt @@ -0,0 +1,6 @@ +Timeline +######## + +.. autoflask:: fittrackee:create_app() + :endpoints: + timeline.get_user_timeline diff --git a/docs/fr/_sources/api/users.rst.txt b/docs/fr/_sources/api/users.rst.txt index c06c6c140..f3ae5ae31 100644 --- a/docs/fr/_sources/api/users.rst.txt +++ b/docs/fr/_sources/api/users.rst.txt @@ -7,4 +7,11 @@ Users users.get_single_user, users.get_picture, users.update_user, - users.delete_user + users.delete_user, + users.follow_user, + users.unfollow_user, + users.get_followers, + users.get_following, + users.block_user, + users.unblock_user, + users.get_user_sanctions diff --git a/docs/fr/_sources/api/workouts.rst.txt b/docs/fr/_sources/api/workouts.rst.txt index a07264864..8c15e90c2 100644 --- a/docs/fr/_sources/api/workouts.rst.txt +++ b/docs/fr/_sources/api/workouts.rst.txt @@ -15,4 +15,7 @@ Workouts workouts.post_workout, workouts.post_workout_no_gpx, workouts.update_workout, - workouts.delete_workout + workouts.delete_workout, + workouts.like_workout, + workouts.undo_workout_like, + workouts.appeal_workout_suspension diff --git a/docs/fr/_sources/cli.rst.txt b/docs/fr/_sources/cli.rst.txt index 175e897bc..274b93b83 100644 --- a/docs/fr/_sources/cli.rst.txt +++ b/docs/fr/_sources/cli.rst.txt @@ -97,6 +97,7 @@ Remove blacklisted tokens expired for more than provided number of days. ``ftcli users create`` """""""""""""""""""""" .. versionadded:: 0.7.15 +.. versionchanged:: 0.8.4 User preference for interface language is added. Create a user account. @@ -104,9 +105,6 @@ Create a user account. - the newly created account is already active. - the CLI allows to create users when registration is disabled. -.. versionchanged:: 0.8.4 - -User preference for interface language is added. .. cssclass:: table-bordered .. list-table:: @@ -147,8 +145,9 @@ Can be used if redis is not set (no dramatiq workers running). ``ftcli users update`` """""""""""""""""""""" .. versionadded:: 0.6.5 +.. versionchanged:: 0.9.0 Add ``--set-role`` option. ``--set-admin`` is now deprecated. -Modify a user account (admin rights, active status, email and password). +Modify a user account (role, active status, email and password). .. cssclass:: table-bordered .. list-table:: @@ -160,7 +159,9 @@ Modify a user account (admin rights, active status, email and password). * - ``USERNAME`` - Username. * - ``--set-admin BOOLEAN`` - - Add/remove admin rights (when adding admin rights, it also activates user account if not active). + - [DEPRECATED] Add/remove admin rights (when adding admin rights, it also activates user account if not active). + * - ``--set-role ROLE`` + - Set user role (when setting 'moderator', 'admin' and 'owner' role, it also activates user account if not active). * - ``--activate`` - Activate user account. * - ``--reset-password`` diff --git a/docs/fr/_sources/features.rst.txt b/docs/fr/_sources/features.rst.txt index 2c04e5e3c..471628e65 100644 --- a/docs/fr/_sources/features.rst.txt +++ b/docs/fr/_sources/features.rst.txt @@ -3,7 +3,7 @@ Features | **FitTrackee** allows you to store and display **gpx** files and some statistics from your **outdoor** activities. | Equipments can be associated with workouts. -| For now, this app is kind of a single-user application. Even if several users can register, a user can only view his own workouts. +| If registration is enabled, multiple users can register and interact with other users (comments, likes). Workouts and comments are visible to other users according to visibility levels. Gpx files are stored in an upload directory (**without encryption**). @@ -11,37 +11,43 @@ With the default configuration, `Open Street Map Workouts -^^^^^^^^ +======== + +Sports +------ + - 18 sports are supported: - - Cycling (Sport) - - Cycling (Transport) - - Cycling (Trekking) (*new in 0.7.27*) - - Cycling (Virtual) (*new in 0.7.3*) - - Hiking - - Mountain Biking - - Mountain Biking (Electric) (*new in 0.5.0*) - - Mountaineering (*new in 0.7.9*) - - Open Water Swimming (*new in 0.7.20*) - - Paragliding (*new in 0.7.19*) - - Rowing (*new in 0.5.0*) - - Running - - Skiing (Alpine) (*new in 0.5.0*) - - Skiing (Cross Country) (*new in 0.5.0*) - - Snowshoes (*new in 0.5.2*) - - Swimrun (*new in 0.8.7*) - - Trail (*new in 0.5.0*) - - Walking + + - Cycling (Sport) + - Cycling (Transport) + - Cycling (Trekking) (*new in 0.7.27*) + - Cycling (Virtual) (*new in 0.7.3*) + - Hiking + - Mountain Biking + - Mountain Biking (Electric) (*new in 0.5.0*) + - Mountaineering (*new in 0.7.9*) + - Open Water Swimming (*new in 0.7.20*) + - Paragliding (*new in 0.7.19*) + - Rowing (*new in 0.5.0*) + - Running + - Skiing (Alpine) (*new in 0.5.0*) + - Skiing (Cross Country) (*new in 0.5.0*) + - Snowshoes (*new in 0.5.2*) + - Swimrun (*new in 0.8.7*) + - Trail (*new in 0.5.0*) + - Walking - (*new in 0.5.0*) Stopped speed threshold used by `gpxpy `_ is not the default one for the following sports (0.1 km/h instead of 1 km/h): - - Hiking - - Mountaineering - - Open Water Swimming - - Paragliding - - Skiing (Cross Country) - - Snowshoes - - Swimrun - - Trail - - Walking + + - Hiking + - Mountaineering + - Open Water Swimming + - Paragliding + - Skiing (Cross Country) + - Snowshoes + - Swimrun + - Trail + - Walking .. note:: It can be overridden in user preferences. @@ -50,8 +56,14 @@ Workouts | Except the stopped speed threshold, all sports are analyzed in the same way (no specificity taken into account for the moment). | Swimrun is displayed as a single activity with no difference between segments for now. -- Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts. -- Workout creation by uploading manually a gpx file or a zip archive containing a limited number of gpx files (related data are stored in database in metric system). +Workouts +-------- + +- A workout can be created by: + + - uploading manually a gpx file or a zip archive containing a limited number of gpx files, + - | or entering date, time, duration and distance (without gpx file). + | Ascent and descent can also be provided (*new in 0.7.10*). .. warning:: | Only **gpx** files with time and elevation are supported (otherwise, errors may occur on upload). @@ -59,89 +71,204 @@ Workouts .. note:: | Calculated values may differ from values calculated by the application that originally generated the gpx files, in particular the maximum speed. +.. note:: + | Related data are stored in database in metric system. + .. note:: | For now, **FitTrackee** has no importer, but some `third-party tools `__ allow you to import workouts. - | If the name is present in the gpx file (````), it is used as the workout title. Otherwise, a title is generated from the sport and workout date. | User can add title while uploading gpx file (*new in 0.8.10*). -- | The user can add description (*new in 0.8.9*) and note. - -.. note:: - | This "Description" field is longer than the "Notes" field and will have the same visibility as the workout in a next version (see `#125 `__). The "Notes" field will remain private. - +- | The user can add description (*new in 0.8.9*) and private notes. + | A limited Markdown syntax can be used (*new in 0.9.0*). - If present and no description is provided by the user, the description from the gpx file (````) is used as the workout description (*new in 0.8.10*). -- | A workout can even be created without gpx (the user must enter date, time, duration and distance). - | Ascent and descent can also be provided (*new in 0.7.10*). - | A workout with a gpx file can be displayed with map and charts (speed and elevation (if the gpx file contains elevation data, *updated in 0.7.20*)). | Controls allow full screen view and position reset (*new in 0.5.5*). - | If **Visual Crossing** (*new in 0.7.11*) API key is provided, weather is displayed in workout detail. Data source is displayed in **About** page. | Wind is displayed, with an arrow indicating the direction (a tooltip can be displayed with the direction that the wind is coming **from**) (*new in 0.5.5*). -- An `equipment `__ can be associated with a workout (*new in 0.8.0*). For now, only one equipment can be associated. +- | An `equipment `__ can be associated with a workout (*new in 0.8.0*). For now, only one equipment can be associated. + | Equipment is only visible to workout owner. - Segments can be displayed. -- Workout gpx file can be downloaded (*new in 0.5.1*) -- Workout edition and deletion. -- User statistics, by time period (week, month, year) and sport: - - totals: - - total distance - - total duration - - total workouts - - total ascent (*new in 0.5.0*) - - total descent (*new in 0.5.0*) - - averages: - - average speed (*new in 0.5.1*) - - average distance (*new in 0.8.5*) - - average duration (*new in 0.8.5*) - - average workouts (*new in 0.8.5*) - - average ascent (*new in 0.8.5*) - - average descent (*new in 0.8.5*) -- User statistics by sport (*new in 0.8.5*): - - total workouts - - distance (total and average) - - duration (total and average) - - average speed - - ascent (total and average) - - descent (total and average) - - records +- Records associated with the workout are displayed. .. note:: - | There is a limit on the number of workouts used to calculate statistics to avoid performance issues. The value can be set in administration. - | If the limit is reached, the number of workouts used is displayed. - | The total number of workouts for a given sport is not affected by this limit. + Records may differ from records displayed by the application that originally generated the gpx files. + +- Visibility level can be set separately for workout data and map and analysis data (*new in 0.9.0*): + + - private: only owner can see the workout, + - followers only: only owner and followers can see the workout, + - public: anyone can see the workout even unauthenticated users. + +.. note:: + | A workout with a gpx file whose visibility for map and analysis data does not allow them to be viewed appears as a workout without a gpx file. + +.. note:: + | Default visibility is private. All workouts created before **FitTrackee** 0.9.0 are private. + +.. important:: + | Please keep in mind that the server operating team or the moderation team may view content with restricted visibility. + +- Workout can be edited: + + - sport + - title + - equipment + - description (*new in 0.8.9*) + - private notes + - workout visibility (*new in 0.9.0*) + - map and analysis visibility (*new in 0.9.0*) + - date (only workouts without gpx) + - duration (only workouts without gpx) + - distance (only workouts without gpx) + - ascent and descent (only workouts without gpx) (*new in 0.7.10*) + +- Workout gpx file can be downloaded (*new in 0.5.1*). +- Workout can be deleted. +- Workouts list. + + - The user can filter workouts on: + + - date + - sports (only sports with workouts are displayed in sport dropdown) + - equipment (only equipments with workouts are displayed in equipment dropdown) (*new in 0.8.0*) + - title (*new in 0.7.15*) + - description (*new in 0.8.9*) + - notes (*new in 0.8.0*) + - distance + - duration + - average speed + - maximum speed + + - Workouts can be sorted by: + + - date + - distance + - duration + - average speed + +- A user can report a workout that violates instance rules. This will send a notification to moderators and administrators. + +Interactions +============ + +.. versionadded:: 0.9.0 + +Users +----- +- | Users directory. + | A user can configure visibility in directory in the user preferences. + | This has an impact on username completion when writing comments (only profiles visible in users directory or followed users are suggested). + +.. note:: + A user profile remains accessible via its URL. + +- | User can send follow request to others users. + | Follow request can be approved or rejected. +- | In order to hide unwanted content, a user can block another user. + | Blocking users hides their workouts on timeline and comments. Notifications from blocked users are not displayed. + | Blocked users cannot see workouts and comments from users who have blocked them, or follow them (if they followed them, they are forced to unfollow them). +- A user can report a user profile that violates instance rules. This will send a notification to moderators and administrators. + +Comments +-------- + +- | Depending on visibility, a user can comment on a workout. + | A limited Markdown syntax can be used. +- The visibility levels for comment are: + + - private: only author and mentioned users can see the comment, + - followers only: only author, followers and mentioned users can see the comment, + - public: anyone can see the comment even unauthenticated users. + +.. important:: + | Please keep in mind that the server operating team or the moderation team may view content with restricted visibility. + +- Comment text can be modified (visibility level cannot be changed). +- A user can report a comment that violates instance rules. This will send a notification to moderators and administrators. + +Likes +----- + +- Depending on visibility, a user can like or "unlike" a workout or a comment. + +Notifications +------------- + +- Notifications are sent for the following event: + + - follow request and follow + - like on comment or workout + - comment on workout + - mention on comment + - suspension or warning (an email is also sent if email sending is enabled) + - suspension or warning lifting (an email is also sent if email sending is enabled) + +- Users with moderation rights can also receive notifications on: + + - report creation + - appeal on suspension or warning + +- Users with administration rights can also receive notifications on user creation. +- Users can mark notifications as read or unread. + +Dashboard +========= + +- A dashboard displays: + + - a graph with monthly statistics + - a monthly calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts. + - user records by sports: -- User records by sports: - average speed - farthest distance - highest ascent (*new in 0.6.11*, can be hidden, see user preferences) - longest duration - maximum speed -.. note:: - Records may differ from records displayed by the application that originally generated the gpx files. + - a timeline with workouts visible to user -- Workouts list. - - The user can filter workouts on: - - date - - sports (only sports with workouts are displayed in sport dropdown) - - equipment (only equipments with workouts are displayed in equipment dropdown) (*new in 0.8.0*) - - title (*new in 0.7.15*) - - description (*new in 0.8.9*) - - notes (*new in 0.8.0*) - - distance - - duration - - average speed - - maximum speed - - Workouts can be sorted by: - - date - - distance - - duration - - average speed +Statistics +========== -.. note:: - For now, only the owner of the workout can see it. +- User statistics, by time period (week, month, year) and sport: + - totals: + + - total distance + - total duration + - total workouts + - total ascent (*new in 0.5.0*) + - total descent (*new in 0.5.0*) + + - averages: + + - average speed (*new in 0.5.1*) + - average distance (*new in 0.8.5*) + - average duration (*new in 0.8.5*) + - average workouts (*new in 0.8.5*) + - average ascent (*new in 0.8.5*) + - average descent (*new in 0.8.5*) + +- User statistics by sport (*new in 0.8.5*): + + - total workouts + - distance (total and average) + - duration (total and average) + - average speed + - ascent (total and average) + - descent (total and average) + - records + +.. note:: + | There is a limit on the number of workouts used to calculate statistics to avoid performance issues. The value can be set in administration. + | If the limit is reached, the number of workouts used is displayed. + | The total number of workouts for a given sport is not affected by this limit. Account & preferences -^^^^^^^^^^^^^^^^^^^^^ +===================== + - A user can create, update and deleted his account. - The user must agree to the privacy policy to register. If a more recent policy is available, a message is displayed on the dashboard to review the new version (*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*). @@ -154,6 +281,8 @@ Account & preferences - A user can reset his password (*new in 0.3.0*) - A user can change his email address (*new in 0.6.0*) - A user can set language, timezone and first day of week. +- A user can set follow requests approval: manually (default) or automatically. (*new in 0.9.0*) +- A user can set profile visibility in users directory: hidden (default) or displayed (*new in 0.9.0*) - A user can set the interface theme (light, dark or according to browser preferences) (*new in 0.7.27*). - A user can choose between metric system and imperial system for distance, elevation and speed display (*new in 0.5.0*) - A user can choose to display or hide ascent records and total on Dashboard (*new in 0.6.11*) @@ -164,11 +293,13 @@ Account & preferences .. note:: Changing this preference will only affect next file uploads. +- A user can set default visibility for workout data and map and analysis (*new in 0.9.0*). - A user can set sport preferences (*new in 0.5.0*): - - change sport color (used for sport image and charts) - - can override stopped speed threshold (for next uploaded gpx files) - - disable/enable a sport - - define default `equipments `__ (*new in 0.8.0*). + + - change sport color (used for sport image and charts) + - can override stopped speed threshold (for next uploaded gpx files) + - disable/enable a sport + - define default `equipments `__ (*new in 0.8.0*). .. note:: | If a sport is disabled by an administrator, it can not be enabled by a user. In this case, it will only appear in preferences if the user has workouts and only sport color can be changed. @@ -181,24 +312,32 @@ Account & preferences .. note:: For now, it's not possible to import these files into another **FitTrackee** instance. +- A user can display blocked users (*new in 0.9.0*). +- A user can view follow requests to approve or reject (*new in 0.9.0*). +- A user can view received sanctions and appeal (*new in 0.9.0*). + Equipments -^^^^^^^^^^ -(*new in 0.8.0*) +========== + +.. versionadded:: 0.8.0 - A user can create equipments that can be associated with workouts. - The following equipment types are available, depending on the sport: - - Shoes: Hiking, Mountaineering, Running, Trail and Walking, - - Bike: Cycling (Sport, Transport, Trekking), Mountain Biking and Mountain Biking (Electric), - - Bike Trainer: Cycling (Virtual), - - Kayak/Boat: Rowing, - - Skis: Skiing (Alpine and Cross Country), - - Snowshoes: Snowshoes. + + - Shoes: Hiking, Mountaineering, Running, Trail and Walking, + - Bike: Cycling (Sport, Transport, Trekking), Mountain Biking and Mountain Biking (Electric), + - Bike Trainer: Cycling (Virtual), + - Kayak/Boat: Rowing, + - Skis: Skiing (Alpine and Cross Country), + - Snowshoes: Snowshoes. + - Equipment is visible only to its owner. - For now only, only one piece of equipment can be associated with a workout. - Following totals are displayed for each piece of equipment: - - total distance - - total duration - - total workouts + + - total distance + - total duration + - total workouts .. note:: | In case of an incorrect total (although this should not happen), it is possible to recalculate totals. @@ -215,19 +354,24 @@ Equipments | An equipment type can be deactivated by an administrator. OAuth Apps -^^^^^^^^^^ -(*new in 0.7.0*) +=========== + +.. versionadded:: 0.7.0 - A user can create `clients `__ for third-party applications. Administration -^^^^^^^^^^^^^^ -(*new in 0.3.0*) +============== + +.. versionadded:: 0.3.0 Application -""""""""""" +----------- -**Configuration** +- Only users if administration rights can access application administration. + +Configuration +~~~~~~~~~~~~~ The following parameters can be set: @@ -244,17 +388,18 @@ The following parameters can be set: .. note:: If email sending is disabled, a warning is displayed. -**About** +About +~~~~~ -(*new in 0.7.13*) +.. versionadded:: 0.7.13 -| It is possible displayed additional information that may be useful to users in **About** page. +| It is possible displayed additional information that may be useful to users in **About** page (like instance rules). | Markdown syntax can be used. +Privacy policy +~~~~~~~~~~~~~~ -**Privacy policy** - -(*new in 0.7.13*) +.. versionadded:: 0.7.13 | A default privacy policy is available (originally adapted from the `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 it. @@ -263,30 +408,93 @@ The following parameters can be set: Only the default privacy policy is translated (if the translation is available). Users -""""" +----- + +.. versionchanged:: 0.9.0 Add moderator and owner role + +- Only users with administration rights can access users administration. +- Roles: + + - user + + - no moderation or administration rights + + - moderator (*new in 0.9.0*): + + - can only access moderation entry in administration + - can see reports + - perform report actions + + - administrator + + - has moderator rights (*new in 0.9.0*) + - can access all entries in administration: + + - application + - moderation + - equipment types + - sports + - users + + - owner (*new in 0.9.0*) : + + - has admin rights + - role can not be modified by other administrator/owner on application + +.. note:: + + Roles defined prior to version 0.9.0 remain unchanged. - display and filter users list - edit a user to: - - add/remove administration rights + - update role (*updated in 0.9.0*). A user with owner role can not be modified by other users. Owner role can only be assigned or removed with **FitTrackee** CLI. - 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 +Moderation +---------- + +.. versionadded:: 0.9.0 + +- Only users with administration or moderation rights can access moderation. +- Display and filter reports list. +- Manage a report: + + - add a comment + - send a warning + - suspend or reactive workout or comment + - suspend or reactive user account + - mark report as resolved or unresolved + +.. note:: + Report content is visible regardless the visibility level. + +- A user can appeal suspension or warning. +- Suspended user can only access his account, appeal the account suspension, request and data export or delete his account. His sessions and comments are no longer visible. + Equipment Types -""""""""""""""" -- enable or disable an equipment type in order to match disabled sports (a equipment type can be disabled even if equipment with this type exists) (*new in 0.8.0*) +--------------- + +.. versionadded:: 0.8.0 + +- Only users with administration rights can access equipment types administration. +- enable or disable an equipment type in order to match disabled sports (a equipment type can be disabled even if equipment with this type exists) (*new in 0.8.0*). Sports -"""""" -- enable or disable a sport (a sport can be disabled even if workout with this sport exists) +------ +- Only users with administration rights can access sports administration. +- Enable or disable a sport (a sport can be disabled even if workout with this sport exists). Translations -^^^^^^^^^^^^ +============ + FitTrackee is available in the following languages (which can be saved in the user preferences): - English @@ -310,48 +518,67 @@ Application translations status on `Weblate `__ for more information) -.. figure:: _images/fittrackee_screenshot-01.png +.. figure:: _images/dashboard.png :alt: FitTrackee Dashboard diff --git a/docs/fr/_sources/installation.rst.txt b/docs/fr/_sources/installation.rst.txt index 33a2cc03e..c370fb57f 100644 --- a/docs/fr/_sources/installation.rst.txt +++ b/docs/fr/_sources/installation.rst.txt @@ -265,6 +265,9 @@ deployment method. Emails ~~~~~~ .. versionadded:: 0.3.0 +.. versionchanged:: 0.5.3 Credentials and port can be omitted +.. versionchanged:: 0.6.5 Disable email sending +.. versionchanged:: 0.7.24 Handle special characters in password To send emails, a valid ``EMAIL_URL`` must be provided: @@ -272,15 +275,16 @@ To send emails, a valid ``EMAIL_URL`` must be provided: - with SSL: ``smtp://username:password@smtp.example.com:465/?ssl=True`` - with STARTTLS: ``smtp://username:password@smtp.example.com:587/?tls=True`` +Credentials can be omitted: ``smtp://smtp.example.com:25``. +If ``:`` is omitted, the port defaults to 25. + +Password can be encoded if it contains special characters. +For instance with password ``passwordwith@and&and?``, the encoded password will be: ``passwordwith%40and%26and%3F``. + .. warning:: | If the email URL is invalid, the application may not start. | Sending emails with Office365 may not work if SMTP auth is disabled. -.. versionchanged:: 0.5.3 - -| Credentials can be omitted: ``smtp://smtp.example.com:25``. -| If ``:`` is omitted, the port defaults to 25. - .. warning:: | Since 0.6.0, newly created accounts must be confirmed (an email with confirmation instructions is sent after registration). @@ -291,22 +295,21 @@ Emails sent by FitTrackee are: - email change (to old and new email addresses) - password change - notification when a data export archive is ready to download (*new in 0.7.13*) +- suspension and warning (*new in 0.9.0*) +- suspension and warning lifting (*new in 0.9.0*) +- rejected appeal (*new in 0.9.0*) -.. 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). +On 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 `__ is available to activate account, modify email and password and handle data export requests. -.. versionchanged:: 0.7.24 - -Password can be encoded if it contains special characters. -For instance with password ``passwordwith@and&and?``, the encoded password will be: ``passwordwith%40and%26and%3F``. - Map tile server ~~~~~~~~~~~~~~~ .. versionadded:: 0.4.0 +.. versionchanged:: 0.6.10 Handle tile server subdomains +.. versionchanged:: 0.7.23 Default tile server (**OpenStreetMap**) no longer requires subdomains Default tile server is now **OpenStreetMap**'s standard tile layer (if environment variables are not initialized). The tile server can be changed by updating ``TILE_SERVER_URL`` and ``MAP_ATTRIBUTION`` variables (`list of tile servers `__). @@ -319,9 +322,6 @@ To keep using **ThunderForest Outdoors**, the configuration is: .. note:: | Check the terms of service of tile provider for map attribution. - -.. versionchanged:: 0.6.10 - Since the tile server can be used for static map generation, some servers require a subdomain. For instance, to set OSM France tile server, the expected values are: @@ -332,9 +332,7 @@ For instance, to set OSM France tile server, the expected values are: The subdomain will be chosen randomly. -.. versionadded:: 0.7.23 - -The default URL is updated: **OpenStreetMap**'s tile server no longer requires subdomains. +The default tile server (**OpenStreetMap**) no longer requires subdomains. API rate limits @@ -375,20 +373,20 @@ API rate limits Weather data ~~~~~~~~~~~~ -.. versionchanged:: 0.7.11 +.. versionchanged:: 0.7.11 Add Visual Crossing to weather providers +.. versionchanged:: 0.7.15 Remove Darksky from weather providers The following weather data providers are supported by **FitTrackee**: - `Visual Crossing `__ (**note**: historical data are provided on hourly period) -To configure a weather provider, set the following environment variables: - -- ``WEATHER_API_KEY``: the key to the corresponding weather provider +.. note:: + **DarkSky** support is discontinued, since the service shut down on March 31, 2023. -.. versionchanged:: 0.7.15 +To configure a weather provider, set the following environment variables: -**DarkSky** support is discontinued, since the service shut down on March 31, 2023. +- ``WEATHER_API_KEY``: the key to the corresponding weather provider Installation @@ -456,11 +454,11 @@ For instance, copy and update ``.env`` file from ``.env.example`` and source the - Open http://localhost:5000 and register -- To set admin rights to the newly created account, use the following command line: +- To set owner role to the newly created account, use the following command line: .. code:: bash - $ ftcli users update --set-admin true + $ ftcli users update --set-role owner .. note:: If the user account is inactive, it activates it. @@ -514,11 +512,11 @@ Dev environment - Open http://localhost:3000 and register -- To set admin rights to the newly created account, use the following command line: +- To set owner role to the newly created account, use the following command line: .. code:: bash - $ make user-set-admin USERNAME= + $ make user-set-role USERNAME= ROLE=owner .. note:: If the user account is inactive, it activates it. @@ -565,11 +563,11 @@ Production environment - Open http://localhost:5000 and register -- To set admin rights to the newly created account, use the following command line: +- To set owner role to the newly created account, use the following command line: .. code:: bash - $ make user-set-admin USERNAME= + $ make user-set-role USERNAME= ROLE=owner .. note:: If the user account is inactive, it activates it. @@ -749,10 +747,10 @@ Examples: WantedBy=multi-user.target -.. note:: +.. seealso:: To handle large files, a higher value for `timeout `__ can be set. -.. note:: +.. seealso:: More information on deployment with Gunicorn in its `documentation `__. - for task queue workers: ``fittrackee_workers.service`` @@ -827,7 +825,7 @@ Examples: } } -.. note:: +.. seealso:: If needed, update configuration to handle larger files (see `client_max_body_size `_). @@ -857,11 +855,11 @@ For **evaluation** purposes, docker files are available, installing **FitTrackee Open http://localhost:8025 to access `MailHog interface `_ (email testing tool) -- To set admin rights to the newly created account, use the following command line: +- To set owner role to the newly created account, use the following command line: .. code:: bash - $ make docker-set-admin USERNAME= + $ make docker-set-role USERNAME= ROLE=owner .. note:: If the user account is inactive, it activates it. diff --git a/docs/fr/_sources/oauth.rst.txt b/docs/fr/_sources/oauth.rst.txt index e9b4d8c10..bf5531cfc 100644 --- a/docs/fr/_sources/oauth.rst.txt +++ b/docs/fr/_sources/oauth.rst.txt @@ -1,6 +1,7 @@ OAuth 2.0 ######### -(*new in 0.7.0*) + +.. versionadded:: 0.7.0 FitTrackee provides a REST API (see `documentation `__) whose most endpoints require authentication/authorization. @@ -28,12 +29,18 @@ The following scopes are available: - ``application:write``: grants write access to application configuration (only for users with administration rights), - ``equipments:read``: grants read access to equipments endpoints (*new in 0.8.0*), - ``equipments:write``: grants write access to equipments endpoints (*new in 0.8.0*), +- ``follow:read``: grants read access to follow requests and followers endpoints (*new in 0.9.0*), +- ``follow:write``: grants write access to requests and followers endpoints (*new in 0.9.0*), +- ``notifications:read``: grants read access to notifications endpoints (*new in 0.9.0*), +- ``notifications:write``: grants write access to notifications endpoints (*new in 0.9.0*), - ``profile:read``: grants read access to auth endpoints, - ``profile:write``: grants write access to auth endpoints, +- ``reports:read``: grants read access to reports endpoints (*new in 0.9.0*), +- ``reports:write``: grants write access to reports endpoints (*new in 0.9.0*), - ``users:read``: grants read access to users endpoints, - ``users:write``: grants write access to users endpoints, -- ``workouts:read``: grants read access to workouts-related endpoints, -- ``workouts:write``: grants write access to workouts-related endpoints. +- ``workouts:read``: grants read access to workouts and comments endpoints (*changed in 0.9.0*), +- ``workouts:write``: grants write access to workouts and comments endpoints (*changed in 0.9.0*). Flow @@ -41,7 +48,7 @@ Flow - The user creates an App (client) on FitTrackee for a third-party application. - .. figure:: _images/fittrackee_screenshot-07.png + .. figure:: _images/oauth2-client-creation.png :alt: OAuth2 client creation on FitTrackee | After registration, the client id and secret are shown. @@ -49,7 +56,7 @@ Flow - | The 3rd-party app needs to redirect to FitTrackee, in order for the user to authorize the 3rd-party app to access user data on FitTrackee. - .. figure:: _images/fittrackee_screenshot-08.png + .. figure:: _images/oauth2-app-authorization.png :alt: App authorization on FitTrackee | The authorization URL is ``https:///profile/apps/authorize``. @@ -72,7 +79,7 @@ Flow | ``https:///profile/apps/authorize?response_type=code&client_id=&scope=profile%3Aread+workouts%3Awrite&state=&code_challenge=&code_challenge_method=S256`` -- | After the authorization, FitTrackee redirects to the 3rd-party app, so the 3rd-party app can get the authorization code from the redirect URL and then fetches an access token with the client id and secret (endpoint `/api/oauth/token `_). +- | After the authorization, FitTrackee redirects to the 3rd-party app, so the 3rd-party app can get the authorization code from the redirect URL and then fetches an access token with the client id and secret (endpoint `/api/oauth/token `_). | Example of a redirect URL: | ``https://example.com/callback?code=&state=`` diff --git a/docs/fr/api/auth.html b/docs/fr/api/auth.html index f35d240f9..d8d94114e 100644 --- a/docs/fr/api/auth.html +++ b/docs/fr/api/auth.html @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -479,6 +484,7 @@

    Authentification et compte GET /api/auth/profile

    Obtenir des informations sur l’utilisateur authentifié (profil, compte, préférences).

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Scope : profile:read

    Exemple de requête :

    GET /api/auth/profile HTTP/1.1
    @@ -501,11 +507,16 @@ 

    Authentification et compte "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "followers": 0, + "following": 0, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -569,7 +580,8 @@

    Authentification et compte "use_dark_mode": null, "use_raw_gpx_speed": false, "username": "sam", - "weekm": false + "weekm": false, + "workouts_visibility": "private" }, "status": "success" } @@ -599,6 +611,7 @@

    Authentification et compte POST /api/auth/profile/edit

    Modifier le profil de l’utilisateur authentifié.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Scope : profile:write

    Exemple de requête :

    POST /api/auth/profile/edit HTTP/1.1
    @@ -621,11 +634,16 @@ 

    Authentification et compte "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "followers": 0, + "following": 0, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -690,6 +708,7 @@

    Authentification et compte "use_raw_gpx_speed": false, "username": "sam" "weekm": true, + "workouts_visibility": "private" }, "message": "user profile updated", "status": "success" @@ -744,6 +763,7 @@

    Authentification et compteprofile:write

    Exemple de requête :

    POST /api/auth/profile/edit/preferences HTTP/1.1
    @@ -766,11 +786,16 @@ 

    Authentification et compte "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "followers": 0, + "following": 0, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "followers_only", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -835,6 +860,7 @@

    Authentification et compte "use_raw_gpx_speed": true, "username": "sam" "weekm": true, + "workouts_visibility": "public" }, "message": "user preferences updated", "status": "success" @@ -846,13 +872,17 @@

    Authentification et compte
    • date_format (string) – le format utilisé pour afficher les dates dans l’application

    • display_ascent (boolean) – afficher les records de dénivelé et le total de dénivelé

    • +
    • hide_profile_in_users_directory (boolean) – si la valeur est true, l’utilisateur n’apparait pas dans le répertoire des utilisateurs.

    • imperial_units (boolean) – afficher la distance en unités impériales

    • language (string) – préférences pour la langue

    • +
    • map_visibility (string) – visibilité de la carte de la séance (public, followers_only, private)

    • +
    • manually_approves_followers (boolean) – if la valeur est false, les demandes de suivi sont automatiquement approuvées

    • start_elevation_at_zero (boolean) – Les graphiques d’altitude commencent-ils à zéro ?

    • timezone (string) – fuseau horaire de l’utilisateur

    • -
    • use_dark_mode (boolean) – Affiche l’interface avec le thème sombre si la valeur est à vrai. Si la valeur est à null, le thème est sélectionné selon les préférences du navigateur.

    • +
    • use_dark_mode (boolean) – Affiche l’interface avec le thème sombre si la valeur est true. Si la valeur est null, le thème est sélectionné selon les préférences du navigateur.

    • use_raw_gpx_speed (boolean) – Utiliser des points gpx non filtrés pour calculer les vitesses

    • weekm (boolean) – La semaine commence-t-elle le lundi ?

    • +
    • workouts_visibility (string) – visibilité des séances de l’utilisateur (public, followers_only, private)

    En-têtes de requête:
    @@ -942,6 +972,10 @@

    Authentification et compteequipment with id <equipment_id> is inactive

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundsport does not exist

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -984,6 +1018,10 @@

    Authentification et compteinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundsport does not exist

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -995,6 +1033,7 @@

    Authentification et compte POST /api/auth/picture

    Mise à jour de l’image de l’utilisateur authentifié.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Scope : profile:write

    Exemple de requête :

    POST /api/auth/picture HTTP/1.1
    @@ -1049,6 +1088,7 @@ 

    Authentification et compte DELETE /api/auth/picture

    Supprimer l’image de l’utilisateur authentifié.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Scope : profile:write

    Exemple de requête :

    DELETE /api/auth/picture HTTP/1.1
    @@ -1131,6 +1171,7 @@ 

    Authentification et compteprofile:write

    Exemple de requête :

    PATCH /api/auth/profile/edit/account HTTP/1.1
    @@ -1153,11 +1194,14 @@ 

    Authentification et compte "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "followers_only", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -1222,6 +1266,7 @@

    Authentification et compte "use_raw_gpx_speed": false, "username": "sam" "weekm": true, + "workouts_visibility": "private" }, "message": "user account updated", "status": "success" @@ -1342,6 +1387,7 @@

    Authentification et compte POST /api/auth/logout

    Déconnexion de l’utilisateur. Si un jeton valide est fourni, il sera invalidé.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Exemple de requête :

    POST /api/auth/logout HTTP/1.1
     Content-Type: application/json
    @@ -1397,8 +1443,9 @@ 

    Authentification et compte POST /api/auth/account/privacy-policy

    L’utilisateur authentifié accepte la politique de confidentialité.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Exemple de requête :

    -
    POST /auth/account/privacy-policy HTTP/1.1
    +
    POST /api/auth/account/privacy-policy HTTP/1.1
     Content-Type: application/json
     
    @@ -1448,8 +1495,9 @@

    Authentification et comptein_progress, successful and errored)

  • nom du fichier et sa taille (en octets) lorsque l’export est réussi

  • +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Exemple de requête :

    -
    GET /auth/account/export HTTP/1.1
    +
    GET /api/auth/account/export HTTP/1.1
     Content-Type: application/json
     
    @@ -1507,8 +1555,9 @@

    Authentification et compte POST /api/auth/account/export/request

    Demande d’export de données pour un utilisateur authentifié.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Exemple de requête :

    -
    POST /auth/account/export/request HTTP/1.1
    +
    POST /api/auth/account/export/request HTTP/1.1
     Content-Type: application/json
     
    @@ -1556,9 +1605,10 @@

    Authentification et compte
    GET /api/auth/account/export/(string: file_name)
    -

    Télécharger une archive d’export de données

    +

    Télécharger une archive d’export de données.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Exemple de requête :

    -
    GET /auth/account/export/download/archive_rgjsR3fHr5Yp.zip HTTP/1.1
    +
    GET /api/auth/account/export/download/archive_rgjsR3fHr5Yp.zip HTTP/1.1
     Content-Type: application/json
     
    @@ -1593,6 +1643,342 @@

    Authentification et compte +
    +GET /api/auth/blocked-users
    +

    Obtenir les utilisateurs bloqués par l’utilisateur authentifié.

    +

    Scope : profile:read

    +

    Exemple de requêtes :

    +
      +
    • sans paramètres :

    • +
    +
    GET /api/auth/blocked-users HTTP/1.1
    +
    +
    +
      +
    • avec des paramètres :

    • +
    +
    GET /api/auth/blocked-users?page=1
    +  HTTP/1.1
    +
    +
    +

    Exemple de réponses :

    +
      +
    • avec les utilisateurs bloqués :

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "blocked_users": [
    +      {
    +        "blocked": true,
    +        "created_at": "Sun, 01 Dec 2024 17:27:49 GMT",
    +        "followers": 0,
    +        "following": 0,
    +        "follows": "false",
    +        "is_followed_by": "false",
    +        "nb_workouts": 1,
    +        "picture": false,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      }
    +    ],
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 1,
    +      "total": 1
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • sans les utilisateurs bloqués :

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "blocked_users": [],
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 0,
    +      "total": 0
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres de requête:
    +
      +
    • page (integer) – page si pagination (par défaut : 1)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/auth/account/suspension
    +

    Obtenir tous les records pour l’utilisateur authentifié.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    +

    Scope : profile:read

    +

    Exemple de requête :

    +
    GET /api/auth/account/suspension HTTP/1.1
    +
    +
    +

    Exemple de réponses :

    +
      +
    • une suspension existe :

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "status": "success",
    +    "user_suspension": {
    +      "action_type": "user_suspension",
    +      "appeal": null,
    +      "comment": null,
    +      "created_at": "Wed, 04 Dec 2024 10:45:13 GMT",
    +      "id": "mmy3qPL3vcFuKJGfFBnCJV",
    +      "reason": "<SUSPENSION REASON>",
    +      "workout": null
    +    }
    +  }
    +
    +
    +
      +
    • aucun suspension :

    • +
    +
    HTTP/1.1 404 NOT FOUND
    +Content-Type: application/json
    +
    +  {
    +    "status": "not found",
    +    "message": "user account is not suspended"
    +  }
    +
    +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 404 Not Founduser account is not suspended

    • +
    +
    +
    +
    + +
    +
    +POST /api/auth/account/suspension/appeal
    +

    Faire appeal de la suspension de l’utilisateur authentifié.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    +

    Scope : profile:write

    +

    Exemple de requête :

    +
    POST /api/auth/account/suspension/appeal HTTP/1.1
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 201 CREATED
    +Content-Type: application/json
    +
    +  {
    +    "status": "success"
    +  }
    +
    +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Objet JSON de requête:
    +
      +
    • text (string) – texte expliquant la raison de l’appel

    • +
    +
    +
    Codes d’état:
    +
    +
    +
    +
    + +
    +
    +GET /api/auth/account/sanctions/(string: action_short_id)
    +

    Obtenir les sanctions pour l’utilisateur authentifié.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    +

    Scope : profile:read

    +

    Exemple de requête :

    +
    GET /api/auth/account/sanctions/mmy3qPL3vcFuKJGfFBnCJV HTTP/1.1
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 SUCCESS
    +Content-Type: application/json
    +
    +  {
    +    "sanction": {
    +      "action_type": "user_suspension",
    +      "appeal": {
    +        "approved": null,
    +        "created_at": "Wed, 04 Dec 2024 10:49:00 GMT",
    +        "id": "7pDujhCVHyA4hv29JZQNgg",
    +        "reason": null,
    +        "text": "<APPEAL TEXT>",
    +        "updated_at": null
    +      },
    +      "comment": null,
    +      "created_at": "Wed, 04 Dec 2024 10:45:13 GMT",
    +      "id": "mmy3qPL3vcFuKJGfFBnCJV",
    +      "reason": "<SANCTION REASON>",
    +      "workout": null
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • action_short_id (string) – identifiant de la suspension

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 404 Not Foundno sanction found

    • +
    +
    +
    +
    + +
    +
    +POST /api/auth/account/sanctions/(string: action_short_id)/appeal
    +

    Faire appel de la suspension

    +

    Scope : profile:write

    +

    Exemple de requête :

    +
    POST /api/auth/account/sanctions/6dxczvMrhkAR72shUz9Pwd/appeal HTTP/1.1
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 201 CREATED
    +Content-Type: application/json
    +
    +  {
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • action_short_id (string) – identifiant de la sanction

    • +
    +
    +
    Objet JSON de requête:
    +
      +
    • text (string) – texte expliquant la raison de l’appel

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
    +
    +
    +
    + diff --git a/docs/fr/api/comments.html b/docs/fr/api/comments.html new file mode 100644 index 000000000..c949b04df --- /dev/null +++ b/docs/fr/api/comments.html @@ -0,0 +1,922 @@ + + + + + + + + + Commentaires - Documentation FitTrackee 0.8.12 + + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    +
    + + + + + Back to top + +
    + +
    + +
    + +
    +
    +
    +

    Commentaires

    +
    +
    +GET /api/workouts/(string: workout_short_id)/comments
    +

    Obtenir les commentaires de le séance.

    +

    Retourne uniquement les commentaires visibles par l’utilisateur authentifié et les commentaires publiques sans authentification.

    +

    Scope : workouts:read

    +

    Exemple de requête :

    +
    GET /api/workouts/2oRDfncv6vpRkfp3yrCYHt/comments HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "comments": [
    +        {
    +          "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +          "id": "WJgTwtqFpnPrHYAK5eX9Pw",
    +          "liked": false,
    +          "likes_count": 0,
    +          "mentions": [],
    +          "modification_date": null,
    +          "suspended_at": null,
    +          "text": "Great!",
    +          "text_html": "Great!",
    +          "text_visibility": "private",
    +          "user": {
    +            "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +            "followers": 3,
    +            "following": 2,
    +            "nb_workouts": 10,
    +            "picture": true,
    +            "role": "user",
    +            "suspended_at": null,
    +            "username": "Sam"
    +          },
    +          "workout_id": "2oRDfncv6vpRkfp3yrCYHt"
    +        }
    +      ]
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • workout_short_id (string) – identifiant court de la séance

    • +
    +
    +
    En-têtes de requête:
    +
      +
    • Authorization – Jeton “OAuth 2.0 Bearer” pour les commentaires avec une visibilité private et``followers_only``

    • +
    +
    +
    Codes d’état:
    +
    +
    +
    +
    + +
    +
    +GET /api/comments/(string: comment_short_id)
    +

    Obtenir le commentaire.

    +

    Scope : workouts:read

    +

    Exemple de requête :

    +
    GET /api/workouts/2oRDfncv6vpRkfp3yrCYHt/comment HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "comment": {
    +      "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +      "id": "T2zeeUXvuy3PLA8MeeUFyk",
    +      "liked": false,
    +      "likes_count": 0,
    +      "mentions": [],
    +      "modification_date": null,
    +      "suspended_at": null,
    +      "text": "Nice!",
    +      "text_html": "Nice!",
    +      "text_visibility": "private",
    +      "user": {
    +        "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +        "followers": 3,
    +        "following": 2,
    +        "nb_workouts": 10,
    +        "picture": true,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "workout_id": null
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • comment_short_id (string) – identifiant court du commentaire

    • +
    +
    +
    En-têtes de requête:
    +
      +
    • Authorization – Jeton “OAuth 2.0 Bearer” pour le commentaire avec une visibilité private et followers_only

    • +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundworkout comment not found

    • +
    +
    +
    +
    + +
    +
    +POST /api/workouts/(string: workout_short_id)/comments
    +

    Poster un commentaire.

    +

    Scope : workouts:write

    +

    Exemple de requête :

    +
    POST /api/workouts/2oRDfncv6vpRkfp3yrCYHt HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "comment": {
    +      "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +      "id": "WJgTwtqFpnPrHYAK5eX9Pw",
    +      "liked": false,
    +      "likes_count": 0,
    +      "mentions": [],
    +      "modification_date": null,
    +      "suspended_at": null,
    +      "text": "Great!",
    +      "text_html": "Great!",
    +      "text_visibility": "private",
    +      "user": {
    +        "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +        "followers": 3,
    +        "following": 2,
    +        "nb_workouts": 10,
    +        "picture": true,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "workout_id": "2oRDfncv6vpRkfp3yrCYHt"
    +    },
    +    "status": "created"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • workout_short_id (string) – identifiant court de la séance

    • +
    +
    +
    Objet JSON de requête:
    +
      +
    • text (string) – contenu du commentaire

    • +
    • text_visibility (string) – niveau de visibilité (public, followers_only, private)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
    +
    +
    +
    + +
    +
    +PATCH /api/comments/(string: comment_short_id)
    +

    Mettre à jour le texte du commentaire.

    +

    Scope : workouts:write

    +

    Exemple de requête :

    +
    PATCH /api/workouts/WJgTwtqFpnPrHYAK5eX9Pw HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "comment": {
    +      "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +      "id": "WJgTwtqFpnPrHYAK5eX9Pw",
    +      "liked": false,
    +      "likes_count": 0,
    +      "mentions": [],
    +      "modification_date": null,
    +      "suspended_at": null,
    +      "text": "Great!",
    +      "text_html": "Great!",
    +      "text_visibility": "private",
    +      "user": {
    +        "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +        "followers": 3,
    +        "following": 2,
    +        "nb_workouts": 10,
    +        "picture": true,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "workout_id": "2oRDfncv6vpRkfp3yrCYHt"
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • comment_short_id (string) – identifiant court du commentaire

    • +
    +
    +
    Objet JSON de requête:
    +
      +
    • text (string) – contenu du commentaire

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
    +
    +
    +
    + +
    +
    +POST /api/comments/(string: comment_short_id)/like
    +

    Ajouter un « like » au commentaire.

    +

    Scope : workouts:write

    +

    Exemple de requête :

    +
    POST /api/comments/WJgTwtqFpnPrHYAK5eX9Pw/like HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "comment": {
    +      "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +      "id": "WJgTwtqFpnPrHYAK5eX9Pw",
    +      "liked": true,
    +      "likes_count": 1,
    +      "mentions": [],
    +      "modification_date": null,
    +      "suspended_at": null,
    +      "text": "Great!",
    +      "text_html": "Great!",
    +      "text_visibility": "private",
    +      "user": {
    +        "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +        "followers": 3,
    +        "following": 2,
    +        "nb_workouts": 10,
    +        "picture": true,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "workout_id": "2oRDfncv6vpRkfp3yrCYHt"
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • comment_short_id (string) – identifiant court du commentaire

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundcomment not found

    • +
    +
    +
    +
    + +
    +
    +POST /api/comments/(string: comment_short_id)/like/undo
    +

    Supprimer le « like » du commentaire.

    +

    Scope : workouts:write

    +

    Exemple de requête :

    +
    POST /api/comments/2oRDfncv6vpRkfp3yrCYHt/like/undo HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "comment": {
    +      "created_at": "Sun, 01 Dec 2024 13:45:34 GMT",
    +      "id": "WJgTwtqFpnPrHYAK5eX9Pw",
    +      "liked": false,
    +      "likes_count": 0,
    +      "mentions": [],
    +      "modification_date": null,
    +      "suspended_at": null,
    +      "text": "Great!",
    +      "text_html": "Great!",
    +      "text_visibility": "private",
    +      "user": {
    +        "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +        "followers": 3,
    +        "following": 2,
    +        "nb_workouts": 10,
    +        "picture": true,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "workout_id": "2oRDfncv6vpRkfp3yrCYHt"
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • comment_short_id (string) – identifiant court du commentaire

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundcomment not found

    • +
    +
    +
    +
    + +
    +
    +POST /api/comments/(string: comment_short_id)/suspension/appeal
    +

    Faire appel de la suspension du commentaire.

    +

    Seul l’auteur du commentaire peut faire appel de la suspension.

    +

    Scope : workouts:write

    +

    Exemple de requête :

    +
    POST /api/comments/WJgTwtqFpnPrHYAK5eX9Pw/suspension/appeal HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 201 CREATED
    +Content-Type: application/json
    +
    +  {
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • comment_short_id (string) – identifiant court du commentaire

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 201 Created – appel créé

    • +
    • 400 Bad Request

        +
      • no text provided

      • +
      • you can appeal only once

      • +
      • workout comment is not suspended

      • +
      • workout comment has no suspension

      • +
      +

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundcomment not found

    • +
    • 500 Internal Server Errorerror, please try again or contact the administrator

    • +
    +
    +
    +
    + +
    +
    +DELETE /api/comments/(string: comment_short_id)
    +

    Supprimer le commentaire de la séance.

    +

    Scope : workouts:write

    +

    Exemple de requête :

    +
    DELETE /api/comments/MzydiCYYfktG3gga2x8AfU HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 204 NO CONTENT
    +Content-Type: application/json
    +
    +
    +
    +
    Paramètres:
    +
      +
    • comment_short_id (string) – identifiant court du commentaire

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/docs/fr/api/configuration.html b/docs/fr/api/configuration.html index 35a330348..2eaf75b9d 100644 --- a/docs/fr/api/configuration.html +++ b/docs/fr/api/configuration.html @@ -3,7 +3,7 @@ - + Configuration - Documentation FitTrackee 0.8.12 @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -331,8 +336,8 @@

    Configuration PATCH /api/config

    Mettre à jour de la configuration de l’application.

    -

    L’utilisateur authentifié doit avoir des droits d’administration.

    Scope : application:write

    +

    Rôle minimum: Administrateur

    Exemple de requête :

    GET /api/config HTTP/1.1
     Content-Type: application/json
    @@ -403,7 +408,11 @@ 

    Configurationvalid email must be provided for admin contact

  • -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 500 Internal Server Errorerror when updating configuration

  • @@ -445,12 +454,12 @@

    Configuration - +
    Next
    -
    Équipements
    +
    Commentaires
    diff --git a/docs/fr/api/equipment_types.html b/docs/fr/api/equipment_types.html index 9dca1053e..b0107f915 100644 --- a/docs/fr/api/equipment_types.html +++ b/docs/fr/api/equipment_types.html @@ -3,7 +3,7 @@ - + Types d’équipement - Documentation FitTrackee 0.8.12 @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -286,7 +291,8 @@

    Types d’équipement
    GET /api/equipment-types
    -

    Obtenir tous les types d’équipement

    +

    Get all types of equipment.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Scope : equipments:read

    Exemple de requête :

    GET /api/equipment-types HTTP/1.1
    @@ -497,7 +503,11 @@ 

    Types d’équipementinvalid token, please log in again

  • -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundequipment_type not found

  • @@ -508,8 +518,8 @@

    Types d’équipement PATCH /api/equipment-types/(int: equipment_type_id)

    Modifier un type d’équipement pour le (dés)activer.

    -

    L’utilisateur authentifié doit avoir des droits d’administration.

    Scope : equipments:write

    +

    Rôle minimum: Administrateur

    Exemple de requête :

    PATCH /api/equipment-types/2 HTTP/1.1
     Content-Type: application/json
    @@ -563,7 +573,11 @@ 

    Types d’équipementinvalid token, please log in again

    -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundequipment_type not found

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -578,12 +592,12 @@

    Types d’équipement - +
    Next
    -
    OAuth2
    +
    Demandes de suivi
    diff --git a/docs/fr/api/equipments.html b/docs/fr/api/equipments.html index 725ea3dd6..927841abf 100644 --- a/docs/fr/api/equipments.html +++ b/docs/fr/api/equipments.html @@ -3,7 +3,7 @@ - + Équipements - Documentation FitTrackee 0.8.12 @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -287,6 +292,7 @@

    Équipements GET /api/equipments

    Obtenir tous les équipements d’un utilisateur. Seul le propriétaire de l’équipement peut voir son équipement.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Scope : equipments:read

    Exemple de requête :

    GET /api/equipments HTTP/1.1
    @@ -369,7 +375,10 @@ 

    Équipementsinvalid token, please log in again

  • -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    +

  • @@ -379,6 +388,7 @@

    Équipements GET /api/equipments/(string: equipment_short_id)

    Obtenir un équipement. Seul le propriétaire de l’équipement peut voir son équipement.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Scope : equipments:read

    Exemple de requête :

    GET /api/equipments/2UkrViYShoAkg8qSUKnUS4 HTTP/1.1
    @@ -453,7 +463,10 @@ 

    Équipementsinvalid token, please log in again

    -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    +

  • 404 Not Foundequipment not found

  • @@ -536,7 +549,11 @@

    Équipementsinvalid token, please log in again

    -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundequipment not found

  • 500 Internal Server ErrorError during equipment save

  • @@ -644,7 +661,11 @@

    Équipementsinvalid token, please log in again

    -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundequipment not found

  • 500 Internal Server ErrorError during equipment update

  • @@ -708,7 +729,11 @@

    Équipementsinvalid token, please log in again

    -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundequipment not found

  • 500 Internal Server ErrorError during equipment save

  • @@ -759,6 +784,7 @@

    Équipements403 Forbidden
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • you cannot delete equipment that has workouts associated with it without 'force' parameter

    @@ -786,14 +812,14 @@

    Équipements - +
    Previous
    -
    Configuration
    +
    Commentaires
    diff --git a/docs/fr/api/follow_requests.html b/docs/fr/api/follow_requests.html new file mode 100644 index 000000000..59308bffe --- /dev/null +++ b/docs/fr/api/follow_requests.html @@ -0,0 +1,557 @@ + + + + + + + + + Demandes de suivi - Documentation FitTrackee 0.8.12 + + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    +
    + + + + + Back to top + +
    + +
    + +
    + +
    +
    +
    +

    Demandes de suivi

    +
    +
    +GET /api/follow-requests
    +

    Obtenir les demandes de suivi à valider, reçues par l’utilisateur authentifié.

    +

    Scope : follow:read

    +

    Exemple de requêtes :

    +
      +
    • sans paramètres

    • +
    +
    GET /api/follow-requests/ HTTP/1.1
    +
    +
    +
      +
    • avec quelques paramètres de requête

    • +
    +
    GET /api/follow-requests?page=1&order=desc  HTTP/1.1
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "data": {
    +    "follow_requests": [
    +      {
    +        "admin": false,
    +        "bio": null,
    +        "birth_date": null,
    +        "created_at": "Thu, 02 Dec 2021 17:50:48 GMT",
    +        "first_name": null,
    +        "followers": 1,
    +        "following": 1,
    +        "last_name": null,
    +        "location": null,
    +        "nb_sports": 0,
    +        "nb_workouts": 0,
    +        "picture": false,
    +        "records": [],
    +        "sports_list": [],
    +        "total_distance": 0.0,
    +        "total_duration": "0:00:00",
    +        "username": "Sam"
    +      }
    +    ]
    +  },
    +  "pagination": {
    +    "has_next": false,
    +    "has_prev": false,
    +    "page": 1,
    +    "pages": 1,
    +    "total": 1
    +  },
    +  "status": "success"
    +}
    +
    +
    +
    +
    Paramètres de requête:
    +
      +
    • page (integer) – page si pagination (par défaut : 1)

    • +
    • per_page (integer) – nombre de demande de suivi par page (par défaut : 10, max : 50)

    • +
    • order (string) – ordre de tri (par défaut : asc)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
    +
    +
    +
    + +
    +
    +POST /api/follow-requests/(user_name)/accept
    +

    Accepter la demande de suivi d’un utilisateur

    +

    Scope : follow:write

    +

    Exemple de requêtes :

    +
    POST /api/follow-requests/Sam/accept HTTP/1.1
    +
    +
    +

    Exemple de réponses :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success",
    +  "message": "Follow request from user 'Sam' is accepted.",
    +}
    +
    +
    +
    +
    Paramètres:
    +
      +
    • user_name (string) – nom de l’utilisateur

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OK – succès

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 400 Bad Request

        +
      • Follow request from user 'user_name' already accepted.

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      • Follow request does not exist.

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +POST /api/follow-requests/(user_name)/reject
    +

    Rejeter la demande de suivi d’un utilisateur.

    +

    Scope : follow:write

    +

    Exemple de requêtes :

    +
    POST /api/follow-requests/Sam/reject HTTP/1.1
    +
    +
    +

    Exemple de réponses :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success",
    +  "message": "Follow request from user 'Sam' is rejected.",
    +}
    +
    +
    +
    +
    Paramètres:
    +
      +
    • user_name (string) – nom de l’utilisateur

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OK – succès

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 400 Bad Request

        +
      • Follow request from user 'user_name' already rejected.

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      • Follow request does not exist.

      • +
      +

    • +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/docs/fr/api/index.html b/docs/fr/api/index.html index c2d5f943d..65eb2b4b8 100644 --- a/docs/fr/api/index.html +++ b/docs/fr/api/index.html @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -288,12 +293,17 @@

    Documentation de l’API
  • Authentification et compte
  • Configuration
  • +
  • Commentaires
  • Équipements
  • Types d’équipement
  • +
  • Demandes de suivi
  • OAuth2
  • +
  • Notifications
  • Records
  • +
  • Signalements
  • Sports
  • Statistiques
  • +
  • Flux de séances
  • Utilisateurs
  • Séances
  • diff --git a/docs/fr/api/notifications.html b/docs/fr/api/notifications.html new file mode 100644 index 000000000..68651628c --- /dev/null +++ b/docs/fr/api/notifications.html @@ -0,0 +1,692 @@ + + + + + + + + + Notifications - Documentation FitTrackee 0.8.12 + + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    +
    + + + + + Back to top + +
    + +
    + +
    + +
    +
    +
    +

    Notifications

    +
    +
    +GET /api/notifications
    +

    Obtenir les notifications de l’utilisateur authentifié.

    +

    Scope : notifications:read

    +

    Exemple de requêtes :

    +
      +
    • sans paramètres :

    • +
    +
    GET /api/notifications HTTP/1.1
    +
    +
    +
      +
    • avec quelques paramètres de requête :

    • +
    +
    GET /api/notifications?page=2&status=unread  HTTP/1.1
    +
    +
    +

    Exemple de réponses :

    +
      +
    • renvoyant au moins une notification :

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "notifications": [
    +      {
    +        "created_at": "Wed, 04 Dec 2024 10:06:35 GMT",
    +        "from": {
    +          "created_at": "Wed, 04 Dec 2024 09:07:08 GMT",
    +          "followers": 0,
    +          "following": 0,
    +          "follows": "pending",
    +          "is_followed_by": "false",
    +          "nb_workouts": 0,
    +          "picture": true,
    +          "role": "admin",
    +          "suspended_at": null,
    +          "username": "admin"
    +        },
    +        "id": 22,
    +        "marked_as_read": false,
    +        "type": "follow_request"
    +      }
    +    ],
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 1,
    +      "total": 1
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • renvoyant aucune notification

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "notifications": [],
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 0,
    +      "total": 0
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres de requête:
    +
      +
    • page (integer) – page si pagination (par défaut : 1)

    • +
    • order (string) – ordre de tri : asc, desc (par défaut : desc)

    • +
    • status (string) – état de lecture de la notification (read, unread)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +PATCH /api/notifications/(int: notification_id)
    +

    Mettre à jour l’état de lecture de la notification reçue par l’utilisateur authentifié.

    +

    Scope : notifications:write

    +

    Exemple de requête :

    +
    PATCH /api/notifications/22 HTTP/1.1
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    + {
    +    "notification": {
    +      "created_at": "Wed, 04 Dec 2024 10:06:35 GMT",
    +      "from": {
    +        "created_at": "Wed, 04 Dec 2024 09:07:08 GMT",
    +        "followers": 0,
    +        "following": 0,
    +        "follows": "pending",
    +        "is_followed_by": "false",
    +        "nb_workouts": 0,
    +        "picture": true,
    +        "role": "admin",
    +        "suspended_at": null,
    +        "username": "admin"
    +      },
    +      "id": 22,
    +      "marked_as_read": true,
    +      "type": "follow_request"
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • notification_id (string) – identifiant de la notification

    • +
    +
    +
    Objet JSON de requête:
    +
      +
    • read_status (boolean) – état de lecture de la notification

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • notification not found

      • +
      +

    • +
    • 500 Internal Server Error

        +
      • error, please try again or contact the administrator

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/notifications/unread
    +

    Obtenir si des notifications non lues existent pour l’utilisateur authentifié.

    +

    Scope : notifications:read

    +

    Exemple de requête :

    +
    GET /api/notifications/unread HTTP/1.1
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    + {
    +    "status": "success",
    +    "unread": false
    +  }
    +
    +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +POST /api/notifications/mark-all-as-read
    +

    Marquer tous les notifications de l’utilisateur authentifié comme lues.

    +

    Scope : notifications:write

    +

    Exemple de requête :

    +
    POST /api/notifications/mark-all-as-read HTTP/1.1
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    + {
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Objet JSON de requête:
    +
      +
    • type (boolean) – type de notifications (optionnel)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 500 Internal Server Error

        +
      • error, please try again or contact the administrator

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/notifications/types
    +

    Obtenir le type des notifications reçues de l’utilisateur authentifié.

    +

    Scope : notifications:read

    +

    Exemple de requêtes :

    +
      +
    • sans paramètres :

    • +
    +
    GET /api/notifications/types HTTP/1.1
    +
    +
    +
      +
    • avec le paramètre de requête :

    • +
    +
    GET /api/notifications/types?status=unread  HTTP/1.1
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    + {
    +    "notification_types": [
    +      "mention"
    +    ],
    +    "status": "success"
    + }
    +
    +
    +
    +
    Paramètres de requête:
    +
      +
    • status (string) – état de lecture de la notification (read, unread)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/docs/fr/api/oauth2.html b/docs/fr/api/oauth2.html index 3d51193f1..5e7a6393c 100644 --- a/docs/fr/api/oauth2.html +++ b/docs/fr/api/oauth2.html @@ -3,7 +3,7 @@ - + OAuth2 - Documentation FitTrackee 0.8.12 @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -288,6 +293,7 @@

    OAuth2 GET /api/oauth/apps

    Obtenir les clients OAuth2 pour l’utilisateur authentifié avec pagination (5 clients/page).

    Ce point d’accès n’est accessible que par le client web FitTrackee.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Exemple de requête :

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • @@ -429,6 +439,7 @@

    OAuth2 GET /api/oauth/apps/(string: client_client_id)

    Obtenir un client OAuth2 avec le “client_id”.

    Ce point d’accès n’est accessible que par le client web FitTrackee.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Exemple de requête :

    GET /api/oauth/apps/o22a27s2aBPUoxJbxV3UjDOx HTTP/1.1
     Content-Type: application/json
    @@ -503,6 +514,7 @@ 

    OAuth2 GET /api/oauth/apps/(int: client_id)/by_id

    Obtenir un client OAuth2 avec l’identifiant (valeur de type entier).

    Ce point d’accès n’est accessible que par le client web FitTrackee.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Exemple de requête :

    GET /api/oauth/apps/1/by_id HTTP/1.1
     Content-Type: application/json
    @@ -577,6 +589,7 @@ 

    OAuth2 DELETE /api/oauth/apps/(int: client_id)

    Delete an OAuth2 client (app).

    Ce point d’accès n’est accessible que par le client web FitTrackee.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Exemple de requête :

    DELETE /api/oauth/apps/1 HTTP/1.1
     Content-Type: application/json
    @@ -618,6 +631,7 @@ 

    OAuth2 POST /api/oauth/apps/(int: client_id)/revoke

    Révoquer tous les tokens associés à un client OAuth2.

    Ce point d’accès n’est accessible que par le client web FitTrackee.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Exemple de requête :

    POST /api/oauth/apps/1/revoke HTTP/1.1
     Content-Type: application/json
    @@ -708,6 +722,10 @@ 

    OAuth2
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • @@ -808,23 +826,23 @@

    OAuth2

    @@ -405,23 +414,23 @@

    Records - +
    Next
    -
    Sports
    +
    Signalements
    - +
    Previous
    -
    OAuth2
    +
    Notifications
    diff --git a/docs/fr/api/reports.html b/docs/fr/api/reports.html new file mode 100644 index 000000000..ceebbdac2 --- /dev/null +++ b/docs/fr/api/reports.html @@ -0,0 +1,1075 @@ + + + + + + + + + Signalements - Documentation FitTrackee 0.8.12 + + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    +
    + + + + + Back to top + +
    + +
    + +
    + +
    +
    +
    +

    Signalements

    +
    +
    +GET /api/reports
    +

    Obtenir les signalements.

    +

    Scope : reports:read

    +

    Rôle minimum: Modérateur

    +

    Exemple de requêtes :

    +
      +
    • sans paramètres :

    • +
    +
    GET /api/reports/ HTTP/1.1
    +
    +
    +
      +
    • avec quelques paramètres de requête :

    • +
    +
    GET /api/reports?page=1&order=desc&order_by=created_at  HTTP/1.1
    +
    +
    +

    Exemple de réponses :

    +
      +
    • renvoyant au moins un signalement :

    • +
    +
    HTTP/1.1 200 SUCCESS
    +Content-Type: application/json
    +
    +  {
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 1,
    +      "total": 1
    +    },
    +    "reports": [
    +      {
    +        "created_at": "Sun, 01 Dec 2024 18:17:30 GMT",
    +        "id": 1,
    +        "is_reported_user_warned": false,
    +        "note": "<REPORT NOTE>",
    +        "object_type": "user",
    +        "reported_by": {
    +          "created_at": "Sun, 01 Dec 2024 17:27:56 GMT",
    +          "email": "moderator@example.com",
    +          "followers": 0,
    +          "following": 0,
    +          "is_active": true,
    +          "nb_workouts": 0,
    +          "picture": false,
    +          "role": "moderator",
    +          "suspended_at": null,
    +          "username": "moderator"
    +        },
    +        "reported_comment": null,
    +        "reported_user": {
    +          "blocked": false,
    +          "created_at": "Sun, 01 Dec 2024 17:27:49 GMT",
    +          "email": "sam@example.com",
    +          "followers": 0,
    +          "following": 0,
    +          "follows": "false",
    +          "is_active": true,
    +          "is_followed_by": "false",
    +          "nb_workouts": 1,
    +          "picture": false,
    +          "role": "user",
    +          "suspended_at": null,
    +          "username": "Sam"
    +        },
    +        "reported_workout": null,
    +        "resolved": false,
    +        "resolved_at": null,
    +        "resolved_by": null,
    +        "updated_at": null
    +      }
    +    ],
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • renvoyant aucun signalement

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 0,
    +      "total": 0
    +    },
    +    "reports": [],
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres de requête:
    +
      +
    • object_type (integer) – type de contenu signalé (comment, user ou workout)

    • +
    • order (string) – ordre de tri : asc, desc (par défaut : desc)

    • +
    • order_by (string) – critère de tri: created_at ou updated_at

    • +
    • page (integer) – page si pagination (par défaut : 1)

    • +
    • reporter (boolean) – nom d’utilisateur du rapporteur

    • +
    • resolved (boolean) – filtrer sur le status des signalements

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 400 Bad Request

        +
      • invalid payload

      • +
      • invalid 'order_by'

      • +
      +

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/reports/(int: report_id)
    +

    Obtenir un signalement.

    +

    Scope : reports:read

    +

    Rôle minimum: Modérateur

    +

    Exemple de requête :

    +
    GET /api/reports/1 HTTP/1.1
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 SUCCESS
    +Content-Type: application/json
    +
    +  {
    +    "report": {
    +      "comments": [],
    +      "created_at": "Sun, 01 Dec 2024 18:17:30 GMT",
    +      "id": 1,
    +      "is_reported_user_warned": false,
    +      "note": "<REPORT NOTE>",
    +      "object_type": "user",
    +      "report_actions": [],
    +      "reported_by": {
    +        "created_at": "Sun, 01 Dec 2024 17:27:56 GMT",
    +        "email": "moderator@example.com",
    +        "followers": 0,
    +        "following": 0,
    +        "is_active": true,
    +        "nb_workouts": 0,
    +        "picture": false,
    +        "role": "moderator",
    +        "suspended_at": null,
    +        "username": "moderator"
    +      },
    +      "reported_comment": null,
    +      "reported_user": {
    +        "blocked": false,
    +        "created_at": "Sun, 01 Dec 2024 17:27:49 GMT",
    +        "email": "sam@example.com",
    +        "followers": 0,
    +        "following": 0,
    +        "follows": "false",
    +        "is_active": true,
    +        "is_followed_by": "false",
    +        "nb_workouts": 1,
    +        "picture": false,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "reported_workout": null,
    +      "resolved": false,
    +      "resolved_at": null,
    +      "resolved_by": null,
    +      "updated_at": null
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 404 Not Found

        +
      • report not found

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/reports/unresolved
    +

    Obtenir si des signalements non résolus existent

    +

    Scope : reports:read

    +

    Rôle minimum: Modérateur

    +

    Exemple de requête :

    +
    POST /api/reports/unresolved HTTP/1.1
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 201 SUCCESS
    +Content-Type: application/json
    +
    +  {
    +    "status": "success",
    +    "unresolved": true
    +  }
    +
    +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +POST /api/reports
    +

    Modifier un signalement.

    +

    Scope : reports:write

    +

    Exemple de requête :

    +
    POST /api/reports HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 201 CREATED
    +Content-Type: application/json
    +
    +
    +
    +
    Objet JSON de requête:
    +
      +
    • note (string) – note décrivant le signalement

    • +
    • object_id (string) – identifiant du contenu signalé

    • +
    • object_type (string) – type du content signalé (comment, workout ou user)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 201 Created – signalement créé

    • +
    • 400 Bad Request

        +
      • invalid payload

      • +
      • users can not report their own comment

      • +
      • users can not report their own profile

      • +
      • users can not report their own workout

      • +
      +

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • comment not found

      • +
      • user not found

      • +
      • workout not found

      • +
      +

    • +
    • 500 Internal Server ErrorError during comment save.

    • +
    +
    +
    +
    + +
    +
    +PATCH /api/reports/(int: report_id)
    +

    Modifier un signalement.

    +

    Scope : reports:write

    +

    Rôle minimum: Modérateur

    +

    Exemple de requête :

    +
    PATCH /api/reports/1 HTTP/1.1
    +
    +
    +

    Exemple de réponse (signalement sur un profil utilisateur):

    +
    HTTP/1.1 200 SUCCESS
    +Content-Type: application/json
    +
    +  {
    +    "report": {
    +      "comments": [
    +        {
    +          "comment": "<REPORT COMMENT>",
    +          "created_at": "Sun, 01 Dec 2024 18:21:38 GMT",
    +          "id": 1,
    +          "report_id": 1,
    +          "user": {
    +            "created_at": "Sun, 01 Dec 2024 17:27:56 GMT",
    +            "email": "moderator@example.com",
    +            "followers": 0,
    +            "following": 0,
    +            "is_active": true,
    +            "nb_workouts": 0,
    +            "picture": false,
    +            "role": "moderator",
    +            "suspended_at": null,
    +            "username": "moderator"
    +          }
    +        }
    +      ],
    +      "created_at": "Sun, 01 Dec 2024 18:17:30 GMT",
    +      "id": 1,
    +      "is_reported_user_warned": false,
    +      "note": "<REPORT NOTE>",
    +      "object_type": "user",
    +      "report_actions": [],
    +      "reported_by": {
    +        "created_at": "Sun, 01 Dec 2024 17:27:56 GMT",
    +        "email": "moderator@example.com",
    +        "followers": 0,
    +        "following": 0,
    +        "is_active": true,
    +        "nb_workouts": 0,
    +        "picture": false,
    +        "role": "moderator",
    +        "suspended_at": null,
    +        "username": "moderator"
    +      },
    +      "reported_comment": null,
    +      "reported_user": {
    +        "blocked": false,
    +        "created_at": "Sun, 01 Dec 2024 17:27:49 GMT",
    +        "email": "sam@example.com",
    +        "followers": 0,
    +        "following": 0,
    +        "follows": "false",
    +        "is_active": true,
    +        "is_followed_by": "false",
    +        "nb_workouts": 1,
    +        "picture": false,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "reported_workout": null,
    +      "resolved": false,
    +      "resolved_at": null,
    +      "resolved_by": null,
    +      "updated_at": "Sun, 01 Dec 2024 18:21:38 GMT"
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • report_id (string) – identifiant du signalement

    • +
    +
    +
    Objet JSON de requête:
    +
      +
    • notes (string) – commentaire du signalement (obligatoire)

    • +
    • resolved (boolean) – état du signalement

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
    +
    +
    +
    + +
    +
    +POST /api/reports/(int: report_id)/actions
    +

    Créer une action liée à un signalement.

    +

    Scope : reports:write

    +

    Rôle minimum: Modérateur

    +

    Exemple de requête :

    +
    POST /api/reports/1/actions HTTP/1.1
    +
    +
    +

    Exemple de réponse (signalement sur un profil utilisateur):

    +
    HTTP/1.1 201 SUCCESS
    +Content-Type: application/json
    +
    +  {
    +    "report": {
    +      "comments": [
    +        {
    +          "comment": "<REPORT COMMENT>",
    +          "created_at": "Sun, 01 Dec 2024 18:21:38 GMT",
    +          "id": 1,
    +          "report_id": 1,
    +          "user": {
    +            "created_at": "Sun, 01 Dec 2024 17:27:56 GMT",
    +            "email": "moderator@example.com",
    +            "followers": 0,
    +            "following": 0,
    +            "is_active": true,
    +            "nb_workouts": 0,
    +            "picture": false,
    +            "role": "moderator",
    +            "suspended_at": null,
    +            "username": "moderator"
    +          }
    +        }
    +      ],
    +      "created_at": "Sun, 01 Dec 2024 18:17:30 GMT",
    +      "id": 1,
    +      "is_reported_user_warned": false,
    +      "note": "<REPORT NOTE>",
    +      "object_type": "user",
    +      "report_actions": [
    +        {
    +          "action_type": "user_warning",
    +          "appeal": null,
    +          "created_at": "Wed, 04 Dec 2024 09:12:25 GMT",
    +          "id": "Hv9KwVDtBHhyfvML7PHovq",
    +          "moderator": {
    +            "created_at": "Sun, 01 Dec 2024 17:27:56 GMT",
    +            "email": "moderator@example.com",
    +            "followers": 0,
    +            "following": 0,
    +            "is_active": true,
    +            "nb_workouts": 0,
    +            "picture": false,
    +            "role": "moderator",
    +            "suspended_at": null,
    +            "username": "moderator"
    +          },
    +          "reason": "<ACTION REASON>",
    +          "report_id": 1,
    +          "user": {
    +            "blocked": false,
    +            "created_at": "Sun, 01 Dec 2024 17:27:49 GMT",
    +            "email": "sam@example.com",
    +            "followers": 0,
    +            "following": 0,
    +            "follows": "false",
    +            "is_active": true,
    +            "is_followed_by": "false",
    +            "nb_workouts": 1,
    +            "picture": false,
    +            "role": "user",
    +            "suspended_at": null,
    +            "username": "Sam"
    +          }
    +        }
    +      ],
    +      "reported_by": {
    +        "created_at": "Sun, 01 Dec 2024 17:27:56 GMT",
    +        "email": "moderator@example.com",
    +        "followers": 0,
    +        "following": 0,
    +        "is_active": true,
    +        "nb_workouts": 0,
    +        "picture": false,
    +        "role": "moderator",
    +        "suspended_at": null,
    +        "username": "moderator"
    +      },
    +      "reported_comment": null,
    +      "reported_user": {
    +        "blocked": false,
    +        "created_at": "Sun, 01 Dec 2024 17:27:49 GMT",
    +        "email": "sam@example.com",
    +        "followers": 0,
    +        "following": 0,
    +        "follows": "false",
    +        "is_active": true,
    +        "is_followed_by": "false",
    +        "nb_workouts": 1,
    +        "picture": false,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      },
    +      "reported_workout": null,
    +      "resolved": false,
    +      "resolved_at": null,
    +      "resolved_by": null,
    +      "updated_at": "Sun, 01 Dec 2024 18:21:38 GMT"
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • report_id (string) – identifiant du signalement

    • +
    +
    +
    Objet JSON de requête:
    +
      +
    • action_type (string) – action type (expected value: +user_suspension, user_unsuspension, user_warning, +comment_suspension, comment_unsuspension, +workout_suspension, workout_unsuspension)

    • +
    • comment_id (string) – identifiant du commentaire concerné par l’action (type: comment_suspension, comment_suspension)

    • +
    • reason (string) – texte expliquent la raison de l’action

    • +
    • username (string) – nom de l’utilisateur concerné par l’action (type: comment_suspension, comment_suspension)

    • +
    • workout_id (string) – identifiant de la séance concernée par l’action (type: comment_suspension, comment_suspension)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
    +
    +
    +
    + +
    +
    +PATCH /api/appeals/(string: appeal_id)
    +

    Gérer la demande d’appel.

    +

    Scope : users:write

    +

    Rôle minimum: Modérateur

    +

    Exemple de requête :

    +
    POST /api/appeals/Z2Ze5qZrnMVmnDejPphASk HTTP/1.1
    +
    +
    +

    Exemple de réponse (signalement sur un profil utilisateur):

    +
    HTTP/1.1 201 SUCCESS
    +Content-Type: application/json
    +
    +  {
    +    "appeal": {
    +      "approved": true,
    +      "created_at": "Wed, 04 Dec 2024 09:29:18 GMT",
    +      "id": "Z2Ze5qZrnMVmnDejPphASk",
    +      "moderator": {
    +        "created_at": "Sun, 01 Dec 2024 17:27:56 GMT",
    +        "email": "moderator@example.com",
    +        "followers": 0,
    +        "following": 0,
    +        "is_active": true,
    +        "nb_workouts": 0,
    +        "picture": false,
    +        "role": "moderator",
    +        "suspended_at": null,
    +        "username": "moderator"
    +      },
    +      "reason": "<REASON>",
    +      "text": "<APPEAL TEXT>",
    +      "updated_at": "Wed, 04 Dec 2024 09:30:21 GMT",
    +      "user": {
    +        "blocked": false,
    +        "created_at": "Sun, 01 Dec 2024 17:27:49 GMT",
    +        "email": "sam@example.com",
    +        "followers": 0,
    +        "following": 0,
    +        "follows": "false",
    +        "is_active": true,
    +        "is_followed_by": "false",
    +        "nb_workouts": 1,
    +        "picture": false,
    +        "role": "user",
    +        "suspended_at": null,
    +        "username": "Sam"
    +      }
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • appeal_id (string) – identifiant de l’appel

    • +
    +
    +
    Objet JSON de requête:
    +
      +
    • approved (boolean) – true si l’appel est accepté, false si rejeté

    • +
    • reason (string) – text expliquant pourquoi l’appel est accepté ou rejeté

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 400 Bad Request

        +
      • invalid payload

      • +
      • comment already reactivated

      • +
      • user account has already been reactivated

      • +
      • workout already reactivated

      • +
      +

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      +

    • +
    • 404 Not Found

        +
      • appeal not found

      • +
      +

    • +
    • 500 Internal Server Error

        +
      • Error during report save.

      • +
      +

    • +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/docs/fr/api/sports.html b/docs/fr/api/sports.html index 26771459b..fe70306e3 100644 --- a/docs/fr/api/sports.html +++ b/docs/fr/api/sports.html @@ -3,7 +3,7 @@ - + Sports - Documentation FitTrackee 0.8.12 @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -286,7 +291,8 @@

    Sports
    GET /api/sports
    -

    Obtenir tous les sports

    +

    Get all sports.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Scope : workouts:read

    Exemple de requête :

    GET /api/sports HTTP/1.1
    @@ -434,7 +440,7 @@ 

    Sports

    En-têtes de requête:
    Codes d’état:
    @@ -519,6 +525,10 @@

    Sports
  • invalid token, please log in again

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundsport not found

  • @@ -531,6 +541,7 @@

    Sports

    Modifier un sport.

    L’utilisateur authentifié doit avoir des droits d’administration.

    Scope : workouts:write

    +

    Rôle minimum: Administrateur

    Exemple de requête :

    PATCH /api/sports/1 HTTP/1.1
     Content-Type: application/json
    @@ -594,14 +605,21 @@ 

    Sports
    Codes d’état:
    @@ -625,14 +643,14 @@

    Sports

    - +
    Previous
    -
    Records
    +
    Signalements
    diff --git a/docs/fr/api/stats.html b/docs/fr/api/stats.html index 33523e83c..ea47dffb3 100644 --- a/docs/fr/api/stats.html +++ b/docs/fr/api/stats.html @@ -3,7 +3,7 @@ - + Statistiques - Documentation FitTrackee 0.8.12 @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -433,7 +438,14 @@

    Statistiquesinvalid token, please log in again

  • -
  • 404 Not Founduser does not exist

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • +
  • 404 Not Found

      +
    • user does not exist

    • +
    +

  • @@ -579,6 +591,10 @@

    Statistiquesinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Found

    • user does not exist

    • sport does not exist

    • @@ -594,6 +610,7 @@

      StatistiquesGET /api/stats/all

      Obtenir toutes les statistiques de l’application.

      Scope : workouts:read

      +

      Rôle minimum: Modérateur

      Exemple de requêtes :

      GET /api/stats/all HTTP/1.1
       
      @@ -628,7 +645,11 @@

      Statistiquesinvalid token, please log in again

  • -
  • 403 Forbiddenyou do not have permissions

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • @@ -641,12 +662,12 @@

    Statistiques - +
    Next
    -
    Utilisateurs
    +
    Flux de séances
    diff --git a/docs/fr/api/timeline.html b/docs/fr/api/timeline.html new file mode 100644 index 000000000..adc181ca7 --- /dev/null +++ b/docs/fr/api/timeline.html @@ -0,0 +1,510 @@ + + + + + + + + + Flux de séances - Documentation FitTrackee 0.8.12 + + + + + + + + + + + + + + + + + + + Contents + + + + + + Menu + + + + + + + + Expand + + + + + + Light mode + + + + + + + + + + + + + + Dark mode + + + + + + + Auto light/dark, in light mode + + + + + + + + + + + + + + + Auto light/dark, in dark mode + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Skip to content + + + +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    +
    +
    + + + + + Back to top + +
    + +
    + +
    + +
    +
    +
    +

    Flux de séances

    +
    +
    +GET /api/timeline
    +

    Obtenir les séances visibles par l’utilisateur authentifié.

    +

    Scope : workouts:read

    +

    Exemple de requêtes :

    +
      +
    • sans paramètres :

    • +
    +
    GET /api/timeline HTTP/1.1
    +
    +
    +
      +
    • avec quelques paramètres de requête :

    • +
    +
    GET /api/timeline?page=2  HTTP/1.1
    +
    +
    +

    Exemple de réponses :

    +
      +
    • renvoyant au moins une séance :

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "workouts": [
    +        {
    +          "ascent": null,
    +          "ave_speed": 10.0,
    +          "bounds": [],
    +          "creation_date": "Sun, 14 Jul 2019 13:51:01 GMT",
    +          "descent": null,
    +          "description": null,
    +          "distance": 10.0,
    +          "duration": "0:17:04",
    +          "equipments": [],
    +          "id": "kjxavSTUrJvoAh2wvCeGEF",
    +          "map": null,
    +          "max_alt": null,
    +          "max_speed": 10.0,
    +          "min_alt": null,
    +          "modification_date": null,
    +          "moving": "0:17:04",
    +          "next_workout": 3,
    +          "notes": null,
    +          "pauses": null,
    +          "previous_workout": null,
    +          "records": [
    +            {
    +              "id": 4,
    +              "record_type": "MS",
    +              "sport_id": 1,
    +              "user": "admin",
    +              "value": 10.0,
    +              "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT",
    +              "workout_id": "kjxavSTUrJvoAh2wvCeGEF"
    +            },
    +            {
    +              "id": 13,
    +              "record_type": "HA",
    +              "sport_id": 1,
    +              "user": "Sam",
    +              "value": 43.97,
    +              "workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
    +              "workout_id": "hvYBqYBRa7wwXpaStWR4V2"
    +            },
    +            {
    +              "id": 3,
    +              "record_type": "LD",
    +              "sport_id": 1,
    +              "user": "admin",
    +              "value": "0:17:04",
    +              "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT",
    +              "workout_id": "kjxavSTUrJvoAh2wvCeGEF"
    +            },
    +            {
    +              "id": 2,
    +              "record_type": "FD",
    +              "sport_id": 1,
    +              "user": "admin",
    +              "value": 10.0,
    +              "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT",
    +              "workout_id": "kjxavSTUrJvoAh2wvCeGEF"
    +            },
    +            {
    +              "id": 1,
    +              "record_type": "AS",
    +              "sport_id": 1,
    +              "user": "admin",
    +              "value": 10.0,
    +              "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT",
    +              "workout_id": "kjxavSTUrJvoAh2wvCeGEF"
    +            }
    +          ],
    +          "segments": [],
    +          "sport_id": 1,
    +          "title": null,
    +          "user": "admin",
    +          "weather_end": null,
    +          "weather_start": null,
    +          "with_gpx": false,
    +          "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT"
    +        }
    +      ]
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • renvoyant aucune séance

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +      "data": {
    +          "workouts": []
    +      },
    +      "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres de requête:
    +
      +
    • page (integer) – page si pagination (par défaut : 1)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 500 Internal Server Errorerror, please try again or contact the administrator

    • +
    +
    +
    +
    + +
    + +
    +
    + +
    + +
    +
    + + + + + + + + \ No newline at end of file diff --git a/docs/fr/api/users.html b/docs/fr/api/users.html index 5c42cf423..aff12f360 100644 --- a/docs/fr/api/users.html +++ b/docs/fr/api/users.html @@ -3,7 +3,7 @@ - + Utilisateurs - Documentation FitTrackee 0.8.12 @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -286,7 +291,7 @@

    Utilisateurs
    GET /api/users
    -

    Obtenir tous les utilisateurs (quel que soit le statut de leur compte), si l’utilisateur authentifié a des droits d’administration.

    +

    Obtenir tous les utilisateurs (quel que soit le statut de leur compte), si l’utilisateur authentifié a des droits d’administration le courriel est renvoyé.

    Ne renvoie les préférences de l’utilisateur que pour l’utilisateur authentifié.

    Scope : users:read

    Exemple de requête :

    @@ -318,11 +323,13 @@

    Utilisateurs "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "admin@example.com", "first_name": null, - "is_admin": true, - "imperial_units": false, - "language": "en", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -378,11 +385,10 @@

    Utilisateurs 4, 6 ], - "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", "username": "admin", - "weekm": false + "workouts_visibility": "private" }, { "admin": false, @@ -391,19 +397,22 @@

    Utilisateurs "created_at": "Sat, 20 Jul 2019 11:27:03 GMT", "email": "sam@example.com", "first_name": null, - "is_admin": false, - "language": "fr", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 0, "nb_workouts": 0, "picture": false, "records": [], "sports_list": [], - "timezone": "Europe/Paris", "total_distance": 0, "total_duration": "0:00:00", - "username": "sam" + "username": "sam", + "workouts_visibility": "private" } ] }, @@ -418,7 +427,11 @@

    Utilisateursasc, desc (par défaut : asc)

  • -
  • order_by (string) – critères de tri : username, created_at, workouts_count, admin, is_active (par défaut : username)

  • +
  • order_by (string) – critères de tri : username, created_at, workouts_count, role, is_active (par défaut : username)

  • +
  • with_following (boolean) – renvoie les utilisateurs dont le profil est masqué si true

  • +
  • with_hidden_users (boolean) – renvoie les utilisateurs dont le profil est masqué si true (seulement si l’utilisateur a les droits d’administration - pour l’administration des utilisateurs

  • +
  • with_inactive (boolean) – renvoie les utilisateurs inactifs si true (seulement si l’utilisateur a les droits d’administration - pour l’administration des utilisateurs

  • +
  • with_suspended (boolean) – renvoie les utilisateurs suspendus si true (seulement si l’utilisateur a les droits d’administration - pour l’administration des utilisateurs

  • En-têtes de requête:
    @@ -435,6 +448,10 @@

    Utilisateursinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • @@ -443,15 +460,18 @@

    Utilisateurs
    GET /api/users/(user_name)
    -

    Obtenir les information d’un utilisateur. Seul l’utilisateur disposant des droits d’administrateur peut obtenir les informations des autres utilisateurs.

    +

    Obtenir les informations de l’utilisateur. Si l’utilisateur est authentifié, ce point d’entrée renvoie les relations. Si l’utilisateur a des droits d’administrations, l’email est renvoyé.

    Ne renvoie les préférences de l’utilisateur que pour l’utilisateur authentifié.

    -

    Scope : users:read

    +

    Scope : users:read pour le client OAuth 2.0

    Exemple de requête :

    GET /api/users/admin HTTP/1.1
     Content-Type: application/json
     

    Exemple de réponse :

    +
      +
    • quand l’utilisateur est authentifié

    • +
    +
      +
    • sans authentification

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "data": [
    +    {
    +      "admin": true,
    +      "bio": null,
    +      "birth_date": null,
    +      "created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
    +      "email": "admin@example.com",
    +      "first_name": null,
    +      "followers": 0,
    +      "following": 0,
    +      "follows": "false",
    +      "is_followed_by": "false",
    +      "last_name": null,
    +      "location": null,
    +      "map_visibility": "private",
    +      "nb_workouts": 6,
    +      "picture": false,
    +      "username": "admin",
    +      "workouts_visibility": "private"
         }
       ],
       "status": "success"
    @@ -542,7 +596,7 @@ 

    UtilisateursEn-têtes de requête:
    Codes d’état:
    @@ -554,6 +608,10 @@

    Utilisateursinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Found

    • user does not exist

    @@ -605,9 +663,10 @@

    Utilisateursusers:write

    +

    Rôle minimum: Administrateur

    Exemple de requête :

  • 400 Bad Request

    • invalid payload

    • +
    • invalid role

    • valid email must be provided

    • new email must be different than current email

    @@ -730,8 +792,15 @@

    Utilisateursinvalid token, please log in again

  • -
  • 403 Forbiddenyou do not have permissions

  • -
  • 404 Not Founduser does not exist

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • +
  • 404 Not Found

      +
    • user does not exist

    • +
    +

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -743,7 +812,8 @@

    UtilisateursDELETE /api/users/(user_name)

    Supprimer un compte utilisateur.

    Un utilisateur ne peut supprimer que son propre compte.

    -

    Un administrateur peut supprimer tous les comptes sauf le sien s’il est le seul administrateur.

    +

    Un utilisateur avec des droits d’administration peut supprimer tous les comptes sauf le sien s’il est le seul utilisateur avec des droits d’administration.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    Scope : users:write

    Exemple de requête :

    DELETE /api/users/john_doe HTTP/1.1
    @@ -780,13 +850,636 @@ 

    Utilisateursyou can not delete your account, no other user has admin rights

    -
  • 404 Not Founduser does not exist

  • +
  • 404 Not Found

      +
    • user does not exist

    • +
    +

  • +
  • 500 Internal Server Errorerror, please try again or contact the administrator

  • + +

    + +

    + +
    +
    +POST /api/users/(user_name)/follow
    +

    Envoyer une demande de suivi à un utilisateur.

    +

    Scope : follow:write

    +

    Exemple de requête :

    +
    POST /api/users/john_doe/follow HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success",
    +  "message": "Follow request to user 'john_doe' is sent.",
    +}
    +
    +
    +
    +
    Paramètres:
    +
      +
    • user_name (string) – nom de l’utilisateur

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OK – succès

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      +

    • +
    • 500 Internal Server Errorerror, please try again or contact the administrator

    • +
    +
    +
    +
    + +
    +
    +POST /api/users/(user_name)/unfollow
    +

    Arrêter de suivre un utilisateur.

    +

    Scope : follow:write

    +

    Exemple de requête :

    +
    POST /api/users/john_doe/unfollow HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success",
    +  "message": "Undo for a follow request to user 'john_doe' is sent.",
    +}
    +
    +
    +
    +
    Paramètres:
    +
      +
    • user_name (string) – nom de l’utilisateur

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OK – succès

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      +

    • 500 Internal Server Errorerror, please try again or contact the administrator

    +
    +
    +GET /api/users/(user_name)/followers
    +

    Obtenir les utilisateurs suivants l’utilisateur authentifié. Si l’utilisateur a des droits d’administration, l’email est également renvoyé.

    +

    Scope : follow:read

    +

    Exemple de requête :

    +
      +
    • sans paramètres

    • +
    +
    GET /api/users/sam/followers HTTP/1.1
    +Content-Type: application/json
    +
    +
    +
      +
    • avec le paramètre “page” :

    • +
    +
    GET /api/users/sam/followers?page=1 HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "data": {
    +    "followers": [
    +      {
    +        "admin": false,
    +        "bio": null,
    +        "birth_date": null,
    +        "created_at": "Thu, 02 Dec 2021 17:50:48 GMT",
    +        "first_name": null,
    +        "followers": 1,
    +        "following": 1,
    +        "follows": "true",
    +        "is_followed_by": "false",
    +        "last_name": null,
    +        "location": null,
    +        "map_visibility": "followers_only",
    +        "nb_sports": 0,
    +        "nb_workouts": 0,
    +        "picture": false,
    +        "records": [],
    +        "sports_list": [],
    +        "total_distance": 0.0,
    +        "total_duration": "0:00:00",
    +        "username": "JohnDoe",
    +        "workouts_visibility": "followers_only"
    +      }
    +    ]
    +  },
    +  "pagination": {
    +    "has_next": false,
    +    "has_prev": false,
    +    "page": 1,
    +    "pages": 1,
    +    "total": 1
    +  },
    +  "status": "success"
    +}
    +
    +
    +
    +
    Paramètres:
    +
      +
    • user_name (string) – nom de l’utilisateur

    • +
    +
    +
    Paramètres de requête:
    +
      +
    • page (integer) – page si pagination (par défaut : 1)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OK – succès

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/users/(user_name)/following
    +

    Obtenir les utilisateurs suivis par l’utilisateur authentifié. Si l’utilisateur a des droits d’administration, l’email est également renvoyé.

    +

    Scope : follow:read

    +

    Exemple de requête :

    +
      +
    • sans paramètres

    • +
    +
    GET /api/users/sam/following HTTP/1.1
    +Content-Type: application/json
    +
    +
    +
      +
    • avec le paramètre “page” :

    • +
    +
    GET /api/users/sam/following?page=1 HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "data": {
    +    "following": [
    +      {
    +        "admin": false,
    +        "bio": null,
    +        "birth_date": null,
    +        "created_at": "Thu, 02 Dec 2021 17:50:48 GMT",
    +        "first_name": null,
    +        "followers": 1,
    +        "following": 1,
    +        "follows": "false",
    +        "is_followed_by": "true",
    +        "last_name": null,
    +        "location": null,
    +        "map_visibility": "followers_only",
    +        "nb_sports": 0,
    +        "nb_workouts": 0,
    +        "picture": false,
    +        "records": [],
    +        "sports_list": [],
    +        "total_distance": 0.0,
    +        "total_duration": "0:00:00",
    +        "username": "JohnDoe",
    +        "workouts_visibility": "followers_only"
    +      }
    +    ]
    +  },
    +  "pagination": {
    +    "has_next": false,
    +    "has_prev": false,
    +    "page": 1,
    +    "pages": 1,
    +    "total": 1
    +  },
    +  "status": "success"
    +}
    +
    +
    +
    +
    Paramètres:
    +
      +
    • user_name (string) – nom de l’utilisateur

    • +
    +
    +
    Paramètres de requête:
    +
      +
    • page (integer) – page si pagination (par défaut : 1)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OK – succès

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user does not exist

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +POST /api/users/(user_name)/block
    +

    Bloquer un utilisateur

    +

    Scope : users:write

    +

    Exemple de requête :

    +
    GET /api/users/sam/block HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success"
    +}
    +
    +
    +
    +
    Paramètres:
    +
      +
    • user_name (string) – nom de l’utilisateur

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OK – succès

    • +
    • 400 Bad Request

        +
      • invalid payload

      • +
      +

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user not found

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +POST /api/users/(user_name)/unblock
    +

    Débloquer un utilisateur

    +

    Scope : users:write

    +

    Exemple de requête :

    +
    GET /api/users/sam/unblock HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +{
    +  "status": "success"
    +}
    +
    +
    +
    +
    Paramètres:
    +
      +
    • user_name (string) – nom de l’utilisateur

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OK – succès

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Found

        +
      • user not found

      • +
      +

    • +
    +
    +
    +
    + +
    +
    +GET /api/users/(user_name)/sanctions
    +

    Obtenir les sanctions de l’utilisateur.

    +

    Les sanctions sont renvoyées uniquement si: - l’utilisateur est l’utilisateur authentifié - l’utilisateur a des droits de modération.

    +

    Un utilisateur suspendu peut accéder à ce point d’accès.

    +

    Scope : users:read

    +

    Exemple de requêtes :

    +
      +
    • sans paramètres :

    • +
    +
    GET /api/users/Sam/sanctions HTTP/1.1
    +
    +
    +
      +
    • avec des paramètres :

    • +
    +
    GET /api/users/Sam/sanctions?page=2 HTTP/1.1
    +
    +
    +

    Exemple de réponses :

    +
      +
    • si des sanctions existent (réponse avec des droits de modération)

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "sanctions": [
    +        {
    +          "action_type": "workout_suspension",
    +          "appeal": {
    +            "approved": null,
    +            "created_at": "Wed, 04 Dec 2024 11:00:04 GMT",
    +            "id": "2ULe2hWhSnYCS2VHbsikB9",
    +            "moderator": null,
    +            "reason": null,
    +            "text": "<APPEAL TEXT>",
    +            "updated_at": null,
    +            "user": {
    +              "blocked": false,
    +              "created_at": "Wed, 04 Dec 2024 09:07:06 GMT",
    +              "email": "sam@example.com",
    +              "followers": 0,
    +              "following": 0,
    +              "follows": false,
    +              "is_active": true,
    +              "is_followed_by": false,
    +              "nb_workouts": 1,
    +              "picture": false,
    +              "role": "user",
    +              "suspended_at": null,
    +              "username": "Sam"
    +            }
    +          },
    +          "created_at": "Wed, 04 Dec 2024 10:59:45 GMT",
    +          "id": "6dxczvMrhkAR72shUz9Pwd",
    +          "moderator": {
    +            "blocked": false,
    +            "created_at": "Wed, 01 Mar 2023 12:31:17 GMT",
    +            "email": "admin@example.com",
    +            "followers": 0,
    +            "following": 0,
    +            "follows": "false",
    +            "is_active": true,
    +            "is_followed_by": "false",
    +            "nb_workouts": 0,
    +            "picture": true,
    +            "role": "admin",
    +            "suspended_at": null,
    +            "username": "admin"
    +          },
    +          "reason": "<SUSPENSION REASON>",
    +          "report_id": 2,
    +          "user": {
    +            "blocked": false,
    +            "created_at": "Sun, 01 Dec 2024 17:27:49 GMT",
    +            "email": "sam@example.com",
    +            "followers": 0,
    +            "following": 0,
    +            "follows": "false",
    +            "is_active": true,
    +            "is_followed_by": "false",
    +            "nb_workouts": 1,
    +            "picture": false,
    +            "role": "user",
    +            "suspended_at": null,
    +            "username": "Sam"
    +          }
    +        }
    +      ]
    +    },
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 1,
    +      "total": 1
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • si des sanctions existent (réponse pour l’utilisateur authentifié)

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "sanctions": [
    +        {
    +          "action_type": "workout_suspension",
    +          "appeal": {
    +            "approved": null,
    +            "created_at": "Wed, 04 Dec 2024 16:50:55 GMT",
    +            "id": "kcj6hdGQqPKaaKQmfQj8Jv",
    +            "reason": null,
    +            "text": "<APPEAL TEXT>",
    +            "updated_at": null
    +          },
    +          "created_at": "Wed, 04 Dec 2024 16:50:44 GMT",
    +          "id": "6nvxvAyoh9Zkr8RMXhu54T",
    +          "reason": "<SUSPENSION REASON>"
    +        }
    +      ]
    +    },
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 1,
    +      "total": 1
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
      +
    • aucune sanction

    • +
    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "sanctions": []
    +    },
    +    "pagination": {
    +      "has_next": false,
    +      "has_prev": false,
    +      "page": 1,
    +      "pages": 0,
    +      "total": 0
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • user_name (string) – nom de l’utilisateur

    • +
    +
    +
    Paramètres de requête:
    +
      +
    • page (integer) – page si pagination (par défaut : 1)

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OK – succès

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      +

    • +
    • 404 Not Found

        +
      • user not found

      • +
      +

    • +
    +
    +
    +
    + @@ -803,14 +1496,14 @@

    Utilisateurs - +
    Previous
    -
    Statistiques
    +
    Flux de séances
    diff --git a/docs/fr/api/workouts.html b/docs/fr/api/workouts.html index a1dabdc89..b2d7d2310 100644 --- a/docs/fr/api/workouts.html +++ b/docs/fr/api/workouts.html @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -322,7 +327,10 @@

    Séances "duration": "0:17:04", "equipments": [], "id": "kjxavSTUrJvoAh2wvCeGEF", + "liked": false, + "likes_count": 0, "map": null, + "map_visibility": "private", "max_alt": null, "max_speed": 10.0, "min_alt": null, @@ -381,12 +389,24 @@

    Séances ], "segments": [], "sport_id": 1, + "suspended": false, + "suspended_at": null, "title": null, - "user": "admin", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "weather_end": null, "weather_start": null, "with_gpx": false, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_visibility": "private" } ] }, @@ -429,7 +449,7 @@

    Séancesave_speed, distance, duration, workout_date (default: workout_date)

  • -
  • equipment_id (string) – id de l’équipement (si valeur à “none”, seules les séances sans équipements seront renvoyées)

  • +
  • equipment_id (string) – identifiant de l’équipement (si valeur à none, seules les séances sans équipements seront renvoyées)

  • notes (string) – une partie (ou la totalité) des notes de la séance, la correspondance avec les notes ne tient pas compte des majuscules et des minuscules

  • description (string) – une partie de la description de la séance, la correspondance avec la description ne tient pas compte des majuscules et des minuscules

  • @@ -448,6 +468,10 @@

    Séancesinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -458,7 +482,6 @@

    Séances GET /api/workouts/(string: workout_short_id)

    Obtenir une séance.

    -

    Scope : workouts:read

    Exemple de requête :

    @@ -555,7 +597,6 @@

    Séances GET /api/workouts/(string: workout_short_id)/gpx

    Obtenir un fichier gpx pour une séance affichée sur une carte avec Leaflet.

    -

    Scope : workouts:read

    Exemple de requête :

    GET /api/workouts/kjxavSTUrJvoAh2wvCeGEF/gpx HTTP/1.1
     Content-Type: application/json
    @@ -582,7 +623,7 @@ 

    SéancesEn-têtes de requête:
      -
    • Authorization – Jeton “OAuth 2.0 Bearer”

    • +
    • Authorization – Jeton “OAuth 2.0 Bearer” pour la séance avec une visibilité private et followers_only pour la carte

    Codes d’état:
    @@ -594,6 +635,11 @@

    Séancesinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Found

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Found

  • +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundworkout not found

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -753,9 +807,8 @@

    Séances GET /api/workouts/(string: workout_short_id)/gpx/segment/(int: segment_id)

    Obtenir un fichier gpx pour le segment d’une séance pour l’afficher sur la carte avec Leaflet.

    -

    Scope : workouts:read

    Exemple de requête :

    -
    GET /api/workouts/kjxavSTUrJvoAh2wvCeGEF/gpx/segment/0 HTTP/1.1
    +
    GET /api/workouts/kjxavSTUrJvoAh2wvCeGEF/gpx/segment/1 HTTP/1.1
     Content-Type: application/json
     
    @@ -781,7 +834,7 @@

    SéancesEn-têtes de requête:
      -
    • Authorization – Jeton “OAuth 2.0 Bearer”

    • +
    • Authorization – Jeton “OAuth 2.0 Bearer” pour la séance avec une visibilité private et followers_only pour la carte

    Codes d’état:
    @@ -794,6 +847,11 @@

    Séancesinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions

    • +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundworkout not found

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -830,6 +888,10 @@

    Séancesinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundmap does not exist

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -892,6 +954,10 @@

    Séancesinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Found

  • +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 413 Request Entity Too Largeerror during picture update: file size exceeds 1.0MB

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -1059,8 +1173,12 @@

    Séances "description": null, "distance": 10.0, "duration": "0:17:04", + "id": "Kd5wyhwLtVozw6o3AU5M4J", + "liked": false, + "likes_count": 0, "equipments": [], "map": null, + "map_visibility": "private", "max_alt": null, "max_speed": 10.0, "min_alt": null, @@ -1110,13 +1228,25 @@

    Séances ], "segments": [], "sport_id": 1, + "suspended": false, + "suspended_at": null, "title": null, - "user": "admin", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "uuid": "kjxavSTUrJvoAh2wvCeGEF" "weather_end": null, "weather_start": null, "with_gpx": false, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_visibility": "private" } ] }, @@ -1162,6 +1292,10 @@

    Séancesinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -1174,7 +1308,7 @@

    Séancesworkouts:write

    Exemple de requête :

    -
    PATCH /api/workouts/1 HTTP/1.1
    +
    PATCH /api/workouts/2oRDfncv6vpRkfp3yrCYHt HTTP/1.1
     Content-Type: application/json
     
    @@ -1195,7 +1329,11 @@

    Séances "distance": 10.0, "duration": "0:17:04", "equipments": [], + "id": "2oRDfncv6vpRkfp3yrCYHt", + "liked": false, + "likes_count": 0, "map": null, + "map_visibility": "private", "max_alt": null, "max_speed": 10.0, "min_alt": null, @@ -1245,13 +1383,25 @@

    Séances ], "segments": [], "sport_id": 1, + "suspended": false, + "suspended_at": null, "title": null, - "user": "admin", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "uuid": "kjxavSTUrJvoAh2wvCeGEF" "weather_end": null, "weather_start": null, "with_gpx": false, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_visibility": "private" } ] }, @@ -1272,11 +1422,13 @@

    Séancesprivate, followers_only or public)

  • notes (string) – notes (longueur max. : 500 caractères, dans le cas contraire elles seront tronquées)

  • sport_id (integer) – identifiant du sport de la séance

  • title (string) – titre de la séance (longueur max. : 255 caractères, dans le cas contraire il sera tronqué)

  • -
  • equipment_ids (array of strings) – l’id de l’équipement à associer à cette séance (si un équipement est déjà associé, il sera remplacé). Note: pour le moment, un seul équipement peut être associé. Si la liste est vide, l’équipement sera supprimé.

  • workout_date (string) – date de la séance dans le fuseau horaire de l’utilisateur (format : %Y-%m-%d %H:%M) (seulement pour les séances sans gpx)

  • +
  • workout_visibility (string) – visibilité de la séance (private, followers_only or public)

  • En-têtes de requête:
    @@ -1302,6 +1454,10 @@

    Séancesinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • 404 Not Foundworkout not found

  • 500 Internal Server Errorerror, please try again or contact the administrator

  • @@ -1344,6 +1500,263 @@

    Séancesinvalid token, please log in again

    +
  • 403 Forbidden

      +
    • you do not have permissions, your account is suspended

    • +
    +

  • +
  • 404 Not Foundworkout not found

  • +
  • 500 Internal Server Errorerror, please try again or contact the administrator

  • + + + + + +
    +
    +POST /api/workouts/(string: workout_short_id)/like
    +

    Ajouter un « like » à une séance.

    +

    Scope : workouts:write

    +

    Exemple de requête :

    +
    POST /api/workouts/HgzYFXgvWKCEpdq3vYk67q/like HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "workouts": [
    +        {
    +          "ascent": 231.208,
    +          "ave_speed": 13.12,
    +          "bounds": [],
    +          "creation_date": "Wed, 04 Dec 2024 09:18:26 GMT",
    +          "descent": 234.208,
    +          "description": null,
    +          "distance": 23.41,
    +          "duration": "3:32:27",
    +          "equipments": [],
    +          "id": "HgzYFXgvWKCEpdq3vYk67q",
    +          "liked": true,
    +          "likes_count": 1,
    +          "map": null,
    +          "map_visibility": "private",
    +          "max_alt": 104.44,
    +          "max_speed": 25.59,
    +          "min_alt": 19.0,
    +          "modification_date": "Wed, 04 Dec 2024 16:45:14 GMT",
    +          "moving": "1:47:04",
    +          "next_workout": null,
    +          "notes": null,
    +          "pauses": "1:23:51",
    +          "previous_workout": null,
    +          "records": [],
    +          "segments": [],
    +          "sport_id": 1,
    +          "suspended": false,
    +          "title": "Cycling (Sport) - 2016-04-26 16:42:27",
    +          "user": {
    +            "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +            "followers": 0,
    +            "following": 0,
    +            "nb_workouts": 1,
    +            "picture": false,
    +            "role": "user",
    +            "suspended_at": null,
    +            "username": "Sam"
    +          },
    +          "weather_end": null,
    +          "weather_start": null,
    +          "with_gpx": false,
    +          "workout_date": "Tue, 26 Apr 2016 14:42:27 GMT",
    +          "workout_visibility": "public"
    +        }
    +      ]
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • workout_short_id (string) – identifiant court de la séance

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundcomment not found

    • +
    +
    +
    +
    + +
    +
    +POST /api/workouts/(string: workout_short_id)/like/undo
    +

    Supprimer le « like » de la séance.

    +

    Scope : workouts:write

    +

    Exemple de requête :

    +
    POST /api/workouts/HgzYFXgvWKCEpdq3vYk67q/like/undo HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 200 OK
    +Content-Type: application/json
    +
    +  {
    +    "data": {
    +      "workouts": [
    +        {
    +          "ascent": 231.208,
    +          "ave_speed": 13.12,
    +          "bounds": [],
    +          "creation_date": "Wed, 04 Dec 2024 09:18:26 GMT",
    +          "descent": 234.208,
    +          "description": null,
    +          "distance": 23.41,
    +          "duration": "3:32:27",
    +          "equipments": [],
    +          "id": "HgzYFXgvWKCEpdq3vYk67q",
    +          "liked": false,
    +          "likes_count": 0,
    +          "map": null,
    +          "map_visibility": "private",
    +          "max_alt": 104.44,
    +          "max_speed": 25.59,
    +          "min_alt": 19.0,
    +          "modification_date": "Wed, 04 Dec 2024 16:45:14 GMT",
    +          "moving": "1:47:04",
    +          "next_workout": null,
    +          "notes": null,
    +          "pauses": "1:23:51",
    +          "previous_workout": null,
    +          "records": [],
    +          "segments": [],
    +          "sport_id": 1,
    +          "suspended": false,
    +          "title": "Cycling (Sport) - 2016-04-26 16:42:27",
    +          "user": {
    +            "created_at": "Sun, 24 Nov 2024 16:52:14 GMT",
    +            "followers": 0,
    +            "following": 0,
    +            "nb_workouts": 1,
    +            "picture": false,
    +            "role": "user",
    +            "suspended_at": null,
    +            "username": "Sam"
    +          },
    +          "weather_end": null,
    +          "weather_start": null,
    +          "with_gpx": false,
    +          "workout_date": "Tue, 26 Apr 2016 14:42:27 GMT",
    +          "workout_visibility": "public"
    +        }
    +      ]
    +    },
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • workout_short_id (string) – identifiant court de la séance

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
      +
    • 200 OKsuccess

    • +
    • 401 Unauthorized

        +
      • provide a valid auth token

      • +
      • signature expired, please log in again

      • +
      • invalid token, please log in again

      • +
      +

    • +
    • 403 Forbidden

        +
      • you do not have permissions

      • +
      • you do not have permissions, your account is suspended

      • +
      +

    • +
    • 404 Not Foundcomment not found

    • +
    +
    +
    +
    + +
    +
    +POST /api/workouts/(string: workout_short_id)/suspension/appeal
    +

    Faire appel de la suspension de la séance.

    +

    Seul l’auteur de la séance peut fait appel de la suspension.

    +

    Scope : workouts:write

    +

    Exemple de requête :

    +
    POST /api/workouts/2oRDfncv6vpRkfp3yrCYHt/suspension/appeal HTTP/1.1
    +Content-Type: application/json
    +
    +
    +

    Exemple de réponse :

    +
    HTTP/1.1 201 CREATED
    +Content-Type: application/json
    +
    +  {
    +    "status": "success"
    +  }
    +
    +
    +
    +
    Paramètres:
    +
      +
    • workout_short_id (string) – identifiant court de la séance

    • +
    +
    +
    En-têtes de requête:
    +
    +
    +
    Codes d’état:
    +
    diff --git a/docs/fr/changelog.html b/docs/fr/changelog.html index ee493e8e1..1336f0567 100644 --- a/docs/fr/changelog.html +++ b/docs/fr/changelog.html @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -2626,10 +2631,10 @@

    Version 0.1.0 - Première version 🎉 (04/07/2018)

    Statistiques du mois en cours

  • Records par sports :

      -
    • vitesse moyenne

    • -
    • la plus grande distance

    • -
    • durée la plus longue

    • -
    • vitesse maximale

    • +
    • la vitesse moyenne

    • +
    • la distance la plus grande

    • +
    • la durée la plus longue

    • +
    • la vitesse maximale

  • Liste des activités et recherche

  • diff --git a/docs/fr/cli.html b/docs/fr/cli.html index 2af0e47c2..8649e65bf 100644 --- a/docs/fr/cli.html +++ b/docs/fr/cli.html @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -400,18 +405,17 @@

    ftcli

    Ajouté dans la version 0.7.15.

    +
    +

    Modifié dans la version 0.8.4: La préférence de l’utilisateur pour la langue de l’interface est ajoutée.

    +

    Créer un compte utilisateur.

    Note

    • le compte nouvellement créé est déjà actif.

    • -
    • le CLI permet de créer des utilisateurs lorsque l’enregistrement est désactivé.

    • +
    • la CLI permet de créer des utilisateurs lorsque l’enregistrement est désactivé.

    -
    -

    Modifié dans la version 0.8.4.

    -
    -

    La préférence de l’utilisateur pour la langue de l’interface est ajoutée.

    @@ -470,7 +474,10 @@

    ftcli

    Ajouté dans la version 0.6.5.

    -

    Modifier le compte d’un utilisateur (droits d’administration, statut actif, email et mot de passe).

    +
    +

    Modifié dans la version 0.9.0: Add --set-role option. --set-admin is now deprecated.

    +
    +

    Modifier le compte d’un utilisateur (rôle, statut actif, email et mot de passe).

    @@ -487,15 +494,18 @@

    ftcli

    Nom de l’utilisateur.

    - + + + + - + - + - + diff --git a/docs/fr/features.html b/docs/fr/features.html index 798b5d7a3..212b0f5b8 100644 --- a/docs/fr/features.html +++ b/docs/fr/features.html @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -286,15 +291,17 @@

    Fonctionnalités
    FitTrackee vous permet de stocker et d’afficher les données issues des fichiers gpx et quelques statistiques à partir de vos activités sportives en plein air.
    Des équipements peuvent être associés aux séances.
    -
    Pour le moment, l’application est plutôt de type mono-utilisateur. Même s’il est possible pour plusieurs utilisateurs de s’enregistrer, un utilisateur authentifié ne peut voir que ses propres séances.
    +
    Si l’inscription est activée, plusieurs utilisateurs peuvent s’inscrire et interagir avec les autres utilisateurs (commentaires, « likes »). Les séances et commentaires sont visibles selon le niveau de visibilité configuré.

    Les fichier .gpx sont stockés dans un répertoire du serveur (sans aucun chiffrement).

    Avec la configuration par défaut, Open Street Map est utilisé comme serveur de tuile sur la page de détail de la séance et pour la génération des images statiques des traces.

    Séances

    +
    +

    Sports

    +
    +

    Séances

    +
      +
    • Une séance peut être créée en :

      +
        +
      • ajoutant manuellement d’un fichier gpx ou d’une archive zip contenant un nombre limité de fichiers gpx

      • +
      • +
        ou entrant les date, heure, durée et distance (sans fichier gpx).
        +
        Les dénivelés positif et négatif peuvent être également fournis (nouveau dans la version 0.7.10).
        +
        +
      • +
      +
    + @@ -373,52 +393,236 @@

    Séances -
    L’utilisateur peut ajouter une description (nouveau dans la version 0.8.9) et une note.
    +
    L’utilisateur peut ajouter une description (nouveau dans la version 0.8.9) et des notes personnelles.
    +
    Une syntaxe Markdown limitée peut être utilisée (nouveau dans la version 0.9.0).
    + +

  • +
  • Si présente et qu’aucune description n’est fournie par l’utilisateur, la description du fichier gpx (<desc></desc>) est utilisée comme description de la séance.

  • +
  • +
    Une séance avec un fichier gpx est affichée avec une carte et des graphiques (vitesse et altitude (si le fichier contient les données relatives à l’altitude (mis à jour dans la version 0.7.20)).
    +
    Des boutons permettent une visualisation en plein écran et la réinitialisation de la position (nouveau dans la version 0.5.5).
    +
    +
  • +
  • +
    Si la clé d’API de Visual Crossing (nouveau in 0.7.11) est fournie, la météo est affichée dans les détails de l’entraînement. La source des données est affichée dans la page A propos.
    +
    La vitesse du vent est affichée, avec une flèche indiquant la direction (une infobulle peut être affichée avec la direction (provenance du vent)) (nouveau dans la version 0.5.5).
  • +
  • +
    Un équipement peut être associé à une séance (nouveau dans la version 0.8.0). Pour le moment, un seul équipement peut être associé.
    +
    Les équipements ne sont visibles que par leur propriétaire.
    +
    +
  • +
  • Les segments peuvent être affichés.

  • +
  • Les records associés à la séances sont affichés.

  • + +
    +

    Note

    +

    Les records peuvent différer des records affichés par l’application qui a généré les fichiers gpx à l’origine.

    +
    +
      +
    • Le niveau de visibilité peut être configuré séparément pour les données de la séance d’une part et la carte et l’analyse d’autre part (nouveau dans la version 0.9.0)

      +
        +
      • privé: seul le propriétaire de la séance peut afficher la séance,

      • +
      • abonnés seulement: seul le propriétaire de la séance et les abonnés peuvent afficher la séance,

      • +
      • public: tout le monde peut voir la séance, même les utilisateurs non authentifiés.

      • +
      +

    Note

    -
    Le champ « Description » est plus long que le champ « Notes » et il aura la même visibilité que la séance dans une prochaine version (cf. #125). Le champ « Notes » restera privé.
    +
    Une séance avec un fichier gpx dont la visibilité pour la carte et l’analyse est privée apparait comme une séance sans fichier gpx.
    +
    +

    Note

    +
    +
    La visibilité par défault est « privé ». La visibilité des séances créés avant la version 0.9.0 de FitTrackee est « privé ».
    +
    +
    +
    +

    Important

    +
    +
    Veuillez garder à l’esprit que l’équipe d’exploitation du serveur ou l’équipe de modération peuvent visualiser les contenus à visibilité restreinte.
    +
    +
    +
      +
    • Une séance peut être modifiée :

      +
        +
      • sport

      • +
      • titre

      • +
      • équipement

      • +
      • description (nouveau dans la version 0.8.9)

      • +
      • notes personnelles

      • +
      • visibilité de la séance (nouveau dans la version 0.9.0)

      • +
      • visibilité de la carte et de l’analyse (nouveau dans la version 0.9.0)

      • +
      • date (seulement pour les séances sans gpx)

      • +
      • durée (seulement pour les séances sans gpx)

      • +
      • distance (seulement pour les séances sans gpx)

      • +
      • dénivelés positif et négatif (seulement pour les séances sans gpx) (nouveau dans la version 0.7.10).

      • +
      +
    • +
    • Le fichier gpx peut être téléchargé (nouveau dans la version 0.5.1).

    • +
    • Une séance peut être supprimée.

    • +
    • Liste des séances.

      +
        +
      • L’utilisateur peut filtrer les séances selon :

        +
          +
        • la date

        • +
        • le sport (seuls les sports comportant des séances sont affichés dans la liste déroulante)

        • +
        • l’équipement (seuls les équipements associés des séances sont affichés dans la liste déroulante) (nouveau dans la version 0.8.0)

        • +
        • le titre (nouveau dans la version 0.7.15)

        • +
        • description (nouveau dans la version 0.8.9)

        • +
        • les notes (nouveau dans la version 0.8.0)

        • +
        • la distance

        • +
        • la durée

        • +
        • la vitesse moyenne

        • +
        • la vitesse maximale

        • +
        +
      • +
      • Les séances peuvent être triées par :

        +
          +
        • la date

        • +
        • la distance

        • +
        • la durée

        • +
        • la vitesse moyenne

        • +
        +
      • +
      +
    • +
    • Un utilisateur peut signaler une séance qui enfreint les règles de l’instance. Une notification sera envoyée aux modérateurs et administrateurs.

    • +
    + + +
    +

    Interactions

    +
    +

    Ajouté dans la version 0.9.0.

    +
    +
    +

    Utilisateurs

      -
    • Si présente et qu’aucune description n’est fournie par l’utilisateur, la description du fichier gpx (<desc></desc>) est utilisée comme description de la séance.

    • -
      Une séance peut même être créée sans fichier gpx (l’utilisateur doit entrer la date, l’heure, la durée et la distance).
      -
      Les dénivelés positif et négatif peuvent être également fournis (nouveau dans la version 0.7.10).
      +
      Répertoire des utilisateurs.
      +
      Un utilisateur peut configurer sa visibilité dans le répertoire dans les préférences utilisateur.
      +
      Cela impacte également la complétion des noms d’utilisateurs lors de la rédaction d’un commentaire (seuls les utilisateurs visibles dans le répertoire et les utilisateurs suivis sont proposés).
    • +
    +
    +

    Note

    +

    Le profil d’un utilisateur reste accessible via son URL.

    +
    +
    • -
      Une séance avec un fichier gpx est affichée avec une carte et des graphiques (vitesse et altitude (si le fichier contient les données relatives à l’altitude (mis à jour dans la version 0.7.20)).
      -
      Des boutons permettent une visualisation en plein écran et la réinitialisation de la position (nouveau dans la version 0.5.5).
      +
      Un utilisateur peut envoyer des demandes de suivi à d’autres utilisateurs
      +
      Une demande de suivi peut être acceptée ou rejetée.
    • -
      Si la clé d’API de Visual Crossing (nouveau in 0.7.11) est fournie, la météo est affichée dans les détails de l’entraînement. La source des données est affichée dans la page A propos.
      -
      La vitesse du vent est affichée, avec une flèche indiquant la direction (une infobulle peut être affichée avec la direction (provenance du vent)) (nouveau dans la version 0.5.5).
      +
      Afin de masquer du contenu indésirable, l’utilisateur peut bloquer un autre utilisateur.
      +
      Le blocage des utilisateurs masque leurs séances sur le flux d’activités et les commentaires. Les notifications des utilisateurs bloqués ne s’affichent pas.
      +
      Les utilisateurs bloqués ne peuvent pas voir les séances et les commentaires des utilisateurs qui les ont bloqués, ni les suivre (s’ils les suivaient, les abonnements sont automatiquement supprimés).
    • -
    • Un équipement peut être associé à une séance (nouveau dans la version 0.8.0). Pour le moment, un seul équipement peut être associé.

    • -
    • Les segments peuvent être affichés.

    • -
    • Le fichier gpx peut être téléchargé (nouveau dans la version 0.5.1)

    • -
    • Modification et suppression d’activité.

    • -
    • -
      Statistiques pour l’utilisateur, par période (semaine, mois, année) et par sport :
        -
      • -
        totaux :
          +
        • Un utilisateur peut signaler un profil qui enfreint les règles de l’instance. Une notification sera envoyée aux modérateurs et administrateurs.

        • +
        +
    +
    +

    Commentaires

    +
      +
    • +
      Selon la visibilité, un utilisateur peut commenter une séance.
      +
      Une syntaxe Markdown limitée peut être utilisée.
      +
      +
    • +
    • Les niveaux de visibilité pour les commentaires sont :

      +
        +
      • privé: seul l’auteur et les utilisateurs mentionnés peuvent voir le commentaire,

      • +
      • abonnés seulement: seul l’auteur, les utilisateurs mentionnés et les abonnés peuvent voir le commentaire,

      • +
      • public: tout le monde peut voir le commentaire, même les utilisateurs non authentifiés.

      • +
      +
    • +
    +
    +

    Important

    +
    +
    Veuillez garder à l’esprit que l’équipe d’exploitation du serveur ou l’équipe de modération peuvent visualiser les contenus à visibilité restreinte.
    +
    +
    +
      +
    • Le texte du commentaire peut être modifié (le niveau de visibilité ne peut pas être modifié).

    • +
    • Un utilisateur peut signaler un commentaire qui enfreint les règles de l’instance. Une notification sera envoyée aux modérateurs et administrateurs.

    • +
    +
    +
    +

    Likes

    +
      +
    • Selon la visibilité de l’instance, un utilisateur peut ajouter ou supprimer « like » sur une séance ou un commentaire.

    • +
    +
    +
    +

    Notifications

    +
      +
    • Des notifications peuvent être envoyées pour les évènements suivants :

      +
        +
      • demande de suivi et suivi

      • +
      • « like » sur un commentaire ou une séance

      • +
      • commentaire sur une séance

      • +
      • mention dans un commentaire

      • +
      • suspension ou avertissement (un courriel est également envoyé si l’envoi des courriels est activé).

      • +
      • levée de suspension ou d’avertissement (un courriel est également envoyé si l’envoi des courriels est activé).

      • +
      +
    • +
    • Les utilisateurs avec des droits de modération peuvent également recevoir des notifications pour :

      +
        +
      • la création de signalements

      • +
      • les appels sur des suspensions

      • +
      +
    • +
    • Les utilisateurs avec des droits d’administration peuvent également recevoir des notifications lors de l’inscription d’un utilisateur.

    • +
    • Les utilisateurs peuvent marquer les notifications comme lues ou non lues.

    • +
    +
    +
    +
    +

    Tableau de bord

    +
      +
    • Le tableau de bord affiche :

      +
        +
      • les statistiques du mois en cours

      • +
      • un calendrier mensuel affichant les séances et les records. La semaine peut commencer le dimanche ou le lundi (ce qui peut être modifié dans les préférences de l’utilisateur). Le calendrier affiche jusqu’à 100 séances.

      • +
      • records de l’utilisateur par sport :

        +
          +
        • la vitesse moyenne

        • +
        • la distance la plus grande

        • +
        • dénivelé positif le plus élevé (nouveau dans la version 0.6.11, peut être masqué, cf. préférences utilisateur)

        • +
        • la durée la plus longue

        • +
        • la vitesse maximale

        • +
        +
      • +
      • un flux d’activités affichant avec les séances visibles par l’utilisateur

      • +
      +
    • +
    +
    +
    +

    Statistiques

    +

    Compte et préférences

    @@ -527,6 +670,8 @@

    Compte et préférences

    L’utilisateur peut réinitialiser son mot de passe (nouveau dans la version in 0.3.0)

  • Un utilisateur peut modifier son adresse électronique (nouveau dans la version 0.6.0)

  • L’utilisateur peut définir la langue, le fuseau horaire et le premier jour de la semaine.

  • +
  • Un utilisateur peut configurer l’approbation des demandes de suivi : manuel (par défaut) ou automatique (nouveau dans la version 0.9.0)

  • +
  • Un utilisateur peut configurer sa visibilité dans le répertoire : masqué (par défaut) ou affiché (nouveau dans la version 0.9.0)

  • L’utilisateur peut définir le theme de l’interface (clair (valeur par défaut), sombre ou selon les préférences du navigateur). (nouveau dans la version 0.7.27).

  • L’utilisateur peut choisir entre le système métrique et le système impérial pour la distance, l’affichage de l’altitude et de la vitesse (nouveau dans la version 0.5.0)

  • Un utilisateur peut choisir d’afficher ou cacher les records et le total de dénivelé positif sur le tableau de bord (nouveau dans la version 0.6.11)

  • @@ -539,15 +684,14 @@

    Compte et préférencesLa modification de cette préférence n’affectera que les prochains fichiers ajoutés.

    +
      +
    • Un utilisateur peut afficher les utilisateurs bloqués (nouveau dans la version 0.9.0)

    • +
    • Un utilisateur peut afficher les demandes d’abonnements pour les accepter ou les rejeter (nouveau dans la version 0.9.0).

    • +
    • Un utilisateur peut afficher les sanctions reçues (nouveau dans la version 0.9.0).

    • +

    Équipements

    -

    (nouveau dans la version in 0.8.0)

    +
    +

    Ajouté dans la version 0.8.0.

    +
    @@ -627,17 +774,25 @@

    Équipements

    Applications OAuth

    -

    (nouveau dans la version in 0.7.0)

    +
    +

    Ajouté dans la version 0.7.0.

    +
    • Un utilisateur peut créer des clients pour des applications tierces.

    Administration

    -

    (nouveau dans la version 0.3.0)

    +
    +

    Ajouté dans la version 0.3.0.

    +

    Application

    -

    Configuration

    +
      +
    • Only users if administration rights can access application administration.

    • +
    +
    +

    Configuration

    Les paramètres suivants peuvent être définis :

    +
    +

    A propos

    +
    +

    Ajouté dans la version 0.7.13.

    +
    -
    Il est possible d’afficher des informations supplémentaires qui peuvent être utiles aux utilisateurs dans la page A propos.
    +
    Il est possible d’afficher des informations supplémentaires qui peuvent être utiles aux utilisateurs dans la page A propos (comme les règles de l’instance).
    La syntaxe Markdown peut être utilisée.
    -

    Politique de confidentialité

    -

    (nouveau dans la version 0.7.13)

    +
    +
    +

    Politique de confidentialité

    +
    +

    Ajouté dans la version 0.7.13.

    +
    Une politique de confidentialité par défaut est disponible (adaptée de la Politique de confidentialité de Discourse).
    Une politique de confidentialité personnalisée peut être définie si nécessaire (la syntaxe Markdown peut être utilisée). Une mise à jour de la politique affichera un message sur le tableau de bord des utilisateurs pour qu’ils puissent en prendre connaissance.
    @@ -672,13 +835,60 @@

    Application -

    Utilisateurs

    +

    +
    +

    Utilisateurs

    +
    +

    Modifié dans la version 0.9.0: Ajouter des rôles de modérateur et propriétaire

    +
    +
      +
    • Seuls les utilisateurs ayant des droits d’administration peuvent accéder à l’administration des utilisateurs.

    • +
    • Rôles :

      +
        +
      • utilisateur

        +
          +
        • pas de droits de modération ou d’administration

        • +
        +
      • +
      • modérateur (nouveau dans la version 0.9.0)

        +
          +
        • peut seulement accéder à l’entrée modération dans l’administration

        • +
        • peut voir les signalements

        • +
        • effectuer des actions liées aux signalements

        • +
        +
      • +
      • administrateur

        +
          +
        • a les droits de modérations (nouveau dans la version 0.9.0)

        • +
        • peut accéder à toutes les entrées de l’administration :

          +
            +
          • application

          • +
          • modération

          • +
          • types d’équipement

          • +
          • sports

          • +
          • utilisateurs

          • +
          +
        • +
        +
      • +
      • propriétaire (nouveau dans la version 0.9.0)

        +
          +
        • has les droits d’administration

        • +
        • ce rôle ne peut pas être modifié par un autre administrateur ou propriétaire dans l’application

        • +
        +
      • +
      +
    • +
    +
    +

    Note

    +

    Roles defined prior to version 0.9.0 remain unchanged.

    +
    • afficher et filtrer la liste des utilisateurs

    • modifier le compte d’un utilisateur pour :

        -
      • ajouter/supprimer des droits d’administration

      • +
      • mettre à jour le rôle (nouveau dans la version 0.9.0). Un utilisateur avec un rôle de propriétaire ne peut pas être modifié par un autre utilisateur. Le rôle de propriétaire ne peut être assigné ou retiré qu’avec la CLI de FitTrackee.

      • activer son compte (nouveau dans la version 0.6.0)

      • mettre à jour son adresse électronique (au cas où son compte serait bloqué) (nouveau dans la version 0.6.0)

      • réinitialiser son mot de passe (dans le cas où son compte est verrouillé) (nouveau dans la version 0.6.0). Si l’envoi des courriels est désactivé, cela n’est possible que via l’interface de ligne de commande (CLI).

      • @@ -687,16 +897,48 @@

        Utilisateurs +

        Modération

        +
        +

        Ajouté dans la version 0.9.0.

        +
        +
          +
        • Seul l’utilisateur ayant des droits d’administration peut modifier un autre utilisateur.

        • +
        • Afficher et filtrer la liste des utilisateurs.

        • +
        • Gérer un signalement :

          +
            +
          • ajouter un commentaire

          • +
          • envoyer un avertissement

          • +
          • suspendre ou réactiver une séance ou un commentaire

          • +
          • suspendre ou réactiver le compte d’un utilisateur

          • +
          • marquer un signalement comme résolu ou non résolu

          • +
          +
        • +
        +
        +

        Note

        +

        Le contenu signalé est visible quelque soit le niveau de visibilité.

        +
        +
          +
        • Un utilisateur peut faire appel de la suspension ou de l’avertissement.

        • +
        • L’utilisateur suspendu peut uniquement accéder à son compte, faire appel de la suspension du compte, demander l’export de données ou supprimer son compte. Ses séances et commentaires ne sont plus visibles.

        • +
        +

    Types d’équipement

    +
    +

    Ajouté dans la version 0.8.0.

    +
      -
    • activer ou désactiver un type d’équipement afin de correspondre aux sports désactivés (un type d’équipement peut être désactivé même si un équipement pour ce type existe) (nouveau dans la version 0.8.0)

    • +
    • Seuls les utilisateurs disposant de droits d’administration peuvent accéder l’administration des types d’équipement.

    • +
    • activer ou désactiver un type d’équipement afin de correspondre aux sports désactivés (un type d’équipement peut être désactivé même si un équipement pour ce type existe) (nouveau dans la version 0.8.0).

    -
    -

    Sports

    +
    +

    Sports

      -
    • activer ou désactiver un sport (un sport peut être désactivé même si une séance avec ce sport existe)

    • +
    • Seuls les utilisateurs ayant des droits d’administration peuvent accéder à l’administration des sports.

    • +
    • activer ou désactiver un sport (un sport peut être désactivé même si une séance avec ce sport existe).

    @@ -719,56 +961,68 @@

    TraductionsWeblate (peut différer de la version publiée) :

    +

    État d’avancement de la traduction de l’application sur Weblate (peut différer de la version publiée) :

    https://hosted.weblate.org/widgets/fittrackee/-/multi-auto.svg

    Captures d’écran

    -
    -

    Tableau de bord

    +
    +

    Tableau de bord

    -Tableau de bord sur FitTrackee +Tableau de bord sur FitTrackee

    Page de détail d’une séance

    -Séance sur FitTrackee +Séance sur FitTrackee

    Liste des séances

    -Séances sur FitTrackee +Séances sur FitTrackee
    -
    -

    Statistiques

    +
    +

    Statistiques

    -Statistics sur FitTrackee +Statistics sur FitTrackee
    -Statistiques pour un sport sur FitTrackee +Statistiques pour un sport sur FitTrackee
    -
    -

    Équipements

    +
    +

    Équipements

    -Équipements sur FitTrackee +Équipements sur FitTrackee
    -Détail d'un équipements sur FitTrackee +Détail d'un équipement sur FitTrackee
    -
    -

    Administration

    +
    +

    Notifications

    -Administration de FitTrackee +Notifications sur FitTrackee
    +
    +
    +

    Répertoire des utilisateurs

    -Administration des sports sur FitTrackee +Répertoire des utilisateurs sur FitTrackee +
    +
    +
    +

    Administration

    +
    +Administration de FitTrackee +
    +
    +Administration des sports sur FitTrackee
    @@ -834,25 +1088,46 @@

    Administration

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `),`

    --set-admin BOOLEAN

    Ajouter/supprimer des droits d’administration (lors de l’ajout de droits d’administration, le compte de l’utilisateur est également activé s’il ne l’est pas.

    [DÉPRÉCIÉ] Ajouter/supprimer des droits d’administration (lors de l’ajout de droits d’administration, le compte de l’utilisateur est également activé s’il ne l’est pas).

    --set-role ROLE

    Configurer le rôle (lors de la configuration des rôles “moderator”, “admin” et “owner”, le compte de l’utilisateur est également activé s’il ne l’est pas.

    --activate

    --activate

    Activer le compte d’un utilisateur.

    --reset-password

    --reset-password

    Réinitialiser le mot de passe de l’utilisateur (un nouveau mot de passe sera affiché).

    --update-email EMAIL

    --update-email EMAIL

    Mettre à jour l’adresse électronique de l’utilisateur.

    + GET /api/auth/account/sanctions/(string:action_short_id) +
    + GET /api/auth/account/suspension +
    + GET /api/auth/blocked-users +
    GET /api/auth/profile
    + GET /api/comments/(string:comment_short_id) +
    @@ -333,6 +362,30 @@

    Table de routage HTTP

    GET /api/equipments/(string:equipment_short_id)
    + GET /api/follow-requests +
    + GET /api/notifications +
    + GET /api/notifications/types +
    + GET /api/notifications/unread +
    @@ -363,6 +416,24 @@

    Table de routage HTTP

    GET /api/records
    + GET /api/reports +
    + GET /api/reports/(int:report_id) +
    + GET /api/reports/unresolved +
    @@ -393,6 +464,12 @@

    Table de routage HTTP

    GET /api/stats/all
    + GET /api/timeline +
    @@ -405,12 +482,30 @@

    Table de routage HTTP

    GET /api/users/(user_name)
    + GET /api/users/(user_name)/followers +
    + GET /api/users/(user_name)/following +
    GET /api/users/(user_name)/picture
    + GET /api/users/(user_name)/sanctions +
    @@ -435,6 +530,12 @@

    Table de routage HTTP

    GET /api/workouts/(string:workout_short_id)/chart_data/segment/(int:segment_id)
    + GET /api/workouts/(string:workout_short_id)/comments +
    @@ -489,6 +590,18 @@

    Table de routage HTTP

    POST /api/auth/account/resend-confirmation
    + POST /api/auth/account/sanctions/(string:action_short_id)/appeal +
    + POST /api/auth/account/suspension/appeal +
    @@ -549,6 +662,24 @@

    Table de routage HTTP

    POST /api/auth/register
    + POST /api/comments/(string:comment_short_id)/like +
    + POST /api/comments/(string:comment_short_id)/like/undo +
    + POST /api/comments/(string:comment_short_id)/suspension/appeal +
    @@ -561,6 +692,24 @@

    Table de routage HTTP

    POST /api/equipments/(string:equipment_short_id)/refresh
    + POST /api/follow-requests/(user_name)/accept +
    + POST /api/follow-requests/(user_name)/reject +
    + POST /api/notifications/mark-all-as-read +
    @@ -591,12 +740,72 @@

    Table de routage HTTP

    POST /api/oauth/token
    + POST /api/reports +
    + POST /api/reports/(int:report_id)/actions +
    + POST /api/users/(user_name)/block +
    + POST /api/users/(user_name)/follow +
    + POST /api/users/(user_name)/unblock +
    + POST /api/users/(user_name)/unfollow +
    POST /api/workouts
    + POST /api/workouts/(string:workout_short_id)/comments +
    + POST /api/workouts/(string:workout_short_id)/like +
    + POST /api/workouts/(string:workout_short_id)/like/undo +
    + POST /api/workouts/(string:workout_short_id)/suspension/appeal +
    @@ -615,6 +824,12 @@

    Table de routage HTTP

    DELETE /api/auth/profile/reset/sports/(sport_id)
    + DELETE /api/comments/(string:comment_short_id) +
    @@ -639,12 +854,24 @@

    Table de routage HTTP

    DELETE /api/workouts/(string:workout_short_id)
    + PATCH /api/appeals/(string:appeal_id) +
    PATCH /api/auth/profile/edit/account
    + PATCH /api/comments/(string:comment_short_id) +
    @@ -663,6 +890,18 @@

    Table de routage HTTP

    PATCH /api/equipments/(string:equipment_short_id)
    + PATCH /api/notifications/(int:notification_id) +
    + PATCH /api/reports/(int:report_id) +
    diff --git a/docs/fr/index.html b/docs/fr/index.html index 9632babbd..df5f2c457 100644 --- a/docs/fr/index.html +++ b/docs/fr/index.html @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -300,7 +305,7 @@

    FitTrackee(cf. tickets pour plus d’informations)
    -Tableau de bord sur FitTrackee +Tableau de bord sur FitTrackee
    diff --git a/docs/fr/installation.html b/docs/fr/installation.html index 8343bb23c..9c0cb7f85 100644 --- a/docs/fr/installation.html +++ b/docs/fr/installation.html @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -681,12 +686,23 @@

    Courriels

    Ajouté dans la version 0.3.0.

    +
    +

    Modifié dans la version 0.5.3: Les informations d’identification et le port peuvent être omis

    +
    +
    +

    Modifié dans la version 0.6.5: Désactivation de l’envoi des courriels

    +
    +
    +

    Modifié dans la version 0.7.24: gestion des caractères spéciaux dans le mot de passe

    +

    Pour l’envoi des courriels, une valeur valide pour la variable EMAIL_URL doit être fourni :

    • avec un SMTP server sans chiffrement : smtp://username:password@smtp.example.com:25

    • avec SSL : smtp://username:password@smtp.example.com:465/?ssl=True

    • avec STARTTLS : smtp://username:password@smtp.example.com:587/?tls=True

    +

    Les informations d’identification peuvent être omises : smtp://smtp.example.com:25. Si :<port> est omis :<port>, the port utilisé par défault est 25.

    +

    Le mot de passe peut être encodé s’il contient des caractères spéciaux. Par exemple avec le mot de passe passwordwith@and&and?, le mot de passe encodé sera passwordwith%40and%26and%3F.

    -
    -

    Modifié dans la version 0.5.3.

    -
    -
    -
    Les informations d’identification peuvent être omises : smtp://smtp.example.com:25.
    -
    Si :<port> est omis, the port par défaut est 25.
    -

    Avertissement

    Dans le cas des instances avec un seul utilisateur, il est possible de désactiver l’envoi de courriels en laissant la variable EMAIL_URL vide (dans ce cas il n’est pas nécessaire de lancer les workers de dramatiq).

    Une interface de ligne de commande (CLI) est disponible pour activer les comptes, modifier l’adresse électronique et le mot de passe et gérer les demandes d’exports de données.

    -
    -

    Modifié dans la version 0.7.24.

    -
    -

    Le mot de passe peut être encodé s’il contient des caractères spéciaux. Par exemple avec le mot de passe passwordwith@and&and?, le mot de passe encodé sera passwordwith%40and%26and%3F.

  • Serveur de tuiles

    Ajouté dans la version 0.4.0.

    +
    +

    Modifié dans la version 0.6.10: Gestion des sous-domaines du serveur de tuile

    +
    +
    +

    Modifié dans la version 0.7.23: Le serveur de tuiles par défaut (OpenStreetMap) ne nécessite plus de sous-domaines.

    +

    Le serveur de tuiles par défaut est maintenant le serveur standard d”OpenStreetMap (si les variables d’environnements ne sont pas initialisées). Le serveur de tuile peut être changé en modifiant les variables TILE_SERVER_URL et MAP_ATTRIBUTION (liste des serveurs de tuiles).

    Pour conserver ThunderForest Outdoors, la configuration est :

    Limitation d’accès à l’API

    @@ -800,20 +805,23 @@

    Limitation d’accès à l’API

    Données météo

    -

    Modifié dans la version 0.7.11.

    +

    Modifié dans la version 0.7.11: Ajout de Visual Crossing parmi les fournisseurs de données météo.

    +
    +
    +

    Modifié dans la version 0.7.15: Suppression de Darksky des fournisseurs de données météo.

    Les fournisseurs de données météo suivants sont pris en charge par FitTrackee :

    • Visual Crossing (note : les données historiques sont fournies sur une période d’une heure)

    +
    +

    Note

    +

    Le support de DarkSky est interrompu, depuis l’arrêt du service le 31 Mars 2023.

    +

    Pour configurer un fournisseur de données météo, initialiser les variables d’environnement suivantes :

    • WEATHER_API_KEY : clé d’API correspondant au fournisseur de données météo

    -
    -

    Modifié dans la version 0.7.15.

    -
    -

    Le support de DarkSky est interrompu, depuis l’arrêt du service le 31 Mars 2023.

    Installation

    @@ -887,9 +895,9 @@

    A partir de PyPI
  • Ouvrir l’URL http://localhost:5000 avec un navigateur et s’inscrire

  • -
  • Pour donner les droits d’administration au compte nouvellement créé utiliser la ligne de commande suivante :

  • +
  • Pour attribuer le rôle de propriétaire au compte nouvellement créé, utiliser la ligne de commande suivante :

  • -
    $ ftcli users update <username> --set-admin true
    +
    $ ftcli users update <username> --set-role owner
     
    @@ -946,9 +954,9 @@

    Environnements de développement
  • Ouvrir l’URL http://localhost:3000 avec un navigateur et s’inscrire

  • -
  • Pour donner les droits d’administration au compte nouvellement créé utiliser la ligne de commande suivante :

  • +
  • Pour attribuer le rôle de propriétaire au compte nouvellement créé, utiliser la ligne de commande suivante :

  • -
    $ make user-set-admin USERNAME=<username>
    +
    $ make user-set-role USERNAME=<username> ROLE=owner
     
    @@ -998,9 +1006,9 @@

    Environnements de production
  • Ouvrir l’URL http://localhost:5000 avec un navigateur et s’inscrire

  • -
  • Pour donner les droits d’administration au compte nouvellement créé utiliser la ligne de commande suivante :

  • +
  • Pour attribuer le rôle de propriétaire au compte nouvellement créé, utiliser la ligne de commande suivante :

  • -
    $ make user-set-admin USERNAME=<username>
    +
    $ make user-set-role USERNAME=<username> ROLE=owner
     
    @@ -1183,12 +1191,12 @@

    DéploiementWantedBy=multi-user.target

    -

    @@ -1294,9 +1302,9 @@

    Installationhttp://localhost:8025 pour accéder à l’interface de MailHog (outil de test)

      -
    • Pour donner les droits d’administration au compte nouvellement créé utiliser la ligne de commande suivante :

    • +
    • Pour attribuer le rôle de propriétaire au compte nouvellement créé, utiliser la ligne de commande suivante :

    -
    $ make docker-set-admin USERNAME=<username>
    +
    $ make docker-set-role USERNAME=<username> ROLE=owner
     
    diff --git a/docs/fr/oauth.html b/docs/fr/oauth.html index c24e189a3..fe8a9c1e8 100644 --- a/docs/fr/oauth.html +++ b/docs/fr/oauth.html @@ -215,12 +215,17 @@
  • Documentation de l’API @@ -283,7 +288,9 @@

    OAuth 2.0

    -

    (nouveau dans la version in 0.7.0)

    +
    +

    Ajouté dans la version 0.7.0.

    +

    FitTrackee fournit une API REST (voir documentation) dont la plupart des points d’accès nécessitent une authentification/autorisation.

    Pour permettre à une application tierce d’interagir avec les points d’accès de l’API, un client OAuth2 peut être créé dans les paramètres de l’utilisateur (onglet “apps”).

    @@ -298,12 +305,18 @@

    Scopes
  • application:write : permet d’accéder en écriture à la configuration de l’application (uniquement pour les utilisateurs ayant des droits d’administration),

  • equipments:read : accorde un accès en lecture aux points d’entrée des équipements (nouveau dans la version 0.8.0),

  • equipments:write : accorde un accès en écriture aux points d’entrée des équipements (nouveau dans la version 0.8.0),

  • +
  • follow:read : accorde un accès en lecture aux points d’entrée des demandes d’abonnements et des abonnés (nouveau dans la version 0.9.0),

  • +
  • follow:write : accorde un accès en écriture aux points d’entrée des demandes d’abonnements et des abonnés (nouveau dans la version 0.9.0),

  • +
  • notifications:read : accorde un accès en lecture aux points d’entrée des notifications (nouveau dans la version 0.9.0),

  • +
  • notifications:write : accorde un accès en écriture aux points d’entrée des notifications (nouveau dans la version 0.9.0),

  • profile:read : accorde un accès en lecture aux points d’entrée d’authentification/profil utilisateur,

  • profile:write : accorde l’accès en écriture aux points d’entrée d’authentification/profil utilisateur,

  • +
  • reports:read : accorde un accès en lecture aux points d’entrée des signalements (nouveau dans la version 0.9.0),

  • +
  • reports:write : accorde un accès en écriture aux points d’entrée des signalements (nouveau dans la version 0.9.0),

  • users:read : accorde un accès en lecture aux points d’entrée des utilisateurs,

  • users:write : accorde un accès en écriture aux points d’entrée des utilisateurs,

  • -
  • workouts:read : accorde un accès en lecture aux points d’entrée associés aux séances,

  • -
  • workouts:write : accorde un accès en écriture aux points d’entrée associés aux séances.

  • +
  • workouts:read : accorde un accès en lecture aux points d’entrée des séances et des commentaires (modifié dans la version 0.9.0),

  • +
  • workouts:write : accorde un accès en écriture aux points d’entrée des séances et aux commentaires (modifié dans la version 0.9.0).

  • @@ -311,7 +324,7 @@

    Flux
  • L’utilisateur crée une application (client) sur FitTrackee pour une application tierce.

    -Création d'un client OAuth2 sur FitTrackee +Création d'un client OAuth2 sur FitTrackee
  • -Autorisation de l'application sur FitTrackee +Autorisation de l'application sur FitTrackee

  • - +
    Après autorisation, FitTrackee redirige vers l’application tierce, de sorte que l’application tierce puisse obtenir le code d’autorisation à partir de l’URL de redirection et récupère ensuite un jeton d’accès avec l’identifiant et le secret du client (point d’accès /api/oauth/token).
    Exemple d’URL de redirection :
    https://example.com/callback?code=<AUTHORIZATION_CODE>&state=<STATE>
    diff --git a/docs/fr/objects.inv b/docs/fr/objects.inv index df5131a5a..030f8bcdd 100644 Binary files a/docs/fr/objects.inv and b/docs/fr/objects.inv differ diff --git a/docs/fr/search.html b/docs/fr/search.html index b8dada2ec..432251f06 100644 --- a/docs/fr/search.html +++ b/docs/fr/search.html @@ -216,12 +216,17 @@
  • Documentation de l’API diff --git a/docs/fr/searchindex.js b/docs/fr/searchindex.js index 0387e493d..2adbaaf49 100644 --- a/docs/fr/searchindex.js +++ b/docs/fr/searchindex.js @@ -1 +1 @@ -Search.setIndex({"alltitles": {"A partir de PyPI": [[15, "from-pypi"], [15, "id3"]], "A partir des sources": [[15, "from-sources"], [15, "id4"]], "Administrateur": [[18, null]], "Administration": [[13, "administration"], [13, "id2"]], "Application": [[13, "application"]], "Applications OAuth": [[13, "oauth-apps"]], "Authentification et compte": [[0, null]], "Base de donn\u00e9es": [[12, "database"]], "Bugs corrig\u00e9s": [[11, "bugs-fixed"], [11, "id5"], [11, "id8"], [11, "id11"], [11, "id16"], [11, "id22"], [11, "id27"], [11, "id31"], [11, "id34"], [11, "id36"], [11, "id40"], [11, "id44"], [11, "id48"], [11, "id51"], [11, "id54"], [11, "id57"], [11, "id58"], [11, "id61"], [11, "id63"], [11, "id65"], [11, "id68"], [11, "id71"], [11, "id79"], [11, "id82"], [11, "id85"], [11, "id88"], [11, "id100"], [11, "id105"], [11, "id107"], [11, "id111"], [11, "id114"], [11, "id117"], [11, "id119"], [11, "id122"], [11, "id125"], [11, "id127"], [11, "id130"], [11, "id133"], [11, "id136"], [11, "id141"], [11, "id143"], [11, "id145"], [11, "id147"], [11, "id150"], [11, "id152"], [11, "id158"], [11, "id161"], [11, "id163"], [11, "id165"], [11, "id172"], [11, "id177"], [11, "id179"], [11, "id181"], [11, "id184"], [11, "id186"], [11, "id188"], [11, "id192"], [11, "id202"], [11, "id205"], [11, "id207"], [11, "id210"], [11, "id217"]], "Captures d\u2019\u00e9cran": [[13, "screenshots"]], "Compte et pr\u00e9f\u00e9rences": [[13, "account-preferences"]], "Configuration": [[1, null]], "Courriels": [[15, "emails"]], "Divers": [[11, "misc"], [11, "id1"], [11, "id3"], [11, "id7"], [11, "id14"], [11, "id18"], [11, "id21"], [11, "id25"], [11, "id29"], [11, "id33"], [11, "id38"], [11, "id42"], [11, "id46"], [11, "id53"], [11, "id56"], [11, "id60"], [11, "id62"], [11, "id66"], [11, "id73"], [11, "id76"], [11, "id84"], [11, "id91"], [11, "id102"], [11, "id104"], [11, "id120"], [11, "id134"], [11, "id137"], [11, "id154"], [11, "id156"], [11, "id173"], [11, "id182"], [11, "id189"], [11, "id193"], [11, "id200"], [11, "id211"], [11, "id214"]], "Docker": [[15, "docker"]], "Documentation": [[11, "documentation"], [11, "id75"], [11, "id109"]], "Documentation de l\u2019API": [[4, null]], "Donn\u00e9es m\u00e9t\u00e9o": [[15, "weather-data"]], "D\u00e9pannage": [[19, null]], "D\u00e9pendances principales": [[15, "main-dependencies"]], "D\u00e9ploiement": [[15, "deployment"]], "D\u00e9veloppement": [[15, "development"]], "Environnement de production": [[15, "prod-environment"]], "Environnements de d\u00e9veloppement": [[15, "dev-environment"], [15, "id5"]], "Environnements de production": [[15, "production-environment"]], "FitTrackee": [[14, null]], "FitTrackee ne d\u00e9marre pas": [[18, "fittrackee-fails-to-start"]], "Flux": [[16, "flow"]], "Fonctionnalit\u00e9s": [[11, "features"], [11, "id129"], [11, "id139"], [11, "id149"], [13, null]], "Fonctionnalit\u00e9s et am\u00e9liorations": [[11, "features-and-enhancements"], [11, "id4"], [11, "id10"], [11, "id15"], [11, "id19"], [11, "id26"], [11, "id30"], [11, "id39"], [11, "id43"], [11, "id47"], [11, "id50"], [11, "id67"], [11, "id70"], [11, "id78"], [11, "id81"], [11, "id87"], [11, "id92"], [11, "id94"], [11, "id96"], [11, "id99"], [11, "id110"], [11, "id116"]], "Historique des modifications": [[11, null]], "Installation": [[15, null], [15, "id2"], [15, "id6"]], "Interface de ligne de commande": [[12, null]], "Les images de la carte ne sont pas affich\u00e9es mais la carte est affich\u00e9e dans le d\u00e9tail de la s\u00e9ance": [[18, "map-images-are-not-displayed-but-map-is-shown-in-workout-detail"]], "Limitation d\u2019acc\u00e8s \u00e0 l\u2019API": [[15, "api-rate-limits"]], "Liste des s\u00e9ances": [[13, "workouts-list"]], "Mise \u00e0 jour": [[15, "upgrade"]], "NixOS": [[15, "nixos"]], "Nouvelles fonctionnalit\u00e9s": [[11, "new-features"], [11, "id167"], [11, "id169"], [11, "id171"], [11, "id176"], [11, "id191"], [11, "id195"], [11, "id197"], [11, "id199"], [11, "id204"], [11, "id209"], [11, "id213"], [11, "id216"], [11, "id219"]], "OAuth 2.0": [[16, null]], "OAuth2": [[5, null], [12, "oauth2"]], "Outils d\u2019importation": [[17, "import-tools"]], "Outils tiers": [[17, null]], "Page de d\u00e9tail d\u2019une s\u00e9ance": [[13, "workout-detail"]], "Points d'acc\u00e8s :": [[4, null]], "Pr\u00e9requis": [[15, "prerequisites"]], "Pull Requests": [[11, "pull-requests"], [11, "id123"], [11, "id126"], [11, "id142"], [11, "id151"], [11, "id155"], [11, "id159"], [11, "id174"]], "Records": [[6, null]], "Ressources": [[16, "resources"]], "Scopes": [[16, "scopes"]], "Scripts d\u2019installation": [[17, "installation-scripts"]], "Serveur de tuiles": [[15, "map-tile-server"]], "Sports": [[7, null], [13, "sports"]], "Statistiques": [[8, null], [13, "statistics"]], "S\u00e9ances": [[10, null], [13, "workouts"]], "S\u00e9curit\u00e9": [[11, "security"]], "Table des mati\u00e8res": [[14, "table-of-contents"]], "Tableau de bord": [[13, "dashboard"]], "Tickets Ferm\u00e9s": [[11, "issues-closed"], [11, "id121"], [11, "id124"], [11, "id128"], [11, "id132"], [11, "id135"], [11, "id138"], [11, "id140"], [11, "id144"], [11, "id146"], [11, "id148"], [11, "id153"], [11, "id157"], [11, "id160"], [11, "id162"], [11, "id164"], [11, "id166"], [11, "id168"], [11, "id170"], [11, "id175"], [11, "id178"], [11, "id180"], [11, "id183"], [11, "id185"], [11, "id187"], [11, "id190"], [11, "id194"], [11, "id196"], [11, "id198"], [11, "id201"], [11, "id203"], [11, "id206"], [11, "id208"], [11, "id212"], [11, "id215"], [11, "id218"]], "Traductions": [[11, "translations"], [11, "id2"], [11, "id6"], [11, "id9"], [11, "id12"], [11, "id13"], [11, "id17"], [11, "id20"], [11, "id23"], [11, "id24"], [11, "id28"], [11, "id32"], [11, "id35"], [11, "id37"], [11, "id41"], [11, "id45"], [11, "id49"], [11, "id52"], [11, "id55"], [11, "id59"], [11, "id64"], [11, "id69"], [11, "id72"], [11, "id74"], [11, "id77"], [11, "id80"], [11, "id83"], [11, "id86"], [11, "id89"], [11, "id90"], [11, "id93"], [11, "id95"], [11, "id97"], [11, "id98"], [11, "id101"], [11, "id103"], [11, "id106"], [11, "id108"], [11, "id112"], [11, "id113"], [11, "id115"], [11, "id118"], [11, "id131"], [13, "translations"]], "Types d\u2019\u00e9quipement": [[2, null], [13, "equipment-types"]], "Utilisateurs": [[9, null], [12, "users"], [13, "users"]], "Variables d\u2019environnement": [[15, "environment-variables"]], "Version 0.1.0 - Premi\u00e8re version \ud83c\udf89 (04/07/2018)": [[11, "version-0-1-0-first-release-2018-07-04"]], "Version 0.1.1 - Corrections et am\u00e9liorations (07/02/2019)": [[11, "version-0-1-1-fix-and-improvements-2019-02-07"]], "Version 0.2.0 - Statistiques (07/07/2019)": [[11, "version-0-2-0-statistics-2019-07-07"]], "Version 0.2.1 - Correction et am\u00e9liorations (01/09/2019)": [[11, "version-0-2-1-fix-and-improvements-2019-09-01"]], "Version 0.2.2 - Corrections des statistiques (23/09/2019)": [[11, "version-0-2-2-statistics-fix-2019-09-23"]], "Version 0.2.3 - FitTrackee disponible en Fran\u00e7ais (29/12/2019)": [[11, "version-0-2-3-fittrackee-available-in-french-2019-12-29"]], "Version 0.2.4 - Corrections mineures (30/01/2020)": [[11, "version-0-2-4-minor-fix-2020-01-30"]], "Version 0.2.5 - Corrections et am\u00e9liorations (31/01/2020)": [[11, "version-0-2-5-fix-and-improvements-2020-01-31"]], "Version 0.3.0 - Administration (15/07/2020)": [[11, "version-0-3-0-administration-2020-07-15"]], "Version 0.4.0 - FitTrackee sur PyPI (19/09/2020)": [[11, "version-0-4-0-fittrackee-on-pypi-2020-09-19"]], "Version 0.4.1 (31/12/2020)": [[11, "version-0-4-1-2020-12-31"]], "Version 0.4.2 (03/01/2021)": [[11, "version-0-4-2-2021-01-03"]], "Version 0.4.3 (10/01/2021)": [[11, "version-0-4-3-2021-01-10"]], "Version 0.4.4 (31/01/2021)": [[11, "version-0-4-4-2021-01-31"]], "Version 0.4.5 (17/02/2021)": [[11, "version-0-4-5-2021-02-17"]], "Version 0.4.6 (21/02/2021)": [[11, "version-0-4-6-2021-02-21"]], "Version 0.4.7 (07/04/2021)": [[11, "version-0-4-7-2021-04-07"]], "Version 0.4.8 (03/07/2021)": [[11, "version-0-4-8-2021-07-03"]], "Version 0.4.9 (16/07/2021)": [[11, "version-0-4-9-2021-07-16"]], "Version 0.5.0 (14/11/2021)": [[11, "version-0-5-0-2021-11-14"]], "Version 0.5.1 (30/11/2021)": [[11, "version-0-5-1-2021-11-30"]], "Version 0.5.2 (19/12/2021)": [[11, "version-0-5-2-2021-12-19"]], "Version 0.5.3 (01/01/2022)": [[11, "version-0-5-3-2022-01-01"]], "Version 0.5.4 (01/01/2022)": [[11, "version-0-5-4-2022-01-01"]], "Version 0.5.5 (19/01/2022)": [[11, "version-0-5-5-2022-01-19"]], "Version 0.5.6 (05/02/2022)": [[11, "version-0-5-6-2022-02-05"]], "Version 0.5.7 (13/02/2022)": [[11, "version-0-5-7-2022-02-13"]], "Version 0.6.0 (27/03/2022)": [[11, "version-0-6-0-2022-03-27"]], "Version 0.6.1 (27/03/2022)": [[11, "version-0-6-1-2022-03-27"]], "Version 0.6.10 (13/07/2022)": [[11, "version-0-6-10-2022-07-13"]], "Version 0.6.11 (27/02/2022)": [[11, "version-0-6-11-2022-07-27"]], "Version 0.6.12 (14/09/2022)": [[11, "version-0-6-12-2022-09-14"]], "Version 0.6.2 (03/04/2022)": [[11, "version-0-6-2-2022-04-03"]], "Version 0.6.3 (09/04/2022)": [[11, "version-0-6-3-2022-04-09"]], "Version 0.6.4 (23/04/2022)": [[11, "version-0-6-4-2022-04-23"]], "Version 0.6.5 (24/04/2022)": [[11, "version-0-6-5-2022-04-24"]], "Version 0.6.6 (29/05/2022)": [[11, "version-0-6-6-2022-05-29"]], "Version 0.6.7 (11/06/2022)": [[11, "version-0-6-7-2022-06-11"]], "Version 0.6.8 (22/06/2022)": [[11, "version-0-6-8-2022-06-22"]], "Version 0.6.9 (03/07/2022)": [[11, "version-0-6-9-2022-07-03"]], "Version 0.7.0 (19/09/2022)": [[11, "version-0-7-0-2022-09-19"]], "Version 0.7.1 (21/09/2022)": [[11, "version-0-7-1-2022-09-21"]], "Version 0.7.10 (21/12/2022)": [[11, "version-0-7-10-2022-12-21"]], "Version 0.7.11 (31/12/2022)": [[11, "version-0-7-11-2022-12-31"]], "Version 0.7.12 (16/02/2023)": [[11, "version-0-7-12-2023-02-16"]], "Version 0.7.13 (05/03/2023)": [[11, "version-0-7-13-2023-03-05"]], "Version 0.7.14 (08/03/2023)": [[11, "version-0-7-14-2023-03-08"]], "Version 0.7.15 (12/04/2023)": [[11, "version-0-7-15-2023-04-12"]], "Version 0.7.16 (29/05/2023)": [[11, "version-0-7-16-2023-05-29"]], "Version 0.7.17 (03/06/2023)": [[11, "version-0-7-17-2023-06-03"]], "Version 0.7.18 (25/06/2023)": [[11, "version-0-7-18-2023-06-25"]], "Version 0.7.19 (15/07/2023)": [[11, "version-0-7-19-2023-07-15"]], "Version 0.7.2 (21/09/2022)": [[11, "version-0-7-2-2022-09-21"]], "Version 0.7.20 (22/07/2023)": [[11, "version-0-7-20-2023-07-22"]], "Version 0.7.21 (30/07/2023)": [[11, "version-0-7-21-2023-07-30"]], "Version 0.7.22 (23/08/2023)": [[11, "version-0-7-22-2023-08-23"]], "Version 0.7.23 (14/09/2023)": [[11, "version-0-7-23-2023-09-14"]], "Version 0.7.24 (04/10/2023)": [[11, "version-0-7-24-2023-10-04"]], "Version 0.7.25 (08/10/2023)": [[11, "version-0-7-25-2023-10-08"]], "Version 0.7.26 (19/11/2023)": [[11, "version-0-7-26-2023-11-19"]], "Version 0.7.27 (20/12/2023)": [[11, "version-0-7-27-2023-12-20"]], "Version 0.7.28 (23/12/2023)": [[11, "version-0-7-28-2023-12-23"]], "Version 0.7.29 (04/01/2024)": [[11, "version-0-7-29-2024-01-06"]], "Version 0.7.3 (01/11/2022)": [[11, "version-0-7-3-2022-11-01"]], "Version 0.7.30 (04/02/2024)": [[11, "version-0-7-30-2024-02-04"]], "Version 0.7.31 (10/02/2024)": [[11, "version-0-7-31-2024-02-10"]], "Version 0.7.32 (10/03/2024)": [[11, "version-0-7-32-2024-03-10"]], "Version 0.7.4 (05/11/2022)": [[11, "version-0-7-4-2022-11-05"]], "Version 0.7.5 (09/11/2022)": [[11, "version-0-7-5-2022-11-09"]], "Version 0.7.6 (09/11/2022)": [[11, "version-0-7-6-2022-11-09"]], "Version 0.7.7 (27/11/2022)": [[11, "version-0-7-7-2022-11-27"]], "Version 0.7.8 (30/11/2022)": [[11, "version-0-7-8-2022-11-30"]], "Version 0.7.9 (11/12/2022)": [[11, "version-0-7-9-2022-12-11"]], "Version 0.8.0 (21/04/2024)": [[11, "version-0-8-0-2024-04-21"]], "Version 0.8.1 (01/05/2024)": [[11, "version-0-8-1-2024-05-01"]], "Version 0.8.10 (09/10/2024)": [[11, "version-0-8-10-2024-10-09"]], "Version 0.8.11 (30/10/2024)": [[11, "version-0-8-11-2024-10-30"]], "Version 0.8.12 (17/11/2024)": [[11, "version-0-8-12-2024-11-17"]], "Version 0.8.2 (08/05/2024)": [[11, "version-0-8-2-2024-05-08"]], "Version 0.8.3 (09/05/2024)": [[11, "version-0-8-3-2024-05-09"]], "Version 0.8.4 (22/05/2024)": [[11, "version-0-8-4-2024-05-22"]], "Version 0.8.5 (29/06/2024)": [[11, "version-0-8-5-2024-06-29"]], "Version 0.8.6 (03/08/2024)": [[11, "version-0-8-6-2024-08-03"]], "Version 0.8.7 (25/08/2024)": [[11, "version-0-8-7-2024-08-25"]], "Version 0.8.8 (01/09/2024)": [[11, "version-0-8-8-2024-09-01"]], "Version 0.8.9 (21/09/2024)": [[11, "version-0-8-9-2024-09-21"]], "Yunohost": [[15, "yunohost"]], "ftcli db drop": [[12, "ftcli-db-drop"]], "ftcli db upgrade": [[12, "ftcli-db-upgrade"]], "ftcli oauth2 clean": [[12, "ftcli-oauth2-clean"]], "ftcli users clean_archives": [[12, "ftcli-users-clean-archives"]], "ftcli users clean_tokens": [[12, "ftcli-users-clean-tokens"]], "ftcli users create": [[12, "ftcli-users-create"]], "ftcli users export_archives": [[12, "ftcli-users-export-archives"]], "ftcli users update": [[12, "ftcli-users-update"]], "\u00c9chec du chargement ou du t\u00e9l\u00e9chargement de fichiers": [[18, "failed-to-upload-or-download-files"]], "\u00c9quipements": [[3, null], [13, "equipments"], [13, "id1"]]}, "docnames": ["api/auth", "api/configuration", "api/equipment_types", "api/equipments", "api/index", "api/oauth2", "api/records", "api/sports", "api/stats", "api/users", "api/workouts", "changelog", "cli", "features", "index", "installation", "oauth", "third_party_tools", "troubleshooting/administrator", "troubleshooting/index"], "envversion": {"sphinx": 62, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["api/auth.rst", "api/configuration.rst", "api/equipment_types.rst", "api/equipments.rst", "api/index.rst", "api/oauth2.rst", "api/records.rst", "api/sports.rst", "api/stats.rst", "api/users.rst", "api/workouts.rst", "changelog.md", "cli.rst", "features.rst", "index.rst", "installation.rst", "oauth.rst", "third_party_tools.rst", "troubleshooting/administrator.rst", "troubleshooting/index.rst"], "indexentries": {"api_rate_limits": [[15, "envvar-API_RATE_LIMITS", false]], "app_log": [[15, "envvar-APP_LOG", false]], "app_secret_key": [[15, "envvar-APP_SECRET_KEY", false]], "app_settings": [[15, "envvar-APP_SETTINGS", false]], "app_workers": [[15, "envvar-APP_WORKERS", false]], "database_disable_pooling": [[15, "envvar-DATABASE_DISABLE_POOLING", false]], "database_url": [[15, "envvar-DATABASE_URL", false]], "default_staticmap": [[15, "envvar-DEFAULT_STATICMAP", false]], "email_url": [[15, "envvar-EMAIL_URL", false]], "flask_app": [[15, "envvar-FLASK_APP", false]], "host": [[15, "envvar-HOST", false]], "map_attribution": [[15, "envvar-MAP_ATTRIBUTION", false]], "port": [[15, "envvar-PORT", false]], "redis_url": [[15, "envvar-REDIS_URL", false]], "sender_email": [[15, "envvar-SENDER_EMAIL", false]], "staticmap_subdomains": [[15, "envvar-STATICMAP_SUBDOMAINS", false]], "tile_server_url": [[15, "envvar-TILE_SERVER_URL", false]], "ui_url": [[15, "envvar-UI_URL", false]], "upload_folder": [[15, "envvar-UPLOAD_FOLDER", false]], "variable d'environnement": [[15, "envvar-API_RATE_LIMITS", false], [15, "envvar-APP_LOG", false], [15, "envvar-APP_SECRET_KEY", false], [15, "envvar-APP_SETTINGS", false], [15, "envvar-APP_WORKERS", false], [15, "envvar-DATABASE_DISABLE_POOLING", false], [15, "envvar-DATABASE_URL", false], [15, "envvar-DEFAULT_STATICMAP", false], [15, "envvar-EMAIL_URL", false], [15, "envvar-FLASK_APP", false], [15, "envvar-HOST", false], [15, "envvar-MAP_ATTRIBUTION", false], [15, "envvar-PORT", false], [15, "envvar-REDIS_URL", false], [15, "envvar-SENDER_EMAIL", false], [15, "envvar-STATICMAP_SUBDOMAINS", false], [15, "envvar-TILE_SERVER_URL", false], [15, "envvar-UI_URL", false], [15, "envvar-UPLOAD_FOLDER", false], [15, "envvar-VITE_APP_API_URL", false], [15, "envvar-WEATHER_API_KEY", false], [15, "envvar-WEATHER_API_PROVIDER", false], [15, "envvar-WORKERS_PROCESSES", false]], "vite_app_api_url": [[15, "envvar-VITE_APP_API_URL", false]], "weather_api_key": [[15, "envvar-WEATHER_API_KEY", false]], "weather_api_provider \ud83c\udd95": [[15, "envvar-WEATHER_API_PROVIDER", false]], "workers_processes": [[15, "envvar-WORKERS_PROCESSES", false]]}, "objects": {"": [[0, 0, 1, "post--api-auth-account-confirm", "/api/auth/account/confirm"], [0, 1, 1, "get--api-auth-account-export", "/api/auth/account/export"], [0, 1, 1, "get--api-auth-account-export-(string-file_name)", "/api/auth/account/export/(string:file_name)"], [0, 0, 1, "post--api-auth-account-export-request", "/api/auth/account/export/request"], [0, 0, 1, "post--api-auth-account-privacy-policy", "/api/auth/account/privacy-policy"], [0, 0, 1, "post--api-auth-account-resend-confirmation", "/api/auth/account/resend-confirmation"], [0, 0, 1, "post--api-auth-email-update", "/api/auth/email/update"], [0, 0, 1, "post--api-auth-login", "/api/auth/login"], [0, 0, 1, "post--api-auth-logout", "/api/auth/logout"], [0, 0, 1, "post--api-auth-password-reset-request", "/api/auth/password/reset-request"], [0, 0, 1, "post--api-auth-password-update", "/api/auth/password/update"], [0, 2, 1, "delete--api-auth-picture", "/api/auth/picture"], [0, 0, 1, "post--api-auth-picture", "/api/auth/picture"], [0, 1, 1, "get--api-auth-profile", "/api/auth/profile"], [0, 0, 1, "post--api-auth-profile-edit", "/api/auth/profile/edit"], [0, 3, 1, "patch--api-auth-profile-edit-account", "/api/auth/profile/edit/account"], [0, 0, 1, "post--api-auth-profile-edit-preferences", "/api/auth/profile/edit/preferences"], [0, 0, 1, "post--api-auth-profile-edit-sports", "/api/auth/profile/edit/sports"], [0, 2, 1, "delete--api-auth-profile-reset-sports-(sport_id)", "/api/auth/profile/reset/sports/(sport_id)"], [0, 0, 1, "post--api-auth-register", "/api/auth/register"], [1, 1, 1, "get--api-config", "/api/config"], [1, 3, 1, "patch--api-config", "/api/config"], [2, 1, 1, "get--api-equipment-types", "/api/equipment-types"], [2, 1, 1, "get--api-equipment-types-(int-equipment_type_id)", "/api/equipment-types/(int:equipment_type_id)"], [2, 3, 1, "patch--api-equipment-types-(int-equipment_type_id)", "/api/equipment-types/(int:equipment_type_id)"], [3, 1, 1, "get--api-equipments", "/api/equipments"], [3, 0, 1, "post--api-equipments", "/api/equipments"], [3, 2, 1, "delete--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [3, 1, 1, "get--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [3, 3, 1, "patch--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [3, 0, 1, "post--api-equipments-(string-equipment_short_id)-refresh", "/api/equipments/(string:equipment_short_id)/refresh"], [5, 1, 1, "get--api-oauth-apps", "/api/oauth/apps"], [5, 0, 1, "post--api-oauth-apps", "/api/oauth/apps"], [5, 2, 1, "delete--api-oauth-apps-(int-client_id)", "/api/oauth/apps/(int:client_id)"], [5, 1, 1, "get--api-oauth-apps-(int-client_id)-by_id", "/api/oauth/apps/(int:client_id)/by_id"], [5, 0, 1, "post--api-oauth-apps-(int-client_id)-revoke", "/api/oauth/apps/(int:client_id)/revoke"], [5, 1, 1, "get--api-oauth-apps-(string-client_client_id)", "/api/oauth/apps/(string:client_client_id)"], [5, 0, 1, "post--api-oauth-authorize", "/api/oauth/authorize"], [5, 0, 1, "post--api-oauth-revoke", "/api/oauth/revoke"], [5, 0, 1, "post--api-oauth-token", "/api/oauth/token"], [1, 1, 1, "get--api-ping", "/api/ping"], [6, 1, 1, "get--api-records", "/api/records"], [7, 1, 1, "get--api-sports", "/api/sports"], [7, 1, 1, "get--api-sports-(int-sport_id)", "/api/sports/(int:sport_id)"], [7, 3, 1, "patch--api-sports-(int-sport_id)", "/api/sports/(int:sport_id)"], [8, 1, 1, "get--api-stats-(user_name)-by_sport", "/api/stats/(user_name)/by_sport"], [8, 1, 1, "get--api-stats-(user_name)-by_time", "/api/stats/(user_name)/by_time"], [8, 1, 1, "get--api-stats-all", "/api/stats/all"], [9, 1, 1, "get--api-users", "/api/users"], [9, 2, 1, "delete--api-users-(user_name)", "/api/users/(user_name)"], [9, 1, 1, "get--api-users-(user_name)", "/api/users/(user_name)"], [9, 3, 1, "patch--api-users-(user_name)", "/api/users/(user_name)"], [9, 1, 1, "get--api-users-(user_name)-picture", "/api/users/(user_name)/picture"], [10, 1, 1, "get--api-workouts", "/api/workouts"], [10, 0, 1, "post--api-workouts", "/api/workouts"], [10, 2, 1, "delete--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [10, 3, 1, "patch--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)-chart_data", "/api/workouts/(string:workout_short_id)/chart_data"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)-chart_data-segment-(int-segment_id)", "/api/workouts/(string:workout_short_id)/chart_data/segment/(int:segment_id)"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)-gpx", "/api/workouts/(string:workout_short_id)/gpx"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)-gpx-download", "/api/workouts/(string:workout_short_id)/gpx/download"], [10, 1, 1, "get--api-workouts-(string-workout_short_id)-gpx-segment-(int-segment_id)", "/api/workouts/(string:workout_short_id)/gpx/segment/(int:segment_id)"], [10, 1, 1, "get--api-workouts-map-(map_id)", "/api/workouts/map/(map_id)"], [10, 0, 1, "post--api-workouts-no_gpx", "/api/workouts/no_gpx"], [15, 4, 1, "-", "API_RATE_LIMITS"], [15, 4, 1, "-", "APP_LOG"], [15, 4, 1, "-", "APP_SECRET_KEY"], [15, 4, 1, "-", "APP_SETTINGS"], [15, 4, 1, "-", "APP_WORKERS"], [15, 4, 1, "-", "DATABASE_DISABLE_POOLING"], [15, 4, 1, "-", "DATABASE_URL"], [15, 4, 1, "-", "DEFAULT_STATICMAP"], [15, 4, 1, "-", "EMAIL_URL"], [15, 4, 1, "-", "FLASK_APP"], [15, 4, 1, "-", "HOST"], [15, 4, 1, "-", "MAP_ATTRIBUTION"], [15, 4, 1, "-", "PORT"], [15, 4, 1, "-", "REDIS_URL"], [15, 4, 1, "-", "SENDER_EMAIL"], [15, 4, 1, "-", "STATICMAP_SUBDOMAINS"], [15, 4, 1, "-", "TILE_SERVER_URL"], [15, 4, 1, "-", "UI_URL"], [15, 4, 1, "-", "UPLOAD_FOLDER"], [15, 4, 1, "-", "VITE_APP_API_URL"], [15, 4, 1, "-", "WEATHER_API_KEY"], [15, 4, 1, "envvar-WEATHER_API_PROVIDER", "WEATHER_API_PROVIDER \ud83c\udd95"], [15, 4, 1, "-", "WORKERS_PROCESSES"]], "/api/workouts/map_tile/(s)/(z)/(x)/(y)": [[10, 1, 1, "get--api-workouts-map_tile-(s)-(z)-(x)-(y).png", "png"]]}, "objnames": {"0": ["http", "post", "HTTP post"], "1": ["http", "get", "HTTP get"], "2": ["http", "delete", "HTTP delete"], "3": ["http", "patch", "HTTP patch"], "4": ["std", "envvar", "variable d'environnement"]}, "objtypes": {"0": "http:post", "1": "http:get", "2": "http:delete", "3": "http:patch", "4": "std:envvar"}, "terms": {"0": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15], "00": [0, 3, 6, 9, 10], "000": 13, "000000": 0, "01": [0, 6, 8, 9, 10], "02": 10, "03": [9, 10], "04": 10, "06": [3, 5, 8], "0667062": 5, "06ba975": 11, "07": [0, 6, 9, 10], "075aeb9": 11, "08": [0, 3, 6, 9, 10], "09": [0, 9], "0mb": [0, 10], "1": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 12, 13, 15], "10": [0, 1, 3, 6, 9, 10, 13, 15], "100": [10, 11, 13], "1000": 8, "10000": [1, 10], "101": [8, 11], "104": 11, "1048576": 1, "10485760": 1, "106": 11, "109": 11, "11": [0, 6, 9, 13, 15], "112": 11, "113": 11, "115": 11, "116": 11, "12": [0, 1, 6, 9, 10, 15, 17], "121": 11, "123": 11, "1232004": 10, "12341": 8, "1234538": 10, "125": [11, 13], "1267": 8, "127": [11, 15], "129": 11, "13": [0, 6, 9, 10, 12, 13, 15], "131": 11, "134": 11, "135": 11, "1375986": 11, "138": 11, "14": [0, 5, 9, 10], "140": 11, "145": 11, "146": 11, "149": 11, "15": [8, 10, 12, 13, 15], "150": 8, "151": 11, "152": 11, "155": 11, "156": [8, 11], "1563529507772": 10, "16": [8, 10, 13], "160": 11, "161": 11, "162": 11, "1658660147": 5, "167": 11, "169": 11, "17": [0, 10], "171": 11, "173": 11, "175": 11, "177": 11, "178": [8, 11], "18": [0, 6, 9, 10, 13, 15], "180": 11, "19": 13, "190": 11, "191": 11, "192": 11, "193": 11, "195": 11, "196": 11, "197": 11, "1m": 15, "1mb": 15, "2": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 13, 14, 15], "20": [9, 13], "200": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 15], "201": [3, 10, 11], "2017": [8, 10], "2018": [8, 10], "2019": [0, 6, 8, 9, 10], "2022": 5, "2023": [0, 3, 15], "203": 8, "204": [0, 3, 5, 9, 10], "208": 11, "209": 11, "21": 3, "210": 11, "212": 11, "213": 11, "22": 10, "223": 11, "224": 11, "225": 11, "23": 15, "230": 11, "231": 11, "232": 11, "236": 11, "237": 11, "239": 11, "24": 15, "241": 11, "242": 11, "244": 11, "246": 11, "247": 11, "25": 15, "250": 11, "252": 11, "255": 10, "257": 11, "258": 11, "259": 11, "26": 15, "260": 11, "261": 11, "264": 11, "265": 11, "266": 11, "26and": 15, "27": [0, 5, 9, 13], "270": 11, "271": 11, "273": 11, "274": 11, "275": 11, "278": 11, "279": [10, 11], "28": 3, "280": [10, 11], "282": [8, 11], "287": 11, "289": 11, "29": 3, "290": 11, "2930": 10, "294": 11, "297": 11, "2bcff2e": 11, "2e1ee2c": 11, "2ukrviyshoakg8qsuknus4": 3, "3": [0, 2, 7, 8, 9, 10, 13, 15], "30": [0, 8], "300": 15, "3000": 15, "301": [11, 15], "304": 11, "305": 11, "307": 11, "308": 11, "31": [0, 10, 13, 15], "310": 11, "314": 11, "315": 11, "318": 11, "319": 11, "320": 11, "323": 11, "328": 11, "329": 11, "33": [8, 11], "3320": 8, "333": 11, "338": 11, "34": 11, "34614d5": 11, "35": [0, 11], "350": 11, "351": 11, "352": 11, "354": 11, "356": 11, "357": 11, "358": 11, "359": 11, "36": 11, "365": 11, "366": 11, "367": 11, "369": 11, "37": 11, "370": 11, "371": 11, "374": 11, "375": 11, "376": 11, "377": 11, "380": 11, "3821e37": 11, "384": 11, "386": 11, "388": 11, "39": 10, "390": 11, "391": 11, "393": 11, "394": 11, "395": 11, "397": 11, "398": 11, "399": 11, "3aread": 16, "3awrit": 16, "3b6fa25": 11, "3c8d9c2": 11, "3f": 15, "4": [0, 2, 7, 8, 9, 10, 12, 13, 15], "40": 11, "400": [0, 1, 2, 3, 5, 7, 8, 9, 10, 11], "401": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11], "402": 11, "403": [0, 1, 2, 3, 7, 8, 9, 10], "404": [0, 2, 3, 5, 7, 8, 9, 10, 11], "406": 11, "407": 11, "409": 11, "40and": 15, "41": 11, "410": 11, "4109": 10, "411": 11, "413": [0, 10], "415": 11, "416": 11, "417": 11, "418": 11, "42": 11, "421": 11, "422": 11, "426": 11, "427": 11, "428": 11, "43": [0, 6, 9, 10, 11], "431": 11, "433": 11, "436": 11, "438": 11, "44": [10, 11], "441": 11, "443": 15, "444": 11, "449": 11, "45": 10, "450": 11, "455": 11, "456": 11, "46": [8, 11], "464": 11, "465": 15, "468": 11, "469": 11, "47": [8, 10, 11], "471": 11, "472": 11, "473": 11, "474": 11, "475": 11, "476": 11, "477": 11, "478": 11, "479": 11, "48": 8, "481": 11, "482": 11, "484": 11, "488": 11, "489": 11, "490": 11, "494": 11, "495": 11, "496": 11, "499": 11, "4c3fc34": 11, "5": [2, 5, 7, 8, 9, 10, 12, 13, 15], "50": [0, 3, 8, 9, 11, 15], "500": [0, 1, 2, 3, 7, 9, 10, 11], "5000": 15, "502": 11, "504": 11, "506": 11, "507": 11, "5078118": 10, "5079733": 10, "508": 11, "51": 10, "510": 11, "511": 11, "512": 11, "51758b4": 11, "52": 11, "521": 11, "524": 11, "526": 11, "527": 11, "528": 11, "53": [5, 11], "530": 11, "531": 11, "532": 11, "533": 11, "534": 11, "536": 11, "537": 11, "538": 11, "54": 11, "540": 11, "542": 11, "543": 11, "5432": 15, "544": 11, "545": 11, "546": 11, "550": 11, "551": 11, "555": 11, "556": 11, "557": 11, "558": 11, "56": 11, "563": 11, "564": 11, "565": 11, "566": 11, "57": [10, 11], "571": 11, "575": 11, "58": [0, 9, 11], "582": 11, "583": 11, "587": [11, 15], "588": 11, "59": [8, 11], "590": 11, "591": 11, "592": 11, "593": 11, "595": 11, "598": 11, "6": [0, 2, 3, 7, 9, 10, 12, 13, 15], "60": 11, "600": 11, "603": 11, "604": 11, "607": 11, "608": 11, "609": 11, "60e164d": 11, "61": 11, "610": 11, "612": 11, "613": 8, "614": 11, "616": 11, "617": 11, "618": 11, "62": 11, "620": 11, "621": 11, "622": 11, "624": 11, "625": 11, "626": 11, "628": 11, "629": 11, "63": 10, "631": 11, "633": 11, "634": 11, "635": 11, "636": 11, "637": 11, "639": [11, 12], "64": 11, "640": 11, "645": 11, "651": 11, "652": 11, "66": 11, "67": [0, 8, 9], "6e215a": 11, "7": [10, 12, 13, 15, 16], "70": 11, "71": 11, "72": 11, "720": 0, "73": 11, "7380": 10, "74": 11, "75": 11, "7641": 8, "78": 8, "79": 11, "8": [0, 1, 10, 12, 13, 15, 16], "80": [11, 15], "8025": 15, "81": 11, "82": 11, "83": 11, "84": 11, "85": 11, "864000": 5, "87": 11, "877fa0f": 11, "88": 11, "89": 11, "895": [0, 9], "8aa4cff": 11, "9": [0, 6, 9, 13, 15], "90": 11, "91": 11, "92": 11, "924": 0, "93": 11, "95": [8, 11], "97": [0, 6, 9, 10, 11], "98": 11, "99": [8, 11], "9960": 8, "AS": [0, 6, 9, 10], "Avec": 13, "Ce": [5, 11], "Cette": [11, 14, 15], "D": 15, "Des": 13, "Du": 11, "EU": 11, "Elle": 13, "Elles": 15, "En": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 13, 14], "Et": 11, "Il": [0, 11, 13, 14, 15, 16], "Ils": 16, "J": 11, "L": [0, 1, 2, 7, 11, 13, 15, 16], "La": [0, 10, 11, 12, 13, 15, 16, 18], "Le": [0, 3, 11, 13, 15], "Les": [0, 6, 10, 13, 14, 15, 16, 19], "M": 10, "MS": [0, 6, 9, 10], "Mon": 10, "Ne": 9, "Par": [15, 16], "Pas": 11, "Pour": [10, 11, 13, 15, 16], "S": 13, "SA": 15, "Sur": 15, "Tue": 3, "Un": [3, 9, 11, 13, 15], "Une": [11, 12, 13, 15, 18], "Y": [0, 8, 10], "_": [0, 11], "__main__": 15, "_blank": 15, "_workers_": 15, "a": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 13, 14], "a458f5f": 11, "aaron": 16, "ability": 11, "about": [1, 11, 15], "absolu": [15, 18], "acced": [8, 15, 16], "accept": [0, 11, 13], "accepted_policy": 0, "accepted_privacy_policy": 0, "acces": [0, 1, 5, 16], "access": [0, 11], "access_token": 5, "accessibility": 11, "accessibl": [5, 11, 15, 16], "accord": 16, "account": [0, 9, 11], "actif": [0, 3, 12, 13], "action": 11, "activ": [0, 1, 2, 7, 9, 11, 12, 13, 14, 15], "activat": [9, 12], "activit": 11, "activity": 11, "actuel": [0, 3], "adapt": [13, 15], "add": 11, "added": [0, 3, 10, 11], "adding": 11, "additional": 11, "additionnel": 15, "admin": [0, 1, 2, 6, 7, 8, 9, 10, 12, 15], "admin_contact": 1, "administr": [1, 2, 7, 9, 12, 15, 16, 19], "administrator": [0, 2, 3, 7, 9, 10], "adress": [0, 1, 9, 12, 13, 15], "aff4d68": 11, "affect": [11, 13], "affich": [0, 10, 11, 12, 13, 14, 15, 16, 19], "affichag": 13, "afin": [11, 13, 16], "after": [11, 15], "again": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "agplv3": 11, "agre": 0, "aid": 14, "ains": [13, 15], "air": [13, 14], "ajout": [0, 9, 11, 12, 13, 14, 15, 17], "alert": 11, "all": [8, 15], "allemand": [11, 13], "allow": [11, 15], "allowed": [0, 10], "alor": 15, "alphanumer": 11, "alphanumeric": 0, "alpin": 13, "already": [0, 3, 11], "also": 11, "altern": 11, "altitud": [0, 11, 13], "alway": 15, "al\u00e9atoir": [12, 15], "an": [0, 5, 10, 11], "analys": [13, 15], "ancien": 15, "and": [0, 3, 11, 12, 15], "android": 14, "anglais": [0, 11, 12, 13], "ann\u00e9": [8, 13], "anoth": 3, "antiali": 11, "anymor": 11, "apach": 14, "api": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 13, 14, 16], "api_rate_limit": 15, "apikey": 15, "app": [5, 11, 15, 16], "app_log": 15, "app_secret_key": 15, "app_setting": 15, "app_worker": 15, "appar": 13, "appara\u00eetr": 13, "application_directory": 15, "appliqu": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16], "apport": 11, "apres": [0, 5, 11, 13, 15, 16], "archiv": [0, 1, 11, 12, 13, 15], "archive_rgjsr3fhr5yp": 0, "archive_rgjsr3fht295ywnqr5yp": 0, "archlinux": 15, "are": 11, "arg": [12, 15], "argument": [3, 12], "array": [0, 3, 5, 10], "arrow": 11, "arr\u00eat": [0, 11, 13, 15], "asc": [9, 10], "ascent": [10, 11], "asset": 15, "associ": [0, 3, 5, 7, 10, 11, 12, 13, 16], "associated": [3, 11], "assur": 16, "astridx": 11, "at": [11, 15], "atteint": 13, "attendu": 15, "attent": [11, 15, 18], "attribu": 15, "attribut": 11, "aucun": [0, 3, 10, 13, 14, 15], "augment": 18, "aur": [11, 13], "auth": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "auth_token": 0, "authentif": [4, 15, 16], "authentifi": [0, 1, 2, 5, 6, 7, 8, 9, 10, 13], "authlib": [5, 15, 16], "authoriz": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 15, 16], "authorization_cod": [5, 16], "autoespcap": 11, "automat": 13, "autoris": [0, 1, 5, 10, 15, 16], "autr": [0, 9, 13, 15], "avail": 11, "avanc": [11, 13], "avant": [11, 13, 15], "ave_speed": 10, "ave_speed_from": 10, "ave_speed_to": 10, "averag": [8, 11], "average_ascent": 8, "average_descent": 8, "average_dist": 8, "average_dur": 8, "average_speed": 8, "avert": 13, "aviron": 13, "avoir": [1, 2, 7, 15], "awesom": 15, "axe": [10, 13], "axis": 11, "b": 15, "b1536fc": 11, "b29ed7": 11, "b748459": 11, "b862a77": 11, "background": 11, "bad": [0, 1, 2, 3, 5, 7, 8, 9, 10], "bas": [11, 13, 15, 18], "basqu": [11, 13], "bcc568e": 11, "be": [0, 1, 3, 9, 10, 11, 15], "bear": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "below": 15, "besoin": 15, "bet": 11, "between": 11, "bibliothequ": 16, "bien": 13, "bik": [2, 3], "biking": [7, 10], "bin": 15, "bio": [0, 9], "biograph": 0, "birth": 11, "birth_dat": [0, 9], "bjornclauw": 11, "black": 11, "blacklist": 0, "bloqu": 13, "boat": 13, "body": [11, 15], "bokm\u00e5l": [11, 13], "boolean": [0, 1, 2, 3, 5, 7, 9, 12], "boosterl": 11, "bound": 10, "bouton": 13, "bref": 3, "brows": 11, "build": [11, 15], "bulgar": [11, 13], "bulgarian": 11, "button": 11, "by": [11, 15], "by_id": 5, "by_sport": 8, "by_tim": 8, "byakurau": 11, "c88a515": 11, "cach": 13, "calcul": [0, 8, 11, 13], "calendar": 11, "calendri": [11, 13], "callback": [5, 16], "can": [0, 9, 10, 11, 15, 18], "cannot": [3, 11], "caracter": [0, 3, 5, 10, 11, 15, 16], "card": 11, "cart": [10, 11, 13, 14, 15, 19], "cas": [3, 5, 10, 13, 15], "cass": 15, "cb9d02f": 11, "cc": 15, "cc3fe1c": 11, "cc4287e": 11, "cd": 15, "cec": 11, "cel": [3, 11, 13, 15], "celui": [11, 15], "certain": [11, 12, 14, 15], "cf": [13, 14, 15], "chain": 15, "challeng": [5, 16], "champ": [11, 13], "chang": [0, 3, 11, 13, 15], "changed": 11, "changing": 11, "chaqu": 13, "charact": 0, "character": [0, 3, 11], "charg": [0, 5, 11, 12, 13, 15, 16, 19], "chart": [10, 11, 15], "chart_dat": 10, "chaussur": 13, "cha\u00een": [5, 16], "check": 15, "check_workout": 7, "checkbox": 11, "checked": 11, "checking": 15, "chemin": [15, 18], "chiffr": [13, 15], "chois": [13, 15], "choos": 11, "ci": 11, "clair": 13, "cleanup": 11, "clear": 15, "cli": [11, 12, 13, 15], "click": 11, "clickabl": 11, "clicking": 11, "client": [5, 11, 13, 15, 16], "client_client_id": 5, "client_descript": 5, "client_id": [5, 16], "client_max_body_siz": [15, 18], "client_nam": 5, "client_secret": 5, "client_ur": 5, "clon": 15, "closing": 11, "cl\u00e9": [11, 13, 15], "co": 11, "cod": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 16], "code_challeng": [5, 16], "code_challenge_method": [5, 16], "code_verifi": 5, "coll": 15, "color": [0, 7, 11], "com": [0, 1, 5, 9, 11, 15, 16], "comm": [11, 13, 15], "command": [11, 13, 14, 15], "commenc": [0, 8, 11, 13, 15, 18], "commencent": 0, "commend": 11, "compatibl": 15, "complet": 11, "completed": 0, "compl\u00e9mentair": 11, "comport": 13, "compos": 15, "compt": [4, 9, 10, 11, 12, 15, 17], "comradekingu": 11, "condit": 15, "confidential": [0, 1, 11, 13], "config": [1, 11, 15], "configur": [4, 11, 13, 15, 16, 18], "configured": 11, "confirm": [0, 5, 11, 13, 15], "confusedalex": 11, "connaiss": [11, 13], "connect": [0, 13], "connexion": [0, 15], "conserv": [14, 15], "consult": 13, "contact": [0, 1, 2, 3, 7, 9, 10, 13], "contain": [11, 15], "containing": 11, "conten": [11, 13, 15], "content": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11], "contient": [11, 13, 15], "continu": 15, "contr": 16, "contrair": [10, 13], "contributeur": [11, 15, 17], "contributing": 11, "contributor": [1, 15], "control": [11, 15], "contr\u00f4l": 1, "copi": 15, "copy": [1, 15], "copyright": [1, 15], "cor": 11, "corp": 16, "correctly": 11, "correspond": [0, 10, 15], "correspondr": 13, "could": 11, "couleur": [0, 13], "cour": [11, 12, 14, 15], "courriel": [0, 9, 11, 13, 18], "cours": [11, 13], "court": [3, 10], "cp": 15, "creat": [11, 15], "create_app": 15, "created": [3, 5, 10], "created_at": [0, 9], "creation": 11, "creation_dat": [3, 10], "creativecommon": 15, "credential": 0, "criter": 9, "criteri": 10, "critical": 18, "cross": [5, 16], "crossing": [13, 15], "cr\u00e9": [0, 3, 5, 10, 11, 12, 13, 15, 16], "cr\u00e9ation": [0, 11, 13], "csrf": [5, 16], "current": [0, 9, 11], "custom": 15, "cycling": [7, 11], "czech": 11, "dan": [0, 1, 3, 10, 11, 12, 13, 14, 15, 16, 19], "danielsiersleben": 11, "dark": 11, "darksky": [11, 15], "dashboard": 11, "dat": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 13], "databas": [11, 12, 15], "database_disable_pooling": 15, "database_url": [15, 18], "date_format": 0, "date_string": 0, "davidhenrythoreau": 11, "day": [11, 12, 15], "db": 15, "dd": 0, "debian": [15, 17], "default": [8, 10, 11, 15], "default_equipment_id": 0, "default_for_sport_id": 3, "default_staticmap": [11, 15], "definit": 11, "del": 18, "delet": [0, 3, 5, 9, 10, 11], "deleted": 11, "deleting": 11, "demand": [0, 11, 12, 13, 15], "deployment": 11, "deprecated": 11, "depuis": [12, 15], "derni": [11, 15], "derri": 16, "desc": [9, 10, 13], "descent": [10, 11], "descript": [3, 5, 10, 11, 12, 13, 15], "dessous": 11, "detail": 15, "detailed": 11, "detect": 11, "deux": 12, "dev": 15, "development": 11, "df": 15, "diagnostic": 15, "dialect": 18, "differ": 13, "different": [9, 11], "diff\u00e9rent": [11, 13], "dimanch": [8, 13], "direct": [11, 13, 15], "directory": [11, 15], "disabl": 11, "disabled": 0, "discours": 13, "display": 11, "display_ascent": 0, "displayed": 11, "displaying": 11, "disponibl": [0, 6, 12, 13, 15, 16, 17], "dispos": [0, 9, 13], "distanc": [0, 6, 10, 11, 13], "distance_from": 10, "distance_to": 10, "dkm": 11, "do": [0, 1, 2, 3, 7, 8, 9, 10, 11], "dock": 11, "docu": 11, "document": [14, 15, 16, 18], "doit": [0, 1, 2, 3, 5, 7, 10, 11, 13, 15, 16, 18], "doiv": 13, "doivent": [10, 15, 16], "domain": [10, 15], "don": [0, 5, 8, 10, 11, 13, 14, 16, 18], "dont": [13, 16], "dor\u00e9nav": 11, "dotenv": 11, "dotlambd": 11, "doubl": 10, "down": 11, "download": [0, 10, 11], "dperruso": 11, "dramatiq": [11, 12, 15], "droit": [1, 2, 7, 9, 11, 12, 13, 15, 16], "drop": 11, "dropdown": 11, "dur": [6, 10, 11, 13], "durat": [10, 11], "duration_from": 10, "duration_to": 10, "during": [0, 3, 10], "dutch": 11, "d\u00e9": 2, "d\u00e9but": [8, 10], "d\u00e9connexion": 0, "d\u00e9crivent": 15, "d\u00e9faut": [0, 3, 5, 9, 10, 11, 12, 13, 15], "d\u00e9fin": [3, 11, 13, 16, 18], "d\u00e9j\u00e0": [10, 12], "d\u00e9livr": 5, "d\u00e9marr": [11, 15, 19], "d\u00e9marrag": 15, "d\u00e9nivel": [0, 6, 10, 13], "d\u00e9pannag": 14, "d\u00e9part": 13, "d\u00e9pend": 11, "d\u00e9plac": 11, "d\u00e9p\u00f4t": 15, "d\u00e9roul": 13, "d\u00e9sactiv": [0, 11, 12, 13, 15], "d\u00e9sorm": 11, "d\u00e9tail": 19, "d\u00e9velopp": [11, 12, 14], "e": 0, "e2e": 11, "ea0ac99": 11, "eau": 13, "edit": [0, 11], "editing": 11, "elev": [10, 11], "elles": 10, "email": [0, 1, 9, 11, 12, 18], "email_to_confirm": 0, "email_url": [15, 18], "empty": 11, "emp\u00each": 16, "encod": 15, "encoded": 11, "encor": [11, 14], "endpoint": 11, "enfin": 11, "english": 11, "enlev": 3, "enregistr": [0, 12, 13, 16], "ensembl": 15, "ensuit": 16, "enter": 11, "enti": 5, "entity": [0, 10], "entra\u00een": [8, 10, 13], "entre": [3, 5, 13, 16], "entrer": 13, "entry": 11, "entr\u00e9": [11, 15, 16], "enumerat": 15, "env": [11, 15], "environ": [11, 12, 18], "environment": [11, 15, 18], "envoi": [0, 9, 11, 13, 15, 16], "equal": 1, "equipment": [0, 2, 3, 10, 11, 16], "equipment_id": [0, 10], "equipment_short_id": 3, "equipment_typ": [2, 3], "equipment_type_id": [2, 3], "equipment_type_label": 3, "erral": 11, "erreur": [0, 5, 11, 13, 18], "error": [0, 1, 2, 3, 7, 9, 10, 11, 15], "errored": 0, "espac": 16, "espagnol": [11, 13], "estim": 11, "etat": 13, "europ": [0, 9], "ewm": 11, "exampl": [0, 1, 5, 9, 11, 15, 16], "exc": 18, "exceed": [0, 3, 10, 11], "exceeding": 11, "except": [13, 18], "exclu": 13, "exclur": 13, "execstart": 15, "exempl": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 13, 14, 15, 16], "exhaust": 14, "exist": [0, 3, 8, 9, 10, 11, 13, 14, 15], "exit": [12, 15], "expir": 12, "expired": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "expires_at": 5, "expires_in": 5, "exploit": 15, "export": [0, 11, 12, 13, 14, 15], "exp\u00e9diteur": 15, "extens": [0, 10, 15], "extr\u00eam": 13, "ex\u00e9cu": [11, 12], "ex\u00e9cut": 11, "f2aec30": 11, "f96dcef": 11, "fa33f4d996844a5c73ecd1ae24456ab8": 10, "facilit": 11, "facult": [3, 16], "fail": 11, "fair": 15, "fait": [11, 15], "fals": [0, 1, 2, 5, 7, 9, 10, 15], "falsif": [5, 16], "famill": 0, "farthest": 11, "fa\u00e7on": 15, "fb10602": 11, "fd": [0, 6, 9, 10], "featur": 11, "fichi": [0, 1, 10, 11, 12, 13, 14, 15, 17, 19], "field": 11, "fil": [0, 1, 10, 11, 15], "file_nam": 0, "file_siz": 0, "filt": 11, "filter": 11, "filtering": 11, "filtr": [0, 11, 13], "fin": [8, 10, 11, 15], "finish": 11, "first": [3, 11], "first_nam": [0, 9], "fit": [15, 17], "fitotrack": 14, "fittracke": [3, 5, 12, 13, 15, 16, 17, 19], "fittrackee_client": 15, "fittrackee_host": 16, "fittrackee_install": 17, "fittrackee_worker": 15, "fittrackee_ynh": 15, "fix": [11, 15], "flake8": 11, "flask": [11, 15], "flask_app": 15, "flaticon": 15, "flech": 13, "float": [0, 10], "flow": 16, "fmstrat": 11, "fonction": [15, 16], "fonctionnal": [14, 15], "fond": 15, "foot": 11, "for": [0, 1, 3, 10, 11, 15], "forbidden": [0, 1, 2, 3, 7, 8, 9, 10], "forc": 3, "forgery": [5, 16], "fork": 15, "form": [0, 5, 10, 11], "format": [0, 8, 10, 11, 13, 14], "fort": 15, "forwarded": [15, 16], "found": [0, 2, 3, 5, 7, 8, 9, 10, 11], "fourn": [0, 3, 5, 8, 10, 12, 13, 15, 16, 18], "fournisseur": [11, 15], "fr": [0, 9, 15], "franc": [13, 15], "freepik": 15, "french": 11, "fri": 10, "from": [3, 8, 10, 11, 15], "ft": 11, "ftcli": 15, "full": 11, "fullchain": 15, "fullscreen": 11, "furo": 11, "fuseau": [0, 10, 13], "galician": 11, "galicien": [11, 13], "gallegonovato": 11, "gard": 15, "garmin": 17, "generat": 11, "ger": [11, 12, 13, 15, 18], "german": [11, 15], "gestion": [11, 15], "get": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11], "getting": 1, "gif": 0, "git": 15, "github": 15, "gl": 0, "gmt": [0, 3, 5, 6, 9, 10], "gnu": 11, "gorgoback": 11, "gp": [11, 13], "gpl": 14, "gpx": [0, 5, 10, 11, 13, 14, 15, 17], "gpx_limit_import": 1, "gpxpy": [0, 11, 13, 15], "grammar": 11, "grand": [11, 13], "grant_typ": 5, "graph": 11, "graphiqu": [0, 11, 13, 15], "gray": 11, "great": [1, 11], "gr\u00e2c": [15, 17], "guid": 15, "guillemet": 10, "gunicorn": [15, 18], "gz": 15, "gzip": 0, "g\u00e9ner": [5, 12, 13, 15, 16], "h": [10, 13], "ha": [0, 6, 9, 10], "handl": 11, "has": [3, 9, 11], "has_equipment": 2, "has_next": 5, "has_prev": 5, "has_workout": 7, "hav": [1, 2, 3, 7, 8, 9, 10, 11], "help": [12, 15], "heur": [11, 13, 15], "hexadecimal": 0, "hexad\u00e9cimal": 0, "hidden": 11, "hiding": 11, "hiking": 7, "his": 11, "histor": [14, 15], "hom": 15, "horair": [0, 10, 13], "host": 15, "hosted": 11, "hosting": 15, "hour": 15, "how": 15, "href": [1, 15], "html": 11, "http": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 15, 16], "http2": 15, "hvybqybra7wwxpastwr4v2": [0, 6, 9, 10], "h\u00f4t": 15, "i18n": 11, "icon": 11, "ic\u00f4n": 15, "id": [0, 2, 3, 5, 6, 7, 9, 10, 11], "identif": 15, "identifi": [0, 5, 7, 8, 10, 16], "if": [11, 15], "imag": [0, 9, 10, 11, 13, 15, 19], "imperial": 11, "imperial_unit": [0, 9], "implementing": 11, "import": [3, 5, 11, 13, 15], "importing": 11, "improv": 11, "improved": 11, "imp\u00e9rial": [0, 13], "in": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 13, 15, 16], "in_progress": 0, "inact": [0, 3, 9, 10, 13, 15], "inclus": 11, "incomplet": 12, "inconsistent": 11, "incorrect": [3, 11, 13], "index": 10, "indiqu": 13, "infobull": 13, "inform": [0, 1, 9, 11, 13, 14, 15], "inf\u00e9rieur": 11, "init": 11, "initial": [11, 15], "initialis": [11, 15], "inscript": [0, 1, 11, 13, 15], "inscrir": [1, 11, 13, 15], "insensibl": 15, "instabl": [14, 15], "install": [11, 14], "instanc": [1, 11, 13, 15], "instant": 13, "instead": 11, "instruct": [0, 11, 13, 15], "int": [0, 2, 3, 5, 7, 10], "integ": [1, 2, 3, 5, 7, 8, 9, 10], "integer": 3, "integr": 15, "interag": 16, "interceptor": 11, "interfac": [0, 11, 13, 14, 15], "internal": [0, 1, 2, 3, 7, 9, 10], "interrompu": 15, "introduit": 11, "invalid": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 15], "invalidat": 11, "invalidemailurlschem": 18, "ip": 15, "is": [0, 3, 10, 11], "is_act": [0, 2, 3, 7, 9], "is_active_for_us": 7, "is_admin": 9, "is_email_sending_enabled": 1, "is_registration_enabled": 1, "iso": 12, "isort": 11, "issu": [11, 13], "issued_at": 5, "it": [0, 3], "italian": 11, "italien": [11, 13], "jan": 10, "jat255": 11, "javascript": [11, 15], "jderuit": 11, "jeton": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 12, 15, 16], "jinj": 11, "jmlich": 11, "john_do": 9, "jour": [0, 1, 3, 7, 9, 10, 11, 12, 13], "journal": [15, 18], "jpeg": 9, "jpg": 0, "js": [10, 15], "json": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 13], "jul": [0, 6, 9, 10], "july": 5, "jusqu": 13, "jwt": 15, "kayak": 13, "kayak_boat": 2, "keep": 11, "key": 15, "keyboard": 11, "kjxavsturjvoah2wvcegef": 10, "km": [10, 13], "koen": 11, "komoot": 15, "label": [2, 3, 7, 11, 13], "laiss": 15, "lanc": [11, 15], "lang": [0, 11, 12], "langu": [0, 11, 12, 13], "languag": [0, 9, 11, 12], "laquel": 13, "larg": [0, 10, 15], "last_nam": [0, 9], "latitud": 10, "lavoi": 11, "layout": 11, "ld": [0, 6, 9, 10], "leaflet": [10, 15], "lectur": 16, "legal": 15, "legitimat": 11, "lequel": [0, 15], "less": 11, "lettr": 12, "libr": 13, "librair": [5, 15], "library": 11, "licenc": 15, "licens": [11, 14, 15], "lien": 15, "lieu": 13, "lign": [11, 13, 14, 15], "lim": [11, 15], "limit": [3, 11, 13], "lin": [12, 15], "link": 11, "lint": 15, "linux": 15, "lir": 11, "list": [5, 10, 11, 12, 14, 15], "listen": 15, "ll": 15, "load": [11, 18], "loading": 11, "local": [0, 11, 14, 15], "localhost": [11, 15], "localis": [0, 15], "localiz": 11, "locat": [0, 9, 15], "log": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 15, 18], "logfil": 15, "logged": 0, "login": [0, 11], "logo": 15, "logout": [0, 11], "long": [11, 13], "longitud": 10, "longu": [3, 6, 11, 13], "longueur": 10, "lor": [0, 12, 13], "lorsqu": [0, 11, 12, 15], "lukasitaly": 11, "lund": [0, 8, 13], "mailhog": 15, "mainten": [11, 15], "majeur": 11, "majuscul": 10, "mak": [11, 15], "makefil": 15, "malformed": 0, "manag": [11, 12], "mani": [13, 15], "manqu": 11, "map": [10, 11, 13, 14], "map_attribu": [1, 15], "map_id": 10, "map_til": 10, "mar": [0, 3, 11, 15], "mara21": 11, "march": [11, 13], "mariusz": 11, "mariuz": 11, "markdown": [11, 13], "marker": 11, "masqu": 13, "match": 0, "matching": 11, "mati": 13, "max": [1, 9, 10, 11, 12], "max_alt": 10, "max_single_file_siz": 1, "max_speed": 10, "max_speed_from": 10, "max_speed_to": 10, "max_user": 1, "max_zip_file_siz": 1, "maximal": [1, 6, 10, 11, 12, 13], "maximum": [1, 13], "may": 11, "md": 11, "measur": 11, "meilleur": 16, "mensuel": [11, 13], "mention": 15, "menu": [11, 13], "merc": 11, "messag": [0, 1, 5, 9, 10, 11, 12, 13, 15, 16], "mettr": [1, 9, 10, 11, 12, 13, 15], "mi": 11, "microsecond": 11, "migrat": [11, 12, 15], "min": 11, "min_alt": 10, "minimal": [10, 11, 13, 16], "minimum": 0, "minor": 11, "minuscul": 10, "minut": 15, "mis": [0, 3, 7, 9, 10, 11, 13, 16], "missing": [0, 11], "mm": 0, "mmm": 0, "mo": 13, "mobil": [11, 14], "mod": 11, "modal": 11, "model": 11, "modif": [0, 13, 14], "modifi": [0, 2, 3, 7, 9, 12, 13, 15], "modification_dat": 10, "modify": 11, "modul": [11, 15], "moin": [3, 10], "mois": [8, 11, 13], "moment": [0, 10, 11, 13], "mondstern": 11, "mono": [11, 13], "month": [8, 11], "mor": 11, "morning": 10, "mot": [0, 9, 12, 13, 15], "mountain": 7, "mountaineering": 11, "mous": 11, "mov": 11, "moving": [10, 11], "moyen": [6, 8, 10, 11, 13, 15], "mult": 15, "multipart": [0, 5, 10], "multipl": 11, "must": [0, 1, 3, 9, 10, 11], "mv": 15, "my": 3, "mynixos": 15, "m\u00e9thod": [5, 15, 16], "m\u00e9triqu": 13, "m\u00e9t\u00e9o": [11, 13], "m\u00eam": [11, 13, 15], "nag": 13, "naissanc": 0, "nam": [5, 11, 13], "nano": 15, "navig": [0, 11, 13, 15], "nb": 0, "nb_sport": [0, 9], "nb_workout": [0, 9], "nbsp": 15, "nederland": 11, "need": 15, "net": 15, "netinstall": 17, "nettoi": 11, "network": 15, "new": [0, 3, 9, 11], "new_email": 9, "new_password": 0, "next_workout": 10, "nginx": [11, 13, 15, 16, 18], "niveau": 11, "nixpkg": 15, "nl": 0, "no": [0, 3, 5, 9, 10, 11], "no_gpx": 10, "nod": 15, "nofollow": 15, "noir": 12, "nom": [0, 5, 8, 9, 11, 12, 13, 15], "nombr": [1, 9, 10, 12, 13, 15], "nomenclatur": 11, "non": [0, 2, 3, 5, 7, 8, 10, 11, 14, 15], "noopen": 15, "noreferr": 15, "norv\u00e9gien": [11, 13], "norwegian": 11, "nosuchmoduleerror": 18, "not": [0, 1, 2, 3, 5, 7, 8, 9, 10, 11, 13, 15], "notat": 15, "notif": 15, "nouveau": [0, 11, 12, 13, 16], "nouveaut": 13, "nouvel": [0, 3, 9, 12, 13, 15], "null": [0, 1, 3, 7, 9, 10, 11], "numb": [1, 11, 15], "nuv9cy8vqonrqkhtz5pqaq2zw7msh0mornpjr14amswd6f6": 5, "n\u00e9cessair": [13, 15], "n\u00e9cessit": [11, 15, 16], "n\u00e9cessitent": [15, 16], "n\u00e9erland": [11, 13], "n\u00e9gat": [10, 13], "o22a27s2abpuoxjbxv3ujdox": 5, "oauth": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 14, 15], "oauth2": [4, 16], "oauthlib": 16, "objet": [0, 1, 2, 3, 7, 9, 10], "obligatoir": [3, 5, 8, 10, 11, 12, 15, 16], "obten": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 16], "octet": 0, "of": [0, 1, 3, 10, 11, 15, 16], "office365": 15, "offset": 11, "ok": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "omis": 15, "ondrejzivny": 11, "one": [0, 10], "onglet": 16, "ongoing": 0, "only": [0, 10, 11], "open": [11, 13, 14], "openstreetmap": [1, 11, 15], "opentrack": 14, "option": [11, 12, 15], "optionnel": [5, 15], "or": [0, 1, 2, 3, 7, 9, 10, 11], "order": [9, 10, 11], "order_by": [9, 10], "ordre": [9, 10], "org": [1, 15], "origin": 13, "osm": 15, "osmfr": 15, "other": [0, 3, 9], "out": 0, "outdoor": [11, 15], "outil": [13, 14, 15], "ouvr": 15, "over": 11, "overlap": 11, "owner": 15, "o\u00f9": [3, 13, 15], "packag": [11, 15], "packaged": 11, "paf38": 11, "pag": [5, 9, 10, 11], "pagin": [5, 9, 10], "paquet": [11, 15], "par_pag": 9, "paragliding": 11, "paralleliz": 11, "paramet": [3, 15], "parameter": [3, 11], "parametr": [0, 2, 3, 5, 7, 8, 9, 10, 11, 13, 16], "parapent": 13, "pareck": 16, "paris": [0, 9], "parm": 11, "pars": 11, "part": [0, 10, 11, 13, 14, 16, 17], "particuli": 13, "partiel": 11, "pass": [0, 9, 12, 13, 15], "password": [0, 11, 12, 15], "passwordwith": 15, "patch": [0, 1, 2, 3, 7, 9, 10], "paus": [10, 11], "payload": [0, 1, 2, 5, 7, 9, 10], "pem": 15, "per": [11, 15], "per_pag": [9, 10], "perform": 13, "period": 8, "perm": [1, 2, 3, 7, 8, 9, 10], "permet": [3, 11, 12, 13, 14, 16], "permettent": 13, "permettr": 16, "personnalis": [11, 13], "petit": 11, "peut": [0, 3, 8, 9, 10, 11, 12, 13, 15, 16], "peuvent": [0, 11, 13, 14, 15], "pg_dump": 15, "pictur": [0, 9, 10, 11], "piec": 3, "pied": [11, 13], "pil": 11, "ping": 1, "pip": 15, "pipenv": 11, "pkce": [5, 16], "pleas": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "plein": [13, 14], "plugin": 18, "pluj": 11, "plupart": 16, "plus": [3, 6, 11, 12, 13, 14, 15, 18], "plusieur": [11, 13, 14, 15], "plut\u00f4t": 13, "png": [0, 10, 15], "poetry": [11, 15], "point": [0, 1, 5, 11, 13, 15, 16], "policy": [0, 11], "polish": 11, "polit": [0, 1, 11, 13], "polon": [11, 13], "pong": 1, "pooling": 15, "port": 15, "portug": [11, 13], "portugues": 11, "posit": [10, 11, 13], "possibl": [11, 13, 14, 15], "post": [0, 3, 5, 10, 16], "postgr": [11, 18], "postgresql": [11, 15, 18], "postgresql10": 11, "pourr": 15, "pouv": 11, "pr": 11, "preferent": [0, 11], "premi": 13, "prendr": [11, 13], "prepar": 11, "present": 11, "prevent": 15, "previous_workout": 10, "pris": [0, 5, 11, 12, 13, 15, 16], "priv": [11, 13], "privacy": [0, 11], "privacy_policy": 1, "privacy_policy_dat": 1, "privileg": 15, "privkey": 15, "problem": [11, 13, 15], "proced": 15, "process": [11, 15], "processed": 0, "processus": 15, "prochain": [11, 13], "product": 11, "productionconfig": 15, "produir": 13, "profil": [0, 5, 11, 16], "project": 15, "projet": 15, "propos": [11, 13], "propr": [3, 9, 13, 14], "propri\u00e9tair": [3, 11, 13], "proto": [15, 16], "proven": 13, "provid": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11], "provided": [0, 1, 3, 9], "provider": 11, "proxy": [15, 16], "proxy_add_x_forwarded_for": 15, "proxy_pass": 15, "proxy_redirect": 15, "proxy_set_head": [15, 16], "pr\u00e9c\u00e9dent": 11, "pr\u00e9f\u00e9rent": [0, 3, 9, 12], "pr\u00e9nom": 0, "pr\u00e9requ": 11, "pr\u00e9sent": 13, "pr\u00eat": 15, "publi": [13, 15], "puiss": 16, "puissent": [11, 13], "pull": 15, "pwd": 15, "py": 15, "python": [11, 15, 16], "p\u00e9riod": [8, 13, 15], "q": 9, "qrj7by6h2iyjsv8sersfgv": 3, "quand": 11, "quant": 11, "quelqu": [3, 9, 10, 11, 13, 16], "queu": 15, "qwerty287": 11, "r": 15, "rafra\u00eech": 5, "randon": [11, 13], "rapport": 11, "raquet": 13, "rat": [11, 15], "read": [0, 2, 3, 5, 6, 7, 8, 9, 10, 16], "readm": 11, "real": 15, "reason": 0, "rebuild": 11, "recalcul": [3, 13], "recherch": 11, "recommand": [5, 15, 16], "record": [0, 4, 9, 10, 11, 13], "record_typ": [0, 6, 9, 10], "red": [11, 12, 15], "redirect": [5, 11, 16], "redirect_ur": 5, "redirig": [5, 16], "redis_url": 15, "red\u00e9marr": 15, "refacto": 11, "refactoris": 11, "refresh": 3, "refresh_token": 5, "refreshed": 11, "regist": 0, "registr": [0, 11], "rel": 15, "relat": 13, "relev": 11, "remaining": 11, "remarqu": 15, "remote_addr": 15, "remov": 11, "remplac": [10, 11, 13, 15], "rencontr": 15, "renomm": 11, "renvoi": [0, 5, 9, 10], "replac": 11, "requ": [0, 16], "request": [0, 1, 2, 3, 5, 7, 8, 9, 10, 15, 16], "request_ur": 15, "requested": 0, "required": 0, "requiring": 11, "requis": 3, "requ\u00eat": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 16], "resend": 0, "resent": 0, "reset": [0, 11, 12], "reset_password": 9, "resetting": 11, "resolu": 11, "respons": 11, "response_typ": [5, 16], "rest": [11, 13, 16], "restart": 15, "restartsec": 15, "result": 11, "retourn": [6, 8], "return": [11, 15], "revok": 5, "revoked": 0, "reworked": 11, "rid": 11, "right": [9, 11], "roehv64thcg28wcewzhrnvlusoduvw8nvnhkcml57": 5, "rout": [11, 15], "ruff": 11, "run": 15, "runn": 14, "running": 7, "russ": 13, "russian": 11, "r\u00e9cent": 13, "r\u00e9cuper": [10, 13, 15, 16], "r\u00e9duit": 11, "r\u00e9initialis": [0, 9, 12, 13, 15], "r\u00e9pertoir": [13, 15], "r\u00e9pons": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "r\u00e9tro": 15, "r\u00e9uss": 0, "r\u00e9voqu": 5, "s256": [5, 16], "sam": [0, 3, 6, 9, 10, 11], "samr1": 15, "san": [5, 8, 9, 10, 11, 13, 14, 15], "sat": 9, "sauf": [9, 15], "sauvegard": 15, "sav": [3, 11], "schem": [15, 16], "scop": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "screen": 11, "script": 11, "search": 11, "second": [3, 10], "secret": [5, 15, 16], "section": 11, "security": 11, "see": [11, 15], "seem": 11, "segment": [10, 11, 13], "segment_id": 10, "selected": [0, 10], "selon": [0, 13, 15], "semain": [0, 8, 13], "send": 11, "sender_email": 15, "sending": 11, "ser": [0, 3, 10, 11, 12, 13, 15], "serv": [0, 1, 2, 3, 7, 9, 10, 11, 15], "server_nam": 15, "serveur": [10, 11, 13, 14, 18], "servic": [11, 15], "session": 16, "set": [11, 12, 15], "setting": 15, "seuil": [0, 13], "seul": [0, 3, 5, 8, 9, 10, 11, 13, 15, 16], "sh": 17, "shel": 15, "sho": [2, 3], "should": 11, "show": [11, 12, 15], "shown": 11, "shura0": 11, "si": [0, 3, 5, 7, 8, 9, 10, 11, 12, 13, 15, 16, 18], "sien": 9, "signatur": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "sikm": 11, "simontb": 11, "simpl": 15, "simplified": 16, "simplify": 11, "sinon": [13, 15], "sit": [5, 16], "siz": [0, 1, 10, 11, 15], "ski": 13, "skis": [2, 13], "skylan0916": 11, "slothj": 11, "slow": 11, "small": 11, "smtp": [11, 15, 18], "snowsho": [2, 11], "som": 11, "sombr": [0, 13], "sorry": 0, "sort": 16, "sorting": 10, "soum": 15, "sourc": 13, "sous": [10, 15], "spanish": 11, "special": 11, "specific": 15, "specify": 11, "speed": [10, 11], "spinn": 11, "sport": [0, 1, 3, 4, 8, 10, 11, 15], "sport_id": [0, 3, 6, 7, 8, 9, 10], "sport_label": 3, "sportiv": [13, 14], "sports_list": [0, 9], "sp\u00e9cial": 15, "sp\u00e9cif": 13, "sql": 15, "sqlalchemy": [11, 15, 18], "ssl": 15, "ssl_certificat": 15, "ssl_certificate_key": 15, "standard": [11, 15], "standarderror": 15, "standardiz": 11, "standardoutput": 15, "start": 11, "start_elevation_at_zero": 0, "startlimitintervalsec": 15, "starttl": 15, "stat": [5, 8, 11, 16], "static": 11, "staticmap": 15, "staticmap_subdomain": [11, 15], "statiqu": [13, 15], "statist": [1, 4], "statistic": [1, 8, 11], "stats_workouts_lim": 1, "status": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 13], "statut": [3, 9, 12], "sticky": 11, "stock": [13, 14, 16], "stop": 15, "stopp": 15, "stopped": 11, "stopped_speed_threshold": [0, 7], "strateg": 15, "strav": 17, "street": [13, 14], "strength": 11, "string": [0, 1, 3, 5, 7, 8, 9, 10], "subdomain": 11, "succes": [0, 2, 3, 5, 7, 8, 10], "success": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "successful": 0, "successfully": 0, "such": 11, "suiv": [0, 6, 13, 15, 16, 17, 18], "suivent": 15, "suivr": 14, "sun": [0, 6, 9, 10], "sunday": 10, "supplied": 3, "suppl\u00e9mentair": 13, "support": [5, 11, 15, 16, 18], "suppress": [3, 11, 13], "supprim": [0, 3, 5, 9, 10, 11, 12, 13], "swimming": 11, "swimrun": [11, 13], "switch": 11, "synchronis": 17, "syntax": 13, "syslog": 15, "syslogidentifi": 15, "system": [13, 15], "systemd": 15, "s\u00e9anc": [0, 1, 3, 4, 7, 8, 11, 14, 16, 17, 19], "s\u00e9cur": 16, "s\u00e9lection": [0, 13], "s\u00e9mant": 15, "s\u00e9par": [15, 16], "tabl": 11, "taill": [0, 1, 13, 15], "taken": 0, "tant": 3, "tar": 15, "target": 15, "task": 15, "tchequ": [11, 13], "temp": 8, "term": 11, "test": [11, 15], "textar": 11, "than": [1, 9, 11], "thank": 11, "that": [0, 3, 11], "the": [0, 2, 3, 7, 9, 10, 11, 15], "them": [0, 11, 13], "this": [10, 12, 15], "thovi98": 11, "threshold": 11, "thu": 5, "thunderforest": [11, 15], "ticket": 14, "tient": 10, "tier": [13, 14], "tierc": [13, 16], "til": [11, 15], "tile_server_url": 15, "tim": [8, 10, 11], "timeout": [15, 18], "timezon": [0, 9, 11], "titl": [10, 11], "titr": [10, 13], "tl": [11, 15], "to": [0, 1, 3, 8, 10, 11, 15, 17], "token": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 16], "token_typ": 5, "too": [0, 10], "tool": 11, "tooling": 11, "tooltip": 11, "total": [0, 3, 5, 8, 10, 11, 13], "total_ascent": [0, 8], "total_dist": [0, 3, 8, 9], "total_dur": [0, 3, 8, 9], "total_moving": 3, "total_workout": 8, "toujour": 13, "tous": [2, 3, 5, 6, 7, 8, 9, 11, 13, 15], "tout": [3, 8, 13, 15], "trac": [13, 15], "track": 11, "tracke": 15, "traduit": 13, "trail": 13, "trailing": 11, "train": 2, "trait": [0, 12], "translat": 11, "translated": 11, "transport": [7, 11, 13], "traxy": 11, "trekking": [11, 13], "tri": [9, 10, 13], "tronqu": 10, "trouv": [2, 3, 5, 7, 10], "tru": [0, 1, 2, 3, 5, 7, 9, 11, 15], "try": [0, 2, 3, 7, 9, 10], "tuil": [10, 11, 13], "typ": [0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 15], "typescript": 15, "typo": 11, "typos": 11, "t\u00e2ch": 15, "t\u00e9l\u00e9charg": [0, 10, 12, 13, 15, 19], "t\u00e9l\u00e9vers": [11, 13], "t\u00eat": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 16], "u": 15, "uberspac": 15, "ubuntu": 15, "ui": 11, "ui_url": 15, "unauthorized": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "uncommenting": 15, "underscor": 0, "uniqu": [0, 5, 11, 12, 15, 16], "unit": [0, 11, 15], "unitair": 13, "up": [11, 14], "updat": [0, 3, 10, 11, 15], "updated": [0, 3, 11], "updating": 1, "upgrad": [11, 15], "upload": [11, 15, 17], "upload_fold": [15, 18], "uploaded": [1, 11, 15], "uploading": 11, "uploads_dir_siz": 8, "uri": 11, "url": [0, 5, 11, 15, 16, 18], "urtzai": 11, "usag": [12, 15], "use": 11, "use_dark_mod": 0, "use_raw_gpx_speed": 0, "used": 11, "user": [0, 1, 3, 6, 8, 9, 10, 11, 15, 16], "user_id": [0, 3], "user_nam": [8, 9], "usernam": [0, 9, 11, 12, 15], "using": 11, "util": [11, 13], "utilis": [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 15, 16], "utility": 15, "uuid": [10, 11], "v0": 15, "v3": 14, "valeur": [0, 3, 5, 10, 12, 13, 15, 16, 18], "valid": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 15, 18], "validat": 11, "valu": [0, 6, 9, 10, 11], "variabl": [11, 18], "various": 11, "vent": 13, "venv": 15, "ver": [5, 11, 15, 16], "verrouill": 13, "version": [1, 12, 13, 15, 16], "vi": [13, 15], "vid": [10, 12, 15], "view": [11, 15], "virgul": 15, "virtual": 11, "virtualenv": [11, 15], "virtuel": [13, 15], "visibil": [11, 13], "visibl": 13, "visual": [13, 15], "visualcrossing": 11, "visualis": 13, "vite_app_api_url": 15, "vitess": [0, 6, 10, 11, 13, 15], "voir": [3, 11, 13, 15, 16, 18], "volumin": [13, 18], "voodoopt": 11, "votr": [14, 15], "vrai": 0, "vtt": [11, 13], "vu": [11, 15], "vue3": 15, "vue_app_api_url": 15, "vuex": 15, "v\u00e9lo": [11, 13], "v\u00e9rif": [5, 11, 16, 18], "v\u00e9rifi": [7, 15, 18], "walking": 7, "want": 15, "wantedby": 15, "was": [0, 3], "wat": 11, "weath": 11, "weather_ap": 15, "weather_api_key": 15, "weather_api_provid": [11, 15], "weather_end": 10, "weather_provid": 1, "weather_start": 10, "web": [0, 5, 14, 15, 16], "weblat": [11, 13], "websit": 5, "wed": 0, "week": [8, 11], "weekend": 11, "weekm": [0, 8, 9], "wget": 15, "when": [1, 11], "whit": 11, "wind": 11, "with": [0, 3, 10, 11, 15], "with_gpx": 10, "without": [3, 10, 11], "work": [11, 15, 18], "worker": [11, 12, 15], "workers_process": 15, "workflow": 11, "workingdirectory": 15, "workout": [1, 3, 5, 6, 7, 8, 10, 11, 16], "workout_dat": [0, 6, 9, 10], "workout_id": [0, 6, 9, 10], "workout_short_id": 10, "workouts_count": [3, 9], "writ": [0, 1, 2, 3, 5, 7, 9, 10, 16], "www": [1, 15], "x": [0, 10, 11, 15, 16], "xmgz": 11, "xml": 10, "xxxx": 15, "xzf": 15, "yarn": 15, "year": 8, "you": [0, 1, 2, 3, 7, 8, 9, 10, 15], "your": 9, "yyyy": 0, "z": [10, 15], "zero": 11, "zip": [0, 1, 10, 11, 13], "zoom": 10, "z\u00e9ro": [0, 13], "\u00c0": 15, "\u00e9chang": 16, "\u00e9chapp": [10, 11], "\u00e9chec": 19, "\u00e9cran": 11, "\u00e9cras": 15, "\u00e9critur": 16, "\u00e9gal": [11, 12, 13, 14, 15], "\u00e9lectr": 13, "\u00e9lectron": [0, 1, 9, 12, 13, 15], "\u00e9lev": [6, 13, 15], "\u00e9mettr": 5, "\u00e9miss": 5, "\u00e9quip": [0, 4, 10, 11, 16], "\u00e9tap": 15, "\u00e9tat": [0, 1, 2, 3, 5, 6, 7, 8, 9, 10], "\u00e9valu": [11, 15], "\u00e9ventuel": 3, "\u00e9vit": [5, 11, 13], "\u00eatre": [0, 3, 5, 10, 11, 12, 13, 14, 15, 16, 18], "\u0153uvr": 16}, "titles": ["Authentification et compte", "Configuration", "Types d\u2019\u00e9quipement", "\u00c9quipements", "Documentation de l\u2019API", "OAuth2", "Records", "Sports", "Statistiques", "Utilisateurs", "S\u00e9ances", "Historique des modifications", "Interface de ligne de commande", "Fonctionnalit\u00e9s", "FitTrackee", "Installation", "OAuth 2.0", "Outils tiers", "Administrateur", "D\u00e9pannage"], "titleterms": {"0": [11, 16], "01": 11, "02": 11, "03": 11, "04": 11, "05": 11, "06": 11, "07": 11, "08": 11, "09": 11, "1": 11, "10": 11, "11": 11, "12": 11, "13": 11, "14": 11, "15": 11, "16": 11, "17": 11, "18": 11, "19": 11, "2": [11, 16], "20": 11, "2018": 11, "2019": 11, "2020": 11, "2021": 11, "2022": 11, "2023": 11, "2024": 11, "21": 11, "22": 11, "23": 11, "24": 11, "25": 11, "26": 11, "27": 11, "28": 11, "29": 11, "3": 11, "30": 11, "31": 11, "32": 11, "4": 11, "5": 11, "6": 11, "7": 11, "8": 11, "9": 11, "Les": 18, "a": 15, "acces": [4, 15], "administr": [11, 13, 18], "affich": 18, "am\u00e9lior": 11, "api": [4, 15], "appliqu": 13, "authentif": 0, "bas": 12, "bord": 13, "bug": 11, "captur": 13, "cart": 18, "charg": 18, "clean": 12, "clean_arch": 12, "clean_token": 12, "command": 12, "compt": [0, 13], "configur": 1, "correct": 11, "corrig": 11, "courriel": 15, "creat": 12, "dan": 18, "db": 12, "disponibl": 11, "diver": 11, "dock": 15, "document": [4, 11], "don": [12, 15], "drop": 12, "d\u00e9marr": 18, "d\u00e9pannag": 19, "d\u00e9pend": 15, "d\u00e9ploi": 15, "d\u00e9tail": [13, 18], "d\u00e9velopp": 15, "environ": 15, "export_arch": 12, "ferm": 11, "fichi": 18, "fittracke": [11, 14, 18], "flux": 16, "fonctionnal": [11, 13], "franc": 11, "ftcli": 12, "histor": 11, "imag": 18, "import": 17, "install": [15, 17], "interfac": 12, "jour": 15, "lign": 12, "limit": 15, "list": 13, "mati": 14, "mineur": 11, "mis": 15, "modif": 11, "m\u00e9t\u00e9o": 15, "nixos": 15, "nouvel": 11, "oauth": [13, 16], "oauth2": [5, 12], "outil": 17, "pag": 13, "part": 15, "point": 4, "premi": 11, "principal": 15, "product": 15, "pr\u00e9f\u00e9rent": 13, "pr\u00e9requ": 15, "pull": 11, "pyp": [11, 15], "record": 6, "request": 11, "ressourc": 16, "scop": 16, "script": 17, "serveur": 15, "sourc": 15, "sport": [7, 13], "statist": [8, 11, 13], "s\u00e9anc": [10, 13, 18], "s\u00e9cur": 11, "tabl": 14, "tableau": 13, "ticket": 11, "tier": 17, "traduct": [11, 13], "tuil": 15, "typ": [2, 13], "t\u00e9l\u00e9charg": 18, "updat": 12, "upgrad": 12, "user": 12, "utilis": [9, 12, 13], "variabl": 15, "version": 11, "yunohost": 15, "\u00e9chec": 18, "\u00e9cran": 13, "\u00e9quip": [2, 3, 13]}}) \ No newline at end of file +Search.setIndex({"alltitles": {"A partir de PyPI": [[20, "from-pypi"], [20, "id3"]], "A partir des sources": [[20, "from-sources"], [20, "id4"]], "A propos": [[18, "about"]], "Administrateur": [[23, null]], "Administration": [[18, "administration"], [18, "id8"]], "Application": [[18, "application"]], "Applications OAuth": [[18, "oauth-apps"]], "Authentification et compte": [[0, null]], "Base de donn\u00e9es": [[17, "database"]], "Bugs corrig\u00e9s": [[16, "bugs-fixed"], [16, "id5"], [16, "id8"], [16, "id11"], [16, "id16"], [16, "id22"], [16, "id27"], [16, "id31"], [16, "id34"], [16, "id36"], [16, "id40"], [16, "id44"], [16, "id48"], [16, "id51"], [16, "id54"], [16, "id57"], [16, "id58"], [16, "id61"], [16, "id63"], [16, "id65"], [16, "id68"], [16, "id71"], [16, "id79"], [16, "id82"], [16, "id85"], [16, "id88"], [16, "id100"], [16, "id105"], [16, "id107"], [16, "id111"], [16, "id114"], [16, "id117"], [16, "id119"], [16, "id122"], [16, "id125"], [16, "id127"], [16, "id130"], [16, "id133"], [16, "id136"], [16, "id141"], [16, "id143"], [16, "id145"], [16, "id147"], [16, "id150"], [16, "id152"], [16, "id158"], [16, "id161"], [16, "id163"], [16, "id165"], [16, "id172"], [16, "id177"], [16, "id179"], [16, "id181"], [16, "id184"], [16, "id186"], [16, "id188"], [16, "id192"], [16, "id202"], [16, "id205"], [16, "id207"], [16, "id210"], [16, "id217"]], "Captures d\u2019\u00e9cran": [[18, "screenshots"]], "Commentaires": [[1, null], [18, "comments"]], "Compte et pr\u00e9f\u00e9rences": [[18, "account-preferences"]], "Configuration": [[2, null], [18, "configuration"]], "Courriels": [[20, "emails"]], "Demandes de suivi": [[5, null]], "Divers": [[16, "misc"], [16, "id1"], [16, "id3"], [16, "id7"], [16, "id14"], [16, "id18"], [16, "id21"], [16, "id25"], [16, "id29"], [16, "id33"], [16, "id38"], [16, "id42"], [16, "id46"], [16, "id53"], [16, "id56"], [16, "id60"], [16, "id62"], [16, "id66"], [16, "id73"], [16, "id76"], [16, "id84"], [16, "id91"], [16, "id102"], [16, "id104"], [16, "id120"], [16, "id134"], [16, "id137"], [16, "id154"], [16, "id156"], [16, "id173"], [16, "id182"], [16, "id189"], [16, "id193"], [16, "id200"], [16, "id211"], [16, "id214"]], "Docker": [[20, "docker"]], "Documentation": [[16, "documentation"], [16, "id75"], [16, "id109"]], "Documentation de l\u2019API": [[6, null]], "Donn\u00e9es m\u00e9t\u00e9o": [[20, "weather-data"]], "D\u00e9pannage": [[24, null]], "D\u00e9pendances principales": [[20, "main-dependencies"]], "D\u00e9ploiement": [[20, "deployment"]], "D\u00e9veloppement": [[20, "development"]], "Environnement de production": [[20, "prod-environment"]], "Environnements de d\u00e9veloppement": [[20, "dev-environment"], [20, "id5"]], "Environnements de production": [[20, "production-environment"]], "FitTrackee": [[19, null]], "FitTrackee ne d\u00e9marre pas": [[23, "fittrackee-fails-to-start"]], "Flux": [[21, "flow"]], "Flux de s\u00e9ances": [[13, null]], "Fonctionnalit\u00e9s": [[16, "features"], [16, "id129"], [16, "id139"], [16, "id149"], [18, null]], "Fonctionnalit\u00e9s et am\u00e9liorations": [[16, "features-and-enhancements"], [16, "id4"], [16, "id10"], [16, "id15"], [16, "id19"], [16, "id26"], [16, "id30"], [16, "id39"], [16, "id43"], [16, "id47"], [16, "id50"], [16, "id67"], [16, "id70"], [16, "id78"], [16, "id81"], [16, "id87"], [16, "id92"], [16, "id94"], [16, "id96"], [16, "id99"], [16, "id110"], [16, "id116"]], "Historique des modifications": [[16, null]], "Installation": [[20, null], [20, "id2"], [20, "id6"]], "Interactions": [[18, "interactions"]], "Interface de ligne de commande": [[17, null]], "Les images de la carte ne sont pas affich\u00e9es mais la carte est affich\u00e9e dans le d\u00e9tail de la s\u00e9ance": [[23, "map-images-are-not-displayed-but-map-is-shown-in-workout-detail"]], "Likes": [[18, "likes"]], "Limitation d\u2019acc\u00e8s \u00e0 l\u2019API": [[20, "api-rate-limits"]], "Liste des s\u00e9ances": [[18, "workouts-list"]], "Mise \u00e0 jour": [[20, "upgrade"]], "Mod\u00e9ration": [[18, "moderation"]], "NixOS": [[20, "nixos"]], "Notifications": [[7, null], [18, "notifications"], [18, "id7"]], "Nouvelles fonctionnalit\u00e9s": [[16, "new-features"], [16, "id167"], [16, "id169"], [16, "id171"], [16, "id176"], [16, "id191"], [16, "id195"], [16, "id197"], [16, "id199"], [16, "id204"], [16, "id209"], [16, "id213"], [16, "id216"], [16, "id219"]], "OAuth 2.0": [[21, null]], "OAuth2": [[8, null], [17, "oauth2"]], "Outils d\u2019importation": [[22, "import-tools"]], "Outils tiers": [[22, null]], "Page de d\u00e9tail d\u2019une s\u00e9ance": [[18, "workout-detail"]], "Points d'acc\u00e8s :": [[6, null]], "Politique de confidentialit\u00e9": [[18, "privacy-policy"]], "Pr\u00e9requis": [[20, "prerequisites"]], "Pull Requests": [[16, "pull-requests"], [16, "id123"], [16, "id126"], [16, "id142"], [16, "id151"], [16, "id155"], [16, "id159"], [16, "id174"]], "Records": [[9, null]], "Ressources": [[21, "resources"]], "R\u00e9pertoire des utilisateurs": [[18, "users-directory"]], "Scopes": [[21, "scopes"]], "Scripts d\u2019installation": [[22, "installation-scripts"]], "Serveur de tuiles": [[20, "map-tile-server"]], "Signalements": [[10, null]], "Sports": [[11, null], [18, "sports"], [18, "id3"]], "Statistiques": [[12, null], [18, "statistics"], [18, "id5"]], "S\u00e9ances": [[15, null], [18, "workouts"], [18, "id1"]], "S\u00e9curit\u00e9": [[16, "security"]], "Table des mati\u00e8res": [[19, "table-of-contents"]], "Tableau de bord": [[18, "dashboard"], [18, "id4"]], "Tickets Ferm\u00e9s": [[16, "issues-closed"], [16, "id121"], [16, "id124"], [16, "id128"], [16, "id132"], [16, "id135"], [16, "id138"], [16, "id140"], [16, "id144"], [16, "id146"], [16, "id148"], [16, "id153"], [16, "id157"], [16, "id160"], [16, "id162"], [16, "id164"], [16, "id166"], [16, "id168"], [16, "id170"], [16, "id175"], [16, "id178"], [16, "id180"], [16, "id183"], [16, "id185"], [16, "id187"], [16, "id190"], [16, "id194"], [16, "id196"], [16, "id198"], [16, "id201"], [16, "id203"], [16, "id206"], [16, "id208"], [16, "id212"], [16, "id215"], [16, "id218"]], "Traductions": [[16, "translations"], [16, "id2"], [16, "id6"], [16, "id9"], [16, "id12"], [16, "id13"], [16, "id17"], [16, "id20"], [16, "id23"], [16, "id24"], [16, "id28"], [16, "id32"], [16, "id35"], [16, "id37"], [16, "id41"], [16, "id45"], [16, "id49"], [16, "id52"], [16, "id55"], [16, "id59"], [16, "id64"], [16, "id69"], [16, "id72"], [16, "id74"], [16, "id77"], [16, "id80"], [16, "id83"], [16, "id86"], [16, "id89"], [16, "id90"], [16, "id93"], [16, "id95"], [16, "id97"], [16, "id98"], [16, "id101"], [16, "id103"], [16, "id106"], [16, "id108"], [16, "id112"], [16, "id113"], [16, "id115"], [16, "id118"], [16, "id131"], [18, "translations"]], "Types d\u2019\u00e9quipement": [[3, null], [18, "equipment-types"]], "Utilisateurs": [[14, null], [17, "users"], [18, "users"], [18, "id2"]], "Variables d\u2019environnement": [[20, "environment-variables"]], "Version 0.1.0 - Premi\u00e8re version \ud83c\udf89 (04/07/2018)": [[16, "version-0-1-0-first-release-2018-07-04"]], "Version 0.1.1 - Corrections et am\u00e9liorations (07/02/2019)": [[16, "version-0-1-1-fix-and-improvements-2019-02-07"]], "Version 0.2.0 - Statistiques (07/07/2019)": [[16, "version-0-2-0-statistics-2019-07-07"]], "Version 0.2.1 - Correction et am\u00e9liorations (01/09/2019)": [[16, "version-0-2-1-fix-and-improvements-2019-09-01"]], "Version 0.2.2 - Corrections des statistiques (23/09/2019)": [[16, "version-0-2-2-statistics-fix-2019-09-23"]], "Version 0.2.3 - FitTrackee disponible en Fran\u00e7ais (29/12/2019)": [[16, "version-0-2-3-fittrackee-available-in-french-2019-12-29"]], "Version 0.2.4 - Corrections mineures (30/01/2020)": [[16, "version-0-2-4-minor-fix-2020-01-30"]], "Version 0.2.5 - Corrections et am\u00e9liorations (31/01/2020)": [[16, "version-0-2-5-fix-and-improvements-2020-01-31"]], "Version 0.3.0 - Administration (15/07/2020)": [[16, "version-0-3-0-administration-2020-07-15"]], "Version 0.4.0 - FitTrackee sur PyPI (19/09/2020)": [[16, "version-0-4-0-fittrackee-on-pypi-2020-09-19"]], "Version 0.4.1 (31/12/2020)": [[16, "version-0-4-1-2020-12-31"]], "Version 0.4.2 (03/01/2021)": [[16, "version-0-4-2-2021-01-03"]], "Version 0.4.3 (10/01/2021)": [[16, "version-0-4-3-2021-01-10"]], "Version 0.4.4 (31/01/2021)": [[16, "version-0-4-4-2021-01-31"]], "Version 0.4.5 (17/02/2021)": [[16, "version-0-4-5-2021-02-17"]], "Version 0.4.6 (21/02/2021)": [[16, "version-0-4-6-2021-02-21"]], "Version 0.4.7 (07/04/2021)": [[16, "version-0-4-7-2021-04-07"]], "Version 0.4.8 (03/07/2021)": [[16, "version-0-4-8-2021-07-03"]], "Version 0.4.9 (16/07/2021)": [[16, "version-0-4-9-2021-07-16"]], "Version 0.5.0 (14/11/2021)": [[16, "version-0-5-0-2021-11-14"]], "Version 0.5.1 (30/11/2021)": [[16, "version-0-5-1-2021-11-30"]], "Version 0.5.2 (19/12/2021)": [[16, "version-0-5-2-2021-12-19"]], "Version 0.5.3 (01/01/2022)": [[16, "version-0-5-3-2022-01-01"]], "Version 0.5.4 (01/01/2022)": [[16, "version-0-5-4-2022-01-01"]], "Version 0.5.5 (19/01/2022)": [[16, "version-0-5-5-2022-01-19"]], "Version 0.5.6 (05/02/2022)": [[16, "version-0-5-6-2022-02-05"]], "Version 0.5.7 (13/02/2022)": [[16, "version-0-5-7-2022-02-13"]], "Version 0.6.0 (27/03/2022)": [[16, "version-0-6-0-2022-03-27"]], "Version 0.6.1 (27/03/2022)": [[16, "version-0-6-1-2022-03-27"]], "Version 0.6.10 (13/07/2022)": [[16, "version-0-6-10-2022-07-13"]], "Version 0.6.11 (27/02/2022)": [[16, "version-0-6-11-2022-07-27"]], "Version 0.6.12 (14/09/2022)": [[16, "version-0-6-12-2022-09-14"]], "Version 0.6.2 (03/04/2022)": [[16, "version-0-6-2-2022-04-03"]], "Version 0.6.3 (09/04/2022)": [[16, "version-0-6-3-2022-04-09"]], "Version 0.6.4 (23/04/2022)": [[16, "version-0-6-4-2022-04-23"]], "Version 0.6.5 (24/04/2022)": [[16, "version-0-6-5-2022-04-24"]], "Version 0.6.6 (29/05/2022)": [[16, "version-0-6-6-2022-05-29"]], "Version 0.6.7 (11/06/2022)": [[16, "version-0-6-7-2022-06-11"]], "Version 0.6.8 (22/06/2022)": [[16, "version-0-6-8-2022-06-22"]], "Version 0.6.9 (03/07/2022)": [[16, "version-0-6-9-2022-07-03"]], "Version 0.7.0 (19/09/2022)": [[16, "version-0-7-0-2022-09-19"]], "Version 0.7.1 (21/09/2022)": [[16, "version-0-7-1-2022-09-21"]], "Version 0.7.10 (21/12/2022)": [[16, "version-0-7-10-2022-12-21"]], "Version 0.7.11 (31/12/2022)": [[16, "version-0-7-11-2022-12-31"]], "Version 0.7.12 (16/02/2023)": [[16, "version-0-7-12-2023-02-16"]], "Version 0.7.13 (05/03/2023)": [[16, "version-0-7-13-2023-03-05"]], "Version 0.7.14 (08/03/2023)": [[16, "version-0-7-14-2023-03-08"]], "Version 0.7.15 (12/04/2023)": [[16, "version-0-7-15-2023-04-12"]], "Version 0.7.16 (29/05/2023)": [[16, "version-0-7-16-2023-05-29"]], "Version 0.7.17 (03/06/2023)": [[16, "version-0-7-17-2023-06-03"]], "Version 0.7.18 (25/06/2023)": [[16, "version-0-7-18-2023-06-25"]], "Version 0.7.19 (15/07/2023)": [[16, "version-0-7-19-2023-07-15"]], "Version 0.7.2 (21/09/2022)": [[16, "version-0-7-2-2022-09-21"]], "Version 0.7.20 (22/07/2023)": [[16, "version-0-7-20-2023-07-22"]], "Version 0.7.21 (30/07/2023)": [[16, "version-0-7-21-2023-07-30"]], "Version 0.7.22 (23/08/2023)": [[16, "version-0-7-22-2023-08-23"]], "Version 0.7.23 (14/09/2023)": [[16, "version-0-7-23-2023-09-14"]], "Version 0.7.24 (04/10/2023)": [[16, "version-0-7-24-2023-10-04"]], "Version 0.7.25 (08/10/2023)": [[16, "version-0-7-25-2023-10-08"]], "Version 0.7.26 (19/11/2023)": [[16, "version-0-7-26-2023-11-19"]], "Version 0.7.27 (20/12/2023)": [[16, "version-0-7-27-2023-12-20"]], "Version 0.7.28 (23/12/2023)": [[16, "version-0-7-28-2023-12-23"]], "Version 0.7.29 (04/01/2024)": [[16, "version-0-7-29-2024-01-06"]], "Version 0.7.3 (01/11/2022)": [[16, "version-0-7-3-2022-11-01"]], "Version 0.7.30 (04/02/2024)": [[16, "version-0-7-30-2024-02-04"]], "Version 0.7.31 (10/02/2024)": [[16, "version-0-7-31-2024-02-10"]], "Version 0.7.32 (10/03/2024)": [[16, "version-0-7-32-2024-03-10"]], "Version 0.7.4 (05/11/2022)": [[16, "version-0-7-4-2022-11-05"]], "Version 0.7.5 (09/11/2022)": [[16, "version-0-7-5-2022-11-09"]], "Version 0.7.6 (09/11/2022)": [[16, "version-0-7-6-2022-11-09"]], "Version 0.7.7 (27/11/2022)": [[16, "version-0-7-7-2022-11-27"]], "Version 0.7.8 (30/11/2022)": [[16, "version-0-7-8-2022-11-30"]], "Version 0.7.9 (11/12/2022)": [[16, "version-0-7-9-2022-12-11"]], "Version 0.8.0 (21/04/2024)": [[16, "version-0-8-0-2024-04-21"]], "Version 0.8.1 (01/05/2024)": [[16, "version-0-8-1-2024-05-01"]], "Version 0.8.10 (09/10/2024)": [[16, "version-0-8-10-2024-10-09"]], "Version 0.8.11 (30/10/2024)": [[16, "version-0-8-11-2024-10-30"]], "Version 0.8.12 (17/11/2024)": [[16, "version-0-8-12-2024-11-17"]], "Version 0.8.2 (08/05/2024)": [[16, "version-0-8-2-2024-05-08"]], "Version 0.8.3 (09/05/2024)": [[16, "version-0-8-3-2024-05-09"]], "Version 0.8.4 (22/05/2024)": [[16, "version-0-8-4-2024-05-22"]], "Version 0.8.5 (29/06/2024)": [[16, "version-0-8-5-2024-06-29"]], "Version 0.8.6 (03/08/2024)": [[16, "version-0-8-6-2024-08-03"]], "Version 0.8.7 (25/08/2024)": [[16, "version-0-8-7-2024-08-25"]], "Version 0.8.8 (01/09/2024)": [[16, "version-0-8-8-2024-09-01"]], "Version 0.8.9 (21/09/2024)": [[16, "version-0-8-9-2024-09-21"]], "Yunohost": [[20, "yunohost"]], "ftcli db drop": [[17, "ftcli-db-drop"]], "ftcli db upgrade": [[17, "ftcli-db-upgrade"]], "ftcli oauth2 clean": [[17, "ftcli-oauth2-clean"]], "ftcli users clean_archives": [[17, "ftcli-users-clean-archives"]], "ftcli users clean_tokens": [[17, "ftcli-users-clean-tokens"]], "ftcli users create": [[17, "ftcli-users-create"]], "ftcli users export_archives": [[17, "ftcli-users-export-archives"]], "ftcli users update": [[17, "ftcli-users-update"]], "\u00c9chec du chargement ou du t\u00e9l\u00e9chargement de fichiers": [[23, "failed-to-upload-or-download-files"]], "\u00c9quipements": [[4, null], [18, "equipments"], [18, "id6"]]}, "docnames": ["api/auth", "api/comments", "api/configuration", "api/equipment_types", "api/equipments", "api/follow_requests", "api/index", "api/notifications", "api/oauth2", "api/records", "api/reports", "api/sports", "api/stats", "api/timeline", "api/users", "api/workouts", "changelog", "cli", "features", "index", "installation", "oauth", "third_party_tools", "troubleshooting/administrator", "troubleshooting/index"], "envversion": {"sphinx": 62, "sphinx.domains.c": 3, "sphinx.domains.changeset": 1, "sphinx.domains.citation": 1, "sphinx.domains.cpp": 9, "sphinx.domains.index": 1, "sphinx.domains.javascript": 3, "sphinx.domains.math": 2, "sphinx.domains.python": 4, "sphinx.domains.rst": 2, "sphinx.domains.std": 2}, "filenames": ["api/auth.rst", "api/comments.rst", "api/configuration.rst", "api/equipment_types.rst", "api/equipments.rst", "api/follow_requests.rst", "api/index.rst", "api/notifications.rst", "api/oauth2.rst", "api/records.rst", "api/reports.rst", "api/sports.rst", "api/stats.rst", "api/timeline.rst", "api/users.rst", "api/workouts.rst", "changelog.md", "cli.rst", "features.rst", "index.rst", "installation.rst", "oauth.rst", "third_party_tools.rst", "troubleshooting/administrator.rst", "troubleshooting/index.rst"], "indexentries": {"api_rate_limits": [[20, "envvar-API_RATE_LIMITS", false]], "app_log": [[20, "envvar-APP_LOG", false]], "app_secret_key": [[20, "envvar-APP_SECRET_KEY", false]], "app_settings": [[20, "envvar-APP_SETTINGS", false]], "app_workers": [[20, "envvar-APP_WORKERS", false]], "database_disable_pooling": [[20, "envvar-DATABASE_DISABLE_POOLING", false]], "database_url": [[20, "envvar-DATABASE_URL", false]], "default_staticmap": [[20, "envvar-DEFAULT_STATICMAP", false]], "email_url": [[20, "envvar-EMAIL_URL", false]], "flask_app": [[20, "envvar-FLASK_APP", false]], "host": [[20, "envvar-HOST", false]], "map_attribution": [[20, "envvar-MAP_ATTRIBUTION", false]], "port": [[20, "envvar-PORT", false]], "redis_url": [[20, "envvar-REDIS_URL", false]], "sender_email": [[20, "envvar-SENDER_EMAIL", false]], "staticmap_subdomains": [[20, "envvar-STATICMAP_SUBDOMAINS", false]], "tile_server_url": [[20, "envvar-TILE_SERVER_URL", false]], "ui_url": [[20, "envvar-UI_URL", false]], "upload_folder": [[20, "envvar-UPLOAD_FOLDER", false]], "variable d'environnement": [[20, "envvar-API_RATE_LIMITS", false], [20, "envvar-APP_LOG", false], [20, "envvar-APP_SECRET_KEY", false], [20, "envvar-APP_SETTINGS", false], [20, "envvar-APP_WORKERS", false], [20, "envvar-DATABASE_DISABLE_POOLING", false], [20, "envvar-DATABASE_URL", false], [20, "envvar-DEFAULT_STATICMAP", false], [20, "envvar-EMAIL_URL", false], [20, "envvar-FLASK_APP", false], [20, "envvar-HOST", false], [20, "envvar-MAP_ATTRIBUTION", false], [20, "envvar-PORT", false], [20, "envvar-REDIS_URL", false], [20, "envvar-SENDER_EMAIL", false], [20, "envvar-STATICMAP_SUBDOMAINS", false], [20, "envvar-TILE_SERVER_URL", false], [20, "envvar-UI_URL", false], [20, "envvar-UPLOAD_FOLDER", false], [20, "envvar-VITE_APP_API_URL", false], [20, "envvar-WEATHER_API_KEY", false], [20, "envvar-WEATHER_API_PROVIDER", false], [20, "envvar-WORKERS_PROCESSES", false]], "vite_app_api_url": [[20, "envvar-VITE_APP_API_URL", false]], "weather_api_key": [[20, "envvar-WEATHER_API_KEY", false]], "weather_api_provider \ud83c\udd95": [[20, "envvar-WEATHER_API_PROVIDER", false]], "workers_processes": [[20, "envvar-WORKERS_PROCESSES", false]]}, "objects": {"": [[10, 0, 1, "patch--api-appeals-(string-appeal_id)", "/api/appeals/(string:appeal_id)"], [0, 1, 1, "post--api-auth-account-confirm", "/api/auth/account/confirm"], [0, 2, 1, "get--api-auth-account-export", "/api/auth/account/export"], [0, 2, 1, "get--api-auth-account-export-(string-file_name)", "/api/auth/account/export/(string:file_name)"], [0, 1, 1, "post--api-auth-account-export-request", "/api/auth/account/export/request"], [0, 1, 1, "post--api-auth-account-privacy-policy", "/api/auth/account/privacy-policy"], [0, 1, 1, "post--api-auth-account-resend-confirmation", "/api/auth/account/resend-confirmation"], [0, 2, 1, "get--api-auth-account-sanctions-(string-action_short_id)", "/api/auth/account/sanctions/(string:action_short_id)"], [0, 1, 1, "post--api-auth-account-sanctions-(string-action_short_id)-appeal", "/api/auth/account/sanctions/(string:action_short_id)/appeal"], [0, 2, 1, "get--api-auth-account-suspension", "/api/auth/account/suspension"], [0, 1, 1, "post--api-auth-account-suspension-appeal", "/api/auth/account/suspension/appeal"], [0, 2, 1, "get--api-auth-blocked-users", "/api/auth/blocked-users"], [0, 1, 1, "post--api-auth-email-update", "/api/auth/email/update"], [0, 1, 1, "post--api-auth-login", "/api/auth/login"], [0, 1, 1, "post--api-auth-logout", "/api/auth/logout"], [0, 1, 1, "post--api-auth-password-reset-request", "/api/auth/password/reset-request"], [0, 1, 1, "post--api-auth-password-update", "/api/auth/password/update"], [0, 3, 1, "delete--api-auth-picture", "/api/auth/picture"], [0, 1, 1, "post--api-auth-picture", "/api/auth/picture"], [0, 2, 1, "get--api-auth-profile", "/api/auth/profile"], [0, 1, 1, "post--api-auth-profile-edit", "/api/auth/profile/edit"], [0, 0, 1, "patch--api-auth-profile-edit-account", "/api/auth/profile/edit/account"], [0, 1, 1, "post--api-auth-profile-edit-preferences", "/api/auth/profile/edit/preferences"], [0, 1, 1, "post--api-auth-profile-edit-sports", "/api/auth/profile/edit/sports"], [0, 3, 1, "delete--api-auth-profile-reset-sports-(sport_id)", "/api/auth/profile/reset/sports/(sport_id)"], [0, 1, 1, "post--api-auth-register", "/api/auth/register"], [1, 3, 1, "delete--api-comments-(string-comment_short_id)", "/api/comments/(string:comment_short_id)"], [1, 2, 1, "get--api-comments-(string-comment_short_id)", "/api/comments/(string:comment_short_id)"], [1, 0, 1, "patch--api-comments-(string-comment_short_id)", "/api/comments/(string:comment_short_id)"], [1, 1, 1, "post--api-comments-(string-comment_short_id)-like", "/api/comments/(string:comment_short_id)/like"], [1, 1, 1, "post--api-comments-(string-comment_short_id)-like-undo", "/api/comments/(string:comment_short_id)/like/undo"], [1, 1, 1, "post--api-comments-(string-comment_short_id)-suspension-appeal", "/api/comments/(string:comment_short_id)/suspension/appeal"], [2, 2, 1, "get--api-config", "/api/config"], [2, 0, 1, "patch--api-config", "/api/config"], [3, 2, 1, "get--api-equipment-types", "/api/equipment-types"], [3, 2, 1, "get--api-equipment-types-(int-equipment_type_id)", "/api/equipment-types/(int:equipment_type_id)"], [3, 0, 1, "patch--api-equipment-types-(int-equipment_type_id)", "/api/equipment-types/(int:equipment_type_id)"], [4, 2, 1, "get--api-equipments", "/api/equipments"], [4, 1, 1, "post--api-equipments", "/api/equipments"], [4, 3, 1, "delete--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [4, 2, 1, "get--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [4, 0, 1, "patch--api-equipments-(string-equipment_short_id)", "/api/equipments/(string:equipment_short_id)"], [4, 1, 1, "post--api-equipments-(string-equipment_short_id)-refresh", "/api/equipments/(string:equipment_short_id)/refresh"], [5, 2, 1, "get--api-follow-requests", "/api/follow-requests"], [5, 1, 1, "post--api-follow-requests-(user_name)-accept", "/api/follow-requests/(user_name)/accept"], [5, 1, 1, "post--api-follow-requests-(user_name)-reject", "/api/follow-requests/(user_name)/reject"], [7, 2, 1, "get--api-notifications", "/api/notifications"], [7, 0, 1, "patch--api-notifications-(int-notification_id)", "/api/notifications/(int:notification_id)"], [7, 1, 1, "post--api-notifications-mark-all-as-read", "/api/notifications/mark-all-as-read"], [7, 2, 1, "get--api-notifications-types", "/api/notifications/types"], [7, 2, 1, "get--api-notifications-unread", "/api/notifications/unread"], [8, 2, 1, "get--api-oauth-apps", "/api/oauth/apps"], [8, 1, 1, "post--api-oauth-apps", "/api/oauth/apps"], [8, 3, 1, "delete--api-oauth-apps-(int-client_id)", "/api/oauth/apps/(int:client_id)"], [8, 2, 1, "get--api-oauth-apps-(int-client_id)-by_id", "/api/oauth/apps/(int:client_id)/by_id"], [8, 1, 1, "post--api-oauth-apps-(int-client_id)-revoke", "/api/oauth/apps/(int:client_id)/revoke"], [8, 2, 1, "get--api-oauth-apps-(string-client_client_id)", "/api/oauth/apps/(string:client_client_id)"], [8, 1, 1, "post--api-oauth-authorize", "/api/oauth/authorize"], [8, 1, 1, "post--api-oauth-revoke", "/api/oauth/revoke"], [8, 1, 1, "post--api-oauth-token", "/api/oauth/token"], [2, 2, 1, "get--api-ping", "/api/ping"], [9, 2, 1, "get--api-records", "/api/records"], [10, 2, 1, "get--api-reports", "/api/reports"], [10, 1, 1, "post--api-reports", "/api/reports"], [10, 2, 1, "get--api-reports-(int-report_id)", "/api/reports/(int:report_id)"], [10, 0, 1, "patch--api-reports-(int-report_id)", "/api/reports/(int:report_id)"], [10, 1, 1, "post--api-reports-(int-report_id)-actions", "/api/reports/(int:report_id)/actions"], [10, 2, 1, "get--api-reports-unresolved", "/api/reports/unresolved"], [11, 2, 1, "get--api-sports", "/api/sports"], [11, 2, 1, "get--api-sports-(int-sport_id)", "/api/sports/(int:sport_id)"], [11, 0, 1, "patch--api-sports-(int-sport_id)", "/api/sports/(int:sport_id)"], [12, 2, 1, "get--api-stats-(user_name)-by_sport", "/api/stats/(user_name)/by_sport"], [12, 2, 1, "get--api-stats-(user_name)-by_time", "/api/stats/(user_name)/by_time"], [12, 2, 1, "get--api-stats-all", "/api/stats/all"], [13, 2, 1, "get--api-timeline", "/api/timeline"], [14, 2, 1, "get--api-users", "/api/users"], [14, 3, 1, "delete--api-users-(user_name)", "/api/users/(user_name)"], [14, 2, 1, "get--api-users-(user_name)", "/api/users/(user_name)"], [14, 0, 1, "patch--api-users-(user_name)", "/api/users/(user_name)"], [14, 1, 1, "post--api-users-(user_name)-block", "/api/users/(user_name)/block"], [14, 1, 1, "post--api-users-(user_name)-follow", "/api/users/(user_name)/follow"], [14, 2, 1, "get--api-users-(user_name)-followers", "/api/users/(user_name)/followers"], [14, 2, 1, "get--api-users-(user_name)-following", "/api/users/(user_name)/following"], [14, 2, 1, "get--api-users-(user_name)-picture", "/api/users/(user_name)/picture"], [14, 2, 1, "get--api-users-(user_name)-sanctions", "/api/users/(user_name)/sanctions"], [14, 1, 1, "post--api-users-(user_name)-unblock", "/api/users/(user_name)/unblock"], [14, 1, 1, "post--api-users-(user_name)-unfollow", "/api/users/(user_name)/unfollow"], [15, 2, 1, "get--api-workouts", "/api/workouts"], [15, 1, 1, "post--api-workouts", "/api/workouts"], [15, 3, 1, "delete--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [15, 0, 1, "patch--api-workouts-(string-workout_short_id)", "/api/workouts/(string:workout_short_id)"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)-chart_data", "/api/workouts/(string:workout_short_id)/chart_data"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)-chart_data-segment-(int-segment_id)", "/api/workouts/(string:workout_short_id)/chart_data/segment/(int:segment_id)"], [1, 2, 1, "get--api-workouts-(string-workout_short_id)-comments", "/api/workouts/(string:workout_short_id)/comments"], [1, 1, 1, "post--api-workouts-(string-workout_short_id)-comments", "/api/workouts/(string:workout_short_id)/comments"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)-gpx", "/api/workouts/(string:workout_short_id)/gpx"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)-gpx-download", "/api/workouts/(string:workout_short_id)/gpx/download"], [15, 2, 1, "get--api-workouts-(string-workout_short_id)-gpx-segment-(int-segment_id)", "/api/workouts/(string:workout_short_id)/gpx/segment/(int:segment_id)"], [15, 1, 1, "post--api-workouts-(string-workout_short_id)-like", "/api/workouts/(string:workout_short_id)/like"], [15, 1, 1, "post--api-workouts-(string-workout_short_id)-like-undo", "/api/workouts/(string:workout_short_id)/like/undo"], [15, 1, 1, "post--api-workouts-(string-workout_short_id)-suspension-appeal", "/api/workouts/(string:workout_short_id)/suspension/appeal"], [15, 2, 1, "get--api-workouts-map-(map_id)", "/api/workouts/map/(map_id)"], [15, 1, 1, "post--api-workouts-no_gpx", "/api/workouts/no_gpx"], [20, 4, 1, "-", "API_RATE_LIMITS"], [20, 4, 1, "-", "APP_LOG"], [20, 4, 1, "-", "APP_SECRET_KEY"], [20, 4, 1, "-", "APP_SETTINGS"], [20, 4, 1, "-", "APP_WORKERS"], [20, 4, 1, "-", "DATABASE_DISABLE_POOLING"], [20, 4, 1, "-", "DATABASE_URL"], [20, 4, 1, "-", "DEFAULT_STATICMAP"], [20, 4, 1, "-", "EMAIL_URL"], [20, 4, 1, "-", "FLASK_APP"], [20, 4, 1, "-", "HOST"], [20, 4, 1, "-", "MAP_ATTRIBUTION"], [20, 4, 1, "-", "PORT"], [20, 4, 1, "-", "REDIS_URL"], [20, 4, 1, "-", "SENDER_EMAIL"], [20, 4, 1, "-", "STATICMAP_SUBDOMAINS"], [20, 4, 1, "-", "TILE_SERVER_URL"], [20, 4, 1, "-", "UI_URL"], [20, 4, 1, "-", "UPLOAD_FOLDER"], [20, 4, 1, "-", "VITE_APP_API_URL"], [20, 4, 1, "-", "WEATHER_API_KEY"], [20, 4, 1, "envvar-WEATHER_API_PROVIDER", "WEATHER_API_PROVIDER \ud83c\udd95"], [20, 4, 1, "-", "WORKERS_PROCESSES"]], "/api/workouts/map_tile/(s)/(z)/(x)/(y)": [[15, 2, 1, "get--api-workouts-map_tile-(s)-(z)-(x)-(y).png", "png"]]}, "objnames": {"0": ["http", "patch", "HTTP patch"], "1": ["http", "post", "HTTP post"], "2": ["http", "get", "HTTP get"], "3": ["http", "delete", "HTTP delete"], "4": ["std", "envvar", "variable d'environnement"]}, "objtypes": {"0": "http:patch", "1": "http:post", "2": "http:get", "3": "http:delete", "4": "std:envvar"}, "terms": {"0": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20], "00": [0, 4, 5, 9, 13, 14, 15], "000": 18, "000000": 0, "01": [0, 1, 9, 10, 12, 13, 14, 15], "02": [5, 14, 15], "03": [14, 15], "04": [0, 7, 10, 13, 14, 15], "06": [4, 7, 8, 12, 14], "0667062": 8, "06ba975": 16, "07": [0, 7, 9, 13, 14, 15], "075aeb9": 16, "08": [0, 4, 7, 9, 13, 14, 15], "09": [0, 7, 10, 14, 15], "0mb": [0, 15], "1": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 20], "10": [0, 1, 2, 4, 5, 7, 9, 13, 14, 15, 18, 20], "100": [15, 16, 18], "1000": 12, "10000": [2, 15], "101": [12, 16], "104": [15, 16], "1048576": 2, "10485760": 2, "106": 16, "109": 16, "11": [0, 9, 14, 15, 18, 20], "112": 16, "113": 16, "115": 16, "116": 16, "12": [0, 2, 9, 10, 14, 15, 20, 22], "121": 16, "123": 16, "1232004": 15, "12341": 12, "1234538": 15, "125": 16, "1267": 12, "127": [16, 20], "129": 16, "13": [0, 1, 9, 13, 14, 15, 17, 18, 20], "131": 16, "134": 16, "135": 16, "1375986": 16, "138": 16, "14": [0, 1, 8, 13, 14, 15], "140": 16, "145": 16, "146": 16, "149": 16, "15": [12, 15, 17, 18, 20], "150": 12, "151": 16, "152": 16, "155": 16, "156": [12, 16], "1563529507772": 15, "158": 15, "16": [1, 12, 14, 15, 18], "160": 16, "161": 16, "162": 16, "1658660147": 8, "167": 16, "169": 16, "17": [0, 5, 10, 13, 14, 15], "171": 16, "173": 16, "175": 16, "177": 16, "178": [12, 16], "18": [0, 9, 10, 14, 15, 18, 20], "180": 16, "19": [15, 18], "190": 16, "191": 16, "192": 16, "193": 16, "195": 16, "196": 16, "197": 16, "1m": 20, "1mb": 20, "2": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20], "20": [14, 15, 18], "200": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20], "201": [0, 1, 4, 10, 15, 16], "2016": 15, "2017": [12, 15], "2018": [12, 13, 15], "2019": [0, 9, 12, 13, 14, 15], "2021": [5, 14], "2022": 8, "2023": [0, 4, 14, 20], "2024": [0, 1, 7, 10, 14, 15], "203": 12, "204": [0, 1, 4, 8, 14, 15], "208": [15, 16], "209": 16, "21": [4, 10], "210": 16, "212": 16, "213": 16, "22": [7, 15], "223": 16, "224": 16, "225": 16, "23": [15, 20], "230": 16, "231": [15, 16], "232": 16, "234": 15, "236": 16, "237": 16, "239": 16, "24": [1, 15, 20], "241": 16, "242": 16, "244": 16, "246": 16, "247": 16, "25": [10, 15, 20], "250": 16, "252": 16, "255": 15, "257": 16, "258": 16, "259": 16, "26": [15, 20], "260": 16, "261": 16, "264": 16, "265": 16, "266": 16, "26and": 20, "27": [0, 8, 10, 14, 15, 18], "270": 16, "271": 16, "273": 16, "274": 16, "275": 16, "278": 16, "279": [15, 16], "28": 4, "280": [15, 16], "282": [12, 16], "287": 16, "289": 16, "29": [4, 10], "290": 16, "2930": 15, "294": 16, "297": 16, "2bcff2e": 16, "2e1ee2c": 16, "2ordfncv6vprkfp3yrcyht": [1, 15], "2ukrviyshoakg8qsuknus4": 4, "2ule2hwhsnycs2vhbsikb9": 14, "3": [0, 1, 3, 11, 12, 13, 14, 15, 18, 20], "30": [0, 10, 12, 15], "300": 20, "3000": 20, "301": [16, 20], "304": 16, "305": 16, "307": 16, "308": 16, "31": [0, 14, 15, 18, 20], "310": 16, "314": 16, "315": 16, "318": 16, "319": 16, "32": 15, "320": 16, "323": 16, "328": 16, "329": 16, "33": [12, 16], "3320": 12, "333": 16, "338": 16, "34": [1, 16], "34614d5": 16, "35": [0, 7, 15, 16], "350": 16, "351": 16, "352": 16, "354": 16, "356": 16, "357": 16, "358": 16, "359": 16, "36": 16, "365": 16, "366": 16, "367": 16, "369": 16, "37": 16, "370": 16, "371": 16, "374": 16, "375": 16, "376": 16, "377": 16, "38": 10, "380": 16, "3821e37": 16, "384": 16, "386": 16, "388": 16, "39": 15, "390": 16, "391": 16, "393": 16, "394": 16, "395": 16, "397": 16, "398": 16, "399": 16, "3aread": 21, "3awrit": 21, "3b6fa25": 16, "3c8d9c2": 16, "3f": 20, "4": [0, 3, 11, 12, 13, 14, 15, 17, 18, 20], "40": 16, "400": [0, 1, 2, 3, 4, 5, 8, 10, 11, 12, 14, 15, 16], "401": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], "402": 16, "403": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "404": [0, 1, 3, 4, 5, 7, 8, 10, 11, 12, 14, 15, 16], "406": 16, "407": 16, "409": 16, "40and": 20, "41": [15, 16], "410": 16, "4109": 15, "411": 16, "413": [0, 15], "415": 16, "416": 16, "417": 16, "418": 16, "42": [15, 16], "421": 16, "422": 16, "426": 16, "427": [15, 16], "428": 16, "43": [0, 9, 13, 14, 15, 16], "431": 16, "433": 16, "435": 15, "436": 16, "438": 16, "44": [14, 15, 16], "441": 16, "443": 20, "444": 16, "449": 16, "45": [0, 1, 14, 15], "450": 16, "455": 16, "456": 16, "46": [12, 16], "464": 16, "465": 20, "468": 16, "469": 16, "47": [12, 15, 16], "471": 16, "472": 16, "473": 16, "474": 16, "475": 16, "476": 16, "477": 16, "478": [15, 16], "479": 16, "48": [5, 12, 14], "481": 16, "482": 16, "484": 16, "488": 16, "489": 16, "49": [0, 10, 14], "490": 16, "494": 16, "495": 16, "496": 16, "499": [15, 16], "4c3fc34": 16, "5": [3, 8, 11, 12, 14, 15, 17, 18, 20], "50": [0, 4, 5, 12, 14, 16, 20], "500": [0, 1, 2, 3, 4, 5, 7, 10, 11, 13, 14, 15, 16], "5000": 20, "502": 16, "504": 16, "506": 16, "507": 16, "5078118": 15, "5079733": 15, "508": 16, "51": [13, 15], "510": 16, "511": 16, "512": 16, "517587": 15, "51758b4": 16, "52": [1, 15, 16], "521": 16, "524": 16, "526": 16, "527": 16, "528": 16, "53": [8, 16], "530": 16, "531": 16, "532": 16, "533": 16, "534": 16, "536": 16, "537": 16, "538": 16, "54": 16, "540": 16, "542": 16, "543": 16, "5432": 20, "544": 16, "545": 16, "546": 16, "55": [14, 15], "550": 16, "551": 16, "555": 16, "556": 16, "557": 16, "558": 16, "56": [10, 16], "560627": 15, "563": 16, "564": 16, "565": 16, "566": 16, "57": [15, 16], "571": 16, "575": 16, "58": [0, 14, 16], "582": 16, "583": 16, "587": [16, 20], "588": 16, "59": [12, 14, 15, 16], "590": 16, "591": 16, "592": 16, "593": 16, "595": 16, "598": 16, "6": [0, 3, 4, 11, 14, 15, 17, 18, 20], "60": 16, "600": 16, "603": 16, "604": 16, "607": 16, "608": 16, "609": 16, "60e164d": 16, "61": 16, "610": 16, "612": 16, "613": 12, "614": 16, "616": 16, "617": 16, "618": 16, "62": 16, "620": 16, "621": [15, 16], "622": 16, "624": 16, "625": 16, "626": 16, "628": 16, "629": 16, "63": 15, "631": 16, "633": 16, "634": 16, "635": 16, "636": 16, "637": 16, "639": [16, 17], "64": 16, "640": 16, "645": 16, "651": 16, "652": 16, "66": 16, "67": [0, 12, 14], "6dxczvmrhkar72shuz9pwd": [0, 14], "6e215a": 16, "6nvxvayoh9zkr8rmxhu54t": 14, "7": [15, 17, 18, 20, 21], "70": 16, "71": 16, "72": 16, "720": 0, "73": 16, "7380": 15, "74": 16, "75": 16, "7641": 12, "78": 12, "79": 16, "7pdujhcvhya4hv29jzqngg": 0, "8": [0, 2, 15, 17, 18, 20, 21], "80": [16, 20], "8025": 20, "81": 16, "82": 16, "83": 16, "84": 16, "85": 16, "864000": 8, "87": 16, "877fa0f": 16, "88": 16, "89": 16, "895": [0, 14], "8aa4cff": 16, "9": [0, 9, 14, 15, 17, 18, 20, 21], "90": 16, "91": 16, "92": 16, "924": 0, "93": 16, "93706": 15, "95": [12, 16], "97": [0, 9, 13, 14, 15, 16], "98": 16, "981933": 15, "99": [12, 16], "9960": 12, "AS": [0, 9, 13, 14, 15], "Avec": 18, "Ce": [8, 16], "Cette": [16, 19, 20], "D": 20, "Des": 18, "Du": 16, "EU": 16, "Elle": 18, "Elles": 20, "En": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19], "Et": 16, "Il": [0, 16, 18, 19, 20, 21], "Ils": 21, "J": 16, "L": [0, 11, 16, 18, 20, 21], "La": [0, 15, 16, 17, 18, 20, 21, 23], "Le": [0, 4, 14, 16, 18, 20], "Les": [0, 9, 14, 15, 18, 19, 20, 21, 24], "M": 15, "MS": [0, 9, 13, 14, 15], "Mon": [13, 15], "Ne": 14, "Par": [20, 21], "Pas": 16, "Pour": [15, 16, 18, 20, 21], "S": 18, "SA": 20, "Ses": 18, "Sur": 20, "Tue": [4, 15], "Un": [0, 3, 4, 8, 11, 14, 16, 18, 20], "Une": [16, 17, 18, 20, 23], "Y": [0, 12, 15], "_": [0, 16], "__main__": 20, "_blank": 20, "_workers_": 20, "a": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 19], "a458f5f": 16, "aaron": 21, "ability": 16, "abon": [18, 21], "about": [2, 16, 20], "absolu": [20, 23], "ac075ec36dc25dcc20c270d2005f0398": 15, "acced": [0, 3, 4, 8, 11, 12, 14, 18, 20, 21], "accept": [0, 5, 10, 16, 18], "accepted": 5, "accepted_policy": 0, "accepted_privacy_policy": 0, "acces": [0, 2, 3, 4, 8, 11, 14, 21], "access": [0, 16, 18], "access_token": 8, "accessibility": 16, "accessibl": [8, 16, 18, 20, 21], "accord": 21, "account": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], "actif": [0, 4, 17, 18], "action": [10, 16, 18], "action_short_id": 0, "action_typ": [0, 10, 14], "activ": [0, 2, 3, 11, 14, 16, 17, 18, 19, 20], "activat": [14, 17], "activit": 16, "activity": 16, "actuel": [0, 4], "adapt": [18, 20], "add": [16, 17], "added": [0, 4, 15, 16], "adding": 16, "additional": 16, "additionnel": 20, "admin": [0, 2, 3, 5, 7, 9, 11, 12, 13, 14, 15, 17], "admin_contact": 2, "administr": [2, 3, 11, 14, 17, 21, 24], "administrator": [0, 1, 3, 4, 7, 11, 13, 14, 15], "adress": [0, 2, 14, 17, 18, 20], "aff4d68": 16, "affect": [16, 18], "affich": [0, 15, 16, 17, 18, 19, 20, 21, 24], "affichag": 18, "affichent": 18, "afin": [16, 18, 21], "after": [16, 20], "again": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "agplv3": 16, "agre": 0, "aid": 19, "ains": [18, 20], "air": [18, 19], "ajout": [0, 1, 14, 15, 16, 17, 18, 19, 20, 21, 22], "alert": 16, "all": [3, 7, 11, 12, 20], "allemand": [16, 18], "allow": [16, 20], "allowed": [0, 15], "alor": 20, "alphanumer": 16, "alphanumeric": 0, "alpin": 18, "already": [0, 4, 5, 10, 16], "also": 16, "altern": 16, "altitud": [0, 16, 18], "alway": 20, "al\u00e9atoir": [17, 20], "an": [0, 8, 15, 16], "analys": [15, 18, 20], "ancien": 20, "and": [0, 4, 16, 17, 20], "android": 19, "anglais": [0, 16, 17, 18], "ann\u00e9": [12, 18], "anoth": 4, "antiali": 16, "anymor": 16, "apach": 19, "api": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21], "api_rate_limit": 20, "apikey": 20, "app": [8, 16, 20, 21], "app_log": 20, "app_secret_key": 20, "app_setting": 20, "app_worker": 20, "appar": [0, 18], "appara\u00eetr": 18, "appeal": [0, 1, 10, 14, 15], "appeal_id": 10, "appel": [0, 1, 10, 15, 18, 20], "application_directory": 20, "appliqu": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 19, 20, 21], "apport": 16, "approb": 18, "approuv": 0, "approved": [0, 10, 14], "apr": 15, "apres": [0, 8, 14, 16, 18, 20, 21], "archiv": [0, 2, 16, 17, 18, 20], "archive_rgjsr3fhr5yp": 0, "archive_rgjsr3fht295ywnqr5yp": 0, "archlinux": 20, "are": 16, "arg": [17, 20], "argument": [4, 17], "array": [0, 4, 8, 15], "arrow": 16, "arr\u00eat": [0, 14, 16, 18, 20], "asc": [5, 7, 10, 14, 15], "ascent": [13, 15, 16], "asset": 20, "assign": 18, "associ": [0, 4, 8, 11, 15, 16, 17, 18], "associated": [4, 16], "assur": 21, "astridx": 16, "at": [16, 20], "atteint": 18, "attendu": 20, "attent": [16, 20, 23], "attribu": 20, "attribut": 16, "aucun": [0, 4, 7, 10, 13, 14, 15, 18, 19, 20], "augment": 23, "aur": 16, "auteur": [1, 15, 18], "auth": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "auth_token": 0, "authentif": [1, 6, 14, 20, 21], "authentifi": [0, 1, 5, 7, 8, 9, 11, 12, 13, 14, 15, 18], "authlib": [8, 20, 21], "authoriz": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 21], "authorization_cod": [8, 21], "autoespcap": 16, "automat": [0, 18], "autoris": [0, 2, 8, 15, 20, 21], "autr": [0, 18, 20], "avail": 16, "avanc": [16, 18], "avant": [16, 18, 20], "ave_speed": [13, 15], "ave_speed_from": 15, "ave_speed_to": 15, "averag": [12, 16], "average_ascent": 12, "average_descent": 12, "average_dist": 12, "average_dur": 12, "average_speed": 12, "avert": [18, 20], "aviron": 18, "avoir": [11, 20], "awesom": 20, "axe": [15, 18], "axis": 16, "b": 20, "b1536fc": 16, "b29ed7": 16, "b748459": 16, "b862a77": 16, "background": 16, "bad": [0, 1, 2, 3, 4, 5, 8, 10, 11, 12, 14, 15], "bas": [16, 18, 20, 23], "basqu": [16, 18], "bcc568e": 16, "be": [0, 2, 4, 14, 15, 16, 20], "bear": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "been": 10, "below": 20, "besoin": 20, "bet": 16, "between": 16, "bibliothequ": 21, "bien": 18, "bik": [3, 4], "biking": [11, 15], "bin": 20, "bio": [0, 5, 14], "biograph": 0, "birth": 16, "birth_dat": [0, 5, 14], "bjornclauw": 16, "black": 16, "blacklist": 0, "blocag": 18, "block": 14, "blocked": [0, 10, 14], "blocked_user": 0, "bloqu": [0, 14, 18], "boat": 18, "body": [16, 20], "bokm\u00e5l": [16, 18], "boolean": [0, 2, 3, 4, 7, 8, 10, 11, 14, 17], "boosterl": 16, "bound": [13, 15], "bouton": 18, "bref": 4, "brows": 16, "build": [16, 20], "bulgar": [16, 18], "bulgarian": 16, "button": 16, "by": [16, 20], "by_id": 8, "by_sport": 12, "by_tim": 12, "byakurau": 16, "c88a515": 16, "cach": 18, "calcul": [0, 12, 16, 18], "calendar": 16, "calendri": [16, 18], "callback": [8, 21], "can": [0, 1, 10, 14, 15, 16, 18, 20, 23], "cannot": [4, 16], "caracter": [0, 4, 8, 15, 16, 20, 21], "card": 16, "cart": [0, 15, 16, 18, 19, 20, 24], "cas": [4, 8, 15, 18, 20], "cass": 20, "cb9d02f": 16, "cc": 20, "cc3fe1c": 16, "cc4287e": 16, "cd": 20, "cec": 16, "cel": [4, 16, 18, 20], "celui": [16, 20], "certain": [16, 17, 19, 20], "cf": [18, 19, 20], "chain": 20, "challeng": [8, 21], "champ": 16, "chang": [0, 4, 16, 18, 20], "changed": 16, "changing": 16, "chaqu": 18, "charact": 0, "character": [0, 4, 16], "charg": [0, 8, 16, 17, 18, 20, 21, 24], "chart": [15, 16, 20], "chart_dat": 15, "chaussur": 18, "cha\u00een": [8, 21], "check": 20, "check_workout": 11, "checkbox": 16, "checked": 16, "checking": 20, "chemin": [20, 23], "chiffr": [18, 20], "chois": [18, 20], "choos": 16, "ci": 16, "clair": 18, "cleanup": 16, "clear": 20, "cli": [14, 16, 17, 18, 20], "click": 16, "clickabl": 16, "clicking": 16, "client": [8, 14, 16, 18, 20, 21], "client_client_id": 8, "client_descript": 8, "client_id": [8, 21], "client_max_body_siz": [20, 23], "client_nam": 8, "client_secret": 8, "client_ur": 8, "clon": 20, "closing": 16, "cl\u00e9": [16, 18, 20], "co": 16, "cod": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 21], "code_challeng": [8, 21], "code_challenge_method": [8, 21], "code_verifi": 8, "coll": 20, "color": [0, 11, 16], "com": [0, 2, 8, 10, 14, 16, 20, 21], "comm": [7, 16, 18, 20], "command": [16, 18, 19, 20], "commenc": [0, 12, 16, 18, 20, 23], "commencent": 0, "commend": 16, "comment": [0, 1, 10, 15, 18], "comment_id": 10, "comment_short_id": 1, "comment_suspens": 10, "comment_unsuspens": 10, "commentair": [6, 10, 21], "compatibl": 20, "complet": [16, 18], "completed": 0, "compl\u00e9mentair": 16, "comport": 18, "compos": 20, "compt": [6, 14, 15, 16, 17, 20, 22], "comradekingu": 16, "concern": 10, "condit": 20, "confidential": [0, 2, 16], "config": [2, 16, 20], "configur": [6, 16, 17, 20, 21, 23], "configured": 16, "confirm": [0, 8, 16, 18, 20], "confusedalex": 16, "connaiss": [16, 18], "connect": [0, 18], "connexion": [0, 20], "conserv": [19, 20], "consult": 18, "contact": [0, 1, 2, 3, 4, 7, 11, 13, 14, 15, 18], "contain": [16, 20], "containing": 16, "conten": [16, 18, 20], "content": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], "contenu": [1, 10, 18], "contenus": 18, "contient": [16, 18, 20], "continu": 20, "contr": 21, "contrair": [15, 18], "contributeur": [16, 20, 22], "contributing": 16, "contributor": [2, 20], "control": [16, 20], "contr\u00f4l": 2, "copi": 20, "copy": [2, 20], "copyright": [2, 20], "cor": 16, "corp": 21, "correctly": 16, "correspond": [0, 15, 20], "correspondr": 18, "could": 16, "couleur": [0, 18], "cour": [16, 17, 18, 19, 20], "courriel": [0, 14, 16, 18, 23], "cours": [16, 18], "court": [1, 4, 15], "cp": 20, "creat": [16, 20], "create_app": 20, "created": [0, 1, 4, 8, 10, 15], "created_at": [0, 1, 5, 7, 10, 14, 15], "creation": 16, "creation_dat": [4, 13, 15], "creativecommon": 20, "credential": 0, "criter": [10, 14], "criteri": 15, "critical": 23, "cross": [8, 21], "crossing": [18, 20], "cr\u00e9": [0, 1, 4, 8, 10, 15, 16, 17, 18, 20, 21], "cr\u00e9ation": [0, 16, 18], "csrf": [8, 21], "current": [0, 14, 16], "custom": 20, "cycling": [11, 15, 16], "czech": 16, "dan": [0, 2, 4, 15, 16, 17, 18, 19, 20, 21, 24], "danielsiersleben": 16, "dark": 16, "darksky": [16, 20], "dashboard": 16, "dat": [0, 1, 2, 3, 4, 5, 8, 9, 11, 12, 13, 14, 15, 16, 18], "databas": [16, 17, 20], "database_disable_pooling": 20, "database_url": [20, 23], "date_format": 0, "date_string": 0, "davidhenrythoreau": 16, "day": [16, 17, 20], "db": 20, "dd": 0, "debian": [20, 22], "dec": [0, 1, 5, 7, 10, 14, 15], "default": [12, 15, 16, 20], "default_equipment_id": 0, "default_for_sport_id": 4, "default_staticmap": [16, 20], "defined": 18, "definit": 16, "del": 23, "delet": [0, 1, 4, 8, 14, 15, 16], "deleted": 16, "deleting": 16, "demand": [0, 6, 10, 14, 16, 17, 18, 20, 21], "deployment": 16, "deprecated": [16, 17], "depuis": [17, 20], "derni": [16, 20], "derri": 21, "desc": [5, 7, 10, 14, 15, 18], "descent": [13, 15, 16], "descript": [4, 8, 13, 15, 16, 17, 18, 20], "dessous": 16, "detail": 20, "detailed": 16, "detect": 16, "deux": 17, "dev": 20, "development": 16, "df": 20, "diagnostic": 20, "dialect": 23, "differ": 18, "different": [14, 16], "diff\u00e9rent": [16, 18], "dimanch": [12, 18], "direct": [16, 18, 20], "directory": [16, 20], "disabl": 16, "disabled": 0, "discours": 18, "display": 16, "display_ascent": 0, "displayed": 16, "displaying": 16, "disponibl": [0, 9, 17, 18, 20, 21, 22], "dispos": [0, 18], "distanc": [0, 9, 13, 15, 16, 18], "distance_from": 15, "distance_to": 15, "dkm": 16, "do": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], "dock": 16, "docu": 16, "document": [19, 20, 21, 23], "doit": [0, 4, 8, 11, 15, 16, 18, 20, 21, 23], "doiv": 18, "doivent": [15, 20, 21], "domain": [15, 20], "don": [0, 8, 12, 14, 15, 16, 18, 19, 21, 23], "dont": [14, 18, 21], "dor\u00e9nav": 16, "dotenv": 16, "dotlambd": 16, "doubl": 15, "down": 16, "download": [0, 15, 16], "dperruso": 16, "dramatiq": [16, 17, 20], "droit": [11, 14, 16, 17, 18, 21], "drop": 16, "dropdown": 16, "dur": [9, 15, 16, 18], "durat": [13, 15, 16], "duration_from": 15, "duration_to": 15, "during": [0, 1, 4, 10, 15], "dutch": 16, "d\u00e9": [3, 14], "d\u00e9bloqu": 14, "d\u00e9but": [12, 15], "d\u00e9connexion": 0, "d\u00e9criv": 10, "d\u00e9crivent": 20, "d\u00e9fault": [18, 20], "d\u00e9faut": [0, 4, 5, 7, 8, 10, 13, 14, 15, 16, 17, 18, 20], "d\u00e9fin": [4, 16, 18, 21, 23], "d\u00e9j\u00e0": [15, 17], "d\u00e9livr": 8, "d\u00e9marr": [16, 20, 24], "d\u00e9marrag": 20, "d\u00e9nivel": [0, 9, 15, 18], "d\u00e9pannag": 19, "d\u00e9part": 18, "d\u00e9pend": 16, "d\u00e9plac": 16, "d\u00e9pr\u00e9ci": 17, "d\u00e9p\u00f4t": 20, "d\u00e9roul": 18, "d\u00e9sactiv": [0, 14, 16, 17, 18, 20], "d\u00e9sorm": 16, "d\u00e9tail": 24, "d\u00e9velopp": [16, 17, 19], "e": 0, "e2e": 16, "ea0ac99": 16, "eau": 18, "edit": [0, 16], "editing": 16, "effectu": 18, "elev": [15, 16], "elles": 15, "email": [0, 2, 10, 14, 16, 17, 23], "email_to_confirm": 0, "email_url": [20, 23], "empty": 16, "emp\u00each": 21, "encod": 20, "encoded": 16, "encor": [16, 19], "endpoint": 16, "enfin": 16, "enfreint": 18, "english": 16, "enlev": 4, "enregistr": [0, 17, 18, 21], "ensembl": 20, "ensuit": 21, "enter": 16, "enti": 8, "entity": [0, 15], "entrant": 18, "entra\u00een": [12, 15, 18], "entre": [4, 8, 18, 21], "entry": 16, "entr\u00e9": [14, 16, 18, 20, 21], "enumerat": 20, "env": [16, 20], "environ": [16, 17, 23], "environment": [16, 20, 23], "envoi": [0, 14, 16, 18, 20, 21], "equal": 2, "equipment": [0, 3, 4, 13, 15, 16, 21], "equipment_id": [0, 15], "equipment_short_id": 4, "equipment_typ": [3, 4], "equipment_type_id": [3, 4], "equipment_type_label": 4, "erral": 16, "erreur": [0, 8, 16, 18, 23], "error": [0, 1, 2, 3, 4, 5, 7, 10, 11, 13, 14, 15, 16, 20], "errored": 0, "espac": 21, "espagnol": [16, 18], "esprit": 18, "estim": 16, "europ": 0, "ewm": 16, "exampl": [0, 2, 8, 10, 14, 16, 20, 21], "exc": 23, "exceed": [0, 4, 15, 16], "exceeding": 16, "except": [18, 23], "exclu": 18, "exclur": 18, "execstart": 20, "exempl": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21], "exhaust": 19, "exist": [0, 4, 5, 12, 14, 15, 16, 18, 19, 20], "existent": [7, 10, 14], "exit": [17, 20], "expected": 10, "expir": 17, "expired": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "expires_at": 8, "expires_in": 8, "expliqu": [0, 10], "expliquent": 10, "exploit": [18, 20], "export": [0, 16, 17, 18, 19, 20], "exp\u00e9diteur": 20, "extens": [0, 15, 20], "extr\u00eam": 18, "ex\u00e9cu": [16, 17], "ex\u00e9cut": 16, "f2aec30": 16, "f96dcef": 16, "fa33f4d996844a5c73ecd1ae24456ab8": 15, "facilit": 16, "facult": [4, 21], "fail": 16, "fair": [0, 1, 15, 18, 20], "fait": [15, 16, 20], "fals": [0, 1, 2, 3, 5, 7, 8, 10, 11, 13, 14, 15, 20], "falsif": [8, 21], "famill": 0, "farthest": 16, "fa\u00e7on": 20, "fb10602": 16, "fd": [0, 9, 13, 14, 15], "featur": 16, "fichi": [0, 2, 15, 16, 17, 18, 19, 20, 22, 24], "field": 16, "fil": [0, 2, 15, 16, 20], "file_nam": 0, "file_siz": 0, "filt": 16, "filter": 16, "filtering": 16, "filtr": [0, 10, 16, 18], "fin": [12, 15, 16, 20], "finish": 16, "first": [4, 16], "first_nam": [0, 5, 14], "fit": [20, 22], "fitotrack": 19, "fittracke": [4, 8, 17, 18, 20, 21, 22, 24], "fittrackee_client": 20, "fittrackee_host": 21, "fittrackee_install": 22, "fittrackee_worker": 20, "fittrackee_ynh": 20, "fix": [16, 20], "flake8": 16, "flask": [16, 20], "flask_app": 20, "flaticon": 20, "flech": 18, "float": [0, 15], "flow": 21, "flux": [6, 18], "fmstrat": 16, "follow": [0, 5, 7, 10, 14, 21], "follow_request": [5, 7], "follower": [0, 1, 5, 7, 10, 14, 15], "followers_only": [0, 1, 14, 15], "following": [0, 1, 5, 7, 10, 14, 15], "fonction": [20, 21], "fonctionnal": [19, 20], "fond": 20, "foot": 16, "for": [0, 2, 4, 14, 15, 16, 20], "forbidden": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "forc": 4, "forgery": [8, 21], "fork": 20, "form": [0, 8, 15, 16], "format": [0, 12, 15, 16, 18, 19], "fort": 20, "forwarded": [20, 21], "found": [0, 1, 3, 4, 5, 7, 8, 10, 11, 12, 14, 15, 16], "fourn": [0, 4, 8, 12, 15, 17, 18, 20, 21, 23], "fournisseur": [16, 20], "fr": [0, 20], "franc": [18, 20], "freepik": 20, "french": 16, "fri": 15, "from": [4, 5, 7, 12, 15, 16, 20], "ft": 16, "ftcli": 20, "full": 16, "fullchain": 20, "fullscreen": 16, "furo": 16, "fuseau": [0, 15, 18], "galician": 16, "galicien": [16, 18], "gallegonovato": 16, "gard": [15, 18, 20], "garmin": 22, "generat": 16, "ger": [10, 16, 17, 18, 20, 23], "german": [16, 20], "gestion": [16, 20], "get": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], "getting": 2, "gif": 0, "git": 20, "github": 20, "gl": 0, "gmt": [0, 1, 4, 5, 7, 8, 9, 10, 13, 14, 15], "gnu": 16, "gorgoback": 16, "gp": [16, 18], "gpl": 19, "gpx": [0, 8, 15, 16, 18, 19, 20, 22], "gpx_limit_import": 2, "gpxpy": [0, 16, 18, 20], "grammar": 16, "grand": [16, 18], "grant_typ": 8, "graph": 16, "graphiqu": [0, 16, 18, 20], "gray": 16, "great": [1, 2, 16], "gr\u00e2c": [20, 22], "guid": 20, "guillemet": 15, "gunicorn": [20, 23], "gz": 20, "gzip": 0, "g\u00e9ner": [8, 17, 18, 20, 21], "h": [15, 18], "ha": [0, 9, 13, 14, 15], "handl": 16, "has": [1, 4, 10, 14, 15, 16, 18], "has_equipment": 3, "has_next": [0, 5, 7, 8, 10, 14], "has_prev": [0, 5, 7, 8, 10, 14], "has_workout": 11, "hav": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], "help": [17, 20], "heur": [16, 18, 20], "hexadecimal": 0, "hexad\u00e9cimal": 0, "hgzyfxgvwkcepdq3vyk67q": 15, "hidden": 16, "hide_profile_in_users_directory": 0, "hiding": 16, "hiking": 11, "his": 16, "histor": [19, 20], "hom": 20, "horair": [0, 15, 18], "host": 20, "hosted": 16, "hosting": 20, "hour": 20, "how": 20, "href": [2, 20], "html": 16, "http": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 21], "http2": 20, "hv9kwvdtbhhyfvml7phovq": 10, "hvybqybra7wwxpastwr4v2": [0, 9, 13, 14, 15], "h\u00f4t": 20, "i18n": 16, "icon": 16, "ic\u00f4n": 20, "id": [0, 1, 3, 4, 7, 8, 9, 10, 11, 13, 14, 15, 16], "identif": 20, "identifi": [0, 1, 7, 8, 10, 11, 12, 15, 21], "if": [0, 16, 18, 20], "imag": [0, 14, 15, 16, 18, 20, 24], "impact": 18, "imperial": 16, "imperial_unit": 0, "implementing": 16, "import": [4, 8, 16, 18, 20], "importing": 16, "improv": 16, "improved": 16, "imp\u00e9rial": [0, 18], "in": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20], "in_progress": 0, "inact": [0, 4, 14, 15, 18, 20], "inclus": 16, "incomplet": 17, "inconsistent": 16, "incorrect": [4, 16, 18], "index": 15, "indiqu": 18, "ind\u00e9sir": 18, "infobull": 18, "inform": [0, 2, 14, 16, 18, 19, 20], "inf\u00e9rieur": 16, "init": 16, "initial": [16, 20], "initialis": [16, 20], "inscript": [0, 2, 16, 18, 20], "inscrir": [2, 16, 18, 20], "insensibl": 20, "instabl": [19, 20], "install": [16, 19], "instanc": [2, 16, 18, 20], "instant": 18, "instead": 16, "instruct": [0, 16, 18, 20], "int": [0, 3, 4, 7, 8, 10, 11, 15], "integ": [0, 2, 3, 4, 5, 7, 8, 10, 11, 12, 13, 14, 15], "integer": 4, "integr": 20, "interag": [18, 21], "interceptor": 16, "interfac": [0, 16, 18, 19, 20], "internal": [0, 1, 2, 3, 4, 5, 7, 10, 11, 13, 14, 15], "interrompu": 20, "introduit": 16, "invalid": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20], "invalidat": 16, "invalidemailurlschem": 23, "ip": 20, "is": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], "is_act": [0, 3, 4, 10, 11, 14], "is_active_for_us": 11, "is_email_sending_enabled": 2, "is_followed_by": [0, 7, 10, 14], "is_registration_enabled": 2, "is_reported_user_warned": 10, "iso": 17, "isort": 16, "issu": [16, 18], "issued_at": 8, "it": [0, 4], "italian": 16, "italien": [16, 18], "jan": [13, 15], "jat255": 16, "javascript": [16, 20], "jderuit": 16, "jeton": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 20, 21], "jinj": 16, "jmlich": 16, "john_do": 14, "johndo": 14, "jour": [0, 1, 2, 4, 7, 11, 14, 15, 16, 17, 18], "journal": [20, 23], "jpeg": 14, "jpg": 0, "js": [15, 20], "json": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18], "jul": [0, 9, 13, 14, 15], "july": 8, "jusqu": 18, "jwt": 20, "kayak": 18, "kayak_boat": 3, "kcj6hdgqqpkaakqmfqj8jv": 14, "kd5wyhwltvozw6o3au5m4j": 15, "keep": 16, "key": 20, "keyboard": 16, "kjxavsturjvoah2wvcegef": [13, 15], "km": [15, 18], "koen": 16, "komoot": 20, "label": [3, 4, 11, 16, 18], "laiss": 20, "lanc": [16, 20], "lang": [0, 16, 17], "langu": [0, 16, 17, 18], "languag": [0, 16, 17], "laquel": 18, "larg": [0, 15, 20], "last_nam": [0, 5, 14], "latitud": 15, "lavoi": 16, "layout": 16, "ld": [0, 9, 13, 14, 15], "leaflet": [15, 20], "lectur": [7, 21], "legal": 20, "legitimat": 16, "lequel": [0, 20], "less": 16, "lettr": 17, "lev": [18, 20], "li": [10, 18], "libr": 18, "librair": [8, 20], "library": 16, "licenc": 20, "licens": [16, 19, 20], "lien": 20, "lieu": 18, "lign": [16, 18, 19, 20], "lik": [1, 15], "liked": [1, 15], "likes_count": [1, 15], "lim": [16, 20], "limit": [4, 16, 18], "lin": [17, 20], "link": 16, "lint": 20, "linux": 20, "lir": 16, "list": [8, 15, 16, 17, 19, 20], "listen": 20, "ll": 20, "load": [16, 23], "loading": 16, "local": [0, 16, 19, 20], "localhost": [16, 20], "localis": [0, 20], "localiz": 16, "locat": [0, 5, 14, 20], "log": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 23], "logfil": 20, "logged": 0, "login": [0, 16], "logo": 20, "logout": [0, 16], "long": 16, "longitud": 15, "longu": [4, 9, 16, 18], "longueur": 15, "lor": [0, 17, 18], "lorsqu": [0, 16, 17, 20], "lu": [7, 18], "lukasitaly": 16, "lund": [0, 12, 18], "mailhog": 20, "mainten": [16, 20], "majeur": 16, "majuscul": 15, "mak": [16, 20], "makefil": 20, "malformed": 0, "manag": [16, 17], "mani": [18, 20], "manqu": 16, "manually_approves_follower": 0, "manuel": 18, "map": [13, 15, 16, 18, 19], "map_attribu": [2, 20], "map_id": 15, "map_til": 15, "map_visibility": [0, 14, 15], "mar": [0, 4, 14, 16, 20], "mara21": 16, "march": [16, 18], "mariusz": 16, "mariuz": 16, "mark": 7, "markdown": [16, 18], "marked_as_read": 7, "marker": 16, "marqu": [7, 18], "masqu": [14, 18], "match": 0, "matching": 16, "mati": 18, "max": [2, 5, 14, 15, 16, 17], "max_alt": [13, 15], "max_single_file_siz": 2, "max_speed": [13, 15], "max_speed_from": 15, "max_speed_to": 15, "max_user": 2, "max_zip_file_siz": 2, "maximal": [2, 9, 15, 16, 17, 18], "maximum": [2, 18], "may": 16, "md": 16, "measur": 16, "meilleur": 21, "mensuel": [16, 18], "mention": [1, 7, 18, 20], "menu": [16, 18], "merc": 16, "messag": [0, 2, 5, 8, 14, 15, 16, 17, 18, 20, 21], "mettr": [1, 2, 7, 14, 15, 16, 17, 18, 20], "mi": 16, "microsecond": 16, "migrat": [16, 17, 20], "min": 16, "min_alt": [13, 15], "minimal": [15, 16, 18, 21], "minimum": [0, 2, 3, 10, 11, 12, 14], "minor": 16, "minuscul": 15, "minut": 20, "mis": [0, 4, 11, 14, 15, 16, 18, 21], "missing": [0, 16], "mm": 0, "mmm": 0, "mmy3qpl3vcfukjgffbncjv": 0, "mo": 18, "mobil": [16, 19], "mod": 16, "modal": 16, "model": 16, "moder": [10, 12, 14], "moderator": [10, 14, 17], "modif": [0, 18, 19], "modifi": [0, 3, 4, 10, 11, 17, 18, 20, 21], "modification_dat": [1, 13, 15], "modify": 16, "modul": [16, 20], "moin": [4, 7, 10, 13, 15], "mois": [12, 16, 18], "moment": [0, 15, 16, 18], "mond": 18, "mondstern": 16, "mono": 16, "month": [12, 16], "mor": 16, "morning": 15, "mot": [0, 14, 17, 18, 20], "mountain": 11, "mountaineering": 16, "mous": 16, "mov": 16, "moving": [13, 15, 16], "moyen": [9, 12, 15, 16, 18, 20], "mult": 20, "multipart": [0, 8, 15], "multipl": 16, "must": [0, 2, 4, 14, 15, 16], "mv": 20, "my": 4, "mynixos": 20, "mzydicyyfktg3gga2x8afu": 1, "m\u00e9thod": [8, 20, 21], "m\u00e9triqu": 18, "m\u00e9t\u00e9o": [16, 18], "m\u00eam": [16, 18, 20], "nag": 18, "naissanc": 0, "nam": [8, 16, 18], "nano": 20, "navig": [0, 16, 18, 20], "nb": 0, "nb_sport": [0, 5, 14], "nb_workout": [0, 1, 5, 7, 10, 14, 15], "nbsp": 20, "nederland": 16, "need": 20, "net": 20, "netinstall": 22, "nettoi": 16, "network": 20, "new": [0, 4, 14, 16], "new_email": 14, "new_password": 0, "next_workout": [13, 15], "nginx": [16, 18, 20, 21, 23], "ni": 18, "nic": 1, "niveau": [1, 16, 18], "nixpkg": 20, "nl": 0, "no": [0, 1, 4, 8, 14, 15, 16], "no_gpx": 15, "nod": 20, "nofollow": 20, "noir": 17, "nom": [0, 5, 8, 10, 12, 14, 16, 17, 18, 20], "nombr": [2, 5, 14, 15, 17, 18, 20], "nomenclatur": 16, "non": [0, 3, 4, 7, 8, 10, 11, 12, 15, 16, 18, 19, 20], "noopen": 20, "noreferr": 20, "norv\u00e9gien": [16, 18], "norwegian": 16, "nosuchmoduleerror": 23, "not": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20], "notat": 20, "notif": [6, 20, 21], "notification_id": 7, "notification_typ": 7, "nouveau": [0, 16, 17, 18, 20, 21], "nouveaut": 18, "nouvel": [0, 4, 14, 17, 18, 20], "nov": [1, 15], "now": 17, "null": [0, 1, 2, 4, 5, 7, 10, 11, 13, 14, 15, 16], "numb": [2, 16, 20], "nuv9cy8vqonrqkhtz5pqaq2zw7msh0mornpjr14amswd6f6": 8, "n\u00e9cessair": [18, 20], "n\u00e9cessit": [16, 20, 21], "n\u00e9cessitent": [20, 21], "n\u00e9erland": [16, 18], "n\u00e9gat": [15, 18], "o22a27s2abpuoxjbxv3ujdox": 8, "oauth": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 19, 20], "oauth2": [6, 21], "oauthlib": 21, "object_id": 10, "object_typ": 10, "objet": [0, 1, 2, 3, 4, 7, 10, 11, 14, 15], "obligatoir": [4, 8, 10, 12, 15, 16, 17, 20, 21], "obten": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 21], "octet": 0, "of": [0, 2, 3, 4, 15, 16, 20, 21], "office365": 20, "offset": 16, "ok": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "omis": 20, "once": [0, 1, 15], "ondrejzivny": 16, "one": [0, 15], "onglet": 21, "ongoing": 0, "only": [0, 1, 15, 16, 18], "open": [16, 18, 19], "openstreetmap": [2, 16, 20], "opentrack": 19, "option": [16, 17, 20], "optionnel": [7, 8, 20], "or": [0, 1, 2, 3, 4, 7, 11, 13, 14, 15, 16], "order": [5, 7, 10, 14, 15, 16], "order_by": [10, 14, 15], "ordre": [5, 7, 10, 14, 15], "org": [2, 20], "origin": 18, "osm": 20, "osmfr": 20, "other": [0, 4, 14], "out": 0, "outdoor": [16, 20], "outil": [18, 19, 20], "ouvr": 20, "over": 16, "overlap": 16, "own": 10, "owner": [14, 17, 20], "o\u00f9": [4, 18, 20], "packag": [16, 20], "packaged": 16, "paf38": 16, "pag": [0, 5, 7, 8, 10, 13, 14, 15, 16], "pagin": [0, 5, 7, 8, 10, 13, 14, 15], "paquet": [16, 20], "par_pag": 14, "paragliding": 16, "paralleliz": 16, "paramet": [4, 20], "parameter": [4, 16], "parametr": [0, 1, 3, 4, 5, 7, 8, 10, 11, 12, 13, 14, 15, 16, 18, 21], "parapent": 18, "pareck": 21, "paris": 0, "parm": [16, 20], "pars": 16, "part": [0, 15, 16, 18, 19, 21, 22], "particuli": 18, "partiel": 16, "pass": [0, 14, 17, 18, 20], "password": [0, 16, 17, 20], "passwordwith": 20, "patch": [0, 1, 2, 3, 4, 7, 10, 11, 14, 15], "paus": [13, 15, 16], "payload": [0, 1, 2, 3, 8, 10, 11, 14, 15], "pem": 20, "pending": 7, "per": [16, 20], "per_pag": [5, 14, 15], "perform": 18, "period": 12, "perm": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "permet": [4, 16, 17, 18, 19, 21], "permettent": 18, "permettr": 21, "personnalis": [16, 18], "personnel": 18, "petit": 16, "peut": [0, 1, 3, 4, 8, 11, 12, 14, 15, 16, 17, 18, 20, 21], "peuvent": [0, 16, 18, 19, 20], "pg_dump": 20, "pictur": [0, 1, 5, 7, 10, 14, 15, 16], "piec": 4, "pied": [16, 18], "pil": 16, "ping": 2, "pip": 20, "pipenv": 16, "pkce": [8, 21], "pleas": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "plein": [18, 19], "plugin": 23, "pluj": 16, "plupart": 21, "plus": [4, 9, 16, 17, 18, 19, 20, 23], "plusieur": [16, 18, 19, 20], "png": [0, 15, 20], "poetry": [16, 20], "point": [0, 2, 3, 4, 8, 11, 14, 16, 18, 20, 21], "policy": [0, 16], "polish": 16, "polit": [0, 2, 16], "polon": [16, 18], "pong": 2, "pooling": 20, "port": 20, "portug": [16, 18], "portugues": 16, "posit": [15, 16, 18], "possibl": [16, 18, 19, 20], "post": [0, 1, 4, 5, 7, 8, 10, 14, 15, 21], "postgr": [16, 23], "postgresql": [16, 20, 23], "postgresql10": 16, "pourquoi": 10, "pourr": 20, "pouv": 16, "pr": 16, "preferent": [0, 16], "premi": 18, "prendr": [16, 18], "prepar": 16, "present": 16, "prevent": 20, "previous_workout": [13, 15], "prior": 18, "pris": [0, 8, 16, 17, 18, 20, 21], "priv": [16, 18], "privacy": [0, 16], "privacy_policy": 2, "privacy_policy_dat": 2, "privat": [0, 1, 14, 15], "privileg": 20, "privkey": 20, "problem": [16, 18, 20], "proced": 20, "process": [16, 20], "processed": 0, "processus": 20, "prochain": [16, 18], "product": 16, "productionconfig": 20, "produir": 18, "profil": [0, 8, 10, 14, 16, 18, 21], "project": 20, "projet": 20, "propos": 16, "propr": [4, 14, 19], "propri\u00e9tair": [4, 16, 18, 20], "proto": [20, 21], "proven": 18, "provid": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], "provided": [0, 1, 2, 4, 14, 15], "provider": 16, "proxy": [20, 21], "proxy_add_x_forwarded_for": 20, "proxy_pass": 20, "proxy_redirect": 20, "proxy_set_head": [20, 21], "pr\u00e9c\u00e9dent": 16, "pr\u00e9f\u00e9rent": [0, 4, 14, 17], "pr\u00e9nom": 0, "pr\u00e9requ": 16, "pr\u00e9sent": 18, "pr\u00eat": 20, "psjeexbjz2jjnqctcpxvvf": 15, "publi": [18, 20], "public": [0, 1, 15, 18], "publiqu": 1, "puiss": 21, "puissent": [16, 18], "pull": 20, "pwd": 20, "py": 20, "python": [16, 20, 21], "p\u00e9riod": [12, 18, 20], "q": 14, "qrj7by6h2iyjsv8sersfgv": 4, "quand": [14, 16], "quant": 16, "quelqu": [4, 5, 7, 10, 13, 14, 15, 16, 18, 21], "queu": 20, "qwerty287": 16, "r": 20, "rafra\u00eech": 8, "raison": [0, 10], "randon": [16, 18], "rapport": 16, "rapporteur": 10, "raquet": 18, "rat": [16, 20], "reactivated": 10, "read": [0, 1, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 21], "read_status": 7, "readm": 16, "real": 20, "reason": [0, 10, 14], "rebuild": 16, "recalcul": [4, 18], "recevoir": 18, "recherch": 16, "recommand": [8, 20, 21], "record": [0, 5, 6, 13, 14, 15, 16, 18], "record_typ": [0, 9, 13, 14, 15], "red": [16, 17, 20], "redirect": [8, 16, 21], "redirect_ur": 8, "redirig": [8, 21], "redis_url": 20, "red\u00e9marr": 20, "refacto": 16, "refactoris": 16, "refresh": 4, "refresh_token": 8, "refreshed": 16, "regist": 0, "registr": [0, 16], "regl": 18, "reject": 5, "rejected": 5, "rejet": [5, 10, 18, 20], "rel": 20, "relat": [14, 18], "relev": 16, "remain": 18, "remaining": 16, "remarqu": 20, "remote_addr": 20, "remov": 16, "remplac": [15, 16, 18, 20], "rencontr": 20, "renomm": 16, "renvoi": [0, 7, 8, 10, 13, 14, 15], "replac": 16, "report": [10, 21], "report_act": 10, "report_id": [10, 14], "reported_by": 10, "reported_comment": 10, "reported_us": 10, "reported_workout": 10, "requ": [0, 21], "request": [0, 1, 2, 3, 4, 5, 8, 10, 11, 12, 14, 15, 20, 21], "request_ur": 20, "requested": 0, "required": 0, "requiring": 16, "requis": 4, "requ\u00eat": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 21], "resend": 0, "resent": 0, "reset": [0, 16, 17], "reset_password": 14, "resetting": 16, "resolu": 16, "resolved": 10, "resolved_at": 10, "resolved_by": 10, "respons": 16, "response_typ": [8, 21], "rest": [16, 18, 21], "restart": 20, "restartsec": 20, "restreint": 18, "result": 16, "retir": 18, "retourn": [1, 9, 12], "return": [16, 20], "revok": 8, "revoked": 0, "reworked": 16, "re\u00e7u": [5, 7, 18], "rid": 16, "right": [14, 16, 18], "roehv64thcg28wcewzhrnvlusoduvw8nvnhkcml57": 8, "rol": [0, 1, 7, 10, 14, 15, 17, 18, 20], "rout": [16, 20], "ruff": 16, "run": 20, "runn": 19, "running": 11, "russ": 18, "russian": 16, "r\u00e9activ": 18, "r\u00e9cent": 18, "r\u00e9cuper": [15, 18, 20, 21], "r\u00e9dact": 18, "r\u00e9duit": 16, "r\u00e9initialis": [0, 14, 17, 18, 20], "r\u00e9pertoir": [0, 20], "r\u00e9pons": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "r\u00e9solu": 18, "r\u00e9solus": 10, "r\u00e9tro": 20, "r\u00e9uss": 0, "r\u00e9voqu": 8, "r\u00f4l": [2, 3, 10, 11, 12, 14, 17, 18, 20], "s256": [8, 21], "sam": [0, 1, 4, 5, 9, 10, 13, 14, 15, 16], "samr1": 20, "san": [0, 1, 5, 7, 8, 10, 12, 13, 14, 15, 16, 18, 19, 20], "sanction": [0, 14, 18], "sat": 14, "sauf": [14, 20], "sauvegard": 20, "sav": [1, 4, 10, 16], "schem": [20, 21], "scop": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "screen": 16, "script": 16, "search": 16, "second": [4, 15], "secret": [8, 20, 21], "section": 16, "security": 16, "see": [16, 20], "seem": 16, "segment": [13, 15, 16, 18], "segment_id": 15, "selected": [0, 15], "selon": [0, 18, 20], "semain": [0, 12, 18], "send": 16, "sender_email": 20, "sending": 16, "sent": 14, "ser": [0, 4, 15, 16, 17, 18, 20], "serv": [0, 1, 2, 3, 4, 5, 7, 10, 11, 13, 14, 15, 16, 20], "server_nam": 20, "serveur": [15, 16, 18, 19, 23], "servic": [16, 20], "session": 21, "set": [16, 17, 20], "setting": 20, "seuil": [0, 18], "seul": [0, 1, 4, 8, 12, 14, 15, 16, 18, 20, 21], "sh": 22, "shel": 20, "sho": [3, 4], "should": 16, "show": [16, 17, 20], "shown": 16, "shura0": 16, "si": [0, 4, 5, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 20, 21, 23], "sien": 14, "signal": [6, 14, 18, 21], "signatur": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "sikm": 16, "simontb": 16, "simpl": 20, "simplified": 21, "simplify": 16, "sinon": [18, 20], "sit": [8, 21], "siz": [0, 2, 15, 16, 20], "ski": 18, "skis": [3, 18], "skylan0916": 16, "slothj": 16, "slow": 16, "small": 16, "smtp": [16, 20, 23], "snowsho": [3, 16], "som": 16, "sombr": [0, 18], "sorry": 0, "sort": 21, "sorting": 15, "soum": 20, "sourc": 18, "sous": [15, 20], "spanish": 16, "special": 16, "specific": 20, "specify": 16, "speed": [15, 16], "spinn": 16, "sport": [0, 2, 4, 6, 12, 15, 16, 20], "sport_id": [0, 4, 9, 11, 12, 13, 14, 15], "sport_label": 4, "sportiv": [18, 19], "sports_list": [0, 5, 14], "sp\u00e9cial": 20, "sp\u00e9cif": 18, "sql": 20, "sqlalchemy": [16, 20, 23], "ssl": 20, "ssl_certificat": 20, "ssl_certificate_key": 20, "standard": [16, 20], "standarderror": 20, "standardiz": 16, "standardoutput": 20, "start": 16, "start_elevation_at_zero": 0, "startlimitintervalsec": 20, "starttl": 20, "stat": [8, 12, 16, 21], "static": 16, "staticmap": 20, "staticmap_subdomain": [16, 20], "statiqu": [18, 20], "statist": [2, 6], "statistic": [2, 12, 16], "stats_workouts_lim": 2, "status": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18], "statut": [4, 14, 17], "sticky": 16, "stock": [18, 19, 21], "stop": 20, "stopp": 20, "stopped": 16, "stopped_speed_threshold": [0, 11], "strateg": 20, "strav": 22, "street": [18, 19], "strength": 16, "string": [0, 1, 2, 4, 5, 7, 8, 10, 11, 12, 14, 15], "subdomain": 16, "succes": [0, 3, 4, 5, 8, 11, 12, 14, 15], "success": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "successful": 0, "successfully": 0, "such": 16, "suiv": [0, 6, 9, 14, 18, 20, 21, 22, 23], "suivent": 20, "suivr": [14, 18, 19], "sun": [0, 1, 9, 10, 13, 14, 15], "sunday": 15, "supplied": 4, "suppl\u00e9mentair": 18, "support": [8, 16, 20, 21, 23], "suppress": [4, 16, 20], "supprim": [0, 1, 4, 8, 14, 15, 16, 17, 18], "suspended": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "suspended_at": [0, 1, 7, 10, 14, 15], "suspendr": 18, "suspendu": [0, 3, 4, 8, 11, 14, 18], "suspendus": 14, "suspens": [0, 1, 14, 15, 18, 20], "swimming": 16, "swimrun": [16, 18], "switch": 16, "synchronis": 22, "syntax": 18, "syslog": 20, "syslogidentifi": 20, "system": [18, 20], "systemd": 20, "s\u00e9anc": [0, 1, 2, 4, 6, 10, 11, 12, 16, 19, 21, 22, 24], "s\u00e9cur": 21, "s\u00e9lection": [0, 18], "s\u00e9mant": 20, "s\u00e9par": [18, 20, 21], "t2zeeuxvuy3pla8meeufyk": 1, "tabl": 16, "taill": [0, 2, 18, 20], "taken": 0, "tant": 4, "tar": 20, "target": 20, "task": 20, "tchequ": [16, 18], "temp": 12, "term": 16, "test": [16, 20], "text": [0, 1, 10, 14, 15, 18], "text_html": 1, "text_visibility": 1, "textar": 16, "than": [2, 14, 16], "thank": 16, "that": [0, 4, 16], "the": [0, 1, 3, 4, 7, 11, 13, 14, 15, 16, 20], "their": 10, "them": [0, 16, 18], "this": [15, 17, 20], "thovi98": 16, "threshold": 16, "thu": [5, 8, 14], "thunderforest": [16, 20], "ticket": 19, "tient": 15, "tier": [18, 19], "tierc": [18, 21], "til": [16, 20], "tile_server_url": 20, "tim": [12, 15, 16], "timelin": 13, "timeout": [20, 23], "timezon": [0, 16], "titl": [13, 15, 16], "titr": [15, 18], "tl": [16, 20], "to": [0, 2, 4, 12, 14, 15, 16, 18, 20, 22], "token": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 21], "token_typ": 8, "too": [0, 15], "tool": 16, "tooling": 16, "tooltip": 16, "total": [0, 4, 5, 7, 8, 10, 12, 14, 15, 16, 18], "total_ascent": [0, 12], "total_dist": [0, 4, 5, 12, 14], "total_dur": [0, 4, 5, 12, 14], "total_moving": 4, "total_workout": 12, "toujour": 18, "tous": [0, 4, 7, 8, 9, 12, 14, 16, 18, 20], "tout": [4, 12, 18, 20], "trac": [18, 20], "track": 16, "tracke": 20, "traduit": 18, "trail": 18, "trailing": 16, "train": 3, "trait": [0, 17], "translat": 16, "translated": 16, "transport": [11, 16, 18], "traxy": 16, "trekking": [16, 18], "tri": [5, 7, 10, 14, 15, 18], "tronqu": 15, "trouv": [3, 4, 8, 11, 15], "tru": [0, 1, 2, 3, 4, 7, 8, 10, 11, 14, 15, 16, 20], "try": [0, 1, 3, 4, 7, 11, 13, 14, 15], "tuil": [15, 16, 18], "typ": [0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 20], "typescript": 20, "typo": 16, "typos": 16, "t\u00e2ch": 20, "t\u00e9l\u00e9charg": [0, 15, 17, 18, 20, 24], "t\u00e9l\u00e9vers": [16, 18], "t\u00eat": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 21], "u": 20, "uberspac": 20, "ubuntu": 20, "ui": 16, "ui_url": 20, "unauthorized": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "unblock": 14, "unchanged": 18, "uncommenting": 20, "underscor": 0, "undo": [1, 14, 15], "unfollow": 14, "uniqu": [0, 1, 8, 14, 16, 17, 18, 20, 21], "unit": [0, 16, 20], "unitair": 18, "unread": 7, "unresolved": 10, "up": [16, 19], "updat": [0, 4, 15, 16, 20], "updated": [0, 4, 16], "updated_at": [0, 10, 14], "updating": 2, "upgrad": [16, 20], "upload": [16, 20, 22], "upload_fold": [20, 23], "uploaded": [2, 16, 20], "uploading": 16, "uploads_dir_siz": 12, "uri": 16, "url": [0, 8, 16, 18, 20, 21, 23], "urtzai": 16, "usag": [17, 20], "use": 16, "use_dark_mod": 0, "use_raw_gpx_speed": 0, "used": 16, "user": [0, 1, 2, 4, 5, 9, 10, 12, 13, 14, 15, 16, 18, 20, 21], "user_id": [0, 4], "user_nam": [5, 12, 14], "user_suspens": [0, 10], "user_unsuspens": 10, "user_warning": 10, "usernam": [0, 1, 5, 7, 10, 14, 15, 16, 17, 20], "using": 16, "util": [16, 18], "utilis": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 20, 21], "utility": 20, "uuid": [15, 16], "v0": 20, "v3": 19, "valeur": [0, 4, 8, 15, 17, 18, 20, 21, 23], "valid": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20, 23], "validat": 16, "valu": [0, 9, 10, 13, 14, 15, 16], "variabl": [16, 23], "various": 16, "vent": 18, "venv": 20, "ver": [8, 16, 20, 21], "verrouill": 18, "version": [2, 17, 18, 20, 21], "veuill": 18, "vi": [14, 18, 20], "vid": [15, 17, 20], "view": [16, 20], "virgul": 20, "virtual": 16, "virtualenv": [16, 20], "virtuel": [18, 20], "visibil": [0, 1, 15, 16, 18], "visibl": [1, 13, 18], "visual": [18, 20], "visualcrossing": 16, "visualis": 18, "vite_app_api_url": 20, "vitess": [0, 9, 15, 16, 18, 20], "voir": [4, 16, 18, 20, 21, 23], "volumin": [18, 23], "voodoopt": 16, "votr": [19, 20], "vtt": [15, 16, 18], "vu": [16, 20], "vue3": 20, "vue_app_api_url": 20, "vuex": 20, "v\u00e9lo": [16, 18], "v\u00e9rif": [8, 16, 21, 23], "v\u00e9rifi": [11, 20, 23], "walking": 11, "want": 20, "wantedby": 20, "was": [0, 4], "wat": 16, "weath": 16, "weather_ap": 20, "weather_api_key": 20, "weather_api_provid": [16, 20], "weather_end": [13, 15], "weather_provid": 2, "weather_start": [13, 15], "web": [0, 8, 19, 20, 21], "weblat": [16, 18], "websit": 8, "wed": [0, 7, 10, 14, 15], "week": [12, 16], "weekend": 16, "weekm": [0, 12], "wget": 20, "when": [2, 16], "whit": 16, "wind": 16, "with": [0, 4, 15, 16, 20], "with_following": 14, "with_gpx": [13, 15], "with_hidden_user": 14, "with_inact": 14, "with_suspended": 14, "without": [4, 15, 16], "wjgtwtqfpnprhyak5ex9pw": 1, "work": [16, 20, 23], "worker": [16, 17, 20], "workers_process": 20, "workflow": 16, "workingdirectory": 20, "workout": [0, 1, 2, 4, 8, 9, 10, 11, 12, 13, 15, 16, 21], "workout_dat": [0, 9, 13, 14, 15], "workout_id": [0, 1, 9, 10, 13, 14, 15], "workout_short_id": [1, 15], "workout_suspens": [10, 14], "workout_unsuspens": 10, "workout_visibility": 15, "workouts_count": [4, 14], "workouts_visibility": [0, 14], "writ": [0, 1, 2, 3, 4, 5, 7, 8, 10, 11, 14, 15, 21], "www": [2, 20], "x": [0, 15, 16, 20, 21], "xmgz": 16, "xml": 15, "xxxx": 20, "xzf": 20, "yarn": 20, "year": 12, "you": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20], "your": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15], "yyyy": 0, "z": [15, 20], "z2ze5qzrnmvmndejpphask": 10, "zero": 16, "zip": [0, 2, 15, 16, 18], "zoom": 15, "z\u00e9ro": [0, 18], "\u00c0": 20, "\u00e9chang": 21, "\u00e9chapp": [15, 16], "\u00e9chec": 24, "\u00e9cran": 16, "\u00e9cras": 20, "\u00e9critur": 21, "\u00e9gal": [14, 16, 17, 18, 19, 20], "\u00e9lectr": 18, "\u00e9lectron": [0, 2, 14, 17, 18, 20], "\u00e9lev": [9, 18, 20], "\u00e9mettr": 8, "\u00e9miss": 8, "\u00e9quip": [0, 6, 15, 16, 21], "\u00e9tap": 20, "\u00e9tat": [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18], "\u00e9valu": [16, 20], "\u00e9ven": 18, "\u00e9ventuel": 4, "\u00e9vit": [8, 16, 18], "\u00eatre": [0, 4, 8, 14, 15, 16, 17, 18, 19, 20, 21, 23], "\u0153uvr": 21}, "titles": ["Authentification et compte", "Commentaires", "Configuration", "Types d\u2019\u00e9quipement", "\u00c9quipements", "Demandes de suivi", "Documentation de l\u2019API", "Notifications", "OAuth2", "Records", "Signalements", "Sports", "Statistiques", "Flux de s\u00e9ances", "Utilisateurs", "S\u00e9ances", "Historique des modifications", "Interface de ligne de commande", "Fonctionnalit\u00e9s", "FitTrackee", "Installation", "OAuth 2.0", "Outils tiers", "Administrateur", "D\u00e9pannage"], "titleterms": {"0": [16, 21], "01": 16, "02": 16, "03": 16, "04": 16, "05": 16, "06": 16, "07": 16, "08": 16, "09": 16, "1": 16, "10": 16, "11": 16, "12": 16, "13": 16, "14": 16, "15": 16, "16": 16, "17": 16, "18": 16, "19": 16, "2": [16, 21], "20": 16, "2018": 16, "2019": 16, "2020": 16, "2021": 16, "2022": 16, "2023": 16, "2024": 16, "21": 16, "22": 16, "23": 16, "24": 16, "25": 16, "26": 16, "27": 16, "28": 16, "29": 16, "3": 16, "30": 16, "31": 16, "32": 16, "4": 16, "5": 16, "6": 16, "7": 16, "8": 16, "9": 16, "Les": 23, "a": [18, 20], "acces": [6, 20], "administr": [16, 18, 23], "affich": 23, "am\u00e9lior": 16, "api": [6, 20], "appliqu": 18, "authentif": 0, "bas": 17, "bord": 18, "bug": 16, "captur": 18, "cart": 23, "charg": 23, "clean": 17, "clean_arch": 17, "clean_token": 17, "command": 17, "commentair": [1, 18], "compt": [0, 18], "confidential": 18, "configur": [2, 18], "correct": 16, "corrig": 16, "courriel": 20, "creat": 17, "dan": 23, "db": 17, "demand": 5, "disponibl": 16, "diver": 16, "dock": 20, "document": [6, 16], "don": [17, 20], "drop": 17, "d\u00e9marr": 23, "d\u00e9pannag": 24, "d\u00e9pend": 20, "d\u00e9ploi": 20, "d\u00e9tail": [18, 23], "d\u00e9velopp": 20, "environ": 20, "export_arch": 17, "ferm": 16, "fichi": 23, "fittracke": [16, 19, 23], "flux": [13, 21], "fonctionnal": [16, 18], "franc": 16, "ftcli": 17, "histor": 16, "imag": 23, "import": 22, "install": [20, 22], "interact": 18, "interfac": 17, "jour": 20, "lign": 17, "lik": 18, "limit": 20, "list": 18, "mati": 19, "mineur": 16, "mis": 20, "moder": 18, "modif": 16, "m\u00e9t\u00e9o": 20, "nixos": 20, "notif": [7, 18], "nouvel": 16, "oauth": [18, 21], "oauth2": [8, 17], "outil": 22, "pag": 18, "part": 20, "point": 6, "polit": 18, "premi": 16, "principal": 20, "product": 20, "propos": 18, "pr\u00e9f\u00e9rent": 18, "pr\u00e9requ": 20, "pull": 16, "pyp": [16, 20], "record": 9, "request": 16, "ressourc": 21, "r\u00e9pertoir": 18, "scop": 21, "script": 22, "serveur": 20, "signal": 10, "sourc": 20, "sport": [11, 18], "statist": [12, 16, 18], "suiv": 5, "s\u00e9anc": [13, 15, 18, 23], "s\u00e9cur": 16, "tabl": 19, "tableau": 18, "ticket": 16, "tier": 22, "traduct": [16, 18], "tuil": 20, "typ": [3, 18], "t\u00e9l\u00e9charg": 23, "updat": 17, "upgrad": 17, "user": 17, "utilis": [14, 17, 18], "variabl": 20, "version": 16, "yunohost": 20, "\u00e9chec": 23, "\u00e9cran": 18, "\u00e9quip": [3, 4, 18]}}) \ No newline at end of file diff --git a/docs/fr/third_party_tools.html b/docs/fr/third_party_tools.html index c0fb22cce..f266617a5 100644 --- a/docs/fr/third_party_tools.html +++ b/docs/fr/third_party_tools.html @@ -215,12 +215,17 @@
  • Documentation de l’API diff --git a/docs/fr/troubleshooting/administrator.html b/docs/fr/troubleshooting/administrator.html index 3c885ed49..e3dde38d8 100644 --- a/docs/fr/troubleshooting/administrator.html +++ b/docs/fr/troubleshooting/administrator.html @@ -215,12 +215,17 @@
  • Documentation de l’API diff --git a/docs/fr/troubleshooting/index.html b/docs/fr/troubleshooting/index.html index 612c02a8a..1d99923f0 100644 --- a/docs/fr/troubleshooting/index.html +++ b/docs/fr/troubleshooting/index.html @@ -215,12 +215,17 @@
  • Documentation de l’API diff --git a/docsrc/gettext/.doctrees/api/auth.doctree b/docsrc/gettext/.doctrees/api/auth.doctree index 8f4568391..09dadc9e1 100644 Binary files a/docsrc/gettext/.doctrees/api/auth.doctree and b/docsrc/gettext/.doctrees/api/auth.doctree differ diff --git a/docsrc/gettext/.doctrees/api/comments.doctree b/docsrc/gettext/.doctrees/api/comments.doctree new file mode 100644 index 000000000..9bba988f8 Binary files /dev/null and b/docsrc/gettext/.doctrees/api/comments.doctree differ diff --git a/docsrc/gettext/.doctrees/api/follow_requests.doctree b/docsrc/gettext/.doctrees/api/follow_requests.doctree new file mode 100644 index 000000000..cafdb9b31 Binary files /dev/null and b/docsrc/gettext/.doctrees/api/follow_requests.doctree differ diff --git a/docsrc/gettext/.doctrees/api/index.doctree b/docsrc/gettext/.doctrees/api/index.doctree index 724bd7beb..adc598d89 100644 Binary files a/docsrc/gettext/.doctrees/api/index.doctree and b/docsrc/gettext/.doctrees/api/index.doctree differ diff --git a/docsrc/gettext/.doctrees/api/notifications.doctree b/docsrc/gettext/.doctrees/api/notifications.doctree new file mode 100644 index 000000000..e87f00a30 Binary files /dev/null and b/docsrc/gettext/.doctrees/api/notifications.doctree differ diff --git a/docsrc/gettext/.doctrees/api/reports.doctree b/docsrc/gettext/.doctrees/api/reports.doctree new file mode 100644 index 000000000..beb8a095d Binary files /dev/null and b/docsrc/gettext/.doctrees/api/reports.doctree differ diff --git a/docsrc/gettext/.doctrees/api/timeline.doctree b/docsrc/gettext/.doctrees/api/timeline.doctree new file mode 100644 index 000000000..f5027acce Binary files /dev/null and b/docsrc/gettext/.doctrees/api/timeline.doctree differ diff --git a/docsrc/gettext/.doctrees/api/users.doctree b/docsrc/gettext/.doctrees/api/users.doctree index 905730eff..0dfda5faf 100644 Binary files a/docsrc/gettext/.doctrees/api/users.doctree and b/docsrc/gettext/.doctrees/api/users.doctree differ diff --git a/docsrc/gettext/.doctrees/api/workouts.doctree b/docsrc/gettext/.doctrees/api/workouts.doctree index aad363c00..addd9f86a 100644 Binary files a/docsrc/gettext/.doctrees/api/workouts.doctree and b/docsrc/gettext/.doctrees/api/workouts.doctree differ diff --git a/docsrc/gettext/.doctrees/changelog.doctree b/docsrc/gettext/.doctrees/changelog.doctree index 0b8f5c69c..fc2d64d28 100644 Binary files a/docsrc/gettext/.doctrees/changelog.doctree and b/docsrc/gettext/.doctrees/changelog.doctree differ diff --git a/docsrc/gettext/.doctrees/cli.doctree b/docsrc/gettext/.doctrees/cli.doctree index e8b0b9258..c71e788a0 100644 Binary files a/docsrc/gettext/.doctrees/cli.doctree and b/docsrc/gettext/.doctrees/cli.doctree differ diff --git a/docsrc/gettext/.doctrees/environment.pickle b/docsrc/gettext/.doctrees/environment.pickle index bd36bef01..27a453a9c 100644 Binary files a/docsrc/gettext/.doctrees/environment.pickle and b/docsrc/gettext/.doctrees/environment.pickle differ diff --git a/docsrc/gettext/.doctrees/features.doctree b/docsrc/gettext/.doctrees/features.doctree index 50c976550..e24105649 100644 Binary files a/docsrc/gettext/.doctrees/features.doctree and b/docsrc/gettext/.doctrees/features.doctree differ diff --git a/docsrc/gettext/.doctrees/index.doctree b/docsrc/gettext/.doctrees/index.doctree index 3c1def1ff..dd68716c3 100644 Binary files a/docsrc/gettext/.doctrees/index.doctree and b/docsrc/gettext/.doctrees/index.doctree differ diff --git a/docsrc/gettext/.doctrees/installation.doctree b/docsrc/gettext/.doctrees/installation.doctree index a99851429..e1d30508f 100644 Binary files a/docsrc/gettext/.doctrees/installation.doctree and b/docsrc/gettext/.doctrees/installation.doctree differ diff --git a/docsrc/gettext/.doctrees/oauth.doctree b/docsrc/gettext/.doctrees/oauth.doctree index 3e702a777..da566fe44 100644 Binary files a/docsrc/gettext/.doctrees/oauth.doctree and b/docsrc/gettext/.doctrees/oauth.doctree differ diff --git a/docsrc/gettext/docs.pot b/docsrc/gettext/docs.pot index 04adb22de..28b20ff51 100644 --- a/docsrc/gettext/docs.pot +++ b/docsrc/gettext/docs.pot @@ -9,7 +9,7 @@ msgstr "" "Project-Id-Version: FitTrackee 0.8.12\n" "\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-17 19:34+0100\n" +"POT-Creation-Date: 2024-12-14 10:30+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -46,9 +46,12 @@ msgid "error on registration:" msgstr "" #: ../source/api/auth.rst:0 +#: ../source/api/comments.rst:0 #: ../source/api/configuration.rst:0 #: ../source/api/equipment_types.rst:0 #: ../source/api/equipments.rst:0 +#: ../source/api/notifications.rst:0 +#: ../source/api/reports.rst:0 #: ../source/api/sports.rst:0 #: ../source/api/users.rst:0 #: ../source/api/workouts.rst:0 @@ -76,13 +79,18 @@ msgid "``true`` if user accepted privacy policy" msgstr "" #: ../source/api/auth.rst:0 +#: ../source/api/comments.rst:0 #: ../source/api/configuration.rst:0 #: ../source/api/equipment_types.rst:0 #: ../source/api/equipments.rst:0 +#: ../source/api/follow_requests.rst:0 +#: ../source/api/notifications.rst:0 #: ../source/api/oauth2.rst:0 #: ../source/api/records.rst:0 +#: ../source/api/reports.rst:0 #: ../source/api/sports.rst:0 #: ../source/api/stats.rst:0 +#: ../source/api/timeline.rst:0 #: ../source/api/users.rst:0 #: ../source/api/workouts.rst:0 msgid "Status Codes" @@ -196,18 +204,27 @@ msgstr "" msgid "Get authenticated user info (profile, account, preferences)." msgstr "" +#: ../../:1 +msgid "Suspended user can access this endpoint." +msgstr "" + #: ../../:1 msgid "**Scope**: ``profile:read``" msgstr "" #: ../source/api/auth.rst:0 +#: ../source/api/comments.rst:0 #: ../source/api/configuration.rst:0 #: ../source/api/equipment_types.rst:0 #: ../source/api/equipments.rst:0 +#: ../source/api/follow_requests.rst:0 +#: ../source/api/notifications.rst:0 #: ../source/api/oauth2.rst:0 #: ../source/api/records.rst:0 +#: ../source/api/reports.rst:0 #: ../source/api/sports.rst:0 #: ../source/api/stats.rst:0 +#: ../source/api/timeline.rst:0 #: ../source/api/users.rst:0 #: ../source/api/workouts.rst:0 msgid "Request Headers" @@ -309,6 +326,10 @@ msgstr "" msgid "display highest ascent records and total" msgstr "" +#: ../../:1 +msgid "if ``true``, user does not appear in users directory" +msgstr "" + #: ../../:1 msgid "display distance in imperial units" msgstr "" @@ -317,6 +338,14 @@ msgstr "" msgid "language preferences" msgstr "" +#: ../../:1 +msgid "workout map visibility (``public``, ``followers_only``, ``private``)" +msgstr "" + +#: ../../:1 +msgid "if ``false``, follow requests are automatically approved" +msgstr "" + #: ../../:1 msgid "do elevation plots start at zero?" msgstr "" @@ -326,7 +355,7 @@ msgid "user time zone" msgstr "" #: ../../:1 -msgid "Display interface with dark mode if true. If null, it uses browser preferences." +msgid "Display interface with dark mode if ``true``. If ``null``, it uses browser preferences." msgstr "" #: ../../:1 @@ -337,6 +366,10 @@ msgstr "" msgid "does week start on Monday?" msgstr "" +#: ../../:1 +msgid "user workouts visibility (``public``, ``followers_only``, ``private``)" +msgstr "" + #: ../../:1 msgid "``user preferences updated``" msgstr "" @@ -409,6 +442,14 @@ msgstr "" msgid "``equipment with id is inactive``" msgstr "" +#: ../../:1 +msgid "- ``you do not have permissions, your account is suspended``" +msgstr "" + +#: ../../:1 +msgid "``you do not have permissions, your account is suspended``" +msgstr "" + #: ../../:1 msgid "``sport does not exist``" msgstr "" @@ -418,9 +459,13 @@ msgid "Reset authenticated user preferences for a given sport." msgstr "" #: ../source/api/auth.rst:0 +#: ../source/api/comments.rst:0 #: ../source/api/equipment_types.rst:0 #: ../source/api/equipments.rst:0 +#: ../source/api/follow_requests.rst:0 +#: ../source/api/notifications.rst:0 #: ../source/api/oauth2.rst:0 +#: ../source/api/reports.rst:0 #: ../source/api/sports.rst:0 #: ../source/api/stats.rst:0 #: ../source/api/users.rst:0 @@ -656,7 +701,7 @@ msgid "``completed request already exists``" msgstr "" #: ../../:1 -msgid "Download a data export archive" +msgid "Download a data export archive." msgstr "" #: ../../:1 @@ -667,7 +712,239 @@ msgstr "" msgid "``file not found``" msgstr "" +#: ../../:1 +msgid "Get blocked users by authenticated user" +msgstr "" + +#: ../../:1 +msgid "**Example requests**:" +msgstr "" + +#: ../../:1 +msgid "without parameters:" +msgstr "" + +#: ../../:1 +msgid "with parameters:" +msgstr "" + +#: ../../:1 +msgid "with blocked users:" +msgstr "" + +#: ../../:1 +msgid "no blocked users:" +msgstr "" + +#: ../source/api/auth.rst:0 +#: ../source/api/equipments.rst:0 +#: ../source/api/follow_requests.rst:0 +#: ../source/api/notifications.rst:0 +#: ../source/api/oauth2.rst:0 +#: ../source/api/reports.rst:0 +#: ../source/api/sports.rst:0 +#: ../source/api/stats.rst:0 +#: ../source/api/timeline.rst:0 +#: ../source/api/users.rst:0 +#: ../source/api/workouts.rst:0 +msgid "Query Parameters" +msgstr "" + +#: ../../:1 +msgid "page if using pagination (default: 1)" +msgstr "" + +#: ../../:1 +msgid "Get suspension if exists for authenticated user." +msgstr "" + +#: ../../:1 +msgid "suspension exists:" +msgstr "" + +#: ../../:1 +msgid "no suspension:" +msgstr "" + +#: ../../:1 +msgid "``user account is not suspended``" +msgstr "" + +#: ../../:1 +msgid "Appeal suspension for authenticated user." +msgstr "" + +#: ../../:1 +msgid "text explaining appeal" +msgstr "" + +#: ../../:1 +msgid "appeal for suspension created" +msgstr "" + +#: ../../:1 +msgid "- ``no text provided`` - ``you can appeal only once``" +msgstr "" + +#: ../../:1 +msgid "``no text provided``" +msgstr "" + +#: ../../:1 +msgid "``you can appeal only once``" +msgstr "" + +#: ../../:1 +msgid "Get sanction for authenticated user." +msgstr "" + +#: ../../:1 +msgid "suspension id" +msgstr "" + +#: ../../:1 +msgid "``no sanction found``" +msgstr "" + +#: ../../:1 +msgid "Appeal a sanction" +msgstr "" + +#: ../../:1 +msgid "sanction id" +msgstr "" + +#: ../../:1 +msgid "appeal created" +msgstr "" + +#: ../source/api/comments.rst:2 +#: ../source/features.rst:174 +msgid "Comments" +msgstr "" + +#: ../../:1 +msgid "Get workout comments." +msgstr "" + +#: ../../:1 +msgid "It returns only comments visible to authenticated user and only public comments when no authentication provided." +msgstr "" + +#: ../../:1 +msgid "**Scope**: ``workouts:read``" +msgstr "" + +#: ../../:1 +msgid "workout short id" +msgstr "" + +#: ../../:1 +msgid "OAuth 2.0 Bearer Token for comments with ``private`` and ``followers_only`` visibility" +msgstr "" + +#: ../../:1 +msgid "``workout not found``" +msgstr "" + +#: ../../:1 +msgid "``Error during comment save.``" +msgstr "" + +#: ../../:1 +msgid "Get comment." +msgstr "" + +#: ../../:1 +msgid "comment short id" +msgstr "" + +#: ../../:1 +msgid "OAuth 2.0 Bearer Token for comment with ``private`` and ``followers_only`` visibility" +msgstr "" + +#: ../../:1 +msgid "``workout comment not found``" +msgstr "" + +#: ../../:1 +msgid "Post a comment." +msgstr "" + +#: ../../:1 +msgid "**Scope**: ``workouts:write``" +msgstr "" + +#: ../../:1 +msgid "comment content" +msgstr "" + +#: ../../:1 +msgid "visibility level (``public``, ``followers_only``, ``private``)" +msgstr "" + +#: ../../:1 +msgid "``created``" +msgstr "" + +#: ../../:1 +msgid "- ``invalid payload``" +msgstr "" + +#: ../../:1 +msgid "Update comment text." +msgstr "" + +#: ../../:1 +msgid "- ``you do not have permissions`` - ``you do not have permissions, your account is suspended``" +msgstr "" + +#: ../../:1 +msgid "``you do not have permissions``" +msgstr "" + +#: ../../:1 +msgid "``comment not found``" +msgstr "" + +#: ../../:1 +msgid "Add a \"like\" to a comment." +msgstr "" + +#: ../../:1 +msgid "Remove a comment \"like\"." +msgstr "" + +#: ../../:1 +msgid "Appeal comment suspension." +msgstr "" + +#: ../../:1 +msgid "Only comment author can appeal the suspension." +msgstr "" + +#: ../../:1 +msgid "- ``no text provided`` - ``you can appeal only once`` - ``workout comment is not suspended`` - ``workout comment has no suspension``" +msgstr "" + +#: ../../:1 +msgid "``workout comment is not suspended``" +msgstr "" + +#: ../../:1 +msgid "``workout comment has no suspension``" +msgstr "" + +#: ../../:1 +msgid "Delete workout comment." +msgstr "" + +#: ../../:1 +msgid "comment deleted" +msgstr "" + #: ../source/api/configuration.rst:2 +#: ../source/features.rst:374 msgid "Configuration" msgstr "" @@ -763,10 +1040,6 @@ msgstr "" msgid "- ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - ``valid email must be provided for admin contact``" msgstr "" -#: ../../:1 -msgid "``you do not have permissions``" -msgstr "" - #: ../../:1 msgid "``error when updating configuration``" msgstr "" @@ -776,7 +1049,7 @@ msgid "health check endpoint" msgstr "" #: ../source/api/equipment_types.rst:2 -#: ../source/features.rst:279 +#: ../source/features.rst:481 msgid "Equipment Types" msgstr "" @@ -841,8 +1114,8 @@ msgid "equipment type active status" msgstr "" #: ../source/api/equipments.rst:2 -#: ../source/features.rst:185 -#: ../source/features.rst:343 +#: ../source/features.rst:320 +#: ../source/features.rst:554 msgid "Equipments" msgstr "" @@ -854,15 +1127,6 @@ msgstr "" msgid "with some query parameters (get all equipment of type \"Shoes\")" msgstr "" -#: ../source/api/equipments.rst:0 -#: ../source/api/oauth2.rst:0 -#: ../source/api/sports.rst:0 -#: ../source/api/stats.rst:0 -#: ../source/api/users.rst:0 -#: ../source/api/workouts.rst:0 -msgid "Query Parameters" -msgstr "" - #: ../../:1 msgid "Get an equipment item. Only the equipment owner can see his equipment." msgstr "" @@ -1004,4484 +1268,5271 @@ msgstr "" msgid "``you cannot delete equipment that has workouts associated with it without 'force' parameter``" msgstr "" -#: ../source/api/index.rst:4 -msgid "Endpoints:" +#: ../source/api/follow_requests.rst:2 +msgid "Follow requests" msgstr "" -#: ../source/api/index.rst:2 -msgid "API documentation" +#: ../../:1 +msgid "Get follow requests to process, received by authenticated user." msgstr "" -#: ../source/api/oauth2.rst:2 -#: ../source/cli.rst:40 -msgid "OAuth2" +#: ../../:1 +msgid "**Scope**: ``follow:read``" msgstr "" #: ../../:1 -msgid "Get OAuth2 clients (apps) for authenticated user with pagination (5 clients/page)." +msgid "without parameters" msgstr "" #: ../../:1 -msgid "This endpoint is only accessible by FitTrackee client (first-party application)." +msgid "with some query parameters" msgstr "" #: ../../:1 -msgid "without parameters:" +msgid "number of follow requests per page (default: 10, max: 50)" msgstr "" #: ../../:1 -msgid "with 'page' parameter:" +msgid "sorting order (default: ``asc``)" msgstr "" #: ../../:1 -msgid "page for pagination (default: 1)" +msgid "Accept a follow request from user." msgstr "" #: ../../:1 -msgid "Create an OAuth2 client (app) for the authenticated user." +msgid "**Scope**: ``follow:write``" msgstr "" -#: ../source/api/oauth2.rst:0 -msgid "JSON Parameters" +#: ../../:1 +msgid "user name" msgstr "" #: ../../:1 -msgid "client name" +msgid "- ``Follow request from user 'user_name' already accepted.``" msgstr "" #: ../../:1 -msgid "client URL" +msgid "``Follow request from user 'user_name' already accepted.``" msgstr "" #: ../../:1 -msgid "list of client redirect URLs (string)" +msgid "- ``user does not exist`` - ``Follow request does not exist.``" msgstr "" #: ../../:1 -msgid "client scopes" +msgid "``user does not exist``" msgstr "" #: ../../:1 -msgid "client description (optional)" +msgid "``Follow request does not exist.``" msgstr "" #: ../../:1 -msgid "Get an OAuth2 client (app) by 'client_id'." +msgid "Reject a follow request from user." msgstr "" #: ../../:1 -msgid "not found:" +msgid "- ``Follow request from user 'user_name' already rejected.``" msgstr "" #: ../../:1 -msgid "OAuth2 client client_id" +msgid "``Follow request from user 'user_name' already rejected.``" msgstr "" -#: ../../:1 -msgid "``OAuth2 client not found``" +#: ../source/api/index.rst:4 +msgid "Endpoints:" msgstr "" -#: ../../:1 -msgid "Get an OAuth2 client (app) by id (integer value)." +#: ../source/api/index.rst:2 +msgid "API documentation" msgstr "" -#: ../../:1 -msgid "OAuth2 client id" +#: ../source/api/notifications.rst:2 +#: ../source/features.rst:196 +#: ../source/features.rst:564 +msgid "Notifications" msgstr "" #: ../../:1 -msgid "Delete an OAuth2 client (app)." +msgid "Get authenticated user notifications." msgstr "" #: ../../:1 -msgid "OAuth2 client deleted" +msgid "**Scope**: ``notifications:read``" msgstr "" #: ../../:1 -msgid "Revoke all tokens associated to an OAuth2 client (app)." +msgid "with some query parameters:" msgstr "" #: ../../:1 -msgid "Authorize an OAuth2 client (app). If successful, it redirects to the client callback URL with the code to issue a token." +msgid "returning at least one notification:" msgstr "" #: ../../:1 -msgid "OAuth2 client 'client_id'" +msgid "returning no notifications" msgstr "" #: ../../:1 -msgid "client response type (only 'code' is supported by FitTrackee)" +msgid "sorting order: ``asc``, ``desc`` (default: ``desc``)" msgstr "" #: ../../:1 -msgid "OAuth2 client scopes" +msgid "notification read status (``read``, ``unread``)" msgstr "" #: ../../:1 -msgid "confirmation (must be ``true``)" +msgid "Update authenticated user notification read status." msgstr "" #: ../../:1 -msgid "unique value to prevent cross-site request forgery (not mandatory but recommended)" +msgid "**Scope**: ``notifications:write``" msgstr "" #: ../../:1 -msgid "string generated from a code verifier (for PKCE, not mandatory but recommended)" +msgid "notification id" msgstr "" #: ../../:1 -msgid "method used to create challenge, for instance \"S256\" (mandatory if `code_challenge` provided)" +msgid "notification read status" msgstr "" #: ../../:1 -msgid "- ``invalid payload`` - errors returned by Authlib library" +msgid "- ``notification not found``" msgstr "" #: ../../:1 -msgid "errors returned by Authlib library" +msgid "``notification not found``" msgstr "" #: ../../:1 -msgid "Issue or refresh token for a given OAuth2 client (app)." +msgid "- ``error, please try again or contact the administrator``" msgstr "" #: ../../:1 -msgid "OAuth2 client secret" +msgid "Get if unread notifications exist for authenticated user." msgstr "" #: ../../:1 -msgid "OAuth2 client grant type (only 'authorization_code' (for token issue) and 'refresh_token' (for token refresh) are supported by FitTrackee)" +msgid "Mark all authenticated user notifications as read." msgstr "" #: ../../:1 -msgid "code generated after authorizing the client (for token issue)" +msgid "notification type (optional)" msgstr "" #: ../../:1 -msgid "code verifier (for token issue with PKCE, not mandatory)" +msgid "Get types of notifications received by authenticated user." msgstr "" #: ../../:1 -msgid "refresh token (for token refresh)" +msgid "with query parameter:" msgstr "" -#: ../../:1 -msgid "Revoke a token for a given OAuth2 client (app)." +#: ../source/api/oauth2.rst:2 +#: ../source/cli.rst:40 +msgid "OAuth2" msgstr "" #: ../../:1 -msgid "access token to revoke" +msgid "Get OAuth2 clients (apps) for authenticated user with pagination (5 clients/page)." msgstr "" -#: ../source/api/records.rst:2 -msgid "Records" +#: ../../:1 +msgid "This endpoint is only accessible by FitTrackee client (first-party application)." msgstr "" #: ../../:1 -msgid "Get all records for authenticated user." +msgid "with 'page' parameter:" msgstr "" #: ../../:1 -msgid "Following types of records are available:" +msgid "page for pagination (default: 1)" msgstr "" #: ../../:1 -msgid "average speed (record_type: ``AS``)" +msgid "Create an OAuth2 client (app) for the authenticated user." msgstr "" -#: ../../:1 -msgid "farthest distance (record_type: ``FD``)" +#: ../source/api/oauth2.rst:0 +msgid "JSON Parameters" msgstr "" #: ../../:1 -msgid "highest ascent (record_type: ``HA``)" +msgid "client name" msgstr "" #: ../../:1 -msgid "longest duration (record_type: ``LD``)" +msgid "client URL" msgstr "" #: ../../:1 -msgid "maximum speed (record_type: ``MS``)" +msgid "list of client redirect URLs (string)" msgstr "" #: ../../:1 -msgid "**Scope**: ``workouts:read``" +msgid "client scopes" msgstr "" #: ../../:1 -msgid "returning records" +msgid "client description (optional)" msgstr "" #: ../../:1 -msgid "no records" +msgid "Get an OAuth2 client (app) by 'client_id'." msgstr "" -#: ../source/api/sports.rst:2 -#: ../source/features.rst:284 -msgid "Sports" +#: ../../:1 +msgid "not found:" msgstr "" #: ../../:1 -msgid "Get all sports" +msgid "OAuth2 client client_id" msgstr "" #: ../../:1 -msgid "for non admin user:" +msgid "``OAuth2 client not found``" msgstr "" #: ../../:1 -msgid "for admin user and check_workouts=true:" +msgid "Get an OAuth2 client (app) by id (integer value)." msgstr "" #: ../../:1 -msgid "check if sport has workouts" +msgid "OAuth2 client id" msgstr "" #: ../../:1 -msgid "Get a sport" +msgid "Delete an OAuth2 client (app)." msgstr "" #: ../../:1 -msgid "success for non admin user:" +msgid "OAuth2 client deleted" msgstr "" #: ../../:1 -msgid "sport not found:" +msgid "Revoke all tokens associated to an OAuth2 client (app)." msgstr "" #: ../../:1 -msgid "``sport not found``" +msgid "Authorize an OAuth2 client (app). If successful, it redirects to the client callback URL with the code to issue a token." msgstr "" #: ../../:1 -msgid "Update a sport." +msgid "OAuth2 client 'client_id'" msgstr "" #: ../../:1 -msgid "**Scope**: ``workouts:write``" +msgid "client response type (only 'code' is supported by FitTrackee)" msgstr "" #: ../../:1 -msgid "sport active status" +msgid "OAuth2 client scopes" msgstr "" #: ../../:1 -msgid "sport updated" +msgid "confirmation (must be ``true``)" msgstr "" -#: ../source/api/stats.rst:2 -#: ../source/features.rst:335 -msgid "Statistics" +#: ../../:1 +msgid "unique value to prevent cross-site request forgery (not mandatory but recommended)" msgstr "" #: ../../:1 -msgid "Get workouts statistics for a user by time. For now only authenticated users can access their statistics." +msgid "string generated from a code verifier (for PKCE, not mandatory but recommended)" msgstr "" #: ../../:1 -msgid "**Example requests**:" +msgid "method used to create challenge, for instance \"S256\" (mandatory if `code_challenge` provided)" msgstr "" #: ../../:1 -msgid "with parameters:" +msgid "- ``invalid payload`` - errors returned by Authlib library" msgstr "" #: ../../:1 -msgid "success for total:" +msgid "errors returned by Authlib library" msgstr "" #: ../../:1 -msgid "success for average:" +msgid "Issue or refresh token for a given OAuth2 client (app)." msgstr "" #: ../../:1 -msgid "no workouts:" +msgid "OAuth2 client secret" msgstr "" #: ../../:1 -msgid "username" +msgid "OAuth2 client grant type (only 'authorization_code' (for token issue) and 'refresh_token' (for token refresh) are supported by FitTrackee)" msgstr "" #: ../../:1 -msgid "start date (format: ``%Y-%m-%d``)" +msgid "code generated after authorizing the client (for token issue)" msgstr "" #: ../../:1 -msgid "end date (format: ``%Y-%m-%d``)" +msgid "code verifier (for token issue with PKCE, not mandatory)" msgstr "" #: ../../:1 -msgid "time frame: - ``week``: week starting Sunday - ``weekm``: week starting Monday - ``month``: month - ``year``: year (default)" +msgid "refresh token (for token refresh)" msgstr "" #: ../../:1 -msgid "time frame:" +msgid "Revoke a token for a given OAuth2 client (app)." msgstr "" #: ../../:1 -msgid "``week``: week starting Sunday" +msgid "access token to revoke" msgstr "" -#: ../../:1 -msgid "``weekm``: week starting Monday" +#: ../source/api/records.rst:2 +msgid "Records" msgstr "" #: ../../:1 -msgid "``month``: month" +msgid "Get all records for authenticated user." msgstr "" #: ../../:1 -msgid "``year``: year (default)" +msgid "Following types of records are available:" msgstr "" #: ../../:1 -msgid "stats type: - ``total``: calculating totals - ``average``: calculating averages" +msgid "average speed (record_type: ``AS``)" msgstr "" #: ../../:1 -msgid "stats type:" +msgid "farthest distance (record_type: ``FD``)" msgstr "" #: ../../:1 -msgid "``total``: calculating totals" +msgid "highest ascent (record_type: ``HA``)" msgstr "" #: ../../:1 -msgid "``average``: calculating averages" +msgid "longest duration (record_type: ``LD``)" msgstr "" #: ../../:1 -msgid "- ``invalid stats type`` - ``invalid time period``" +msgid "maximum speed (record_type: ``MS``)" msgstr "" #: ../../:1 -msgid "``invalid stats type``" +msgid "returning records" msgstr "" #: ../../:1 -msgid "``invalid time period``" +msgid "no records" msgstr "" -#: ../../:1 -msgid "``user does not exist``" +#: ../source/api/reports.rst:2 +msgid "Reports" msgstr "" #: ../../:1 -msgid "Get workouts statistics for a user by sport. For now only authenticated users can access their statistics." +msgid "Get reports." msgstr "" #: ../../:1 -msgid "without parameters (get stats for all sports with workouts):" +msgid "**Scope**: ``reports:read``" msgstr "" #: ../../:1 -msgid "with sport id:" +msgid "**Minimum role**: Moderator" msgstr "" #: ../../:1 -msgid "success for all sports:" +msgid "returning at least one report:" msgstr "" #: ../../:1 -msgid "success for a given sport:" +msgid "returning no reports" msgstr "" #: ../../:1 -msgid "sport id (not mandatory). If not provided, statistics for all sports are returned." +msgid "reported content type (``comment``, ``user`` or ``workout``)" msgstr "" #: ../../:1 -msgid "- ``invalid stats type``" +msgid "sorting criteria: ``created_at`` or ``updated_at``" msgstr "" #: ../../:1 -msgid "- ``user does not exist`` - ``sport does not exist``" +msgid "reporter username" msgstr "" #: ../../:1 -msgid "Get all application statistics." +msgid "filter on report status" msgstr "" -#: ../source/api/users.rst:2 -#: ../source/cli.rst:61 -#: ../source/features.rst:266 -msgid "Users" +#: ../../:1 +msgid "- ``invalid payload`` - ``invalid 'order_by'``" msgstr "" #: ../../:1 -msgid "Get all users (regardless their account status), if authenticated user has admin rights." +msgid "``invalid 'order_by'``" msgstr "" #: ../../:1 -msgid "It returns user preferences only for authenticated user." +msgid "Get report." msgstr "" #: ../../:1 -msgid "**Scope**: ``users:read``" +msgid "- ``report not found``" msgstr "" #: ../../:1 -msgid "with some query parameters:" +msgid "``report not found``" msgstr "" #: ../../:1 -msgid "page if using pagination (default: 1)" +msgid "Get if unresolved reports exist." msgstr "" #: ../../:1 -msgid "number of users per page (default: 10, max: 50)" +msgid "- ``you do not have permissions``" msgstr "" #: ../../:1 -msgid "query on user name" +msgid "Create a report." msgstr "" #: ../../:1 -msgid "sorting order: ``asc``, ``desc`` (default: ``asc``)" +msgid "**Scope**: ``reports:write``" msgstr "" #: ../../:1 -msgid "sorting criteria: ``username``, ``created_at``, ``workouts_count``, ``admin``, ``is_active`` (default: ``username``)" +msgid "note describing report" msgstr "" #: ../../:1 -msgid "Get single user details. Only user with admin rights can get other users details." +msgid "id of content reported" msgstr "" #: ../../:1 -msgid "user name" +msgid "type of content reported (``comment``, ``workout`` or ``user``)" msgstr "" #: ../../:1 -msgid "- ``user does not exist``" +msgid "report created" msgstr "" #: ../../:1 -msgid "get user picture" +msgid "- ``invalid payload`` - ``users can not report their own comment`` - ``users can not report their own profile`` - ``users can not report their own workout``" msgstr "" #: ../../:1 -msgid "- ``user does not exist`` - ``No picture.``" +msgid "``users can not report their own comment``" msgstr "" #: ../../:1 -msgid "``No picture.``" +msgid "``users can not report their own profile``" msgstr "" #: ../../:1 -msgid "Update user account." +msgid "``users can not report their own workout``" msgstr "" #: ../../:1 -msgid "add/remove admin rights (regardless user account status)" +msgid "- ``comment not found`` - ``user not found`` - ``workout not found``" msgstr "" #: ../../:1 -msgid "reset password (and send email to update user password, if sending enabled)" +msgid "``user not found``" msgstr "" #: ../../:1 -msgid "update user email (and send email to new user email, if sending enabled)" +msgid "Update report." msgstr "" #: ../../:1 -msgid "activate account for an inactive user" +msgid "**Example response** (report on user profile):" msgstr "" #: ../../:1 -msgid "Only user with admin rights can modify another user." +msgid "report id" msgstr "" #: ../../:1 -msgid "**Scope**: ``users:write``" +msgid "report comment (mandatory)" msgstr "" #: ../../:1 -msgid "activate user account" +msgid "report status" msgstr "" #: ../../:1 -msgid "does the user have administrator rights" +msgid "Create report action." msgstr "" #: ../../:1 -msgid "new user email" +msgid "action type (expected value: ``user_suspension``, ``user_unsuspension``, ``user_warning``, ``comment_suspension``, ``comment_unsuspension``, ``workout_suspension``, ``workout_unsuspension``)" msgstr "" #: ../../:1 -msgid "reset user password" +msgid "id of comment affected by action (type: ``comment_suspension``, ``comment_suspension``)" msgstr "" #: ../../:1 -msgid "- ``invalid payload`` - ``valid email must be provided`` - ``new email must be different than current email``" +msgid "text explaining the reason for the action" msgstr "" #: ../../:1 -msgid "``valid email must be provided``" +msgid "username of user affected by action (type: ``user_suspension``, ``user_unsuspension``, ``user_warning``)" msgstr "" #: ../../:1 -msgid "``new email must be different than current email``" +msgid "id of workout affected by action (type: ``workout_suspension``, ``workout_unsuspension``)" msgstr "" #: ../../:1 -msgid "Delete a user account." +msgid "- ``invalid payload`` - ``invalid 'action_type'``" msgstr "" #: ../../:1 -msgid "A user can only delete his own account." +msgid "``invalid 'action_type'``" msgstr "" #: ../../:1 -msgid "An admin can delete all accounts except his account if he's the only one admin." +msgid "- ``Error during report save.``" msgstr "" #: ../../:1 -msgid "user account deleted" +msgid "``Error during report save.``" msgstr "" #: ../../:1 -msgid "- ``you do not have permissions`` - ``you can not delete your account, no other user has admin rights``" +msgid "Process appeal." msgstr "" #: ../../:1 -msgid "``you can not delete your account, no other user has admin rights``" +msgid "**Scope**: ``users:write``" msgstr "" -#: ../source/api/workouts.rst:2 -#: ../source/features.rst:14 -msgid "Workouts" +#: ../../:1 +msgid "appeal id" msgstr "" #: ../../:1 -msgid "Get workouts for the authenticated user." +msgid "``true`` if appeal is approved, ``false`` if rejected" msgstr "" #: ../../:1 -msgid "returning at least one workout:" +msgid "text explaining why the appeal was approved or rejected" msgstr "" #: ../../:1 -msgid "returning no workouts" +msgid "- ``invalid payload`` - ``comment already reactivated`` - ``user account has already been reactivated`` - ``workout already reactivated``" msgstr "" #: ../../:1 -msgid "number of workouts per page (default: 5, max: 100)" +msgid "``comment already reactivated``" msgstr "" #: ../../:1 -msgid "any part (or all) of the workout title; title matching is case-insensitive" +msgid "``user account has already been reactivated``" msgstr "" #: ../../:1 -msgid "minimal distance" +msgid "``workout already reactivated``" msgstr "" #: ../../:1 -msgid "maximal distance" +msgid "- ``appeal not found``" msgstr "" #: ../../:1 -msgid "minimal duration (format: ``%H:%M``)" +msgid "``appeal not found``" msgstr "" -#: ../../:1 -msgid "maximal distance (format: ``%H:%M``)" +#: ../source/api/sports.rst:2 +#: ../source/features.rst:17 +#: ../source/features.rst:490 +msgid "Sports" msgstr "" #: ../../:1 -msgid "minimal average speed" +msgid "Get all sports" msgstr "" #: ../../:1 -msgid "maximal average speed" +msgid "for non admin user:" msgstr "" #: ../../:1 -msgid "minimal max. speed" +msgid "for admin user and check_workouts=true:" msgstr "" #: ../../:1 -msgid "maximal max. speed" +msgid "check if sport has workouts" msgstr "" #: ../../:1 -msgid "sorting order: ``asc``, ``desc`` (default: ``desc``)" +msgid "Get a sport" msgstr "" #: ../../:1 -msgid "sorting criteria: ``ave_speed``, ``distance``, ``duration``, ``workout_date`` (default: ``workout_date``)" +msgid "success for non admin user:" msgstr "" #: ../../:1 -msgid "equipment id (if 'none', only workouts without equipments will be returned)" +msgid "sport not found:" msgstr "" #: ../../:1 -msgid "any part (or all) of the workout notes, notes matching is case-insensitive" +msgid "``sport not found``" msgstr "" #: ../../:1 -msgid "any part of the workout description; description matching is case-insensitive" +msgid "Update a sport." msgstr "" #: ../../:1 -msgid "Get a workout." +msgid "sport active status" msgstr "" #: ../../:1 -msgid "workout not found:" +msgid "sport updated" msgstr "" -#: ../../:1 -msgid "workout short id" +#: ../source/api/stats.rst:2 +#: ../source/features.rst:233 +#: ../source/features.rst:545 +msgid "Statistics" msgstr "" #: ../../:1 -msgid "``workout not found``" +msgid "Get workouts statistics for a user by time. For now only authenticated users can access their statistics." msgstr "" #: ../../:1 -msgid "Get gpx file for a workout displayed on map with Leaflet." +msgid "success for total:" msgstr "" #: ../../:1 -msgid "- ``workout not found`` - ``no gpx file for this workout``" +msgid "success for average:" msgstr "" #: ../../:1 -msgid "``no gpx file for this workout``" +msgid "no workouts:" msgstr "" #: ../../:1 -msgid "Get chart data from a workout gpx file, to display it with Chart.js." +msgid "username" msgstr "" #: ../../:1 -msgid "segment id" +msgid "start date (format: ``%Y-%m-%d``)" msgstr "" #: ../../:1 -msgid "Get gpx file for a workout segment displayed on map with Leaflet." +msgid "end date (format: ``%Y-%m-%d``)" msgstr "" #: ../../:1 -msgid "Get map image for workouts with gpx." +msgid "time frame: - ``week``: week starting Sunday - ``weekm``: week starting Monday - ``month``: month - ``year``: year (default)" msgstr "" #: ../../:1 -msgid "workout map id" +msgid "time frame:" msgstr "" #: ../../:1 -msgid "``map does not exist``" +msgid "``week``: week starting Sunday" msgstr "" #: ../../:1 -msgid "Get map tile from tile server." +msgid "``weekm``: week starting Monday" msgstr "" #: ../../:1 -msgid "subdomain" +msgid "``month``: month" msgstr "" #: ../../:1 -msgid "zoom" +msgid "``year``: year (default)" msgstr "" #: ../../:1 -msgid "index of the tile along the map's x axis" +msgid "stats type: - ``total``: calculating totals - ``average``: calculating averages" msgstr "" #: ../../:1 -msgid "index of the tile along the map's y axis" +msgid "stats type:" msgstr "" #: ../../:1 -msgid "Status codes are status codes returned by tile server" +msgid "``total``: calculating totals" msgstr "" #: ../../:1 -msgid "Download gpx file." +msgid "``average``: calculating averages" msgstr "" #: ../../:1 -msgid "- ``workout not found`` - ``no gpx file for workout``" +msgid "- ``invalid stats type`` - ``invalid time period``" msgstr "" #: ../../:1 -msgid "``no gpx file for workout``" +msgid "``invalid stats type``" msgstr "" #: ../../:1 -msgid "Post a workout with a gpx file." +msgid "``invalid time period``" msgstr "" #: ../../:1 -msgid "gpx file (allowed extensions: .gpx, .zip)" +msgid "Get workouts statistics for a user by sport. For now only authenticated users can access their statistics." msgstr "" #: ../../:1 -msgid "sport id, equipment id, description, title and notes, for example: ``{\"sport_id\": 1, \"notes\": \"\", \"title\": \"\", \"description\": \"\", \"equipment_ids\": []}``. Double quotes in notes, description and title must be escaped. The maximum length is 500 characters for notes, 10000 characters for description and 255 for title. Otherwise, they will be truncated. When description and title are provided, they replace the description and title from gpx file. For `equipment_ids`, the id of the equipment to associate with this workout. **Note**: for now only one equipment can be associated. If not provided and default equipment exists for sport, default equipment will be associated. Notes, description, title and equipment ids are not mandatory." +msgid "without parameters (get stats for all sports with workouts):" msgstr "" #: ../../:1 -msgid "sport id, equipment id, description, title and notes, for example: ``{\"sport_id\": 1, \"notes\": \"\", \"title\": \"\", \"description\": \"\", \"equipment_ids\": []}``. Double quotes in notes, description and title must be escaped." +msgid "with sport id:" msgstr "" #: ../../:1 -msgid "The maximum length is 500 characters for notes, 10000 characters for description and 255 for title. Otherwise, they will be truncated. When description and title are provided, they replace the description and title from gpx file." +msgid "success for all sports:" msgstr "" #: ../../:1 -msgid "For `equipment_ids`, the id of the equipment to associate with this workout. **Note**: for now only one equipment can be associated. If not provided and default equipment exists for sport, default equipment will be associated." +msgid "success for a given sport:" msgstr "" #: ../../:1 -msgid "Notes, description, title and equipment ids are not mandatory." +msgid "sport id (not mandatory). If not provided, statistics for all sports are returned." msgstr "" #: ../../:1 -msgid "workout created" +msgid "- ``invalid stats type``" msgstr "" #: ../../:1 -msgid "- ``invalid payload`` - ``no file part`` - ``no selected file`` - ``file extension not allowed`` - ``equipment_ids must be an array of strings`` - ``only one equipment can be added`` - ``equipment with id does not exist`` - ``invalid equipment id for sport`` - ``equipment with id is inactive``" +msgid "- ``user does not exist`` - ``sport does not exist``" msgstr "" #: ../../:1 -msgid "Post a workout without gpx file." +msgid "Get all application statistics." +msgstr "" + +#: ../source/api/timeline.rst:2 +msgid "Timeline" msgstr "" #: ../../:1 -msgid "workout ascent (not mandatory, must be provided with descent)" +msgid "Get workouts visible to authenticated user." msgstr "" #: ../../:1 -msgid "workout descent (not mandatory, must be provided with ascent)" +msgid "returning at least one workout:" msgstr "" #: ../../:1 -msgid "workout description (not mandatory, max length: 10000 characters, otherwise it will be truncated)" +msgid "returning no workouts" +msgstr "" + +#: ../source/api/users.rst:2 +#: ../source/cli.rst:61 +#: ../source/features.rst:158 +#: ../source/features.rst:411 +msgid "Users" msgstr "" #: ../../:1 -msgid "workout distance in km" +msgid "Get all users. If authenticated user has admin rights, users email is returned." msgstr "" #: ../../:1 -msgid "workout duration in seconds" +msgid "It returns user preferences only for authenticated user." msgstr "" #: ../../:1 -msgid "the id of the equipment to associate with this workout. **Note**: for now only one equipment can be associated. If not provided and default equipment exists for sport, default equipment will be associated." +msgid "**Scope**: ``users:read``" msgstr "" #: ../../:1 -msgid "notes (not mandatory, max length: 500 characters, otherwise they will be truncated)" +msgid "number of users per page (default: 10, max: 50)" msgstr "" #: ../../:1 -msgid "workout sport id" +msgid "query on user name" msgstr "" #: ../../:1 -msgid "workout title (not mandatory, max length: 255 characters, otherwise it will be truncated)" +msgid "sorting order: ``asc``, ``desc`` (default: ``asc``)" msgstr "" #: ../../:1 -msgid "workout date, in user timezone (format: ``%Y-%m-%d %H:%M``)" +msgid "sorting criteria: ``username``, ``created_at``, ``workouts_count``, ``role``, ``is_active`` (default: ``username``)" msgstr "" #: ../../:1 -msgid "- ``invalid payload`` - ``equipment_ids must be an array of strings`` - ``only one equipment can be added`` - ``equipment with id does not exist`` - ``invalid equipment id for sport`` - ``equipment with id is inactive``" +msgid "returns hidden users followed by user if true" msgstr "" #: ../../:1 -msgid "Update a workout." +msgid "returns hidden users if ``true`` (only if authenticated user has administration rights - for users administration)" msgstr "" #: ../../:1 -msgid "workout ascent (only for workout without gpx, must be provided with descent)" +msgid "returns inactive users if ``true`` (only if authenticated user has administration rights - for users administration)" msgstr "" #: ../../:1 -msgid "workout descent (only for workout without gpx, must be provided with ascent)" +msgid "returns suspended users if ``true`` (only if authenticated user has administration rights - for users administration)" msgstr "" #: ../../:1 -msgid "workout description (max length: 10000 characters, otherwise it will be truncated)" +msgid "Get single user details. If a user is authenticated, it returns relationships. If authenticated user has admin rights, user email is returned." msgstr "" #: ../../:1 -msgid "workout distance in km (only for workout without gpx)" +msgid "**Scope**: ``users:read`` for Oauth 2.0 client" msgstr "" #: ../../:1 -msgid "workout duration in seconds (only for workout without gpx)" +msgid "when a user is authenticated:" msgstr "" #: ../../:1 -msgid "notes (max length: 500 characters, otherwise they will be truncated)" +msgid "when no authentication:" msgstr "" #: ../../:1 -msgid "workout title (max length: 255 characters, otherwise it will be truncated)" +msgid "OAuth 2.0 Bearer Token if user is authenticated" msgstr "" #: ../../:1 -msgid "the id of the equipment to associate with this workout (any existing equipment for this workout will be replaced). **Note**: for now only one equipment can be associated. If an empty array, equipment for this workout will be removed." +msgid "- ``user does not exist``" msgstr "" #: ../../:1 -msgid "workout date in user timezone (format: ``%Y-%m-%d %H:%M``) (only for workout without gpx)" +msgid "get user picture" msgstr "" #: ../../:1 -msgid "workout updated" +msgid "- ``user does not exist`` - ``No picture.``" msgstr "" #: ../../:1 -msgid "Delete a workout." +msgid "``No picture.``" msgstr "" #: ../../:1 -msgid "workout deleted" +msgid "Update user account." msgstr "" -#: ../source/changelog.md:1 -msgid "Change log" +#: ../../:1 +msgid "add/remove admin rights (regardless user account status)" msgstr "" -#: ../source/changelog.md:3 -msgid "Version 0.8.12 (2024/11/17)" +#: ../../:1 +msgid "reset password (and send email to update user password, if sending enabled)" msgstr "" -#: ../source/changelog.md:5 -#: ../source/changelog.md:105 -#: ../source/changelog.md:157 -#: ../source/changelog.md:199 -#: ../source/changelog.md:276 -#: ../source/changelog.md:358 -#: ../source/changelog.md:423 -#: ../source/changelog.md:473 -#: ../source/changelog.md:515 -#: ../source/changelog.md:546 -#: ../source/changelog.md:581 -#: ../source/changelog.md:622 -#: ../source/changelog.md:659 -#: ../source/changelog.md:693 -#: ../source/changelog.md:728 -#: ../source/changelog.md:763 -#: ../source/changelog.md:770 -#: ../source/changelog.md:804 -#: ../source/changelog.md:816 -#: ../source/changelog.md:848 -#: ../source/changelog.md:865 -#: ../source/changelog.md:906 -#: ../source/changelog.md:1017 -#: ../source/changelog.md:1067 -#: ../source/changelog.md:1113 -#: ../source/changelog.md:1139 -#: ../source/changelog.md:1237 -#: ../source/changelog.md:1265 -#: ../source/changelog.md:1276 -#: ../source/changelog.md:1303 -#: ../source/changelog.md:1324 -#: ../source/changelog.md:1342 -#: ../source/changelog.md:1358 -#: ../source/changelog.md:1379 -#: ../source/changelog.md:1401 -#: ../source/changelog.md:1408 -#: ../source/changelog.md:1428 -#: ../source/changelog.md:1450 -#: ../source/changelog.md:1468 -#: ../source/changelog.md:1503 -#: ../source/changelog.md:1514 -#: ../source/changelog.md:1525 -#: ../source/changelog.md:1537 -#: ../source/changelog.md:1557 -#: ../source/changelog.md:1563 -#: ../source/changelog.md:1615 -#: ../source/changelog.md:1639 -#: ../source/changelog.md:1650 -#: ../source/changelog.md:1661 -#: ../source/changelog.md:1706 -#: ../source/changelog.md:1739 -#: ../source/changelog.md:1751 -#: ../source/changelog.md:1762 -#: ../source/changelog.md:1778 -#: ../source/changelog.md:1791 -#: ../source/changelog.md:1803 -#: ../source/changelog.md:1824 -#: ../source/changelog.md:1902 -#: ../source/changelog.md:1919 -#: ../source/changelog.md:1930 -#: ../source/changelog.md:1950 -#: ../source/changelog.md:1985 -msgid "Bugs Fixed" +#: ../../:1 +msgid "update user email (and send email to new user email, if sending enabled)" msgstr "" -#: ../source/changelog.md:7 -msgid "#652 - User can not login on new installation" +#: ../../:1 +msgid "activate account for an inactive user" msgstr "" -#: ../source/changelog.md:9 -#: ../source/changelog.md:41 -#: ../source/changelog.md:82 -#: ../source/changelog.md:136 -#: ../source/changelog.md:255 -#: ../source/changelog.md:301 -#: ../source/changelog.md:346 -#: ../source/changelog.md:406 -#: ../source/changelog.md:450 -#: ../source/changelog.md:499 -#: ../source/changelog.md:566 -#: ../source/changelog.md:604 -#: ../source/changelog.md:642 -#: ../source/changelog.md:702 -#: ../source/changelog.md:738 -#: ../source/changelog.md:779 -#: ../source/changelog.md:809 -#: ../source/changelog.md:853 -#: ../source/changelog.md:924 -#: ../source/changelog.md:971 -#: ../source/changelog.md:1092 -#: ../source/changelog.md:1165 -#: ../source/changelog.md:1245 -#: ../source/changelog.md:1258 -#: ../source/changelog.md:1364 -#: ../source/changelog.md:1456 -#: ../source/changelog.md:1477 -#: ../source/changelog.md:1585 -#: ../source/changelog.md:1601 -#: ../source/changelog.md:1710 -#: ../source/changelog.md:1766 -#: ../source/changelog.md:1807 -#: ../source/changelog.md:1833 -#: ../source/changelog.md:1888 -#: ../source/changelog.md:1954 -#: ../source/changelog.md:1969 -msgid "Misc" +#: ../../:1 +msgid "deactivate account after report." msgstr "" -#: ../source/changelog.md:11 -msgid "PR#651 - Tests - add databases to parallelize more tests" +#: ../../:1 +msgid "**Minimum role**: Administrator" msgstr "" -#: ../source/changelog.md:15 -msgid "Version 0.8.11 (2024/10/30)" +#: ../../:1 +msgid "(de-)activate user account" msgstr "" -#: ../source/changelog.md:17 -msgid "FitTrackee is now available for Python 3.13. Python 3.8 is no longer supported, the minimum version is now Python 3.9." +#: ../../:1 +msgid "user role (``user``, ``admin``, ``moderator``). ``owner`` can only be set via **CLI**." msgstr "" -#: ../source/changelog.md:20 -#: ../source/changelog.md:60 -#: ../source/changelog.md:110 -#: ../source/changelog.md:161 -#: ../source/changelog.md:203 -#: ../source/changelog.md:233 -#: ../source/changelog.md:280 -#: ../source/changelog.md:325 -#: ../source/changelog.md:362 -#: ../source/changelog.md:387 -#: ../source/changelog.md:429 -#: ../source/changelog.md:479 -#: ../source/changelog.md:519 -#: ../source/changelog.md:550 -#: ../source/changelog.md:588 -#: ../source/changelog.md:626 -#: ../source/changelog.md:663 -#: ../source/changelog.md:698 -#: ../source/changelog.md:733 -#: ../source/changelog.md:774 -#: ../source/changelog.md:822 -#: ../source/changelog.md:871 -#: ../source/changelog.md:912 -#: ../source/changelog.md:956 -#: ../source/changelog.md:993 -#: ../source/changelog.md:1022 -#: ../source/changelog.md:1072 -#: ../source/changelog.md:1117 -#: ../source/changelog.md:1146 -#: ../source/changelog.md:1161 -#: ../source/changelog.md:1178 -#: ../source/changelog.md:1198 -#: ../source/changelog.md:1213 -#: ../source/changelog.md:1226 -#: ../source/changelog.md:1241 -#: ../source/changelog.md:1254 -#: ../source/changelog.md:1269 -#: ../source/changelog.md:1283 -#: ../source/changelog.md:1308 -#: ../source/changelog.md:1317 -#: ../source/changelog.md:1328 -#: ../source/changelog.md:1347 -#: ../source/changelog.md:1433 -#: ../source/features.rst:289 -msgid "Translations" +#: ../../:1 +msgid "new user email" msgstr "" -#: ../source/changelog.md:22 -msgid "PR#640 - Translations update (Basque)" +#: ../../:1 +msgid "reset user password" msgstr "" -#: ../source/changelog.md:23 -msgid "PR#645 - Translations update (Russian, Polish)" +#: ../../:1 +msgid "- ``invalid payload`` - ``invalid role`` - ``valid email must be provided`` - ``new email must be different than current email``" msgstr "" -#: ../source/changelog.md:25 -#: ../source/changelog.md:66 -#: ../source/changelog.md:121 -#: ../source/changelog.md:169 -#: ../source/changelog.md:209 -#: ../source/changelog.md:241 -#: ../source/changelog.md:287 -#: ../source/changelog.md:332 -#: ../source/changelog.md:366 -#: ../source/changelog.md:393 -#: ../source/changelog.md:437 -#: ../source/changelog.md:487 -#: ../source/changelog.md:524 -#: ../source/changelog.md:554 -#: ../source/changelog.md:593 -#: ../source/changelog.md:631 -#: ../source/changelog.md:668 -#: ../source/changelog.md:707 -#: ../source/changelog.md:746 -#: ../source/changelog.md:787 -#: ../source/changelog.md:829 -#: ../source/changelog.md:878 -#: ../source/changelog.md:930 -#: ../source/changelog.md:977 -#: ../source/changelog.md:998 -#: ../source/changelog.md:1030 -#: ../source/changelog.md:1082 -msgid "Translation status:" +#: ../../:1 +msgid "``invalid role``" msgstr "" -#: ../source/changelog.md:26 -#: ../source/changelog.md:122 -#: ../source/changelog.md:333 -#: ../source/changelog.md:367 -#: ../source/changelog.md:525 -#: ../source/changelog.md:555 -msgid "Basque: 100%" +#: ../../:1 +msgid "``valid email must be provided``" msgstr "" -#: ../source/changelog.md:27 -#: ../source/changelog.md:68 -msgid "Bulgarian: 98%" +#: ../../:1 +msgid "``new email must be different than current email``" msgstr "" -#: ../source/changelog.md:28 -#: ../source/changelog.md:69 -#: ../source/changelog.md:124 -#: ../source/changelog.md:211 -msgid "Czech: 72%" +#: ../../:1 +msgid "Delete a user account." msgstr "" -#: ../source/changelog.md:29 -#: ../source/changelog.md:70 -#: ../source/changelog.md:125 -#: ../source/changelog.md:173 -#: ../source/changelog.md:212 -#: ../source/changelog.md:556 -#: ../source/changelog.md:594 -#: ../source/changelog.md:708 -#: ../source/changelog.md:999 -#: ../source/changelog.md:1031 -msgid "Dutch: 99%" +#: ../../:1 +msgid "A user can only delete his own account." msgstr "" -#: ../source/changelog.md:30 -#: ../source/changelog.md:71 -#: ../source/changelog.md:126 -#: ../source/changelog.md:174 -#: ../source/changelog.md:213 -#: ../source/changelog.md:245 -#: ../source/changelog.md:291 -#: ../source/changelog.md:336 -#: ../source/changelog.md:370 -#: ../source/changelog.md:397 -#: ../source/changelog.md:441 -#: ../source/changelog.md:490 -#: ../source/changelog.md:527 -#: ../source/changelog.md:557 -#: ../source/changelog.md:595 -#: ../source/changelog.md:633 -#: ../source/changelog.md:670 -#: ../source/changelog.md:709 -#: ../source/changelog.md:748 -#: ../source/changelog.md:789 -#: ../source/changelog.md:831 -#: ../source/changelog.md:880 -#: ../source/changelog.md:932 -#: ../source/changelog.md:979 -#: ../source/changelog.md:1000 -#: ../source/changelog.md:1032 -#: ../source/changelog.md:1084 -msgid "English: 100%" +#: ../../:1 +msgid "A user with admin rights can delete all accounts except his account if he is the only user with admin rights. Only owner can delete his own account." msgstr "" -#: ../source/changelog.md:31 -#: ../source/changelog.md:72 -#: ../source/changelog.md:127 -#: ../source/changelog.md:175 -#: ../source/changelog.md:214 -#: ../source/changelog.md:246 -#: ../source/changelog.md:292 -#: ../source/changelog.md:337 -#: ../source/changelog.md:371 -#: ../source/changelog.md:398 -#: ../source/changelog.md:442 -#: ../source/changelog.md:491 -#: ../source/changelog.md:528 -#: ../source/changelog.md:558 -#: ../source/changelog.md:596 -#: ../source/changelog.md:634 -#: ../source/changelog.md:671 -#: ../source/changelog.md:710 -#: ../source/changelog.md:749 -#: ../source/changelog.md:790 -#: ../source/changelog.md:832 -#: ../source/changelog.md:881 -#: ../source/changelog.md:933 -#: ../source/changelog.md:980 -#: ../source/changelog.md:1001 -#: ../source/changelog.md:1033 -#: ../source/changelog.md:1085 -msgid "French: 100%" +#: ../../:1 +msgid "user account deleted" msgstr "" -#: ../source/changelog.md:32 -#: ../source/changelog.md:73 -#: ../source/changelog.md:128 -#: ../source/changelog.md:176 -#: ../source/changelog.md:247 -#: ../source/changelog.md:293 -#: ../source/changelog.md:338 -#: ../source/changelog.md:372 -#: ../source/changelog.md:399 -#: ../source/changelog.md:443 -#: ../source/changelog.md:529 -#: ../source/changelog.md:559 -#: ../source/changelog.md:597 -#: ../source/changelog.md:635 -#: ../source/changelog.md:672 -#: ../source/changelog.md:750 -#: ../source/changelog.md:791 -#: ../source/changelog.md:833 -#: ../source/changelog.md:882 -#: ../source/changelog.md:981 -#: ../source/changelog.md:1002 -#: ../source/changelog.md:1086 -msgid "Galician: 100%" +#: ../../:1 +msgid "- ``you do not have permissions`` - ``you can not delete your account, no other user has admin rights``" msgstr "" -#: ../source/changelog.md:33 -#: ../source/changelog.md:74 -#: ../source/changelog.md:129 -#: ../source/changelog.md:177 -#: ../source/changelog.md:339 -#: ../source/changelog.md:373 -#: ../source/changelog.md:400 -#: ../source/changelog.md:530 -#: ../source/changelog.md:560 -#: ../source/changelog.md:598 -#: ../source/changelog.md:636 -#: ../source/changelog.md:673 -#: ../source/changelog.md:751 -#: ../source/changelog.md:792 -#: ../source/changelog.md:834 -#: ../source/changelog.md:982 -#: ../source/changelog.md:1087 -msgid "German: 100%" +#: ../../:1 +msgid "``you can not delete your account, no other user has admin rights``" msgstr "" -#: ../source/changelog.md:34 -#: ../source/changelog.md:75 -msgid "Italian: 81%" +#: ../../:1 +msgid "Send a follow request to a user." msgstr "" -#: ../source/changelog.md:35 -#: ../source/changelog.md:76 -#: ../source/changelog.md:131 -#: ../source/changelog.md:179 -#: ../source/changelog.md:218 -#: ../source/changelog.md:250 -#: ../source/changelog.md:296 -msgid "Norwegian Bokmål: 52%" +#: ../../:1 +msgid "- ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - ``you do not have permissions`` - ``you do not have permissions, your account is suspended``" msgstr "" -#: ../source/changelog.md:36 -#: ../source/changelog.md:533 -#: ../source/changelog.md:639 -#: ../source/changelog.md:754 -#: ../source/changelog.md:795 -#: ../source/changelog.md:837 -msgid "Polish: 100%" +#: ../../:1 +msgid "Unfollow a user." msgstr "" -#: ../source/changelog.md:37 -#: ../source/changelog.md:78 -#: ../source/changelog.md:133 -msgid "Portuguese: 97%" +#: ../../:1 +msgid "Get user followers. If the authenticated user has admin rights, it returns following users with additional field 'email'" msgstr "" -#: ../source/changelog.md:38 -msgid "Russian: 62%" +#: ../../:1 +msgid "with page parameter" msgstr "" -#: ../source/changelog.md:39 -#: ../source/changelog.md:80 -#: ../source/changelog.md:134 -#: ../source/changelog.md:182 -#: ../source/changelog.md:253 -#: ../source/changelog.md:299 -#: ../source/changelog.md:344 -#: ../source/changelog.md:377 -#: ../source/changelog.md:404 -#: ../source/changelog.md:448 -#: ../source/changelog.md:497 -#: ../source/changelog.md:534 -#: ../source/changelog.md:564 -#: ../source/changelog.md:602 -#: ../source/changelog.md:640 -#: ../source/changelog.md:677 -#: ../source/changelog.md:755 -#: ../source/changelog.md:796 -#: ../source/changelog.md:838 -#: ../source/changelog.md:887 -#: ../source/changelog.md:939 -#: ../source/changelog.md:986 -#: ../source/changelog.md:1007 -#: ../source/changelog.md:1039 -#: ../source/changelog.md:1090 -msgid "Spanish: 100%" +#: ../../:1 +msgid "Get user following. If the authenticate user has admin rights, it returns following users with additional field 'email'" msgstr "" -#: ../source/changelog.md:43 -msgid "#455 - Drop support for Python 3.8" +#: ../../:1 +msgid "Block a user" msgstr "" -#: ../source/changelog.md:44 -msgid "#639 - Add support for Python 3.13" +#: ../../:1 +msgid "- ``user not found``" msgstr "" -#: ../source/changelog.md:47 -#: ../source/changelog.md:87 -#: ../source/changelog.md:141 -#: ../source/changelog.md:185 -#: ../source/changelog.md:224 -#: ../source/changelog.md:260 -#: ../source/changelog.md:306 -#: ../source/changelog.md:351 -#: ../source/changelog.md:380 -#: ../source/changelog.md:411 -#: ../source/changelog.md:456 -#: ../source/changelog.md:505 -#: ../source/changelog.md:537 -#: ../source/changelog.md:571 -#: ../source/changelog.md:609 -#: ../source/changelog.md:647 -#: ../source/changelog.md:679 -#: ../source/changelog.md:718 -#: ../source/changelog.md:757 -#: ../source/changelog.md:798 -#: ../source/changelog.md:840 -#: ../source/changelog.md:889 -#: ../source/changelog.md:941 -#: ../source/changelog.md:1043 -#: ../source/changelog.md:1097 -#: ../source/changelog.md:1152 -#: ../source/changelog.md:1184 -#: ../source/changelog.md:1441 -#: ../source/changelog.md:1724 -msgid "Thanks to the contributors:" +#: ../../:1 +msgid "Unblock a user" msgstr "" -#: ../source/changelog.md:48 -#: ../source/changelog.md:145 -#: ../source/changelog.md:225 -#: ../source/changelog.md:572 -msgid "@erral" +#: ../../:1 +msgid "Get user sanctions." +msgstr "" + +#: ../../:1 +msgid "It returns sanctions only if: - user name is authenticated user username - user has moderation rights." +msgstr "" + +#: ../../:1 +msgid "if sanctions exist (response with moderation rights)" +msgstr "" + +#: ../../:1 +msgid "if sanctions exist (response for authenticated user)" +msgstr "" + +#: ../../:1 +msgid "no sanctions" +msgstr "" + +#: ../source/api/workouts.rst:2 +#: ../source/features.rst:14 +#: ../source/features.rst:60 +msgid "Workouts" +msgstr "" + +#: ../../:1 +msgid "Get workouts for the authenticated user." +msgstr "" + +#: ../../:1 +msgid "number of workouts per page (default: 5, max: 100)" +msgstr "" + +#: ../../:1 +msgid "any part (or all) of the workout title; title matching is case-insensitive" +msgstr "" + +#: ../../:1 +msgid "minimal distance" +msgstr "" + +#: ../../:1 +msgid "maximal distance" +msgstr "" + +#: ../../:1 +msgid "minimal duration (format: ``%H:%M``)" +msgstr "" + +#: ../../:1 +msgid "maximal distance (format: ``%H:%M``)" +msgstr "" + +#: ../../:1 +msgid "minimal average speed" +msgstr "" + +#: ../../:1 +msgid "maximal average speed" +msgstr "" + +#: ../../:1 +msgid "minimal max. speed" +msgstr "" + +#: ../../:1 +msgid "maximal max. speed" +msgstr "" + +#: ../../:1 +msgid "sorting criteria: ``ave_speed``, ``distance``, ``duration``, ``workout_date`` (default: ``workout_date``)" +msgstr "" + +#: ../../:1 +msgid "equipment id (if ``none``, only workouts without equipments will be returned)" +msgstr "" + +#: ../../:1 +msgid "any part (or all) of the workout notes, notes matching is case-insensitive" +msgstr "" + +#: ../../:1 +msgid "any part of the workout description; description matching is case-insensitive" +msgstr "" + +#: ../../:1 +msgid "Get a workout." +msgstr "" + +#: ../../:1 +msgid "workout not found:" +msgstr "" + +#: ../../:1 +msgid "OAuth 2.0 Bearer Token for workout with ``private`` or ``followers_only`` visibility" +msgstr "" + +#: ../../:1 +msgid "Get gpx file for a workout displayed on map with Leaflet." +msgstr "" + +#: ../../:1 +msgid "OAuth 2.0 Bearer Token for workout with ``private`` or ``followers_only`` map visibility" +msgstr "" + +#: ../../:1 +msgid "- ``workout not found`` - ``no gpx file for this workout``" +msgstr "" + +#: ../../:1 +msgid "``no gpx file for this workout``" +msgstr "" + +#: ../../:1 +msgid "Get chart data from a workout gpx file, to display it with Chart.js." +msgstr "" + +#: ../../:1 +msgid "segment id" +msgstr "" + +#: ../../:1 +msgid "Get gpx file for a workout segment displayed on map with Leaflet." +msgstr "" + +#: ../../:1 +msgid "Get map image for workouts with gpx." +msgstr "" + +#: ../../:1 +msgid "workout map id" +msgstr "" + +#: ../../:1 +msgid "``map does not exist``" +msgstr "" + +#: ../../:1 +msgid "Get map tile from tile server." +msgstr "" + +#: ../../:1 +msgid "subdomain" +msgstr "" + +#: ../../:1 +msgid "zoom" +msgstr "" + +#: ../../:1 +msgid "index of the tile along the map's x axis" +msgstr "" + +#: ../../:1 +msgid "index of the tile along the map's y axis" +msgstr "" + +#: ../../:1 +msgid "Status codes are status codes returned by tile server" +msgstr "" + +#: ../../:1 +msgid "Download gpx file." +msgstr "" + +#: ../../:1 +msgid "- ``workout not found`` - ``no gpx file for workout``" +msgstr "" + +#: ../../:1 +msgid "``no gpx file for workout``" +msgstr "" + +#: ../../:1 +msgid "Post a workout with a gpx file." +msgstr "" + +#: ../../:1 +msgid "gpx file (allowed extensions: .gpx, .zip)" +msgstr "" + +#: ../../:1 +msgid "sport id, equipment id, description, title and notes, for example: ``{\"sport_id\": 1, \"notes\": \"\", \"title\": \"\", \"description\": \"\", \"equipment_ids\": []}``. Double quotes in notes, description and title must be escaped. The maximum length is 500 characters for notes, 10000 characters for description and 255 for title. Otherwise, they will be truncated. When description and title are provided, they replace the description and title from gpx file. For `equipment_ids`, the id of the equipment to associate with this workout. **Note**: for now only one equipment can be associated. If not provided and default equipment exists for sport, default equipment will be associated. Notes, description, title and equipment ids are not mandatory." +msgstr "" + +#: ../../:1 +msgid "sport id, equipment id, description, title and notes, for example: ``{\"sport_id\": 1, \"notes\": \"\", \"title\": \"\", \"description\": \"\", \"equipment_ids\": []}``. Double quotes in notes, description and title must be escaped." +msgstr "" + +#: ../../:1 +msgid "The maximum length is 500 characters for notes, 10000 characters for description and 255 for title. Otherwise, they will be truncated. When description and title are provided, they replace the description and title from gpx file." +msgstr "" + +#: ../../:1 +msgid "For `equipment_ids`, the id of the equipment to associate with this workout. **Note**: for now only one equipment can be associated. If not provided and default equipment exists for sport, default equipment will be associated." +msgstr "" + +#: ../../:1 +msgid "Notes, description, title and equipment ids are not mandatory." +msgstr "" + +#: ../../:1 +msgid "workout created" +msgstr "" + +#: ../../:1 +msgid "- ``invalid payload`` - ``no file part`` - ``no selected file`` - ``file extension not allowed`` - ``equipment_ids must be an array of strings`` - ``only one equipment can be added`` - ``equipment with id does not exist`` - ``invalid equipment id for sport`` - ``equipment with id is inactive``" +msgstr "" + +#: ../../:1 +msgid "Post a workout without gpx file." +msgstr "" + +#: ../../:1 +msgid "workout ascent (not mandatory, must be provided with descent)" +msgstr "" + +#: ../../:1 +msgid "workout descent (not mandatory, must be provided with ascent)" +msgstr "" + +#: ../../:1 +msgid "workout description (not mandatory, max length: 10000 characters, otherwise it will be truncated)" +msgstr "" + +#: ../../:1 +msgid "workout distance in km" +msgstr "" + +#: ../../:1 +msgid "workout duration in seconds" +msgstr "" + +#: ../../:1 +msgid "the id of the equipment to associate with this workout. **Note**: for now only one equipment can be associated. If not provided and default equipment exists for sport, default equipment will be associated." +msgstr "" + +#: ../../:1 +msgid "notes (not mandatory, max length: 500 characters, otherwise they will be truncated)" +msgstr "" + +#: ../../:1 +msgid "workout sport id" +msgstr "" + +#: ../../:1 +msgid "workout title (not mandatory, max length: 255 characters, otherwise it will be truncated)" +msgstr "" + +#: ../../:1 +msgid "workout date, in user timezone (format: ``%Y-%m-%d %H:%M``)" +msgstr "" + +#: ../../:1 +msgid "- ``invalid payload`` - ``equipment_ids must be an array of strings`` - ``only one equipment can be added`` - ``equipment with id does not exist`` - ``invalid equipment id for sport`` - ``equipment with id is inactive``" +msgstr "" + +#: ../../:1 +msgid "Update a workout." +msgstr "" + +#: ../../:1 +msgid "workout ascent (only for workout without gpx, must be provided with descent)" +msgstr "" + +#: ../../:1 +msgid "workout descent (only for workout without gpx, must be provided with ascent)" +msgstr "" + +#: ../../:1 +msgid "workout description (max length: 10000 characters, otherwise it will be truncated)" +msgstr "" + +#: ../../:1 +msgid "workout distance in km (only for workout without gpx)" +msgstr "" + +#: ../../:1 +msgid "workout duration in seconds (only for workout without gpx)" +msgstr "" + +#: ../../:1 +msgid "the id of the equipment to associate with this workout (any existing equipment for this workout will be replaced). **Note**: for now only one equipment can be associated. If an empty array, equipment for this workout will be removed." +msgstr "" + +#: ../../:1 +msgid "map and analysis data visibility (``private``, ``followers_only`` or ``public``)" +msgstr "" + +#: ../../:1 +msgid "notes (max length: 500 characters, otherwise they will be truncated)" +msgstr "" + +#: ../../:1 +msgid "workout title (max length: 255 characters, otherwise it will be truncated)" +msgstr "" + +#: ../../:1 +msgid "workout date in user timezone (format: ``%Y-%m-%d %H:%M``) (only for workout without gpx)" +msgstr "" + +#: ../../:1 +msgid "workout visibility (``private``, ``followers_only`` or ``public``)" +msgstr "" + +#: ../../:1 +msgid "workout updated" +msgstr "" + +#: ../../:1 +msgid "Delete a workout." +msgstr "" + +#: ../../:1 +msgid "workout deleted" +msgstr "" + +#: ../../:1 +msgid "Add a \"like\" to a workout." +msgstr "" + +#: ../../:1 +msgid "Remove workout \"like\"." +msgstr "" + +#: ../../:1 +msgid "Appeal workout suspension." +msgstr "" + +#: ../../:1 +msgid "Only workout author can appeal the suspension." +msgstr "" + +#: ../../:1 +msgid "- ``no text provided`` - ``you can appeal only once`` - ``workout is not suspended`` - ``workout has no suspension``" +msgstr "" + +#: ../../:1 +msgid "``workout is not suspended``" +msgstr "" + +#: ../../:1 +msgid "``workout has no suspension``" +msgstr "" + +#: ../source/changelog.md:1 +msgid "Change log" +msgstr "" + +#: ../source/changelog.md:3 +msgid "Version 0.8.12 (2024/11/17)" +msgstr "" + +#: ../source/changelog.md:5 +#: ../source/changelog.md:105 +#: ../source/changelog.md:157 +#: ../source/changelog.md:199 +#: ../source/changelog.md:276 +#: ../source/changelog.md:358 +#: ../source/changelog.md:423 +#: ../source/changelog.md:473 +#: ../source/changelog.md:515 +#: ../source/changelog.md:546 +#: ../source/changelog.md:581 +#: ../source/changelog.md:622 +#: ../source/changelog.md:659 +#: ../source/changelog.md:693 +#: ../source/changelog.md:728 +#: ../source/changelog.md:763 +#: ../source/changelog.md:770 +#: ../source/changelog.md:804 +#: ../source/changelog.md:816 +#: ../source/changelog.md:848 +#: ../source/changelog.md:865 +#: ../source/changelog.md:906 +#: ../source/changelog.md:1017 +#: ../source/changelog.md:1067 +#: ../source/changelog.md:1113 +#: ../source/changelog.md:1139 +#: ../source/changelog.md:1237 +#: ../source/changelog.md:1265 +#: ../source/changelog.md:1276 +#: ../source/changelog.md:1303 +#: ../source/changelog.md:1324 +#: ../source/changelog.md:1342 +#: ../source/changelog.md:1358 +#: ../source/changelog.md:1379 +#: ../source/changelog.md:1401 +#: ../source/changelog.md:1408 +#: ../source/changelog.md:1428 +#: ../source/changelog.md:1450 +#: ../source/changelog.md:1468 +#: ../source/changelog.md:1503 +#: ../source/changelog.md:1514 +#: ../source/changelog.md:1525 +#: ../source/changelog.md:1537 +#: ../source/changelog.md:1557 +#: ../source/changelog.md:1563 +#: ../source/changelog.md:1615 +#: ../source/changelog.md:1639 +#: ../source/changelog.md:1650 +#: ../source/changelog.md:1661 +#: ../source/changelog.md:1706 +#: ../source/changelog.md:1739 +#: ../source/changelog.md:1751 +#: ../source/changelog.md:1762 +#: ../source/changelog.md:1778 +#: ../source/changelog.md:1791 +#: ../source/changelog.md:1803 +#: ../source/changelog.md:1824 +#: ../source/changelog.md:1902 +#: ../source/changelog.md:1919 +#: ../source/changelog.md:1930 +#: ../source/changelog.md:1950 +#: ../source/changelog.md:1985 +msgid "Bugs Fixed" +msgstr "" + +#: ../source/changelog.md:7 +msgid "#652 - User can not login on new installation" +msgstr "" + +#: ../source/changelog.md:9 +#: ../source/changelog.md:41 +#: ../source/changelog.md:82 +#: ../source/changelog.md:136 +#: ../source/changelog.md:255 +#: ../source/changelog.md:301 +#: ../source/changelog.md:346 +#: ../source/changelog.md:406 +#: ../source/changelog.md:450 +#: ../source/changelog.md:499 +#: ../source/changelog.md:566 +#: ../source/changelog.md:604 +#: ../source/changelog.md:642 +#: ../source/changelog.md:702 +#: ../source/changelog.md:738 +#: ../source/changelog.md:779 +#: ../source/changelog.md:809 +#: ../source/changelog.md:853 +#: ../source/changelog.md:924 +#: ../source/changelog.md:971 +#: ../source/changelog.md:1092 +#: ../source/changelog.md:1165 +#: ../source/changelog.md:1245 +#: ../source/changelog.md:1258 +#: ../source/changelog.md:1364 +#: ../source/changelog.md:1456 +#: ../source/changelog.md:1477 +#: ../source/changelog.md:1585 +#: ../source/changelog.md:1601 +#: ../source/changelog.md:1710 +#: ../source/changelog.md:1766 +#: ../source/changelog.md:1807 +#: ../source/changelog.md:1833 +#: ../source/changelog.md:1888 +#: ../source/changelog.md:1954 +#: ../source/changelog.md:1969 +msgid "Misc" +msgstr "" + +#: ../source/changelog.md:11 +msgid "PR#651 - Tests - add databases to parallelize more tests" +msgstr "" + +#: ../source/changelog.md:15 +msgid "Version 0.8.11 (2024/10/30)" +msgstr "" + +#: ../source/changelog.md:17 +msgid "FitTrackee is now available for Python 3.13. Python 3.8 is no longer supported, the minimum version is now Python 3.9." +msgstr "" + +#: ../source/changelog.md:20 +#: ../source/changelog.md:60 +#: ../source/changelog.md:110 +#: ../source/changelog.md:161 +#: ../source/changelog.md:203 +#: ../source/changelog.md:233 +#: ../source/changelog.md:280 +#: ../source/changelog.md:325 +#: ../source/changelog.md:362 +#: ../source/changelog.md:387 +#: ../source/changelog.md:429 +#: ../source/changelog.md:479 +#: ../source/changelog.md:519 +#: ../source/changelog.md:550 +#: ../source/changelog.md:588 +#: ../source/changelog.md:626 +#: ../source/changelog.md:663 +#: ../source/changelog.md:698 +#: ../source/changelog.md:733 +#: ../source/changelog.md:774 +#: ../source/changelog.md:822 +#: ../source/changelog.md:871 +#: ../source/changelog.md:912 +#: ../source/changelog.md:956 +#: ../source/changelog.md:993 +#: ../source/changelog.md:1022 +#: ../source/changelog.md:1072 +#: ../source/changelog.md:1117 +#: ../source/changelog.md:1146 +#: ../source/changelog.md:1161 +#: ../source/changelog.md:1178 +#: ../source/changelog.md:1198 +#: ../source/changelog.md:1213 +#: ../source/changelog.md:1226 +#: ../source/changelog.md:1241 +#: ../source/changelog.md:1254 +#: ../source/changelog.md:1269 +#: ../source/changelog.md:1283 +#: ../source/changelog.md:1308 +#: ../source/changelog.md:1317 +#: ../source/changelog.md:1328 +#: ../source/changelog.md:1347 +#: ../source/changelog.md:1433 +#: ../source/features.rst:496 +msgid "Translations" +msgstr "" + +#: ../source/changelog.md:22 +msgid "PR#640 - Translations update (Basque)" +msgstr "" + +#: ../source/changelog.md:23 +msgid "PR#645 - Translations update (Russian, Polish)" +msgstr "" + +#: ../source/changelog.md:25 +#: ../source/changelog.md:66 +#: ../source/changelog.md:121 +#: ../source/changelog.md:169 +#: ../source/changelog.md:209 +#: ../source/changelog.md:241 +#: ../source/changelog.md:287 +#: ../source/changelog.md:332 +#: ../source/changelog.md:366 +#: ../source/changelog.md:393 +#: ../source/changelog.md:437 +#: ../source/changelog.md:487 +#: ../source/changelog.md:524 +#: ../source/changelog.md:554 +#: ../source/changelog.md:593 +#: ../source/changelog.md:631 +#: ../source/changelog.md:668 +#: ../source/changelog.md:707 +#: ../source/changelog.md:746 +#: ../source/changelog.md:787 +#: ../source/changelog.md:829 +#: ../source/changelog.md:878 +#: ../source/changelog.md:930 +#: ../source/changelog.md:977 +#: ../source/changelog.md:998 +#: ../source/changelog.md:1030 +#: ../source/changelog.md:1082 +msgid "Translation status:" +msgstr "" + +#: ../source/changelog.md:26 +#: ../source/changelog.md:122 +#: ../source/changelog.md:333 +#: ../source/changelog.md:367 +#: ../source/changelog.md:525 +#: ../source/changelog.md:555 +msgid "Basque: 100%" +msgstr "" + +#: ../source/changelog.md:27 +#: ../source/changelog.md:68 +msgid "Bulgarian: 98%" +msgstr "" + +#: ../source/changelog.md:28 +#: ../source/changelog.md:69 +#: ../source/changelog.md:124 +#: ../source/changelog.md:211 +msgid "Czech: 72%" +msgstr "" + +#: ../source/changelog.md:29 +#: ../source/changelog.md:70 +#: ../source/changelog.md:125 +#: ../source/changelog.md:173 +#: ../source/changelog.md:212 +#: ../source/changelog.md:556 +#: ../source/changelog.md:594 +#: ../source/changelog.md:708 +#: ../source/changelog.md:999 +#: ../source/changelog.md:1031 +msgid "Dutch: 99%" +msgstr "" + +#: ../source/changelog.md:30 +#: ../source/changelog.md:71 +#: ../source/changelog.md:126 +#: ../source/changelog.md:174 +#: ../source/changelog.md:213 +#: ../source/changelog.md:245 +#: ../source/changelog.md:291 +#: ../source/changelog.md:336 +#: ../source/changelog.md:370 +#: ../source/changelog.md:397 +#: ../source/changelog.md:441 +#: ../source/changelog.md:490 +#: ../source/changelog.md:527 +#: ../source/changelog.md:557 +#: ../source/changelog.md:595 +#: ../source/changelog.md:633 +#: ../source/changelog.md:670 +#: ../source/changelog.md:709 +#: ../source/changelog.md:748 +#: ../source/changelog.md:789 +#: ../source/changelog.md:831 +#: ../source/changelog.md:880 +#: ../source/changelog.md:932 +#: ../source/changelog.md:979 +#: ../source/changelog.md:1000 +#: ../source/changelog.md:1032 +#: ../source/changelog.md:1084 +msgid "English: 100%" +msgstr "" + +#: ../source/changelog.md:31 +#: ../source/changelog.md:72 +#: ../source/changelog.md:127 +#: ../source/changelog.md:175 +#: ../source/changelog.md:214 +#: ../source/changelog.md:246 +#: ../source/changelog.md:292 +#: ../source/changelog.md:337 +#: ../source/changelog.md:371 +#: ../source/changelog.md:398 +#: ../source/changelog.md:442 +#: ../source/changelog.md:491 +#: ../source/changelog.md:528 +#: ../source/changelog.md:558 +#: ../source/changelog.md:596 +#: ../source/changelog.md:634 +#: ../source/changelog.md:671 +#: ../source/changelog.md:710 +#: ../source/changelog.md:749 +#: ../source/changelog.md:790 +#: ../source/changelog.md:832 +#: ../source/changelog.md:881 +#: ../source/changelog.md:933 +#: ../source/changelog.md:980 +#: ../source/changelog.md:1001 +#: ../source/changelog.md:1033 +#: ../source/changelog.md:1085 +msgid "French: 100%" +msgstr "" + +#: ../source/changelog.md:32 +#: ../source/changelog.md:73 +#: ../source/changelog.md:128 +#: ../source/changelog.md:176 +#: ../source/changelog.md:247 +#: ../source/changelog.md:293 +#: ../source/changelog.md:338 +#: ../source/changelog.md:372 +#: ../source/changelog.md:399 +#: ../source/changelog.md:443 +#: ../source/changelog.md:529 +#: ../source/changelog.md:559 +#: ../source/changelog.md:597 +#: ../source/changelog.md:635 +#: ../source/changelog.md:672 +#: ../source/changelog.md:750 +#: ../source/changelog.md:791 +#: ../source/changelog.md:833 +#: ../source/changelog.md:882 +#: ../source/changelog.md:981 +#: ../source/changelog.md:1002 +#: ../source/changelog.md:1086 +msgid "Galician: 100%" +msgstr "" + +#: ../source/changelog.md:33 +#: ../source/changelog.md:74 +#: ../source/changelog.md:129 +#: ../source/changelog.md:177 +#: ../source/changelog.md:339 +#: ../source/changelog.md:373 +#: ../source/changelog.md:400 +#: ../source/changelog.md:530 +#: ../source/changelog.md:560 +#: ../source/changelog.md:598 +#: ../source/changelog.md:636 +#: ../source/changelog.md:673 +#: ../source/changelog.md:751 +#: ../source/changelog.md:792 +#: ../source/changelog.md:834 +#: ../source/changelog.md:982 +#: ../source/changelog.md:1087 +msgid "German: 100%" +msgstr "" + +#: ../source/changelog.md:34 +#: ../source/changelog.md:75 +msgid "Italian: 81%" +msgstr "" + +#: ../source/changelog.md:35 +#: ../source/changelog.md:76 +#: ../source/changelog.md:131 +#: ../source/changelog.md:179 +#: ../source/changelog.md:218 +#: ../source/changelog.md:250 +#: ../source/changelog.md:296 +msgid "Norwegian Bokmål: 52%" +msgstr "" + +#: ../source/changelog.md:36 +#: ../source/changelog.md:533 +#: ../source/changelog.md:639 +#: ../source/changelog.md:754 +#: ../source/changelog.md:795 +#: ../source/changelog.md:837 +msgid "Polish: 100%" +msgstr "" + +#: ../source/changelog.md:37 +#: ../source/changelog.md:78 +#: ../source/changelog.md:133 +msgid "Portuguese: 97%" +msgstr "" + +#: ../source/changelog.md:38 +msgid "Russian: 62%" +msgstr "" + +#: ../source/changelog.md:39 +#: ../source/changelog.md:80 +#: ../source/changelog.md:134 +#: ../source/changelog.md:182 +#: ../source/changelog.md:253 +#: ../source/changelog.md:299 +#: ../source/changelog.md:344 +#: ../source/changelog.md:377 +#: ../source/changelog.md:404 +#: ../source/changelog.md:448 +#: ../source/changelog.md:497 +#: ../source/changelog.md:534 +#: ../source/changelog.md:564 +#: ../source/changelog.md:602 +#: ../source/changelog.md:640 +#: ../source/changelog.md:677 +#: ../source/changelog.md:755 +#: ../source/changelog.md:796 +#: ../source/changelog.md:838 +#: ../source/changelog.md:887 +#: ../source/changelog.md:939 +#: ../source/changelog.md:986 +#: ../source/changelog.md:1007 +#: ../source/changelog.md:1039 +#: ../source/changelog.md:1090 +msgid "Spanish: 100%" +msgstr "" + +#: ../source/changelog.md:43 +msgid "#455 - Drop support for Python 3.8" +msgstr "" + +#: ../source/changelog.md:44 +msgid "#639 - Add support for Python 3.13" +msgstr "" + +#: ../source/changelog.md:47 +#: ../source/changelog.md:87 +#: ../source/changelog.md:141 +#: ../source/changelog.md:185 +#: ../source/changelog.md:224 +#: ../source/changelog.md:260 +#: ../source/changelog.md:306 +#: ../source/changelog.md:351 +#: ../source/changelog.md:380 +#: ../source/changelog.md:411 +#: ../source/changelog.md:456 +#: ../source/changelog.md:505 +#: ../source/changelog.md:537 +#: ../source/changelog.md:571 +#: ../source/changelog.md:609 +#: ../source/changelog.md:647 +#: ../source/changelog.md:679 +#: ../source/changelog.md:718 +#: ../source/changelog.md:757 +#: ../source/changelog.md:798 +#: ../source/changelog.md:840 +#: ../source/changelog.md:889 +#: ../source/changelog.md:941 +#: ../source/changelog.md:1043 +#: ../source/changelog.md:1097 +#: ../source/changelog.md:1152 +#: ../source/changelog.md:1184 +#: ../source/changelog.md:1441 +#: ../source/changelog.md:1724 +msgid "Thanks to the contributors:" +msgstr "" + +#: ../source/changelog.md:48 +#: ../source/changelog.md:145 +#: ../source/changelog.md:225 +#: ../source/changelog.md:572 +msgid "@erral" +msgstr "" + +#: ../source/changelog.md:49 +#: ../source/changelog.md:92 +msgid "@sikmir" +msgstr "" + +#: ../source/changelog.md:50 +#: ../source/changelog.md:539 +#: ../source/changelog.md:649 +msgid "Mariuz" +msgstr "" + +#: ../source/changelog.md:53 +msgid "Version 0.8.10 (2024/10/09)" +msgstr "" + +#: ../source/changelog.md:55 +#: ../source/changelog.md:101 +#: ../source/changelog.md:195 +#: ../source/changelog.md:270 +#: ../source/changelog.md:320 +#: ../source/changelog.md:419 +#: ../source/changelog.md:468 +#: ../source/changelog.md:577 +#: ../source/changelog.md:617 +#: ../source/changelog.md:655 +#: ../source/changelog.md:687 +#: ../source/changelog.md:860 +#: ../source/changelog.md:899 +#: ../source/changelog.md:1012 +#: ../source/changelog.md:1060 +#: ../source/changelog.md:1133 +#: ../source/changelog.md:1173 +#: ../source/changelog.md:1194 +#: ../source/changelog.md:1209 +#: ../source/changelog.md:1233 +#: ../source/changelog.md:1296 +#: ../source/changelog.md:1336 +msgid "Features and enhancements" +msgstr "" + +#: ../source/changelog.md:57 +msgid "PR#635 - Add ability to replace gpx title when adding a workout" +msgstr "" + +#: ../source/changelog.md:58 +msgid "PR#636 - Get description from gpx file if present" +msgstr "" + +#: ../source/changelog.md:62 +msgid "#629 - [Translation Request] Russian" +msgstr "" + +#: ../source/changelog.md:63 +msgid "PR#633 - Translations update (Russian, Dutch, Italian, Galician)" +msgstr "" + +#: ../source/changelog.md:64 +msgid "PR#637 - Translations update (Spanish, Russian, German and Galician)" +msgstr "" + +#: ../source/changelog.md:67 +#: ../source/changelog.md:170 +#: ../source/changelog.md:210 +msgid "Basque: 99%" +msgstr "" + +#: ../source/changelog.md:77 +#: ../source/changelog.md:676 +msgid "Polish: 98%" +msgstr "" + +#: ../source/changelog.md:79 +msgid "Russian: 61%" +msgstr "" + +#: ../source/changelog.md:84 +msgid "PR#634 - CI - add PostgreSQL 17" +msgstr "" + +#: ../source/changelog.md:88 +#: ../source/changelog.md:142 +msgid "@boosterl" +msgstr "" + +#: ../source/changelog.md:89 +#: ../source/changelog.md:146 +#: ../source/changelog.md:186 +#: ../source/changelog.md:308 +#: ../source/changelog.md:458 +#: ../source/changelog.md:507 +#: ../source/changelog.md:610 +#: ../source/changelog.md:680 +#: ../source/changelog.md:720 +#: ../source/changelog.md:890 +#: ../source/changelog.md:943 +#: ../source/changelog.md:1045 +#: ../source/changelog.md:1101 +msgid "@gallegonovato" +msgstr "" + +#: ../source/changelog.md:90 +#: ../source/changelog.md:147 +#: ../source/changelog.md:189 +#: ../source/changelog.md:414 +#: ../source/changelog.md:611 +#: ../source/changelog.md:681 +#: ../source/changelog.md:842 +#: ../source/changelog.md:1102 +#: ../source/changelog.md:1154 +#: ../source/changelog.md:1187 +msgid "@qwerty287" +msgstr "" + +#: ../source/changelog.md:91 +msgid "@Shura0" +msgstr "" + +#: ../source/changelog.md:93 +#: ../source/changelog.md:148 +#: ../source/changelog.md:190 +#: ../source/changelog.md:226 +#: ../source/changelog.md:265 +#: ../source/changelog.md:311 +#: ../source/changelog.md:461 +#: ../source/changelog.md:508 +#: ../source/changelog.md:612 +#: ../source/changelog.md:682 +#: ../source/changelog.md:721 +#: ../source/changelog.md:799 +#: ../source/changelog.md:891 +#: ../source/changelog.md:944 +#: ../source/changelog.md:1048 +#: ../source/changelog.md:1103 +msgid "@xmgz" +msgstr "" + +#: ../source/changelog.md:96 +msgid "Version 0.8.9 (2024/09/21)" +msgstr "" + +#: ../source/changelog.md:98 +msgid "This release introduces a new field: the workout description.
    This field is longer than the \"Notes\" field and will have the same visibility as the workout in a next version (see #125). The \"Notes\" field will remain private." +msgstr "" + +#: ../source/changelog.md:103 +msgid "#610 - Add a description field to workout" +msgstr "" + +#: ../source/changelog.md:107 +msgid "#621 - email username may contain special characters" +msgstr "" + +#: ../source/changelog.md:108 +msgid "#622 - Fix email sending by adding 'Message-ID'" +msgstr "" + +#: ../source/changelog.md:112 +msgid "PR#616 - Translations update (Dutch)" +msgstr "" + +#: ../source/changelog.md:113 +msgid "PR#617 - Translations update (Italian)" +msgstr "" + +#: ../source/changelog.md:114 +msgid "PR#618 - Translations update (Polish)" +msgstr "" + +#: ../source/changelog.md:115 +msgid "PR#620 - Translations update (Polish)" +msgstr "" + +#: ../source/changelog.md:116 +msgid "PR#624 - Translations update (Spanish)" +msgstr "" + +#: ../source/changelog.md:117 +msgid "PR#625 - Translations update (Galician and Basque)" +msgstr "" + +#: ../source/changelog.md:118 +msgid "PR#626 - Translations update (German)" +msgstr "" + +#: ../source/changelog.md:119 +msgid "PR#631 - Translations update (Basque)" +msgstr "" + +#: ../source/changelog.md:123 +msgid "Bulgarian: 99%" +msgstr "" + +#: ../source/changelog.md:130 +#: ../source/changelog.md:178 +#: ../source/changelog.md:217 +#: ../source/changelog.md:249 +msgid "Italian: 82%" +msgstr "" + +#: ../source/changelog.md:132 +#: ../source/changelog.md:563 +#: ../source/changelog.md:601 +#: ../source/changelog.md:715 +msgid "Polish: 99%" +msgstr "" + +#: ../source/changelog.md:138 +msgid "PR#628 - Replace markdown library" +msgstr "" + +#: ../source/changelog.md:143 +#: ../source/changelog.md:307 +msgid "@byakurau" +msgstr "" + +#: ../source/changelog.md:144 +msgid "@dotlambda" +msgstr "" + +#: ../source/changelog.md:150 +#: ../source/changelog.md:228 +#: ../source/changelog.md:313 +#: ../source/changelog.md:510 +#: ../source/changelog.md:723 +#: ../source/changelog.md:894 +#: ../source/changelog.md:946 +#: ../source/changelog.md:1050 +#: ../source/changelog.md:1108 +#: ../source/changelog.md:1156 +#: ../source/changelog.md:1204 +#: ../source/changelog.md:1218 +#: ../source/changelog.md:1312 +#: ../source/changelog.md:1351 +msgid "Note: This release contains database migration (see upgrade instructions in documentation)" +msgstr "" + +#: ../source/changelog.md:153 +msgid "Version 0.8.8 (2024/09/01)" +msgstr "" + +#: ../source/changelog.md:155 +msgid "FitTrackee is now available in Bulgarian." +msgstr "" + +#: ../source/changelog.md:159 +msgid "#614 - Labels are not translated on workouts average chart" +msgstr "" + +#: ../source/changelog.md:163 +msgid "PR#607 - Translations update (German)" +msgstr "" + +#: ../source/changelog.md:164 +msgid "#608 - [translations request] Bulgarian" +msgstr "" + +#: ../source/changelog.md:165 +msgid "PR#609 - Translations update (Galician and Spanish)" +msgstr "" + +#: ../source/changelog.md:166 +msgid "PR#612 - Translations update (Bulgarian and Czech)" +msgstr "" + +#: ../source/changelog.md:171 +msgid "Bulgarian: 100%" +msgstr "" + +#: ../source/changelog.md:172 +#: ../source/changelog.md:243 +#: ../source/changelog.md:289 +msgid "Czech: 73%" +msgstr "" + +#: ../source/changelog.md:180 +#: ../source/changelog.md:219 +msgid "Polish: 91%" +msgstr "" + +#: ../source/changelog.md:181 +#: ../source/changelog.md:220 +#: ../source/changelog.md:252 +#: ../source/changelog.md:298 +msgid "Portuguese: 98%" +msgstr "" + +#: ../source/changelog.md:187 +#: ../source/changelog.md:352 +#: ../source/changelog.md:381 +#: ../source/changelog.md:412 +#: ../source/changelog.md:460 +msgid "@jmlich" +msgstr "" + +#: ../source/changelog.md:188 +msgid "@mara21" +msgstr "" + +#: ../source/changelog.md:193 +msgid "Version 0.8.7 (2024/08/25)" +msgstr "" + +#: ../source/changelog.md:197 +msgid "#604 - New sport: Swimrun" +msgstr "" + +#: ../source/changelog.md:201 +msgid "PR#598 - CLI - fix limit for user data export cleanup" +msgstr "" + +#: ../source/changelog.md:205 +msgid "PR#600 - Translations update (Galician)" +msgstr "" + +#: ../source/changelog.md:206 +msgid "PR#603 - Translations update (Basque)" +msgstr "" + +#: ../source/changelog.md:215 +#: ../source/changelog.md:492 +#: ../source/changelog.md:711 +#: ../source/changelog.md:1034 +msgid "Galician: 99%" +msgstr "" + +#: ../source/changelog.md:216 +#: ../source/changelog.md:294 +msgid "German: 98%" +msgstr "" + +#: ../source/changelog.md:221 +#: ../source/changelog.md:716 +msgid "Spanish: 99%" +msgstr "" + +#: ../source/changelog.md:231 +msgid "Version 0.8.6 (2024/08/03)" +msgstr "" + +#: ../source/changelog.md:235 +msgid "PR#590 - Translations update (Italian)" +msgstr "" + +#: ../source/changelog.md:236 +msgid "PR#591 - Translations update (Galician)" +msgstr "" + +#: ../source/changelog.md:237 +msgid "PR#592 - Translations update (German, Dutch)" +msgstr "" + +#: ../source/changelog.md:238 +msgid "PR#593 - Translations update (German)" +msgstr "" + +#: ../source/changelog.md:239 +msgid "fb10602 - update and fix translations" +msgstr "" + +#: ../source/changelog.md:242 +#: ../source/changelog.md:288 +msgid "Basque: 98%" +msgstr "" + +#: ../source/changelog.md:244 +#: ../source/changelog.md:526 +#: ../source/changelog.md:632 +#: ../source/changelog.md:747 +#: ../source/changelog.md:788 +#: ../source/changelog.md:830 +#: ../source/changelog.md:978 +msgid "Dutch: 100%" +msgstr "" + +#: ../source/changelog.md:248 +#: ../source/changelog.md:712 +#: ../source/changelog.md:1003 +#: ../source/changelog.md:1035 +msgid "German: 99%" +msgstr "" + +#: ../source/changelog.md:251 +#: ../source/changelog.md:297 +msgid "Polish: 92%" +msgstr "" + +#: ../source/changelog.md:257 +msgid "PR#595 - CI - speed up tests" +msgstr "" + +#: ../source/changelog.md:261 +msgid "@ConfusedAlex" +msgstr "" + +#: ../source/changelog.md:262 +msgid "@lukasitaly" +msgstr "" + +#: ../source/changelog.md:263 +msgid "@simontb" +msgstr "" + +#: ../source/changelog.md:264 +msgid "@slothje" +msgstr "" + +#: ../source/changelog.md:268 +msgid "Version 0.8.5 (2024/06/29)" +msgstr "" + +#: ../source/changelog.md:272 +msgid "#566 - [Feature] Improved statistics section with average calculation" msgstr "" -#: ../source/changelog.md:49 -#: ../source/changelog.md:92 -msgid "@sikmir" +#: ../source/changelog.md:273 +msgid "PR#575 - Add page to display sport statistics" msgstr "" -#: ../source/changelog.md:50 -#: ../source/changelog.md:539 -#: ../source/changelog.md:649 -msgid "Mariuz" +#: ../source/changelog.md:274 +msgid "PR#587 - Improve user forms" msgstr "" -#: ../source/changelog.md:53 -msgid "Version 0.8.10 (2024/10/09)" +#: ../source/changelog.md:278 +msgid "PR#588 - Fix click on workout chart checkbox labels" msgstr "" -#: ../source/changelog.md:55 -#: ../source/changelog.md:101 -#: ../source/changelog.md:195 -#: ../source/changelog.md:270 -#: ../source/changelog.md:320 -#: ../source/changelog.md:419 -#: ../source/changelog.md:468 -#: ../source/changelog.md:577 -#: ../source/changelog.md:617 -#: ../source/changelog.md:655 -#: ../source/changelog.md:687 -#: ../source/changelog.md:860 -#: ../source/changelog.md:899 -#: ../source/changelog.md:1012 -#: ../source/changelog.md:1060 -#: ../source/changelog.md:1133 -#: ../source/changelog.md:1173 -#: ../source/changelog.md:1194 -#: ../source/changelog.md:1209 -#: ../source/changelog.md:1233 -#: ../source/changelog.md:1296 -#: ../source/changelog.md:1336 -msgid "Features and enhancements" +#: ../source/changelog.md:282 +msgid "PR#564 - Translations update (Dutch)" msgstr "" -#: ../source/changelog.md:57 -msgid "PR#635 - Add ability to replace gpx title when adding a workout" +#: ../source/changelog.md:283 +msgid "PR#565 - Translations update (Polish)" msgstr "" -#: ../source/changelog.md:58 -msgid "PR#636 - Get description from gpx file if present" +#: ../source/changelog.md:284 +msgid "PR#571 - Translations update (Galician, Spanish)" msgstr "" -#: ../source/changelog.md:62 -msgid "#629 - [Translation Request] Russian" +#: ../source/changelog.md:285 +msgid "PR#582 - Translations update (Galician, Spanish)" msgstr "" -#: ../source/changelog.md:63 -msgid "PR#633 - Translations update (Russian, Dutch, Italian, Galician)" +#: ../source/changelog.md:290 +#: ../source/changelog.md:335 +#: ../source/changelog.md:369 +#: ../source/changelog.md:396 +#: ../source/changelog.md:440 +#: ../source/changelog.md:669 +#: ../source/changelog.md:1083 +msgid "Dutch: 98%" msgstr "" -#: ../source/changelog.md:64 -msgid "PR#637 - Translations update (Spanish, Russian, German and Galician)" +#: ../source/changelog.md:295 +msgid "Italian: 73%" msgstr "" -#: ../source/changelog.md:67 -#: ../source/changelog.md:170 -#: ../source/changelog.md:210 -msgid "Basque: 99%" +#: ../source/changelog.md:303 +msgid "PR#583 - Simplify docker deployment" msgstr "" -#: ../source/changelog.md:77 -#: ../source/changelog.md:676 -msgid "Polish: 98%" +#: ../source/changelog.md:309 +#: ../source/changelog.md:459 +msgid "@jderuiter" msgstr "" -#: ../source/changelog.md:79 -msgid "Russian: 61%" +#: ../source/changelog.md:310 +msgid "@pluja" msgstr "" -#: ../source/changelog.md:84 -msgid "PR#634 - CI - add PostgreSQL 17" +#: ../source/changelog.md:316 +msgid "Version 0.8.4 (2024/05/22)" msgstr "" -#: ../source/changelog.md:88 -#: ../source/changelog.md:142 -msgid "@boosterl" +#: ../source/changelog.md:318 +msgid "FitTrackee is now available in Portuguese." msgstr "" -#: ../source/changelog.md:89 -#: ../source/changelog.md:146 -#: ../source/changelog.md:186 -#: ../source/changelog.md:308 -#: ../source/changelog.md:458 -#: ../source/changelog.md:507 -#: ../source/changelog.md:610 -#: ../source/changelog.md:680 -#: ../source/changelog.md:720 -#: ../source/changelog.md:890 -#: ../source/changelog.md:943 -#: ../source/changelog.md:1045 -#: ../source/changelog.md:1101 -msgid "@gallegonovato" +#: ../source/changelog.md:322 +msgid "f2aec30 - add password strength estimation for Czech" msgstr "" -#: ../source/changelog.md:90 -#: ../source/changelog.md:147 -#: ../source/changelog.md:189 -#: ../source/changelog.md:414 -#: ../source/changelog.md:611 -#: ../source/changelog.md:681 -#: ../source/changelog.md:842 -#: ../source/changelog.md:1102 -#: ../source/changelog.md:1154 -#: ../source/changelog.md:1187 -msgid "@qwerty287" +#: ../source/changelog.md:323 +msgid "#563 - CLI - init language preference on user creation" msgstr "" -#: ../source/changelog.md:91 -msgid "@Shura0" +#: ../source/changelog.md:327 +msgid "#550 - Typo: par page instead of per page" msgstr "" -#: ../source/changelog.md:93 -#: ../source/changelog.md:148 -#: ../source/changelog.md:190 -#: ../source/changelog.md:226 -#: ../source/changelog.md:265 -#: ../source/changelog.md:311 -#: ../source/changelog.md:461 -#: ../source/changelog.md:508 -#: ../source/changelog.md:612 -#: ../source/changelog.md:682 -#: ../source/changelog.md:721 -#: ../source/changelog.md:799 -#: ../source/changelog.md:891 -#: ../source/changelog.md:944 -#: ../source/changelog.md:1048 -#: ../source/changelog.md:1103 -msgid "@xmgz" +#: ../source/changelog.md:328 +msgid "PR#551 - Translations update (Czech)" msgstr "" -#: ../source/changelog.md:96 -msgid "Version 0.8.9 (2024/09/21)" +#: ../source/changelog.md:329 +msgid "PR#555 - Translations update (Czech)" msgstr "" -#: ../source/changelog.md:98 -msgid "This release introduces a new field: the workout description.
    This field is longer than the \"Notes\" field and will have the same visibility as the workout in a next version (see #125). The \"Notes\" field will remain private." +#: ../source/changelog.md:330 +msgid "#558 - [translations request] Portuguese" msgstr "" -#: ../source/changelog.md:103 -msgid "#610 - Add a description field to workout" +#: ../source/changelog.md:334 +msgid "Czech: 74%" msgstr "" -#: ../source/changelog.md:107 -msgid "#621 - email username may contain special characters" +#: ../source/changelog.md:340 +#: ../source/changelog.md:374 +#: ../source/changelog.md:401 +#: ../source/changelog.md:445 +msgid "Italian: 74%" msgstr "" -#: ../source/changelog.md:108 -msgid "#622 - Fix email sending by adding 'Message-ID'" +#: ../source/changelog.md:341 +#: ../source/changelog.md:375 +#: ../source/changelog.md:402 +#: ../source/changelog.md:446 +#: ../source/changelog.md:495 +msgid "Norwegian Bokmål: 53%" msgstr "" -#: ../source/changelog.md:112 -msgid "PR#616 - Translations update (Dutch)" +#: ../source/changelog.md:342 +#: ../source/changelog.md:376 +#: ../source/changelog.md:403 +#: ../source/changelog.md:447 +msgid "Polish: 88%" msgstr "" -#: ../source/changelog.md:113 -msgid "PR#617 - Translations update (Italian)" +#: ../source/changelog.md:343 +msgid "Portuguese: 100%" msgstr "" -#: ../source/changelog.md:114 -msgid "PR#618 - Translations update (Polish)" +#: ../source/changelog.md:348 +msgid "PR#556 - API - minor refacto" msgstr "" -#: ../source/changelog.md:115 -msgid "PR#620 - Translations update (Polish)" +#: ../source/changelog.md:349 +msgid "PR#557 - API - prepare SQLAlchemy migration" msgstr "" -#: ../source/changelog.md:116 -msgid "PR#624 - Translations update (Spanish)" +#: ../source/changelog.md:353 +msgid "@voodoopt" msgstr "" -#: ../source/changelog.md:117 -msgid "PR#625 - Translations update (Galician and Basque)" +#: ../source/changelog.md:356 +msgid "Version 0.8.3 (2024/05/09)" msgstr "" -#: ../source/changelog.md:118 -msgid "PR#626 - Translations update (German)" +#: ../source/changelog.md:360 +msgid "#546 - workout data are not refreshed after displaying segment" msgstr "" -#: ../source/changelog.md:119 -msgid "PR#631 - Translations update (Basque)" +#: ../source/changelog.md:364 +msgid "PR#545 - Translations update (Basque, Czech)" msgstr "" -#: ../source/changelog.md:123 -msgid "Bulgarian: 99%" +#: ../source/changelog.md:368 +#: ../source/changelog.md:395 +msgid "Czech: 25%" msgstr "" -#: ../source/changelog.md:130 -#: ../source/changelog.md:178 -#: ../source/changelog.md:217 -#: ../source/changelog.md:249 -msgid "Italian: 82%" +#: ../source/changelog.md:382 +msgid "@urtzai" msgstr "" -#: ../source/changelog.md:132 -#: ../source/changelog.md:563 -#: ../source/changelog.md:601 -#: ../source/changelog.md:715 -msgid "Polish: 99%" +#: ../source/changelog.md:385 +msgid "Version 0.8.2 (2024/05/08)" msgstr "" -#: ../source/changelog.md:138 -msgid "PR#628 - Replace markdown library" +#: ../source/changelog.md:389 +msgid "PR#540 - Translations update (German)" msgstr "" -#: ../source/changelog.md:143 -#: ../source/changelog.md:307 -msgid "@byakurau" +#: ../source/changelog.md:390 +msgid "PR#542 - Translations update (Czech)" msgstr "" -#: ../source/changelog.md:144 -msgid "@dotlambda" +#: ../source/changelog.md:391 +msgid "PR#544 - Translations update (German, Czech)" msgstr "" -#: ../source/changelog.md:150 -#: ../source/changelog.md:228 -#: ../source/changelog.md:313 -#: ../source/changelog.md:510 -#: ../source/changelog.md:723 -#: ../source/changelog.md:894 -#: ../source/changelog.md:946 -#: ../source/changelog.md:1050 -#: ../source/changelog.md:1108 -#: ../source/changelog.md:1156 -#: ../source/changelog.md:1204 -#: ../source/changelog.md:1218 -#: ../source/changelog.md:1312 -#: ../source/changelog.md:1351 -msgid "Note: This release contains database migration (see upgrade instructions in documentation)" +#: ../source/changelog.md:394 +#: ../source/changelog.md:438 +msgid "Basque: 88%" msgstr "" -#: ../source/changelog.md:153 -msgid "Version 0.8.8 (2024/09/01)" +#: ../source/changelog.md:408 +msgid "PR#543 - tools - replace black, flake8 and isort with ruff" msgstr "" -#: ../source/changelog.md:155 -msgid "FitTrackee is now available in Bulgarian." +#: ../source/changelog.md:413 +msgid "@OndrejZivny" msgstr "" -#: ../source/changelog.md:159 -msgid "#614 - Labels are not translated on workouts average chart" +#: ../source/changelog.md:417 +msgid "Version 0.8.1 (2024/05/01)" msgstr "" -#: ../source/changelog.md:163 -msgid "PR#607 - Translations update (German)" +#: ../source/changelog.md:421 +msgid "PR#527 - improve Sports endpoints response time" msgstr "" -#: ../source/changelog.md:164 -msgid "#608 - [translations request] Bulgarian" +#: ../source/changelog.md:425 +msgid "PR#531 - Minor navigation fixes on mobile" msgstr "" -#: ../source/changelog.md:165 -msgid "PR#609 - Translations update (Galician and Spanish)" +#: ../source/changelog.md:426 +msgid "PR#532 - Fix footer color on dark mode" msgstr "" -#: ../source/changelog.md:166 -msgid "PR#612 - Translations update (Bulgarian and Czech)" +#: ../source/changelog.md:427 +msgid "PR#536 - Accessibility fixes" msgstr "" -#: ../source/changelog.md:171 -msgid "Bulgarian: 100%" +#: ../source/changelog.md:431 +msgid "PR#526 - Translations update (Dutch, Galician, Norwegian Bokmål)" msgstr "" -#: ../source/changelog.md:172 -#: ../source/changelog.md:243 -#: ../source/changelog.md:289 -msgid "Czech: 73%" +#: ../source/changelog.md:432 +msgid "PR#533 - Translations update (Czech)" msgstr "" -#: ../source/changelog.md:180 -#: ../source/changelog.md:219 -msgid "Polish: 91%" +#: ../source/changelog.md:433 +msgid "#534 - [translations request] Czech" msgstr "" -#: ../source/changelog.md:181 -#: ../source/changelog.md:220 -#: ../source/changelog.md:252 -#: ../source/changelog.md:298 -msgid "Portuguese: 98%" +#: ../source/changelog.md:434 +msgid "PR#537 - Translations update (Spanish)" msgstr "" -#: ../source/changelog.md:187 -#: ../source/changelog.md:352 -#: ../source/changelog.md:381 -#: ../source/changelog.md:412 -#: ../source/changelog.md:460 -msgid "@jmlich" +#: ../source/changelog.md:435 +msgid "PR#538 - Translations update (Galician)" msgstr "" -#: ../source/changelog.md:188 -msgid "@mara21" +#: ../source/changelog.md:439 +msgid "Czech: 15%" msgstr "" -#: ../source/changelog.md:193 -msgid "Version 0.8.7 (2024/08/25)" +#: ../source/changelog.md:444 +msgid "German: 88%" msgstr "" -#: ../source/changelog.md:197 -msgid "#604 - New sport: Swimrun" +#: ../source/changelog.md:452 +msgid "PR#528 - README reworked" msgstr "" -#: ../source/changelog.md:201 -msgid "PR#598 - CLI - fix limit for user data export cleanup" +#: ../source/changelog.md:453 +msgid "PR#530 - specify AGPLv3 license" msgstr "" -#: ../source/changelog.md:205 -msgid "PR#600 - Translations update (Galician)" +#: ../source/changelog.md:457 +#: ../source/changelog.md:758 +#: ../source/changelog.md:1099 +msgid "@comradekingu" msgstr "" -#: ../source/changelog.md:206 -msgid "PR#603 - Translations update (Basque)" +#: ../source/changelog.md:464 +msgid "Version 0.8.0 (2024/04/21)" msgstr "" -#: ../source/changelog.md:215 -#: ../source/changelog.md:492 -#: ../source/changelog.md:711 -#: ../source/changelog.md:1034 -msgid "Galician: 99%" +#: ../source/changelog.md:466 +msgid "FitTrackee now lets you associate equipment with workouts and filter workouts on notes." msgstr "" -#: ../source/changelog.md:216 -#: ../source/changelog.md:294 -msgid "German: 98%" +#: ../source/changelog.md:470 +msgid "#259 - Feature request: \"Equipment\" that can be associated with workouts" msgstr "" -#: ../source/changelog.md:221 -#: ../source/changelog.md:716 -msgid "Spanish: 99%" +#: ../source/changelog.md:471 +msgid "#512 - Add ability to filter on notes in Workouts List" msgstr "" -#: ../source/changelog.md:231 -msgid "Version 0.8.6 (2024/08/03)" +#: ../source/changelog.md:475 +msgid "#508 - Stopped speed threshold unit is missing on sports list" msgstr "" -#: ../source/changelog.md:235 -msgid "PR#590 - Translations update (Italian)" +#: ../source/changelog.md:476 +msgid "3b6fa25 - fix workouts table display on small resolutions" msgstr "" -#: ../source/changelog.md:236 -msgid "PR#591 - Translations update (Galician)" +#: ../source/changelog.md:477 +msgid "51758b4 - fix on filters hiding on small resolutions" msgstr "" -#: ../source/changelog.md:237 -msgid "PR#592 - Translations update (German, Dutch)" +#: ../source/changelog.md:481 +msgid "PR#507 Translations update (Galician)" msgstr "" -#: ../source/changelog.md:238 -msgid "PR#593 - Translations update (German)" +#: ../source/changelog.md:482 +msgid "PR#510 Translations update (Spanish)" msgstr "" -#: ../source/changelog.md:239 -msgid "fb10602 - update and fix translations" +#: ../source/changelog.md:483 +msgid "PR#511 Translations update (Galician)" msgstr "" -#: ../source/changelog.md:242 -#: ../source/changelog.md:288 -msgid "Basque: 98%" +#: ../source/changelog.md:484 +msgid "PR#521 Translations update (Spanish)" msgstr "" -#: ../source/changelog.md:244 -#: ../source/changelog.md:526 -#: ../source/changelog.md:632 -#: ../source/changelog.md:747 -#: ../source/changelog.md:788 -#: ../source/changelog.md:830 -#: ../source/changelog.md:978 -msgid "Dutch: 100%" +#: ../source/changelog.md:485 +msgid "PR#524 Translations update (Spanish)" msgstr "" -#: ../source/changelog.md:248 -#: ../source/changelog.md:712 -#: ../source/changelog.md:1003 -#: ../source/changelog.md:1035 -msgid "German: 99%" +#: ../source/changelog.md:488 +msgid "Basque: 89%" msgstr "" -#: ../source/changelog.md:251 -#: ../source/changelog.md:297 -msgid "Polish: 92%" +#: ../source/changelog.md:489 +msgid "Dutch: 89%" msgstr "" -#: ../source/changelog.md:257 -msgid "PR#595 - CI - speed up tests" +#: ../source/changelog.md:493 +msgid "German: 89%" msgstr "" -#: ../source/changelog.md:261 -msgid "@ConfusedAlex" +#: ../source/changelog.md:494 +msgid "Italian: 75%" msgstr "" -#: ../source/changelog.md:262 -msgid "@lukasitaly" +#: ../source/changelog.md:496 +msgid "Polish: 89%" msgstr "" -#: ../source/changelog.md:263 -msgid "@simontb" +#: ../source/changelog.md:501 +msgid "#502 - Remove deprecated commands" msgstr "" -#: ../source/changelog.md:264 -msgid "@slothje" +#: ../source/changelog.md:502 +msgid "PR#506 - CLI - update database commands" msgstr "" -#: ../source/changelog.md:268 -msgid "Version 0.8.5 (2024/06/29)" +#: ../source/changelog.md:506 +#: ../source/changelog.md:1047 +#: ../source/changelog.md:1100 +#: ../source/changelog.md:1186 +msgid "@jat255" msgstr "" -#: ../source/changelog.md:272 -msgid "#566 - [Feature] Improved statistics section with average calculation" +#: ../source/changelog.md:513 +msgid "Version 0.7.32 (2024/03/10)" msgstr "" -#: ../source/changelog.md:273 -msgid "PR#575 - Add page to display sport statistics" +#: ../source/changelog.md:517 +msgid "#504 - Database migrations fail" msgstr "" -#: ../source/changelog.md:274 -msgid "PR#587 - Improve user forms" +#: ../source/changelog.md:521 +msgid "#496 Translations update (Dutch)" msgstr "" -#: ../source/changelog.md:278 -msgid "PR#588 - Fix click on workout chart checkbox labels" +#: ../source/changelog.md:522 +msgid "#499 Translations update (Polish)" msgstr "" -#: ../source/changelog.md:282 -msgid "PR#564 - Translations update (Dutch)" +#: ../source/changelog.md:531 +#: ../source/changelog.md:561 +#: ../source/changelog.md:599 +#: ../source/changelog.md:637 +#: ../source/changelog.md:674 +msgid "Italian: 84%" msgstr "" -#: ../source/changelog.md:283 -msgid "PR#565 - Translations update (Polish)" +#: ../source/changelog.md:532 +#: ../source/changelog.md:562 +#: ../source/changelog.md:600 +#: ../source/changelog.md:638 +#: ../source/changelog.md:675 +msgid "Norwegian Bokmål: 60%" msgstr "" -#: ../source/changelog.md:284 -msgid "PR#571 - Translations update (Galician, Spanish)" +#: ../source/changelog.md:538 +#: ../source/changelog.md:841 +#: ../source/changelog.md:1044 +#: ../source/changelog.md:1098 +#: ../source/changelog.md:1153 +#: ../source/changelog.md:1185 +msgid "@bjornclauw" msgstr "" -#: ../source/changelog.md:285 -msgid "PR#582 - Translations update (Galician, Spanish)" +#: ../source/changelog.md:542 +msgid "Version 0.7.31 (2024/02/10)" msgstr "" -#: ../source/changelog.md:290 -#: ../source/changelog.md:335 -#: ../source/changelog.md:369 -#: ../source/changelog.md:396 -#: ../source/changelog.md:440 -#: ../source/changelog.md:669 -#: ../source/changelog.md:1083 -msgid "Dutch: 98%" +#: ../source/changelog.md:544 +msgid "Basque is now available in FitTrackee interface." msgstr "" -#: ../source/changelog.md:295 -msgid "Italian: 73%" +#: ../source/changelog.md:548 +msgid "PR#495 - fix menu display when clicking on application name" msgstr "" -#: ../source/changelog.md:303 -msgid "PR#583 - Simplify docker deployment" +#: ../source/changelog.md:552 +msgid "#490 [Translation Request] EU - Basque" msgstr "" -#: ../source/changelog.md:309 -#: ../source/changelog.md:459 -msgid "@jderuiter" +#: ../source/changelog.md:568 +msgid "PR#494 - Update install-python command" msgstr "" -#: ../source/changelog.md:310 -msgid "@pluja" +#: ../source/changelog.md:575 +msgid "Version 0.7.30 (2024/02/04)" msgstr "" -#: ../source/changelog.md:316 -msgid "Version 0.8.4 (2024/05/22)" +#: ../source/changelog.md:579 +msgid "b748459 - Update alert message colors on dark mode" msgstr "" -#: ../source/changelog.md:318 -msgid "FitTrackee is now available in Portuguese." +#: ../source/changelog.md:583 +msgid "PR#481 - Handle keyboard navigation on dropdowns" msgstr "" -#: ../source/changelog.md:322 -msgid "f2aec30 - add password strength estimation for Czech" +#: ../source/changelog.md:584 +msgid "3821e37 - Make calendar arrows accessible to keyboard navigation" msgstr "" -#: ../source/changelog.md:323 -msgid "#563 - CLI - init language preference on user creation" +#: ../source/changelog.md:585 +msgid "PR#488 - CLI - fix user creation when user already exists with same email" msgstr "" -#: ../source/changelog.md:327 -msgid "#550 - Typo: par page instead of per page" +#: ../source/changelog.md:586 +msgid "PR#489 - Handle keyboard navigation on calendar" msgstr "" -#: ../source/changelog.md:328 -msgid "PR#551 - Translations update (Czech)" +#: ../source/changelog.md:590 +msgid "PR#482 - Translations update (Galician and Spanish)" msgstr "" -#: ../source/changelog.md:329 -msgid "PR#555 - Translations update (Czech)" +#: ../source/changelog.md:591 +msgid "PR#484 - Translations update (German)" msgstr "" -#: ../source/changelog.md:330 -msgid "#558 - [translations request] Portuguese" +#: ../source/changelog.md:606 +msgid "aff4d68 - CI - update actions version" msgstr "" -#: ../source/changelog.md:334 -msgid "Czech: 74%" +#: ../source/changelog.md:615 +msgid "Version 0.7.29 (2024/01/06)" msgstr "" -#: ../source/changelog.md:340 -#: ../source/changelog.md:374 -#: ../source/changelog.md:401 -#: ../source/changelog.md:445 -msgid "Italian: 74%" +#: ../source/changelog.md:619 +msgid "8aa4cff - Update loader color on dark theme" msgstr "" -#: ../source/changelog.md:341 -#: ../source/changelog.md:375 -#: ../source/changelog.md:402 -#: ../source/changelog.md:446 -#: ../source/changelog.md:495 -msgid "Norwegian Bokmål: 53%" +#: ../source/changelog.md:620 +msgid "#478 - Make application name clickable to access dashboard" msgstr "" -#: ../source/changelog.md:342 -#: ../source/changelog.md:376 -#: ../source/changelog.md:403 -#: ../source/changelog.md:447 -msgid "Polish: 88%" +#: ../source/changelog.md:624 +msgid "PR#479 - Minor fixes on UI" msgstr "" -#: ../source/changelog.md:343 -msgid "Portuguese: 100%" +#: ../source/changelog.md:628 +msgid "PR#476 - Translations update (Polish)" msgstr "" -#: ../source/changelog.md:348 -msgid "PR#556 - API - minor refacto" +#: ../source/changelog.md:629 +msgid "PR#477 - Translations update (Dutch)" msgstr "" -#: ../source/changelog.md:349 -msgid "PR#557 - API - prepare SQLAlchemy migration" +#: ../source/changelog.md:644 +msgid "PR#475 - Build - use poetry-core instead of poetry" msgstr "" -#: ../source/changelog.md:353 -msgid "@voodoopt" +#: ../source/changelog.md:648 +msgid "@traxys" msgstr "" -#: ../source/changelog.md:356 -msgid "Version 0.8.3 (2024/05/09)" +#: ../source/changelog.md:650 +msgid "Koen" msgstr "" -#: ../source/changelog.md:360 -msgid "#546 - workout data are not refreshed after displaying segment" +#: ../source/changelog.md:653 +msgid "Version 0.7.28 (2023/12/23)" msgstr "" -#: ../source/changelog.md:364 -msgid "PR#545 - Translations update (Basque, Czech)" +#: ../source/changelog.md:657 +msgid "PR#474 - Improve links display" msgstr "" -#: ../source/changelog.md:368 -#: ../source/changelog.md:395 -msgid "Czech: 25%" +#: ../source/changelog.md:661 +msgid "6e215aa - fix background color on dark theme when modal is displayed" msgstr "" -#: ../source/changelog.md:382 -msgid "@urtzai" +#: ../source/changelog.md:665 +msgid "PR#473 - Translations update (Galician, Spanish and German)" msgstr "" -#: ../source/changelog.md:385 -msgid "Version 0.8.2 (2024/05/08)" +#: ../source/changelog.md:685 +msgid "Version 0.7.27 (2023/12/20)" msgstr "" -#: ../source/changelog.md:389 -msgid "PR#540 - Translations update (German)" +#: ../source/changelog.md:689 +msgid "#113 - add a dark mode" msgstr "" -#: ../source/changelog.md:390 -msgid "PR#542 - Translations update (Czech)" +#: ../source/changelog.md:690 +msgid "PR#464 - Update user preferences display" msgstr "" -#: ../source/changelog.md:391 -msgid "PR#544 - Translations update (German, Czech)" +#: ../source/changelog.md:691 +msgid "PR#471 - add new sport: \"Cycling (Trekking)\"" msgstr "" -#: ../source/changelog.md:394 -#: ../source/changelog.md:438 -msgid "Basque: 88%" +#: ../source/changelog.md:695 +msgid "PR#469 - change UI display only on login ou user preferences update" msgstr "" -#: ../source/changelog.md:408 -msgid "PR#543 - tools - replace black, flake8 and isort with ruff" +#: ../source/changelog.md:696 +msgid "PR#472 - fix redirection when resetting password" msgstr "" -#: ../source/changelog.md:413 -msgid "@OndrejZivny" +#: ../source/changelog.md:700 +msgid "PR#468 - Translations update (Galician & Spanish)" msgstr "" -#: ../source/changelog.md:417 -msgid "Version 0.8.1 (2024/05/01)" +#: ../source/changelog.md:704 +msgid "#456 - Drop PostgreSQL 11 support" msgstr "" -#: ../source/changelog.md:421 -msgid "PR#527 - improve Sports endpoints response time" +#: ../source/changelog.md:713 +#: ../source/changelog.md:752 +#: ../source/changelog.md:793 +#: ../source/changelog.md:835 +#: ../source/changelog.md:884 +#: ../source/changelog.md:936 +msgid "Italian: 85%" msgstr "" -#: ../source/changelog.md:425 -msgid "PR#531 - Minor navigation fixes on mobile" +#: ../source/changelog.md:714 +#: ../source/changelog.md:753 +msgid "Norwegian Bokmål: 61%" msgstr "" -#: ../source/changelog.md:426 -msgid "PR#532 - Fix footer color on dark mode" +#: ../source/changelog.md:719 +msgid "@DavidHenryThoreau" msgstr "" -#: ../source/changelog.md:427 -msgid "PR#536 - Accessibility fixes" +#: ../source/changelog.md:726 +msgid "Version 0.7.26 (2023/11/19)" msgstr "" -#: ../source/changelog.md:431 -msgid "PR#526 - Translations update (Dutch, Galician, Norwegian Bokmål)" +#: ../source/changelog.md:730 +msgid "#224 - Missing elevation results in incorrect ascent/descent total" msgstr "" -#: ../source/changelog.md:432 -msgid "PR#533 - Translations update (Czech)" +#: ../source/changelog.md:735 +msgid "PR#444 - Translations update (Norwegian Bokmål)" msgstr "" -#: ../source/changelog.md:433 -msgid "#534 - [translations request] Czech" +#: ../source/changelog.md:740 +msgid "In addition to dependencies update:" msgstr "" -#: ../source/changelog.md:434 -msgid "PR#537 - Translations update (Spanish)" +#: ../source/changelog.md:742 +msgid "PR#449 - Update vue, tooling and chart library" msgstr "" -#: ../source/changelog.md:435 -msgid "PR#538 - Translations update (Galician)" +#: ../source/changelog.md:743 +msgid "PR#450 - Update gpxpy to 1.6.1" msgstr "" -#: ../source/changelog.md:439 -msgid "Czech: 15%" +#: ../source/changelog.md:761 +msgid "Version 0.7.25 (2023/10/08)" msgstr "" -#: ../source/changelog.md:444 -msgid "German: 88%" +#: ../source/changelog.md:765 +msgid "#441 - Errors after upgrade to 0.7.24" msgstr "" -#: ../source/changelog.md:452 -msgid "PR#528 - README reworked" +#: ../source/changelog.md:768 +msgid "Version 0.7.24 (2023/10/04)" msgstr "" -#: ../source/changelog.md:453 -msgid "PR#530 - specify AGPLv3 license" +#: ../source/changelog.md:772 +msgid "PR#433 - Handle encoded password in EMAIL_URL" msgstr "" -#: ../source/changelog.md:457 -#: ../source/changelog.md:758 -#: ../source/changelog.md:1099 -msgid "@comradekingu" +#: ../source/changelog.md:776 +msgid "PR#427 - fix typos and translations + refacto" msgstr "" -#: ../source/changelog.md:464 -msgid "Version 0.8.0 (2024/04/21)" +#: ../source/changelog.md:777 +msgid "PR#431 - Translations update (Galician)" msgstr "" -#: ../source/changelog.md:466 -msgid "FitTrackee now lets you associate equipment with workouts and filter workouts on notes." +#: ../source/changelog.md:781 +msgid "PR#428 - CI - Add PostgreSQL 16" msgstr "" -#: ../source/changelog.md:470 -msgid "#259 - Feature request: \"Equipment\" that can be associated with workouts" +#: ../source/changelog.md:782 +msgid "2bcff2e - API - update Flask to 3.0+" msgstr "" -#: ../source/changelog.md:471 -msgid "#512 - Add ability to filter on notes in Workouts List" +#: ../source/changelog.md:783 +msgid "PR#436 - CI - Add Python 3.12" msgstr "" -#: ../source/changelog.md:475 -msgid "#508 - Stopped speed threshold unit is missing on sports list" +#: ../source/changelog.md:784 +msgid "PR#438 - CI - update workflows" msgstr "" -#: ../source/changelog.md:476 -msgid "3b6fa25 - fix workouts table display on small resolutions" +#: ../source/changelog.md:794 +#: ../source/changelog.md:836 +#: ../source/changelog.md:885 +#: ../source/changelog.md:937 +#: ../source/changelog.md:1089 +msgid "Norwegian Bokmål: 35%" msgstr "" -#: ../source/changelog.md:477 -msgid "51758b4 - fix on filters hiding on small resolutions" +#: ../source/changelog.md:802 +msgid "Version 0.7.23 (2023/09/14)" msgstr "" -#: ../source/changelog.md:481 -msgid "PR#507 Translations update (Galician)" +#: ../source/changelog.md:806 +msgid "PR#421 - remove darksky from available weather providers in .env" msgstr "" -#: ../source/changelog.md:482 -msgid "PR#510 Translations update (Spanish)" +#: ../source/changelog.md:807 +msgid "PR#426 - Update default tile server (thanks to @astridx)" msgstr "" -#: ../source/changelog.md:483 -msgid "PR#511 Translations update (Galician)" +#: ../source/changelog.md:811 +msgid "PR#422 - CI - fix e2e tests with packaged version" msgstr "" -#: ../source/changelog.md:484 -msgid "PR#521 Translations update (Spanish)" +#: ../source/changelog.md:814 +msgid "Version 0.7.22 (2023/08/23)" msgstr "" -#: ../source/changelog.md:485 -msgid "PR#524 Translations update (Spanish)" +#: ../source/changelog.md:818 +msgid "PR#411 - Fix various typos" msgstr "" -#: ../source/changelog.md:488 -msgid "Basque: 89%" +#: ../source/changelog.md:819 +msgid "PR#416 - fix modal navigation and closing" msgstr "" -#: ../source/changelog.md:489 -msgid "Dutch: 89%" +#: ../source/changelog.md:824 +msgid "PR#410 - Translations update (German)" msgstr "" -#: ../source/changelog.md:493 -msgid "German: 89%" +#: ../source/changelog.md:825 +msgid "PR#415 - Translations update (Polish)" msgstr "" - -#: ../source/changelog.md:494 -msgid "Italian: 75%" + +#: ../source/changelog.md:826 +msgid "PR#417 - Translations update (Polish)" msgstr "" -#: ../source/changelog.md:496 -msgid "Polish: 89%" +#: ../source/changelog.md:827 +msgid "PR#418 - Translations update (Dutch)" msgstr "" -#: ../source/changelog.md:501 -msgid "#502 - Remove deprecated commands" +#: ../source/changelog.md:843 +#: ../source/changelog.md:892 +msgid "Mariusz" msgstr "" -#: ../source/changelog.md:502 -msgid "PR#506 - CLI - update database commands" +#: ../source/changelog.md:846 +msgid "Version 0.7.21 (2023/07/30)" msgstr "" -#: ../source/changelog.md:506 -#: ../source/changelog.md:1047 -#: ../source/changelog.md:1100 -#: ../source/changelog.md:1186 -msgid "@jat255" +#: ../source/changelog.md:850 +msgid "#407 - Workout display error when speeds are zero" msgstr "" -#: ../source/changelog.md:513 -msgid "Version 0.7.32 (2024/03/10)" +#: ../source/changelog.md:855 +msgid "PR#409 - CI - update actions version" msgstr "" -#: ../source/changelog.md:517 -msgid "#504 - Database migrations fail" +#: ../source/changelog.md:858 +msgid "Version 0.7.20 (2023/07/22)" msgstr "" -#: ../source/changelog.md:521 -msgid "#496 Translations update (Dutch)" +#: ../source/changelog.md:862 +msgid "#400 - Add new sport: open water swimming" msgstr "" -#: ../source/changelog.md:522 -msgid "#499 Translations update (Polish)" +#: ../source/changelog.md:867 +msgid "PR#398 - Fix language dropdown label" msgstr "" -#: ../source/changelog.md:531 -#: ../source/changelog.md:561 -#: ../source/changelog.md:599 -#: ../source/changelog.md:637 -#: ../source/changelog.md:674 -msgid "Italian: 84%" +#: ../source/changelog.md:868 +msgid "#402 - handle gpx file without elevation" msgstr "" -#: ../source/changelog.md:532 -#: ../source/changelog.md:562 -#: ../source/changelog.md:600 -#: ../source/changelog.md:638 -#: ../source/changelog.md:675 -msgid "Norwegian Bokmål: 60%" +#: ../source/changelog.md:873 +msgid "PR#399 - Translations update (Galician)" msgstr "" -#: ../source/changelog.md:538 -#: ../source/changelog.md:841 -#: ../source/changelog.md:1044 -#: ../source/changelog.md:1098 -#: ../source/changelog.md:1153 -#: ../source/changelog.md:1185 -msgid "@bjornclauw" +#: ../source/changelog.md:874 +msgid "PR#401 - Translations update (Galician and Polish)" msgstr "" -#: ../source/changelog.md:542 -msgid "Version 0.7.31 (2024/02/10)" +#: ../source/changelog.md:875 +msgid "PR#406 - Translations update (Galician and Spanish)" msgstr "" -#: ../source/changelog.md:544 -msgid "Basque is now available in FitTrackee interface." +#: ../source/changelog.md:879 +#: ../source/changelog.md:931 +msgid "Dutch: 97%" msgstr "" -#: ../source/changelog.md:548 -msgid "PR#495 - fix menu display when clicking on application name" +#: ../source/changelog.md:883 +#: ../source/changelog.md:935 +msgid "German: 97%" msgstr "" -#: ../source/changelog.md:552 -msgid "#490 [Translation Request] EU - Basque" +#: ../source/changelog.md:886 +msgid "Polish: 56%" msgstr "" -#: ../source/changelog.md:568 -msgid "PR#494 - Update install-python command" +#: ../source/changelog.md:897 +msgid "Version 0.7.19 (2023/07/15)" msgstr "" -#: ../source/changelog.md:575 -msgid "Version 0.7.30 (2024/02/04)" +#: ../source/changelog.md:901 +msgid "PR#380 - Update documentation link" msgstr "" -#: ../source/changelog.md:579 -msgid "b748459 - Update alert message colors on dark mode" +#: ../source/changelog.md:902 +msgid "#390 - Improve UI" msgstr "" -#: ../source/changelog.md:583 -msgid "PR#481 - Handle keyboard navigation on dropdowns" +#: ../source/changelog.md:903 +msgid "#391 - Add new sport: paragliding" msgstr "" -#: ../source/changelog.md:584 -msgid "3821e37 - Make calendar arrows accessible to keyboard navigation" +#: ../source/changelog.md:908 +msgid "#384 - Inconsistent page with between workout with and without GPS data" msgstr "" -#: ../source/changelog.md:585 -msgid "PR#488 - CLI - fix user creation when user already exists with same email" +#: ../source/changelog.md:909 +msgid "#393 - PIL.Image module has no attribute ANTIALIAS" msgstr "" -#: ../source/changelog.md:586 -msgid "PR#489 - Handle keyboard navigation on calendar" +#: ../source/changelog.md:914 +msgid "PR#394 - Translations update (Galician)" msgstr "" -#: ../source/changelog.md:590 -msgid "PR#482 - Translations update (Galician and Spanish)" +#: ../source/changelog.md:915 +msgid "PR#397 - Translations update (Spanish)" msgstr "" -#: ../source/changelog.md:591 -msgid "PR#484 - Translations update (German)" +#: ../source/changelog.md:918 +#: ../source/changelog.md:965 +#: ../source/changelog.md:1287 +msgid "Documentation" msgstr "" -#: ../source/changelog.md:606 -msgid "aff4d68 - CI - update actions version" +#: ../source/changelog.md:920 +msgid "PR#386 - Minor fix in CONTRIBUTING.md" msgstr "" -#: ../source/changelog.md:615 -msgid "Version 0.7.29 (2024/01/06)" +#: ../source/changelog.md:921 +msgid "PR#388 - Minor typo in CONTRIBUTING.md" msgstr "" -#: ../source/changelog.md:619 -msgid "8aa4cff - Update loader color on dark theme" +#: ../source/changelog.md:926 +msgid "#395 - CI - test a packaged version of FitTrackee" msgstr "" -#: ../source/changelog.md:620 -msgid "#478 - Make application name clickable to access dashboard" +#: ../source/changelog.md:927 +msgid "cc3fe1c CI - update python and postgresql default versions" msgstr "" -#: ../source/changelog.md:624 -msgid "PR#479 - Minor fixes on UI" +#: ../source/changelog.md:934 +msgid "Galician: 98%" msgstr "" -#: ../source/changelog.md:628 -msgid "PR#476 - Translations update (Polish)" +#: ../source/changelog.md:938 +msgid "Polish: 42%" msgstr "" -#: ../source/changelog.md:629 -msgid "PR#477 - Translations update (Dutch)" +#: ../source/changelog.md:942 +msgid "@dkm" msgstr "" -#: ../source/changelog.md:644 -msgid "PR#475 - Build - use poetry-core instead of poetry" +#: ../source/changelog.md:949 +msgid "Version 0.7.18 (2023/06/25)" msgstr "" -#: ../source/changelog.md:648 -msgid "@traxys" +#: ../source/changelog.md:951 +msgid "Polish is available in FitTrackee interface (partially translated).
    Documentation is now translated in French (note: documentation translations are not yet available on Weblate)." msgstr "" -#: ../source/changelog.md:650 -msgid "Koen" +#: ../source/changelog.md:954 +msgid "Important: Python 3.7 is no longer supported, the minimum version is now Python 3.8.1." msgstr "" -#: ../source/changelog.md:653 -msgid "Version 0.7.28 (2023/12/23)" +#: ../source/changelog.md:958 +msgid "#351 - [Translation Request] Polish" msgstr "" -#: ../source/changelog.md:657 -msgid "PR#474 - Improve links display" +#: ../source/changelog.md:959 +msgid "PR#370 - Translations update (Dutch, thanks to @bjornclauw)" msgstr "" -#: ../source/changelog.md:661 -msgid "6e215aa - fix background color on dark theme when modal is displayed" +#: ../source/changelog.md:960 +msgid "PR#371 - Translations update (Polish, thanks to Mariusz on Weblate)" msgstr "" -#: ../source/changelog.md:665 -msgid "PR#473 - Translations update (Galician, Spanish and German)" +#: ../source/changelog.md:961 +msgid "PR#375 - Translations update (French, thanks to @Thovi98)" msgstr "" -#: ../source/changelog.md:685 -msgid "Version 0.7.27 (2023/12/20)" +#: ../source/changelog.md:962 +msgid "PR#376 - Translations update (German, thanks to @qwerty287)" msgstr "" -#: ../source/changelog.md:689 -msgid "#113 - add a dark mode" +#: ../source/changelog.md:967 +msgid "1375986 - Change documentation theme for Furo" msgstr "" -#: ../source/changelog.md:690 -msgid "PR#464 - Update user preferences display" +#: ../source/changelog.md:968 +msgid "#377 - Init documentation translation" msgstr "" -#: ../source/changelog.md:691 -msgid "PR#471 - add new sport: \"Cycling (Trekking)\"" +#: ../source/changelog.md:973 +msgid "#354 - Drop support for Python 3.7" msgstr "" -#: ../source/changelog.md:695 -msgid "PR#469 - change UI display only on login ou user preferences update" +#: ../source/changelog.md:974 +msgid "PR#374 - Docker - install fittrackee in a virtualenv" msgstr "" -#: ../source/changelog.md:696 -msgid "PR#472 - fix redirection when resetting password" +#: ../source/changelog.md:983 +#: ../source/changelog.md:1004 +#: ../source/changelog.md:1036 +#: ../source/changelog.md:1088 +msgid "Italian: 87%" msgstr "" -#: ../source/changelog.md:700 -msgid "PR#468 - Translations update (Galician & Spanish)" +#: ../source/changelog.md:984 +#: ../source/changelog.md:1005 +#: ../source/changelog.md:1037 +msgid "Norwegian Bokmål: 36%" msgstr "" -#: ../source/changelog.md:704 -msgid "#456 - Drop PostgreSQL 11 support" +#: ../source/changelog.md:985 +msgid "Polish: 43%" msgstr "" -#: ../source/changelog.md:713 -#: ../source/changelog.md:752 -#: ../source/changelog.md:793 -#: ../source/changelog.md:835 -#: ../source/changelog.md:884 -#: ../source/changelog.md:936 -msgid "Italian: 85%" +#: ../source/changelog.md:988 +msgid "Thanks to all contributors." msgstr "" -#: ../source/changelog.md:714 -#: ../source/changelog.md:753 -msgid "Norwegian Bokmål: 61%" +#: ../source/changelog.md:991 +msgid "Version 0.7.17 (2023/06/03)" msgstr "" -#: ../source/changelog.md:719 -msgid "@DavidHenryThoreau" +#: ../source/changelog.md:995 +msgid "PR#366, PR#369 - Translations update from Hosted Weblate (Galician, thanks to @xmgz)" msgstr "" -#: ../source/changelog.md:726 -msgid "Version 0.7.26 (2023/11/19)" +#: ../source/changelog.md:996 +msgid "PR#367 - Translations update (Spanish, French)" msgstr "" -#: ../source/changelog.md:730 -msgid "#224 - Missing elevation results in incorrect ascent/descent total" +#: ../source/changelog.md:1006 +#: ../source/changelog.md:1038 +msgid "Polish: 3%" msgstr "" -#: ../source/changelog.md:735 -msgid "PR#444 - Translations update (Norwegian Bokmål)" +#: ../source/changelog.md:1010 +msgid "Version 0.7.16 (2023/05/29)" msgstr "" -#: ../source/changelog.md:740 -msgid "In addition to dependencies update:" +#: ../source/changelog.md:1014 +msgid "PR#358 - Add user preference for filtering of GPX speed data" msgstr "" -#: ../source/changelog.md:742 -msgid "PR#449 - Update vue, tooling and chart library" +#: ../source/changelog.md:1019 +msgid "#359 - Footer overlaps content on user preferences page" msgstr "" -#: ../source/changelog.md:743 -msgid "PR#450 - Update gpxpy to 1.6.1" +#: ../source/changelog.md:1024 +msgid "PR#350 - Translations update from Hosted Weblate (Galician)" msgstr "" -#: ../source/changelog.md:761 -msgid "Version 0.7.25 (2023/10/08)" +#: ../source/changelog.md:1025 +msgid "PR#352 - Translations update from Hosted Weblate (Dutch)" msgstr "" -#: ../source/changelog.md:765 -msgid "#441 - Errors after upgrade to 0.7.24" +#: ../source/changelog.md:1026 +msgid "PR#356 - Init Polish translation files" msgstr "" -#: ../source/changelog.md:768 -msgid "Version 0.7.24 (2023/10/04)" +#: ../source/changelog.md:1027 +msgid "PR#357 - Translations update from Hosted Weblate (Polish)" msgstr "" -#: ../source/changelog.md:772 -msgid "PR#433 - Handle encoded password in EMAIL_URL" +#: ../source/changelog.md:1028 +msgid "PR#365 - Translations update from Hosted Weblate (Spanish)" msgstr "" -#: ../source/changelog.md:776 -msgid "PR#427 - fix typos and translations + refacto" +#: ../source/changelog.md:1041 +msgid "Note: Polish is not yet available in FitTrackee interface." msgstr "" -#: ../source/changelog.md:777 -msgid "PR#431 - Translations update (Galician)" +#: ../source/changelog.md:1046 +msgid "@gnu-ewm" msgstr "" -#: ../source/changelog.md:781 -msgid "PR#428 - CI - Add PostgreSQL 16" +#: ../source/changelog.md:1053 +msgid "Version 0.7.15 (2023/04/12)" msgstr "" -#: ../source/changelog.md:782 -msgid "2bcff2e - API - update Flask to 3.0+" +#: ../source/changelog.md:1055 +msgid "Among enhancements and fixes, FitTrackee is now available in Galician, Spanish and partially in Norwegian Bokmål (see translation status below)." msgstr "" -#: ../source/changelog.md:783 -msgid "PR#436 - CI - Add Python 3.12" +#: ../source/changelog.md:1057 +msgid "Note: DarkSky API support is removed, since the service shut down on March 31, 2023." msgstr "" -#: ../source/changelog.md:784 -msgid "PR#438 - CI - update workflows" +#: ../source/changelog.md:1062 +msgid "#319 - Add cli to create users" msgstr "" -#: ../source/changelog.md:794 -#: ../source/changelog.md:836 -#: ../source/changelog.md:885 -#: ../source/changelog.md:937 -#: ../source/changelog.md:1089 -msgid "Norwegian Bokmål: 35%" +#: ../source/changelog.md:1063 +msgid "#329 - Make \"start elevation axis at zero\" sticky" msgstr "" -#: ../source/changelog.md:802 -msgid "Version 0.7.23 (2023/09/14)" +#: ../source/changelog.md:1064 +msgid "#333 - Feature request: filter workouts by title" msgstr "" -#: ../source/changelog.md:806 -msgid "PR#421 - remove darksky from available weather providers in .env" +#: ../source/changelog.md:1065 +msgid "#338 - Display relevant error message when
  • ${n}
  • -`}checkbox({checked:t}){return"'}paragraph({tokens:t}){return`

    ${this.parser.parseInline(t)}

    -`}table(t){let n="",a="";for(let r=0;r${s}
    - -`+n+` -`+s+`
    -`}tablerow({text:t}){return`

  • + +`+n+` +`+s+`
    +`}tablerow({text:t}){return` +${t} +`}tablecell(t){const n=this.parser.parseInline(t.tokens),a=t.header?"th":"td";return(t.align?`<${a} align="${t.align}">`:`<${a}>`)+n+` +`}strong({tokens:t}){return`${this.parser.parseInline(t)}`}em({tokens:t}){return`${this.parser.parseInline(t)}`}codespan({text:t}){return`${Aa(t,!0)}`}br(t){return"
    "}del({tokens:t}){return`${this.parser.parseInline(t)}`}link({href:t,title:n,tokens:a}){const s=this.parser.parseInline(a),o=nf(t);if(o===null)return s;t=o;let i='
    ",i}image({href:t,title:n,text:a}){const s=nf(t);if(s===null)return Aa(a);t=s;let o=`${a}{const l=r[u].flat(1/0);a=a.concat(this.walkTokens(l,n))}):r.tokens&&(a=a.concat(this.walkTokens(r.tokens,n)))}}return a}use(...t){const n=this.defaults.extensions||{renderers:{},childTokens:{}};return t.forEach(a=>{const s={...a};if(s.async=this.defaults.async||s.async||!1,a.extensions&&(a.extensions.forEach(o=>{if(!o.name)throw new Error("extension name required");if("renderer"in o){const i=n.renderers[o.name];i?n.renderers[o.name]=function(...r){let u=o.renderer.apply(this,r);return u===!1&&(u=i.apply(this,r)),u}:n.renderers[o.name]=o.renderer}if("tokenizer"in o){if(!o.level||o.level!=="block"&&o.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const i=n[o.level];i?i.unshift(o.tokenizer):n[o.level]=[o.tokenizer],o.start&&(o.level==="block"?n.startBlock?n.startBlock.push(o.start):n.startBlock=[o.start]:o.level==="inline"&&(n.startInline?n.startInline.push(o.start):n.startInline=[o.start]))}"childTokens"in o&&o.childTokens&&(n.childTokens[o.name]=o.childTokens)}),s.extensions=n),a.renderer){const o=this.defaults.renderer||new qu(this.defaults);for(const i in a.renderer){if(!(i in o))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;const r=i,u=a.renderer[r],l=o[r];o[r]=(...d)=>{let E=u.apply(o,d);return E===!1&&(E=l.apply(o,d)),E||""}}s.renderer=o}if(a.tokenizer){const o=this.defaults.tokenizer||new Ku(this.defaults);for(const i in a.tokenizer){if(!(i in o))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;const r=i,u=a.tokenizer[r],l=o[r];o[r]=(...d)=>{let E=u.apply(o,d);return E===!1&&(E=l.apply(o,d)),E}}s.tokenizer=o}if(a.hooks){const o=this.defaults.hooks||new gi;for(const i in a.hooks){if(!(i in o))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;const r=i,u=a.hooks[r],l=o[r];gi.passThroughHooks.has(i)?o[r]=d=>{if(this.defaults.async)return Promise.resolve(u.call(o,d)).then(c=>l.call(o,c));const E=u.call(o,d);return l.call(o,E)}:o[r]=(...d)=>{let E=u.apply(o,d);return E===!1&&(E=l.apply(o,d)),E}}s.hooks=o}if(a.walkTokens){const o=this.defaults.walkTokens,i=a.walkTokens;s.walkTokens=function(r){let u=[];return u.push(i.call(this,r)),o&&(u=u.concat(o.call(this,r))),u}}this.defaults={...this.defaults,...s}}),this}setOptions(t){return this.defaults={...this.defaults,...t},this}lexer(t,n){return jn.lex(t,n??this.defaults)}parser(t,n){return Yn.parse(t,n??this.defaults)}parseMarkdown(t){return(a,s)=>{const o={...s},i={...this.defaults,...o},r=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&o.async===!1)return r(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof a>"u"||a===null)return r(new Error("marked(): input parameter is undefined or null"));if(typeof a!="string")return r(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(a)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=t);const u=i.hooks?i.hooks.provideLexer():t?jn.lex:jn.lexInline,l=i.hooks?i.hooks.provideParser():t?Yn.parse:Yn.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(a):a).then(d=>u(d,i)).then(d=>i.hooks?i.hooks.processAllTokens(d):d).then(d=>i.walkTokens?Promise.all(this.walkTokens(d,i.walkTokens)).then(()=>d):d).then(d=>l(d,i)).then(d=>i.hooks?i.hooks.postprocess(d):d).catch(r);try{i.hooks&&(a=i.hooks.preprocess(a));let d=u(a,i);i.hooks&&(d=i.hooks.processAllTokens(d)),i.walkTokens&&this.walkTokens(d,i.walkTokens);let E=l(d,i);return i.hooks&&(E=i.hooks.postprocess(E)),E}catch(d){return r(d)}}}onError(t,n){return a=>{if(a.message+=` +Please report this to https://github.com/markedjs/marked.`,t){const s="

    An error occurred:

    "+Aa(a.message+"",!0)+"
    ";return n?Promise.resolve(s):s}if(n)return Promise.reject(a);throw a}}}const Vs=new hVe;function St(e,t){return Vs.parse(e,t)}St.options=St.setOptions=function(e){return Vs.setOptions(e),St.defaults=Vs.defaults,nO(St.defaults),St};St.getDefaults=Rp;St.defaults=js;St.use=function(...e){return Vs.use(...e),St.defaults=Vs.defaults,nO(St.defaults),St};St.walkTokens=function(e,t){return Vs.walkTokens(e,t)};St.parseInline=Vs.parseInline;St.Parser=Yn;St.parser=Yn.parse;St.Renderer=qu;St.TextRenderer=Lp;St.Lexer=jn;St.lexer=jn.lex;St.Tokenizer=Ku;St.Hooks=gi;St.parse=St;St.options;St.setOptions;St.use;St.walkTokens;St.parseInline;Yn.parse;jn.lex;var ea={},ta={},vc={},na={},br={},of;function SVe(){return of||(of=1,Object.defineProperty(br,"__esModule",{value:!0}),br.default=new Uint16Array('ᵁ<Õıʊҝջאٵ۞ޢߖࠏ੊ઑඡ๭༉༦჊ረዡᐕᒝᓃᓟᔥ\0\0\0\0\0\0ᕫᛍᦍᰒᷝ὾⁠↰⊍⏀⏻⑂⠤⤒ⴈ⹈⿎〖㊺㘹㞬㣾㨨㩱㫠㬮ࠀEMabcfglmnoprstu\\bfms„‹•˜¦³¹ÈÏlig耻Æ䃆P耻&䀦cute耻Á䃁reve;䄂Āiyx}rc耻Â䃂;䐐r;쀀𝔄rave耻À䃀pha;䎑acr;䄀d;橓Āgp¡on;䄄f;쀀𝔸plyFunction;恡ing耻Å䃅Ācs¾Ãr;쀀𝒜ign;扔ilde耻Ã䃃ml耻Ä䃄ЀaceforsuåûþėĜĢħĪĀcrêòkslash;或Ŷöø;櫧ed;挆y;䐑ƀcrtąċĔause;戵noullis;愬a;䎒r;쀀𝔅pf;쀀𝔹eve;䋘còēmpeq;扎܀HOacdefhilorsuōőŖƀƞƢƵƷƺǜȕɳɸɾcy;䐧PY耻©䂩ƀcpyŝŢźute;䄆Ā;iŧŨ拒talDifferentialD;慅leys;愭ȀaeioƉƎƔƘron;䄌dil耻Ç䃇rc;䄈nint;戰ot;䄊ĀdnƧƭilla;䂸terDot;䂷òſi;䎧rcleȀDMPTLJNjǑǖot;抙inus;抖lus;投imes;抗oĀcsǢǸkwiseContourIntegral;戲eCurlyĀDQȃȏoubleQuote;思uote;怙ȀlnpuȞȨɇɕonĀ;eȥȦ户;橴ƀgitȯȶȺruent;扡nt;戯ourIntegral;戮ĀfrɌɎ;愂oduct;成nterClockwiseContourIntegral;戳oss;樯cr;쀀𝒞pĀ;Cʄʅ拓ap;才րDJSZacefiosʠʬʰʴʸˋ˗ˡ˦̳ҍĀ;oŹʥtrahd;椑cy;䐂cy;䐅cy;䐏ƀgrsʿ˄ˇger;怡r;憡hv;櫤Āayː˕ron;䄎;䐔lĀ;t˝˞戇a;䎔r;쀀𝔇Āaf˫̧Ācm˰̢riticalȀADGT̖̜̀̆cute;䂴oŴ̋̍;䋙bleAcute;䋝rave;䁠ilde;䋜ond;拄ferentialD;慆Ѱ̽\0\0\0͔͂\0Ѕf;쀀𝔻ƀ;DE͈͉͍䂨ot;惜qual;扐blèCDLRUVͣͲ΂ϏϢϸontourIntegraìȹoɴ͹\0\0ͻ»͉nArrow;懓Āeo·ΤftƀARTΐΖΡrrow;懐ightArrow;懔eåˊngĀLRΫτeftĀARγιrrow;柸ightArrow;柺ightArrow;柹ightĀATϘϞrrow;懒ee;抨pɁϩ\0\0ϯrrow;懑ownArrow;懕erticalBar;戥ǹABLRTaВЪаўѿͼrrowƀ;BUНОТ憓ar;椓pArrow;懵reve;䌑eft˒к\0ц\0ѐightVector;楐eeVector;楞ectorĀ;Bљњ憽ar;楖ightǔѧ\0ѱeeVector;楟ectorĀ;BѺѻ懁ar;楗eeĀ;A҆҇护rrow;憧ĀctҒҗr;쀀𝒟rok;䄐ࠀNTacdfglmopqstuxҽӀӄӋӞӢӧӮӵԡԯԶՒ՝ՠեG;䅊H耻Ð䃐cute耻É䃉ƀaiyӒӗӜron;䄚rc耻Ê䃊;䐭ot;䄖r;쀀𝔈rave耻È䃈ement;戈ĀapӺӾcr;䄒tyɓԆ\0\0ԒmallSquare;旻erySmallSquare;斫ĀgpԦԪon;䄘f;쀀𝔼silon;䎕uĀaiԼՉlĀ;TՂՃ橵ilde;扂librium;懌Āci՗՚r;愰m;橳a;䎗ml耻Ë䃋Āipժկsts;戃onentialE;慇ʀcfiosօֈ֍ֲ׌y;䐤r;쀀𝔉lledɓ֗\0\0֣mallSquare;旼erySmallSquare;斪Ͱֺ\0ֿ\0\0ׄf;쀀𝔽All;戀riertrf;愱cò׋؀JTabcdfgorstר׬ׯ׺؀ؒؖ؛؝أ٬ٲcy;䐃耻>䀾mmaĀ;d׷׸䎓;䏜reve;䄞ƀeiy؇،ؐdil;䄢rc;䄜;䐓ot;䄠r;쀀𝔊;拙pf;쀀𝔾eater̀EFGLSTصلَٖٛ٦qualĀ;Lؾؿ扥ess;招ullEqual;执reater;檢ess;扷lantEqual;橾ilde;扳cr;쀀𝒢;扫ЀAacfiosuڅڋږڛڞڪھۊRDcy;䐪Āctڐڔek;䋇;䁞irc;䄤r;愌lbertSpace;愋ǰگ\0ڲf;愍izontalLine;攀Āctۃۅòکrok;䄦mpńېۘownHumðįqual;扏܀EJOacdfgmnostuۺ۾܃܇܎ܚܞܡܨ݄ݸދޏޕcy;䐕lig;䄲cy;䐁cute耻Í䃍Āiyܓܘrc耻Î䃎;䐘ot;䄰r;愑rave耻Ì䃌ƀ;apܠܯܿĀcgܴܷr;䄪inaryI;慈lieóϝǴ݉\0ݢĀ;eݍݎ戬Āgrݓݘral;戫section;拂isibleĀCTݬݲomma;恣imes;恢ƀgptݿރވon;䄮f;쀀𝕀a;䎙cr;愐ilde;䄨ǫޚ\0ޞcy;䐆l耻Ï䃏ʀcfosuެ޷޼߂ߐĀiyޱ޵rc;䄴;䐙r;쀀𝔍pf;쀀𝕁ǣ߇\0ߌr;쀀𝒥rcy;䐈kcy;䐄΀HJacfosߤߨ߽߬߱ࠂࠈcy;䐥cy;䐌ppa;䎚Āey߶߻dil;䄶;䐚r;쀀𝔎pf;쀀𝕂cr;쀀𝒦րJTaceflmostࠥࠩࠬࡐࡣ঳সে্਷ੇcy;䐉耻<䀼ʀcmnpr࠷࠼ࡁࡄࡍute;䄹bda;䎛g;柪lacetrf;愒r;憞ƀaeyࡗ࡜ࡡron;䄽dil;䄻;䐛Āfsࡨ॰tԀACDFRTUVarࡾࢩࢱࣦ࣠ࣼयज़ΐ४Ānrࢃ࢏gleBracket;柨rowƀ;BR࢙࢚࢞憐ar;懤ightArrow;懆eiling;挈oǵࢷ\0ࣃbleBracket;柦nǔࣈ\0࣒eeVector;楡ectorĀ;Bࣛࣜ懃ar;楙loor;挊ightĀAV࣯ࣵrrow;憔ector;楎Āerँगeƀ;AVउऊऐ抣rrow;憤ector;楚iangleƀ;BEतथऩ抲ar;槏qual;抴pƀDTVषूौownVector;楑eeVector;楠ectorĀ;Bॖॗ憿ar;楘ectorĀ;B॥०憼ar;楒ightáΜs̀EFGLSTॾঋকঝঢভqualGreater;拚ullEqual;扦reater;扶ess;檡lantEqual;橽ilde;扲r;쀀𝔏Ā;eঽা拘ftarrow;懚idot;䄿ƀnpw৔ਖਛgȀLRlr৞৷ਂਐeftĀAR০৬rrow;柵ightArrow;柷ightArrow;柶eftĀarγਊightáοightáϊf;쀀𝕃erĀLRਢਬeftArrow;憙ightArrow;憘ƀchtਾੀੂòࡌ;憰rok;䅁;扪Ѐacefiosuਗ਼੝੠੷੼અઋ઎p;椅y;䐜Ādl੥੯iumSpace;恟lintrf;愳r;쀀𝔐nusPlus;戓pf;쀀𝕄cò੶;䎜ҀJacefostuણધભીଔଙඑ඗ඞcy;䐊cute;䅃ƀaey઴હાron;䅇dil;䅅;䐝ƀgswે૰଎ativeƀMTV૓૟૨ediumSpace;怋hiĀcn૦૘ë૙eryThiî૙tedĀGL૸ଆreaterGreateòٳessLesóੈLine;䀊r;쀀𝔑ȀBnptଢନଷ଺reak;恠BreakingSpace;䂠f;愕ڀ;CDEGHLNPRSTV୕ୖ୪୼஡௫ఄ౞಄ದ೘ൡඅ櫬Āou୛୤ngruent;扢pCap;扭oubleVerticalBar;戦ƀlqxஃஊ஛ement;戉ualĀ;Tஒஓ扠ilde;쀀≂̸ists;戄reater΀;EFGLSTஶஷ஽௉௓௘௥扯qual;扱ullEqual;쀀≧̸reater;쀀≫̸ess;批lantEqual;쀀⩾̸ilde;扵umpń௲௽ownHump;쀀≎̸qual;쀀≏̸eĀfsఊధtTriangleƀ;BEచఛడ拪ar;쀀⧏̸qual;括s̀;EGLSTవశ఼ౄోౘ扮qual;扰reater;扸ess;쀀≪̸lantEqual;쀀⩽̸ilde;扴estedĀGL౨౹reaterGreater;쀀⪢̸essLess;쀀⪡̸recedesƀ;ESಒಓಛ技qual;쀀⪯̸lantEqual;拠ĀeiಫಹverseElement;戌ghtTriangleƀ;BEೋೌ೒拫ar;쀀⧐̸qual;拭ĀquೝഌuareSuĀbp೨೹setĀ;E೰ೳ쀀⊏̸qual;拢ersetĀ;Eഃആ쀀⊐̸qual;拣ƀbcpഓതൎsetĀ;Eഛഞ쀀⊂⃒qual;抈ceedsȀ;ESTലള഻െ抁qual;쀀⪰̸lantEqual;拡ilde;쀀≿̸ersetĀ;E൘൛쀀⊃⃒qual;抉ildeȀ;EFT൮൯൵ൿ扁qual;扄ullEqual;扇ilde;扉erticalBar;戤cr;쀀𝒩ilde耻Ñ䃑;䎝܀Eacdfgmoprstuvලෂ෉෕ෛ෠෧෼ขภยา฿ไlig;䅒cute耻Ó䃓Āiy෎ීrc耻Ô䃔;䐞blac;䅐r;쀀𝔒rave耻Ò䃒ƀaei෮ෲ෶cr;䅌ga;䎩cron;䎟pf;쀀𝕆enCurlyĀDQฎบoubleQuote;怜uote;怘;橔Āclวฬr;쀀𝒪ash耻Ø䃘iŬื฼de耻Õ䃕es;樷ml耻Ö䃖erĀBP๋๠Āar๐๓r;怾acĀek๚๜;揞et;掴arenthesis;揜Ҁacfhilors๿ງຊຏຒດຝະ໼rtialD;戂y;䐟r;쀀𝔓i;䎦;䎠usMinus;䂱Āipຢອncareplanåڝf;愙Ȁ;eio຺ູ໠໤檻cedesȀ;EST່້໏໚扺qual;檯lantEqual;扼ilde;找me;怳Ādp໩໮uct;戏ortionĀ;aȥ໹l;戝Āci༁༆r;쀀𝒫;䎨ȀUfos༑༖༛༟OT耻"䀢r;쀀𝔔pf;愚cr;쀀𝒬؀BEacefhiorsu༾གྷཇའཱིྦྷྪྭ႖ႩႴႾarr;椐G耻®䂮ƀcnrཎནབute;䅔g;柫rĀ;tཛྷཝ憠l;椖ƀaeyཧཬཱron;䅘dil;䅖;䐠Ā;vླྀཹ愜erseĀEUྂྙĀlq྇ྎement;戋uilibrium;懋pEquilibrium;楯r»ཹo;䎡ghtЀACDFTUVa࿁࿫࿳ဢဨၛႇϘĀnr࿆࿒gleBracket;柩rowƀ;BL࿜࿝࿡憒ar;懥eftArrow;懄eiling;按oǵ࿹\0စbleBracket;柧nǔည\0နeeVector;楝ectorĀ;Bဝသ懂ar;楕loor;挋Āerိ၃eƀ;AVဵံြ抢rrow;憦ector;楛iangleƀ;BEၐၑၕ抳ar;槐qual;抵pƀDTVၣၮၸownVector;楏eeVector;楜ectorĀ;Bႂႃ憾ar;楔ectorĀ;B႑႒懀ar;楓Āpuႛ႞f;愝ndImplies;楰ightarrow;懛ĀchႹႼr;愛;憱leDelayed;槴ڀHOacfhimoqstuფჱჷჽᄙᄞᅑᅖᅡᅧᆵᆻᆿĀCcჩხHcy;䐩y;䐨FTcy;䐬cute;䅚ʀ;aeiyᄈᄉᄎᄓᄗ檼ron;䅠dil;䅞rc;䅜;䐡r;쀀𝔖ortȀDLRUᄪᄴᄾᅉownArrow»ОeftArrow»࢚ightArrow»࿝pArrow;憑gma;䎣allCircle;战pf;쀀𝕊ɲᅭ\0\0ᅰt;戚areȀ;ISUᅻᅼᆉᆯ斡ntersection;抓uĀbpᆏᆞsetĀ;Eᆗᆘ抏qual;抑ersetĀ;Eᆨᆩ抐qual;抒nion;抔cr;쀀𝒮ar;拆ȀbcmpᇈᇛሉላĀ;sᇍᇎ拐etĀ;Eᇍᇕqual;抆ĀchᇠህeedsȀ;ESTᇭᇮᇴᇿ扻qual;檰lantEqual;扽ilde;承Tháྌ;我ƀ;esሒሓሣ拑rsetĀ;Eሜም抃qual;抇et»ሓրHRSacfhiorsሾቄ቉ቕ቞ቱቶኟዂወዑORN耻Þ䃞ADE;愢ĀHc቎ቒcy;䐋y;䐦Ābuቚቜ;䀉;䎤ƀaeyብቪቯron;䅤dil;䅢;䐢r;쀀𝔗Āeiቻ኉Dzኀ\0ኇefore;戴a;䎘Ācn኎ኘkSpace;쀀  Space;怉ldeȀ;EFTካኬኲኼ戼qual;扃ullEqual;扅ilde;扈pf;쀀𝕋ipleDot;惛Āctዖዛr;쀀𝒯rok;䅦ૡዷጎጚጦ\0ጬጱ\0\0\0\0\0ጸጽ፷ᎅ\0᏿ᐄᐊᐐĀcrዻጁute耻Ú䃚rĀ;oጇገ憟cir;楉rǣጓ\0጖y;䐎ve;䅬Āiyጞጣrc耻Û䃛;䐣blac;䅰r;쀀𝔘rave耻Ù䃙acr;䅪Ādiፁ፩erĀBPፈ፝Āarፍፐr;䁟acĀekፗፙ;揟et;掵arenthesis;揝onĀ;P፰፱拃lus;抎Āgp፻፿on;䅲f;쀀𝕌ЀADETadps᎕ᎮᎸᏄϨᏒᏗᏳrrowƀ;BDᅐᎠᎤar;椒ownArrow;懅ownArrow;憕quilibrium;楮eeĀ;AᏋᏌ报rrow;憥ownáϳerĀLRᏞᏨeftArrow;憖ightArrow;憗iĀ;lᏹᏺ䏒on;䎥ing;䅮cr;쀀𝒰ilde;䅨ml耻Ü䃜ҀDbcdefosvᐧᐬᐰᐳᐾᒅᒊᒐᒖash;披ar;櫫y;䐒ashĀ;lᐻᐼ抩;櫦Āerᑃᑅ;拁ƀbtyᑌᑐᑺar;怖Ā;iᑏᑕcalȀBLSTᑡᑥᑪᑴar;戣ine;䁼eparator;杘ilde;所ThinSpace;怊r;쀀𝔙pf;쀀𝕍cr;쀀𝒱dash;抪ʀcefosᒧᒬᒱᒶᒼirc;䅴dge;拀r;쀀𝔚pf;쀀𝕎cr;쀀𝒲Ȁfiosᓋᓐᓒᓘr;쀀𝔛;䎞pf;쀀𝕏cr;쀀𝒳ҀAIUacfosuᓱᓵᓹᓽᔄᔏᔔᔚᔠcy;䐯cy;䐇cy;䐮cute耻Ý䃝Āiyᔉᔍrc;䅶;䐫r;쀀𝔜pf;쀀𝕐cr;쀀𝒴ml;䅸ЀHacdefosᔵᔹᔿᕋᕏᕝᕠᕤcy;䐖cute;䅹Āayᕄᕉron;䅽;䐗ot;䅻Dzᕔ\0ᕛoWidtè૙a;䎖r;愨pf;愤cr;쀀𝒵௡ᖃᖊᖐ\0ᖰᖶᖿ\0\0\0\0ᗆᗛᗫᙟ᙭\0ᚕ᚛ᚲᚹ\0ᚾcute耻á䃡reve;䄃̀;Ediuyᖜᖝᖡᖣᖨᖭ戾;쀀∾̳;房rc耻â䃢te肻´̆;䐰lig耻æ䃦Ā;r²ᖺ;쀀𝔞rave耻à䃠ĀepᗊᗖĀfpᗏᗔsym;愵èᗓha;䎱ĀapᗟcĀclᗤᗧr;䄁g;樿ɤᗰ\0\0ᘊʀ;adsvᗺᗻᗿᘁᘇ戧nd;橕;橜lope;橘;橚΀;elmrszᘘᘙᘛᘞᘿᙏᙙ戠;榤e»ᘙsdĀ;aᘥᘦ戡ѡᘰᘲᘴᘶᘸᘺᘼᘾ;榨;榩;榪;榫;榬;榭;榮;榯tĀ;vᙅᙆ戟bĀ;dᙌᙍ抾;榝Āptᙔᙗh;戢»¹arr;捼Āgpᙣᙧon;䄅f;쀀𝕒΀;Eaeiop዁ᙻᙽᚂᚄᚇᚊ;橰cir;橯;扊d;手s;䀧roxĀ;e዁ᚒñᚃing耻å䃥ƀctyᚡᚦᚨr;쀀𝒶;䀪mpĀ;e዁ᚯñʈilde耻ã䃣ml耻ä䃤Āciᛂᛈoninôɲnt;樑ࠀNabcdefiklnoprsu᛭ᛱᜰ᜼ᝃᝈ᝸᝽០៦ᠹᡐᜍ᤽᥈ᥰot;櫭Ācrᛶ᜞kȀcepsᜀᜅᜍᜓong;扌psilon;䏶rime;怵imĀ;e᜚᜛戽q;拍Ŷᜢᜦee;抽edĀ;gᜬᜭ挅e»ᜭrkĀ;t፜᜷brk;掶Āoyᜁᝁ;䐱quo;怞ʀcmprtᝓ᝛ᝡᝤᝨausĀ;eĊĉptyv;榰séᜌnoõēƀahwᝯ᝱ᝳ;䎲;愶een;扬r;쀀𝔟g΀costuvwឍឝឳេ៕៛៞ƀaiuបពរðݠrc;旯p»፱ƀdptឤឨឭot;樀lus;樁imes;樂ɱឹ\0\0ើcup;樆ar;昅riangleĀdu៍្own;施p;斳plus;樄eåᑄåᒭarow;植ƀako៭ᠦᠵĀcn៲ᠣkƀlst៺֫᠂ozenge;槫riangleȀ;dlr᠒᠓᠘᠝斴own;斾eft;旂ight;斸k;搣Ʊᠫ\0ᠳƲᠯ\0ᠱ;斒;斑4;斓ck;斈ĀeoᠾᡍĀ;qᡃᡆ쀀=⃥uiv;쀀≡⃥t;挐Ȁptwxᡙᡞᡧᡬf;쀀𝕓Ā;tᏋᡣom»Ꮜtie;拈؀DHUVbdhmptuvᢅᢖᢪᢻᣗᣛᣬ᣿ᤅᤊᤐᤡȀLRlrᢎᢐᢒᢔ;敗;敔;敖;敓ʀ;DUduᢡᢢᢤᢦᢨ敐;敦;敩;敤;敧ȀLRlrᢳᢵᢷᢹ;敝;敚;敜;教΀;HLRhlrᣊᣋᣍᣏᣑᣓᣕ救;敬;散;敠;敫;敢;敟ox;槉ȀLRlrᣤᣦᣨᣪ;敕;敒;攐;攌ʀ;DUduڽ᣷᣹᣻᣽;敥;敨;攬;攴inus;抟lus;択imes;抠ȀLRlrᤙᤛᤝ᤟;敛;敘;攘;攔΀;HLRhlrᤰᤱᤳᤵᤷ᤻᤹攂;敪;敡;敞;攼;攤;攜Āevģ᥂bar耻¦䂦Ȁceioᥑᥖᥚᥠr;쀀𝒷mi;恏mĀ;e᜚᜜lƀ;bhᥨᥩᥫ䁜;槅sub;柈Ŭᥴ᥾lĀ;e᥹᥺怢t»᥺pƀ;Eeįᦅᦇ;檮Ā;qۜۛೡᦧ\0᧨ᨑᨕᨲ\0ᨷᩐ\0\0᪴\0\0᫁\0\0ᬡᬮ᭍᭒\0᯽\0ᰌƀcpr᦭ᦲ᧝ute;䄇̀;abcdsᦿᧀᧄ᧊᧕᧙戩nd;橄rcup;橉Āau᧏᧒p;橋p;橇ot;橀;쀀∩︀Āeo᧢᧥t;恁îړȀaeiu᧰᧻ᨁᨅǰ᧵\0᧸s;橍on;䄍dil耻ç䃧rc;䄉psĀ;sᨌᨍ橌m;橐ot;䄋ƀdmnᨛᨠᨦil肻¸ƭptyv;榲t脀¢;eᨭᨮ䂢räƲr;쀀𝔠ƀceiᨽᩀᩍy;䑇ckĀ;mᩇᩈ朓ark»ᩈ;䏇r΀;Ecefms᩟᩠ᩢᩫ᪤᪪᪮旋;槃ƀ;elᩩᩪᩭ䋆q;扗eɡᩴ\0\0᪈rrowĀlr᩼᪁eft;憺ight;憻ʀRSacd᪒᪔᪖᪚᪟»ཇ;擈st;抛irc;抚ash;抝nint;樐id;櫯cir;槂ubsĀ;u᪻᪼晣it»᪼ˬ᫇᫔᫺\0ᬊonĀ;eᫍᫎ䀺Ā;qÇÆɭ᫙\0\0᫢aĀ;t᫞᫟䀬;䁀ƀ;fl᫨᫩᫫戁îᅠeĀmx᫱᫶ent»᫩eóɍǧ᫾\0ᬇĀ;dኻᬂot;橭nôɆƀfryᬐᬔᬗ;쀀𝕔oäɔ脀©;sŕᬝr;愗Āaoᬥᬩrr;憵ss;朗Ācuᬲᬷr;쀀𝒸Ābpᬼ᭄Ā;eᭁᭂ櫏;櫑Ā;eᭉᭊ櫐;櫒dot;拯΀delprvw᭠᭬᭷ᮂᮬᯔ᯹arrĀlr᭨᭪;椸;椵ɰ᭲\0\0᭵r;拞c;拟arrĀ;p᭿ᮀ憶;椽̀;bcdosᮏᮐᮖᮡᮥᮨ截rcap;橈Āauᮛᮞp;橆p;橊ot;抍r;橅;쀀∪︀Ȁalrv᮵ᮿᯞᯣrrĀ;mᮼᮽ憷;椼yƀevwᯇᯔᯘqɰᯎ\0\0ᯒreã᭳uã᭵ee;拎edge;拏en耻¤䂤earrowĀlrᯮ᯳eft»ᮀight»ᮽeäᯝĀciᰁᰇoninôǷnt;戱lcty;挭ঀAHabcdefhijlorstuwz᰸᰻᰿ᱝᱩᱵᲊᲞᲬᲷ᳻᳿ᴍᵻᶑᶫᶻ᷆᷍rò΁ar;楥Ȁglrs᱈ᱍ᱒᱔ger;怠eth;愸òᄳhĀ;vᱚᱛ怐»ऊūᱡᱧarow;椏aã̕Āayᱮᱳron;䄏;䐴ƀ;ao̲ᱼᲄĀgrʿᲁr;懊tseq;橷ƀglmᲑᲔᲘ耻°䂰ta;䎴ptyv;榱ĀirᲣᲨsht;楿;쀀𝔡arĀlrᲳᲵ»ࣜ»သʀaegsv᳂͸᳖᳜᳠mƀ;oș᳊᳔ndĀ;ș᳑uit;晦amma;䏝in;拲ƀ;io᳧᳨᳸䃷de脀÷;o᳧ᳰntimes;拇nø᳷cy;䑒cɯᴆ\0\0ᴊrn;挞op;挍ʀlptuwᴘᴝᴢᵉᵕlar;䀤f;쀀𝕕ʀ;emps̋ᴭᴷᴽᵂqĀ;d͒ᴳot;扑inus;戸lus;戔quare;抡blebarwedgåúnƀadhᄮᵝᵧownarrowóᲃarpoonĀlrᵲᵶefôᲴighôᲶŢᵿᶅkaro÷གɯᶊ\0\0ᶎrn;挟op;挌ƀcotᶘᶣᶦĀryᶝᶡ;쀀𝒹;䑕l;槶rok;䄑Ādrᶰᶴot;拱iĀ;fᶺ᠖斿Āah᷀᷃ròЩaòྦangle;榦Āci᷒ᷕy;䑟grarr;柿ऀDacdefglmnopqrstuxḁḉḙḸոḼṉṡṾấắẽỡἪἷὄ὎὚ĀDoḆᴴoôᲉĀcsḎḔute耻é䃩ter;橮ȀaioyḢḧḱḶron;䄛rĀ;cḭḮ扖耻ê䃪lon;払;䑍ot;䄗ĀDrṁṅot;扒;쀀𝔢ƀ;rsṐṑṗ檚ave耻è䃨Ā;dṜṝ檖ot;檘Ȁ;ilsṪṫṲṴ檙nters;揧;愓Ā;dṹṺ檕ot;檗ƀapsẅẉẗcr;䄓tyƀ;svẒẓẕ戅et»ẓpĀ1;ẝẤijạả;怄;怅怃ĀgsẪẬ;䅋p;怂ĀgpẴẸon;䄙f;쀀𝕖ƀalsỄỎỒrĀ;sỊị拕l;槣us;橱iƀ;lvỚớở䎵on»ớ;䏵ȀcsuvỪỳἋἣĀioữḱrc»Ḯɩỹ\0\0ỻíՈantĀglἂἆtr»ṝess»Ṻƀaeiἒ἖Ἒls;䀽st;扟vĀ;DȵἠD;橸parsl;槥ĀDaἯἳot;打rr;楱ƀcdiἾὁỸr;愯oô͒ĀahὉὋ;䎷耻ð䃰Āmrὓὗl耻ë䃫o;悬ƀcipὡὤὧl;䀡sôծĀeoὬὴctatioîՙnentialåչৡᾒ\0ᾞ\0ᾡᾧ\0\0ῆῌ\0ΐ\0ῦῪ \0 ⁚llingdotseñṄy;䑄male;晀ƀilrᾭᾳ῁lig;耀ffiɩᾹ\0\0᾽g;耀ffig;耀ffl;쀀𝔣lig;耀filig;쀀fjƀaltῙ῜ῡt;晭ig;耀flns;斱of;䆒ǰ΅\0ῳf;쀀𝕗ĀakֿῷĀ;vῼ´拔;櫙artint;樍Āao‌⁕Ācs‑⁒ႉ‸⁅⁈\0⁐β•‥‧‪‬\0‮耻½䂽;慓耻¼䂼;慕;慙;慛Ƴ‴\0‶;慔;慖ʴ‾⁁\0\0⁃耻¾䂾;慗;慜5;慘ƶ⁌\0⁎;慚;慝8;慞l;恄wn;挢cr;쀀𝒻ࢀEabcdefgijlnorstv₂₉₟₥₰₴⃰⃵⃺⃿℃ℒℸ̗ℾ⅒↞Ā;lٍ₇;檌ƀcmpₐₕ₝ute;䇵maĀ;dₜ᳚䎳;檆reve;䄟Āiy₪₮rc;䄝;䐳ot;䄡Ȁ;lqsؾق₽⃉ƀ;qsؾٌ⃄lanô٥Ȁ;cdl٥⃒⃥⃕c;檩otĀ;o⃜⃝檀Ā;l⃢⃣檂;檄Ā;e⃪⃭쀀⋛︀s;檔r;쀀𝔤Ā;gٳ؛mel;愷cy;䑓Ȁ;Eajٚℌℎℐ;檒;檥;檤ȀEaesℛℝ℩ℴ;扩pĀ;p℣ℤ檊rox»ℤĀ;q℮ℯ檈Ā;q℮ℛim;拧pf;쀀𝕘Āci⅃ⅆr;愊mƀ;el٫ⅎ⅐;檎;檐茀>;cdlqr׮ⅠⅪⅮⅳⅹĀciⅥⅧ;檧r;橺ot;拗Par;榕uest;橼ʀadelsↄⅪ←ٖ↛ǰ↉\0↎proø₞r;楸qĀlqؿ↖lesó₈ií٫Āen↣↭rtneqq;쀀≩︀Å↪ԀAabcefkosy⇄⇇⇱⇵⇺∘∝∯≨≽ròΠȀilmr⇐⇔⇗⇛rsðᒄf»․ilôکĀdr⇠⇤cy;䑊ƀ;cwࣴ⇫⇯ir;楈;憭ar;意irc;䄥ƀalr∁∎∓rtsĀ;u∉∊晥it»∊lip;怦con;抹r;쀀𝔥sĀew∣∩arow;椥arow;椦ʀamopr∺∾≃≞≣rr;懿tht;戻kĀlr≉≓eftarrow;憩ightarrow;憪f;쀀𝕙bar;怕ƀclt≯≴≸r;쀀𝒽asè⇴rok;䄧Ābp⊂⊇ull;恃hen»ᱛૡ⊣\0⊪\0⊸⋅⋎\0⋕⋳\0\0⋸⌢⍧⍢⍿\0⎆⎪⎴cute耻í䃭ƀ;iyݱ⊰⊵rc耻î䃮;䐸Ācx⊼⊿y;䐵cl耻¡䂡ĀfrΟ⋉;쀀𝔦rave耻ì䃬Ȁ;inoܾ⋝⋩⋮Āin⋢⋦nt;樌t;戭fin;槜ta;愩lig;䄳ƀaop⋾⌚⌝ƀcgt⌅⌈⌗r;䄫ƀelpܟ⌏⌓inåގarôܠh;䄱f;抷ed;䆵ʀ;cfotӴ⌬⌱⌽⍁are;愅inĀ;t⌸⌹戞ie;槝doô⌙ʀ;celpݗ⍌⍐⍛⍡al;抺Āgr⍕⍙eróᕣã⍍arhk;樗rod;樼Ȁcgpt⍯⍲⍶⍻y;䑑on;䄯f;쀀𝕚a;䎹uest耻¿䂿Āci⎊⎏r;쀀𝒾nʀ;EdsvӴ⎛⎝⎡ӳ;拹ot;拵Ā;v⎦⎧拴;拳Ā;iݷ⎮lde;䄩ǫ⎸\0⎼cy;䑖l耻ï䃯̀cfmosu⏌⏗⏜⏡⏧⏵Āiy⏑⏕rc;䄵;䐹r;쀀𝔧ath;䈷pf;쀀𝕛ǣ⏬\0⏱r;쀀𝒿rcy;䑘kcy;䑔Ѐacfghjos␋␖␢␧␭␱␵␻ppaĀ;v␓␔䎺;䏰Āey␛␠dil;䄷;䐺r;쀀𝔨reen;䄸cy;䑅cy;䑜pf;쀀𝕜cr;쀀𝓀஀ABEHabcdefghjlmnoprstuv⑰⒁⒆⒍⒑┎┽╚▀♎♞♥♹♽⚚⚲⛘❝❨➋⟀⠁⠒ƀart⑷⑺⑼rò৆òΕail;椛arr;椎Ā;gঔ⒋;檋ar;楢ॣ⒥\0⒪\0⒱\0\0\0\0\0⒵Ⓔ\0ⓆⓈⓍ\0⓹ute;䄺mptyv;榴raîࡌbda;䎻gƀ;dlࢎⓁⓃ;榑åࢎ;檅uo耻«䂫rЀ;bfhlpst࢙ⓞⓦⓩ⓫⓮⓱⓵Ā;f࢝ⓣs;椟s;椝ë≒p;憫l;椹im;楳l;憢ƀ;ae⓿─┄檫il;椙Ā;s┉┊檭;쀀⪭︀ƀabr┕┙┝rr;椌rk;杲Āak┢┬cĀek┨┪;䁻;䁛Āes┱┳;榋lĀdu┹┻;榏;榍Ȁaeuy╆╋╖╘ron;䄾Ādi═╔il;䄼ìࢰâ┩;䐻Ȁcqrs╣╦╭╽a;椶uoĀ;rนᝆĀdu╲╷har;楧shar;楋h;憲ʀ;fgqs▋▌উ◳◿扤tʀahlrt▘▤▷◂◨rrowĀ;t࢙□aé⓶arpoonĀdu▯▴own»њp»०eftarrows;懇ightƀahs◍◖◞rrowĀ;sࣴࢧarpoonó྘quigarro÷⇰hreetimes;拋ƀ;qs▋ও◺lanôবʀ;cdgsব☊☍☝☨c;檨otĀ;o☔☕橿Ā;r☚☛檁;檃Ā;e☢☥쀀⋚︀s;檓ʀadegs☳☹☽♉♋pproøⓆot;拖qĀgq♃♅ôউgtò⒌ôছiíলƀilr♕࣡♚sht;楼;쀀𝔩Ā;Eজ♣;檑š♩♶rĀdu▲♮Ā;l॥♳;楪lk;斄cy;䑙ʀ;achtੈ⚈⚋⚑⚖rò◁orneòᴈard;楫ri;旺Āio⚟⚤dot;䅀ustĀ;a⚬⚭掰che»⚭ȀEaes⚻⚽⛉⛔;扨pĀ;p⛃⛄檉rox»⛄Ā;q⛎⛏檇Ā;q⛎⚻im;拦Ѐabnoptwz⛩⛴⛷✚✯❁❇❐Ānr⛮⛱g;柬r;懽rëࣁgƀlmr⛿✍✔eftĀar০✇ightá৲apsto;柼ightá৽parrowĀlr✥✩efô⓭ight;憬ƀafl✶✹✽r;榅;쀀𝕝us;樭imes;樴š❋❏st;戗áፎƀ;ef❗❘᠀旊nge»❘arĀ;l❤❥䀨t;榓ʀachmt❳❶❼➅➇ròࢨorneòᶌarĀ;d྘➃;業;怎ri;抿̀achiqt➘➝ੀ➢➮➻quo;怹r;쀀𝓁mƀ;egল➪➬;檍;檏Ābu┪➳oĀ;rฟ➹;怚rok;䅂萀<;cdhilqrࠫ⟒☹⟜⟠⟥⟪⟰Āci⟗⟙;檦r;橹reå◲mes;拉arr;楶uest;橻ĀPi⟵⟹ar;榖ƀ;ef⠀भ᠛旃rĀdu⠇⠍shar;楊har;楦Āen⠗⠡rtneqq;쀀≨︀Å⠞܀Dacdefhilnopsu⡀⡅⢂⢎⢓⢠⢥⢨⣚⣢⣤ઃ⣳⤂Dot;戺Ȁclpr⡎⡒⡣⡽r耻¯䂯Āet⡗⡙;時Ā;e⡞⡟朠se»⡟Ā;sျ⡨toȀ;dluျ⡳⡷⡻owîҌefôएðᏑker;斮Āoy⢇⢌mma;権;䐼ash;怔asuredangle»ᘦr;쀀𝔪o;愧ƀcdn⢯⢴⣉ro耻µ䂵Ȁ;acdᑤ⢽⣀⣄sôᚧir;櫰ot肻·Ƶusƀ;bd⣒ᤃ⣓戒Ā;uᴼ⣘;横ţ⣞⣡p;櫛ò−ðઁĀdp⣩⣮els;抧f;쀀𝕞Āct⣸⣽r;쀀𝓂pos»ᖝƀ;lm⤉⤊⤍䎼timap;抸ఀGLRVabcdefghijlmoprstuvw⥂⥓⥾⦉⦘⧚⧩⨕⨚⩘⩝⪃⪕⪤⪨⬄⬇⭄⭿⮮ⰴⱧⱼ⳩Āgt⥇⥋;쀀⋙̸Ā;v⥐௏쀀≫⃒ƀelt⥚⥲⥶ftĀar⥡⥧rrow;懍ightarrow;懎;쀀⋘̸Ā;v⥻ే쀀≪⃒ightarrow;懏ĀDd⦎⦓ash;抯ash;抮ʀbcnpt⦣⦧⦬⦱⧌la»˞ute;䅄g;쀀∠⃒ʀ;Eiop඄⦼⧀⧅⧈;쀀⩰̸d;쀀≋̸s;䅉roø඄urĀ;a⧓⧔普lĀ;s⧓ସdz⧟\0⧣p肻 ଷmpĀ;e௹ఀʀaeouy⧴⧾⨃⨐⨓ǰ⧹\0⧻;橃on;䅈dil;䅆ngĀ;dൾ⨊ot;쀀⩭̸p;橂;䐽ash;怓΀;Aadqsxஒ⨩⨭⨻⩁⩅⩐rr;懗rĀhr⨳⨶k;椤Ā;oᏲᏰot;쀀≐̸uiöୣĀei⩊⩎ar;椨í஘istĀ;s஠டr;쀀𝔫ȀEest௅⩦⩹⩼ƀ;qs஼⩭௡ƀ;qs஼௅⩴lanô௢ií௪Ā;rஶ⪁»ஷƀAap⪊⪍⪑rò⥱rr;憮ar;櫲ƀ;svྍ⪜ྌĀ;d⪡⪢拼;拺cy;䑚΀AEadest⪷⪺⪾⫂⫅⫶⫹rò⥦;쀀≦̸rr;憚r;急Ȁ;fqs఻⫎⫣⫯tĀar⫔⫙rro÷⫁ightarro÷⪐ƀ;qs఻⪺⫪lanôౕĀ;sౕ⫴»శiíౝĀ;rవ⫾iĀ;eచథiäඐĀpt⬌⬑f;쀀𝕟膀¬;in⬙⬚⬶䂬nȀ;Edvஉ⬤⬨⬮;쀀⋹̸ot;쀀⋵̸ǡஉ⬳⬵;拷;拶iĀ;vಸ⬼ǡಸ⭁⭃;拾;拽ƀaor⭋⭣⭩rȀ;ast୻⭕⭚⭟lleì୻l;쀀⫽⃥;쀀∂̸lint;樔ƀ;ceಒ⭰⭳uåಥĀ;cಘ⭸Ā;eಒ⭽ñಘȀAait⮈⮋⮝⮧rò⦈rrƀ;cw⮔⮕⮙憛;쀀⤳̸;쀀↝̸ghtarrow»⮕riĀ;eೋೖ΀chimpqu⮽⯍⯙⬄୸⯤⯯Ȁ;cerല⯆ഷ⯉uå൅;쀀𝓃ortɭ⬅\0\0⯖ará⭖mĀ;e൮⯟Ā;q൴൳suĀbp⯫⯭å೸åഋƀbcp⯶ⰑⰙȀ;Ees⯿ⰀഢⰄ抄;쀀⫅̸etĀ;eഛⰋqĀ;qണⰀcĀ;eലⰗñസȀ;EesⰢⰣൟⰧ抅;쀀⫆̸etĀ;e൘ⰮqĀ;qൠⰣȀgilrⰽⰿⱅⱇìௗlde耻ñ䃱çృiangleĀlrⱒⱜeftĀ;eచⱚñదightĀ;eೋⱥñ೗Ā;mⱬⱭ䎽ƀ;esⱴⱵⱹ䀣ro;愖p;怇ҀDHadgilrsⲏⲔⲙⲞⲣⲰⲶⳓⳣash;抭arr;椄p;쀀≍⃒ash;抬ĀetⲨⲬ;쀀≥⃒;쀀>⃒nfin;槞ƀAetⲽⳁⳅrr;椂;쀀≤⃒Ā;rⳊⳍ쀀<⃒ie;쀀⊴⃒ĀAtⳘⳜrr;椃rie;쀀⊵⃒im;쀀∼⃒ƀAan⳰⳴ⴂrr;懖rĀhr⳺⳽k;椣Ā;oᏧᏥear;椧ቓ᪕\0\0\0\0\0\0\0\0\0\0\0\0\0ⴭ\0ⴸⵈⵠⵥ⵲ⶄᬇ\0\0ⶍⶫ\0ⷈⷎ\0ⷜ⸙⸫⸾⹃Ācsⴱ᪗ute耻ó䃳ĀiyⴼⵅrĀ;c᪞ⵂ耻ô䃴;䐾ʀabios᪠ⵒⵗLjⵚlac;䅑v;樸old;榼lig;䅓Ācr⵩⵭ir;榿;쀀𝔬ͯ⵹\0\0⵼\0ⶂn;䋛ave耻ò䃲;槁Ābmⶈ෴ar;榵Ȁacitⶕ⶘ⶥⶨrò᪀Āir⶝ⶠr;榾oss;榻nå๒;槀ƀaeiⶱⶵⶹcr;䅍ga;䏉ƀcdnⷀⷅǍron;䎿;榶pf;쀀𝕠ƀaelⷔ⷗ǒr;榷rp;榹΀;adiosvⷪⷫⷮ⸈⸍⸐⸖戨rò᪆Ȁ;efmⷷⷸ⸂⸅橝rĀ;oⷾⷿ愴f»ⷿ耻ª䂪耻º䂺gof;抶r;橖lope;橗;橛ƀclo⸟⸡⸧ò⸁ash耻ø䃸l;折iŬⸯ⸴de耻õ䃵esĀ;aǛ⸺s;樶ml耻ö䃶bar;挽ૡ⹞\0⹽\0⺀⺝\0⺢⺹\0\0⻋ຜ\0⼓\0\0⼫⾼\0⿈rȀ;astЃ⹧⹲຅脀¶;l⹭⹮䂶leìЃɩ⹸\0\0⹻m;櫳;櫽y;䐿rʀcimpt⺋⺏⺓ᡥ⺗nt;䀥od;䀮il;怰enk;怱r;쀀𝔭ƀimo⺨⺰⺴Ā;v⺭⺮䏆;䏕maô੶ne;明ƀ;tv⺿⻀⻈䏀chfork»´;䏖Āau⻏⻟nĀck⻕⻝kĀ;h⇴⻛;愎ö⇴sҀ;abcdemst⻳⻴ᤈ⻹⻽⼄⼆⼊⼎䀫cir;樣ir;樢Āouᵀ⼂;樥;橲n肻±ຝim;樦wo;樧ƀipu⼙⼠⼥ntint;樕f;쀀𝕡nd耻£䂣Ԁ;Eaceinosu່⼿⽁⽄⽇⾁⾉⾒⽾⾶;檳p;檷uå໙Ā;c໎⽌̀;acens່⽙⽟⽦⽨⽾pproø⽃urlyeñ໙ñ໎ƀaes⽯⽶⽺pprox;檹qq;檵im;拨iíໟmeĀ;s⾈ຮ怲ƀEas⽸⾐⽺ð⽵ƀdfp໬⾙⾯ƀals⾠⾥⾪lar;挮ine;挒urf;挓Ā;t໻⾴ï໻rel;抰Āci⿀⿅r;쀀𝓅;䏈ncsp;怈̀fiopsu⿚⋢⿟⿥⿫⿱r;쀀𝔮pf;쀀𝕢rime;恗cr;쀀𝓆ƀaeo⿸〉〓tĀei⿾々rnionóڰnt;樖stĀ;e【】䀿ñἙô༔઀ABHabcdefhilmnoprstux぀けさすムㄎㄫㅇㅢㅲㆎ㈆㈕㈤㈩㉘㉮㉲㊐㊰㊷ƀartぇおがròႳòϝail;検aròᱥar;楤΀cdenqrtとふへみわゔヌĀeuねぱ;쀀∽̱te;䅕iãᅮmptyv;榳gȀ;del࿑らるろ;榒;榥å࿑uo耻»䂻rր;abcfhlpstw࿜ガクシスゼゾダッデナp;極Ā;f࿠ゴs;椠;椳s;椞ë≝ð✮l;楅im;楴l;憣;憝Āaiパフil;椚oĀ;nホボ戶aló༞ƀabrョリヮrò៥rk;杳ĀakンヽcĀekヹ・;䁽;䁝Āes㄂㄄;榌lĀduㄊㄌ;榎;榐Ȁaeuyㄗㄜㄧㄩron;䅙Ādiㄡㄥil;䅗ì࿲âヺ;䑀Ȁclqsㄴㄷㄽㅄa;椷dhar;楩uoĀ;rȎȍh;憳ƀacgㅎㅟངlȀ;ipsླྀㅘㅛႜnåႻarôྩt;断ƀilrㅩဣㅮsht;楽;쀀𝔯ĀaoㅷㆆrĀduㅽㅿ»ѻĀ;l႑ㆄ;楬Ā;vㆋㆌ䏁;䏱ƀgns㆕ㇹㇼht̀ahlrstㆤㆰ㇂㇘㇤㇮rrowĀ;t࿜ㆭaéトarpoonĀduㆻㆿowîㅾp»႒eftĀah㇊㇐rrowó࿪arpoonóՑightarrows;應quigarro÷ニhreetimes;拌g;䋚ingdotseñἲƀahm㈍㈐㈓rò࿪aòՑ;怏oustĀ;a㈞㈟掱che»㈟mid;櫮Ȁabpt㈲㈽㉀㉒Ānr㈷㈺g;柭r;懾rëဃƀafl㉇㉊㉎r;榆;쀀𝕣us;樮imes;樵Āap㉝㉧rĀ;g㉣㉤䀩t;榔olint;樒arò㇣Ȁachq㉻㊀Ⴜ㊅quo;怺r;쀀𝓇Ābu・㊊oĀ;rȔȓƀhir㊗㊛㊠reåㇸmes;拊iȀ;efl㊪ၙᠡ㊫方tri;槎luhar;楨;愞ൡ㋕㋛㋟㌬㌸㍱\0㍺㎤\0\0㏬㏰\0㐨㑈㑚㒭㒱㓊㓱\0㘖\0\0㘳cute;䅛quï➺Ԁ;Eaceinpsyᇭ㋳㋵㋿㌂㌋㌏㌟㌦㌩;檴ǰ㋺\0㋼;檸on;䅡uåᇾĀ;dᇳ㌇il;䅟rc;䅝ƀEas㌖㌘㌛;檶p;檺im;择olint;樓iíሄ;䑁otƀ;be㌴ᵇ㌵担;橦΀Aacmstx㍆㍊㍗㍛㍞㍣㍭rr;懘rĀhr㍐㍒ë∨Ā;oਸ਼਴t耻§䂧i;䀻war;椩mĀin㍩ðnuóñt;朶rĀ;o㍶⁕쀀𝔰Ȁacoy㎂㎆㎑㎠rp;景Āhy㎋㎏cy;䑉;䑈rtɭ㎙\0\0㎜iäᑤaraì⹯耻­䂭Āgm㎨㎴maƀ;fv㎱㎲㎲䏃;䏂Ѐ;deglnprካ㏅㏉㏎㏖㏞㏡㏦ot;橪Ā;q኱ኰĀ;E㏓㏔檞;檠Ā;E㏛㏜檝;檟e;扆lus;樤arr;楲aròᄽȀaeit㏸㐈㐏㐗Āls㏽㐄lsetmé㍪hp;樳parsl;槤Ādlᑣ㐔e;挣Ā;e㐜㐝檪Ā;s㐢㐣檬;쀀⪬︀ƀflp㐮㐳㑂tcy;䑌Ā;b㐸㐹䀯Ā;a㐾㐿槄r;挿f;쀀𝕤aĀdr㑍ЂesĀ;u㑔㑕晠it»㑕ƀcsu㑠㑹㒟Āau㑥㑯pĀ;sᆈ㑫;쀀⊓︀pĀ;sᆴ㑵;쀀⊔︀uĀbp㑿㒏ƀ;esᆗᆜ㒆etĀ;eᆗ㒍ñᆝƀ;esᆨᆭ㒖etĀ;eᆨ㒝ñᆮƀ;afᅻ㒦ְrť㒫ֱ»ᅼaròᅈȀcemt㒹㒾㓂㓅r;쀀𝓈tmîñiì㐕aræᆾĀar㓎㓕rĀ;f㓔ឿ昆Āan㓚㓭ightĀep㓣㓪psiloîỠhé⺯s»⡒ʀbcmnp㓻㕞ሉ㖋㖎Ҁ;Edemnprs㔎㔏㔑㔕㔞㔣㔬㔱㔶抂;櫅ot;檽Ā;dᇚ㔚ot;櫃ult;櫁ĀEe㔨㔪;櫋;把lus;檿arr;楹ƀeiu㔽㕒㕕tƀ;en㔎㕅㕋qĀ;qᇚ㔏eqĀ;q㔫㔨m;櫇Ābp㕚㕜;櫕;櫓c̀;acensᇭ㕬㕲㕹㕻㌦pproø㋺urlyeñᇾñᇳƀaes㖂㖈㌛pproø㌚qñ㌗g;晪ڀ123;Edehlmnps㖩㖬㖯ሜ㖲㖴㗀㗉㗕㗚㗟㗨㗭耻¹䂹耻²䂲耻³䂳;櫆Āos㖹㖼t;檾ub;櫘Ā;dሢ㗅ot;櫄sĀou㗏㗒l;柉b;櫗arr;楻ult;櫂ĀEe㗤㗦;櫌;抋lus;櫀ƀeiu㗴㘉㘌tƀ;enሜ㗼㘂qĀ;qሢ㖲eqĀ;q㗧㗤m;櫈Ābp㘑㘓;櫔;櫖ƀAan㘜㘠㘭rr;懙rĀhr㘦㘨ë∮Ā;oਫ਩war;椪lig耻ß䃟௡㙑㙝㙠ዎ㙳㙹\0㙾㛂\0\0\0\0\0㛛㜃\0㜉㝬\0\0\0㞇ɲ㙖\0\0㙛get;挖;䏄rë๟ƀaey㙦㙫㙰ron;䅥dil;䅣;䑂lrec;挕r;쀀𝔱Ȁeiko㚆㚝㚵㚼Dz㚋\0㚑eĀ4fኄኁaƀ;sv㚘㚙㚛䎸ym;䏑Ācn㚢㚲kĀas㚨㚮pproø዁im»ኬsðኞĀas㚺㚮ð዁rn耻þ䃾Ǭ̟㛆⋧es膀×;bd㛏㛐㛘䃗Ā;aᤏ㛕r;樱;樰ƀeps㛡㛣㜀á⩍Ȁ;bcf҆㛬㛰㛴ot;挶ir;櫱Ā;o㛹㛼쀀𝕥rk;櫚á㍢rime;怴ƀaip㜏㜒㝤dåቈ΀adempst㜡㝍㝀㝑㝗㝜㝟ngleʀ;dlqr㜰㜱㜶㝀㝂斵own»ᶻeftĀ;e⠀㜾ñम;扜ightĀ;e㊪㝋ñၚot;旬inus;樺lus;樹b;槍ime;樻ezium;揢ƀcht㝲㝽㞁Āry㝷㝻;쀀𝓉;䑆cy;䑛rok;䅧Āio㞋㞎xô᝷headĀlr㞗㞠eftarro÷ࡏightarrow»ཝऀAHabcdfghlmoprstuw㟐㟓㟗㟤㟰㟼㠎㠜㠣㠴㡑㡝㡫㢩㣌㣒㣪㣶ròϭar;楣Ācr㟜㟢ute耻ú䃺òᅐrǣ㟪\0㟭y;䑞ve;䅭Āiy㟵㟺rc耻û䃻;䑃ƀabh㠃㠆㠋ròᎭlac;䅱aòᏃĀir㠓㠘sht;楾;쀀𝔲rave耻ù䃹š㠧㠱rĀlr㠬㠮»ॗ»ႃlk;斀Āct㠹㡍ɯ㠿\0\0㡊rnĀ;e㡅㡆挜r»㡆op;挏ri;旸Āal㡖㡚cr;䅫肻¨͉Āgp㡢㡦on;䅳f;쀀𝕦̀adhlsuᅋ㡸㡽፲㢑㢠ownáᎳarpoonĀlr㢈㢌efô㠭ighô㠯iƀ;hl㢙㢚㢜䏅»ᏺon»㢚parrows;懈ƀcit㢰㣄㣈ɯ㢶\0\0㣁rnĀ;e㢼㢽挝r»㢽op;挎ng;䅯ri;旹cr;쀀𝓊ƀdir㣙㣝㣢ot;拰lde;䅩iĀ;f㜰㣨»᠓Āam㣯㣲rò㢨l耻ü䃼angle;榧ހABDacdeflnoprsz㤜㤟㤩㤭㦵㦸㦽㧟㧤㧨㧳㧹㧽㨁㨠ròϷarĀ;v㤦㤧櫨;櫩asèϡĀnr㤲㤷grt;榜΀eknprst㓣㥆㥋㥒㥝㥤㦖appá␕othinçẖƀhir㓫⻈㥙opô⾵Ā;hᎷ㥢ïㆍĀiu㥩㥭gmá㎳Ābp㥲㦄setneqĀ;q㥽㦀쀀⊊︀;쀀⫋︀setneqĀ;q㦏㦒쀀⊋︀;쀀⫌︀Āhr㦛㦟etá㚜iangleĀlr㦪㦯eft»थight»ၑy;䐲ash»ံƀelr㧄㧒㧗ƀ;beⷪ㧋㧏ar;抻q;扚lip;拮Ābt㧜ᑨaòᑩr;쀀𝔳tré㦮suĀbp㧯㧱»ജ»൙pf;쀀𝕧roð໻tré㦴Ācu㨆㨋r;쀀𝓋Ābp㨐㨘nĀEe㦀㨖»㥾nĀEe㦒㨞»㦐igzag;榚΀cefoprs㨶㨻㩖㩛㩔㩡㩪irc;䅵Ādi㩀㩑Ābg㩅㩉ar;機eĀ;qᗺ㩏;扙erp;愘r;쀀𝔴pf;쀀𝕨Ā;eᑹ㩦atèᑹcr;쀀𝓌ૣណ㪇\0㪋\0㪐㪛\0\0㪝㪨㪫㪯\0\0㫃㫎\0㫘ៜ៟tré៑r;쀀𝔵ĀAa㪔㪗ròσrò৶;䎾ĀAa㪡㪤ròθrò৫að✓is;拻ƀdptឤ㪵㪾Āfl㪺ឩ;쀀𝕩imåឲĀAa㫇㫊ròώròਁĀcq㫒ីr;쀀𝓍Āpt៖㫜ré។Ѐacefiosu㫰㫽㬈㬌㬑㬕㬛㬡cĀuy㫶㫻te耻ý䃽;䑏Āiy㬂㬆rc;䅷;䑋n耻¥䂥r;쀀𝔶cy;䑗pf;쀀𝕪cr;쀀𝓎Ācm㬦㬩y;䑎l耻ÿ䃿Ԁacdefhiosw㭂㭈㭔㭘㭤㭩㭭㭴㭺㮀cute;䅺Āay㭍㭒ron;䅾;䐷ot;䅼Āet㭝㭡træᕟa;䎶r;쀀𝔷cy;䐶grarr;懝pf;쀀𝕫cr;쀀𝓏Ājn㮅㮇;怍j;怌'.split("").map(function(e){return e.charCodeAt(0)}))),br}var Cr={},rf;function AVe(){return rf||(rf=1,Object.defineProperty(Cr,"__esModule",{value:!0}),Cr.default=new Uint16Array("Ȁaglq \x1Bɭ\0\0p;䀦os;䀧t;䀾t;䀼uot;䀢".split("").map(function(e){return e.charCodeAt(0)}))),Cr}var bc={},uf;function lf(){return uf||(uf=1,function(e){var t;Object.defineProperty(e,"__esModule",{value:!0}),e.replaceCodePoint=e.fromCodePoint=void 0;var n=new Map([[0,65533],[128,8364],[130,8218],[131,402],[132,8222],[133,8230],[134,8224],[135,8225],[136,710],[137,8240],[138,352],[139,8249],[140,338],[142,381],[145,8216],[146,8217],[147,8220],[148,8221],[149,8226],[150,8211],[151,8212],[152,732],[153,8482],[154,353],[155,8250],[156,339],[158,382],[159,376]]);e.fromCodePoint=(t=String.fromCodePoint)!==null&&t!==void 0?t:function(o){var i="";return o>65535&&(o-=65536,i+=String.fromCharCode(o>>>10&1023|55296),o=56320|o&1023),i+=String.fromCharCode(o),i};function a(o){var i;return o>=55296&&o<=57343||o>1114111?65533:(i=n.get(o))!==null&&i!==void 0?i:o}e.replaceCodePoint=a;function s(o){return(0,e.fromCodePoint)(a(o))}e.default=s}(bc)),bc}var cf;function ju(){return cf||(cf=1,function(e){var t=na&&na.__createBinding||(Object.create?function(y,z,Z,Ae){Ae===void 0&&(Ae=Z);var J=Object.getOwnPropertyDescriptor(z,Z);(!J||("get"in J?!z.__esModule:J.writable||J.configurable))&&(J={enumerable:!0,get:function(){return z[Z]}}),Object.defineProperty(y,Ae,J)}:function(y,z,Z,Ae){Ae===void 0&&(Ae=Z),y[Ae]=z[Z]}),n=na&&na.__setModuleDefault||(Object.create?function(y,z){Object.defineProperty(y,"default",{enumerable:!0,value:z})}:function(y,z){y.default=z}),a=na&&na.__importStar||function(y){if(y&&y.__esModule)return y;var z={};if(y!=null)for(var Z in y)Z!=="default"&&Object.prototype.hasOwnProperty.call(y,Z)&&t(z,y,Z);return n(z,y),z},s=na&&na.__importDefault||function(y){return y&&y.__esModule?y:{default:y}};Object.defineProperty(e,"__esModule",{value:!0}),e.decodeXML=e.decodeHTMLStrict=e.decodeHTMLAttribute=e.decodeHTML=e.determineBranch=e.EntityDecoder=e.DecodingMode=e.BinTrieFlags=e.fromCodePoint=e.replaceCodePoint=e.decodeCodePoint=e.xmlDecodeTree=e.htmlDecodeTree=void 0;var o=s(SVe());e.htmlDecodeTree=o.default;var i=s(AVe());e.xmlDecodeTree=i.default;var r=a(lf());e.decodeCodePoint=r.default;var u=lf();Object.defineProperty(e,"replaceCodePoint",{enumerable:!0,get:function(){return u.replaceCodePoint}}),Object.defineProperty(e,"fromCodePoint",{enumerable:!0,get:function(){return u.fromCodePoint}});var l;(function(y){y[y.NUM=35]="NUM",y[y.SEMI=59]="SEMI",y[y.EQUALS=61]="EQUALS",y[y.ZERO=48]="ZERO",y[y.NINE=57]="NINE",y[y.LOWER_A=97]="LOWER_A",y[y.LOWER_F=102]="LOWER_F",y[y.LOWER_X=120]="LOWER_X",y[y.LOWER_Z=122]="LOWER_Z",y[y.UPPER_A=65]="UPPER_A",y[y.UPPER_F=70]="UPPER_F",y[y.UPPER_Z=90]="UPPER_Z"})(l||(l={}));var d=32,E;(function(y){y[y.VALUE_LENGTH=49152]="VALUE_LENGTH",y[y.BRANCH_LENGTH=16256]="BRANCH_LENGTH",y[y.JUMP_TABLE=127]="JUMP_TABLE"})(E=e.BinTrieFlags||(e.BinTrieFlags={}));function c(y){return y>=l.ZERO&&y<=l.NINE}function m(y){return y>=l.UPPER_A&&y<=l.UPPER_F||y>=l.LOWER_A&&y<=l.LOWER_F}function _(y){return y>=l.UPPER_A&&y<=l.UPPER_Z||y>=l.LOWER_A&&y<=l.LOWER_Z||c(y)}function h(y){return y===l.EQUALS||_(y)}var O;(function(y){y[y.EntityStart=0]="EntityStart",y[y.NumericStart=1]="NumericStart",y[y.NumericDecimal=2]="NumericDecimal",y[y.NumericHex=3]="NumericHex",y[y.NamedEntity=4]="NamedEntity"})(O||(O={}));var S;(function(y){y[y.Legacy=0]="Legacy",y[y.Strict=1]="Strict",y[y.Attribute=2]="Attribute"})(S=e.DecodingMode||(e.DecodingMode={}));var R=function(){function y(z,Z,Ae){this.decodeTree=z,this.emitCodePoint=Z,this.errors=Ae,this.state=O.EntityStart,this.consumed=1,this.result=0,this.treeIndex=0,this.excess=1,this.decodeMode=S.Strict}return y.prototype.startEntity=function(z){this.decodeMode=z,this.state=O.EntityStart,this.result=0,this.treeIndex=0,this.excess=1,this.consumed=1},y.prototype.write=function(z,Z){switch(this.state){case O.EntityStart:return z.charCodeAt(Z)===l.NUM?(this.state=O.NumericStart,this.consumed+=1,this.stateNumericStart(z,Z+1)):(this.state=O.NamedEntity,this.stateNamedEntity(z,Z));case O.NumericStart:return this.stateNumericStart(z,Z);case O.NumericDecimal:return this.stateNumericDecimal(z,Z);case O.NumericHex:return this.stateNumericHex(z,Z);case O.NamedEntity:return this.stateNamedEntity(z,Z)}},y.prototype.stateNumericStart=function(z,Z){return Z>=z.length?-1:(z.charCodeAt(Z)|d)===l.LOWER_X?(this.state=O.NumericHex,this.consumed+=1,this.stateNumericHex(z,Z+1)):(this.state=O.NumericDecimal,this.stateNumericDecimal(z,Z))},y.prototype.addToNumericResult=function(z,Z,Ae,J){if(Z!==Ae){var ce=Ae-Z;this.result=this.result*Math.pow(J,ce)+parseInt(z.substr(Z,ce),J),this.consumed+=ce}},y.prototype.stateNumericHex=function(z,Z){for(var Ae=Z;Z>14;Z>14,ce!==0){if(Te===l.SEMI)return this.emitNamedEntityData(this.treeIndex,ce,this.consumed+this.excess);this.decodeMode!==S.Strict&&(this.result=this.treeIndex,this.consumed+=this.excess,this.excess=0)}}return-1},y.prototype.emitNotTerminatedNamedEntity=function(){var z,Z=this,Ae=Z.result,J=Z.decodeTree,ce=(J[Ae]&E.VALUE_LENGTH)>>14;return this.emitNamedEntityData(Ae,ce,this.consumed),(z=this.errors)===null||z===void 0||z.missingSemicolonAfterCharacterReference(),this.consumed},y.prototype.emitNamedEntityData=function(z,Z,Ae){var J=this.decodeTree;return this.emitCodePoint(Z===1?J[z]&~E.VALUE_LENGTH:J[z+1],Ae),Z===3&&this.emitCodePoint(J[z+2],Ae),Ae},y.prototype.end=function(){var z;switch(this.state){case O.NamedEntity:return this.result!==0&&(this.decodeMode!==S.Attribute||this.result===this.treeIndex)?this.emitNotTerminatedNamedEntity():0;case O.NumericDecimal:return this.emitNumericEntity(0,2);case O.NumericHex:return this.emitNumericEntity(0,3);case O.NumericStart:return(z=this.errors)===null||z===void 0||z.absenceOfDigitsInNumericCharacterReference(this.consumed),0;case O.EntityStart:return 0}},y}();e.EntityDecoder=R;function g(y){var z="",Z=new R(y,function(Ae){return z+=(0,r.fromCodePoint)(Ae)});return function(J,ce){for(var Te=0,De=0;(De=J.indexOf("&",De))>=0;){z+=J.slice(Te,De),Z.startEntity(ce);var Ve=Z.write(J,De+1);if(Ve<0){Te=De+Z.end();break}Te=De+Ve,De=Ve===0?Te+1:Te}var xe=z+J.slice(Te);return z="",xe}}function I(y,z,Z,Ae){var J=(z&E.BRANCH_LENGTH)>>7,ce=z&E.JUMP_TABLE;if(J===0)return ce!==0&&Ae===ce?Z:-1;if(ce){var Te=Ae-ce;return Te<0||Te>=J?-1:y[Z+Te]-1}for(var De=Z,Ve=De+J-1;De<=Ve;){var xe=De+Ve>>>1,ot=y[xe];if(otAe)Ve=xe-1;else return y[xe+J]}return-1}e.determineBranch=I;var N=g(o.default),b=g(i.default);function C(y,z){return z===void 0&&(z=S.Legacy),N(y,z)}e.decodeHTML=C;function k(y){return N(y,S.Attribute)}e.decodeHTMLAttribute=k;function P(y){return N(y,S.Strict)}e.decodeHTMLStrict=P;function $(y){return b(y,S.Strict)}e.decodeXML=$}(na)),na}var df;function dO(){return df||(df=1,function(e){Object.defineProperty(e,"__esModule",{value:!0}),e.QuoteType=void 0;var t=ju(),n;(function(c){c[c.Tab=9]="Tab",c[c.NewLine=10]="NewLine",c[c.FormFeed=12]="FormFeed",c[c.CarriageReturn=13]="CarriageReturn",c[c.Space=32]="Space",c[c.ExclamationMark=33]="ExclamationMark",c[c.Number=35]="Number",c[c.Amp=38]="Amp",c[c.SingleQuote=39]="SingleQuote",c[c.DoubleQuote=34]="DoubleQuote",c[c.Dash=45]="Dash",c[c.Slash=47]="Slash",c[c.Zero=48]="Zero",c[c.Nine=57]="Nine",c[c.Semi=59]="Semi",c[c.Lt=60]="Lt",c[c.Eq=61]="Eq",c[c.Gt=62]="Gt",c[c.Questionmark=63]="Questionmark",c[c.UpperA=65]="UpperA",c[c.LowerA=97]="LowerA",c[c.UpperF=70]="UpperF",c[c.LowerF=102]="LowerF",c[c.UpperZ=90]="UpperZ",c[c.LowerZ=122]="LowerZ",c[c.LowerX=120]="LowerX",c[c.OpeningSquareBracket=91]="OpeningSquareBracket"})(n||(n={}));var a;(function(c){c[c.Text=1]="Text",c[c.BeforeTagName=2]="BeforeTagName",c[c.InTagName=3]="InTagName",c[c.InSelfClosingTag=4]="InSelfClosingTag",c[c.BeforeClosingTagName=5]="BeforeClosingTagName",c[c.InClosingTagName=6]="InClosingTagName",c[c.AfterClosingTagName=7]="AfterClosingTagName",c[c.BeforeAttributeName=8]="BeforeAttributeName",c[c.InAttributeName=9]="InAttributeName",c[c.AfterAttributeName=10]="AfterAttributeName",c[c.BeforeAttributeValue=11]="BeforeAttributeValue",c[c.InAttributeValueDq=12]="InAttributeValueDq",c[c.InAttributeValueSq=13]="InAttributeValueSq",c[c.InAttributeValueNq=14]="InAttributeValueNq",c[c.BeforeDeclaration=15]="BeforeDeclaration",c[c.InDeclaration=16]="InDeclaration",c[c.InProcessingInstruction=17]="InProcessingInstruction",c[c.BeforeComment=18]="BeforeComment",c[c.CDATASequence=19]="CDATASequence",c[c.InSpecialComment=20]="InSpecialComment",c[c.InCommentLike=21]="InCommentLike",c[c.BeforeSpecialS=22]="BeforeSpecialS",c[c.SpecialStartSequence=23]="SpecialStartSequence",c[c.InSpecialTag=24]="InSpecialTag",c[c.BeforeEntity=25]="BeforeEntity",c[c.BeforeNumericEntity=26]="BeforeNumericEntity",c[c.InNamedEntity=27]="InNamedEntity",c[c.InNumericEntity=28]="InNumericEntity",c[c.InHexEntity=29]="InHexEntity"})(a||(a={}));function s(c){return c===n.Space||c===n.NewLine||c===n.Tab||c===n.FormFeed||c===n.CarriageReturn}function o(c){return c===n.Slash||c===n.Gt||s(c)}function i(c){return c>=n.Zero&&c<=n.Nine}function r(c){return c>=n.LowerA&&c<=n.LowerZ||c>=n.UpperA&&c<=n.UpperZ}function u(c){return c>=n.UpperA&&c<=n.UpperF||c>=n.LowerA&&c<=n.LowerF}var l;(function(c){c[c.NoValue=0]="NoValue",c[c.Unquoted=1]="Unquoted",c[c.Single=2]="Single",c[c.Double=3]="Double"})(l=e.QuoteType||(e.QuoteType={}));var d={Cdata:new Uint8Array([67,68,65,84,65,91]),CdataEnd:new Uint8Array([93,93,62]),CommentEnd:new Uint8Array([45,45,62]),ScriptEnd:new Uint8Array([60,47,115,99,114,105,112,116]),StyleEnd:new Uint8Array([60,47,115,116,121,108,101]),TitleEnd:new Uint8Array([60,47,116,105,116,108,101])},E=function(){function c(m,_){var h=m.xmlMode,O=h===void 0?!1:h,S=m.decodeEntities,R=S===void 0?!0:S;this.cbs=_,this.state=a.Text,this.buffer="",this.sectionStart=0,this.index=0,this.baseState=a.Text,this.isSpecial=!1,this.running=!0,this.offset=0,this.currentSequence=void 0,this.sequenceIndex=0,this.trieIndex=0,this.trieCurrent=0,this.entityResult=0,this.entityExcess=0,this.xmlMode=O,this.decodeEntities=R,this.entityTrie=O?t.xmlDecodeTree:t.htmlDecodeTree}return c.prototype.reset=function(){this.state=a.Text,this.buffer="",this.sectionStart=0,this.index=0,this.baseState=a.Text,this.currentSequence=void 0,this.running=!0,this.offset=0},c.prototype.write=function(m){this.offset+=this.buffer.length,this.buffer=m,this.parse()},c.prototype.end=function(){this.running&&this.finish()},c.prototype.pause=function(){this.running=!1},c.prototype.resume=function(){this.running=!0,this.indexthis.sectionStart&&this.cbs.ontext(this.sectionStart,this.index),this.state=a.BeforeTagName,this.sectionStart=this.index):this.decodeEntities&&m===n.Amp&&(this.state=a.BeforeEntity)},c.prototype.stateSpecialStartSequence=function(m){var _=this.sequenceIndex===this.currentSequence.length,h=_?o(m):(m|32)===this.currentSequence[this.sequenceIndex];if(!h)this.isSpecial=!1;else if(!_){this.sequenceIndex++;return}this.sequenceIndex=0,this.state=a.InTagName,this.stateInTagName(m)},c.prototype.stateInSpecialTag=function(m){if(this.sequenceIndex===this.currentSequence.length){if(m===n.Gt||s(m)){var _=this.index-this.currentSequence.length;if(this.sectionStart<_){var h=this.index;this.index=_,this.cbs.ontext(this.sectionStart,_),this.index=h}this.isSpecial=!1,this.sectionStart=_+2,this.stateInClosingTagName(m);return}this.sequenceIndex=0}(m|32)===this.currentSequence[this.sequenceIndex]?this.sequenceIndex+=1:this.sequenceIndex===0?this.currentSequence===d.TitleEnd?this.decodeEntities&&m===n.Amp&&(this.state=a.BeforeEntity):this.fastForwardTo(n.Lt)&&(this.sequenceIndex=1):this.sequenceIndex=+(m===n.Lt)},c.prototype.stateCDATASequence=function(m){m===d.Cdata[this.sequenceIndex]?++this.sequenceIndex===d.Cdata.length&&(this.state=a.InCommentLike,this.currentSequence=d.CdataEnd,this.sequenceIndex=0,this.sectionStart=this.index+1):(this.sequenceIndex=0,this.state=a.InDeclaration,this.stateInDeclaration(m))},c.prototype.fastForwardTo=function(m){for(;++this.index>14)-1;if(!this.allowLegacyEntity()&&m!==n.Semi)this.trieIndex+=h;else{var O=this.index-this.entityExcess+1;O>this.sectionStart&&this.emitPartial(this.sectionStart,O),this.entityResult=this.trieIndex,this.trieIndex+=h,this.entityExcess=0,this.sectionStart=this.index+1,h===0&&this.emitNamedEntity()}}},c.prototype.emitNamedEntity=function(){if(this.state=this.baseState,this.entityResult!==0){var m=(this.entityTrie[this.entityResult]&t.BinTrieFlags.VALUE_LENGTH)>>14;switch(m){case 1:{this.emitCodePoint(this.entityTrie[this.entityResult]&~t.BinTrieFlags.VALUE_LENGTH);break}case 2:{this.emitCodePoint(this.entityTrie[this.entityResult+1]);break}case 3:this.emitCodePoint(this.entityTrie[this.entityResult+1]),this.emitCodePoint(this.entityTrie[this.entityResult+2])}}},c.prototype.stateBeforeNumericEntity=function(m){(m|32)===n.LowerX?(this.entityExcess++,this.state=a.InHexEntity):(this.state=a.InNumericEntity,this.stateInNumericEntity(m))},c.prototype.emitNumericEntity=function(m){var _=this.index-this.entityExcess-1,h=_+2+ +(this.state===a.InHexEntity);h!==this.index&&(_>this.sectionStart&&this.emitPartial(this.sectionStart,_),this.sectionStart=this.index+Number(m),this.emitCodePoint((0,t.replaceCodePoint)(this.entityResult))),this.state=this.baseState},c.prototype.stateInNumericEntity=function(m){m===n.Semi?this.emitNumericEntity(!0):i(m)?(this.entityResult=this.entityResult*10+(m-n.Zero),this.entityExcess++):(this.allowLegacyEntity()?this.emitNumericEntity(!1):this.state=this.baseState,this.index--)},c.prototype.stateInHexEntity=function(m){m===n.Semi?this.emitNumericEntity(!0):i(m)?(this.entityResult=this.entityResult*16+(m-n.Zero),this.entityExcess++):u(m)?(this.entityResult=this.entityResult*16+((m|32)-n.LowerA+10),this.entityExcess++):(this.allowLegacyEntity()?this.emitNumericEntity(!1):this.state=this.baseState,this.index--)},c.prototype.allowLegacyEntity=function(){return!this.xmlMode&&(this.baseState===a.Text||this.baseState===a.InSpecialTag)},c.prototype.cleanup=function(){this.running&&this.sectionStart!==this.index&&(this.state===a.Text||this.state===a.InSpecialTag&&this.sequenceIndex===0?(this.cbs.ontext(this.sectionStart,this.index),this.sectionStart=this.index):(this.state===a.InAttributeValueDq||this.state===a.InAttributeValueSq||this.state===a.InAttributeValueNq)&&(this.cbs.onattribdata(this.sectionStart,this.index),this.sectionStart=this.index))},c.prototype.shouldContinue=function(){return this.index0&&b.has(this.stack[this.stack.length-1]);){var C=this.stack.pop();(g=(R=this.cbs).onclosetag)===null||g===void 0||g.call(R,C,!0)}this.isVoidElement(S)||(this.stack.push(S),c.has(S)?this.foreignContext.push(!0):m.has(S)&&this.foreignContext.push(!1)),(N=(I=this.cbs).onopentagname)===null||N===void 0||N.call(I,S),this.cbs.onopentag&&(this.attribs={})},O.prototype.endOpenTag=function(S){var R,g;this.startIndex=this.openTagStart,this.attribs&&((g=(R=this.cbs).onopentag)===null||g===void 0||g.call(R,this.tagname,this.attribs,S),this.attribs=null),this.cbs.onclosetag&&this.isVoidElement(this.tagname)&&this.cbs.onclosetag(this.tagname,!0),this.tagname=""},O.prototype.onopentagend=function(S){this.endIndex=S,this.endOpenTag(!1),this.startIndex=S+1},O.prototype.onclosetag=function(S,R){var g,I,N,b,C,k;this.endIndex=R;var P=this.getSlice(S,R);if(this.lowerCaseTagNames&&(P=P.toLowerCase()),(c.has(P)||m.has(P))&&this.foreignContext.pop(),this.isVoidElement(P))!this.options.xmlMode&&P==="br"&&((I=(g=this.cbs).onopentagname)===null||I===void 0||I.call(g,"br"),(b=(N=this.cbs).onopentag)===null||b===void 0||b.call(N,"br",{},!0),(k=(C=this.cbs).onclosetag)===null||k===void 0||k.call(C,"br",!1));else{var $=this.stack.lastIndexOf(P);if($!==-1)if(this.cbs.onclosetag)for(var y=this.stack.length-$;y--;)this.cbs.onclosetag(this.stack.pop(),y!==0);else this.stack.length=$;else!this.options.xmlMode&&P==="p"&&(this.emitOpenTag("p"),this.closeCurrentTag(!0))}this.startIndex=R+1},O.prototype.onselfclosingtag=function(S){this.endIndex=S,this.options.xmlMode||this.options.recognizeSelfClosing||this.foreignContext[this.foreignContext.length-1]?(this.closeCurrentTag(!1),this.startIndex=S+1):this.onopentagend(S)},O.prototype.closeCurrentTag=function(S){var R,g,I=this.tagname;this.endOpenTag(S),this.stack[this.stack.length-1]===I&&((g=(R=this.cbs).onclosetag)===null||g===void 0||g.call(R,I,!S),this.stack.pop())},O.prototype.onattribname=function(S,R){this.startIndex=S;var g=this.getSlice(S,R);this.attribname=this.lowerCaseAttributeNames?g.toLowerCase():g},O.prototype.onattribdata=function(S,R){this.attribvalue+=this.getSlice(S,R)},O.prototype.onattribentity=function(S){this.attribvalue+=(0,s.fromCodePoint)(S)},O.prototype.onattribend=function(S,R){var g,I;this.endIndex=R,(I=(g=this.cbs).onattribute)===null||I===void 0||I.call(g,this.attribname,this.attribvalue,S===a.QuoteType.Double?'"':S===a.QuoteType.Single?"'":S===a.QuoteType.NoValue?void 0:null),this.attribs&&!Object.prototype.hasOwnProperty.call(this.attribs,this.attribname)&&(this.attribs[this.attribname]=this.attribvalue),this.attribvalue=""},O.prototype.getInstructionName=function(S){var R=S.search(_),g=R<0?S:S.substr(0,R);return this.lowerCaseTagNames&&(g=g.toLowerCase()),g},O.prototype.ondeclaration=function(S,R){this.endIndex=R;var g=this.getSlice(S,R);if(this.cbs.onprocessinginstruction){var I=this.getInstructionName(g);this.cbs.onprocessinginstruction("!".concat(I),"!".concat(g))}this.startIndex=R+1},O.prototype.onprocessinginstruction=function(S,R){this.endIndex=R;var g=this.getSlice(S,R);if(this.cbs.onprocessinginstruction){var I=this.getInstructionName(g);this.cbs.onprocessinginstruction("?".concat(I),"?".concat(g))}this.startIndex=R+1},O.prototype.oncomment=function(S,R,g){var I,N,b,C;this.endIndex=R,(N=(I=this.cbs).oncomment)===null||N===void 0||N.call(I,this.getSlice(S,R-g)),(C=(b=this.cbs).oncommentend)===null||C===void 0||C.call(b),this.startIndex=R+1},O.prototype.oncdata=function(S,R,g){var I,N,b,C,k,P,$,y,z,Z;this.endIndex=R;var Ae=this.getSlice(S,R-g);this.options.xmlMode||this.options.recognizeCDATA?((N=(I=this.cbs).oncdatastart)===null||N===void 0||N.call(I),(C=(b=this.cbs).ontext)===null||C===void 0||C.call(b,Ae),(P=(k=this.cbs).oncdataend)===null||P===void 0||P.call(k)):((y=($=this.cbs).oncomment)===null||y===void 0||y.call($,"[CDATA[".concat(Ae,"]]")),(Z=(z=this.cbs).oncommentend)===null||Z===void 0||Z.call(z)),this.startIndex=R+1},O.prototype.onend=function(){var S,R;if(this.cbs.onclosetag){this.endIndex=this.startIndex;for(var g=this.stack.length;g>0;this.cbs.onclosetag(this.stack[--g],!0));}(R=(S=this.cbs).onend)===null||R===void 0||R.call(S)},O.prototype.reset=function(){var S,R,g,I;(R=(S=this.cbs).onreset)===null||R===void 0||R.call(S),this.tokenizer.reset(),this.tagname="",this.attribname="",this.attribs=null,this.stack.length=0,this.startIndex=0,this.endIndex=0,(I=(g=this.cbs).onparserinit)===null||I===void 0||I.call(g,this),this.buffers.length=0,this.bufferOffset=0,this.writeIndex=0,this.ended=!1},O.prototype.parseComplete=function(S){this.reset(),this.end(S)},O.prototype.getSlice=function(S,R){for(;S-this.bufferOffset>=this.buffers[0].length;)this.shiftBuffer();for(var g=this.buffers[0].slice(S-this.bufferOffset,R-this.bufferOffset);R-this.bufferOffset>this.buffers[0].length;)this.shiftBuffer(),g+=this.buffers[0].slice(0,R-this.bufferOffset);return g},O.prototype.shiftBuffer=function(){this.bufferOffset+=this.buffers[0].length,this.writeIndex--,this.buffers.shift()},O.prototype.write=function(S){var R,g;if(this.ended){(g=(R=this.cbs).onerror)===null||g===void 0||g.call(R,new Error(".write() after done!"));return}this.buffers.push(S),this.tokenizer.running&&(this.tokenizer.write(S),this.writeIndex++)},O.prototype.end=function(S){var R,g;if(this.ended){(g=(R=this.cbs).onerror)===null||g===void 0||g.call(R,new Error(".end() after done!"));return}S&&this.write(S),this.ended=!0,this.tokenizer.end()},O.prototype.pause=function(){this.tokenizer.pause()},O.prototype.resume=function(){for(this.tokenizer.resume();this.tokenizer.running&&this.writeIndex0?this.children[this.children.length-1]:null},enumerable:!1,configurable:!0}),Object.defineProperty(b.prototype,"childNodes",{get:function(){return this.children},set:function(C){this.children=C},enumerable:!1,configurable:!0}),b}(a);Ze.NodeWithChildren=u;var l=function(N){e(b,N);function b(){var C=N!==null&&N.apply(this,arguments)||this;return C.type=n.ElementType.CDATA,C}return Object.defineProperty(b.prototype,"nodeType",{get:function(){return 4},enumerable:!1,configurable:!0}),b}(u);Ze.CDATA=l;var d=function(N){e(b,N);function b(){var C=N!==null&&N.apply(this,arguments)||this;return C.type=n.ElementType.Root,C}return Object.defineProperty(b.prototype,"nodeType",{get:function(){return 9},enumerable:!1,configurable:!0}),b}(u);Ze.Document=d;var E=function(N){e(b,N);function b(C,k,P,$){P===void 0&&(P=[]),$===void 0&&($=C==="script"?n.ElementType.Script:C==="style"?n.ElementType.Style:n.ElementType.Tag);var y=N.call(this,P)||this;return y.name=C,y.attribs=k,y.type=$,y}return Object.defineProperty(b.prototype,"nodeType",{get:function(){return 1},enumerable:!1,configurable:!0}),Object.defineProperty(b.prototype,"tagName",{get:function(){return this.name},set:function(C){this.name=C},enumerable:!1,configurable:!0}),Object.defineProperty(b.prototype,"attributes",{get:function(){var C=this;return Object.keys(this.attribs).map(function(k){var P,$;return{name:k,value:C.attribs[k],namespace:(P=C["x-attribsNamespace"])===null||P===void 0?void 0:P[k],prefix:($=C["x-attribsPrefix"])===null||$===void 0?void 0:$[k]}})},enumerable:!1,configurable:!0}),b}(u);Ze.Element=E;function c(N){return(0,n.isTag)(N)}Ze.isTag=c;function m(N){return N.type===n.ElementType.CDATA}Ze.isCDATA=m;function _(N){return N.type===n.ElementType.Text}Ze.isText=_;function h(N){return N.type===n.ElementType.Comment}Ze.isComment=h;function O(N){return N.type===n.ElementType.Directive}Ze.isDirective=O;function S(N){return N.type===n.ElementType.Root}Ze.isDocument=S;function R(N){return Object.prototype.hasOwnProperty.call(N,"children")}Ze.hasChildren=R;function g(N,b){b===void 0&&(b=!1);var C;if(_(N))C=new o(N.data);else if(h(N))C=new i(N.data);else if(c(N)){var k=b?I(N.children):[],P=new E(N.name,t({},N.attribs),k);k.forEach(function(Z){return Z.parent=P}),N.namespace!=null&&(P.namespace=N.namespace),N["x-attribsNamespace"]&&(P["x-attribsNamespace"]=t({},N["x-attribsNamespace"])),N["x-attribsPrefix"]&&(P["x-attribsPrefix"]=t({},N["x-attribsPrefix"])),C=P}else if(m(N)){var k=b?I(N.children):[],$=new l(k);k.forEach(function(Ae){return Ae.parent=$}),C=$}else if(S(N)){var k=b?I(N.children):[],y=new d(k);k.forEach(function(Ae){return Ae.parent=y}),N["x-mode"]&&(y["x-mode"]=N["x-mode"]),C=y}else if(O(N)){var z=new r(N.name,N.data);N["x-name"]!=null&&(z["x-name"]=N["x-name"],z["x-publicId"]=N["x-publicId"],z["x-systemId"]=N["x-systemId"]),C=z}else throw new Error("Not implemented yet: ".concat(N.type));return C.startIndex=N.startIndex,C.endIndex=N.endIndex,N.sourceCodeLocation!=null&&(C.sourceCodeLocation=N.sourceCodeLocation),C}Ze.cloneNode=g;function I(N){for(var b=N.map(function(k){return g(k,!0)}),C=1;C$\x80-\uFFFF]/g;var t=new Map([[34,"""],[38,"&"],[39,"'"],[60,"<"],[62,">"]]);e.getCodePoint=String.prototype.codePointAt!=null?function(s,o){return s.codePointAt(o)}:function(s,o){return(s.charCodeAt(o)&64512)===55296?(s.charCodeAt(o)-55296)*1024+s.charCodeAt(o+1)-56320+65536:s.charCodeAt(o)};function n(s){for(var o="",i=0,r;(r=e.xmlReplacer.exec(s))!==null;){var u=r.index,l=s.charCodeAt(u),d=t.get(l);d!==void 0?(o+=s.substring(i,u)+d,i=u+1):(o+="".concat(s.substring(i,u),"&#x").concat((0,e.getCodePoint)(s,u).toString(16),";"),i=e.xmlReplacer.lastIndex+=+((l&64512)===55296))}return o+s.substr(i)}e.encodeXML=n,e.escape=n;function a(s,o){return function(r){for(var u,l=0,d="";u=s.exec(r);)l!==u.index&&(d+=r.substring(l,u.index)),d+=o.get(u[0].charCodeAt(0)),l=u.index+1;return d+r.substring(l)}}e.escapeUTF8=a(/[&<>'"]/g,t),e.escapeAttribute=a(/["&\u00A0]/g,new Map([[34,"""],[38,"&"],[160," "]])),e.escapeText=a(/[&<>\u00A0]/g,new Map([[38,"&"],[60,"<"],[62,">"],[160," "]]))}(Dc)),Dc}var Af;function Of(){if(Af)return Ua;Af=1;var e=Ua&&Ua.__importDefault||function(r){return r&&r.__esModule?r:{default:r}};Object.defineProperty(Ua,"__esModule",{value:!0}),Ua.encodeNonAsciiHTML=Ua.encodeHTML=void 0;var t=e(OVe()),n=TE(),a=/[\t\n!-,./:-@[-`\f{-}$\x80-\uFFFF]/g;function s(r){return i(a,r)}Ua.encodeHTML=s;function o(r){return i(n.xmlReplacer,r)}Ua.encodeNonAsciiHTML=o;function i(r,u){for(var l="",d=0,E;(E=r.exec(u))!==null;){var c=E.index;l+=u.substring(d,c);var m=u.charCodeAt(c),_=t.default.get(m);if(typeof _=="object"){if(c+10&&(C+=E(I.children,N)),(N.xmlMode||!d.has(I.name))&&(C+=""))),C}function O(I){return"<".concat(I.data,">")}function S(I,N){var b,C=I.data||"";return((b=N.encodeEntities)!==null&&b!==void 0?b:N.decodeEntities)!==!1&&!(!N.xmlMode&&I.parent&&r.has(I.parent.name))&&(C=N.xmlMode||N.encodeEntities!=="utf8"?(0,o.encodeXML)(C):(0,o.escapeText)(C)),C}function R(I){return"")}function g(I){return"")}return Nn}var Nf;function EO(){if(Nf)return dn;Nf=1;var e=dn&&dn.__importDefault||function(l){return l&&l.__esModule?l:{default:l}};Object.defineProperty(dn,"__esModule",{value:!0}),dn.innerText=dn.textContent=dn.getText=dn.getInnerHTML=dn.getOuterHTML=void 0;var t=Es(),n=e(RVe()),a=ur();function s(l,d){return(0,n.default)(l,d)}dn.getOuterHTML=s;function o(l,d){return(0,t.hasChildren)(l)?l.children.map(function(E){return s(E,d)}).join(""):""}dn.getInnerHTML=o;function i(l){return Array.isArray(l)?l.map(i).join(""):(0,t.isTag)(l)?l.name==="br"?` +`:i(l.children):(0,t.isCDATA)(l)?i(l.children):(0,t.isText)(l)?l.data:""}dn.getText=i;function r(l){return Array.isArray(l)?l.map(r).join(""):(0,t.hasChildren)(l)&&!(0,t.isComment)(l)?r(l.children):(0,t.isText)(l)?l.data:""}dn.textContent=r;function u(l){return Array.isArray(l)?l.map(u).join(""):(0,t.hasChildren)(l)&&(l.type===a.ElementType.Tag||(0,t.isCDATA)(l))?u(l.children):(0,t.isText)(l)?l.data:""}return dn.innerText=u,dn}var Kt={},vf;function NVe(){if(vf)return Kt;vf=1,Object.defineProperty(Kt,"__esModule",{value:!0}),Kt.prevElementSibling=Kt.nextElementSibling=Kt.getName=Kt.hasAttrib=Kt.getAttributeValue=Kt.getSiblings=Kt.getParent=Kt.getChildren=void 0;var e=Es();function t(l){return(0,e.hasChildren)(l)?l.children:[]}Kt.getChildren=t;function n(l){return l.parent||null}Kt.getParent=n;function a(l){var d,E,c=n(l);if(c!=null)return t(c);for(var m=[l],_=l.prev,h=l.next;_!=null;)m.unshift(_),d=_,_=d.prev;for(;h!=null;)m.push(h),E=h,h=E.next;return m}Kt.getSiblings=a;function s(l,d){var E;return(E=l.attribs)===null||E===void 0?void 0:E[d]}Kt.getAttributeValue=s;function o(l,d){return l.attribs!=null&&Object.prototype.hasOwnProperty.call(l.attribs,d)&&l.attribs[d]!=null}Kt.hasAttrib=o;function i(l){return l.name}Kt.getName=i;function r(l){for(var d,E=l.next;E!==null&&!(0,e.isTag)(E);)d=E,E=d.next;return E}Kt.nextElementSibling=r;function u(l){for(var d,E=l.prev;E!==null&&!(0,e.isTag)(E);)d=E,E=d.prev;return E}return Kt.prevElementSibling=u,Kt}var En={},bf;function vVe(){if(bf)return En;bf=1,Object.defineProperty(En,"__esModule",{value:!0}),En.prepend=En.prependChild=En.append=En.appendChild=En.replaceElement=En.removeElement=void 0;function e(i){if(i.prev&&(i.prev.next=i.next),i.next&&(i.next.prev=i.prev),i.parent){var r=i.parent.children,u=r.lastIndexOf(i);u>=0&&r.splice(u,1)}i.next=null,i.prev=null,i.parent=null}En.removeElement=e;function t(i,r){var u=r.prev=i.prev;u&&(u.next=r);var l=r.next=i.next;l&&(l.prev=r);var d=r.parent=i.parent;if(d){var E=d.children;E[E.lastIndexOf(i)]=r,i.parent=null}}En.replaceElement=t;function n(i,r){if(e(r),r.next=null,r.parent=i,i.children.push(r)>1){var u=i.children[i.children.length-2];u.next=r,r.prev=u}else r.prev=null}En.appendChild=n;function a(i,r){e(r);var u=i.parent,l=i.next;if(r.next=l,r.prev=i,i.next=r,r.parent=u,l){if(l.prev=r,u){var d=u.children;d.splice(d.lastIndexOf(l),0,r)}}else u&&u.children.push(r)}En.append=a;function s(i,r){if(e(r),r.parent=i,r.prev=null,i.children.unshift(r)!==1){var u=i.children[1];u.prev=r,r.next=u}else r.next=null}En.prependChild=s;function o(i,r){e(r);var u=i.parent;if(u){var l=u.children;l.splice(l.indexOf(i),0,r)}i.prev&&(i.prev.next=r),r.parent=u,r.prev=i.prev,r.next=i,i.prev=r}return En.prepend=o,En}var pn={},Cf;function pO(){if(Cf)return pn;Cf=1,Object.defineProperty(pn,"__esModule",{value:!0}),pn.findAll=pn.existsOne=pn.findOne=pn.findOneChild=pn.find=pn.filter=void 0;var e=Es();function t(r,u,l,d){return l===void 0&&(l=!0),d===void 0&&(d=1/0),n(r,Array.isArray(u)?u:[u],l,d)}pn.filter=t;function n(r,u,l,d){for(var E=[],c=[u],m=[0];;){if(m[0]>=c[0].length){if(m.length===1)return E;c.shift(),m.shift();continue}var _=c[0][m[0]++];if(r(_)&&(E.push(_),--d<=0))return E;l&&(0,e.hasChildren)(_)&&_.children.length>0&&(m.unshift(0),c.unshift(_.children))}}pn.find=n;function a(r,u){return u.find(r)}pn.findOneChild=a;function s(r,u,l){l===void 0&&(l=!0);for(var d=null,E=0;E0&&(d=s(r,c.children,!0));else continue}return d}pn.findOne=s;function o(r,u){return u.some(function(l){return(0,e.isTag)(l)&&(r(l)||o(r,l.children))})}pn.existsOne=o;function i(r,u){for(var l=[],d=[u],E=[0];;){if(E[0]>=d[0].length){if(d.length===1)return l;d.shift(),E.shift();continue}var c=d[0][E[0]++];(0,e.isTag)(c)&&(r(c)&&l.push(c),c.children.length>0&&(E.unshift(0),d.unshift(c.children)))}}return pn.findAll=i,pn}var wn={},Pf;function mO(){if(Pf)return wn;Pf=1,Object.defineProperty(wn,"__esModule",{value:!0}),wn.getElementsByTagType=wn.getElementsByTagName=wn.getElementById=wn.getElements=wn.testElement=void 0;var e=Es(),t=pO(),n={tag_name:function(E){return typeof E=="function"?function(c){return(0,e.isTag)(c)&&E(c.name)}:E==="*"?e.isTag:function(c){return(0,e.isTag)(c)&&c.name===E}},tag_type:function(E){return typeof E=="function"?function(c){return E(c.type)}:function(c){return c.type===E}},tag_contains:function(E){return typeof E=="function"?function(c){return(0,e.isText)(c)&&E(c.data)}:function(c){return(0,e.isText)(c)&&c.data===E}}};function a(E,c){return typeof c=="function"?function(m){return(0,e.isTag)(m)&&c(m.attribs[E])}:function(m){return(0,e.isTag)(m)&&m.attribs[E]===c}}function s(E,c){return function(m){return E(m)||c(m)}}function o(E){var c=Object.keys(E).map(function(m){var _=E[m];return Object.prototype.hasOwnProperty.call(n,m)?n[m](_):a(m,_)});return c.length===0?null:c.reduce(s)}function i(E,c){var m=o(E);return m?m(c):!0}wn.testElement=i;function r(E,c,m,_){_===void 0&&(_=1/0);var h=o(E);return h?(0,t.filter)(h,c,m,_):[]}wn.getElements=r;function u(E,c,m){return m===void 0&&(m=!0),Array.isArray(c)||(c=[c]),(0,t.findOne)(a("id",E),c,m)}wn.getElementById=u;function l(E,c,m,_){return m===void 0&&(m=!0),_===void 0&&(_=1/0),(0,t.filter)(n.tag_name(E),c,m,_)}wn.getElementsByTagName=l;function d(E,c,m,_){return m===void 0&&(m=!0),_===void 0&&(_=1/0),(0,t.filter)(n.tag_type(E),c,m,_)}return wn.getElementsByTagType=d,wn}var Lc={},Df;function bVe(){return Df||(Df=1,function(e){Object.defineProperty(e,"__esModule",{value:!0}),e.uniqueSort=e.compareDocumentPosition=e.DocumentPosition=e.removeSubsets=void 0;var t=Es();function n(i){for(var r=i.length;--r>=0;){var u=i[r];if(r>0&&i.lastIndexOf(u,r-1)>=0){i.splice(r,1);continue}for(var l=u.parent;l;l=l.parent)if(i.includes(l)){i.splice(r,1);break}}return i}e.removeSubsets=n;var a;(function(i){i[i.DISCONNECTED=1]="DISCONNECTED",i[i.PRECEDING=2]="PRECEDING",i[i.FOLLOWING=4]="FOLLOWING",i[i.CONTAINS=8]="CONTAINS",i[i.CONTAINED_BY=16]="CONTAINED_BY"})(a=e.DocumentPosition||(e.DocumentPosition={}));function s(i,r){var u=[],l=[];if(i===r)return 0;for(var d=(0,t.hasChildren)(i)?i:i.parent;d;)u.unshift(d),d=d.parent;for(d=(0,t.hasChildren)(r)?r:r.parent;d;)l.unshift(d),d=d.parent;for(var E=Math.min(u.length,l.length),c=0;c_.indexOf(O)?m===r?a.FOLLOWING|a.CONTAINED_BY:a.FOLLOWING:m===i?a.PRECEDING|a.CONTAINS:a.PRECEDING}e.compareDocumentPosition=s;function o(i){return i=i.filter(function(r,u,l){return!l.includes(r,u+1)}),i.sort(function(r,u){var l=s(r,u);return l&a.PRECEDING?-1:l&a.FOLLOWING?1:0}),i}e.uniqueSort=o}(Lc)),Lc}var ni={},Lf;function CVe(){if(Lf)return ni;Lf=1,Object.defineProperty(ni,"__esModule",{value:!0}),ni.getFeed=void 0;var e=EO(),t=mO();function n(c){var m=u(E,c);return m?m.name==="feed"?a(m):s(m):null}ni.getFeed=n;function a(c){var m,_=c.children,h={type:"atom",items:(0,t.getElementsByTagName)("entry",_).map(function(R){var g,I=R.children,N={media:r(I)};d(N,"id","id",I),d(N,"title","title",I);var b=(g=u("link",I))===null||g===void 0?void 0:g.attribs.href;b&&(N.link=b);var C=l("summary",I)||l("content",I);C&&(N.description=C);var k=l("updated",I);return k&&(N.pubDate=new Date(k)),N})};d(h,"id","id",_),d(h,"title","title",_);var O=(m=u("link",_))===null||m===void 0?void 0:m.attribs.href;O&&(h.link=O),d(h,"description","subtitle",_);var S=l("updated",_);return S&&(h.updated=new Date(S)),d(h,"author","email",_,!0),h}function s(c){var m,_,h=(_=(m=u("channel",c.children))===null||m===void 0?void 0:m.children)!==null&&_!==void 0?_:[],O={type:c.name.substr(0,3),id:"",items:(0,t.getElementsByTagName)("item",c.children).map(function(R){var g=R.children,I={media:r(g)};d(I,"id","guid",g),d(I,"title","title",g),d(I,"link","link",g),d(I,"description","description",g);var N=l("pubDate",g)||l("dc:date",g);return N&&(I.pubDate=new Date(N)),I})};d(O,"title","title",h),d(O,"link","link",h),d(O,"description","description",h);var S=l("lastBuildDate",h);return S&&(O.updated=new Date(S)),d(O,"author","managingEditor",h,!0),O}var o=["url","type","lang"],i=["fileSize","bitrate","framerate","samplingrate","channels","duration","height","width"];function r(c){return(0,t.getElementsByTagName)("media:content",c).map(function(m){for(var _=m.attribs,h={medium:_.medium,isDefault:!!_.isDefault},O=0,S=o;O{if(typeof e!="string")throw new TypeError("Expected a string");return e.replace(/[|\\{}()[\]^$+*?.]/g,"\\$&").replace(/-/g,"\\x2d")}),$c}var Dr={},kf;function LVe(){if(kf)return Dr;kf=1,Object.defineProperty(Dr,"__esModule",{value:!0});/*! + * is-plain-object + * + * Copyright (c) 2014-2017, Jon Schlinkert. + * Released under the MIT License. + */function e(n){return Object.prototype.toString.call(n)==="[object Object]"}function t(n){var a,s;return e(n)===!1?!1:(a=n.constructor,a===void 0?!0:(s=a.prototype,!(e(s)===!1||s.hasOwnProperty("isPrototypeOf")===!1)))}return Dr.isPlainObject=t,Dr}var Uc,wf;function yVe(){if(wf)return Uc;wf=1;var e=function(R){return t(R)&&!n(R)};function t(S){return!!S&&typeof S=="object"}function n(S){var R=Object.prototype.toString.call(S);return R==="[object RegExp]"||R==="[object Date]"||o(S)}var a=typeof Symbol=="function"&&Symbol.for,s=a?Symbol.for("react.element"):60103;function o(S){return S.$$typeof===s}function i(S){return Array.isArray(S)?[]:{}}function r(S,R){return R.clone!==!1&&R.isMergeableObject(S)?h(i(S),S,R):S}function u(S,R,g){return S.concat(R).map(function(I){return r(I,g)})}function l(S,R){if(!R.customMerge)return h;var g=R.customMerge(S);return typeof g=="function"?g:h}function d(S){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(S).filter(function(R){return Object.propertyIsEnumerable.call(S,R)}):[]}function E(S){return Object.keys(S).concat(d(S))}function c(S,R){try{return R in S}catch{return!1}}function m(S,R){return c(S,R)&&!(Object.hasOwnProperty.call(S,R)&&Object.propertyIsEnumerable.call(S,R))}function _(S,R,g){var I={};return g.isMergeableObject(S)&&E(S).forEach(function(N){I[N]=r(S[N],g)}),E(R).forEach(function(N){m(S,N)||(c(S,N)&&g.isMergeableObject(R[N])?I[N]=l(N,g)(S[N],R[N],g):I[N]=r(R[N],g))}),I}function h(S,R,g){g=g||{},g.arrayMerge=g.arrayMerge||u,g.isMergeableObject=g.isMergeableObject||e,g.cloneUnlessOtherwiseSpecified=r;var I=Array.isArray(R),N=Array.isArray(S),b=I===N;return b?I?g.arrayMerge(S,R,g):_(S,R,g):r(R,g)}h.all=function(R,g){if(!Array.isArray(R))throw new Error("first argument should be an array");return R.reduce(function(I,N){return h(I,N,g)},{})};var O=h;return Uc=O,Uc}var Qr={exports:{}},$Ve=Qr.exports,Mf;function UVe(){return Mf||(Mf=1,function(e){(function(t,n){e.exports?e.exports=n():t.parseSrcset=n()})($Ve,function(){return function(t){function n(I){return I===" "||I===" "||I===` +`||I==="\f"||I==="\r"}function a(I){var N,b=I.exec(t.substring(O));if(b)return N=b[0],O+=N.length,N}for(var s=t.length,o=/^[ \t\n\r\u000c]+/,i=/^[, \t\n\r\u000c]+/,r=/^[^ \t\n\r\u000c]+/,u=/[,]+$/,l=/^\d+$/,d=/^-?(?:[0-9]+|[0-9]*\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/,E,c,m,_,h,O=0,S=[];;){if(a(i),O>=s)return S;E=a(r),c=[],E.slice(-1)===","?(E=E.replace(u,""),g()):R()}function R(){for(a(o),m="",_="in descriptor";;){if(h=t.charAt(O),_==="in descriptor")if(n(h))m&&(c.push(m),m="",_="after descriptor");else if(h===","){O+=1,m&&c.push(m),g();return}else if(h==="(")m=m+h,_="in parens";else if(h===""){m&&c.push(m),g();return}else m=m+h;else if(_==="in parens")if(h===")")m=m+h,_="in descriptor";else if(h===""){c.push(m),g();return}else m=m+h;else if(_==="after descriptor"&&!n(h))if(h===""){g();return}else _="in descriptor",O-=1;O+=1}}function g(){var I=!1,N,b,C,k,P={},$,y,z,Z,Ae;for(k=0;k",typeof this.line<"u"&&(this.message+=":"+this.line+":"+this.column),this.message+=": "+this.reason}showSourceCode(s){if(!this.source)return"";let o=this.source;s==null&&(s=e.isColorSupported);let i=m=>m,r=m=>m,u=m=>m;if(s){let{bold:m,gray:_,red:h}=e.createColors(!0);r=O=>m(h(O)),i=O=>_(O),t&&(u=O=>t(O))}let l=o.split(/\r?\n/),d=Math.max(this.line-3,0),E=Math.min(this.line+2,l.length),c=String(E).length;return l.slice(d,E).map((m,_)=>{let h=d+1+_,O=" "+(" "+h).slice(-c)+" | ";if(h===this.line){if(m.length>160){let R=20,g=Math.max(0,this.column-R),I=Math.max(this.column+R,this.endColumn+R),N=m.slice(g,I),b=i(O.replace(/\d/g," "))+m.slice(0,Math.min(this.column-1,R-1)).replace(/[^\t]/g," ");return r(">")+i(O)+u(N)+` + `+b+r("^")}let S=i(O.replace(/\d/g," "))+m.slice(0,this.column-1).replace(/[^\t]/g," ");return r(">")+i(O)+u(m)+` + `+S+r("^")}return" "+i(O)+u(m)}).join(` +`)}toString(){let s=this.showSourceCode();return s&&(s=` + +`+s+` +`),this.name+": "+this.message+s}}return kc=n,n.default=n,kc}var wc,zf;function TO(){if(zf)return wc;zf=1;const e={after:` +`,beforeClose:` +`,beforeComment:` +`,beforeDecl:` +`,beforeOpen:" ",beforeRule:` +`,colon:": ",commentLeft:" ",commentRight:" ",emptyBody:"",indent:" ",semicolon:!1};function t(a){return a[0].toUpperCase()+a.slice(1)}class n{constructor(s){this.builder=s}atrule(s,o){let i="@"+s.name,r=s.params?this.rawValue(s,"params"):"";if(typeof s.raws.afterName<"u"?i+=s.raws.afterName:r&&(i+=" "),s.nodes)this.block(s,i+r);else{let u=(s.raws.between||"")+(o?";":"");this.builder(i+r+u,s)}}beforeAfter(s,o){let i;s.type==="decl"?i=this.raw(s,null,"beforeDecl"):s.type==="comment"?i=this.raw(s,null,"beforeComment"):o==="before"?i=this.raw(s,null,"beforeRule"):i=this.raw(s,null,"beforeClose");let r=s.parent,u=0;for(;r&&r.type!=="root";)u+=1,r=r.parent;if(i.includes(` +`)){let l=this.raw(s,null,"indent");if(l.length)for(let d=0;d0&&s.nodes[o].type==="comment";)o-=1;let i=this.raw(s,"semicolon");for(let r=0;r{if(r=E.raws[o],typeof r<"u")return!1})}return typeof r>"u"&&(r=e[i]),l.rawCache[i]=r,r}rawBeforeClose(s){let o;return s.walk(i=>{if(i.nodes&&i.nodes.length>0&&typeof i.raws.after<"u")return o=i.raws.after,o.includes(` +`)&&(o=o.replace(/[^\n]+$/,"")),!1}),o&&(o=o.replace(/\S/g,"")),o}rawBeforeComment(s,o){let i;return s.walkComments(r=>{if(typeof r.raws.before<"u")return i=r.raws.before,i.includes(` +`)&&(i=i.replace(/[^\n]+$/,"")),!1}),typeof i>"u"?i=this.raw(o,null,"beforeDecl"):i&&(i=i.replace(/\S/g,"")),i}rawBeforeDecl(s,o){let i;return s.walkDecls(r=>{if(typeof r.raws.before<"u")return i=r.raws.before,i.includes(` +`)&&(i=i.replace(/[^\n]+$/,"")),!1}),typeof i>"u"?i=this.raw(o,null,"beforeRule"):i&&(i=i.replace(/\S/g,"")),i}rawBeforeOpen(s){let o;return s.walk(i=>{if(i.type!=="decl"&&(o=i.raws.between,typeof o<"u"))return!1}),o}rawBeforeRule(s){let o;return s.walk(i=>{if(i.nodes&&(i.parent!==s||s.first!==i)&&typeof i.raws.before<"u")return o=i.raws.before,o.includes(` +`)&&(o=o.replace(/[^\n]+$/,"")),!1}),o&&(o=o.replace(/\S/g,"")),o}rawColon(s){let o;return s.walkDecls(i=>{if(typeof i.raws.between<"u")return o=i.raws.between.replace(/[^\s:]/g,""),!1}),o}rawEmptyBody(s){let o;return s.walk(i=>{if(i.nodes&&i.nodes.length===0&&(o=i.raws.after,typeof o<"u"))return!1}),o}rawIndent(s){if(s.raws.indent)return s.raws.indent;let o;return s.walk(i=>{let r=i.parent;if(r&&r!==s&&r.parent&&r.parent===s&&typeof i.raws.before<"u"){let u=i.raws.before.split(` +`);return o=u[u.length-1],o=o.replace(/\S/g,""),!1}}),o}rawSemicolon(s){let o;return s.walk(i=>{if(i.nodes&&i.nodes.length&&i.last.type==="decl"&&(o=i.raws.semicolon,typeof o<"u"))return!1}),o}rawValue(s,o){let i=s[o],r=s.raws[o];return r&&r.value===i?r.raw:i}root(s){this.body(s),s.raws.after&&this.builder(s.raws.after)}rule(s){this.block(s,this.rawValue(s,"selector")),s.raws.ownSemicolon&&this.builder(s.raws.ownSemicolon,s,"end")}stringify(s,o){if(!this[s.type])throw new Error("Unknown AST node type "+s.type+". Maybe you need to change PostCSS stringifier.");this[s.type](s,o)}}return wc=n,n.default=n,wc}var Mc,xf;function Dl(){if(xf)return Mc;xf=1;let e=TO();function t(n,a){new e(a).stringify(n)}return Mc=t,t.default=t,Mc}var yr={},Bf;function $p(){return Bf||(Bf=1,yr.isClean=Symbol("isClean"),yr.my=Symbol("my")),yr}var Wc,Gf;function Ll(){if(Gf)return Wc;Gf=1;let e=yp(),t=TO(),n=Dl(),{isClean:a,my:s}=$p();function o(u,l){let d=new u.constructor;for(let E in u){if(!Object.prototype.hasOwnProperty.call(u,E)||E==="proxyCache")continue;let c=u[E],m=typeof c;E==="parent"&&m==="object"?l&&(d[E]=l):E==="source"?d[E]=c:Array.isArray(c)?d[E]=c.map(_=>o(_,d)):(m==="object"&&c!==null&&(c=o(c)),d[E]=c)}return d}function i(u,l){if(l&&typeof l.offset<"u")return l.offset;let d=1,E=1,c=0;for(let m=0;ml.root().toProxy():l[d]},set(l,d,E){return l[d]===E||(l[d]=E,(d==="prop"||d==="value"||d==="name"||d==="params"||d==="important"||d==="text")&&l.markDirty()),!0}}}markClean(){this[a]=!0}markDirty(){if(this[a]){this[a]=!1;let l=this;for(;l=l.parent;)l[a]=!1}}next(){if(!this.parent)return;let l=this.parent.index(this);return this.parent.nodes[l+1]}positionBy(l){let d=this.source.start;if(l.index)d=this.positionInside(l.index);else if(l.word){let c=this.source.input.css.slice(i(this.source.input.css,this.source.start),i(this.source.input.css,this.source.end)).indexOf(l.word);c!==-1&&(d=this.positionInside(c))}return d}positionInside(l){let d=this.source.start.column,E=this.source.start.line,c=i(this.source.input.css,this.source.start),m=c+l;for(let _=c;_typeof O=="object"&&O.toJSON?O.toJSON(null,d):O);else if(typeof h=="object"&&h.toJSON)E[_]=h.toJSON(null,d);else if(_==="source"){let O=d.get(h.input);O==null&&(O=m,d.set(h.input,m),m++),E[_]={end:h.end,inputId:O,start:h.start}}else E[_]=h}return c&&(E.inputs=[...d.keys()].map(_=>_.toJSON())),E}toProxy(){return this.proxyCache||(this.proxyCache=new Proxy(this,this.getProxyProcessor())),this.proxyCache}toString(l=n){l.stringify&&(l=l.stringify);let d="";return l(this,E=>{d+=E}),d}warn(l,d,E){let c={node:this};for(let m in E)c[m]=E[m];return l.warn(d,c)}get proxyOf(){return this}}return Wc=r,r.default=r,Wc}var Fc,Vf;function yl(){if(Vf)return Fc;Vf=1;let e=Ll();class t extends e{constructor(a){super(a),this.type="comment"}}return Fc=t,t.default=t,Fc}var zc,Hf;function $l(){if(Hf)return zc;Hf=1;let e=Ll();class t extends e{constructor(a){a&&typeof a.value<"u"&&typeof a.value!="string"&&(a={...a,value:String(a.value)}),super(a),this.type="decl"}get variable(){return this.prop.startsWith("--")||this.prop[0]==="$"}}return zc=t,t.default=t,zc}var xc,Kf;function Ys(){if(Kf)return xc;Kf=1;let e=yl(),t=$l(),n=Ll(),{isClean:a,my:s}=$p(),o,i,r,u;function l(c){return c.map(m=>(m.nodes&&(m.nodes=l(m.nodes)),delete m.source,m))}function d(c){if(c[a]=!1,c.proxyOf.nodes)for(let m of c.proxyOf.nodes)d(m)}class E extends n{append(...m){for(let _ of m){let h=this.normalize(_,this.last);for(let O of h)this.proxyOf.nodes.push(O)}return this.markDirty(),this}cleanRaws(m){if(super.cleanRaws(m),this.nodes)for(let _ of this.nodes)_.cleanRaws(m)}each(m){if(!this.proxyOf.nodes)return;let _=this.getIterator(),h,O;for(;this.indexes[_]m[_](...h.map(O=>typeof O=="function"?(S,R)=>O(S.toProxy(),R):O)):_==="every"||_==="some"?h=>m[_]((O,...S)=>h(O.toProxy(),...S)):_==="root"?()=>m.root().toProxy():_==="nodes"?m.nodes.map(h=>h.toProxy()):_==="first"||_==="last"?m[_].toProxy():m[_]:m[_]},set(m,_,h){return m[_]===h||(m[_]=h,(_==="name"||_==="params"||_==="selector")&&m.markDirty()),!0}}}index(m){return typeof m=="number"?m:(m.proxyOf&&(m=m.proxyOf),this.proxyOf.nodes.indexOf(m))}insertAfter(m,_){let h=this.index(m),O=this.normalize(_,this.proxyOf.nodes[h]).reverse();h=this.index(m);for(let R of O)this.proxyOf.nodes.splice(h+1,0,R);let S;for(let R in this.indexes)S=this.indexes[R],h"u")m=[];else if(Array.isArray(m)){m=m.slice(0);for(let O of m)O.parent&&O.parent.removeChild(O,"ignore")}else if(m.type==="root"&&this.type!=="document"){m=m.nodes.slice(0);for(let O of m)O.parent&&O.parent.removeChild(O,"ignore")}else if(m.type)m=[m];else if(m.prop){if(typeof m.value>"u")throw new Error("Value field is missed in node creation");typeof m.value!="string"&&(m.value=String(m.value)),m=[new t(m)]}else if(m.selector||m.selectors)m=[new u(m)];else if(m.name)m=[new o(m)];else if(m.text)m=[new e(m)];else throw new Error("Unknown node type in node creation");return m.map(O=>(O[s]||E.rebuild(O),O=O.proxyOf,O.parent&&O.parent.removeChild(O),O[a]&&d(O),O.raws||(O.raws={}),typeof O.raws.before>"u"&&_&&typeof _.raws.before<"u"&&(O.raws.before=_.raws.before.replace(/\S/g,"")),O.parent=this.proxyOf,O))}prepend(...m){m=m.reverse();for(let _ of m){let h=this.normalize(_,this.first,"prepend").reverse();for(let O of h)this.proxyOf.nodes.unshift(O);for(let O in this.indexes)this.indexes[O]=this.indexes[O]+h.length}return this.markDirty(),this}push(m){return m.parent=this,this.proxyOf.nodes.push(m),this}removeAll(){for(let m of this.proxyOf.nodes)m.parent=void 0;return this.proxyOf.nodes=[],this.markDirty(),this}removeChild(m){m=this.index(m),this.proxyOf.nodes[m].parent=void 0,this.proxyOf.nodes.splice(m,1);let _;for(let h in this.indexes)_=this.indexes[h],_>=m&&(this.indexes[h]=_-1);return this.markDirty(),this}replaceValues(m,_,h){return h||(h=_,_={}),this.walkDecls(O=>{_.props&&!_.props.includes(O.prop)||_.fast&&!O.value.includes(_.fast)||(O.value=O.value.replace(m,h))}),this.markDirty(),this}some(m){return this.nodes.some(m)}walk(m){return this.each((_,h)=>{let O;try{O=m(_,h)}catch(S){throw _.addToError(S)}return O!==!1&&_.walk&&(O=_.walk(m)),O})}walkAtRules(m,_){return _?m instanceof RegExp?this.walk((h,O)=>{if(h.type==="atrule"&&m.test(h.name))return _(h,O)}):this.walk((h,O)=>{if(h.type==="atrule"&&h.name===m)return _(h,O)}):(_=m,this.walk((h,O)=>{if(h.type==="atrule")return _(h,O)}))}walkComments(m){return this.walk((_,h)=>{if(_.type==="comment")return m(_,h)})}walkDecls(m,_){return _?m instanceof RegExp?this.walk((h,O)=>{if(h.type==="decl"&&m.test(h.prop))return _(h,O)}):this.walk((h,O)=>{if(h.type==="decl"&&h.prop===m)return _(h,O)}):(_=m,this.walk((h,O)=>{if(h.type==="decl")return _(h,O)}))}walkRules(m,_){return _?m instanceof RegExp?this.walk((h,O)=>{if(h.type==="rule"&&m.test(h.selector))return _(h,O)}):this.walk((h,O)=>{if(h.type==="rule"&&h.selector===m)return _(h,O)}):(_=m,this.walk((h,O)=>{if(h.type==="rule")return _(h,O)}))}get first(){if(this.proxyOf.nodes)return this.proxyOf.nodes[0]}get last(){if(this.proxyOf.nodes)return this.proxyOf.nodes[this.proxyOf.nodes.length-1]}}return E.registerParse=c=>{i=c},E.registerRule=c=>{u=c},E.registerAtRule=c=>{o=c},E.registerRoot=c=>{r=c},xc=E,E.default=E,E.rebuild=c=>{c.type==="atrule"?Object.setPrototypeOf(c,o.prototype):c.type==="rule"?Object.setPrototypeOf(c,u.prototype):c.type==="decl"?Object.setPrototypeOf(c,t.prototype):c.type==="comment"?Object.setPrototypeOf(c,e.prototype):c.type==="root"&&Object.setPrototypeOf(c,r.prototype),c[s]=!0,c.nodes&&c.nodes.forEach(m=>{E.rebuild(m)})},xc}var Bc,qf;function Up(){if(qf)return Bc;qf=1;let e=Ys();class t extends e{constructor(a){super(a),this.type="atrule"}append(...a){return this.proxyOf.nodes||(this.nodes=[]),super.append(...a)}prepend(...a){return this.proxyOf.nodes||(this.nodes=[]),super.prepend(...a)}}return Bc=t,t.default=t,e.registerAtRule(t),Bc}var Gc,jf;function kp(){if(jf)return Gc;jf=1;let e=Ys(),t,n;class a extends e{constructor(o){super({type:"document",...o}),this.nodes||(this.nodes=[])}toResult(o={}){return new t(new n,this,o).stringify()}}return a.registerLazyResult=s=>{t=s},a.registerProcessor=s=>{n=s},Gc=a,a.default=a,Gc}var Vc,Yf;function WVe(){if(Yf)return Vc;Yf=1;let e="useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";return Vc={nanoid:(a=21)=>{let s="",o=a|0;for(;o--;)s+=e[Math.random()*64|0];return s},customAlphabet:(a,s=21)=>(o=s)=>{let i="",r=o|0;for(;r--;)i+=a[Math.random()*a.length|0];return i}},Vc}var Hc,Xf;function _O(){if(Xf)return Hc;Xf=1;let{existsSync:e,readFileSync:t}=sa,{dirname:n,join:a}=sa,{SourceMapConsumer:s,SourceMapGenerator:o}=sa;function i(u){return Buffer?Buffer.from(u,"base64").toString():window.atob(u)}class r{constructor(l,d){if(d.map===!1)return;this.loadAnnotation(l),this.inline=this.startWith(this.annotation,"data:");let E=d.map?d.map.prev:void 0,c=this.loadMap(d.from,E);!this.mapFile&&d.from&&(this.mapFile=d.from),this.mapFile&&(this.root=n(this.mapFile)),c&&(this.text=c)}consumer(){return this.consumerCache||(this.consumerCache=new s(this.text)),this.consumerCache}decodeInline(l){let d=/^data:application\/json;charset=utf-?8;base64,/,E=/^data:application\/json;base64,/,c=/^data:application\/json;charset=utf-?8,/,m=/^data:application\/json,/,_=l.match(c)||l.match(m);if(_)return decodeURIComponent(l.substr(_[0].length));let h=l.match(d)||l.match(E);if(h)return i(l.substr(h[0].length));let O=l.match(/data:application\/json;([^,]+),/)[1];throw new Error("Unsupported source map encoding "+O)}getAnnotationURL(l){return l.replace(/^\/\*\s*# sourceMappingURL=/,"").trim()}isMap(l){return typeof l!="object"?!1:typeof l.mappings=="string"||typeof l._mappings=="string"||Array.isArray(l.sections)}loadAnnotation(l){let d=l.match(/\/\*\s*# sourceMappingURL=/g);if(!d)return;let E=l.lastIndexOf(d.pop()),c=l.indexOf("*/",E);E>-1&&c>-1&&(this.annotation=this.getAnnotationURL(l.substring(E,c)))}loadFile(l){if(this.root=n(l),e(l))return this.mapFile=l,t(l,"utf-8").toString().trim()}loadMap(l,d){if(d===!1)return!1;if(d){if(typeof d=="string")return d;if(typeof d=="function"){let E=d(l);if(E){let c=this.loadFile(E);if(!c)throw new Error("Unable to load previous source map: "+E.toString());return c}}else{if(d instanceof s)return o.fromSourceMap(d).toString();if(d instanceof o)return d.toString();if(this.isMap(d))return JSON.stringify(d);throw new Error("Unsupported previous source map format: "+d.toString())}}else{if(this.inline)return this.decodeInline(this.annotation);if(this.annotation){let E=this.annotation;return l&&(E=a(n(l),E)),this.loadFile(E)}}}startWith(l,d){return l?l.substr(0,d.length)===d:!1}withContent(){return!!(this.consumer().sourcesContent&&this.consumer().sourcesContent.length>0)}}return Hc=r,r.default=r,Hc}var Kc,Qf;function Ul(){if(Qf)return Kc;Qf=1;let{nanoid:e}=WVe(),{isAbsolute:t,resolve:n}=sa,{SourceMapConsumer:a,SourceMapGenerator:s}=sa,{fileURLToPath:o,pathToFileURL:i}=sa,r=yp(),u=_O(),l=sa,d=Symbol("fromOffsetCache"),E=!!(a&&s),c=!!(n&&t);class m{constructor(h,O={}){if(h===null||typeof h>"u"||typeof h=="object"&&!h.toString)throw new Error(`PostCSS received ${h} instead of CSS string`);if(this.css=h.toString(),this.css[0]==="\uFEFF"||this.css[0]==="￾"?(this.hasBOM=!0,this.css=this.css.slice(1)):this.hasBOM=!1,O.from&&(!c||/^\w+:\/\//.test(O.from)||t(O.from)?this.file=O.from:this.file=n(O.from)),c&&E){let S=new u(this.css,O);if(S.text){this.map=S;let R=S.consumer().file;!this.file&&R&&(this.file=this.mapResolve(R))}}this.file||(this.id=""),this.map&&(this.map.file=this.from)}error(h,O,S,R={}){let g,I,N;if(O&&typeof O=="object"){let C=O,k=S;if(typeof C.offset=="number"){let P=this.fromOffset(C.offset);O=P.line,S=P.col}else O=C.line,S=C.column;if(typeof k.offset=="number"){let P=this.fromOffset(k.offset);I=P.line,g=P.col}else I=k.line,g=k.column}else if(!S){let C=this.fromOffset(O);O=C.line,S=C.col}let b=this.origin(O,S,I,g);return b?N=new r(h,b.endLine===void 0?b.line:{column:b.column,line:b.line},b.endLine===void 0?b.column:{column:b.endColumn,line:b.endLine},b.source,b.file,R.plugin):N=new r(h,I===void 0?O:{column:S,line:O},I===void 0?S:{column:g,line:I},this.css,this.file,R.plugin),N.input={column:S,endColumn:g,endLine:I,line:O,source:this.css},this.file&&(i&&(N.input.url=i(this.file).toString()),N.input.file=this.file),N}fromOffset(h){let O,S;if(this[d])S=this[d];else{let g=this.css.split(` +`);S=new Array(g.length);let I=0;for(let N=0,b=g.length;N=O)R=S.length-1;else{let g=S.length-2,I;for(;R>1),h=S[I+1])R=I+1;else{R=I;break}}return{col:h-S[R]+1,line:R+1}}mapResolve(h){return/^\w+:\/\//.test(h)?h:n(this.map.consumer().sourceRoot||this.map.root||".",h)}origin(h,O,S,R){if(!this.map)return!1;let g=this.map.consumer(),I=g.originalPositionFor({column:O,line:h});if(!I.source)return!1;let N;typeof S=="number"&&(N=g.originalPositionFor({column:R,line:S}));let b;t(I.source)?b=i(I.source):b=new URL(I.source,this.map.consumer().sourceRoot||i(this.map.mapFile));let C={column:I.column,endColumn:N&&N.column,endLine:N&&N.line,line:I.line,url:b.toString()};if(b.protocol==="file:")if(o)C.file=o(b);else throw new Error("file: protocol is not available in this PostCSS build");let k=g.sourceContentFor(I.source);return k&&(C.source=k),C}toJSON(){let h={};for(let O of["hasBOM","css","file","id"])this[O]!=null&&(h[O]=this[O]);return this.map&&(h.map={...this.map},h.map.consumerCache&&(h.map.consumerCache=void 0)),h}get from(){return this.file||this.id}}return Kc=m,m.default=m,l&&l.registerInput&&l.registerInput(m),Kc}var qc,Zf;function lr(){if(Zf)return qc;Zf=1;let e=Ys(),t,n;class a extends e{constructor(o){super(o),this.type="root",this.nodes||(this.nodes=[])}normalize(o,i,r){let u=super.normalize(o);if(i){if(r==="prepend")this.nodes.length>1?i.raws.before=this.nodes[1].raws.before:delete i.raws.before;else if(this.first!==i)for(let l of u)l.raws.before=i.raws.before}return u}removeChild(o,i){let r=this.index(o);return!i&&r===0&&this.nodes.length>1&&(this.nodes[1].raws.before=this.nodes[r].raws.before),super.removeChild(o)}toResult(o={}){return new t(new n,this,o).stringify()}}return a.registerLazyResult=s=>{t=s},a.registerProcessor=s=>{n=s},qc=a,a.default=a,e.registerRoot(a),qc}var jc,Jf;function fO(){if(Jf)return jc;Jf=1;let e={comma(t){return e.split(t,[","],!0)},space(t){let n=[" ",` +`," "];return e.split(t,n)},split(t,n,a){let s=[],o="",i=!1,r=0,u=!1,l="",d=!1;for(let E of t)d?d=!1:E==="\\"?d=!0:u?E===l&&(u=!1):E==='"'||E==="'"?(u=!0,l=E):E==="("?r+=1:E===")"?r>0&&(r-=1):r===0&&n.includes(E)&&(i=!0),i?(o!==""&&s.push(o.trim()),o="",i=!1):o+=E;return(a||o!=="")&&s.push(o.trim()),s}};return jc=e,e.default=e,jc}var Yc,eh;function wp(){if(eh)return Yc;eh=1;let e=Ys(),t=fO();class n extends e{constructor(s){super(s),this.type="rule",this.nodes||(this.nodes=[])}get selectors(){return t.comma(this.selector)}set selectors(s){let o=this.selector?this.selector.match(/,\s*/):null,i=o?o[0]:","+this.raw("between","beforeOpen");this.selector=s.join(i)}}return Yc=n,n.default=n,e.registerRule(n),Yc}var Xc,th;function FVe(){if(th)return Xc;th=1;let e=Up(),t=yl(),n=$l(),a=Ul(),s=_O(),o=lr(),i=wp();function r(u,l){if(Array.isArray(u))return u.map(c=>r(c));let{inputs:d,...E}=u;if(d){l=[];for(let c of d){let m={...c,__proto__:a.prototype};m.map&&(m.map={...m.map,__proto__:s.prototype}),l.push(m)}}if(E.nodes&&(E.nodes=u.nodes.map(c=>r(c,l))),E.source){let{inputId:c,...m}=E.source;E.source=m,c!=null&&(E.source.input=l[c])}if(E.type==="root")return new o(E);if(E.type==="decl")return new n(E);if(E.type==="rule")return new i(E);if(E.type==="comment")return new t(E);if(E.type==="atrule")return new e(E);throw new Error("Unknown node type: "+u.type)}return Xc=r,r.default=r,Xc}var Qc,nh;function hO(){if(nh)return Qc;nh=1;let{dirname:e,relative:t,resolve:n,sep:a}=sa,{SourceMapConsumer:s,SourceMapGenerator:o}=sa,{pathToFileURL:i}=sa,r=Ul(),u=!!(s&&o),l=!!(e&&n&&t&&a);class d{constructor(c,m,_,h){this.stringify=c,this.mapOpts=_.map||{},this.root=m,this.opts=_,this.css=h,this.originalCSS=h,this.usesFileUrls=!this.mapOpts.from&&this.mapOpts.absolute,this.memoizedFileURLs=new Map,this.memoizedPaths=new Map,this.memoizedURLs=new Map}addAnnotation(){let c;this.isInline()?c="data:application/json;base64,"+this.toBase64(this.map.toString()):typeof this.mapOpts.annotation=="string"?c=this.mapOpts.annotation:typeof this.mapOpts.annotation=="function"?c=this.mapOpts.annotation(this.opts.to,this.root):c=this.outputFile()+".map";let m=` +`;this.css.includes(`\r +`)&&(m=`\r +`),this.css+=m+"/*# sourceMappingURL="+c+" */"}applyPrevMaps(){for(let c of this.previous()){let m=this.toUrl(this.path(c.file)),_=c.root||e(c.file),h;this.mapOpts.sourcesContent===!1?(h=new s(c.text),h.sourcesContent&&(h.sourcesContent=null)):h=c.consumer(),this.map.applySourceMap(h,m,this.toUrl(this.path(_)))}}clearAnnotation(){if(this.mapOpts.annotation!==!1)if(this.root){let c;for(let m=this.root.nodes.length-1;m>=0;m--)c=this.root.nodes[m],c.type==="comment"&&c.text.startsWith("# sourceMappingURL=")&&this.root.removeChild(m)}else this.css&&(this.css=this.css.replace(/\n*\/\*#[\S\s]*?\*\/$/gm,""))}generate(){if(this.clearAnnotation(),l&&u&&this.isMap())return this.generateMap();{let c="";return this.stringify(this.root,m=>{c+=m}),[c]}}generateMap(){if(this.root)this.generateString();else if(this.previous().length===1){let c=this.previous()[0].consumer();c.file=this.outputFile(),this.map=o.fromSourceMap(c,{ignoreInvalidMapping:!0})}else this.map=new o({file:this.outputFile(),ignoreInvalidMapping:!0}),this.map.addMapping({generated:{column:0,line:1},original:{column:0,line:1},source:this.opts.from?this.toUrl(this.path(this.opts.from)):""});return this.isSourcesContent()&&this.setSourcesContent(),this.root&&this.previous().length>0&&this.applyPrevMaps(),this.isAnnotation()&&this.addAnnotation(),this.isInline()?[this.css]:[this.css,this.map]}generateString(){this.css="",this.map=new o({file:this.outputFile(),ignoreInvalidMapping:!0});let c=1,m=1,_="",h={generated:{column:0,line:0},original:{column:0,line:0},source:""},O,S;this.stringify(this.root,(R,g,I)=>{if(this.css+=R,g&&I!=="end"&&(h.generated.line=c,h.generated.column=m-1,g.source&&g.source.start?(h.source=this.sourcePath(g),h.original.line=g.source.start.line,h.original.column=g.source.start.column-1,this.map.addMapping(h)):(h.source=_,h.original.line=1,h.original.column=0,this.map.addMapping(h))),S=R.match(/\n/g),S?(c+=S.length,O=R.lastIndexOf(` +`),m=R.length-O):m+=R.length,g&&I!=="start"){let N=g.parent||{raws:{}};(!(g.type==="decl"||g.type==="atrule"&&!g.nodes)||g!==N.last||N.raws.semicolon)&&(g.source&&g.source.end?(h.source=this.sourcePath(g),h.original.line=g.source.end.line,h.original.column=g.source.end.column-1,h.generated.line=c,h.generated.column=m-2,this.map.addMapping(h)):(h.source=_,h.original.line=1,h.original.column=0,h.generated.line=c,h.generated.column=m-1,this.map.addMapping(h)))}})}isAnnotation(){return this.isInline()?!0:typeof this.mapOpts.annotation<"u"?this.mapOpts.annotation:this.previous().length?this.previous().some(c=>c.annotation):!0}isInline(){if(typeof this.mapOpts.inline<"u")return this.mapOpts.inline;let c=this.mapOpts.annotation;return typeof c<"u"&&c!==!0?!1:this.previous().length?this.previous().some(m=>m.inline):!0}isMap(){return typeof this.opts.map<"u"?!!this.opts.map:this.previous().length>0}isSourcesContent(){return typeof this.mapOpts.sourcesContent<"u"?this.mapOpts.sourcesContent:this.previous().length?this.previous().some(c=>c.withContent()):!0}outputFile(){return this.opts.to?this.path(this.opts.to):this.opts.from?this.path(this.opts.from):"to.css"}path(c){if(this.mapOpts.absolute||c.charCodeAt(0)===60||/^\w+:\/\//.test(c))return c;let m=this.memoizedPaths.get(c);if(m)return m;let _=this.opts.to?e(this.opts.to):".";typeof this.mapOpts.annotation=="string"&&(_=e(n(_,this.mapOpts.annotation)));let h=t(_,c);return this.memoizedPaths.set(c,h),h}previous(){if(!this.previousMaps)if(this.previousMaps=[],this.root)this.root.walk(c=>{if(c.source&&c.source.input.map){let m=c.source.input.map;this.previousMaps.includes(m)||this.previousMaps.push(m)}});else{let c=new r(this.originalCSS,this.opts);c.map&&this.previousMaps.push(c.map)}return this.previousMaps}setSourcesContent(){let c={};if(this.root)this.root.walk(m=>{if(m.source){let _=m.source.input.from;if(_&&!c[_]){c[_]=!0;let h=this.usesFileUrls?this.toFileUrl(_):this.toUrl(this.path(_));this.map.setSourceContent(h,m.source.input.css)}}});else if(this.css){let m=this.opts.from?this.toUrl(this.path(this.opts.from)):"";this.map.setSourceContent(m,this.css)}}sourcePath(c){return this.mapOpts.from?this.toUrl(this.mapOpts.from):this.usesFileUrls?this.toFileUrl(c.source.input.from):this.toUrl(this.path(c.source.input.from))}toBase64(c){return Buffer?Buffer.from(c).toString("base64"):window.btoa(unescape(encodeURIComponent(c)))}toFileUrl(c){let m=this.memoizedFileURLs.get(c);if(m)return m;if(i){let _=i(c).toString();return this.memoizedFileURLs.set(c,_),_}else throw new Error("`map.absolute` option is not available in this PostCSS build")}toUrl(c){let m=this.memoizedURLs.get(c);if(m)return m;a==="\\"&&(c=c.replace(/\\/g,"/"));let _=encodeURI(c).replace(/[#?]/g,encodeURIComponent);return this.memoizedURLs.set(c,_),_}}return Qc=d,Qc}var Zc,ah;function zVe(){if(ah)return Zc;ah=1;const e=39,t=34,n=92,a=47,s=10,o=32,i=12,r=9,u=13,l=91,d=93,E=40,c=41,m=123,_=125,h=59,O=42,S=58,R=64,g=/[\t\n\f\r "#'()/;[\\\]{}]/g,I=/[\t\n\f\r !"#'():;@[\\\]{}]|\/(?=\*)/g,N=/.[\r\n"'(/\\]/,b=/[\da-f]/i;return Zc=function(k,P={}){let $=k.css.valueOf(),y=P.ignoreErrors,z,Z,Ae,J,ce,Te,De,Ve,xe,ot,re=$.length,Oe=0,pt=[],wt=[];function It(){return Oe}function de(ae){throw k.error("Unclosed "+ae,Oe)}function H(){return wt.length===0&&Oe>=re}function fe(ae){if(wt.length)return wt.pop();if(Oe>=re)return;let Ie=ae?ae.ignoreUnclosed:!1;switch(z=$.charCodeAt(Oe),z){case s:case o:case r:case u:case i:{J=Oe;do J+=1,z=$.charCodeAt(J);while(z===o||z===s||z===r||z===u||z===i);Te=["space",$.slice(Oe,J)],Oe=J-1;break}case l:case d:case m:case _:case S:case h:case c:{let U=String.fromCharCode(z);Te=[U,U,Oe];break}case E:{if(ot=pt.length?pt.pop()[1]:"",xe=$.charCodeAt(Oe+1),ot==="url"&&xe!==e&&xe!==t&&xe!==o&&xe!==s&&xe!==r&&xe!==i&&xe!==u){J=Oe;do{if(De=!1,J=$.indexOf(")",J+1),J===-1)if(y||Ie){J=Oe;break}else de("bracket");for(Ve=J;$.charCodeAt(Ve-1)===n;)Ve-=1,De=!De}while(De);Te=["brackets",$.slice(Oe,J+1),Oe,J],Oe=J}else J=$.indexOf(")",Oe+1),Z=$.slice(Oe,J+1),J===-1||N.test(Z)?Te=["(","(",Oe]:(Te=["brackets",Z,Oe,J],Oe=J);break}case e:case t:{ce=z===e?"'":'"',J=Oe;do{if(De=!1,J=$.indexOf(ce,J+1),J===-1)if(y||Ie){J=Oe+1;break}else de("string");for(Ve=J;$.charCodeAt(Ve-1)===n;)Ve-=1,De=!De}while(De);Te=["string",$.slice(Oe,J+1),Oe,J],Oe=J;break}case R:{g.lastIndex=Oe+1,g.test($),g.lastIndex===0?J=$.length-1:J=g.lastIndex-2,Te=["at-word",$.slice(Oe,J+1),Oe,J],Oe=J;break}case n:{for(J=Oe,Ae=!0;$.charCodeAt(J+1)===n;)J+=1,Ae=!Ae;if(z=$.charCodeAt(J+1),Ae&&z!==a&&z!==o&&z!==s&&z!==r&&z!==u&&z!==i&&(J+=1,b.test($.charAt(J)))){for(;b.test($.charAt(J+1));)J+=1;$.charCodeAt(J+1)===o&&(J+=1)}Te=["word",$.slice(Oe,J+1),Oe,J],Oe=J;break}default:{z===a&&$.charCodeAt(Oe+1)===O?(J=$.indexOf("*/",Oe+2)+1,J===0&&(y||Ie?J=$.length:de("comment")),Te=["comment",$.slice(Oe,J+1),Oe,J],Oe=J):(I.lastIndex=Oe+1,I.test($),I.lastIndex===0?J=$.length-1:J=I.lastIndex-2,Te=["word",$.slice(Oe,J+1),Oe,J],pt.push(Te),Oe=J);break}}return Oe++,Te}function Ce(ae){wt.push(ae)}return{back:Ce,endOfFile:H,nextToken:fe,position:It}},Zc}var Jc,sh;function xVe(){if(sh)return Jc;sh=1;let e=Up(),t=yl(),n=$l(),a=lr(),s=wp(),o=zVe();const i={empty:!0,space:!0};function r(l){for(let d=l.length-1;d>=0;d--){let E=l[d],c=E[3]||E[2];if(c)return c}}class u{constructor(d){this.input=d,this.root=new a,this.current=this.root,this.spaces="",this.semicolon=!1,this.createTokenizer(),this.root.source={input:d,start:{column:1,line:1,offset:0}}}atrule(d){let E=new e;E.name=d[1].slice(1),E.name===""&&this.unnamedAtrule(E,d),this.init(E,d[2]);let c,m,_,h=!1,O=!1,S=[],R=[];for(;!this.tokenizer.endOfFile();){if(d=this.tokenizer.nextToken(),c=d[0],c==="("||c==="["?R.push(c==="("?")":"]"):c==="{"&&R.length>0?R.push("}"):c===R[R.length-1]&&R.pop(),R.length===0)if(c===";"){E.source.end=this.getPosition(d[2]),E.source.end.offset++,this.semicolon=!0;break}else if(c==="{"){O=!0;break}else if(c==="}"){if(S.length>0){for(_=S.length-1,m=S[_];m&&m[0]==="space";)m=S[--_];m&&(E.source.end=this.getPosition(m[3]||m[2]),E.source.end.offset++)}this.end(d);break}else S.push(d);else S.push(d);if(this.tokenizer.endOfFile()){h=!0;break}}E.raws.between=this.spacesAndCommentsFromEnd(S),S.length?(E.raws.afterName=this.spacesAndCommentsFromStart(S),this.raw(E,"params",S),h&&(d=S[S.length-1],E.source.end=this.getPosition(d[3]||d[2]),E.source.end.offset++,this.spaces=E.raws.between,E.raws.between="")):(E.raws.afterName="",E.params=""),O&&(E.nodes=[],this.current=E)}checkMissedSemicolon(d){let E=this.colon(d);if(E===!1)return;let c=0,m;for(let _=E-1;_>=0&&(m=d[_],!(m[0]!=="space"&&(c+=1,c===2)));_--);throw this.input.error("Missed semicolon",m[0]==="word"?m[3]+1:m[2])}colon(d){let E=0,c,m,_;for(let[h,O]of d.entries()){if(m=O,_=m[0],_==="("&&(E+=1),_===")"&&(E-=1),E===0&&_===":")if(!c)this.doubleColon(m);else{if(c[0]==="word"&&c[1]==="progid")continue;return h}c=m}return!1}comment(d){let E=new t;this.init(E,d[2]),E.source.end=this.getPosition(d[3]||d[2]),E.source.end.offset++;let c=d[1].slice(2,-2);if(/^\s*$/.test(c))E.text="",E.raws.left=c,E.raws.right="";else{let m=c.match(/^(\s*)([^]*\S)(\s*)$/);E.text=m[2],E.raws.left=m[1],E.raws.right=m[3]}}createTokenizer(){this.tokenizer=o(this.input)}decl(d,E){let c=new n;this.init(c,d[0][2]);let m=d[d.length-1];for(m[0]===";"&&(this.semicolon=!0,d.pop()),c.source.end=this.getPosition(m[3]||m[2]||r(d)),c.source.end.offset++;d[0][0]!=="word";)d.length===1&&this.unknownWord(d),c.raws.before+=d.shift()[1];for(c.source.start=this.getPosition(d[0][2]),c.prop="";d.length;){let R=d[0][0];if(R===":"||R==="space"||R==="comment")break;c.prop+=d.shift()[1]}c.raws.between="";let _;for(;d.length;)if(_=d.shift(),_[0]===":"){c.raws.between+=_[1];break}else _[0]==="word"&&/\w/.test(_[1])&&this.unknownWord([_]),c.raws.between+=_[1];(c.prop[0]==="_"||c.prop[0]==="*")&&(c.raws.before+=c.prop[0],c.prop=c.prop.slice(1));let h=[],O;for(;d.length&&(O=d[0][0],!(O!=="space"&&O!=="comment"));)h.push(d.shift());this.precheckMissedSemicolon(d);for(let R=d.length-1;R>=0;R--){if(_=d[R],_[1].toLowerCase()==="!important"){c.important=!0;let g=this.stringFrom(d,R);g=this.spacesFromEnd(d)+g,g!==" !important"&&(c.raws.important=g);break}else if(_[1].toLowerCase()==="important"){let g=d.slice(0),I="";for(let N=R;N>0;N--){let b=g[N][0];if(I.trim().startsWith("!")&&b!=="space")break;I=g.pop()[1]+I}I.trim().startsWith("!")&&(c.important=!0,c.raws.important=I,d=g)}if(_[0]!=="space"&&_[0]!=="comment")break}d.some(R=>R[0]!=="space"&&R[0]!=="comment")&&(c.raws.between+=h.map(R=>R[1]).join(""),h=[]),this.raw(c,"value",h.concat(d),E),c.value.includes(":")&&!E&&this.checkMissedSemicolon(d)}doubleColon(d){throw this.input.error("Double colon",{offset:d[2]},{offset:d[2]+d[1].length})}emptyRule(d){let E=new s;this.init(E,d[2]),E.selector="",E.raws.between="",this.current=E}end(d){this.current.nodes&&this.current.nodes.length&&(this.current.raws.semicolon=this.semicolon),this.semicolon=!1,this.current.raws.after=(this.current.raws.after||"")+this.spaces,this.spaces="",this.current.parent?(this.current.source.end=this.getPosition(d[2]),this.current.source.end.offset++,this.current=this.current.parent):this.unexpectedClose(d)}endFile(){this.current.parent&&this.unclosedBlock(),this.current.nodes&&this.current.nodes.length&&(this.current.raws.semicolon=this.semicolon),this.current.raws.after=(this.current.raws.after||"")+this.spaces,this.root.source.end=this.getPosition(this.tokenizer.position())}freeSemicolon(d){if(this.spaces+=d[1],this.current.nodes){let E=this.current.nodes[this.current.nodes.length-1];E&&E.type==="rule"&&!E.raws.ownSemicolon&&(E.raws.ownSemicolon=this.spaces,this.spaces="")}}getPosition(d){let E=this.input.fromOffset(d);return{column:E.col,line:E.line,offset:d}}init(d,E){this.current.push(d),d.source={input:this.input,start:this.getPosition(E)},d.raws.before=this.spaces,this.spaces="",d.type!=="comment"&&(this.semicolon=!1)}other(d){let E=!1,c=null,m=!1,_=null,h=[],O=d[1].startsWith("--"),S=[],R=d;for(;R;){if(c=R[0],S.push(R),c==="("||c==="[")_||(_=R),h.push(c==="("?")":"]");else if(O&&m&&c==="{")_||(_=R),h.push("}");else if(h.length===0)if(c===";")if(m){this.decl(S,O);return}else break;else if(c==="{"){this.rule(S);return}else if(c==="}"){this.tokenizer.back(S.pop()),E=!0;break}else c===":"&&(m=!0);else c===h[h.length-1]&&(h.pop(),h.length===0&&(_=null));R=this.tokenizer.nextToken()}if(this.tokenizer.endOfFile()&&(E=!0),h.length>0&&this.unclosedBracket(_),E&&m){if(!O)for(;S.length&&(R=S[S.length-1][0],!(R!=="space"&&R!=="comment"));)this.tokenizer.back(S.pop());this.decl(S,O)}else this.unknownWord(S)}parse(){let d;for(;!this.tokenizer.endOfFile();)switch(d=this.tokenizer.nextToken(),d[0]){case"space":this.spaces+=d[1];break;case";":this.freeSemicolon(d);break;case"}":this.end(d);break;case"comment":this.comment(d);break;case"at-word":this.atrule(d);break;case"{":this.emptyRule(d);break;default:this.other(d);break}this.endFile()}precheckMissedSemicolon(){}raw(d,E,c,m){let _,h,O=c.length,S="",R=!0,g,I;for(let N=0;Nb+C[1],"");d.raws[E]={raw:N,value:S}}d[E]=S}rule(d){d.pop();let E=new s;this.init(E,d[0][2]),E.raws.between=this.spacesAndCommentsFromEnd(d),this.raw(E,"selector",d),this.current=E}spacesAndCommentsFromEnd(d){let E,c="";for(;d.length&&(E=d[d.length-1][0],!(E!=="space"&&E!=="comment"));)c=d.pop()[1]+c;return c}spacesAndCommentsFromStart(d){let E,c="";for(;d.length&&(E=d[0][0],!(E!=="space"&&E!=="comment"));)c+=d.shift()[1];return c}spacesFromEnd(d){let E,c="";for(;d.length&&(E=d[d.length-1][0],E==="space");)c=d.pop()[1]+c;return c}stringFrom(d,E){let c="";for(let m=E;ma.type==="warning")}get content(){return this.css}}return nd=t,t.default=t,nd}var ad,uh;function AO(){if(uh)return ad;uh=1;let e=Ys(),t=kp(),n=hO(),a=Mp(),s=Wp(),o=lr(),i=Dl(),{isClean:r,my:u}=$p();const l={atrule:"AtRule",comment:"Comment",decl:"Declaration",document:"Document",root:"Root",rule:"Rule"},d={AtRule:!0,AtRuleExit:!0,Comment:!0,CommentExit:!0,Declaration:!0,DeclarationExit:!0,Document:!0,DocumentExit:!0,Once:!0,OnceExit:!0,postcssPlugin:!0,prepare:!0,Root:!0,RootExit:!0,Rule:!0,RuleExit:!0},E={Once:!0,postcssPlugin:!0,prepare:!0},c=0;function m(g){return typeof g=="object"&&typeof g.then=="function"}function _(g){let I=!1,N=l[g.type];return g.type==="decl"?I=g.prop.toLowerCase():g.type==="atrule"&&(I=g.name.toLowerCase()),I&&g.append?[N,N+"-"+I,c,N+"Exit",N+"Exit-"+I]:I?[N,N+"-"+I,N+"Exit",N+"Exit-"+I]:g.append?[N,c,N+"Exit"]:[N,N+"Exit"]}function h(g){let I;return g.type==="document"?I=["Document",c,"DocumentExit"]:g.type==="root"?I=["Root",c,"RootExit"]:I=_(g),{eventIndex:0,events:I,iterator:0,node:g,visitorIndex:0,visitors:[]}}function O(g){return g[r]=!1,g.nodes&&g.nodes.forEach(I=>O(I)),g}let S={};class R{constructor(I,N,b){this.stringified=!1,this.processed=!1;let C;if(typeof N=="object"&&N!==null&&(N.type==="root"||N.type==="document"))C=O(N);else if(N instanceof R||N instanceof s)C=O(N.root),N.map&&(typeof b.map>"u"&&(b.map={}),b.map.inline||(b.map.inline=!1),b.map.prev=N.map);else{let k=a;b.syntax&&(k=b.syntax.parse),b.parser&&(k=b.parser),k.parse&&(k=k.parse);try{C=k(N,b)}catch(P){this.processed=!0,this.error=P}C&&!C[u]&&e.rebuild(C)}this.result=new s(I,C,b),this.helpers={...S,postcss:S,result:this.result},this.plugins=this.processor.plugins.map(k=>typeof k=="object"&&k.prepare?{...k,...k.prepare(this.result)}:k)}async(){return this.error?Promise.reject(this.error):this.processed?Promise.resolve(this.result):(this.processing||(this.processing=this.runAsync()),this.processing)}catch(I){return this.async().catch(I)}finally(I){return this.async().then(I,I)}getAsyncError(){throw new Error("Use process(css).then(cb) to work with async plugins")}handleError(I,N){let b=this.result.lastPlugin;try{N&&N.addToError(I),this.error=I,I.name==="CssSyntaxError"&&!I.plugin?(I.plugin=b.postcssPlugin,I.setMessage()):b.postcssVersion}catch(C){console&&console.error&&console.error(C)}return I}prepareVisitors(){this.listeners={};let I=(N,b,C)=>{this.listeners[b]||(this.listeners[b]=[]),this.listeners[b].push([N,C])};for(let N of this.plugins)if(typeof N=="object")for(let b in N){if(!d[b]&&/^[A-Z]/.test(b))throw new Error(`Unknown event ${b} in ${N.postcssPlugin}. Try to update PostCSS (${this.processor.version} now).`);if(!E[b])if(typeof N[b]=="object")for(let C in N[b])C==="*"?I(N,b,N[b][C]):I(N,b+"-"+C.toLowerCase(),N[b][C]);else typeof N[b]=="function"&&I(N,b,N[b])}this.hasListener=Object.keys(this.listeners).length>0}async runAsync(){this.plugin=0;for(let I=0;I0;){let b=this.visitTick(N);if(m(b))try{await b}catch(C){let k=N[N.length-1].node;throw this.handleError(C,k)}}}if(this.listeners.OnceExit)for(let[N,b]of this.listeners.OnceExit){this.result.lastPlugin=N;try{if(I.type==="document"){let C=I.nodes.map(k=>b(k,this.helpers));await Promise.all(C)}else await b(I,this.helpers)}catch(C){throw this.handleError(C)}}}return this.processed=!0,this.stringify()}runOnRoot(I){this.result.lastPlugin=I;try{if(typeof I=="object"&&I.Once){if(this.result.root.type==="document"){let N=this.result.root.nodes.map(b=>I.Once(b,this.helpers));return m(N[0])?Promise.all(N):N}return I.Once(this.result.root,this.helpers)}else if(typeof I=="function")return I(this.result.root,this.result)}catch(N){throw this.handleError(N)}}stringify(){if(this.error)throw this.error;if(this.stringified)return this.result;this.stringified=!0,this.sync();let I=this.result.opts,N=i;I.syntax&&(N=I.syntax.stringify),I.stringifier&&(N=I.stringifier),N.stringify&&(N=N.stringify);let C=new n(N,this.result.root,this.result.opts).generate();return this.result.css=C[0],this.result.map=C[1],this.result}sync(){if(this.error)throw this.error;if(this.processed)return this.result;if(this.processed=!0,this.processing)throw this.getAsyncError();for(let I of this.plugins){let N=this.runOnRoot(I);if(m(N))throw this.getAsyncError()}if(this.prepareVisitors(),this.hasListener){let I=this.result.root;for(;!I[r];)I[r]=!0,this.walkSync(I);if(this.listeners.OnceExit)if(I.type==="document")for(let N of I.nodes)this.visitSync(this.listeners.OnceExit,N);else this.visitSync(this.listeners.OnceExit,I)}return this.result}then(I,N){return this.async().then(I,N)}toString(){return this.css}visitSync(I,N){for(let[b,C]of I){this.result.lastPlugin=b;let k;try{k=C(N,this.helpers)}catch(P){throw this.handleError(P,N.proxyOf)}if(N.type!=="root"&&N.type!=="document"&&!N.parent)return!0;if(m(k))throw this.getAsyncError()}}visitTick(I){let N=I[I.length-1],{node:b,visitors:C}=N;if(b.type!=="root"&&b.type!=="document"&&!b.parent){I.pop();return}if(C.length>0&&N.visitorIndex{C[r]||this.walkSync(C)});else{let C=this.listeners[b];if(C&&this.visitSync(C,I.toProxy()))return}}warnings(){return this.sync().warnings()}get content(){return this.stringify().content}get css(){return this.stringify().css}get map(){return this.stringify().map}get messages(){return this.sync().messages}get opts(){return this.result.opts}get processor(){return this.result.processor}get root(){return this.sync().root}get[Symbol.toStringTag](){return"LazyResult"}}return R.registerPostcss=g=>{S=g},ad=R,R.default=R,o.registerLazyResult(R),t.registerLazyResult(R),ad}var sd,lh;function BVe(){if(lh)return sd;lh=1;let e=hO(),t=Mp();const n=Wp();let a=Dl();class s{constructor(i,r,u){r=r.toString(),this.stringified=!1,this._processor=i,this._css=r,this._opts=u,this._map=void 0;let l,d=a;this.result=new n(this._processor,l,this._opts),this.result.css=r;let E=this;Object.defineProperty(this.result,"root",{get(){return E.root}});let c=new e(d,l,this._opts,r);if(c.isMap()){let[m,_]=c.generate();m&&(this.result.css=m),_&&(this.result.map=_)}else c.clearAnnotation(),this.result.css=c.css}async(){return this.error?Promise.reject(this.error):Promise.resolve(this.result)}catch(i){return this.async().catch(i)}finally(i){return this.async().then(i,i)}sync(){if(this.error)throw this.error;return this.result}then(i,r){return this.async().then(i,r)}toString(){return this._css}warnings(){return[]}get content(){return this.result.css}get css(){return this.result.css}get map(){return this.result.map}get messages(){return[]}get opts(){return this.result.opts}get processor(){return this.result.processor}get root(){if(this._root)return this._root;let i,r=t;try{i=r(this._css,this._opts)}catch(u){this.error=u}if(this.error)throw this.error;return this._root=i,i}get[Symbol.toStringTag](){return"NoWorkResult"}}return sd=s,s.default=s,sd}var od,ch;function GVe(){if(ch)return od;ch=1;let e=kp(),t=AO(),n=BVe(),a=lr();class s{constructor(i=[]){this.version="8.4.49",this.plugins=this.normalize(i)}normalize(i){let r=[];for(let u of i)if(u.postcss===!0?u=u():u.postcss&&(u=u.postcss),typeof u=="object"&&Array.isArray(u.plugins))r=r.concat(u.plugins);else if(typeof u=="object"&&u.postcssPlugin)r.push(u);else if(typeof u=="function")r.push(u);else if(!(typeof u=="object"&&(u.parse||u.stringify)))throw new Error(u+" is not a PostCSS plugin");return r}process(i,r={}){return!this.plugins.length&&!r.parser&&!r.stringifier&&!r.syntax?new n(this,i,r):new t(this,i,r)}use(i){return this.plugins=this.plugins.concat(this.normalize([i])),this}}return od=s,s.default=s,a.registerProcessor(s),e.registerProcessor(s),od}var id,dh;function VVe(){if(dh)return id;dh=1;var e={};let t=Up(),n=yl(),a=Ys(),s=yp(),o=$l(),i=kp(),r=FVe(),u=Ul(),l=AO(),d=fO(),E=Ll(),c=Mp(),m=GVe(),_=Wp(),h=lr(),O=wp(),S=Dl(),R=SO();function g(...I){return I.length===1&&Array.isArray(I[0])&&(I=I[0]),new m(I)}return g.plugin=function(N,b){let C=!1;function k(...$){console&&console.warn&&!C&&(C=!0,console.warn(N+`: postcss.plugin was deprecated. Migration guide: +https://evilmartians.com/chronicles/postcss-8-plugin-migration`),e.LANG&&e.LANG.startsWith("cn")&&console.warn(N+`: 里面 postcss.plugin 被弃用. 迁移指南: +https://www.w3ctech.com/topic/2226`));let y=b(...$);return y.postcssPlugin=N,y.postcssVersion=new m().version,y}let P;return Object.defineProperty(k,"postcss",{get(){return P||(P=k()),P}}),k.process=function($,y,z){return g([k(z)]).process($,y)},k},g.stringify=S,g.parse=c,g.fromJSON=r,g.list=d,g.comment=I=>new n(I),g.atRule=I=>new t(I),g.decl=I=>new o(I),g.rule=I=>new O(I),g.root=I=>new h(I),g.document=I=>new i(I),g.CssSyntaxError=s,g.Declaration=o,g.Container=a,g.Processor=m,g.Document=i,g.Comment=n,g.Warning=R,g.AtRule=t,g.Result=_,g.Input=u,g.Rule=O,g.Root=h,g.Node=E,l.registerPostcss(g),id=g,g.default=g,id}var rd,Eh;function HVe(){if(Eh)return rd;Eh=1;const e=PVe(),t=DVe(),{isPlainObject:n}=LVe(),a=yVe(),s=UVe(),{parse:o}=VVe(),i=["img","audio","video","picture","svg","object","map","iframe","embed"],r=["script","style"];function u(O,S){O&&Object.keys(O).forEach(function(R){S(O[R],R)})}function l(O,S){return{}.hasOwnProperty.call(O,S)}function d(O,S){const R=[];return u(O,function(g){S(g)&&R.push(g)}),R}function E(O){for(const S in O)if(l(O,S))return!1;return!0}function c(O){return O.map(function(S){if(!S.url)throw new Error("URL missing");return S.url+(S.w?` ${S.w}w`:"")+(S.h?` ${S.h}h`:"")+(S.d?` ${S.d}x`:"")}).join(", ")}rd=_;const m=/^[^\0\t\n\f\r /<=>]+$/;function _(O,S,R){if(O==null)return"";typeof O=="number"&&(O=O.toString());let g="",I="";function N(ae,Ie){const U=this;this.tag=ae,this.attribs=Ie||{},this.tagPosition=g.length,this.text="",this.mediaChildren=[],this.updateParentNodeText=function(){if(ce.length){const M=ce[ce.length-1];M.text+=U.text}},this.updateParentNodeMediaChildren=function(){ce.length&&i.includes(this.tag)&&ce[ce.length-1].mediaChildren.push(this.tag)}}S=Object.assign({},_.defaults,S),S.parser=Object.assign({},h,S.parser);const b=function(ae){return S.allowedTags===!1||(S.allowedTags||[]).indexOf(ae)>-1};r.forEach(function(ae){b(ae)&&!S.allowVulnerableTags&&console.warn(` + +⚠️ Your \`allowedTags\` option includes, \`${ae}\`, which is inherently +vulnerable to XSS attacks. Please remove it from \`allowedTags\`. +Or, to disable this warning, add the \`allowVulnerableTags\` option +and ensure you are accounting for this risk. + +`)});const C=S.nonTextTags||["script","style","textarea","option"];let k,P;S.allowedAttributes&&(k={},P={},u(S.allowedAttributes,function(ae,Ie){k[Ie]=[];const U=[];ae.forEach(function(M){typeof M=="string"&&M.indexOf("*")>=0?U.push(t(M).replace(/\\\*/g,".*")):k[Ie].push(M)}),U.length&&(P[Ie]=new RegExp("^("+U.join("|")+")$"))}));const $={},y={},z={};u(S.allowedClasses,function(ae,Ie){if(k&&(l(k,Ie)||(k[Ie]=[]),k[Ie].push("class")),$[Ie]=ae,Array.isArray(ae)){const U=[];$[Ie]=[],z[Ie]=[],ae.forEach(function(M){typeof M=="string"&&M.indexOf("*")>=0?U.push(t(M).replace(/\\\*/g,".*")):M instanceof RegExp?z[Ie].push(M):$[Ie].push(M)}),U.length&&(y[Ie]=new RegExp("^("+U.join("|")+")$"))}});const Z={};let Ae;u(S.transformTags,function(ae,Ie){let U;typeof ae=="function"?U=ae:typeof ae=="string"&&(U=_.simpleTransform(ae)),Ie==="*"?Ae=U:Z[Ie]=U});let J,ce,Te,De,Ve,xe,ot=!1;Oe();const re=new e.Parser({onopentag:function(ae,Ie){if(S.enforceHtmlBoundary&&ae==="html"&&Oe(),Ve){xe++;return}const U=new N(ae,Ie);ce.push(U);let M=!1;const Y=!!U.text;let pe;if(l(Z,ae)&&(pe=Z[ae](ae,Ie),U.attribs=Ie=pe.attribs,pe.text!==void 0&&(U.innerText=pe.text),ae!==pe.tagName&&(U.name=ae=pe.tagName,De[J]=pe.tagName)),Ae&&(pe=Ae(ae,Ie),U.attribs=Ie=pe.attribs,ae!==pe.tagName&&(U.name=ae=pe.tagName,De[J]=pe.tagName)),(!b(ae)||S.disallowedTagsMode==="recursiveEscape"&&!E(Te)||S.nestingLimit!=null&&J>=S.nestingLimit)&&(M=!0,Te[J]=!0,(S.disallowedTagsMode==="discard"||S.disallowedTagsMode==="completelyDiscard")&&C.indexOf(ae)!==-1&&(Ve=!0,xe=1),Te[J]=!0),J++,M){if(S.disallowedTagsMode==="discard"||S.disallowedTagsMode==="completelyDiscard")return;I=g,g=""}g+="<"+ae,ae==="script"&&(S.allowedScriptHostnames||S.allowedScriptDomains)&&(U.innerText=""),(!k||l(k,ae)||k["*"])&&u(Ie,function(oe,L){if(!m.test(L)){delete U.attribs[L];return}if(oe===""&&!S.allowedEmptyAttributes.includes(L)&&(S.nonBooleanAttributes.includes(L)||S.nonBooleanAttributes.includes("*"))){delete U.attribs[L];return}let W=!1;if(!k||l(k,ae)&&k[ae].indexOf(L)!==-1||k["*"]&&k["*"].indexOf(L)!==-1||l(P,ae)&&P[ae].test(L)||P["*"]&&P["*"].test(L))W=!0;else if(k&&k[ae]){for(const G of k[ae])if(n(G)&&G.name&&G.name===L){W=!0;let j="";if(G.multiple===!0){const Ee=oe.split(" ");for(const ge of Ee)G.values.indexOf(ge)!==-1&&(j===""?j=ge:j+=" "+ge)}else G.values.indexOf(oe)>=0&&(j=oe);oe=j}}if(W){if(S.allowedSchemesAppliedToAttributes.indexOf(L)!==-1&&wt(ae,oe)){delete U.attribs[L];return}if(ae==="script"&&L==="src"){let G=!0;try{const j=It(oe);if(S.allowedScriptHostnames||S.allowedScriptDomains){const Ee=(S.allowedScriptHostnames||[]).find(function(V){return V===j.url.hostname}),ge=(S.allowedScriptDomains||[]).find(function(V){return j.url.hostname===V||j.url.hostname.endsWith(`.${V}`)});G=Ee||ge}}catch{G=!1}if(!G){delete U.attribs[L];return}}if(ae==="iframe"&&L==="src"){let G=!0;try{const j=It(oe);if(j.isRelativeUrl)G=l(S,"allowIframeRelativeUrls")?S.allowIframeRelativeUrls:!S.allowedIframeHostnames&&!S.allowedIframeDomains;else if(S.allowedIframeHostnames||S.allowedIframeDomains){const Ee=(S.allowedIframeHostnames||[]).find(function(V){return V===j.url.hostname}),ge=(S.allowedIframeDomains||[]).find(function(V){return j.url.hostname===V||j.url.hostname.endsWith(`.${V}`)});G=Ee||ge}}catch{G=!1}if(!G){delete U.attribs[L];return}}if(L==="srcset")try{let G=s(oe);if(G.forEach(function(j){wt("srcset",j.url)&&(j.evil=!0)}),G=d(G,function(j){return!j.evil}),G.length)oe=c(d(G,function(j){return!j.evil})),U.attribs[L]=oe;else{delete U.attribs[L];return}}catch{delete U.attribs[L];return}if(L==="class"){const G=$[ae],j=$["*"],Ee=y[ae],ge=z[ae],V=z["*"],ie=y["*"],Pe=[Ee,ie].concat(ge,V).filter(function(We){return We});if(G&&j?oe=Ce(oe,a(G,j),Pe):oe=Ce(oe,G||j,Pe),!oe.length){delete U.attribs[L];return}}if(L==="style"){if(S.parseStyleAttributes)try{const G=o(ae+" {"+oe+"}",{map:!1}),j=de(G,S.allowedStyles);if(oe=H(j),oe.length===0){delete U.attribs[L];return}}catch{typeof window<"u"&&console.warn('Failed to parse "'+ae+" {"+oe+`}", If you're running this in a browser, we recommend to disable style parsing: options.parseStyleAttributes: false, since this only works in a node environment due to a postcss dependency, More info: https://github.com/apostrophecms/sanitize-html/issues/547`),delete U.attribs[L];return}else if(S.allowedStyles)throw new Error("allowedStyles option cannot be used together with parseStyleAttributes: false.")}g+=" "+L,oe&&oe.length?g+='="'+pt(oe,!0)+'"':S.allowedEmptyAttributes.includes(L)&&(g+='=""')}else delete U.attribs[L]}),S.selfClosing.indexOf(ae)!==-1?g+=" />":(g+=">",U.innerText&&!Y&&!S.textFilter&&(g+=pt(U.innerText),ot=!0)),M&&(g=I+pt(g),I="")},ontext:function(ae){if(Ve)return;const Ie=ce[ce.length-1];let U;if(Ie&&(U=Ie.tag,ae=Ie.innerText!==void 0?Ie.innerText:ae),S.disallowedTagsMode==="completelyDiscard"&&!b(U))ae="";else if((S.disallowedTagsMode==="discard"||S.disallowedTagsMode==="completelyDiscard")&&(U==="script"||U==="style"))g+=ae;else{const M=pt(ae,!1);S.textFilter&&!ot?g+=S.textFilter(M,U):ot||(g+=M)}if(ce.length){const M=ce[ce.length-1];M.text+=ae}},onclosetag:function(ae,Ie){if(Ve)if(xe--,!xe)Ve=!1;else return;const U=ce.pop();if(!U)return;if(U.tag!==ae){ce.push(U);return}Ve=S.enforceHtmlBoundary?ae==="html":!1,J--;const M=Te[J];if(M){if(delete Te[J],S.disallowedTagsMode==="discard"||S.disallowedTagsMode==="completelyDiscard"){U.updateParentNodeText();return}I=g,g=""}if(De[J]&&(ae=De[J],delete De[J]),S.exclusiveFilter&&S.exclusiveFilter(U)){g=g.substr(0,U.tagPosition);return}if(U.updateParentNodeMediaChildren(),U.updateParentNodeText(),S.selfClosing.indexOf(ae)!==-1||Ie&&!b(ae)&&["escape","recursiveEscape"].indexOf(S.disallowedTagsMode)>=0){M&&(g=I,I="");return}g+="",M&&(g=I+pt(g),I=""),ot=!1}},S.parser);return re.write(O),re.end(),g;function Oe(){g="",J=0,ce=[],Te={},De={},Ve=!1,xe=0}function pt(ae,Ie){return typeof ae!="string"&&(ae=ae+""),S.parser.decodeEntities&&(ae=ae.replace(/&/g,"&").replace(//g,">"),Ie&&(ae=ae.replace(/"/g,"""))),ae=ae.replace(/&(?![a-zA-Z0-9#]{1,20};)/g,"&").replace(//g,">"),Ie&&(ae=ae.replace(/"/g,""")),ae}function wt(ae,Ie){for(Ie=Ie.replace(/[\x00-\x20]+/g,"");;){const Y=Ie.indexOf("",Y+4);if(pe===-1)break;Ie=Ie.substring(0,Y)+Ie.substring(pe+3)}const U=Ie.match(/^([a-zA-Z][a-zA-Z0-9.\-+]*):/);if(!U)return Ie.match(/^[/\\]{2}/)?!S.allowProtocolRelative:!1;const M=U[1].toLowerCase();return l(S.allowedSchemesByTag,ae)?S.allowedSchemesByTag[ae].indexOf(M)===-1:!S.allowedSchemes||S.allowedSchemes.indexOf(M)===-1}function It(ae){if(ae=ae.replace(/^(\w+:)?\s*[\\/]\s*[\\/]/,"$1//"),ae.startsWith("relative:"))throw new Error("relative: exploit attempt");let Ie="relative://relative-site";for(let Y=0;Y<100;Y++)Ie+=`/${Y}`;const U=new URL(ae,Ie);return{isRelativeUrl:U&&U.hostname==="relative-site"&&U.protocol==="relative:",url:U}}function de(ae,Ie){if(!Ie)return ae;const U=ae.nodes[0];let M;return Ie[U.selector]&&Ie["*"]?M=a(Ie[U.selector],Ie["*"]):M=Ie[U.selector]||Ie["*"],M&&(ae.nodes[0].nodes=U.nodes.reduce(fe(M),[])),ae}function H(ae){return ae.nodes[0].nodes.reduce(function(Ie,U){return Ie.push(`${U.prop}:${U.value}${U.important?" !important":""}`),Ie},[]).join(";")}function fe(ae){return function(Ie,U){return l(ae,U.prop)&&ae[U.prop].some(function(Y){return Y.test(U.value)})&&Ie.push(U),Ie}}function Ce(ae,Ie,U){return Ie?(ae=ae.split(/\s+/),ae.filter(function(M){return Ie.indexOf(M)!==-1||U.some(function(Y){return Y.test(M)})}).join(" ")):ae}}const h={decodeEntities:!0};return _.defaults={allowedTags:["address","article","aside","footer","header","h1","h2","h3","h4","h5","h6","hgroup","main","nav","section","blockquote","dd","div","dl","dt","figcaption","figure","hr","li","main","ol","p","pre","ul","a","abbr","b","bdi","bdo","br","cite","code","data","dfn","em","i","kbd","mark","q","rb","rp","rt","rtc","ruby","s","samp","small","span","strong","sub","sup","time","u","var","wbr","caption","col","colgroup","table","tbody","td","tfoot","th","thead","tr"],nonBooleanAttributes:["abbr","accept","accept-charset","accesskey","action","allow","alt","as","autocapitalize","autocomplete","blocking","charset","cite","class","color","cols","colspan","content","contenteditable","coords","crossorigin","data","datetime","decoding","dir","dirname","download","draggable","enctype","enterkeyhint","fetchpriority","for","form","formaction","formenctype","formmethod","formtarget","headers","height","hidden","high","href","hreflang","http-equiv","id","imagesizes","imagesrcset","inputmode","integrity","is","itemid","itemprop","itemref","itemtype","kind","label","lang","list","loading","low","max","maxlength","media","method","min","minlength","name","nonce","optimum","pattern","ping","placeholder","popover","popovertarget","popovertargetaction","poster","preload","referrerpolicy","rel","rows","rowspan","sandbox","scope","shape","size","sizes","slot","span","spellcheck","src","srcdoc","srclang","srcset","start","step","style","tabindex","target","title","translate","type","usemap","value","width","wrap","onauxclick","onafterprint","onbeforematch","onbeforeprint","onbeforeunload","onbeforetoggle","onblur","oncancel","oncanplay","oncanplaythrough","onchange","onclick","onclose","oncontextlost","oncontextmenu","oncontextrestored","oncopy","oncuechange","oncut","ondblclick","ondrag","ondragend","ondragenter","ondragleave","ondragover","ondragstart","ondrop","ondurationchange","onemptied","onended","onerror","onfocus","onformdata","onhashchange","oninput","oninvalid","onkeydown","onkeypress","onkeyup","onlanguagechange","onload","onloadeddata","onloadedmetadata","onloadstart","onmessage","onmessageerror","onmousedown","onmouseenter","onmouseleave","onmousemove","onmouseout","onmouseover","onmouseup","onoffline","ononline","onpagehide","onpageshow","onpaste","onpause","onplay","onplaying","onpopstate","onprogress","onratechange","onreset","onresize","onrejectionhandled","onscroll","onscrollend","onsecuritypolicyviolation","onseeked","onseeking","onselect","onslotchange","onstalled","onstorage","onsubmit","onsuspend","ontimeupdate","ontoggle","onunhandledrejection","onunload","onvolumechange","onwaiting","onwheel"],disallowedTagsMode:"discard",allowedAttributes:{a:["href","name","target"],img:["src","srcset","alt","title","width","height","loading"]},allowedEmptyAttributes:["alt"],selfClosing:["img","br","hr","area","base","basefont","input","link","meta"],allowedSchemes:["http","https","ftp","mailto","tel"],allowedSchemesByTag:{},allowedSchemesAppliedToAttributes:["href","src","cite"],allowProtocolRelative:!0,enforceHtmlBoundary:!1,parseStyleAttributes:!0},_.simpleTransform=function(O,S,R){return R=R===void 0?!0:R,S=S||{},function(g,I){let N;if(R)for(N in S)I[N]=S[N];else I=S;return{tagName:O,attribs:I}}},rd}var KVe=HVe();const OO=RE(KVe),IO=e=>OO(MGe(St.parseInline(e,{pedantic:!0}),{target:"_blank",validate:{email:()=>!1}}),{allowedTags:["a","p","span","strong","em","img"]}),qVe=e=>{const{value:t,selectionStart:n}=e,a=t.slice(0,n).search(/@\S+$/),s=t.slice(n).search(/\s/),o=a<0?"":s<0?t.slice(a+1):t.slice(a+1,s+n);return o.trim().length>1?{position:a,usernameQuery:o}:{position:null,usernameQuery:null}},jVe=(e,t,n,a)=>e.substring(0,t+1)+a+" "+e.substring(t+n.length+2),Vi=e=>{const t=St.parse(e,{breaks:!0});return OO(t)},gO=()=>["private","followers_only","public"],RO=(e,t)=>t==="private"||t==="followers_only"&&e=="public"?t:e,NO=e=>{switch(e){case"public":return["private","followers_only","public"];case"followers_only":return["private","followers_only"];case"private":return["private"]}},YVe=e=>{switch(e){case"public":return["private","followers_only","public"];case"followers_only":return["private","followers_only"];case"private":return["private"]}},XVe={class:"add-comment"},QVe={class:"form-items"},ZVe={class:"form-item add-comment-label"},JVe={for:"comment",class:"visually-hidden"},eHe={class:"markdown-hints info-box"},tHe={key:0,class:"users-suggestions"},nHe=["onClick","onKeydown"],aHe={class:"form-select-buttons"},sHe={key:0,class:"form-item text-visibility"},oHe={for:"text_visibility"},iHe=["value"],rHe={key:1},uHe={key:2,class:"comment-buttons"},lHe={class:"confirm",type:"submit"},cHe=Q({__name:"CommentEdition",props:{workout:{},commentsLoading:{},authUser:{},comment:{default:null},name:{default:"text"},mentions:{default:()=>[]}},setup(e){var N;const t=e,{authUser:n,comment:a,commentsLoading:s,mentions:o,name:i,workout:r}=_e(t),u=$e(),{errorMessages:l}=He();let d={position:null,usernameQuery:null};const E=Se(h()),c=Se(a!=null&&a.value?a.value.text_visibility:(N=r.value)==null?void 0:N.workout_visibility),m=F(()=>a.value?a.value.id===s.value:s.value==="new"),_=F(()=>u.getters[me.GETTERS.USERS]);function h(){var b,C,k;if(a!=null&&a.value)return a.value.text||"";if(o.value.length>0){const P=o.value.filter($=>$.username!==n.value.username);if(P.length>0)return P.map($=>`@${$.username}`).join(" ")+" "}return(b=r.value)!=null&&b.user&&((C=r.value)==null?void 0:C.user.username)!==n.value.username?`@${(k=r.value)==null?void 0:k.user.username} `:""}function O(b){u.dispatch(me.ACTIONS.GET_USERS,{per_page:5,q:b,with_following:"true"})}function S(b){E.value=b.value,d=qVe(b),d.usernameQuery?O(d.usernameQuery):u.dispatch(me.ACTIONS.EMPTY_USERS)}function R(b,C,k){b.preventDefault(),b.stopPropagation();const P=`text${k?`-${k.id}`:""}`;if(d.position!==null&&d.usernameQuery){const $=jVe(E.value,d.position,d.usernameQuery,C.username),y=document.getElementById(P);y&&y instanceof HTMLTextAreaElement&&(y.value=$,y.focus(),y.selectionStart=$.length,E.value=$)}u.dispatch(me.ACTIONS.EMPTY_USERS)}function g(){S({value:"",selectionStart:0}),u.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{})}function I(){if(r.value)if(a!=null&&a.value&&a.value.id){const b={id:a.value.id,text:E.value,workout_id:r.value.id};u.dispatch(ee.ACTIONS.EDIT_WORKOUT_COMMENT,b)}else{const b={text:E.value,text_visibility:c.value,workout_id:r.value.id};u.dispatch(ee.ACTIONS.ADD_COMMENT,b),S({value:"",selectionStart:0})}}return Et(()=>{u.dispatch(me.ACTIONS.EMPTY_USERS)}),(b,C)=>{const k=q("CustomTextArea"),P=q("Loader"),$=q("ErrorMessage");return f(),v("div",XVe,[p("form",{onSubmit:Ne(I,["prevent"])},[p("div",QVe,[p("div",ZVe,[p("label",JVe,A(b.$t("workouts.COMMENTS.ADD")),1),w(k,{id:"comment",class:"comment",name:T(i),input:E.value,required:!0,placeholder:b.$t("workouts.COMMENTS.ADD"),onUpdateValue:S},null,8,["name","input","placeholder"]),p("div",eHe,[C[1]||(C[1]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(b.$t("workouts.MARKDOWN_SYNTAX")),1)]),_.value.length>0?(f(),v("ul",tHe,[(f(!0),v(le,null,be(_.value,y=>(f(),v("li",{key:y.username,tabindex:"0",onClick:z=>R(z,y,T(a)),onKeydown:je(z=>R(z,y,T(a)),["enter"])},[w(Jt,{user:y},null,8,["user"]),p("span",null,A(y.username),1)],40,nHe))),128))])):D("",!0)])]),p("div",aHe,[!T(a)&&T(r)&&T(r).workout_visibility?(f(),v("div",sHe,[p("label",oHe,A(b.$t("visibility_levels.VISIBILITY"))+": ",1),Me(p("select",{id:"text_visibility","onUpdate:modelValue":C[0]||(C[0]=y=>c.value=y)},[(f(!0),v(le,null,be(T(YVe)(T(r).workout_visibility),y=>(f(),v("option",{value:y,key:y},A(b.$t(`visibility_levels.COMMENT_LEVELS.${y}`)),9,iHe))),128))],512),[[Sn,c.value]])])):D("",!0),C[2]||(C[2]=p("div",{class:"spacer"},null,-1)),m.value?(f(),v("div",rHe,[w(P)])):(f(),v("div",uHe,[p("button",lHe,A(b.$t("buttons.SUBMIT")),1),p("button",{class:"cancel",onClick:Ne(g,["prevent"])},A(b.$t("buttons.CANCEL")),1)]))]),T(l)?(f(),B($,{key:0,message:T(l)},null,8,["message"])):D("",!0)],32)])}}}),vO=se(cHe,[["__scopeId","data-v-96400748"]]),dHe={class:"report-form"},EHe={class:"form-items"},pHe={class:"form-item"},mHe={for:"report"},THe={class:"form-select-buttons"},_He={key:0},fHe={key:1,class:"report-buttons"},hHe={class:"confirm",type:"submit"},SHe=Q({__name:"ReportForm",props:{objectId:{},objectType:{}},setup(e){const t=e,{objectId:n,objectType:a}=_e(t),s=$e(),o={comment:"workouts.COMMENTS.REPORT",user:"user.REPORT",workout:"workouts.REPORT_WORKOUT"},i=Se(""),r=F(()=>s.getters[te.GETTERS.ERROR_MESSAGES]),u=F(()=>s.getters[ye.GETTERS.REPORT_STATUS]),l=F(()=>o[a.value]);function d(m){i.value=m.value}function E(){i.value="",s.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),s.commit(ye.MUTATIONS.SET_REPORT_STATUS,null),a.value==="comment"?s.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{}):a.value==="workout"?s.commit(ee.MUTATIONS.SET_CURRENT_REPORTING,!1):s.commit(me.MUTATIONS.UPDATE_USER_CURRENT_REPORTING,!1)}function c(){s.dispatch(ye.ACTIONS.SUBMIT_REPORT,{object_id:n.value,object_type:a.value,note:i.value})}return(m,_)=>{const h=q("CustomTextArea"),O=q("Loader"),S=q("ErrorMessage");return f(),v("div",dHe,[p("form",{onSubmit:Ne(c,["prevent"])},[p("div",EHe,[p("div",pHe,[p("label",mHe,A(m.$t(l.value)),1),w(h,{class:"report-textarea",name:"report",required:!0,placeholder:m.$t("common.REPORT_PLACEHOLDER"),onUpdateValue:d},null,8,["placeholder"])])]),p("div",THe,[_[0]||(_[0]=p("div",{class:"spacer"},null,-1)),u.value==="loading"?(f(),v("div",_He,[w(O)])):(f(),v("div",fHe,[p("button",hHe,A(m.$t("buttons.SUBMIT")),1),p("button",{class:"cancel",onClick:Ne(E,["prevent"])},A(m.$t("buttons.CANCEL")),1)]))]),r.value?(f(),B(S,{key:0,message:r.value},null,8,["message"])):D("",!0)],32)])}}}),Fp=se(SHe,[["__scopeId","data-v-40798811"]]),AHe=Q({__name:"Username",props:{user:{}},setup(e){const t=e,{user:n}=_e(t);return(a,s)=>{const o=q("router-link");return T(n).username?(f(),B(o,{key:0,class:"user-name",to:{name:a.$route.path.startsWith("/admin")?"UserFromAdmin":"User",params:{username:T(n).username}},title:T(n).username},{default:X(()=>[x(A(T(n).username),1)]),_:1},8,["to","title"])):D("",!0)}}}),Ri=se(AHe,[["__scopeId","data-v-b000441f"]]),OHe=["id"],IHe={class:"comment-detail"},gHe={class:"comment-info"},RHe=["title"],NHe=["innerHTML"],vHe={key:1,class:"suspended info-box"},bHe={key:3,class:"comment-actions"},CHe=["disabled","title"],PHe={key:0,class:"likes-count","aria-hidden":"true"},DHe=["title"],LHe=["title"],yHe=["title"],$He=["title"],UHe={key:0,class:"likes-count","aria-hidden":"true"},kHe={key:6,class:"report-submitted"},wHe={class:"info-box"},MHe=Q({__name:"Comment",props:{comment:{},workout:{default:null},authUser:{},commentsLoading:{},currentCommentEdition:{default:null},forNotification:{type:Boolean,default:!1},forAdmin:{type:Boolean,default:!1},displayAppeal:{type:Boolean,default:!1},hideSuspensionAppeal:{type:Boolean,default:!1},action:{default:null}},setup(e){const t=e,{action:n,authUser:a,comment:s,currentCommentEdition:o,forAdmin:i,forNotification:r,workout:u}=_e(t),l=it(),d=$e(),{displayAppealForm:E}=Tp(),{displayOptions:c,locale:m}=He(),_=F(()=>d.getters[ye.GETTERS.REPORT_STATUS]),h=F(()=>l.params.commentId),O=F(()=>{var $,y,z,Z;return s.value.id===h.value||((($=o.value)==null?void 0:$.type)==="delete"||((y=o.value)==null?void 0:y.type)==="report")&&((Z=(z=o.value)==null?void 0:z.comment)==null?void 0:Z.id)===s.value.id}),S=F(()=>{var $,y,z,Z;return s.value.user.username===(a==null?void 0:a.value.username)&&(($=n.value)==null?void 0:$.action_type)==="comment_suspension"&&(!n.value.appeal||((y=n.value.appeal)==null?void 0:y.approved)===!1||((z=n.value.appeal)==null?void 0:z.approved)===null&&!((Z=n.value.appeal)!=null&&Z.updated_at))&&s.value.suspended_at!==null&&s.value.suspension!==void 0&&E.value!==s.value.id}),R=F(()=>!r.value&&!s.value.suspended&&!g(a.value,s.value.user)&&!N()&&_.value!==`comment-${s.value.id}-created`);function g($,y){return $&&$.username===y.username}function I(){var $,y,z;return(($=o.value)==null?void 0:$.type)==="edit"&&((z=(y=o.value)==null?void 0:y.comment)==null?void 0:z.id)===s.value.id}function N(){var $,y,z;return(($=o.value)==null?void 0:$.type)==="report"&&((z=(y=o.value)==null?void 0:y.comment)==null?void 0:z.id)===s.value.id}function b($){d.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{type:"delete",comment:$})}function C($){d.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{type:"report",comment:$}),d.commit(ye.MUTATIONS.SET_REPORT_STATUS,null)}function k($){d.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{type:$,comment:s.value}),setTimeout(()=>{const y=document.getElementById(`text-${s.value.id}`);y&&y.focus()},100)}function P($){d.dispatch($.liked?ee.ACTIONS.UNDO_LIKE_COMMENT:ee.ACTIONS.LIKE_COMMENT,$)}return Le(()=>l.params.workoutId,()=>{d.commit(ye.MUTATIONS.SET_REPORT_STATUS,null)}),Et(()=>d.commit(ye.MUTATIONS.SET_REPORT_STATUS,null)),($,y)=>{const z=q("router-link"),Z=q("VisibilityIcon");return f(),v("div",{class:"workout-comment",id:T(s).id},[w(Jt,{user:T(s).user},null,8,["user"]),p("div",IHe,[p("div",gHe,[w(Ri,{user:T(s).user},null,8,["user"]),y[4]||(y[4]=p("div",{class:"spacer"},null,-1)),w(z,{class:"comment-date",to:`${T(s).workout_id?`/workouts/${T(s).workout_id}`:""}/comments/${T(s).id}`,title:T($t)(T(s).created_at,T(c).timezone,T(c).dateFormat)},{default:X(()=>[x(A(T(xs)(new Date(T(s).created_at),new Date,{addSuffix:!0,locale:T(m)})),1)]),_:1},8,["to","title"]),T(s).modification_date?(f(),v("div",{key:0,class:"comment-edited",title:T($t)(T(s).modification_date,T(c).timezone,T(c).dateFormat)}," ("+A($.$t("common.EDITED"))+") ",9,RHe)):D("",!0),w(Z,{visibility:T(s).text_visibility,"is-comment":!0},null,8,["visibility"])]),T(s).text_html?(f(),v(le,{key:0},[I()?(f(),B(vO,{key:1,workout:T(u),comment:T(s),"comments-loading":$.commentsLoading,name:`text-${T(s).id}`,authUser:T(a)},null,8,["workout","comment","comments-loading","name","authUser"])):(f(),v("span",{key:0,class:he(["comment-text",{highlight:O.value}]),innerHTML:T(IO)(T(s).text_html)},null,10,NHe))],64)):D("",!0),T(s).suspended&&!T(s).suspension?(f(),v("div",vHe,[y[5]||(y[5]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A($.$t("workouts.COMMENTS.SUSPENDED_COMMENT_BY_ADMIN")),1)])):D("",!0),S.value&&T(n)&&T(s).suspended?(f(),B(uGe,{key:2,"hide-suspension-appeal":$.hideSuspensionAppeal,action:T(n),comment:T(s)},null,8,["hide-suspension-appeal","action","comment"])):D("",!0),T(a).username&&!T(i)?(f(),v("div",bHe,[!T(s).suspended&&!T(r)?(f(),v("button",{key:0,class:"transparent icon-button likes",onClick:y[0]||(y[0]=Ae=>T(r)?null:P(T(s))),disabled:T(r),title:`${$.$t(`workouts.${T(s).liked?"REMOVE_LIKE":"COMMENTS.LIKE_COMMENT"}`)} (${T(s).likes_count} ${$.$t("workouts.LIKES",T(s).likes_count)})`},[p("i",{class:he(["fa",{"fa-heart":T(s).likes_count>0,"fa-heart-o":T(s).likes_count===0,liked:T(s).liked}]),"aria-hidden":"true"},null,2),T(s).likes_count>0?(f(),v("span",PHe,A(T(s).likes_count),1)):D("",!0)],8,CHe)):D("",!0),R.value?(f(),v("button",{key:1,class:"transparent icon-button",onClick:y[1]||(y[1]=Ae=>C(T(s))),title:$.$t("workouts.COMMENTS.REPORT")},y[6]||(y[6]=[p("i",{class:"fa fa-flag","aria-hidden":"true"},null,-1)]),8,DHe)):D("",!0),g(T(a),T(s).user)&&!T(r)?(f(),v("button",{key:2,class:"transparent icon-button",onClick:y[2]||(y[2]=()=>k("edit")),title:$.$t("workouts.COMMENTS.EDIT")},y[7]||(y[7]=[p("i",{class:"fa fa-edit","aria-hidden":"true"},null,-1)]),8,LHe)):D("",!0),g(T(a),T(s).user)&&!T(r)?(f(),v("button",{key:3,class:"transparent icon-button",onClick:y[3]||(y[3]=Ae=>b(T(s))),title:$.$t("workouts.COMMENTS.DELETE")},y[8]||(y[8]=[p("i",{class:"fa fa-trash","aria-hidden":"true"},null,-1)]),8,yHe)):D("",!0)])):D("",!0),T(a).username?D("",!0):(f(),v("div",{key:4,class:"comment-likes",title:`${T(s).likes_count} ${$.$t("workouts.LIKES",T(s).likes_count)}`},[p("i",{class:he(["fa",{"fa-heart":T(s).likes_count>0,"fa-heart-o":T(s).likes_count===0}]),"aria-hidden":"true"},null,2),T(s).likes_count>0?(f(),v("span",UHe,A(T(s).likes_count),1)):D("",!0)],8,$He)),N()?(f(),B(Fp,{key:5,"object-id":T(s).id,"object-type":"comment"},null,8,["object-id"])):D("",!0),_.value===`comment-${T(s).id}-created`?(f(),v("div",kHe,[p("div",wHe,[p("span",null,[y[9]||(y[9]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A($.$t("common.REPORT_SUBMITTED")),1)])])])):D("",!0)])],8,OHe)}}}),zp=se(MHe,[["__scopeId","data-v-5931fda5"]]),WHe={class:"alert-message"},FHe={key:0},zHe=["innerHTML"],xHe=Q({__name:"AlertMessage",props:{message:{},param:{default:()=>""}},setup(e){const t=e,{message:n,param:a}=_e(t);return(s,o)=>{const i=q("i18n-t");return f(),v("div",WHe,[T(a)?(f(),v("span",FHe,[w(i,{keypath:T(n)},{default:X(()=>[p("span",null,A(T(a)),1)]),_:1},8,["keypath"])])):(f(),v("span",{key:1,innerHTML:s.$t(T(n))},null,8,zHe)),Pt(s.$slots,"additionalMessage",{},void 0,!0)])}}}),BHe=se(xHe,[["__scopeId","data-v-fc0d2d13"]]),GHe={},VHe={class:"card"},HHe={class:"card-title"},KHe={class:"card-content"};function qHe(e,t){return f(),v("div",VHe,[p("div",HHe,[Pt(e.$slots,"title")]),p("div",KHe,[Pt(e.$slots,"content")])])}const bO=se(GHe,[["render",qHe]]),jHe={class:"custom-textarea"},YHe=["id","name","maxLength","disabled","rows","required","placeholder"],XHe={class:"remaining-chars"},QHe=Q({__name:"CustomTextArea",props:{name:{},charLimit:{default:500},disabled:{type:Boolean,default:!1},input:{default:""},rows:{default:2},required:{type:Boolean,default:!1},placeholder:{default:""}},emits:["updateValue"],setup(e,{emit:t}){const n=e,a=t,{input:s}=_e(n),o=Se(s.value?s.value:"");function i(r){const u=r.target;a("updateValue",{value:u.value,selectionStart:u.selectionStart})}return Le(()=>n.input,r=>{o.value=r===null?"":r}),(r,u)=>(f(),v("div",jHe,[Me(p("textarea",{id:r.name,name:r.name,maxLength:r.charLimit,disabled:r.disabled,rows:r.rows,required:r.required,placeholder:r.placeholder,"onUpdate:modelValue":u[0]||(u[0]=l=>o.value=l),onInput:i},null,40,YHe),[[st,o.value]]),p("div",XHe,A(r.$t("workouts.REMAINING_CHARS"))+": "+A(o.value.length)+"/"+A(r.charLimit),1)]))}}),CO=se(QHe,[["__scopeId","data-v-8d139e95"]]),bn={ft:{unit:"ft",system:"imperial",multiplier:1,defaultTarget:"m"},mi:{unit:"mi",system:"imperial",multiplier:5280,defaultTarget:"km"},m:{unit:"m",system:"metric",multiplier:1,defaultTarget:"ft"},km:{unit:"m",system:"metric",multiplier:1e3,defaultTarget:"mi"}},ZHe={metric:{imperial:3.280839895,metric:1},imperial:{metric:1/3.280839895,imperial:1}},Xt=(e,t,n,a=3)=>{const s=bn[t],o=bn[n],i=e*s.multiplier*ZHe[s.system][o.system]/o.multiplier;return a!==null?parseFloat(i.toFixed(a)):i},Zr=(e,t,n)=>{const a=n?bn[e].defaultTarget:e;return n?Xt(t,e,a,2):t},ph=(e,t)=>{const n=t?e*1.8+32:e,a=t?" °F":"°C";return`${n===0?0:Number(n).toFixed(1)}${a}`},JHe=(e,t)=>{const n=t?e*2.2369363:e,a=t?" mph":"m/s";return`${n===0?0:Number(n).toFixed(1)}${a}`},eKe=Q({__name:"Distance",props:{distance:{},unitFrom:{},useImperialUnits:{type:Boolean},digits:{default:2},displayUnit:{type:Boolean,default:!0},speed:{type:Boolean,default:!1},strong:{type:Boolean,default:!1}},setup(e){const t=e,{digits:n,displayUnit:a,distance:s,speed:o,strong:i,unitFrom:r,useImperialUnits:u}=_e(t),l=F(()=>u.value?bn[r.value].defaultTarget:r.value),d=F(()=>u.value?Xt(s.value,r.value,l.value,n.value):parseFloat(s.value.toFixed(n.value)));return(E,c)=>(f(),v(le,null,[p("span",{class:he(["distance",{strong:T(i)}])},A(d.value),3),c[0]||(c[0]=x(" "+A(" ")+" ")),T(a)?(f(),v("span",{key:0,class:he(["unit",{strong:T(i)}])},A(l.value)+A(T(o)?"/h":""),3)):D("",!0)],64))}}),tKe=se(eKe,[["__scopeId","data-v-3aadc3cb"]]),nKe={class:"dropdown-wrapper"},aKe=["aria-expanded","aria-label"],sKe=["aria-labelledby"],oKe=["id","onClick","onKeydown","onMouseover"],iKe=Q({__name:"Dropdown",props:{options:{},selected:{},buttonLabel:{},listLabel:{},isMenuOpen:{type:Boolean}},emits:{selected:e=>e},setup(e,{emit:t}){const n=e,{isMenuOpen:a,options:s,selected:o}=_e(n),i=t,r=it(),u=Se(!1),l=Se(null),d=Se(_(o.value));function E(){if(u.value)c();else{u.value=!0;const S=document.getElementById(`dropdown-item-${d.value}`);S==null||S.focus()}}function c(){var S;u.value=!1,d.value=_(o.value),(S=l.value)==null||S.focus()}function m(S){i("selected",S),u.value=!1}function _(S){const R=s.value.findIndex(g=>g.value===S);return R>=0?R:0}function h(S){let R=!1;u.value&&(S.key==="ArrowDown"&&(R=!0,d.value+=1,d.value>s.value.length&&(d.value=0)),S.key==="ArrowUp"&&(R=!0,d.value-=1,d.value<0&&(d.value=s.value.length-1)),S.key==="Home"&&(R=!0,d.value=0),S.key==="End"&&(R=!0,d.value=s.value.length-1),S.key==="Enter"&&(R=!0,m(s.value[d.value])),(S.key==="Escape"||S.key==="Tab")&&(R=S.key==="Escape",c())),R&&(S.stopPropagation(),S.preventDefault())}function O(S){d.value=S}return Le(()=>r.path,()=>u.value=!1),Le(()=>o.value,S=>d.value=_(S)),Le(()=>a.value,S=>{S||c()}),Tt(()=>{document.addEventListener("keydown",h)}),Et(()=>{document.removeEventListener("keydown",h)}),(S,R)=>(f(),v("div",nKe,[p("button",{"aria-controls":"dropdown-list","aria-expanded":u.value,"aria-haspopup":"true","aria-label":S.buttonLabel,class:"dropdown-selector transparent",onClick:R[0]||(R[0]=g=>E()),ref_key:"dropdownButton",ref:l},[Pt(S.$slots,"default",{},void 0,!0)],8,aKe),u.value?(f(),v("ul",{key:0,"aria-labelledby":S.listLabel,class:"dropdown-list",id:"dropdown-list",role:"menu"},[(f(!0),v(le,null,be(T(s),(g,I)=>(f(),v("li",{class:he(["dropdown-item",{selected:g.value===T(o),focused:I===d.value}]),key:I,id:`dropdown-item-${I}`,tabindex:"-1",onClick:N=>m(g),onKeydown:je(N=>m(g),["enter"]),onMouseover:N=>O(I),role:"menuitem"},A(g.label),43,oKe))),128))],8,sKe)):D("",!0)]))}}),rKe=se(iKe,[["__scopeId","data-v-f0445cd3"]]),uKe={class:"error-message"},lKe={key:0},cKe={key:1},dKe={key:2},EKe=Q({__name:"ErrorMessage",props:{message:{}},setup(e){const t=e,{message:n}=_e(t);return(a,s)=>(f(),v("div",uKe,[Array.isArray(T(n))?(f(),v("ul",lKe,[(f(!0),v(le,null,be(T(n),(o,i)=>(f(),v("li",{key:i},A(a.$t(o)),1))),128))])):typeof T(n)=="string"?(f(),v("div",cKe,A(a.$t(T(n)).replace("api.ERROR.","")),1)):(f(),v("div",dKe,A(a.$t(`equipments.ERRORS.${T(n).status}`,{equipmentId:T(n).equipmentId,equipmentLabel:T(n).equipmentLabel})),1))]))}}),pKe=se(EKe,[["__scopeId","data-v-5d2995e8"]]),mKe={name:"Bike"},TKe={version:"1.1",id:"bike",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 35 35","xml:space":"preserve"};function _Ke(e,t,n,a,s,o){return f(),v("svg",TKe,t[0]||(t[0]=[p("desc",{id:"BikeEquipmentDescription"},"bike",-1),p("g",null,[p("path",{d:`M25.8 14.32c-0.64 0-1.24 0.12-1.84 0.32l-1.52-3 2.6-3.88c0.28-0.4 0.12-1.32-0.72-1.32h-3.32c-0.48 0-0.84 + 0.36-0.84 0.84s0.36 0.84 0.84 0.84h1.76l-1.76 2.64h-9.68c-0.4 0-0.68 0.36-0.68 0.36v0 0 0 0l-2.56 + 3.76c-0.72-0.36-1.56-0.56-2.44-0.56-3.12 0-5.64 2.52-5.64 5.64s2.52 5.64 5.68 5.64c2.8 0 5.16-2.080 + 5.56-4.8h4.6c0.48 0 0.68-0.4 0.68-0.4v0l4.8-7.2 1.16 2.2c-1.4 1.040-2.32 2.68-2.32 4.56 0 3.12 2.52 5.64 5.64 + 5.64s5.64-2.52 5.64-5.64-2.56-5.64-5.64-5.64zM15.92 18.32l-3.2-5.92h7.12l-3.92 5.92zM11.24 13.2l3.16 + 5.92h-3.2c-0.2-1.28-0.84-2.44-1.76-3.28l1.8-2.64zM8.52 17.24c0.48 0.52 0.84 1.16 1 1.88h-2.28l1.28-1.88zM5.68 + 23.88c-2.16 0-3.96-1.76-3.96-3.96s1.76-3.92 3.96-3.92c0.52 0 1 0.12 1.48 0.28l-2.16 3.2c-0.36 0.44-0.040 1.28 + 0.68 1.28v0 0h3.84c-0.4 1.8-2 3.12-3.84 3.12zM25.8 23.88c-2.16 0-3.96-1.76-3.96-3.96 0-1.2 0.56-2.32 + 1.44-3.040l1.8 3.44c0.2 0.32 0.68 0.56 1.16 0.36 0.4-0.16 0.56-0.72 0.36-1.12l-1.8-3.4c0.32-0.080 0.68-0.16 + 1.040-0.16 2.16 0 3.96 1.76 3.96 3.96s-1.8 3.92-4 3.92zM9.8 9.4h3.080c0.48 0 0.84-0.36 + 0.84-0.84s-0.36-0.84-0.84-0.84h-3.080c-0.48 0-0.84 0.36-0.84 0.84-0.040 0.44 0.36 0.84 0.84 0.84z`})],-1)]))}const fKe=se(mKe,[["render",_Ke]]),hKe={name:"BikeTrainer"},SKe={version:"1.1",id:"bike_trainer",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 512 512","xml:space":"preserve",style:{"shape-rendering":"geometricPrecision","text-rendering":"geometricPrecision","image-rendering":"optimizeQuality","fill-rule":"evenodd","clip-rule":"evenodd"}};function AKe(e,t,n,a,s,o){return f(),v("svg",SKe,t[0]||(t[0]=[Dn(`bike trainer`,7)]))}const OKe=se(hKe,[["render",AKe]]),IKe={name:"Kayak_Boat"},gKe={id:"kayak",version:"1.1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"-200 -150 800 800","xml:space":"preserve"};function RKe(e,t,n,a,s,o){return f(),v("svg",gKe,t[0]||(t[0]=[p("desc",{id:"kayakBoatEquipmentDescription"},"kayak",-1),p("g",null,[p("g",null,[p("path",{d:`M506.376,55.175L458.322,7.122c-5.504-5.504-14.428-5.504-19.933,0l-30.985,30.985 + c-14.252,14.252-17.674,35.223-10.286,52.713c-1.12,0.678-2.183,1.483-3.15,2.45L91.981,395.257 + c-1.027,1.027-1.872,2.163-2.574,3.361c-17.508-7.434-38.522-4.024-52.798,10.25L5.623,439.852 + c-5.504,5.504-5.504,14.429,0,19.933l48.054,48.054c5.504,5.504,14.428,5.504,19.933,0l30.985-30.985 + c15.23-15.23,18.103-38.138,8.621-56.267c0.783-0.549,1.537-1.157,2.237-1.857L417.44,116.745 + c0.634-0.634,1.188-1.317,1.699-2.021c18.126,9.473,41.025,6.598,56.252-8.629l30.985-30.985 + C511.88,69.604,511.88,60.679,506.376,55.175z`})])],-1),p("g",null,[p("g",null,[p("path",{d:`M287.822,18.22C281.289,6.959,269.286,0.007,256.267,0c-13.02-0.008-25.057,6.925-31.591,18.186 + c-29.734,51.242-69.75,138.626-69.75,237.815c0,9.547,0.378,18.983,1.074,28.29l53.352-53.352v-47.247 + c0-25.894,20.992-46.886,46.886-46.886c13.039,0,24.831,5.326,33.329,13.917l40.982-40.982 + C317.405,73.151,301.526,41.842,287.822,18.22z`})])],-1),p("g",null,[p("g",null,[p("path",{d:`M356.238,224.889l-53.113,53.115v50.303c0,25.894-20.992,46.886-46.886,46.886c-13.817,0-26.235-5.979-34.816-15.488 + l-40.306,40.306c13.297,37.559,29.552,69.661,43.538,93.767c6.534,11.261,18.536,18.214,31.556,18.22s25.057-6.925,31.591-18.186 + c29.734-51.242,69.75-138.626,69.75-237.815C357.552,245.483,357.078,235.11,356.238,224.889z`})])],-1)]))}const NKe=se(IKe,[["render",RKe]]),vKe={name:"Shoes"},bKe={id:"shoes",version:"1.1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 512 512","xml:space":"preserve"};function CKe(e,t,n,a,s,o){return f(),v("svg",bKe,t[0]||(t[0]=[Dn(`shoes`,4)]))}const PKe=se(vKe,[["render",CKe]]),DKe={name:"Shoes"},LKe={id:"skis",version:"1.1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 512 512","xml:space":"preserve"};function yKe(e,t,n,a,s,o){return f(),v("svg",LKe,t[0]||(t[0]=[p("desc",{id:"skisEquipmentDescription"},"skis",-1),p("path",{d:`m 105.01644,82.858537 c -0.75155,0 -1.40914,0.134433 -2.1607,0.373428 -1.52187,0.492925 -2.39554,1.127758 -3.400739,2.673751 + -1.01459,1.553454 -1.85068,4.040497 -2.16069,7.192229 -0.62942,6.296003 0.79852,14.996885 3.184669,23.511055 4.81929,17.02088 + 12.99235,33.25755 12.99235,33.25755 l 31.75279,62.51192 11.9308,-22.33103 28.18296,9.48509 -31.7528,-62.48952 c 0,0 -8.36096,-16.25161 + -19.82202,-31.129 -5.73054,-7.438695 -12.30656,-14.481551 -18.22498,-18.66396 -3.00618,-2.091199 -5.82448,-3.420599 -7.89123,-3.973273 + -1.03337,-0.283807 -1.87886,-0.41824 -2.63041,-0.41824 z m 144.48464,0.07468 c -0.75155,0 -1.59704,0.134434 -2.63042,0.41824 + -2.06674,0.552674 -4.88504,1.882085 -7.79728,3.973284 -6.01236,4.182398 -12.58839,11.217793 -18.31892,18.656489 -11.46107,14.87739 + -19.82202,31.12153 -19.82202,31.12153 l -14.56119,28.70918 20.94933,41.2265 2.34858,4.63051 31.47097,-61.69038 c 0,0 8.17306,-16.23667 + 12.96416,-33.27247 2.34858,-8.51417 3.75773,-17.222527 3.19408,-23.51853 -0.37578,-3.151743 -1.22127,-5.646237 -2.1607,-7.199702 + -1.03338,-1.553465 -1.87887,-2.188297 -3.4759,-2.681223 -0.75155,-0.238994 -1.40915,-0.373428 -2.16069,-0.373428 z m + 91.21884,119.325343 v 34.35542 h 24.42522 v -34.35542 z m 80.79115,0 v 34.35542 h 24.42522 v -34.35542 z m -255.33761,5.45205 + -12.02473,22.40571 7.98517,15.60931 11.93079,-22.40571 28.18296,9.48509 -7.89123,-15.60931 z m 16.90978,33.23514 -11.93079,22.33102 + 6.76391,13.36874 40.11375,-12.92063 -6.76391,-13.29406 z m -38.23488,6.57234 -8.36094,16.28148 19.91595,6.42297 z m + 199.62928,2.53931 v 153.85252 h -21.60693 v 13.44343 h 21.60693 v 17.17771 h 16.90978 v -17.17771 h 21.60693 V 403.90992 H 361.38742 + V 250.0574 Z m 80.79115,0 v 153.85252 h -21.60693 v 13.44343 h 21.60693 v 17.17771 h 16.90978 V 417.35335 H 463.7855 V 403.90992 + H 442.17857 V 250.0574 Z m -200.94449,26.13999 -40.0198,12.92062 10.52164,20.76263 40.11374,-12.92062 -4.88504,-9.63446 z m + -94.03714,0.0747 -10.61558,20.76262 40.0198,12.92063 8.45488,-16.58022 -2.25462,-4.48115 -0.65761,-1.34434 z m 110.94691,33.16045 + -40.0198,12.84594 55.61437,109.48923 40.0198,-12.92063 z m -127.95063,0.0373 -55.623763,109.33985 40.057384,12.92063 55.680129,-109.33986 z`},null,-1)]))}const $Ke=se(DKe,[["render",yKe]]),UKe={name:"Shoes"},kKe={id:"shoes",version:"1.1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 512 512","xml:space":"preserve",style:{"shape-rendering":"geometricPrecision","text-rendering":"geometricPrecision","image-rendering":"optimizeQuality","fill-rule":"evenodd","clip-rule":"evenodd"}};function wKe(e,t,n,a,s,o){return f(),v("svg",kKe,t[0]||(t[0]=[Dn(`snowshoes`,3)]))}const MKe=se(UKe,[["render",wKe]]),WKe=["title"],FKe=Q({__name:"index",props:{equipmentTypeLabel:{},title:{}},setup(e){const t=e,{equipmentTypeLabel:n,title:a}=_e(t),{darkTheme:s}=He();return(o,i)=>(f(),v("div",{class:"equipment-type-img",style:Va({fill:T(s)?"#cfd0d0":"#2c3e50"}),title:T(a)},[T(n)==="Bike"?(f(),B(fKe,{key:0})):D("",!0),T(n)==="Bike Trainer"?(f(),B(OKe,{key:1})):D("",!0),T(n)==="Kayak_Boat"?(f(),B(NKe,{key:2})):D("",!0),T(n)==="Shoes"?(f(),B(PKe,{key:3})):D("",!0),T(n)==="Skis"?(f(),B($Ke,{key:4})):D("",!0),T(n)==="Snowshoes"?(f(),B(MKe,{key:5})):D("",!0)],12,WKe))}}),zKe={name:"CyclingSport"},xKe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 491.737 491.737",style:{"enable-background":"new 0 0 491.737 491.737"},"xml:space":"preserve"};function BKe(e,t,n,a,s,o){return f(),v("svg",xKe,t[0]||(t[0]=[p("desc",{id:"cyclingSportDescription"}," silhouette of a person riding a bicycle ",-1),p("g",null,[p("path",{d:`M321.097,112.359c20.973,12.338,47.985,5.315,60.293-15.652c12.34-20.973,5.35-47.974-15.623-60.304 + c-21.009-12.332-47.99-5.317-60.314,15.65C293.129,73.036,300.103,100.027,321.097,112.359z`}),p("path",{d:`M393.081,264.102c-2.414,0-4.8,0.194-7.169,0.362l-14.431-71.605l4.702-1.757c10.666-3.987,16.093-15.868,12.098-26.54 + c-3.994-10.681-15.946-16.084-26.531-12.09l-51.823,19.38l-2.321-18.864c6.3-13.193,5.541-29.78-4.767-41.482 + c-21.224-24.092-47.12-12.508-55.191-5.976l-106.884,86.555l0.016,0.024c-3.319,2.893-6.089,6.485-7.86,10.842 + c-2.191,5.396-2.596,11.067-1.564,16.384c-8.503,0.669-15.255,7.571-15.255,16.246c0,9.085,7.346,16.44,16.432,16.48l-6.797,15.906 + c-8.62-2.465-17.674-3.866-27.066-3.866C44.27,264.102,0,308.354,0,362.754c0,54.403,44.27,98.663,98.668,98.663 + c54.403,0,98.652-44.26,98.652-98.663c0-36.228-19.683-67.867-48.858-85.024l10.957-25.652h17.767l60.281,24.462l-32.201,52.773 + c-8.297,13.612-3.994,31.382,9.615,39.685c4.691,2.86,9.878,4.229,15,4.229c9.729,0,19.234-4.929,24.677-13.838l29.339-48.095 + l19.072,11.511c-5.447,12.227-8.54,25.726-8.54,39.95c0,54.403,44.254,98.663,98.652,98.663c54.402,0,98.656-44.26,98.656-98.663 + C491.737,308.354,447.483,264.102,393.081,264.102z M98.668,436.671c-40.756,0-73.923-33.161-73.923-73.917 + c0-40.756,33.167-73.909,73.923-73.909c5.944,0,11.649,0.896,17.188,2.224l-20.476,47.893 + c-11.758,1.619-20.843,11.598-20.843,23.792c0,13.323,10.808,24.132,24.13,24.132c8.767,0,16.367-4.745,20.589-11.76h52.065 + C165.395,409.988,135.188,436.671,98.668,436.671z M171.322,350.383h-52.065c-0.355-0.588-0.708-1.176-1.112-1.732l20.476-47.901 + C155.679,311.776,167.793,329.595,171.322,350.383z M296.781,290.175l7.666-12.564c4.416-7.233,5.431-16.038,2.774-24.084 + c-2.661-8.046-8.718-14.515-16.562-17.704l-52.725-21.395l32.443-26.281l1.804,14.691c0.756,6.267,4.366,11.841,9.761,15.12 + c3.271,1.981,6.979,2.988,10.698,2.988c2.435,0,4.88-0.435,7.218-1.306l48.15-18.001l13.627,67.691 + c-18.268,6.162-34.117,17.51-45.848,32.314L296.781,290.175z M375.396,337.633l-38.003-22.94 + c7.877-9.118,17.787-16.319,29.205-20.734L375.396,337.633z M393.081,436.671c-40.757,0-73.907-33.161-73.907-73.917 + c0-9.544,1.965-18.597,5.268-26.983l44.541,26.888c0,0.032-0.016,0.064-0.016,0.095c0,13.323,10.808,24.132,24.114,24.132 + c13.322,0,24.118-10.81,24.118-24.132c0-10.478-6.721-19.307-16.06-22.64l-10.277-51.043c0.756-0.024,1.463-0.226,2.22-0.226 + c40.757,0,73.911,33.153,73.911,73.909C466.992,403.51,433.838,436.671,393.081,436.671z`})],-1)]))}const GKe=se(zKe,[["render",BKe]]),VKe={name:"CyclingTransport"},HKe={version:"1.1",id:"Capa_1",x:"0px",y:"0px",viewBox:"0 0 491.737 491.737",style:{"enable-background":"new 0 0 491.737 491.737"},"xml:space":"preserve",xmlns:"http://www.w3.org/2000/svg","xmlns:svg":"http://www.w3.org/2000/svg"};function KKe(e,t,n,a,s,o){return f(),v("svg",HKe,t[0]||(t[0]=[p("desc",{id:"cyclingTransportDescription"}," silhouette of a person riding a bicycle (for transportation) ",-1),p("g",{id:"g147"},[p("path",{d:"m 189.097,82.359 c 20.97701,12.331184 47.97442,5.308784 60.293,-15.652 12.32942,-20.979222 5.35418,-47.981117 -15.623,-60.304 -21.00482,-12.3391184 -47.99,-5.317 -60.314,15.65 -12.324,20.983 -5.34599,47.967183 15.644,60.306 z",id:"path143"}),p("path",{d:"m 393.081,264.102 c -2.414,0 -4.8,0.194 -7.169,0.362 l -14.431,-71.605 4.702,-1.757 c 10.666,-3.987 16.093,-15.868 12.098,-26.54 -3.994,-10.681 -15.946,-16.084 -26.531,-12.09 l -63.05508,-1.53717 C 284.04753,137.09803 248.90259,106.55858 243.33317,101.62481 217.77732,75.090916 186.1698,85.012419 178.0988,91.544419 L 140.764,192.085 l 0.016,0.024 c -3.319,2.893 -6.089,6.485 -7.86,10.842 -2.191,5.396 -2.596,11.067 -1.564,16.384 -8.503,0.669 -15.255,7.571 -15.255,16.246 0,9.085 7.346,16.44 16.432,16.48 l -6.797,15.906 c -8.62,-2.465 -17.674,-3.866 -27.066,-3.866 C 44.27,264.102 0,308.354 0,362.754 c 0,54.403 44.27,98.663 98.668,98.663 54.403,0 98.652,-44.26 98.652,-98.663 0,-36.228 -19.683,-67.867 -48.858,-85.024 l 10.957,-25.652 h 17.767 l 60.281,24.462 -32.201,52.773 c -8.297,13.612 -3.994,31.382 9.615,39.685 4.691,2.86 9.878,4.229 15,4.229 9.729,0 19.234,-4.929 24.677,-13.838 l 29.339,-48.095 19.072,11.511 c -5.447,12.227 -8.54,25.726 -8.54,39.95 0,54.403 44.254,98.663 98.652,98.663 54.402,0 98.656,-44.26 98.656,-98.663 0,-54.401 -44.254,-98.653 -98.656,-98.653 z M 98.668,436.671 c -40.756,0 -73.923,-33.161 -73.923,-73.917 0,-40.756 33.167,-73.909 73.923,-73.909 5.944,0 11.649,0.896 17.188,2.224 L 95.38,338.962 c -11.758,1.619 -20.843,11.598 -20.843,23.792 0,13.323 10.808,24.132 24.13,24.132 8.767,0 16.367,-4.745 20.589,-11.76 h 52.065 c -5.926,34.862 -36.133,61.545 -72.653,61.545 z m 72.654,-86.288 h -52.065 c -0.355,-0.588 -0.708,-1.176 -1.112,-1.732 l 20.476,-47.901 c 17.058,11.026 29.172,28.845 32.701,49.633 z m 125.459,-60.208 7.666,-12.564 c 4.416,-7.233 5.431,-16.038 2.774,-24.084 -2.661,-8.046 -8.718,-14.515 -16.562,-17.704 l -73.83357,-31.7176 16.7558,-45.21274 c 10.36934,4.13303 41.82171,27.90767 45.77423,28.08592 3.271,1.981 8.57725,1.46711 12.29625,1.46711 2.435,0 18.50584,0.70472 20.84384,-0.16628 L 343.32113,188.03378 361.635,269.33 c -18.268,6.162 -34.117,17.51 -45.848,32.314 z m 78.615,47.458 -38.003,-22.94 c 7.877,-9.118 17.787,-16.319 29.205,-20.734 z m 17.685,99.038 c -40.757,0 -73.907,-33.161 -73.907,-73.917 0,-9.544 1.965,-18.597 5.268,-26.983 l 44.541,26.888 c 0,0.032 -0.016,0.064 -0.016,0.095 0,13.323 10.808,24.132 24.114,24.132 13.322,0 24.118,-10.81 24.118,-24.132 0,-10.478 -6.721,-19.307 -16.06,-22.64 l -10.277,-51.043 c 0.756,-0.024 1.463,-0.226 2.22,-0.226 40.757,0 73.911,33.153 73.911,73.909 -10e-4,40.756 -33.155,73.917 -73.912,73.917 z",id:"path145"})],-1)]))}const qKe=se(VKe,[["render",KKe]]),jKe={name:"CyclingTransport"},YKe={version:"1.1",id:"Capa_1",x:"0px",y:"0px",viewBox:"0 0 491.737 491.737",style:{"enable-background":"new 0 0 491.737 491.737"},"xml:space":"preserve",xmlns:"http://www.w3.org/2000/svg","xmlns:svg":"http://www.w3.org/2000/svg"};function XKe(e,t,n,a,s,o){return f(),v("svg",YKe,t[0]||(t[0]=[Dn(' silhouette of a person riding a bicycle for touring/trekking ',2)]))}const QKe=se(jKe,[["render",XKe]]),ZKe={name:"CyclingVirtual"},JKe={version:"1.1",id:"Capa_1",x:"0px",y:"0px",viewBox:"0 0 491.737 491.737",style:{"enable-background":"new 0 0 491.737 491.737"},"xml:space":"preserve",xmlns:"http://www.w3.org/2000/svg","xmlns:svg":"http://www.w3.org/2000/svg"};function eqe(e,t,n,a,s,o){return f(),v("svg",JKe,t[0]||(t[0]=[Dn(' silhouette of a person riding a bicycle with virtual indicator ',4)]))}const tqe=se(ZKe,[["render",eqe]]),nqe={name:"Hiking"},aqe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 463.507 463.507",style:{"enable-background":"new 0 0 463.507 463.507"},"xml:space":"preserve"};function sqe(e,t,n,a,s,o){return f(),v("svg",aqe,t[0]||(t[0]=[p("desc",{id:"hikingDescription"},"silhouette of a person hiking",-1),p("g",null,[p("path",{d:`M246.413,78.492c21.688,0,39.255-17.573,39.255-39.251c0-21.67-17.567-39.24-39.255-39.24 + c-21.652,0-39.242,17.57-39.242,39.24C207.171,60.919,224.761,78.492,246.413,78.492z`}),p("path",{d:`M386.604,202.858c0-11.185-9.066-20.251-20.253-20.251h-68.479l-38.62-54.832l0.127-0.933 + c1.378-10.474-1.474-21.067-7.911-29.444c-6.441-8.378-15.932-13.852-26.408-15.23c-11.596-1.511-22.592,2.224-30.852,9.225V45.779 + c0-7.847-6.362-14.217-14.225-14.217H140.59c-7.867,0-14.225,6.37-14.225,14.217v168.953c0,20.68,15.821,37.476,35.979,39.446 + l-3.043,7.073l-23.859,90.136l-53.73,72.188c-8.006,10.768-5.794,25.987,4.984,34.001c4.348,3.245,9.443,4.811,14.491,4.811 + c7.422,0,14.729-3.385,19.511-9.795l56.529-75.945c1.851-2.484,3.213-5.299,4.003-8.289l16.266-61.414l44.521,40.877l-6.076,88.603 + c-0.917,13.393,9.177,24.99,22.58,25.908c0.552,0.04,1.124,0.056,1.691,0.056c12.66,0,23.339-9.819,24.208-22.642l6.882-100.264 + c0.508-7.364-2.371-14.572-7.815-19.564l-45.994-42.219l13.992-90.613l19.331,27.435c3.801,5.387,9.972,8.592,16.552,8.592h70.882 + l1.339,232.294c0,4.478,3.626,8.101,8.101,8.101c4.479,0,8.101-3.624,8.101-8.101l-1.339-234.036 + C381.588,218.245,386.604,211.15,386.604,202.858z`})],-1)]))}const oqe=se(nqe,[["render",sqe]]),iqe={name:"MountainBiking"},rqe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 503.162 503.162",style:{"enable-background":"new 0 0 503.162 503.162"},"xml:space":"preserve"};function uqe(e,t,n,a,s,o){return f(),v("svg",rqe,t[0]||(t[0]=[p("desc",{id:"mountainBikingDescription"}," silhouette of a person riding a mountain bike ",-1),p("g",null,[p("g",null,[p("path",{d:`M149.951,67.997c15.711-7.143,22.739-25.675,15.596-41.416c-7.124-15.701-25.723-22.682-41.453-15.539 + c-15.721,7.134-22.702,25.752-15.578,41.444C115.679,68.216,134.23,75.14,149.951,67.997z`}),p("path",{d:`M87.517,89.072l-32.828,87.755c-1.979,5.967-1.683,12.594,1.1,18.733c4.055,8.922,12.604,14.525,21.755,15.271 + l76.873,6.244l29.137,64.184c4.122,9.046,14.832,13.148,23.906,9.017c9.075-4.131,13.072-14.859,8.951-23.944l-36.424-80.201 + c0,0-3.605-13.76-21.343-14.133l-43.873-2.572l21.009-55.166l31.671,20.588c5.584,3.663,10.997,3.682,15.1,1.722l55.051-24.997 + c17.069-7.755,6.952-30.036-10.108-22.29l-47.506,21.707l-53.55-34.846c0,0-11.638-8.013-24.241-2.285 + C102.205,73.858,91.112,77.243,87.517,89.072z`}),p("path",{d:`M423.687,182.488l-2.61,15.042c-2.123,12.154-13.35,25.092-25.092,28.888l-3.711,1.195 + c3.041-16.543,1.282-34.148-6.215-50.633c-19.498-42.974-70.094-61.87-112.943-42.419 + c-42.878,19.479-61.936,70.017-42.438,112.981c17.069,37.562,57.881,56.744,96.534,47.966l-0.784,1.415 + c-5.968,10.796-20.817,19.221-33.144,18.8l-17.892-0.622c-12.336-0.411-30.514,5.002-40.603,12.116l-22.376,15.759 + c-10.107,7.104-28.276,12.632-40.612,12.354l-12.001-0.277c12.718-22.845,14.889-51.159,3.242-76.806 + c-19.517-42.955-70.074-61.879-113.019-42.381c-42.792,19.44-61.87,70.007-42.372,112.933 + c16.667,36.711,56.084,55.788,93.914,48.444l-1.32,2.056c-6.675,10.385-22.08,18.398-34.406,17.92l-32.79-1.291 + c-12.326-0.497-24.021,8.97-26.096,21.143l-2.62,15.339c-0.564,3.271-0.354,6.11,0.401,8.501c-0.43,1.778-0.736,3.548-0.736,5.326 + v9.562c0,10.557,8.568,19.125,19.125,19.125h460.932c10.557,0,19.115-8.568,19.106-19.125l-0.125-167.507 + c0-2.782-0.593-5.221-1.616-7.286c1.396-3.806,2.057-7.841,1.598-11.839l-4.677-40.497c-1.415-12.249-9.763-29.146-18.637-37.724 + l-36.127-34.951C434.712,167.418,425.79,170.325,423.687,182.488z`})])],-1)]))}const lqe=se(iqe,[["render",uqe]]),cqe={name:"MountainBikingElectric"},dqe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:svg":"http://www.w3.org/2000/svg",x:"0px",y:"0px",viewBox:"0 0 503.162 503.162",style:{"enable-background":"new 0 0 503.162 503.162"},"xml:space":"preserve"};function Eqe(e,t,n,a,s,o){return f(),v("svg",dqe,t[0]||(t[0]=[Dn(' silhouette of a person riding an electric mountain bike ',18)]))}const pqe=se(cqe,[["render",Eqe]]),mqe={name:"Mountaineering"},Tqe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 495.017 495.017",style:{"enable-background":"new 0 0 495.017 495.017"},"xml:space":"preserve"};function _qe(e,t,n,a,s,o){return f(),v("svg",Tqe,t[0]||(t[0]=[Dn(` silhouette of a person doing mountaineering `,2)]))}const fqe=se(mqe,[["render",_qe]]),hqe={name:"OpenWaterSwimming"},Sqe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:svg":"http://www.w3.org/2000/svg",x:"0px",y:"0px",viewBox:"0 0 492.508 492.508",style:{"enable-background":"new 0 0 492.508 492.508"},"xml:space":"preserve"};function Aqe(e,t,n,a,s,o){return f(),v("svg",Sqe,t[0]||(t[0]=[Dn(` silhouette of a person swimming in open water `,3)]))}const Oqe=se(hqe,[["render",Aqe]]),Iqe={name:"Paragliding"},gqe={version:"1.1",id:"Capa_1",x:"0px",y:"0px",viewBox:"0 0 170 170","xml:space":"preserve",xmlns:"http://www.w3.org/2000/svg","xmlns:svg":"http://www.w3.org/2000/svg"};function Rqe(e,t,n,a,s,o){return f(),v("svg",gqe,t[0]||(t[0]=[Dn('silhouette of a person paragliding',3)]))}const Nqe=se(Iqe,[["render",Rqe]]),vqe={name:"Rowing"},bqe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 534.51 534.51",style:{"enable-background":"new 0 0 534.51 534.51"},"xml:space":"preserve"};function Cqe(e,t,n,a,s,o){return f(),v("svg",bqe,t[0]||(t[0]=[p("desc",{id:"rowingDescription"},"silhouette of a person rowing",-1),p("g",null,[p("g",null,[p("path",{d:`M70.517,393.857h132.622l-67.205,68.631c-3.28,3.385-3.204,8.797,0.172,12.076c1.597,1.568,3.72,2.43,5.958,2.43 + c2.305,0,4.533-0.947,6.12-2.572l78.881-80.555h221.315c12.45,0,30.676-6.006,40.296-13.914 + c8.788-7.229,19.049-16.217,26.345-24.299c10.375-9.811,22.214-23.639-4.255-22.834c-6.98,0.248-206.789,0.02-223.926,0 + l47.144-48.139l22.176-22.032l28.209,9.17c10.414,3.385,22.472-0.803,26.919-9.362c4.446-8.558,0.268-18.407-9.343-21.993 + l-17.404-6.512l54.698-54.774c12.507,6.617,28.362,4.867,38.899-5.671l46.541-46.607c13.11-13.015,13.11-33.957,0-46.923 + c-12.909-13.034-34.042-13.034-46.942,0l-46.512,46.598c-10.892,10.815-12.508,27.139-5.26,39.742l-57.853,59.077L253.744,183.09 + c-1.482-0.851-14.153-5.786-26.833-10.643c-14.898-5.719-30.371,0.583-34.53,13.971l-7.525,24.241l-35.324,118.451 + c-0.468,1.34-0.603,2.562-0.746,3.711H14.136c-12.45,0-17.662,8.836-11.618,19.727l23.237,21.592 + C39.123,385.068,58.066,393.857,70.517,393.857z M272.63,248.727c1.797-5.967,8.099-9.39,14.075-7.64l51.37,14.975l-33.038,33.737 + l-43.453,43.012h-14.2L272.63,248.727z`}),p("circle",{cx:"248.953",cy:"109.842",r:"52.326"})])],-1)]))}const Pqe=se(vqe,[["render",Cqe]]),Dqe={name:"Running"},Lqe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 494.49 494.49",style:{"enable-background":"new 0 0 494.49 494.49"},"xml:space":"preserve"};function yqe(e,t,n,a,s,o){return f(),v("svg",Lqe,t[0]||(t[0]=[p("desc",{id:"runningDescription"},"silhouette of a running person",-1),p("g",null,[p("path",{d:`M282.74,80.771c22.318,0,40.401-18.08,40.401-40.389C323.141,18.084,305.058,0,282.74,0 + c-22.281,0-40.378,18.084-40.378,40.383C242.362,62.691,260.458,80.771,282.74,80.771z`}),p("path",{d:`M400.207,188.547H331.47l-38.766-55.03l0.123-0.944c1.384-10.514-1.475-21.146-7.94-29.556 + c-6.461-8.409-16.007-13.903-26.52-15.287c-10.926-1.429-22.619,3.12-31.206,8.646c-1.441,0.928-84.97,54.921-84.97,54.921 + c-5.175,3.358-8.542,8.877-9.165,15.016c-0.634,6.13,1.574,12.222,5.976,16.541l58.982,58l-6.417,48.954l-18.707,65.584l-67.8-19.4 + c-12.911-3.676-26.44,3.796-30.159,16.747c-3.699,12.951,3.799,26.459,16.758,30.168l91.271,26.109 + c2.192,0.627,4.444,0.936,6.7,0.936c4.113,0,8.195-1.04,11.848-3.073c5.655-3.146,9.833-8.409,11.611-14.635l21.963-77.057 + l26.365,36.639l6.684,119.628c0.73,12.991,11.501,23.036,24.349,23.036c0.441,0,0.92-0.016,1.379-0.039 + c13.453-0.748,23.745-12.262,23-25.713l-7.083-126.736c-0.271-4.643-1.846-9.116-4.56-12.887l-32.24-44.811l11.959-91.279 + l19.409,27.555c3.794,5.407,10.005,8.624,16.613,8.624h79.28c11.226,0,20.326-9.101,20.326-20.329 + C420.533,197.647,411.432,188.547,400.207,188.547z M204.606,190.357l-19.026-18.717l23.476-15.206L204.606,190.357z`})],-1)]))}const $qe=se(Dqe,[["render",yqe]]),Uqe={name:"SkiingAlpine"},kqe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 522.362 522.362",style:{"enable-background":"new 0 0 522.362 522.362"},"xml:space":"preserve"};function wqe(e,t,n,a,s,o){return f(),v("svg",kqe,t[0]||(t[0]=[p("desc",{id:"alpineSkiingDescription"},"silhouette of a person skiing",-1),p("g",null,[p("g",null,[p("path",{d:`M14.314,351.859L408.088,461.78c4.484,1.311,9.104,1.97,13.731,1.97l0,0c17.471,0,33.746-9.429,42.467-24.604 + c2.716-4.724,3.137-10.27,1.157-15.214c-1.912-4.762-5.767-8.31-10.576-9.744c-7.373-2.237-15.481,1.109-22.146,9.229 + c-3.548,4.303-9.496,6.244-15.07,4.714l-136.467-38.078c1.396-0.555,2.812-1.195,4.236-2.065l103.266-63.15 + c15.443-9.362,18.493-33.957,7.812-43.883l-64.758-60.233l39.972-21.688l69.682,9.744c5.23,0.736,9.744-2.037,10.079-6.187 + c0.344-4.141-3.624-8.1-8.855-8.826l-48.605-6.828l22.472-12.192c39.895-23.275,5.823-85.192-35.334-63.112l-87.554,61.19 + l-117.503-16.515l2.543-12.087c0.545-2.582-1.109-5.116-3.691-5.661c-2.572-0.593-5.116,1.1-5.661,3.701l-2.678,12.709 + l-18.331-2.582c-5.231-0.727-9.467,2.123-9.467,6.369s4.236,8.281,9.467,9.017l15.176,2.123l-2.544,12.087 + c-0.545,2.582,1.109,5.125,3.691,5.671c0.334,0.076,0.66,0.105,0.994,0.105c2.209,0,4.198-1.54,4.676-3.787l2.678-12.737 + l102.414,14.315l-5.308,3.72c-4.332,3.022-10.155,9.151-11.723,14.201c-3.844,12.45-1.473,26.717,10.452,37.705l68.802,62.175 + c0,0-50.978,31.776-74.998,46.397c-14.219,8.606-13.674,23.858-6.129,33.393L23.705,318.199 + c-10.012-2.792-20.569,2.554-23.113,11.695C-2,339.169,4.159,349.029,14.314,351.859z`}),p("path",{d:`M450.842,72.003c-15.291,16.715-14.201,42.667,2.639,58.121c16.706,15.31,42.716,14.086,58.073-2.668 + c15.386-16.677,14.172-42.734-2.544-58.016C492.305,54.064,466.17,55.192,450.842,72.003z`})])],-1)]))}const Mqe=se(Uqe,[["render",wqe]]),Wqe={name:"SkiingCrossCountry"},Fqe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 296 296",style:{"enable-background":"new 0 0 296 296"},"xml:space":"preserve"};function zqe(e,t,n,a,s,o){return f(),v("svg",Fqe,t[0]||(t[0]=[p("desc",{id:"crossCountrySkiingDescription"}," silhouette of a person skiing (cross country) ",-1),p("g",null,[p("path",{d:`M241.313,246.906h-39.564l0.456-1.273c5.62-18.374,3.479-37.58-6.027-54.278c-5.653-9.929-13.443-18.018-22.745-23.959 + l12.886-51.126c3.035-12.05-4.272-24.266-16.322-27.301c-2.634-0.664-5.278-0.826-7.833-0.559 + c-0.151-0.011-0.299-0.032-0.452-0.038c-26.03-1.077-51.443,8.485-70.33,25.993L41.586,82.855 + c-4.332-2.741-10.065-1.451-12.806,2.881s-1.451,10.065,2.88,12.806l47.625,30.136c-2.557,6.013-0.962,13.224,4.375,17.531 + c2.774,2.24,6.104,3.33,9.413,3.33c4.377-0.001,8.717-1.907,11.68-5.579l0.478-0.591c8.686-10.766,20.465-18.479,33.484-22.317 + l-11.095,44.046c-1.061,4.214-0.854,8.448,0.374,12.305c-12.908,17.917-28.398,33.68-46.198,46.979l-14.649,10.727 + c-3.938,2.943-6.001,7.798-6.005,11.798H8.25c-4.556,0-8.083,4.131-8.083,8.688v2.063c0,4.556,3.527,8.25,8.083,8.25h55.188 + h161.375H280c9.113,0,16.167-7.387,16.167-16.5v-2.5H241.313z M153.974,192.398c0.741-0.153,1.471-0.231,2.188-0.457 + c5.742,3.406,10.53,8.313,13.945,14.311c5.378,9.447,6.59,20.402,3.41,30.797l-1.799,5.674c-0.447,1.461-0.656,3.184-0.657,4.184 + h-69.909C121.581,231.906,139.297,213.274,153.974,192.398z M167,57.938c0-15.378,12.466-27.844,27.844-27.844 + s27.844,12.466,27.844,27.844s-12.466,27.844-27.844,27.844S167,73.315,167,57.938z`})],-1)]))}const xqe=se(Wqe,[["render",zqe]]),Bqe={name:"Snowshoes"},Gqe={version:"1.1",id:"Capa_1",x:"0px",y:"0px",viewBox:"0 0 494.49 494.49",style:{"enable-background":"new 0 0 494.49 494.49"},"xml:space":"preserve",xmlns:"http://www.w3.org/2000/svg","xmlns:svg":"http://www.w3.org/2000/svg"};function Vqe(e,t,n,a,s,o){return f(),v("svg",Gqe,t[0]||(t[0]=[Dn('silhouette of a person with snowshoes',6)]))}const Hqe=se(Bqe,[["render",Vqe]]),Kqe={name:"Swimrun"},qqe={version:"1.1",id:"Capa_1",x:"0px",y:"0px",viewBox:"0 0 492.508 492.508","xml:space":"preserve",xmlns:"http://www.w3.org/2000/svg","xmlns:svg":"http://www.w3.org/2000/svg"};function jqe(e,t,n,a,s,o){return f(),v("svg",qqe,t[0]||(t[0]=[Dn(' silhouette of a person swimming in open water and another silhouette of a person running ',4)]))}const Yqe=se(Kqe,[["render",jqe]]),Xqe={name:"Trail"},Qqe={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 535.876 535.876",style:{"enable-background":"new 0 0 535.876 535.876"},"xml:space":"preserve"};function Zqe(e,t,n,a,s,o){return f(),v("svg",Qqe,t[0]||(t[0]=[p("desc",{id:"trailDescription"},"silhouette of a person running (trail)",-1),p("g",null,[p("g",null,[p("path",{d:`M505.204,326.949c-5.805-10.892-15.176-10.862-20.923,0.067l-7.114,13.512c-5.757,10.92-20.416,19.775-32.752,19.775 + h-16.753c-12.346,0-29.645,6.847-38.643,15.29l-32.35,30.342l-1.233-105.388c0.822-18.446-10.596-24.634-10.596-24.634 + l-56.543-25.972l33.67-67.645l38.527,22.912c0,0,15.472,9.715,26.727-9.324l37.026-63.093c1.625-3.519,2.094-7.564,1.052-11.609 + c-2.381-9.256-11.81-14.86-21.095-12.479c-4.083,1.042-7.459,3.49-9.754,6.713l-30.772,51.962l-62.777-37.102 + c-4.733-2.716-10.519-3.691-16.257-2.209l-78.345,20.12c-5.03,1.291-8.96,4.762-11.017,9.142l-38.097,76.165 + c-2.591,3.854-3.624,8.73-2.371,13.607c2.285,8.941,11.456,14.363,20.435,12.059c5.04-1.291,8.951-4.743,10.997-9.161 + l35.314-70.858l28.936-7.296l-76.203,149.921l-65.981,16.734c-1.759,0.393-3.48,1.023-5.163,1.836 + c-10.596,5.412-14.679,18.179-9.267,28.803c4.733,9.266,15.252,13.636,24.901,10.978l78.067-20.187 + c18.6-4.093,23.313-14.449,23.313-14.449l30.581-58.79l62.28,35.678l0.593,86.216c0.01,1.08,0.259,2.161,0.421,3.241l-11.6-4.188 + c-11.608-4.188-30.57-4.608-42.361-0.956l-26.135,8.128c-11.79,3.663-30.791,3.338-42.447-0.717l-19.221-6.694 + c-11.657-4.054-28.831-0.984-38.374,6.838L109.9,433.954c-9.544,7.822-26.67,10.72-38.25,6.483L40.84,429.135 + c-11.58-4.256-25.608,1.157-31.336,12.097l-7.21,13.789c-5.728,10.93-0.354,19.794,11.982,19.794H500.27 + c12.336,0,24.309-9.802,26.728-21.907l8.004-39.981c2.409-12.097-0.325-30.733-6.14-41.616L505.204,326.949z`}),p("path",{d:`M326.395,126.625c17.806-4.562,28.563-22.721,23.983-40.526c-4.581-17.844-22.74-28.554-40.555-23.983 + c-17.796,4.581-28.535,22.711-23.964,40.535C290.439,120.476,308.599,131.205,326.395,126.625z`})])],-1)]))}const Jqe=se(Xqe,[["render",Zqe]]),eje={name:"Walking"},tje={version:"1.1",id:"Capa_1",x:"0px",y:"0px",viewBox:"0 0 494.49 494.49",style:{"enable-background":"new 0 0 494.49 494.49"},"xml:space":"preserve",xmlns:"http://www.w3.org/2000/svg","xmlns:svg":"http://www.w3.org/2000/svg"};function nje(e,t,n,a,s,o){return f(),v("svg",tje,t[0]||(t[0]=[Dn('silhouette of a walking person',3)]))}const aje=se(eje,[["render",nje]]),xp={"Cycling (Sport)":"#4c9792","Cycling (Trekking)":"#a8af88","Cycling (Transport)":"#88af98","Cycling (Virtual)":"#64a360",Hiking:"#bb757c","Mountain Biking":"#d4b371","Mountain Biking (Electric)":"#fc9d6f",Mountaineering:"#48b3b7","Open Water Swimming":"#4058a4",Paragliding:"#c23c50",Rowing:"#fcce72",Running:"#835b83","Skiing (Alpine)":"#67a4bd","Skiing (Cross Country)":"#9498d0",Snowshoes:"#5780a8",Swimrun:"#3d9fc9",Trail:"#09a98a",Walking:"#838383"},sje=e=>{const t={};return e.map(n=>t[n.id]=n.color?n.color:xp[n.label]),t},oje=(e,t)=>{const n=e.translatedLabel.toLowerCase(),a=t.translatedLabel.toLowerCase();return n>a?1:ne.filter(s=>n==="all"?!0:a.includes(s.id)||s[n]).map(s=>({...s,translatedLabel:t(`sports.${s.label}.LABEL`)})).sort(oje),Bp=(e,t)=>t.filter(n=>n.id===e.sport_id).map(n=>n.label)[0],Gp=(e,t)=>t.filter(n=>n.id===e.sport_id).map(n=>n.color)[0];function ln(){const e=$e(),{t}=yt(),n=Ut("sportColors"),a="#838383",s=Se(!1),o=Se(""),i=F(()=>e.getters[Zt.GETTERS.SPORTS]),r=F(()=>ca(i.value,t)),u=kt({sport_id:0,color:null,is_active:!0,stopped_speed_threshold:1,fromSport:!1});function l(_){u.is_active=_.target.checked}function d(_){s.value=_}function E(_){const h={...u};h.stopped_speed_threshold=_.imperial_units?Xt(u.stopped_speed_threshold,"mi","km",2):u.stopped_speed_threshold,e.dispatch(K.ACTIONS.UPDATE_USER_SPORT_PREFERENCES,h)}function c(_,h=!1){e.dispatch(K.ACTIONS.RESET_USER_SPORT_PREFERENCES,{sportId:_,fromSport:h})}function m(_){return _?i.value.filter(h=>h.id===_.sport_id)[0]:null}return{defaultColor:a,defaultEquipmentId:o,displayModal:s,sportColors:n,sportPayload:u,sports:i,translatedSports:r,getWorkoutSport:m,resetSport:c,updateDisplayModal:d,updateIsActive:l,updateSport:E}}const ije=["title"],rje=Q({__name:"index",props:{sportLabel:{},color:{},title:{default:""}},setup(e){const t=e,{color:n,sportLabel:a,title:s}=_e(t),{sportColors:o}=ln();return(i,r)=>(f(),v("div",{class:"sport-img",style:Va({fill:T(n)?T(n):T(o)[T(a)]}),title:T(s)?T(s):i.$t(`sports.${T(a)}.LABEL`)},[T(a)==="Cycling (Sport)"?(f(),B(GKe,{key:0})):D("",!0),T(a)==="Cycling (Trekking)"?(f(),B(QKe,{key:1})):D("",!0),T(a)==="Cycling (Transport)"?(f(),B(qKe,{key:2})):D("",!0),T(a)==="Cycling (Virtual)"?(f(),B(tqe,{key:3})):D("",!0),T(a)==="Hiking"?(f(),B(oqe,{key:4})):D("",!0),T(a)==="Mountain Biking"?(f(),B(lqe,{key:5})):D("",!0),T(a)==="Mountain Biking (Electric)"?(f(),B(pqe,{key:6})):D("",!0),T(a)==="Mountaineering"?(f(),B(fqe,{key:7})):D("",!0),T(a)==="Paragliding"?(f(),B(Nqe,{key:8})):D("",!0),T(a)==="Open Water Swimming"?(f(),B(Oqe,{key:9})):D("",!0),T(a)==="Rowing"?(f(),B(Pqe,{key:10})):D("",!0),T(a)==="Running"?(f(),B($qe,{key:11})):D("",!0),T(a)==="Skiing (Alpine)"?(f(),B(Mqe,{key:12})):D("",!0),T(a)==="Skiing (Cross Country)"?(f(),B(xqe,{key:13})):D("",!0),T(a)==="Snowshoes"?(f(),B(Hqe,{key:14})):D("",!0),T(a)==="Swimrun"?(f(),B(Yqe,{key:15})):D("",!0),T(a)==="Trail"?(f(),B(Jqe,{key:16})):D("",!0),T(a)==="Walking"?(f(),B(aje,{key:17})):D("",!0)],12,ije))}}),uje={},lje={class:"loader"};function cje(e,t){return f(),v("div",lje)}const kl=se(uje,[["render",cje],["__scopeId","data-v-8b613881"]]),dje={class:"custom-modal"},Eje={key:0,class:"modal-message"},pje={key:1,class:"modal-message"},mje={key:2,class:"info-box"},Tje={key:4},_je={key:5,class:"modal-buttons"},fje=Q({__name:"Modal",props:{title:{},message:{},strongMessage:{default:()=>""},loading:{type:Boolean,default:!1},warning:{default:()=>""},hideErrorMessage:{type:Boolean,default:!1}},emits:["cancelAction","confirmAction"],setup(e,{emit:t}){const n=e,{title:a,message:s,strongMessage:o}=_e(n),i=t,{errorMessages:r}=He();let u=null,l=null,d=null;function E(c){var m;(c.key==="Tab"||c.keyCode===9)&&(c.preventDefault(),((m=document.activeElement)==null?void 0:m.id)==="cancel-button"?u==null||u.focus():l==null||l.focus())}return Tt(()=>{d=document.activeElement,l=document.getElementById("cancel-button"),u=document.getElementById("confirm-button"),l&&l.focus(),document.addEventListener("keydown",E)}),Et(()=>{document.removeEventListener("keydown",E),d==null||d.focus()}),(c,m)=>{const _=q("i18n-t"),h=q("ErrorMessage"),O=q("Loader"),S=q("Card");return f(),v("div",{id:"modal",role:"dialog",onClick:m[2]||(m[2]=Ne(R=>i("cancelAction"),["self"]))},[p("div",dje,[w(S,null,{title:X(()=>[x(A(T(a)),1)]),content:X(()=>[T(o)?(f(),v("div",Eje,[w(_,{keypath:T(s)},{default:X(()=>[p("span",null,A(T(o)),1)]),_:1},8,["keypath"])])):(f(),v("div",pje,A(T(s)),1)),c.warning?(f(),v("div",mje,[m[3]||(m[3]=p("i",{class:"fa fa-exclamation-triangle","aria-hidden":"true"},null,-1)),x(" "+A(c.warning),1)])):D("",!0),T(r)&&!c.hideErrorMessage?(f(),B(h,{key:3,message:T(r)},null,8,["message"])):D("",!0),c.loading?(f(),v("div",Tje,[w(O)])):(f(),v("div",_je,[T(r)?D("",!0):(f(),v("button",{key:0,class:he(["confirm",{danger:c.warning}]),id:"confirm-button",onClick:m[0]||(m[0]=R=>i("confirmAction"))},A(c.$t("buttons.YES")),3)),p("button",{tabindex:"0",id:"cancel-button",class:"cancel",onClick:m[1]||(m[1]=R=>i("cancelAction"))},A(c.$t(`buttons.${T(r)?"CANCEL":"NO"}`)),1)]))]),_:1})])])}}}),hje=se(fje,[["__scopeId","data-v-9235de5f"]]),Sje={class:"visibility"},Aje=["title"],Oje=Q({__name:"VisibilityIcon",props:{visibility:{},isComment:{type:Boolean,default:!1}},setup(e){const t=e,{visibility:n,isComment:a}=_e(t);function s(o){switch(o){case"public":return"globe";case"followers_only":return"users";default:case"private":return"lock"}}return(o,i)=>(f(),v("span",Sje,[p("i",{class:he(`fa fa-${s(T(n))}`),"aria-hidden":"true",title:o.$t(`visibility_levels.${T(a)?"COMMENT_":""}LEVELS.${T(n)}`)},null,10,Aje)]))}}),Ije=[{target:BHe,name:"AlertMessage"},{target:bO,name:"Card"},{target:CO,name:"CustomTextArea"},{target:tKe,name:"Distance"},{target:rKe,name:"Dropdown"},{target:pKe,name:"ErrorMessage"},{target:kl,name:"Loader"},{target:hje,name:"Modal"},{target:rje,name:"SportImage"},{target:FKe,name:"EquipmentTypeImage"},{target:Oje,name:"VisibilityIcon"},{target:zp,name:"Comment"}],gje={mounted:(e,t)=>{e.clickOutsideEvent=function(n){e===n.target||e.contains(n.target)||t.value(n)},document.body.addEventListener("click",e.clickOutsideEvent),document.body.addEventListener("touchstart",e.clickOutsideEvent)},unmounted:function(e){e.clickOutsideEvent&&(document.body.removeEventListener("click",e.clickOutsideEvent),document.body.removeEventListener("touchstart",e.clickOutsideEvent),e.clickOutsideEvent=void 0)}},Rje=["bytes","KB","MB","GB","TB"],PO=e=>{if(!e)return{size:"0",suffix:"bytes"};const t=Math.floor(Math.log(e)/Math.log(1024)),n=(e/Math.pow(1024,t)).toFixed(1),a=Rje[t];return{size:n,suffix:a}},Yu=e=>{if(!e)return"0 bytes";const t=PO(e);return`${t.size}${t.suffix}`},Nje=e=>{const t=e/1048576;return!e&&0||+t.toFixed(2)},vje={id:"admin-app",class:"admin-card"},bje={for:"admin_contact"},Cje=["value"],Pje=["disabled"],Dje={for:"max_users"},Lje=["disabled"],yje={class:"admin-help"},$je={class:"info-box"},Uje={for:"max_single_file_size"},kje=["disabled"],wje={for:"max_zip_file_size"},Mje=["disabled"],Wje={for:"gpx_limit_import"},Fje=["disabled"],zje={for:"stats_workouts_limit"},xje=["disabled"],Bje={class:"admin-help"},Gje={class:"info-box"},Vje={class:"about-label",for:"about"},Hje={class:"textarea-description"},Kje=["innerHTML"],qje={class:"privacy-policy-label",for:"privacy_policy"},jje={class:"textarea-description"},Yje=["innerHTML"],Xje={key:5,class:"form-buttons"},Qje={class:"confirm",type:"submit"},Zje={key:6,class:"form-buttons"},Jje=Q({__name:"AdminApplication",props:{edition:{type:Boolean,default:!1}},setup(e){const t=e,{edition:n}=_e(t),a=yn(),s=$e(),{appConfig:o,errorMessages:i}=He(),r=kt({admin_contact:"",max_users:0,max_single_file_size:0,max_zip_file_size:0,gpx_limit_import:0,about:"",privacy_policy:"",stats_workouts_limit:0});function u(E){Object.keys(r).map(c=>{["max_single_file_size","max_zip_file_size"].includes(c)?r[c]=Nje(E[c]):["about","privacy_policy"].includes(c)?r[c]=E[c]!==null?E[c]:"":r[c]=E[c]})}function l(){u(o.value),s.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),a.push("/admin/application")}function d(){const E=Object.assign({},r);E.max_single_file_size*=1048576,E.max_zip_file_size*=1048576,s.dispatch(te.ACTIONS.UPDATE_APPLICATION_CONFIG,E)}return tt(()=>{o.value&&u(o.value)}),(E,c)=>{const m=q("ErrorMessage"),_=q("Card");return f(),v("div",vje,[w(_,null,{title:X(()=>[x(A(E.$t("admin.APP_CONFIG.TITLE")),1)]),content:X(()=>[p("form",{class:"admin-form",onSubmit:Ne(d,["prevent"])},[p("label",bje,[x(A(E.$t("admin.APP_CONFIG.ADMIN_CONTACT"))+": ",1),!T(n)&&!r.admin_contact?(f(),v("input",{key:0,class:"no-contact",value:E.$t("admin.APP_CONFIG.NO_CONTACT_EMAIL"),disabled:""},null,8,Cje)):Me((f(),v("input",{key:1,id:"admin_contact",name:"admin_contact",type:"email","onUpdate:modelValue":c[0]||(c[0]=h=>r.admin_contact=h),disabled:!T(n)},null,8,Pje)),[[st,r.admin_contact]])]),p("label",Dje,[x(A(E.$t("admin.APP_CONFIG.MAX_USERS_LABEL"))+": ",1),Me(p("input",{id:"max_users",name:"max_users",type:"number",min:"0","onUpdate:modelValue":c[1]||(c[1]=h=>r.max_users=h),disabled:!T(n)},null,8,Lje),[[st,r.max_users]])]),p("div",yje,[p("span",$je,[c[10]||(c[10]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(E.$t("admin.APP_CONFIG.MAX_USERS_HELP")),1)])]),p("label",Uje,[x(A(E.$t("admin.APP_CONFIG.SINGLE_UPLOAD_MAX_SIZE_LABEL"))+": ",1),Me(p("input",{id:"max_single_file_size",name:"max_single_file_size",type:"number",step:"0.1",min:"0","onUpdate:modelValue":c[2]||(c[2]=h=>r.max_single_file_size=h),disabled:!T(n)},null,8,kje),[[st,r.max_single_file_size]])]),p("label",wje,[x(A(E.$t("admin.APP_CONFIG.ZIP_UPLOAD_MAX_SIZE_LABEL"))+": ",1),Me(p("input",{id:"max_zip_file_size",name:"max_zip_file_size",type:"number",step:"0.1",min:"0","onUpdate:modelValue":c[3]||(c[3]=h=>r.max_zip_file_size=h),disabled:!T(n)},null,8,Mje),[[st,r.max_zip_file_size]])]),p("label",Wje,[x(A(E.$t("admin.APP_CONFIG.MAX_FILES_IN_ZIP_LABEL"))+": ",1),Me(p("input",{id:"gpx_limit_import",name:"gpx_limit_import",type:"number",min:"0","onUpdate:modelValue":c[4]||(c[4]=h=>r.gpx_limit_import=h),disabled:!T(n)},null,8,Fje),[[st,r.gpx_limit_import]])]),p("label",zje,[x(A(E.$t("admin.APP_CONFIG.STATS_WORKOUTS_LIMIT_LABEL"))+": ",1),Me(p("input",{id:"stats_workouts_limit",name:"stats_workouts_limit",type:"number",min:"0","onUpdate:modelValue":c[5]||(c[5]=h=>r.stats_workouts_limit=h),disabled:!T(n)},null,8,xje),[[st,r.stats_workouts_limit]])]),p("div",Bje,[p("span",Gje,[c[11]||(c[11]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(E.$t("admin.APP_CONFIG.STATS_WORKOUTS_LIMIT_HELP")),1)])]),p("label",Vje,A(E.$t("admin.ABOUT.TEXT"))+": ",1),p("span",Hje,A(E.$t("admin.ABOUT.DESCRIPTION")),1),T(n)?Me((f(),v("textarea",{key:0,id:"about",name:"about",rows:"10","onUpdate:modelValue":c[6]||(c[6]=h=>r.about=h)},null,512)),[[st,r.about]]):(f(),v("div",{key:1,innerHTML:r.about?T(Vi)(r.about):E.$t("admin.NO_TEXT_ENTERED"),class:"textarea-content"},null,8,Kje)),p("label",qje,A(Fe(E.$t("privacy_policy.TITLE")))+": ",1),p("span",jje,A(E.$t("admin.PRIVACY_POLICY_DESCRIPTION")),1),T(n)?Me((f(),v("textarea",{key:2,id:"privacy_policy",name:"privacy_policy",rows:"20","onUpdate:modelValue":c[7]||(c[7]=h=>r.privacy_policy=h)},null,512)),[[st,r.privacy_policy]]):(f(),v("div",{key:3,innerHTML:r.privacy_policy?T(Vi)(r.privacy_policy):E.$t("admin.NO_TEXT_ENTERED"),class:"textarea-content"},null,8,Yje)),T(i)?(f(),B(m,{key:4,message:T(i)},null,8,["message"])):D("",!0),T(n)?(f(),v("div",Xje,[p("button",Qje,A(E.$t("buttons.SUBMIT")),1),p("button",{class:"cancel",onClick:Ne(l,["prevent"])},A(E.$t("buttons.CANCEL")),1)])):(f(),v("div",Zje,[p("button",{class:"confirm",onClick:c[8]||(c[8]=Ne(h=>E.$router.push("/admin/application/edit"),["prevent"]))},A(E.$t("buttons.EDIT")),1),p("button",{class:"cancel",onClick:c[9]||(c[9]=Ne(h=>E.$router.push("/admin"),["prevent"]))},A(E.$t("admin.BACK_TO_ADMIN")),1)]))],32)]),_:1})])}}}),mh=se(Jje,[["__scopeId","data-v-3a7598c9"]]),eYe=(e,t)=>{const n=e.translatedLabel.toLowerCase(),a=t.translatedLabel.toLowerCase();return n>a?1:ne.map(n=>({...n,translatedLabel:t(`equipment_types.${n.label}.LABEL`)})).sort(eYe),Vp=(e,t)=>{const n=e.label.toLowerCase(),a=t.label.toLowerCase();return n>a?1:na?e.filter(o=>LO[o.equipment_type.label].includes(a.label)).filter(o=>n=="all"?!0:n=="withIncludedIds"&&s.includes(o.id)||o.is_active).map(o=>({...o,label:o.is_active?o.label:`${o.label} (${t("common.INACTIVE")})`})).sort(Vp):[],tYe={id:"admin-equipment-types",class:"admin-card"},nYe={class:"responsive-table"},aYe={class:"text-left"},sYe={class:"text-left equipment-type-action"},oYe={class:"text-center"},iYe={class:"cell-heading"},rYe={class:"equipment-type-label"},uYe={class:"cell-heading"},lYe={class:"text-center"},cYe={class:"cell-heading"},dYe={class:"equipment-type-action"},EYe={class:"cell-heading"},pYe={class:"action-button"},mYe=["onClick"],TYe={key:0,class:"has-equipments"},_Ye=Q({__name:"AdminEquipmentTypes",setup(e){const t=$e(),{t:n}=yt(),{errorMessages:a}=He(),s=F(()=>DO(t.getters[Be.GETTERS.EQUIPMENT_TYPES],n));function o(){t.dispatch(Be.ACTIONS.GET_EQUIPMENT_TYPES)}function i(r,u){t.dispatch(Be.ACTIONS.UPDATE_EQUIPMENT_TYPE,{id:r,isActive:u})}return tt(()=>o()),(r,u)=>{const l=q("EquipmentTypeImage"),d=q("ErrorMessage"),E=q("Card");return f(),v("div",tYe,[w(E,null,{title:X(()=>[x(A(r.$t("admin.EQUIPMENT_TYPES.TITLE")),1)]),content:X(()=>[p("button",{class:"top-button",onClick:u[0]||(u[0]=Ne(c=>r.$router.push("/admin"),["prevent"]))},A(r.$t("admin.BACK_TO_ADMIN")),1),p("div",nYe,[p("table",null,[p("thead",null,[p("tr",null,[u[2]||(u[2]=p("th",null,"#",-1)),p("th",null,A(r.$t("admin.EQUIPMENT_TYPES.TABLE.IMAGE")),1),p("th",aYe,A(r.$t("admin.EQUIPMENT_TYPES.TABLE.LABEL")),1),p("th",null,A(r.$t("admin.EQUIPMENT_TYPES.TABLE.ACTIVE")),1),p("th",sYe,A(r.$t("admin.ACTION")),1)])]),p("tbody",null,[(f(!0),v(le,null,be(s.value,c=>(f(),v("tr",{key:c.id},[p("td",oYe,[u[3]||(u[3]=p("span",{class:"cell-heading"},"id",-1)),x(" "+A(c.id),1)]),p("td",null,[p("span",iYe,A(r.$t("admin.EQUIPMENT_TYPES.TABLE.IMAGE")),1),w(l,{title:c.translatedLabel,"equipment-type-label":c.label},null,8,["title","equipment-type-label"])]),p("td",rYe,[p("span",uYe,A(r.$t("admin.EQUIPMENT_TYPES.TABLE.LABEL")),1),x(" "+A(c.translatedLabel),1)]),p("td",lYe,[p("span",cYe,A(r.$t("admin.EQUIPMENT_TYPES.TABLE.ACTIVE")),1),p("i",{class:he(`fa fa${c.is_active?"-check":""}`),"aria-hidden":"true"},null,2)]),p("td",dYe,[p("span",EYe,A(r.$t("admin.ACTION")),1),p("div",pYe,[p("button",{class:he({danger:c.is_active}),onClick:m=>i(c.id,!c.is_active)},A(r.$t(`buttons.${c.is_active?"DIS":"EN"}ABLE`)),11,mYe),c.has_equipments?(f(),v("span",TYe,[u[4]||(u[4]=p("i",{class:"fa fa-warning","aria-hidden":"true"},null,-1)),x(" "+A(r.$t("admin.EQUIPMENT_TYPES.TABLE.HAS_EQUIPMENTS")),1)])):D("",!0)])])]))),128))])]),T(a)?(f(),B(d,{key:0,message:T(a)},null,8,["message"])):D("",!0),p("button",{onClick:u[1]||(u[1]=Ne(c=>r.$router.push("/admin"),["prevent"]))},A(r.$t("admin.BACK_TO_ADMIN")),1)])]),_:1})])}}}),fYe=se(_Ye,[["__scopeId","data-v-a3acbd9a"]]),hYe={class:"stat-card"},SYe={class:"stat-content box"},AYe={class:"stat-icon"},OYe={class:"stat-details"},IYe={class:"stat-huge"},gYe={class:"stat"},xa=Q({__name:"StatCard",props:{icon:{},text:{},value:{}},setup(e){const t=e,{icon:n,text:a,value:s}=_e(t);return(o,i)=>(f(),v("div",hYe,[p("div",SYe,[p("div",AYe,[p("i",{class:he(["fa",`fa-${T(n)}`])},null,2)]),p("div",OYe,[p("div",IYe,A(T(s)),1),p("div",gYe,A(T(a)),1)])])]))}}),RYe={id:"user-stats"},NYe=Q({__name:"AppStatsCards",props:{appStatistics:{}},setup(e){const t=e,{appStatistics:n}=_e(t),a=F(()=>PO(n.value.uploads_dir_size));return(s,o)=>(f(),v("div",RYe,[w(xa,{icon:"users",value:T(n).users,text:s.$t("user.ACTIVE_USER",T(n).users)},null,8,["value","text"]),w(xa,{icon:"tags",value:T(n).sports,text:s.$t("workouts.SPORT",T(n).sports)},null,8,["value","text"]),w(xa,{icon:"calendar",value:T(n).workouts,text:s.$t("workouts.WORKOUT",T(n).workouts)},null,8,["value","text"]),w(xa,{icon:"folder-open",value:a.value.size,text:a.value.suffix},null,8,["value","text"])]))}}),vYe={id:"admin-menu",class:"center-card"},bYe={class:"admin-menu description-list"},CYe={class:"application-config-details"},PYe={class:"registration-status"},DYe={key:0,class:"email-sending-status"},LYe={class:"application-config-details"},yYe=Q({__name:"AdminMenu",setup(e){const t=$e(),{appConfig:n}=He(),{authUserHasAdminRights:a}=Ke(),s=F(()=>t.getters[te.GETTERS.APP_STATS]),o=F(()=>t.getters[ye.GETTERS.UNRESOLVED_REPORTS_STATUS]);return tt(()=>t.dispatch(ye.ACTIONS.GET_UNRESOLVED_REPORTS_STATUS)),Tt(()=>{const i=document.getElementById("adminLink");i&&i.focus()}),(i,r)=>{const u=q("router-link");return f(),v("div",vYe,[w(bO,null,{title:X(()=>[x(A(i.$t("admin.ADMINISTRATION")),1)]),content:X(()=>[w(NYe,{appStatistics:s.value},null,8,["appStatistics"]),p("div",bYe,[p("dl",null,[T(a)?(f(),v(le,{key:0},[p("dt",null,[w(u,{id:"adminLink",to:"/admin/application"},{default:X(()=>[x(A(i.$t("admin.APPLICATION")),1)]),_:1})]),p("dd",CYe,[x(A(i.$t("admin.UPDATE_APPLICATION_DESCRIPTION"))+" ",1),p("span",PYe,A(i.$t(`admin.REGISTRATION_${T(n).is_registration_enabled?"ENABLED":"DISABLED"}`)),1),T(n).is_email_sending_enabled?D("",!0):(f(),v("span",DYe,[r[0]||(r[0]=p("i",{class:"fa fa-exclamation-triangle","aria-hidden":"true"},null,-1)),x(" "+A(i.$t("admin.EMAIL_SENDING_DISABLED")),1)]))]),p("dt",null,[w(u,{to:"/admin/equipment-types"},{default:X(()=>[x(A(Fe(i.$t("equipments.EQUIPMENT_TYPE",0))),1)]),_:1})]),p("dd",null,A(i.$t("admin.ENABLE_DISABLE_EQUIPMENT_TYPES")),1)],64)):D("",!0),p("dt",null,[w(u,{id:"adminLink",to:"/admin/reports"},{default:X(()=>[x(A(i.$t("admin.APP_MODERATION.TITLE")),1)]),_:1})]),p("dd",LYe,[x(A(i.$t("admin.APP_MODERATION.DESCRIPTION"))+" ",1),o.value?(f(),B(u,{key:0,to:"/admin/reports?resolved=false"},{default:X(()=>[x(A(i.$t("admin.APP_MODERATION.UNRESOLVED_REPORTS_EXIST")),1)]),_:1})):D("",!0)]),T(a)?(f(),v(le,{key:1},[p("dt",null,[w(u,{to:"/admin/sports"},{default:X(()=>[x(A(Fe(i.$t("workouts.SPORT",0))),1)]),_:1})]),p("dd",null,A(i.$t("admin.ENABLE_DISABLE_SPORTS")),1),p("dt",null,[w(u,{to:"/admin/users"},{default:X(()=>[x(A(Fe(i.$t("user.USER",0))),1)]),_:1})]),p("dd",null,A(i.$t("admin.ADMIN_RIGHTS_DELETE_USER_ACCOUNT")),1)],64)):D("",!0)])])]),_:1})])}}}),$Ye=se(yYe,[["__scopeId","data-v-18089d26"]]),UYe=["id"],kYe={class:"appeal-text"},wYe=["title"],MYe={class:"appeal-actions-buttons"},WYe={class:"small approve",value:"approve"},FYe={class:"small reject",value:"reject"},zYe={key:1,class:"automatically-approved"},xYe={key:2,class:"description-list"},BYe=["title"],GYe=Q({__name:"AdminReportActionAppeal",props:{appeal:{},authUser:{}},emits:["updateAppeal","closeAppeal"],setup(e,{emit:t}){const n=e,{appeal:a,authUser:s}=_e(n),o=t,{errorMessages:i,locale:r}=He(),u=Se("");function l(c){c.preventDefault(),o("updateAppeal",{approved:c.submitter.value==="approve",appealId:a.value.id,reason:u.value})}function d(c){u.value=c.value}function E(){o("closeAppeal")}return(c,m)=>{const _=q("ErrorMessage"),h=q("i18n-t");return f(),v("div",{class:"appeal box",id:`appeal-${T(a).id}`},[p("div",kYe,A(T(a).text),1),p("span",{class:"appeal-date",title:T($t)(T(a).created_at,T(s).timezone,T(s).date_format)},A(T(xs)(new Date(T(a).created_at),new Date,{addSuffix:!0,locale:T(r)})),9,wYe),T(a).updated_at===null?(f(),v(le,{key:0},[T(a).approved===null?(f(),v("form",{key:0,onSubmit:Ne(l,["prevent"]),class:"appeal-actions"},[w(CO,{name:"appeal-reason",required:!0,placeholder:c.$t("admin.APP_MODERATION.TEXTAREA_PLACEHOLDER.UPDATE_APPEAL"),onUpdateValue:d},null,8,["placeholder"]),T(i)?(f(),B(_,{key:0,message:T(i)},null,8,["message"])):D("",!0),p("div",MYe,[p("button",WYe,A(c.$t("buttons.APPROVE")),1),p("button",FYe,A(c.$t("buttons.REJECT")),1),p("button",{class:"small reject",type:"button",onClick:E},A(c.$t("buttons.CANCEL")),1)])],32)):D("",!0)],64)):T(a).approved===null?(f(),v("div",zYe,A(c.$t("admin.APP_MODERATION.APPEAL.AUTOMATICALLY_APPROVED_BY_UNSUSPENSION")),1)):(f(),v("div",xYe,[w(h,{keypath:`admin.APP_MODERATION.APPEAL.${T(a).approved?"APPROVED":"REJECTED"}`,tag:"p"},{default:X(()=>[p("span",{class:"report-action-date",title:T($t)(T(a).updated_at,T(s).timezone,T(s).date_format)},A(T(xs)(new Date(T(a).updated_at),new Date,{addSuffix:!0,locale:T(r)})),9,BYe)]),_:1},8,["keypath"]),p("dl",null,[p("dt",null,A(c.$t("admin.APP_MODERATION.APPEAL.REASON_IS")),1),p("dd",null,A(T(a).reason),1)])]))],8,UYe)}}}),VYe=se(GYe,[["__scopeId","data-v-88ea8757"]]),HYe={id:"error"},KYe={class:"error-content"},qYe=Q({__name:"Error",props:{title:{},message:{},buttonText:{},path:{default:"/"}},setup(e){const t=e,{buttonText:n,title:a,message:s,path:o}=_e(t);return(i,r)=>(f(),v("div",HYe,[p("div",KYe,[p("h1",null,A(T(a)),1),p("p",null,A(T(s)),1),T(n)?(f(),v("button",{key:0,onClick:r[0]||(r[0]=u=>i.$router.push(T(o))),class:"upper"},A(T(n)),1)):D("",!0)])]))}}),jYe=se(qYe,[["__scopeId","data-v-48ec856d"]]),Wo=Q({__name:"NotFound",props:{target:{default:"PAGE"}},setup(e){const t=e,{target:n}=_e(t),a=Se(),s=Se(!1);function o(){a.value=setTimeout(()=>{s.value=!0},500)}return Tt(()=>o()),Et(()=>{a.value&&clearTimeout(a.value)}),(i,r)=>s.value?(f(),B(jYe,{key:0,title:"404",message:i.$t(`error.NOT_FOUND.${T(n)}`),"button-text":i.$t("common.HOME")},null,8,["message","button-text"])):D("",!0)}}),YYe={key:0,class:"user-actions"},XYe={key:0,class:"blocked-user"},QYe={class:"blocked"},ZYe={key:1,class:"actions-buttons"},JYe={key:2},eXe={key:3,class:"follows-you"},tXe={key:1,class:"user-actions"},nXe={class:"follows-you"},aXe=Q({__name:"UserRelationshipActions",props:{authUser:{},user:{},from:{},displayFollowsYou:{type:Boolean,default:!1}},emits:["updatedUser"],setup(e,{emit:t}){const n=e,{authUser:a,from:s,user:o,displayFollowsYou:i}=_e(n),r=t,u=$e();function l(E,c){r("updatedUser",E),u.dispatch(me.ACTIONS.UPDATE_RELATIONSHIP,{username:E,action:`${c?"un":""}follow`,from:s.value})}function d(E,c){r("updatedUser",E),u.dispatch(me.ACTIONS.UPDATE_RELATIONSHIP,{username:E,action:`${c?"":"un"}block`,from:s.value})}return(E,c)=>(f(),v(le,null,[T(o).username!==T(a).username?(f(),v("div",YYe,[T(o).blocked?(f(),v("div",XYe,[p("div",QYe,A(E.$t("user.RELATIONSHIPS.BLOCKED")),1),p("button",{onClick:c[0]||(c[0]=m=>d(T(o).username,!1))},A(E.$t("buttons.UNBLOCK")),1)])):T(o).is_followed_by!=="pending"?(f(),v("div",ZYe,[p("button",{onClick:c[1]||(c[1]=m=>l(T(o).username,T(o).is_followed_by==="true")),class:he({danger:T(o).is_followed_by==="true"})},A(E.$t(`buttons.${T(o).is_followed_by==="true"?"UN":""}FOLLOW`)),3),p("button",{onClick:c[2]||(c[2]=m=>d(T(o).username,!0))},A(E.$t("buttons.BLOCK")),1)])):(f(),v("div",JYe,[p("button",{onClick:c[3]||(c[3]=m=>l(T(o).username,!0))},A(E.$t("buttons.CANCEL_FOLLOW_REQUEST")),1)])),T(i)&&T(o).follows==="true"&&T(s)!=="notifications"?(f(),v("div",eXe,A(E.$t("user.RELATIONSHIPS.FOLLOWS_YOU")),1)):D("",!0)])):D("",!0),T(o).username===T(a).username&&T(s)!=="userInfos"?(f(),v("div",tXe,[p("div",nXe,A(E.$t("user.YOU")),1)])):D("",!0)],64))}}),Xu=se(aXe,[["__scopeId","data-v-f72b5b8c"]]),sXe={class:"user-stats"},oXe={class:"user-stat"},iXe={class:"stat-number"},rXe={class:"stat-label"},uXe={class:"user-stat"},lXe={key:1,class:"stat-number"},cXe={class:"stat-label"},dXe={class:"user-stat"},EXe={key:1,class:"stat-number"},pXe={class:"stat-label"},mXe=Q({__name:"UserStats",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),{authUser:a}=Ke(),s=F(()=>a.value.username?n.value.username===a.value.username?!a.value.suspended_at:!0:!1);function o(i,r,u){return i.username===(r==null?void 0:r.username)&&u.includes("/profile")?"profile":`users/${i.username}`}return(i,r)=>{const u=q("router-link");return f(),v("div",sXe,[p("div",oXe,[p("span",iXe,A(T(n).nb_workouts),1),p("span",rXe,A(i.$t("workouts.WORKOUT",T(n).nb_workouts)),1)]),p("div",uXe,[s.value?(f(),B(u,{key:0,to:`/${o(T(n),T(a),i.$route.path)}/following`,class:"stat-number"},{default:X(()=>[x(A(T(n).following),1)]),_:1},8,["to"])):(f(),v("span",lXe,A(T(n).following),1)),p("span",cXe,A(i.$t("user.RELATIONSHIPS.FOLLOWING",T(n).following)),1)]),p("div",dXe,[s.value?(f(),B(u,{key:0,to:`/${o(T(n),T(a),i.$route.path)}/followers`,class:"stat-number"},{default:X(()=>[x(A(T(n).followers),1)]),_:1},8,["to"])):(f(),v("span",EXe,A(T(n).followers),1)),p("span",pXe,A(i.$t("user.RELATIONSHIPS.FOLLOWER",T(n).followers)),1)])])}}}),Hp=se(mXe,[["__scopeId","data-v-9af0bb02"]]),TXe={class:"box"},_Xe={class:"user-card"},fXe={class:"user-header"},hXe={class:"stats-role"},SXe={key:0,class:"role"},AXe={class:"user-role"},OXe=Q({__name:"UserCard",props:{authUser:{},user:{},updatedUser:{},from:{},hideRelationship:{type:Boolean}},emits:["updatedUserRelationship"],setup(e,{emit:t}){const n=e,{authUser:a,from:s,hideRelationship:o,updatedUser:i,user:r}=_e(n),u=it(),{authUserHasModeratorRights:l}=Ke(),d=t,{errorMessages:E}=He(),c=F(()=>r.value.suspended_at?$t(r.value.suspended_at,a.value.timezone,a.value.date_format):null),m=F(()=>{var O;return l.value&&u.params.reportId!=((O=r.value.suspension_report_id)==null?void 0:O.toString())}),_=F(()=>r.value.role!=="user"?`user.ROLES.${r.value.role}`:"");function h(O){d("updatedUserRelationship",O)}return(O,S)=>{const R=q("router-link"),g=q("AlertMessage"),I=q("i18n-t"),N=q("ErrorMessage");return f(),v("div",TXe,[p("div",_Xe,[p("div",fXe,[w(Jt,{user:T(r)},null,8,["user"]),w(R,{class:"user-name",to:O.$route.path.startsWith("/admin")?`/admin/users/${T(r).username}`:`/users/${T(r).username}?from=users`,title:T(r).username},{default:X(()=>[x(A(T(r).username),1)]),_:1},8,["to","title"])]),p("div",hXe,[w(Hp,{user:T(r)},null,8,["user"]),_.value?(f(),v("div",SXe,[p("div",AXe,A(O.$t(_.value)),1)])):D("",!0)])]),T(o)!==!0?(f(),B(Xu,{key:0,authUser:T(a),user:T(r),from:T(s)?T(s):"userCard",displayFollowsYou:!0,onUpdatedUser:h},null,8,["authUser","user","from"])):D("",!0),"is_active"in T(r)&&!T(r).is_active?(f(),B(g,{key:1,message:"user.THIS_USER_ACCOUNT_IS_INACTIVE"})):D("",!0),"suspended_at"in T(r)&&T(r).suspended_at!==null?(f(),B(g,{key:2,message:"user.ACCOUNT_SUSPENDED_AT",param:c.value},So({_:2},[m.value?{name:"additionalMessage",fn:X(()=>[w(I,{keypath:"common.SEE_REPORT",tag:"span"},{default:X(()=>[w(R,{to:`/admin/reports/${T(r).suspension_report_id}`},{default:X(()=>[x(" #"+A(T(r).suspension_report_id),1)]),_:1},8,["to"])]),_:1})]),key:"0"}:void 0]),1032,["param"])):D("",!0),T(E)&&(T(i)&&T(i)===T(r).username||!T(i))?(f(),B(N,{key:3,message:T(E)},null,8,["message"])):D("",!0)])}}}),Kp=se(OXe,[["__scopeId","data-v-45957e2b"]]),$O="/img/workouts/mountains.svg",IXe=["alt"],UO=Q({__name:"StaticMap",props:{workout:{},displayHover:{type:Boolean,default:!1}},setup(e){const t=e,{displayHover:n}=_e(t),a=`${nr()}workouts/map/${t.workout.map}`;return(s,o)=>{const i=q("router-link");return f(),v("div",{class:he(["static-map",{"display-hover":T(n)}])},[T(n)?(f(),v("img",{key:0,src:a,alt:s.$t("workouts.WORKOUT_MAP")},null,8,IXe)):(f(),B(i,{key:1,class:"bg-map-image",to:{name:"Workout",params:{workoutId:s.workout.id}},style:Va({backgroundImage:`url(${a})`}),"aria-label":s.$t("workouts.WORKOUT_MAP")},null,8,["to","style","aria-label"])),o[0]||(o[0]=p("div",{class:"map-attribution"},[p("a",{class:"map-attribution-text",href:"https://www.openstreetmap.org/copyright",target:"_blank",rel:"noopener noreferrer"}," © OpenStreetMap ")],-1))],2)}}}),gXe={class:"timeline-workout workout-card"},RXe={class:"box"},NXe={class:"workout-card-title"},vXe={class:"workout-user-date"},bXe={class:"workout-user"},CXe={class:"workout-date-visibility"},PXe=["datetime","title"],DXe={class:"workout-map"},LXe={class:"no-map"},yXe={class:"img"},$Xe={class:"data"},UXe={key:0},kXe={class:"data"},wXe={key:0,class:"data elevation"},MXe=["alt"],WXe={class:"data-values"},FXe={key:1,class:"data altitude"},zXe={class:"data-values"},xXe=Q({__name:"WorkoutCard",props:{user:{},useImperialUnits:{type:Boolean},dateFormat:{},timezone:{},workout:{default:()=>({})},sport:{default:()=>({})}},setup(e){const t=e,{dateFormat:n,sport:a,timezone:s,user:o,useImperialUnits:i,workout:r}=_e(t),{locale:u}=He(),l=F(()=>$t(r.value.workout_date,s.value,n.value));function d(c){return c.with_gpx&&c.min_alt!==null&&c.max_alt!==null}function E(c){return d(c)&&c.ascent!==null&&c.descent!==null}return(c,m)=>{var R;const _=q("router-link"),h=q("VisibilityIcon"),O=q("SportImage"),S=q("Distance");return f(),v("div",gXe,[p("div",RXe,[p("div",NXe,[p("div",vXe,[p("div",bXe,[w(Jt,{user:T(o)},null,8,["user"]),w(Ri,{user:T(o)},null,8,["user"])]),T(r).id?(f(),B(_,{key:0,class:"workout-title",to:{name:"Workout",params:{workoutId:T(r).id}}},{default:X(()=>[x(A(T(r).title),1)]),_:1},8,["to"])):D("",!0),p("div",CXe,[T(r).workout_date&&T(o)?(f(),v("time",{key:0,class:"workout-date",datetime:l.value,title:l.value},A(T(xs)(new Date(T(r).workout_date),new Date,{addSuffix:!0,locale:T(u)})),9,PXe)):D("",!0),T(r).workout_visibility?(f(),B(h,{key:1,visibility:T(r).workout_visibility},null,8,["visibility"])):D("",!0)])])]),p("div",DXe,[T(r).with_gpx?(f(),B(UO,{key:0,workout:T(r)},null,8,["workout"])):T(r).id?(f(),B(_,{key:1,to:{name:"Workout",params:{workoutId:T(r).id}}},{default:X(()=>[p("div",LXe,A(c.$t("workouts.NO_MAP")),1)]),_:1},8,["to"])):D("",!0)]),p("div",{class:he(["workout-data",{"without-elevation":!d(T(r))}]),onClick:m[0]||(m[0]=g=>T(r).id?c.$router.push({name:"Workout",params:{workoutId:T(r).id}}):null)},[p("div",yXe,[(R=T(a))!=null&&R.label?(f(),B(O,{key:0,"sport-label":T(a).label,color:T(a).color},null,8,["sport-label","color"])):D("",!0)]),p("div",$Xe,[m[1]||(m[1]=p("i",{class:"fa fa-clock-o","aria-hidden":"true"},null,-1)),T(r)?(f(),v("span",UXe,A(T(r).moving),1)):D("",!0)]),p("div",kXe,[m[2]||(m[2]=p("i",{class:"fa fa-road","aria-hidden":"true"},null,-1)),T(r).id?(f(),B(S,{key:0,distance:T(r).distance,digits:3,unitFrom:"km",useImperialUnits:T(i)},null,8,["distance","useImperialUnits"])):D("",!0)]),d(T(r))?(f(),v("div",wXe,[p("img",{class:"mountains",src:$O,alt:c.$t("workouts.ELEVATION")},null,8,MXe),p("div",WXe,[T(r).id?(f(),B(S,{key:0,distance:T(r).min_alt,unitFrom:"m",displayUnit:!1,useImperialUnits:T(i)},null,8,["distance","useImperialUnits"])):D("",!0),m[3]||(m[3]=x("/ ")),T(r).id?(f(),B(S,{key:1,distance:T(r).max_alt,unitFrom:"m",useImperialUnits:T(i)},null,8,["distance","useImperialUnits"])):D("",!0)])])):D("",!0),E(T(r))?(f(),v("div",FXe,[m[6]||(m[6]=p("i",{class:"fa fa-location-arrow","aria-hidden":"true"},null,-1)),p("div",zXe,[m[4]||(m[4]=x(" +")),T(r).id?(f(),B(S,{key:0,distance:T(r).ascent,unitFrom:"m",displayUnit:!1,useImperialUnits:T(i)},null,8,["distance","useImperialUnits"])):D("",!0),m[5]||(m[5]=x("/- ")),T(r).id?(f(),B(S,{key:1,distance:T(r).descent,unitFrom:"m",useImperialUnits:T(i)},null,8,["distance","useImperialUnits"])):D("",!0)])])):D("",!0)],2)])])}}}),Qu=se(xXe,[["__scopeId","data-v-bf03bb8a"]]),BXe={key:0,class:"report-loading"},GXe={key:0,id:"admin-report",class:"admin-card"},VXe={key:0,class:"report-status"},HXe={class:"report-data"},KXe={class:"report-detail"},qXe={key:1,class:"deleted-object"},jXe={key:2,class:"deleted-object"},YXe={key:1,class:"deleted-object"},XXe={key:2,class:"deleted-object"},QXe={key:1,class:"deleted-object"},ZXe={key:1,class:"deleted-object"},JXe={class:"report-info"},eQe={key:0,class:"report-comment-user"},tQe={key:1,class:"deleted-object"},nQe={key:0},aQe={key:1},sQe={key:2},oQe={key:3},iQe={class:"resolver-user"},rQe={key:4},uQe={key:5},lQe={key:0,class:"report-comment"},cQe={class:"report-comment-info"},dQe={class:"report-comment-user"},EQe=["title"],pQe={class:"report-comment-comment"},mQe={key:1,class:"report-action"},TQe=["title"],_Qe=["onClick"],fQe={key:0,class:"report-action-note"},hQe={key:0,class:"no-notes"},SQe={key:0,class:"comment-textarea"},AQe={for:"report-comment"},OQe={class:"comment-buttons"},IQe=["disabled"],gQe=["disabled"],RQe={class:"action-loading"},NQe={key:0,class:"fa fa-spinner fa-pulse","aria-hidden":"true"},vQe={key:1,class:"actions-buttons"},bQe={key:1,class:"container"},CQe={key:2,class:"container"},PQe=Q({__name:"AdminReport",setup(e){const t=it(),n=yn(),a=$e(),{t:s}=yt(),{errorMessages:o,locale:i}=He(),{authUser:r,authUserSuccess:u,dateFormat:l}=Ke(),{sports:d}=ln(),E=Se(""),c=Se(null),m=Se(""),_=Se([]),h=F(()=>a.getters[ye.GETTERS.REPORT]),O=F(()=>h.value.reported_comment||h.value.reported_workout),S=F(()=>a.getters[ye.GETTERS.REPORT_LOADING]),R=F(()=>a.getters[ye.GETTERS.REPORT_UPDATE_LOADING]),g=Se(!1),I=F(()=>Oe()),N=F(()=>c.value!==null&&["ADD_COMMENT","MARK_AS_RESOLVED","MARK_AS_UNRESOLVED"].includes(c.value)),b=F(()=>{var H,fe;return(H=h.value.reported_user)!=null&&H.suspended_at?$t((fe=h.value.reported_user)==null?void 0:fe.suspended_at,r.value.timezone,r.value.date_format):null}),C=F(()=>{var H,fe;return t.params.reportId!=((fe=(H=h.value.reported_user)==null?void 0:H.suspension_report_id)==null?void 0:fe.toString())});function k(){a.dispatch(ye.ACTIONS.GET_REPORT,{reportId:+t.params.reportId,loader:"REPORT"})}function P(H=null){de(),c.value=H,g.value=!0}function $(H){E.value=H.value}function y(){g.value=!1,E.value="",c.value=null,a.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES)}function z(){const H={reportId:h.value.id,comment:E.value};c.value&&["MARK_AS_RESOLVED","MARK_AS_UNRESOLVED"].includes(c.value)&&(H.resolved=c.value==="MARK_AS_RESOLVED"),a.dispatch(ye.ACTIONS.UPDATE_REPORT,H)}function Z(){switch(c.value){case"SEND_WARNING_EMAIL":Ve();break;case"SUSPEND_ACCOUNT":case"SUSPEND_CONTENT":De("suspension");break;case"UNSUSPEND_ACCOUNT":J();break;case"UNSUSPEND_CONTENT":ce();break;default:return z()}}function Ae(){switch(c.value){case"MARK_AS_RESOLVED":return`admin.APP_MODERATION.ACTIONS.${c.value}`;default:return"buttons.SUBMIT"}}function J(){if(h.value.reported_user&&c.value){const fe={action_type:`user_${c.value==="SUSPEND_ACCOUNT"?"":"un"}suspension`,report_id:h.value.id,username:h.value.reported_user.username};E.value&&(fe.reason=E.value),a.dispatch(ye.ACTIONS.SUBMIT_ADMIN_ACTION,fe)}}function ce(){var H;if(O.value&&c.value){const Ce={action_type:`${h.value.reported_comment?"comment":"workout"}_${(H=c.value)!=null&&H.startsWith("SUSPEND")?"":"un"}suspension`,report_id:h.value.id};h.value.reported_comment?Ce.comment_id=h.value.reported_comment.id:h.value.reported_workout&&(Ce.workout_id=h.value.reported_workout.id),E.value&&(Ce.reason=E.value),a.dispatch(ye.ACTIONS.SUBMIT_ADMIN_ACTION,Ce)}}function Te(){De(""),c.value==="SUSPEND_CONTENT"?ce():J()}function De(H){m.value=H,H!==""&&a.commit(me.MUTATIONS.UPDATE_IS_SUCCESS,!1)}function Ve(){var fe;const H={action_type:"user_warning",report_id:h.value.id,username:(fe=h.value.reported_user)==null?void 0:fe.username};E.value&&(H.reason=E.value),a.dispatch(ye.ACTIONS.SUBMIT_ADMIN_ACTION,H)}function xe(){n.go(-1),a.commit(ye.MUTATIONS.EMPTY_REPORT)}function ot(H){return $t(H,r.value.timezone,r.value.date_format)}function re(H,fe){return Ti(new Date(H.created_at),new Date(fe.created_at))}function Oe(){return!h.value.report_actions&&!h.value.comments?[]:[...h.value.report_actions,...h.value.comments].sort(re)}function pt(H){_.value.includes(H)?(_.value.splice(_.value.indexOf(H),1),a.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES)):_.value.push(H)}function wt(H){a.dispatch(ye.ACTIONS.PROCESS_APPEAL,{...H,reportId:h.value.id})}function It(){var ae,Ie;const H=(ae=c.value)!=null&&ae.includes("SUSPEND")?(Ie=c.value)==null?void 0:Ie.split("_")[0]:c.value,fe=s(`admin.APP_MODERATION.TEXTAREA_PLACEHOLDER.${H}`);let Ce="";return H&&(Ce=["ADD_COMMENT","MARK_AS_RESOLVED","MARK_AS_UNRESOLVED"].includes(H)?"":` ${s("admin.APP_MODERATION.TEXTAREA_PLACEHOLDER.INFORMATION_VISIBLE_TO_USER")}`),`${fe}${Ce}`}function de(){a.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),_.value=[]}return Le(()=>h.value.comments,()=>{g.value=!1,E.value=""}),Le(()=>t.params.reportId,()=>{k()}),Le(()=>u.value,H=>{H&&De("")}),tt(async()=>k()),Et(()=>a.commit(me.MUTATIONS.UPDATE_IS_SUCCESS,!1)),(H,fe)=>{var oe;const Ce=q("Modal"),ae=q("router-link"),Ie=q("i18n-t"),U=q("AlertMessage"),M=q("Card"),Y=q("CustomTextArea"),pe=q("ErrorMessage");return S.value?(f(),v("div",BXe,[w(kl)])):(f(),v(le,{key:1},[(oe=h.value)!=null&&oe.id?(f(),v("div",GXe,[m.value&&h.value.reported_user?(f(),B(Ce,{key:0,title:H.$t("common.CONFIRMATION"),message:`admin.CONFIRM_${c.value}`,strongMessage:h.value.reported_user.username,onConfirmAction:Te,onCancelAction:fe[0]||(fe[0]=L=>De("")),onKeydown:fe[1]||(fe[1]=je(L=>De(""),["esc"]))},null,8,["title","message","strongMessage"])):D("",!0),w(M,null,{title:X(()=>[x(A(H.$t("admin.APP_MODERATION.REPORT"))+" #"+A(h.value.id)+" ",1),h.value.resolved?(f(),v("span",VXe," ("+A(H.$t("admin.APP_MODERATION.RESOLVED.TRUE"))+") ",1)):D("",!0)]),content:X(()=>[p("div",HXe,[p("div",KXe,[w(M,{class:"report-detail-card"},{title:X(()=>[x(A(H.$t("admin.APP_MODERATION.REPORTED_CONTENT")),1)]),content:X(()=>{var L,W,G;return[h.value.object_type==="comment"?(f(),v(le,{key:0},[h.value.reported_comment?(f(),B(zp,{key:0,"auth-user":T(r),comment:h.value.reported_comment,"comments-loading":null,"for-admin":!0},null,8,["auth-user","comment"])):(f(),v("span",qXe,A(H.$t("admin.DELETED_COMMENT")),1)),h.value.reported_user?D("",!0):(f(),v("span",jXe," ("+A(H.$t("admin.DELETED_USER").toLocaleLowerCase())+") ",1))],64)):D("",!0),h.value.object_type==="workout"?(f(),v(le,{key:1},[h.value.reported_workout?(f(),B(Qu,{workout:h.value.reported_workout,sport:T(d).filter(j=>{var Ee;return j.id===((Ee=h.value.reported_workout)==null?void 0:Ee.sport_id)})[0],user:h.value.reported_workout.user,useImperialUnits:T(r).imperial_units,dateFormat:T(l),timezone:T(r).timezone,key:h.value.reported_workout.id},null,8,["workout","sport","user","useImperialUnits","dateFormat","timezone"])):(f(),v("span",YXe,A(H.$t("admin.DELETED_WORKOUT")),1)),h.value.reported_user?(W=(L=h.value.reported_workout)==null?void 0:L.suspension)!=null&&W.report_id?(f(),B(U,{key:3,message:"workouts.SUSPENDED_BY_ADMIN"},So({_:2},[h.value.reported_workout.suspension.report_id!==parseInt(T(t).params.reportId)?{name:"additionalMessage",fn:X(()=>[w(Ie,{keypath:"common.SEE_REPORT",tag:"span"},{default:X(()=>[w(ae,{to:`/admin/reports/${h.value.reported_workout.suspension.report_id}`},{default:X(()=>[x(" #"+A(h.value.reported_workout.suspension.report_id),1)]),_:1},8,["to"])]),_:1})]),key:"0"}:void 0]),1024)):D("",!0):(f(),v("span",XXe," ("+A(H.$t("admin.DELETED_USER").toLocaleLowerCase())+") ",1))],64)):D("",!0),h.value.object_type==="user"?(f(),v(le,{key:2},[h.value.reported_user?(f(),B(Kp,{key:0,authUser:T(r),user:h.value.reported_user,hideRelationship:!0},null,8,["authUser","user"])):(f(),v("span",QXe,A(H.$t("admin.DELETED_USER")),1))],64)):((G=h.value.reported_user)==null?void 0:G.suspended_at)!==null?(f(),B(U,{key:3,message:"user.ACCOUNT_SUSPENDED_AT",param:b.value},So({_:2},[C.value?{name:"additionalMessage",fn:X(()=>[w(Ie,{keypath:"common.SEE_REPORT"},{default:X(()=>{var j;return[w(ae,{to:`/admin/reports/${(j=h.value.reported_user)==null?void 0:j.suspension_report_id}`},{default:X(()=>{var Ee;return[x(A((Ee=h.value.reported_user)==null?void 0:Ee.suspension_report_id),1)]}),_:1},8,["to"])]}),_:1})]),key:"0"}:void 0]),1032,["param"])):D("",!0)]}),_:1}),w(M,{class:"report-detail-card"},{title:X(()=>[x(A(H.$t("admin.APP_MODERATION.REPORT_NOTE"))+" ",1),h.value.reported_by?(f(),v(le,{key:0},[w(ae,{class:"link-with-image",to:`/admin/users/${h.value.reported_by.username}`},{default:X(()=>[x(A(h.value.reported_by.username),1)]),_:1},8,["to"]),x(" ("+A(H.$t("admin.APP_MODERATION.REPORTER"))+") ",1)],64)):(f(),v("span",ZXe,A(H.$t("admin.DELETED_USER").toLocaleLowerCase()),1))]),content:X(()=>[x(A(h.value.note),1)]),_:1})]),p("dl",JXe,[p("dt",null,A(H.$t("admin.APP_MODERATION.ORDER_BY.CREATED_AT"))+":",1),p("dd",null,A(ot(h.value.created_at)),1),p("dt",null,A(H.$t("admin.APP_MODERATION.REPORTED_BY"))+":",1),p("dd",null,[h.value.reported_by?(f(),v("div",eQe,[w(Jt,{user:h.value.reported_by},null,8,["user"]),w(Ri,{user:h.value.reported_by},null,8,["user"])])):(f(),v("span",tQe,A(H.$t("admin.DELETED_USER")),1))]),p("dt",null,A(H.$t("admin.APP_MODERATION.STATUS"))+":",1),p("dd",null,A(H.$t(`admin.APP_MODERATION.RESOLVED.${h.value.resolved?"TRUE":"FALSE"}`)),1),h.value.resolved_at?(f(),v("dt",nQe,A(H.$t("admin.APP_MODERATION.RESOLVED_AT"))+": ",1)):D("",!0),h.value.resolved_at?(f(),v("dd",aQe,[p("time",null,A(ot(h.value.resolved_at)),1)])):D("",!0),h.value.resolved_by?(f(),v("dt",sQe,A(H.$t("admin.APP_MODERATION.RESOLVED_BY"))+": ",1)):D("",!0),h.value.resolved_by?(f(),v("dd",oQe,[p("div",iQe,[w(Jt,{user:h.value.resolved_by},null,8,["user"]),w(Ri,{user:h.value.resolved_by},null,8,["user"])])])):D("",!0),h.value.updated_at?(f(),v("dt",rQe,A(H.$t("common.LAST_UPDATED_ON"))+": ",1)):D("",!0),h.value.updated_at?(f(),v("dd",uQe,[p("time",null,A(ot(h.value.updated_at)),1)])):D("",!0)])]),w(M,{class:"report-action-and-comments"},{title:X(()=>[x(A(H.$t("admin.APP_MODERATION.NOTES_AND_ACTIONS")),1)]),content:X(()=>[(f(!0),v(le,null,be(I.value,L=>(f(),v("div",{key:L.id},["comment"in L&&!("action_type"in L)?(f(),v("div",lQe,[p("div",cQe,[p("div",dQe,[w(Jt,{user:L.user},null,8,["user"]),w(Ri,{user:L.user},null,8,["user"])]),p("div",{class:"report-comment-date",title:ot(L.created_at)},A(T(xs)(new Date(L.created_at),new Date,{addSuffix:!0,locale:T(i)})),9,EQe)]),p("div",pQe,A(L.comment),1)])):D("",!0),"action_type"in L?(f(),v("div",mQe,[p("div",null,[fe[7]||(fe[7]=x(" • ")),w(Ie,{keypath:`admin.APP_MODERATION.REPORT_ACTIONS.${L.action_type}`},{default:X(()=>[L.action_type.startsWith("user_")&&L.user?(f(),B(ae,{key:0,class:"user-name",to:`/admin/users/${L.user.username}`,title:L.user.username},{default:X(()=>[x(A(L.user.username),1)]),_:2},1032,["to","title"])):D("",!0),w(ae,{class:"user-name",to:`/admin/users/${L.moderator.username}`,title:L.moderator.username},{default:X(()=>[x(A(L.moderator.username),1)]),_:2},1032,["to","title"]),p("span",{class:"report-action-date",title:ot(L.created_at)},A(T(xs)(new Date(L.created_at),new Date,{addSuffix:!0,locale:T(i)})),9,TQe)]),_:2},1032,["keypath"]),L.appeal?(f(),v("button",{key:0,class:"appeal-button small transparent",onClick:W=>pt(L.appeal.id)},A(H.$t(`admin.APP_MODERATION.APPEAL.${_.value.includes(L.appeal.id)?"HIDE":"SEE"}`)),9,_Qe)):D("",!0)]),L.reason?(f(),v("div",fQe,[p("span",null,A(H.$t("admin.APP_MODERATION.REASON"))+":",1),x(" "+A(L.reason),1)])):D("",!0),L.appeal&&_.value.includes(L.appeal.id)?(f(),B(VYe,{key:1,appeal:L.appeal,"auth-user":T(r),onUpdateAppeal:wt,onCloseAppeal:W=>pt(L.appeal.id)},null,8,["appeal","auth-user","onCloseAppeal"])):D("",!0)])):D("",!0)]))),128)),I.value.length==0?(f(),v("div",hQe,A(H.$t("common.NO_NOTES")),1)):D("",!0)]),_:1}),w(M,{class:"report-detail-card"},{title:X(()=>[x(A(H.$t("admin.ACTION",0)),1)]),content:X(()=>[g.value?(f(),v("div",SQe,[p("form",{onSubmit:Ne(Z,["prevent"])},[p("label",AQe,A(H.$t(`admin.APP_MODERATION.ACTIONS.${c.value}`)),1),w(Y,{class:"report-comment-textarea",name:"report-comment",required:N.value,placeholder:It(),disabled:R.value,onUpdateValue:$},null,8,["required","placeholder","disabled"]),p("div",OQe,[p("button",{class:"confirm",type:"submit",disabled:R.value},A(H.$t(Ae())),9,IQe),p("button",{class:"cancel",onClick:Ne(y,["prevent"]),disabled:R.value},A(H.$t("buttons.CANCEL")),9,gQe),p("div",RQe,[R.value?(f(),v("i",NQe)):D("",!0)])]),T(o)?(f(),B(pe,{key:0,message:T(o)},null,8,["message"])):D("",!0)],32)])):(f(),v("div",vQe,[p("button",{onClick:fe[2]||(fe[2]=L=>P("ADD_COMMENT"))},A(H.$t("admin.APP_MODERATION.ACTIONS.ADD_COMMENT")),1),!h.value.resolved&&h.value.reported_user&&!h.value.is_reported_user_warned?(f(),v("button",{key:0,onClick:fe[3]||(fe[3]=L=>P("SEND_WARNING_EMAIL"))},A(H.$t("admin.APP_MODERATION.ACTIONS.SEND_WARNING_EMAIL")),1)):D("",!0),!h.value.resolved&&O.value?(f(),v("button",{key:1,class:he({danger:O.value.suspended_at===null}),onClick:fe[4]||(fe[4]=L=>P(`${O.value.suspended_at===null?"":"UN"}SUSPEND_CONTENT`))},A(H.$t(`admin.APP_MODERATION.ACTIONS.${O.value.suspended_at===null?"":"UN"}SUSPEND_CONTENT`)),3)):D("",!0),!h.value.resolved&&h.value.reported_user?(f(),v("button",{key:2,class:he({danger:h.value.reported_user.suspended_at===null}),onClick:fe[5]||(fe[5]=L=>P(`${h.value.reported_user.suspended_at?"UN":""}SUSPEND_ACCOUNT`))},A(H.$t(`admin.APP_MODERATION.ACTIONS.${h.value.reported_user.suspended_at?"UN":""}SUSPEND_ACCOUNT`)),3)):D("",!0),p("button",{onClick:fe[6]||(fe[6]=L=>P(`MARK_AS_${h.value.resolved?"UN":""}RESOLVED`))},A(H.$t(`admin.APP_MODERATION.ACTIONS.MARK_AS_${h.value.resolved?"UN":""}RESOLVED`)),1)]))]),_:1}),p("button",{onClick:Ne(xe,["prevent"])},A(H.$t("buttons.BACK")),1)]),_:1})])):S.value?(f(),v("div",bQe)):(f(),v("div",CQe,[w(Wo,{target:"REPORT"})]))],64))}}}),DQe=se(PQe,[["__scopeId","data-v-e9598d83"]]),LQe={class:"table-selects"},yQe=["value"],$Qe=["value"],UQe=["value"],kQe=["value"],wQe=["value"],MQe=["value"],qp=Q({__name:"FilterSelects",props:{order_by:{},query:{},sort:{},message:{}},emits:["updateSelect"],setup(e,{emit:t}){const n=e,{order_by:a,query:s,sort:o,message:i}=_e(n),r=t,u=[10,25,50,100];function l(d){r("updateSelect",d.target.id,d.target.value)}return(d,E)=>(f(),v("div",LQe,[p("label",null,[x(A(d.$t("common.SELECTS.ORDER_BY.LABEL"))+": ",1),p("select",{name:"order_by",id:"order_by",value:T(s).order_by,onChange:l},[(f(!0),v(le,null,be(T(a),c=>(f(),v("option",{value:c,key:c},A(d.$t(`${T(i)}.${c.toUpperCase()}`)),9,$Qe))),128))],40,yQe)]),p("label",null,[x(A(d.$t("common.SELECTS.ORDER.LABEL"))+": ",1),p("select",{name:"order",id:"order",value:T(s).order,onChange:l},[(f(!0),v(le,null,be(T(o),c=>(f(),v("option",{value:c,key:c},A(d.$t(`common.SELECTS.ORDER.${c.toUpperCase()}`)),9,kQe))),128))],40,UQe)]),Pt(d.$slots,"additionalFilters"),p("label",null,[x(A(d.$t("common.SELECTS.PER_PAGE.LABEL"))+": ",1),p("select",{name:"per_page",id:"per_page",value:T(s).per_page,onChange:l},[(f(),v(le,null,be(u,c=>p("option",{value:c,key:c},A(c),9,MQe)),64))],40,wQe)])]))}}),wl=["asc","desc"],Ml=1,WQe=10,Hi=(e,t)=>e&&typeof e=="string"&&+e>0?+e:t,Th=(e,t,n)=>e&&typeof e=="string"&&t.includes(e)?e:n,Do=(e,t,n,a)=>{const o=(a||{}).defaultSort||"asc",i={};return i.page=Hi(e.page,Ml),i.per_page=Hi(e.per_page,WQe),i.order=Th(e.order,wl,o),i.order_by=Th(e.order_by,t,n),typeof e.q=="string"?i.q=e.q:delete i.q,typeof e.notes=="string"?i.notes=e.notes:delete i.notes,typeof e.description=="string"?i.description=e.description:delete i.description,typeof e.object_type=="string"?i.object_type=e.object_type:delete i.object_type,typeof e.resolved=="string"?i.resolved=e.resolved:delete i.resolved,i},FQe=["equipment_id","from","to","ave_speed_from","ave_speed_to","max_speed_from","max_speed_to","distance_from","distance_to","duration_from","duration_to","sport_id","title"],no=(e,t=1)=>Array.from({length:e-t+1},(n,a)=>t+a),zQe=(e,t)=>{if(e<0)return[];if(e<9)return no(e);let n=[1,2];return t<4?n=n.concat([3,4,5]):t<6?n=n.concat(no(t+2,3)):(n=n.concat(["..."]),t=e-2&&+n[n.length-1]{const u=q("router-link");return f(),v("nav",xQe,[p("ul",BQe,[p("li",{class:he(["page-prev",{disabled:!T(n).has_prev}])},[w(u,{class:"page-link",to:{path:T(a),query:o(T(n).page,-1)},disabled:!T(n).has_prev,tabindex:T(n).has_prev?0:-1},{default:X(({navigate:l})=>[Pt(i.$slots,"default",{onClick:d=>T(n).has_next?l:null},()=>[x(A(i.$t("common.PREVIOUS"))+" ",1),r[0]||(r[0]=p("i",{class:"fa fa-chevron-left","aria-hidden":"true"},null,-1))],!0)]),_:3},8,["to","disabled","tabindex"])],2),(f(!0),v(le,null,be(T(zQe)(T(n).pages,T(n).page),l=>(f(),v("li",{key:l,class:he(["page",{active:l===T(n).page}])},[l==="..."?(f(),v("span",GQe," ... ")):(f(),B(u,{key:1,class:"page-link",to:{path:T(a),query:o(+l)}},{default:X(()=>[x(A(l),1)]),_:2},1032,["to"]))],2))),128)),p("li",{class:he(["page-next",{disabled:!T(n).has_next}])},[w(u,{class:"page-link",to:{path:T(a),query:o(T(n).page,1)},disabled:!T(n).has_next,tabindex:T(n).has_next?0:-1},{default:X(({navigate:l})=>[Pt(i.$slots,"default",{onClick:d=>T(n).has_next?l:null},()=>[x(A(i.$t("common.NEXT"))+" ",1),r[1]||(r[1]=p("i",{class:"fa fa-chevron-right","aria-hidden":"true"},null,-1))],!0)]),_:3},8,["to","disabled","tabindex"])],2)])])}}}),da=se(VQe,[["__scopeId","data-v-d7c0bddb"]]),HQe={id:"admin-reports",class:"admin-card"},KQe=["value"],qQe=["value"],jQe=["value"],YQe={value:"true"},XQe={value:"false"},QQe={key:0,class:"no-reports"},ZQe={key:1,class:"responsive-table"},JQe={class:"left-text"},eZe={class:"left-text"},tZe={class:"left-text"},nZe={class:"left-text"},aZe={class:"left-text"},sZe={class:"left-text"},oZe={class:"left-text"},iZe={class:"cell-heading"},rZe={key:1,class:"deleted-object"},uZe={class:"cell-heading"},lZe={key:0},cZe={class:"cell-heading"},dZe={key:1,class:"deleted-object"},EZe={class:"cell-heading"},pZe={class:"cell-heading"},mZe={class:"cell-heading"},TZe={key:0},_Ze=["onClick"],_h=20,fh="created_at",fZe=Q({__name:"AdminReports",setup(e){const t=it(),n=yn(),a=$e(),{errorMessages:s}=He(),{authUser:o}=Ke(),i=["created_at","updated_at"],r={comment:"workouts.COMMENTS.COMMENT",user:"user.USER",workout:"workouts.WORKOUT"};let u=kt(Do(t.query,i,fh,{defaultSort:"desc"}));const l=F(()=>a.getters[ye.GETTERS.REPORTS]),d=F(()=>a.getters[ye.GETTERS.REPORTS_PAGINATION]);function E(R){a.dispatch(ye.ACTIONS.GET_REPORTS,R)}function c(R){const g=R.target;g.value?u.object_type=g.value:delete u.object_type,n.push({path:"/admin/reports",query:u})}function m(R){const g=R.target;g.value?u.resolved=g.value:delete u.resolved,n.push({path:"/admin/reports",query:u})}function _(R,g){u[R]=g,R==="per_page"&&(u.page=1),n.push({path:"/admin/reports",query:u})}function h(R){return $t(R,o.value.timezone,o.value.date_format)}function O(R){return R=="user.USER"?"user.USER_PROFILE":R}function S(R){var I,N;let g;switch(R.object_type){case"workout":g=(I=R.reported_workout)==null?void 0:I.title;break;case"comment":g=((N=R.reported_comment)==null?void 0:N.text)||"";break;default:g=""}return g?g.length>_h?`${g.substring(0,_h-1)}…`:g:""}return Le(()=>t.query,R=>{u=Do(R,i,fh,{query:u}),E(u)}),tt(()=>E(u)),(R,g)=>{const I=q("router-link"),N=q("ErrorMessage"),b=q("Card");return f(),v("div",HQe,[w(b,null,{title:X(()=>[x(A(R.$t("admin.APP_MODERATION.TITLE")),1)]),content:X(()=>[p("button",{class:"top-button",onClick:g[0]||(g[0]=Ne(C=>R.$router.push("/admin"),["prevent"]))},A(R.$t("admin.BACK_TO_ADMIN")),1),w(qp,{sort:T(wl),order_by:i,query:T(u),message:"admin.APP_MODERATION.ORDER_BY",onUpdateSelect:_},{additionalFilters:X(()=>[p("label",null,[x(A(R.$t("common.TYPE"))+": ",1),p("select",{name:"object_type",id:"object_type",value:T(u).object_type,onChange:c},[g[2]||(g[2]=p("option",{value:""},null,-1)),(f(!0),v(le,null,be(Object.keys(r),C=>(f(),v("option",{value:C,key:C},A(R.$t(r[C])),9,qQe))),128))],40,KQe)]),p("label",null,[x(A(R.$t("admin.APP_MODERATION.STATUS"))+": ",1),p("select",{name:"resolved",id:"resolved",value:T(u).resolved,onChange:m},[g[3]||(g[3]=p("option",{value:""},null,-1)),p("option",YQe,A(R.$t("admin.APP_MODERATION.RESOLVED.TRUE")),1),p("option",XQe,A(R.$t("admin.APP_MODERATION.RESOLVED.FALSE")),1)],40,jQe)])]),_:1},8,["sort","query"]),l.value.length===0?(f(),v("div",QQe,A(R.$t("admin.APP_MODERATION.NO_REPORTS_FOUND")),1)):(f(),v("div",ZQe,[p("table",null,[p("thead",null,[p("tr",null,[g[4]||(g[4]=p("th",{class:"left-text"},"#",-1)),p("th",JQe,A(R.$t("admin.APP_MODERATION.REPORTED_USER")),1),p("th",eZe,A(R.$t("admin.APP_MODERATION.REPORTED_CONTENT")),1),p("th",tZe,A(R.$t("admin.APP_MODERATION.REPORTED_BY")),1),p("th",nZe,A(Fe(R.$t("admin.APP_MODERATION.ORDER_BY.CREATED_AT"))),1),p("th",aZe,A(R.$t("admin.APP_MODERATION.RESOLVED.TRUE")),1),p("th",sZe,A(Fe(R.$t("common.LAST_UPDATED_ON"))),1),p("th",oZe,A(R.$t("admin.ACTION")),1)])]),p("tbody",null,[(f(!0),v(le,null,be(l.value,C=>(f(),v("tr",{key:C.created_at},[p("td",null,[w(I,{to:`/admin/reports/${C.id}`},{default:X(()=>[x(A(C.id),1)]),_:2},1032,["to"])]),p("td",null,[p("span",iZe,A(R.$t("admin.APP_MODERATION.REPORTED_USER")),1),C.reported_user?(f(),B(I,{key:0,class:"link-with-image",to:`/admin/users/${C.reported_user.username}`},{default:X(()=>[w(Jt,{user:C.reported_user},null,8,["user"]),x(" "+A(C.reported_user.username),1)]),_:2},1032,["to"])):(f(),v("span",rZe,A(R.$t("admin.DELETED_USER")),1))]),p("td",null,[p("span",uZe,A(R.$t("admin.APP_MODERATION.REPORTED_CONTENT")),1),x(" "+A(R.$t(O(r[C.object_type])))+" ",1),S(C)?(f(),v("span",lZe," ("+A(S(C))+") ",1)):D("",!0)]),p("td",null,[p("span",cZe,A(R.$t("admin.APP_MODERATION.REPORTED_BY")),1),C.reported_by?(f(),B(I,{key:0,class:"link-with-image",to:`/admin/users/${C.reported_by.username}`},{default:X(()=>[w(Jt,{user:C.reported_by},null,8,["user"]),x(" "+A(C.reported_by.username),1)]),_:2},1032,["to"])):(f(),v("span",dZe,A(R.$t("admin.DELETED_USER")),1))]),p("td",null,[p("span",EZe,A(Fe(R.$t("admin.APP_MODERATION.ORDER_BY.CREATED_AT"))),1),p("time",null,A(h(C.created_at)),1)]),p("td",null,[p("span",pZe,A(R.$t("admin.APP_MODERATION.RESOLVED.TRUE")),1),p("i",{class:he(`fa fa${C.resolved?"-check":""}-square-o`),"aria-hidden":"true"},null,2)]),p("td",null,[p("span",mZe,A(Fe(R.$t("common.LAST_UPDATED_ON"))),1),C.updated_at?(f(),v("time",TZe,A(h(C.updated_at)),1)):D("",!0)]),p("td",null,[p("button",{onClick:k=>R.$router.push(`/admin/reports/${C.id}`)},A(R.$t("admin.APP_MODERATION.VIEW_REPORT")),9,_Ze)])]))),128))])]),d.value.page?(f(),B(da,{key:0,path:"/admin/users",pagination:d.value,query:T(u)},null,8,["pagination","query"])):D("",!0),T(s)?(f(),B(N,{key:1,message:T(s)},null,8,["message"])):D("",!0),p("button",{onClick:g[1]||(g[1]=Ne(C=>R.$router.push("/admin"),["prevent"]))},A(R.$t("admin.BACK_TO_ADMIN")),1)]))]),_:1})])}}}),hZe=se(fZe,[["__scopeId","data-v-88c1fd48"]]),SZe={id:"admin-sports",class:"admin-card"},AZe={class:"responsive-table"},OZe={class:"text-left"},IZe={class:"text-left sport-action"},gZe={class:"text-center"},RZe={class:"cell-heading"},NZe={class:"sport-label"},vZe={class:"cell-heading"},bZe={class:"text-center"},CZe={class:"cell-heading"},PZe={class:"sport-action"},DZe={class:"cell-heading"},LZe={class:"action-button"},yZe=["onClick"],$Ze={key:0,class:"has-workouts"},UZe=Q({__name:"AdminSports",setup(e){const t=$e(),{errorMessages:n}=He(),{translatedSports:a}=ln();function s(o,i){t.dispatch(Zt.ACTIONS.UPDATE_SPORTS,{id:o,isActive:i})}return tt(()=>t.dispatch(Zt.ACTIONS.GET_SPORTS,!0)),(o,i)=>{const r=q("SportImage"),u=q("ErrorMessage"),l=q("Card");return f(),v("div",SZe,[w(l,null,{title:X(()=>[x(A(o.$t("admin.SPORTS.TITLE")),1)]),content:X(()=>[p("button",{class:"top-button",onClick:i[0]||(i[0]=Ne(d=>o.$router.push("/admin"),["prevent"]))},A(o.$t("admin.BACK_TO_ADMIN")),1),p("div",AZe,[p("table",null,[p("thead",null,[p("tr",null,[i[2]||(i[2]=p("th",null,"#",-1)),p("th",null,A(o.$t("admin.SPORTS.TABLE.IMAGE")),1),p("th",OZe,A(o.$t("admin.SPORTS.TABLE.LABEL")),1),p("th",null,A(o.$t("admin.SPORTS.TABLE.ACTIVE")),1),p("th",IZe,A(o.$t("admin.ACTION")),1)])]),p("tbody",null,[(f(!0),v(le,null,be(T(a),d=>(f(),v("tr",{key:d.id},[p("td",gZe,[i[3]||(i[3]=p("span",{class:"cell-heading"},"id",-1)),x(" "+A(d.id),1)]),p("td",null,[p("span",RZe,A(o.$t("admin.SPORTS.TABLE.IMAGE")),1),w(r,{title:d.translatedLabel,"sport-label":d.label,color:d.color},null,8,["title","sport-label","color"])]),p("td",NZe,[p("span",vZe,A(o.$t("admin.SPORTS.TABLE.LABEL")),1),x(" "+A(d.translatedLabel),1)]),p("td",bZe,[p("span",CZe,A(o.$t("admin.SPORTS.TABLE.ACTIVE")),1),p("i",{class:he(`fa fa${d.is_active?"-check":""}`),"aria-hidden":"true"},null,2)]),p("td",PZe,[p("span",DZe,A(o.$t("admin.ACTION")),1),p("div",LZe,[p("button",{class:he({danger:d.is_active}),onClick:E=>s(d.id,!d.is_active)},A(o.$t(`buttons.${d.is_active?"DIS":"EN"}ABLE`)),11,yZe),d.has_workouts?(f(),v("span",$Ze,[i[4]||(i[4]=p("i",{class:"fa fa-warning","aria-hidden":"true"},null,-1)),x(" "+A(o.$t("admin.SPORTS.TABLE.HAS_WORKOUTS")),1)])):D("",!0)])])]))),128))])]),T(n)?(f(),B(u,{key:0,message:T(n)},null,8,["message"])):D("",!0),p("button",{onClick:i[1]||(i[1]=Ne(d=>o.$router.push("/admin"),["prevent"]))},A(o.$t("admin.BACK_TO_ADMIN")),1)])]),_:1})])}}}),kZe=se(UZe,[["__scopeId","data-v-f6f9d907"]]),wZe={class:"users-filters"},MZe={class:"search-username"},WZe=["placeholder"],FZe=Q({__name:"UsersNameFilter",emits:["filterOnUsername"],setup(e,{emit:t}){const n=t,a=it(),s=Se(a.query.q?a.query.q:"");function o(){s.value!==""&&n("filterOnUsername",s)}function i(){s.value="",n("filterOnUsername",s.value)}return(r,u)=>(f(),v("div",wZe,[p("div",MZe,[Me(p("input",{id:"username",name:"username","onUpdate:modelValue":u[0]||(u[0]=l=>s.value=l),onKeyup:je(o,["enter"]),placeholder:r.$t("user.FILTER_ON_USERNAME")},null,40,WZe),[[st,s.value,void 0,{trim:!0}]]),s.value!==""?(f(),v("i",{key:0,class:"fa fa-times","aria-hidden":"true",onClick:i})):D("",!0)]),p("i",{class:he(["fa fa-search",{"fa-disabled":s.value===""}]),"aria-hidden":"true",onClick:o},null,2)]))}}),kO=se(FZe,[["__scopeId","data-v-359360da"]]),zZe={id:"admin-users",class:"admin-card"},xZe={key:0,class:"no-users"},BZe={key:1,class:"responsive-table"},GZe={class:"left-text"},VZe={class:"left-text"},HZe={class:"cell-heading"},KZe={class:"cell-heading"},qZe={class:"cell-heading"},jZe={class:"text-center"},YZe={class:"cell-heading"},XZe={class:"text-center"},QZe={class:"cell-heading"},ZZe={class:"text-center"},JZe={class:"cell-heading"},eJe={class:"text-center"},tJe={class:"cell-heading"},nJe={class:"text-center"},aJe={class:"cell-heading"},sJe={class:"roles"},oJe={key:0,class:"roles-buttons"},iJe=["onClick"],rJe={key:1},uJe=["disabled","onClick"],hh="created_at",lJe=Q({__name:"AdminUsers",setup(e){const t=$e(),n=it(),a=yn(),{errorMessages:s}=He(),{authUser:o}=Ke(),i=["is_active","role","created_at","username","workouts_count"];let r=kt(Do(n.query,i,hh));const u=F(()=>t.getters[me.GETTERS.USERS]),l=F(()=>t.getters[me.GETTERS.USERS_PAGINATION]),d=F(()=>t.getters[me.GETTERS.USERS_IS_SUCCESS]),E=Se("");function c(g){t.dispatch(me.ACTIONS.GET_USERS_FOR_ADMIN,g)}function m(g){S("q",g.value)}function _(g){return E.value===g}function h(g){switch(g){case"admin":return["moderator","user"];case"moderator":return["admin","user"];case"user":return["admin","moderator"];default:return[]}}function O(g,I){t.dispatch(me.ACTIONS.UPDATE_USER,{username:g,role:I})}function S(g,I){r[g]=I,g==="per_page"&&(r.page=1),a.push({path:"/admin/users",query:r})}function R(g){return g.username===o.value.username||g.suspended_at!==null||g.role==="owner"}return Le(()=>n.query,g=>{r=Do(g,i,hh,{query:r}),c(r)}),Le(()=>d.value,g=>{g&&(E.value="")}),tt(()=>c(r)),Et(()=>{t.dispatch(me.ACTIONS.EMPTY_USERS),t.commit(me.MUTATIONS.UPDATE_IS_SUCCESS,!1)}),(g,I)=>{const N=q("router-link"),b=q("ErrorMessage"),C=q("Card");return f(),v("div",zZe,[w(C,null,{title:X(()=>[x(A(Fe(g.$t("user.USER",0))),1)]),content:X(()=>[p("button",{class:"top-button",onClick:I[0]||(I[0]=Ne(k=>g.$router.push("/admin"),["prevent"]))},A(g.$t("admin.BACK_TO_ADMIN")),1),w(kO,{onFilterOnUsername:m}),w(qp,{sort:T(wl),order_by:i,query:T(r),message:"admin.USERS.SELECTS.ORDER_BY",onUpdateSelect:S},null,8,["sort","query"]),u.value.length===0?(f(),v("div",xZe,A(g.$t("user.NO_USERS_FOUND")),1)):(f(),v("div",BZe,[p("table",null,[p("thead",null,[p("tr",null,[I[3]||(I[3]=p("th",null,"#",-1)),p("th",GZe,A(g.$t("user.USERNAME")),1),p("th",VZe,A(g.$t("user.PROFILE.REGISTRATION_DATE")),1),p("th",null,A(Fe(g.$t("workouts.WORKOUT",0))),1),p("th",null,A(g.$t("admin.ACTIVE")),1),p("th",null,A(g.$t("user.ROLE")),1),p("th",null,A(g.$t("user.SUSPENDED")),1),p("th",null,A(g.$t("admin.ACTION")),1)])]),p("tbody",null,[(f(!0),v(le,null,be(u.value,k=>(f(),v("tr",{key:k.username},[p("td",null,[p("span",HZe,A(g.$t("user.PROFILE.PICTURE")),1),w(Jt,{user:k},null,8,["user"])]),p("td",null,[p("span",KZe,A(g.$t("user.USERNAME")),1),w(N,{to:`/admin/users/${k.username}`},{default:X(()=>[x(A(k.username),1)]),_:2},1032,["to"]),T(s)&&E.value===k.username?(f(),B(b,{key:0,message:T(s)},null,8,["message"])):D("",!0)]),p("td",null,[p("span",qZe,A(g.$t("user.PROFILE.REGISTRATION_DATE")),1),p("time",null,A(T($t)(k.created_at,T(o).timezone,T(o).date_format)),1)]),p("td",jZe,[p("span",YZe,A(Fe(g.$t("workouts.WORKOUT",0))),1),x(" "+A(k.nb_workouts),1)]),p("td",XZe,[p("span",QZe,A(g.$t("admin.ACTIVE")),1),p("i",{class:he(`fa fa${k.is_active?"-check":""}-square-o`),"aria-hidden":"true"},null,2)]),p("td",ZZe,[p("span",JZe,A(g.$t("user.ROLE")),1),x(" "+A(g.$t(`user.ROLES.${k.role}`)),1)]),p("td",eJe,[p("span",tJe,A(g.$t("user.SUSPENDED")),1),p("i",{class:he(`fa fa${k.suspended_at!==null?"-check":""}-square-o`),"aria-hidden":"true"},null,2)]),p("td",nJe,[p("span",aJe,A(g.$t("admin.ACTION")),1),p("div",sJe,[_(k.username)?(f(),v("div",oJe,[(f(!0),v(le,null,be(h(k.role),P=>(f(),v("button",{class:he({danger:k.role==="admin"||P==="user"}),key:P,onClick:$=>O(k.username,P)},A(g.$t(`admin.USERS.TABLE.CHANGE_TO_${P.toUpperCase()}`)),11,iJe))),128)),p("button",{onClick:I[1]||(I[1]=P=>E.value="")},A(g.$t("buttons.CANCEL")),1)])):(f(),v("div",rJe,[p("button",{disabled:R(k),onClick:P=>E.value=k.username},A(g.$t("admin.USERS.TABLE.CHANGE_ROLE")),9,uJe)]))])])]))),128))])]),l.value.page?(f(),B(da,{key:0,path:"/admin/users",pagination:l.value,query:T(r)},null,8,["pagination","query"])):D("",!0),E.value===""&&T(s)?(f(),B(b,{key:1,message:T(s)},null,8,["message"])):D("",!0),p("button",{onClick:I[2]||(I[2]=Ne(k=>g.$router.push("/admin"),["prevent"]))},A(g.$t("admin.BACK_TO_ADMIN")),1)]))]),_:1})])}}}),cJe=se(lJe,[["__scopeId","data-v-4934ef0c"]]),dJe={class:"box"},EJe={class:"user-header"},pJe={key:0,class:"follows-you"},mJe={key:1,class:"follows-you"},TJe={class:"user-details"},_Je={class:"user-name"},fJe={key:2,class:"user-role"},hJe=Q({__name:"UserHeader",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),a=it(),{displayOptions:s}=He(),{authUser:o,authUserHasModeratorRights:i}=Ke(),r=F(()=>n.value.suspended_at?$t(n.value.suspended_at,s.value.timezone,s.value.dateFormat):""),u=F(()=>n.value.suspended_at!==null&&a.name!=="AuthUserAccountSuspension"&&n.value.username===(o==null?void 0:o.value.username)),l=F(()=>i.value&&n.value.suspension_report_id!==void 0),d=F(()=>n.value.role!=="user"?`user.ROLES.${n.value.role}`:"");return(E,c)=>{const m=q("router-link"),_=q("i18n-t"),h=q("AlertMessage");return f(),v("div",dJe,[p("div",EJe,[T(n).follows==="true"?(f(),v("div",pJe,A(E.$t("user.RELATIONSHIPS.FOLLOWS_YOU")),1)):T(n).username===T(o).username&&!E.$route.path.startsWith("/profile")?(f(),v("div",mJe,A(E.$t("user.YOU")),1)):D("",!0),w(Jt,{user:T(n)},null,8,["user"]),p("div",TJe,[p("div",_Je,A(T(n).username),1),w(Hp,{user:T(n)},null,8,["user"])]),d.value?(f(),v("div",fJe,A(E.$t(d.value)),1)):D("",!0)]),"suspended_at"in T(n)&&T(n).suspended_at!==null?(f(),B(h,{key:0,message:"user.ACCOUNT_SUSPENDED_AT",param:r.value},So({_:2},[u.value||l.value?{name:"additionalMessage",fn:X(()=>[u.value?(f(),B(m,{key:0,to:"/profile/suspension",class:"appeal-link"},{default:X(()=>[x(A(E.$t("user.APPEAL")),1)]),_:1})):D("",!0),l.value?(f(),B(_,{key:1,keypath:"common.SEE_REPORT"},{default:X(()=>[w(m,{to:`/admin/reports/${T(n).suspension_report_id}`},{default:X(()=>[x(A(T(n).suspension_report_id),1)]),_:1},8,["to"])]),_:1})):D("",!0)]),key:"0"}:void 0]),1032,["param"])):D("",!0)])}}}),wO=se(hJe,[["__scopeId","data-v-12bd7069"]]),SJe={class:"profile-tabs"},AJe={class:"profile-tabs-links"},_E=Q({__name:"UserProfileTabs",props:{tabs:{},selectedTab:{},edition:{type:Boolean}},setup(e){const t=e,{edition:n,selectedTab:a,tabs:s}=_e(t);Tt(()=>{const i=document.getElementById(`tab-${s.value[0]}`);i&&i.focus()});function o(i){switch(i){case"ACCOUNT":case"PICTURE":case"PRIVACY-POLICY":return`/profile/edit/${i.toLocaleLowerCase()}`;case"APPS":case"BLOCKED-USERS":case"EQUIPMENTS":case"FOLLOW-REQUESTS":case"MODERATION":case"PREFERENCES":case"SPORTS":return`/profile${n.value?"/edit":""}/${i.toLocaleLowerCase()}`;default:case"PROFILE":return`/profile${n.value?"/edit":""}`}}return(i,r)=>{const u=q("router-link");return f(),v("div",SJe,[p("div",AJe,[(f(!0),v(le,null,be(T(s),l=>(f(),B(u,{class:he(["profile-tab",{selected:l===T(a)}]),to:o(l),key:l},{default:X(()=>[x(A(i.$t(`user.PROFILE.TABS.${l}`)),1)]),_:2},1032,["class","to"]))),128))])])}}}),OJe={id:"user-profile"},IJe={class:"box"},gJe=Q({__name:"index",props:{user:{},tab:{}},setup(e){const t=e,{user:n,tab:a}=_e(t),s=$e(),o=F(()=>s.getters[K.GETTERS.IS_SUSPENDED]),i=F(()=>o.value?["PROFILE","PREFERENCES","SPORTS","EQUIPMENTS","APPS","MODERATION"]:["PROFILE","PREFERENCES","SPORTS","EQUIPMENTS","APPS"]),r=F(()=>o.value?[]:["FOLLOW-REQUESTS","BLOCKED-USERS","MODERATION"]);return(u,l)=>{const d=q("router-view");return f(),v("div",OJe,[w(wO,{user:T(n)},null,8,["user"]),p("div",IJe,[w(_E,{tabs:i.value,selectedTab:T(a),edition:!1},null,8,["tabs","selectedTab"]),w(_E,{tabs:r.value,selectedTab:T(a),edition:!1},null,8,["tabs","selectedTab"]),w(d,{user:T(n)},null,8,["user"])])])}}}),RJe=se(gJe,[["__scopeId","data-v-4b475df4"]]),NJe={id:"user-moderation-detail"},vJe={id:"user-reports",class:"description-list"},bJe={key:0,id:"user-sanctions"},CJe={key:0},PJe={class:"last-sanctions"},DJe=Q({__name:"UserAdminReports",props:{authUser:{},user:{}},setup(e){const t=e,{authUser:n,user:a}=_e(t),s=it(),o=$e();let i=kt(d(s.query));const r=F(()=>o.getters[me.GETTERS.USER_SANCTIONS]),u=F(()=>o.getters[me.GETTERS.USER_SANCTIONS_LOADING]),l=F(()=>o.getters[me.GETTERS.USER_SANCTIONS_PAGINATION]);function d(m){const _={};return m.page&&(_.page=Hi(m.page,Ml)),_}function E(m){if(m.updated_at)switch(m.approved){case!0:return"APPROVED";case!1:return"REJECTED";default:return"IN_PROGRESS"}return"IN_PROGRESS"}function c(m){o.dispatch(me.ACTIONS.GET_USER_SANCTIONS,{username:a.value.username,...m})}return Le(()=>s.query,async m=>{i=d(m),c(i)}),tt(()=>c({})),Et(()=>o.commit(me.MUTATIONS.UPDATE_USER_SANCTIONS,[])),(m,_)=>{const h=q("router-link");return f(),v("div",NJe,[p("div",vJe,[p("dl",null,[p("dt",null,A(m.$t("user.PROFILE.CREATED_REPORTS"))+":",1),p("dd",null,A(T(a).created_reports_count),1),p("dt",null,A(m.$t("user.PROFILE.REPORTS_FROM_OTHER_USERS"))+":",1),p("dd",null,A(T(a).reported_count),1)])]),T(a).sanctions_count?(f(),v("div",bJe,[p("strong",null,A(m.$t("user.PROFILE.LATEST_SANCTIONS_RECEIVED"))+":",1),p("div",null,A(m.$t("user.PROFILE.USER_RECEIVED_SANCTIONS",{count:T(a).sanctions_count})),1),u.value?(f(),v("div",CJe,[w(kl)])):(f(),v(le,{key:1},[p("ul",PJe,[(f(!0),v(le,null,be(r.value,O=>(f(),v("li",{key:O.id},[p("div",null,[x(A(m.$t(`user.PROFILE.SANCTIONS.${O.action_type}`,{date:T($t)(O.created_at,T(n).timezone,T(n).date_format)}))+" ",1),O.appeal?(f(),v("span",{key:0,class:he(["info-box appeal",{approved:E(O.appeal)==="APPROVED",rejected:E(O.appeal)==="REJECTED"}])},[p("i",{class:he(["fa",{"fa-info-circle":E(O.appeal)!=="REJECTED","fa-times":E(O.appeal)==="REJECTED"}]),"aria-hidden":"true"},null,2),x(" "+A(m.$t(`user.APPEAL_${E(O.appeal)}`)),1)],2)):D("",!0)]),w(h,{to:`/admin/reports/${O.report_id}`},{default:X(()=>[x(A(m.$t("admin.APP_MODERATION.VIEW_REPORT"))+" #"+A(O.report_id),1)]),_:2},1032,["to"])]))),128))]),w(da,{pagination:l.value,path:`/admin/users/${T(a).username}`,query:T(i)},null,8,["pagination","path","query"])],64))])):D("",!0)])}}}),LJe=se(DJe,[["__scopeId","data-v-bfe7e915"]]),yJe={id:"user-infos",class:"description-list"},$Je={key:1,class:"info-box success-message"},UJe={key:4,class:"email-form form-box"},kJe={class:"form-items",for:"email"},wJe={class:"form-items",for:"email"},MJe={class:"form-buttons"},WJe={class:"confirm",type:"submit"},FJe={key:5},zJe={key:0},xJe={key:1},BJe={key:2},GJe={key:3},VJe={key:4},HJe={key:5},KJe={key:6},qJe={key:7},jJe={key:8},YJe={key:9},XJe={key:10},QJe={key:11,class:"user-bio"},ZJe={key:0,class:"report-submitted"},JJe={class:"info-box"},eet={key:0},tet={key:0,class:"profile-buttons"},net={key:1,class:"profile-buttons"},aet=Q({__name:"UserInfos",props:{user:{},authUser:{},fromAdmin:{type:Boolean,default:!1}},setup(e){const t=e,{authUser:n,user:a,fromAdmin:s}=_e(t),o=$e(),{appConfig:i,appLanguage:r,displayOptions:u,errorMessages:l}=He(),{authUserHasModeratorRights:d,authUserHasAdminRights:E,isAuthenticated:c}=Ke(),m=Se(""),_=Se(!1),h=Se(!1),O=Se(""),S=Se(""),R=F(()=>o.getters[me.GETTERS.USER_CURRENT_REPORTING]),g=F(()=>o.getters[ye.GETTERS.REPORT_STATUS]),I=F(()=>a.value.created_at?$t(a.value.created_at,u.value.timezone,u.value.dateFormat):""),N=F(()=>a.value.birth_date?gn(new Date(a.value.birth_date),`${Ss(u.value.dateFormat,r.value)}`,{locale:Gs[r.value]}):""),b=F(()=>o.getters[me.GETTERS.USERS_IS_SUCCESS]);function C(ce){m.value=ce,ce!==""&&o.commit(me.MUTATIONS.UPDATE_IS_SUCCESS,!1)}function k(ce){o.dispatch(me.ACTIONS.DELETE_USER_ACCOUNT,{username:ce})}function P(ce){S.value="password-reset",o.dispatch(me.ACTIONS.UPDATE_USER,{username:ce,resetPassword:!0})}function $(ce){o.dispatch(me.ACTIONS.UPDATE_USER,{username:ce,activate:!0})}function y(){Ae(),O.value=a.value.email_to_confirm?a.value.email_to_confirm:"",h.value=!0,S.value="email-update"}function z(){O.value="",h.value=!1}function Z(ce){o.dispatch(me.ACTIONS.UPDATE_USER,{username:ce,new_email:O.value})}function Ae(){o.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),o.commit(me.MUTATIONS.UPDATE_IS_SUCCESS,!1),o.commit(me.MUTATIONS.UPDATE_USER_CURRENT_REPORTING,!1),o.commit(ye.MUTATIONS.SET_REPORT_STATUS,null),S.value=""}function J(){o.commit(me.MUTATIONS.UPDATE_USER_CURRENT_REPORTING,!0)}return Le(()=>b.value,ce=>{ce&&(C(""),z())}),Et(()=>Ae()),(ce,Te)=>{var ot,re,Oe,pt,wt,It,de;const De=q("Modal"),Ve=q("AlertMessage"),xe=q("ErrorMessage");return f(),v("div",yJe,[m.value?(f(),B(De,{key:0,title:ce.$t("common.CONFIRMATION"),message:m.value==="delete"?"admin.CONFIRM_USER_ACCOUNT_DELETION":"admin.CONFIRM_USER_PASSWORD_RESET",strongMessage:T(a).username,onConfirmAction:Te[0]||(Te[0]=H=>m.value==="delete"?k(T(a).username):P(T(a).username)),onCancelAction:Te[1]||(Te[1]=H=>C("")),onKeydown:Te[2]||(Te[2]=je(H=>C(""),["esc"]))},null,8,["title","message","strongMessage"])):D("",!0),b.value?(f(),v("div",$Je,A(ce.$t(`admin.${S.value==="password-reset"?"PASSWORD_RESET":"USER_EMAIL_UPDATE"}_SUCCESSFUL`)),1)):D("",!0),T(d)&&!T(a).is_active?(f(),B(Ve,{key:2,message:"user.THIS_USER_ACCOUNT_IS_INACTIVE"})):D("",!0),T(l)&&!R.value?(f(),B(xe,{key:3,message:T(l)},null,8,["message"])):D("",!0),h.value?(f(),v("div",UJe,[p("form",{class:he({errors:_.value}),onSubmit:Te[5]||(Te[5]=Ne(H=>Z(T(a).username),["prevent"]))},[p("label",kJe,[x(A(ce.$t("admin.CURRENT_EMAIL"))+" ",1),Me(p("input",{id:"email",type:"email","onUpdate:modelValue":Te[3]||(Te[3]=H=>T(a).email=H),disabled:""},null,512),[[st,T(a).email]])]),p("label",wJe,[x(A(ce.$t("admin.NEW_EMAIL"))+"* ",1),Me(p("input",{id:"new-email",type:"email",required:"","onUpdate:modelValue":Te[4]||(Te[4]=H=>O.value=H)},null,512),[[st,O.value]])]),p("div",MJe,[p("button",WJe,A(ce.$t("buttons.SUBMIT")),1),p("button",{class:"cancel",onClick:Ne(z,["prevent"])},A(ce.$t("buttons.CANCEL")),1)])],34)])):(f(),v("div",FJe,[p("dl",null,[p("dt",null,A(ce.$t("user.PROFILE.REGISTRATION_DATE"))+":",1),p("dd",null,[p("time",null,A(I.value),1)]),T(c)?(f(),v(le,{key:0},[T(s)?(f(),v("dt",zJe,A(ce.$t("user.EMAIL"))+":",1)):D("",!0),T(s)?(f(),v("dd",xJe,A(T(a).email),1)):D("",!0),T(a).first_name?(f(),v("dt",BJe,A(ce.$t("user.PROFILE.FIRST_NAME"))+":",1)):D("",!0),T(a).first_name?(f(),v("dd",GJe,A(T(a).first_name),1)):D("",!0),T(a).last_name?(f(),v("dt",VJe,A(ce.$t("user.PROFILE.LAST_NAME"))+":",1)):D("",!0),T(a).last_name?(f(),v("dd",HJe,A(T(a).last_name),1)):D("",!0),N.value?(f(),v("dt",KJe,A(ce.$t("user.PROFILE.BIRTH_DATE"))+":",1)):D("",!0),N.value?(f(),v("dd",qJe,[p("time",null,A(N.value),1)])):D("",!0),T(a).location?(f(),v("dt",jJe,A(ce.$t("user.PROFILE.LOCATION"))+":",1)):D("",!0),T(a).location?(f(),v("dd",YJe,A(T(a).location),1)):D("",!0),T(a).bio?(f(),v("dt",XJe,A(ce.$t("user.PROFILE.BIO"))+":",1)):D("",!0),T(a).bio?(f(),v("dd",QJe,A(T(a).bio),1)):D("",!0)],64)):D("",!0)]),g.value===`user-${T(a).username}-created`?(f(),v("div",ZJe,[p("div",JJe,[p("span",null,[Te[12]||(Te[12]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(ce.$t("common.REPORT_SUBMITTED")),1)])])])):D("",!0),R.value?(f(),B(Fp,{key:1,"object-id":T(a).username,"object-type":"user"},null,8,["object-id"])):(f(),v(le,{key:2},[T(n)&&T(d)&&T(s)?(f(),v("div",eet,[w(LJe,{authUser:T(n),user:T(a)},null,8,["authUser","user"])])):D("",!0),T(c)?(f(),v(le,{key:1},[T(s)?(f(),v("div",tet,[T(a).role!=="owner"&&T(E)?(f(),v(le,{key:0},[((ot=T(n))==null?void 0:ot.username)!==T(a).username?(f(),v("button",{key:0,class:"danger",onClick:Te[6]||(Te[6]=Ne(H=>C("delete"),["prevent"]))},A(ce.$t("admin.DELETE_USER")),1)):D("",!0),T(a).is_active?D("",!0):(f(),v("button",{key:1,onClick:Te[7]||(Te[7]=Ne(H=>$(T(a).username),["prevent"]))},A(ce.$t("admin.ACTIVATE_USER_ACCOUNT")),1)),((re=T(n))==null?void 0:re.username)!==T(a).username?(f(),v("button",{key:2,onClick:Ne(y,["prevent"])},A(ce.$t("admin.UPDATE_USER_EMAIL")),1)):D("",!0),((Oe=T(n))==null?void 0:Oe.username)!==T(a).username&&T(i).is_email_sending_enabled?(f(),v("button",{key:3,onClick:Te[8]||(Te[8]=Ne(H=>C("reset"),["prevent"]))},A(ce.$t("admin.RESET_USER_PASSWORD")),1)):D("",!0),(pt=T(n))!=null&&pt.username?(f(),B(Xu,{key:4,authUser:T(n),user:T(a),from:"userInfos"},null,8,["authUser","user"])):D("",!0)],64)):D("",!0),p("button",{onClick:Te[9]||(Te[9]=H=>ce.$router.go(-1))},A(ce.$t("buttons.BACK")),1)])):(f(),v("div",net,[ce.$route.path==="/profile"||T(a).username===((wt=T(n))==null?void 0:wt.username)?(f(),v("button",{key:0,onClick:Te[10]||(Te[10]=H=>ce.$router.push("/profile/edit"))},A(ce.$t("user.PROFILE.EDIT")),1)):D("",!0),(It=T(n))!=null&&It.username?(f(),B(Xu,{key:1,authUser:T(n),user:T(a),from:"userInfos"},null,8,["authUser","user"])):D("",!0),ce.$route.name==="User"&&T(a).username!==((de=T(n))==null?void 0:de.username)&&T(a).suspended_at===null&&g.value!==`user-${T(a).username}-created`?(f(),v("button",{key:2,onClick:J},A(ce.$t("user.REPORT")),1)):D("",!0),p("button",{onClick:Te[11]||(Te[11]=H=>ce.$router.go(-1))},A(ce.$t("buttons.BACK")),1)]))],64)):D("",!0)],64))]))])}}}),MO=se(aet,[["__scopeId","data-v-7c182b65"]]),set={id:"user-preferences",class:"description-list"},oet={class:"preferences-section"},iet={class:"preferences-section"},ret={class:"preferences-section"},uet={class:"info-box raw-speed-help"},cet={class:"profile-buttons"},det=Q({__name:"UserPreferences",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),{dateFormat:a,timezone:s}=Ke(),o=F(()=>n.value.language&&n.value.language in fo?fo[n.value.language]:fo.en),i=F(()=>n.value.weekm?"MONDAY":"SUNDAY"),r=F(()=>n.value.display_ascent?"DISPLAYED":"HIDDEN"),u=F(()=>n.value.use_dark_mode===!0?"DARK":n.value.use_dark_mode===!1?"LIGHT":"DEFAULT");return(l,d)=>(f(),v("div",set,[p("div",oet,A(l.$t("user.PROFILE.INTERFACE")),1),p("dl",null,[p("dt",null,A(l.$t("user.PROFILE.LANGUAGE"))+":",1),p("dd",null,A(o.value),1),p("dt",null,A(l.$t("user.PROFILE.THEME_MODE.LABEL"))+":",1),p("dd",null,A(l.$t(`user.PROFILE.THEME_MODE.VALUES.${u.value}`)),1),p("dt",null,A(l.$t("user.PROFILE.TIMEZONE"))+":",1),p("dd",null,A(T(s)),1),p("dt",null,A(l.$t("user.PROFILE.DATE_FORMAT"))+":",1),p("dd",null,A(T(a)),1),p("dt",null,A(l.$t("user.PROFILE.FIRST_DAY_OF_WEEK"))+":",1),p("dd",null,A(l.$t(`user.PROFILE.${i.value}`)),1)]),p("div",iet,A(l.$t("user.PROFILE.TABS.ACCOUNT")),1),p("dl",null,[p("dt",null,A(l.$t("user.PROFILE.FOLLOW_REQUESTS_APPROVAL.LABEL"))+":",1),p("dd",null,A(l.$t(`user.PROFILE.FOLLOW_REQUESTS_APPROVAL.${T(n).manually_approves_followers?"MANUALLY":"AUTOMATICALLY"}`)),1),p("dt",null,A(l.$t("user.PROFILE.PROFILE_IN_USERS_DIRECTORY.LABEL"))+":",1),p("dd",null,A(l.$t(`user.PROFILE.PROFILE_IN_USERS_DIRECTORY.${T(n).hide_profile_in_users_directory?"HIDDEN":"DISPLAYED"}`)),1)]),p("div",ret,A(l.$t("workouts.WORKOUT",0)),1),p("dl",null,[p("dt",null,A(l.$t("user.PROFILE.UNITS.LABEL"))+":",1),p("dd",null,A(l.$t(`user.PROFILE.UNITS.${T(n).imperial_units?"IMPERIAL":"METRIC"}`)),1),p("dt",null,A(l.$t("user.PROFILE.ASCENT_DATA"))+":",1),p("dd",null,A(l.$t(`common.${r.value}`)),1),p("dt",null,A(l.$t("user.PROFILE.ELEVATION_CHART_START.LABEL"))+":",1),p("dd",null,A(l.$t(`user.PROFILE.ELEVATION_CHART_START.${T(n).start_elevation_at_zero?"ZERO":"MIN_ALT"}`)),1),p("dt",null,A(l.$t("user.PROFILE.USE_RAW_GPX_SPEED.LABEL"))+":",1),p("dd",null,A(l.$t(`user.PROFILE.USE_RAW_GPX_SPEED.${T(n).use_raw_gpx_speed?"RAW_SPEED":"FILTERED_SPEED"}`)),1),p("div",uet,[p("span",null,[d[2]||(d[2]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(l.$t("user.PROFILE.USE_RAW_GPX_SPEED.HELP")),1)])])]),p("dl",null,[p("dt",null,A(l.$t("visibility_levels.WORKOUTS_VISIBILITY"))+":",1),p("dd",null,A(l.$t(`visibility_levels.LEVELS.${T(n).workouts_visibility}`)),1),p("dt",null,A(l.$t("visibility_levels.MAP_VISIBILITY"))+":",1),p("dd",null,A(l.$t(`visibility_levels.LEVELS.${T(n).map_visibility}`)),1)]),p("div",cet,[p("button",{onClick:d[0]||(d[0]=E=>l.$router.push("/profile/edit/preferences"))},A(l.$t("user.PROFILE.EDIT_PREFERENCES")),1),p("button",{onClick:d[1]||(d[1]=E=>l.$router.push("/"))},A(l.$t("common.HOME")),1)])]))}}),Eet=se(det,[["__scopeId","data-v-e5571cf3"]]),pet={class:"users-list"},met={key:0},Tet={class:"user-name"},_et={key:0,class:"blocked-user"},fet=["onClick"],het={key:1,class:"follow-requests-list-actions"},Aet=["onClick"],Oet=["onClick"],Iet={key:1,class:"no-users-list"},get={class:"profile-buttons"},Ret=Q({__name:"UsersList",props:{itemType:{}},setup(e){const t=e,{itemType:n}=_e(t),a=it(),s=$e(),o={page:1},i=F(()=>s.getters[K.GETTERS[n.value==="follow-requests"?"FOLLOW_REQUESTS":"BLOCKED_USERS"]]),r=F(()=>s.getters[me.GETTERS.USERS_PAGINATION]);function u(c){s.dispatch(K.ACTIONS[n.value==="follow-requests"?"GET_FOLLOW_REQUESTS":"GET_BLOCKED_USERS"],c)}function l(c,m){s.dispatch(K.ACTIONS.UPDATE_FOLLOW_REQUESTS,{username:c,action:m,getFollowRequests:!0})}function d(c,m){const _={username:c,action:"unblock",from:n.value,payload:E(a.query)};s.dispatch(me.ACTIONS.UPDATE_RELATIONSHIP,_)}function E(c){return o.page=c.page?+c.page:1,o}return Le(()=>a.query,c=>{a.path==="/profile/follow-requests"&&s.dispatch(K.ACTIONS.GET_FOLLOW_REQUESTS,E(c)),a.path==="/profile/blocked-users"&&s.dispatch(K.ACTIONS.GET_BLOCKED_USERS,E(c))}),tt(()=>u(E(a.query))),Et(()=>{s.commit(K.MUTATIONS[n.value==="follow-requests"?"UPDATE_FOLLOW_REQUESTS":"UPDATE_BLOCKED_USERS"],[])}),(c,m)=>{const _=q("router-link");return f(),v("div",pet,[i.value.length>0?(f(),v("div",met,[(f(!0),v(le,null,be(i.value,h=>(f(),v("div",{key:h.username,class:"box item"},[w(Jt,{user:h},null,8,["user"]),p("div",Tet,[w(_,{to:`/users/${h.username}?from=users`},{default:X(()=>[x(A(h.username),1)]),_:2},1032,["to"])]),h.blocked?(f(),v("div",_et,[p("button",{onClick:O=>d(h.username)},A(c.$t("buttons.UNBLOCK")),9,fet)])):(f(),v("div",het,[p("button",{onClick:O=>l(h.username,"accept")},[m[1]||(m[1]=p("i",{class:"fa fa-check","aria-hidden":"true"},null,-1)),x(" "+A(c.$t("buttons.ACCEPT")),1)],8,Aet),p("button",{onClick:O=>l(h.username,"reject"),class:"danger"},[m[2]||(m[2]=p("i",{class:"fa fa-times","aria-hidden":"true"},null,-1)),x(" "+A(c.$t("buttons.REJECT")),1)],8,Oet)]))]))),128))])):(f(),v("p",Iet,A(c.$t(T(n)==="follow-requests"?"user.RELATIONSHIPS.NO_FOLLOW_REQUESTS":"user.NO_USERS_FOUND")),1)),r.value.total>0?(f(),B(da,{key:2,path:`/profile/${T(n)}`,pagination:r.value,query:{}},null,8,["path","pagination"])):D("",!0),p("div",get,[p("button",{onClick:m[0]||(m[0]=h=>c.$router.push("/"))},A(c.$t("common.HOME")),1)])])}}}),Sh=se(Ret,[["__scopeId","data-v-b29d1311"]]),Net={id:"user-profile-edition",class:"center-card"},vet=Q({__name:"index",props:{user:{},tab:{}},setup(e){const t=e,{user:n,tab:a}=_e(t),{isAuthUserSuspended:s}=Ke(),o=F(()=>s.value?["PROFILE","ACCOUNT","PICTURE","PREFERENCES","PRIVACY-POLICY"]:["PROFILE","ACCOUNT","PICTURE","PREFERENCES","SPORTS","EQUIPMENTS","PRIVACY-POLICY"]);return(i,r)=>{const u=q("router-view"),l=q("Card");return f(),v("div",Net,[w(l,null,{title:X(()=>[x(A(i.$t(`user.PROFILE.${T(a)}_EDITION`)),1)]),content:X(()=>[w(_E,{tabs:o.value,selectedTab:T(a),edition:!0},null,8,["tabs","selectedTab"]),w(u,{user:T(n)},null,8,["user"])]),_:1})])}}}),Ni=new Map,bet=e=>{const{method:t,url:n,params:a={},data:s={}}=e;return[t,n,JSON.stringify(a),JSON.stringify(s)].join("")},Lo=e=>{const t=bet(e);if(Ni.has(t)){const n=Ni.get(t)||{};n==null||n.abort(),Ni.delete(t)}return t},os=Gt.create({baseURL:nr()});os.interceptors.request.use(e=>{const t=new AbortController;e.signal=t.signal;const n=Lo(e);return Ni.set(n,t),e},e=>Promise.reject(e));os.interceptors.response.use(e=>(Lo(e.config),e),e=>(e.message!=="canceled"&&e.response&&Lo(e.response.config),Promise.reject(e)));const WO=(e,t)=>{e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.delete(`users/${t.username}`).then(n=>{n.status===204?t.fromAdmin?rt.push("/admin/users"):e.dispatch(K.ACTIONS.LOGOUT).then(()=>rt.push("/")):ne(e,null)}).catch(n=>ne(e,n))},Ah=(e,t,n=!1)=>{e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(me.MUTATIONS.UPDATE_USERS_LOADING,!0),n&&(t.with_inactive="true",t.with_hidden="true",t.with_suspended="true"),ve.get("users",{params:t}).then(a=>{a.data.status==="success"?(e.commit(me.MUTATIONS.UPDATE_USERS,a.data.data.users),e.commit(me.MUTATIONS.UPDATE_USERS_PAGINATION,a.data.pagination)):ne(e,null)}).catch(a=>ne(e,a)).finally(()=>e.commit(me.MUTATIONS.UPDATE_USERS_LOADING,!1))},Cet={[me.ACTIONS.EMPTY_USER](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(me.MUTATIONS.UPDATE_USER,{})},[me.ACTIONS.EMPTY_USERS](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(me.MUTATIONS.UPDATE_USERS,[]),e.commit(me.MUTATIONS.UPDATE_USERS_PAGINATION,{})},[me.ACTIONS.EMPTY_RELATIONSHIPS](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(me.MUTATIONS.UPDATE_USER_RELATIONSHIPS,[]),e.commit(me.MUTATIONS.UPDATE_USERS_PAGINATION,{})},[me.ACTIONS.GET_USER](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(me.MUTATIONS.UPDATE_USERS_LOADING,!0),ve.get(`users/${t}`).then(n=>{n.data.status==="success"?e.commit(me.MUTATIONS.UPDATE_USER,n.data.data.users[0]):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(me.MUTATIONS.UPDATE_USERS_LOADING,!1))},[me.ACTIONS.GET_USER_SANCTIONS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(me.MUTATIONS.UPDATE_USER_SANCTIONS_LOADING,!0);const{username:n,...a}=t;ve.get(`users/${n}/sanctions`,{params:a}).then(s=>{s.data.status==="success"?(e.commit(me.MUTATIONS.UPDATE_USER_SANCTIONS,s.data.data.sanctions),e.commit(me.MUTATIONS.UPDATE_USER_SANCTIONS_PAGINATION,s.data.pagination)):ne(e,null)}).catch(s=>ne(e,s)).finally(()=>e.commit(me.MUTATIONS.UPDATE_USER_SANCTIONS_LOADING,!1))},[me.ACTIONS.GET_USERS](e,t){Ah(e,t,!1)},[me.ACTIONS.GET_USERS_FOR_ADMIN](e,t){Ah(e,t,!0)},[me.ACTIONS.UPDATE_USER](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(me.MUTATIONS.UPDATE_IS_SUCCESS,!1);const n={};t.role!==void 0&&(n.role=t.role),t.resetPassword&&(n.reset_password=t.resetPassword),"activate"in t&&t.activate!==void 0&&(n.activate=t.activate),t.new_email!==void 0&&(n.new_email=t.new_email),ve.patch(`users/${t.username}`,n).then(a=>{a.data.status==="success"?(e.commit(me.MUTATIONS.UPDATE_USER_IN_USERS,a.data.data.users[0]),(t.resetPassword||t.new_email||t.role)&&e.commit(me.MUTATIONS.UPDATE_IS_SUCCESS,!0),(t.activate||t.new_email)&&e.commit(me.MUTATIONS.UPDATE_USER,a.data.data.users[0])):ne(e,null)}).catch(a=>ne(e,a)).finally(()=>e.commit(me.MUTATIONS.UPDATE_USERS_LOADING,!1))},[me.ACTIONS.UPDATE_RELATIONSHIP](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(me.MUTATIONS.UPDATE_USERS_LOADING,!0),ve.post(`users/${t.username}/${t.action}`).then(n=>{n.data.status==="success"?ve.get(`users/${t.username}`).then(a=>{if(a.data.status==="success"){if(t.from==="blocked-users"){e.dispatch(K.ACTIONS.GET_BLOCKED_USERS,t.payload);return}e.commit(t.from==="userInfos"?me.MUTATIONS.UPDATE_USER:t.from==="userCard"?me.MUTATIONS.UPDATE_USER_IN_USERS:me.MUTATIONS.UPDATE_USER_IN_RELATIONSHIPS,a.data.data.users[0]),e.dispatch(K.ACTIONS.GET_USER_PROFILE)}else ne(e,null)}):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(me.MUTATIONS.UPDATE_USERS_LOADING,!1))},[me.ACTIONS.GET_RELATIONSHIPS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(me.MUTATIONS.UPDATE_USERS_LOADING,!0),ve.get(`users/${t.username}/${t.relationship}`,{params:{page:t.page}}).then(n=>{n.data.status==="success"?(e.commit(me.MUTATIONS.UPDATE_USER_RELATIONSHIPS,n.data.data[t.relationship]),e.commit(me.MUTATIONS.UPDATE_USERS_PAGINATION,n.data.pagination)):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(me.MUTATIONS.UPDATE_USERS_LOADING,!1))},[me.ACTIONS.DELETE_USER_ACCOUNT](e,t){WO(e,{username:t.username,fromAdmin:!0})}},$r=e=>{localStorage.removeItem("authToken"),e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Wt.MUTATIONS.EMPTY_USER_STATS),e.commit(Wt.MUTATIONS.EMPTY_USER_SPORT_STATS),e.commit(K.MUTATIONS.CLEAR_AUTH_USER_TOKEN),e.commit(K.MUTATIONS.UPDATE_FOLLOW_REQUESTS,[]),e.commit(me.MUTATIONS.UPDATE_USERS,[]),e.commit(ee.MUTATIONS.EMPTY_WORKOUTS),e.commit(ee.MUTATIONS.EMPTY_WORKOUT),rt.push("/login")},Pet={[K.ACTIONS.CHECK_AUTH_USER](e){window.localStorage.authToken&&!e.getters[K.GETTERS.IS_AUTHENTICATED]&&(e.commit(K.MUTATIONS.UPDATE_AUTH_TOKEN,window.localStorage.authToken),e.dispatch(K.ACTIONS.GET_USER_PROFILE,!0)),!window.localStorage.authToken&&e.getters[K.GETTERS.IS_AUTHENTICATED]&&$r(e)},[K.ACTIONS.CONFIRM_ACCOUNT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),os.post("auth/account/confirm",{token:t.token}).then(n=>{if(n.data.status==="success"){const a=n.data.auth_token;window.localStorage.setItem("authToken",a),e.commit(K.MUTATIONS.UPDATE_AUTH_TOKEN,a),e.dispatch(K.ACTIONS.GET_USER_PROFILE).then(()=>rt.push("/"))}else ne(e,null)}).catch(n=>{ne(e,n)})},[K.ACTIONS.CONFIRM_EMAIL](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_IS_SUCCESS,!1),os.post("/auth/email/update",{token:t.token}).then(n=>{n.data.status==="success"?(e.commit(K.MUTATIONS.UPDATE_IS_SUCCESS,!0),t.refreshUser&&e.dispatch(K.ACTIONS.GET_USER_PROFILE).then(()=>rt.push("/profile/edit/account")),rt.push("/profile/edit/account")):ne(e,null)}).catch(n=>{ne(e,n)})},[K.ACTIONS.GET_USER_PROFILE](e,t=!1){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("auth/profile").then(n=>{if(n.data.status==="success"){const a=e.getters[K.GETTERS.IS_PROFILE_NOT_LOADED];e.commit(K.MUTATIONS.UPDATE_AUTH_USER_PROFILE,n.data.data),n.data.data.accepted_privacy_policy||e.dispatch(te.ACTIONS.GET_APPLICATION_PRIVACY_POLICY),e.commit(me.MUTATIONS.UPDATE_USER_IN_USERS,n.data.data),(a||t)&&(n.data.data.language&&e.dispatch(te.ACTIONS.UPDATE_APPLICATION_LANGUAGE,n.data.data.language),e.commit(te.MUTATIONS.UPDATE_DARK_MODE,n.data.data.use_dark_mode)),e.commit(te.MUTATIONS.UPDATE_DISPLAY_OPTIONS,n.data.data),e.dispatch(Zt.ACTIONS.GET_SPORTS),e.dispatch(Be.ACTIONS.GET_EQUIPMENTS),e.dispatch(Be.ACTIONS.GET_EQUIPMENT_TYPES),n.data.data.suspended_at===null?Wn.dispatch(dt.ACTIONS.GET_UNREAD_STATUS):!rt.currentRoute.value.path.startsWith("/profile")&&!rt.currentRoute.value.meta.allowedToSuspendedUser&&rt.push("/profile")}else ne(e,null),$r(e)}).catch(n=>{n.message!=="canceled"&&(ne(e,n),$r(e))})},[K.ACTIONS.GET_ACCOUNT_SUSPENSION](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),ve.get("auth/account/suspension").then(t=>{t.data.status==="success"?e.commit(K.MUTATIONS.SET_ACCOUNT_SUSPENSION,t.data.user_suspension):ne(e,null)}).catch(t=>{t.message!=="canceled"&&ne(e,t)}).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))},[K.ACTIONS.GET_FOLLOW_REQUESTS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),ve.get("follow-requests",{params:t}).then(n=>{n.data.status==="success"?(e.commit(K.MUTATIONS.UPDATE_FOLLOW_REQUESTS,n.data.data.follow_requests),e.commit(me.MUTATIONS.UPDATE_USERS_PAGINATION,n.data.pagination)):ne(e,null)}).catch(n=>{ne(e,n)}).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))},[K.ACTIONS.LOGIN_OR_REGISTER](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_IS_REGISTRATION_SUCCESS,!1),os.post(`/auth/${t.actionType}`,t.formData).then(n=>{if(n.data.status==="success")if(t.actionType==="login"){const a=n.data.auth_token;window.localStorage.setItem("authToken",a),e.commit(K.MUTATIONS.UPDATE_AUTH_TOKEN,a),e.dispatch(K.ACTIONS.GET_USER_PROFILE,!0).then(()=>rt.push(typeof t.redirectUrl=="string"?t.redirectUrl:"/"))}else rt.push("/login").then(()=>e.commit(K.MUTATIONS.UPDATE_IS_REGISTRATION_SUCCESS,!0));else ne(e,null)}).catch(n=>ne(e,n))},[K.ACTIONS.LOGOUT](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.post("auth/logout").then(t=>{t.data.status==="success"?$r(e):ne(e,null)}).catch(t=>ne(e,t))},[K.ACTIONS.APPEAL](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),e.commit(K.MUTATIONS.UPDATE_IS_SUCCESS,!1);const n=t.actionType==="user_suspension"?"auth/account/suspension/appeal":`auth/account/sanctions/${t.actionId}/appeal`;ve.post(n,{text:t.text}).then(a=>{a.data.status==="success"?e.commit(K.MUTATIONS.UPDATE_IS_SUCCESS,!0):ne(e,null)}).catch(a=>{a.message!=="canceled"&&ne(e,a)}).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))},[K.ACTIONS.UPDATE_FOLLOW_REQUESTS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.post(`follow-requests/${t.username}/${t.action}`).then(n=>{n.data.status==="success"?t.getFollowRequests&&e.dispatch(K.ACTIONS.GET_FOLLOW_REQUESTS).then(()=>e.dispatch(K.ACTIONS.GET_USER_PROFILE)):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))},[K.ACTIONS.UPDATE_USER_PROFILE](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),ve.post("auth/profile/edit",t).then(n=>{n.data.status==="success"?(e.commit(K.MUTATIONS.UPDATE_AUTH_USER_PROFILE,n.data.data),rt.push("/profile")):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))},[K.ACTIONS.UPDATE_USER_ACCOUNT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),e.commit(K.MUTATIONS.UPDATE_IS_SUCCESS,!1),ve.patch("auth/profile/edit/account",t).then(n=>{n.data.status==="success"?(e.commit(K.MUTATIONS.UPDATE_AUTH_USER_PROFILE,n.data.data),e.commit(K.MUTATIONS.UPDATE_IS_SUCCESS,!0)):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))},[K.ACTIONS.UPDATE_USER_PREFERENCES](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),ve.post("auth/profile/edit/preferences",t).then(n=>{n.data.status==="success"?(e.commit(K.MUTATIONS.UPDATE_AUTH_USER_PROFILE,n.data.data),e.commit(te.MUTATIONS.UPDATE_DISPLAY_OPTIONS,n.data.data),e.commit(te.MUTATIONS.UPDATE_DARK_MODE,n.data.data.use_dark_mode),e.dispatch(te.ACTIONS.UPDATE_APPLICATION_LANGUAGE,n.data.data.language).then(()=>rt.push("/profile/preferences"))):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))},[K.ACTIONS.RESET_USER_SPORT_PREFERENCES](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),ve.delete(`auth/profile/reset/sports/${t.sportId}`).then(n=>{n.status===204?(e.dispatch(Zt.ACTIONS.GET_SPORTS),t.fromSport&&rt.push(`/profile/sports/${t.sportId}`)):ne(e,null)}).catch(n=>{ne(e,n),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1)})},[K.ACTIONS.UPDATE_USER_SPORT_PREFERENCES](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0);const{fromSport:n,...a}=t;ve.post("auth/profile/edit/sports",a).then(s=>{s.data.status==="success"?(e.dispatch(Zt.ACTIONS.GET_SPORTS),n&&rt.push(`/profile/sports/${a.sport_id}`)):ne(e,null)}).catch(s=>{ne(e,s),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1)})},[K.ACTIONS.UPDATE_USER_PICTURE](e,t){if(e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),!t.picture)throw new Error("No file part");const n=new FormData;n.append("file",t.picture),ve.post("auth/picture",n,{headers:{"content-type":"multipart/form-data"}}).then(a=>{a.data.status==="success"?e.dispatch(K.ACTIONS.GET_USER_PROFILE).then(()=>rt.push("/profile")):ne(e,null)}).catch(a=>ne(e,a)).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))},[K.ACTIONS.DELETE_ACCOUNT](e,t){WO(e,t)},[K.ACTIONS.DELETE_PICTURE](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),ve.delete("auth/picture").then(t=>{t.status===204?e.dispatch(K.ACTIONS.GET_USER_PROFILE).then(()=>rt.push("/profile")):ne(e,null)}).catch(t=>ne(e,t)).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))},[K.ACTIONS.SEND_PASSWORD_RESET_REQUEST](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),os.post("auth/password/reset-request",t).then(n=>{n.data.status==="success"?rt.push("/password-reset/sent"):ne(e,null)}).catch(n=>ne(e,n))},[K.ACTIONS.RESEND_ACCOUNT_CONFIRMATION_EMAIL](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),os.post("auth/account/resend-confirmation",t).then(n=>{n.data.status==="success"?rt.push("/account-confirmation/email-sent"):ne(e,null)}).catch(n=>ne(e,n))},[K.ACTIONS.RESET_USER_PASSWORD](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),os.post("auth/password/update",t).then(n=>{n.data.status==="success"?rt.push("/password-reset/password-updated"):ne(e,null)}).catch(n=>ne(e,n))},[K.ACTIONS.ACCEPT_PRIVACY_POLICY](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.post("auth/account/privacy-policy",{accepted_policy:t}).then(n=>{n.data.status==="success"?e.dispatch(K.ACTIONS.GET_USER_PROFILE).then(()=>rt.push("/profile")):ne(e,null)}).catch(n=>ne(e,n))},[K.ACTIONS.REQUEST_DATA_EXPORT](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.post("auth/account/export/request").then(t=>{t.data.status==="success"?e.commit(K.MUTATIONS.SET_EXPORT_REQUEST,t.data.request):ne(e,null)}).catch(t=>ne(e,t))},[K.ACTIONS.GET_REQUEST_DATA_EXPORT](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("auth/account/export").then(t=>{t.data.status==="success"?e.commit(K.MUTATIONS.SET_EXPORT_REQUEST,t.data.request):ne(e,null)}).catch(t=>ne(e,t))},[K.ACTIONS.GET_BLOCKED_USERS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),ve.get("auth/blocked-users",{params:t}).then(n=>{n.data.status==="success"?(e.commit(K.MUTATIONS.UPDATE_BLOCKED_USERS,n.data.blocked_users),e.commit(me.MUTATIONS.UPDATE_USERS_PAGINATION,n.data.pagination)):ne(e,null)}).catch(n=>{ne(e,n)}).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))},[K.ACTIONS.GET_USER_SANCTION](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!0),ve.get(`auth/account/sanctions/${t}`).then(n=>{n.data.status==="success"?e.commit(K.MUTATIONS.SET_USER_SANCTION,n.data.sanction):ne(e,null)}).catch(n=>{n.message!=="canceled"&&ne(e,n)}).finally(()=>e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1))}},Det={[K.GETTERS.AUTH_TOKEN]:e=>e.authToken,[K.GETTERS.AUTH_USER_PROFILE]:e=>e.authUserProfile,[K.GETTERS.BLOCKED_USERS]:e=>e.blockedUsers,[K.GETTERS.FOLLOW_REQUESTS]:e=>e.followRequests,[K.GETTERS.EXPORT_REQUEST]:e=>e.exportRequest,[K.GETTERS.IS_AUTHENTICATED]:e=>e.authToken!==null,[K.GETTERS.HAS_ADMIN_RIGHTS]:e=>e.authUserProfile&&["admin","owner"].includes(e.authUserProfile.role),[K.GETTERS.HAS_MODERATOR_RIGHTS]:e=>e.authUserProfile&&["admin","moderator","owner"].includes(e.authUserProfile.role),[K.GETTERS.HAS_OWNER_RIGHTS]:e=>e.authUserProfile&&e.authUserProfile.role==="owner",[K.GETTERS.IS_REGISTRATION_SUCCESS]:e=>e.isRegistrationSuccess,[K.GETTERS.IS_SUCCESS]:e=>e.isSuccess,[K.GETTERS.IS_SUSPENDED]:e=>e.authUserProfile&&e.authUserProfile.suspended_at!==null,[K.GETTERS.IS_PROFILE_LOADED]:e=>{var t;return((t=e.authUserProfile)==null?void 0:t.username)!==void 0},[K.GETTERS.USER_LOADING]:e=>e.loading,[K.GETTERS.IS_PROFILE_NOT_LOADED]:e=>e.authUserProfile.username===void 0,[K.GETTERS.ACCOUNT_SUSPENSION]:e=>e.userReportAction,[K.GETTERS.USER_SANCTION]:e=>e.userReportAction},Let={[K.MUTATIONS.CLEAR_AUTH_USER_TOKEN](e){e.authToken=null,e.authUserProfile={}},[K.MUTATIONS.UPDATE_AUTH_TOKEN](e,t){e.authToken=t},[K.MUTATIONS.UPDATE_AUTH_USER_PROFILE](e,t){e.authUserProfile=t},[K.MUTATIONS.UPDATE_IS_REGISTRATION_SUCCESS](e,t){e.isRegistrationSuccess=t},[K.MUTATIONS.UPDATE_IS_SUCCESS](e,t){e.isSuccess=t},[K.MUTATIONS.UPDATE_FOLLOW_REQUESTS](e,t){e.followRequests=t},[K.MUTATIONS.UPDATE_USER_LOADING](e,t){e.loading=t},[K.MUTATIONS.SET_EXPORT_REQUEST](e,t){e.exportRequest=t},[K.MUTATIONS.UPDATE_BLOCKED_USERS](e,t){e.blockedUsers=t},[K.MUTATIONS.SET_ACCOUNT_SUSPENSION](e,t){e.userReportAction=t},[K.MUTATIONS.SET_USER_SANCTION](e,t){e.userReportAction=t}},yet={authToken:null,authUserProfile:{},isSuccess:!1,isRegistrationSuccess:!1,loading:!1,exportRequest:null,followRequests:[],blockedUsers:[],userReportAction:{}},$et={state:yet,actions:Pet,getters:Det,mutations:Let},Uet={[Be.ACTIONS.ADD_EQUIPMENT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.post("equipments",{description:t.description,equipment_type_id:t.equipmentTypeId,label:t.label,default_for_sport_ids:t.defaultForSportIds}).then(n=>{if(n.data.status==="created"){if(n.data.data.equipments.length>0){const a=n.data.data.equipments[0];e.commit(Be.MUTATIONS.ADD_EQUIPMENT,a),rt.push(`/profile/equipments/${a.id}`)}e.dispatch(Zt.ACTIONS.GET_SPORTS),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1)}else ne(e,null)}).catch(n=>ne(e,n))},[Be.ACTIONS.DELETE_EQUIPMENT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.delete(`equipments/${t.id}${t.force?"?force":""}`).then(()=>{e.commit(Be.MUTATIONS.REMOVE_EQUIPMENT,t.id),e.dispatch(Zt.ACTIONS.GET_SPORTS),rt.push("/profile/equipments")}).catch(n=>ne(e,n))},[Be.ACTIONS.GET_EQUIPMENT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get(`equipments/${t}`).then(n=>{n.data.status==="success"?n.data.data.equipments.length>0&&e.commit(Be.MUTATIONS.UPDATE_EQUIPMENT,n.data.data.equipments[0]):ne(e,null)}).catch(n=>ne(e,n))},[Be.ACTIONS.GET_EQUIPMENTS](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("equipments").then(t=>{t.data.status==="success"?e.commit(Be.MUTATIONS.SET_EQUIPMENTS,t.data.data.equipments):ne(e,null)}).catch(t=>ne(e,t))},[Be.ACTIONS.GET_EQUIPMENT_TYPES](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("equipment-types").then(t=>{t.data.status==="success"?(e.commit(Be.MUTATIONS.SET_EQUIPMENT_TYPES,t.data.data.equipment_types),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1)):ne(e,null)}).catch(t=>ne(e,t))},[Be.ACTIONS.REFRESH_EQUIPMENT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Be.MUTATIONS.SET_LOADING,!0),ve.post(`equipments/${t}/refresh`).then(n=>{n.data.status==="success"?n.data.data.equipments.length>0&&(e.commit(Be.MUTATIONS.UPDATE_EQUIPMENT,n.data.data.equipments[0]),rt.push(`/profile/equipments/${t}`)):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(Be.MUTATIONS.SET_LOADING,!1))},[Be.ACTIONS.UPDATE_EQUIPMENT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Be.MUTATIONS.SET_LOADING,!0),ve.patch(`equipments/${t.id}`,{description:t.description,equipment_type_id:t.equipmentTypeId,is_active:t.isActive,label:t.label,default_for_sport_ids:t.defaultForSportIds}).then(n=>{n.data.status==="success"?n.data.data.equipments.length>0&&(e.commit(Be.MUTATIONS.UPDATE_EQUIPMENT,n.data.data.equipments[0]),e.dispatch(Zt.ACTIONS.GET_SPORTS),rt.push(`/profile/equipments/${t.id}`)):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(Be.MUTATIONS.SET_LOADING,!1))},[Be.ACTIONS.UPDATE_EQUIPMENT_TYPE](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Be.MUTATIONS.SET_LOADING,!0),ve.patch(`equipment-types/${t.id}`,{is_active:t.isActive}).then(n=>{n.data.status==="success"?e.dispatch(Be.ACTIONS.GET_EQUIPMENT_TYPES):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(Be.MUTATIONS.SET_LOADING,!1))}},ket={[Be.GETTERS.EQUIPMENTS]:e=>e.equipments,[Be.GETTERS.EQUIPMENT_TYPES]:e=>e.equipmentTypes,[Be.GETTERS.LOADING]:e=>e.loading},wet={[Be.MUTATIONS.ADD_EQUIPMENT](e,t){e.equipments.push(t)},[Be.MUTATIONS.REMOVE_EQUIPMENT](e,t){e.equipments=e.equipments.filter(n=>n.id!=t)},[Be.MUTATIONS.SET_EQUIPMENTS](e,t){e.equipments=t},[Be.MUTATIONS.SET_EQUIPMENT_TYPES](e,t){e.equipmentTypes=t},[Be.MUTATIONS.SET_LOADING](e,t){e.loading=t},[Be.MUTATIONS.UPDATE_EQUIPMENT](e,t){const n=e.equipments.findIndex(a=>a.id===t.id);n!==-1&&(e.equipments[n]=t)}},Met={equipments:[],equipmentTypes:[],loading:!1},Wet={state:Met,actions:Uet,getters:ket,mutations:wet},Fet={[dt.ACTIONS.GET_UNREAD_STATUS](e){ve.get("notifications/unread").then(t=>{t.data.status==="success"&&e.commit(dt.MUTATIONS.UPDATE_UNREAD_STATUS,t.data.unread)}).catch(t=>ne(e,t))},[dt.ACTIONS.GET_NOTIFICATION_TYPES](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("notifications/types",{params:t}).then(n=>{n.data.status==="success"?e.commit(dt.MUTATIONS.UPDATE_TYPES,n.data.notification_types):ne(e,null)}).catch(n=>{n.message!=="canceled"&&ne(e,n)})},[dt.ACTIONS.GET_NOTIFICATIONS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("notifications",{params:t}).then(n=>{n.data.status==="success"?(e.commit(dt.MUTATIONS.UPDATE_NOTIFICATIONS,n.data.notifications),e.commit(dt.MUTATIONS.UPDATE_PAGINATION,n.data.pagination)):ne(e,null)}).catch(n=>{n.message!=="canceled"&&ne(e,n)})},[dt.ACTIONS.MARK_ALL_AS_READ](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES);const n={};t.type&&(n.type=t.type),ve.post("notifications/mark-all-as-read",n).then(a=>{a.data.status==="success"?(e.dispatch(dt.ACTIONS.GET_NOTIFICATIONS,t),e.dispatch(dt.ACTIONS.GET_UNREAD_STATUS)):ne(e,null)}).catch(a=>{a.message!=="canceled"&&ne(e,a)})},[dt.ACTIONS.UPDATE_STATUS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.patch(`notifications/${t.notificationId}`,{read_status:t.markedAsRead}).then(n=>{n.data.status==="success"?(e.dispatch(dt.ACTIONS.GET_NOTIFICATIONS,t.currentQuery),e.dispatch(dt.ACTIONS.GET_UNREAD_STATUS)):ne(e,null)}).catch(n=>{n.message!=="canceled"&&ne(e,n)})}},zet={[dt.GETTERS.NOTIFICATIONS]:e=>e.notifications,[dt.GETTERS.PAGINATION]:e=>e.pagination,[dt.GETTERS.TYPES]:e=>e.types,[dt.GETTERS.UNREAD_STATUS]:e=>e.unread},xet={[dt.MUTATIONS.UPDATE_NOTIFICATIONS](e,t){e.notifications=t},[dt.MUTATIONS.UPDATE_PAGINATION](e,t){e.pagination=t},[dt.MUTATIONS.UPDATE_TYPES](e,t){e.types=t},[dt.MUTATIONS.UPDATE_UNREAD_STATUS](e,t){e.unread=t},[dt.MUTATIONS.EMPTY_NOTIFICATIONS](e){e.notifications=[],e.pagination={}}},Bet={notifications:[],unread:!1,pagination:{},types:[]},Get={state:Bet,actions:Fet,getters:zet,mutations:xet},Oh=(e,t)=>{e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get(t).then(n=>{n.data.status==="success"?e.commit(nt.MUTATIONS.SET_CLIENT,n.data.data.client):ne(e,null)}).catch(n=>ne(e,n))},Vet={[nt.ACTIONS.AUTHORIZE_CLIENT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES);const n=new FormData;n.set("client_id",t.client_id),n.set("response_type",t.response_type),n.set("scope",t.scope),n.set("confirm","true"),t.state&&n.set("state",t.state),t.code_challenge&&n.set("code_challenge",t.code_challenge),t.code_challenge_method&&n.set("code_challenge_method",t.code_challenge_method),ve.post("oauth/authorize",n,{headers:{"Content-Type":"multipart/form-data"}}).then(a=>{a.status==200&&a.data.redirect_url?window.location.href=a.data.redirect_url:ne(e,null)}).catch(a=>ne(e,a))},[nt.ACTIONS.CREATE_CLIENT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.post("oauth/apps",t).then(n=>{n.data.status==="created"?(e.commit(nt.MUTATIONS.SET_CLIENT,n.data.data.client),rt.push(`/profile/apps/${n.data.data.client.id}/created`)):ne(e,null)}).catch(n=>ne(e,n))},[nt.ACTIONS.DELETE_CLIENT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.delete(`oauth/apps/${t}`).then(n=>{n.status===204?e.dispatch(nt.ACTIONS.GET_CLIENTS).then(()=>rt.push("/profile/apps")):ne(e,null)}).catch(n=>ne(e,n))},[nt.ACTIONS.GET_CLIENT_BY_CLIENT_ID](e,t){Oh(e,`oauth/apps/${t}`)},[nt.ACTIONS.GET_CLIENT_BY_ID](e,t){Oh(e,`oauth/apps/${t}/by_id`)},[nt.ACTIONS.GET_CLIENTS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("oauth/apps",{params:t}).then(n=>{n.data.status==="success"?(e.commit(nt.MUTATIONS.SET_CLIENTS,n.data.data.clients),e.commit(nt.MUTATIONS.SET_CLIENTS_PAGINATION,n.data.pagination)):ne(e,null)}).catch(n=>ne(e,n))},[nt.ACTIONS.REVOKE_ALL_TOKENS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(nt.MUTATIONS.SET_REVOCATION_SUCCESSFUL,!1),ve.post(`oauth/apps/${t}/revoke`).then(n=>{n.data.status==="success"?e.commit(nt.MUTATIONS.SET_REVOCATION_SUCCESSFUL,!0):ne(e,null)}).catch(n=>ne(e,n))}},Het={[nt.GETTERS.CLIENT]:e=>e.client,[nt.GETTERS.CLIENTS]:e=>e.clients,[nt.GETTERS.CLIENTS_PAGINATION]:e=>e.pagination,[nt.GETTERS.REVOCATION_SUCCESSFUL]:e=>e.revocationSuccessful},Ket={[nt.MUTATIONS.SET_CLIENT](e,t){e.client=t},[nt.MUTATIONS.EMPTY_CLIENT](e){e.client={}},[nt.MUTATIONS.SET_CLIENTS](e,t){e.clients=t},[nt.MUTATIONS.SET_CLIENTS_PAGINATION](e,t){e.pagination=t},[nt.MUTATIONS.SET_REVOCATION_SUCCESSFUL](e,t){e.revocationSuccessful=t}},qet={client:{},clients:[],pagination:{},revocationSuccessful:!1},jet={state:qet,actions:Vet,getters:Het,mutations:Ket},Yet={[ye.ACTIONS.EMPTY_REPORTS](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ye.MUTATIONS.EMPTY_REPORT),e.commit(ye.MUTATIONS.SET_REPORTS,[]),e.commit(ye.MUTATIONS.SET_REPORTS_PAGINATION,{})},[ye.ACTIONS.GET_REPORT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ye.MUTATIONS[`SET_${t.loader}_LOADING`],!0),ve.get(`reports/${t.reportId}`).then(n=>{n.data.status==="success"?e.commit(ye.MUTATIONS.SET_REPORT,n.data.report):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(ye.MUTATIONS[`SET_${t.loader}_LOADING`],!1))},[ye.ACTIONS.GET_REPORTS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("reports",{params:t}).then(n=>{n.data.status==="success"?(e.commit(ye.MUTATIONS.SET_REPORTS,n.data.reports),e.commit(ye.MUTATIONS.SET_REPORTS_PAGINATION,n.data.pagination)):ne(e,null)}).catch(n=>ne(e,n))},[ye.ACTIONS.PROCESS_APPEAL](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES);const{appealId:n,reportId:a,...s}=t;ve.patch(`appeals/${n}`,s).then(o=>{o.data.status==="success"?e.dispatch(ye.ACTIONS.GET_REPORT,{reportId:a,loader:"REPORT_UPDATE"}):ne(e,null)}).catch(o=>{ne(e,o)})},[ye.ACTIONS.SUBMIT_ADMIN_ACTION](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(me.MUTATIONS.UPDATE_IS_SUCCESS,!1),e.commit(ye.MUTATIONS.SET_REPORT_UPDATE_LOADING,!0);const{report_id:n,...a}=t;ve.post(`reports/${n}/actions`,a).then(s=>{s.data.status==="success"?(e.commit(ye.MUTATIONS.SET_REPORT,s.data.report),e.commit(me.MUTATIONS.UPDATE_IS_SUCCESS,!0)):ne(e,null)}).catch(s=>{ne(e,s)}).finally(()=>e.commit(ye.MUTATIONS.SET_REPORT_UPDATE_LOADING,!1))},[ye.ACTIONS.SUBMIT_REPORT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ye.MUTATIONS.SET_REPORT_STATUS,"loading"),ve.post("reports",t).then(n=>{n.data.status==="created"?(e.commit(ye.MUTATIONS.SET_REPORT_STATUS,`${t.object_type}-${t.object_id}-created`),t.object_type==="comment"&&e.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{}),t.object_type==="workout"&&e.commit(ee.MUTATIONS.SET_CURRENT_REPORTING,!1),t.object_type==="user"&&e.commit(me.MUTATIONS.UPDATE_USER_CURRENT_REPORTING,!1)):(e.commit(ye.MUTATIONS.SET_REPORT_STATUS,null),ne(e,null))}).catch(n=>{ne(e,n),e.commit(ye.MUTATIONS.SET_REPORT_STATUS,null)})},[ye.ACTIONS.UPDATE_REPORT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ye.MUTATIONS.SET_REPORT_UPDATE_LOADING,!0);const{reportId:n,...a}=t;ve.patch(`reports/${n}`,a).then(s=>{s.data.status==="success"?e.commit(ye.MUTATIONS.SET_REPORT,s.data.report):(e.commit(ye.MUTATIONS.SET_REPORT_STATUS,null),ne(e,null))}).catch(s=>{ne(e,s),e.commit(ye.MUTATIONS.SET_REPORT_STATUS,null)}).finally(()=>e.commit(ye.MUTATIONS.SET_REPORT_UPDATE_LOADING,!1))},[ye.ACTIONS.GET_UNRESOLVED_REPORTS_STATUS](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("reports/unresolved").then(t=>{t.data.status==="success"?e.commit(ye.MUTATIONS.SET_UNRESOLVED_REPORTS_STATUS,t.data.unresolved):ne(e,null)}).catch(t=>ne(e,t))}},Xet={[ye.GETTERS.UNRESOLVED_REPORTS_STATUS]:e=>e.unresolved,[ye.GETTERS.REPORT]:e=>e.report,[ye.GETTERS.REPORT_LOADING]:e=>e.reportLoading,[ye.GETTERS.REPORT_STATUS]:e=>e.reportStatus,[ye.GETTERS.REPORT_UPDATE_LOADING]:e=>e.reportUpdateLoading,[ye.GETTERS.REPORTS]:e=>e.reports,[ye.GETTERS.REPORTS_PAGINATION]:e=>e.pagination},Qet={[ye.MUTATIONS.EMPTY_REPORT](e){e.report={}},[ye.MUTATIONS.SET_REPORT](e,t){e.report=t},[ye.MUTATIONS.SET_REPORT_LOADING](e,t){e.reportLoading=t},[ye.MUTATIONS.SET_REPORT_STATUS](e,t){e.reportStatus=t},[ye.MUTATIONS.SET_REPORT_UPDATE_LOADING](e,t){e.reportUpdateLoading=t},[ye.MUTATIONS.SET_REPORTS](e,t){e.reports=t},[ye.MUTATIONS.SET_REPORTS_PAGINATION](e,t){e.pagination=t},[ye.MUTATIONS.SET_UNRESOLVED_REPORTS_STATUS](e,t){e.unresolved=t}},Zet={unresolved:!1,report:{},reports:[],pagination:{},reportStatus:null,reportLoading:!1,reportUpdateLoading:!1},Jet={state:Zet,actions:Yet,getters:Xet,mutations:Qet},{locale:ett}=Mo.global,ttt={[te.ACTIONS.GET_APPLICATION_CONFIG](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(te.MUTATIONS.UPDATE_APPLICATION_LOADING,!0),ve.get("config").then(t=>{t.data.status==="success"?e.commit(te.MUTATIONS.UPDATE_APPLICATION_CONFIG,t.data.data):ne(e,null)}).catch(t=>ne(e,t)).finally(()=>e.commit(te.MUTATIONS.UPDATE_APPLICATION_LOADING,!1))},[te.ACTIONS.GET_APPLICATION_STATS](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("stats/all").then(t=>{t.data.status==="success"?e.commit(te.MUTATIONS.UPDATE_APPLICATION_STATS,t.data.data):ne(e,null)}).catch(t=>ne(e,t))},[te.ACTIONS.GET_APPLICATION_PRIVACY_POLICY](e){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get("config").then(t=>{t.data.status==="success"?e.commit(te.MUTATIONS.UPDATE_APPLICATION_PRIVACY_POLICY,t.data.data):ne(e,null)}).catch(t=>ne(e,t))},[te.ACTIONS.UPDATE_APPLICATION_CONFIG](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.patch("config",t).then(n=>{n.data.status==="success"?(e.commit(te.MUTATIONS.UPDATE_APPLICATION_CONFIG,n.data.data),rt.push("/admin/application")):ne(e,null)}).catch(n=>ne(e,n))},[te.ACTIONS.UPDATE_APPLICATION_LANGUAGE](e,t){var n;(n=document.querySelector("html"))==null||n.setAttribute("lang",t),e.commit(te.MUTATIONS.UPDATE_LANG,t),ett.value=t}},ntt={[te.GETTERS.APP_CONFIG]:e=>e.application.config,[te.GETTERS.APP_LOADING]:e=>e.appLoading,[te.GETTERS.APP_STATS]:e=>e.application.statistics,[te.GETTERS.DARK_MODE]:e=>e.darkMode,[te.GETTERS.ERROR_MESSAGES]:e=>e.errorMessages,[te.GETTERS.LANGUAGE]:e=>e.language,[te.GETTERS.LOCALE]:e=>e.locale,[te.GETTERS.DISPLAY_OPTIONS]:e=>e.application.displayOptions},att={[te.MUTATIONS.EMPTY_ERROR_MESSAGES](e){e.errorMessages=null},[te.MUTATIONS.SET_ERROR_MESSAGES](e,t){e.errorMessages=t},[te.MUTATIONS.UPDATE_APPLICATION_CONFIG](e,t){e.application.config=t},[te.MUTATIONS.UPDATE_APPLICATION_LOADING](e,t){e.appLoading=t},[te.MUTATIONS.UPDATE_APPLICATION_PRIVACY_POLICY](e,t){e.application.config.privacy_policy=t.privacy_policy,e.application.config.privacy_policy_date=t.privacy_policy_date},[te.MUTATIONS.UPDATE_APPLICATION_STATS](e,t){e.application.statistics=t},[te.MUTATIONS.UPDATE_LANG](e,t){t in Gs?(e.language=t,e.locale=Gs[t]):(e.language="en",e.locale=ir)},[te.MUTATIONS.UPDATE_DARK_MODE](e,t){e.darkMode=t},[te.MUTATIONS.UPDATE_DISPLAY_OPTIONS](e,t){e.application.displayOptions={...e.application.displayOptions,dateFormat:t.date_format,displayAscent:t.display_ascent,timezone:t.timezone,useImperialUnits:t.imperial_units}}},stt={root:!0,language:"en",locale:ir,errorMessages:null,application:{statistics:{sports:0,uploads_dir_size:0,users:0,workouts:0},displayOptions:{dateFormat:"MM/dd/yyyy",displayAscent:!0,timezone:Intl.DateTimeFormat().resolvedOptions().timeZone?Intl.DateTimeFormat().resolvedOptions().timeZone:"Europe/Paris",useImperialUnits:!1}},appLoading:!1,darkMode:null},ott={[Zt.ACTIONS.GET_SPORTS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get(`sports${t?"?check_workouts=true":""}`).then(n=>{n.data.status==="success"?(e.commit(Zt.MUTATIONS.SET_SPORTS,n.data.data.sports),e.commit(K.MUTATIONS.UPDATE_USER_LOADING,!1)):ne(e,null)}).catch(n=>ne(e,n))},[Zt.ACTIONS.UPDATE_SPORTS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.patch(`sports/${t.id}`,{is_active:t.isActive}).then(n=>{n.data.status==="success"?e.dispatch(Zt.ACTIONS.GET_SPORTS):ne(e,null)}).catch(n=>ne(e,n))}},itt={[Zt.GETTERS.SPORTS]:e=>e.sports},rtt={[Zt.MUTATIONS.SET_SPORTS](e,t){e.sports=t}},utt={sports:[]},ltt={state:utt,actions:ott,getters:itt,mutations:rtt},ctt={[Wt.ACTIONS.GET_USER_STATS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get(`stats/${t.username}/by_time`,{params:t.params}).then(n=>{n.data.status==="success"?e.commit(Wt.MUTATIONS.UPDATE_USER_STATS,n.data.data.statistics):ne(e,null)}).catch(n=>ne(e,n))},[Wt.ACTIONS.GET_USER_SPORT_STATS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(Wt.MUTATIONS.UPDATE_STATS_LOADING,!0),ve.get(`stats/${t.username}/by_sport`,{params:{sport_id:t.sportId}}).then(n=>{n.data.status==="success"?(e.commit(Wt.MUTATIONS.UPDATE_USER_SPORT_STATS,n.data.data.statistics),e.commit(Wt.MUTATIONS.UPDATE_TOTAL_WORKOUTS,n.data.data.total_workouts)):ne(e,null)}).catch(n=>ne(e,n)).finally(()=>e.commit(Wt.MUTATIONS.UPDATE_STATS_LOADING,!1))}},dtt={[Wt.GETTERS.USER_SPORT_STATS]:e=>e.sportStatistics,[Wt.GETTERS.USER_STATS]:e=>e.statistics,[Wt.GETTERS.STATS_LOADING]:e=>e.loading,[Wt.GETTERS.TOTAL_WORKOUTS]:e=>e.totalWorkouts},Ett={[Wt.MUTATIONS.UPDATE_USER_STATS](e,t){e.statistics=t},[Wt.MUTATIONS.EMPTY_USER_STATS](e){e.statistics={}},[Wt.MUTATIONS.EMPTY_USER_SPORT_STATS](e){e.sportStatistics={},e.totalWorkouts=0},[Wt.MUTATIONS.UPDATE_USER_SPORT_STATS](e,t){e.sportStatistics=t},[Wt.MUTATIONS.UPDATE_STATS_LOADING](e,t){e.loading=t},[Wt.MUTATIONS.UPDATE_TOTAL_WORKOUTS](e,t){e.totalWorkouts=t}},ptt={statistics:{},sportStatistics:{},totalWorkouts:0,loading:!1},mtt={state:ptt,actions:ctt,getters:dtt,mutations:Ett},Ttt={[me.GETTERS.USER]:e=>e.user,[me.GETTERS.USER_CURRENT_REPORTING]:e=>e.currentReporting,[me.GETTERS.USER_RELATIONSHIPS]:e=>e.user_relationships,[me.GETTERS.USER_SANCTIONS]:e=>e.userSanctions.sanctions,[me.GETTERS.USER_SANCTIONS_LOADING]:e=>e.userSanctions.loading,[me.GETTERS.USER_SANCTIONS_PAGINATION]:e=>e.userSanctions.pagination,[me.GETTERS.USERS]:e=>e.users,[me.GETTERS.USERS_IS_SUCCESS]:e=>e.isSuccess,[me.GETTERS.USERS_LOADING]:e=>e.loading,[me.GETTERS.USERS_PAGINATION]:e=>e.pagination},_tt={[me.MUTATIONS.UPDATE_USER](e,t){e.user=t},[me.MUTATIONS.UPDATE_USER_IN_USERS](e,t){e.users=e.users.map(n=>n.username===t.username?t:n)},[me.MUTATIONS.UPDATE_USER_IN_RELATIONSHIPS](e,t){e.user_relationships=e.user_relationships.map(n=>n.username===t.username?t:n)},[me.MUTATIONS.UPDATE_USER_RELATIONSHIPS](e,t){e.user_relationships=t},[me.MUTATIONS.UPDATE_USER_SANCTIONS](e,t){e.userSanctions.sanctions=t},[me.MUTATIONS.UPDATE_USER_SANCTIONS_LOADING](e,t){e.userSanctions.loading=t},[me.MUTATIONS.UPDATE_USER_SANCTIONS_PAGINATION](e,t){e.userSanctions.pagination=t},[me.MUTATIONS.UPDATE_USERS](e,t){e.users=t},[me.MUTATIONS.UPDATE_USERS_LOADING](e,t){e.loading=t},[me.MUTATIONS.UPDATE_USERS_PAGINATION](e,t){e.pagination=t},[me.MUTATIONS.UPDATE_IS_SUCCESS](e,t){e.isSuccess=t},[me.MUTATIONS.UPDATE_USER_CURRENT_REPORTING](e,t){e.currentReporting=t}},ftt={user:{},userSanctions:{sanctions:[],loading:!1,pagination:{}},user_relationships:[],users:[],loading:!1,isSuccess:!1,pagination:{},currentReporting:!1},htt={state:ftt,actions:Cet,getters:Ttt,mutations:_tt},Ur=(e,t,n)=>{e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get(n.match("TIMELINE")?"timeline":"workouts",{params:t}).then(a=>{a.data.status==="success"?(e.commit(ee.MUTATIONS[n],a.data.data.workouts),n===$s.SET_USER_WORKOUTS&&e.commit(ee.MUTATIONS.SET_WORKOUTS_PAGINATION,a.data.pagination)):ne(e,null)}).catch(a=>ne(e,a))},FO=(e,t,n)=>{n?e.dispatch(ee.ACTIONS.GET_WORKOUT_COMMENTS,n):e.dispatch(ee.ACTIONS.GET_WORKOUT_COMMENT,t)},Ih=(e,t,n=!1)=>{ve.post(`comments/${t.id}/like${n?"/undo":""}`).then(a=>{a.data.status==="success"&&FO(e,t.id,t.workout_id)}).catch(a=>{ne(e,a)})},gh=(e,t,n=!1)=>{ve.post(`workouts/${t}/like${n?"/undo":""}`).then(a=>{a.data.status==="success"&&e.commit(ee.MUTATIONS.SET_WORKOUT,a.data.data.workouts[0])}).catch(a=>{ne(e,a)})},Stt={[ee.ACTIONS.GET_CALENDAR_WORKOUTS](e,t){e.commit(ee.MUTATIONS.EMPTY_CALENDAR_WORKOUTS),Ur(e,t,$s.SET_CALENDAR_WORKOUTS)},[ee.ACTIONS.GET_USER_WORKOUTS](e,t){Ur(e,t,$s.SET_USER_WORKOUTS)},[ee.ACTIONS.GET_TIMELINE_WORKOUTS](e,t){Ur(e,t,$s.SET_TIMELINE_WORKOUTS)},[ee.ACTIONS.GET_MORE_TIMELINE_WORKOUTS](e,t){Ur(e,t,$s.ADD_TIMELINE_WORKOUTS)},[ee.ACTIONS.GET_WORKOUT_DATA](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.SET_WORKOUT_LOADING,!0);const n=t.segmentId?`/segment/${t.segmentId}`:"";ve.get(`workouts/${t.workoutId}`).then(a=>{const s=a.data.data.workouts[0];if(a.data.status==="success"){if(t.segmentId&&(s.segments.length===0||!s.segments[+t.segmentId-1]))throw new Error("WORKOUT_NOT_FOUND");e.commit(ee.MUTATIONS.SET_WORKOUT,a.data.data.workouts[0]),a.data.data.workouts[0].with_gpx&&(ve.get(`workouts/${t.workoutId}/chart_data${n}`).then(o=>{o.data.status==="success"&&e.commit(ee.MUTATIONS.SET_WORKOUT_CHART_DATA,o.data.data.chart_data)}),ve.get(`workouts/${t.workoutId}/gpx${n}`).then(o=>{o.data.status==="success"&&e.commit(ee.MUTATIONS.SET_WORKOUT_GPX,o.data.data.gpx)})),t.segmentId||e.dispatch(ee.ACTIONS.GET_WORKOUT_COMMENTS,a.data.data.workouts[0].id)}else e.commit(ee.MUTATIONS.EMPTY_WORKOUT),ne(e,null)}).catch(a=>{e.commit(ee.MUTATIONS.EMPTY_WORKOUT),ne(e,a)}).finally(()=>e.commit(ee.MUTATIONS.SET_WORKOUT_LOADING,!1))},[ee.ACTIONS.DELETE_WORKOUT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.SET_WORKOUT_LOADING,!0),ve.delete(`workouts/${t.workoutId}`).then(()=>{e.commit(ee.MUTATIONS.EMPTY_WORKOUT),e.dispatch(K.ACTIONS.GET_USER_PROFILE),rt.push("/")}).catch(n=>{ne(e,n)}).finally(()=>e.commit(ee.MUTATIONS.SET_WORKOUT_LOADING,!1))},[ee.ACTIONS.EDIT_WORKOUT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.SET_WORKOUT_LOADING,!0),ve.patch(`workouts/${t.workoutId}`,t.data).then(()=>{e.dispatch(K.ACTIONS.GET_USER_PROFILE),e.dispatch(ee.ACTIONS.GET_WORKOUT_DATA,{workoutId:t.workoutId}).then(()=>{rt.push({name:"Workout",params:{workoutId:t.workoutId}})})}).catch(n=>{ne(e,n)}).finally(()=>e.commit(ee.MUTATIONS.SET_WORKOUT_LOADING,!1))},[ee.ACTIONS.EDIT_WORKOUT_CONTENT](e,t){e.commit(ee.MUTATIONS.SET_WORKOUT_CONTENT_LOADING,!0),e.commit(ee.MUTATIONS.SET_WORKOUT_CONTENT_TYPE,t.contentType),e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES);const n={[t.contentType==="NOTES"?"notes":"description"]:t.content};ve.patch(`workouts/${t.workoutId}`,n).then(a=>{const s=a.data.data.workouts[0];e.commit(ee.MUTATIONS.SET_WORKOUT_CONTENT,s)}).catch(a=>{ne(e,a)}).finally(()=>e.commit(ee.MUTATIONS.SET_WORKOUT_CONTENT_LOADING,!1))},[ee.ACTIONS.ADD_WORKOUT](e,t){if(e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.SET_WORKOUT_LOADING,!0),!t.file)throw new Error("No file part");const n=t.notes.replace(/"/g,'\\"'),a=t.description.replace(/"/g,'\\"'),s=t.title.replace(/"/g,'\\"'),o=new FormData;o.append("file",t.file),o.append("data",`{"sport_id": ${t.sport_id}, "notes": "${n}", "description": "${a}", "title": "${s}", "equipment_ids": [${t.equipment_ids.map(i=>`"${i}"`).join(",")}], "workout_visibility": "${t.workout_visibility}", "map_visibility": "${t.map_visibility}"}`),ve.post("workouts",o,{headers:{"content-type":"multipart/form-data"}}).then(i=>{if(i.data.status==="created"){e.dispatch(K.ACTIONS.GET_USER_PROFILE);const r=i.data.data.workouts[0];rt.push(i.data.data.workouts.length===1?`/workouts/${r.id}`:"/")}}).catch(i=>{ne(e,i)}).finally(()=>e.commit(ee.MUTATIONS.SET_WORKOUT_LOADING,!1))},[ee.ACTIONS.ADD_WORKOUT_WITHOUT_GPX](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.SET_WORKOUT_LOADING,!0),ve.post("workouts/no_gpx",t).then(n=>{if(n.data.status==="created"){e.dispatch(K.ACTIONS.GET_USER_PROFILE);const a=n.data.data.workouts[0];rt.push(`/workouts/${a.id}`)}}).catch(n=>{ne(e,n)}).finally(()=>e.commit(ee.MUTATIONS.SET_WORKOUT_LOADING,!1))},[ee.ACTIONS.ADD_COMMENT](e,t){e.commit(ee.MUTATIONS.SET_COMMENT_LOADING,"new");const n={text:t.text,text_visibility:t.text_visibility};e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.post(`/workouts/${t.workout_id}/comments`,n).then(a=>{a.data.status==="created"?(e.dispatch(ee.ACTIONS.GET_WORKOUT_COMMENTS,t.workout_id),e.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{})):ne(e,null)}).catch(a=>{ne(e,a),e.commit(ee.MUTATIONS.SET_COMMENT_LOADING,null)})},[ee.ACTIONS.GET_WORKOUT_COMMENT](e,t){e.commit(ee.MUTATIONS.SET_COMMENT_LOADING,"loading"),e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get(`/comments/${t}`).then(n=>{n.data.status==="success"?(e.commit(ee.MUTATIONS.SET_WORKOUT_COMMENTS,[n.data.comment]),e.commit(ee.MUTATIONS.SET_COMMENT_LOADING,null)):ne(e,null)}).catch(n=>{ne(e,n)}).finally(()=>e.commit(ee.MUTATIONS.SET_COMMENT_LOADING,null))},[ee.ACTIONS.GET_WORKOUT_COMMENTS](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),ve.get(`/workouts/${t}/comments`).then(n=>{n.data.status==="success"?(e.commit(ee.MUTATIONS.SET_WORKOUT_COMMENTS,n.data.data.comments),e.commit(ee.MUTATIONS.SET_COMMENT_LOADING,null)):ne(e,null)}).catch(n=>{ne(e,n.status===500?null:n,"error when getting comments")}).finally(()=>e.commit(ee.MUTATIONS.SET_COMMENT_LOADING,null))},[ee.ACTIONS.DELETE_WORKOUT_COMMENT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.SET_COMMENT_LOADING,"delete"),ve.delete(`comments/${t.commentId}`).then(n=>{n.status===204&&(t.workoutId?e.dispatch(ee.ACTIONS.GET_WORKOUT_COMMENTS,t.workoutId):rt.push("/"))}).catch(n=>{ne(e,n)})},[ee.ACTIONS.EDIT_WORKOUT_COMMENT](e,t){e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.SET_COMMENT_LOADING,t.id),ve.patch(`comments/${t.id}`,{text:t.text}).then(n=>{n.data.status==="success"&&(FO(e,t.id,t.workout_id),e.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{}))}).catch(n=>{ne(e,n),e.commit(ee.MUTATIONS.SET_COMMENT_LOADING,null)})},[ee.ACTIONS.LIKE_COMMENT](e,t){Ih(e,t)},[ee.ACTIONS.UNDO_LIKE_COMMENT](e,t){Ih(e,t,!0)},[ee.ACTIONS.LIKE_WORKOUT](e,t){gh(e,t)},[ee.ACTIONS.UNDO_LIKE_WORKOUT](e,t){gh(e,t,!0)},[ee.ACTIONS.MAKE_APPEAL](e,t){const n=`${t.objectType}_${t.objectId}`;e.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),e.commit(ee.MUTATIONS.SET_APPEAL_LOADING,n),e.commit(ee.MUTATIONS.SET_SUCCESS,null),ve.post(`${t.objectType}s/${t.objectId}/suspension/appeal`,{text:t.text}).then(a=>{a.data.status==="success"&&e.commit(ee.MUTATIONS.SET_SUCCESS,n)}).catch(a=>{ne(e,a)}).finally(()=>e.commit(ee.MUTATIONS.SET_APPEAL_LOADING,null))}},Att={[ee.GETTERS.APPEAL_LOADING]:e=>e.appealLoading,[ee.GETTERS.CALENDAR_WORKOUTS]:e=>e.calendar_workouts,[ee.GETTERS.CURRENT_REPORTING]:e=>e.workoutData.currentReporting,[ee.GETTERS.SUCCESS]:e=>e.success,[ee.GETTERS.TIMELINE_WORKOUTS]:e=>e.timeline_workouts,[ee.GETTERS.USER_WORKOUTS]:e=>e.user_workouts,[ee.GETTERS.WORKOUT_CONTENT_EDITION]:e=>e.workoutContent,[ee.GETTERS.WORKOUT_DATA]:e=>e.workoutData,[ee.GETTERS.WORKOUTS_PAGINATION]:e=>e.pagination},Ott={[ee.MUTATIONS.ADD_TIMELINE_WORKOUTS](e,t){e.timeline_workouts=e.timeline_workouts.concat(t)},[ee.MUTATIONS.SET_APPEAL_LOADING](e,t){e.appealLoading=t},[ee.MUTATIONS.SET_CALENDAR_WORKOUTS](e,t){e.calendar_workouts=t},[ee.MUTATIONS.SET_SUCCESS](e,t){e.success=t},[ee.MUTATIONS.SET_TIMELINE_WORKOUTS](e,t){e.timeline_workouts=t},[ee.MUTATIONS.SET_USER_WORKOUTS](e,t){e.user_workouts=t},[ee.MUTATIONS.SET_WORKOUTS_PAGINATION](e,t){e.pagination=t},[ee.MUTATIONS.SET_WORKOUT](e,t){e.workoutData.workout=t},[ee.MUTATIONS.SET_WORKOUT_CHART_DATA](e,t){e.workoutData.chartData=t},[ee.MUTATIONS.SET_WORKOUT_GPX](e,t){e.workoutData.gpx=t},[ee.MUTATIONS.SET_WORKOUT_LOADING](e,t){e.workoutData.loading=t},[ee.MUTATIONS.SET_WORKOUT_CONTENT](e,t){e.workoutData.workout=t},[ee.MUTATIONS.SET_WORKOUT_CONTENT_LOADING](e,t){e.workoutContent.loading=t},[ee.MUTATIONS.SET_WORKOUT_CONTENT_TYPE](e,t){e.workoutContent.contentType=t},[ee.MUTATIONS.EMPTY_CALENDAR_WORKOUTS](e){e.calendar_workouts=[]},[ee.MUTATIONS.EMPTY_WORKOUTS](e){e.calendar_workouts=[],e.user_workouts=[],e.timeline_workouts=[]},[ee.MUTATIONS.EMPTY_WORKOUT](e){e.workoutData={gpx:"",loading:!1,workout:{},chartData:[],comments:[],commentsLoading:null,currentCommentEdition:{},currentReporting:!1}},[ee.MUTATIONS.SET_WORKOUT_COMMENTS](e,t){e.workoutData.comments=t},[ee.MUTATIONS.ADD_WORKOUT_COMMENT](e,t){e.workoutData.comments.push(t)},[ee.MUTATIONS.SET_COMMENT_LOADING](e,t){e.workoutData.commentsLoading=t},[ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION](e,t){e.workoutData.currentCommentEdition=t},[ee.MUTATIONS.SET_CURRENT_REPORTING](e,t){e.workoutData.currentReporting=t}},Itt={calendar_workouts:[],timeline_workouts:[],pagination:{},user_workouts:[],workoutData:{gpx:"",loading:!1,workout:{},chartData:[],comments:[],commentsLoading:null,currentCommentEdition:{},currentReporting:!1},workoutContent:{loading:!1,contentType:""},success:null,appealLoading:null},gtt={state:Itt,actions:Stt,getters:Att,mutations:Ott},Rtt={authUserModule:$et,equipmentModule:Wet,notificationsModule:Get,oAuthModule:jet,reportsModule:Jet,sportsModule:ltt,statsModule:mtt,usersModule:htt,workoutsModule:gtt},Ntt={state:stt,actions:ttt,getters:ntt,mutations:att,modules:Rtt},Wn=N2(Ntt),ve=Gt.create({baseURL:nr()});ve.interceptors.request.use(e=>{const t=new AbortController;e.signal=t.signal;const n=Lo(e);Ni.set(n,t);const a=Wn.getters[K.GETTERS.AUTH_TOKEN];if(a){const s=`Bearer ${a}`;e.headers&&e.headers.Authorization!==s&&(e.headers.Authorization=`Bearer ${a}`)}return e},e=>Promise.reject(e));ve.interceptors.response.use(e=>(Lo(e.config),e),e=>(e.message!=="canceled"&&e.response&&Lo(e.response.config),Promise.reject(e)));const fE=(e,t)=>e.push.apply(e,t),yo=e=>e.sort((t,n)=>t.i-n.i||t.j-n.j),Rh=e=>{const t={};let n=1;return e.forEach(a=>{t[a]=n,n+=1}),t};var vtt={4:[[1,2],[2,3]],5:[[1,3],[2,3],[2,4]],6:[[1,2],[2,4],[4,5]],7:[[1,3],[2,3],[4,5],[4,6]],8:[[2,4],[4,6]]};const Nh=2050,vh=1e3,btt=vtt,Ctt=10,Ptt=1e4,zO=10,xO=50,BO=20,GO=/^[A-Z\xbf-\xdf][^A-Z\xbf-\xdf]+$/,Dtt=/^[^A-Z\xbf-\xdf]+[A-Z\xbf-\xdf]$/,Ltt=/^[A-Z\xbf-\xdf]+$/,VO=/^[^a-z\xdf-\xff]+$/,ytt=/^[a-z\xdf-\xff]+$/,$tt=/^[^A-Z\xbf-\xdf]+$/,Utt=/[a-z\xdf-\xff]/,ktt=/[A-Z\xbf-\xdf]/,wtt=/[^A-Za-z\xbf-\xdf]/gi,Mtt=/^\d+$/,jp=new Date().getFullYear(),Wtt={recentYear:/19\d\d|200\d|201\d|202\d/g},HO=[" ",",",";",":","|","/","\\","_",".","-"],Ftt=HO.length;class ztt{match({password:t}){const n=[...this.getMatchesWithoutSeparator(t),...this.getMatchesWithSeparator(t)],a=this.filterNoise(n);return yo(a)}getMatchesWithSeparator(t){const n=[],a=/^(\d{1,4})([\s/\\_.-])(\d{1,2})\2(\d{1,4})$/;for(let s=0;s<=Math.abs(t.length-6);s+=1)for(let o=s+5;o<=s+9&&!(o>=t.length);o+=1){const i=t.slice(s,+o+1||9e9),r=a.exec(i);if(r!=null){const u=this.mapIntegersToDayMonthYear([parseInt(r[1],10),parseInt(r[3],10),parseInt(r[4],10)]);u!=null&&n.push({pattern:"date",token:i,i:s,j:o,separator:r[2],year:u.year,month:u.month,day:u.day})}}return n}getMatchesWithoutSeparator(t){const n=[],a=/^\d{4,8}$/,s=o=>Math.abs(o.year-jp);for(let o=0;o<=Math.abs(t.length-4);o+=1)for(let i=o+3;i<=o+7&&!(i>=t.length);i+=1){const r=t.slice(o,+i+1||9e9);if(a.exec(r)){const u=[],l=r.length;if(btt[l].forEach(([E,c])=>{const m=this.mapIntegersToDayMonthYear([parseInt(r.slice(0,E),10),parseInt(r.slice(E,c),10),parseInt(r.slice(c),10)]);m!=null&&u.push(m)}),u.length>0){let E=u[0],c=s(u[0]);u.slice(1).forEach(m=>{const _=s(m);_{let a=!1;const s=t.length;for(let o=0;o=n.j){a=!0;break}}return!a})}mapIntegersToDayMonthYear(t){if(t[1]>31||t[1]<=0)return null;let n=0,a=0,s=0;for(let o=0,i=t.length;o99&&rNh)return null;r>31&&(a+=1),r>12&&(n+=1),r<=0&&(s+=1)}return a>=2||n===3||s>=2?null:this.getDayMonth(t)}getDayMonth(t){const n=[[t[2],t.slice(0,2)],[t[0],t.slice(1,3)]],a=n.length;for(let s=0;s=1&&o<=31&&i>=1&&i<=12)return{day:o,month:i}}return null}twoToFourDigitYear(t){return t>99?t:t>50?t+1900:t+2e3}}const Ma=new Uint32Array(65536),xtt=(e,t)=>{const n=e.length,a=t.length,s=1<{const n=t.length,a=e.length,s=[],o=[],i=Math.ceil(n/32),r=Math.ceil(a/32);for(let _=0;_>>R&1,N=s[R/32|0]>>>R&1,b=g|_,C=((g|N)&h)+h^h|g|N;let k=_|~(C|h),P=h&C;k>>>31^I&&(o[R/32|0]^=1<>>31^N&&(s[R/32|0]^=1<>>_&1,S=s[_/32|0]>>>_&1,R=h|l,g=((h|S)&d)+d^d|h|S;let I=l|~(g|d),N=d&g;m+=I>>>a-1&1,m-=N>>>a-1&1,I>>>31^O&&(o[_/32|0]^=1<<_),N>>>31^S&&(s[_/32|0]^=1<<_),I=I<<1|O,N=N<<1|S,d=N|~(R|I),l=I&R}for(let _=E;_{if(e.length{const a=e.length<=t.length,s=e.length<=n;return a||s?Math.ceil(e.length/4):n},Htt=(e,t,n)=>{let a=0;const s=Object.keys(t).find(o=>{const i=Vtt(e,o,n);if(Math.abs(e.length-o.length)>i)return!1;const r=Gtt(e,o),u=r<=i;return u&&(a=r),u});return s?{levenshteinDistance:a,levenshteinDistanceEntry:s}:{}};var bh={a:["4","@"],b:["8"],c:["(","{","[","<"],d:["6","|)"],e:["3"],f:["#"],g:["6","9","&"],h:["#","|-|"],i:["1","!","|"],k:["<","|<"],l:["!","1","|","7"],m:["^^","nn","2n","/\\\\/\\\\"],n:["//"],o:["0","()"],q:["9"],u:["|_|"],s:["$","5"],t:["+","7"],v:["<",">","/"],w:["^/","uu","vv","2u","2v","\\\\/\\\\/"],x:["%","><"],z:["2"]},ud={warnings:{straightRow:"straightRow",keyPattern:"keyPattern",simpleRepeat:"simpleRepeat",extendedRepeat:"extendedRepeat",sequences:"sequences",recentYears:"recentYears",dates:"dates",topTen:"topTen",topHundred:"topHundred",common:"common",similarToCommon:"similarToCommon",wordByItself:"wordByItself",namesByThemselves:"namesByThemselves",commonNames:"commonNames",userInputs:"userInputs",pwned:"pwned"},suggestions:{l33t:"l33t",reverseWords:"reverseWords",allUppercase:"allUppercase",capitalization:"capitalization",dates:"dates",recentYears:"recentYears",associatedYears:"associatedYears",sequences:"sequences",repeated:"repeated",longerKeyboardPattern:"longerKeyboardPattern",anotherWord:"anotherWord",useWords:"useWords",noNeed:"noNeed",pwned:"pwned"},timeEstimation:{ltSecond:"ltSecond",second:"second",seconds:"seconds",minute:"minute",minutes:"minutes",hour:"hour",hours:"hours",day:"day",days:"days",month:"month",months:"months",year:"year",years:"years",centuries:"centuries"}};class Ki{constructor(t=[]){this.parents=t,this.children=new Map}addSub(t,...n){const a=t.charAt(0);this.children.has(a)||this.children.set(a,new Ki([...this.parents,a]));let s=this.children.get(a);for(let o=1;o(Object.entries(e).forEach(([n,a])=>{a.forEach(s=>{t.addSub(s,n)})}),t);class Ktt{constructor(){this.matchers={},this.l33tTable=bh,this.trieNodeRoot=Ch(bh,new Ki),this.dictionary={userInputs:[]},this.rankedDictionaries={},this.rankedDictionariesMaxWordSize={},this.translations=ud,this.graphs={},this.useLevenshteinDistance=!1,this.levenshteinThreshold=2,this.l33tMaxSubstitutions=100,this.maxLength=256,this.setRankedDictionaries()}setOptions(t={}){t.l33tTable&&(this.l33tTable=t.l33tTable,this.trieNodeRoot=Ch(t.l33tTable,new Ki)),t.dictionary&&(this.dictionary=t.dictionary,this.setRankedDictionaries()),t.translations&&this.setTranslations(t.translations),t.graphs&&(this.graphs=t.graphs),t.useLevenshteinDistance!==void 0&&(this.useLevenshteinDistance=t.useLevenshteinDistance),t.levenshteinThreshold!==void 0&&(this.levenshteinThreshold=t.levenshteinThreshold),t.l33tMaxSubstitutions!==void 0&&(this.l33tMaxSubstitutions=t.l33tMaxSubstitutions),t.maxLength!==void 0&&(this.maxLength=t.maxLength)}setTranslations(t){if(this.checkCustomTranslations(t))this.translations=t;else throw new Error("Invalid translations object fallback to keys")}checkCustomTranslations(t){let n=!0;return Object.keys(ud).forEach(a=>{if(a in t){const s=a;Object.keys(ud[s]).forEach(o=>{o in t[s]||(n=!1)})}else n=!1}),n}setRankedDictionaries(){const t={},n={};Object.keys(this.dictionary).forEach(a=>{t[a]=Rh(this.dictionary[a]),n[a]=this.getRankedDictionariesMaxWordSize(this.dictionary[a])}),this.rankedDictionaries=t,this.rankedDictionariesMaxWordSize=n}getRankedDictionariesMaxWordSize(t){const n=t.map(a=>typeof a!="string"?a.toString().length:a.length);return n.length===0?0:n.reduce((a,s)=>Math.max(a,s),-1/0)}buildSanitizedRankedDictionary(t){const n=[];return t.forEach(a=>{const s=typeof a;(s==="string"||s==="number"||s==="boolean")&&n.push(a.toString().toLowerCase())}),Rh(n)}extendUserInputsDictionary(t){this.dictionary.userInputs||(this.dictionary.userInputs=[]);const n=[...this.dictionary.userInputs,...t];this.rankedDictionaries.userInputs=this.buildSanitizedRankedDictionary(n),this.rankedDictionariesMaxWordSize.userInputs=this.getRankedDictionariesMaxWordSize(n)}addMatcher(t,n){this.matchers[t]?console.info(`Matcher ${t} already exists`):this.matchers[t]=n}}const Ge=new Ktt;class qtt{constructor(t){this.defaultMatch=t}match({password:t}){const n=t.split("").reverse().join("");return this.defaultMatch({password:n}).map(a=>({...a,token:a.token.split("").reverse().join(""),reversed:!0,i:t.length-1-a.j,j:t.length-1-a.i}))}}class jtt{constructor({substr:t,limit:n,trieRoot:a}){this.buffer=[],this.finalPasswords=[],this.substr=t,this.limit=n,this.trieRoot=a}getAllPossibleSubsAtIndex(t){const n=[];let a=this.trieRoot;for(let s=t;s=this.limit)return;if(a===this.substr.length){t===n&&this.finalPasswords.push({password:this.buffer.join(""),changes:o});return}const u=[...this.getAllPossibleSubsAtIndex(a)];let l=!1;for(let d=a+u.length-1;d>=a;d-=1){const E=u[d-a];if(E.isTerminal()){if(i===E.parents.join("")&&r>=3)continue;l=!0;const c=E.subs;for(const m of c){this.buffer.push(m);const _=o.concat({i:s,letter:m,substitution:E.parents.join("")});if(this.helper({onlyFullSub:t,isFullSub:n,index:d+1,subIndex:s+m.length,changes:_,lastSubLetter:E.parents.join(""),consecutiveSubCount:i===E.parents.join("")?r+1:1}),this.buffer.pop(),this.finalPasswords.length>=this.limit)return}}}if(!t||!l){const d=this.substr.charAt(a);this.buffer.push(d),this.helper({onlyFullSub:t,isFullSub:n&&!l,index:a+1,subIndex:s+1,changes:o,lastSubLetter:i,consecutiveSubCount:r}),this.buffer.pop()}}getAll(){return this.helper({onlyFullSub:!0,isFullSub:!0,index:0,subIndex:0,changes:[],lastSubLetter:void 0,consecutiveSubCount:0}),this.helper({onlyFullSub:!1,isFullSub:!0,index:0,subIndex:0,changes:[],lastSubLetter:void 0,consecutiveSubCount:0}),this.finalPasswords}}const Ytt=(e,t,n)=>new jtt({substr:e,limit:t,trieRoot:n}).getAll(),Xtt=(e,t,n)=>{const s=e.changes.filter(l=>l.il-d.letter.length+d.substitution.length,t),o=e.changes.filter(l=>l.i>=t&&l.i<=n),i=o.reduce((l,d)=>l-d.letter.length+d.substitution.length,n-t+s),r=[],u=[];return o.forEach(l=>{r.findIndex(E=>E.letter===l.letter&&E.substitution===l.substitution)<0&&(r.push({letter:l.letter,substitution:l.substitution}),u.push(`${l.substitution} -> ${l.letter}`))}),{i:s,j:i,subs:r,subDisplay:u.join(", ")}};class Qtt{constructor(t){this.defaultMatch=t}isAlreadyIncluded(t,n){return t.some(a=>Object.entries(a).every(([s,o])=>s==="subs"||o===n[s]))}match({password:t}){const n=[],a=Ytt(t,Ge.l33tMaxSubstitutions,Ge.trieNodeRoot);let s=!1,o=!0;return a.forEach(i=>{if(s)return;const r=this.defaultMatch({password:i.password,useLevenshtein:o});o=!1,r.forEach(u=>{s||(s=u.i===0&&u.j===t.length-1);const l=Xtt(i,u.i,u.j),d=t.slice(l.i,+l.j+1||9e9),E={...u,l33t:!0,token:d,...l},c=this.isAlreadyIncluded(n,E);d.toLowerCase()!==u.matchedWord&&!c&&n.push(E)})}),n.filter(i=>i.token.length>1)}}class Ztt{constructor(){this.l33t=new Qtt(this.defaultMatch),this.reverse=new qtt(this.defaultMatch)}match({password:t}){const n=[...this.defaultMatch({password:t}),...this.reverse.match({password:t}),...this.l33t.match({password:t})];return yo(n)}defaultMatch({password:t,useLevenshtein:n=!0}){const a=[],s=t.length,o=t.toLowerCase();return Object.keys(Ge.rankedDictionaries).forEach(i=>{const r=Ge.rankedDictionaries[i],u=Ge.rankedDictionariesMaxWordSize[i],l=Math.min(u,s);for(let d=0;d{const o=n[s];o.lastIndex=0;let i;for(;i=o.exec(t);)if(i){const r=i[0];a.push({pattern:"regex",token:r,i:i.index,j:i.index+i[0].length-1,regexName:s,regexMatch:i})}}),yo(a)}}var Xs={nCk(e,t){let n=e;if(t>n)return 0;if(t===0)return 1;let a=1;for(let s=1;s<=t;s+=1)a*=n,a/=s,n-=1;return a},log10(e){return e===0?0:Math.log(e)/Math.log(10)},log2(e){return Math.log(e)/Math.log(2)},factorial(e){let t=1;for(let n=2;n<=e;n+=1)t*=n;return t}},ent=({token:e})=>{let t=Ctt**e.length;t===Number.POSITIVE_INFINITY&&(t=Number.MAX_VALUE);let n;return e.length===1?n=zO+1:n=xO+1,Math.max(t,n)},tnt=({year:e,separator:t})=>{let a=Math.max(Math.abs(e-jp),BO)*365;return t&&(a*=4),a};const nnt=e=>{const t=e.split(""),n=t.filter(i=>i.match(ktt)).length,a=t.filter(i=>i.match(Utt)).length;let s=0;const o=Math.min(n,a);for(let i=1;i<=o;i+=1)s+=Xs.nCk(n+a,i);return s};var ant=e=>{const t=e.replace(wtt,"");if(t.match($tt)||t.toLowerCase()===t)return 1;const n=[GO,Dtt,VO],a=n.length;for(let s=0;s{let n=0,a=e.indexOf(t);for(;a>=0;)n+=1,a=e.indexOf(t,a+t.length);return n},snt=({sub:e,token:t})=>{const n=t.toLowerCase(),a=Ph(n,e.substitution),s=Ph(n,e.letter);return{subbedCount:a,unsubbedCount:s}};var ont=({l33t:e,subs:t,token:n})=>{if(!e)return 1;let a=1;return t.forEach(s=>{const{subbedCount:o,unsubbedCount:i}=snt({sub:s,token:n});if(o===0||i===0)a*=2;else{const r=Math.min(i,o);let u=0;for(let l=1;l<=r;l+=1)u+=Xs.nCk(i+o,l);a*=u}}),a},int=({rank:e,reversed:t,l33t:n,subs:a,token:s,dictionaryName:o})=>{const i=e,r=ant(s),u=ont({l33t:n,subs:a,token:s}),l=t&&2||1;let d;return o==="diceware"?d=6**5/2:d=i*r*u*l,{baseGuesses:i,uppercaseVariations:r,l33tVariations:u,calculation:d}},rnt=({regexName:e,regexMatch:t,token:n})=>{const a={alphaLower:26,alphaUpper:26,alpha:52,alphanumeric:62,digits:10,symbols:33};if(e in a)return a[e]**n.length;switch(e){case"recentYear":return Math.max(Math.abs(parseInt(t[0],10)-jp),BO)}return 0},unt=({baseGuesses:e,repeatCount:t})=>e*t,lnt=({token:e,ascending:t})=>{const n=e.charAt(0);let a=0;return["a","A","z","Z","0","1","9"].includes(n)?a=4:n.match(/\d/)?a=10:a=26,t||(a*=2),a*e.length};const cnt=e=>{let t=0;return Object.keys(e).forEach(n=>{const a=e[n];t+=a.filter(s=>!!s).length}),t/=Object.entries(e).length,t},dnt=({token:e,graph:t,turns:n})=>{const a=Object.keys(Ge.graphs[t]).length,s=cnt(Ge.graphs[t]);let o=0;const i=e.length;for(let r=2;r<=i;r+=1){const u=Math.min(n,r-1);for(let l=1;l<=u;l+=1)o+=Xs.nCk(r-1,l-1)*a*s**l}return o};var Ent=({graph:e,token:t,shiftedCount:n,turns:a})=>{let s=dnt({token:t,graph:e,turns:a});if(n){const o=t.length-n;if(n===0||o===0)s*=2;else{let i=0;for(let r=1;r<=Math.min(n,o);r+=1)i+=Xs.nCk(n+o,r);s*=i}}return Math.round(s)},pnt=()=>Ftt;const mnt=(e,t)=>{let n=1;return e.token.lengthDh[e]?Dh[e](t):Ge.matchers[e]&&"scoring"in Ge.matchers[e]?Ge.matchers[e].scoring(t):0;var _nt=(e,t)=>{const n={};if("guesses"in e&&e.guesses!=null)return e;const a=mnt(e,t),s=Tnt(e.pattern,e);let o=0;typeof s=="number"?o=s:e.pattern==="dictionary"&&(o=s.calculation,n.baseGuesses=s.baseGuesses,n.uppercaseVariations=s.uppercaseVariations,n.l33tVariations=s.l33tVariations);const i=Math.max(o,a);return{...e,...n,guesses:i,guessesLog10:Xs.log10(i)}};const Mn={password:"",optimal:{},excludeAdditive:!1,separatorRegex:void 0,fillArray(e,t){const n=[];for(let a=0;a1&&(s*=this.optimal.pi[a.i-1][t-1]);let o=Xs.factorial(t)*s;this.excludeAdditive||(o+=Ptt**(t-1));let i=!1;Object.keys(this.optimal.g[n]).forEach(r=>{const u=this.optimal.g[n][r];parseInt(r,10)<=t&&u<=o&&(i=!0)}),i||(this.optimal.g[n][t]=o,this.optimal.m[n][t]=a,this.optimal.pi[n][t]=s)},bruteforceUpdate(e){let t=this.makeBruteforceMatch(0,e);this.update(t,1);for(let n=1;n<=e;n+=1){t=this.makeBruteforceMatch(n,e);const a=this.optimal.m[n-1];Object.keys(a).forEach(s=>{a[s].pattern!=="bruteforce"&&this.update(t,parseInt(s,10)+1)})}},unwind(e){const t=[];let n=e-1,a=0,s=1/0;const o=this.optimal.g[n];for(o&&Object.keys(o).forEach(i=>{const r=o[i];r=0;){const i=this.optimal.m[n][a];t.unshift(i),n=i.i-1,a-=1}return t}};var hE={mostGuessableMatchSequence(e,t,n=!1){Mn.password=e,Mn.excludeAdditive=n;const a=e.length;let s=Mn.fillArray(a,"array");t.forEach(u=>{s[u.j].push(u)}),s=s.map(u=>u.sort((l,d)=>l.i-d.i)),Mn.optimal={m:Mn.fillArray(a,"object"),pi:Mn.fillArray(a,"object"),g:Mn.fillArray(a,"object")};for(let u=0;u{l.i>0?Object.keys(Mn.optimal.m[l.i-1]).forEach(d=>{Mn.update(l,parseInt(d,10)+1)}):Mn.update(l,1)}),Mn.bruteforceUpdate(u);const o=Mn.unwind(a),i=o.length,r=this.getGuesses(e,i);return{password:e,guesses:r,guessesLog10:Xs.log10(r),sequence:o}},getGuesses(e,t){const n=e.length;let a=0;return e.length===0?a=1:a=Mn.optimal.g[n-1][t],a}};class fnt{match({password:t,omniMatch:n}){const a=[];let s=0;for(;si instanceof Promise)?Promise.all(a):a}normalizeMatch(t,n,a,s){const o={pattern:"repeat",i:a.index,j:n,token:a[0],baseToken:t,baseGuesses:0,repeatCount:a[0].length/t.length};return s instanceof Promise?s.then(i=>({...o,baseGuesses:i})):{...o,baseGuesses:s}}getGreedyMatch(t,n){const a=/(.+)\1+/g;return a.lastIndex=n,a.exec(t)}getLazyMatch(t,n){const a=/(.+?)\1+/g;return a.lastIndex=n,a.exec(t)}setMatchToken(t,n){const a=/^(.+?)\1+$/;let s,o="";if(n&&t[0].length>n[0].length){s=t;const i=a.exec(s[0]);i&&(o=i[1])}else s=n,s&&(o=s[1]);return{match:s,baseToken:o}}getBaseGuesses(t,n){const a=n.match(t);return a instanceof Promise?a.then(o=>hE.mostGuessableMatchSequence(t,o).guesses):hE.mostGuessableMatchSequence(t,a).guesses}}class hnt{constructor(){this.MAX_DELTA=5}match({password:t}){const n=[];if(t.length===1)return[];let a=0,s=null;const o=t.length;for(let i=1;i1||Math.abs(a)===1){const i=Math.abs(a);if(i>0&&i<=this.MAX_DELTA){const r=s.slice(t,+n+1||9e9),{sequenceName:u,sequenceSpace:l}=this.getSequence(r);return o.push({pattern:"sequence",i:t,j:n,token:s.slice(t,+n+1||9e9),sequenceName:u,sequenceSpace:l,ascending:a>0})}}return null}getSequence(t){let n="unicode",a=26;return ytt.test(t)?(n="lower",a=26):Ltt.test(t)?(n="upper",a=26):Mtt.test(t)&&(n="digits",a=10),{sequenceName:n,sequenceSpace:a}}}class Snt{constructor(){this.SHIFTED_RX=/[~!@#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:"ZXCVBNM<>?]/}match({password:t}){const n=[];return Object.keys(Ge.graphs).forEach(a=>{const s=Ge.graphs[a];fE(n,this.helper(t,s,a))}),yo(n)}checkIfShifted(t,n,a){return!t.includes("keypad")&&this.SHIFTED_RX.test(n.charAt(a))?1:0}helper(t,n,a){let s;const o=[];let i=0;const r=t.length;for(;i2&&o.push({pattern:"spatial",i,j:u-1,token:t.slice(i,u),graph:a,turns:d,shiftedCount:s}),i=u;break}}}return o}}const Ant=new RegExp(`[${HO.join("")}]`);class Zu{static getMostUsedSeparatorChar(t){const n=[...t.split("").filter(s=>Ant.test(s)).reduce((s,o)=>{const i=s.get(o);return i?s.set(o,i+1):s.set(o,1),s},new Map).entries()].sort(([s,o],[i,r])=>r-o);if(!n.length)return;const a=n[0];if(!(a[1]<2))return a[0]}static getSeparatorRegex(t){return new RegExp(`([^${t} +])(${t})(?!${t})`,"g")}match({password:t}){const n=[];if(t.length===0)return n;const a=Zu.getMostUsedSeparatorChar(t);if(a===void 0)return n;const s=Zu.getSeparatorRegex(a);for(const o of t.matchAll(s)){if(o.index===void 0)continue;const i=o.index+1;n.push({pattern:"separator",token:a,i,j:i})}return n}}class Ont{constructor(){this.matchers={date:ztt,dictionary:Ztt,regex:Jtt,repeat:fnt,sequence:hnt,spatial:Snt,separator:Zu}}match(t){const n=[],a=[];return[...Object.keys(this.matchers),...Object.keys(Ge.matchers)].forEach(o=>{if(!this.matchers[o]&&!Ge.matchers[o])return;const i=this.matchers[o]?this.matchers[o]:Ge.matchers[o].Matching,u=new i().match({password:t,omniMatch:this});u instanceof Promise?(u.then(l=>{fE(n,l)}),a.push(u)):fE(n,u)}),a.length>0?new Promise((o,i)=>{Promise.all(a).then(()=>{o(yo(n))}).catch(r=>{i(r)})}):yo(n)}}const KO=1,qO=KO*60,jO=qO*60,YO=jO*24,XO=YO*31,QO=XO*12,Int=QO*100,ld={second:KO,minute:qO,hour:jO,day:YO,month:XO,year:QO,century:Int};class gnt{translate(t,n){let a=t;n!==void 0&&n!==1&&(a+="s");const{timeEstimation:s}=Ge.translations;return s[a].replace("{base}",`${n}`)}estimateAttackTimes(t){const n={onlineThrottling100PerHour:t/.027777777777777776,onlineNoThrottling10PerSecond:t/10,offlineSlowHashing1e4PerSecond:t/1e4,offlineFastHashing1e10PerSecond:t/1e10},a={onlineThrottling100PerHour:"",onlineNoThrottling10PerSecond:"",offlineSlowHashing1e4PerSecond:"",offlineFastHashing1e10PerSecond:""};return Object.keys(n).forEach(s=>{const o=n[s];a[s]=this.displayTime(o)}),{crackTimesSeconds:n,crackTimesDisplay:a,score:this.guessesToScore(t)}}guessesToScore(t){return t<1005?0:t<1000005?1:t<100000005?2:t<1e10+5?3:4}displayTime(t){let n="centuries",a;const s=Object.keys(ld),o=s.findIndex(i=>t-1&&(n=s[o-1],o!==0?a=Math.round(t/ld[n]):n="ltSecond"),this.translate(n,a)}}var Rnt=()=>null,Nnt=()=>({warning:Ge.translations.warnings.dates,suggestions:[Ge.translations.suggestions.dates]});const vnt=(e,t)=>{let n=null;return t&&!e.l33t&&!e.reversed?e.rank<=10?n=Ge.translations.warnings.topTen:e.rank<=100?n=Ge.translations.warnings.topHundred:n=Ge.translations.warnings.common:e.guessesLog10<=4&&(n=Ge.translations.warnings.similarToCommon),n},bnt=(e,t)=>{let n=null;return t&&(n=Ge.translations.warnings.wordByItself),n},Cnt=(e,t)=>t?Ge.translations.warnings.namesByThemselves:Ge.translations.warnings.commonNames,Pnt=(e,t)=>{let n=null;const a=e.dictionaryName,s=a==="lastnames"||a.toLowerCase().includes("firstnames");return a==="passwords"?n=vnt(e,t):a.includes("wikipedia")?n=bnt(e,t):s?n=Cnt(e,t):a==="userInputs"&&(n=Ge.translations.warnings.userInputs),n};var Dnt=(e,t)=>{const n=Pnt(e,t),a=[],s=e.token;return s.match(GO)?a.push(Ge.translations.suggestions.capitalization):s.match(VO)&&s.toLowerCase()!==s&&a.push(Ge.translations.suggestions.allUppercase),e.reversed&&e.token.length>=4&&a.push(Ge.translations.suggestions.reverseWords),e.l33t&&a.push(Ge.translations.suggestions.l33t),{warning:n,suggestions:a}},Lnt=e=>e.regexName==="recentYear"?{warning:Ge.translations.warnings.recentYears,suggestions:[Ge.translations.suggestions.recentYears,Ge.translations.suggestions.associatedYears]}:{warning:null,suggestions:[]},ynt=e=>{let t=Ge.translations.warnings.extendedRepeat;return e.baseToken.length===1&&(t=Ge.translations.warnings.simpleRepeat),{warning:t,suggestions:[Ge.translations.suggestions.repeated]}},$nt=()=>({warning:Ge.translations.warnings.sequences,suggestions:[Ge.translations.suggestions.sequences]}),Unt=e=>{let t=Ge.translations.warnings.keyPattern;return e.turns===1&&(t=Ge.translations.warnings.straightRow),{warning:t,suggestions:[Ge.translations.suggestions.longerKeyboardPattern]}},knt=()=>null;const Lh={warning:null,suggestions:[]};class wnt{constructor(){this.matchers={bruteforce:Rnt,date:Nnt,dictionary:Dnt,regex:Lnt,repeat:ynt,sequence:$nt,spatial:Unt,separator:knt},this.defaultFeedback={warning:null,suggestions:[]},this.setDefaultSuggestions()}setDefaultSuggestions(){this.defaultFeedback.suggestions.push(Ge.translations.suggestions.useWords,Ge.translations.suggestions.noNeed)}getFeedback(t,n){if(n.length===0)return this.defaultFeedback;if(t>2)return Lh;const a=Ge.translations.suggestions.anotherWord,s=this.getLongestMatch(n);let o=this.getMatchFeedback(s,n.length===1);return o!=null?o.suggestions.unshift(a):o={warning:null,suggestions:[a]},o}getLongestMatch(t){let n=t[0];return t.slice(1).forEach(s=>{s.token.length>n.token.length&&(n=s)}),n}getMatchFeedback(t,n){return this.matchers[t.pattern]?this.matchers[t.pattern](t,n):Ge.matchers[t.pattern]&&"feedback"in Ge.matchers[t.pattern]?Ge.matchers[t.pattern].feedback(t,n):Lh}}const ZO=()=>new Date().getTime(),Mnt=(e,t,n)=>{const a=new wnt,s=new gnt,o=hE.mostGuessableMatchSequence(t,e),i=ZO()-n,r=s.estimateAttackTimes(o.guesses);return{calcTime:i,...o,...r,feedback:a.getFeedback(r.score,o.sequence)}},Wnt=(e,t)=>new Ont().match(e),Fnt=(e,t)=>{const n=ZO(),a=Wnt(e);if(a instanceof Promise)throw new Error("You are using a Promised matcher, please use `zxcvbnAsync` for it.");return Mnt(a,e,n)},znt="modulepreload",xnt=function(e){return"/"+e},yh={},xt=function(t,n,a){let s=Promise.resolve();if(n&&n.length>0){document.getElementsByTagName("link");const i=document.querySelector("meta[property=csp-nonce]"),r=(i==null?void 0:i.nonce)||(i==null?void 0:i.getAttribute("nonce"));s=Promise.allSettled(n.map(u=>{if(u=xnt(u),u in yh)return;yh[u]=!0;const l=u.endsWith(".css"),d=l?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${d}`))return;const E=document.createElement("link");if(E.rel=l?"stylesheet":znt,l||(E.as="script"),E.crossOrigin="",E.href=u,r&&E.setAttribute("nonce",r),document.head.appendChild(E),l)return new Promise((c,m)=>{E.addEventListener("load",c),E.addEventListener("error",()=>m(new Error(`Unable to preload CSS for ${u}`)))})}))}function o(i){const r=new Event("vite:preloadError",{cancelable:!0});if(r.payload=i,window.dispatchEvent(r),!r.defaultPrevented)throw i}return s.then(i=>{for(const r of i||[])r.status==="rejected"&&o(r.reason);return t().catch(o)})},Bnt=async e=>{switch(e){case"fr":return await xt(()=>import("./password.fr-LQIeIoMk.js"),[]);case"de":return await xt(()=>import("./password.de-SDMVbHi1.js"),[]);case"it":return await xt(()=>import("./password.it-CReO5S7F.js"),[]);case"es":return await xt(()=>import("./password.es-es-DLU3Rh6X.js"),[]);case"pl":return await xt(()=>import("./password.pl-T3z7Kg0O.js"),[]);case"cs":return await xt(()=>import("./password.cs-CLn3Tyh5.js"),[]);default:return await xt(()=>import("./password.en-BDtqNyGO.js"),[])}},$h=async e=>{const t=await xt(()=>import("./password.common-bdamX4EN.js"),[]),n=await Bnt(e),a={graphs:t.adjacencyGraphs,dictionary:{...t.dictionary,...n.dictionary}};Ge.setOptions(a)},Gnt=e=>{switch(e){case 2:return"AVERAGE";case 3:return"GOOD";case 4:return"STRONG";default:return"WEAK"}},Vnt={class:"password-strength"},Hnt={for:"password-strength",class:"visually-hidden"},Knt=["value"],qnt={key:0,class:"password-strength-details"},jnt={class:"password-strength-value"},Ynt={key:0,class:"info-box"},Xnt={class:"password-feedback"},Qnt=Q({__name:"PasswordStength",props:{password:{}},setup(e){const t=e,{password:n}=_e(t),a=$e(),{appLanguage:s}=He(),o=F(()=>a.getters[K.GETTERS.IS_SUCCESS]),i=Se(0),r=Se(""),u=Se([]),l=Se("0% 100%");tt(async()=>await $h(s.value));function d(E){const c=Fnt(E);i.value=c.score,r.value=Gnt(i.value),u.value=c.feedback.suggestions,l.value=i.value*100/4+"% 100%"}return Le(()=>s.value,async E=>{await $h(E)}),Le(()=>n.value,async E=>{o.value?r.value="":d(E)}),(E,c)=>(f(),v("div",Vnt,[p("label",Hnt,A(E.$t("user.PASSWORD_STRENGTH.LABEL")),1),p("input",{id:"password-strength",class:he(["password-slider",`strength-${i.value}`]),style:Va({backgroundSize:l.value}),type:"range",value:i.value,min:"0",max:"4",step:"1",tabindex:-1,autocomplete:"off"},null,14,Knt),r.value?(f(),v("div",qnt,[p("span",jnt,A(E.$t("user.PASSWORD_STRENGTH.LABEL"))+": "+A(E.$t(`user.PASSWORD_STRENGTH.${r.value}`)),1),u.value.length>0?(f(),v("div",Ynt,[p("ul",Xnt,[(f(!0),v(le,null,be(u.value,m=>(f(),v("li",{key:m},A(E.$t(`user.PASSWORD_STRENGTH.SUGGESTIONS.${m}`)),1))),128))])])):D("",!0)])):D("",!0)]))}}),Znt=se(Qnt,[["__scopeId","data-v-dee3cf5a"]]),Jnt={class:"password-input"},eat=["id","disabled","placeholder","required","type","autocomplete"],tat={class:"show-password"},nat={key:0,class:"form-info"},aat=Q({__name:"PasswordInput",props:{checkStrength:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},id:{default:"password"},password:{default:""},placeholder:{},required:{type:Boolean,default:!1},autocomplete:{}},emits:["updatePassword","passwordError"],setup(e,{emit:t}){const n=e,{autocomplete:a,checkStrength:s,disabled:o,id:i,password:r,placeholder:u,required:l}=_e(n),d=t,E=Se(!1),c=Se("");function m(){E.value=!E.value}function _(O){d("updatePassword",O.target.value)}function h(){d("passwordError")}return Le(()=>r.value,O=>{O===""&&(c.value="",E.value=!1)}),(O,S)=>(f(),v("div",Jnt,[Me(p("input",{id:T(i),disabled:T(o),placeholder:T(u),required:T(l),type:E.value?"text":"password","onUpdate:modelValue":S[0]||(S[0]=R=>c.value=R),minlength:"8",onInput:_,onInvalid:h,autocomplete:T(a)},null,40,eat),[[fN,c.value]]),p("div",tat,[p("button",{class:"transparent",onClick:Ne(m,["prevent"]),type:"button"},[x(A(O.$t(`user.${E.value?"HIDE":"SHOW"}_PASSWORD`))+" ",1),p("i",{class:he(["fa",`fa-eye${E.value?"-slash":""}`]),"aria-hidden":"true"},null,2)])]),T(s)?(f(),v("div",nat,[S[1]||(S[1]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(O.$t("user.PASSWORD_INFO")),1)])):D("",!0),T(s)?(f(),B(Znt,{key:1,password:c.value},null,8,["password"])):D("",!0)]))}}),SE=se(aat,[["__scopeId","data-v-56852c2e"]]),sat={id:"user-infos-edition"},oat={class:"profile-form form-box"},iat={key:1,class:"info-box success-message"},rat={class:"form-items",for:"email"},uat=["disabled"],lat={class:"form-items",for:"password-field"},cat={class:"form-items",for:"new-password-field"},dat={class:"form-buttons"},Eat={class:"confirm",type:"submit"},pat={class:"data-export"},mat={class:"info-box"},Tat={key:0,class:"data-export-archive"},_at={key:1},fat={key:2},hat=Q({__name:"UserAccountEdition",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),a=$e(),{appConfig:s,errorMessages:o}=He(),{authUserLoading:i,authUserSuccess:r}=Ke(),u=kt({email:"",password:"",new_password:""}),l=Se(!1),d=Se(!1),E=Se(!1),c=Se(!1),m=F(()=>a.getters[K.GETTERS.EXPORT_REQUEST]),_=F(()=>g());function h(){d.value=!0}function O($){u.email=$.email}function S($){u.password=$}function R($){u.new_password=$}function g(){return m.value?$t(m.value.created_at,n.value.timezone,n.value.date_format,!0,null,!0):null}function I(){return _.value?GD(new Date(_.value),KD(new Date,1)):!0}function N(){const $={email:u.email,password:u.password};u.new_password&&($.new_password=u.new_password),l.value=u.email!==n.value.email,a.dispatch(K.ACTIONS.UPDATE_USER_ACCOUNT,$)}function b($){E.value=$}function C($){a.dispatch(K.ACTIONS.DELETE_ACCOUNT,{username:$})}function k(){a.dispatch(K.ACTIONS.REQUEST_DATA_EXPORT)}async function P($){c.value=!0,await ve.get(`/auth/account/export/${$}`,{responseType:"blob"}).then(y=>{const z=window.URL.createObjectURL(new Blob([y.data],{type:"application/zip"})),Z=document.createElement("a");Z.href=z,Z.setAttribute("download",$),document.body.appendChild(Z),Z.click()}).finally(()=>c.value=!1)}return Le(()=>r.value,async $=>{$&&(S(""),R(""),O(n.value),d.value=!1)}),Le(()=>n.value.email,async()=>{O(n.value)}),Tt(()=>{t.user&&(a.dispatch(K.ACTIONS.GET_REQUEST_DATA_EXPORT),O(t.user))}),Et(()=>{a.commit(K.MUTATIONS.UPDATE_IS_SUCCESS,!1)}),($,y)=>{const z=q("Modal"),Z=q("ErrorMessage");return f(),v("div",sat,[E.value?(f(),B(z,{key:0,title:$.$t("common.CONFIRMATION"),message:$.$t("user.CONFIRM_ACCOUNT_DELETION"),onConfirmAction:y[0]||(y[0]=Ae=>C(T(n).username)),onCancelAction:y[1]||(y[1]=Ae=>b(!1)),onKeydown:y[2]||(y[2]=je(Ae=>b(!1),["esc"]))},null,8,["title","message"])):D("",!0),p("div",oat,[T(o)?(f(),B(Z,{key:0,message:T(o)},null,8,["message"])):D("",!0),T(r)?(f(),v("div",iat,A($.$t(`user.PROFILE.SUCCESSFUL_${l.value&&T(s).is_email_sending_enabled?"EMAIL_":""}UPDATE`)),1)):D("",!0),p("form",{class:he({errors:d.value}),onSubmit:Ne(N,["prevent"])},[p("label",rat,[x(A($.$t("user.EMAIL"))+"* ",1),Me(p("input",{id:"email","onUpdate:modelValue":y[3]||(y[3]=Ae=>u.email=Ae),disabled:T(i),required:!0,onInvalid:h,autocomplete:"email"},null,40,uat),[[st,u.email]])]),p("label",lat,[x(A($.$t("user.CURRENT_PASSWORD"))+"* ",1),w(SE,{id:"password-field",disabled:T(i),password:u.password,required:!0,onUpdatePassword:S,onPasswordError:h,autocomplete:"current-password"},null,8,["disabled","password"])]),p("label",cat,[x(A($.$t("user.NEW_PASSWORD"))+" ",1),w(SE,{id:"new-password-field",disabled:T(i),checkStrength:!0,password:u.new_password,isSuccess:!1,onUpdatePassword:R,onPasswordError:h,autocomplete:"new-password"},null,8,["disabled","password"])]),p("div",dat,[p("button",Eat,A($.$t("buttons.SUBMIT")),1),p("button",{class:"cancel",onClick:y[4]||(y[4]=Ne(Ae=>$.$router.push("/profile"),["prevent"]))},A($.$t("buttons.CANCEL")),1),p("button",{class:"danger",onClick:y[5]||(y[5]=Ne(Ae=>b(!0),["prevent"]))},A($.$t("buttons.DELETE_MY_ACCOUNT")),1),I()?(f(),v("button",{key:0,class:"confirm",onClick:Ne(k,["prevent"])},A($.$t("buttons.REQUEST_DATA_EXPORT")),1)):D("",!0)])],34),p("div",pat,[p("span",mat,[y[7]||(y[7]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A($.$t("user.EXPORT_REQUEST.ONLY_ONE_EXPORT_PER_DAY")),1)]),m.value?(f(),v("div",Tat,[x(A($.$t("user.EXPORT_REQUEST.DATA_EXPORT"))+" ("+A(_.value)+"): ",1),m.value.status==="successful"?(f(),v("span",{key:0,class:"archive-link",onClick:y[6]||(y[6]=Ne(Ae=>P(m.value.file_name),["prevent"]))},[y[8]||(y[8]=p("i",{class:"fa fa-download","aria-hidden":"true"},null,-1)),x(" "+A($.$t("user.EXPORT_REQUEST.DOWNLOAD_ARCHIVE"))+" ("+A(T(Yu)(m.value.file_size))+") ",1)])):(f(),v("span",_at,A($.$t(`user.EXPORT_REQUEST.STATUS.${m.value.status}`)),1)),c.value?(f(),v("span",fat,[x(A($.$t("user.EXPORT_REQUEST.GENERATING_LINK"))+" ",1),y[9]||(y[9]=p("i",{class:"fa fa-spinner fa-pulse","aria-hidden":"true"},null,-1))])):D("",!0)])):D("",!0)])])])}}}),Sat=se(hat,[["__scopeId","data-v-881b0d2d"]]),Aat={id:"user-infos-edition"},Oat={class:"profile-form form-box"},Iat={class:"form-items",for:"registrationDate"},gat=["value"],Rat={class:"form-items",for:"first_name"},Nat=["disabled"],vat={class:"form-items",for:"last_name"},bat={class:"form-items",for:"birth_date"},Cat=["disabled"],Pat={class:"form-items",for:"location"},Dat=["disabled"],Lat={class:"form-items"},yat={class:"form-buttons"},$at={class:"confirm",type:"submit"},Uat=Q({__name:"UserInfosEdition",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),a=$e(),{errorMessages:s}=He(),{authUserLoading:o}=Ke(),i=kt({first_name:"",last_name:"",birth_date:"",location:"",bio:""}),r=F(()=>n.value.created_at?$t(n.value.created_at,n.value.timezone,n.value.date_format):"");function u(E){i.first_name=E.first_name?E.first_name:"",i.last_name=E.last_name?E.last_name:"",i.birth_date=E.birth_date?gn(new Date(E.birth_date),"yyyy-MM-dd"):"",i.location=E.location?E.location:"",i.bio=E.bio?E.bio:""}function l(E){i.bio=E.value}function d(){a.dispatch(K.ACTIONS.UPDATE_USER_PROFILE,i)}return Tt(()=>{n.value&&u(n.value)}),(E,c)=>{const m=q("ErrorMessage"),_=q("CustomTextArea");return f(),v("div",Aat,[p("div",Oat,[T(s)?(f(),B(m,{key:0,message:T(s)},null,8,["message"])):D("",!0),p("form",{onSubmit:Ne(d,["prevent"])},[p("label",Iat,[x(A(E.$t("user.PROFILE.REGISTRATION_DATE"))+" ",1),p("input",{id:"registrationDate",value:r.value,disabled:""},null,8,gat)]),p("label",Rat,[x(A(E.$t("user.PROFILE.FIRST_NAME"))+" ",1),Me(p("input",{id:"first_name","onUpdate:modelValue":c[0]||(c[0]=h=>i.first_name=h),disabled:T(o)},null,8,Nat),[[st,i.first_name]])]),p("label",vat,[x(A(E.$t("user.PROFILE.LAST_NAME"))+" ",1),Me(p("input",{id:"last_name","onUpdate:modelValue":c[1]||(c[1]=h=>i.last_name=h)},null,512),[[st,i.last_name]])]),p("label",bat,[x(A(E.$t("user.PROFILE.BIRTH_DATE"))+" ",1),Me(p("input",{id:"birth_date",type:"date",class:"birth-date","onUpdate:modelValue":c[2]||(c[2]=h=>i.birth_date=h),disabled:T(o)},null,8,Cat),[[st,i.birth_date]])]),p("label",Pat,[x(A(E.$t("user.PROFILE.LOCATION"))+" ",1),Me(p("input",{id:"location","onUpdate:modelValue":c[3]||(c[3]=h=>i.location=h),disabled:T(o)},null,8,Dat),[[st,i.location]])]),p("label",Lat,[x(A(E.$t("user.PROFILE.BIO"))+" ",1),w(_,{name:"bio",charLimit:200,input:i.bio,disabled:T(o),onUpdateValue:l},null,8,["input","disabled"])]),p("div",yat,[p("button",$at,A(E.$t("buttons.SUBMIT")),1),p("button",{class:"cancel",onClick:c[4]||(c[4]=Ne(h=>E.$router.push("/profile"),["prevent"]))},A(E.$t("buttons.CANCEL")),1)])],32)])])}}}),kat=se(Uat,[["__scopeId","data-v-74879b02"]]),wat={id:"user-picture-edition"},Mat={class:"user-picture-form"},Wat={class:"picture-help"},Fat={class:"info-box"},zat={class:"picture-buttons"},xat=["disabled"],Bat=Q({__name:"UserPictureEdition",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),a=$e(),{appConfig:s,errorMessages:o}=He(),i=Se(null),r=F(()=>s.value.max_single_file_size?Yu(s.value.max_single_file_size):"");function u(){a.dispatch(K.ACTIONS.DELETE_PICTURE)}function l(E){E.target.files!==null&&(i.value=E.target.files[0])}function d(){i.value&&a.dispatch(K.ACTIONS.UPDATE_USER_PICTURE,{picture:i.value})}return(E,c)=>{const m=q("ErrorMessage");return f(),v("div",wat,[p("div",Mat,[T(o)?(f(),B(m,{key:0,message:T(o)},null,8,["message"])):D("",!0),w(Jt,{user:T(n)},null,8,["user"]),p("form",{onSubmit:Ne(d,["prevent"])},[p("input",{type:"file",name:"picture",accept:".png,.jpg,.gif",onInput:l},null,32),p("div",Wat,[p("span",Fat,[c[1]||(c[1]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(E.$t("workouts.MAX_SIZE"))+": "+A(r.value),1)])]),p("div",zat,[p("button",{type:"submit",disabled:!i.value},A(E.$t("user.PROFILE.PICTURE_UPDATE")),9,xat),T(n).picture?(f(),v("button",{key:0,class:"danger",onClick:u},A(E.$t("user.PROFILE.PICTURE_REMOVE")),1)):D("",!0),p("button",{class:"cancel",onClick:c[0]||(c[0]=_=>E.$router.push("/profile"))},A(E.$t("user.PROFILE.BACK_TO_PROFILE")),1)])],32)])])}}}),Gat=se(Bat,[["__scopeId","data-v-0a8e1dca"]]),Uh=["Africa/Abidjan","Africa/Accra","Africa/Algiers","Africa/Bissau","Africa/Cairo","Africa/Casablanca","Africa/Ceuta","Africa/El_Aaiun","Africa/Johannesburg","Africa/Juba","Africa/Khartoum","Africa/Lagos","Africa/Maputo","Africa/Monrovia","Africa/Nairobi","Africa/Ndjamena","Africa/Sao_Tome","Africa/Tripoli","Africa/Tunis","Africa/Windhoek","America/Adak","America/Anchorage","America/Araguaina","America/Argentina/Buenos_Aires","America/Argentina/Catamarca","America/Argentina/Cordoba","America/Argentina/Jujuy","America/Argentina/La_Rioja","America/Argentina/Mendoza","America/Argentina/Rio_Gallegos","America/Argentina/Salta","America/Argentina/San_Juan","America/Argentina/San_Luis","America/Argentina/Tucuman","America/Argentina/Ushuaia","America/Asuncion","America/Atikokan","America/Bahia","America/Bahia_Banderas","America/Barbados","America/Belem","America/Belize","America/Blanc-Sablon","America/Boa_Vista","America/Bogota","America/Boise","America/Cambridge_Bay","America/Campo_Grande","America/Cancun","America/Caracas","America/Cayenne","America/Chicago","America/Chihuahua","America/Costa_Rica","America/Creston","America/Cuiaba","America/Curacao","America/Danmarkshavn","America/Dawson","America/Dawson_Creek","America/Denver","America/Detroit","America/Edmonton","America/Eirunepe","America/El_Salvador","America/Fortaleza","America/Fort_Nelson","America/Glace_Bay","America/Godthab","America/Goose_Bay","America/Grand_Turk","America/Guatemala","America/Guayaquil","America/Guyana","America/Halifax","America/Havana","America/Hermosillo","America/Indiana/Indianapolis","America/Indiana/Knox","America/Indiana/Marengo","America/Indiana/Petersburg","America/Indiana/Tell_City","America/Indiana/Vevay","America/Indiana/Vincennes","America/Indiana/Winamac","America/Inuvik","America/Iqaluit","America/Jamaica","America/Juneau","America/Kentucky/Louisville","America/Kentucky/Monticello","America/La_Paz","America/Lima","America/Los_Angeles","America/Maceio","America/Managua","America/Manaus","America/Martinique","America/Matamoros","America/Mazatlan","America/Menominee","America/Merida","America/Metlakatla","America/Mexico_City","America/Miquelon","America/Moncton","America/Monterrey","America/Montevideo","America/Nassau","America/New_York","America/Nipigon","America/Nome","America/Noronha","America/North_Dakota/Beulah","America/North_Dakota/Center","America/North_Dakota/New_Salem","America/Ojinaga","America/Panama","America/Pangnirtung","America/Paramaribo","America/Phoenix","America/Port-au-Prince","America/Port_of_Spain","America/Porto_Velho","America/Puerto_Rico","America/Punta_Arenas","America/Rainy_River","America/Rankin_Inlet","America/Recife","America/Regina","America/Resolute","America/Rio_Branco","America/Santarem","America/Santiago","America/Santo_Domingo","America/Sao_Paulo","America/Scoresbysund","America/Sitka","America/St_Johns","America/Swift_Current","America/Tegucigalpa","America/Thule","America/Thunder_Bay","America/Tijuana","America/Toronto","America/Vancouver","America/Whitehorse","America/Winnipeg","America/Yakutat","America/Yellowknife","Antarctica/Casey","Antarctica/Davis","Antarctica/DumontDUrville","Antarctica/Macquarie","Antarctica/Mawson","Antarctica/Palmer","Antarctica/Rothera","Antarctica/Syowa","Antarctica/Troll","Antarctica/Vostok","Asia/Almaty","Asia/Amman","Asia/Anadyr","Asia/Aqtau","Asia/Aqtobe","Asia/Ashgabat","Asia/Atyrau","Asia/Baghdad","Asia/Baku","Asia/Bangkok","Asia/Barnaul","Asia/Beirut","Asia/Bishkek","Asia/Brunei","Asia/Chita","Asia/Choibalsan","Asia/Colombo","Asia/Damascus","Asia/Dhaka","Asia/Dili","Asia/Dubai","Asia/Dushanbe","Asia/Famagusta","Asia/Gaza","Asia/Hebron","Asia/Ho_Chi_Minh","Asia/Hong_Kong","Asia/Hovd","Asia/Irkutsk","Asia/Jakarta","Asia/Jayapura","Asia/Jerusalem","Asia/Kabul","Asia/Kamchatka","Asia/Karachi","Asia/Kathmandu","Asia/Khandyga","Asia/Kolkata","Asia/Krasnoyarsk","Asia/Kuala_Lumpur","Asia/Kuching","Asia/Macau","Asia/Magadan","Asia/Makassar","Asia/Manila","Asia/Nicosia","Asia/Novokuznetsk","Asia/Novosibirsk","Asia/Omsk","Asia/Oral","Asia/Pontianak","Asia/Pyongyang","Asia/Qatar","Asia/Qostanay","Asia/Qyzylorda","Asia/Riyadh","Asia/Sakhalin","Asia/Samarkand","Asia/Seoul","Asia/Shanghai","Asia/Singapore","Asia/Srednekolymsk","Asia/Taipei","Asia/Tashkent","Asia/Tbilisi","Asia/Tehran","Asia/Thimphu","Asia/Tokyo","Asia/Tomsk","Asia/Ulaanbaatar","Asia/Urumqi","Asia/Ust-Nera","Asia/Vladivostok","Asia/Yakutsk","Asia/Yangon","Asia/Yekaterinburg","Asia/Yerevan","Atlantic/Azores","Atlantic/Bermuda","Atlantic/Canary","Atlantic/Cape_Verde","Atlantic/Faroe","Atlantic/Madeira","Atlantic/Reykjavik","Atlantic/South_Georgia","Atlantic/Stanley","Australia/Adelaide","Australia/Brisbane","Australia/Broken_Hill","Australia/Currie","Australia/Darwin","Australia/Eucla","Australia/Hobart","Australia/Lindeman","Australia/Lord_Howe","Australia/Melbourne","Australia/Perth","Australia/Sydney","Europe/Amsterdam","Europe/Andorra","Europe/Astrakhan","Europe/Athens","Europe/Belgrade","Europe/Berlin","Europe/Brussels","Europe/Bucharest","Europe/Budapest","Europe/Chisinau","Europe/Copenhagen","Europe/Dublin","Europe/Gibraltar","Europe/Helsinki","Europe/Istanbul","Europe/Kaliningrad","Europe/Kiev","Europe/Kirov","Europe/Lisbon","Europe/London","Europe/Luxembourg","Europe/Madrid","Europe/Malta","Europe/Minsk","Europe/Monaco","Europe/Moscow","Europe/Oslo","Europe/Paris","Europe/Prague","Europe/Riga","Europe/Rome","Europe/Samara","Europe/Saratov","Europe/Simferopol","Europe/Sofia","Europe/Stockholm","Europe/Tallinn","Europe/Tirane","Europe/Ulyanovsk","Europe/Uzhgorod","Europe/Vienna","Europe/Vilnius","Europe/Volgograd","Europe/Warsaw","Europe/Zaporozhye","Europe/Zurich","Indian/Chagos","Indian/Christmas","Indian/Cocos","Indian/Kerguelen","Indian/Mahe","Indian/Maldives","Indian/Mauritius","Indian/Reunion","Pacific/Apia","Pacific/Auckland","Pacific/Bougainville","Pacific/Chatham","Pacific/Chuuk","Pacific/Easter","Pacific/Efate","Pacific/Enderbury","Pacific/Fakaofo","Pacific/Fiji","Pacific/Funafuti","Pacific/Galapagos","Pacific/Gambier","Pacific/Guadalcanal","Pacific/Guam","Pacific/Honolulu","Pacific/Kiritimati","Pacific/Kosrae","Pacific/Kwajalein","Pacific/Majuro","Pacific/Marquesas","Pacific/Nauru","Pacific/Niue","Pacific/Norfolk","Pacific/Noumea","Pacific/Pago_Pago","Pacific/Palau","Pacific/Pitcairn","Pacific/Pohnpei","Pacific/Port_Moresby","Pacific/Rarotonga","Pacific/Tahiti","Pacific/Tarawa","Pacific/Tongatapu","Pacific/Wake","Pacific/Wallis"],Vat={id:"tz-dropdown"},Hat=["value","disabled","aria-expanded"],Kat=["aria-label"],qat=["id","onClick","onMouseover","autofocus"],jat=Q({__name:"TimezoneDropdown",props:{input:{},disabled:{type:Boolean,default:!1}},emits:["updateTimezone"],setup(e,{emit:t}){const n=e,{input:a,disabled:s}=_e(n),o=t,i=Se(a.value),r=Se(!1),u=Se(0),l=F(()=>a.value?Uh.filter(I=>d(I)):Uh);function d(I){return I.toLowerCase().match(i.value.toLowerCase())}function E(I){u.value=I}function c(I){l.value.length>I&&(i.value=l.value[I],o("updateTimezone",i.value),r.value=!1)}function m(I){I.preventDefault(),l.value.length>0&&c(u.value)}function _(I){I.preventDefault(),r.value=!0,i.value=I.target.value.trim()}function h(){c(u.value)}function O(I){const N=document.getElementById(`tz-dropdown-item-${I}`);N&&(N.focus(),N.scrollIntoView({behavior:"smooth",block:"nearest"}))}function S(){r.value=!0,u.value=u.value===null?0:u.value+=1,u.value>=l.value.length&&(u.value=0),O(u.value)}function R(){r.value=!0,u.value=u.value===null?l.value.length-1:u.value-=1,u.value<=-1&&(u.value=l.value.length-1),O(u.value)}function g(){r.value&&(r.value=!1,i.value=a.value)}return Le(()=>n.input,I=>{i.value=I}),(I,N)=>(f(),v("div",Vat,[p("input",{class:"tz-dropdown-input",id:"timezone",name:"timezone",value:i.value,disabled:T(s),required:"",role:"combobox","aria-autocomplete":"list","aria-controls":"tz-dropdown-list","aria-expanded":r.value,onKeydown:[N[0]||(N[0]=je(b=>g(),["esc"])),je(m,["enter"]),N[2]||(N[2]=je(b=>S(),["down"])),N[3]||(N[3]=je(b=>R(),["up"]))],onInput:_,onBlur:N[1]||(N[1]=b=>h())},null,40,Hat),r.value?(f(),v("ul",{key:0,class:"tz-dropdown-list",id:"tz-dropdown-list",role:"listbox",tabindex:"-1","aria-label":I.$t("user.PROFILE.TIMEZONE",0)},[(f(!0),v(le,null,be(l.value,(b,C)=>(f(),v("li",{key:b,id:`tz-dropdown-item-${C}`,class:he(["tz-dropdown-item",{focus:C===u.value}]),onClick:k=>c(C),onMouseover:k=>E(C),autofocus:C===u.value,role:"option"},A(b),43,qat))),128))],8,Kat)):D("",!0)]))}}),Yat=se(jat,[["__scopeId","data-v-de57165c"]]),Xat={id:"user-preferences-edition"},Qat={class:"profile-form form-box"},Zat={class:"preferences-section"},Jat={class:"form-items"},est=["disabled"],tst=["value"],nst={class:"form-items"},ast=["disabled"],sst=["value"],ost={class:"form-items"},ist={class:"form-items"},rst=["disabled"],ust=["value"],lst={class:"form-items form-checkboxes"},cst={class:"checkboxes-label"},dst={class:"checkboxes"},Est=["id","name","checked","disabled","onInput"],pst={class:"checkbox-label"},mst={class:"preferences-section"},Tst={class:"form-items form-checkboxes"},_st={class:"checkboxes-label"},fst={class:"checkboxes"},hst=["id","name","checked","disabled","onInput"],Sst={class:"checkbox-label"},Ast={class:"form-items form-checkboxes"},Ost={class:"checkboxes-label"},Ist={class:"checkboxes"},gst=["id","name","checked","disabled","onInput"],Rst={class:"checkbox-label"},Nst={class:"preferences-section"},vst={class:"form-items form-checkboxes"},bst={class:"checkboxes-label"},Cst={class:"checkboxes"},Pst=["id","name","checked","disabled","onInput"],Dst={class:"checkbox-label"},Lst={class:"form-items form-checkboxes"},yst={class:"checkboxes-label"},$st={class:"checkboxes"},Ust=["id","name","checked","disabled","onInput"],kst={class:"checkbox-label"},wst={class:"form-items form-checkboxes"},Mst={class:"checkboxes-label"},Wst={class:"checkboxes"},Fst=["id","name","checked","disabled","onInput"],zst={class:"checkbox-label"},xst={class:"form-items form-checkboxes"},Bst={class:"checkboxes-label"},Gst={class:"checkboxes"},Vst=["id","name","checked","disabled","onInput"],Hst={class:"checkbox-label"},Kst={class:"info-box raw-speed-help"},qst={class:"form-items"},jst=["disabled"],Yst=["value"],Xst={class:"form-items"},Qst=["disabled"],Zst=["value"],Jst={class:"form-buttons"},eot={class:"confirm",type:"submit"},tot=Q({__name:"UserPreferencesEdition",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),a=$e(),{errorMessages:s}=He(),{authUserLoading:o}=Ke(),i=[{label:"SUNDAY",value:!1},{label:"MONDAY",value:!0}],r=[{label:"METRIC",value:!1},{label:"IMPERIAL",value:!0}],u=[{label:"DISPLAYED",value:!0},{label:"HIDDEN",value:!1}],l=[{label:"ZERO",value:!0},{label:"MIN_ALT",value:!1}],d=[{label:"FILTERED_SPEED",value:!1},{label:"RAW_SPEED",value:!0}],E=[{label:"DARK",value:!0},{label:"DEFAULT",value:null},{label:"LIGHT",value:!1}],c=[{label:"MANUALLY",value:!0},{label:"AUTOMATICALLY",value:!1}],m=[{label:"HIDDEN",value:!0},{label:"DISPLAYED",value:!1}],_=kt({date_format:"dd/MM/yyyy",display_ascent:!0,hide_profile_in_users_directory:!0,imperial_units:!1,language:"en",manually_approves_followers:!0,map_visibility:"private",start_elevation_at_zero:!1,timezone:"Europe/Paris",use_dark_mode:!1,use_raw_gpx_speed:!1,weekm:!1,workouts_visibility:"private"}),h=F(()=>pBe(new Date().toUTCString(),n.value.timezone,_.language)),O=F(()=>gO()),S=F(()=>NO(_.workouts_visibility));function R(b){_.display_ascent=b.display_ascent,_.start_elevation_at_zero=b.start_elevation_at_zero?b.start_elevation_at_zero:!1,_.use_raw_gpx_speed=b.use_raw_gpx_speed?b.use_raw_gpx_speed:!1,_.imperial_units=b.imperial_units?b.imperial_units:!1,_.language=b.language&&b.language in fo?b.language:"en",_.manually_approves_followers="manually_approves_followers"in b?b.manually_approves_followers:!0,_.map_visibility=b.map_visibility?b.map_visibility:"private",_.timezone=b.timezone?b.timezone:"Europe/Paris",_.date_format=b.date_format?b.date_format:"dd/MM/yyyy",_.weekm=b.weekm?b.weekm:!1,_.use_dark_mode=b.use_dark_mode,_.workouts_visibility=b.workouts_visibility?b.workouts_visibility:"private",_.hide_profile_in_users_directory=b.hide_profile_in_users_directory}function g(){a.dispatch(K.ACTIONS.UPDATE_USER_PREFERENCES,_)}function I(b,C){_[b]=C}function N(){_.map_visibility=RO(_.map_visibility,_.workouts_visibility)}return Tt(()=>{n.value&&R(n.value)}),(b,C)=>{const k=q("ErrorMessage");return f(),v("div",Xat,[p("div",Qat,[T(s)?(f(),B(k,{key:0,message:T(s)},null,8,["message"])):D("",!0),p("form",{onSubmit:Ne(g,["prevent"])},[p("div",Zat,A(b.$t("user.PROFILE.INTERFACE")),1),p("label",Jat,[x(A(b.$t("user.PROFILE.LANGUAGE"))+" ",1),Me(p("select",{id:"language","onUpdate:modelValue":C[0]||(C[0]=P=>_.language=P),disabled:T(o)},[(f(!0),v(le,null,be(T(aE),P=>(f(),v("option",{value:P.value,key:P.value},A(P.label),9,tst))),128))],8,est),[[Sn,_.language]])]),p("label",nst,[x(A(b.$t("user.PROFILE.THEME_MODE.LABEL"))+" ",1),Me(p("select",{id:"use_dark_mode","onUpdate:modelValue":C[1]||(C[1]=P=>_.use_dark_mode=P),disabled:T(o)},[(f(),v(le,null,be(E,P=>p("option",{value:P.value,key:P.label},A(b.$t(`user.PROFILE.THEME_MODE.VALUES.${P.label}`)),9,sst)),64))],8,ast),[[Sn,_.use_dark_mode]])]),p("label",ost,[x(A(b.$t("user.PROFILE.TIMEZONE"))+" ",1),w(Yat,{input:_.timezone,disabled:T(o),onUpdateTimezone:C[2]||(C[2]=P=>I("timezone",P))},null,8,["input","disabled"])]),p("label",ist,[x(A(b.$t("user.PROFILE.DATE_FORMAT"))+" ",1),Me(p("select",{id:"date_format","onUpdate:modelValue":C[3]||(C[3]=P=>_.date_format=P),disabled:T(o)},[(f(!0),v(le,null,be(h.value,P=>(f(),v("option",{value:P.value,key:P.value},A(P.label),9,ust))),128))],8,rst),[[Sn,_.date_format]])]),p("div",lst,[p("span",cst,A(b.$t("user.PROFILE.FIRST_DAY_OF_WEEK")),1),p("div",dst,[(f(),v(le,null,be(i,P=>p("label",{key:P.label},[p("input",{type:"radio",id:P.label,name:P.label,checked:P.value===_.weekm,disabled:T(o),onInput:$=>I("weekm",P.value)},null,40,Est),p("span",pst,A(b.$t(`user.PROFILE.${P.label}`)),1)])),64))])]),p("div",mst,A(b.$t("user.PROFILE.TABS.ACCOUNT")),1),p("div",Tst,[p("span",_st,A(b.$t("user.PROFILE.FOLLOW_REQUESTS_APPROVAL.LABEL")),1),p("div",fst,[(f(),v(le,null,be(c,P=>p("label",{key:P.label},[p("input",{type:"radio",id:P.label,name:P.label,checked:P.value===_.manually_approves_followers,disabled:T(o),onInput:$=>I("manually_approves_followers",P.value)},null,40,hst),p("span",Sst,A(b.$t(`user.PROFILE.FOLLOW_REQUESTS_APPROVAL.${P.label}`)),1)])),64))])]),p("div",Ast,[p("span",Ost,A(b.$t("user.PROFILE.PROFILE_IN_USERS_DIRECTORY.LABEL")),1),p("div",Ist,[(f(),v(le,null,be(m,P=>p("label",{key:P.label},[p("input",{type:"radio",id:`hide_profile_${P.label}`,name:`hide_profile_${P.label}`,checked:P.value===_.hide_profile_in_users_directory,disabled:T(o),onInput:$=>I("hide_profile_in_users_directory",P.value)},null,40,gst),p("span",Rst,A(b.$t(`user.PROFILE.PROFILE_IN_USERS_DIRECTORY.${P.label}`)),1)])),64))])]),p("div",Nst,A(b.$t("workouts.WORKOUT",0)),1),p("div",vst,[p("span",bst,A(b.$t("user.PROFILE.UNITS.LABEL")),1),p("div",Cst,[(f(),v(le,null,be(r,P=>p("label",{key:P.label},[p("input",{type:"radio",id:P.label,name:P.label,checked:P.value===_.imperial_units,disabled:T(o),onInput:$=>I("imperial_units",P.value)},null,40,Pst),p("span",Dst,A(b.$t(`user.PROFILE.UNITS.${P.label}`)),1)])),64))])]),p("div",Lst,[p("span",yst,A(b.$t("user.PROFILE.ASCENT_DATA")),1),p("div",$st,[(f(),v(le,null,be(u,P=>p("label",{key:P.label},[p("input",{type:"radio",id:P.label,name:P.label,checked:P.value===_.display_ascent,disabled:T(o),onInput:$=>I("display_ascent",P.value)},null,40,Ust),p("span",kst,A(b.$t(`common.${P.label}`)),1)])),64))])]),p("div",wst,[p("span",Mst,A(b.$t("user.PROFILE.ELEVATION_CHART_START.LABEL")),1),p("div",Wst,[(f(),v(le,null,be(l,P=>p("label",{key:P.label},[p("input",{type:"radio",id:P.label,name:P.label,checked:P.value===_.start_elevation_at_zero,disabled:T(o),onInput:$=>I("start_elevation_at_zero",P.value)},null,40,Fst),p("span",zst,A(b.$t(`user.PROFILE.ELEVATION_CHART_START.${P.label}`)),1)])),64))])]),p("div",xst,[p("span",Bst,A(b.$t("user.PROFILE.USE_RAW_GPX_SPEED.LABEL")),1),p("div",Gst,[(f(),v(le,null,be(d,P=>p("label",{key:P.label},[p("input",{type:"radio",id:P.label,name:P.label,checked:P.value===_.use_raw_gpx_speed,disabled:T(o),onInput:$=>I("use_raw_gpx_speed",P.value)},null,40,Vst),p("span",Hst,A(b.$t(`user.PROFILE.USE_RAW_GPX_SPEED.${P.label}`)),1)])),64))]),p("div",Kst,[p("span",null,[C[7]||(C[7]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(b.$t("user.PROFILE.USE_RAW_GPX_SPEED.HELP")),1)])])]),p("label",qst,[x(A(b.$t("visibility_levels.WORKOUTS_VISIBILITY"))+" ",1),Me(p("select",{id:"workouts_visibility","onUpdate:modelValue":C[4]||(C[4]=P=>_.workouts_visibility=P),disabled:T(o),onChange:N},[(f(!0),v(le,null,be(O.value,P=>(f(),v("option",{value:P,key:P},A(b.$t(`visibility_levels.LEVELS.${P}`)),9,Yst))),128))],40,jst),[[Sn,_.workouts_visibility]])]),p("label",Xst,[x(A(b.$t("visibility_levels.MAP_VISIBILITY"))+" ",1),Me(p("select",{id:"map_visibility","onUpdate:modelValue":C[5]||(C[5]=P=>_.map_visibility=P),disabled:T(o)},[(f(!0),v(le,null,be(S.value,P=>(f(),v("option",{value:P,key:P},A(b.$t(`visibility_levels.LEVELS.${P}`)),9,Zst))),128))],8,Qst),[[Sn,_.map_visibility]])]),p("div",Jst,[p("button",eot,A(b.$t("buttons.SUBMIT")),1),p("button",{class:"cancel",onClick:C[6]||(C[6]=Ne(P=>b.$router.push("/profile/preferences"),["prevent"]))},A(b.$t("buttons.CANCEL")),1)])],32)])])}}}),not=se(tot,[["__scopeId","data-v-fba76a81"]]),aot={class:"privacy-policy-text"},sot={class:"last-update"},oot=["innerHTML"],iot=["innerHTML"],rot="Sat, 30 Nov 2024 10:00:00 GMT",uot=Q({__name:"PrivacyPolicy",setup(e){const{appConfig:t}=He(),{dateFormat:n,timezone:a}=Ke(),s=["DATA_COLLECTED","INFORMATION_USAGE","INFORMATION_PROTECTION","INFORMATION_DISCLOSURE","SITE_USAGE_BY_CHILDREN","YOUR_CONSENT","ACCOUNT_DELETION","CHANGES_TO_OUR_PRIVACY_POLICY"],o=F(()=>i());function i(){return $t(t.value.privacy_policy&&t.value.privacy_policy_date?`${t.value.privacy_policy_date}`:rot,a.value,n.value,!1)}return(r,u)=>(f(),v("div",aot,[p("h1",null,A(Fe(r.$t("privacy_policy.TITLE"))),1),p("p",sot,[x(A(r.$t("privacy_policy.LAST_UPDATE"))+": ",1),p("time",null,A(o.value),1)]),T(t).privacy_policy?(f(),v("div",{key:0,innerHTML:T(Vi)(T(t).privacy_policy)},null,8,oot)):(f(),v(le,{key:1},be(s,l=>(f(),v(le,{key:l},[p("h2",null,A(r.$t(`privacy_policy.CONTENT.${l}.TITLE`)),1),p("p",{innerHTML:T(Vi)(r.$t(`privacy_policy.CONTENT.${l}.CONTENT`))},null,8,iot)],64))),64))]))}}),JO=se(uot,[["__scopeId","data-v-e7650734"]]),lot={id:"user-privacy-policy"},cot={key:1},dot={class:"policy-content"},Eot={for:"accepted_policy",class:"accepted_policy"},pot={class:"form-buttons"},mot={class:"confirm",type:"submit"},Tot=Q({__name:"UserPrivacyPolicyValidation",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),a=$e(),{errorMessages:s}=He(),o=Se(!1),i=Se(!1);function r(){a.dispatch(K.ACTIONS.ACCEPT_PRIVACY_POLICY,o.value)}function u(){i.value=!0}return(l,d)=>{const E=q("ErrorMessage"),c=q("router-link"),m=q("i18n-t");return f(),v("div",lot,[T(s)?(f(),B(E,{key:0,message:T(s)},null,8,["message"])):D("",!0),T(n).accepted_privacy_policy?(f(),v("div",cot,[p("p",null,[w(m,{keypath:"user.YOU_HAVE_ACCEPTED_PRIVACY_POLICY"},{default:X(()=>[w(c,{to:"/privacy-policy"},{default:X(()=>[x(A(l.$t("privacy_policy.TITLE")),1)]),_:1})]),_:1})]),p("button",{class:"cancel",onClick:d[0]||(d[0]=_=>l.$router.push("/profile"))},A(l.$t("user.PROFILE.BACK_TO_PROFILE")),1)])):(f(),v("form",{key:2,class:he({errors:i.value}),onSubmit:d[3]||(d[3]=Ne(_=>r(),["prevent"]))},[p("div",dot,[w(JO)]),p("label",Eot,[Me(p("input",{type:"checkbox",id:"accepted_policy",required:"","onUpdate:modelValue":d[1]||(d[1]=_=>o.value=_),onInvalid:u},null,544),[[cl,o.value]]),p("span",null,[w(m,{keypath:"user.READ_AND_ACCEPT_PRIVACY_POLICY"},{default:X(()=>[x(A(l.$t("privacy_policy.TITLE")),1)]),_:1})])]),w(c,{to:"/profile/edit/account"},{default:X(()=>[x(A(l.$t("user.I_WANT_TO_DELETE_MY_ACCOUNT")),1)]),_:1}),p("div",pot,[p("button",mot,A(l.$t("buttons.SUBMIT")),1),p("button",{class:"cancel",onClick:d[2]||(d[2]=_=>l.$router.push("/profile"))},A(l.$t("user.PROFILE.BACK_TO_PROFILE")),1)])],34))])}}}),_ot=se(Tot,[["__scopeId","data-v-ac974385"]]),fot={key:0},hot={key:1},Sot={key:2},Aot={class:"no-suspension"},Oot=Q({__name:"UserAccountSuspension",setup(e){const t=$e(),{authUserLoading:n,authUserSuccess:a}=Ke(),s=Se(""),o=F(()=>t.getters[K.GETTERS.ACCOUNT_SUSPENSION]);function i(){t.dispatch(K.ACTIONS.GET_ACCOUNT_SUSPENSION)}function r(u){s.value=u,t.dispatch(K.ACTIONS.APPEAL,{actionId:o.value.id,actionType:"user_suspension",text:u})}return Tt(()=>i()),Et(()=>{t.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),t.commit(K.MUTATIONS.UPDATE_IS_SUCCESS,!1)}),(u,l)=>{const d=q("Loader");return T(n)&&!s.value?(f(),v("div",fot,[w(d)])):o.value.id?(f(),v("div",hot,[p("div",null,A(u.$t("user.YOUR_ACCOUNT_HAS_BEEN_SUSPENDED"))+".",1),w(vl,{"report-action":o.value,success:T(a),loading:T(n),onSubmitForm:r},{additionalButtons:X(()=>[p("button",{onClick:l[0]||(l[0]=E=>u.$router.push("/profile"))},A(u.$t("user.PROFILE.BACK_TO_PROFILE")),1)]),_:1},8,["report-action","success","loading"])])):(f(),v("div",Sot,[p("div",Aot,A(u.$t("user.ACTIVE_ACCOUNT")),1),p("button",{onClick:l[1]||(l[1]=E=>u.$router.push("/profile"))},A(u.$t("user.PROFILE.BACK_TO_PROFILE")),1)]))}}}),Iot=se(Oot,[["__scopeId","data-v-2a9aa8c5"]]),got=["equipments:read","equipments:write","follow:read","follow:write","notifications:read","notifications:write","profile:read","profile:write","reports:read","reports:write","users:read","users:write","workouts:read","workouts:write"],Rot=["application:write"],Not={id:"new-oauth2-app"},vot={id:"new-oauth2-title"},bot={id:"apps-form"},Cot={class:"form-items"},Pot={class:"form-item"},Dot={for:"app-name"},Lot={class:"form-item"},yot={for:"app-description"},$ot={class:"form-item"},Uot={for:"app-url"},kot={class:"form-item"},wot={for:"app-redirect-uri"},Mot={class:"form-item-scope"},Wot={class:"form-item-scope-label"},Fot={class:"scope-label"},zot=["name","checked","onChange"],xot=["innerHTML"],Bot={class:"form-buttons"},Got=["disabled"],Vot=Q({__name:"AddUserApp",setup(e){const t=$e(),{errorMessages:n}=He(),{authUserHasAdminRights:a}=Ke(),s=kt({client_name:"",client_uri:"",client_description:"",description:"",redirect_uri:""}),o=kt([]),i=F(()=>d(a.value,Rot,got));function r(){const E={client_name:s.client_name,client_description:s.client_description,client_uri:s.client_uri,redirect_uris:[s.redirect_uri],scope:o.sort().join(" ")};t.dispatch(nt.ACTIONS.CREATE_CLIENT,E)}function u(E){s.client_description=E.value}function l(E){const c=o.indexOf(E);c>-1?o.splice(c,1):o.push(E)}function d(E,c,m){const _=[...m];return E&&_.push(...c),_.sort()}return(E,c)=>{const m=q("CustomTextArea"),_=q("ErrorMessage");return f(),v("div",Not,[p("h1",vot,A(E.$t("oauth2.ADD_A_NEW_APP")),1),p("div",bot,[p("form",{onSubmit:Ne(r,["prevent"])},[p("div",Cot,[p("div",Pot,[p("label",Dot,A(E.$t("oauth2.APP.NAME"))+"*",1),Me(p("input",{id:"app-name",type:"text",required:"","onUpdate:modelValue":c[0]||(c[0]=h=>s.client_name=h)},null,512),[[st,s.client_name]])]),p("div",Lot,[p("label",yot,A(E.$t("oauth2.APP.DESCRIPTION")),1),w(m,{name:"app-description",charLimit:200,input:s.description,onUpdateValue:u},null,8,["input"])]),p("div",$ot,[p("label",Uot,A(E.$t("oauth2.APP.URL"))+"*",1),Me(p("input",{id:"app-url",type:"text",required:"","onUpdate:modelValue":c[1]||(c[1]=h=>s.client_uri=h)},null,512),[[st,s.client_uri]])]),p("div",kot,[p("label",wot,A(E.$t("oauth2.APP.REDIRECT_URL"))+"* ",1),Me(p("input",{id:"app-redirect-uri",type:"text",required:"","onUpdate:modelValue":c[2]||(c[2]=h=>s.redirect_uri=h)},null,512),[[st,s.redirect_uri]])]),p("div",Mot,[p("div",Wot,A(E.$t("oauth2.APP.SCOPE.LABEL"))+"* ",1),(f(!0),v(le,null,be(i.value,h=>(f(),v("div",{class:"form-item-scope-checkboxes",key:h},[p("label",Fot,[p("input",{type:"checkbox",name:h,checked:o.includes(h),onChange:O=>l(h)},null,40,zot),p("code",null,A(h),1)]),p("p",{class:"scope-description",innerHTML:E.$t(`oauth2.APP.SCOPE.${h}_DESCRIPTION`)},null,8,xot)]))),128))])]),T(n)?(f(),B(_,{key:0,message:T(n)},null,8,["message"])):D("",!0),p("div",Bot,[p("button",{class:"confirm",type:"submit",disabled:o.length===0},A(E.$t("buttons.SUBMIT")),9,Got),p("button",{class:"cancel",onClick:c[3]||(c[3]=Ne(()=>E.$router.push("/profile/apps"),["prevent"]))},A(E.$t("buttons.CANCEL")),1)])],32)])])}}}),Hot=se(Vot,[["__scopeId","data-v-f0f43085"]]),Kot={id:"authorize-oauth2-app"},qot={key:0},jot={id:"authorize-oauth2-title"},Yot={class:"oauth2-access description-list"},Xot={class:"client-scope"},Qot=["innerHTML"],Zot={class:"authorize-oauth2-buttons"},Jot={key:1},eit={class:"no-app"},tit=Q({__name:"AuthorizeUserApp",setup(e){const t=it(),n=$e(),{errorMessages:a}=He(),s=F(()=>n.getters[nt.GETTERS.CLIENT]);function o(){t.query.client_id&&typeof t.query.client_id=="string"&&n.dispatch(nt.ACTIONS.GET_CLIENT_BY_CLIENT_ID,t.query.client_id)}function i(){n.dispatch(nt.ACTIONS.AUTHORIZE_CLIENT,{client_id:`${t.query.client_id}`,redirect_uri:`${t.query.redirect_uri}`,response_type:`${t.query.response_type}`,scope:`${t.query.scope}`,state:`${t.query.state?t.query.state:""}`,code_challenge:`${t.query.code_challenge?t.query.code_challenge:""}`,code_challenge_method:`${t.query.code_challenge_method?t.query.code_challenge_method:""}`})}return tt(()=>o()),(r,u)=>{const l=q("router-link"),d=q("i18n-t"),E=q("ErrorMessage");return f(),v("div",Kot,[s.value.client_id?(f(),v("div",qot,[p("h1",jot,[w(d,{keypath:"oauth2.AUTHORIZE_APP"},{default:X(()=>[w(l,{to:{name:"UserApp",params:{id:s.value.id}}},{default:X(()=>[x(A(s.value.name),1)]),_:1},8,["to"])]),_:1})]),T(a)?(f(),B(E,{key:0,message:T(a)},null,8,["message"])):D("",!0),p("div",Yot,[p("p",null,A(r.$t("oauth2.APP_REQUESTING_ACCESS")),1),p("dl",null,[(f(!0),v(le,null,be(s.value.scope.split(" "),c=>(f(),v(le,{key:c},[p("dt",Xot,[p("code",null,A(c),1)]),p("dd",{innerHTML:r.$t(`oauth2.APP.SCOPE.${c}_DESCRIPTION`)},null,8,Qot)],64))),128))]),p("div",Zot,[p("button",{class:"danger",onClick:i},A(r.$t("buttons.AUTHORIZE")),1),p("button",{class:"cancel",onClick:u[0]||(u[0]=c=>r.$router.push("/profile/apps"))},A(r.$t("buttons.CANCEL")),1)])])])):(f(),v("div",Jot,[p("p",eit,A(r.$t("oauth2.NO_APP")),1),p("button",{onClick:u[1]||(u[1]=c=>r.$router.push("/profile/apps"))},A(r.$t("buttons.BACK")),1)]))])}}}),nit=se(tit,[["__scopeId","data-v-6462d75b"]]),ait={id:"oauth2-apps"},sit=Q({__name:"index",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),a=$e();return Et(()=>{a.commit(nt.MUTATIONS.SET_CLIENTS,[])}),(s,o)=>{const i=q("router-view");return f(),v("div",ait,[w(i,{authUser:T(n)},null,8,["authUser"])])}}}),oit={id:"oauth2-app",class:"description-list"},iit={key:1},rit={key:0,class:"info-box success-message"},uit=["title"],lit={key:0},cit={key:1,class:"app-secret"},dit=["title"],Eit={class:"client-scopes"},pit={class:"app-buttons"},mit={key:2},Tit={class:"no-app"},_it=Q({__name:"UserApp",props:{authUser:{},afterCreation:{type:Boolean,default:!1}},setup(e){const t=e,{afterCreation:n,authUser:a}=_e(t),s=it(),o=$e(),i=Se(!1),r=Se(""),u=Se(!1),l=Se(!1),d=Se(!1),E=F(()=>o.getters[nt.GETTERS.CLIENT]),c=F(()=>o.getters[nt.GETTERS.REVOCATION_SUCCESSFUL]);function m(){!n.value&&s.params.id&&typeof s.params.id=="string"&&o.dispatch(nt.ACTIONS.GET_CLIENT_BY_ID,+s.params.id)}function _(g){r.value=g?"oauth2.APP_DELETION_CONFIRMATION":"oauth2.TOKENS_REVOCATION_CONFIRMATION",h(!0)}function h(g){i.value=g,g||(r.value="")}function O(g){r.value==="oauth2.APP_DELETION_CONFIRMATION"?o.dispatch(nt.ACTIONS.DELETE_CLIENT,g):o.dispatch(nt.ACTIONS.REVOKE_ALL_TOKENS,g)}function S(){navigator.clipboard.writeText(E.value.client_id),u.value=!0,l.value=!1,setTimeout(()=>{u.value=!1},3e3)}function R(){E.value.client_secret&&(navigator.clipboard.writeText(E.value.client_secret),l.value=!0,u.value=!1,setTimeout(()=>{l.value=!1},3e3))}return Le(()=>c.value,g=>{g&&h(!1)}),tt(()=>{m(),navigator.clipboard&&(d.value=!0)}),Et(()=>{o.commit(nt.MUTATIONS.EMPTY_CLIENT),o.commit(nt.MUTATIONS.SET_REVOCATION_SUCCESSFUL,!1)}),(g,I)=>{const N=q("Modal");return f(),v("div",oit,[i.value?(f(),B(N,{key:0,title:g.$t("common.CONFIRMATION"),message:g.$t(r.value),onConfirmAction:I[0]||(I[0]=b=>O(E.value.id)),onCancelAction:I[1]||(I[1]=b=>h(!1)),onKeydown:I[2]||(I[2]=je(b=>h(!1),["esc"]))},null,8,["title","message"])):D("",!0),E.value&&E.value.client_id?(f(),v("div",iit,[T(n)||c.value?(f(),v("div",rit,A(g.$t(T(n)?"oauth2.APP_CREATED_SUCCESSFULLY":"oauth2.TOKENS_REVOKED")),1)):D("",!0),p("dl",null,[p("dt",null,A(g.$t("oauth2.APP.CLIENT_ID"))+":",1),p("dd",null,[x(A(E.value.client_id)+" ",1),T(n)&&d.value?(f(),v("i",{key:0,class:he(`fa fa-${u.value?"check":"copy"}`),"aria-hidden":"true",title:g.$t("oauth2.COPY_TO_CLIPBOARD"),onClick:S},null,10,uit)):D("",!0)]),T(n)&&E.value.client_secret?(f(),v("dt",lit,A(g.$t("oauth2.APP.CLIENT_SECRET"))+": ",1)):D("",!0),T(n)&&E.value.client_secret?(f(),v("dd",cit,[x(A(E.value.client_secret)+" ",1),d.value?(f(),v("i",{key:0,class:he(`fa fa-${l.value?"check":"copy"}`),"aria-hidden":"true",title:g.$t("oauth2.COPY_TO_CLIPBOARD"),onClick:R},null,10,dit)):D("",!0)])):D("",!0),p("dt",null,A(Fe(g.$t("oauth2.APP.ISSUE_AT")))+":",1),p("dd",null,[p("time",null,A(T($t)(E.value.issued_at,T(a).timezone,T(a).date_format)),1)]),p("dt",null,A(g.$t("oauth2.APP.NAME"))+":",1),p("dd",null,A(E.value.name),1),p("dt",null,A(g.$t("oauth2.APP.DESCRIPTION"))+":",1),p("dd",{class:he({"no-description":!E.value.client_description})},A(E.value.client_description?E.value.client_description:g.$t("common.NO_DESCRIPTION")),3),p("dt",null,A(g.$t("oauth2.APP.URL"))+":",1),p("dd",null,A(E.value.website),1),p("dt",null,A(g.$t("oauth2.APP.REDIRECT_URL"))+":",1),p("dd",null,A(E.value.redirect_uris.length>0?E.value.redirect_uris[0]:""),1),p("dt",null,A(g.$t("oauth2.APP.SCOPE.LABEL"))+":",1),p("dd",Eit,[(f(!0),v(le,null,be(E.value.scope.split(" "),b=>(f(),v("span",{class:"client-scope",key:b},[p("code",null,A(b),1)]))),128))])]),p("div",pit,[p("button",{class:"danger",onClick:I[3]||(I[3]=b=>_(!1))},A(g.$t("oauth2.REVOKE_ALL_TOKENS")),1),p("button",{class:"danger",onClick:I[4]||(I[4]=b=>_(!0))},A(g.$t("oauth2.DELETE_APP")),1),p("button",{onClick:I[5]||(I[5]=b=>g.$router.push("/profile/apps"))},A(g.$t("buttons.BACK")),1)])])):(f(),v("div",mit,[p("p",Tit,A(g.$t("oauth2.NO_APP")),1),p("button",{onClick:I[6]||(I[6]=b=>g.$router.push("/profile/apps"))},A(g.$t("buttons.BACK")),1)]))])}}}),kh=se(_it,[["__scopeId","data-v-7371d7c1"]]),fit={id:"oauth2-apps-list"},hit={class:"apps-list"},Sit={key:0},Ait={class:"app-issued-at"},Oit={key:1,class:"no-apps"},Iit={class:"app-list-buttons"},git=Q({__name:"UserAppsList",props:{authUser:{}},setup(e){const t=e,{authUser:n}=_e(t),a=$e(),s=it();let o=u(s.query);const i=F(()=>a.getters[nt.GETTERS.CLIENTS]),r=F(()=>a.getters[nt.GETTERS.CLIENTS_PAGINATION]);function u(d){const E={};return d.page&&(E.page=Hi(d.page,Ml)),E}function l(d){a.dispatch(nt.ACTIONS.GET_CLIENTS,d)}return Le(()=>s.query,async d=>{o=u(d),l(o)}),tt(()=>{l(o)}),(d,E)=>{const c=q("router-link");return f(),v("div",fit,[p("h1",hit,A(d.$t("oauth2.APPS_LIST")),1),i.value.length>0?(f(),v("ul",Sit,[(f(!0),v(le,null,be(i.value,m=>(f(),v("li",{key:m.client_id},[w(c,{to:{name:"UserApp",params:{id:m.id}}},{default:X(()=>[x(A(m.name),1)]),_:2},1032,["to"]),p("span",Ait,[x(A(d.$t("oauth2.APP.ISSUE_AT"))+" ",1),p("time",null,A(T($t)(m.issued_at,T(n).timezone,T(n).date_format)),1)])]))),128))])):(f(),v("div",Oit,A(d.$t("oauth2.NO_APPS")),1)),i.value.length>0?(f(),B(da,{key:2,pagination:r.value,path:"/profile/apps",query:T(o)},null,8,["pagination","query"])):D("",!0),p("div",Iit,[T(n).suspended_at?D("",!0):(f(),v("button",{key:0,onClick:E[0]||(E[0]=m=>d.$router.push("/profile/apps/new"))},A(d.$t("oauth2.NEW_APP")),1)),p("button",{onClick:E[1]||(E[1]=m=>d.$router.push("/"))},A(d.$t("common.HOME")),1)])])}}}),Rit=se(git,[["__scopeId","data-v-018b8e7c"]]);function cd(e){return e===0?!1:Array.isArray(e)&&e.length===0?!0:!e}function Nit(e){return(...t)=>!e(...t)}function vit(e,t){return e===void 0&&(e="undefined"),e===null&&(e="null"),e===!1&&(e="false"),e.toString().toLowerCase().indexOf(t.trim())!==-1}function eI(e,t,n,a){return t?e.filter(s=>vit(a(s,n),t)).sort((s,o)=>a(s,n).length-a(o,n).length):e}function bit(e){return e.filter(t=>!t.$isLabel)}function dd(e,t){return n=>n.reduce((a,s)=>s[e]&&s[e].length?(a.push({$groupLabel:s[t],$isLabel:!0}),a.concat(s[e])):a,[])}function Cit(e,t,n,a,s){return o=>o.map(i=>{if(!i[n])return console.warn("Options passed to vue-multiselect do not contain groups, despite the config."),[];const r=eI(i[n],e,t,s);return r.length?{[a]:i[a],[n]:r}:[]})}const wh=(...e)=>t=>e.reduce((n,a)=>a(n),t);var Pit={data(){return{search:"",isOpen:!1,preferredOpenDirection:"below",optimizedHeight:this.maxHeight}},props:{internalSearch:{type:Boolean,default:!0},options:{type:Array,required:!0},multiple:{type:Boolean,default:!1},trackBy:{type:String},label:{type:String},searchable:{type:Boolean,default:!0},clearOnSelect:{type:Boolean,default:!0},hideSelected:{type:Boolean,default:!1},placeholder:{type:String,default:"Select option"},allowEmpty:{type:Boolean,default:!0},resetAfter:{type:Boolean,default:!1},closeOnSelect:{type:Boolean,default:!0},customLabel:{type:Function,default(e,t){return cd(e)?"":t?e[t]:e}},taggable:{type:Boolean,default:!1},tagPlaceholder:{type:String,default:"Press enter to create a tag"},tagPosition:{type:String,default:"top"},max:{type:[Number,Boolean],default:!1},id:{default:null},optionsLimit:{type:Number,default:1e3},groupValues:{type:String},groupLabel:{type:String},groupSelect:{type:Boolean,default:!1},blockKeys:{type:Array,default(){return[]}},preserveSearch:{type:Boolean,default:!1},preselectFirst:{type:Boolean,default:!1},preventAutofocus:{type:Boolean,default:!1}},mounted(){!this.multiple&&this.max&&console.warn("[Vue-Multiselect warn]: Max prop should not be used when prop Multiple equals false."),this.preselectFirst&&!this.internalValue.length&&this.options.length&&this.select(this.filteredOptions[0])},computed:{internalValue(){return this.modelValue||this.modelValue===0?Array.isArray(this.modelValue)?this.modelValue:[this.modelValue]:[]},filteredOptions(){const e=this.search||"",t=e.toLowerCase().trim();let n=this.options.concat();return this.internalSearch?n=this.groupValues?this.filterAndFlat(n,t,this.label):eI(n,t,this.label,this.customLabel):n=this.groupValues?dd(this.groupValues,this.groupLabel)(n):n,n=this.hideSelected?n.filter(Nit(this.isSelected)):n,this.taggable&&t.length&&!this.isExistingOption(t)&&(this.tagPosition==="bottom"?n.push({isTag:!0,label:e}):n.unshift({isTag:!0,label:e})),n.slice(0,this.optionsLimit)},valueKeys(){return this.trackBy?this.internalValue.map(e=>e[this.trackBy]):this.internalValue},optionKeys(){return(this.groupValues?this.flatAndStrip(this.options):this.options).map(t=>this.customLabel(t,this.label).toString().toLowerCase())},currentOptionLabel(){return this.multiple?this.searchable?"":this.placeholder:this.internalValue.length?this.getOptionLabel(this.internalValue[0]):this.searchable?"":this.placeholder}},watch:{internalValue:{handler(){this.resetAfter&&this.internalValue.length&&(this.search="",this.$emit("update:modelValue",this.multiple?[]:null))},deep:!0},search(){this.$emit("search-change",this.search)}},emits:["open","search-change","close","select","update:modelValue","remove","tag"],methods:{getValue(){return this.multiple?this.internalValue:this.internalValue.length===0?null:this.internalValue[0]},filterAndFlat(e,t,n){return wh(Cit(t,n,this.groupValues,this.groupLabel,this.customLabel),dd(this.groupValues,this.groupLabel))(e)},flatAndStrip(e){return wh(dd(this.groupValues,this.groupLabel),bit)(e)},updateSearch(e){this.search=e},isExistingOption(e){return this.options?this.optionKeys.indexOf(e)>-1:!1},isSelected(e){const t=this.trackBy?e[this.trackBy]:e;return this.valueKeys.indexOf(t)>-1},isOptionDisabled(e){return!!e.$isDisabled},getOptionLabel(e){if(cd(e))return"";if(e.isTag)return e.label;if(e.$isLabel)return e.$groupLabel;const t=this.customLabel(e,this.label);return cd(t)?"":t},select(e,t){if(e.$isLabel&&this.groupSelect){this.selectGroup(e);return}if(!(this.blockKeys.indexOf(t)!==-1||this.disabled||e.$isDisabled||e.$isLabel)&&!(this.max&&this.multiple&&this.internalValue.length===this.max)&&!(t==="Tab"&&!this.pointerDirty)){if(e.isTag)this.$emit("tag",e.label,this.id),this.search="",this.closeOnSelect&&!this.multiple&&this.deactivate();else{if(this.isSelected(e)){t!=="Tab"&&this.removeElement(e);return}this.multiple?this.$emit("update:modelValue",this.internalValue.concat([e])):this.$emit("update:modelValue",e),this.$emit("select",e,this.id),this.clearOnSelect&&(this.search="")}this.closeOnSelect&&this.deactivate()}},selectGroup(e){const t=this.options.find(n=>n[this.groupLabel]===e.$groupLabel);if(t){if(this.wholeGroupSelected(t)){this.$emit("remove",t[this.groupValues],this.id);const n=this.trackBy?t[this.groupValues].map(s=>s[this.trackBy]):t[this.groupValues],a=this.internalValue.filter(s=>n.indexOf(this.trackBy?s[this.trackBy]:s)===-1);this.$emit("update:modelValue",a)}else{let n=t[this.groupValues].filter(a=>!(this.isOptionDisabled(a)||this.isSelected(a)));this.max&&n.splice(this.max-this.internalValue.length),this.$emit("select",n,this.id),this.$emit("update:modelValue",this.internalValue.concat(n))}this.closeOnSelect&&this.deactivate()}},wholeGroupSelected(e){return e[this.groupValues].every(t=>this.isSelected(t)||this.isOptionDisabled(t))},wholeGroupDisabled(e){return e[this.groupValues].every(this.isOptionDisabled)},removeElement(e,t=!0){if(this.disabled||e.$isDisabled)return;if(!this.allowEmpty&&this.internalValue.length<=1){this.deactivate();return}const n=typeof e=="object"?this.valueKeys.indexOf(e[this.trackBy]):this.valueKeys.indexOf(e);if(this.multiple){const a=this.internalValue.slice(0,n).concat(this.internalValue.slice(n+1));this.$emit("update:modelValue",a)}else this.$emit("update:modelValue",null);this.$emit("remove",e,this.id),this.closeOnSelect&&t&&this.deactivate()},removeLastElement(){this.blockKeys.indexOf("Delete")===-1&&this.search.length===0&&Array.isArray(this.internalValue)&&this.internalValue.length&&this.removeElement(this.internalValue[this.internalValue.length-1],!1)},activate(){this.isOpen||this.disabled||(this.adjustPosition(),this.groupValues&&this.pointer===0&&this.filteredOptions.length&&(this.pointer=1),this.isOpen=!0,this.searchable?(this.preserveSearch||(this.search=""),this.preventAutofocus||this.$nextTick(()=>this.$refs.search&&this.$refs.search.focus())):this.preventAutofocus||typeof this.$el<"u"&&this.$el.focus(),this.$emit("open",this.id))},deactivate(){this.isOpen&&(this.isOpen=!1,this.searchable?this.$refs.search!==null&&typeof this.$refs.search<"u"&&this.$refs.search.blur():typeof this.$el<"u"&&this.$el.blur(),this.preserveSearch||(this.search=""),this.$emit("close",this.getValue(),this.id))},toggle(){this.isOpen?this.deactivate():this.activate()},adjustPosition(){if(typeof window>"u")return;const e=this.$el.getBoundingClientRect().top,t=window.innerHeight-this.$el.getBoundingClientRect().bottom;t>this.maxHeight||t>e||this.openDirection==="below"||this.openDirection==="bottom"?(this.preferredOpenDirection="below",this.optimizedHeight=Math.min(t-40,this.maxHeight)):(this.preferredOpenDirection="above",this.optimizedHeight=Math.min(e-40,this.maxHeight))}}},Dit={data(){return{pointer:0,pointerDirty:!1}},props:{showPointer:{type:Boolean,default:!0},optionHeight:{type:Number,default:40}},computed:{pointerPosition(){return this.pointer*this.optionHeight},visibleElements(){return this.optimizedHeight/this.optionHeight}},watch:{filteredOptions(){this.pointerAdjust()},isOpen(){this.pointerDirty=!1},pointer(){this.$refs.search&&this.$refs.search.setAttribute("aria-activedescendant",this.id+"-"+this.pointer.toString())}},methods:{optionHighlight(e,t){return{"multiselect__option--highlight":e===this.pointer&&this.showPointer,"multiselect__option--selected":this.isSelected(t)}},groupHighlight(e,t){if(!this.groupSelect)return["multiselect__option--disabled",{"multiselect__option--group":t.$isLabel}];const n=this.options.find(a=>a[this.groupLabel]===t.$groupLabel);return n&&!this.wholeGroupDisabled(n)?["multiselect__option--group",{"multiselect__option--highlight":e===this.pointer&&this.showPointer},{"multiselect__option--group-selected":this.wholeGroupSelected(n)}]:"multiselect__option--disabled"},addPointerElement({key:e}="Enter"){this.filteredOptions.length>0&&this.select(this.filteredOptions[this.pointer],e),this.pointerReset()},pointerForward(){this.pointer0?(this.pointer--,this.$refs.list.scrollTop>=this.pointerPosition&&(this.$refs.list.scrollTop=this.pointerPosition),this.filteredOptions[this.pointer]&&this.filteredOptions[this.pointer].$isLabel&&!this.groupSelect&&this.pointerBackward()):this.filteredOptions[this.pointer]&&this.filteredOptions[0].$isLabel&&!this.groupSelect&&this.pointerForward(),this.pointerDirty=!0},pointerReset(){this.closeOnSelect&&(this.pointer=0,this.$refs.list&&(this.$refs.list.scrollTop=0))},pointerAdjust(){this.pointer>=this.filteredOptions.length-1&&(this.pointer=this.filteredOptions.length?this.filteredOptions.length-1:0),this.filteredOptions.length>0&&this.filteredOptions[this.pointer].$isLabel&&!this.groupSelect&&this.pointerForward()},pointerSet(e){this.pointer=e,this.pointerDirty=!0}}},tI={name:"vue-multiselect",mixins:[Pit,Dit],compatConfig:{MODE:3,ATTR_ENUMERATED_COERCION:!1},props:{name:{type:String,default:""},modelValue:{type:null,default(){return[]}},selectLabel:{type:String,default:"Press enter to select"},selectGroupLabel:{type:String,default:"Press enter to select group"},selectedLabel:{type:String,default:"Selected"},deselectLabel:{type:String,default:"Press enter to remove"},deselectGroupLabel:{type:String,default:"Press enter to deselect group"},showLabels:{type:Boolean,default:!0},limit:{type:Number,default:99999},maxHeight:{type:Number,default:300},limitText:{type:Function,default:e=>`and ${e} more`},loading:{type:Boolean,default:!1},disabled:{type:Boolean,default:!1},spellcheck:{type:Boolean,default:!1},openDirection:{type:String,default:""},showNoOptions:{type:Boolean,default:!0},showNoResults:{type:Boolean,default:!0},tabindex:{type:Number,default:0},required:{type:Boolean,default:!1}},computed:{hasOptionGroup(){return this.groupValues&&this.groupLabel&&this.groupSelect},isSingleLabelVisible(){return(this.singleValue||this.singleValue===0)&&(!this.isOpen||!this.searchable)&&!this.visibleValues.length},isPlaceholderVisible(){return!this.internalValue.length&&(!this.searchable||!this.isOpen)},visibleValues(){return this.multiple?this.internalValue.slice(0,this.limit):[]},singleValue(){return this.internalValue[0]},deselectLabelText(){return this.showLabels?this.deselectLabel:""},deselectGroupLabelText(){return this.showLabels?this.deselectGroupLabel:""},selectLabelText(){return this.showLabels?this.selectLabel:""},selectGroupLabelText(){return this.showLabels?this.selectGroupLabel:""},selectedLabelText(){return this.showLabels?this.selectedLabel:""},inputStyle(){return this.searchable||this.multiple&&this.modelValue&&this.modelValue.length?this.isOpen?{width:"100%"}:{width:"0",position:"absolute",padding:"0"}:""},contentStyle(){return this.options.length?{display:"inline-block"}:{display:"block"}},isAbove(){return this.openDirection==="above"||this.openDirection==="top"?!0:this.openDirection==="below"||this.openDirection==="bottom"?!1:this.preferredOpenDirection==="above"},showSearchInput(){return this.searchable&&(this.hasSingleSelectedSlot&&(this.visibleSingleValue||this.visibleSingleValue===0)?this.isOpen:!0)}}};const Lit={ref:"tags",class:"multiselect__tags"},yit={class:"multiselect__tags-wrap"},$it={class:"multiselect__spinner"},Uit={key:0},kit={class:"multiselect__option"},wit={class:"multiselect__option"},Mit=x("No elements found. Consider changing the search query."),Wit={class:"multiselect__option"},Fit=x("List is empty.");function zit(e,t,n,a,s,o){return f(),B("div",{tabindex:e.searchable?-1:n.tabindex,class:[{"multiselect--active":e.isOpen,"multiselect--disabled":n.disabled,"multiselect--above":o.isAbove,"multiselect--has-options-group":o.hasOptionGroup},"multiselect"],onFocus:t[14]||(t[14]=i=>e.activate()),onBlur:t[15]||(t[15]=i=>e.searchable?!1:e.deactivate()),onKeydown:[t[16]||(t[16]=je(Ne(i=>e.pointerForward(),["self","prevent"]),["down"])),t[17]||(t[17]=je(Ne(i=>e.pointerBackward(),["self","prevent"]),["up"]))],onKeypress:t[18]||(t[18]=je(Ne(i=>e.addPointerElement(i),["stop","self"]),["enter","tab"])),onKeyup:t[19]||(t[19]=je(i=>e.deactivate(),["esc"])),role:"combobox","aria-owns":"listbox-"+e.id},[Pt(e.$slots,"caret",{toggle:e.toggle},()=>[w("div",{onMousedown:t[1]||(t[1]=Ne(i=>e.toggle(),["prevent","stop"])),class:"multiselect__select"},null,32)]),Pt(e.$slots,"clear",{search:e.search}),w("div",Lit,[Pt(e.$slots,"selection",{search:e.search,remove:e.removeElement,values:o.visibleValues,isOpen:e.isOpen},()=>[Me(w("div",yit,[(f(!0),B(le,null,be(o.visibleValues,(i,r)=>Pt(e.$slots,"tag",{option:i,search:e.search,remove:e.removeElement},()=>[(f(),B("span",{class:"multiselect__tag",key:r},[w("span",{textContent:A(e.getOptionLabel(i))},null,8,["textContent"]),w("i",{tabindex:"1",onKeypress:je(Ne(u=>e.removeElement(i),["prevent"]),["enter"]),onMousedown:Ne(u=>e.removeElement(i),["prevent"]),class:"multiselect__tag-icon"},null,40,["onKeypress","onMousedown"])]))])),256))],512),[[Ho,o.visibleValues.length>0]]),e.internalValue&&e.internalValue.length>n.limit?Pt(e.$slots,"limit",{key:0},()=>[w("strong",{class:"multiselect__strong",textContent:A(n.limitText(e.internalValue.length-n.limit))},null,8,["textContent"])]):D("v-if",!0)]),w(ym,{name:"multiselect__loading"},{default:X(()=>[Pt(e.$slots,"loading",{},()=>[Me(w("div",$it,null,512),[[Ho,n.loading]])])]),_:3}),e.searchable?(f(),B("input",{key:0,ref:"search",name:n.name,id:e.id,type:"text",autocomplete:"off",spellcheck:n.spellcheck,placeholder:e.placeholder,required:n.required,style:o.inputStyle,value:e.search,disabled:n.disabled,tabindex:n.tabindex,onInput:t[2]||(t[2]=i=>e.updateSearch(i.target.value)),onFocus:t[3]||(t[3]=Ne(i=>e.activate(),["prevent"])),onBlur:t[4]||(t[4]=Ne(i=>e.deactivate(),["prevent"])),onKeyup:t[5]||(t[5]=je(i=>e.deactivate(),["esc"])),onKeydown:[t[6]||(t[6]=je(Ne(i=>e.pointerForward(),["prevent"]),["down"])),t[7]||(t[7]=je(Ne(i=>e.pointerBackward(),["prevent"]),["up"])),t[9]||(t[9]=je(Ne(i=>e.removeLastElement(),["stop"]),["delete"]))],onKeypress:t[8]||(t[8]=je(Ne(i=>e.addPointerElement(i),["prevent","stop","self"]),["enter"])),class:"multiselect__input","aria-controls":"listbox-"+e.id},null,44,["name","id","spellcheck","placeholder","required","value","disabled","tabindex","aria-controls"])):D("v-if",!0),o.isSingleLabelVisible?(f(),B("span",{key:1,class:"multiselect__single",onMousedown:t[10]||(t[10]=Ne((...i)=>e.toggle&&e.toggle(...i),["prevent"]))},[Pt(e.$slots,"singleLabel",{option:o.singleValue},()=>[x(A(e.currentOptionLabel),1)])],32)):D("v-if",!0),o.isPlaceholderVisible?(f(),B("span",{key:2,class:"multiselect__placeholder",onMousedown:t[11]||(t[11]=Ne((...i)=>e.toggle&&e.toggle(...i),["prevent"]))},[Pt(e.$slots,"placeholder",{},()=>[x(A(e.placeholder),1)])],32)):D("v-if",!0)],512),w(ym,{name:"multiselect"},{default:X(()=>[Me(w("div",{class:"multiselect__content-wrapper",onFocus:t[12]||(t[12]=(...i)=>e.activate&&e.activate(...i)),tabindex:"-1",onMousedown:t[13]||(t[13]=Ne(()=>{},["prevent"])),style:{maxHeight:e.optimizedHeight+"px"},ref:"list"},[w("ul",{class:"multiselect__content",style:o.contentStyle,role:"listbox",id:"listbox-"+e.id,"aria-multiselectable":e.multiple},[Pt(e.$slots,"beforeList"),e.multiple&&e.max===e.internalValue.length?(f(),B("li",Uit,[w("span",kit,[Pt(e.$slots,"maxElements",{},()=>[x("Maximum of "+A(e.max)+" options selected. First remove a selected option to select another.",1)])])])):D("v-if",!0),!e.max||e.internalValue.length(f(),B("li",{class:"multiselect__element",key:r,"aria-selected":e.isSelected(i),id:e.id+"-"+r,role:i&&(i.$isLabel||i.$isDisabled)?null:"option"},[i&&(i.$isLabel||i.$isDisabled)?D("v-if",!0):(f(),B("span",{key:0,class:[e.optionHighlight(r,i),"multiselect__option"],onClick:Ne(u=>e.select(i),["stop"]),onMouseenter:Ne(u=>e.pointerSet(r),["self"]),"data-select":i&&i.isTag?e.tagPlaceholder:o.selectLabelText,"data-selected":o.selectedLabelText,"data-deselect":o.deselectLabelText},[Pt(e.$slots,"option",{option:i,search:e.search,index:r},()=>[w("span",null,A(e.getOptionLabel(i)),1)])],42,["onClick","onMouseenter","data-select","data-selected","data-deselect"])),i&&(i.$isLabel||i.$isDisabled)?(f(),B("span",{key:1,"data-select":e.groupSelect&&o.selectGroupLabelText,"data-deselect":e.groupSelect&&o.deselectGroupLabelText,class:[e.groupHighlight(r,i),"multiselect__option"],onMouseenter:Ne(u=>e.groupSelect&&e.pointerSet(r),["self"]),onMousedown:Ne(u=>e.selectGroup(i),["prevent"])},[Pt(e.$slots,"option",{option:i,search:e.search,index:r},()=>[w("span",null,A(e.getOptionLabel(i)),1)])],42,["data-select","data-deselect","onMouseenter","onMousedown"])):D("v-if",!0)],8,["aria-selected","id","role"]))),128)):D("v-if",!0),Me(w("li",null,[w("span",wit,[Pt(e.$slots,"noResult",{search:e.search},()=>[Mit])])],512),[[Ho,n.showNoResults&&e.filteredOptions.length===0&&e.search&&!n.loading]]),Me(w("li",null,[w("span",Wit,[Pt(e.$slots,"noOptions",{},()=>[Fit])])],512),[[Ho,n.showNoOptions&&(e.options.length===0||o.hasOptionGroup===!0&&e.filteredOptions.length===0)&&!e.search&&!n.loading]]),Pt(e.$slots,"afterList")],12,["id","aria-multiselectable"])],36),[[Ho,e.isOpen]])]),_:3})],42,["tabindex","aria-owns"])}tI.render=zit;const xit=Q({__name:"SportsMultiSelect",props:{sports:{},name:{},equipmentSports:{default:()=>[]},disabled:{type:Boolean,default:!1}},emits:["updatedValues"],setup(e,{emit:t}){const n=e,{equipmentSports:a,name:s,sports:o}=_e(n),i=t,r=Se([]);function u(l){i("updatedValues",l.map(d=>d.id))}return Le(()=>a.value,async l=>{r.value=l,u(l)}),tt(()=>{a.value&&(r.value=a.value)}),(l,d)=>T(o)?(f(),B(T(tI),{key:0,placeholder:"",id:T(s),name:T(s),disabled:l.disabled,modelValue:r.value,"onUpdate:modelValue":[d[0]||(d[0]=E=>r.value=E),u],multiple:!0,options:T(o),taggable:!0,label:"translatedLabel","track-by":"id",selectLabel:l.$t("workouts.MULTISELECT.selectLabel"),selectedLabel:l.$t("workouts.MULTISELECT.selectedLabel"),deselectLabel:l.$t("workouts.MULTISELECT.deselectLabel")},null,8,["id","name","disabled","modelValue","options","selectLabel","selectedLabel","deselectLabel"])):D("",!0)}}),Bit=se(xit,[["__scopeId","data-v-016d8e47"]]);function Yp(){const e=it(),t=$e(),{t:n}=yt(),a=F(()=>u(s.value)),s=F(()=>t.getters[Be.GETTERS.EQUIPMENTS]),o=F(()=>t.getters[Be.GETTERS.LOADING]),i=F(()=>t.getters[Be.GETTERS.EQUIPMENT_TYPES]),r=F(()=>DO(i.value,n));function u(l){if(!e.params.id)return null;const d=l.filter(E=>e.params.id?E.id===e.params.id:null);return d.length===0?null:d[0]}return{equipment:a,equipments:s,equipmentTypes:i,translatedEquipmentTypes:r,equipmentsLoading:o}}const Git={id:"new-equipment"},Vit={key:0,id:"new-equipment-title"},Hit={id:"equipment-form"},Kit={class:"form-items"},qit={class:"form-item"},jit={for:"equipment-label"},Yit={class:"equipment-label-help"},Xit={class:"info-box"},Qit={class:"form-item"},Zit={for:"equipment-type-id"},Jit=["value"],ert={key:0,class:"equipment-warning"},trt={class:"info-box"},nrt={class:"form-item"},art={for:"equipment-description"},srt={key:1,class:"form-item-checkbox"},ort={for:"equipment-active"},irt={class:"form-item"},rrt={for:"equipment-sports"},urt={class:"form-buttons"},lrt=["disabled"],crt=["disabled"],drt=Q({__name:"EquipmentEdition",props:{translatedEquipmentTypes:{},equipmentsLoading:{type:Boolean}},setup(e){const t=e,{equipmentsLoading:n,translatedEquipmentTypes:a}=_e(t),s=$e(),o=it(),{t:i}=yt(),{errorMessages:r}=He(),{equipment:u}=Yp(),l=kt({id:"",label:"",description:"",equipmentTypeId:0,isActive:!0,defaultForSportIds:[]}),d=Se(!1),E=F(()=>ca(s.getters[Zt.GETTERS.SPORTS],i)),c=F(()=>a.value.filter(b=>b.id===l.equipmentTypeId)),m=F(()=>c.value.length>0?E.value.filter(b=>LO[c.value[0].label].includes(b.label)):[]),_=Se([]),h=F(()=>a.value.filter(b=>{var C;return b.is_active||((C=u.value)==null?void 0:C.equipment_type.id)===b.id}));function O(b){_.value=ca(E.value,i,"all").filter(C=>b.default_for_sport_ids.includes(C.id))}function S(b){l.id=b.id,l.label=b.label,l.description=b.description?b.description:"",l.equipmentTypeId=b.equipment_type.id,l.isActive=b.is_active,O(b)}function R(){s.dispatch(Be.ACTIONS[l.id?"UPDATE_EQUIPMENT":"ADD_EQUIPMENT"],l)}function g(b){l.description=b}function I(){d.value=!0}function N(b){l.defaultForSportIds=b}return Le(()=>u.value,b=>{o.params.id&&(b!=null&&b.id)&&S(b)}),Le(()=>l.equipmentTypeId,b=>{u.value&&b===u.value.equipment_type.id?O(u.value):_.value=[]}),Tt(()=>{var C;const b=document.getElementById("equipment-label");b==null||b.focus(),o.params.id&&o.params.id&&(C=u.value)!=null&&C.id&&S(u.value)}),(b,C)=>{var $,y;const k=q("CustomTextArea"),P=q("ErrorMessage");return f(),v("div",Git,[l.id?D("",!0):(f(),v("h1",Vit,A(b.$t("equipments.ADD_A_NEW_EQUIPMENT")),1)),p("div",Hit,[p("form",{class:he({errors:d.value}),onSubmit:Ne(R,["prevent"])},[p("div",Kit,[p("div",qit,[p("label",jit,A(Fe(b.$t("common.LABEL")))+"* ",1),Me(p("input",{id:"equipment-label",maxlength:"50",type:"text",required:"",onInvalid:I,"onUpdate:modelValue":C[0]||(C[0]=z=>l.label=z)},null,544),[[st,l.label]]),p("div",Yit,[p("span",Xit,[C[4]||(C[4]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(b.$t("equipments.50_CHARACTERS_MAX")),1)])])]),p("div",Qit,[p("label",Zit,A(Fe(b.$t("equipments.EQUIPMENT_TYPE")))+"* ",1),Me(p("select",{id:"equipment-type-id",required:"",onInvalid:I,"onUpdate:modelValue":C[1]||(C[1]=z=>l.equipmentTypeId=z)},[(f(!0),v(le,null,be(h.value,z=>(f(),v("option",{value:z.id,key:z.id},A(z.translatedLabel)+" "+A(z.is_active?"":`(${b.$t("common.INACTIVE")})`),9,Jit))),128))],544),[[Sn,l.equipmentTypeId]])]),($=T(u))!=null&&$.workouts_count&&l.equipmentTypeId!==((y=T(u))==null?void 0:y.equipment_type.id)?(f(),v("div",ert,[p("span",trt,[C[5]||(C[5]=p("i",{class:"fa fa-exclamation-triangle warning","aria-hidden":"true"},null,-1)),x(" "+A(b.$t("equipments.ALL_WORKOUTS_ASSOCIATIONS_REMOVED")),1)])])):D("",!0),p("div",nrt,[p("label",art,A(b.$t("common.DESCRIPTION")),1),w(k,{name:"equipment-description",charLimit:200,input:l.description,onUpdateValue:g},null,8,["input"])]),l.id?(f(),v("div",srt,[p("label",ort,A(Fe(b.$t("common.ACTIVE"))),1),Me(p("input",{id:"equipment-active",name:"equipment-active",type:"checkbox","onUpdate:modelValue":C[2]||(C[2]=z=>l.isActive=z)},null,512),[[cl,l.isActive]])])):D("",!0),p("div",irt,[p("label",rrt,A(Fe(b.$t("equipments.DEFAULT_FOR_SPORTS",0))),1),w(Bit,{sports:m.value,name:"equipment-sports",equipmentSports:_.value,disabled:!l.equipmentTypeId,onUpdatedValues:N},null,8,["sports","equipmentSports","disabled"])])]),T(r)?(f(),B(P,{key:0,message:T(r)},null,8,["message"])):D("",!0),p("div",urt,[p("button",{class:"confirm",type:"submit",disabled:T(n)},A(b.$t("buttons.SUBMIT")),9,lrt),p("button",{class:"cancel",disabled:T(n),onClick:C[3]||(C[3]=Ne(()=>{var z;return b.$router.push((z=T(u))!=null&&z.id?b.$route.query.fromEdition?"/profile/edit/equipments":`/profile/equipments/${T(u).id}`:"/profile/equipments")},["prevent"]))},A(b.$t("buttons.CANCEL")),9,crt)])],34)])])}}}),Mh=se(drt,[["__scopeId","data-v-a596b0f9"]]),Ert={key:0,id:"user-equipments"},Wh=Q({__name:"index",props:{user:{},isEdition:{type:Boolean}},setup(e){const t=e,{user:n}=_e(t),a=it(),s=$e(),{equipments:o,translatedEquipmentTypes:i,equipmentsLoading:r}=Yp();return Le(()=>a.name,u=>{u==="UserEquipmentsList"&&s.dispatch(Be.ACTIONS.GET_EQUIPMENTS)}),tt(()=>{s.dispatch(Be.ACTIONS.GET_EQUIPMENT_TYPES),s.dispatch(Be.ACTIONS.GET_EQUIPMENTS)}),(u,l)=>{const d=q("router-view");return T(i)?(f(),v("div",Ert,[w(d,{authUser:T(n),equipments:T(o),translatedEquipmentTypes:T(i),isEdition:u.isEdition,equipmentsLoading:T(r)},null,8,["authUser","equipments","translatedEquipmentTypes","isEdition","equipmentsLoading"])])):D("",!0)}}}),prt=(e,t=!1)=>{let n="0";t&&(n=String(Math.floor(e/86400)),e%=86400);const a=String(Math.floor(e/3600)).padStart(2,"0");e%=3600;const s=String(Math.floor(e/60)).padStart(2,"0"),o=String(e%60).padStart(2,"0");return t?`${n==="0"?"":`${n}d `}${a==="00"?"":`${a}h `}${s}m ${o}s`:`${a==="00"?"":`${a}:`}${s}:${o}`},Xp=(e,t)=>{const n=e.match(/day/g)?e.split(", ")[1]:e;return{days:e.match(/day/g)?`${e.split(" ")[0]} ${e.match(/days/g)?t("common.DAY",2):t("common.DAY",1)}`:`0 ${t("common.DAY",2)},`,duration:`${n.split(":")[0]}h ${n.split(":")[1]}min`}},AE=(e,t)=>{if(e.match(/day/g)){const n=Xp(e,t);return`${n.days}, ${n.duration}`}return e},mrt={key:0,id:"user-equipment",class:"description-list"},Trt={class:"equipment-type"},_rt={key:0,class:"equipment-description"},frt={key:1,class:"no-description"},hrt={class:"duration-detail"},Srt={class:"sports-list"},Art={class:"equipment-buttons"},Ort=["disabled"],Irt=["disabled"],grt=["disabled"],Rrt=["disabled"],Nrt={key:1},vrt={class:"no-equipment"},brt=["disabled"],Crt=Q({__name:"UserEquipment",props:{authUser:{},equipmentsLoading:{type:Boolean}},setup(e){const t=e,{authUser:n}=_e(t),a=$e(),{t:s}=yt(),{errorMessages:o}=He(),{equipment:i}=Yp(),{sportColors:r,sports:u}=ln(),l=Se(!1),d=F(()=>ca(u.value,s,"all",n.value.sports_list).filter(_=>{var h;return i.value?(h=i.value)==null?void 0:h.default_for_sport_ids.includes(_.id):!1}));function E(_){l.value=_}function c(){var _,h;if((_=i.value)!=null&&_.id){const O={id:i.value.id};((h=i.value)==null?void 0:h.workouts_count)>0&&(O.force=!0),a.dispatch(Be.ACTIONS.DELETE_EQUIPMENT,O)}}function m(_){a.dispatch(Be.ACTIONS.REFRESH_EQUIPMENT,_)}return tt(()=>{a.dispatch(Be.ACTIONS.GET_EQUIPMENTS)}),(_,h)=>{const O=q("Modal"),S=q("EquipmentTypeImage"),R=q("router-link"),g=q("Distance"),I=q("SportImage"),N=q("ErrorMessage");return T(i)?(f(),v("div",mrt,[l.value?(f(),B(O,{key:0,title:_.$t("common.CONFIRMATION"),message:"user.PROFILE.EQUIPMENTS.CONFIRM_EQUIPMENT_DELETION",strongMessage:T(i).label,warning:T(i).workouts_count>0?_.$t("user.PROFILE.EQUIPMENTS.EQUIPMENT_ASSOCIATED_WITH_WORKOUTS"):"",onConfirmAction:c,onCancelAction:h[0]||(h[0]=b=>E(!1)),onKeydown:h[1]||(h[1]=je(b=>E(!1),["esc"]))},null,8,["title","strongMessage","warning"])):D("",!0),p("dl",null,[p("dt",null,A(Fe(_.$t("common.LABEL"))),1),p("dd",null,A(T(i).label),1),p("dt",null,A(Fe(_.$t("equipments.EQUIPMENT_TYPE"))),1),p("dd",Trt,[w(S,{title:_.$t(`equipment_types.${T(i).equipment_type.label}.LABEL`),"equipment-type-label":T(i).equipment_type.label},null,8,["title","equipment-type-label"]),p("span",null,A(_.$t(`equipment_types.${T(i).equipment_type.label}.LABEL`))+" "+A(T(i).equipment_type.is_active?"":`(${_.$t("common.INACTIVE")})`),1)]),p("dt",null,A(_.$t("common.DESCRIPTION")),1),p("dd",null,[T(i).description?(f(),v("span",_rt,A(T(i).description),1)):(f(),v("span",frt,A(_.$t("common.NO_DESCRIPTION")),1))]),p("dt",null,A(Fe(_.$t("workouts.WORKOUT",0))),1),p("dd",null,[T(i).workouts_count?(f(),B(R,{key:0,to:`/workouts?equipment_id=${T(i).id}`},{default:X(()=>[x(A(T(i).workouts_count),1)]),_:1},8,["to"])):(f(),v(le,{key:1},[x(A(T(i).workouts_count),1)],64))]),p("dt",null,A(Fe(_.$t("workouts.TOTAL_DISTANCE",0))),1),p("dd",null,[w(g,{distance:T(i).total_distance,unitFrom:"km",digits:2,displayUnit:!1,useImperialUnits:T(n).imperial_units},null,8,["distance","useImperialUnits"]),p("span",null,A(T(n).imperial_units?"miles":"km"),1)]),p("dt",null,A(Fe(_.$t("workouts.TOTAL_DURATION",0))),1),p("dd",null,[x(A(T(AE)(T(i).total_moving,_.$t))+" ",1),T(i).total_duration!==T(i).total_moving?(f(),v(le,{key:0},[h[7]||(h[7]=x(" (")),p("span",hrt,A(_.$t("common.TOTAL_DURATION_WITH_PAUSES"))+": ",1),x(" "+A(T(AE)(T(i).total_duration,_.$t))+") ",1)],64)):D("",!0)]),p("dt",null,A(Fe(_.$t("common.ACTIVE",0))),1),p("dd",null,[p("i",{class:he(`fa fa-${T(i).is_active?"check-":""}square-o`),"aria-hidden":"true"},null,2)]),T(i).default_for_sport_ids.length>0?(f(),v(le,{key:0},[p("dt",null,A(Fe(_.$t("equipments.DEFAULT_FOR_SPORTS",0))),1),p("dd",Srt,[(f(!0),v(le,null,be(d.value,b=>(f(),v("span",{class:he(["sport-badge",{inactive:!b.is_active_for_user}]),key:b.label},[w(I,{title:b.translatedLabel,"sport-label":b.label,color:b.color?b.color:T(r)[b.label]},null,8,["title","sport-label","color"]),w(R,{to:`/profile/sports/${b.id}?fromEquipmentId=${T(i).id}`},{default:X(()=>[x(A(b.translatedLabel)+" "+A(b.is_active_for_user?"":`(${_.$t("common.INACTIVE")})`),1)]),_:2},1032,["to"])],2))),128))])],64)):D("",!0)]),T(o)?(f(),B(N,{key:1,message:T(o)},null,8,["message"])):D("",!0),p("div",Art,[T(n).suspended_at?D("",!0):(f(),v(le,{key:0},[p("button",{onClick:h[2]||(h[2]=b=>_.$router.push(`/profile/edit/equipments/${T(i).id}`)),disabled:_.equipmentsLoading},A(_.$t("buttons.EDIT")),9,Ort),p("button",{disabled:_.equipmentsLoading,onClick:h[3]||(h[3]=b=>m(T(i).id))},A(_.$t("buttons.REFRESH_TOTALS")),9,Irt),p("button",{class:"danger",onClick:h[4]||(h[4]=b=>l.value=!0),disabled:_.equipmentsLoading},A(_.$t("buttons.DELETE")),9,grt)],64)),p("button",{disabled:_.equipmentsLoading,onClick:h[5]||(h[5]=b=>_.$router.push(_.$route.query.fromWorkoutId?`/workouts/${_.$route.query.fromWorkoutId}`:_.$route.query.fromSportId?`/profile/sports/${_.$route.query.fromSportId}`:"/profile/equipments"))},A(_.$t("buttons.BACK")),9,Rrt)])])):(f(),v("div",Nrt,[p("p",vrt,A(_.$t("equipments.NO_EQUIPMENT")),1),p("button",{onClick:h[6]||(h[6]=b=>_.$router.push("/profile/equipments")),disabled:_.equipmentsLoading},A(_.$t("buttons.BACK")),9,brt)]))}}}),Prt=se(Crt,[["__scopeId","data-v-a3438555"]]),Drt={id:"user-equipments-list"},Lrt={key:0,class:"mobile-display"},yrt={key:1,class:"equipments-list"},$rt={key:3},Urt={class:"responsive-table"},krt={class:"text-left"},wrt={class:"text-left"},Mrt={class:"text-left"},Wrt={class:"text-left"},Frt={key:0},zrt={class:"equipment-label"},xrt={class:"cell-heading"},Brt={class:"column"},Grt={class:"cell-heading"},Vrt={class:"column"},Hrt={class:"cell-heading"},Krt={class:"active"},qrt={class:"cell-heading"},jrt={key:0,class:"action-buttons"},Yrt={class:"cell-heading"},Xrt=["onClick"],Qrt={class:"equipments-list-buttons"},Zrt=Q({__name:"UserEquipmentsList",props:{equipments:{},translatedEquipmentTypes:{},authUser:{},isEdition:{type:Boolean}},setup(e){const t=e,{authUser:n,isEdition:a,equipments:s,translatedEquipmentTypes:o}=_e(t),i=F(()=>r(s.value));function r(u){const l={};return u.map(d=>{d.equipment_type.id in l?l[d.equipment_type.id].push(d):l[d.equipment_type.id]=[d]}),l}return(u,l)=>{const d=q("EquipmentTypeImage"),E=q("router-link"),c=q("Distance");return f(),v("div",Drt,[T(s).length>0?(f(),v("div",Lrt,[T(a)?D("",!0):(f(),v("button",{key:0,onClick:l[0]||(l[0]=m=>u.$router.push("/profile/edit/equipments"))},A(u.$t("equipments.EDIT_EQUIPMENTS")),1)),T(a)?D("",!0):(f(),v("button",{key:1,onClick:l[1]||(l[1]=m=>u.$router.push("/profile/equipments/new"))},A(u.$t("equipments.NEW_EQUIPMENT")),1)),T(a)?(f(),v("button",{key:2,onClick:l[2]||(l[2]=m=>u.$router.push("/profile/equipments"))},A(u.$t("buttons.BACK")),1)):(f(),v("button",{key:3,onClick:l[3]||(l[3]=m=>u.$router.push("/"))},A(u.$t("common.HOME")),1))])):D("",!0),T(a)?D("",!0):(f(),v("h1",yrt,A(u.$t("user.PROFILE.EQUIPMENTS.YOUR_EQUIPMENTS")),1)),T(s).length===0?(f(),v("p",{key:2,class:he(["no-equipments",{edition:T(a)}])},A(u.$t("equipments.NO_EQUIPMENTS")),3)):(f(),v("div",$rt,[(f(!0),v(le,null,be(T(o),m=>(f(),v(le,{key:m.label},[i.value[m.id]?(f(),v(le,{key:0},[p("h2",null,[w(d,{title:m.translatedLabel,"equipment-type-label":m.label},null,8,["title","equipment-type-label"]),x(" "+A(m.translatedLabel)+" "+A(m.is_active?"":`(${u.$t("common.INACTIVE")})`),1)]),p("div",Urt,[p("table",null,[p("thead",null,[p("tr",null,[p("th",krt,A(u.$t("common.LABEL")),1),p("th",wrt,A(u.$t("workouts.WORKOUT",0)),1),p("th",Mrt,A(Fe(u.$t("workouts.TOTAL_DISTANCE"))),1),p("th",Wrt,A(u.$t("common.ACTIVE")),1),T(a)&&!T(n).suspended_at?(f(),v("th",Frt,A(u.$t("common.ACTION")),1)):D("",!0),l[8]||(l[8]=p("th",null,null,-1))])]),p("tbody",null,[(f(!0),v(le,null,be(i.value[m.id].sort(T(Vp)),_=>(f(),v("tr",{key:_.label},[p("td",zrt,[p("span",xrt,A(u.$t("common.LABEL")),1),w(E,{to:{name:"Equipment",params:{id:_.id}}},{default:X(()=>[x(A(_.label),1)]),_:2},1032,["to"])]),p("td",Brt,[p("span",Grt,A(u.$t("workouts.WORKOUT",0)),1),_.workouts_count?(f(),B(E,{key:0,to:`/workouts?equipment_id=${_.id}`},{default:X(()=>[x(A(_.workouts_count),1)]),_:2},1032,["to"])):(f(),v(le,{key:1},[x(A(_.workouts_count),1)],64))]),p("td",Vrt,[p("span",Hrt,A(u.$t("workouts.TOTAL_DISTANCE",0)),1),w(c,{distance:_.total_distance,unitFrom:"km",digits:2,displayUnit:!1,useImperialUnits:T(n).imperial_units},null,8,["distance","useImperialUnits"]),p("span",null,A(T(n).imperial_units?"miles":"km"),1)]),p("td",Krt,[p("span",qrt,A(u.$t("common.ACTIVE")),1),p("i",{class:he(`fa fa${_.is_active?"-check":""}`),"aria-hidden":"true"},null,2)]),T(a)&&!T(n).suspended_at?(f(),v("td",jrt,[p("span",Yrt,A(u.$t("user.PROFILE.SPORT.ACTION")),1),p("button",{onClick:h=>u.$router.push(`/profile/edit/equipments/${_.id}${T(a)?"?fromEdition=true":""}`)},A(u.$t("buttons.EDIT")),9,Xrt)])):D("",!0)]))),128))])])])],64)):D("",!0)],64))),128))])),p("div",Qrt,[!T(a)&&!T(n).suspended_at&&T(s).length>0?(f(),v("button",{key:0,onClick:l[4]||(l[4]=m=>u.$router.push("/profile/edit/equipments"))},A(u.$t("equipments.EDIT_EQUIPMENTS")),1)):D("",!0),!T(a)&&!T(n).suspended_at?(f(),v("button",{key:1,onClick:l[5]||(l[5]=m=>u.$router.push("/profile/equipments/new"))},A(u.$t("equipments.NEW_EQUIPMENT")),1)):D("",!0),T(a)?(f(),v("button",{key:2,onClick:l[6]||(l[6]=m=>u.$router.push("/profile/equipments"))},A(u.$t("buttons.BACK")),1)):(f(),v("button",{key:3,onClick:l[7]||(l[7]=m=>u.$router.push("/"))},A(u.$t("common.HOME")),1))])])}}}),Fh=se(Zrt,[["__scopeId","data-v-235c4af3"]]),Jrt=Q({__name:"index",props:{user:{}},setup(e){const t=e,{user:n}=_e(t);return(a,s)=>{const o=q("router-view");return f(),v("div",null,[w(o,{authUser:T(n)},null,8,["authUser"])])}}}),eut={key:0,class:"notification-object"},tut={class:"box comment-box"},nut=Q({__name:"CommentForUser",props:{comment:{},displayObjectName:{type:Boolean},action:{}},setup(e){const t=e,{comment:n,displayObjectName:a}=_e(t),{authUser:s}=Ke();return(o,i)=>(f(),v(le,null,[T(a)?(f(),v("div",eut,A(o.$t("workouts.COMMENTS.COMMENT"))+": ",1)):D("",!0),p("div",tut,[w(zp,{comment:T(n),authUser:T(s),"display-appeal":!1,"hide-suspension-appeal":T(a),"comments-loading":"null","for-notification":!0,action:o.action},null,8,["comment","authUser","hide-suspension-appeal","action"])])],64))}}),Qp=se(nut,[["__scopeId","data-v-816618bb"]]),aut={class:"appeal-action"},sut=Q({__name:"WorkoutActionAppeal",props:{action:{},workout:{},displaySuspensionMessage:{type:Boolean,default:!1}},setup(e){const t=e,{workout:n}=_e(t),{appealLoading:a,displayAppealForm:s,success:o,submitAppeal:i,cancelAppeal:r}=Tp(),u=F(()=>`workout_${n.value.id}`);return(l,d)=>(f(),v("div",aut,[p("div",{class:he({suspended:l.displaySuspensionMessage,"info-box":l.displaySuspensionMessage})},[l.displaySuspensionMessage?(f(),v(le,{key:0},[d[4]||(d[4]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(l.$t("workouts.SUSPENDED_BY_ADMIN")),1)],64)):D("",!0),l.displaySuspensionMessage&&!T(o)&&!T(s)?(f(),v("button",{key:1,class:"transparent appeal-button",onClick:d[0]||(d[0]=E=>s.value=u.value)},A(l.$t("user.APPEAL")),1)):D("",!0)],2),T(s)?(f(),B(vl,{key:0,"report-action":l.action,success:T(o)===u.value,loading:T(a)===u.value,onSubmitForm:d[2]||(d[2]=E=>T(i)(E,"workout",T(n).id)),onHideMessage:d[3]||(d[3]=E=>s.value=null)},{cancelButton:X(()=>[p("button",{onClick:d[1]||(d[1]=E=>T(r)())},A(l.$t("buttons.CANCEL")),1)]),_:1},8,["report-action","success","loading"])):D("",!0)]))}}),nI=se(sut,[["__scopeId","data-v-a3f01233"]]),out={key:0,class:"notification-object"},iut=Q({__name:"WorkoutForUser",props:{action:{default:null},displayAppeal:{type:Boolean},displayObjectName:{type:Boolean},workout:{},reportId:{}},setup(e){const t=e,{action:n,displayAppeal:a,displayObjectName:s,reportId:o,workout:i}=_e(t),{getWorkoutSport:r}=ln(),{dateFormat:u,imperialUnits:l,timezone:d}=Ke(),E=F(()=>r(i.value)),c=F(()=>{var m,_,h;return i.value.suspended===!0&&n.value!==null&&(!n.value.appeal||((m=n.value.appeal)==null?void 0:m.approved)===!1||((_=n.value.appeal)==null?void 0:_.approved)===null&&!((h=n.value.appeal)!=null&&h.updated_at))&&a.value});return(m,_)=>{var R;const h=q("router-link"),O=q("i18n-t"),S=q("AlertMessage");return f(),v(le,null,[T(s)?(f(),v("div",out,A(m.$t("workouts.WORKOUT"))+": ",1)):D("",!0),w(Qu,{workout:T(i),sport:E.value,user:T(i).user,useImperialUnits:T(l),dateFormat:T(u),timezone:T(d)},null,8,["workout","sport","user","useImperialUnits","dateFormat","timezone"]),T(n)&&c.value?(f(),B(nI,{key:1,action:T(n),workout:T(i),"display-suspension-message":T(n).action_type==="workout_suspension"},null,8,["action","workout","display-suspension-message"])):(R=T(i).suspension)!=null&&R.report_id?(f(),B(S,{key:2,message:"workouts.SUSPENDED_BY_ADMIN"},So({_:2},[T(i).suspension.report_id!==T(o)?{name:"additionalMessage",fn:X(()=>[w(O,{keypath:"common.SEE_REPORT",tag:"span"},{default:X(()=>[w(h,{to:`/admin/reports/${T(i).suspension.report_id}`},{default:X(()=>[x(" #"+A(T(i).suspension.report_id),1)]),_:1},8,["to"])]),_:1})]),key:"0"}:void 0]),1024)):D("",!0)],64)}}}),Zp=se(iut,[["__scopeId","data-v-b44d39c8"]]),rut={id:"user-sanction"},uut={key:0},lut={key:1},cut={key:2},dut={class:"no-warning"},Eut={class:"buttons"},put=Q({__name:"UserSanctionDetail",props:{authUser:{}},setup(e){const t=e,{authUser:n}=_e(t),a=$e(),s=it(),{authUserLoading:o,authUserSuccess:i}=Ke(),r=Se(""),u=F(()=>a.getters[K.GETTERS.USER_SANCTION]);function l(){a.dispatch(K.ACTIONS.GET_USER_SANCTION,s.params.action_id)}function d(E){r.value=E,a.dispatch(K.ACTIONS.APPEAL,{actionId:u.value.id,actionType:"user_warning",text:E})}return Tt(()=>l()),Et(()=>{a.commit(K.MUTATIONS.UPDATE_IS_SUCCESS,!1),a.commit(K.MUTATIONS.SET_USER_SANCTION,{})}),(E,c)=>{const m=q("Loader");return f(),v("div",rut,[T(o)&&!r.value?(f(),v("div",uut,[w(m)])):u.value.id?(f(),v("div",lut,[p("h1",null,A(E.$t(`user.PROFILE.SANCTIONS.${u.value.action_type}`,{date:T($t)(u.value.created_at,T(n).timezone,T(n).date_format)})),1),u.value.comment?(f(),B(Qp,{key:0,"display-object-name":!0,comment:u.value.comment},null,8,["comment"])):u.value.workout?(f(),B(Zp,{key:1,action:u.value,"display-appeal":!1,"display-object-name":!0,workout:u.value.workout},null,8,["action","workout"])):D("",!0),w(vl,{"report-action":u.value,success:T(i),loading:T(o),"can-appeal":u.value.action_type!=="user_suspension"&&!T(n).suspended_at,onSubmitForm:d},null,8,["report-action","success","loading","can-appeal"])])):(f(),v("div",cut,[p("div",dut,A(E.$t("user.NO_WARNING_FOUND")),1)])),p("div",Eut,[p("button",{onClick:c[0]||(c[0]=_=>E.$router.push("/profile/moderation"))},A(E.$t("buttons.BACK")),1),T(n).suspended_at?D("",!0):(f(),v(le,{key:0},[p("button",{onClick:c[1]||(c[1]=_=>E.$router.push("/"))},A(E.$t("common.HOME")),1),p("button",{onClick:c[2]||(c[2]=_=>E.$router.push("/notifications"))},A(E.$t("notifications.NOTIFICATIONS",0)),1)],64))])])}}}),mut=se(put,[["__scopeId","data-v-f297e854"]]),Tut={id:"user-moderation"},_ut={key:0,id:"user-sanctions"},fut={key:0},hut={class:"last-sanctions"},Sut={key:1},Aut={class:"no-sanctions"},Out=Q({__name:"UserSanctionsList",props:{authUser:{}},setup(e){const t=e,{authUser:n}=_e(t),a=it(),s=$e(),{displayOptions:o}=He();let i=kt(d(a.query));const r=F(()=>s.getters[me.GETTERS.USER_SANCTIONS]),u=F(()=>s.getters[me.GETTERS.USER_SANCTIONS_LOADING]),l=F(()=>s.getters[me.GETTERS.USER_SANCTIONS_PAGINATION]);function d(m){const _={};return m.page&&(_.page=Hi(m.page,Ml)),_}function E(m){if(m.updated_at)switch(m.approved){case!0:return"APPROVED";case!1:return"REJECTED";default:return"IN_PROGRESS"}return"IN_PROGRESS"}function c(m){s.dispatch(me.ACTIONS.GET_USER_SANCTIONS,{username:n.value.username,...m})}return Le(()=>a.query,async m=>{i=d(m),c(i)}),tt(()=>c({})),Et(()=>s.commit(me.MUTATIONS.UPDATE_USER_SANCTIONS,[])),(m,_)=>{const h=q("router-link");return f(),v("div",Tut,[p("h1",null,A(m.$t("user.PROFILE.SANCTIONS_RECEIVED")),1),T(n).sanctions_count?(f(),v("div",_ut,[u.value?(f(),v("div",fut,[w(kl)])):(f(),v(le,{key:1},[p("ul",hut,[(f(!0),v(le,null,be(r.value,O=>(f(),v("li",{key:O.id},[p("div",null,[w(h,{to:`/profile/moderation/sanctions/${O.id}`},{default:X(()=>[x(A(m.$t(`user.PROFILE.SANCTIONS.${O.action_type}`,{date:T($t)(O.created_at,T(o).timezone,T(o).dateFormat)})),1)]),_:2},1032,["to"]),O.appeal?(f(),v("span",{key:0,class:he(["info-box appeal",{approved:E(O.appeal)==="APPROVED",rejected:E(O.appeal)==="REJECTED"}])},[p("i",{class:he(["fa",{"fa-info-circle":E(O.appeal)!=="REJECTED","fa-times":E(O.appeal)==="REJECTED"}]),"aria-hidden":"true"},null,2),x(" "+A(m.$t(`user.PROFILE.SANCTION_APPEAL.${E(O.appeal)}`)),1)],2)):D("",!0)])]))),128))]),w(da,{pagination:l.value,path:"/profile/moderation",query:T(i)},null,8,["pagination","query"])],64))])):(f(),v("div",Sut,[p("p",Aut,A(m.$t("user.PROFILE.NO_SANCTIONS")),1)])),p("div",null,[p("button",{onClick:_[0]||(_[0]=O=>m.$router.push("/"))},A(m.$t("common.HOME")),1)])])}}}),Iut=se(Out,[["__scopeId","data-v-bb47e769"]]),gut={class:"relationships"},Rut={key:0},Nut={class:"user-relationships"},vut={key:1,class:"no-relationships"},but={class:"profile-buttons"},Cut=Q({__name:"UserRelationships",props:{user:{},relationship:{}},setup(e){const t=e,{relationship:n,user:a}=_e(t),s=$e(),o=it(),{authUser:i}=Ke(),r=F(()=>({username:a.value.username,relationship:n.value,page:1})),u=F(()=>s.getters[me.GETTERS.USER_RELATIONSHIPS]),l=F(()=>s.getters[me.GETTERS.USERS_PAGINATION]);function d(E){s.dispatch(me.ACTIONS.GET_RELATIONSHIPS,E)}return Le(()=>o.path,E=>{r.value.page=l.value.page,r.value.relationship=E.includes("following")?"following":"followers",d(r.value)}),Le(()=>o.query,(E,c)=>{E.page!==c.page&&(r.value.page=E.page?+E.page:1,d(r.value))}),Le(()=>a.value.following,()=>{d(r.value)}),Le(()=>a.value.followers,()=>{d(r.value)}),tt(()=>d(r.value)),Et(()=>{s.dispatch(me.ACTIONS.EMPTY_RELATIONSHIPS)}),(E,c)=>(f(),v("div",gut,[u.value.length>0?(f(),v("div",Rut,[p("div",Nut,[(f(!0),v(le,null,be(u.value,m=>(f(),B(Kp,{key:m.username,authUser:T(i),user:m,from:"relationship"},null,8,["authUser","user"]))),128))]),w(da,{path:`/profile/${T(n)}`,pagination:l.value,query:{}},null,8,["path","pagination"])])):(f(),v("p",vut,A(E.$t(`user.RELATIONSHIPS.NO_${T(n).toUpperCase()}`)),1)),p("div",but,[p("button",{onClick:c[0]||(c[0]=m=>E.$route.path.startsWith("/profile")?E.$router.push("/profile"):E.$router.push(`/users/${T(a).username}`))},A(E.$t("user.PROFILE.BACK_TO_PROFILE")),1)])]))}}),kr=se(Cut,[["__scopeId","data-v-2a9a43ae"]]),Put={id:"users-sports"},zh=Q({__name:"index",props:{user:{},isEdition:{type:Boolean}},setup(e){const t=e,{user:n,isEdition:a}=_e(t),s=$e(),{t:o}=yt(),{sports:i}=ln(),r=F(()=>ca(i.value,o,"is_active",n.value.sports_list));return Et(()=>{s.commit(nt.MUTATIONS.SET_CLIENTS,[])}),(u,l)=>{const d=q("router-view");return f(),v("div",Put,[w(d,{authUser:T(n),isEdition:T(a),translatedSports:r.value},null,8,["authUser","isEdition","translatedSports"])])}}}),Dut=Q({__name:"EquipmentBadge",props:{equipment:{},workoutId:{},sportId:{}},setup(e){const t=e,{equipment:n,sportId:a,workoutId:s}=_e(t);return(o,i)=>{var l;const r=q("EquipmentTypeImage"),u=q("router-link");return f(),B(u,{class:he(["equipment-badge",{inactive:!T(n).is_active}]),to:{name:"Equipment",params:{id:T(n).id},query:{fromWorkoutId:T(s),fromSportId:(l=T(a))==null?void 0:l.toString()}}},{default:X(()=>[w(r,{title:o.$t(`equipment_types.${T(n).equipment_type.label}.LABEL`),"equipment-type-label":T(n).equipment_type.label},null,8,["title","equipment-type-label"]),p("span",null,A(T(n).label)+" "+A(T(n).is_active?"":`(${o.$t("common.INACTIVE")})`),1)]),_:1},8,["class","to"])}}}),aI=se(Dut,[["__scopeId","data-v-35b40eb3"]]),Lut={key:0,id:"user-sport",class:"description-list"},yut={class:"sport-equipments"},$ut={key:0,class:"no-equipments"},Uut={class:"sport-buttons"},kut=["disabled"],wut={key:1},Mut={class:"no-sport"},Wut=Q({__name:"UserSport",props:{authUser:{},translatedSports:{}},setup(e){const t=e,{translatedSports:n}=_e(t),a=it(),{errorMessages:s}=He(),{displayModal:o,sportColors:i,resetSport:r,updateDisplayModal:u}=ln(),{authUserLoading:l}=Ke(),d=F(()=>E(n.value));function E(c){if(!a.params.id)return null;const m=c.filter(_=>a.params.id?_.id===+a.params.id:null);return m.length===0?null:m[0]}return Le(()=>l.value,c=>{!c&&!s.value&&u(!1)}),(c,m)=>{const _=q("Modal"),h=q("SportImage"),O=q("Distance");return d.value?(f(),v("div",Lut,[T(o)?(f(),B(_,{key:0,title:c.$t("common.CONFIRMATION"),message:c.$t(`user.PROFILE.SPORT.CONFIRM_SPORT_RESET${d.value.default_equipments.length>0?"_WITH_EQUIPMENTS":""}`),onConfirmAction:m[0]||(m[0]=S=>T(r)(d.value.id,!0)),onCancelAction:m[1]||(m[1]=S=>T(u)(!1)),onKeydown:m[2]||(m[2]=je(S=>T(u)(!1),["esc"]))},null,8,["title","message"])):D("",!0),p("dl",null,[p("dt",null,A(Fe(c.$t("workouts.SPORT",1))),1),p("dd",null,A(d.value.translatedLabel),1),p("dt",null,A(Fe(c.$t("user.PROFILE.SPORT.COLOR"))),1),p("dd",null,[w(h,{title:d.value.translatedLabel,"sport-label":d.value.label,color:d.value.color?d.value.color:T(i)[d.value.label]},null,8,["title","sport-label","color"])]),p("dt",null,A(Fe(c.$t("workouts.WORKOUT",0))),1),p("dd",null,[p("i",{class:he(`fa fa-${c.authUser.sports_list.includes(d.value.id)?"check-":""}square-o`),"aria-hidden":"true"},null,2)]),p("dt",null,A(Fe(c.$t("user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD"))),1),p("dd",null,[w(O,{distance:d.value.stopped_speed_threshold,unitFrom:"km",speed:!0,useImperialUnits:c.authUser.imperial_units},null,8,["distance","useImperialUnits"])]),p("dt",null,A(Fe(c.$t("common.ACTIVE",0))),1),p("dd",null,[p("i",{class:he(`fa fa-${d.value.is_active_for_user?"check-":""}square-o`),"aria-hidden":"true"},null,2)]),p("dt",null,A(c.$t("user.PROFILE.SPORT.DEFAULT_EQUIPMENTS",1)),1),p("dd",yut,[(f(!0),v(le,null,be(d.value.default_equipments,S=>(f(),B(aI,{equipment:S,"sport-id":d.value.id,key:S.label},null,8,["equipment","sport-id"]))),128)),d.value.default_equipments.length===0?(f(),v("div",$ut,A(c.$t("equipments.NO_EQUIPMENTS")),1)):D("",!0)])]),p("div",Uut,[c.authUser.suspended_at?D("",!0):(f(),v(le,{key:0},[p("button",{onClick:m[3]||(m[3]=S=>c.$router.push(`/profile/edit/sports/${d.value.id}`))},A(c.$t("buttons.EDIT")),1),p("button",{disabled:T(l),class:"danger",onClick:m[4]||(m[4]=Ne(S=>T(u)(!0),["prevent"]))},A(c.$t("buttons.RESET")),9,kut)],64)),p("button",{onClick:m[5]||(m[5]=S=>c.$router.push(T(a).query.fromEquipmentId?`/profile/equipments/${T(a).query.fromEquipmentId}`:"/profile/sports"))},A(c.$t("buttons.BACK")),1)])])):(f(),v("div",wut,[p("p",Mut,A(c.$t("user.NO_SPORT_FOUND")),1),p("button",{onClick:m[6]||(m[6]=S=>c.$router.push("/profile/sports"))},A(c.$t("buttons.BACK")),1)]))}}}),Fut=se(Wut,[["__scopeId","data-v-fbec81b5"]]),zut={key:0,id:"sport-edition"},xut={class:"form-items"},But={class:"form-item"},Gut={for:"sport-label"},Vut={class:"form-item"},Hut={for:"sport-color"},Kut=["disabled"],qut={class:"form-item"},jut={for:"sport-threshold"},Yut=["disabled"],Xut={class:"form-item-checkbox"},Qut={for:"equipment-active"},Zut=["checked","disabled"],Jut={class:"form-item"},elt={for:"sport-default-equipment"},tlt=["disabled"],nlt={value:""},alt=["value"],slt={class:"form-buttons"},olt=["disabled"],ilt=["disabled"],rlt=Q({__name:"UserSportEdition",props:{authUser:{},translatedSports:{}},setup(e){const t=e,{authUser:n,translatedSports:a}=_e(t),{t:s}=yt(),o=$e(),i=it(),{errorMessages:r}=He(),{defaultColor:u,defaultEquipmentId:l,sportColors:d,sportPayload:E,updateIsActive:c,updateSport:m}=ln(),{authUserLoading:_}=Ke(),h=Se(!1),O=F(()=>g(a.value)),S=F(()=>o.getters[Be.GETTERS.EQUIPMENTS]),R=F(()=>S.value&&O.value?yO(S.value,s,"withIncludedIds",O.value,O.value.default_equipments.map(C=>C.id)):[]);function g(C){if(!i.params.id)return null;const k=C.filter(P=>i.params.id?P.id===+i.params.id:null);return k.length===0?null:k[0]}function I(C,k=!1){C!==null&&(E.sport_id=C.id,E.color=C.color?C.color:d?d[C.label]:u,E.is_active=C.is_active_for_user,E.stopped_speed_threshold=+`${n.value.imperial_units?Xt(C.stopped_speed_threshold,"km","mi",2):parseFloat(C.stopped_speed_threshold.toFixed(2))}`,E.fromSport=!0,k&&(l.value=C.default_equipments.length>0?C.default_equipments[0].id:""))}function N(){E.default_equipment_ids=l.value?[l.value]:[],m(n.value)}function b(){h.value=!0}return Le(()=>O.value,C=>{i.params.id&&(C!=null&&C.id)&&I(C,!0)}),Tt(()=>{var k;const C=document.getElementById("sport-color");C==null||C.focus(),i.params.id&&i.params.id&&(k=O.value)!=null&&k.id&&I(O.value,!0)}),(C,k)=>{const P=q("ErrorMessage");return O.value?(f(),v("div",zut,[p("form",{class:he({errors:h.value}),onSubmit:Ne(N,["prevent"])},[p("div",xut,[p("div",But,[p("label",Gut,A(Fe(C.$t("workouts.SPORT",1))),1),x(" "+A(O.value.translatedLabel),1)]),p("div",Vut,[p("label",Hut,A(Fe(C.$t("user.PROFILE.SPORT.COLOR"))),1),Me(p("input",{id:"sport-color",name:"sport-color",class:"sport-color",type:"color",required:"","onUpdate:modelValue":k[0]||(k[0]=$=>T(E).color=$),disabled:T(_),onInvalid:b},null,40,Kut),[[st,T(E).color]])]),p("div",qut,[p("label",jut,A(Fe(C.$t("user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD")))+" ("+A(`${T(n).imperial_units?"mi":"km"}/h`)+")* ",1),Me(p("input",{id:"sport-threshold",name:"sport-threshold",class:"threshold-input",type:"number",min:"0",step:"0.1",required:"","onUpdate:modelValue":k[1]||(k[1]=$=>T(E).stopped_speed_threshold=$),disabled:T(_),onInvalid:b},null,40,Yut),[[st,T(E).stopped_speed_threshold]])]),p("div",Xut,[p("label",Qut,A(Fe(C.$t("common.ACTIVE"))),1),p("input",{id:"equipment-active",name:"equipment-active",type:"checkbox",checked:O.value.is_active_for_user,onChange:k[2]||(k[2]=(...$)=>T(c)&&T(c)(...$)),disabled:T(_)},null,40,Zut)]),p("div",Jut,[p("label",elt,A(C.$t("user.PROFILE.SPORT.DEFAULT_EQUIPMENTS",1)),1),Me(p("select",{id:"sport-default-equipment",onInvalid:b,disabled:T(_),"onUpdate:modelValue":k[3]||(k[3]=$=>qt(l)?l.value=$:null)},[p("option",nlt,A(C.$t("equipments.NO_EQUIPMENTS")),1),(f(!0),v(le,null,be(R.value,$=>(f(),v("option",{value:$.id,key:$.id},A($.label),9,alt))),128))],40,tlt),[[Sn,T(l)]])])]),T(r)?(f(),B(P,{key:0,message:T(r)},null,8,["message"])):D("",!0),p("div",slt,[p("button",{class:"confirm",type:"submit",disabled:T(_)},A(C.$t("buttons.SUBMIT")),9,olt),p("button",{class:"cancel",onClick:k[4]||(k[4]=Ne(()=>{var $;return C.$router.push(`/profile/sports/${($=O.value)==null?void 0:$.id}`)},["prevent"])),disabled:T(_)},A(C.$t("buttons.CANCEL")),9,ilt)])],34)])):D("",!0)}}}),ult=se(rlt,[["__scopeId","data-v-aaa31377"]]),llt={id:"user-sport-preferences"},clt={key:1,class:"responsive-table"},dlt={class:"mobile-display"},Elt={key:0,class:"profile-buttons mobile-display"},plt={key:1,class:"profile-buttons"},mlt={class:"text-left"},Tlt={class:"threshold"},_lt={key:0},flt={class:"cell-heading"},hlt={class:"cell-heading"},Slt={key:2,class:"disabled-message"},Alt={key:3,class:"fa fa-refresh fa-spin fa-fw"},Olt={class:"cell-heading"},Ilt={class:"cell-heading"},glt={class:"cell-heading"},Rlt=["checked"],Nlt={class:"cell-heading"},vlt={key:1},blt={key:0,class:"action-buttons"},Clt={class:"cell-heading"},Plt=["onClick"],Dlt={key:1,class:"edition-buttons"},Llt=["disabled"],ylt=["disabled"],$lt=["disabled"],Ult={key:0,class:"profile-buttons"},klt={key:1,class:"profile-buttons"},wlt=Q({__name:"UserSportPreferences",props:{authUser:{},translatedSports:{},isEdition:{type:Boolean}},setup(e){const t=e,{authUser:n,isEdition:a,translatedSports:s}=_e(t),o=$e(),{errorMessages:i}=He(),{defaultColor:r,displayModal:u,sportColors:l,sportPayload:d,resetSport:E,updateDisplayModal:c,updateIsActive:m,updateSport:_}=ln(),{authUserLoading:h}=Ke(),O=Se(!1);function S(I){I!==null?(d.sport_id=I.id,d.color=I.color?I.color:l?l[I.label]:r,d.is_active=I.is_active_for_user,d.stopped_speed_threshold=+`${n.value.imperial_units?Xt(I.stopped_speed_threshold,"km","mi",2):parseFloat(I.stopped_speed_threshold.toFixed(2))}`,O.value=I.default_equipments.length>0):g()}function R(I){return d.sport_id===I}function g(){d.sport_id=0,d.color=null,d.is_active=!0,d.stopped_speed_threshold=1,O.value=!1,o.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES)}return Le(()=>h.value,I=>{!I&&!i.value&&(g(),c(!1))}),(I,N)=>{const b=q("Modal"),C=q("SportImage"),k=q("router-link"),P=q("ErrorMessage"),$=q("Distance");return f(),v("div",llt,[T(u)?(f(),B(b,{key:0,title:I.$t("common.CONFIRMATION"),message:I.$t(`user.PROFILE.SPORT.CONFIRM_SPORT_RESET${O.value?"_WITH_EQUIPMENTS":""}`),onConfirmAction:N[0]||(N[0]=y=>T(E)(T(d).sport_id)),onCancelAction:N[1]||(N[1]=y=>T(c)(!1)),onKeydown:N[2]||(N[2]=je(y=>T(c)(!1),["esc"]))},null,8,["title","message"])):D("",!0),T(s).length>0?(f(),v("div",clt,[p("div",dlt,[T(a)?(f(),v("div",Elt,[p("button",{class:"cancel",onClick:N[3]||(N[3]=Ne(y=>I.$router.push("/profile/sports"),["prevent"]))},A(I.$t("buttons.BACK")),1)])):(f(),v("div",plt,[p("button",{onClick:N[4]||(N[4]=y=>I.$router.push("/profile/edit/sports"))},A(I.$t("user.PROFILE.EDIT_SPORTS_PREFERENCES")),1),p("button",{onClick:N[5]||(N[5]=y=>I.$router.push("/"))},A(I.$t("common.HOME")),1)]))]),p("table",null,[p("thead",null,[p("tr",null,[p("th",null,A(I.$t("user.PROFILE.SPORT.COLOR")),1),p("th",mlt,A(I.$t("workouts.SPORT",0)),1),p("th",null,A(I.$t("workouts.WORKOUT",0)),1),p("th",null,A(I.$t("equipments.EQUIPMENT",0)),1),p("th",null,A(I.$t("user.PROFILE.SPORT.IS_ACTIVE")),1),p("th",null,[p("div",Tlt,[p("span",null,A(I.$t("user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD")),1),p("span",null," ("+A(`${T(n).imperial_units?"mi":"km"}/h`)+") ",1)])]),T(a)&&!T(n).suspended_at?(f(),v("th",_lt,A(I.$t("user.PROFILE.SPORT.ACTION")),1)):D("",!0)])]),p("tbody",null,[(f(!0),v(le,null,be(T(s),y=>(f(),v("tr",{key:y.id},[p("td",null,[p("span",flt,A(I.$t("user.PROFILE.SPORT.COLOR")),1),R(y.id)?Me((f(),v("input",{key:0,class:"sport-color",type:"color","onUpdate:modelValue":N[6]||(N[6]=z=>T(d).color=z)},null,512)),[[st,T(d).color]]):(f(),B(C,{key:1,title:y.translatedLabel,"sport-label":y.label,color:y.color?y.color:T(l)[y.label]},null,8,["title","sport-label","color"]))]),p("td",{class:he(["sport-label",{"disabled-sport":!y.is_active}])},[p("span",hlt,A(I.$t("user.PROFILE.SPORT.LABEL")),1),R(y.id)?(f(),v(le,{key:0},[x(A(y.translatedLabel),1)],64)):(f(),B(k,{key:1,to:`/profile/sports/${y.id}`},{default:X(()=>[x(A(y.translatedLabel),1)]),_:2},1032,["to"])),y.is_active?D("",!0):(f(),v("span",Slt," ("+A(I.$t("user.PROFILE.SPORT.DISABLED_BY_ADMIN"))+") ",1)),T(h)&&R(y.id)?(f(),v("i",Alt)):D("",!0),T(i)&&T(d).sport_id===y.id?(f(),B(P,{key:4,message:T(i)},null,8,["message"])):D("",!0)],2),p("td",{class:he(["text-center",{"disabled-sport":!y.is_active}])},[p("span",Olt,A(I.$t("workouts.WORKOUT",0)),1),p("i",{class:he(`fa fa${T(n).sports_list.includes(y.id)?"-check":""}`),"aria-hidden":"true"},null,2)],2),p("td",{class:he(["text-center",{"disabled-sport":!y.is_active}])},[p("span",Ilt,A(I.$t("equipments.EQUIPMENT",0)),1),p("i",{class:he(`fa fa${y.default_equipments.length>0?"-check":""}`),"aria-hidden":"true"},null,2)],2),p("td",{class:he(["text-center",{"disabled-sport":!y.is_active}])},[p("span",glt,A(I.$t("user.PROFILE.SPORT.IS_ACTIVE")),1),R(y.id)&&y.is_active?(f(),v("input",{key:0,type:"checkbox",checked:y.is_active_for_user,onChange:N[7]||(N[7]=(...z)=>T(m)&&T(m)(...z))},null,40,Rlt)):(f(),v("i",{key:1,class:he(`fa fa${y.is_active_for_user?"-check":""}`),"aria-hidden":"true"},null,2))],2),p("td",{class:he(["text-center",{"disabled-sport":!y.is_active}])},[p("span",Nlt,A(I.$t("user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD"))+" "+A(`${T(n).imperial_units?"mi":"km"}/h`),1),R(y.id)&&y.is_active?Me((f(),v("input",{key:0,class:"threshold-input",type:"number",min:"0",step:"0.1","onUpdate:modelValue":N[8]||(N[8]=z=>T(d).stopped_speed_threshold=z)},null,512)),[[st,T(d).stopped_speed_threshold]]):(f(),v("span",vlt,[w($,{distance:y.stopped_speed_threshold,unitFrom:"km",speed:!0,useImperialUnits:T(n).imperial_units,displayUnit:!1},null,8,["distance","useImperialUnits"])]))],2),T(a)&&!T(n).suspended_at?(f(),v("td",blt,[p("span",Clt,A(I.$t("user.PROFILE.SPORT.ACTION")),1),T(d).sport_id===0?(f(),v("button",{key:0,onClick:z=>S(y)},A(I.$t("buttons.EDIT")),9,Plt)):D("",!0),R(y.id)?(f(),v("div",Dlt,[p("button",{disabled:T(h),onClick:N[9]||(N[9]=Ne(z=>T(_)(T(n)),["prevent"]))},A(I.$t("buttons.SUBMIT")),9,Llt),p("button",{disabled:T(h),class:"warning",onClick:N[10]||(N[10]=Ne(z=>T(c)(!0),["prevent"]))},A(I.$t("buttons.RESET")),9,ylt),p("button",{disabled:T(h),onClick:N[11]||(N[11]=z=>S(null))},A(I.$t("buttons.CANCEL")),9,$lt)])):D("",!0)])):D("",!0)]))),128))])]),T(a)?(f(),v("div",Ult,[p("button",{class:"cancel",onClick:N[12]||(N[12]=Ne(y=>I.$router.push("/profile/sports"),["prevent"]))},A(I.$t("buttons.BACK")),1)])):(f(),v("div",klt,[T(n).suspended_at?D("",!0):(f(),v("button",{key:0,onClick:N[13]||(N[13]=y=>I.$router.push("/profile/edit/sports"))},A(I.$t("user.PROFILE.EDIT_SPORTS_PREFERENCES")),1)),p("button",{onClick:N[14]||(N[14]=y=>I.$router.push("/"))},A(I.$t("common.HOME")),1)]))])):D("",!0)])}}}),xh=se(wlt,[["__scopeId","data-v-2f0e6a61"]]),Mlt={class:"about-text"},Wlt=["innerHTML"],Flt=["href"],zlt={href:"https://github.com/SamR1/FitTrackee",target:"_blank",rel:"noopener noreferrer"},xlt={key:0},Blt=["href"],Glt={key:1},Vlt=["href"],Hlt={class:"about-instance"},Klt=["innerHTML"],qlt=Q({__name:"About",setup(e){const{appConfig:t,appLanguage:n}=He(),a=F(()=>o()),s=F(()=>i());function o(){const r={};return t.value.weatherProvider==="visualcrossing"&&(r.name="Visual Crossing",r.url="https://www.visualcrossing.com"),r}function i(){let r="https://samr1.github.io/FitTrackee/";return n.value==="fr"&&(r+="fr/"),r}return(r,u)=>{const l=q("i18n-t");return f(),v("div",Mlt,[p("div",null,[p("p",{class:"error-message",innerHTML:r.$t("about.FITTRACKEE_DESCRIPTION")},null,8,Wlt),p("p",null,[u[0]||(u[0]=p("i",{class:"fa fa-book fa-padding","aria-hidden":"true"},null,-1)),p("a",{class:"documentation-link",href:s.value,target:"_blank",rel:"noopener noreferrer"},A(Fe(r.$t("common.DOCUMENTATION"))),9,Flt)]),p("p",null,[u[1]||(u[1]=p("i",{class:"fa fa-github fa-padding","aria-hidden":"true"},null,-1)),p("a",zlt,A(r.$t("about.SOURCE_CODE")),1)]),p("p",null,[u[3]||(u[3]=p("i",{class:"fa fa-balance-scale fa-padding","aria-hidden":"true"},null,-1)),w(l,{keypath:"about.FITTRACKEE_LICENSE"},{default:X(()=>u[2]||(u[2]=[p("a",{href:"https://choosealicense.com/licenses/agpl-3.0/",target:"_blank",rel:"noopener noreferrer"},"AGPLv3",-1)])),_:1})]),T(t).admin_contact?(f(),v("div",xlt,[u[4]||(u[4]=p("i",{class:"fa fa-envelope-o fa-padding","aria-hidden":"true"},null,-1)),p("a",{href:`mailto:${T(t).admin_contact}`},A(r.$t("about.CONTACT_ADMIN")),9,Blt)])):D("",!0),a.value&&a.value.name?(f(),v("div",Glt,[x(A(r.$t("about.WEATHER_DATA_FROM"))+" ",1),p("a",{href:a.value.url,target:"_blank",rel:"nofollow noopener"},A(a.value.name),9,Vlt)])):D("",!0),T(t).about?(f(),v(le,{key:2},[p("p",Hlt,A(r.$t("about.ABOUT_THIS_INSTANCE")),1),p("div",{innerHTML:T(Vi)(T(t).about)},null,8,Klt)],64)):D("",!0)])])}}}),jlt=se(qlt,[["__scopeId","data-v-28993d11"]]),Ylt={},Xlt={id:"bike"};function Qlt(e,t){return f(),v("div",Xlt,t[0]||(t[0]=[p("img",{class:"bike-img",src:"/img/bike.svg",alt:"mountain bike"},null,-1)]))}const sI=se(Ylt,[["render",Qlt],["__scopeId","data-v-dc181e30"]]),Zlt={id:"about",class:"view"},Jlt={class:"container"},ect={class:"container-sub"},tct={class:"container-sub about-details"},nct=Q({__name:"AboutView",setup(e){return(t,n)=>(f(),v("div",Zlt,[p("div",Jlt,[p("div",ect,[w(sI)]),p("div",tct,[w(jlt)])])]))}}),act=se(nct,[["__scopeId","data-v-ef9c7198"]]),sct={id:"admin",class:"view"},oct={key:0,class:"container"},ict={key:1,class:"container"},rct=Q({__name:"AdminView",setup(e){const t=$e(),{authUserHasModeratorRights:n,authUserHasAdminRights:a,authUserLoading:s}=Ke();return tt(()=>{n.value&&t.dispatch(te.ACTIONS.GET_APPLICATION_STATS)}),(o,i)=>{const r=q("router-view");return f(),v("div",sct,[T(s)?D("",!0):(f(),v("div",oct,[(o.$route.meta.minimumRole==="moderator"?T(n):T(a))?(f(),B(r,{key:0})):(f(),v("div",ict,[w(Wo)])),i[0]||(i[0]=p("div",{id:"bottom"},null,-1))]))])}}}),uct=se(rct,[["__scopeId","data-v-580b02ed"]]),lct={},cct={class:"no-workouts box"};function dct(e,t){const n=q("router-link");return f(),v("div",cct,[p("div",null,[x(A(e.$t("workouts.NO_WORKOUTS"))+" ",1),w(n,{to:"/workouts/add"},{default:X(()=>[x(A(e.$t("workouts.UPLOAD_FIRST_WORKOUT")),1)]),_:1})])])}const Jp=se(lct,[["render",dct],["__scopeId","data-v-b0c91cc6"]]),us={ligthMode:{text:"#666",line:"rgba(0, 0, 0, 0.1)"},darkMode:{text:"#a1a1a1",line:"#3f3f3f"}},Ect=(e,t,n,a=!1)=>{const s={speed:{label:t("workouts.SPEED"),backgroundColor:["transparent"],borderColor:[a?"#5f5c97":"#8884d8"],borderWidth:2,data:[],yAxisID:"ySpeed"},elevation:{label:t("workouts.ELEVATION"),backgroundColor:[a?"#303030":"#e5e5e5"],borderColor:[a?"#222222":"#cccccc"],borderWidth:1,fill:!0,data:[],yAxisID:"yElevation"}},o=[],i=[],r=[];return e.map(u=>{o.push(Zr("km",u.distance,n)),i.push(u.duration),s.speed.data.push(Zr("km",u.speed,n)),u.elevation!==void 0&&s.elevation.data.push(Zr("m",u.elevation,n)),r.push({latitude:u.latitude,longitude:u.longitude})}),{distance_labels:o,duration_labels:i,datasets:s,coordinates:r}},pct=e=>{const t=e.length;if(t===0)return{};const n={};return e.map(a=>{n[a.sport_id]||(n[a.sport_id]={count:0,percentage:0}),n[a.sport_id].count+=1,n[a.sport_id].percentage=n[a.sport_id].count/t}),n},qi={order:"desc",order_by:"workout_date"},mct={id:"timeline"},Tct={class:"section-title"},_ct={key:0},fct={key:1},hct={key:1,class:"more-workouts"},wr=5,Sct=Q({__name:"Timeline",props:{sports:{},authUser:{}},setup(e){const t=e,{sports:n,authUser:a}=_e(t),{dateFormat:s}=Ke(),o=$e(),i=Se(1),r=F(()=>a.value.nb_workouts>=wr?wr:a.value.nb_workouts),u=F(()=>o.getters[ee.GETTERS.TIMELINE_WORKOUTS]),l=F(()=>u.value.length>0?u.value[u.value.length-1].previous_workout!==null:!1),d=F(()=>o.getters[K.GETTERS.IS_SUSPENDED]);function E(){d.value||o.dispatch(ee.ACTIONS.GET_TIMELINE_WORKOUTS,{page:i.value,per_page:wr,...qi})}function c(){d.value||(i.value+=1,o.dispatch(ee.ACTIONS.GET_MORE_TIMELINE_WORKOUTS,{page:i.value,per_page:wr,...qi}))}return tt(()=>E()),(m,_)=>(f(),v("div",mct,[p("div",Tct,A(m.$t("workouts.LATEST_WORKOUTS")),1),T(a).nb_workouts>0&&u.value.length===0?(f(),v("div",_ct,[(f(!0),v(le,null,be([...Array(r.value).keys()],h=>(f(),B(Qu,{user:T(a),useImperialUnits:T(a).imperial_units,dateFormat:T(s),timezone:T(a).timezone,key:h},null,8,["user","useImperialUnits","dateFormat","timezone"]))),128))])):(f(),v("div",fct,[(f(!0),v(le,null,be(u.value,h=>(f(),B(Qu,{workout:h,sport:u.value.length>0?T(n).filter(O=>O.id===h.sport_id)[0]:null,user:h.user,useImperialUnits:T(a).imperial_units,dateFormat:T(s),timezone:T(a).timezone,key:h.id},null,8,["workout","sport","user","useImperialUnits","dateFormat","timezone"]))),128)),u.value.length===0?(f(),B(Jp,{key:0})):D("",!0),l.value?(f(),v("div",hct,[p("button",{onClick:c},A(m.$t("workouts.LOAD_MORE_WORKOUT")),1)])):D("",!0)]))]))}}),Act=se(Sct,[["__scopeId","data-v-27e6a546"]]),Oct=["title"],oI=Q({__name:"CalendarWorkout",props:{displayHARecord:{type:Boolean},workout:{},sportLabel:{},sportColor:{}},setup(e){const t=e,{displayHARecord:n,workout:a,sportLabel:s,sportColor:o}=_e(t);return(i,r)=>{const u=q("SportImage"),l=q("router-link");return f(),B(l,{class:"calendar-workout",to:{name:"Workout",params:{workoutId:T(a).id}}},{default:X(()=>[w(u,{"sport-label":T(s),title:T(a).title,color:T(o)},null,8,["sport-label","title","color"]),p("sup",null,[T(a).records.length>0?(f(),v("i",{key:0,class:"fa fa-trophy custom-fa-small","aria-hidden":"true",title:T(a).records.filter(d=>T(n)?!0:d.record_type!=="HA").map(d=>` ${i.$t(`workouts.RECORD_${d.record_type}`)}`)[0]},null,8,Oct)):D("",!0)])]),_:1},8,["to"])}}}),Ict={class:"donut-chart"},gct={height:"34",width:"34",viewBox:"0 0 34 34"},Rct=["stroke","stroke-dashoffset","transform"],Bh=16,Gh=16,Vh=14,Nct=Q({__name:"DonutChart",props:{colors:{},datasets:{}},setup(e){const t=e,{colors:n,datasets:a}=_e(t);let s=-90;const o=2*Math.PI*Vh;function i(u,l){return l-u*l}function r(u,l){const d=`rotate(${s}, ${Bh}, ${Gh})`;return s=l*360+s,d}return(u,l)=>(f(),v("div",Ict,[(f(),v("svg",gct,[(f(!0),v(le,null,be(Object.entries(T(a)),(d,E)=>(f(),v("g",{key:E},[p("circle",{cx:Bh,cy:Gh,r:Vh,fill:"transparent",stroke:T(n)[+d[0]],"stroke-dashoffset":i(d[1].percentage,o),"stroke-dasharray":o,"stroke-width":"3","stroke-opacity":"0.8",transform:r(E,d[1].percentage)},null,8,Rct)]))),128))]))]))}}),vct={class:"calendar-workouts-chart"},bct=["id"],Cct={class:"workouts-count"},Pct={key:0,class:"workouts-pane"},Dct=["id"],Lct=Q({__name:"CalendarWorkoutsChart",props:{colors:{},datasets:{},sports:{},workouts:{},displayHARecord:{type:Boolean},index:{}},setup(e){const t=e,{colors:n,datasets:a,index:s,sports:o,workouts:i}=_e(t);let r=0;const u=Se(!0);function l(){const c=document.getElementById(`workouts-pane-${s.value}`);return c!=null&&c.children&&(c==null?void 0:c.children.length)>0?c:null}async function d(c){var _;c.preventDefault(),c.stopPropagation(),u.value=!u.value,await un();const m=l();u.value?(_=document.getElementById(`workouts-donut-${s.value}`))==null||_.focus():(m==null?void 0:m.children[0]).focus()}function E(c){if(!u.value){if(!u.value&&(c.key==="Tab"||c.keyCode===9)){c.preventDefault(),c.stopPropagation();const m=l();m&&(c.shiftKey?(r-=1,r<0&&(r=m.children.length-1)):(r+=1,r>=m.children.length&&(r=0)),m.children[r].focus())}c.key==="Escape"&&d(c)}}return Tt(()=>{document.addEventListener("keydown",E)}),Et(()=>{document.removeEventListener("keydown",E)}),(c,m)=>{const _=oR("click-outside");return f(),v("div",vct,[p("button",{class:"workouts-chart transparent",id:`workouts-donut-${T(s)}`,onClick:d},[p("div",Cct,A(T(i).length),1),w(Nct,{datasets:T(a),colors:T(n)},null,8,["datasets","colors"])],8,bct),u.value?D("",!0):(f(),v("div",Pct,[Me((f(),v("div",{class:"more-workouts",id:`workouts-pane-${T(s)}`},[p("button",{class:"calendar-more-close transparent",onClick:d},m[0]||(m[0]=[p("i",{class:"fa fa-times","aria-hidden":"true"},null,-1)])),(f(!0),v(le,null,be(T(i),(h,O)=>(f(),B(oI,{key:O,displayHARecord:c.displayHARecord,workout:h,sportLabel:T(Bp)(h,T(o)),sportColor:T(Gp)(h,T(o))},null,8,["displayHARecord","workout","sportLabel","sportColor"]))),128))],8,Dct)),[[_,d]])]))])}}}),Hh=se(Lct,[["__scopeId","data-v-fda2985a"]]),yct={class:"calendar-workouts"},$ct={class:"desktop-display"},Uct={key:0,class:"workouts-display"},kct={key:1,class:"donut-display"},wct={class:"mobile-display"},Mct={key:0,class:"donut-display"},Kh=6,Wct=Q({__name:"CalendarWorkouts",props:{displayHARecord:{type:Boolean},workouts:{},sports:{},index:{}},setup(e){const t=e,{displayHARecord:n,index:a,sports:s,workouts:o}=_e(t),i=F(()=>pct(t.workouts)),r=F(()=>sje(t.sports));return(u,l)=>(f(),v("div",yct,[p("div",$ct,[T(o).length<=Kh?(f(),v("div",Uct,[(f(!0),v(le,null,be(T(o).slice(0,Kh),(d,E)=>(f(),B(oI,{key:E,displayHARecord:T(n),workout:d,sportLabel:T(Bp)(d,T(s)),sportColor:T(Gp)(d,T(s))},null,8,["displayHARecord","workout","sportLabel","sportColor"]))),128))])):(f(),v("div",kct,[w(Hh,{workouts:T(o),sports:T(s),datasets:i.value,colors:r.value,displayHARecord:T(n),index:T(a)},null,8,["workouts","sports","datasets","colors","displayHARecord","index"])]))]),p("div",wct,[T(o).length>0?(f(),v("div",Mct,[w(Hh,{workouts:T(o),sports:T(s),datasets:i.value,colors:r.value,displayHARecord:T(n),index:T(a)},null,8,["workouts","sports","datasets","colors","displayHARecord","index"])])):D("",!0)])]))}}),Fct={class:"calendar-cells"},zct={class:"calendar-cell-day"},xct=Q({__name:"CalendarCells",props:{currentDay:{},displayHARecord:{type:Boolean},endDate:{},sports:{},startDate:{},timezone:{},weekStartingMonday:{type:Boolean},workouts:{}},setup(e){const t=e,{currentDay:n,displayHARecord:a,endDate:s,sports:o,startDate:i,timezone:r,weekStartingMonday:u,workouts:l}=_e(t),d=Se([]);function E(){d.value=[];let _=i.value;for(;_<=s.value;){const h=[];for(let O=0;O<7;O++)h.push(_),_=ar(_,1);d.value.push(h)}}function c(_){return u.value?[5,6].includes(_):[0,6].includes(_)}function m(_,h){return h?h.filter(O=>_1(Nl(O.workout_date,r.value),_)).reverse():[]}return Le(()=>t.currentDay,()=>E()),Tt(()=>E()),(_,h)=>(f(),v("div",Fct,[(f(!0),v(le,null,be(d.value,(O,S)=>(f(),v("div",{class:"calendar-row",key:S},[(f(!0),v(le,null,be(O,(R,g)=>(f(),v("div",{class:he(["calendar-cell",{"disabled-cell":!T(VD)(R,T(n)),"week-end":c(g),today:T(HD)(R)}]),key:g},[w(Wct,{workouts:m(R,T(l)),sports:T(o),displayHARecord:T(a),index:g},null,8,["workouts","sports","displayHARecord","index"]),p("div",zct,A(T(gn)(R,"d")),1)],2))),128))]))),128))]))}}),Bct={class:"calendar-days"},Gct=Q({__name:"CalendarDays",props:{startDate:{},localeOptions:{}},setup(e){const t=e,n=[];for(let a=0;a<7;a++)n.push(ar(t.startDate,a));return(a,s)=>(f(),v("div",Bct,[(f(),v(le,null,be(n,(o,i)=>p("div",{class:"calendar-day",key:i},A(T(gn)(o,a.localeOptions.code==="eu"?"EEEEEE.":"EEE",{locale:a.localeOptions})),1)),64))]))}}),Vct={class:"calendar-header"},Hct=["aria-label"],Kct={class:"calendar-month"},qct=["aria-label"],jct=Q({__name:"CalendarHeader",props:{day:{},localeOptions:{}},emits:["displayNextMonth","displayPreviousMonth"],setup(e,{emit:t}){const n=e,{day:a,localeOptions:s}=_e(n),o=t;return(i,r)=>(f(),v("div",Vct,[p("button",{class:"calendar-arrow calendar-arrow-left transparent",onClick:r[0]||(r[0]=u=>o("displayPreviousMonth")),"aria-label":i.$t("common.PREVIOUS")},r[2]||(r[2]=[p("i",{class:"fa fa-chevron-left","aria-hidden":"true"},null,-1)]),8,Hct),p("div",Kct,[p("span",null,A(T(gn)(T(a),"MMM yyyy",{locale:T(s)})),1)]),p("button",{class:"calendar-arrow calendar-arrow-right transparent",onClick:r[1]||(r[1]=u=>o("displayNextMonth")),"aria-label":i.$t("common.NEXT")},r[3]||(r[3]=[p("i",{class:"fa fa-chevron-right","aria-hidden":"true"},null,-1)]),8,qct)]))}}),Yct={id:"user-calendar"},Xct={class:"section-title"},Qct={class:"calendar-card box"},qh="yyyy-MM-dd",Zct=Q({__name:"index",props:{sports:{},user:{}},setup(e){const t=e,{sports:n,user:a}=_e(t),s=$e(),{locale:o}=He(),{isAuthUserSuspended:i}=Ke(),r=Se(new Date),u=Se(K_(r.value,a.value.weekm)),l=F(()=>s.getters[ee.GETTERS.CALENDAR_WORKOUTS]);function d(){if(!i.value){u.value=K_(r.value,t.user.weekm);const m={from:gn(u.value.start,qh),to:gn(u.value.end,qh),page:1,per_page:100,...qi};s.dispatch(ee.ACTIONS.GET_CALENDAR_WORKOUTS,m)}}function E(){r.value=Ro(r.value,1),d()}function c(){r.value=Mi(r.value,1),d()}return tt(()=>d()),(m,_)=>(f(),v("div",Yct,[p("div",Xct,A(m.$t("workouts.MY_WORKOUTS")),1),p("div",Qct,[w(jct,{day:r.value,"locale-options":T(o),onDisplayNextMonth:E,onDisplayPreviousMonth:c},null,8,["day","locale-options"]),w(Gct,{"start-date":u.value.start,"locale-options":T(o)},null,8,["start-date","locale-options"]),w(xct,{currentDay:r.value,displayHARecord:T(a).display_ascent,"end-date":u.value.end,sports:T(n),"start-date":u.value.start,timezone:T(a).timezone,workouts:l.value,weekStartingMonday:T(a).weekm},null,8,["currentDay","displayHARecord","end-date","sports","start-date","timezone","workouts","weekStartingMonday"])])]))}}),iI={data:{type:Object,required:!0},options:{type:Object,default:()=>({})},plugins:{type:Array,default:()=>[]},datasetIdKey:{type:String,default:"label"},updateMode:{type:String,default:void 0}},Jct={ariaLabel:{type:String},ariaDescribedby:{type:String}},edt={type:{type:String,required:!0},destroyDelay:{type:Number,default:0},...iI,...Jct},tdt=RS[0]==="2"?(e,t)=>Object.assign(e,{attrs:t}):(e,t)=>Object.assign(e,t);function ao(e){return Yi(e)?ut(e):e}function ndt(e){let t=arguments.length>1&&arguments[1]!==void 0?arguments[1]:e;return Yi(t)?new Proxy(e,{}):e}function adt(e,t){const n=e.options;n&&t&&Object.assign(n,t)}function rI(e,t){e.labels=t}function uI(e,t,n){const a=[];e.datasets=t.map(s=>{const o=e.datasets.find(i=>i[n]===s[n]);return!o||!s.data||a.includes(o)?{...s}:(a.push(o),Object.assign(o,s),o)})}function sdt(e,t){const n={labels:[],datasets:[]};return rI(n,e.labels),uI(n,e.datasets,t),n}const odt=Q({props:edt,setup(e,t){let{expose:n,slots:a}=t;const s=Se(null),o=sl(null);n({chart:o});const i=()=>{if(!s.value)return;const{type:l,data:d,options:E,plugins:c,datasetIdKey:m}=e,_=sdt(d,m),h=ndt(_,d);o.value=new gE(s.value,{type:l,data:h,options:{...E},plugins:c})},r=()=>{const l=ut(o.value);l&&(e.destroyDelay>0?setTimeout(()=>{l.destroy(),o.value=null},e.destroyDelay):(l.destroy(),o.value=null))},u=l=>{l.update(e.updateMode)};return Tt(i),Et(r),Le([()=>e.options,()=>e.data],(l,d)=>{let[E,c]=l,[m,_]=d;const h=ut(o.value);if(!h)return;let O=!1;if(E){const S=ao(E),R=ao(m);S&&S!==R&&(adt(h,S),O=!0)}if(c){const S=ao(c.labels),R=ao(_.labels),g=ao(c.datasets),I=ao(_.datasets);S!==R&&(rI(h.config.data,S),O=!0),g&&g!==I&&(uI(h.config.data,g,e.datasetIdKey),O=!0)}O&&un(()=>{u(h)})},{deep:!0}),()=>Cn("canvas",{role:"img",ariaLabel:e.ariaLabel,ariaDescribedby:e.ariaDescribedby,ref:s},[Cn("p",{},[a.default?a.default():""])])}});function lI(e,t){return gE.register(t),Q({props:iI,setup(n,a){let{expose:s}=a;const o=sl(null),i=r=>{o.value=r==null?void 0:r.chart};return s({chart:o}),()=>Cn(odt,tdt({ref:i},{type:e,...n}))}})}const idt=lI("bar",l0),rdt=lI("line",c0),ai=(e,t,n,a=!0,s="km")=>{const o=n?bn[s].defaultTarget:s;switch(e){case"average_speed":return`${t.toFixed(2)} ${o}/h`;case"average_duration":case"total_duration":return prt(t,a);case"average_distance":case"average_ascent":case"average_descent":case"total_distance":case"total_ascent":case"total_descent":return`${t.toFixed(2)} ${o}`;default:return t.toString()}},udt=Q({__name:"Chart",props:{datasets:{},labels:{},displayedData:{},displayedSportIds:{},fullStats:{type:Boolean},useImperialUnits:{type:Boolean},label:{}},setup(e){const t=e,{datasets:n,labels:a,displayedData:s,displayedSportIds:o,fullStats:i,useImperialUnits:r}=_e(t),{t:u}=yt(),{darkTheme:l}=He(),d=F(()=>({color:l.value?us.darkMode.line:us.ligthMode.line})),E=F(()=>({color:l.value?us.darkMode.text:us.ligthMode.text})),c=F(()=>s.value!=="average_workouts"&&s.value.startsWith("average")),m=F(()=>({labels:a.value,datasets:JSON.parse(JSON.stringify(n.value))})),_=F(()=>({responsive:!0,maintainAspectRatio:!1,animation:!1,layout:{padding:{top:i.value?40:22}},scales:{x:{stacked:!0,grid:{drawOnChartArea:!1,...d.value},border:{...d.value},ticks:{...E.value}},y:{stacked:!s.value.startsWith("average"),grid:{drawOnChartArea:!1,...d.value},border:{...d.value},ticks:{maxTicksLimit:6,callback:function(R){return ai(s.value,+R,r.value,!1,S(s.value))},...E.value},afterFit:function(R){R.width=i.value?90:60}}},plugins:{datalabels:{anchor:"end",align:"end",color:function(R){return c.value&&R.dataset.backgroundColor?R.dataset.backgroundColor[0]:E.value.color},rotation:function(R){return i.value&&R.chart.chartArea.width<580?310:0},display:function(R){return i.value&&R.chart.chartArea.width<300?!1:c.value?o.value.length==1?"auto":!1:!0},formatter:function(R,g){if(s.value.startsWith("average"))return ai(s.value,R,r.value,!1);{const I=g.chart.data.datasets.map(N=>N.data[g.dataIndex]).reduce((N,b)=>O(N,b),0);return g.datasetIndex===o.value.length-1&&I>0?ai(s.value,I,r.value,!1,S(s.value)):null}}},legend:{display:!1},tooltip:{interaction:{intersect:!0,mode:"index",position:c.value?"nearest":"average"},filter:function(R){return R.formattedValue!=="0"},callbacks:{label:function(R){let g=s.value==="average_workouts"?u("workouts.WORKOUT",0):u(`sports.${R.dataset.label}.LABEL`)||"";return g&&(g+=": "),R.parsed.y!==null&&(g+=ai(s.value,R.parsed.y,r.value,!0,S(s.value))),g},footer:function(R){if(s.value.startsWith("average"))return"";let g=0;return R.map(I=>{g+=I.parsed.y}),`${u("common.TOTAL")}: `+ai(s.value,g,r.value,!0,S(s.value))}}}}}));function h(R){return isNaN(R)?0:+R}function O(R,g){return h(R)+h(g)}function S(R){return R.includes("scent")?"m":"km"}return(R,g)=>(f(),v("div",{class:he(["bar-chart",{minimal:!T(i)}])},[w(T(idt),{data:m.value,options:_.value,"aria-label":R.label},null,8,["data","options","aria-label"])],2))}}),ldt=se(udt,[["__scopeId","data-v-f96e822f"]]),{locale:Ju}=Mo.global,cI={week:{api:"yyyy-MM-dd",chart:"MM/dd/yyyy"},month:{api:"yyyy-MM",chart:"MM/yyyy"},year:{api:"yyyy",chart:"yyyy"}},cdt=["average_ascent","average_descent","average_distance","average_duration","average_speed","total_workouts","total_duration","total_distance","total_ascent","total_descent"],ddt=(e,t)=>{const n=[];for(let a=lBe(e.duration,e.start,t);a<=e.end;a=cBe(e.duration,a))n.push(a);return n},Sa=(e,t,n=!1)=>{const a={label:e,backgroundColor:[t],data:[]};return n?(a.type="line",a.borderColor=[t],a.spanGaps=!0):a.type="bar",a},Edt=e=>{const t={average_ascent:[],average_descent:[],average_distance:[],average_duration:[],average_speed:[],average_workouts:[],total_workouts:[],total_distance:[],total_duration:[],total_ascent:[],total_descent:[]};return e.map(n=>{const a=n.color?n.color:xp[n.label];t.average_ascent.push(Sa(n.label,a,!0)),t.average_descent.push(Sa(n.label,a,!0)),t.average_distance.push(Sa(n.label,a,!0)),t.average_duration.push(Sa(n.label,a,!0)),t.average_speed.push(Sa(n.label,a,!0)),t.total_workouts.push(Sa(n.label,a)),t.total_distance.push(Sa(n.label,a)),t.total_duration.push(Sa(n.label,a)),t.total_ascent.push(Sa(n.label,a)),t.total_descent.push(Sa(n.label,a))}),t},pdt=(e,t,n)=>{switch(e){case"average_speed":case"total_distance":case"total_ascent":case"total_descent":case"average_distance":case"average_ascent":case"average_descent":return Zr(["average_speed","total_distance","average_distance"].includes(e)?"km":"m",t,n);default:case"total_workouts":case"total_duration":case"average_duration":return t}},OE=(e,t,n,a)=>gn(e,t==="week"?Ss(n,Ju.value):a,{locale:Gs[Ju.value]}),mdt=(e,t,n,a,s,o,i)=>{const r=ddt(e,t),u=cI[e.duration],l=n.filter(m=>a.includes(m.id)),d=[],E=Edt(l),c={};return l.map(m=>c[m.label]=m.id),r.map(m=>{const _=gn(m,u.api),h=OE(m,e.duration,i,u.chart);gn(m,e.duration==="week"?Ss(i,Ju.value):u.chart,{locale:Gs[Ju.value]}),d.push(h),cdt.map(O=>{E[O].map(S=>{S.data.push(_ in s&&c[S.label]in s[_]?pdt(O,s[_][c[S.label]][O],o):O.startsWith("average")?null:0)})})}),{labels:d,datasets:E}},Tdt=(e,t,n,a)=>{const s=n?1:0,o=t==="year"?ep(Vd(e,9)):t==="week"?Ol(Mi(e,2),{weekStartsOn:s}):or(Mi(e,11)),i=t==="year"?f1(e):t==="week"?tp(e,{weekStartsOn:s}):sr(e);return{duration:t,end:i,start:o,statsType:a}},_dt=(e,t,n)=>{const{duration:a,start:s,end:o}=e,i=n?1:0;return{duration:a,end:a==="year"?f1(t?Vd(o,1):mu(o,1)):a==="week"?tp(t?JT(o,1):Gd(o,1),{weekStartsOn:i}):sr(t?Mi(o,1):Ro(o,1)),start:a==="year"?ep(t?Vd(s,1):mu(s,1)):a==="week"?Ol(t?JT(s,1):Gd(s,1),{weekStartsOn:i}):or(t?Mi(s,1):Ro(s,1)),statsType:e.statsType}},jh=e=>{const t=e.reduce((a,s)=>(a||0)+(s||0),0);return+(e.length?(t||0)/e.length:0).toFixed(1)},fdt=(e,t)=>{const n=e.label.toLowerCase(),a=t.label.toLowerCase();return n>a?1:n{const n=[],a={label:"workouts_average",backgroundColor:[],data:[]};let s=[];const o=e.map(i=>(i.label=t(`sports.${i.label}.LABEL`),i)).sort(fdt);for(const i of o)a.data.push(jh(i.data)),a.backgroundColor.push(i.backgroundColor[0]),n.push(i.label),s.length>0?s=s.map((r,u)=>r+(i.data[u]||0)):s=i.data.map(r=>r||0);return{labels:n,datasets:{workouts_average:[a]},workoutsAverage:jh(s)}},Sdt={class:"stats-chart"},Adt={key:0},Odt={key:1},Idt={class:"chart-radio"},gdt=["value","checked","disabled"],Rdt=["value","checked","disabled"],Ndt=["value","checked","disabled"],vdt={key:0},bdt=["checked","disabled"],Cdt={key:1},Pdt=["value","checked","disabled"],Ddt={key:2},Ldt=["value","checked","disabled"],ydt={class:"workouts-average"},$dt={key:0,class:"info-box"},Udt=Q({__name:"index",props:{sports:{},user:{},chartParams:{},displayedSportIds:{default:()=>[]},fullStats:{type:Boolean,default:!1},hideChartIfNoData:{type:Boolean,default:!1},isDisabled:{type:Boolean,default:!1},selectedTimeFrame:{default:null}},setup(e){const t=e,{sports:n,user:a,chartParams:s,displayedSportIds:o,fullStats:i,hideChartIfNoData:r,isDisabled:u}=_e(t),l=$e(),{t:d}=yt(),E=Se("total_distance"),c=F(()=>l.getters[Wt.GETTERS.USER_STATS]),m=F(()=>cI[s.value.duration].chart),_=F(()=>OE(s.value.start,s.value.duration,a.value.date_format,m.value)),h=F(()=>OE(s.value.end,s.value.duration,a.value.date_format,m.value)),O=F(()=>mdt(s.value,a.value.weekm,n.value,o.value,c.value,a.value.imperial_units,a.value.date_format)),S=F(()=>O.value.datasets[E.value]),R=F(()=>O.value.labels),g=F(()=>Object.keys(c.value).length===0),I=F(()=>s.value.statsType),N=F(()=>hdt(O.value.datasets.total_workouts,d));function b(P){a.value.suspended_at||l.dispatch(Wt.ACTIONS.GET_USER_STATS,{username:a.value.username,params:P})}function C(P){E.value=P.target.value}function k(P,$){return{from:gn(P.start,"yyyy-MM-dd"),to:gn(P.end,"yyyy-MM-dd"),time:P.duration==="week"?`week${$.weekm?"m":""}`:P.duration,type:I.value}}return Le(()=>s.value,async P=>{b(k(P,a.value))}),Le(()=>I.value,async P=>{E.value=P==="total"&&E.value==="average_speed"?"total_distance":`${I.value}_${E.value.split("_")[1]}`}),tt(()=>b(k(s.value,a.value))),(P,$)=>(f(),v("div",Sdt,[T(r)&&g.value?(f(),v("div",Adt,A(P.$t("workouts.NO_WORKOUTS")),1)):(f(),v("div",Odt,[p("div",Idt,[p("label",null,[p("input",{type:"radio",name:"value_type",value:`${I.value}_distance`,checked:E.value===`${I.value}_distance`,disabled:T(u),onClick:C},null,8,gdt),x(" "+A(P.$t("workouts.DISTANCE")),1)]),p("label",null,[p("input",{type:"radio",name:"value_type",value:`${I.value}_duration`,checked:E.value===`${I.value}_duration`,disabled:T(u),onClick:C},null,8,Rdt),x(" "+A(P.$t("workouts.DURATION")),1)]),p("label",null,[p("input",{type:"radio",name:"value_type",value:`${I.value}_workouts`,checked:E.value===`${I.value}_workouts`,disabled:T(u),onClick:C},null,8,Ndt),x(" "+A(P.$t("workouts.WORKOUT",2)),1)]),T(i)&&I.value==="average"?(f(),v("label",vdt,[p("input",{type:"radio",name:"value_type",value:"average_speed",checked:E.value==="average_speed",disabled:T(u),onClick:C},null,8,bdt),x(" "+A(P.$t("workouts.SPEED")),1)])):D("",!0),T(i)?(f(),v("label",Cdt,[p("input",{type:"radio",name:"value_type",value:`${I.value}_ascent`,checked:E.value===`${I.value}_ascent`,disabled:T(u),onClick:C},null,8,Pdt),x(" "+A(P.$t("workouts.ASCENT")),1)])):D("",!0),T(i)?(f(),v("label",Ddt,[p("input",{type:"radio",name:"value_type",value:`${I.value}_descent`,checked:E.value===`${I.value}_descent`,disabled:T(u),onClick:C},null,8,Ldt),x(" "+A(P.$t("workouts.DESCENT")),1)])):D("",!0)]),R.value.length>0||N.value.labels.length>0?(f(),B(ldt,{key:0,datasets:E.value==="average_workouts"?N.value.datasets.workouts_average:S.value,labels:E.value==="average_workouts"?N.value.labels:R.value,displayedData:E.value,displayedSportIds:T(o),fullStats:T(i),useImperialUnits:T(a).imperial_units,label:P.$t(`statistics.STATISTICS_CHARTS.${T(s).duration}`)+` (${_.value} - ${h.value})`},null,8,["datasets","labels","displayedData","displayedSportIds","fullStats","useImperialUnits","label"])):D("",!0),p("div",ydt,[E.value==="average_workouts"&&P.selectedTimeFrame?(f(),v("div",$dt,[$[0]||($[0]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(P.$t("statistics.DATES"))+": "+A(_.value)+" - "+A(h.value)+", "+A(P.$t("statistics.WORKOUTS_AVERAGE"))+": "+A(N.value.workoutsAverage)+"/"+A(P.$t(`statistics.TIME_FRAMES.${P.selectedTimeFrame}`)),1)])):D("",!0)])]))]))}}),dI=se(Udt,[["__scopeId","data-v-f61443a0"]]),kdt={class:"user-month-stats"},wdt={class:"section-title"},Mdt={class:"box"},Wdt=Q({__name:"UserMonthStats",props:{sports:{},user:{}},setup(e){const t=e,{sports:n,user:a}=_e(t),s=new Date,o={duration:"week",start:or(s),end:sr(s),statsType:"total"},i=F(()=>n.value.map(r=>r.id));return(r,u)=>(f(),v("div",kdt,[p("div",wdt,[u[0]||(u[0]=p("i",{class:"fa fa-calendar custom-fa-small","aria-hidden":"true"},null,-1)),x(" "+A(r.$t("dashboard.THIS_MONTH")),1)]),p("div",Mdt,[w(dI,{sports:T(n),user:T(a),"chart-params":o,"displayed-sport-ids":i.value,"hide-chart-if-no-data":!0},null,8,["sports","user","displayed-sport-ids"])])]))}}),Fdt=se(Wdt,[["__scopeId","data-v-f5b0f1af"]]),zdt={class:"record"},xdt={class:"record-type"},Bdt={class:"record-value"},Gdt={class:"record-date"},Vdt=Q({__name:"SportRecordsTable",props:{record:{}},setup(e){const t=e,{record:n}=_e(t);return(a,s)=>{const o=q("router-link");return f(),v("div",zdt,[p("span",xdt,A(Fe(T(n).label)),1),p("span",Bdt,A(T(n).value),1),p("span",Gdt,[w(o,{to:{name:"Workout",params:{workoutId:T(n).workout_id}}},{default:X(()=>[p("time",null,A(T(n).workout_date),1)]),_:1},8,["to"])])])}}}),EI=se(Vdt,[["__scopeId","data-v-f8125b68"]]),{locale:Hdt}=Mo.global,Kdt=(e,t,n,a)=>{const s="km",o=n?bn[s].defaultTarget:s,i="m",r=n?bn[i].defaultTarget:i;let u;switch(e.record_type){case"AS":case"MS":u=`${Xt(+e.value,s,o,2)} ${o}/h`;break;case"FD":u=`${Xt(+e.value,s,o,3)} ${o}`;break;case"HA":u=`${Xt(+e.value,i,r,2)} ${r}`;break;case"LD":u=e.value;break;default:throw new Error(`Invalid record type, expected: "AS", "FD", "HA", "LD", "MD", got: "${e.record_type}"`)}return{id:e.id,record_type:e.record_type,sport_id:e.sport_id,value:u,user:e.user,workout_date:$t(e.workout_date,t,a,!1),workout_id:e.workout_id}},pI=(e,t)=>{const n=e.label.toLowerCase(),a=t.label.toLowerCase();return n>a?1:n(o=Ss(o,Hdt.value),e.filter(r=>s?!0:r.record_type!=="HA").reduce((r,u)=>{const l=t.find(d=>d.id===u.sport_id);return l&&l.label&&(i===null||l.id===i)&&(r[l.translatedLabel]===void 0&&(r[l.translatedLabel]={label:l.label,color:l.color,records:[]}),r[l.translatedLabel].records.push(Kdt(u,n,a,o))),r},{})),qdt={class:"records-card"},jdt=Q({__name:"RecordsCard",props:{records:{},sportTranslatedLabel:{}},setup(e){const t=e,{records:n,sportTranslatedLabel:a}=_e(t),s=$e(),{t:o}=yt(),i=F(()=>s.getters[te.GETTERS.LANGUAGE]),r=F(()=>i.value==="bg");function u(l){const d=[];return l.map(E=>{d.push({...E,label:o(`workouts.RECORD_${E.record_type}`)})}),d.sort(pI)}return(l,d)=>{const E=q("SportImage"),c=q("Card");return f(),v("div",qdt,[w(c,null,{title:X(()=>[w(E,{"sport-label":T(n).label,color:T(n).color},null,8,["sport-label","color"]),x(" "+A(T(a)),1)]),content:X(()=>[(f(!0),v(le,null,be(u(T(n).records),m=>(f(),B(EI,{class:he({"max-width":r.value}),record:m,key:m.id},null,8,["class","record"]))),128))]),_:1})])}}}),Ydt=se(jdt,[["__scopeId","data-v-7ab88f3c"]]),Xdt={class:"user-records-section"},Qdt={class:"section-title"},Zdt={class:"title"},Jdt={class:"user-records"},eEt={key:0,class:"no-records"},tEt=Q({__name:"index",props:{sports:{},user:{}},setup(e){const t=e,{user:n}=_e(t),{t:a}=yt(),s=F(()=>mI(n.value.records,ca(t.sports,a),n.value.timezone,n.value.imperial_units,n.value.display_ascent,n.value.date_format));return(o,i)=>(f(),v("div",Xdt,[p("div",Qdt,[i[0]||(i[0]=p("i",{class:"fa fa-trophy custom-fa-small","aria-hidden":"true"},null,-1)),p("span",Zdt,A(o.$t("workouts.RECORD",2)),1)]),p("div",Jdt,[Object.keys(s.value).length===0?(f(),v("div",eEt,A(o.$t("workouts.NO_RECORDS")),1)):D("",!0),(f(!0),v(le,null,be(Object.keys(s.value).sort(),r=>(f(),B(Ydt,{sportTranslatedLabel:r,records:s.value[r],key:r,useImperialUnits:T(n).imperial_units},null,8,["sportTranslatedLabel","records","useImperialUnits"]))),128))])]))}}),nEt=se(tEt,[["__scopeId","data-v-e0d23747"]]),aEt={id:"user-stats"},Ed="km",pd="m",sEt=Q({__name:"index",props:{user:{}},setup(e){const t=e,{user:n}=_e(t),{t:a}=yt(),s=F(()=>Xp(n.value.total_duration,a)),o=F(()=>n.value.imperial_units?bn[Ed].defaultTarget:Ed),i=F(()=>n.value.imperial_units?Xt(n.value.total_distance,Ed,o.value,2):parseFloat(n.value.total_distance.toFixed(2))),r=F(()=>n.value.imperial_units?bn[pd].defaultTarget:pd),u=F(()=>n.value.imperial_units?Xt(n.value.total_ascent,pd,r.value,2):parseFloat(n.value.total_ascent.toFixed(2)));return(l,d)=>(f(),v("div",aEt,[w(xa,{icon:"calendar",value:T(n).nb_workouts,text:l.$t("workouts.WORKOUT",T(n).nb_workouts)},null,8,["value","text"]),w(xa,{icon:"road",value:i.value,text:o.value==="mi"?"miles":o.value},null,8,["value","text"]),T(n).display_ascent?(f(),B(xa,{key:0,icon:"location-arrow",value:u.value,text:r.value==="ft"?"feet":r.value},null,8,["value","text"])):D("",!0),w(xa,{icon:"clock-o",value:s.value.days,text:s.value.duration},null,8,["value","text"]),T(n).display_ascent?D("",!0):(f(),B(xa,{key:1,icon:"tags",value:T(n).nb_sports,text:l.$t("workouts.SPORT",T(n).nb_sports)},null,8,["value","text"]))]))}}),oEt={},iEt={class:"privacy-policy-message"};function rEt(e,t){const n=q("router-link"),a=q("i18n-t");return f(),v("div",iEt,[p("span",null,[w(a,{keypath:"user.LAST_PRIVACY_POLICY_TO_VALIDATE"},{default:X(()=>[w(n,{to:"/profile/edit/privacy-policy",class:"policy-link"},{default:X(()=>[x(A(e.$t("user.REVIEW")),1)]),_:1})]),_:1})])])}const uEt=se(oEt,[["render",rEt],["__scopeId","data-v-1b250692"]]),lEt={key:0,id:"dashboard",class:"view"},cEt={class:"container mobile-menu"},dEt={class:"box"},EEt={key:0,class:"container privacy-policy-message"},pEt={class:"container"},mEt={class:"container dashboard-container"},TEt={class:"left-container dashboard-sub-container"},_Et={class:"right-container dashboard-sub-container"},fEt={key:1,class:"app-loading"},hEt=Q({__name:"Dashboard",setup(e){const t=$e(),{authUser:n}=Ke(),{sports:a}=ln(),s=Se("calendar");function o(i){s.value=i}return tt(()=>t.dispatch(K.ACTIONS.GET_USER_PROFILE)),(i,r)=>{const u=q("Loader");return T(n).username&&T(a).length>0?(f(),v("div",lEt,[p("div",cEt,[p("div",dEt,[p("div",{class:he(["mobile-menu-item",{"is-selected":s.value==="calendar"}]),onClick:r[0]||(r[0]=l=>o("calendar"))},r[4]||(r[4]=[p("i",{class:"fa fa-calendar","aria-hidden":"true"},null,-1)]),2),p("div",{class:he(["mobile-menu-item",{"is-selected":s.value==="chart"}]),onClick:r[1]||(r[1]=l=>o("chart"))},r[5]||(r[5]=[p("i",{class:"fa fa-bar-chart","aria-hidden":"true"},null,-1)]),2),p("div",{class:he(["mobile-menu-item",{"is-selected":s.value==="timeline"}]),onClick:r[2]||(r[2]=l=>o("timeline"))},r[6]||(r[6]=[p("i",{class:"fa fa-map-o","aria-hidden":"true"},null,-1)]),2),p("div",{class:he(["mobile-menu-item",{"is-selected":s.value==="records"}]),onClick:r[3]||(r[3]=l=>o("records"))},r[7]||(r[7]=[p("i",{class:"fa fa-trophy","aria-hidden":"true"},null,-1)]),2)])]),T(n).accepted_privacy_policy?D("",!0):(f(),v("div",EEt,[w(uEt)])),p("div",pEt,[w(sEt,{user:T(n)},null,8,["user"])]),p("div",mEt,[p("div",TEt,[w(Fdt,{sports:T(a),user:T(n),class:he({"is-hidden":s.value!=="chart"})},null,8,["sports","user","class"]),w(nEt,{sports:T(a),user:T(n),class:he({"is-hidden":s.value!=="records"})},null,8,["sports","user","class"])]),p("div",_Et,[w(Zct,{sports:T(a),user:T(n),class:he({"is-hidden":s.value!=="calendar"})},null,8,["sports","user","class"]),w(Act,{sports:T(a),authUser:T(n),class:he({"is-hidden":s.value!=="timeline"})},null,8,["sports","authUser","class"])])]),r[8]||(r[8]=p("div",{id:"bottom"},null,-1))])):(f(),v("div",fEt,[w(u)]))}}}),SEt=se(hEt,[["__scopeId","data-v-6fbc5028"]]),AEt={class:"not-found view"},OEt=Q({__name:"NotFoundView",setup(e){return(t,n)=>(f(),v("div",AEt,[w(Wo)]))}}),IEt={key:0,class:"follow-request"},gEt={class:"follow-request-user"},REt={class:"user-name"},NEt={key:0,class:"follow-request-actions"},vEt={key:1,class:"follow-request-actions"},bEt=Q({__name:"RelationshipDetail",props:{authUser:{},notification:{}},emits:["updatedUserRelationship"],setup(e,{emit:t}){const n=e,{authUser:a,notification:s}=_e(n),o=t,i=$e();function r(u,l){i.dispatch(K.ACTIONS.UPDATE_FOLLOW_REQUESTS,{username:u,action:l}),o("updatedUserRelationship")}return(u,l)=>{const d=q("router-link");return T(s).from?(f(),v("div",IEt,[p("div",gEt,[w(Jt,{user:T(s).from},null,8,["user"]),p("div",REt,[w(d,{to:`/users/${T(s).from.username}`},{default:X(()=>[x(A(T(s).from.username),1)]),_:1},8,["to"])])]),T(s).type==="follow_request"?(f(),v("div",NEt,[p("button",{onClick:l[0]||(l[0]=E=>r(T(s).from.username,"accept"))},[l[3]||(l[3]=p("i",{class:"fa fa-check","aria-hidden":"true"},null,-1)),x(" "+A(u.$t("buttons.ACCEPT")),1)]),p("button",{onClick:l[1]||(l[1]=E=>r(T(s).from.username,"reject")),class:"danger"},[l[4]||(l[4]=p("i",{class:"fa fa-times","aria-hidden":"true"},null,-1)),x(" "+A(u.$t("buttons.REJECT")),1)])])):(f(),v("div",vEt,[w(Xu,{authUser:T(a),user:T(s).from,from:"notifications",displayFollowsYou:!0,onUpdatedUser:l[2]||(l[2]=()=>o("updatedUserRelationship"))},null,8,["authUser","user"])]))])):D("",!0)}}}),CEt=se(bEt,[["__scopeId","data-v-4f481d19"]]),PEt={class:"report-notification"},DEt={key:0,class:"reported-workout"},LEt={key:1,class:"reported-comment"},yEt={key:2,class:"reported-user"},$Et={class:"user-name"},UEt={key:3,class:"reported-user"},kEt={class:"deleted-object"},wEt={class:"report-button"},MEt=Q({__name:"ReportNotification",props:{report:{}},setup(e){const t=e,{report:n}=_e(t);return(a,s)=>{const o=q("router-link");return f(),v("div",PEt,[T(n).reported_workout?(f(),v("div",DEt,[w(Zp,{"display-appeal":!1,"display-object-name":!0,workout:T(n).reported_workout,"report-id":T(n).id},null,8,["workout","report-id"])])):T(n).reported_comment?(f(),v("div",LEt,[w(Qp,{"display-object-name":!0,comment:T(n).reported_comment},null,8,["comment"])])):T(n).reported_user?(f(),v("div",yEt,[w(Jt,{user:T(n).reported_user},null,8,["user"]),p("div",$Et,[w(o,{to:`/users/${T(n).reported_user.username}`},{default:X(()=>[x(A(T(n).reported_user.username),1)]),_:1},8,["to"])])])):(f(),v("div",UEt,[p("span",kEt,A(a.$t("admin.DELETED_USER")),1)])),p("div",wEt,[p("button",{onClick:s[0]||(s[0]=i=>a.$router.push(`/admin/reports/${T(n).id}`))},A(a.$t("admin.APP_MODERATION.VIEW_REPORT"))+" #"+A(T(n).id),1)])])}}}),WEt=se(MEt,[["__scopeId","data-v-b6fb44aa"]]),FEt={class:"notification-data-button"},zEt={class:"notification-date"},xEt=["title"],BEt={class:"hidden-content"},GEt={key:0},VEt={class:"notification-reason"},HEt={key:5,class:"auth-user"},KEt={class:"user-name"},qEt={key:6},jEt={key:0,class:"info-box appeal-in-progress"},YEt=Q({__name:"NotificationDetail",props:{authUser:{},notification:{}},emits:["reload","updateReadStatus"],setup(e,{emit:t}){const n=e,{authUser:a,notification:s}=_e(n),o=t,{locale:i}=He(),r=F(()=>m(s.value.type));function u(){o("reload")}function l(_,h){o("updateReadStatus",{notificationId:_,markedAsRead:h})}function d(_){return["comment_like","comment_suspension","comment_unsuspension","mention","user_warning","user_warning_lifting","workout_comment"].includes(_)&&s.value.comment!==void 0}function E(_){return["follow","follow_request","account_creation"].includes(_)}function c(_){var h;switch(_){case"account_creation":return"notifications.SIGN_UP";case"comment_like":return"notifications.LIKED_YOUR_COMMENT";case"comment_suspension":return"notifications.YOUR_COMMENT_HAS_BEEN_SUSPENDED";case"comment_unsuspension":return"notifications.YOUR_COMMENT_HAS_BEEN_UNSUSPENDED";case"follow":return"user.RELATIONSHIPS.FOLLOWS_YOU";case"follow_request":return"notifications.SEND_FOLLOW_REQUEST_TO_YOU";case"mention":return"notifications.MENTIONED_YOU";case"suspension_appeal":return"notifications.APPEALED_SUSPENSION";case"user_warning":return"notifications.YOU_RECEIVED_A_WARNING";case"user_warning_appeal":return"notifications.APPEALED_USER_WARNING";case"user_warning_lifting":return"notifications.YOUR_WARNING_HAS_BEEN_LIFTED";case"workout_comment":return"notifications.COMMENTED_YOUR_WORKOUT";case"workout_like":return"notifications.LIKED_YOUR_WORKOUT";case"workout_suspension":return"notifications.YOUR_WORKOUT_HAS_BEEN_SUSPENDED";case"workout_unsuspension":return"notifications.YOUR_WORKOUT_HAS_BEEN_UNSUSPENDED";case"report":return`notifications.REPORTED_USER_${(h=s.value.report)!=null&&h.object_type?s.value.report.object_type.toUpperCase():""}`;default:return""}}function m(_){switch(_){case"follow":case"follow_request":return"user-plus";case"mention":return"at";case"comment_suspension":case"comment_unsuspension":case"report":case"suspension_appeal":case"user_warning":case"user_warning_appeal":case"user_warning_lifting":case"workout_suspension":case"workout_unsuspension":return"flag";case"comment_like":case"workout_like":return"heart";default:return"comment"}}return(_,h)=>{const O=q("router-link"),S=q("Card");return T(s).id?(f(),B(S,{key:0,class:he(["notification-card",{read:T(s).marked_as_read}])},{title:X(()=>[p("div",null,[p("i",{class:he([`fa-${r.value}`,"fa notification-icon"]),"aria-hidden":"true"},null,2),T(s).from?(f(),B(O,{key:0,to:`/users/${T(s).from.username}`},{default:X(()=>[x(A(T(s).from.username),1)]),_:1},8,["to"])):D("",!0),x(" "+A(_.$t(c(T(s).type))),1)]),p("div",FEt,[p("div",zEt,A(T(xs)(new Date(T(s).created_at),new Date,{addSuffix:!0,locale:T(i)})),1),p("button",{class:"mark-action",title:_.$t(`notifications.MARK_AS_${T(s).marked_as_read?"UN":""}READ`),onClick:h[0]||(h[0]=()=>l(T(s).id,!T(s).marked_as_read))},[p("span",BEt,A(_.$t(`notifications.MARK_AS_${T(s).marked_as_read?"UN":""}READ`)),1),p("i",{class:he(["fa",`fa-eye${T(s).marked_as_read?"-slash":""}`]),"aria-hidden":"true"},null,2)],8,xEt)])]),content:X(()=>{var R,g,I,N,b,C;return[(R=T(s).report_action)!=null&&R.reason?(f(),v("div",GEt,[p("span",VEt,A(_.$t("admin.APP_MODERATION.REASON"))+": ",1),x(" "+A(T(s).report_action.reason),1)])):D("",!0),d(T(s).type)&&T(s).comment?(f(),B(Qp,{key:1,"display-object-name":T(s).type.startsWith("user_warning"),comment:T(s).comment,action:T(s).report_action},null,8,["display-object-name","comment","action"])):E(T(s).type)?(f(),B(CEt,{key:2,notification:T(s),authUser:T(a),onUpdatedUserRelationship:u},null,8,["notification","authUser"])):["report","suspension_appeal","user_warning_appeal"].includes(T(s).type)&&T(s).report?(f(),B(WEt,{key:3,report:T(s).report},null,8,["report"])):T(s).workout?(f(),B(Zp,{key:4,action:T(s).report_action,"display-appeal":T(s).type!=="user_warning","display-object-name":T(s).type.startsWith("user_warning"),workout:T(s).workout},null,8,["action","display-appeal","display-object-name","workout"])):D("",!0),((g=T(s).report_action)==null?void 0:g.action_type)==="user_warning_lifting"&&!T(s).comment&&!T(s).workout?(f(),v("div",HEt,[w(Jt,{user:T(a)},null,8,["user"]),p("div",KEt,[w(O,{to:`/users/${T(a).username}`},{default:X(()=>[x(A(T(a).username),1)]),_:1},8,["to"])])])):D("",!0),((I=T(s).report_action)==null?void 0:I.action_type)==="user_warning"?(f(),v("div",qEt,[((b=(N=T(s).report_action)==null?void 0:N.appeal)==null?void 0:b.approved)===null?(f(),v("div",jEt,[p("span",null,[h[1]||(h[1]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(_.$t("user.APPEAL_IN_PROGRESS")),1)])])):(C=T(s).report_action)!=null&&C.appeal?D("",!0):(f(),B(O,{key:1,class:"appeal-link",to:`profile/moderation/sanctions/${T(s).report_action.id}`},{default:X(()=>[x(A(_.$t("user.APPEAL")),1)]),_:1},8,["to"]))])):D("",!0)]}),_:1},8,["class"])):D("",!0)}}}),XEt=se(YEt,[["__scopeId","data-v-a92551c1"]]),QEt={key:0,id:"notifications"},ZEt={key:0,class:"no-notifications box"},JEt=Q({__name:"Notifications",setup(e){const t=$e(),n=it(),{authUser:a,isAuthUserSuspended:s}=Ke();let o=kt(u(n.query));const i=F(()=>t.getters[dt.GETTERS.NOTIFICATIONS]),r=F(()=>t.getters[dt.GETTERS.PAGINATION]);function u(m){const _={};return"page"in m&&m.page&&(_.page=+m.page),"type"in m&&m.type&&(_.type=m.type),"status"in m&&m.status==="unread"&&(_.status="unread"),_}function l(){setTimeout(()=>{d(o)},500)}function d(m){s.value||t.dispatch(dt.ACTIONS.GET_NOTIFICATIONS,m)}function E(m){t.dispatch(dt.ACTIONS.UPDATE_STATUS,{...m,currentQuery:o})}function c(){t.dispatch(dt.ACTIONS.MARK_ALL_AS_READ,o)}return Le(()=>n.query,m=>{o=u(m),d(o)}),Le(()=>s.value,m=>{m||d(o)}),tt(()=>d(o)),Et(()=>{t.commit(dt.MUTATIONS.EMPTY_NOTIFICATIONS)}),(m,_)=>T(a).username?(f(),v("div",QEt,[i.value.length===0?(f(),v("div",ZEt,A(m.$t("notifications.NO_NOTIFICATIONS")),1)):(f(),v(le,{key:1},[p("button",{class:"mark-all-action",onClick:c},A(m.$t("notifications.MARK_ALL_AS_READ")),1),(f(!0),v(le,null,be(i.value,h=>(f(),B(XEt,{key:h.id,"auth-user":T(a),notification:h,onReload:l,onUpdateReadStatus:E},null,8,["auth-user","notification"]))),128)),r.value.page?(f(),B(da,{key:0,path:"/notifications",pagination:r.value,query:T(o)},null,8,["pagination","query"])):D("",!0)],64))])):D("",!0)}}),ept=se(JEt,[["__scopeId","data-v-a25ddea4"]]),tpt={class:"notifications-filters"},npt={class:"box"},apt={class:"form"},spt={class:"form-all-items"},opt={class:"form-items-group"},ipt={class:"status-title"},rpt={class:"status-radio"},upt=["checked"],lpt=["checked"],cpt={class:"form-items-group"},dpt={class:"form-item"},Ept=["disabled","value"],ppt={value:""},mpt=["value"],Tpt=Q({__name:"NotificationsFilters",setup(e){const t=it(),n=yn(),a=$e(),{t:s}=yt(),{authUserHasModeratorRights:o,isAuthUserSuspended:i}=Ke(),r=F(()=>a.getters[dt.GETTERS.TYPES]),u=F(()=>S());let l=Object.assign({},t.query);const d=Se(E(t.query));function E(R){return"status"in R?R.status:null}function c(R){d.value=R,l.status=R,_()}function m(R){const g=R.target;g.value===""?delete l[g.name]:l[g.name]=g.value,_()}function _(){"page"in l&&(l.page="1"),n.push({path:"/notifications",query:l})}function h(R,g){return R.label>g.label?1:R.label!["report","suspension_appeal","user_warning_appeal"].includes(g)||o.value).map(g=>{R.push({label:s(`notifications.TYPES.${g}`),value:g})}),R.sort(h)}return Le(()=>t.query,R=>{l=Object.assign({},R),d.value=E(R),O()}),tt(()=>O()),Et(()=>{a.commit(dt.MUTATIONS.UPDATE_TYPES,[])}),(R,g)=>(f(),v("div",tpt,[p("div",npt,[p("form",apt,[p("div",spt,[p("div",opt,[p("span",ipt,A(R.$t("notifications.STATUS")),1),p("div",rpt,[p("label",null,[p("input",{type:"radio",name:"duration",checked:d.value==="unread",onClick:g[0]||(g[0]=I=>c("unread"))},null,8,upt),x(" "+A(R.$t("notifications.UNREAD")),1)]),p("label",null,[p("input",{type:"radio",name:"all",checked:d.value!=="unread",onClick:g[1]||(g[1]=I=>c("all"))},null,8,lpt),x(" "+A(R.$t("notifications.ALL")),1)])])]),p("div",cpt,[p("div",dpt,[p("label",null,A(R.$t("notifications.TYPES.LABEL")),1),p("select",{name:"type",disabled:u.value.length===0,value:R.$route.query.type,onChange:m},[u.value.length>0?(f(),v(le,{key:0},[p("option",ppt,A(R.$t("notifications.TYPES.ALL")),1),g[2]||(g[2]=p("option",{disabled:""},"──────",-1))],64)):D("",!0),(f(!0),v(le,null,be(u.value,I=>(f(),v("option",{value:I.value,key:I.value},A(I.label),9,mpt))),128))],40,Ept)])])])])])]))}}),_pt=se(Tpt,[["__scopeId","data-v-fc2eda5e"]]),fpt={id:"notifications",class:"view items-list-view"},hpt={class:"container items-list-container"},Spt={class:"display-filters"},Apt={class:"list-container"},Opt=Q({__name:"NotificationsView",setup(e){const t=Se(!0);function n(){t.value=!t.value}return(a,s)=>(f(),v("div",fpt,[p("div",hpt,[p("div",{class:he(["filters-container",{hidden:t.value}])},[w(_pt)],2),p("div",Spt,[p("div",{onClick:n},[p("i",{class:he(`fa fa-caret-${t.value?"down":"up"}`),"aria-hidden":"true"},null,2),p("span",null,A(a.$t(`workouts.${t.value?"DISPLAY":"HIDE"}_FILTERS`)),1)])]),p("div",Apt,[w(ept)])])]))}}),Ipt={id:"privacy-policy",class:"view"},gpt={class:"container"},Rpt=Q({__name:"PrivacyPolicyView",setup(e){const t=$e();return tt(()=>{t.dispatch(te.ACTIONS.GET_APPLICATION_PRIVACY_POLICY)}),(n,a)=>(f(),v("div",Ipt,[p("div",gpt,[w(JO)]),a[0]||(a[0]=p("div",{id:"bottom"},null,-1))]))}}),Npt={class:"chart-menu"},vpt=["disabled","aria-label"],bpt={class:"time-frames custom-checkboxes-group"},Cpt={class:"time-frames-checkboxes custom-checkboxes"},Ppt=["id","name","checked","onInput","disabled"],Dpt=["id","tabindex","onKeydown"],Lpt=["disabled","aria-label"],ypt={class:"stats-type"},$pt={class:"stats-type-radio"},Upt=["checked","disabled"],kpt=["checked","disabled"],wpt=Q({__name:"StatsMenu",props:{isDisabled:{type:Boolean}},emits:["arrowClick","statsTypeUpdate","timeFrameUpdate"],setup(e,{emit:t}){const n=e,{isDisabled:a}=_e(n),s=t,o=["week","month","year"],i=Se("month"),r=Se("total");function u(d){i.value=d,s("timeFrameUpdate",d)}function l(d){r.value=d.target.value,s("statsTypeUpdate",r.value)}return(d,E)=>(f(),v(le,null,[p("div",Npt,[p("button",{class:"chart-arrow transparent",onClick:E[0]||(E[0]=c=>s("arrowClick",!0)),onKeydown:E[1]||(E[1]=je(c=>s("arrowClick",!0),["enter"])),disabled:T(a),"aria-label":d.$t("common.PREVIOUS")},E[4]||(E[4]=[p("i",{class:"fa fa-chevron-left","aria-hidden":"true"},null,-1)]),40,vpt),p("div",bpt,[p("div",Cpt,[(f(),v(le,null,be(o,c=>p("div",{class:"time-frame custom-checkbox",key:c},[p("label",null,[p("input",{type:"radio",id:c,name:c,checked:i.value===c,onInput:m=>u(c),disabled:T(a)},null,40,Ppt),p("span",{id:`frame-${c}`,tabindex:T(a)?-1:0,role:"button",onKeydown:je(m=>u(c),["enter"])},A(d.$t(`statistics.TIME_FRAMES.${c}`)),41,Dpt)])])),64))])]),p("button",{class:"chart-arrow transparent",onClick:E[2]||(E[2]=c=>s("arrowClick",!1)),onKeydown:E[3]||(E[3]=je(c=>s("arrowClick",!1),["enter"])),disabled:T(a),"aria-label":d.$t("common.NEXT")},E[5]||(E[5]=[p("i",{class:"fa fa-chevron-right","aria-hidden":"true"},null,-1)]),40,Lpt)]),p("div",ypt,[p("div",$pt,[p("label",null,[p("input",{type:"radio",name:"stats_type",value:"total",checked:r.value==="total",disabled:T(a),onClick:l},null,8,Upt),x(" "+A(d.$t("common.TOTAL")),1)]),p("label",null,[p("input",{type:"radio",name:"stats_type",value:"average",checked:r.value==="average",disabled:T(a),onClick:l},null,8,kpt),x(" "+A(d.$t("statistics.AVERAGE")),1)])])])],64))}}),Mpt=se(wpt,[["__scopeId","data-v-dacfdeb2"]]),Wpt={class:"sports-menu"},Fpt=["id","name","checked","onInput","onKeyup"],zpt={class:"sport-label"},xpt=Q({__name:"StatsSportsMenu",props:{userSports:{},selectedSportIds:{default:()=>[]}},emits:["selectedSportIdsUpdate"],setup(e,{emit:t}){const n=e,{selectedSportIds:a,userSports:s}=_e(n),o=t,{t:i}=yt(),{sportColors:r}=ln(),u=F(()=>ca(s.value,i));function l(d){o("selectedSportIdsUpdate",d)}return(d,E)=>{const c=q("SportImage");return f(),v("div",Wpt,[(f(!0),v(le,null,be(u.value,m=>(f(),v("label",{type:"checkbox",key:m.id,style:Va({color:m.color?m.color:T(r)[m.label]})},[p("input",{type:"checkbox",id:`${m.id}`,name:m.label,checked:T(a).includes(m.id),onInput:_=>l(m.id),onKeyup:je(Ne(_=>l(m.id),["prevent"]),["space"])},null,40,Fpt),w(c,{"sport-label":m.label,color:m.color},null,8,["sport-label","color"]),p("span",zpt,A(m.translatedLabel),1)],4))),128))])}}}),Bpt={key:0,id:"user-statistics"},Gpt=Q({__name:"index",props:{sports:{},user:{},isDisabled:{type:Boolean}},setup(e){const t=e,{sports:n,user:a}=_e(t),{t:s}=yt(),o=Se("month"),i=Se("total"),r=Se(c(o.value,i.value)),u=Se(_(n.value)),l=F(()=>ca(t.sports,s));function d(O){o.value=O,r.value=c(O,i.value)}function E(O){i.value=O,r.value=c(o.value,O)}function c(O,S){return Tdt(new Date,O,t.user.weekm,S)}function m(O){r.value=_dt(r.value,O,t.user.weekm)}function _(O){return O.map(S=>S.id)}function h(O){u.value.includes(O)?u.value=u.value.filter(S=>S!==O):u.value.push(O)}return Le(()=>t.sports,O=>{u.value=_(O)}),(O,S)=>l.value?(f(),v("div",Bpt,[w(Mpt,{onStatsTypeUpdate:E,onTimeFrameUpdate:d,onArrowClick:m,isDisabled:O.isDisabled},null,8,["isDisabled"]),w(dI,{sports:T(n),user:T(a),chartParams:r.value,"displayed-sport-ids":u.value,fullStats:!0,isDisabled:O.isDisabled,selectedTimeFrame:o.value},null,8,["sports","user","chartParams","displayed-sport-ids","isDisabled","selectedTimeFrame"]),w(xpt,{"selected-sport-ids":u.value,"user-sports":T(n),onSelectedSportIdsUpdate:h},null,8,["selected-sport-ids","user-sports"])])):D("",!0)}}),Vpt=se(Gpt,[["__scopeId","data-v-f9158924"]]),Hpt={class:"sport-stat-card"},Kpt={class:"stat-content"},qpt={class:"stat-icon"},jpt={class:"stat-details"},Ypt={class:"stat-label"},Xpt={class:"stat-values"},Qpt={key:0,class:"fa fa-refresh fa-spin fa-fw"},Zpt={key:1,class:"stat-huge"},Jpt={key:2,class:"stat"},emt={key:0,class:"stat-average"},tmt={key:0},so=Q({__name:"SportStatCard",props:{icon:{},text:{default:""},totalValue:{},label:{},loading:{type:Boolean}},setup(e){const t=e,{icon:n,loading:a,text:s,totalValue:o}=_e(t);return(i,r)=>(f(),v("div",Hpt,[p("div",Kpt,[p("div",qpt,[p("i",{class:he(["fa",`fa-${T(n)}`])},null,2)]),p("div",jpt,[p("div",Ypt,A(i.label),1),p("div",Xpt,[T(a)?(f(),v("i",Qpt)):(f(),v("span",Zpt,A(T(o)?T(o):""),1)),T(s)?(f(),v("span",Jpt,A(T(s)),1)):D("",!0)]),["calendar","tachometer"].includes(T(n))?D("",!0):(f(),v("div",emt,[T(a)?(f(),v("div",tmt,r[0]||(r[0]=[p("i",{class:"fa fa-refresh fa-spin fa-fw"},null,-1)]))):Pt(i.$slots,"average",{key:1})]))])])]))}}),nmt={id:"sport-statistics"},amt={for:"sport"},smt=["value"],omt={key:0,class:"sport-statistics"},imt={class:"sport-img-label"},rmt={class:"sport-label"},umt={class:"label"},lmt={class:"statistics"},cmt={key:0,class:"statistics-workouts-count"},dmt={key:1,class:"statistics-workouts-count"},Emt={class:"statistics"},pmt={class:"records"},mmt={class:"label"},Tmt=Q({__name:"SportStatistics",props:{sports:{},authUser:{}},setup(e){const t=e,{authUser:n,sports:a}=_e(t),s=it(),o=yn(),i=$e(),{t:r}=yt(),u=F(()=>ca(a.value,r,"all")),l=F(()=>u.value.map(C=>C.id)),d=F(()=>s.query.sport_id&&l.value.includes(+s.query.sport_id)?+s.query.sport_id:l.value[0]),E=F(()=>mI(n.value.records,u.value,n.value.timezone,n.value.imperial_units,n.value.display_ascent,n.value.date_format,d.value)),c=F(()=>u.value.find(C=>C.id===d.value)),m=F(()=>i.getters.USER_SPORT_STATS[d.value]),_=F(()=>i.getters.TOTAL_WORKOUTS),h=F(()=>n.value.imperial_units?bn.km.defaultTarget:"km"),O=F(()=>n.value.imperial_units?bn.m.defaultTarget:"m"),S=F(()=>i.getters.STATS_LOADING),R=F(()=>m.value?Xp(m.value.total_duration,r):{days:"",duration:""});function g(C,k){if(C===void 0)return"";const P=n.value.imperial_units?bn[k].defaultTarget:k;return n.value.imperial_units?Xt(C,k,P,2):C}function I(){i.dispatch(Wt.ACTIONS.GET_USER_SPORT_STATS,{username:n.value.username,sportId:d.value})}function N(C){var P,$;const k=[];return(P=c.value)!=null&&P.translatedLabel&&C[($=c.value)==null?void 0:$.translatedLabel].records.map(y=>{k.push({...y,label:r(`workouts.RECORD_${y.record_type}`)})}),k.sort(pI)}function b(C){o.push({path:"/statistics",query:{chart:"by_sport",sport_id:C.target.value}})}return Le(()=>s.query,()=>{I()}),tt(()=>I()),(C,k)=>{var y,z,Z,Ae,J,ce;const P=q("SportImage"),$=q("Distance");return f(),v("div",nmt,[p("label",amt,A(C.$t("workouts.SPORT",1))+": ",1),Me(p("select",{id:"sport","onUpdate:modelValue":k[0]||(k[0]=Te=>d.value=Te),onChange:b},[(f(!0),v(le,null,be(u.value,Te=>(f(),v("option",{value:Te.id,key:Te.id},A(Te.translatedLabel),9,smt))),128))],544),[[Sn,d.value]]),c.value?(f(),v("div",omt,[p("div",imt,[w(P,{"sport-label":c.value.label,color:c.value.color},null,8,["sport-label","color"]),p("div",rmt,A(c.value.translatedLabel),1)]),p("div",null,[p("div",umt,[k[1]||(k[1]=p("i",{class:"fa fa-line-chart custom-fa-small","aria-hidden":"true"},null,-1)),x(" "+A(C.$t("statistics.STATISTICS",0)),1)]),p("div",lmt,[w(so,{icon:"calendar",loading:S.value,"total-value":_.value,label:C.$t("workouts.WORKOUT",0)},null,8,["loading","total-value","label"])]),m.value&&m.value.total_workouts<_.value?(f(),v("div",cmt,A(C.$t("statistics.STATISTICS_ON_LAST_WORKOUTS",{count:m.value.total_workouts})),1)):(f(),v("div",dmt,A(C.$t("statistics.STATISTICS_ON_ALL_WORKOUTS")),1)),p("div",Emt,[w(so,{icon:"road",loading:S.value,"total-value":g((y=m.value)==null?void 0:y.total_distance,"km"),text:h.value,label:C.$t("workouts.DISTANCE")},{average:X(()=>[p("div",null,A(C.$t("statistics.AVERAGE"))+":",1),m.value?(f(),B($,{key:0,distance:m.value.average_distance,unitFrom:"km",useImperialUnits:T(n).imperial_units},null,8,["distance","useImperialUnits"])):D("",!0)]),_:1},8,["loading","total-value","text","label"]),w(so,{icon:"clock-o",loading:S.value,"total-value":R.value.days,text:R.value.duration,label:C.$t("workouts.DURATION")},{average:X(()=>[p("div",null,A(C.$t("statistics.AVERAGE"))+":",1),p("span",null,A(m.value?T(AE)(m.value.average_duration,C.$t):""),1)]),_:1},8,["loading","total-value","text","label"]),w(so,{icon:"tachometer",loading:S.value,"total-value":g((z=m.value)==null?void 0:z.average_speed,"km"),text:`${h.value}/h`,label:C.$t("workouts.AVE_SPEED")},null,8,["loading","total-value","text","label"]),((Z=m.value)==null?void 0:Z.total_ascent)!==null?(f(),B(so,{key:0,icon:"location-arrow",loading:S.value,"total-value":g((Ae=m.value)==null?void 0:Ae.total_ascent,"m"),text:O.value,label:C.$t("workouts.ASCENT")},{average:X(()=>[p("div",null,A(C.$t("statistics.AVERAGE"))+":",1),m.value?(f(),B($,{key:0,distance:m.value.average_ascent,unitFrom:"m",useImperialUnits:T(n).imperial_units},null,8,["distance","useImperialUnits"])):D("",!0)]),_:1},8,["loading","total-value","text","label"])):D("",!0),((J=m.value)==null?void 0:J.total_descent)!==null?(f(),B(so,{key:1,icon:"location-arrow fa-rotate-90",loading:S.value,"total-value":g((ce=m.value)==null?void 0:ce.total_descent,"m"),text:O.value,label:C.$t("workouts.DESCENT")},{average:X(()=>[p("div",null,A(C.$t("statistics.AVERAGE"))+":",1),m.value?(f(),B($,{key:0,distance:m.value.average_descent,unitFrom:"m",useImperialUnits:T(n).imperial_units},null,8,["distance","useImperialUnits"])):D("",!0)]),_:1},8,["loading","total-value","text","label"])):D("",!0)])]),p("div",pmt,[p("div",mmt,[k[2]||(k[2]=p("i",{class:"fa fa-trophy custom-fa-small","aria-hidden":"true"},null,-1)),x(" "+A(C.$t("workouts.RECORD",0)),1)]),p("div",null,[(f(!0),v(le,null,be(N(E.value),Te=>(f(),B(EI,{record:Te,key:Te.id},null,8,["record"]))),128))])])])):D("",!0)])}}}),_mt=se(Tmt,[["__scopeId","data-v-dad31bfb"]]),fmt={id:"statistics",class:"view"},hmt={key:0,class:"container"},Smt=["value"],Amt=Q({__name:"StatisticsView",setup(e){const t=it(),n=yn(),{authUser:a}=Ke(),{sports:s}=ln(),o=["by_time","by_sport"],i=Se("by_time"),r=F(()=>s.value.filter(d=>a.value.sports_list.includes(d.id))),u=F(()=>a.value.nb_workouts===0);function l(d){n.push({path:"/statistics",query:{chart:d.target.value}})}return tt(()=>{i.value=t.query.chart&&o.includes(t.query.chart)?t.query.chart:"by_time"}),Tt(()=>{if(!u.value){const d=document.getElementById("stats-type");d==null||d.focus()}}),(d,E)=>{const c=q("Card");return f(),v("div",fmt,[T(a).username?(f(),v("div",hmt,[w(c,null,{title:X(()=>[x(A(d.$t("statistics.STATISTICS"))+" ",1),r.value.length>0?Me((f(),v("select",{key:0,class:"stats-types",name:"stats-type",id:"stats-type","onUpdate:modelValue":E[0]||(E[0]=m=>i.value=m),onChange:l},[(f(),v(le,null,be(o,m=>p("option",{value:m,key:m},A(d.$t(`statistics.STATISTICS_TYPES.${m}`)),9,Smt)),64))],544)),[[Sn,i.value]]):D("",!0)]),content:X(()=>[d.$route.query.chart!=="by_sport"?(f(),B(Vpt,{key:0,class:he({"stats-disabled":u.value}),user:T(a),sports:r.value,isDisabled:u.value},null,8,["class","user","sports","isDisabled"])):r.value.length>0?(f(),B(_mt,{key:1,sports:r.value,authUser:T(a)},null,8,["sports","authUser"])):D("",!0)]),_:1}),T(a).nb_workouts===0?(f(),B(Jp,{key:0})):D("",!0)])):D("",!0)])}}}),Omt=se(Amt,[["__scopeId","data-v-94133818"]]),Imt={name:"EmailSent"},gmt={version:"1.1",id:"Capa_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 345.834 345.834",style:{"enable-background":"new 0 0 345.834 345.834"},"xml:space":"preserve"};function Rmt(e,t,n,a,s,o){return f(),v("svg",gmt,t[0]||(t[0]=[p("g",null,[p("path",{d:`M339.798,260.429c0.13-0.026,0.257-0.061,0.385-0.094c0.109-0.028,0.219-0.051,0.326-0.084 + c0.125-0.038,0.247-0.085,0.369-0.129c0.108-0.039,0.217-0.074,0.324-0.119c0.115-0.048,0.226-0.104,0.338-0.157 + c0.109-0.052,0.22-0.1,0.327-0.158c0.107-0.057,0.208-0.122,0.312-0.184c0.107-0.064,0.215-0.124,0.319-0.194 + c0.111-0.074,0.214-0.156,0.321-0.236c0.09-0.067,0.182-0.13,0.27-0.202c0.162-0.133,0.316-0.275,0.466-0.421 + c0.027-0.026,0.056-0.048,0.083-0.075c0.028-0.028,0.052-0.059,0.079-0.088c0.144-0.148,0.284-0.3,0.416-0.46 + c0.077-0.094,0.144-0.192,0.216-0.289c0.074-0.1,0.152-0.197,0.221-0.301c0.074-0.111,0.139-0.226,0.207-0.34 + c0.057-0.096,0.118-0.19,0.171-0.289c0.062-0.115,0.114-0.234,0.169-0.351c0.049-0.104,0.101-0.207,0.146-0.314 + c0.048-0.115,0.086-0.232,0.128-0.349c0.041-0.114,0.085-0.227,0.12-0.343c0.036-0.118,0.062-0.238,0.092-0.358 + c0.029-0.118,0.063-0.234,0.086-0.353c0.028-0.141,0.045-0.283,0.065-0.425c0.014-0.1,0.033-0.199,0.043-0.3 + c0.025-0.249,0.038-0.498,0.038-0.748V92.76c0-4.143-3.357-7.5-7.5-7.5h-236.25c-0.066,0-0.13,0.008-0.196,0.01 + c-0.143,0.004-0.285,0.01-0.427,0.022c-0.113,0.009-0.225,0.022-0.337,0.037c-0.128,0.016-0.255,0.035-0.382,0.058 + c-0.119,0.021-0.237,0.046-0.354,0.073c-0.119,0.028-0.238,0.058-0.356,0.092c-0.117,0.033-0.232,0.069-0.346,0.107 + c-0.117,0.04-0.234,0.082-0.349,0.128c-0.109,0.043-0.216,0.087-0.322,0.135c-0.118,0.053-0.235,0.11-0.351,0.169 + c-0.099,0.051-0.196,0.103-0.292,0.158c-0.116,0.066-0.23,0.136-0.343,0.208c-0.093,0.06-0.184,0.122-0.274,0.185 + c-0.106,0.075-0.211,0.153-0.314,0.235c-0.094,0.075-0.186,0.152-0.277,0.231c-0.09,0.079-0.179,0.158-0.266,0.242 + c-0.099,0.095-0.194,0.194-0.288,0.294c-0.047,0.05-0.097,0.094-0.142,0.145c-0.027,0.03-0.048,0.063-0.074,0.093 + c-0.094,0.109-0.182,0.223-0.27,0.338c-0.064,0.084-0.13,0.168-0.19,0.254c-0.078,0.112-0.15,0.227-0.222,0.343 + c-0.059,0.095-0.12,0.189-0.174,0.286c-0.063,0.112-0.118,0.227-0.175,0.342c-0.052,0.105-0.106,0.21-0.153,0.317 + c-0.049,0.113-0.092,0.23-0.135,0.345c-0.043,0.113-0.087,0.225-0.124,0.339c-0.037,0.115-0.067,0.232-0.099,0.349 + c-0.032,0.12-0.066,0.239-0.093,0.36c-0.025,0.113-0.042,0.228-0.062,0.342c-0.022,0.13-0.044,0.26-0.06,0.39 + c-0.013,0.108-0.019,0.218-0.027,0.328c-0.01,0.14-0.019,0.28-0.021,0.421c-0.001,0.041-0.006,0.081-0.006,0.122v46.252 + c0,4.143,3.357,7.5,7.5,7.5s7.5-3.357,7.5-7.5v-29.595l66.681,59.037c-0.348,0.245-0.683,0.516-0.995,0.827l-65.687,65.687v-49.288 + c0-4.143-3.357-7.5-7.5-7.5s-7.5,3.357-7.5,7.5v9.164h-38.75c-4.143,0-7.5,3.357-7.5,7.5s3.357,7.5,7.5,7.5h38.75v43.231 + c0,4.143,3.357,7.5,7.5,7.5h236.25c0.247,0,0.494-0.013,0.74-0.037c0.115-0.011,0.226-0.033,0.339-0.049 + C339.542,260.469,339.67,260.454,339.798,260.429z M330.834,234.967l-65.688-65.687c-0.042-0.042-0.087-0.077-0.13-0.117 + l49.383-41.897c3.158-2.68,3.546-7.412,0.866-10.571c-2.678-3.157-7.41-3.547-10.571-0.866l-84.381,71.59l-98.444-87.158h208.965 + V234.967z M185.878,179.888c0.535-0.535,0.969-1.131,1.308-1.765l28.051,24.835c1.418,1.255,3.194,1.885,4.972,1.885 + c1.726,0,3.451-0.593,4.853-1.781l28.587-24.254c0.26,0.38,0.553,0.743,0.89,1.08l65.687,65.687H120.191L185.878,179.888z`}),p("path",{d:`M7.5,170.676h126.667c4.143,0,7.5-3.357,7.5-7.5s-3.357-7.5-7.5-7.5H7.5c-4.143,0-7.5,3.357-7.5,7.5 + S3.357,170.676,7.5,170.676z`}),p("path",{d:`M20.625,129.345H77.5c4.143,0,7.5-3.357,7.5-7.5s-3.357-7.5-7.5-7.5H20.625c-4.143,0-7.5,3.357-7.5,7.5 + S16.482,129.345,20.625,129.345z`}),p("path",{d:"M62.5,226.51h-55c-4.143,0-7.5,3.357-7.5,7.5s3.357,7.5,7.5,7.5h55c4.143,0,7.5-3.357,7.5-7.5S66.643,226.51,62.5,226.51z"})],-1)]))}const TI=se(Imt,[["render",Rmt]]),Nmt={id:"user-form"},vmt={key:2,class:"info-box success-message"},bmt={class:"form-items"},Cmt={key:0,for:"username"},Pmt=["disabled"],Dmt={key:2,class:"form-info"},Lmt={key:3,for:"email"},ymt=["disabled"],$mt={key:5,class:"form-info"},Umt={key:6,for:"password"},kmt={key:8,for:"accepted_policy",class:"accepted_policy"},wmt=["disabled"],Mmt=["disabled"],Wmt={key:3},Fmt={key:0},zmt={key:4},xmt={class:"account"},Bmt={key:5},Gmt=Q({__name:"UserAuthForm",props:{action:{},token:{default:""}},setup(e){const t=e,{action:n,token:a}=_e(t),s=it(),o=$e(),{appConfig:i,appLanguage:r,errorMessages:u}=He(),{authUserSuccess:l}=Ke(),d=kt({username:"",email:"",password:"",accepted_policy:!1}),E=Se(!1),c=F(()=>O(t.action)),m=F(()=>o.getters[K.GETTERS.IS_REGISTRATION_SUCCESS]),_=F(()=>t.action==="register"&&!i.value.is_registration_enabled),h=F(()=>["reset-request","account-confirmation-resend"].includes(t.action)&&!i.value.is_email_sending_enabled);function O(N){switch(N){case"reset-request":case"reset":return"buttons.SUBMIT";default:return`buttons.${t.action.toUpperCase()}`}}function S(){E.value=!0}function R(N){d.password=N}function g(N){switch(N){case"reset":return a.value?o.dispatch(K.ACTIONS.RESET_USER_PASSWORD,{password:d.password,token:String(a.value)}):o.commit(te.MUTATIONS.SET_ERROR_MESSAGES,"user.INVALID_TOKEN");case"reset-request":return o.dispatch(K.ACTIONS.SEND_PASSWORD_RESET_REQUEST,{email:d.email});case"account-confirmation-resend":return o.dispatch(K.ACTIONS.RESEND_ACCOUNT_CONFIRMATION_EMAIL,{email:d.email});default:d.language=r.value,o.dispatch(K.ACTIONS.LOGIN_OR_REGISTER,{actionType:N,formData:d,redirectUrl:s.query.from})}}function I(){d.username="",d.email="",d.password="",d.accepted_policy=!1}return Le(()=>s.path,async()=>{o.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),o.commit(K.MUTATIONS.UPDATE_IS_SUCCESS,!1),o.commit(K.MUTATIONS.UPDATE_IS_REGISTRATION_SUCCESS,!1),E.value=!1,I()}),(N,b)=>{const C=q("AlertMessage"),k=q("router-link"),P=q("i18n-t"),$=q("ErrorMessage");return f(),v("div",{id:"user-auth-form",class:he(`${["reset","reset-request"].includes(T(n))?T(n):"user-form"}`)},[p("div",Nmt,[p("div",{class:he(["form-box",{disabled:_.value}])},[_.value?(f(),B(C,{key:0,message:"user.REGISTER_DISABLED"})):D("",!0),h.value?(f(),B(C,{key:1,message:"admin.EMAIL_SENDING_DISABLED"})):D("",!0),T(l)||m.value?(f(),v("div",vmt,A(N.$t(`user.PROFILE.SUCCESSFUL_${m.value?`REGISTRATION${T(i).is_email_sending_enabled?"_WITH_EMAIL":""}`:"UPDATE"}`)),1)):D("",!0),p("form",{class:he({errors:E.value}),onSubmit:b[3]||(b[3]=Ne(y=>g(T(n)),["prevent"]))},[p("div",bmt,[T(n)==="register"?(f(),v("label",Cmt,A(N.$t("user.USERNAME",0)),1)):D("",!0),T(n)==="register"?Me((f(),v("input",{key:1,id:"username",disabled:_.value,required:"",pattern:"[a-zA-Z0-9_]+",minlength:"3",maxlength:"30",onInvalid:S,"onUpdate:modelValue":b[0]||(b[0]=y=>d.username=y),autocomplete:"username"},null,40,Pmt)),[[st,d.username]]):D("",!0),T(n)==="register"?(f(),v("div",Dmt,[b[4]||(b[4]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(N.$t("user.USERNAME_INFO")),1)])):D("",!0),T(n)!=="reset"?(f(),v("label",Lmt,A(N.$t("user.EMAIL",0)),1)):D("",!0),T(n)!=="reset"?Me((f(),v("input",{key:4,id:"email",disabled:_.value||h.value,required:"",onInvalid:S,type:"email","onUpdate:modelValue":b[1]||(b[1]=y=>d.email=y),autocomplete:"email"},null,40,ymt)),[[st,d.email]]):D("",!0),["reset-request","register","account-confirmation-resend"].includes(T(n))?(f(),v("div",$mt,[b[5]||(b[5]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(N.$t("user.EMAIL_INFO")),1)])):D("",!0),["account-confirmation-resend","reset-request"].includes(T(n))?D("",!0):(f(),v("label",Umt,A(N.$t(`user.${T(n)==="reset"?"ENTER_PASSWORD":"PASSWORD"}`)),1)),["account-confirmation-resend","reset-request"].includes(T(n))?D("",!0):(f(),B(SE,{key:7,id:"password",disabled:_.value,required:!0,password:d.password,checkStrength:["reset","register"].includes(T(n)),onUpdatePassword:R,onPasswordError:S,autocomplete:"current-password"},null,8,["disabled","password","checkStrength"])),T(n)==="register"?(f(),v("label",kmt,[Me(p("input",{type:"checkbox",id:"accepted_policy",disabled:_.value,required:"",onInvalid:S,"onUpdate:modelValue":b[2]||(b[2]=y=>d.accepted_policy=y)},null,40,wmt),[[cl,d.accepted_policy]]),p("span",null,[w(P,{keypath:"user.READ_AND_ACCEPT_PRIVACY_POLICY"},{default:X(()=>[w(k,{to:"/privacy-policy",target:"_blank"},{default:X(()=>[x(A(N.$t("privacy_policy.TITLE")),1)]),_:1})]),_:1})])])):D("",!0)]),p("button",{type:"submit",disabled:_.value||h.value},A(N.$t(c.value)),9,Mmt)],34),T(n)==="login"?(f(),v("div",Wmt,[w(k,{class:"links",to:"/register"},{default:X(()=>[x(A(N.$t("user.REGISTER")),1)]),_:1}),T(i).is_email_sending_enabled?(f(),v("span",Fmt,"-")):D("",!0),T(i).is_email_sending_enabled?(f(),B(k,{key:1,class:"links",to:"/password-reset/request"},{default:X(()=>[x(A(N.$t("user.PASSWORD_FORGOTTEN")),1)]),_:1})):D("",!0)])):D("",!0),T(n)==="register"?(f(),v("div",zmt,[p("span",xmt,A(N.$t("user.ALREADY_HAVE_ACCOUNT")),1),w(k,{class:"links",to:"/login"},{default:X(()=>[x(A(N.$t("user.LOGIN")),1)]),_:1})])):D("",!0),["login","register"].includes(T(n))&&T(i).is_email_sending_enabled?(f(),v("div",Bmt,[w(k,{class:"links",to:"/account-confirmation/resend"},{default:X(()=>[x(A(N.$t("user.ACCOUNT_CONFIRMATION_NOT_RECEIVED")),1)]),_:1})])):D("",!0),T(u)?(f(),B($,{key:6,message:T(u)},null,8,["message"])):D("",!0)],2)])],2)}}}),em=se(Gmt,[["__scopeId","data-v-8a25f980"]]),Vmt={id:"account-confirmation-email",class:"center-card with-margin"},Hmt={key:0,class:"email-sent"},Kmt={class:"email-sent-message"},qmt={key:1},jmt=Q({__name:"AccountConfirmationEmail",props:{action:{}},setup(e){const t=e,{action:n}=_e(t);return(a,s)=>{const o=q("Card");return f(),v("div",Vmt,[T(n)==="email-sent"?(f(),v("div",Hmt,[w(TI),p("div",Kmt,A(a.$t("user.ACCOUNT_CONFIRMATION_SENT")),1)])):(f(),v("div",qmt,[w(o,null,{title:X(()=>[x(A(a.$t("user.RESENT_ACCOUNT_CONFIRMATION")),1)]),content:X(()=>[w(em,{action:T(n)},null,8,["action"])]),_:1})]))])}}}),Ymt=se(jmt,[["__scopeId","data-v-08a26b50"]]),Xmt={id:"account-confirmation",class:"view"},Qmt={class:"container"},Zmt=Q({__name:"AccountConfirmationResendView",props:{action:{}},setup(e){const t=e,{action:n}=_e(t);return(a,s)=>(f(),v("div",Xmt,[p("div",Qmt,[w(Ymt,{action:T(n)},null,8,["action"])])]))}}),Yh=se(Zmt,[["__scopeId","data-v-9a9c1644"]]),Jmt={key:0,id:"account-confirmation",class:"center-card with-margin"},eTt={class:"error-message"},tTt=Q({__name:"AccountConfirmationView",setup(e){const t=yn(),n=$e(),{errorMessages:a}=He(),{token:s}=Ke();function o(){s.value?n.dispatch(K.ACTIONS.CONFIRM_ACCOUNT,{token:s.value}):t.push("/")}return tt(()=>o()),(i,r)=>{const u=q("router-link");return T(a)?(f(),v("div",Jmt,[w(mp),p("p",eTt,[p("span",null,A(i.$t("error.SOMETHING_WRONG"))+".",1),w(u,{class:"links",to:"/account-confirmation/resend"},{default:X(()=>[x(A(i.$t("buttons.ACCOUNT-CONFIRMATION-RESEND"))+"? ",1)]),_:1})])])):D("",!0)}}}),nTt=se(tTt,[["__scopeId","data-v-576433a9"]]),aTt={key:0,id:"email-update",class:"center-card with-margin"},sTt={class:"error-message"},oTt=Q({__name:"EmailUpdateView",setup(e){const t=yn(),n=$e(),{errorMessages:a}=He(),{authUser:s,isAuthenticated:o,token:i}=Ke();function r(){i.value?n.dispatch(K.ACTIONS.CONFIRM_EMAIL,{token:i.value,refreshUser:o.value}):t.push("/")}return Le(()=>a.value,u=>{s.value.username&&u&&t.push("/")}),tt(()=>r()),(u,l)=>{const d=q("router-link"),E=q("i18n-t");return T(a)&&!T(s).username?(f(),v("div",aTt,[w(mp),p("p",sTt,[p("span",null,A(u.$t("error.SOMETHING_WRONG"))+".",1),p("span",null,[w(E,{keypath:"user.PROFILE.ERRORED_EMAIL_UPDATE"},{default:X(()=>[w(d,{to:"/login"},{default:X(()=>[x(A(u.$t("user.LOG_IN")),1)]),_:1})]),_:1})])])])):D("",!0)}}}),iTt=se(oTt,[["__scopeId","data-v-8710c2be"]]),rTt={id:"loginOrRegister",class:"view"},uTt={class:"container"},lTt={class:"container-sub"},cTt={class:"container-sub"},dTt=Q({__name:"LoginOrRegister",props:{action:{}},setup(e){const t=e,{action:n}=_e(t);return(a,s)=>(f(),v("div",rTt,[p("div",uTt,[p("div",lTt,[w(sI)]),p("div",cTt,[w(em,{action:T(n)},null,8,["action"])])])]))}}),Xh=se(dTt,[["__scopeId","data-v-7344db74"]]),ETt={name:"Password"},pTt={version:"1.1",id:"Layer_1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",x:"0px",y:"0px",viewBox:"0 0 512.001 512.001",style:{"enable-background":"new 0 0 512.001 512.001"},"xml:space":"preserve"};function mTt(e,t,n,a,s,o){return f(),v("svg",pTt,t[0]||(t[0]=[Dn(``,7)]))}const TTt=se(ETt,[["render",mTt]]),_Tt={id:"password-action-done",class:"center-card with-margin"},fTt={class:"password-message"},hTt={key:0},STt=Q({__name:"PasswordActionDone",props:{action:{}},setup(e){const t=e,{action:n}=_e(t);return(a,s)=>{const o=q("router-link"),i=q("i18n-t");return f(),v("div",_Tt,[T(n)==="request-sent"?(f(),B(TI,{key:0})):(f(),B(TTt,{key:1})),p("div",fTt,[T(n)==="request-sent"?(f(),v("span",hTt,A(a.$t("user.PASSWORD_SENT_EMAIL_TEXT")),1)):(f(),B(i,{key:1,keypath:"user.PASSWORD_UPDATED"},{default:X(()=>[w(o,{to:"/login"},{default:X(()=>[x(A(a.$t("common.HERE")),1)]),_:1})]),_:1}))])])}}}),ATt=se(STt,[["__scopeId","data-v-4f0a2bcc"]]),OTt={id:"password-reset-request",class:"center-card with-margin"},ITt=Q({__name:"PasswordResetForm",props:{action:{},token:{default:""}},setup(e){const t=e,{action:n,token:a}=_e(t);return(s,o)=>{const i=q("Card");return f(),v("div",OTt,[w(i,null,{title:X(()=>[x(A(s.$t("user.RESET_PASSWORD")),1)]),content:X(()=>[w(em,{action:T(n),token:T(a)},null,8,["action","token"])]),_:1})])}}}),gTt=se(ITt,[["__scopeId","data-v-25ace80f"]]),RTt={id:"password-reset",class:"view"},NTt={class:"container"},vTt=Q({__name:"PasswordResetView",props:{action:{}},setup(e){const t=e,{action:n}=_e(t),a=yn(),{token:s}=Ke();return tt(()=>{t.action==="reset"&&!s.value&&a.push("/")}),(o,i)=>(f(),v("div",RTt,[p("div",NTt,[T(n).startsWith("reset")?(f(),B(gTt,{key:0,action:T(n),token:T(s)},null,8,["action","token"])):(f(),B(ATt,{key:1,action:T(n)},null,8,["action"]))])]))}}),Mr=se(vTt,[["__scopeId","data-v-f7d7dbd1"]]),bTt={key:0,id:"profile",class:"view"},CTt=Q({__name:"ProfileView",setup(e){const{authUser:t}=Ke();return(n,a)=>{const s=q("router-view");return T(t).username?(f(),v("div",bTt,[w(s,{user:T(t)},null,8,["user"]),a[0]||(a[0]=p("div",{id:"bottom"},null,-1))])):D("",!0)}}}),PTt=se(CTt,[["__scopeId","data-v-10b39629"]]),DTt={id:"user",class:"view"},LTt={class:"box"},yTt={key:1},$Tt=Q({__name:"UserView",props:{fromAdmin:{type:Boolean}},setup(e){const t=e,{fromAdmin:n}=_e(t),a=it(),s=$e(),{authUser:o}=Ke(),i=F(()=>s.getters[me.GETTERS.USER]);function r(u){u.username&&typeof u.username=="string"&&(s.dispatch(me.ACTIONS.GET_USER,u.username),s.dispatch(me.ACTIONS.EMPTY_RELATIONSHIPS))}return Le(()=>a.params,u=>{r(u)}),tt(()=>{r(a.params)}),Qi(()=>{s.dispatch(me.ACTIONS.EMPTY_USER),s.dispatch(me.ACTIONS.EMPTY_RELATIONSHIPS)}),(u,l)=>{const d=q("router-view");return f(),v("div",DTt,[i.value.username?(f(),v(le,{key:0},[w(wO,{user:i.value},null,8,["user"]),p("div",LTt,[u.$route.path.includes("follow")?(f(),B(d,{key:0,authUser:T(o),user:i.value},null,8,["authUser","user"])):(f(),B(MO,{key:1,authUser:T(o),user:i.value,"from-admin":T(n)},null,8,["authUser","user","from-admin"]))])],64)):(f(),v("div",yTt,[w(Wo,{target:"USER"})]))])}}}),Qh=se($Tt,[["__scopeId","data-v-e856f76b"]]),UTt={class:"users-list"},kTt={key:0,class:"container users-container"},wTt={key:1,class:"no-users"},MTt="created_at",WTt=Q({__name:"UsersList",props:{authUser:{}},setup(e){const t=e,{authUser:n}=_e(t),a=$e(),s=it(),o=yn(),{isAuthUserSuspended:i}=Ke(),r=["created_at","username","workouts_count"];let u=kt(h(s.query));const l=Se(null),d=F(()=>a.getters[me.GETTERS.USERS]),E=F(()=>a.getters[me.GETTERS.USERS_PAGINATION]);function c(O){i.value||(O.per_page=9,a.dispatch(me.ACTIONS.GET_USERS,O))}function m(O){l.value=O}function _(O){if(O.value!=="")u=h({q:O.value});else{const S=Object.assign({},s.query);u=h(S)}o.push({path:"/users",query:u})}function h(O){const S=Do(O,r,MTt);return O.q&&(S.q=O.q),S}return Le(()=>s.query,O=>{u=h(O),c(u)}),tt(()=>c(u)),Et(()=>{a.dispatch(me.ACTIONS.EMPTY_USERS)}),(O,S)=>(f(),v("div",UTt,[w(kO,{onFilterOnUsername:_}),d.value.length>0?(f(),v("div",kTt,[(f(!0),v(le,null,be(d.value,R=>(f(),v("div",{key:R.username,class:"user-box"},[w(Kp,{authUser:T(n),user:R,updatedUser:l.value,onUpdatedUserRelationship:m},null,8,["authUser","user","updatedUser"])]))),128))])):(f(),v("div",wTt,A(O.$t("user.NO_USERS_FOUND")),1)),E.value.page?(f(),B(da,{key:2,path:"/users",pagination:E.value,query:T(u)},null,8,["pagination","query"])):D("",!0)]))}}),FTt=se(WTt,[["__scopeId","data-v-b77a3b27"]]),zTt={key:0,id:"users",class:"view"},xTt={class:"container"},BTt=Q({__name:"UsersView",setup(e){const{authUser:t}=Ke();return(n,a)=>T(t).username?(f(),v("div",zTt,[p("div",xTt,[w(FTt,{authUser:T(t)},null,8,["authUser"])])])):D("",!0)}}),GTt={id:"workout-form"},VTt={class:"form-items"},HTt={key:0,class:"form-item-radio"},KTt=["checked","disabled"],qTt={for:"withGpx"},jTt=["checked","disabled"],YTt={for:"withoutGpx"},XTt={class:"form-item"},QTt={for:"sport"},ZTt=["disabled"],JTt=["value"],e_t={key:1,class:"form-item"},t_t={for:"gpxFile"},n_t=["disabled"],a_t={class:"files-help info-box"},s_t={class:"form-item"},o_t={for:"title"},i_t=["required","disabled"],r_t={key:0,class:"field-help"},u_t={class:"info-box"},l_t={key:2},c_t={class:"workout-date-duration"},d_t={class:"form-item"},E_t={class:"workout-date-time"},p_t=["disabled"],m_t=["disabled"],T_t={class:"form-item"},__t={for:"workout-duration-hour",class:"visually-hidden"},f_t=["disabled"],h_t={for:"workout-duration-minutes",class:"visually-hidden"},S_t=["disabled"],A_t={for:"workout-duration-seconds",class:"visually-hidden"},O_t=["disabled"],I_t={class:"workout-data"},g_t={class:"form-item"},R_t=["disabled"],N_t={class:"form-item"},v_t=["disabled"],b_t={class:"form-item"},C_t=["disabled"],P_t={key:3,class:"form-item"},D_t={for:"workout-equipment"},L_t=["disabled"],y_t={value:""},$_t=["value"],U_t={class:"form-item"},k_t={for:"workout_visibility"},w_t=["disabled"],M_t=["value"],W_t={key:4,class:"form-item"},F_t={for:"map_visibility"},z_t=["disabled"],x_t=["value"],B_t={key:5,class:"form-item"},G_t={for:"description"},V_t={key:0,class:"field-help"},H_t={class:"info-box"},K_t={key:6,class:"form-item"},q_t={for:"notes"},j_t={key:0,class:"field-help"},Y_t={class:"info-box"},X_t={key:1},Q_t={key:2,class:"form-buttons"},Z_t=["disabled"],J_t=Q({__name:"WorkoutEdition",props:{authUser:{},sports:{},isCreation:{type:Boolean,default:!1},loading:{type:Boolean,default:!1},workout:{default:()=>({})}},setup(e){const t=e,{authUser:n,workout:a,isCreation:s,loading:o}=_e(t),i=yn(),r=$e(),{t:u}=yt(),{appConfig:l,errorMessages:d}=He();let E=null;const c=kt({sport_id:"",title:"",notes:"",workoutDate:"",workoutTime:"",workoutDurationHour:"",workoutDurationMinutes:"",workoutDurationSeconds:"",workoutDistance:"",workoutAscent:"",workoutDescent:"",equipment_id:"",description:"",mapVisibility:n.value.map_visibility,workoutVisibility:n.value.workouts_visibility}),m=Se(a.value.id&&a.value.with_gpx?!0:s.value),_=Se(!1),h=Se([]),O=F(()=>ca(t.sports,u,"is_active_for_user",a.value.id?[a.value.sport_id]:[])),S=F(()=>gO()),R=F(()=>l.value.max_single_file_size?Yu(l.value.max_single_file_size):""),g=F(()=>l.value.gpx_limit_import),I=F(()=>l.value.max_zip_file_size?Yu(l.value.max_zip_file_size):""),N=F(()=>r.getters[Be.GETTERS.EQUIPMENTS]),b=F(()=>c.sport_id?O.value.filter(re=>re.id===+c.sport_id)[0]:null),C=F(()=>N.value?yO(N.value,u,s.value?"is_active":"withIncludedIds",b.value,s.value?[]:a.value.equipments.map(re=>re.id)):[]),k=F(()=>NO(c.workoutVisibility));function P(re){c.notes=re.value}function $(re){c.description=re.value}function y(){m.value=!m.value,_.value=!1}function z(re){re.target.files&&(E=re.target.files[0])}function Z(re){if(c.sport_id=`${re.sport_id}`,c.title=re.title,c.description=re.description,c.notes=re.notes,c.equipment_id=re.equipments.length>0?`${re.equipments[0].id}`:"",c.workoutVisibility=re.workout_visibility?re.workout_visibility:"private",c.mapVisibility=re.map_visibility?re.map_visibility:"private",!re.with_gpx){const Oe=G1(Nl(re.workout_date,t.authUser.timezone),"yyyy-MM-dd");if(re.duration){const pt=re.duration.split(":");c.workoutDurationHour=pt[0],c.workoutDurationMinutes=pt[1],c.workoutDurationSeconds=pt[2]}re.distance&&(c.workoutDistance=`${n.value.imperial_units?Xt(re.distance,"km","mi",3):parseFloat(re.distance.toFixed(3))}`),c.workoutDate=Oe.workout_date,c.workoutTime=Oe.workout_time,c.workoutAscent=re.ascent===null?"":`${n.value.imperial_units?Xt(re.ascent,"m","ft",2):parseFloat(re.ascent.toFixed(2))}`,c.workoutDescent=re.descent===null?"":`${n.value.imperial_units?Xt(re.descent,"m","ft",2):parseFloat(re.descent.toFixed(2))}`}}function Ae(){return h.value.includes("workouts.INVALID_DISTANCE")}function J(){return h.value.includes("workouts.INVALID_DURATION")}function ce(){return h.value.includes("workouts.INVALID_ASCENT_OR_DESCENT")}function Te(re){h.value=[],re.duration=+c.workoutDurationHour*3600+ +c.workoutDurationMinutes*60+ +c.workoutDurationSeconds,re.duration<=0&&h.value.push("workouts.INVALID_DURATION"),re.distance=n.value.imperial_units?Xt(+c.workoutDistance,"mi","km",3):+c.workoutDistance,re.distance<=0&&h.value.push("workouts.INVALID_DISTANCE"),re.workout_date=`${c.workoutDate} ${c.workoutTime}`,re.ascent=c.workoutAscent===""?null:n.value.imperial_units?Xt(+c.workoutAscent,"ft","m",3):+c.workoutAscent,re.descent=c.workoutDescent===""?null:n.value.imperial_units?Xt(+c.workoutDescent,"ft","m",3):+c.workoutDescent,(re.ascent!==null&&re.descent===null||re.ascent===null&&re.descent!==null)&&h.value.push("workouts.INVALID_ASCENT_OR_DESCENT"),re.workout_visibility=c.workoutVisibility}function De(){const re={sport_id:+c.sport_id,description:c.description,notes:c.notes,equipment_ids:c.equipment_id&&C.value.find(Oe=>Oe.id===c.equipment_id)?[c.equipment_id]:[],title:c.title,workout_visibility:c.workoutVisibility};if(t.workout.id)t.workout.with_gpx?re.map_visibility=c.mapVisibility:Te(re),h.value.length>0?r.commit(te.MUTATIONS.SET_ERROR_MESSAGES,h.value):r.dispatch(ee.ACTIONS.EDIT_WORKOUT,{workoutId:t.workout.id,data:re});else if(m.value){if(!E){r.commit(te.MUTATIONS.SET_ERROR_MESSAGES,"workouts.NO_FILE_PROVIDED");return}re.file=E,re.map_visibility=c.mapVisibility,r.dispatch(ee.ACTIONS.ADD_WORKOUT,re)}else Te(re),h.value.length>0?r.commit(te.MUTATIONS.SET_ERROR_MESSAGES,h.value):r.dispatch(ee.ACTIONS.ADD_WORKOUT_WITHOUT_GPX,re)}function Ve(){t.workout.id?i.push({name:"Workout",params:{workoutId:t.workout.id}}):i.go(-1)}function xe(){_.value=!0}function ot(){c.mapVisibility=RO(c.mapVisibility,c.workoutVisibility)}return Le(()=>t.workout,async(re,Oe)=>{re!==Oe&&re&&re.id&&Z(re)}),Le(()=>b.value,re=>{s.value&&(c.equipment_id=re!=null&&re.default_equipments&&(re==null?void 0:re.default_equipments.length)>0?`${re.default_equipments[0].id}`:"")}),Tt(()=>{let re;t.workout.id?(Z(t.workout),re=document.getElementById("sport")):re=document.getElementById("withGpx"),re&&re.focus()}),(re,Oe)=>{const pt=q("CustomTextArea"),wt=q("ErrorMessage"),It=q("Loader"),de=q("Card");return f(),v("div",{id:"workout-edition",class:he(["center-card",{"center-form":T(a)&&T(a).with_gpx,"with-margin":!T(s)}])},[w(de,null,{title:X(()=>[x(A(re.$t(`workouts.${T(s)?"ADD":"EDIT"}_WORKOUT`)),1)]),content:X(()=>[p("div",GTt,[p("form",{class:he({errors:_.value}),onSubmit:Ne(De,["prevent"])},[p("div",VTt,[T(s)?(f(),v("div",HTt,[p("div",null,[p("input",{id:"withGpx",type:"radio",checked:m.value,disabled:T(o),onClick:y},null,8,KTt),p("label",qTt,A(re.$t("workouts.WITH_GPX")),1)]),p("div",null,[p("input",{id:"withoutGpx",type:"radio",checked:!m.value,disabled:T(o),onClick:y},null,8,jTt),p("label",YTt,A(re.$t("workouts.WITHOUT_GPX")),1)])])):D("",!0),p("div",XTt,[p("label",QTt,A(re.$t("workouts.SPORT",1))+"*: ",1),Me(p("select",{id:"sport",required:"",onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[0]||(Oe[0]=H=>c.sport_id=H)},[(f(!0),v(le,null,be(O.value,H=>(f(),v("option",{value:H.id,key:H.id},A(H.translatedLabel),9,JTt))),128))],40,ZTt),[[Sn,c.sport_id]])]),T(s)&&m.value?(f(),v("div",e_t,[p("label",t_t,A(re.$t("workouts.GPX_FILE"))+" "+A(re.$t("workouts.ZIP_ARCHIVE_DESCRIPTION"))+"*: ",1),p("input",{id:"gpxFile",name:"gpxFile",type:"file",accept:".gpx, .zip",disabled:T(o),required:"",onInvalid:xe,onInput:z},null,40,n_t),p("div",a_t,[p("div",null,[p("strong",null,A(re.$t("workouts.GPX_FILE"))+":",1),p("ul",null,[p("li",null,A(re.$t("workouts.MAX_SIZE"))+": "+A(R.value),1)])]),p("div",null,[p("strong",null,A(re.$t("workouts.ZIP_ARCHIVE"))+":",1),p("ul",null,[p("li",null,A(re.$t("workouts.NO_FOLDER")),1),p("li",null,A(re.$t("workouts.MAX_FILES"))+": "+A(g.value),1),p("li",null,A(re.$t("workouts.MAX_SIZE"))+": "+A(I.value),1)])])])])):D("",!0),p("div",s_t,[p("label",o_t,A(re.$t("workouts.TITLE"))+A(T(s)?"":"*")+": ",1),Me(p("input",{id:"title",name:"title",type:"text",required:!T(s),onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[1]||(Oe[1]=H=>c.title=H),maxlength:"255"},null,40,i_t),[[st,c.title]]),m.value&&T(s)?(f(),v("div",r_t,[p("span",u_t,[Oe[13]||(Oe[13]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(re.$t("workouts.TITLE_FIELD_HELP")),1)])])):D("",!0)]),m.value?D("",!0):(f(),v("div",l_t,[p("div",c_t,[p("div",d_t,[p("label",null,A(re.$t("workouts.WORKOUT_DATE"))+"*:",1),p("div",E_t,[Me(p("input",{id:"workout-date",name:"workout-date",type:"date",required:"",onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[2]||(Oe[2]=H=>c.workoutDate=H)},null,40,p_t),[[st,c.workoutDate]]),Me(p("input",{id:"workout-time",name:"workout-time",class:"workout-time",type:"time",required:"",onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[3]||(Oe[3]=H=>c.workoutTime=H)},null,40,m_t),[[st,c.workoutTime]])])]),p("div",T_t,[p("label",null,A(re.$t("workouts.DURATION"))+"*:",1),p("div",null,[p("label",__t,A(re.$t("common.HOURS",0)),1),Me(p("input",{id:"workout-duration-hour",name:"workout-duration-hour",class:he(["workout-duration",{errored:J()}]),type:"text",placeholder:"HH",minlength:"1",maxlength:"2",pattern:"^([0-1]?[0-9]|2[0-3])$",required:"",onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[4]||(Oe[4]=H=>c.workoutDurationHour=H)},null,42,f_t),[[st,c.workoutDurationHour]]),Oe[14]||(Oe[14]=x(" : ")),p("label",h_t,A(re.$t("common.MINUTES",0)),1),Me(p("input",{id:"workout-duration-minutes",name:"workout-duration-minutes",class:he(["workout-duration",{errored:J()}]),type:"text",pattern:"^([0-5][0-9])$",minlength:"2",maxlength:"2",placeholder:"MM",required:"",onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[5]||(Oe[5]=H=>c.workoutDurationMinutes=H)},null,42,S_t),[[st,c.workoutDurationMinutes]]),Oe[15]||(Oe[15]=x(" : ")),p("label",A_t,A(re.$t("common.SECONDS",0)),1),Me(p("input",{id:"workout-duration-seconds",name:"workout-duration-seconds",class:he(["workout-duration",{errored:J()}]),type:"text",pattern:"^([0-5][0-9])$",minlength:"2",maxlength:"2",placeholder:"SS",required:"",onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[6]||(Oe[6]=H=>c.workoutDurationSeconds=H)},null,42,O_t),[[st,c.workoutDurationSeconds]])])])]),p("div",I_t,[p("div",g_t,[p("label",null,A(re.$t("workouts.DISTANCE"))+" ("+A(T(n).imperial_units?"mi":"km")+")*: ",1),Me(p("input",{class:he({errored:Ae()}),name:"workout-distance",type:"number",min:"0",step:"0.001",required:"",onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[7]||(Oe[7]=H=>c.workoutDistance=H)},null,42,R_t),[[st,c.workoutDistance]])]),p("div",N_t,[p("label",null,A(re.$t("workouts.ASCENT"))+" ("+A(T(n).imperial_units?"ft":"m")+"): ",1),Me(p("input",{class:he({errored:ce()}),name:"workout-ascent",type:"number",min:"0",step:"0.01",onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[8]||(Oe[8]=H=>c.workoutAscent=H)},null,42,v_t),[[st,c.workoutAscent]])]),p("div",b_t,[p("label",null,A(re.$t("workouts.DESCENT"))+" ("+A(T(n).imperial_units?"ft":"m")+"): ",1),Me(p("input",{class:he({errored:ce()}),name:"workout-descent",type:"number",min:"0",step:"0.01",onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[9]||(Oe[9]=H=>c.workoutDescent=H)},null,42,C_t),[[st,c.workoutDescent]])])])])),N.value?(f(),v("div",P_t,[p("label",D_t,A(re.$t("equipments.EQUIPMENT",1))+": ",1),Me(p("select",{id:"workout-equipment",onInvalid:xe,disabled:T(o),"onUpdate:modelValue":Oe[10]||(Oe[10]=H=>c.equipment_id=H)},[p("option",y_t,A(re.$t("equipments.NO_EQUIPMENTS")),1),(f(!0),v(le,null,be(C.value,H=>(f(),v("option",{value:H.id,key:H.id},A(H.label),9,$_t))),128))],40,L_t),[[Sn,c.equipment_id]])])):D("",!0),p("div",U_t,[p("label",k_t,A(re.$t("visibility_levels.WORKOUT_VISIBILITY"))+": ",1),Me(p("select",{id:"workout_visibility","onUpdate:modelValue":Oe[11]||(Oe[11]=H=>c.workoutVisibility=H),disabled:T(o),onChange:ot},[(f(!0),v(le,null,be(S.value,H=>(f(),v("option",{value:H,key:H},A(re.$t(`visibility_levels.LEVELS.${H}`)),9,M_t))),128))],40,w_t),[[Sn,c.workoutVisibility]])]),m.value?(f(),v("div",W_t,[p("label",F_t,A(re.$t("visibility_levels.MAP_VISIBILITY"))+": ",1),Me(p("select",{id:"map_visibility","onUpdate:modelValue":Oe[12]||(Oe[12]=H=>c.mapVisibility=H),disabled:T(o)},[(f(!0),v(le,null,be(k.value,H=>(f(),v("option",{value:H,key:H},A(re.$t(`visibility_levels.LEVELS.${H}`)),9,x_t))),128))],8,z_t),[[Sn,c.mapVisibility]])])):D("",!0),T(s)?(f(),v("div",B_t,[p("label",G_t,A(re.$t("workouts.DESCRIPTION"))+": ",1),w(pt,{name:"description",input:c.description,disabled:T(o),charLimit:1e4,rows:5,onUpdateValue:$},null,8,["input","disabled"]),m.value&&T(s)?(f(),v("div",V_t,[p("span",H_t,[Oe[16]||(Oe[16]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(re.$t("workouts.DESCRIPTION_FIELD_HELP")),1)])])):D("",!0)])):D("",!0),T(s)?(f(),v("div",K_t,[p("label",q_t,A(re.$t("workouts.PRIVATE_NOTES"))+": ",1),w(pt,{name:"notes",input:c.notes,disabled:T(o),onUpdateValue:P},null,8,["input","disabled"]),T(s)?(f(),v("div",j_t,[p("span",Y_t,[Oe[17]||(Oe[17]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(re.$t("workouts.PRIVATE_NOTES_FIELD_HELP")),1)])])):D("",!0)])):D("",!0)]),T(d)?(f(),B(wt,{key:0,message:T(d)},null,8,["message"])):D("",!0),T(o)?(f(),v("div",X_t,[w(It)])):(f(),v("div",Q_t,[p("button",{class:"confirm",type:"submit",disabled:T(o)},A(re.$t("buttons.SUBMIT")),9,Z_t),p("button",{class:"cancel",onClick:Ne(Ve,["prevent"])},A(re.$t("buttons.CANCEL")),1)]))],34)])]),_:1})],2)}}}),_I=se(J_t,[["__scopeId","data-v-f13344ed"]]),eft={id:"add-workout",class:"view"},tft={class:"container"},nft=Q({__name:"AddWorkout",setup(e){const t=$e(),{authUser:n}=Ke(),{sports:a}=ln(),s=F(()=>t.getters[ee.GETTERS.WORKOUT_DATA]);return(o,i)=>(f(),v("div",eft,[p("div",tft,[w(_I,{authUser:T(n),sports:T(a),isCreation:!0,loading:s.value.loading},null,8,["authUser","sports","loading"])])]))}}),aft={class:"workout-comments"},sft={key:0,class:"no-comments"},oft={key:1},ift={key:1,class:"add-comment"},rft={key:2,class:"add-comment-button"},uft=Q({__name:"Comments",props:{workoutData:{},authUser:{}},setup(e){const t=e,{workoutData:n}=_e(t),a=it(),s=$e(),{errorMessages:o}=He(),i=Se(),r=F(()=>n.value.comments),u=F(()=>n.value.currentCommentEdition.type==="delete"),l=F(()=>n.value.currentCommentEdition.type==="new"),d=F(()=>n.value.commentsLoading==="all"),E=F(()=>n.value.commentsLoading==="delete"),c=F(()=>a.params.commentId);function m(){const S=n.value.currentCommentEdition.comment;S&&s.dispatch(ee.ACTIONS.DELETE_WORKOUT_COMMENT,{workoutId:S.workout_id,commentId:S.id})}function _(){s.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{})}function h(){s.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{type:"new"}),i.value=setTimeout(()=>{const S=document.getElementById("text");S&&(S.focus(),S.scrollIntoView({behavior:"smooth"}))},100)}function O(S){i.value=setTimeout(()=>{const R=document.getElementById(S);R&&R.scrollIntoView({behavior:"smooth"})},500)}return Le(()=>n.value.comments,()=>{s.commit(ee.MUTATIONS.SET_CURRENT_COMMENT_EDITION,{})}),Tt(()=>{un(()=>{c.value&&O(c.value)})}),Et(()=>{i.value&&clearTimeout(i.value)}),(S,R)=>{const g=q("Modal"),I=q("Loader"),N=q("Comment"),b=q("ErrorMessage"),C=q("Card");return f(),v("div",aft,[u.value?(f(),B(g,{key:0,title:S.$t("common.CONFIRMATION"),message:S.$t("workouts.COMMENTS.DELETION_CONFIRMATION"),loading:E.value,onConfirmAction:m,onCancelAction:_},null,8,["title","message","loading"])):D("",!0),w(C,null,So({title:X(()=>[x(A(Fe(S.$t("workouts.COMMENTS.LABEL",0))),1)]),_:2},[d.value?{name:"content",fn:X(()=>[w(I)]),key:"0"}:{name:"content",fn:X(()=>[(f(!0),v(le,null,be(r.value,k=>(f(),B(N,{key:k.id,comment:k,workout:T(n).workout,"current-comment-edition":T(n).currentCommentEdition,authUser:S.authUser,"comments-loading":"workoutData.commentsLoading",action:k.suspension},null,8,["comment","workout","current-comment-edition","authUser","action"]))),128)),T(n).comments.length===0?(f(),v("div",sft,[T(o)?(f(),B(b,{key:0,message:T(o)},null,8,["message"])):(f(),v("span",oft,A(S.$t("workouts.COMMENTS.NO_COMMENTS")),1))])):D("",!0),l.value?(f(),v("div",ift,[S.authUser.username?(f(),B(vO,{key:0,workout:T(n).workout,"comments-loading":"workoutData.commentsLoading","auth-user":S.authUser},null,8,["workout","auth-user"])):D("",!0)])):S.authUser.username&&T(n).workout.id?(f(),v("div",rft,[p("button",{onClick:Ne(h,["prevent"])},A(S.$t("workouts.COMMENTS.ADD")),1)])):D("",!0)]),key:"1"}]),1024)])}}}),fI=se(uft,[["__scopeId","data-v-00028283"]]),lft={id:"comments",class:"view"},cft={class:"container"},dft={class:"comments-container"},Eft={key:0},pft={class:"box no-workout"},mft={key:1},Tft=Q({__name:"CommentView",setup(e){const t=it(),n=$e(),{authUser:a}=Ke(),s=F(()=>n.getters[ee.GETTERS.WORKOUT_DATA]);return Le(()=>t.params.commentId,async o=>{o&&n.dispatch(ee.ACTIONS.GET_WORKOUT_COMMENT,o)}),tt(()=>{n.dispatch(ee.ACTIONS.GET_WORKOUT_COMMENT,t.params.commentId)}),Et(()=>{n.commit(ee.MUTATIONS.EMPTY_WORKOUT)}),(o,i)=>(f(),v("div",lft,[p("div",cft,[p("div",dft,[s.value.comments.length>0?(f(),v("div",Eft,[p("div",pft,A(o.$t("workouts.NO_WORKOUT_AVAILABLE")),1),w(fI,{workoutData:s.value,"auth-user":T(a),"with-parent":!0},null,8,["workoutData","auth-user"]),i[0]||(i[0]=p("div",{id:"bottom"},null,-1))])):(f(),v("div",mft,[s.value.commentsLoading?D("",!0):(f(),B(Wo,{key:0,target:"COMMENT"}))]))])])]))}}),_ft=se(Tft,[["__scopeId","data-v-da00ce02"]]),fft={id:"edit-workout",class:"view"},hft={class:"container"},Sft=Q({__name:"EditWorkout",setup(e){const t=it(),n=$e(),{authUser:a}=Ke(),{sports:s}=ln(),o=F(()=>n.getters[ee.GETTERS.WORKOUT_DATA]);return Le(()=>t.params.workoutId,async i=>{i||n.commit(ee.MUTATIONS.EMPTY_WORKOUT)}),tt(()=>{n.dispatch(ee.ACTIONS.GET_WORKOUT_DATA,{workoutId:t.params.workoutId})}),(i,r)=>(f(),v("div",fft,[p("div",hft,[o.value.workout.id?(f(),B(_I,{key:0,authUser:T(a),sports:T(s),workout:o.value.workout,loading:o.value.loading},null,8,["authUser","sports","workout","loading"])):D("",!0)])]))}}),Aft={id:"workout-card-title"},Oft=["disabled","title"],Ift={class:"workout-card-title"},gft={key:0,class:"workout-title-date"},Rft={key:0,class:"workout-title"},Nft={key:0},vft=["aria-label"],bft={key:0,class:"likes-count"},Cft=["aria-label"],Pft=["aria-label"],Dft=["aria-label"],Lft=["title"],yft=["title"],$ft={key:0,class:"likes-count"},Uft={key:1,class:"workout-title"},kft={class:"workout-segment"},wft={class:"workout-date"},Mft=["datetime"],Wft={class:"workout-link"},Fft=["disabled","title"],zft=Q({__name:"WorkoutCardTitle",props:{sport:{},workoutObject:{},isWorkoutOwner:{type:Boolean}},emits:["displayModal"],setup(e,{emit:t}){const n=e,{isWorkoutOwner:a,sport:s,workoutObject:o}=_e(n),i=t,r=$e(),{isAuthenticated:u}=Ke(),l=F(()=>r.getters[ee.GETTERS.CURRENT_REPORTING]),d=F(()=>r.getters[ye.GETTERS.REPORT_STATUS]);async function E(h){await ve.get(`workouts/${h}/gpx/download`,{responseType:"blob"}).then(O=>{const S=window.URL.createObjectURL(new Blob([O.data],{type:"application/gpx+xml"})),R=document.createElement("a");R.href=S,R.setAttribute("download",`${h}.gpx`),document.body.appendChild(R),R.click()})}function c(){i("displayModal",!0)}function m(h){r.dispatch(h.liked?ee.ACTIONS.UNDO_LIKE_WORKOUT:ee.ACTIONS.LIKE_WORKOUT,h.workoutId)}function _(){r.commit(ee.MUTATIONS.SET_CURRENT_REPORTING,!0)}return(h,O)=>{const S=q("SportImage"),R=q("router-link");return f(),v("div",Aft,[T(a)?(f(),v("button",{key:0,class:he(["workout-previous workout-arrow transparent",{inactive:!T(o).previousUrl}]),disabled:!T(o).previousUrl,title:T(o).previousUrl?h.$t(`workouts.PREVIOUS_${T(o).type}`):h.$t(`workouts.NO_PREVIOUS_${T(o).type}`),onClick:O[0]||(O[0]=g=>T(o).previousUrl?h.$router.push(T(o).previousUrl):null)},O[5]||(O[5]=[p("i",{class:"fa fa-chevron-left","aria-hidden":"true"},null,-1)]),10,Oft)):D("",!0),p("div",Ift,[w(S,{"sport-label":T(s).label,color:T(s).color},null,8,["sport-label","color"]),T(a)||!T(o).suspended?(f(),v("div",gft,[T(o).type==="WORKOUT"?(f(),v("div",Rft,[p("span",null,A(T(o).title),1),T(u)?(f(),v("div",Nft,[p("button",{class:"transparent icon-button likes",onClick:O[1]||(O[1]=g=>m(T(o))),"aria-label":`${h.$t(`workouts.${T(o).liked?"REMOVE_LIKE":"LIKE_WORKOUT"}`)} (${T(o).likes_count} ${h.$t("workouts.LIKES",T(o).likes_count)})`},[p("i",{class:he(["fa",{"fa-heart":T(o).likes_count>0,"fa-heart-o":T(o).likes_count===0,liked:T(o).liked}]),"aria-hidden":"true"},null,2),T(o).likes_count>0?(f(),v("span",bft,A(T(o).likes_count),1)):D("",!0)],8,vft),T(a)?(f(),v("button",{key:0,class:"transparent icon-button",onClick:O[2]||(O[2]=g=>h.$router.push({name:"EditWorkout",params:{workoutId:T(o).workoutId}})),"aria-label":h.$t("workouts.EDIT_WORKOUT")},O[6]||(O[6]=[p("i",{class:"fa fa-edit","aria-hidden":"true"},null,-1)]),8,Cft)):D("",!0),T(o).with_gpx&&T(a)?(f(),v("button",{key:1,class:"transparent icon-button",onClick:O[3]||(O[3]=Ne(g=>E(T(o).workoutId),["prevent"])),"aria-label":h.$t("workouts.DOWNLOAD_WORKOUT")},O[7]||(O[7]=[p("i",{class:"fa fa-download","aria-hidden":"true"},null,-1)]),8,Pft)):D("",!0),T(a)?(f(),v("button",{key:2,id:"delete-workout-button",class:"transparent icon-button",onClick:Ne(c,["prevent"]),"aria-label":h.$t("workouts.DELETE_WORKOUT")},O[8]||(O[8]=[p("i",{class:"fa fa-trash","aria-hidden":"true"},null,-1)]),8,Dft)):D("",!0),!T(a)&&!l.value&&d.value!==`workout-${T(o).workoutId}-created`?(f(),v("button",{key:3,class:"transparent icon-button",onClick:Ne(_,["prevent"]),title:h.$t("workouts.REPORT_WORKOUT")},O[9]||(O[9]=[p("i",{class:"fa fa-flag","aria-hidden":"true"},null,-1)]),8,Lft)):D("",!0)])):(f(),v("div",{key:1,title:`${T(o).likes_count} ${h.$t("workouts.LIKES",T(o).likes_count)}`},[p("i",{class:he(["fa",{"fa-heart":T(o).likes_count>0,"fa-heart-o":T(o).likes_count===0,liked:T(o).liked}])},null,2),T(o).likes_count>0?(f(),v("span",$ft,A(T(o).likes_count),1)):D("",!0)],8,yft))])):T(o).segmentId!==null?(f(),v("div",Uft,[x(A(T(o).title)+" ",1),p("span",kft,[O[10]||(O[10]=x(" — ")),O[11]||(O[11]=p("i",{class:"fa fa-map-marker","aria-hidden":"true"},null,-1)),x(" "+A(h.$t("workouts.SEGMENT"))+" "+A(T(o).segmentId+1),1)])])):D("",!0),p("div",wft,[p("time",{datetime:T(o).workoutFullDate},A(T(o).workoutDate)+" - "+A(T(o).workoutTime),9,Mft),p("span",Wft,[T(o).type==="SEGMENT"?(f(),B(R,{key:0,to:{name:"Workout",params:{workoutId:T(o).workoutId}}},{default:X(()=>[x(" > "+A(h.$t("workouts.BACK_TO_WORKOUT")),1)]),_:1},8,["to"])):D("",!0)])])])):D("",!0)]),T(a)?(f(),v("button",{key:1,class:he(["workout-next workout-arrow transparent",{inactive:!T(o).nextUrl}]),disabled:!T(o).nextUrl,title:T(o).nextUrl?h.$t(`workouts.NEXT_${T(o).type}`):h.$t(`workouts.NO_NEXT_${T(o).type}`),onClick:O[4]||(O[4]=g=>T(o).nextUrl?h.$router.push(T(o).nextUrl):null)},O[12]||(O[12]=[p("i",{class:"fa fa-chevron-right","aria-hidden":"true"},null,-1)]),10,Fft)):D("",!0)])}}}),xft=se(zft,[["__scopeId","data-v-0dda19ba"]]),Bft={key:0,class:"workout-record"},Gft=Q({__name:"WorkoutRecord",props:{recordType:{},workoutObject:{}},setup(e){const t=e,{recordType:n,workoutObject:a}=_e(t);return(s,o)=>T(a).records&&T(a).records.find(i=>i.record_type===T(n))?(f(),v("span",Bft,o[0]||(o[0]=[p("sup",null,[p("i",{class:"fa fa-trophy","aria-hidden":"true"})],-1)]))):D("",!0)}}),si=se(Gft,[["__scopeId","data-v-0c5d556a"]]),Vft="/img/weather/temperature.svg",Hft="/img/weather/pour-rain.svg",Kft="/img/weather/breeze.svg",qft=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"],jft=e=>{const t=Math.floor(e/22.5+.5);return qft[t%16]},Yft={class:"wind"},Xft={class:"wind-bearing"},Qft=["title"],Zft=Q({__name:"WeatherWind",props:{weather:{},useImperialUnits:{type:Boolean}},setup(e){const t=e,{useImperialUnits:n,weather:a}=_e(t),{t:s}=yt();function o(i){return s(`workouts.WEATHER.WIND_DIRECTIONS.${jft(i)}`)}return(i,r)=>(f(),v("div",Yft,[x(A(T(JHe)(T(a).wind,T(n)))+" ",1),p("div",Xft,[T(a).windBearing?(f(),v("i",{key:0,class:"fa fa-long-arrow-down",style:Va({transform:`rotate(${T(a).windBearing}deg)`}),"aria-hidden":"true",title:o(T(a).windBearing)},null,12,Qft)):D("",!0)])]))}}),Zh=se(Zft,[["__scopeId","data-v-e1f7f9cc"]]),Jft={key:0,id:"workout-weather"},eht={class:"weather-table"},tht={class:"weather-th"},nht=["src","alt","title"],aht={class:"weather-th"},sht=["src","alt","title"],oht=["alt","title"],iht=["alt","title"],rht=["alt","title"],uht=Q({__name:"WorkoutWeather",props:{workoutObject:{},useImperialUnits:{type:Boolean}},setup(e){const t=e,{useImperialUnits:n,workoutObject:a}=_e(t);return(s,o)=>T(a).weatherStart&&T(a).weatherEnd?(f(),v("div",Jft,[p("table",eht,[p("thead",null,[p("tr",null,[o[0]||(o[0]=p("th",null,null,-1)),p("th",null,[p("div",tht,[x(A(s.$t("workouts.START"))+" ",1),p("img",{class:"weather-img",src:`/img/weather/${T(a).weatherStart.icon}.svg`,alt:s.$t(`workouts.WEATHER.DARK_SKY.${T(a).weatherStart.icon}`),title:s.$t(`workouts.WEATHER.DARK_SKY.${T(a).weatherStart.icon}`)},null,8,nht)])]),p("th",null,[p("div",aht,[x(A(s.$t("workouts.END"))+" ",1),p("img",{class:"weather-img",src:`/img/weather/${T(a).weatherEnd.icon}.svg`,alt:s.$t(`workouts.WEATHER.DARK_SKY.${T(a).weatherEnd.icon}`),title:s.$t(`workouts.WEATHER.DARK_SKY.${T(a).weatherEnd.icon}`)},null,8,sht)])])])]),p("tbody",null,[p("tr",null,[p("td",null,[p("img",{class:"weather-img weather-img-small",src:Vft,alt:s.$t("workouts.WEATHER.TEMPERATURE"),title:s.$t("workouts.WEATHER.TEMPERATURE")},null,8,oht)]),p("td",null,A(T(ph)(T(a).weatherStart.temperature,T(n))),1),p("td",null,A(T(ph)(T(a).weatherEnd.temperature,T(n))),1)]),p("tr",null,[p("td",null,[p("img",{class:"weather-img weather-img-small",src:Hft,alt:s.$t("workouts.WEATHER.HUMIDITY"),title:s.$t("workouts.WEATHER.HUMIDITY")},null,8,iht)]),p("td",null,A(Number(T(a).weatherStart.humidity*100).toFixed(1))+"% ",1),p("td",null,A(Number(T(a).weatherEnd.humidity*100).toFixed(1))+"% ",1)]),p("tr",null,[p("td",null,[p("img",{class:"weather-img weather-img-small",src:Kft,alt:s.$t("workouts.WEATHER.WIND"),title:s.$t("workouts.WEATHER.WIND")},null,8,rht)]),p("td",null,[w(Zh,{weather:T(a).weatherStart,useImperialUnits:T(n)},null,8,["weather","useImperialUnits"])]),p("td",null,[w(Zh,{weather:T(a).weatherEnd,useImperialUnits:T(n)},null,8,["weather","useImperialUnits"])])])])])])):D("",!0)}}),lht=se(uht,[["__scopeId","data-v-d507bd79"]]),cht={id:"workout-info"},dht={class:"workout-data"},Eht={class:"label"},pht={class:"value"},mht={key:0},Tht={class:"value"},_ht={class:"value"},fht={key:0,class:"workout-data"},hht={class:"label"},Sht={key:1,class:"workout-data"},Aht={class:"label"},Oht={class:"label"},Iht={key:2,class:"workout-data"},ght=["alt"],Rht={class:"label"},Nht={class:"label"},vht={key:3,class:"workout-data"},bht={class:"label"},Cht={class:"label"},Pht=Q({__name:"WorkoutData",props:{workoutObject:{},useImperialUnits:{type:Boolean},displayHARecord:{type:Boolean}},setup(e){const t=e,{displayHARecord:n,workoutObject:a,useImperialUnits:s}=_e(t),o=F(()=>a.value.pauses!=="0:00:00"&&a.value.pauses!==null);return(i,r)=>{const u=q("Distance");return f(),v("div",cht,[p("div",dht,[r[0]||(r[0]=p("i",{class:"fa fa-clock-o","aria-hidden":"true"},null,-1)),p("span",Eht,A(i.$t("workouts.DURATION")),1),r[1]||(r[1]=x(": ")),p("span",pht,A(T(a).moving),1),w(si,{workoutObject:T(a),recordType:"LD"},null,8,["workoutObject"]),o.value?(f(),v("div",mht,[x(" ("+A(i.$t("workouts.PAUSES"))+": ",1),p("span",Tht,A(T(a).pauses),1),x(" - "+A(i.$t("workouts.TOTAL_DURATION"))+": ",1),p("span",_ht,A(T(a).duration)+")",1)])):D("",!0)]),T(a).distance!==null?(f(),v("div",fht,[r[2]||(r[2]=p("i",{class:"fa fa-road","aria-hidden":"true"},null,-1)),p("span",hht,A(i.$t("workouts.DISTANCE")),1),r[3]||(r[3]=x(": ")),w(u,{distance:T(a).distance,digits:3,unitFrom:"km",strong:!0,useImperialUnits:T(s)},null,8,["distance","useImperialUnits"]),w(si,{workoutObject:T(a),recordType:"FD"},null,8,["workoutObject"])])):D("",!0),T(a).aveSpeed!==null&&T(a).maxSpeed!==null?(f(),v("div",Sht,[r[4]||(r[4]=p("i",{class:"fa fa-tachometer","aria-hidden":"true"},null,-1)),p("span",Aht,A(i.$t("workouts.AVERAGE_SPEED")),1),r[5]||(r[5]=x(": ")),w(u,{distance:T(a).aveSpeed,unitFrom:"km",speed:!0,strong:!0,useImperialUnits:T(s)},null,8,["distance","useImperialUnits"]),w(si,{workoutObject:T(a),recordType:"AS"},null,8,["workoutObject"]),r[6]||(r[6]=p("br",null,null,-1)),p("span",Oht,A(i.$t("workouts.MAX_SPEED")),1),r[7]||(r[7]=x(": ")),w(u,{distance:T(a).maxSpeed,unitFrom:"km",speed:!0,strong:!0,useImperialUnits:T(s)},null,8,["distance","useImperialUnits"]),w(si,{workoutObject:T(a),recordType:"MS"},null,8,["workoutObject"])])):D("",!0),T(a).maxAlt!==null&&T(a).minAlt!==null?(f(),v("div",Iht,[p("img",{class:"mountains",src:$O,alt:i.$t("workouts.ELEVATION")},null,8,ght),p("span",Rht,A(i.$t("workouts.MIN_ALTITUDE")),1),r[8]||(r[8]=x(": ")),w(u,{distance:T(a).minAlt,unitFrom:"m",strong:!0,useImperialUnits:T(s)},null,8,["distance","useImperialUnits"]),r[9]||(r[9]=p("br",null,null,-1)),p("span",Nht,A(i.$t("workouts.MAX_ALTITUDE")),1),r[10]||(r[10]=x(": ")),w(u,{distance:T(a).maxAlt,unitFrom:"m",strong:!0,useImperialUnits:T(s)},null,8,["distance","useImperialUnits"])])):D("",!0),T(a).ascent!==null&&T(a).descent!==null?(f(),v("div",vht,[r[11]||(r[11]=p("i",{class:"fa fa-location-arrow","aria-hidden":"true"},null,-1)),p("span",bht,A(i.$t("workouts.ASCENT")),1),r[12]||(r[12]=x(": ")),w(u,{distance:T(a).ascent,unitFrom:"m",strong:!0,useImperialUnits:T(s)},null,8,["distance","useImperialUnits"]),T(n)?(f(),B(si,{key:0,workoutObject:T(a),recordType:"HA"},null,8,["workoutObject"])):D("",!0),r[13]||(r[13]=p("br",null,null,-1)),p("span",Cht,A(i.$t("workouts.DESCENT")),1),r[14]||(r[14]=x(": ")),w(u,{distance:T(a).descent,unitFrom:"m",strong:!0,useImperialUnits:T(s)},null,8,["distance","useImperialUnits"])])):D("",!0),w(lht,{workoutObject:T(a),useImperialUnits:T(s)},null,8,["workoutObject","useImperialUnits"])])}}}),Dht=se(Pht,[["__scopeId","data-v-299a57f0"]]);function ho(e,t){return Array.from(e.getElementsByTagName(t))}function Fo(e){return e==null||e.normalize(),(e==null?void 0:e.textContent)||""}function Hs(e,t,n){const a=e.getElementsByTagName(t),s=a.length?a[0]:null;return s&&n&&n(s),s}function Lht(e,t,n){const a={};if(!e)return a;const s=e.getElementsByTagName(t),o=s.length?s[0]:null;return o&&n?n(o,a):a}function hI(e,t,n){const a=Fo(Hs(e,t));return a&&n?n(a)||{}:{}}function Jh(e,t,n){const a=Number.parseFloat(Fo(Hs(e,t)));if(!Number.isNaN(a))return a&&n?n(a)||{}:{}}function yht(e,t,n){const a=Number.parseFloat(Fo(Hs(e,t)));if(!Number.isNaN(a))return n&&n(a),a}function IE(e,t){const n={};for(const a of t)hI(e,a,s=>{n[a]=s});return n}function $ht(e){return(e==null?void 0:e.nodeType)===1}function SI(e){let t=[];if(e===null)return t;for(const n of Array.from(e.childNodes)){if(!$ht(n))continue;const a=Uht(n.nodeName);if(a==="gpxtpx:TrackPointExtension")t=t.concat(SI(n));else{const s=Fo(n);t.push([a,kht(s)])}}return t}function Uht(e){return["heart","gpxtpx:hr","hr"].includes(e)?"heart":e}function kht(e){const t=Number.parseFloat(e);return Number.isNaN(t)?e:t}function AI(e){const t=[Number.parseFloat(e.getAttribute("lon")||""),Number.parseFloat(e.getAttribute("lat")||"")];if(Number.isNaN(t[0])||Number.isNaN(t[1]))return null;yht(e,"ele",a=>{t.push(a)});const n=Hs(e,"time");return{coordinates:t,time:n?Fo(n):null,extendedValues:SI(Hs(e,"extensions"))}}function OI(e){return Lht(e,"line",t=>Object.assign({},hI(t,"color",a=>({stroke:`#${a}`})),Jh(t,"opacity",a=>({"stroke-opacity":a})),Jh(t,"width",a=>({"stroke-width":a*96/25.4}))))}function tm(e,t){var s;const n=IE(t,["name","cmt","desc","type","time","keywords"]);for(const[o,i]of e)for(const r of Array.from(t.getElementsByTagNameNS(i,"*")))n[r.tagName.replace(":","_")]=(s=Fo(r))==null?void 0:s.trim();const a=ho(t,"link");return a.length&&(n.links=a.map(o=>Object.assign({href:o.getAttribute("href")},IE(o,["text","type"])))),n}function II(e,t){const n=ho(e,t),a=[],s=[],o={};for(let i=0;i1,r=Object.assign({_gpxType:"trk"},tm(e,t),OI(Hs(t,"extensions")),s.length?{coordinateProperties:{times:i?s:s[0]}}:{});for(const l of o){a.push(l.line),r.coordinateProperties||(r.coordinateProperties={});const d=r.coordinateProperties,E=Object.entries(l.extendedValues);for(let c=0;cnew Array(h.line.length).fill(null))),d[m][c]=_):d[m]=_}}return{type:"Feature",properties:r,geometry:i?{type:"MultiLineString",coordinates:a}:{type:"LineString",coordinates:a[0]}}}function Wht(e,t){const n=Object.assign(tm(e,t),IE(t,["sym"])),a=AI(t);return a?{type:"Feature",properties:n,geometry:{type:"Point",coordinates:a.coordinates}}:null}function*Fht(e){var o,i;const t="gpxx",n="http://www.garmin.com/xmlschemas/GpxExtensions/v3",a=[[t,n]],s=(o=e.getElementsByTagName("gpx")[0])==null?void 0:o.attributes;if(s)for(const r of Array.from(s))(i=r.name)!=null&&i.startsWith("xmlns:")&&r.value!==n&&a.push([r.name,r.value]);for(const r of ho(e,"trk")){const u=Mht(a,r);u&&(yield u)}for(const r of ho(e,"rte")){const u=wht(a,r);u&&(yield u)}for(const r of ho(e,"wpt")){const u=Wht(a,r);u&&(yield u)}}function zht(e){return{type:"FeatureCollection",features:Array.from(Fht(e))}}const e0=(e,t)=>{for(const n of Object.keys(t))e.on(n,t[n])},gI=e=>{for(const t of Object.keys(e)){const n=e[t];n&&is(n.cancel)&&n.cancel()}},xht=e=>!e||typeof e.charAt!="function"?e:e.charAt(0).toUpperCase()+e.slice(1),is=e=>typeof e=="function",qa=(e,t,n)=>{for(const a in n){const s="set"+xht(a);e[s]?Le(()=>n[a],(o,i)=>{e[s](o,i)}):t[s]&&Le(()=>n[a],o=>{t[s](o)})}},va=(e,t,n={})=>{const a={...n};for(const s in e){const o=t[s],i=e[s];o&&(o&&o.custom===!0||i!==void 0&&(a[s]=i))}return a},Qs=e=>{const t={},n={};for(const a in e)if(a.startsWith("on")&&!a.startsWith("onUpdate")&&a!=="onReady"){const s=a.slice(2).toLocaleLowerCase();t[s]=e[a]}else n[a]=e[a];return{listeners:t,attrs:n}},Bht=async e=>{const t=await Promise.all([xt(()=>import("./maps-DkiRMund.js").then(n=>n.m),__vite__mapDeps([0,1])),xt(()=>import("./maps-DkiRMund.js").then(n=>n.b),__vite__mapDeps([0,1])),xt(()=>import("./maps-DkiRMund.js").then(n=>n.c),__vite__mapDeps([0,1]))]);delete e.Default.prototype._getIconUrl,e.Default.mergeOptions({iconRetinaUrl:t[0].default,iconUrl:t[1].default,shadowUrl:t[2].default})},Wr=e=>{const t=Se((...a)=>console.warn(`Method ${e} has been invoked without being replaced`)),n=(...a)=>t.value(...a);return n.wrapped=t,On(e,n),n},Fr=(e,t)=>e.wrapped.value=t,ia=typeof self=="object"&&self.self===self&&self||typeof global=="object"&&global.global===global&&global||globalThis,Zn=e=>{const t=Ut(e);if(t===void 0)throw new Error(`Attempt to inject ${e.description} before it was provided.`);return t},ja=Symbol("useGlobalLeaflet"),As=Symbol("addLayer"),nm=Symbol("removeLayer"),RI=Symbol("registerControl"),NI=Symbol("registerLayerControl"),vI=Symbol("canSetParentHtml"),bI=Symbol("setParentHtml"),CI=Symbol("setIcon"),Ght=Symbol("bindPopup"),Vht=Symbol("bindTooltip"),Hht=Symbol("unbindPopup"),Kht=Symbol("unbindTooltip"),cr={options:{type:Object,default:()=>({}),custom:!0}},Wl=e=>({options:e.options,methods:{}}),zo={...cr,pane:{type:String},attribution:{type:String},name:{type:String,custom:!0},layerType:{type:String,custom:!0},visible:{type:Boolean,custom:!0,default:!0}},am=(e,t,n)=>{const a=Zn(As),s=Zn(nm),{options:o,methods:i}=Wl(e),r=va(e,zo,o),u=()=>a({leafletObject:t.value}),l=()=>s({leafletObject:t.value}),d={...i,setAttribution(E){l(),t.value.options.attribution=E,e.visible&&u()},setName(){l(),e.visible&&u()},setLayerType(){l(),e.visible&&u()},setVisible(E){t.value&&(E?u():l())},bindPopup(E){if(!t.value||!is(t.value.bindPopup)){console.warn("Attempt to bind popup before bindPopup method available on layer.");return}t.value.bindPopup(E)},bindTooltip(E){if(!t.value||!is(t.value.bindTooltip)){console.warn("Attempt to bind tooltip before bindTooltip method available on layer.");return}t.value.bindTooltip(E)},unbindTooltip(){t.value&&(is(t.value.closeTooltip)&&t.value.closeTooltip(),is(t.value.unbindTooltip)&&t.value.unbindTooltip())},unbindPopup(){t.value&&(is(t.value.closePopup)&&t.value.closePopup(),is(t.value.unbindPopup)&&t.value.unbindPopup())},updateVisibleProp(E){n.emit("update:visible",E)}};return On(Ght,d.bindPopup),On(Vht,d.bindTooltip),On(Hht,d.unbindPopup),On(Kht,d.unbindTooltip),Et(()=>{d.unbindPopup(),d.unbindTooltip(),l()}),{options:r,methods:d}},sm=(e,t)=>{if(e&&t.default)return Cn("div",{style:{display:"none"}},t.default())},qht={...zo,interactive:{type:Boolean,default:void 0},bubblingMouseEvents:{type:Boolean,default:void 0}},PI={...qht,stroke:{type:Boolean,default:void 0},color:{type:String},weight:{type:Number},opacity:{type:Number},lineCap:{type:String},lineJoin:{type:String},dashArray:{type:String},dashOffset:{type:String},fill:{type:Boolean,default:void 0},fillColor:{type:String},fillOpacity:{type:Number},fillRule:{type:String},className:{type:String}},jht={...PI,radius:{type:Number},latLng:{type:[Object,Array],required:!0,custom:!0}};({...jht});const xo={...cr,position:{type:String}},DI=(e,t)=>{const{options:n,methods:a}=Wl(e),s=va(e,xo,n),o={...a,setPosition(i){t.value&&t.value.setPosition(i)}};return Et(()=>{t.value&&t.value.remove()}),{options:s,methods:o}},Yht=e=>e.default?Cn("div",{ref:"root"},e.default()):null,t0=Q({name:"LControl",props:{...xo,disableClickPropagation:{type:Boolean,custom:!0,default:!0},disableScrollPropagation:{type:Boolean,custom:!0,default:!1}},setup(e,t){const n=Se(),a=Se(),s=Ut(ja),o=Zn(RI),{options:i,methods:r}=DI(e,n);return Tt(async()=>{const{Control:u,DomEvent:l}=s?ia.L:await xt(()=>import("./maps-DkiRMund.js").then(E=>E.d),__vite__mapDeps([0,1])),d=u.extend({onAdd(){return a.value}});n.value=Ha(new d(i)),qa(r,n.value,e),o({leafletObject:n.value}),e.disableClickPropagation&&a.value&&l.disableClickPropagation(a.value),e.disableScrollPropagation&&a.value&&l.disableScrollPropagation(a.value),un(()=>t.emit("ready",n.value))}),{root:a,leafletObject:n}},render(){return Yht(this.$slots)}});({...xo});const LI={...xo,collapsed:{type:Boolean,default:void 0},autoZIndex:{type:Boolean,default:void 0},hideSingleBase:{type:Boolean,default:void 0},sortLayers:{type:Boolean,default:void 0},sortFunction:{type:Function}},Xht=(e,t)=>{const{options:n}=DI(e,t);return{options:va(e,LI,n),methods:{addLayer(a){a.layerType==="base"?t.value.addBaseLayer(a.leafletObject,a.name):a.layerType==="overlay"&&t.value.addOverlay(a.leafletObject,a.name)},removeLayer(a){t.value.removeLayer(a.leafletObject)}}}},Qht=Q({name:"LControlLayers",props:LI,setup(e,t){const n=Se(),a=Ut(ja),s=Zn(NI),{options:o,methods:i}=Xht(e,n);return Tt(async()=>{const{control:r}=a?ia.L:await xt(()=>import("./maps-DkiRMund.js").then(u=>u.d),__vite__mapDeps([0,1]));n.value=Ha(r.layers(void 0,void 0,o)),qa(i,n.value,e),s({...e,...i,leafletObject:n.value}),un(()=>t.emit("ready",n.value))}),{leafletObject:n}},render(){return null}});({...xo});({...xo});const Fl={...zo},yI=(e,t,n)=>{const{options:a,methods:s}=am(e,t,n),o=va(e,Fl,a),i={...s,addLayer(r){t.value.addLayer(r.leafletObject)},removeLayer(r){t.value.removeLayer(r.leafletObject)}};return On(As,i.addLayer),On(nm,i.removeLayer),{options:o,methods:i}};({...Fl});const $I={...Fl,geojson:{type:[Object,Array],custom:!0},optionsStyle:{type:Function,custom:!0}},Zht=(e,t,n)=>{const{options:a,methods:s}=yI(e,t,n),o=va(e,$I,a);Object.prototype.hasOwnProperty.call(e,"optionsStyle")&&(o.style=e.optionsStyle);const i={...s,setGeojson(r){t.value.clearLayers(),t.value.addData(r)},setOptionsStyle(r){t.value.setStyle(r)},getGeoJSONData(){return t.value.toGeoJSON()},getBounds(){return t.value.getBounds()}};return{options:o,methods:i}},Jht=Q({props:$I,setup(e,t){const n=Se(),a=Se(!1),s=Ut(ja),o=Zn(As),{methods:i,options:r}=Zht(e,n,t);return Tt(async()=>{const{geoJSON:u}=s?ia.L:await xt(()=>import("./maps-DkiRMund.js").then(d=>d.d),__vite__mapDeps([0,1]));n.value=Ha(u(e.geojson,r));const{listeners:l}=Qs(t.attrs);n.value.on(l),qa(i,n.value,e),o({...e,...i,leafletObject:n.value}),a.value=!0,un(()=>t.emit("ready",n.value))}),{ready:a,leafletObject:n}},render(){return sm(this.ready,this.$slots)}}),om={...zo,opacity:{type:Number},zIndex:{type:Number},tileSize:{type:[Number,Array,Object]},noWrap:{type:Boolean,default:void 0},minZoom:{type:Number},maxZoom:{type:Number},className:{type:String}},UI=(e,t,n)=>{const{options:a,methods:s}=am(e,t,n),o=va(e,om,a),i={...s,setTileComponent(){var r;(r=t.value)==null||r.redraw()}};return Et(()=>{t.value.off()}),{options:o,methods:i}},e0t=(e,t,n,a)=>e.extend({initialize(s){this.tileComponents={},this.on("tileunload",this._unloadTile),n.setOptions(this,s)},createTile(s){const o=this._tileCoordsToKey(s);this.tileComponents[o]=t.create("div");const i=Cn({setup:a,props:["coords"]},{coords:s});return gN(i,this.tileComponents[o]),this.tileComponents[o]},_unloadTile(s){const o=this._tileCoordsToKey(s.coords);this.tileComponents[o]&&(this.tileComponents[o].innerHTML="",this.tileComponents[o]=void 0)}});({...om});const n0={iconUrl:{type:String},iconRetinaUrl:{type:String},iconSize:{type:[Object,Array]},iconAnchor:{type:[Object,Array]},popupAnchor:{type:[Object,Array]},tooltipAnchor:{type:[Object,Array]},shadowUrl:{type:String},shadowRetinaUrl:{type:String},shadowSize:{type:[Object,Array]},shadowAnchor:{type:[Object,Array]},bgPos:{type:[Object,Array]},className:{type:String}},t0t=Q({name:"LIcon",props:{...n0,...cr},setup(e,t){const n=Se(),a=Ut(ja),s=Zn(vI),o=Zn(bI),i=Zn(CI);let r,u,l,d,E;const c=(O,S,R)=>{const g=O&&O.innerHTML;if(!S){R&&E&&s()&&o(g);return}const{listeners:I}=Qs(t.attrs);E&&u(E,I);const{options:N}=Wl(e),b=va(e,n0,N);g&&(b.html=g),E=b.html?l(b):d(b),r(E,I),i(E)},m=()=>{un(()=>c(n.value,!0,!1))},_=()=>{un(()=>c(n.value,!1,!0))},h={setIconUrl:m,setIconRetinaUrl:m,setIconSize:m,setIconAnchor:m,setPopupAnchor:m,setTooltipAnchor:m,setShadowUrl:m,setShadowRetinaUrl:m,setShadowAnchor:m,setBgPos:m,setClassName:m,setHtml:m};return Tt(async()=>{const{DomEvent:O,divIcon:S,icon:R}=a?ia.L:await xt(()=>import("./maps-DkiRMund.js").then(g=>g.d),__vite__mapDeps([0,1]));r=O.on,u=O.off,l=S,d=R,qa(h,{},e),new MutationObserver(_).observe(n.value,{attributes:!0,childList:!0,characterData:!0,subtree:!0}),m()}),{root:n}},render(){const e=this.$slots.default?this.$slots.default():void 0;return Cn("div",{ref:"root"},e)}});({...zo});const n0t=Q({props:Fl,setup(e,t){const n=Se(),a=Se(!1),s=Ut(ja),o=Zn(As),{methods:i}=yI(e,n,t);return Tt(async()=>{const{layerGroup:r}=s?ia.L:await xt(()=>import("./maps-DkiRMund.js").then(l=>l.d),__vite__mapDeps([0,1]));n.value=Ha(r(void 0,e.options));const{listeners:u}=Qs(t.attrs);n.value.on(u),qa(i,n.value,e),o({...e,...i,leafletObject:n.value}),a.value=!0,un(()=>t.emit("ready",n.value))}),{ready:a,leafletObject:n}},render(){return sm(this.ready,this.$slots)}});function kI(e,t,n){var a,s,o;t===void 0&&(t=50),n===void 0&&(n={});var i=(a=n.isImmediate)!=null&&a,r=(s=n.callback)!=null&&s,u=n.maxWait,l=Date.now(),d=[];function E(){if(u!==void 0){var m=Date.now()-l;if(m+t>=u)return u-m}return t}var c=function(){var m=[].slice.call(arguments),_=this;return new Promise(function(h,O){var S=i&&o===void 0;if(o!==void 0&&clearTimeout(o),o=setTimeout(function(){if(o=void 0,l=Date.now(),!i){var g=e.apply(_,m);r&&r(g),d.forEach(function(I){return(0,I.resolve)(g)}),d=[]}},E()),S){var R=e.apply(_,m);return r&&r(R),h(R)}d.push({resolve:h,reject:O})})};return c.cancel=function(m){o!==void 0&&clearTimeout(o),d.forEach(function(_){return(0,_.reject)(m)}),d=[]},c}const a0={...cr,center:{type:[Object,Array]},bounds:{type:[Array,Object]},maxBounds:{type:[Array,Object]},zoom:{type:Number},minZoom:{type:Number},maxZoom:{type:Number},paddingBottomRight:{type:[Object,Array]},paddingTopLeft:{type:Object},padding:{type:Object},worldCopyJump:{type:Boolean,default:void 0},crs:{type:[String,Object]},maxBoundsViscosity:{type:Number},inertia:{type:Boolean,default:void 0},inertiaDeceleration:{type:Number},inertiaMaxSpeed:{type:Number},easeLinearity:{type:Number},zoomAnimation:{type:Boolean,default:void 0},zoomAnimationThreshold:{type:Number},fadeAnimation:{type:Boolean,default:void 0},markerZoomAnimation:{type:Boolean,default:void 0},noBlockingAnimations:{type:Boolean,default:void 0},useGlobalLeaflet:{type:Boolean,default:!0,custom:!0}},a0t=Q({inheritAttrs:!1,emits:["ready","update:zoom","update:center","update:bounds"],props:a0,setup(e,t){const n=Se(),a=kt({ready:!1,layersToAdd:[],layersInControl:[]}),{options:s}=Wl(e),o=va(e,a0,s),{listeners:i,attrs:r}=Qs(t.attrs),u=Wr(As),l=Wr(nm),d=Wr(RI),E=Wr(NI);On(ja,e.useGlobalLeaflet);const c=F(()=>{const S={};return e.noBlockingAnimations&&(S.animate=!1),S}),m=F(()=>{const S=c.value;return e.padding&&(S.padding=e.padding),e.paddingTopLeft&&(S.paddingTopLeft=e.paddingTopLeft),e.paddingBottomRight&&(S.paddingBottomRight=e.paddingBottomRight),S}),_={moveend:kI(S=>{a.leafletRef&&(t.emit("update:zoom",a.leafletRef.getZoom()),t.emit("update:center",a.leafletRef.getCenter()),t.emit("update:bounds",a.leafletRef.getBounds()))}),overlayadd(S){const R=a.layersInControl.find(g=>g.name===S.name);R&&R.updateVisibleProp(!0)},overlayremove(S){const R=a.layersInControl.find(g=>g.name===S.name);R&&R.updateVisibleProp(!1)}};Tt(async()=>{e.useGlobalLeaflet&&(ia.L=ia.L||await xt(()=>import("./maps-DkiRMund.js").then(P=>P.l),__vite__mapDeps([0,1])));const{map:S,CRS:R,Icon:g,latLngBounds:I,latLng:N,stamp:b}=e.useGlobalLeaflet?ia.L:await xt(()=>import("./maps-DkiRMund.js").then(P=>P.d),__vite__mapDeps([0,1]));try{o.beforeMapMount&&await o.beforeMapMount()}catch(P){console.error(`The following error occurred running the provided beforeMapMount hook ${P.message}`)}await Bht(g);const C=typeof o.crs=="string"?R[o.crs]:o.crs;o.crs=C||R.EPSG3857;const k={addLayer(P){P.layerType!==void 0&&(a.layerControl===void 0?a.layersToAdd.push(P):a.layersInControl.find($=>b($.leafletObject)===b(P.leafletObject))||(a.layerControl.addLayer(P),a.layersInControl.push(P))),P.visible!==!1&&a.leafletRef.addLayer(P.leafletObject)},removeLayer(P){P.layerType!==void 0&&(a.layerControl===void 0?a.layersToAdd=a.layersToAdd.filter($=>$.name!==P.name):(a.layerControl.removeLayer(P.leafletObject),a.layersInControl=a.layersInControl.filter($=>b($.leafletObject)!==b(P.leafletObject)))),a.leafletRef.removeLayer(P.leafletObject)},registerLayerControl(P){a.layerControl=P,a.layersToAdd.forEach($=>{a.layerControl.addLayer($)}),a.layersToAdd=[],d(P)},registerControl(P){a.leafletRef.addControl(P.leafletObject)},setZoom(P){const $=a.leafletRef.getZoom();P!==$&&a.leafletRef.setZoom(P,c.value)},setCrs(P){const $=a.leafletRef.getBounds();a.leafletRef.options.crs=P,a.leafletRef.fitBounds($,{animate:!1,padding:[0,0]})},fitBounds(P){a.leafletRef.fitBounds(P,m.value)},setBounds(P){if(!P)return;const $=I(P);$.isValid()&&!(a.lastSetBounds||a.leafletRef.getBounds()).equals($,0)&&(a.lastSetBounds=$,a.leafletRef.fitBounds($))},setCenter(P){if(P==null)return;const $=N(P),y=a.lastSetCenter||a.leafletRef.getCenter();(y.lat!==$.lat||y.lng!==$.lng)&&(a.lastSetCenter=$,a.leafletRef.panTo($,c.value))}};Fr(u,k.addLayer),Fr(l,k.removeLayer),Fr(d,k.registerControl),Fr(E,k.registerLayerControl),a.leafletRef=Ha(S(n.value,o)),qa(k,a.leafletRef,e),e0(a.leafletRef,_),e0(a.leafletRef,i),a.ready=!0,un(()=>t.emit("ready",a.leafletRef))}),Qi(()=>{gI(_),a.leafletRef&&(a.leafletRef.off(),a.leafletRef.remove())});const h=F(()=>a.leafletRef),O=F(()=>a.ready);return{root:n,ready:O,leafletObject:h,attrs:r}},render({attrs:e}){return e.style||(e.style={}),e.style.width||(e.style.width="100%"),e.style.height||(e.style.height="100%"),Cn("div",{...e,ref:"root"},this.ready&&this.$slots.default?this.$slots.default():{})}}),s0t=["Symbol(Comment)","Symbol(Text)"],o0t=["LTooltip","LPopup"],wI={...zo,draggable:{type:Boolean,default:void 0},icon:{type:[Object]},zIndexOffset:{type:Number},latLng:{type:[Object,Array],custom:!0,required:!0}},i0t=(e,t,n)=>{const{options:a,methods:s}=am(e,t,n),o=va(e,wI,a),i={...s,setDraggable(r){t.value.dragging&&(r?t.value.dragging.enable():t.value.dragging.disable())},latLngSync(r){n.emit("update:latLng",r.latlng),n.emit("update:lat-lng",r.latlng)},setLatLng(r){if(r!=null&&t.value){const u=t.value.getLatLng();(!u||!u.equals(r))&&t.value.setLatLng(r)}}};return{options:o,methods:i}},r0t=(e,t)=>{const n=t.slots.default&&t.slots.default();return n&&n.length&&n.some(u0t)};function u0t(e){return!(s0t.includes(e.type.toString())||o0t.includes(e.type.name))}const MI=Q({name:"LMarker",props:wI,setup(e,t){const n=Se(),a=Se(!1),s=Ut(ja),o=Zn(As);On(vI,()=>{var l;return!!((l=n.value)!=null&&l.getElement())}),On(bI,l=>{var d,E;const c=is((d=n.value)==null?void 0:d.getElement)&&((E=n.value)==null?void 0:E.getElement());c&&(c.innerHTML=l)}),On(CI,l=>{var d;return((d=n.value)==null?void 0:d.setIcon)&&n.value.setIcon(l)});const{options:i,methods:r}=i0t(e,n,t),u={moveHandler:kI(r.latLngSync)};return Tt(async()=>{const{marker:l,divIcon:d}=s?ia.L:await xt(()=>import("./maps-DkiRMund.js").then(c=>c.d),__vite__mapDeps([0,1]));r0t(i,t)&&(i.icon=d({className:""})),n.value=Ha(l(e.latLng,i));const{listeners:E}=Qs(t.attrs);n.value.on(E),n.value.on("move",u.moveHandler),qa(r,n.value,e),o({...e,...r,leafletObject:n.value}),a.value=!0,un(()=>t.emit("ready",n.value))}),Qi(()=>gI(u)),{ready:a,leafletObject:n}},render(){return sm(this.ready,this.$slots)}}),l0t={...PI,smoothFactor:{type:Number},noClip:{type:Boolean,default:void 0},latLngs:{type:Array,required:!0,custom:!0}},s0={...l0t},WI={...cr,content:{type:String,default:null}};({...WI});({...s0,latLngs:{...s0.latLngs}});const im={...om,tms:{type:Boolean,default:void 0},subdomains:{type:[String,Array],validator:e=>typeof e=="string"?!0:Array.isArray(e)?e.every(t=>typeof t=="string"):!1},detectRetina:{type:Boolean,default:void 0},url:{type:String,required:!0,custom:!0}},c0t=(e,t,n)=>{const{options:a,methods:s}=UI(e,t,n),o=va(e,im,a),i={...s};return{options:o,methods:i}},d0t=Q({props:im,setup(e,t){const n=Se(),a=Ut(ja),s=Zn(As),{options:o,methods:i}=c0t(e,n,t);return Tt(async()=>{const{tileLayer:r}=a?ia.L:await xt(()=>import("./maps-DkiRMund.js").then(l=>l.d),__vite__mapDeps([0,1]));n.value=Ha(r(e.url,o));const{listeners:u}=Qs(t.attrs);n.value.on(u),qa(i,n.value,e),s({...e,...i,leafletObject:n.value}),un(()=>t.emit("ready",n.value))}),{leafletObject:n}},render(){return null}});({...WI});({...im});const o0=Q({__name:"CustomMarker",props:{markerCoordinates:{},isStart:{type:Boolean}},setup(e){const t=e,{isStart:n,markerCoordinates:a}=_e(t);return(s,o)=>T(a).latitude?(f(),B(T(MI),{key:0,"lat-lng":[T(a).latitude,T(a).longitude]},{default:X(()=>[w(T(t0t),{"icon-url":`/img/workouts/${T(n)?"start":"finish"}.svg`,iconSize:[15,15]},null,8,["icon-url"])]),_:1},8,["lat-lng"])):D("",!0)}}),E0t={id:"workout-map"},p0t={key:0,class:"leaflet-container"},m0t={key:1},T0t={key:1,class:"no-map"},_0t=Q({__name:"index",props:{workoutData:{},markerCoordinates:{default:()=>({})}},setup(e){const t=e,{workoutData:n,markerCoordinates:a}=_e(t),{appConfig:s}=He(),o=Se(!1),i=Se(null),r=F(()=>O()),u=F(()=>_(r)),l=F(()=>n.value&&n.value.gpx?c(n.value.gpx):{}),d=F(()=>m("first")),E=F(()=>m("last"));function c(g){if(!g||g!=="")try{return{jsonData:zht(new DOMParser().parseFromString(g,"text/xml"))}}catch{return console.error("Invalid gpx content"),{}}return{}}function m(g){const I=g==="first"?0:n.value.chartData.length-1;return n.value&&n.value.chartData.length>0?{latitude:n.value.chartData[I].latitude,longitude:n.value.chartData[I].longitude}:{latitude:null,longitude:null}}function _(g){return[(g.value[0][0]+g.value[1][0])/2,(g.value[0][1]+g.value[1][1])/2]}function h(g){var I,N;(I=i.value)!=null&&I.leafletObject&&((N=i.value)==null||N.leafletObject.fitBounds(g))}function O(){return n.value?[[n.value.workout.bounds[0],n.value.workout.bounds[1]],[n.value.workout.bounds[2],n.value.workout.bounds[3]]]:[]}function S(){var g;(g=i.value)==null||g.leafletObject.fitBounds(O())}function R(){o.value=!o.value,o.value||setTimeout(()=>{S()},100)}return(g,I)=>{const N=q("VFullscreen");return f(),v("div",E0t,[T(n).loading?(f(),v("div",p0t)):(f(),v("div",m0t,[T(n).workout.with_gpx?(f(),B(N,{key:0,modelValue:o.value,"onUpdate:modelValue":I[1]||(I[1]=b=>o.value=b)},{default:X(()=>[p("div",{class:he(["leaflet-container",{"fullscreen-map":o.value}])},[l.value.jsonData&&u.value&&r.value.length===2?(f(),B(T(a0t),{key:0,zoom:13,maxZoom:19,center:u.value,bounds:r.value,zoomAnimation:!1,ref_key:"workoutMap",ref:i,onReady:I[0]||(I[0]=b=>h(r.value)),"use-global-leaflet":!1,class:"map","aria-label":g.$t("workouts.WORKOUT_MAP")},{default:X(()=>[w(T(Qht)),w(T(t0),{position:"topleft",class:"map-control",tabindex:"0",role:"button","aria-label":g.$t("workouts.RESET_ZOOM"),onClick:S},{default:X(()=>I[2]||(I[2]=[p("i",{class:"fa fa-refresh","aria-hidden":"true"},null,-1)])),_:1},8,["aria-label"]),w(T(t0),{position:"topleft",class:"map-control",tabindex:"0",role:"button","aria-label":g.$t(`workouts.${o.value?"EXIT":"VIEW"}_FULLSCREEN`),onClick:R},{default:X(()=>[p("i",{class:he(`fa fa-${o.value?"compress":"arrows-alt"}`),"aria-hidden":"true"},null,2)]),_:1},8,["aria-label"]),w(T(d0t),{url:`${T(nr)()}workouts/map_tile/{s}/{z}/{x}/{y}.png`,attribution:T(s).map_attribution,bounds:r.value},null,8,["url","attribution","bounds"]),w(T(Jht),{geojson:l.value.jsonData},null,8,["geojson"]),T(a).latitude?(f(),B(T(MI),{key:0,"lat-lng":[T(a).latitude,T(a).longitude]},null,8,["lat-lng"])):D("",!0),w(T(n0t),{name:g.$t("workouts.START_AND_FINISH"),"layer-type":"overlay"},{default:X(()=>[d.value.latitude?(f(),B(o0,{key:0,markerCoordinates:d.value,isStart:!0},null,8,["markerCoordinates"])):D("",!0),E.value.latitude?(f(),B(o0,{key:1,markerCoordinates:E.value,isStart:!1},null,8,["markerCoordinates"])):D("",!0)]),_:1},8,["name"])]),_:1},8,["center","bounds","aria-label"])):D("",!0)],2)]),_:1},8,["modelValue"])):(f(),v("div",T0t,A(g.$t("workouts.NO_MAP")),1))]))])}}}),f0t=se(_0t,[["__scopeId","data-v-6abbf12a"]]),h0t={key:0,class:"workout-visibility-levels"},S0t={class:"visibility"},A0t={key:0,class:"workout-visibility"},O0t=["title"],I0t={class:"visibility-label"},g0t={key:0,class:"visibility"},R0t=["title"],N0t={class:"visibility-label"},v0t=Q({__name:"WorkoutVisibility",props:{workoutObject:{}},setup(e){const t=e,{workoutObject:n}=_e(t);function a(s){switch(s){case"public":return"globe";case"followers_only":return"users";default:case"private":return"lock"}}return(s,o)=>T(n).workoutVisibility?(f(),v("div",h0t,[x(A(s.$t("visibility_levels.VISIBILITY"))+": ",1),p("div",S0t,[T(n).with_gpx?(f(),v("span",A0t,A(s.$t("workouts.WORKOUT")),1)):D("",!0),p("i",{class:he(`fa fa-${a(T(n).workoutVisibility)}`),"aria-hidden":"true",title:s.$t(`visibility_levels.LEVELS.${T(n).workoutVisibility}`)},null,10,O0t),p("span",I0t," ("+A(s.$t(`visibility_levels.LEVELS.${T(n).workoutVisibility}`))+") ",1)]),T(n).with_gpx?(f(),v("div",g0t,[x(A(s.$t("workouts.MAP"))+" ",1),p("i",{class:he(`fa fa-${a(T(n).mapVisibility)}`),"aria-hidden":"true",title:s.$t(`visibility_levels.LEVELS.${T(n).mapVisibility}`)},null,10,R0t),p("span",N0t," ("+A(s.$t(`visibility_levels.LEVELS.${T(n).mapVisibility}`))+") ",1)])):D("",!0)])):D("",!0)}}),b0t=se(v0t,[["__scopeId","data-v-35269b30"]]),C0t={class:"workout-detail"},P0t={key:2,class:"report-submitted"},D0t={class:"info-box"},L0t={key:1,class:"workout-map-data"},y0t={key:3,class:"workout-equipments"},$0t=Q({__name:"index",props:{authUser:{},displaySegment:{type:Boolean},sports:{},workoutData:{},markerCoordinates:{default:()=>({})},isWorkoutOwner:{type:Boolean}},setup(e){const t=e,n=it(),a=$e(),{getWorkoutSport:s}=ln(),{isWorkoutOwner:o,markerCoordinates:i,workoutData:r}=_e(t),u=F(()=>t.workoutData.workout),l=Se(n.params.workoutId?+n.params.segmentId:null),d=F(()=>u.value.segments.length>0&&l.value?u.value.segments[+l.value-1]:null),E=Se(!1),c=F(()=>s(u.value)),m=F(()=>a.getters[te.GETTERS.DISPLAY_OPTIONS]),_=F(()=>a.getters[ye.GETTERS.REPORT_STATUS]),h=F(()=>g(u.value,d.value)),O=F(()=>u.value.suspended_at!==null&&o.value),S=F(()=>a.getters[ee.GETTERS.SUCCESS]);function R(P,$,y){const z=$&&y&&y!==1?`/workouts/${P.id}/segment/${y-1}`:!$&&P.previous_workout?`/workouts/${P.previous_workout}`:null,Z=$&&y&&yk()),Le(()=>n.params.segmentId,async P=>{l.value=P?+P:null,C()}),Le(()=>n.params.workoutId,async P=>{P&&(E.value=!1,C()),k()}),(P,$)=>{const y=q("Modal"),z=q("Card");return f(),v("div",C0t,[E.value?(f(),B(y,{key:0,title:P.$t("common.CONFIRMATION"),message:P.$t("workouts.WORKOUT_DELETION_CONFIRMATION"),onConfirmAction:$[0]||($[0]=Z=>b(h.value.workoutId)),onCancelAction:N,onKeydown:je(N,["esc"])},null,8,["title","message"])):D("",!0),w(z,null,{title:X(()=>[c.value?(f(),B(xft,{key:0,authUser:P.authUser,sport:c.value,workoutObject:h.value,isWorkoutOwner:T(o),onDisplayModal:$[1]||($[1]=Z=>I(!0))},null,8,["authUser","sport","workoutObject","isWorkoutOwner"])):D("",!0),T(r).currentReporting?(f(),B(Fp,{key:1,"object-id":h.value.workoutId,"object-type":"workout"},null,8,["object-id"])):D("",!0),_.value===`workout-${h.value.workoutId}-created`?(f(),v("div",P0t,[p("div",D0t,[p("span",null,[$[2]||($[2]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(P.$t("common.REPORT_SUBMITTED")),1)])])])):D("",!0)]),content:X(()=>[O.value&&h.value.suspended&&u.value.suspension?(f(),B(nI,{key:0,"display-suspension-message":"",action:u.value.suspension,workout:u.value},null,8,["action","workout"])):D("",!0),T(o)||!h.value.suspended?(f(),v("div",L0t,[w(f0t,{workoutData:T(r),markerCoordinates:T(i)},null,8,["workoutData","markerCoordinates"]),w(Dht,{workoutObject:h.value,useImperialUnits:m.value.useImperialUnits,displayHARecord:m.value.displayAscent},null,8,["workoutObject","useImperialUnits","displayHARecord"])])):D("",!0),h.value.workoutVisibility?(f(),B(b0t,{key:2,workoutObject:h.value,useImperialUnits:m.value.useImperialUnits,displayHARecord:m.value.displayAscent},null,8,["workoutObject","useImperialUnits","displayHARecord"])):D("",!0),h.value.equipments?(f(),v("div",y0t,[(f(!0),v(le,null,be(h.value.equipments,Z=>(f(),B(aI,{equipment:Z,"workout-id":h.value.workoutId,key:Z.label},null,8,["equipment","workout-id"]))),128))])):D("",!0)]),_:1})])}}}),U0t=se($0t,[["__scopeId","data-v-ebe6b17c"]]),k0t=e=>{const t=document.getElementById(e);if(t){let n=t.querySelector("ul");return n||(n=document.createElement("ul"),t.appendChild(n)),n}throw new Error("No legend container")},w0t={id:"htmlLegend",afterUpdate(e,t,n){var o,i,r,u,l,d;const a=k0t(n.containerID);for(;a.firstChild;)a.firstChild.remove();((r=(i=(o=e.options.plugins)==null?void 0:o.legend)==null?void 0:i.labels)!=null&&r.generateLabels?(d=(l=(u=e.options.plugins)==null?void 0:u.legend)==null?void 0:l.labels)==null?void 0:d.generateLabels(e):[]).forEach(E=>{var S,R,g;if(!((g=(R=(S=e.config.options)==null?void 0:S.scales)==null?void 0:R.yElevation)!=null&&g.display)&&E.datasetIndex===1)return;const c=document.createElement("li");c.onclick=I=>{I.preventDefault(),E.datasetIndex!==void 0&&(e.setDatasetVisibility(E.datasetIndex,!e.isDatasetVisible(E.datasetIndex)),e.update())};const m=document.createElement("input");m&&(m.type="checkbox",m.id=E.text,m.checked=!E.hidden);const _=document.createElement("label");_.htmlFor=m.id;const h=document.createTextNode(E.text);_.appendChild(h);const O=document.createElement("span");O&&(O.style.background=String(E.fillStyle),O.style.borderColor=String(E.strokeStyle)),_.appendChild(O),c.appendChild(m),c.appendChild(_),a.appendChild(c)})}},M0t={id:"workout-chart"},W0t={class:"chart-radio"},F0t=["checked"],z0t=["checked"],x0t={class:"line-chart"},B0t={class:"chart-info"},G0t={class:"no-data-cleaning"},V0t={key:0,class:"elevation-start"},H0t=["checked"],K0t=Q({__name:"index",props:{authUser:{},workoutData:{}},emits:["getCoordinates"],setup(e,{emit:t}){const n=e,{authUser:a,workoutData:s}=_e(n),o=t,{t:i}=yt(),{darkTheme:r}=He(),u=Se(!0),l=[w0t],d=k("km"),E=k("m"),c=F(()=>a.value.start_elevation_at_zero),m=F(()=>_.value&&_.value.datasets.elevation.data.length>0),_=F(()=>Ect(s.value.chartData,i,a.value.imperial_units,r.value)),h=F(()=>({labels:u.value?_.value.distance_labels:_.value.duration_labels,datasets:JSON.parse(JSON.stringify([_.value.datasets.speed,_.value.datasets.elevation]))})),O=F(()=>_.value.coordinates),S=F(()=>({color:r.value?us.darkMode.line:us.ligthMode.line})),R=F(()=>({color:r.value?us.darkMode.text:us.ligthMode.text})),g=F(()=>({responsive:!0,maintainAspectRatio:!1,animation:!1,layout:{padding:{top:22}},scales:{x:{grid:{drawOnChartArea:!1,...S.value},border:{...S.value},ticks:{count:10,callback:function(P){return u.value?Number(P).toFixed(2):N(P)},...R.value},type:"linear",bounds:"data",title:{display:!0,text:u.value?i("workouts.DISTANCE")+` (${d})`:i("workouts.DURATION"),...R.value}},ySpeed:{grid:{drawOnChartArea:!1,...S.value},border:{...S.value},position:"left",title:{display:!0,text:i("workouts.SPEED")+` (${d}/h)`,...R.value},ticks:{...R.value}},yElevation:{beginAtZero:c.value,display:m.value,grid:{drawOnChartArea:!1,...S.value},border:{...S.value},position:"right",title:{display:!0,text:i("workouts.ELEVATION")+` (${E})`,...R.value},ticks:{...R.value}}},elements:{point:{pointStyle:"circle",pointRadius:0}},plugins:{datalabels:{display:!1},tooltip:{interaction:{intersect:!1,mode:"index"},callbacks:{label:function(P){const $=` ${P.dataset.label}: ${P.formattedValue}`;return P.dataset.yAxisID==="yElevation"?$+` ${E}`:$+` ${d}/h`},title:function(P){return P.length>0&&b(O.value[P[0].dataIndex]),P.length===0?"":u.value?`${i("workouts.DISTANCE")}: ${P[0].label} ${d}`:`${i("workouts.DURATION")}: ${N(P[0].label.replace(",",""))}`}}},legend:{display:!1},htmlLegend:{containerID:"chart-legend",displayElevation:m.value}}}));function I(){u.value=!u.value}function N(P){return new Date(+P*1e3).toISOString().substr(11,8)}function b(P){o("getCoordinates",P)}function C(){b({latitude:null,longitude:null})}function k(P){return n.authUser.imperial_units?bn[P].defaultTarget:P}return(P,$)=>{const y=q("Card");return f(),v("div",M0t,[w(y,null,{title:X(()=>[x(A(P.$t("workouts.ANALYSIS")),1)]),content:X(()=>[p("div",W0t,[p("label",null,[p("input",{type:"radio",name:"distance",checked:u.value,onClick:I},null,8,F0t),x(" "+A(P.$t("workouts.DISTANCE")),1)]),p("label",null,[p("input",{type:"radio",name:"duration",checked:!u.value,onClick:I},null,8,z0t),x(" "+A(P.$t("workouts.DURATION")),1)])]),$[1]||($[1]=p("div",{id:"chart-legend"},null,-1)),p("div",x0t,[w(T(rdt),{data:h.value,options:g.value,plugins:l,onMouseleave:C,"aria-label":P.$t("workouts.WORKOUT_CHART")},null,8,["data","options","aria-label"])]),p("div",B0t,[p("div",G0t,A(P.$t("workouts.NO_DATA_CLEANING")),1),m.value?(f(),v("div",V0t,[p("label",null,[p("input",{type:"checkbox",checked:c.value,onClick:$[0]||($[0]=z=>c.value=!c.value)},null,8,H0t),x(" "+A(P.$t("workouts.START_ELEVATION_AT_ZERO")),1)])])):D("",!0)])]),_:1})])}}}),q0t=se(K0t,[["__scopeId","data-v-a8515886"]]),j0t={id:"workout-content"},Y0t=["aria-label"],X0t={key:0,class:"fa fa-edit","aria-hidden":"true"},Q0t=["for"],Z0t={class:"markdown-hints info-box"},J0t={class:"form-buttons"},eSt=["disabled"],tSt={key:0,class:"edition-loading"},nSt=["innerHTML"],md=1e3,aSt=Q({__name:"WorkoutContent",props:{content:{default:()=>""},contentType:{},workoutId:{},allowEdition:{type:Boolean,default:!0}},setup(e){const t=e,n=$e(),{content:a,contentType:s,workoutId:o}=_e(t),i=F(()=>a.value!==null&&a.value.length>md),r=F(()=>n.getters[ee.GETTERS.WORKOUT_CONTENT_EDITION]),u=F(()=>r.value.loading&&r.value.contentType===s.value),l=Se(!1),d=F(()=>l.value?a.value:_(a.value)),E=Se(!1),c=Se(""),m=F(()=>r.value.contentType===s.value?n.getters[te.GETTERS.ERROR_MESSAGES]:null);function _(g){return g===null||g.length<=md?g:g.slice(0,md-10)+"…"}function h(){n.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),E.value=!0,c.value=a.value?a.value:""}function O(g){c.value=g.value}function S(){E.value=!1,c.value=a.value?a.value:""}function R(){n.dispatch(ee.ACTIONS.EDIT_WORKOUT_CONTENT,{workoutId:o.value,content:c.value,contentType:s.value})}return Le(()=>u.value,g=>{g||(E.value=!1)}),(g,I)=>{const N=q("CustomTextArea"),b=q("ErrorMessage"),C=q("Card");return f(),v("div",j0t,[w(C,null,{title:X(()=>[x(A(Fe(g.$t(`workouts.${T(s)==="NOTES"?"PRIVATE_NOTES":T(s)}`)))+" ",1),g.allowEdition?(f(),v("button",{key:0,class:"transparent icon-button","aria-label":g.$t("buttons.EDIT"),onClick:h},[E.value?D("",!0):(f(),v("i",X0t))],8,Y0t)):D("",!0)]),content:X(()=>[E.value?(f(),v("form",{key:0,onSubmit:Ne(R,["prevent"])},[p("label",{for:T(s).toLowerCase(),class:"visually-hidden"},A(g.$t(`workouts.${T(s)}`)),9,Q0t),w(N,{name:T(s).toLowerCase(),input:T(a),disabled:u.value,charLimit:T(s)==="NOTES"?500:1e4,rows:T(s)==="NOTES"?2:5,onUpdateValue:O},null,8,["name","input","disabled","charLimit","rows"]),p("div",Z0t,[I[1]||(I[1]=p("i",{class:"fa fa-info-circle","aria-hidden":"true"},null,-1)),x(" "+A(g.$t("workouts.MARKDOWN_SYNTAX")),1)]),p("div",J0t,[p("button",{class:"confirm",type:"submit",disabled:u.value},A(g.$t("buttons.SUBMIT")),9,eSt),p("button",{class:"cancel",onClick:Ne(S,["prevent"])},A(g.$t("buttons.CANCEL")),1),u.value?(f(),v("div",tSt,I[2]||(I[2]=[p("div",null,[p("i",{class:"fa fa-spinner fa-pulse","aria-hidden":"true"})],-1)]))):D("",!0)])],32)):(f(),v(le,{key:1},[p("span",{class:he(["workout-content",{notes:T(s)==="NOTES"||!T(a)}]),innerHTML:d.value&&d.value!==""?T(IO)(d.value):g.$t(`common.NO_${T(s)}`)},null,10,nSt),i.value?(f(),v("button",{key:0,class:"read-more transparent",onClick:I[0]||(I[0]=k=>l.value=!l.value)},[p("i",{class:he(`fa fa-caret-${l.value?"up":"down"}`),"aria-hidden":"true"},null,2),x(" "+A(g.$t(`buttons.${l.value?"HIDE":"READ_MORE"}`)),1)])):D("",!0)],64)),m.value?(f(),B(b,{key:2,message:m.value},null,8,["message"])):D("",!0)]),_:1})])}}}),i0=se(aSt,[["__scopeId","data-v-d2967c38"]]),sSt={id:"workout-segments"},oSt=Q({__name:"WorkoutSegments",props:{segments:{},useImperialUnits:{type:Boolean}},setup(e){const t=e,{segments:n,useImperialUnits:a}=_e(t);return(s,o)=>{const i=q("router-link"),r=q("Distance"),u=q("Card");return f(),v("div",sSt,[w(u,null,{title:X(()=>[x(A(s.$t("workouts.SEGMENT",2)),1)]),content:X(()=>[p("ul",null,[(f(!0),v(le,null,be(T(n),(l,d)=>(f(),v("li",{key:l.segment_id},[w(i,{to:{name:"WorkoutSegment",params:{workoutId:l.workout_id,segmentId:d+1}}},{default:X(()=>[x(A(s.$t("workouts.SEGMENT",1))+" "+A(d+1),1)]),_:2},1032,["to"]),x(" ("+A(s.$t("workouts.DISTANCE"))+": ",1),w(r,{distance:l.distance,unitFrom:"km",useImperialUnits:T(a)},null,8,["distance","useImperialUnits"]),x(", "+A(s.$t("workouts.DURATION"))+": "+A(l.duration)+") ",1)]))),128))])]),_:1})])}}}),iSt=se(oSt,[["__scopeId","data-v-eaec7ac1"]]),rSt={class:"box workout-user"},uSt={class:"user-img-name"},lSt=Q({__name:"WorkoutUser",props:{user:{}},setup(e){const t=e,{user:n}=_e(t);return(a,s)=>{const o=q("router-link");return f(),v("div",rSt,[p("div",uSt,[w(Jt,{user:T(n)},null,8,["user"]),w(o,{class:"user-name",to:`/users/${T(n).username}?from=users`},{default:X(()=>[x(A(T(n).username),1)]),_:1},8,["to"])]),w(Hp,{user:T(n)},null,8,["user"])])}}}),cSt=se(lSt,[["__scopeId","data-v-942d8f8a"]]),dSt={id:"workout",class:"view"},ESt={class:"container"},pSt={key:0,class:"workout-container"},mSt={key:0},TSt={key:0,class:"box suspended"},_St={key:1},fSt=Q({__name:"Workout",props:{displaySegment:{type:Boolean}},setup(e){const t=e,{displaySegment:n}=_e(t),a=it(),s=$e(),{authUser:o}=Ke(),{sports:i}=ln(),r=Se({latitude:null,longitude:null}),u=F(()=>s.getters[ee.GETTERS.WORKOUT_DATA]),l=F(()=>o.value.username===u.value.workout.user.username);function d(E){r.value={latitude:E.latitude,longitude:E.longitude}}return Le(()=>a.params.workoutId,async E=>{E&&s.dispatch(ee.ACTIONS.GET_WORKOUT_DATA,{workoutId:E})}),Le(()=>a.params.segmentId,async E=>{if(a.params.workoutId){const c={workoutId:a.params.workoutId};E&&(c.segmentId=E),s.dispatch(ee.ACTIONS.GET_WORKOUT_DATA,c)}}),tt(()=>{const E={workoutId:a.params.workoutId};t.displaySegment&&(E.segmentId=a.params.segmentId),s.dispatch(ee.ACTIONS.GET_WORKOUT_DATA,E),i.value.length===0&&s.dispatch(Zt.ACTIONS.GET_SPORTS)}),Et(()=>{s.commit(ee.MUTATIONS.EMPTY_WORKOUT)}),(E,c)=>(f(),v("div",dSt,[p("div",ESt,[T(i).length>0?(f(),v("div",pSt,[u.value.workout.id?(f(),v("div",mSt,[w(cSt,{user:u.value.workout.user},null,8,["user"]),u.value.workout.suspended&&!l.value?(f(),v("div",TSt,A(E.$t("workouts.SUSPENDED_BY_ADMIN")),1)):(f(),B(U0t,{key:1,workoutData:u.value,sports:T(i),authUser:T(o),markerCoordinates:r.value,displaySegment:T(n),isWorkoutOwner:l.value},null,8,["workoutData","sports","authUser","markerCoordinates","displaySegment","isWorkoutOwner"])),u.value.workout.with_gpx&&u.value.chartData.length>0?(f(),B(q0t,{key:2,workoutData:u.value,authUser:T(o),displaySegment:T(n),onGetCoordinates:d},null,8,["workoutData","authUser","displaySegment"])):D("",!0),T(n)?D("",!0):(f(),B(i0,{key:3,"workout-id":u.value.workout.id,"content-type":"DESCRIPTION",content:u.value.workout.description,loading:u.value.loading,"allow-edition":l.value},null,8,["workout-id","content","loading","allow-edition"])),!T(n)&&u.value.workout.segments.length>1?(f(),B(iSt,{key:4,segments:u.value.workout.segments,useImperialUnits:T(o)?T(o).imperial_units:!1},null,8,["segments","useImperialUnits"])):D("",!0),l.value&&!T(n)?(f(),B(i0,{key:5,"workout-id":u.value.workout.id,"content-type":"NOTES",content:u.value.workout.notes,loading:u.value.loading},null,8,["workout-id","content","loading"])):D("",!0),T(n)?D("",!0):(f(),B(fI,{key:6,workoutData:u.value,"auth-user":T(o)},null,8,["workoutData","auth-user"])),c[0]||(c[0]=p("div",{id:"bottom"},null,-1))])):(f(),v("div",_St,[u.value.loading?D("",!0):(f(),B(Wo,{key:0,target:T(n)?"SEGMENT":"WORKOUT"},null,8,["target"]))]))])):D("",!0)])]))}}),Td=se(fSt,[["__scopeId","data-v-a394a791"]]),hSt={class:"workouts-filters"},SSt={class:"box"},ASt={class:"form-all-items"},OSt={class:"form-items-group"},ISt={class:"form-item"},gSt={for:"from"},RSt=["value"],NSt={class:"form-item"},vSt={for:"to"},bSt=["value"],CSt={class:"form-item"},PSt={for:"sport_id"},DSt=["value"],LSt=["value"],ySt={class:"form-item form-item-equipment"},$St=["value"],USt={key:0,value:"",disabled:"",selected:""},kSt={value:"none"},wSt=["label"],MSt=["value"],WSt={class:"form-items-group"},FSt={class:"form-item form-item-text"},zSt={for:"title"},xSt={class:"form-inputs-group"},BSt=["value"],GSt={class:"form-item form-item-text"},VSt={for:"notes"},HSt={class:"form-inputs-group"},KSt=["value"],qSt={class:"form-item form-item-text"},jSt={for:"notes"},YSt={class:"form-inputs-group"},XSt=["value"],QSt={class:"form-items-group"},ZSt={class:"form-item"},JSt={class:"form-inputs-group"},eAt=["value"],tAt=["value"],nAt={class:"form-item"},aAt={class:"form-inputs-group"},sAt=["value"],oAt=["value"],iAt={class:"form-items-group"},rAt={class:"form-item"},uAt={class:"form-inputs-group"},lAt=["value"],cAt=["value"],dAt={class:"form-item"},EAt={class:"form-inputs-group"},pAt=["value"],mAt=["value"],TAt={class:"form-button"},_At=Q({__name:"WorkoutsFilters",props:{authUser:{},translatedSports:{}},emits:["filter"],setup(e,{emit:t}){const n=e,{authUser:a}=_e(n),s=t,o=it(),i=yn(),r=jA(),{t:u}=yt();let l=Object.assign({},o.query);const d=F(()=>a.value.imperial_units?bn.km.defaultTarget:"km"),E=F(()=>h(r.getters[Be.GETTERS.EQUIPMENTS]));function c(O){const S=O.target.name,R=O.target.value;R===""?delete l[S]:l[S]=R}function m(){s("filter"),"page"in l&&(l.page="1"),i.push({path:"/workouts",query:l})}function _(){s("filter"),i.push({path:"/workouts",query:{}})}function h(O){const S={};return O.filter(R=>R.workouts_count>0).map(R=>{const g=u(`equipment_types.${R.equipment_type.label}.LABEL`);g in S?S[g].push(R):S[g]=[R]}),S}return Le(()=>o.query,O=>{l=Object.assign({},O)}),Tt(()=>{const O=document.getElementById("from");O&&O.focus()}),(O,S)=>(f(),v("div",hSt,[p("div",SSt,[p("form",{onSubmit:S[0]||(S[0]=Ne(()=>{},["prevent"])),class:"form"},[p("div",ASt,[p("div",OSt,[p("div",ISt,[p("label",gSt,A(O.$t("workouts.FROM"))+": ",1),p("input",{id:"from",name:"from",type:"date",value:O.$route.query.from,onChange:c},null,40,RSt)]),p("div",NSt,[p("label",vSt,A(O.$t("workouts.TO"))+": ",1),p("input",{id:"to",name:"to",type:"date",value:O.$route.query.to,onChange:c},null,40,bSt)]),p("div",CSt,[p("label",PSt,A(O.$t("workouts.SPORT",1))+":",1),p("select",{id:"sport_id",name:"sport_id",value:O.$route.query.sport_id,onChange:c,onKeyup:je(m,["enter"])},[S[1]||(S[1]=p("option",{value:""},null,-1)),(f(!0),v(le,null,be(O.translatedSports.filter(R=>T(a).sports_list.includes(R.id)),R=>(f(),v("option",{value:R.id,key:R.id},A(R.translatedLabel),9,LSt))),128))],40,DSt)]),p("div",ySt,[p("label",null,A(O.$t("equipments.EQUIPMENT",1))+":",1),p("select",{name:"equipment_id",value:O.$route.query.equipment_id,onChange:c,onKeyup:je(m,["enter"])},[S[3]||(S[3]=p("option",{value:""},null,-1)),Object.keys(E.value).length==0?(f(),v("option",USt,A(O.$t("equipments.NO_EQUIPMENTS")),1)):D("",!0),Object.keys(E.value).length>0?(f(),v(le,{key:1},[p("option",kSt,A(O.$t("equipments.WITHOUT_EQUIPMENTS")),1),S[2]||(S[2]=p("option",{disabled:""},"---",-1))],64)):D("",!0),(f(!0),v(le,null,be(Object.keys(E.value).sort(),R=>(f(),v("optgroup",{label:R,key:R},[(f(!0),v(le,null,be(E.value[R].sort(T(Vp)),g=>(f(),v("option",{value:g.id,key:g.id},A(g.label),9,MSt))),128))],8,wSt))),128))],40,$St)])]),p("div",WSt,[p("div",FSt,[p("label",zSt,A(O.$t("workouts.TITLE",1))+":",1),p("div",xSt,[p("input",{id:"title",class:"text",name:"title",value:O.$route.query.title,onChange:c,placeholder:"",type:"text",onKeyup:je(m,["enter"])},null,40,BSt)])]),p("div",GSt,[p("label",VSt,A(O.$t("workouts.DESCRIPTION"))+":",1),p("div",HSt,[p("input",{id:"description",class:"text",name:"description",value:O.$route.query.description,onChange:c,placeholder:"",type:"text",onKeyup:je(m,["enter"])},null,40,KSt)])]),p("div",qSt,[p("label",jSt,A(O.$t("workouts.NOTES"))+":",1),p("div",YSt,[p("input",{id:"notes",class:"text",name:"notes",value:O.$route.query.notes,onChange:c,placeholder:"",type:"text",onKeyup:je(m,["enter"])},null,40,XSt)])])]),p("div",QSt,[p("div",ZSt,[p("label",null,A(O.$t("workouts.DISTANCE"))+" ("+A(d.value)+"): ",1),p("div",JSt,[p("input",{name:"distance_from",type:"number",min:"0",step:"0.1",value:O.$route.query.distance_from,onChange:c,onKeyup:je(m,["enter"])},null,40,eAt),p("span",null,A(O.$t("workouts.TO")),1),p("input",{name:"distance_to",type:"number",min:"0",step:"0.1",value:O.$route.query.distance_to,onChange:c,onKeyup:je(m,["enter"])},null,40,tAt)])]),p("div",nAt,[p("label",null,A(O.$t("workouts.DURATION"))+": ",1),p("div",aAt,[p("input",{name:"duration_from",value:O.$route.query.duration_from,onChange:c,pattern:"^([0-9]*[0-9]):([0-5][0-9])$",placeholder:"hh:mm",type:"text",onKeyup:je(m,["enter"])},null,40,sAt),p("span",null,A(O.$t("workouts.TO")),1),p("input",{name:"duration_to",value:O.$route.query.duration_to,onChange:c,pattern:"^([0-9]*[0-9]):([0-5][0-9])$",placeholder:"hh:mm",type:"text",onKeyup:je(m,["enter"])},null,40,oAt)])])]),p("div",iAt,[p("div",rAt,[p("label",null,A(O.$t("workouts.AVE_SPEED"))+" ("+A(d.value)+"/h): ",1),p("div",uAt,[p("input",{min:"0",name:"ave_speed_from",value:O.$route.query.ave_speed_from,onChange:c,step:"0.1",type:"number",onKeyup:je(m,["enter"])},null,40,lAt),p("span",null,A(O.$t("workouts.TO")),1),p("input",{min:"0",name:"ave_speed_to",value:O.$route.query.ave_speed_to,onChange:c,step:"0.1",type:"number",onKeyup:je(m,["enter"])},null,40,cAt)])]),p("div",dAt,[p("label",null,A(O.$t("workouts.MAX_SPEED"))+" ("+A(d.value)+"/h): ",1),p("div",EAt,[p("input",{min:"0",name:"max_speed_from",value:O.$route.query.max_speed_from,onChange:c,step:"0.1",type:"number",onKeyup:je(m,["enter"])},null,40,pAt),p("span",null,A(O.$t("workouts.TO")),1),p("input",{min:"0",name:"max_speed_to",value:O.$route.query.max_speed_to,onChange:c,step:"0.1",type:"number",onKeyup:je(m,["enter"])},null,40,mAt)])])])]),p("div",TAt,[p("button",{type:"submit",class:"confirm",onClick:m},A(O.$t("buttons.FILTER")),1),p("button",{class:"confirm",onClick:_},A(O.$t("buttons.CLEAR_FILTER")),1)])],32)])]))}}),fAt=se(_At,[["__scopeId","data-v-3341c41a"]]),hAt={class:"workouts-list"},SAt={class:"total"},AAt={class:"total-label"},OAt={key:0},IAt={key:0,class:"workouts-table responsive-table"},gAt={class:"sport-col"},RAt={class:"cell-heading"},NAt=["onMouseover"],vAt={class:"cell-heading"},bAt={key:0,class:"fa fa-map-o","aria-hidden":"true"},CAt={class:"title"},PAt={class:"workout-date"},DAt={class:"cell-heading"},LAt={class:"text-right"},yAt={class:"cell-heading"},$At={class:"text-right"},UAt={class:"cell-heading"},kAt={class:"text-right"},wAt={class:"cell-heading"},MAt={class:"text-right"},WAt={class:"cell-heading"},FAt={class:"text-right"},zAt={class:"cell-heading"},xAt={class:"text-right"},BAt={class:"cell-heading"},GAt=Q({__name:"WorkoutsList",props:{user:{},translatedSports:{}},setup(e){const t=e,{user:n,translatedSports:a}=_e(t),s=it(),o=yn(),i=$e(),r=["ave_speed","distance","duration","workout_date"],{appLanguage:u}=He(),{isAuthUserSuspended:l}=Ke();let d=O(s.query);const E=Se(null),c=F(()=>i.getters[ee.GETTERS.USER_WORKOUTS]),m=F(()=>i.getters[ee.GETTERS.WORKOUTS_PAGINATION]);function _(g){l.value||i.dispatch(ee.ACTIONS.GET_USER_WORKOUTS,n.value.imperial_units?S(g):g)}function h(g,I){const N=Object.assign({},s.query);N[g]=I,g==="per_page"&&(N.page="1"),d=O(N),o.push({path:"/workouts",query:d})}function O(g){const I=Do(g,r,qi.order_by,{defaultSort:qi.order});return Object.keys(g).filter(N=>FQe.includes(N)).map(N=>{typeof g[N]=="string"&&(I[N]=g[N])}),I}function S(g){const I={...g};return Object.entries(I).map(N=>{N[0].match("speed|distance")&&N[1]&&(I[N[0]]=Xt(+N[1],"mi","km"))}),I}function R(g){E.value=g}return Le(()=>s.query,async g=>{d=O(g),_(d)}),tt(()=>{_(d),i.dispatch(Be.ACTIONS.GET_EQUIPMENTS)}),(g,I)=>{const N=q("SportImage"),b=q("VisibilityIcon"),C=q("router-link"),k=q("Distance");return f(),v("div",hAt,[p("div",{class:he(["box",{"empty-table":c.value.length===0}])},[p("div",SAt,[p("span",AAt,A(g.$t("common.TOTAL").toLowerCase())+": ",1),m.value.total?(f(),v("span",OAt,A(m.value.total)+" "+A(g.$t("workouts.WORKOUT",m.value.total)),1)):D("",!0)]),w(qp,{sort:T(wl),order_by:r,query:T(d),message:"workouts",onUpdateSelect:h},null,8,["sort","query"]),c.value.length>0?(f(),v("div",IAt,[w(da,{class:"top-pagination",pagination:m.value,path:"/workouts",query:T(d)},null,8,["pagination","query"]),p("table",null,[p("thead",{class:he({smaller:T(u)==="de"})},[p("tr",null,[I[1]||(I[1]=p("th",{class:"sport-col"},null,-1)),p("th",null,A(Fe(g.$t("workouts.WORKOUT",1))),1),p("th",null,A(Fe(g.$t("workouts.DATE"))),1),p("th",null,A(Fe(g.$t("workouts.DISTANCE"))),1),p("th",null,A(Fe(g.$t("workouts.DURATION"))),1),p("th",null,A(Fe(g.$t("workouts.AVE_SPEED"))),1),p("th",null,A(Fe(g.$t("workouts.MAX_SPEED"))),1),p("th",null,A(Fe(g.$t("workouts.ASCENT"))),1),p("th",null,A(Fe(g.$t("workouts.DESCENT"))),1)])],2),p("tbody",null,[(f(!0),v(le,null,be(c.value,P=>(f(),v("tr",{key:P.id},[p("td",gAt,[p("span",RAt,A(g.$t("workouts.SPORT",1)),1),T(a).length>0?(f(),B(N,{key:0,title:T(a).filter($=>$.id===P.sport_id)[0].translatedLabel,"sport-label":T(Bp)(P,T(a)),color:T(Gp)(P,T(a))},null,8,["title","sport-label","color"])):D("",!0)]),p("td",{class:"workout-title",onMouseover:$=>R(P.id),onMouseleave:I[0]||(I[0]=$=>R(null))},[p("span",vAt,A(Fe(g.$t("workouts.WORKOUT",1))),1),w(C,{class:"nav-item",to:{name:"Workout",params:{workoutId:P.id}}},{default:X(()=>[P.with_gpx?(f(),v("i",bAt)):D("",!0),p("span",CAt,A(P.title),1),w(b,{visibility:P.workout_visibility},null,8,["visibility"])]),_:2},1032,["to"]),P.with_gpx&&E.value===P.id?(f(),B(UO,{key:0,workout:P,"display-hover":!0},null,8,["workout"])):D("",!0)],40,NAt),p("td",PAt,[p("span",DAt,A(g.$t("workouts.DATE")),1),p("time",null,A(T($t)(P.workout_date,T(n).timezone,T(n).date_format)),1)]),p("td",LAt,[p("span",yAt,A(g.$t("workouts.DISTANCE")),1),P.distance!==null?(f(),B(k,{key:0,distance:P.distance,unitFrom:"km",useImperialUnits:T(n).imperial_units},null,8,["distance","useImperialUnits"])):D("",!0)]),p("td",$At,[p("span",UAt,A(g.$t("workouts.DURATION")),1),x(" "+A(P.moving),1)]),p("td",kAt,[p("span",wAt,A(g.$t("workouts.AVE_SPEED")),1),P.ave_speed!==null?(f(),B(k,{key:0,distance:P.ave_speed,unitFrom:"km",speed:!0,useImperialUnits:T(n).imperial_units},null,8,["distance","useImperialUnits"])):D("",!0)]),p("td",MAt,[p("span",WAt,A(g.$t("workouts.MAX_SPEED")),1),P.max_speed!==null?(f(),B(k,{key:0,distance:P.max_speed,unitFrom:"km",speed:!0,useImperialUnits:T(n).imperial_units},null,8,["distance","useImperialUnits"])):D("",!0)]),p("td",FAt,[p("span",zAt,A(g.$t("workouts.ASCENT")),1),P.ascent!==null?(f(),B(k,{key:0,distance:P.ascent,unitFrom:"m",useImperialUnits:T(n).imperial_units},null,8,["distance","useImperialUnits"])):D("",!0)]),p("td",xAt,[p("span",BAt,A(g.$t("workouts.DESCENT")),1),P.descent!==null?(f(),B(k,{key:0,distance:P.descent,unitFrom:"m",useImperialUnits:T(n).imperial_units},null,8,["distance","useImperialUnits"])):D("",!0)])]))),128))])]),w(da,{pagination:m.value,path:"/workouts",query:T(d)},null,8,["pagination","query"])])):D("",!0)],2),c.value.length===0?(f(),B(Jp,{key:0})):D("",!0),I[2]||(I[2]=p("div",{id:"bottom"},null,-1))])}}}),VAt=se(GAt,[["__scopeId","data-v-ac99e5b1"]]),HAt={key:0,id:"workouts",class:"view items-list-view"},KAt={class:"container items-list-container"},qAt={class:"display-filters"},jAt={class:"list-container"},YAt=Q({__name:"WorkoutsView",setup(e){const{t}=yt(),n=$e(),a=F(()=>n.getters[K.GETTERS.AUTH_USER_PROFILE]),s=F(()=>n.getters[Zt.GETTERS.SPORTS]),o=F(()=>ca(s.value,t)),i=Se(!0);function r(){i.value=!i.value}return(u,l)=>a.value.username?(f(),v("div",HAt,[p("div",KAt,[p("div",{class:he(["filters-container",{hidden:i.value}])},[w(fAt,{translatedSports:o.value,authUser:a.value,onFilter:r},null,8,["translatedSports","authUser"])],2),p("div",qAt,[p("div",{onClick:r},[p("i",{class:he(`fa fa-caret-${i.value?"down":"up"}`),"aria-hidden":"true"},null,2),p("span",null,A(u.$t(`workouts.${i.value?"DISPLAY":"HIDE"}_FILTERS`)),1)])]),p("div",jAt,[w(VAt,{user:a.value,translatedSports:o.value},null,8,["user","translatedSports"])])])])):D("",!0)}}),{t:r0}=Mo.global,u0=e=>{const t=/(\/profile)(\/edit)*(\/*)/,n=e.replace(t,"").toUpperCase();return n===""?"PROFILE":n.split("/")[0].toUpperCase()},XAt=[{path:"/",name:"Dashboard",component:SEt,meta:{title:"dashboard.DASHBOARD",allowedToSuspendedUser:!1}},{path:"/login",name:"Login",component:Xh,props:{action:"login"},meta:{title:"user.LOGIN",withoutAuth:!0}},{path:"/register",name:"Register",component:Xh,props:{action:"register"},meta:{title:"user.REGISTER",withoutAuth:!0}},{path:"/account-confirmation",name:"AccountConfirmation",component:nTt,meta:{title:"user.ACCOUNT_CONFIRMATION",withoutAuth:!0}},{path:"/account-confirmation/resend",name:"AccountConfirmationResend",component:Yh,props:{action:"account-confirmation-resend"},meta:{title:"buttons.ACCOUNT-CONFIRMATION-RESEND",withoutAuth:!0}},{path:"/account-confirmation/email-sent",name:"AccountConfirmationEmailSend",component:Yh,props:{action:"email-sent"},meta:{title:"buttons.ACCOUNT-CONFIRMATION-RESEND",withoutAuth:!0}},{path:"/password-reset/sent",name:"PasswordEmailSent",component:Mr,props:{action:"request-sent"},meta:{title:"user.PASSWORD_RESET",withoutAuth:!0}},{path:"/password-reset/request",name:"PasswordResetRequest",component:Mr,props:{action:"reset-request"},meta:{title:"user.PASSWORD_RESET",withoutAuth:!0}},{path:"/password-reset/password-updated",name:"PasswordUpdated",component:Mr,props:{action:"password-updated"},meta:{title:"user.PASSWORD_RESET",withoutAuth:!0}},{path:"/password-reset",name:"PasswordReset",component:Mr,props:{action:"reset"},meta:{title:"user.PASSWORD_RESET",withoutAuth:!0}},{path:"/email-update",name:"EmailUpdate",component:iTt,meta:{title:"user.EMAIL_UPDATE",withoutChecks:!0}},{path:"/profile",name:"Profile",component:PTt,children:[{path:"",name:"UserProfile",component:RJe,props:e=>({tab:u0(e.path)}),children:[{path:"",name:"UserInfos",component:MO,meta:{title:"user.PROFILE.TABS.PROFILE"}},{path:"preferences",name:"UserPreferences",component:Eet,meta:{title:"user.PROFILE.TABS.PREFERENCES"}},{path:"sports",name:"UserSports",component:zh,props:{isEdition:!1},meta:{title:"user.PROFILE.TABS.SPORTS"},children:[{path:"",name:"UserSportPreferences",component:xh,meta:{title:"user.PROFILE.TABS.SPORTS"}},{path:":id",name:"UserSport",component:Fut,meta:{title:"user.PROFILE.TABS.SPORTS"}}]},{path:"apps",name:"UserApps",component:sit,children:[{path:"",name:"UserAppsList",component:Rit,meta:{title:"user.PROFILE.TABS.APPS"}},{path:":id",name:"UserApp",component:kh,meta:{title:"user.PROFILE.TABS.APPS"}},{path:":id/created",name:"CreatedUserApp",component:kh,props:{afterCreation:!0},meta:{title:"user.PROFILE.TABS.APPS"}},{path:"new",name:"AddUserApp",component:Hot,meta:{title:"user.PROFILE.TABS.APPS"}},{path:"authorize",name:"AuthorizeUserApp",component:nit,meta:{title:"user.PROFILE.TABS.APPS"}}]},{path:"equipments",name:"UserEquipments",component:Wh,props:{isEdition:!1},children:[{path:"",name:"UserEquipmentsList",component:Fh,meta:{title:"user.PROFILE.TABS.EQUIPMENTS"}},{path:"new",name:"AddEquipment",component:Mh,meta:{title:"user.PROFILE.TABS.EQUIPMENTS"}},{path:":id",name:"Equipment",component:Prt,meta:{title:"user.PROFILE.TABS.EQUIPMENTS"}}]},{path:"follow-requests",name:"FollowRequests",component:Sh,props:{itemType:"follow-requests"}},{path:"blocked-users",name:"BlockedUsers",component:Sh,props:{itemType:"blocked-users"}},{path:"followers",name:"AuthUserFollowers",component:kr,props:{relationship:"followers"}},{path:"following",name:"AuthUserFollowing",component:kr,props:{relationship:"following"}},{path:"suspension",name:"AuthUserAccountSuspension",component:Iot},{path:"moderation",name:"Moderation",component:Jrt,children:[{path:"",name:"UserSanctionList",component:Iut},{path:"sanctions/:action_id",name:"UserSanctionDetail",component:mut}]}]},{path:"edit",name:"UserProfileEdition",component:vet,props:e=>({tab:u0(e.path)}),children:[{path:"",name:"UserInfosEdition",component:kat,meta:{title:"user.PROFILE.EDIT"}},{path:"account",name:"UserAccountEdition",component:Sat,meta:{title:"user.PROFILE.ACCOUNT_EDITION"}},{path:"picture",name:"UserPictureEdition",component:Gat,meta:{title:"user.PROFILE.PICTURE_EDITION"}},{path:"preferences",name:"UserPreferencesEdition",component:not,meta:{title:"user.PROFILE.EDIT_PREFERENCES"}},{path:"sports",name:"UserSportsEdition",component:zh,props:{isEdition:!0},meta:{title:"user.PROFILE.EDIT_SPORTS_PREFERENCES"},children:[{path:"",name:"UserSportPreferencesEdition",component:xh,meta:{title:"user.PROFILE.TABS.SPORTS"}},{path:":id",name:"UserSportEdition",component:ult,meta:{title:"user.PROFILE.TABS.SPORTS"}}]},{path:"equipments",name:"UserEquipmentsEdition",component:Wh,props:{isEdition:!0},children:[{path:"",name:"UserEquipmentsListEdition",component:Fh,meta:{title:"user.PROFILE.TABS.EQUIPMENTS"}},{path:":id",name:"EquipmentEdition",component:Mh,meta:{title:"user.PROFILE.TABS.EQUIPMENTS"}}]},{path:"privacy-policy",name:"UserPrivacyPolicy",component:_ot,meta:{title:"user.PROFILE.PRIVACY-POLICY_EDITION"}}]}]},{path:"/notifications",name:"Notifications",component:Opt,meta:{allowedToSuspendedUser:!1}},{path:"/statistics",name:"Statistics",component:Omt,meta:{title:"statistics.STATISTICS",allowedToSuspendedUser:!1}},{path:"/users",name:"Users",component:BTt,meta:{allowedToSuspendedUser:!1}},{path:"/users/:username",name:"User",props:{fromAdmin:!1},component:Qh,meta:{title:"user.USER",withoutChecks:!0,allowedToSuspendedUser:!1},children:[{path:"followers",name:"UserFollowers",component:kr,props:{relationship:"followers"},meta:{withoutAuth:!1,withoutChecks:!1}},{path:"following",name:"UserFollowing",component:kr,props:{relationship:"following"},meta:{withoutAuth:!1,withoutChecks:!1}}]},{path:"/workouts",name:"Workouts",component:YAt,meta:{title:"workouts.WORKOUT",count:0,allowedToSuspendedUser:!1}},{path:"/workouts/:workoutId",name:"Workout",component:Td,props:{displaySegment:!1},meta:{title:"workouts.WORKOUT",withoutChecks:!0,allowedToSuspendedUser:!1}},{path:"/workouts/:workoutId/edit",name:"EditWorkout",component:Sft,meta:{title:"workouts.EDIT_WORKOUT",allowedToSuspendedUser:!1}},{path:"/workouts/:workoutId/segment/:segmentId",name:"WorkoutSegment",component:Td,props:{displaySegment:!0},meta:{title:"workouts.SEGMENT",count:0,withoutChecks:!0,allowedToSuspendedUser:!1}},{path:"/workouts/:workoutId/comments/:commentId",name:"WorkoutComment",component:Td,props:{displaySegment:!1},meta:{allowedToSuspendedUser:!1,withoutChecks:!0}},{path:"/comments/:commentId",name:"Comment",component:_ft,meta:{allowedToSuspendedUser:!1,withoutChecks:!0}},{path:"/workouts/add",name:"AddWorkout",component:nft,meta:{title:"workouts.ADD_WORKOUT",allowedToSuspendedUser:!1}},{path:"/admin",name:"Administration",component:uct,meta:{allowedToSuspendedUser:!1,minimumRole:"moderator"},children:[{path:"",name:"AdministrationMenu",component:$Ye,meta:{title:"admin.ADMINISTRATION"}},{path:"application",name:"ApplicationAdministration",component:mh,meta:{title:"admin.APP_CONFIG.TITLE",minimumRole:"admin"}},{path:"application/edit",name:"ApplicationAdministrationEdition",component:mh,props:{edition:!0},meta:{title:"admin.APPLICATION",minimumRole:"admin"}},{path:"equipment-types",name:"EquipmentTypeAdministration",component:fYe,meta:{title:"admin.EQUIPMENT_TYPES.TITLE",minimumRole:"admin"}},{path:"sports",name:"SportsAdministration",component:kZe,meta:{title:"admin.SPORTS.TITLE",minimumRole:"admin"}},{path:"reports",name:"ReportsAdministration",component:hZe,meta:{title:"admin.APP_MODERATION.TITLE"}},{path:"reports/:reportId",name:"ReportAdministration",component:DQe,meta:{title:"admin.APP_MODERATION.REPORT"}},{path:"users/:username",name:"UserFromAdmin",component:Qh,props:{fromAdmin:!0},meta:{title:"admin.USER",count:1}},{path:"users",name:"UsersAdministration",component:cJe,meta:{title:"admin.USERS.TITLE",minimumRole:"admin"}}]},{path:"/about",name:"About",component:act,meta:{title:"common.ABOUT",withoutChecks:!0}},{path:"/privacy-policy",name:"PrivacyPolicy",component:Rpt,meta:{title:"privacy_policy.TITLE",withoutChecks:!0}},{path:"/:pathMatch(.*)*",name:"not-found",component:OEt,meta:{title:"error.NOT_FOUND.PAGE"}}],rt=bP({history:nP("/"),routes:XAt});rt.beforeEach((e,t,n)=>{if("title"in e.meta){const a=typeof e.meta.title=="string"?e.meta.title:"",s=a?typeof e.meta.count=="number"?r0(a,+e.meta.count):r0(a):"";window.document.title=`FitTrackee${a?` - ${Fe(s)}`:""}`}Wn.commit(te.MUTATIONS.EMPTY_ERROR_MESSAGES),Wn.dispatch(K.ACTIONS.CHECK_AUTH_USER).then(()=>{if(e.meta.withoutChecks)return n();if(Wn.getters[K.GETTERS.IS_PROFILE_LOADED]&&Wn.getters[K.GETTERS.IS_SUSPENDED]&&!e.path.startsWith("/profile")&&!e.meta.allowedToSuspendedUser)return n("/profile");if(Wn.getters[K.GETTERS.IS_AUTHENTICATED]&&Wn.getters[K.GETTERS.IS_PROFILE_LOADED]&&!Wn.getters[K.GETTERS.IS_SUSPENDED]&&Wn.dispatch(dt.ACTIONS.GET_UNREAD_STATUS),Wn.getters[K.GETTERS.IS_AUTHENTICATED]&&e.meta.withoutAuth)return n("/");if(!Wn.getters[K.GETTERS.IS_AUTHENTICATED]&&!e.meta.withoutAuth){const a=e.path==="/"?{path:"/login"}:{path:"/login",query:{from:e.fullPath}};n(a)}else n()}).catch(a=>{console.error(a),n()})});gE.register(BI,GI,VI,HI,KI,qI,jI,l0,YI,c0,XI,QI);const FI=RN(HBe).provide("sportColors",xp).use(Mo).use(Wn).use(rt).use(cb,{name:"VFullscreen"}).directive("click-outside",gje);Ije.forEach(e=>{FI.component(e.name,e.target)});FI.mount("#app"); diff --git a/fittrackee/dist/static/maps-oY9oTtTW.js b/fittrackee/dist/static/maps-DkiRMund.js similarity index 99% rename from fittrackee/dist/static/maps-oY9oTtTW.js rename to fittrackee/dist/static/maps-DkiRMund.js index 47f87a2e6..c8824142f 100644 --- a/fittrackee/dist/static/maps-oY9oTtTW.js +++ b/fittrackee/dist/static/maps-DkiRMund.js @@ -4,4 +4,4 @@ function Eh(t,i){for(var o=0;o"u"||!L||!L.Mixin)){t=Dt(t)?t:[t];for(var i=0;i0?Math.floor(t):Math.ceil(t)};O.prototype={clone:function(){return new O(this.x,this.y)},add:function(t){return this.clone()._add(z(t))},_add:function(t){return this.x+=t.x,this.y+=t.y,this},subtract:function(t){return this.clone()._subtract(z(t))},_subtract:function(t){return this.x-=t.x,this.y-=t.y,this},divideBy:function(t){return this.clone()._divideBy(t)},_divideBy:function(t){return this.x/=t,this.y/=t,this},multiplyBy:function(t){return this.clone()._multiplyBy(t)},_multiplyBy:function(t){return this.x*=t,this.y*=t,this},scaleBy:function(t){return new O(this.x*t.x,this.y*t.y)},unscaleBy:function(t){return new O(this.x/t.x,this.y/t.y)},round:function(){return this.clone()._round()},_round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this},floor:function(){return this.clone()._floor()},_floor:function(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this},ceil:function(){return this.clone()._ceil()},_ceil:function(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this},trunc:function(){return this.clone()._trunc()},_trunc:function(){return this.x=qs(this.x),this.y=qs(this.y),this},distanceTo:function(t){t=z(t);var i=t.x-this.x,o=t.y-this.y;return Math.sqrt(i*i+o*o)},equals:function(t){return t=z(t),t.x===this.x&&t.y===this.y},contains:function(t){return t=z(t),Math.abs(t.x)<=Math.abs(this.x)&&Math.abs(t.y)<=Math.abs(this.y)},toString:function(){return"Point("+qt(this.x)+", "+qt(this.y)+")"}};function z(t,i,o){return t instanceof O?t:Dt(t)?new O(t[0],t[1]):t==null?t:typeof t=="object"&&"x"in t&&"y"in t?new O(t.x,t.y):new O(t,i,o)}function nt(t,i){if(t)for(var o=i?[t,i]:t,r=0,h=o.length;r=this.min.x&&o.x<=this.max.x&&i.y>=this.min.y&&o.y<=this.max.y},intersects:function(t){t=Lt(t);var i=this.min,o=this.max,r=t.min,h=t.max,l=h.x>=i.x&&r.x<=o.x,f=h.y>=i.y&&r.y<=o.y;return l&&f},overlaps:function(t){t=Lt(t);var i=this.min,o=this.max,r=t.min,h=t.max,l=h.x>i.x&&r.xi.y&&r.y=i.lat&&h.lat<=o.lat&&r.lng>=i.lng&&h.lng<=o.lng},intersects:function(t){t=lt(t);var i=this._southWest,o=this._northEast,r=t.getSouthWest(),h=t.getNorthEast(),l=h.lat>=i.lat&&r.lat<=o.lat,f=h.lng>=i.lng&&r.lng<=o.lng;return l&&f},overlaps:function(t){t=lt(t);var i=this._southWest,o=this._northEast,r=t.getSouthWest(),h=t.getNorthEast(),l=h.lat>i.lat&&r.lati.lng&&r.lng1,$h=function(){var t=!1;try{var i=Object.defineProperty({},"passive",{get:function(){t=!0}});window.addEventListener("testPassiveEventSupport",st,i),window.removeEventListener("testPassiveEventSupport",st,i)}catch{}return t}(),tu=function(){return!!document.createElement("canvas").getContext}(),mo=!!(document.createElementNS&&hr("svg").createSVGRect),eu=!!mo&&function(){var t=document.createElement("div");return t.innerHTML="",(t.firstChild&&t.firstChild.namespaceURI)==="http://www.w3.org/2000/svg"}(),iu=!mo&&function(){try{var t=document.createElement("div");t.innerHTML='';var i=t.firstChild;return i.style.behavior="url(#default#VML)",i&&typeof i.adj=="object"}catch{return!1}}(),nu=navigator.platform.indexOf("Mac")===0,ou=navigator.platform.indexOf("Linux")===0;function Kt(t){return navigator.userAgent.toLowerCase().indexOf(t)>=0}var T={ie:on,ielt9:Fh,edge:lr,webkit:co,android:cr,android23:fr,androidStock:Uh,opera:fo,chrome:dr,gecko:_r,safari:Vh,phantom:mr,opera12:pr,win:qh,ie3d:gr,webkit3d:_o,gecko3d:vr,any3d:Gh,mobile:xi,mobileWebkit:jh,mobileWebkit3d:Kh,msPointer:yr,pointer:wr,touch:Jh,touchNative:xr,mobileOpera:Yh,mobileGecko:Xh,retina:Qh,passiveEvents:$h,canvas:tu,svg:mo,vml:iu,inlineSvg:eu,mac:nu,linux:ou},Pr=T.msPointer?"MSPointerDown":"pointerdown",Lr=T.msPointer?"MSPointerMove":"pointermove",br=T.msPointer?"MSPointerUp":"pointerup",Tr=T.msPointer?"MSPointerCancel":"pointercancel",$n={touchstart:Pr,touchmove:Lr,touchend:br,touchcancel:Tr},js={touchstart:lu,touchmove:Ki,touchend:Ki,touchcancel:Ki},Re={},Ks=!1;function su(t,i,o){return i==="touchstart"&&uu(),js[i]?(o=js[i].bind(this,o),t.addEventListener($n[i],o,!1),o):(console.warn("wrong event specified:",i),st)}function ru(t,i,o){if(!$n[i]){console.warn("wrong event specified:",i);return}t.removeEventListener($n[i],o,!1)}function au(t){Re[t.pointerId]=t}function hu(t){Re[t.pointerId]&&(Re[t.pointerId]=t)}function Js(t){delete Re[t.pointerId]}function uu(){Ks||(document.addEventListener(Pr,au,!0),document.addEventListener(Lr,hu,!0),document.addEventListener(br,Js,!0),document.addEventListener(Tr,Js,!0),Ks=!0)}function Ki(t,i){if(i.pointerType!==(i.MSPOINTER_TYPE_MOUSE||"mouse")){i.touches=[];for(var o in Re)i.touches.push(Re[o]);i.changedTouches=[i],t(i)}}function lu(t,i){i.MSPOINTER_TYPE_TOUCH&&i.pointerType===i.MSPOINTER_TYPE_TOUCH&&pt(i),Ki(t,i)}function cu(t){var i={},o,r;for(r in t)o=t[r],i[r]=o&&o.bind?o.bind(t):o;return t=i,i.type="dblclick",i.detail=2,i.isTrusted=!1,i._simulated=!0,i}var fu=200;function du(t,i){t.addEventListener("dblclick",i);var o=0,r;function h(l){if(l.detail!==1){r=l.detail;return}if(!(l.pointerType==="mouse"||l.sourceCapabilities&&!l.sourceCapabilities.firesTouchEvents)){var f=zr(l);if(!(f.some(function(_){return _ instanceof HTMLLabelElement&&_.attributes.for})&&!f.some(function(_){return _ instanceof HTMLInputElement||_ instanceof HTMLSelectElement}))){var m=Date.now();m-o<=fu?(r++,r===2&&i(cu(l))):r=1,o=m}}}return t.addEventListener("click",h),{dblclick:i,simDblclick:h}}function _u(t,i){t.removeEventListener("dblclick",i.dblclick),t.removeEventListener("click",i.simDblclick)}var po=rn(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),fi=rn(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),Mr=fi==="webkitTransition"||fi==="OTransition"?fi+"End":"transitionend";function Sr(t){return typeof t=="string"?document.getElementById(t):t}function pi(t,i){var o=t.style[i]||t.currentStyle&&t.currentStyle[i];if((!o||o==="auto")&&document.defaultView){var r=document.defaultView.getComputedStyle(t,null);o=r?r[i]:null}return o==="auto"?null:o}function U(t,i,o){var r=document.createElement(t);return r.className=i||"",o&&o.appendChild(r),r}function it(t){var i=t.parentNode;i&&i.removeChild(t)}function Qi(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function We(t){var i=t.parentNode;i&&i.lastChild!==t&&i.appendChild(t)}function Ue(t){var i=t.parentNode;i&&i.firstChild!==t&&i.insertBefore(t,i.firstChild)}function go(t,i){if(t.classList!==void 0)return t.classList.contains(i);var o=sn(t);return o.length>0&&new RegExp("(^|\\s)"+i+"(\\s|$)").test(o)}function B(t,i){if(t.classList!==void 0)for(var o=xe(i),r=0,h=o.length;r0?2*window.devicePixelRatio:1;function Er(t){return T.edge?t.wheelDeltaY/2:t.deltaY&&t.deltaMode===0?-t.deltaY/gu:t.deltaY&&t.deltaMode===1?-t.deltaY*20:t.deltaY&&t.deltaMode===2?-t.deltaY*60:t.deltaX||t.deltaZ?0:t.wheelDelta?(t.wheelDeltaY||t.wheelDelta)/2:t.detail&&Math.abs(t.detail)<32765?-t.detail*20:t.detail?t.detail/-32765*60:0}function bo(t,i){var o=i.relatedTarget;if(!o)return!0;try{for(;o&&o!==t;)o=o.parentNode}catch{return!1}return o!==t}var vu={__proto__:null,on:A,off:Y,stopPropagation:Pe,disableScrollPropagation:Lo,disableClickPropagation:Pi,preventDefault:pt,stop:Me,getPropagationPath:zr,getMousePosition:kr,getWheelDelta:Er,isExternalTarget:bo,addListener:A,removeListener:Y},Or=yi.extend({run:function(t,i,o,r){this.stop(),this._el=t,this._inProgress=!0,this._duration=o||.25,this._easeOutPower=1/Math.max(r||.5,.2),this._startPos=Te(t),this._offset=i.subtract(this._startPos),this._startTime=+new Date,this.fire("start"),this._animate()},stop:function(){this._inProgress&&(this._step(!0),this._complete())},_animate:function(){this._animId=bt(this._animate,this),this._step()},_step:function(t){var i=+new Date-this._startTime,o=this._duration*1e3;ithis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,i){this._enforcingBounds=!0;var o=this.getCenter(),r=this._limitCenter(o,this._zoom,lt(t));return o.equals(r)||this.panTo(r,i),this._enforcingBounds=!1,this},panInside:function(t,i){i=i||{};var o=z(i.paddingTopLeft||i.padding||[0,0]),r=z(i.paddingBottomRight||i.padding||[0,0]),h=this.project(this.getCenter()),l=this.project(t),f=this.getPixelBounds(),m=Lt([f.min.add(o),f.max.subtract(r)]),_=m.getSize();if(!m.contains(l)){this._enforcingBounds=!0;var v=l.subtract(m.getCenter()),w=m.extend(l).getSize().subtract(_);h.x+=v.x<0?-w.x:w.x,h.y+=v.y<0?-w.y:w.y,this.panTo(this.unproject(h),i),this._enforcingBounds=!1}return this},invalidateSize:function(t){if(!this._loaded)return this;t=j({animate:!1,pan:!0},t===!0?{animate:!0}:t);var i=this.getSize();this._sizeChanged=!0,this._lastCenter=null;var o=this.getSize(),r=i.divideBy(2).round(),h=o.divideBy(2).round(),l=r.subtract(h);return!l.x&&!l.y?this:(t.animate&&t.pan?this.panBy(l):(t.pan&&this._rawPanBy(l),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(X(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:i,newSize:o}))},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){if(t=this._locateOptions=j({timeout:1e4,watch:!1},t),!("geolocation"in navigator))return this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this;var i=X(this._handleGeolocationResponse,this),o=X(this._handleGeolocationError,this);return t.watch?this._locationWatchId=navigator.geolocation.watchPosition(i,o,t):navigator.geolocation.getCurrentPosition(i,o,t),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){if(this._container._leaflet_id){var i=t.code,o=t.message||(i===1?"permission denied":i===2?"position unavailable":"timeout");this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:i,message:"Geolocation error: "+o+"."})}},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var i=t.coords.latitude,o=t.coords.longitude,r=new G(i,o),h=r.toBounds(t.coords.accuracy*2),l=this._locateOptions;if(l.setView){var f=this.getBoundsZoom(h);this.setView(r,l.maxZoom?Math.min(f,l.maxZoom):f)}var m={latlng:r,bounds:h,timestamp:t.timestamp};for(var _ in t.coords)typeof t.coords[_]=="number"&&(m[_]=t.coords[_]);this.fire("locationfound",m)}},addHandler:function(t,i){if(!i)return this;var o=this[t]=new i(this);return this._handlers.push(o),this.options[t]&&o.enable(),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch{this._container._leaflet_id=void 0,this._containerId=void 0}this._locationWatchId!==void 0&&this.stopLocate(),this._stop(),it(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(Zt(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload");var t;for(t in this._layers)this._layers[t].remove();for(t in this._panes)it(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,i){var o="leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),r=U("div",o,i||this._mapPane);return t&&(this._panes[t]=r),r},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds(),i=this.unproject(t.getBottomLeft()),o=this.unproject(t.getTopRight());return new Tt(i,o)},getMinZoom:function(){return this.options.minZoom===void 0?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return this.options.maxZoom===void 0?this._layersMaxZoom===void 0?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,i,o){t=lt(t),o=z(o||[0,0]);var r=this.getZoom()||0,h=this.getMinZoom(),l=this.getMaxZoom(),f=t.getNorthWest(),m=t.getSouthEast(),_=this.getSize().subtract(o),v=Lt(this.project(m,r),this.project(f,r)).getSize(),w=T.any3d?this.options.zoomSnap:1,x=_.x/v.x,M=_.y/v.y,ft=i?Math.max(x,M):Math.min(x,M);return r=this.getScaleZoom(ft,r),w&&(r=Math.round(r/(w/100))*(w/100),r=i?Math.ceil(r/w)*w:Math.floor(r/w)*w),Math.max(h,Math.min(l,r))},getSize:function(){return(!this._size||this._sizeChanged)&&(this._size=new O(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,i){var o=this._getTopLeftPoint(t,i);return new nt(o,o.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(t===void 0?this.getZoom():t)},getPane:function(t){return typeof t=="string"?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,i){var o=this.options.crs;return i=i===void 0?this._zoom:i,o.scale(t)/o.scale(i)},getScaleZoom:function(t,i){var o=this.options.crs;i=i===void 0?this._zoom:i;var r=o.zoom(t*o.scale(i));return isNaN(r)?1/0:r},project:function(t,i){return i=i===void 0?this._zoom:i,this.options.crs.latLngToPoint(H(t),i)},unproject:function(t,i){return i=i===void 0?this._zoom:i,this.options.crs.pointToLatLng(z(t),i)},layerPointToLatLng:function(t){var i=z(t).add(this.getPixelOrigin());return this.unproject(i)},latLngToLayerPoint:function(t){var i=this.project(H(t))._round();return i._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(H(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(lt(t))},distance:function(t,i){return this.options.crs.distance(H(t),H(i))},containerPointToLayerPoint:function(t){return z(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return z(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){var i=this.containerPointToLayerPoint(z(t));return this.layerPointToLatLng(i)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(H(t)))},mouseEventToContainerPoint:function(t){return kr(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){var i=this._container=Sr(t);if(i){if(i._leaflet_id)throw new Error("Map container is already initialized.")}else throw new Error("Map container not found.");A(i,"scroll",this._onScroll,this),this._containerId=V(i)},_initLayout:function(){var t=this._container;this._fadeAnimated=this.options.fadeAnimation&&T.any3d,B(t,"leaflet-container"+(T.touch?" leaflet-touch":"")+(T.retina?" leaflet-retina":"")+(T.ielt9?" leaflet-oldie":"")+(T.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":""));var i=pi(t,"position");i!=="absolute"&&i!=="relative"&&i!=="fixed"&&i!=="sticky"&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),ct(this._mapPane,new O(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(B(t.markerPane,"leaflet-zoom-hide"),B(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,i,o){ct(this._mapPane,new O(0,0));var r=!this._loaded;this._loaded=!0,i=this._limitZoom(i),this.fire("viewprereset");var h=this._zoom!==i;this._moveStart(h,o)._move(t,i)._moveEnd(h),this.fire("viewreset"),r&&this.fire("load")},_moveStart:function(t,i){return t&&this.fire("zoomstart"),i||this.fire("movestart"),this},_move:function(t,i,o,r){i===void 0&&(i=this._zoom);var h=this._zoom!==i;return this._zoom=i,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),r?o&&o.pinch&&this.fire("zoom",o):((h||o&&o.pinch)&&this.fire("zoom",o),this.fire("move",o)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return Zt(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){ct(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={},this._targets[V(this._container)]=this;var i=t?Y:A;i(this._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&i(window,"resize",this._onResize,this),T.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){Zt(this._resizeRequest),this._resizeRequest=bt(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,i){for(var o=[],r,h=i==="mouseout"||i==="mouseover",l=t.target||t.srcElement,f=!1;l;){if(r=this._targets[V(l)],r&&(i==="click"||i==="preclick")&&this._draggableMoved(r)){f=!0;break}if(r&&r.listens(i,!0)&&(h&&!bo(l,t)||(o.push(r),h))||l===this._container)break;l=l.parentNode}return!o.length&&!f&&!h&&this.listens(i,!0)&&(o=[this]),o},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var i=t.target||t.srcElement;if(!(!this._loaded||i._leaflet_disable_events||t.type==="click"&&this._isClickDisabled(i))){var o=t.type;o==="mousedown"&&xo(i),this._fireDOMEvent(t,o)}},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,i,o){if(t.type==="click"){var r=j({},t);r.type="preclick",this._fireDOMEvent(r,r.type,o)}var h=this._findEventTargets(t,i);if(o){for(var l=[],f=0;f0?Math.round(t-i)/2:Math.max(0,Math.ceil(t))-Math.max(0,Math.floor(i))},_limitZoom:function(t){var i=this.getMinZoom(),o=this.getMaxZoom(),r=T.any3d?this.options.zoomSnap:1;return r&&(t=Math.round(t/r)*r),Math.max(i,Math.min(o,t))},_onPanTransitionStep:function(){this.fire("move")},_onPanTransitionEnd:function(){rt(this._mapPane,"leaflet-pan-anim"),this.fire("moveend")},_tryAnimatedPan:function(t,i){var o=this._getCenterOffset(t)._trunc();return(i&&i.animate)!==!0&&!this.getSize().contains(o)?!1:(this.panBy(o,i),!0)},_createAnimProxy:function(){var t=this._proxy=U("div","leaflet-proxy leaflet-zoom-animated");this._panes.mapPane.appendChild(t),this.on("zoomanim",function(i){var o=po,r=this._proxy.style[o];be(this._proxy,this.project(i.center,i.zoom),this.getZoomScale(i.zoom,1)),r===this._proxy.style[o]&&this._animatingZoom&&this._onZoomTransitionEnd()},this),this.on("load moveend",this._animMoveEnd,this),this._on("unload",this._destroyAnimProxy,this)},_destroyAnimProxy:function(){it(this._proxy),this.off("load moveend",this._animMoveEnd,this),delete this._proxy},_animMoveEnd:function(){var t=this.getCenter(),i=this.getZoom();be(this._proxy,this.project(t,i),this.getZoomScale(i,1))},_catchTransitionEnd:function(t){this._animatingZoom&&t.propertyName.indexOf("transform")>=0&&this._onZoomTransitionEnd()},_nothingToAnimate:function(){return!this._container.getElementsByClassName("leaflet-zoom-animated").length},_tryAnimatedZoom:function(t,i,o){if(this._animatingZoom)return!0;if(o=o||{},!this._zoomAnimated||o.animate===!1||this._nothingToAnimate()||Math.abs(i-this._zoom)>this.options.zoomAnimationThreshold)return!1;var r=this.getZoomScale(i),h=this._getCenterOffset(t)._divideBy(1-1/r);return o.animate!==!0&&!this.getSize().contains(h)?!1:(bt(function(){this._moveStart(!0,o.noMoveStart||!1)._animateZoom(t,i,!0)},this),!0)},_animateZoom:function(t,i,o,r){this._mapPane&&(o&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=i,B(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:i,noUpdate:r}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(X(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&rt(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function yu(t,i){return new F(t,i)}var Rt=oe.extend({options:{position:"topright"},initialize:function(t){Q(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var i=this._map;return i&&i.removeControl(this),this.options.position=t,i&&i.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var i=this._container=this.onAdd(t),o=this.getPosition(),r=t._controlCorners[o];return B(i,"leaflet-control"),o.indexOf("bottom")!==-1?r.insertBefore(i,r.firstChild):r.appendChild(i),this._map.on("unload",this.remove,this),this},remove:function(){return this._map?(it(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null,this):this},_refocusOnMap:function(t){this._map&&t&&t.screenX>0&&t.screenY>0&&this._map.getContainer().focus()}}),Li=function(t){return new Rt(t)};F.include({addControl:function(t){return t.addTo(this),this},removeControl:function(t){return t.remove(),this},_initControlPos:function(){var t=this._controlCorners={},i="leaflet-",o=this._controlContainer=U("div",i+"control-container",this._container);function r(h,l){var f=i+h+" "+i+l;t[h+l]=U("div",f,o)}r("top","left"),r("top","right"),r("bottom","left"),r("bottom","right")},_clearControlPos:function(){for(var t in this._controlCorners)it(this._controlCorners[t]);it(this._controlContainer),delete this._controlCorners,delete this._controlContainer}});var Ar=Rt.extend({options:{collapsed:!0,position:"topright",autoZIndex:!0,hideSingleBase:!1,sortLayers:!1,sortFunction:function(t,i,o,r){return o1,this._baseLayersList.style.display=t?"":"none"),this._separator.style.display=i&&t?"":"none",this},_onLayerChange:function(t){this._handlingClick||this._update();var i=this._getLayer(V(t.target)),o=i.overlay?t.type==="add"?"overlayadd":"overlayremove":t.type==="add"?"baselayerchange":null;o&&this._map.fire(o,i)},_createRadioElement:function(t,i){var o='",r=document.createElement("div");return r.innerHTML=o,r.firstChild},_addItem:function(t){var i=document.createElement("label"),o=this._map.hasLayer(t.layer),r;t.overlay?(r=document.createElement("input"),r.type="checkbox",r.className="leaflet-control-layers-selector",r.defaultChecked=o):r=this._createRadioElement("leaflet-base-layers_"+V(this),o),this._layerControlInputs.push(r),r.layerId=V(t.layer),A(r,"click",this._onInputClick,this);var h=document.createElement("span");h.innerHTML=" "+t.name;var l=document.createElement("span");i.appendChild(l),l.appendChild(r),l.appendChild(h);var f=t.overlay?this._overlaysList:this._baseLayersList;return f.appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t=this._layerControlInputs,i,o,r=[],h=[];this._handlingClick=!0;for(var l=t.length-1;l>=0;l--)i=t[l],o=this._getLayer(i.layerId).layer,i.checked?r.push(o):i.checked||h.push(o);for(l=0;l=0;h--)i=t[h],o=this._getLayer(i.layerId).layer,i.disabled=o.options.minZoom!==void 0&&ro.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section;this._preventClick=!0,A(t,"click",pt),this.expand();var i=this;setTimeout(function(){Y(t,"click",pt),i._preventClick=!1})}}),wu=function(t,i,o){return new Ar(t,i,o)},To=Rt.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var i="leaflet-control-zoom",o=U("div",i+" leaflet-bar"),r=this.options;return this._zoomInButton=this._createButton(r.zoomInText,r.zoomInTitle,i+"-in",o,this._zoomIn),this._zoomOutButton=this._createButton(r.zoomOutText,r.zoomOutTitle,i+"-out",o,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),o},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,i,o,r,h){var l=U("a",o,r);return l.innerHTML=t,l.href="#",l.title=i,l.setAttribute("role","button"),l.setAttribute("aria-label",i),Pi(l),A(l,"click",Me),A(l,"click",h,this),A(l,"click",this._refocusOnMap,this),l},_updateDisabled:function(){var t=this._map,i="leaflet-disabled";rt(this._zoomInButton,i),rt(this._zoomOutButton,i),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),(this._disabled||t._zoom===t.getMinZoom())&&(B(this._zoomOutButton,i),this._zoomOutButton.setAttribute("aria-disabled","true")),(this._disabled||t._zoom===t.getMaxZoom())&&(B(this._zoomInButton,i),this._zoomInButton.setAttribute("aria-disabled","true"))}});F.mergeOptions({zoomControl:!0});F.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new To,this.addControl(this.zoomControl))});var xu=function(t){return new To(t)},Zr=Rt.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var i="leaflet-control-scale",o=U("div",i),r=this.options;return this._addScales(r,i+"-line",o),t.on(r.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),o},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,i,o){t.metric&&(this._mScale=U("div",i,o)),t.imperial&&(this._iScale=U("div",i,o))},_update:function(){var t=this._map,i=t.getSize().y/2,o=t.distance(t.containerPointToLatLng([0,i]),t.containerPointToLatLng([this.options.maxWidth,i]));this._updateScales(o)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var i=this._getRoundNum(t),o=i<1e3?i+" m":i/1e3+" km";this._updateScale(this._mScale,o,i/t)},_updateImperial:function(t){var i=t*3.2808399,o,r,h;i>5280?(o=i/5280,r=this._getRoundNum(o),this._updateScale(this._iScale,r+" mi",r/o)):(h=this._getRoundNum(i),this._updateScale(this._iScale,h+" ft",h/i))},_updateScale:function(t,i,o){t.style.width=Math.round(this.options.maxWidth*o)+"px",t.innerHTML=i},_getRoundNum:function(t){var i=Math.pow(10,(Math.floor(t)+"").length-1),o=t/i;return o=o>=10?10:o>=5?5:o>=3?3:o>=2?2:1,i*o}}),Pu=function(t){return new Zr(t)},Lu='',Mo=Rt.extend({options:{position:"bottomright",prefix:'
    '+(T.inlineSvg?Lu+" ":"")+"Leaflet"},initialize:function(t){Q(this,t),this._attributions={}},onAdd:function(t){t.attributionControl=this,this._container=U("div","leaflet-control-attribution"),Pi(this._container);for(var i in t._layers)t._layers[i].getAttribution&&this.addAttribution(t._layers[i].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t?(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update(),this):this},removeAttribution:function(t){return t?(this._attributions[t]&&(this._attributions[t]--,this._update()),this):this},_update:function(){if(this._map){var t=[];for(var i in this._attributions)this._attributions[i]&&t.push(i);var o=[];this.options.prefix&&o.push(this.options.prefix),t.length&&o.push(t.join(", ")),this._container.innerHTML=o.join(' ')}}});F.mergeOptions({attributionControl:!0});F.addInitHook(function(){this.options.attributionControl&&new Mo().addTo(this)});var bu=function(t){return new Mo(t)};Rt.Layers=Ar;Rt.Zoom=To;Rt.Scale=Zr;Rt.Attribution=Mo;Li.layers=wu;Li.zoom=xu;Li.scale=Pu;Li.attribution=bu;var Jt=oe.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled?this:(this._enabled=!0,this.addHooks(),this)},disable:function(){return this._enabled?(this._enabled=!1,this.removeHooks(),this):this},enabled:function(){return!!this._enabled}});Jt.addTo=function(t,i){return t.addHandler(i,this),this};var Tu={Events:Ct},Xs=T.touch?"touchstart mousedown":"mousedown",fe=yi.extend({options:{clickTolerance:3},initialize:function(t,i,o,r){Q(this,r),this._element=t,this._dragStartTarget=i||t,this._preventOutline=o},enable:function(){this._enabled||(A(this._dragStartTarget,Xs,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(fe._dragging===this&&this.finishDrag(!0),Y(this._dragStartTarget,Xs,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){if(this._enabled&&(this._moved=!1,!go(this._element,"leaflet-zoom-anim"))){if(t.touches&&t.touches.length!==1){fe._dragging===this&&this.finishDrag();return}if(!(fe._dragging||t.shiftKey||t.which!==1&&t.button!==1&&!t.touches)&&(fe._dragging=this,this._preventOutline&&xo(this._element),yo(),gi(),!this._moving)){this.fire("down");var i=t.touches?t.touches[0]:t,o=Cr(this._element);this._startPoint=new O(i.clientX,i.clientY),this._startPos=Te(this._element),this._parentScale=Po(o);var r=t.type==="mousedown";A(document,r?"mousemove":"touchmove",this._onMove,this),A(document,r?"mouseup":"touchend touchcancel",this._onUp,this)}}},_onMove:function(t){if(this._enabled){if(t.touches&&t.touches.length>1){this._moved=!0;return}var i=t.touches&&t.touches.length===1?t.touches[0]:t,o=new O(i.clientX,i.clientY)._subtract(this._startPoint);!o.x&&!o.y||Math.abs(o.x)+Math.abs(o.y)l&&(f=m,l=_);l>o&&(i[f]=1,oo(t,i,o,r,f),oo(t,i,o,f,h))}function zu(t,i){for(var o=[t[0]],r=1,h=0,l=t.length;ri&&(o.push(t[r]),h=r);return hi.max.x&&(o|=2),t.yi.max.y&&(o|=8),o}function ku(t,i){var o=i.x-t.x,r=i.y-t.y;return o*o+r*r}function bi(t,i,o,r){var h=i.x,l=i.y,f=o.x-h,m=o.y-l,_=f*f+m*m,v;return _>0&&(v=((t.x-h)*f+(t.y-l)*m)/_,v>1?(h=o.x,l=o.y):v>0&&(h+=f*v,l+=m*v)),f=t.x-h,m=t.y-l,r?f*f+m*m:new O(h,l)}function Bt(t){return!Dt(t[0])||typeof t[0][0]!="object"&&typeof t[0][0]<"u"}function Hr(t){return console.warn("Deprecated use of _flat, please use L.LineUtil.isFlat instead."),Bt(t)}function Fr(t,i){var o,r,h,l,f,m,_,v;if(!t||t.length===0)throw new Error("latlngs not passed");Bt(t)||(console.warn("latlngs are not flat! Only the first ring will be used"),t=t[0]);var w=H([0,0]),x=lt(t),M=x.getNorthWest().distanceTo(x.getSouthWest())*x.getNorthEast().distanceTo(x.getNorthWest());M<1700&&(w=So(t));var ft=t.length,K=[];for(o=0;or){_=(l-r)/h,v=[m.x-_*(m.x-f.x),m.y-_*(m.y-f.y)];break}var _t=i.unproject(z(v));return H([_t.lat+w.lat,_t.lng+w.lng])}var Eu={__proto__:null,simplify:Nr,pointToSegmentDistance:Rr,closestPointOnSegment:Su,clipSegment:Dr,_getEdgeIntersection:tn,_getBitCode:Le,_sqClosestPointOnSegment:bi,isFlat:Bt,_flat:Hr,polylineCenter:Fr},Co={project:function(t){return new O(t.lng,t.lat)},unproject:function(t){return new G(t.y,t.x)},bounds:new nt([-180,-90],[180,90])},so={R:6378137,R_MINOR:6356752314245179e-9,bounds:new nt([-2003750834279e-5,-1549657073972e-5],[2003750834279e-5,1876465623138e-5]),project:function(t){var i=Math.PI/180,o=this.R,r=t.lat*i,h=this.R_MINOR/o,l=Math.sqrt(1-h*h),f=l*Math.sin(r),m=Math.tan(Math.PI/4-r/2)/Math.pow((1-f)/(1+f),l/2);return r=-o*Math.log(Math.max(m,1e-10)),new O(t.lng*i*o,r)},unproject:function(t){for(var i=180/Math.PI,o=this.R,r=this.R_MINOR/o,h=Math.sqrt(1-r*r),l=Math.exp(-t.y/o),f=Math.PI/2-2*Math.atan(l),m=0,_=.1,v;m<15&&Math.abs(_)>1e-7;m++)v=h*Math.sin(f),v=Math.pow((1-v)/(1+v),h/2),_=Math.PI/2-2*Math.atan(l*v)-f,f+=_;return new G(f*i,t.x*i/o)}},Ou={__proto__:null,LonLat:Co,Mercator:so,SphericalMercator:Qn},Au=j({},de,{code:"EPSG:3395",projection:so,transformation:function(){var t=.5/(Math.PI*so.R);return wi(t,.5,-t,.5)}()}),Wr=j({},de,{code:"EPSG:4326",projection:Co,transformation:wi(1/180,1,-1/180,.5)}),Zu=j({},se,{projection:Co,transformation:wi(1,0,-1,0),scale:function(t){return Math.pow(2,t)},zoom:function(t){return Math.log(t)/Math.LN2},distance:function(t,i){var o=i.lng-t.lng,r=i.lat-t.lat;return Math.sqrt(o*o+r*r)},infinite:!0});se.Earth=de;se.EPSG3395=Au;se.EPSG3857=uo;se.EPSG900913=Hh;se.EPSG4326=Wr;se.Simple=Zu;var Ht=yi.extend({options:{pane:"overlayPane",attribution:null,bubblingMouseEvents:!0},addTo:function(t){return t.addLayer(this),this},remove:function(){return this.removeFrom(this._map||this._mapToAdd)},removeFrom:function(t){return t&&t.removeLayer(this),this},getPane:function(t){return this._map.getPane(t?this.options[t]||t:this.options.pane)},addInteractiveTarget:function(t){return this._map._targets[V(t)]=this,this},removeInteractiveTarget:function(t){return delete this._map._targets[V(t)],this},getAttribution:function(){return this.options.attribution},_layerAdd:function(t){var i=t.target;if(i.hasLayer(this)){if(this._map=i,this._zoomAnimated=i._zoomAnimated,this.getEvents){var o=this.getEvents();i.on(o,this),this.once("remove",function(){i.off(o,this)},this)}this.onAdd(i),this.fire("add"),i.fire("layeradd",{layer:this})}}});F.include({addLayer:function(t){if(!t._layerAdd)throw new Error("The provided object is not a Layer.");var i=V(t);return this._layers[i]?this:(this._layers[i]=t,t._mapToAdd=this,t.beforeAdd&&t.beforeAdd(this),this.whenReady(t._layerAdd,t),this)},removeLayer:function(t){var i=V(t);return this._layers[i]?(this._loaded&&t.onRemove(this),delete this._layers[i],this._loaded&&(this.fire("layerremove",{layer:t}),t.fire("remove")),t._map=t._mapToAdd=null,this):this},hasLayer:function(t){return V(t)in this._layers},eachLayer:function(t,i){for(var o in this._layers)t.call(i,this._layers[o]);return this},_addLayers:function(t){t=t?Dt(t)?t:[t]:[];for(var i=0,o=t.length;ithis._layersMaxZoom&&this.setZoom(this._layersMaxZoom),this.options.minZoom===void 0&&this._layersMinZoom&&this.getZoom()=2&&i[0]instanceof G&&i[0].equals(i[o-1])&&i.pop(),i},_setLatLngs:function(t){ie.prototype._setLatLngs.call(this,t),Bt(this._latlngs)&&(this._latlngs=[this._latlngs])},_defaultShape:function(){return Bt(this._latlngs[0])?this._latlngs[0]:this._latlngs[0][0]},_clipPoints:function(){var t=this._renderer._bounds,i=this.options.weight,o=new O(i,i);if(t=new nt(t.min.subtract(o),t.max.add(o)),this._parts=[],!(!this._pxBounds||!this._pxBounds.intersects(t))){if(this.options.noClip){this._parts=this._rings;return}for(var r=0,h=this._rings.length,l;rt.y!=h.y>t.y&&t.x<(h.x-r.x)*(t.y-r.y)/(h.y-r.y)+r.x&&(i=!i);return i||ie.prototype._containsPoint.call(this,t,!0)}});function Wu(t,i){return new He(t,i)}var re=ne.extend({initialize:function(t,i){Q(this,i),this._layers={},t&&this.addData(t)},addData:function(t){var i=Dt(t)?t:t.features,o,r,h;if(i){for(o=0,r=i.length;o0&&h.push(h[0].slice()),h}function Fe(t,i){return t.feature?j({},t.feature,{geometry:i}):ln(i)}function ln(t){return t.type==="Feature"||t.type==="FeatureCollection"?t:{type:"Feature",properties:{},geometry:t}}var Oo={toGeoJSON:function(t){return Fe(this,{type:"Point",coordinates:Eo(this.getLatLng(),t)})}};an.include(Oo);zo.include(Oo);hn.include(Oo);ie.include({toGeoJSON:function(t){var i=!Bt(this._latlngs),o=un(this._latlngs,i?1:0,!1,t);return Fe(this,{type:(i?"Multi":"")+"LineString",coordinates:o})}});He.include({toGeoJSON:function(t){var i=!Bt(this._latlngs),o=i&&!Bt(this._latlngs[0]),r=un(this._latlngs,o?2:i?1:0,!0,t);return i||(r=[r]),Fe(this,{type:(o?"Multi":"")+"Polygon",coordinates:r})}});Ie.include({toMultiPoint:function(t){var i=[];return this.eachLayer(function(o){i.push(o.toGeoJSON(t).geometry.coordinates)}),Fe(this,{type:"MultiPoint",coordinates:i})},toGeoJSON:function(t){var i=this.feature&&this.feature.geometry&&this.feature.geometry.type;if(i==="MultiPoint")return this.toMultiPoint(t);var o=i==="GeometryCollection",r=[];return this.eachLayer(function(h){if(h.toGeoJSON){var l=h.toGeoJSON(t);if(o)r.push(l.geometry);else{var f=ln(l);f.type==="FeatureCollection"?r.push.apply(r,f.features):r.push(f)}}}),o?Fe(this,{geometries:r,type:"GeometryCollection"}):{type:"FeatureCollection",features:r}}});function Ur(t,i){return new re(t,i)}var Uu=Ur,cn=Ht.extend({options:{opacity:1,alt:"",interactive:!1,crossOrigin:!1,errorOverlayUrl:"",zIndex:1,className:""},initialize:function(t,i,o){this._url=t,this._bounds=lt(i),Q(this,o)},onAdd:function(){this._image||(this._initImage(),this.options.opacity<1&&this._updateOpacity()),this.options.interactive&&(B(this._image,"leaflet-interactive"),this.addInteractiveTarget(this._image)),this.getPane().appendChild(this._image),this._reset()},onRemove:function(){it(this._image),this.options.interactive&&this.removeInteractiveTarget(this._image)},setOpacity:function(t){return this.options.opacity=t,this._image&&this._updateOpacity(),this},setStyle:function(t){return t.opacity&&this.setOpacity(t.opacity),this},bringToFront:function(){return this._map&&We(this._image),this},bringToBack:function(){return this._map&&Ue(this._image),this},setUrl:function(t){return this._url=t,this._image&&(this._image.src=t),this},setBounds:function(t){return this._bounds=lt(t),this._map&&this._reset(),this},getEvents:function(){var t={zoom:this._reset,viewreset:this._reset};return this._zoomAnimated&&(t.zoomanim=this._animateZoom),t},setZIndex:function(t){return this.options.zIndex=t,this._updateZIndex(),this},getBounds:function(){return this._bounds},getElement:function(){return this._image},_initImage:function(){var t=this._url.tagName==="IMG",i=this._image=t?this._url:U("img");if(B(i,"leaflet-image-layer"),this._zoomAnimated&&B(i,"leaflet-zoom-animated"),this.options.className&&B(i,this.options.className),i.onselectstart=st,i.onmousemove=st,i.onload=X(this.fire,this,"load"),i.onerror=X(this._overlayOnError,this,"error"),(this.options.crossOrigin||this.options.crossOrigin==="")&&(i.crossOrigin=this.options.crossOrigin===!0?"":this.options.crossOrigin),this.options.zIndex&&this._updateZIndex(),t){this._url=i.src;return}i.src=this._url,i.alt=this.options.alt},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),o=this._map._latLngBoundsToNewLayerBounds(this._bounds,t.zoom,t.center).min;be(this._image,o,i)},_reset:function(){var t=this._image,i=new nt(this._map.latLngToLayerPoint(this._bounds.getNorthWest()),this._map.latLngToLayerPoint(this._bounds.getSouthEast())),o=i.getSize();ct(t,i.min),t.style.width=o.x+"px",t.style.height=o.y+"px"},_updateOpacity:function(){At(this._image,this.options.opacity)},_updateZIndex:function(){this._image&&this.options.zIndex!==void 0&&this.options.zIndex!==null&&(this._image.style.zIndex=this.options.zIndex)},_overlayOnError:function(){this.fire("error");var t=this.options.errorOverlayUrl;t&&this._url!==t&&(this._url=t,this._image.src=t)},getCenter:function(){return this._bounds.getCenter()}}),Vu=function(t,i,o){return new cn(t,i,o)},Vr=cn.extend({options:{autoplay:!0,loop:!0,keepAspectRatio:!0,muted:!1,playsInline:!0},_initImage:function(){var t=this._url.tagName==="VIDEO",i=this._image=t?this._url:U("video");if(B(i,"leaflet-image-layer"),this._zoomAnimated&&B(i,"leaflet-zoom-animated"),this.options.className&&B(i,this.options.className),i.onselectstart=st,i.onmousemove=st,i.onloadeddata=X(this.fire,this,"load"),t){for(var o=i.getElementsByTagName("source"),r=[],h=0;h0?r:[i.src];return}Dt(this._url)||(this._url=[this._url]),!this.options.keepAspectRatio&&Object.prototype.hasOwnProperty.call(i.style,"objectFit")&&(i.style.objectFit="fill"),i.autoplay=!!this.options.autoplay,i.loop=!!this.options.loop,i.muted=!!this.options.muted,i.playsInline=!!this.options.playsInline;for(var l=0;lh?(i.height=h+"px",B(t,l)):rt(t,l),this._containerWidth=this._container.offsetWidth},_animateZoom:function(t){var i=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center),o=this._getAnchor();ct(this._container,i.add(o))},_adjustPan:function(){if(this.options.autoPan){if(this._map._panAnim&&this._map._panAnim.stop(),this._autopanning){this._autopanning=!1;return}var t=this._map,i=parseInt(pi(this._container,"marginBottom"),10)||0,o=this._container.offsetHeight+i,r=this._containerWidth,h=new O(this._containerLeft,-o-this._containerBottom);h._add(Te(this._container));var l=t.layerPointToContainerPoint(h),f=z(this.options.autoPanPadding),m=z(this.options.autoPanPaddingTopLeft||f),_=z(this.options.autoPanPaddingBottomRight||f),v=t.getSize(),w=0,x=0;l.x+r+_.x>v.x&&(w=l.x+r-v.x+_.x),l.x-w-m.x<0&&(w=l.x-m.x),l.y+o+_.y>v.y&&(x=l.y+o-v.y+_.y),l.y-x-m.y<0&&(x=l.y-m.y),(w||x)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([w,x]))}},_getAnchor:function(){return z(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}}),ju=function(t,i){return new fn(t,i)};F.mergeOptions({closePopupOnClick:!0});F.include({openPopup:function(t,i,o){return this._initOverlay(fn,t,i,o).openOn(this),this},closePopup:function(t){return t=arguments.length?t:this._popup,t&&t.close(),this}});Ht.include({bindPopup:function(t,i){return this._popup=this._initOverlay(fn,this._popup,t,i),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ne||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return this._popup?this._popup.isOpen():!1},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){if(!(!this._popup||!this._map)){Me(t);var i=t.layer||t.target;if(this._popup._source===i&&!(i instanceof _e)){this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng);return}this._popup._source=i,this.openPopup(t.latlng)}},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){t.originalEvent.keyCode===13&&this._openPopup(t)}});var dn=jt.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){jt.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){jt.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=jt.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip",i=t+" "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=U("div",i),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+V(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var i,o,r=this._map,h=this._container,l=r.latLngToContainerPoint(r.getCenter()),f=r.layerPointToContainerPoint(t),m=this.options.direction,_=h.offsetWidth,v=h.offsetHeight,w=z(this.options.offset),x=this._getAnchor();m==="top"?(i=_/2,o=v):m==="bottom"?(i=_/2,o=0):m==="center"?(i=_/2,o=v/2):m==="right"?(i=0,o=v/2):m==="left"?(i=_,o=v/2):f.xthis.options.maxZoom||or?this._retainParent(h,l,f,r):!1)},_retainChildren:function(t,i,o,r){for(var h=2*t;h<2*t+2;h++)for(var l=2*i;l<2*i+2;l++){var f=new O(h,l);f.z=o+1;var m=this._tileCoordsToKey(f),_=this._tiles[m];if(_&&_.active){_.retain=!0;continue}else _&&_.loaded&&(_.retain=!0);o+1this.options.maxZoom||this.options.minZoom!==void 0&&h1){this._setView(t,o);return}for(var x=h.min.y;x<=h.max.y;x++)for(var M=h.min.x;M<=h.max.x;M++){var ft=new O(M,x);if(ft.z=this._tileZoom,!!this._isValidTile(ft)){var K=this._tiles[this._tileCoordsToKey(ft)];K?K.current=!0:f.push(ft)}}if(f.sort(function(_t,ae){return _t.distanceTo(l)-ae.distanceTo(l)}),f.length!==0){this._loading||(this._loading=!0,this.fire("loading"));var N=document.createDocumentFragment();for(M=0;Mo.max.x)||!i.wrapLat&&(t.yo.max.y))return!1}if(!this.options.bounds)return!0;var r=this._tileCoordsToBounds(t);return lt(this.options.bounds).overlaps(r)},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var i=this._map,o=this.getTileSize(),r=t.scaleBy(o),h=r.add(o),l=i.unproject(r,t.z),f=i.unproject(h,t.z);return[l,f]},_tileCoordsToBounds:function(t){var i=this._tileCoordsToNwSe(t),o=new Tt(i[0],i[1]);return this.options.noWrap||(o=this._map.wrapLatLngBounds(o)),o},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var i=t.split(":"),o=new O(+i[0],+i[1]);return o.z=+i[2],o},_removeTile:function(t){var i=this._tiles[t];i&&(it(i.el),delete this._tiles[t],this.fire("tileunload",{tile:i.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){B(t,"leaflet-tile");var i=this.getTileSize();t.style.width=i.x+"px",t.style.height=i.y+"px",t.onselectstart=st,t.onmousemove=st,T.ielt9&&this.options.opacity<1&&At(t,this.options.opacity)},_addTile:function(t,i){var o=this._getTilePos(t),r=this._tileCoordsToKey(t),h=this.createTile(this._wrapCoords(t),X(this._tileReady,this,t));this._initTile(h),this.createTile.length<2&&bt(X(this._tileReady,this,t,null,h)),ct(h,o),this._tiles[r]={el:h,coords:t,current:!0},i.appendChild(h),this.fire("tileloadstart",{tile:h,coords:t})},_tileReady:function(t,i,o){i&&this.fire("tileerror",{error:i,tile:o,coords:t});var r=this._tileCoordsToKey(t);o=this._tiles[r],o&&(o.loaded=+new Date,this._map._fadeAnimated?(At(o.el,0),Zt(this._fadeFrame),this._fadeFrame=bt(this._updateOpacity,this)):(o.active=!0,this._pruneTiles()),i||(B(o.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:o.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),T.ielt9||!this._map._fadeAnimated?bt(this._pruneTiles,this):setTimeout(X(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var i=new O(this._wrapX?mi(t.x,this._wrapX):t.x,this._wrapY?mi(t.y,this._wrapY):t.y);return i.z=t.z,i},_pxBoundsToTileRange:function(t){var i=this.getTileSize();return new nt(t.min.unscaleBy(i).floor(),t.max.unscaleBy(i).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});function Yu(t){return new _i(t)}var Ne=_i.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,i){this._url=t,i=Q(this,i),i.detectRetina&&T.retina&&i.maxZoom>0?(i.tileSize=Math.floor(i.tileSize/2),i.zoomReverse?(i.zoomOffset--,i.minZoom=Math.min(i.maxZoom,i.minZoom+1)):(i.zoomOffset++,i.maxZoom=Math.max(i.minZoom,i.maxZoom-1)),i.minZoom=Math.max(0,i.minZoom)):i.zoomReverse?i.minZoom=Math.min(i.maxZoom,i.minZoom):i.maxZoom=Math.max(i.minZoom,i.maxZoom),typeof i.subdomains=="string"&&(i.subdomains=i.subdomains.split("")),this.on("tileunload",this._onTileRemove)},setUrl:function(t,i){return this._url===t&&i===void 0&&(i=!0),this._url=t,i||this.redraw(),this},createTile:function(t,i){var o=document.createElement("img");return A(o,"load",X(this._tileOnLoad,this,i,o)),A(o,"error",X(this._tileOnError,this,i,o)),(this.options.crossOrigin||this.options.crossOrigin==="")&&(o.crossOrigin=this.options.crossOrigin===!0?"":this.options.crossOrigin),typeof this.options.referrerPolicy=="string"&&(o.referrerPolicy=this.options.referrerPolicy),o.alt="",o.src=this.getTileUrl(t),o},getTileUrl:function(t){var i={r:T.retina?"@2x":"",s:this._getSubdomain(t),x:t.x,y:t.y,z:this._getZoomForUrl()};if(this._map&&!this._map.options.crs.infinite){var o=this._globalTileRange.max.y-t.y;this.options.tms&&(i.y=o),i["-y"]=o}return sr(this._url,j(i,this.options))},_tileOnLoad:function(t,i){T.ielt9?setTimeout(X(t,this,null,i),0):t(null,i)},_tileOnError:function(t,i,o){var r=this.options.errorTileUrl;r&&i.getAttribute("src")!==r&&(i.src=r),t(o,i)},_onTileRemove:function(t){t.tile.onload=null},_getZoomForUrl:function(){var t=this._tileZoom,i=this.options.maxZoom,o=this.options.zoomReverse,r=this.options.zoomOffset;return o&&(t=i-t),t+r},_getSubdomain:function(t){var i=Math.abs(t.x+t.y)%this.options.subdomains.length;return this.options.subdomains[i]},_abortLoading:function(){var t,i;for(t in this._tiles)if(this._tiles[t].coords.z!==this._tileZoom&&(i=this._tiles[t].el,i.onload=st,i.onerror=st,!i.complete)){i.src=ji;var o=this._tiles[t].coords;it(i),delete this._tiles[t],this.fire("tileabort",{tile:i,coords:o})}},_removeTile:function(t){var i=this._tiles[t];if(i)return i.el.setAttribute("src",ji),_i.prototype._removeTile.call(this,t)},_tileReady:function(t,i,o){if(!(!this._map||o&&o.getAttribute("src")===ji))return _i.prototype._tileReady.call(this,t,i,o)}});function jr(t,i){return new Ne(t,i)}var Kr=Ne.extend({defaultWmsParams:{service:"WMS",request:"GetMap",layers:"",styles:"",format:"image/jpeg",transparent:!1,version:"1.1.1"},options:{crs:null,uppercase:!1},initialize:function(t,i){this._url=t;var o=j({},this.defaultWmsParams);for(var r in i)r in this.options||(o[r]=i[r]);i=Q(this,i);var h=i.detectRetina&&T.retina?2:1,l=this.getTileSize();o.width=l.x*h,o.height=l.y*h,this.wmsParams=o},onAdd:function(t){this._crs=this.options.crs||t.options.crs,this._wmsVersion=parseFloat(this.wmsParams.version);var i=this._wmsVersion>=1.3?"crs":"srs";this.wmsParams[i]=this._crs.code,Ne.prototype.onAdd.call(this,t)},getTileUrl:function(t){var i=this._tileCoordsToNwSe(t),o=this._crs,r=Lt(o.project(i[0]),o.project(i[1])),h=r.min,l=r.max,f=(this._wmsVersion>=1.3&&this._crs===Wr?[h.y,h.x,l.y,l.x]:[h.x,h.y,l.x,l.y]).join(","),m=Ne.prototype.getTileUrl.call(this,t);return m+or(this.wmsParams,m,this.options.uppercase)+(this.options.uppercase?"&BBOX=":"&bbox=")+f},setParams:function(t,i){return j(this.wmsParams,t),i||this.redraw(),this}});function Xu(t,i){return new Kr(t,i)}Ne.WMS=Kr;jr.wms=Xu;var ee=Ht.extend({options:{padding:.1},initialize:function(t){Q(this,t),V(this),this._layers=this._layers||{}},onAdd:function(){this._container||(this._initContainer(),B(this._container,"leaflet-zoom-animated")),this.getPane().appendChild(this._container),this._update(),this.on("update",this._updatePaths,this)},onRemove:function(){this.off("update",this._updatePaths,this),this._destroyContainer()},getEvents:function(){var t={viewreset:this._reset,zoom:this._onZoom,moveend:this._update,zoomend:this._onZoomEnd};return this._zoomAnimated&&(t.zoomanim=this._onAnimZoom),t},_onAnimZoom:function(t){this._updateTransform(t.center,t.zoom)},_onZoom:function(){this._updateTransform(this._map.getCenter(),this._map.getZoom())},_updateTransform:function(t,i){var o=this._map.getZoomScale(i,this._zoom),r=this._map.getSize().multiplyBy(.5+this.options.padding),h=this._map.project(this._center,i),l=r.multiplyBy(-o).add(h).subtract(this._map._getNewPixelOrigin(t,i));T.any3d?be(this._container,l,o):ct(this._container,l)},_reset:function(){this._update(),this._updateTransform(this._center,this._zoom);for(var t in this._layers)this._layers[t]._reset()},_onZoomEnd:function(){for(var t in this._layers)this._layers[t]._project()},_updatePaths:function(){for(var t in this._layers)this._layers[t]._update()},_update:function(){var t=this.options.padding,i=this._map.getSize(),o=this._map.containerPointToLayerPoint(i.multiplyBy(-t)).round();this._bounds=new nt(o,o.add(i.multiplyBy(1+t*2)).round()),this._center=this._map.getCenter(),this._zoom=this._map.getZoom()}}),Jr=ee.extend({options:{tolerance:0},getEvents:function(){var t=ee.prototype.getEvents.call(this);return t.viewprereset=this._onViewPreReset,t},_onViewPreReset:function(){this._postponeUpdatePaths=!0},onAdd:function(){ee.prototype.onAdd.call(this),this._draw()},_initContainer:function(){var t=this._container=document.createElement("canvas");A(t,"mousemove",this._onMouseMove,this),A(t,"click dblclick mousedown mouseup contextmenu",this._onClick,this),A(t,"mouseout",this._handleMouseOut,this),t._leaflet_disable_events=!0,this._ctx=t.getContext("2d")},_destroyContainer:function(){Zt(this._redrawRequest),delete this._ctx,it(this._container),Y(this._container),delete this._container},_updatePaths:function(){if(!this._postponeUpdatePaths){var t;this._redrawBounds=null;for(var i in this._layers)t=this._layers[i],t._update();this._redraw()}},_update:function(){if(!(this._map._animatingZoom&&this._bounds)){ee.prototype._update.call(this);var t=this._bounds,i=this._container,o=t.getSize(),r=T.retina?2:1;ct(i,t.min),i.width=r*o.x,i.height=r*o.y,i.style.width=o.x+"px",i.style.height=o.y+"px",T.retina&&this._ctx.scale(2,2),this._ctx.translate(-t.min.x,-t.min.y),this.fire("update")}},_reset:function(){ee.prototype._reset.call(this),this._postponeUpdatePaths&&(this._postponeUpdatePaths=!1,this._updatePaths())},_initPath:function(t){this._updateDashArray(t),this._layers[V(t)]=t;var i=t._order={layer:t,prev:this._drawLast,next:null};this._drawLast&&(this._drawLast.next=i),this._drawLast=i,this._drawFirst=this._drawFirst||this._drawLast},_addPath:function(t){this._requestRedraw(t)},_removePath:function(t){var i=t._order,o=i.next,r=i.prev;o?o.prev=r:this._drawLast=r,r?r.next=o:this._drawFirst=o,delete t._order,delete this._layers[V(t)],this._requestRedraw(t)},_updatePath:function(t){this._extendRedrawBounds(t),t._project(),t._update(),this._requestRedraw(t)},_updateStyle:function(t){this._updateDashArray(t),this._requestRedraw(t)},_updateDashArray:function(t){if(typeof t.options.dashArray=="string"){var i=t.options.dashArray.split(/[, ]+/),o=[],r,h;for(h=0;h')}}catch{}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),Qu={_initContainer:function(){this._container=U("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(ee.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var i=t._container=li("shape");B(i,"leaflet-vml-shape "+(this.options.className||"")),i.coordsize="1 1",t._path=li("path"),i.appendChild(t._path),this._updateStyle(t),this._layers[V(t)]=t},_addPath:function(t){var i=t._container;this._container.appendChild(i),t.options.interactive&&t.addInteractiveTarget(i)},_removePath:function(t){var i=t._container;it(i),t.removeInteractiveTarget(i),delete this._layers[V(t)]},_updateStyle:function(t){var i=t._stroke,o=t._fill,r=t.options,h=t._container;h.stroked=!!r.stroke,h.filled=!!r.fill,r.stroke?(i||(i=t._stroke=li("stroke")),h.appendChild(i),i.weight=r.weight+"px",i.color=r.color,i.opacity=r.opacity,r.dashArray?i.dashStyle=Dt(r.dashArray)?r.dashArray.join(" "):r.dashArray.replace(/( *, *)/g," "):i.dashStyle="",i.endcap=r.lineCap.replace("butt","flat"),i.joinstyle=r.lineJoin):i&&(h.removeChild(i),t._stroke=null),r.fill?(o||(o=t._fill=li("fill")),h.appendChild(o),o.color=r.fillColor||r.color,o.opacity=r.fillOpacity):o&&(h.removeChild(o),t._fill=null)},_updateCircle:function(t){var i=t._point.round(),o=Math.round(t._radius),r=Math.round(t._radiusY||o);this._setPath(t,t._empty()?"M0 0":"AL "+i.x+","+i.y+" "+o+","+r+" 0,"+65535*360)},_setPath:function(t,i){t._path.v=i},_bringToFront:function(t){We(t._container)},_bringToBack:function(t){Ue(t._container)}},Yi=T.vml?li:hr,Ti=ee.extend({_initContainer:function(){this._container=Yi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=Yi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){it(this._container),Y(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){if(!(this._map._animatingZoom&&this._bounds)){ee.prototype._update.call(this);var t=this._bounds,i=t.getSize(),o=this._container;(!this._svgSize||!this._svgSize.equals(i))&&(this._svgSize=i,o.setAttribute("width",i.x),o.setAttribute("height",i.y)),ct(o,t.min),o.setAttribute("viewBox",[t.min.x,t.min.y,i.x,i.y].join(" ")),this.fire("update")}},_initPath:function(t){var i=t._path=Yi("path");t.options.className&&B(i,t.options.className),t.options.interactive&&B(i,"leaflet-interactive"),this._updateStyle(t),this._layers[V(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){it(t._path),t.removeInteractiveTarget(t._path),delete this._layers[V(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var i=t._path,o=t.options;i&&(o.stroke?(i.setAttribute("stroke",o.color),i.setAttribute("stroke-opacity",o.opacity),i.setAttribute("stroke-width",o.weight),i.setAttribute("stroke-linecap",o.lineCap),i.setAttribute("stroke-linejoin",o.lineJoin),o.dashArray?i.setAttribute("stroke-dasharray",o.dashArray):i.removeAttribute("stroke-dasharray"),o.dashOffset?i.setAttribute("stroke-dashoffset",o.dashOffset):i.removeAttribute("stroke-dashoffset")):i.setAttribute("stroke","none"),o.fill?(i.setAttribute("fill",o.fillColor||o.color),i.setAttribute("fill-opacity",o.fillOpacity),i.setAttribute("fill-rule",o.fillRule||"evenodd")):i.setAttribute("fill","none"))},_updatePoly:function(t,i){this._setPath(t,ur(t._parts,i))},_updateCircle:function(t){var i=t._point,o=Math.max(Math.round(t._radius),1),r=Math.max(Math.round(t._radiusY),1)||o,h="a"+o+","+r+" 0 1,0 ",l=t._empty()?"M0 0":"M"+(i.x-o)+","+i.y+h+o*2+",0 "+h+-o*2+",0 ";this._setPath(t,l)},_setPath:function(t,i){t._path.setAttribute("d",i)},_bringToFront:function(t){We(t._path)},_bringToBack:function(t){Ue(t._path)}});T.vml&&Ti.include(Qu);function Xr(t){return T.svg||T.vml?new Ti(t):null}F.include({getRenderer:function(t){var i=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer;return i||(i=this._renderer=this._createRenderer()),this.hasLayer(i)||this.addLayer(i),i},_getPaneRenderer:function(t){if(t==="overlayPane"||t===void 0)return!1;var i=this._paneRenderers[t];return i===void 0&&(i=this._createRenderer({pane:t}),this._paneRenderers[t]=i),i},_createRenderer:function(t){return this.options.preferCanvas&&Yr(t)||Xr(t)}});var Qr=He.extend({initialize:function(t,i){He.prototype.initialize.call(this,this._boundsToLatLngs(t),i)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return t=lt(t),[t.getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});function $u(t,i){return new Qr(t,i)}Ti.create=Yi;Ti.pointsToPath=ur;re.geometryToLayer=en;re.coordsToLatLng=ko;re.coordsToLatLngs=nn;re.latLngToCoords=Eo;re.latLngsToCoords=un;re.getFeature=Fe;re.asFeature=ln;F.mergeOptions({boxZoom:!0});var $r=Jt.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){A(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){Y(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){it(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){this._resetStateTimeout!==0&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||t.which!==1&&t.button!==1)return!1;this._clearDeferredResetState(),this._resetState(),gi(),yo(),this._startPoint=this._map.mouseEventToContainerPoint(t),A(document,{contextmenu:Me,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=U("div","leaflet-zoom-box",this._container),B(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var i=new nt(this._point,this._startPoint),o=i.getSize();ct(this._box,i.min),this._box.style.width=o.x+"px",this._box.style.height=o.y+"px"},_finish:function(){this._moved&&(it(this._box),rt(this._container,"leaflet-crosshair")),vi(),wo(),Y(document,{contextmenu:Me,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){if(!(t.which!==1&&t.button!==1)&&(this._finish(),!!this._moved)){this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(X(this._resetState,this),0);var i=new Tt(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point));this._map.fitBounds(i).fire("boxzoomend",{boxZoomBounds:i})}},_onKeyDown:function(t){t.keyCode===27&&(this._finish(),this._clearDeferredResetState(),this._resetState())}});F.addInitHook("addHandler","boxZoom",$r);F.mergeOptions({doubleClickZoom:!0});var ta=Jt.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var i=this._map,o=i.getZoom(),r=i.options.zoomDelta,h=t.originalEvent.shiftKey?o-r:o+r;i.options.doubleClickZoom==="center"?i.setZoom(h):i.setZoomAround(t.containerPoint,h)}});F.addInitHook("addHandler","doubleClickZoom",ta);F.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0});var ea=Jt.extend({addHooks:function(){if(!this._draggable){var t=this._map;this._draggable=new fe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))}B(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){rt(this._map._container,"leaflet-grab"),rt(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t=this._map;if(t._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity){var i=lt(this._map.options.maxBounds);this._offsetLimit=Lt(this._map.latLngToContainerPoint(i.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(i.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))}else this._offsetLimit=null;t.fire("movestart").fire("dragstart"),t.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){if(this._map.options.inertia){var i=this._lastTime=+new Date,o=this._lastPos=this._draggable._absPos||this._draggable._newPos;this._positions.push(o),this._times.push(i),this._prunePositions(i)}this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;this._positions.length>1&&t-this._times[0]>50;)this._positions.shift(),this._times.shift()},_onZoomEnd:function(){var t=this._map.getSize().divideBy(2),i=this._map.latLngToLayerPoint([0,0]);this._initialWorldOffset=i.subtract(t).x,this._worldWidth=this._map.getPixelWorldBounds().getSize().x},_viscousLimit:function(t,i){return t-(t-i)*this._viscosity},_onPreDragLimit:function(){if(!(!this._viscosity||!this._offsetLimit)){var t=this._draggable._newPos.subtract(this._draggable._startPos),i=this._offsetLimit;t.xi.max.x&&(t.x=this._viscousLimit(t.x,i.max.x)),t.y>i.max.y&&(t.y=this._viscousLimit(t.y,i.max.y)),this._draggable._newPos=this._draggable._startPos.add(t)}},_onPreDragWrap:function(){var t=this._worldWidth,i=Math.round(t/2),o=this._initialWorldOffset,r=this._draggable._newPos.x,h=(r-i+o)%t+i-o,l=(r+i+o)%t-i-o,f=Math.abs(h+o)0?l:-l))-i;this._delta=0,this._startTime=null,f&&(t.options.scrollWheelZoom==="center"?t.setZoom(i+f):t.setZoomAround(this._lastMousePos,i+f))}});F.addInitHook("addHandler","scrollWheelZoom",na);var tl=600;F.mergeOptions({tapHold:T.touchNative&&T.safari&&T.mobile,tapTolerance:15});var oa=Jt.extend({addHooks:function(){A(this._map._container,"touchstart",this._onDown,this)},removeHooks:function(){Y(this._map._container,"touchstart",this._onDown,this)},_onDown:function(t){if(clearTimeout(this._holdTimeout),t.touches.length===1){var i=t.touches[0];this._startPos=this._newPos=new O(i.clientX,i.clientY),this._holdTimeout=setTimeout(X(function(){this._cancel(),this._isTapValid()&&(A(document,"touchend",pt),A(document,"touchend touchcancel",this._cancelClickPrevent),this._simulateEvent("contextmenu",i))},this),tl),A(document,"touchend touchcancel contextmenu",this._cancel,this),A(document,"touchmove",this._onMove,this)}},_cancelClickPrevent:function t(){Y(document,"touchend",pt),Y(document,"touchend touchcancel",t)},_cancel:function(){clearTimeout(this._holdTimeout),Y(document,"touchend touchcancel contextmenu",this._cancel,this),Y(document,"touchmove",this._onMove,this)},_onMove:function(t){var i=t.touches[0];this._newPos=new O(i.clientX,i.clientY)},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_simulateEvent:function(t,i){var o=new MouseEvent(t,{bubbles:!0,cancelable:!0,view:window,screenX:i.screenX,screenY:i.screenY,clientX:i.clientX,clientY:i.clientY});o._simulated=!0,i.target.dispatchEvent(o)}});F.addInitHook("addHandler","tapHold",oa);F.mergeOptions({touchZoom:T.touch,bounceAtZoomLimits:!0});var sa=Jt.extend({addHooks:function(){B(this._map._container,"leaflet-touch-zoom"),A(this._map._container,"touchstart",this._onTouchStart,this)},removeHooks:function(){rt(this._map._container,"leaflet-touch-zoom"),Y(this._map._container,"touchstart",this._onTouchStart,this)},_onTouchStart:function(t){var i=this._map;if(!(!t.touches||t.touches.length!==2||i._animatingZoom||this._zooming)){var o=i.mouseEventToContainerPoint(t.touches[0]),r=i.mouseEventToContainerPoint(t.touches[1]);this._centerPoint=i.getSize()._divideBy(2),this._startLatLng=i.containerPointToLatLng(this._centerPoint),i.options.touchZoom!=="center"&&(this._pinchStartLatLng=i.containerPointToLatLng(o.add(r)._divideBy(2))),this._startDist=o.distanceTo(r),this._startZoom=i.getZoom(),this._moved=!1,this._zooming=!0,i._stop(),A(document,"touchmove",this._onTouchMove,this),A(document,"touchend touchcancel",this._onTouchEnd,this),pt(t)}},_onTouchMove:function(t){if(!(!t.touches||t.touches.length!==2||!this._zooming)){var i=this._map,o=i.mouseEventToContainerPoint(t.touches[0]),r=i.mouseEventToContainerPoint(t.touches[1]),h=o.distanceTo(r)/this._startDist;if(this._zoom=i.getScaleZoom(h,this._startZoom),!i.options.bounceAtZoomLimits&&(this._zoomi.getMaxZoom()&&h>1)&&(this._zoom=i._limitZoom(this._zoom)),i.options.touchZoom==="center"){if(this._center=this._startLatLng,h===1)return}else{var l=o._add(r)._divideBy(2)._subtract(this._centerPoint);if(h===1&&l.x===0&&l.y===0)return;this._center=i.unproject(i.project(this._pinchStartLatLng,this._zoom).subtract(l),this._zoom)}this._moved||(i._moveStart(!0,!1),this._moved=!0),Zt(this._animRequest);var f=X(i._move,i,this._center,this._zoom,{pinch:!0,round:!1},void 0);this._animRequest=bt(f,this,!0),pt(t)}},_onTouchEnd:function(){if(!this._moved||!this._zooming){this._zooming=!1;return}this._zooming=!1,Zt(this._animRequest),Y(document,"touchmove",this._onTouchMove,this),Y(document,"touchend touchcancel",this._onTouchEnd,this),this._map.options.zoomAnimation?this._map._animateZoom(this._center,this._map._limitZoom(this._zoom),!0,this._map.options.zoomSnap):this._map._resetView(this._center,this._map._limitZoom(this._zoom))}});F.addInitHook("addHandler","touchZoom",sa);F.BoxZoom=$r;F.DoubleClickZoom=ta;F.Drag=ea;F.Keyboard=ia;F.ScrollWheelZoom=na;F.TapHold=oa;F.TouchZoom=sa;const hl=Object.freeze(Object.defineProperty({__proto__:null,Bounds:nt,Browser:T,CRS:se,Canvas:Jr,Circle:zo,CircleMarker:hn,Class:oe,Control:Rt,DivIcon:Gr,DivOverlay:jt,DomEvent:vu,DomUtil:pu,Draggable:fe,Evented:yi,FeatureGroup:ne,GeoJSON:re,GridLayer:_i,Handler:Jt,Icon:De,ImageOverlay:cn,LatLng:G,LatLngBounds:Tt,Layer:Ht,LayerGroup:Ie,LineUtil:Eu,Map:F,Marker:an,Mixin:Tu,Path:_e,Point:O,PolyUtil:Mu,Polygon:He,Polyline:ie,Popup:fn,PosAnimation:Or,Projection:Ou,Rectangle:Qr,Renderer:ee,SVG:Ti,SVGOverlay:qr,TileLayer:Ne,Tooltip:dn,Transformation:ho,Util:Rh,VideoOverlay:Vr,bind:X,bounds:Lt,canvas:Yr,circle:Hu,circleMarker:Du,control:Li,divIcon:Ju,extend:j,featureGroup:Iu,geoJSON:Ur,geoJson:Uu,gridLayer:Yu,icon:Nu,imageOverlay:Vu,latLng:H,latLngBounds:lt,layerGroup:Bu,map:yu,marker:Ru,point:z,polygon:Wu,polyline:Fu,popup:ju,rectangle:$u,setOptions:Q,stamp:V,svg:Xr,svgOverlay:Gu,tileLayer:jr,tooltip:Ku,transformation:wi,version:Ih,videoOverlay:qu},Symbol.toStringTag,{value:"Module"}));var ci={exports:{}};/* @preserve * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade - */var el=ci.exports,er;function il(){return er||(er=1,function(t,i){(function(o,r){r(i)})(el,function(o){var r="1.9.4";function h(e){var n,s,a,u;for(s=1,a=arguments.length;s"u"||!L||!L.Mixin)){e=Mt(e)?e:[e];for(var n=0;n0?Math.floor(e):Math.ceil(e)};k.prototype={clone:function(){return new k(this.x,this.y)},add:function(e){return this.clone()._add(C(e))},_add:function(e){return this.x+=e.x,this.y+=e.y,this},subtract:function(e){return this.clone()._subtract(C(e))},_subtract:function(e){return this.x-=e.x,this.y-=e.y,this},divideBy:function(e){return this.clone()._divideBy(e)},_divideBy:function(e){return this.x/=e,this.y/=e,this},multiplyBy:function(e){return this.clone()._multiplyBy(e)},_multiplyBy:function(e){return this.x*=e,this.y*=e,this},scaleBy:function(e){return new k(this.x*e.x,this.y*e.y)},unscaleBy:function(e){return new k(this.x/e.x,this.y/e.y)},round:function(){return this.clone()._round()},_round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this},floor:function(){return this.clone()._floor()},_floor:function(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this},ceil:function(){return this.clone()._ceil()},_ceil:function(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this},trunc:function(){return this.clone()._trunc()},_trunc:function(){return this.x=Ao(this.x),this.y=Ao(this.y),this},distanceTo:function(e){e=C(e);var n=e.x-this.x,s=e.y-this.y;return Math.sqrt(n*n+s*s)},equals:function(e){return e=C(e),e.x===this.x&&e.y===this.y},contains:function(e){return e=C(e),Math.abs(e.x)<=Math.abs(this.x)&&Math.abs(e.y)<=Math.abs(this.y)},toString:function(){return"Point("+M(this.x)+", "+M(this.y)+")"}};function C(e,n,s){return e instanceof k?e:Mt(e)?new k(e[0],e[1]):e==null?e:typeof e=="object"&&"x"in e&&"y"in e?new k(e.x,e.y):new k(e,n,s)}function tt(e,n){if(e)for(var s=n?[e,n]:e,a=0,u=s.length;a=this.min.x&&s.x<=this.max.x&&n.y>=this.min.y&&s.y<=this.max.y},intersects:function(e){e=wt(e);var n=this.min,s=this.max,a=e.min,u=e.max,c=u.x>=n.x&&a.x<=s.x,d=u.y>=n.y&&a.y<=s.y;return c&&d},overlaps:function(e){e=wt(e);var n=this.min,s=this.max,a=e.min,u=e.max,c=u.x>n.x&&a.xn.y&&a.y=n.lat&&u.lat<=s.lat&&a.lng>=n.lng&&u.lng<=s.lng},intersects:function(e){e=at(e);var n=this._southWest,s=this._northEast,a=e.getSouthWest(),u=e.getNorthEast(),c=u.lat>=n.lat&&a.lat<=s.lat,d=u.lng>=n.lng&&a.lng<=s.lng;return c&&d},overlaps:function(e){e=at(e);var n=this._southWest,s=this._northEast,a=e.getSouthWest(),u=e.getNorthEast(),c=u.lat>n.lat&&a.latn.lng&&a.lng1,wa=function(){var e=!1;try{var n=Object.defineProperty({},"passive",{get:function(){e=!0}});window.addEventListener("testPassiveEventSupport",x,n),window.removeEventListener("testPassiveEventSupport",x,n)}catch{}return e}(),xa=function(){return!!document.createElement("canvas").getContext}(),Pn=!!(document.createElementNS&&Bo("svg").createSVGRect),Pa=!!Pn&&function(){var e=document.createElement("div");return e.innerHTML="",(e.firstChild&&e.firstChild.namespaceURI)==="http://www.w3.org/2000/svg"}(),La=!Pn&&function(){try{var e=document.createElement("div");e.innerHTML='';var n=e.firstChild;return n.style.behavior="url(#default#VML)",n&&typeof n.adj=="object"}catch{return!1}}(),ba=navigator.platform.indexOf("Mac")===0,Ta=navigator.platform.indexOf("Linux")===0;function Ft(e){return navigator.userAgent.toLowerCase().indexOf(e)>=0}var b={ie:Ci,ielt9:ha,edge:No,webkit:yn,android:Ro,android23:Do,androidStock:la,opera:wn,chrome:Ho,gecko:Fo,safari:ca,phantom:Wo,opera12:Uo,win:fa,ie3d:Vo,webkit3d:xn,gecko3d:qo,any3d:da,mobile:Ye,mobileWebkit:_a,mobileWebkit3d:ma,msPointer:Go,pointer:jo,touch:pa,touchNative:Ko,mobileOpera:ga,mobileGecko:va,retina:ya,passiveEvents:wa,canvas:xa,svg:Pn,vml:La,inlineSvg:Pa,mac:ba,linux:Ta},Jo=b.msPointer?"MSPointerDown":"pointerdown",Yo=b.msPointer?"MSPointerMove":"pointermove",Xo=b.msPointer?"MSPointerUp":"pointerup",Qo=b.msPointer?"MSPointerCancel":"pointercancel",Ln={touchstart:Jo,touchmove:Yo,touchend:Xo,touchcancel:Qo},$o={touchstart:Ea,touchmove:zi,touchend:zi,touchcancel:zi},Se={},ts=!1;function Ma(e,n,s){return n==="touchstart"&&ka(),$o[n]?(s=$o[n].bind(this,s),e.addEventListener(Ln[n],s,!1),s):(console.warn("wrong event specified:",n),x)}function Sa(e,n,s){if(!Ln[n]){console.warn("wrong event specified:",n);return}e.removeEventListener(Ln[n],s,!1)}function Ca(e){Se[e.pointerId]=e}function za(e){Se[e.pointerId]&&(Se[e.pointerId]=e)}function es(e){delete Se[e.pointerId]}function ka(){ts||(document.addEventListener(Jo,Ca,!0),document.addEventListener(Yo,za,!0),document.addEventListener(Xo,es,!0),document.addEventListener(Qo,es,!0),ts=!0)}function zi(e,n){if(n.pointerType!==(n.MSPOINTER_TYPE_MOUSE||"mouse")){n.touches=[];for(var s in Se)n.touches.push(Se[s]);n.changedTouches=[n],e(n)}}function Ea(e,n){n.MSPOINTER_TYPE_TOUCH&&n.pointerType===n.MSPOINTER_TYPE_TOUCH&&mt(n),zi(e,n)}function Oa(e){var n={},s,a;for(a in e)s=e[a],n[a]=s&&s.bind?s.bind(e):s;return e=n,n.type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}var Aa=200;function Za(e,n){e.addEventListener("dblclick",n);var s=0,a;function u(c){if(c.detail!==1){a=c.detail;return}if(!(c.pointerType==="mouse"||c.sourceCapabilities&&!c.sourceCapabilities.firesTouchEvents)){var d=rs(c);if(!(d.some(function(g){return g instanceof HTMLLabelElement&&g.attributes.for})&&!d.some(function(g){return g instanceof HTMLInputElement||g instanceof HTMLSelectElement}))){var p=Date.now();p-s<=Aa?(a++,a===2&&n(Oa(c))):a=1,s=p}}}return e.addEventListener("click",u),{dblclick:n,simDblclick:u}}function Ba(e,n){e.removeEventListener("dblclick",n.dblclick),e.removeEventListener("click",n.simDblclick)}var bn=Oi(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),Xe=Oi(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),is=Xe==="webkitTransition"||Xe==="OTransition"?Xe+"End":"transitionend";function ns(e){return typeof e=="string"?document.getElementById(e):e}function Qe(e,n){var s=e.style[n]||e.currentStyle&&e.currentStyle[n];if((!s||s==="auto")&&document.defaultView){var a=document.defaultView.getComputedStyle(e,null);s=a?a[n]:null}return s==="auto"?null:s}function W(e,n,s){var a=document.createElement(e);return a.className=n||"",s&&s.appendChild(a),a}function et(e){var n=e.parentNode;n&&n.removeChild(e)}function ki(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function Ce(e){var n=e.parentNode;n&&n.lastChild!==e&&n.appendChild(e)}function ze(e){var n=e.parentNode;n&&n.firstChild!==e&&n.insertBefore(e,n.firstChild)}function Tn(e,n){if(e.classList!==void 0)return e.classList.contains(n);var s=Ei(e);return s.length>0&&new RegExp("(^|\\s)"+n+"(\\s|$)").test(s)}function Z(e,n){if(e.classList!==void 0)for(var s=K(n),a=0,u=s.length;a0?2*window.devicePixelRatio:1;function hs(e){return b.edge?e.wheelDeltaY/2:e.deltaY&&e.deltaMode===0?-e.deltaY/Ra:e.deltaY&&e.deltaMode===1?-e.deltaY*20:e.deltaY&&e.deltaMode===2?-e.deltaY*60:e.deltaX||e.deltaZ?0:e.wheelDelta?(e.wheelDeltaY||e.wheelDelta)/2:e.detail&&Math.abs(e.detail)<32765?-e.detail*20:e.detail?e.detail/-32765*60:0}function Nn(e,n){var s=n.relatedTarget;if(!s)return!0;try{for(;s&&s!==e;)s=s.parentNode}catch{return!1}return s!==e}var Da={__proto__:null,on:E,off:J,stopPropagation:ve,disableScrollPropagation:In,disableClickPropagation:ii,preventDefault:mt,stop:ye,getPropagationPath:rs,getMousePosition:as,getWheelDelta:hs,isExternalTarget:Nn,addListener:E,removeListener:J},us=Ke.extend({run:function(e,n,s,a){this.stop(),this._el=e,this._inProgress=!0,this._duration=s||.25,this._easeOutPower=1/Math.max(a||.5,.2),this._startPos=ge(e),this._offset=n.subtract(this._startPos),this._startTime=+new Date,this.fire("start"),this._animate()},stop:function(){this._inProgress&&(this._step(!0),this._complete())},_animate:function(){this._animId=gt(this._animate,this),this._step()},_step:function(e){var n=+new Date-this._startTime,s=this._duration*1e3;nthis.options.maxZoom)?this.setZoom(e):this},panInsideBounds:function(e,n){this._enforcingBounds=!0;var s=this.getCenter(),a=this._limitCenter(s,this._zoom,at(e));return s.equals(a)||this.panTo(a,n),this._enforcingBounds=!1,this},panInside:function(e,n){n=n||{};var s=C(n.paddingTopLeft||n.padding||[0,0]),a=C(n.paddingBottomRight||n.padding||[0,0]),u=this.project(this.getCenter()),c=this.project(e),d=this.getPixelBounds(),p=wt([d.min.add(s),d.max.subtract(a)]),g=p.getSize();if(!p.contains(c)){this._enforcingBounds=!0;var y=c.subtract(p.getCenter()),P=p.extend(c).getSize().subtract(g);u.x+=y.x<0?-P.x:P.x,u.y+=y.y<0?-P.y:P.y,this.panTo(this.unproject(u),n),this._enforcingBounds=!1}return this},invalidateSize:function(e){if(!this._loaded)return this;e=h({animate:!1,pan:!0},e===!0?{animate:!0}:e);var n=this.getSize();this._sizeChanged=!0,this._lastCenter=null;var s=this.getSize(),a=n.divideBy(2).round(),u=s.divideBy(2).round(),c=a.subtract(u);return!c.x&&!c.y?this:(e.animate&&e.pan?this.panBy(c):(e.pan&&this._rawPanBy(c),this.fire("move"),e.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(f(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:n,newSize:s}))},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(e){if(e=this._locateOptions=h({timeout:1e4,watch:!1},e),!("geolocation"in navigator))return this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this;var n=f(this._handleGeolocationResponse,this),s=f(this._handleGeolocationError,this);return e.watch?this._locationWatchId=navigator.geolocation.watchPosition(n,s,e):navigator.geolocation.getCurrentPosition(n,s,e),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(e){if(this._container._leaflet_id){var n=e.code,s=e.message||(n===1?"permission denied":n===2?"position unavailable":"timeout");this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:n,message:"Geolocation error: "+s+"."})}},_handleGeolocationResponse:function(e){if(this._container._leaflet_id){var n=e.coords.latitude,s=e.coords.longitude,a=new q(n,s),u=a.toBounds(e.coords.accuracy*2),c=this._locateOptions;if(c.setView){var d=this.getBoundsZoom(u);this.setView(a,c.maxZoom?Math.min(d,c.maxZoom):d)}var p={latlng:a,bounds:u,timestamp:e.timestamp};for(var g in e.coords)typeof e.coords[g]=="number"&&(p[g]=e.coords[g]);this.fire("locationfound",p)}},addHandler:function(e,n){if(!n)return this;var s=this[e]=new n(this);return this._handlers.push(s),this.options[e]&&s.enable(),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch{this._container._leaflet_id=void 0,this._containerId=void 0}this._locationWatchId!==void 0&&this.stopLocate(),this._stop(),et(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(yt(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload");var e;for(e in this._layers)this._layers[e].remove();for(e in this._panes)et(this._panes[e]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(e,n){var s="leaflet-pane"+(e?" leaflet-"+e.replace("Pane","")+"-pane":""),a=W("div",s,n||this._mapPane);return e&&(this._panes[e]=a),a},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var e=this.getPixelBounds(),n=this.unproject(e.getBottomLeft()),s=this.unproject(e.getTopRight());return new xt(n,s)},getMinZoom:function(){return this.options.minZoom===void 0?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return this.options.maxZoom===void 0?this._layersMaxZoom===void 0?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(e,n,s){e=at(e),s=C(s||[0,0]);var a=this.getZoom()||0,u=this.getMinZoom(),c=this.getMaxZoom(),d=e.getNorthWest(),p=e.getSouthEast(),g=this.getSize().subtract(s),y=wt(this.project(p,a),this.project(d,a)).getSize(),P=b.any3d?this.options.zoomSnap:1,S=g.x/y.x,I=g.y/y.y,vt=n?Math.max(S,I):Math.min(S,I);return a=this.getScaleZoom(vt,a),P&&(a=Math.round(a/(P/100))*(P/100),a=n?Math.ceil(a/P)*P:Math.floor(a/P)*P),Math.max(u,Math.min(c,a))},getSize:function(){return(!this._size||this._sizeChanged)&&(this._size=new k(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(e,n){var s=this._getTopLeftPoint(e,n);return new tt(s,s.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(e){return this.options.crs.getProjectedBounds(e===void 0?this.getZoom():e)},getPane:function(e){return typeof e=="string"?this._panes[e]:e},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(e,n){var s=this.options.crs;return n=n===void 0?this._zoom:n,s.scale(e)/s.scale(n)},getScaleZoom:function(e,n){var s=this.options.crs;n=n===void 0?this._zoom:n;var a=s.zoom(e*s.scale(n));return isNaN(a)?1/0:a},project:function(e,n){return n=n===void 0?this._zoom:n,this.options.crs.latLngToPoint(R(e),n)},unproject:function(e,n){return n=n===void 0?this._zoom:n,this.options.crs.pointToLatLng(C(e),n)},layerPointToLatLng:function(e){var n=C(e).add(this.getPixelOrigin());return this.unproject(n)},latLngToLayerPoint:function(e){var n=this.project(R(e))._round();return n._subtract(this.getPixelOrigin())},wrapLatLng:function(e){return this.options.crs.wrapLatLng(R(e))},wrapLatLngBounds:function(e){return this.options.crs.wrapLatLngBounds(at(e))},distance:function(e,n){return this.options.crs.distance(R(e),R(n))},containerPointToLayerPoint:function(e){return C(e).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(e){return C(e).add(this._getMapPanePos())},containerPointToLatLng:function(e){var n=this.containerPointToLayerPoint(C(e));return this.layerPointToLatLng(n)},latLngToContainerPoint:function(e){return this.layerPointToContainerPoint(this.latLngToLayerPoint(R(e)))},mouseEventToContainerPoint:function(e){return as(e,this._container)},mouseEventToLayerPoint:function(e){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(e))},mouseEventToLatLng:function(e){return this.layerPointToLatLng(this.mouseEventToLayerPoint(e))},_initContainer:function(e){var n=this._container=ns(e);if(n){if(n._leaflet_id)throw new Error("Map container is already initialized.")}else throw new Error("Map container not found.");E(n,"scroll",this._onScroll,this),this._containerId=_(n)},_initLayout:function(){var e=this._container;this._fadeAnimated=this.options.fadeAnimation&&b.any3d,Z(e,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":""));var n=Qe(e,"position");n!=="absolute"&&n!=="relative"&&n!=="fixed"&&n!=="sticky"&&(e.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var e=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),ht(this._mapPane,new k(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(Z(e.markerPane,"leaflet-zoom-hide"),Z(e.shadowPane,"leaflet-zoom-hide"))},_resetView:function(e,n,s){ht(this._mapPane,new k(0,0));var a=!this._loaded;this._loaded=!0,n=this._limitZoom(n),this.fire("viewprereset");var u=this._zoom!==n;this._moveStart(u,s)._move(e,n)._moveEnd(u),this.fire("viewreset"),a&&this.fire("load")},_moveStart:function(e,n){return e&&this.fire("zoomstart"),n||this.fire("movestart"),this},_move:function(e,n,s,a){n===void 0&&(n=this._zoom);var u=this._zoom!==n;return this._zoom=n,this._lastCenter=e,this._pixelOrigin=this._getNewPixelOrigin(e),a?s&&s.pinch&&this.fire("zoom",s):((u||s&&s.pinch)&&this.fire("zoom",s),this.fire("move",s)),this},_moveEnd:function(e){return e&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return yt(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(e){ht(this._mapPane,this._getMapPanePos().subtract(e))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(e){this._targets={},this._targets[_(this._container)]=this;var n=e?J:E;n(this._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&n(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(e?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){yt(this._resizeRequest),this._resizeRequest=gt(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var e=this._getMapPanePos();Math.max(Math.abs(e.x),Math.abs(e.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(e,n){for(var s=[],a,u=n==="mouseout"||n==="mouseover",c=e.target||e.srcElement,d=!1;c;){if(a=this._targets[_(c)],a&&(n==="click"||n==="preclick")&&this._draggableMoved(a)){d=!0;break}if(a&&a.listens(n,!0)&&(u&&!Nn(c,e)||(s.push(a),u))||c===this._container)break;c=c.parentNode}return!s.length&&!d&&!u&&this.listens(n,!0)&&(s=[this]),s},_isClickDisabled:function(e){for(;e&&e!==this._container;){if(e._leaflet_disable_click)return!0;e=e.parentNode}},_handleDOMEvent:function(e){var n=e.target||e.srcElement;if(!(!this._loaded||n._leaflet_disable_events||e.type==="click"&&this._isClickDisabled(n))){var s=e.type;s==="mousedown"&&En(n),this._fireDOMEvent(e,s)}},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(e,n,s){if(e.type==="click"){var a=h({},e);a.type="preclick",this._fireDOMEvent(a,a.type,s)}var u=this._findEventTargets(e,n);if(s){for(var c=[],d=0;d0?Math.round(e-n)/2:Math.max(0,Math.ceil(e))-Math.max(0,Math.floor(n))},_limitZoom:function(e){var n=this.getMinZoom(),s=this.getMaxZoom(),a=b.any3d?this.options.zoomSnap:1;return a&&(e=Math.round(e/a)*a),Math.max(n,Math.min(s,e))},_onPanTransitionStep:function(){this.fire("move")},_onPanTransitionEnd:function(){ot(this._mapPane,"leaflet-pan-anim"),this.fire("moveend")},_tryAnimatedPan:function(e,n){var s=this._getCenterOffset(e)._trunc();return(n&&n.animate)!==!0&&!this.getSize().contains(s)?!1:(this.panBy(s,n),!0)},_createAnimProxy:function(){var e=this._proxy=W("div","leaflet-proxy leaflet-zoom-animated");this._panes.mapPane.appendChild(e),this.on("zoomanim",function(n){var s=bn,a=this._proxy.style[s];pe(this._proxy,this.project(n.center,n.zoom),this.getZoomScale(n.zoom,1)),a===this._proxy.style[s]&&this._animatingZoom&&this._onZoomTransitionEnd()},this),this.on("load moveend",this._animMoveEnd,this),this._on("unload",this._destroyAnimProxy,this)},_destroyAnimProxy:function(){et(this._proxy),this.off("load moveend",this._animMoveEnd,this),delete this._proxy},_animMoveEnd:function(){var e=this.getCenter(),n=this.getZoom();pe(this._proxy,this.project(e,n),this.getZoomScale(n,1))},_catchTransitionEnd:function(e){this._animatingZoom&&e.propertyName.indexOf("transform")>=0&&this._onZoomTransitionEnd()},_nothingToAnimate:function(){return!this._container.getElementsByClassName("leaflet-zoom-animated").length},_tryAnimatedZoom:function(e,n,s){if(this._animatingZoom)return!0;if(s=s||{},!this._zoomAnimated||s.animate===!1||this._nothingToAnimate()||Math.abs(n-this._zoom)>this.options.zoomAnimationThreshold)return!1;var a=this.getZoomScale(n),u=this._getCenterOffset(e)._divideBy(1-1/a);return s.animate!==!0&&!this.getSize().contains(u)?!1:(gt(function(){this._moveStart(!0,s.noMoveStart||!1)._animateZoom(e,n,!0)},this),!0)},_animateZoom:function(e,n,s,a){this._mapPane&&(s&&(this._animatingZoom=!0,this._animateToCenter=e,this._animateToZoom=n,Z(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:e,zoom:n,noUpdate:a}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(f(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&ot(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ha(e,n){return new D(e,n)}var It=zt.extend({options:{position:"topright"},initialize:function(e){N(this,e)},getPosition:function(){return this.options.position},setPosition:function(e){var n=this._map;return n&&n.removeControl(this),this.options.position=e,n&&n.addControl(this),this},getContainer:function(){return this._container},addTo:function(e){this.remove(),this._map=e;var n=this._container=this.onAdd(e),s=this.getPosition(),a=e._controlCorners[s];return Z(n,"leaflet-control"),s.indexOf("bottom")!==-1?a.insertBefore(n,a.firstChild):a.appendChild(n),this._map.on("unload",this.remove,this),this},remove:function(){return this._map?(et(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null,this):this},_refocusOnMap:function(e){this._map&&e&&e.screenX>0&&e.screenY>0&&this._map.getContainer().focus()}}),ni=function(e){return new It(e)};D.include({addControl:function(e){return e.addTo(this),this},removeControl:function(e){return e.remove(),this},_initControlPos:function(){var e=this._controlCorners={},n="leaflet-",s=this._controlContainer=W("div",n+"control-container",this._container);function a(u,c){var d=n+u+" "+n+c;e[u+c]=W("div",d,s)}a("top","left"),a("top","right"),a("bottom","left"),a("bottom","right")},_clearControlPos:function(){for(var e in this._controlCorners)et(this._controlCorners[e]);et(this._controlContainer),delete this._controlCorners,delete this._controlContainer}});var ls=It.extend({options:{collapsed:!0,position:"topright",autoZIndex:!0,hideSingleBase:!1,sortLayers:!1,sortFunction:function(e,n,s,a){return s1,this._baseLayersList.style.display=e?"":"none"),this._separator.style.display=n&&e?"":"none",this},_onLayerChange:function(e){this._handlingClick||this._update();var n=this._getLayer(_(e.target)),s=n.overlay?e.type==="add"?"overlayadd":"overlayremove":e.type==="add"?"baselayerchange":null;s&&this._map.fire(s,n)},_createRadioElement:function(e,n){var s='",a=document.createElement("div");return a.innerHTML=s,a.firstChild},_addItem:function(e){var n=document.createElement("label"),s=this._map.hasLayer(e.layer),a;e.overlay?(a=document.createElement("input"),a.type="checkbox",a.className="leaflet-control-layers-selector",a.defaultChecked=s):a=this._createRadioElement("leaflet-base-layers_"+_(this),s),this._layerControlInputs.push(a),a.layerId=_(e.layer),E(a,"click",this._onInputClick,this);var u=document.createElement("span");u.innerHTML=" "+e.name;var c=document.createElement("span");n.appendChild(c),c.appendChild(a),c.appendChild(u);var d=e.overlay?this._overlaysList:this._baseLayersList;return d.appendChild(n),this._checkDisabledLayers(),n},_onInputClick:function(){if(!this._preventClick){var e=this._layerControlInputs,n,s,a=[],u=[];this._handlingClick=!0;for(var c=e.length-1;c>=0;c--)n=e[c],s=this._getLayer(n.layerId).layer,n.checked?a.push(s):n.checked||u.push(s);for(c=0;c=0;u--)n=e[u],s=this._getLayer(n.layerId).layer,n.disabled=s.options.minZoom!==void 0&&as.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var e=this._section;this._preventClick=!0,E(e,"click",mt),this.expand();var n=this;setTimeout(function(){J(e,"click",mt),n._preventClick=!1})}}),Fa=function(e,n,s){return new ls(e,n,s)},Rn=It.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(e){var n="leaflet-control-zoom",s=W("div",n+" leaflet-bar"),a=this.options;return this._zoomInButton=this._createButton(a.zoomInText,a.zoomInTitle,n+"-in",s,this._zoomIn),this._zoomOutButton=this._createButton(a.zoomOutText,a.zoomOutTitle,n+"-out",s,this._zoomOut),this._updateDisabled(),e.on("zoomend zoomlevelschange",this._updateDisabled,this),s},onRemove:function(e){e.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(e){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(e.shiftKey?3:1))},_createButton:function(e,n,s,a,u){var c=W("a",s,a);return c.innerHTML=e,c.href="#",c.title=n,c.setAttribute("role","button"),c.setAttribute("aria-label",n),ii(c),E(c,"click",ye),E(c,"click",u,this),E(c,"click",this._refocusOnMap,this),c},_updateDisabled:function(){var e=this._map,n="leaflet-disabled";ot(this._zoomInButton,n),ot(this._zoomOutButton,n),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),(this._disabled||e._zoom===e.getMinZoom())&&(Z(this._zoomOutButton,n),this._zoomOutButton.setAttribute("aria-disabled","true")),(this._disabled||e._zoom===e.getMaxZoom())&&(Z(this._zoomInButton,n),this._zoomInButton.setAttribute("aria-disabled","true"))}});D.mergeOptions({zoomControl:!0}),D.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new Rn,this.addControl(this.zoomControl))});var Wa=function(e){return new Rn(e)},cs=It.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(e){var n="leaflet-control-scale",s=W("div",n),a=this.options;return this._addScales(a,n+"-line",s),e.on(a.updateWhenIdle?"moveend":"move",this._update,this),e.whenReady(this._update,this),s},onRemove:function(e){e.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(e,n,s){e.metric&&(this._mScale=W("div",n,s)),e.imperial&&(this._iScale=W("div",n,s))},_update:function(){var e=this._map,n=e.getSize().y/2,s=e.distance(e.containerPointToLatLng([0,n]),e.containerPointToLatLng([this.options.maxWidth,n]));this._updateScales(s)},_updateScales:function(e){this.options.metric&&e&&this._updateMetric(e),this.options.imperial&&e&&this._updateImperial(e)},_updateMetric:function(e){var n=this._getRoundNum(e),s=n<1e3?n+" m":n/1e3+" km";this._updateScale(this._mScale,s,n/e)},_updateImperial:function(e){var n=e*3.2808399,s,a,u;n>5280?(s=n/5280,a=this._getRoundNum(s),this._updateScale(this._iScale,a+" mi",a/s)):(u=this._getRoundNum(n),this._updateScale(this._iScale,u+" ft",u/n))},_updateScale:function(e,n,s){e.style.width=Math.round(this.options.maxWidth*s)+"px",e.innerHTML=n},_getRoundNum:function(e){var n=Math.pow(10,(Math.floor(e)+"").length-1),s=e/n;return s=s>=10?10:s>=5?5:s>=3?3:s>=2?2:1,n*s}}),Ua=function(e){return new cs(e)},Va='',Dn=It.extend({options:{position:"bottomright",prefix:''+(b.inlineSvg?Va+" ":"")+"Leaflet"},initialize:function(e){N(this,e),this._attributions={}},onAdd:function(e){e.attributionControl=this,this._container=W("div","leaflet-control-attribution"),ii(this._container);for(var n in e._layers)e._layers[n].getAttribution&&this.addAttribution(e._layers[n].getAttribution());return this._update(),e.on("layeradd",this._addAttribution,this),this._container},onRemove:function(e){e.off("layeradd",this._addAttribution,this)},_addAttribution:function(e){e.layer.getAttribution&&(this.addAttribution(e.layer.getAttribution()),e.layer.once("remove",function(){this.removeAttribution(e.layer.getAttribution())},this))},setPrefix:function(e){return this.options.prefix=e,this._update(),this},addAttribution:function(e){return e?(this._attributions[e]||(this._attributions[e]=0),this._attributions[e]++,this._update(),this):this},removeAttribution:function(e){return e?(this._attributions[e]&&(this._attributions[e]--,this._update()),this):this},_update:function(){if(this._map){var e=[];for(var n in this._attributions)this._attributions[n]&&e.push(n);var s=[];this.options.prefix&&s.push(this.options.prefix),e.length&&s.push(e.join(", ")),this._container.innerHTML=s.join(' ')}}});D.mergeOptions({attributionControl:!0}),D.addInitHook(function(){this.options.attributionControl&&new Dn().addTo(this)});var qa=function(e){return new Dn(e)};It.Layers=ls,It.Zoom=Rn,It.Scale=cs,It.Attribution=Dn,ni.layers=Fa,ni.zoom=Wa,ni.scale=Ua,ni.attribution=qa;var Ut=zt.extend({initialize:function(e){this._map=e},enable:function(){return this._enabled?this:(this._enabled=!0,this.addHooks(),this)},disable:function(){return this._enabled?(this._enabled=!1,this.removeHooks(),this):this},enabled:function(){return!!this._enabled}});Ut.addTo=function(e,n){return e.addHandler(n,this),this};var Ga={Events:St},fs=b.touch?"touchstart mousedown":"mousedown",le=Ke.extend({options:{clickTolerance:3},initialize:function(e,n,s,a){N(this,a),this._element=e,this._dragStartTarget=n||e,this._preventOutline=s},enable:function(){this._enabled||(E(this._dragStartTarget,fs,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(le._dragging===this&&this.finishDrag(!0),J(this._dragStartTarget,fs,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(e){if(this._enabled&&(this._moved=!1,!Tn(this._element,"leaflet-zoom-anim"))){if(e.touches&&e.touches.length!==1){le._dragging===this&&this.finishDrag();return}if(!(le._dragging||e.shiftKey||e.which!==1&&e.button!==1&&!e.touches)&&(le._dragging=this,this._preventOutline&&En(this._element),Cn(),$e(),!this._moving)){this.fire("down");var n=e.touches?e.touches[0]:e,s=os(this._element);this._startPoint=new k(n.clientX,n.clientY),this._startPos=ge(this._element),this._parentScale=On(s);var a=e.type==="mousedown";E(document,a?"mousemove":"touchmove",this._onMove,this),E(document,a?"mouseup":"touchend touchcancel",this._onUp,this)}}},_onMove:function(e){if(this._enabled){if(e.touches&&e.touches.length>1){this._moved=!0;return}var n=e.touches&&e.touches.length===1?e.touches[0]:e,s=new k(n.clientX,n.clientY)._subtract(this._startPoint);!s.x&&!s.y||Math.abs(s.x)+Math.abs(s.y)c&&(d=p,c=g);c>s&&(n[d]=1,Fn(e,n,s,a,d),Fn(e,n,s,d,u))}function Ya(e,n){for(var s=[e[0]],a=1,u=0,c=e.length;an&&(s.push(e[a]),u=a);return un.max.x&&(s|=2),e.yn.max.y&&(s|=8),s}function Xa(e,n){var s=n.x-e.x,a=n.y-e.y;return s*s+a*a}function oi(e,n,s,a){var u=n.x,c=n.y,d=s.x-u,p=s.y-c,g=d*d+p*p,y;return g>0&&(y=((e.x-u)*d+(e.y-c)*p)/g,y>1?(u=s.x,c=s.y):y>0&&(u+=d*y,c+=p*y)),d=e.x-u,p=e.y-c,a?d*d+p*p:new k(u,c)}function Et(e){return!Mt(e[0])||typeof e[0][0]!="object"&&typeof e[0][0]<"u"}function ys(e){return console.warn("Deprecated use of _flat, please use L.LineUtil.isFlat instead."),Et(e)}function ws(e,n){var s,a,u,c,d,p,g,y;if(!e||e.length===0)throw new Error("latlngs not passed");Et(e)||(console.warn("latlngs are not flat! Only the first ring will be used"),e=e[0]);var P=R([0,0]),S=at(e),I=S.getNorthWest().distanceTo(S.getSouthWest())*S.getNorthEast().distanceTo(S.getNorthWest());I<1700&&(P=Hn(e));var vt=e.length,dt=[];for(s=0;sa){g=(c-a)/u,y=[p.x-g*(p.x-d.x),p.y-g*(p.y-d.y)];break}var Pt=n.unproject(C(y));return R([Pt.lat+P.lat,Pt.lng+P.lng])}var Qa={__proto__:null,simplify:ms,pointToSegmentDistance:ps,closestPointOnSegment:Ka,clipSegment:vs,_getEdgeIntersection:Bi,_getBitCode:we,_sqClosestPointOnSegment:oi,isFlat:Et,_flat:ys,polylineCenter:ws},Wn={project:function(e){return new k(e.lng,e.lat)},unproject:function(e){return new q(e.y,e.x)},bounds:new tt([-180,-90],[180,90])},Un={R:6378137,R_MINOR:6356752314245179e-9,bounds:new tt([-2003750834279e-5,-1549657073972e-5],[2003750834279e-5,1876465623138e-5]),project:function(e){var n=Math.PI/180,s=this.R,a=e.lat*n,u=this.R_MINOR/s,c=Math.sqrt(1-u*u),d=c*Math.sin(a),p=Math.tan(Math.PI/4-a/2)/Math.pow((1-d)/(1+d),c/2);return a=-s*Math.log(Math.max(p,1e-10)),new k(e.lng*n*s,a)},unproject:function(e){for(var n=180/Math.PI,s=this.R,a=this.R_MINOR/s,u=Math.sqrt(1-a*a),c=Math.exp(-e.y/s),d=Math.PI/2-2*Math.atan(c),p=0,g=.1,y;p<15&&Math.abs(g)>1e-7;p++)y=u*Math.sin(d),y=Math.pow((1-y)/(1+y),u/2),g=Math.PI/2-2*Math.atan(c*y)-d,d+=g;return new q(d*n,e.x*n/s)}},$a={__proto__:null,LonLat:Wn,Mercator:Un,SphericalMercator:mn},th=h({},ue,{code:"EPSG:3395",projection:Un,transformation:function(){var e=.5/(Math.PI*Un.R);return Je(e,.5,-e,.5)}()}),xs=h({},ue,{code:"EPSG:4326",projection:Wn,transformation:Je(1/180,1,-1/180,.5)}),eh=h({},Yt,{projection:Wn,transformation:Je(1,0,-1,0),scale:function(e){return Math.pow(2,e)},zoom:function(e){return Math.log(e)/Math.LN2},distance:function(e,n){var s=n.lng-e.lng,a=n.lat-e.lat;return Math.sqrt(s*s+a*a)},infinite:!0});Yt.Earth=ue,Yt.EPSG3395=th,Yt.EPSG3857=gn,Yt.EPSG900913=aa,Yt.EPSG4326=xs,Yt.Simple=eh;var Nt=Ke.extend({options:{pane:"overlayPane",attribution:null,bubblingMouseEvents:!0},addTo:function(e){return e.addLayer(this),this},remove:function(){return this.removeFrom(this._map||this._mapToAdd)},removeFrom:function(e){return e&&e.removeLayer(this),this},getPane:function(e){return this._map.getPane(e?this.options[e]||e:this.options.pane)},addInteractiveTarget:function(e){return this._map._targets[_(e)]=this,this},removeInteractiveTarget:function(e){return delete this._map._targets[_(e)],this},getAttribution:function(){return this.options.attribution},_layerAdd:function(e){var n=e.target;if(n.hasLayer(this)){if(this._map=n,this._zoomAnimated=n._zoomAnimated,this.getEvents){var s=this.getEvents();n.on(s,this),this.once("remove",function(){n.off(s,this)},this)}this.onAdd(n),this.fire("add"),n.fire("layeradd",{layer:this})}}});D.include({addLayer:function(e){if(!e._layerAdd)throw new Error("The provided object is not a Layer.");var n=_(e);return this._layers[n]?this:(this._layers[n]=e,e._mapToAdd=this,e.beforeAdd&&e.beforeAdd(this),this.whenReady(e._layerAdd,e),this)},removeLayer:function(e){var n=_(e);return this._layers[n]?(this._loaded&&e.onRemove(this),delete this._layers[n],this._loaded&&(this.fire("layerremove",{layer:e}),e.fire("remove")),e._map=e._mapToAdd=null,this):this},hasLayer:function(e){return _(e)in this._layers},eachLayer:function(e,n){for(var s in this._layers)e.call(n,this._layers[s]);return this},_addLayers:function(e){e=e?Mt(e)?e:[e]:[];for(var n=0,s=e.length;nthis._layersMaxZoom&&this.setZoom(this._layersMaxZoom),this.options.minZoom===void 0&&this._layersMinZoom&&this.getZoom()=2&&n[0]instanceof q&&n[0].equals(n[s-1])&&n.pop(),n},_setLatLngs:function(e){Qt.prototype._setLatLngs.call(this,e),Et(this._latlngs)&&(this._latlngs=[this._latlngs])},_defaultShape:function(){return Et(this._latlngs[0])?this._latlngs[0]:this._latlngs[0][0]},_clipPoints:function(){var e=this._renderer._bounds,n=this.options.weight,s=new k(n,n);if(e=new tt(e.min.subtract(s),e.max.add(s)),this._parts=[],!(!this._pxBounds||!this._pxBounds.intersects(e))){if(this.options.noClip){this._parts=this._rings;return}for(var a=0,u=this._rings.length,c;ae.y!=u.y>e.y&&e.x<(u.x-a.x)*(e.y-a.y)/(u.y-a.y)+a.x&&(n=!n);return n||Qt.prototype._containsPoint.call(this,e,!0)}});function uh(e,n){return new Oe(e,n)}var $t=Xt.extend({initialize:function(e,n){N(this,n),this._layers={},e&&this.addData(e)},addData:function(e){var n=Mt(e)?e:e.features,s,a,u;if(n){for(s=0,a=n.length;s0&&u.push(u[0].slice()),u}function Ae(e,n){return e.feature?h({},e.feature,{geometry:n}):Fi(n)}function Fi(e){return e.type==="Feature"||e.type==="FeatureCollection"?e:{type:"Feature",properties:{},geometry:e}}var jn={toGeoJSON:function(e){return Ae(this,{type:"Point",coordinates:Gn(this.getLatLng(),e)})}};Ii.include(jn),Vn.include(jn),Ni.include(jn),Qt.include({toGeoJSON:function(e){var n=!Et(this._latlngs),s=Hi(this._latlngs,n?1:0,!1,e);return Ae(this,{type:(n?"Multi":"")+"LineString",coordinates:s})}}),Oe.include({toGeoJSON:function(e){var n=!Et(this._latlngs),s=n&&!Et(this._latlngs[0]),a=Hi(this._latlngs,s?2:n?1:0,!0,e);return n||(a=[a]),Ae(this,{type:(s?"Multi":"")+"Polygon",coordinates:a})}}),ke.include({toMultiPoint:function(e){var n=[];return this.eachLayer(function(s){n.push(s.toGeoJSON(e).geometry.coordinates)}),Ae(this,{type:"MultiPoint",coordinates:n})},toGeoJSON:function(e){var n=this.feature&&this.feature.geometry&&this.feature.geometry.type;if(n==="MultiPoint")return this.toMultiPoint(e);var s=n==="GeometryCollection",a=[];return this.eachLayer(function(u){if(u.toGeoJSON){var c=u.toGeoJSON(e);if(s)a.push(c.geometry);else{var d=Fi(c);d.type==="FeatureCollection"?a.push.apply(a,d.features):a.push(d)}}}),s?Ae(this,{geometries:a,type:"GeometryCollection"}):{type:"FeatureCollection",features:a}}});function bs(e,n){return new $t(e,n)}var lh=bs,Wi=Nt.extend({options:{opacity:1,alt:"",interactive:!1,crossOrigin:!1,errorOverlayUrl:"",zIndex:1,className:""},initialize:function(e,n,s){this._url=e,this._bounds=at(n),N(this,s)},onAdd:function(){this._image||(this._initImage(),this.options.opacity<1&&this._updateOpacity()),this.options.interactive&&(Z(this._image,"leaflet-interactive"),this.addInteractiveTarget(this._image)),this.getPane().appendChild(this._image),this._reset()},onRemove:function(){et(this._image),this.options.interactive&&this.removeInteractiveTarget(this._image)},setOpacity:function(e){return this.options.opacity=e,this._image&&this._updateOpacity(),this},setStyle:function(e){return e.opacity&&this.setOpacity(e.opacity),this},bringToFront:function(){return this._map&&Ce(this._image),this},bringToBack:function(){return this._map&&ze(this._image),this},setUrl:function(e){return this._url=e,this._image&&(this._image.src=e),this},setBounds:function(e){return this._bounds=at(e),this._map&&this._reset(),this},getEvents:function(){var e={zoom:this._reset,viewreset:this._reset};return this._zoomAnimated&&(e.zoomanim=this._animateZoom),e},setZIndex:function(e){return this.options.zIndex=e,this._updateZIndex(),this},getBounds:function(){return this._bounds},getElement:function(){return this._image},_initImage:function(){var e=this._url.tagName==="IMG",n=this._image=e?this._url:W("img");if(Z(n,"leaflet-image-layer"),this._zoomAnimated&&Z(n,"leaflet-zoom-animated"),this.options.className&&Z(n,this.options.className),n.onselectstart=x,n.onmousemove=x,n.onload=f(this.fire,this,"load"),n.onerror=f(this._overlayOnError,this,"error"),(this.options.crossOrigin||this.options.crossOrigin==="")&&(n.crossOrigin=this.options.crossOrigin===!0?"":this.options.crossOrigin),this.options.zIndex&&this._updateZIndex(),e){this._url=n.src;return}n.src=this._url,n.alt=this.options.alt},_animateZoom:function(e){var n=this._map.getZoomScale(e.zoom),s=this._map._latLngBoundsToNewLayerBounds(this._bounds,e.zoom,e.center).min;pe(this._image,s,n)},_reset:function(){var e=this._image,n=new tt(this._map.latLngToLayerPoint(this._bounds.getNorthWest()),this._map.latLngToLayerPoint(this._bounds.getSouthEast())),s=n.getSize();ht(e,n.min),e.style.width=s.x+"px",e.style.height=s.y+"px"},_updateOpacity:function(){kt(this._image,this.options.opacity)},_updateZIndex:function(){this._image&&this.options.zIndex!==void 0&&this.options.zIndex!==null&&(this._image.style.zIndex=this.options.zIndex)},_overlayOnError:function(){this.fire("error");var e=this.options.errorOverlayUrl;e&&this._url!==e&&(this._url=e,this._image.src=e)},getCenter:function(){return this._bounds.getCenter()}}),ch=function(e,n,s){return new Wi(e,n,s)},Ts=Wi.extend({options:{autoplay:!0,loop:!0,keepAspectRatio:!0,muted:!1,playsInline:!0},_initImage:function(){var e=this._url.tagName==="VIDEO",n=this._image=e?this._url:W("video");if(Z(n,"leaflet-image-layer"),this._zoomAnimated&&Z(n,"leaflet-zoom-animated"),this.options.className&&Z(n,this.options.className),n.onselectstart=x,n.onmousemove=x,n.onloadeddata=f(this.fire,this,"load"),e){for(var s=n.getElementsByTagName("source"),a=[],u=0;u0?a:[n.src];return}Mt(this._url)||(this._url=[this._url]),!this.options.keepAspectRatio&&Object.prototype.hasOwnProperty.call(n.style,"objectFit")&&(n.style.objectFit="fill"),n.autoplay=!!this.options.autoplay,n.loop=!!this.options.loop,n.muted=!!this.options.muted,n.playsInline=!!this.options.playsInline;for(var c=0;cu?(n.height=u+"px",Z(e,c)):ot(e,c),this._containerWidth=this._container.offsetWidth},_animateZoom:function(e){var n=this._map._latLngToNewLayerPoint(this._latlng,e.zoom,e.center),s=this._getAnchor();ht(this._container,n.add(s))},_adjustPan:function(){if(this.options.autoPan){if(this._map._panAnim&&this._map._panAnim.stop(),this._autopanning){this._autopanning=!1;return}var e=this._map,n=parseInt(Qe(this._container,"marginBottom"),10)||0,s=this._container.offsetHeight+n,a=this._containerWidth,u=new k(this._containerLeft,-s-this._containerBottom);u._add(ge(this._container));var c=e.layerPointToContainerPoint(u),d=C(this.options.autoPanPadding),p=C(this.options.autoPanPaddingTopLeft||d),g=C(this.options.autoPanPaddingBottomRight||d),y=e.getSize(),P=0,S=0;c.x+a+g.x>y.x&&(P=c.x+a-y.x+g.x),c.x-P-p.x<0&&(P=c.x-p.x),c.y+s+g.y>y.y&&(S=c.y+s-y.y+g.y),c.y-S-p.y<0&&(S=c.y-p.y),(P||S)&&(this.options.keepInView&&(this._autopanning=!0),e.fire("autopanstart").panBy([P,S]))}},_getAnchor:function(){return C(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}}),_h=function(e,n){return new Ui(e,n)};D.mergeOptions({closePopupOnClick:!0}),D.include({openPopup:function(e,n,s){return this._initOverlay(Ui,e,n,s).openOn(this),this},closePopup:function(e){return e=arguments.length?e:this._popup,e&&e.close(),this}}),Nt.include({bindPopup:function(e,n){return this._popup=this._initOverlay(Ui,this._popup,e,n),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(e){return this._popup&&(this instanceof Xt||(this._popup._source=this),this._popup._prepareOpen(e||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return this._popup?this._popup.isOpen():!1},setPopupContent:function(e){return this._popup&&this._popup.setContent(e),this},getPopup:function(){return this._popup},_openPopup:function(e){if(!(!this._popup||!this._map)){ye(e);var n=e.layer||e.target;if(this._popup._source===n&&!(n instanceof ce)){this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(e.latlng);return}this._popup._source=n,this.openPopup(e.latlng)}},_movePopup:function(e){this._popup.setLatLng(e.latlng)},_onKeyPress:function(e){e.originalEvent.keyCode===13&&this._openPopup(e)}});var Vi=Vt.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(e){Vt.prototype.onAdd.call(this,e),this.setOpacity(this.options.opacity),e.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(e){Vt.prototype.onRemove.call(this,e),e.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var e=Vt.prototype.getEvents.call(this);return this.options.permanent||(e.preclick=this.close),e},_initLayout:function(){var e="leaflet-tooltip",n=e+" "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=W("div",n),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+_(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(e){var n,s,a=this._map,u=this._container,c=a.latLngToContainerPoint(a.getCenter()),d=a.layerPointToContainerPoint(e),p=this.options.direction,g=u.offsetWidth,y=u.offsetHeight,P=C(this.options.offset),S=this._getAnchor();p==="top"?(n=g/2,s=y):p==="bottom"?(n=g/2,s=0):p==="center"?(n=g/2,s=y/2):p==="right"?(n=0,s=y/2):p==="left"?(n=g,s=y/2):d.xthis.options.maxZoom||sa?this._retainParent(u,c,d,a):!1)},_retainChildren:function(e,n,s,a){for(var u=2*e;u<2*e+2;u++)for(var c=2*n;c<2*n+2;c++){var d=new k(u,c);d.z=s+1;var p=this._tileCoordsToKey(d),g=this._tiles[p];if(g&&g.active){g.retain=!0;continue}else g&&g.loaded&&(g.retain=!0);s+1this.options.maxZoom||this.options.minZoom!==void 0&&u1){this._setView(e,s);return}for(var S=u.min.y;S<=u.max.y;S++)for(var I=u.min.x;I<=u.max.x;I++){var vt=new k(I,S);if(vt.z=this._tileZoom,!!this._isValidTile(vt)){var dt=this._tiles[this._tileCoordsToKey(vt)];dt?dt.current=!0:d.push(vt)}}if(d.sort(function(Pt,Be){return Pt.distanceTo(c)-Be.distanceTo(c)}),d.length!==0){this._loading||(this._loading=!0,this.fire("loading"));var Ot=document.createDocumentFragment();for(I=0;Is.max.x)||!n.wrapLat&&(e.ys.max.y))return!1}if(!this.options.bounds)return!0;var a=this._tileCoordsToBounds(e);return at(this.options.bounds).overlaps(a)},_keyToBounds:function(e){return this._tileCoordsToBounds(this._keyToTileCoords(e))},_tileCoordsToNwSe:function(e){var n=this._map,s=this.getTileSize(),a=e.scaleBy(s),u=a.add(s),c=n.unproject(a,e.z),d=n.unproject(u,e.z);return[c,d]},_tileCoordsToBounds:function(e){var n=this._tileCoordsToNwSe(e),s=new xt(n[0],n[1]);return this.options.noWrap||(s=this._map.wrapLatLngBounds(s)),s},_tileCoordsToKey:function(e){return e.x+":"+e.y+":"+e.z},_keyToTileCoords:function(e){var n=e.split(":"),s=new k(+n[0],+n[1]);return s.z=+n[2],s},_removeTile:function(e){var n=this._tiles[e];n&&(et(n.el),delete this._tiles[e],this.fire("tileunload",{tile:n.el,coords:this._keyToTileCoords(e)}))},_initTile:function(e){Z(e,"leaflet-tile");var n=this.getTileSize();e.style.width=n.x+"px",e.style.height=n.y+"px",e.onselectstart=x,e.onmousemove=x,b.ielt9&&this.options.opacity<1&&kt(e,this.options.opacity)},_addTile:function(e,n){var s=this._getTilePos(e),a=this._tileCoordsToKey(e),u=this.createTile(this._wrapCoords(e),f(this._tileReady,this,e));this._initTile(u),this.createTile.length<2&>(f(this._tileReady,this,e,null,u)),ht(u,s),this._tiles[a]={el:u,coords:e,current:!0},n.appendChild(u),this.fire("tileloadstart",{tile:u,coords:e})},_tileReady:function(e,n,s){n&&this.fire("tileerror",{error:n,tile:s,coords:e});var a=this._tileCoordsToKey(e);s=this._tiles[a],s&&(s.loaded=+new Date,this._map._fadeAnimated?(kt(s.el,0),yt(this._fadeFrame),this._fadeFrame=gt(this._updateOpacity,this)):(s.active=!0,this._pruneTiles()),n||(Z(s.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:s.el,coords:e})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?gt(this._pruneTiles,this):setTimeout(f(this._pruneTiles,this),250)))},_getTilePos:function(e){return e.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(e){var n=new k(this._wrapX?w(e.x,this._wrapX):e.x,this._wrapY?w(e.y,this._wrapY):e.y);return n.z=e.z,n},_pxBoundsToTileRange:function(e){var n=this.getTileSize();return new tt(e.min.unscaleBy(n).floor(),e.max.unscaleBy(n).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var e in this._tiles)if(!this._tiles[e].loaded)return!1;return!0}});function gh(e){return new ri(e)}var Ze=ri.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(e,n){this._url=e,n=N(this,n),n.detectRetina&&b.retina&&n.maxZoom>0?(n.tileSize=Math.floor(n.tileSize/2),n.zoomReverse?(n.zoomOffset--,n.minZoom=Math.min(n.maxZoom,n.minZoom+1)):(n.zoomOffset++,n.maxZoom=Math.max(n.minZoom,n.maxZoom-1)),n.minZoom=Math.max(0,n.minZoom)):n.zoomReverse?n.minZoom=Math.min(n.maxZoom,n.minZoom):n.maxZoom=Math.max(n.minZoom,n.maxZoom),typeof n.subdomains=="string"&&(n.subdomains=n.subdomains.split("")),this.on("tileunload",this._onTileRemove)},setUrl:function(e,n){return this._url===e&&n===void 0&&(n=!0),this._url=e,n||this.redraw(),this},createTile:function(e,n){var s=document.createElement("img");return E(s,"load",f(this._tileOnLoad,this,n,s)),E(s,"error",f(this._tileOnError,this,n,s)),(this.options.crossOrigin||this.options.crossOrigin==="")&&(s.crossOrigin=this.options.crossOrigin===!0?"":this.options.crossOrigin),typeof this.options.referrerPolicy=="string"&&(s.referrerPolicy=this.options.referrerPolicy),s.alt="",s.src=this.getTileUrl(e),s},getTileUrl:function(e){var n={r:b.retina?"@2x":"",s:this._getSubdomain(e),x:e.x,y:e.y,z:this._getZoomForUrl()};if(this._map&&!this._map.options.crs.infinite){var s=this._globalTileRange.max.y-e.y;this.options.tms&&(n.y=s),n["-y"]=s}return Mi(this._url,h(n,this.options))},_tileOnLoad:function(e,n){b.ielt9?setTimeout(f(e,this,null,n),0):e(null,n)},_tileOnError:function(e,n,s){var a=this.options.errorTileUrl;a&&n.getAttribute("src")!==a&&(n.src=a),e(s,n)},_onTileRemove:function(e){e.tile.onload=null},_getZoomForUrl:function(){var e=this._tileZoom,n=this.options.maxZoom,s=this.options.zoomReverse,a=this.options.zoomOffset;return s&&(e=n-e),e+a},_getSubdomain:function(e){var n=Math.abs(e.x+e.y)%this.options.subdomains.length;return this.options.subdomains[n]},_abortLoading:function(){var e,n;for(e in this._tiles)if(this._tiles[e].coords.z!==this._tileZoom&&(n=this._tiles[e].el,n.onload=x,n.onerror=x,!n.complete)){n.src=me;var s=this._tiles[e].coords;et(n),delete this._tiles[e],this.fire("tileabort",{tile:n,coords:s})}},_removeTile:function(e){var n=this._tiles[e];if(n)return n.el.setAttribute("src",me),ri.prototype._removeTile.call(this,e)},_tileReady:function(e,n,s){if(!(!this._map||s&&s.getAttribute("src")===me))return ri.prototype._tileReady.call(this,e,n,s)}});function Cs(e,n){return new Ze(e,n)}var zs=Ze.extend({defaultWmsParams:{service:"WMS",request:"GetMap",layers:"",styles:"",format:"image/jpeg",transparent:!1,version:"1.1.1"},options:{crs:null,uppercase:!1},initialize:function(e,n){this._url=e;var s=h({},this.defaultWmsParams);for(var a in n)a in this.options||(s[a]=n[a]);n=N(this,n);var u=n.detectRetina&&b.retina?2:1,c=this.getTileSize();s.width=c.x*u,s.height=c.y*u,this.wmsParams=s},onAdd:function(e){this._crs=this.options.crs||e.options.crs,this._wmsVersion=parseFloat(this.wmsParams.version);var n=this._wmsVersion>=1.3?"crs":"srs";this.wmsParams[n]=this._crs.code,Ze.prototype.onAdd.call(this,e)},getTileUrl:function(e){var n=this._tileCoordsToNwSe(e),s=this._crs,a=wt(s.project(n[0]),s.project(n[1])),u=a.min,c=a.max,d=(this._wmsVersion>=1.3&&this._crs===xs?[u.y,u.x,c.y,c.x]:[u.x,u.y,c.x,c.y]).join(","),p=Ze.prototype.getTileUrl.call(this,e);return p+_t(this.wmsParams,p,this.options.uppercase)+(this.options.uppercase?"&BBOX=":"&bbox=")+d},setParams:function(e,n){return h(this.wmsParams,e),n||this.redraw(),this}});function vh(e,n){return new zs(e,n)}Ze.WMS=zs,Cs.wms=vh;var te=Nt.extend({options:{padding:.1},initialize:function(e){N(this,e),_(this),this._layers=this._layers||{}},onAdd:function(){this._container||(this._initContainer(),Z(this._container,"leaflet-zoom-animated")),this.getPane().appendChild(this._container),this._update(),this.on("update",this._updatePaths,this)},onRemove:function(){this.off("update",this._updatePaths,this),this._destroyContainer()},getEvents:function(){var e={viewreset:this._reset,zoom:this._onZoom,moveend:this._update,zoomend:this._onZoomEnd};return this._zoomAnimated&&(e.zoomanim=this._onAnimZoom),e},_onAnimZoom:function(e){this._updateTransform(e.center,e.zoom)},_onZoom:function(){this._updateTransform(this._map.getCenter(),this._map.getZoom())},_updateTransform:function(e,n){var s=this._map.getZoomScale(n,this._zoom),a=this._map.getSize().multiplyBy(.5+this.options.padding),u=this._map.project(this._center,n),c=a.multiplyBy(-s).add(u).subtract(this._map._getNewPixelOrigin(e,n));b.any3d?pe(this._container,c,s):ht(this._container,c)},_reset:function(){this._update(),this._updateTransform(this._center,this._zoom);for(var e in this._layers)this._layers[e]._reset()},_onZoomEnd:function(){for(var e in this._layers)this._layers[e]._project()},_updatePaths:function(){for(var e in this._layers)this._layers[e]._update()},_update:function(){var e=this.options.padding,n=this._map.getSize(),s=this._map.containerPointToLayerPoint(n.multiplyBy(-e)).round();this._bounds=new tt(s,s.add(n.multiplyBy(1+e*2)).round()),this._center=this._map.getCenter(),this._zoom=this._map.getZoom()}}),ks=te.extend({options:{tolerance:0},getEvents:function(){var e=te.prototype.getEvents.call(this);return e.viewprereset=this._onViewPreReset,e},_onViewPreReset:function(){this._postponeUpdatePaths=!0},onAdd:function(){te.prototype.onAdd.call(this),this._draw()},_initContainer:function(){var e=this._container=document.createElement("canvas");E(e,"mousemove",this._onMouseMove,this),E(e,"click dblclick mousedown mouseup contextmenu",this._onClick,this),E(e,"mouseout",this._handleMouseOut,this),e._leaflet_disable_events=!0,this._ctx=e.getContext("2d")},_destroyContainer:function(){yt(this._redrawRequest),delete this._ctx,et(this._container),J(this._container),delete this._container},_updatePaths:function(){if(!this._postponeUpdatePaths){var e;this._redrawBounds=null;for(var n in this._layers)e=this._layers[n],e._update();this._redraw()}},_update:function(){if(!(this._map._animatingZoom&&this._bounds)){te.prototype._update.call(this);var e=this._bounds,n=this._container,s=e.getSize(),a=b.retina?2:1;ht(n,e.min),n.width=a*s.x,n.height=a*s.y,n.style.width=s.x+"px",n.style.height=s.y+"px",b.retina&&this._ctx.scale(2,2),this._ctx.translate(-e.min.x,-e.min.y),this.fire("update")}},_reset:function(){te.prototype._reset.call(this),this._postponeUpdatePaths&&(this._postponeUpdatePaths=!1,this._updatePaths())},_initPath:function(e){this._updateDashArray(e),this._layers[_(e)]=e;var n=e._order={layer:e,prev:this._drawLast,next:null};this._drawLast&&(this._drawLast.next=n),this._drawLast=n,this._drawFirst=this._drawFirst||this._drawLast},_addPath:function(e){this._requestRedraw(e)},_removePath:function(e){var n=e._order,s=n.next,a=n.prev;s?s.prev=a:this._drawLast=a,a?a.next=s:this._drawFirst=s,delete e._order,delete this._layers[_(e)],this._requestRedraw(e)},_updatePath:function(e){this._extendRedrawBounds(e),e._project(),e._update(),this._requestRedraw(e)},_updateStyle:function(e){this._updateDashArray(e),this._requestRedraw(e)},_updateDashArray:function(e){if(typeof e.options.dashArray=="string"){var n=e.options.dashArray.split(/[, ]+/),s=[],a,u;for(u=0;u')}}catch{}return function(e){return document.createElement("<"+e+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),yh={_initContainer:function(){this._container=W("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(te.prototype._update.call(this),this.fire("update"))},_initPath:function(e){var n=e._container=ai("shape");Z(n,"leaflet-vml-shape "+(this.options.className||"")),n.coordsize="1 1",e._path=ai("path"),n.appendChild(e._path),this._updateStyle(e),this._layers[_(e)]=e},_addPath:function(e){var n=e._container;this._container.appendChild(n),e.options.interactive&&e.addInteractiveTarget(n)},_removePath:function(e){var n=e._container;et(n),e.removeInteractiveTarget(n),delete this._layers[_(e)]},_updateStyle:function(e){var n=e._stroke,s=e._fill,a=e.options,u=e._container;u.stroked=!!a.stroke,u.filled=!!a.fill,a.stroke?(n||(n=e._stroke=ai("stroke")),u.appendChild(n),n.weight=a.weight+"px",n.color=a.color,n.opacity=a.opacity,a.dashArray?n.dashStyle=Mt(a.dashArray)?a.dashArray.join(" "):a.dashArray.replace(/( *, *)/g," "):n.dashStyle="",n.endcap=a.lineCap.replace("butt","flat"),n.joinstyle=a.lineJoin):n&&(u.removeChild(n),e._stroke=null),a.fill?(s||(s=e._fill=ai("fill")),u.appendChild(s),s.color=a.fillColor||a.color,s.opacity=a.fillOpacity):s&&(u.removeChild(s),e._fill=null)},_updateCircle:function(e){var n=e._point.round(),s=Math.round(e._radius),a=Math.round(e._radiusY||s);this._setPath(e,e._empty()?"M0 0":"AL "+n.x+","+n.y+" "+s+","+a+" 0,"+65535*360)},_setPath:function(e,n){e._path.v=n},_bringToFront:function(e){Ce(e._container)},_bringToBack:function(e){ze(e._container)}},qi=b.vml?ai:Bo,hi=te.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){et(this._container),J(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){if(!(this._map._animatingZoom&&this._bounds)){te.prototype._update.call(this);var e=this._bounds,n=e.getSize(),s=this._container;(!this._svgSize||!this._svgSize.equals(n))&&(this._svgSize=n,s.setAttribute("width",n.x),s.setAttribute("height",n.y)),ht(s,e.min),s.setAttribute("viewBox",[e.min.x,e.min.y,n.x,n.y].join(" ")),this.fire("update")}},_initPath:function(e){var n=e._path=qi("path");e.options.className&&Z(n,e.options.className),e.options.interactive&&Z(n,"leaflet-interactive"),this._updateStyle(e),this._layers[_(e)]=e},_addPath:function(e){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(e._path),e.addInteractiveTarget(e._path)},_removePath:function(e){et(e._path),e.removeInteractiveTarget(e._path),delete this._layers[_(e)]},_updatePath:function(e){e._project(),e._update()},_updateStyle:function(e){var n=e._path,s=e.options;n&&(s.stroke?(n.setAttribute("stroke",s.color),n.setAttribute("stroke-opacity",s.opacity),n.setAttribute("stroke-width",s.weight),n.setAttribute("stroke-linecap",s.lineCap),n.setAttribute("stroke-linejoin",s.lineJoin),s.dashArray?n.setAttribute("stroke-dasharray",s.dashArray):n.removeAttribute("stroke-dasharray"),s.dashOffset?n.setAttribute("stroke-dashoffset",s.dashOffset):n.removeAttribute("stroke-dashoffset")):n.setAttribute("stroke","none"),s.fill?(n.setAttribute("fill",s.fillColor||s.color),n.setAttribute("fill-opacity",s.fillOpacity),n.setAttribute("fill-rule",s.fillRule||"evenodd")):n.setAttribute("fill","none"))},_updatePoly:function(e,n){this._setPath(e,Io(e._parts,n))},_updateCircle:function(e){var n=e._point,s=Math.max(Math.round(e._radius),1),a=Math.max(Math.round(e._radiusY),1)||s,u="a"+s+","+a+" 0 1,0 ",c=e._empty()?"M0 0":"M"+(n.x-s)+","+n.y+u+s*2+",0 "+u+-s*2+",0 ";this._setPath(e,c)},_setPath:function(e,n){e._path.setAttribute("d",n)},_bringToFront:function(e){Ce(e._path)},_bringToBack:function(e){ze(e._path)}});b.vml&&hi.include(yh);function Os(e){return b.svg||b.vml?new hi(e):null}D.include({getRenderer:function(e){var n=e.options.renderer||this._getPaneRenderer(e.options.pane)||this.options.renderer||this._renderer;return n||(n=this._renderer=this._createRenderer()),this.hasLayer(n)||this.addLayer(n),n},_getPaneRenderer:function(e){if(e==="overlayPane"||e===void 0)return!1;var n=this._paneRenderers[e];return n===void 0&&(n=this._createRenderer({pane:e}),this._paneRenderers[e]=n),n},_createRenderer:function(e){return this.options.preferCanvas&&Es(e)||Os(e)}});var As=Oe.extend({initialize:function(e,n){Oe.prototype.initialize.call(this,this._boundsToLatLngs(e),n)},setBounds:function(e){return this.setLatLngs(this._boundsToLatLngs(e))},_boundsToLatLngs:function(e){return e=at(e),[e.getSouthWest(),e.getNorthWest(),e.getNorthEast(),e.getSouthEast()]}});function wh(e,n){return new As(e,n)}hi.create=qi,hi.pointsToPath=Io,$t.geometryToLayer=Ri,$t.coordsToLatLng=qn,$t.coordsToLatLngs=Di,$t.latLngToCoords=Gn,$t.latLngsToCoords=Hi,$t.getFeature=Ae,$t.asFeature=Fi,D.mergeOptions({boxZoom:!0});var Zs=Ut.extend({initialize:function(e){this._map=e,this._container=e._container,this._pane=e._panes.overlayPane,this._resetStateTimeout=0,e.on("unload",this._destroy,this)},addHooks:function(){E(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){J(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){et(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){this._resetStateTimeout!==0&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(e){if(!e.shiftKey||e.which!==1&&e.button!==1)return!1;this._clearDeferredResetState(),this._resetState(),$e(),Cn(),this._startPoint=this._map.mouseEventToContainerPoint(e),E(document,{contextmenu:ye,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(e){this._moved||(this._moved=!0,this._box=W("div","leaflet-zoom-box",this._container),Z(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(e);var n=new tt(this._point,this._startPoint),s=n.getSize();ht(this._box,n.min),this._box.style.width=s.x+"px",this._box.style.height=s.y+"px"},_finish:function(){this._moved&&(et(this._box),ot(this._container,"leaflet-crosshair")),ti(),zn(),J(document,{contextmenu:ye,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(e){if(!(e.which!==1&&e.button!==1)&&(this._finish(),!!this._moved)){this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(f(this._resetState,this),0);var n=new xt(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point));this._map.fitBounds(n).fire("boxzoomend",{boxZoomBounds:n})}},_onKeyDown:function(e){e.keyCode===27&&(this._finish(),this._clearDeferredResetState(),this._resetState())}});D.addInitHook("addHandler","boxZoom",Zs),D.mergeOptions({doubleClickZoom:!0});var Bs=Ut.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(e){var n=this._map,s=n.getZoom(),a=n.options.zoomDelta,u=e.originalEvent.shiftKey?s-a:s+a;n.options.doubleClickZoom==="center"?n.setZoom(u):n.setZoomAround(e.containerPoint,u)}});D.addInitHook("addHandler","doubleClickZoom",Bs),D.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0});var Is=Ut.extend({addHooks:function(){if(!this._draggable){var e=this._map;this._draggable=new le(e._mapPane,e._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),e.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),e.on("zoomend",this._onZoomEnd,this),e.whenReady(this._onZoomEnd,this))}Z(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){ot(this._map._container,"leaflet-grab"),ot(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var e=this._map;if(e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity){var n=at(this._map.options.maxBounds);this._offsetLimit=wt(this._map.latLngToContainerPoint(n.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(n.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))}else this._offsetLimit=null;e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(e){if(this._map.options.inertia){var n=this._lastTime=+new Date,s=this._lastPos=this._draggable._absPos||this._draggable._newPos;this._positions.push(s),this._times.push(n),this._prunePositions(n)}this._map.fire("move",e).fire("drag",e)},_prunePositions:function(e){for(;this._positions.length>1&&e-this._times[0]>50;)this._positions.shift(),this._times.shift()},_onZoomEnd:function(){var e=this._map.getSize().divideBy(2),n=this._map.latLngToLayerPoint([0,0]);this._initialWorldOffset=n.subtract(e).x,this._worldWidth=this._map.getPixelWorldBounds().getSize().x},_viscousLimit:function(e,n){return e-(e-n)*this._viscosity},_onPreDragLimit:function(){if(!(!this._viscosity||!this._offsetLimit)){var e=this._draggable._newPos.subtract(this._draggable._startPos),n=this._offsetLimit;e.xn.max.x&&(e.x=this._viscousLimit(e.x,n.max.x)),e.y>n.max.y&&(e.y=this._viscousLimit(e.y,n.max.y)),this._draggable._newPos=this._draggable._startPos.add(e)}},_onPreDragWrap:function(){var e=this._worldWidth,n=Math.round(e/2),s=this._initialWorldOffset,a=this._draggable._newPos.x,u=(a-n+s)%e+n-s,c=(a+n+s)%e-n-s,d=Math.abs(u+s)0?c:-c))-n;this._delta=0,this._startTime=null,d&&(e.options.scrollWheelZoom==="center"?e.setZoom(n+d):e.setZoomAround(this._lastMousePos,n+d))}});D.addInitHook("addHandler","scrollWheelZoom",Rs);var xh=600;D.mergeOptions({tapHold:b.touchNative&&b.safari&&b.mobile,tapTolerance:15});var Ds=Ut.extend({addHooks:function(){E(this._map._container,"touchstart",this._onDown,this)},removeHooks:function(){J(this._map._container,"touchstart",this._onDown,this)},_onDown:function(e){if(clearTimeout(this._holdTimeout),e.touches.length===1){var n=e.touches[0];this._startPos=this._newPos=new k(n.clientX,n.clientY),this._holdTimeout=setTimeout(f(function(){this._cancel(),this._isTapValid()&&(E(document,"touchend",mt),E(document,"touchend touchcancel",this._cancelClickPrevent),this._simulateEvent("contextmenu",n))},this),xh),E(document,"touchend touchcancel contextmenu",this._cancel,this),E(document,"touchmove",this._onMove,this)}},_cancelClickPrevent:function e(){J(document,"touchend",mt),J(document,"touchend touchcancel",e)},_cancel:function(){clearTimeout(this._holdTimeout),J(document,"touchend touchcancel contextmenu",this._cancel,this),J(document,"touchmove",this._onMove,this)},_onMove:function(e){var n=e.touches[0];this._newPos=new k(n.clientX,n.clientY)},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_simulateEvent:function(e,n){var s=new MouseEvent(e,{bubbles:!0,cancelable:!0,view:window,screenX:n.screenX,screenY:n.screenY,clientX:n.clientX,clientY:n.clientY});s._simulated=!0,n.target.dispatchEvent(s)}});D.addInitHook("addHandler","tapHold",Ds),D.mergeOptions({touchZoom:b.touch,bounceAtZoomLimits:!0});var Hs=Ut.extend({addHooks:function(){Z(this._map._container,"leaflet-touch-zoom"),E(this._map._container,"touchstart",this._onTouchStart,this)},removeHooks:function(){ot(this._map._container,"leaflet-touch-zoom"),J(this._map._container,"touchstart",this._onTouchStart,this)},_onTouchStart:function(e){var n=this._map;if(!(!e.touches||e.touches.length!==2||n._animatingZoom||this._zooming)){var s=n.mouseEventToContainerPoint(e.touches[0]),a=n.mouseEventToContainerPoint(e.touches[1]);this._centerPoint=n.getSize()._divideBy(2),this._startLatLng=n.containerPointToLatLng(this._centerPoint),n.options.touchZoom!=="center"&&(this._pinchStartLatLng=n.containerPointToLatLng(s.add(a)._divideBy(2))),this._startDist=s.distanceTo(a),this._startZoom=n.getZoom(),this._moved=!1,this._zooming=!0,n._stop(),E(document,"touchmove",this._onTouchMove,this),E(document,"touchend touchcancel",this._onTouchEnd,this),mt(e)}},_onTouchMove:function(e){if(!(!e.touches||e.touches.length!==2||!this._zooming)){var n=this._map,s=n.mouseEventToContainerPoint(e.touches[0]),a=n.mouseEventToContainerPoint(e.touches[1]),u=s.distanceTo(a)/this._startDist;if(this._zoom=n.getScaleZoom(u,this._startZoom),!n.options.bounceAtZoomLimits&&(this._zoomn.getMaxZoom()&&u>1)&&(this._zoom=n._limitZoom(this._zoom)),n.options.touchZoom==="center"){if(this._center=this._startLatLng,u===1)return}else{var c=s._add(a)._divideBy(2)._subtract(this._centerPoint);if(u===1&&c.x===0&&c.y===0)return;this._center=n.unproject(n.project(this._pinchStartLatLng,this._zoom).subtract(c),this._zoom)}this._moved||(n._moveStart(!0,!1),this._moved=!0),yt(this._animRequest);var d=f(n._move,n,this._center,this._zoom,{pinch:!0,round:!1},void 0);this._animRequest=gt(d,this,!0),mt(e)}},_onTouchEnd:function(){if(!this._moved||!this._zooming){this._zooming=!1;return}this._zooming=!1,yt(this._animRequest),J(document,"touchmove",this._onTouchMove,this),J(document,"touchend touchcancel",this._onTouchEnd,this),this._map.options.zoomAnimation?this._map._animateZoom(this._center,this._map._limitZoom(this._zoom),!0,this._map.options.zoomSnap):this._map._resetView(this._center,this._map._limitZoom(this._zoom))}});D.addInitHook("addHandler","touchZoom",Hs),D.BoxZoom=Zs,D.DoubleClickZoom=Bs,D.Drag=Is,D.Keyboard=Ns,D.ScrollWheelZoom=Rs,D.TapHold=Ds,D.TouchZoom=Hs,o.Bounds=tt,o.Browser=b,o.CRS=Yt,o.Canvas=ks,o.Circle=Vn,o.CircleMarker=Ni,o.Class=zt,o.Control=It,o.DivIcon=Ss,o.DivOverlay=Vt,o.DomEvent=Da,o.DomUtil=Na,o.Draggable=le,o.Evented=Ke,o.FeatureGroup=Xt,o.GeoJSON=$t,o.GridLayer=ri,o.Handler=Ut,o.Icon=Ee,o.ImageOverlay=Wi,o.LatLng=q,o.LatLngBounds=xt,o.Layer=Nt,o.LayerGroup=ke,o.LineUtil=Qa,o.Map=D,o.Marker=Ii,o.Mixin=Ga,o.Path=ce,o.Point=k,o.PolyUtil=ja,o.Polygon=Oe,o.Polyline=Qt,o.Popup=Ui,o.PosAnimation=us,o.Projection=$a,o.Rectangle=As,o.Renderer=te,o.SVG=hi,o.SVGOverlay=Ms,o.TileLayer=Ze,o.Tooltip=Vi,o.Transformation=pn,o.Util=je,o.VideoOverlay=Ts,o.bind=f,o.bounds=wt,o.canvas=Es,o.circle=ah,o.circleMarker=rh,o.control=ni,o.divIcon=ph,o.extend=h,o.featureGroup=nh,o.geoJSON=bs,o.geoJson=lh,o.gridLayer=gh,o.icon=oh,o.imageOverlay=ch,o.latLng=R,o.latLngBounds=at,o.layerGroup=ih,o.map=Ha,o.marker=sh,o.point=C,o.polygon=uh,o.polyline=hh,o.popup=_h,o.rectangle=wh,o.setOptions=N,o.stamp=_,o.svg=Os,o.svgOverlay=dh,o.tileLayer=Cs,o.tooltip=mh,o.transformation=Je,o.version=r,o.videoOverlay=fh;var Ph=window.L;o.noConflict=function(){return window.L=Ph,this},window.L=o})}(ci,ci.exports)),ci.exports}var ra=il();const nl=Oh(ra),ul=Eh({__proto__:null,default:nl},[ra]);export{Oh as a,rl as b,al as c,hl as d,ol as g,ul as l,sl as m}; + */var el=ci.exports,er;function il(){return er||(er=1,function(t,i){(function(o,r){r(i)})(el,function(o){var r="1.9.4";function h(e){var n,s,a,u;for(s=1,a=arguments.length;s"u"||!L||!L.Mixin)){e=Mt(e)?e:[e];for(var n=0;n0?Math.floor(e):Math.ceil(e)};k.prototype={clone:function(){return new k(this.x,this.y)},add:function(e){return this.clone()._add(C(e))},_add:function(e){return this.x+=e.x,this.y+=e.y,this},subtract:function(e){return this.clone()._subtract(C(e))},_subtract:function(e){return this.x-=e.x,this.y-=e.y,this},divideBy:function(e){return this.clone()._divideBy(e)},_divideBy:function(e){return this.x/=e,this.y/=e,this},multiplyBy:function(e){return this.clone()._multiplyBy(e)},_multiplyBy:function(e){return this.x*=e,this.y*=e,this},scaleBy:function(e){return new k(this.x*e.x,this.y*e.y)},unscaleBy:function(e){return new k(this.x/e.x,this.y/e.y)},round:function(){return this.clone()._round()},_round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this},floor:function(){return this.clone()._floor()},_floor:function(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this},ceil:function(){return this.clone()._ceil()},_ceil:function(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this},trunc:function(){return this.clone()._trunc()},_trunc:function(){return this.x=Ao(this.x),this.y=Ao(this.y),this},distanceTo:function(e){e=C(e);var n=e.x-this.x,s=e.y-this.y;return Math.sqrt(n*n+s*s)},equals:function(e){return e=C(e),e.x===this.x&&e.y===this.y},contains:function(e){return e=C(e),Math.abs(e.x)<=Math.abs(this.x)&&Math.abs(e.y)<=Math.abs(this.y)},toString:function(){return"Point("+M(this.x)+", "+M(this.y)+")"}};function C(e,n,s){return e instanceof k?e:Mt(e)?new k(e[0],e[1]):e==null?e:typeof e=="object"&&"x"in e&&"y"in e?new k(e.x,e.y):new k(e,n,s)}function tt(e,n){if(e)for(var s=n?[e,n]:e,a=0,u=s.length;a=this.min.x&&s.x<=this.max.x&&n.y>=this.min.y&&s.y<=this.max.y},intersects:function(e){e=wt(e);var n=this.min,s=this.max,a=e.min,u=e.max,c=u.x>=n.x&&a.x<=s.x,d=u.y>=n.y&&a.y<=s.y;return c&&d},overlaps:function(e){e=wt(e);var n=this.min,s=this.max,a=e.min,u=e.max,c=u.x>n.x&&a.xn.y&&a.y=n.lat&&u.lat<=s.lat&&a.lng>=n.lng&&u.lng<=s.lng},intersects:function(e){e=at(e);var n=this._southWest,s=this._northEast,a=e.getSouthWest(),u=e.getNorthEast(),c=u.lat>=n.lat&&a.lat<=s.lat,d=u.lng>=n.lng&&a.lng<=s.lng;return c&&d},overlaps:function(e){e=at(e);var n=this._southWest,s=this._northEast,a=e.getSouthWest(),u=e.getNorthEast(),c=u.lat>n.lat&&a.latn.lng&&a.lng1,wa=function(){var e=!1;try{var n=Object.defineProperty({},"passive",{get:function(){e=!0}});window.addEventListener("testPassiveEventSupport",x,n),window.removeEventListener("testPassiveEventSupport",x,n)}catch{}return e}(),xa=function(){return!!document.createElement("canvas").getContext}(),Pn=!!(document.createElementNS&&Bo("svg").createSVGRect),Pa=!!Pn&&function(){var e=document.createElement("div");return e.innerHTML="",(e.firstChild&&e.firstChild.namespaceURI)==="http://www.w3.org/2000/svg"}(),La=!Pn&&function(){try{var e=document.createElement("div");e.innerHTML='';var n=e.firstChild;return n.style.behavior="url(#default#VML)",n&&typeof n.adj=="object"}catch{return!1}}(),ba=navigator.platform.indexOf("Mac")===0,Ta=navigator.platform.indexOf("Linux")===0;function Ft(e){return navigator.userAgent.toLowerCase().indexOf(e)>=0}var b={ie:Ci,ielt9:ha,edge:No,webkit:yn,android:Ro,android23:Do,androidStock:la,opera:wn,chrome:Ho,gecko:Fo,safari:ca,phantom:Wo,opera12:Uo,win:fa,ie3d:Vo,webkit3d:xn,gecko3d:qo,any3d:da,mobile:Ye,mobileWebkit:_a,mobileWebkit3d:ma,msPointer:Go,pointer:jo,touch:pa,touchNative:Ko,mobileOpera:ga,mobileGecko:va,retina:ya,passiveEvents:wa,canvas:xa,svg:Pn,vml:La,inlineSvg:Pa,mac:ba,linux:Ta},Jo=b.msPointer?"MSPointerDown":"pointerdown",Yo=b.msPointer?"MSPointerMove":"pointermove",Xo=b.msPointer?"MSPointerUp":"pointerup",Qo=b.msPointer?"MSPointerCancel":"pointercancel",Ln={touchstart:Jo,touchmove:Yo,touchend:Xo,touchcancel:Qo},$o={touchstart:Ea,touchmove:zi,touchend:zi,touchcancel:zi},Se={},ts=!1;function Ma(e,n,s){return n==="touchstart"&&ka(),$o[n]?(s=$o[n].bind(this,s),e.addEventListener(Ln[n],s,!1),s):(console.warn("wrong event specified:",n),x)}function Sa(e,n,s){if(!Ln[n]){console.warn("wrong event specified:",n);return}e.removeEventListener(Ln[n],s,!1)}function Ca(e){Se[e.pointerId]=e}function za(e){Se[e.pointerId]&&(Se[e.pointerId]=e)}function es(e){delete Se[e.pointerId]}function ka(){ts||(document.addEventListener(Jo,Ca,!0),document.addEventListener(Yo,za,!0),document.addEventListener(Xo,es,!0),document.addEventListener(Qo,es,!0),ts=!0)}function zi(e,n){if(n.pointerType!==(n.MSPOINTER_TYPE_MOUSE||"mouse")){n.touches=[];for(var s in Se)n.touches.push(Se[s]);n.changedTouches=[n],e(n)}}function Ea(e,n){n.MSPOINTER_TYPE_TOUCH&&n.pointerType===n.MSPOINTER_TYPE_TOUCH&&mt(n),zi(e,n)}function Oa(e){var n={},s,a;for(a in e)s=e[a],n[a]=s&&s.bind?s.bind(e):s;return e=n,n.type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}var Aa=200;function Za(e,n){e.addEventListener("dblclick",n);var s=0,a;function u(c){if(c.detail!==1){a=c.detail;return}if(!(c.pointerType==="mouse"||c.sourceCapabilities&&!c.sourceCapabilities.firesTouchEvents)){var d=rs(c);if(!(d.some(function(g){return g instanceof HTMLLabelElement&&g.attributes.for})&&!d.some(function(g){return g instanceof HTMLInputElement||g instanceof HTMLSelectElement}))){var p=Date.now();p-s<=Aa?(a++,a===2&&n(Oa(c))):a=1,s=p}}}return e.addEventListener("click",u),{dblclick:n,simDblclick:u}}function Ba(e,n){e.removeEventListener("dblclick",n.dblclick),e.removeEventListener("click",n.simDblclick)}var bn=Oi(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),Xe=Oi(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),is=Xe==="webkitTransition"||Xe==="OTransition"?Xe+"End":"transitionend";function ns(e){return typeof e=="string"?document.getElementById(e):e}function Qe(e,n){var s=e.style[n]||e.currentStyle&&e.currentStyle[n];if((!s||s==="auto")&&document.defaultView){var a=document.defaultView.getComputedStyle(e,null);s=a?a[n]:null}return s==="auto"?null:s}function W(e,n,s){var a=document.createElement(e);return a.className=n||"",s&&s.appendChild(a),a}function et(e){var n=e.parentNode;n&&n.removeChild(e)}function ki(e){for(;e.firstChild;)e.removeChild(e.firstChild)}function Ce(e){var n=e.parentNode;n&&n.lastChild!==e&&n.appendChild(e)}function ze(e){var n=e.parentNode;n&&n.firstChild!==e&&n.insertBefore(e,n.firstChild)}function Tn(e,n){if(e.classList!==void 0)return e.classList.contains(n);var s=Ei(e);return s.length>0&&new RegExp("(^|\\s)"+n+"(\\s|$)").test(s)}function Z(e,n){if(e.classList!==void 0)for(var s=K(n),a=0,u=s.length;a0?2*window.devicePixelRatio:1;function hs(e){return b.edge?e.wheelDeltaY/2:e.deltaY&&e.deltaMode===0?-e.deltaY/Ra:e.deltaY&&e.deltaMode===1?-e.deltaY*20:e.deltaY&&e.deltaMode===2?-e.deltaY*60:e.deltaX||e.deltaZ?0:e.wheelDelta?(e.wheelDeltaY||e.wheelDelta)/2:e.detail&&Math.abs(e.detail)<32765?-e.detail*20:e.detail?e.detail/-32765*60:0}function Nn(e,n){var s=n.relatedTarget;if(!s)return!0;try{for(;s&&s!==e;)s=s.parentNode}catch{return!1}return s!==e}var Da={__proto__:null,on:E,off:J,stopPropagation:ve,disableScrollPropagation:In,disableClickPropagation:ii,preventDefault:mt,stop:ye,getPropagationPath:rs,getMousePosition:as,getWheelDelta:hs,isExternalTarget:Nn,addListener:E,removeListener:J},us=Ke.extend({run:function(e,n,s,a){this.stop(),this._el=e,this._inProgress=!0,this._duration=s||.25,this._easeOutPower=1/Math.max(a||.5,.2),this._startPos=ge(e),this._offset=n.subtract(this._startPos),this._startTime=+new Date,this.fire("start"),this._animate()},stop:function(){this._inProgress&&(this._step(!0),this._complete())},_animate:function(){this._animId=gt(this._animate,this),this._step()},_step:function(e){var n=+new Date-this._startTime,s=this._duration*1e3;nthis.options.maxZoom)?this.setZoom(e):this},panInsideBounds:function(e,n){this._enforcingBounds=!0;var s=this.getCenter(),a=this._limitCenter(s,this._zoom,at(e));return s.equals(a)||this.panTo(a,n),this._enforcingBounds=!1,this},panInside:function(e,n){n=n||{};var s=C(n.paddingTopLeft||n.padding||[0,0]),a=C(n.paddingBottomRight||n.padding||[0,0]),u=this.project(this.getCenter()),c=this.project(e),d=this.getPixelBounds(),p=wt([d.min.add(s),d.max.subtract(a)]),g=p.getSize();if(!p.contains(c)){this._enforcingBounds=!0;var y=c.subtract(p.getCenter()),P=p.extend(c).getSize().subtract(g);u.x+=y.x<0?-P.x:P.x,u.y+=y.y<0?-P.y:P.y,this.panTo(this.unproject(u),n),this._enforcingBounds=!1}return this},invalidateSize:function(e){if(!this._loaded)return this;e=h({animate:!1,pan:!0},e===!0?{animate:!0}:e);var n=this.getSize();this._sizeChanged=!0,this._lastCenter=null;var s=this.getSize(),a=n.divideBy(2).round(),u=s.divideBy(2).round(),c=a.subtract(u);return!c.x&&!c.y?this:(e.animate&&e.pan?this.panBy(c):(e.pan&&this._rawPanBy(c),this.fire("move"),e.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(f(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:n,newSize:s}))},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(e){if(e=this._locateOptions=h({timeout:1e4,watch:!1},e),!("geolocation"in navigator))return this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this;var n=f(this._handleGeolocationResponse,this),s=f(this._handleGeolocationError,this);return e.watch?this._locationWatchId=navigator.geolocation.watchPosition(n,s,e):navigator.geolocation.getCurrentPosition(n,s,e),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(e){if(this._container._leaflet_id){var n=e.code,s=e.message||(n===1?"permission denied":n===2?"position unavailable":"timeout");this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:n,message:"Geolocation error: "+s+"."})}},_handleGeolocationResponse:function(e){if(this._container._leaflet_id){var n=e.coords.latitude,s=e.coords.longitude,a=new q(n,s),u=a.toBounds(e.coords.accuracy*2),c=this._locateOptions;if(c.setView){var d=this.getBoundsZoom(u);this.setView(a,c.maxZoom?Math.min(d,c.maxZoom):d)}var p={latlng:a,bounds:u,timestamp:e.timestamp};for(var g in e.coords)typeof e.coords[g]=="number"&&(p[g]=e.coords[g]);this.fire("locationfound",p)}},addHandler:function(e,n){if(!n)return this;var s=this[e]=new n(this);return this._handlers.push(s),this.options[e]&&s.enable(),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch{this._container._leaflet_id=void 0,this._containerId=void 0}this._locationWatchId!==void 0&&this.stopLocate(),this._stop(),et(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(yt(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload");var e;for(e in this._layers)this._layers[e].remove();for(e in this._panes)et(this._panes[e]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(e,n){var s="leaflet-pane"+(e?" leaflet-"+e.replace("Pane","")+"-pane":""),a=W("div",s,n||this._mapPane);return e&&(this._panes[e]=a),a},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var e=this.getPixelBounds(),n=this.unproject(e.getBottomLeft()),s=this.unproject(e.getTopRight());return new xt(n,s)},getMinZoom:function(){return this.options.minZoom===void 0?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return this.options.maxZoom===void 0?this._layersMaxZoom===void 0?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(e,n,s){e=at(e),s=C(s||[0,0]);var a=this.getZoom()||0,u=this.getMinZoom(),c=this.getMaxZoom(),d=e.getNorthWest(),p=e.getSouthEast(),g=this.getSize().subtract(s),y=wt(this.project(p,a),this.project(d,a)).getSize(),P=b.any3d?this.options.zoomSnap:1,S=g.x/y.x,I=g.y/y.y,vt=n?Math.max(S,I):Math.min(S,I);return a=this.getScaleZoom(vt,a),P&&(a=Math.round(a/(P/100))*(P/100),a=n?Math.ceil(a/P)*P:Math.floor(a/P)*P),Math.max(u,Math.min(c,a))},getSize:function(){return(!this._size||this._sizeChanged)&&(this._size=new k(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(e,n){var s=this._getTopLeftPoint(e,n);return new tt(s,s.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(e){return this.options.crs.getProjectedBounds(e===void 0?this.getZoom():e)},getPane:function(e){return typeof e=="string"?this._panes[e]:e},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(e,n){var s=this.options.crs;return n=n===void 0?this._zoom:n,s.scale(e)/s.scale(n)},getScaleZoom:function(e,n){var s=this.options.crs;n=n===void 0?this._zoom:n;var a=s.zoom(e*s.scale(n));return isNaN(a)?1/0:a},project:function(e,n){return n=n===void 0?this._zoom:n,this.options.crs.latLngToPoint(R(e),n)},unproject:function(e,n){return n=n===void 0?this._zoom:n,this.options.crs.pointToLatLng(C(e),n)},layerPointToLatLng:function(e){var n=C(e).add(this.getPixelOrigin());return this.unproject(n)},latLngToLayerPoint:function(e){var n=this.project(R(e))._round();return n._subtract(this.getPixelOrigin())},wrapLatLng:function(e){return this.options.crs.wrapLatLng(R(e))},wrapLatLngBounds:function(e){return this.options.crs.wrapLatLngBounds(at(e))},distance:function(e,n){return this.options.crs.distance(R(e),R(n))},containerPointToLayerPoint:function(e){return C(e).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(e){return C(e).add(this._getMapPanePos())},containerPointToLatLng:function(e){var n=this.containerPointToLayerPoint(C(e));return this.layerPointToLatLng(n)},latLngToContainerPoint:function(e){return this.layerPointToContainerPoint(this.latLngToLayerPoint(R(e)))},mouseEventToContainerPoint:function(e){return as(e,this._container)},mouseEventToLayerPoint:function(e){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(e))},mouseEventToLatLng:function(e){return this.layerPointToLatLng(this.mouseEventToLayerPoint(e))},_initContainer:function(e){var n=this._container=ns(e);if(n){if(n._leaflet_id)throw new Error("Map container is already initialized.")}else throw new Error("Map container not found.");E(n,"scroll",this._onScroll,this),this._containerId=_(n)},_initLayout:function(){var e=this._container;this._fadeAnimated=this.options.fadeAnimation&&b.any3d,Z(e,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":""));var n=Qe(e,"position");n!=="absolute"&&n!=="relative"&&n!=="fixed"&&n!=="sticky"&&(e.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var e=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),ht(this._mapPane,new k(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(Z(e.markerPane,"leaflet-zoom-hide"),Z(e.shadowPane,"leaflet-zoom-hide"))},_resetView:function(e,n,s){ht(this._mapPane,new k(0,0));var a=!this._loaded;this._loaded=!0,n=this._limitZoom(n),this.fire("viewprereset");var u=this._zoom!==n;this._moveStart(u,s)._move(e,n)._moveEnd(u),this.fire("viewreset"),a&&this.fire("load")},_moveStart:function(e,n){return e&&this.fire("zoomstart"),n||this.fire("movestart"),this},_move:function(e,n,s,a){n===void 0&&(n=this._zoom);var u=this._zoom!==n;return this._zoom=n,this._lastCenter=e,this._pixelOrigin=this._getNewPixelOrigin(e),a?s&&s.pinch&&this.fire("zoom",s):((u||s&&s.pinch)&&this.fire("zoom",s),this.fire("move",s)),this},_moveEnd:function(e){return e&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return yt(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(e){ht(this._mapPane,this._getMapPanePos().subtract(e))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(e){this._targets={},this._targets[_(this._container)]=this;var n=e?J:E;n(this._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&n(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(e?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){yt(this._resizeRequest),this._resizeRequest=gt(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var e=this._getMapPanePos();Math.max(Math.abs(e.x),Math.abs(e.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(e,n){for(var s=[],a,u=n==="mouseout"||n==="mouseover",c=e.target||e.srcElement,d=!1;c;){if(a=this._targets[_(c)],a&&(n==="click"||n==="preclick")&&this._draggableMoved(a)){d=!0;break}if(a&&a.listens(n,!0)&&(u&&!Nn(c,e)||(s.push(a),u))||c===this._container)break;c=c.parentNode}return!s.length&&!d&&!u&&this.listens(n,!0)&&(s=[this]),s},_isClickDisabled:function(e){for(;e&&e!==this._container;){if(e._leaflet_disable_click)return!0;e=e.parentNode}},_handleDOMEvent:function(e){var n=e.target||e.srcElement;if(!(!this._loaded||n._leaflet_disable_events||e.type==="click"&&this._isClickDisabled(n))){var s=e.type;s==="mousedown"&&En(n),this._fireDOMEvent(e,s)}},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(e,n,s){if(e.type==="click"){var a=h({},e);a.type="preclick",this._fireDOMEvent(a,a.type,s)}var u=this._findEventTargets(e,n);if(s){for(var c=[],d=0;d0?Math.round(e-n)/2:Math.max(0,Math.ceil(e))-Math.max(0,Math.floor(n))},_limitZoom:function(e){var n=this.getMinZoom(),s=this.getMaxZoom(),a=b.any3d?this.options.zoomSnap:1;return a&&(e=Math.round(e/a)*a),Math.max(n,Math.min(s,e))},_onPanTransitionStep:function(){this.fire("move")},_onPanTransitionEnd:function(){ot(this._mapPane,"leaflet-pan-anim"),this.fire("moveend")},_tryAnimatedPan:function(e,n){var s=this._getCenterOffset(e)._trunc();return(n&&n.animate)!==!0&&!this.getSize().contains(s)?!1:(this.panBy(s,n),!0)},_createAnimProxy:function(){var e=this._proxy=W("div","leaflet-proxy leaflet-zoom-animated");this._panes.mapPane.appendChild(e),this.on("zoomanim",function(n){var s=bn,a=this._proxy.style[s];pe(this._proxy,this.project(n.center,n.zoom),this.getZoomScale(n.zoom,1)),a===this._proxy.style[s]&&this._animatingZoom&&this._onZoomTransitionEnd()},this),this.on("load moveend",this._animMoveEnd,this),this._on("unload",this._destroyAnimProxy,this)},_destroyAnimProxy:function(){et(this._proxy),this.off("load moveend",this._animMoveEnd,this),delete this._proxy},_animMoveEnd:function(){var e=this.getCenter(),n=this.getZoom();pe(this._proxy,this.project(e,n),this.getZoomScale(n,1))},_catchTransitionEnd:function(e){this._animatingZoom&&e.propertyName.indexOf("transform")>=0&&this._onZoomTransitionEnd()},_nothingToAnimate:function(){return!this._container.getElementsByClassName("leaflet-zoom-animated").length},_tryAnimatedZoom:function(e,n,s){if(this._animatingZoom)return!0;if(s=s||{},!this._zoomAnimated||s.animate===!1||this._nothingToAnimate()||Math.abs(n-this._zoom)>this.options.zoomAnimationThreshold)return!1;var a=this.getZoomScale(n),u=this._getCenterOffset(e)._divideBy(1-1/a);return s.animate!==!0&&!this.getSize().contains(u)?!1:(gt(function(){this._moveStart(!0,s.noMoveStart||!1)._animateZoom(e,n,!0)},this),!0)},_animateZoom:function(e,n,s,a){this._mapPane&&(s&&(this._animatingZoom=!0,this._animateToCenter=e,this._animateToZoom=n,Z(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:e,zoom:n,noUpdate:a}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(f(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&ot(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ha(e,n){return new D(e,n)}var It=zt.extend({options:{position:"topright"},initialize:function(e){N(this,e)},getPosition:function(){return this.options.position},setPosition:function(e){var n=this._map;return n&&n.removeControl(this),this.options.position=e,n&&n.addControl(this),this},getContainer:function(){return this._container},addTo:function(e){this.remove(),this._map=e;var n=this._container=this.onAdd(e),s=this.getPosition(),a=e._controlCorners[s];return Z(n,"leaflet-control"),s.indexOf("bottom")!==-1?a.insertBefore(n,a.firstChild):a.appendChild(n),this._map.on("unload",this.remove,this),this},remove:function(){return this._map?(et(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null,this):this},_refocusOnMap:function(e){this._map&&e&&e.screenX>0&&e.screenY>0&&this._map.getContainer().focus()}}),ni=function(e){return new It(e)};D.include({addControl:function(e){return e.addTo(this),this},removeControl:function(e){return e.remove(),this},_initControlPos:function(){var e=this._controlCorners={},n="leaflet-",s=this._controlContainer=W("div",n+"control-container",this._container);function a(u,c){var d=n+u+" "+n+c;e[u+c]=W("div",d,s)}a("top","left"),a("top","right"),a("bottom","left"),a("bottom","right")},_clearControlPos:function(){for(var e in this._controlCorners)et(this._controlCorners[e]);et(this._controlContainer),delete this._controlCorners,delete this._controlContainer}});var ls=It.extend({options:{collapsed:!0,position:"topright",autoZIndex:!0,hideSingleBase:!1,sortLayers:!1,sortFunction:function(e,n,s,a){return s1,this._baseLayersList.style.display=e?"":"none"),this._separator.style.display=n&&e?"":"none",this},_onLayerChange:function(e){this._handlingClick||this._update();var n=this._getLayer(_(e.target)),s=n.overlay?e.type==="add"?"overlayadd":"overlayremove":e.type==="add"?"baselayerchange":null;s&&this._map.fire(s,n)},_createRadioElement:function(e,n){var s='",a=document.createElement("div");return a.innerHTML=s,a.firstChild},_addItem:function(e){var n=document.createElement("label"),s=this._map.hasLayer(e.layer),a;e.overlay?(a=document.createElement("input"),a.type="checkbox",a.className="leaflet-control-layers-selector",a.defaultChecked=s):a=this._createRadioElement("leaflet-base-layers_"+_(this),s),this._layerControlInputs.push(a),a.layerId=_(e.layer),E(a,"click",this._onInputClick,this);var u=document.createElement("span");u.innerHTML=" "+e.name;var c=document.createElement("span");n.appendChild(c),c.appendChild(a),c.appendChild(u);var d=e.overlay?this._overlaysList:this._baseLayersList;return d.appendChild(n),this._checkDisabledLayers(),n},_onInputClick:function(){if(!this._preventClick){var e=this._layerControlInputs,n,s,a=[],u=[];this._handlingClick=!0;for(var c=e.length-1;c>=0;c--)n=e[c],s=this._getLayer(n.layerId).layer,n.checked?a.push(s):n.checked||u.push(s);for(c=0;c=0;u--)n=e[u],s=this._getLayer(n.layerId).layer,n.disabled=s.options.minZoom!==void 0&&as.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var e=this._section;this._preventClick=!0,E(e,"click",mt),this.expand();var n=this;setTimeout(function(){J(e,"click",mt),n._preventClick=!1})}}),Fa=function(e,n,s){return new ls(e,n,s)},Rn=It.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(e){var n="leaflet-control-zoom",s=W("div",n+" leaflet-bar"),a=this.options;return this._zoomInButton=this._createButton(a.zoomInText,a.zoomInTitle,n+"-in",s,this._zoomIn),this._zoomOutButton=this._createButton(a.zoomOutText,a.zoomOutTitle,n+"-out",s,this._zoomOut),this._updateDisabled(),e.on("zoomend zoomlevelschange",this._updateDisabled,this),s},onRemove:function(e){e.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(e){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(e.shiftKey?3:1))},_createButton:function(e,n,s,a,u){var c=W("a",s,a);return c.innerHTML=e,c.href="#",c.title=n,c.setAttribute("role","button"),c.setAttribute("aria-label",n),ii(c),E(c,"click",ye),E(c,"click",u,this),E(c,"click",this._refocusOnMap,this),c},_updateDisabled:function(){var e=this._map,n="leaflet-disabled";ot(this._zoomInButton,n),ot(this._zoomOutButton,n),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),(this._disabled||e._zoom===e.getMinZoom())&&(Z(this._zoomOutButton,n),this._zoomOutButton.setAttribute("aria-disabled","true")),(this._disabled||e._zoom===e.getMaxZoom())&&(Z(this._zoomInButton,n),this._zoomInButton.setAttribute("aria-disabled","true"))}});D.mergeOptions({zoomControl:!0}),D.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new Rn,this.addControl(this.zoomControl))});var Wa=function(e){return new Rn(e)},cs=It.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(e){var n="leaflet-control-scale",s=W("div",n),a=this.options;return this._addScales(a,n+"-line",s),e.on(a.updateWhenIdle?"moveend":"move",this._update,this),e.whenReady(this._update,this),s},onRemove:function(e){e.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(e,n,s){e.metric&&(this._mScale=W("div",n,s)),e.imperial&&(this._iScale=W("div",n,s))},_update:function(){var e=this._map,n=e.getSize().y/2,s=e.distance(e.containerPointToLatLng([0,n]),e.containerPointToLatLng([this.options.maxWidth,n]));this._updateScales(s)},_updateScales:function(e){this.options.metric&&e&&this._updateMetric(e),this.options.imperial&&e&&this._updateImperial(e)},_updateMetric:function(e){var n=this._getRoundNum(e),s=n<1e3?n+" m":n/1e3+" km";this._updateScale(this._mScale,s,n/e)},_updateImperial:function(e){var n=e*3.2808399,s,a,u;n>5280?(s=n/5280,a=this._getRoundNum(s),this._updateScale(this._iScale,a+" mi",a/s)):(u=this._getRoundNum(n),this._updateScale(this._iScale,u+" ft",u/n))},_updateScale:function(e,n,s){e.style.width=Math.round(this.options.maxWidth*s)+"px",e.innerHTML=n},_getRoundNum:function(e){var n=Math.pow(10,(Math.floor(e)+"").length-1),s=e/n;return s=s>=10?10:s>=5?5:s>=3?3:s>=2?2:1,n*s}}),Ua=function(e){return new cs(e)},Va='',Dn=It.extend({options:{position:"bottomright",prefix:''+(b.inlineSvg?Va+" ":"")+"Leaflet"},initialize:function(e){N(this,e),this._attributions={}},onAdd:function(e){e.attributionControl=this,this._container=W("div","leaflet-control-attribution"),ii(this._container);for(var n in e._layers)e._layers[n].getAttribution&&this.addAttribution(e._layers[n].getAttribution());return this._update(),e.on("layeradd",this._addAttribution,this),this._container},onRemove:function(e){e.off("layeradd",this._addAttribution,this)},_addAttribution:function(e){e.layer.getAttribution&&(this.addAttribution(e.layer.getAttribution()),e.layer.once("remove",function(){this.removeAttribution(e.layer.getAttribution())},this))},setPrefix:function(e){return this.options.prefix=e,this._update(),this},addAttribution:function(e){return e?(this._attributions[e]||(this._attributions[e]=0),this._attributions[e]++,this._update(),this):this},removeAttribution:function(e){return e?(this._attributions[e]&&(this._attributions[e]--,this._update()),this):this},_update:function(){if(this._map){var e=[];for(var n in this._attributions)this._attributions[n]&&e.push(n);var s=[];this.options.prefix&&s.push(this.options.prefix),e.length&&s.push(e.join(", ")),this._container.innerHTML=s.join(' ')}}});D.mergeOptions({attributionControl:!0}),D.addInitHook(function(){this.options.attributionControl&&new Dn().addTo(this)});var qa=function(e){return new Dn(e)};It.Layers=ls,It.Zoom=Rn,It.Scale=cs,It.Attribution=Dn,ni.layers=Fa,ni.zoom=Wa,ni.scale=Ua,ni.attribution=qa;var Ut=zt.extend({initialize:function(e){this._map=e},enable:function(){return this._enabled?this:(this._enabled=!0,this.addHooks(),this)},disable:function(){return this._enabled?(this._enabled=!1,this.removeHooks(),this):this},enabled:function(){return!!this._enabled}});Ut.addTo=function(e,n){return e.addHandler(n,this),this};var Ga={Events:St},fs=b.touch?"touchstart mousedown":"mousedown",le=Ke.extend({options:{clickTolerance:3},initialize:function(e,n,s,a){N(this,a),this._element=e,this._dragStartTarget=n||e,this._preventOutline=s},enable:function(){this._enabled||(E(this._dragStartTarget,fs,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(le._dragging===this&&this.finishDrag(!0),J(this._dragStartTarget,fs,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(e){if(this._enabled&&(this._moved=!1,!Tn(this._element,"leaflet-zoom-anim"))){if(e.touches&&e.touches.length!==1){le._dragging===this&&this.finishDrag();return}if(!(le._dragging||e.shiftKey||e.which!==1&&e.button!==1&&!e.touches)&&(le._dragging=this,this._preventOutline&&En(this._element),Cn(),$e(),!this._moving)){this.fire("down");var n=e.touches?e.touches[0]:e,s=os(this._element);this._startPoint=new k(n.clientX,n.clientY),this._startPos=ge(this._element),this._parentScale=On(s);var a=e.type==="mousedown";E(document,a?"mousemove":"touchmove",this._onMove,this),E(document,a?"mouseup":"touchend touchcancel",this._onUp,this)}}},_onMove:function(e){if(this._enabled){if(e.touches&&e.touches.length>1){this._moved=!0;return}var n=e.touches&&e.touches.length===1?e.touches[0]:e,s=new k(n.clientX,n.clientY)._subtract(this._startPoint);!s.x&&!s.y||Math.abs(s.x)+Math.abs(s.y)c&&(d=p,c=g);c>s&&(n[d]=1,Fn(e,n,s,a,d),Fn(e,n,s,d,u))}function Ya(e,n){for(var s=[e[0]],a=1,u=0,c=e.length;an&&(s.push(e[a]),u=a);return un.max.x&&(s|=2),e.yn.max.y&&(s|=8),s}function Xa(e,n){var s=n.x-e.x,a=n.y-e.y;return s*s+a*a}function oi(e,n,s,a){var u=n.x,c=n.y,d=s.x-u,p=s.y-c,g=d*d+p*p,y;return g>0&&(y=((e.x-u)*d+(e.y-c)*p)/g,y>1?(u=s.x,c=s.y):y>0&&(u+=d*y,c+=p*y)),d=e.x-u,p=e.y-c,a?d*d+p*p:new k(u,c)}function Et(e){return!Mt(e[0])||typeof e[0][0]!="object"&&typeof e[0][0]<"u"}function ys(e){return console.warn("Deprecated use of _flat, please use L.LineUtil.isFlat instead."),Et(e)}function ws(e,n){var s,a,u,c,d,p,g,y;if(!e||e.length===0)throw new Error("latlngs not passed");Et(e)||(console.warn("latlngs are not flat! Only the first ring will be used"),e=e[0]);var P=R([0,0]),S=at(e),I=S.getNorthWest().distanceTo(S.getSouthWest())*S.getNorthEast().distanceTo(S.getNorthWest());I<1700&&(P=Hn(e));var vt=e.length,dt=[];for(s=0;sa){g=(c-a)/u,y=[p.x-g*(p.x-d.x),p.y-g*(p.y-d.y)];break}var Pt=n.unproject(C(y));return R([Pt.lat+P.lat,Pt.lng+P.lng])}var Qa={__proto__:null,simplify:ms,pointToSegmentDistance:ps,closestPointOnSegment:Ka,clipSegment:vs,_getEdgeIntersection:Bi,_getBitCode:we,_sqClosestPointOnSegment:oi,isFlat:Et,_flat:ys,polylineCenter:ws},Wn={project:function(e){return new k(e.lng,e.lat)},unproject:function(e){return new q(e.y,e.x)},bounds:new tt([-180,-90],[180,90])},Un={R:6378137,R_MINOR:6356752314245179e-9,bounds:new tt([-2003750834279e-5,-1549657073972e-5],[2003750834279e-5,1876465623138e-5]),project:function(e){var n=Math.PI/180,s=this.R,a=e.lat*n,u=this.R_MINOR/s,c=Math.sqrt(1-u*u),d=c*Math.sin(a),p=Math.tan(Math.PI/4-a/2)/Math.pow((1-d)/(1+d),c/2);return a=-s*Math.log(Math.max(p,1e-10)),new k(e.lng*n*s,a)},unproject:function(e){for(var n=180/Math.PI,s=this.R,a=this.R_MINOR/s,u=Math.sqrt(1-a*a),c=Math.exp(-e.y/s),d=Math.PI/2-2*Math.atan(c),p=0,g=.1,y;p<15&&Math.abs(g)>1e-7;p++)y=u*Math.sin(d),y=Math.pow((1-y)/(1+y),u/2),g=Math.PI/2-2*Math.atan(c*y)-d,d+=g;return new q(d*n,e.x*n/s)}},$a={__proto__:null,LonLat:Wn,Mercator:Un,SphericalMercator:mn},th=h({},ue,{code:"EPSG:3395",projection:Un,transformation:function(){var e=.5/(Math.PI*Un.R);return Je(e,.5,-e,.5)}()}),xs=h({},ue,{code:"EPSG:4326",projection:Wn,transformation:Je(1/180,1,-1/180,.5)}),eh=h({},Yt,{projection:Wn,transformation:Je(1,0,-1,0),scale:function(e){return Math.pow(2,e)},zoom:function(e){return Math.log(e)/Math.LN2},distance:function(e,n){var s=n.lng-e.lng,a=n.lat-e.lat;return Math.sqrt(s*s+a*a)},infinite:!0});Yt.Earth=ue,Yt.EPSG3395=th,Yt.EPSG3857=gn,Yt.EPSG900913=aa,Yt.EPSG4326=xs,Yt.Simple=eh;var Nt=Ke.extend({options:{pane:"overlayPane",attribution:null,bubblingMouseEvents:!0},addTo:function(e){return e.addLayer(this),this},remove:function(){return this.removeFrom(this._map||this._mapToAdd)},removeFrom:function(e){return e&&e.removeLayer(this),this},getPane:function(e){return this._map.getPane(e?this.options[e]||e:this.options.pane)},addInteractiveTarget:function(e){return this._map._targets[_(e)]=this,this},removeInteractiveTarget:function(e){return delete this._map._targets[_(e)],this},getAttribution:function(){return this.options.attribution},_layerAdd:function(e){var n=e.target;if(n.hasLayer(this)){if(this._map=n,this._zoomAnimated=n._zoomAnimated,this.getEvents){var s=this.getEvents();n.on(s,this),this.once("remove",function(){n.off(s,this)},this)}this.onAdd(n),this.fire("add"),n.fire("layeradd",{layer:this})}}});D.include({addLayer:function(e){if(!e._layerAdd)throw new Error("The provided object is not a Layer.");var n=_(e);return this._layers[n]?this:(this._layers[n]=e,e._mapToAdd=this,e.beforeAdd&&e.beforeAdd(this),this.whenReady(e._layerAdd,e),this)},removeLayer:function(e){var n=_(e);return this._layers[n]?(this._loaded&&e.onRemove(this),delete this._layers[n],this._loaded&&(this.fire("layerremove",{layer:e}),e.fire("remove")),e._map=e._mapToAdd=null,this):this},hasLayer:function(e){return _(e)in this._layers},eachLayer:function(e,n){for(var s in this._layers)e.call(n,this._layers[s]);return this},_addLayers:function(e){e=e?Mt(e)?e:[e]:[];for(var n=0,s=e.length;nthis._layersMaxZoom&&this.setZoom(this._layersMaxZoom),this.options.minZoom===void 0&&this._layersMinZoom&&this.getZoom()=2&&n[0]instanceof q&&n[0].equals(n[s-1])&&n.pop(),n},_setLatLngs:function(e){Qt.prototype._setLatLngs.call(this,e),Et(this._latlngs)&&(this._latlngs=[this._latlngs])},_defaultShape:function(){return Et(this._latlngs[0])?this._latlngs[0]:this._latlngs[0][0]},_clipPoints:function(){var e=this._renderer._bounds,n=this.options.weight,s=new k(n,n);if(e=new tt(e.min.subtract(s),e.max.add(s)),this._parts=[],!(!this._pxBounds||!this._pxBounds.intersects(e))){if(this.options.noClip){this._parts=this._rings;return}for(var a=0,u=this._rings.length,c;ae.y!=u.y>e.y&&e.x<(u.x-a.x)*(e.y-a.y)/(u.y-a.y)+a.x&&(n=!n);return n||Qt.prototype._containsPoint.call(this,e,!0)}});function uh(e,n){return new Oe(e,n)}var $t=Xt.extend({initialize:function(e,n){N(this,n),this._layers={},e&&this.addData(e)},addData:function(e){var n=Mt(e)?e:e.features,s,a,u;if(n){for(s=0,a=n.length;s0&&u.push(u[0].slice()),u}function Ae(e,n){return e.feature?h({},e.feature,{geometry:n}):Fi(n)}function Fi(e){return e.type==="Feature"||e.type==="FeatureCollection"?e:{type:"Feature",properties:{},geometry:e}}var jn={toGeoJSON:function(e){return Ae(this,{type:"Point",coordinates:Gn(this.getLatLng(),e)})}};Ii.include(jn),Vn.include(jn),Ni.include(jn),Qt.include({toGeoJSON:function(e){var n=!Et(this._latlngs),s=Hi(this._latlngs,n?1:0,!1,e);return Ae(this,{type:(n?"Multi":"")+"LineString",coordinates:s})}}),Oe.include({toGeoJSON:function(e){var n=!Et(this._latlngs),s=n&&!Et(this._latlngs[0]),a=Hi(this._latlngs,s?2:n?1:0,!0,e);return n||(a=[a]),Ae(this,{type:(s?"Multi":"")+"Polygon",coordinates:a})}}),ke.include({toMultiPoint:function(e){var n=[];return this.eachLayer(function(s){n.push(s.toGeoJSON(e).geometry.coordinates)}),Ae(this,{type:"MultiPoint",coordinates:n})},toGeoJSON:function(e){var n=this.feature&&this.feature.geometry&&this.feature.geometry.type;if(n==="MultiPoint")return this.toMultiPoint(e);var s=n==="GeometryCollection",a=[];return this.eachLayer(function(u){if(u.toGeoJSON){var c=u.toGeoJSON(e);if(s)a.push(c.geometry);else{var d=Fi(c);d.type==="FeatureCollection"?a.push.apply(a,d.features):a.push(d)}}}),s?Ae(this,{geometries:a,type:"GeometryCollection"}):{type:"FeatureCollection",features:a}}});function bs(e,n){return new $t(e,n)}var lh=bs,Wi=Nt.extend({options:{opacity:1,alt:"",interactive:!1,crossOrigin:!1,errorOverlayUrl:"",zIndex:1,className:""},initialize:function(e,n,s){this._url=e,this._bounds=at(n),N(this,s)},onAdd:function(){this._image||(this._initImage(),this.options.opacity<1&&this._updateOpacity()),this.options.interactive&&(Z(this._image,"leaflet-interactive"),this.addInteractiveTarget(this._image)),this.getPane().appendChild(this._image),this._reset()},onRemove:function(){et(this._image),this.options.interactive&&this.removeInteractiveTarget(this._image)},setOpacity:function(e){return this.options.opacity=e,this._image&&this._updateOpacity(),this},setStyle:function(e){return e.opacity&&this.setOpacity(e.opacity),this},bringToFront:function(){return this._map&&Ce(this._image),this},bringToBack:function(){return this._map&&ze(this._image),this},setUrl:function(e){return this._url=e,this._image&&(this._image.src=e),this},setBounds:function(e){return this._bounds=at(e),this._map&&this._reset(),this},getEvents:function(){var e={zoom:this._reset,viewreset:this._reset};return this._zoomAnimated&&(e.zoomanim=this._animateZoom),e},setZIndex:function(e){return this.options.zIndex=e,this._updateZIndex(),this},getBounds:function(){return this._bounds},getElement:function(){return this._image},_initImage:function(){var e=this._url.tagName==="IMG",n=this._image=e?this._url:W("img");if(Z(n,"leaflet-image-layer"),this._zoomAnimated&&Z(n,"leaflet-zoom-animated"),this.options.className&&Z(n,this.options.className),n.onselectstart=x,n.onmousemove=x,n.onload=f(this.fire,this,"load"),n.onerror=f(this._overlayOnError,this,"error"),(this.options.crossOrigin||this.options.crossOrigin==="")&&(n.crossOrigin=this.options.crossOrigin===!0?"":this.options.crossOrigin),this.options.zIndex&&this._updateZIndex(),e){this._url=n.src;return}n.src=this._url,n.alt=this.options.alt},_animateZoom:function(e){var n=this._map.getZoomScale(e.zoom),s=this._map._latLngBoundsToNewLayerBounds(this._bounds,e.zoom,e.center).min;pe(this._image,s,n)},_reset:function(){var e=this._image,n=new tt(this._map.latLngToLayerPoint(this._bounds.getNorthWest()),this._map.latLngToLayerPoint(this._bounds.getSouthEast())),s=n.getSize();ht(e,n.min),e.style.width=s.x+"px",e.style.height=s.y+"px"},_updateOpacity:function(){kt(this._image,this.options.opacity)},_updateZIndex:function(){this._image&&this.options.zIndex!==void 0&&this.options.zIndex!==null&&(this._image.style.zIndex=this.options.zIndex)},_overlayOnError:function(){this.fire("error");var e=this.options.errorOverlayUrl;e&&this._url!==e&&(this._url=e,this._image.src=e)},getCenter:function(){return this._bounds.getCenter()}}),ch=function(e,n,s){return new Wi(e,n,s)},Ts=Wi.extend({options:{autoplay:!0,loop:!0,keepAspectRatio:!0,muted:!1,playsInline:!0},_initImage:function(){var e=this._url.tagName==="VIDEO",n=this._image=e?this._url:W("video");if(Z(n,"leaflet-image-layer"),this._zoomAnimated&&Z(n,"leaflet-zoom-animated"),this.options.className&&Z(n,this.options.className),n.onselectstart=x,n.onmousemove=x,n.onloadeddata=f(this.fire,this,"load"),e){for(var s=n.getElementsByTagName("source"),a=[],u=0;u0?a:[n.src];return}Mt(this._url)||(this._url=[this._url]),!this.options.keepAspectRatio&&Object.prototype.hasOwnProperty.call(n.style,"objectFit")&&(n.style.objectFit="fill"),n.autoplay=!!this.options.autoplay,n.loop=!!this.options.loop,n.muted=!!this.options.muted,n.playsInline=!!this.options.playsInline;for(var c=0;cu?(n.height=u+"px",Z(e,c)):ot(e,c),this._containerWidth=this._container.offsetWidth},_animateZoom:function(e){var n=this._map._latLngToNewLayerPoint(this._latlng,e.zoom,e.center),s=this._getAnchor();ht(this._container,n.add(s))},_adjustPan:function(){if(this.options.autoPan){if(this._map._panAnim&&this._map._panAnim.stop(),this._autopanning){this._autopanning=!1;return}var e=this._map,n=parseInt(Qe(this._container,"marginBottom"),10)||0,s=this._container.offsetHeight+n,a=this._containerWidth,u=new k(this._containerLeft,-s-this._containerBottom);u._add(ge(this._container));var c=e.layerPointToContainerPoint(u),d=C(this.options.autoPanPadding),p=C(this.options.autoPanPaddingTopLeft||d),g=C(this.options.autoPanPaddingBottomRight||d),y=e.getSize(),P=0,S=0;c.x+a+g.x>y.x&&(P=c.x+a-y.x+g.x),c.x-P-p.x<0&&(P=c.x-p.x),c.y+s+g.y>y.y&&(S=c.y+s-y.y+g.y),c.y-S-p.y<0&&(S=c.y-p.y),(P||S)&&(this.options.keepInView&&(this._autopanning=!0),e.fire("autopanstart").panBy([P,S]))}},_getAnchor:function(){return C(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}}),_h=function(e,n){return new Ui(e,n)};D.mergeOptions({closePopupOnClick:!0}),D.include({openPopup:function(e,n,s){return this._initOverlay(Ui,e,n,s).openOn(this),this},closePopup:function(e){return e=arguments.length?e:this._popup,e&&e.close(),this}}),Nt.include({bindPopup:function(e,n){return this._popup=this._initOverlay(Ui,this._popup,e,n),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(e){return this._popup&&(this instanceof Xt||(this._popup._source=this),this._popup._prepareOpen(e||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return this._popup?this._popup.isOpen():!1},setPopupContent:function(e){return this._popup&&this._popup.setContent(e),this},getPopup:function(){return this._popup},_openPopup:function(e){if(!(!this._popup||!this._map)){ye(e);var n=e.layer||e.target;if(this._popup._source===n&&!(n instanceof ce)){this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(e.latlng);return}this._popup._source=n,this.openPopup(e.latlng)}},_movePopup:function(e){this._popup.setLatLng(e.latlng)},_onKeyPress:function(e){e.originalEvent.keyCode===13&&this._openPopup(e)}});var Vi=Vt.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(e){Vt.prototype.onAdd.call(this,e),this.setOpacity(this.options.opacity),e.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(e){Vt.prototype.onRemove.call(this,e),e.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var e=Vt.prototype.getEvents.call(this);return this.options.permanent||(e.preclick=this.close),e},_initLayout:function(){var e="leaflet-tooltip",n=e+" "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=W("div",n),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+_(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(e){var n,s,a=this._map,u=this._container,c=a.latLngToContainerPoint(a.getCenter()),d=a.layerPointToContainerPoint(e),p=this.options.direction,g=u.offsetWidth,y=u.offsetHeight,P=C(this.options.offset),S=this._getAnchor();p==="top"?(n=g/2,s=y):p==="bottom"?(n=g/2,s=0):p==="center"?(n=g/2,s=y/2):p==="right"?(n=0,s=y/2):p==="left"?(n=g,s=y/2):d.xthis.options.maxZoom||sa?this._retainParent(u,c,d,a):!1)},_retainChildren:function(e,n,s,a){for(var u=2*e;u<2*e+2;u++)for(var c=2*n;c<2*n+2;c++){var d=new k(u,c);d.z=s+1;var p=this._tileCoordsToKey(d),g=this._tiles[p];if(g&&g.active){g.retain=!0;continue}else g&&g.loaded&&(g.retain=!0);s+1this.options.maxZoom||this.options.minZoom!==void 0&&u1){this._setView(e,s);return}for(var S=u.min.y;S<=u.max.y;S++)for(var I=u.min.x;I<=u.max.x;I++){var vt=new k(I,S);if(vt.z=this._tileZoom,!!this._isValidTile(vt)){var dt=this._tiles[this._tileCoordsToKey(vt)];dt?dt.current=!0:d.push(vt)}}if(d.sort(function(Pt,Be){return Pt.distanceTo(c)-Be.distanceTo(c)}),d.length!==0){this._loading||(this._loading=!0,this.fire("loading"));var Ot=document.createDocumentFragment();for(I=0;Is.max.x)||!n.wrapLat&&(e.ys.max.y))return!1}if(!this.options.bounds)return!0;var a=this._tileCoordsToBounds(e);return at(this.options.bounds).overlaps(a)},_keyToBounds:function(e){return this._tileCoordsToBounds(this._keyToTileCoords(e))},_tileCoordsToNwSe:function(e){var n=this._map,s=this.getTileSize(),a=e.scaleBy(s),u=a.add(s),c=n.unproject(a,e.z),d=n.unproject(u,e.z);return[c,d]},_tileCoordsToBounds:function(e){var n=this._tileCoordsToNwSe(e),s=new xt(n[0],n[1]);return this.options.noWrap||(s=this._map.wrapLatLngBounds(s)),s},_tileCoordsToKey:function(e){return e.x+":"+e.y+":"+e.z},_keyToTileCoords:function(e){var n=e.split(":"),s=new k(+n[0],+n[1]);return s.z=+n[2],s},_removeTile:function(e){var n=this._tiles[e];n&&(et(n.el),delete this._tiles[e],this.fire("tileunload",{tile:n.el,coords:this._keyToTileCoords(e)}))},_initTile:function(e){Z(e,"leaflet-tile");var n=this.getTileSize();e.style.width=n.x+"px",e.style.height=n.y+"px",e.onselectstart=x,e.onmousemove=x,b.ielt9&&this.options.opacity<1&&kt(e,this.options.opacity)},_addTile:function(e,n){var s=this._getTilePos(e),a=this._tileCoordsToKey(e),u=this.createTile(this._wrapCoords(e),f(this._tileReady,this,e));this._initTile(u),this.createTile.length<2&>(f(this._tileReady,this,e,null,u)),ht(u,s),this._tiles[a]={el:u,coords:e,current:!0},n.appendChild(u),this.fire("tileloadstart",{tile:u,coords:e})},_tileReady:function(e,n,s){n&&this.fire("tileerror",{error:n,tile:s,coords:e});var a=this._tileCoordsToKey(e);s=this._tiles[a],s&&(s.loaded=+new Date,this._map._fadeAnimated?(kt(s.el,0),yt(this._fadeFrame),this._fadeFrame=gt(this._updateOpacity,this)):(s.active=!0,this._pruneTiles()),n||(Z(s.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:s.el,coords:e})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?gt(this._pruneTiles,this):setTimeout(f(this._pruneTiles,this),250)))},_getTilePos:function(e){return e.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(e){var n=new k(this._wrapX?w(e.x,this._wrapX):e.x,this._wrapY?w(e.y,this._wrapY):e.y);return n.z=e.z,n},_pxBoundsToTileRange:function(e){var n=this.getTileSize();return new tt(e.min.unscaleBy(n).floor(),e.max.unscaleBy(n).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var e in this._tiles)if(!this._tiles[e].loaded)return!1;return!0}});function gh(e){return new ri(e)}var Ze=ri.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(e,n){this._url=e,n=N(this,n),n.detectRetina&&b.retina&&n.maxZoom>0?(n.tileSize=Math.floor(n.tileSize/2),n.zoomReverse?(n.zoomOffset--,n.minZoom=Math.min(n.maxZoom,n.minZoom+1)):(n.zoomOffset++,n.maxZoom=Math.max(n.minZoom,n.maxZoom-1)),n.minZoom=Math.max(0,n.minZoom)):n.zoomReverse?n.minZoom=Math.min(n.maxZoom,n.minZoom):n.maxZoom=Math.max(n.minZoom,n.maxZoom),typeof n.subdomains=="string"&&(n.subdomains=n.subdomains.split("")),this.on("tileunload",this._onTileRemove)},setUrl:function(e,n){return this._url===e&&n===void 0&&(n=!0),this._url=e,n||this.redraw(),this},createTile:function(e,n){var s=document.createElement("img");return E(s,"load",f(this._tileOnLoad,this,n,s)),E(s,"error",f(this._tileOnError,this,n,s)),(this.options.crossOrigin||this.options.crossOrigin==="")&&(s.crossOrigin=this.options.crossOrigin===!0?"":this.options.crossOrigin),typeof this.options.referrerPolicy=="string"&&(s.referrerPolicy=this.options.referrerPolicy),s.alt="",s.src=this.getTileUrl(e),s},getTileUrl:function(e){var n={r:b.retina?"@2x":"",s:this._getSubdomain(e),x:e.x,y:e.y,z:this._getZoomForUrl()};if(this._map&&!this._map.options.crs.infinite){var s=this._globalTileRange.max.y-e.y;this.options.tms&&(n.y=s),n["-y"]=s}return Mi(this._url,h(n,this.options))},_tileOnLoad:function(e,n){b.ielt9?setTimeout(f(e,this,null,n),0):e(null,n)},_tileOnError:function(e,n,s){var a=this.options.errorTileUrl;a&&n.getAttribute("src")!==a&&(n.src=a),e(s,n)},_onTileRemove:function(e){e.tile.onload=null},_getZoomForUrl:function(){var e=this._tileZoom,n=this.options.maxZoom,s=this.options.zoomReverse,a=this.options.zoomOffset;return s&&(e=n-e),e+a},_getSubdomain:function(e){var n=Math.abs(e.x+e.y)%this.options.subdomains.length;return this.options.subdomains[n]},_abortLoading:function(){var e,n;for(e in this._tiles)if(this._tiles[e].coords.z!==this._tileZoom&&(n=this._tiles[e].el,n.onload=x,n.onerror=x,!n.complete)){n.src=me;var s=this._tiles[e].coords;et(n),delete this._tiles[e],this.fire("tileabort",{tile:n,coords:s})}},_removeTile:function(e){var n=this._tiles[e];if(n)return n.el.setAttribute("src",me),ri.prototype._removeTile.call(this,e)},_tileReady:function(e,n,s){if(!(!this._map||s&&s.getAttribute("src")===me))return ri.prototype._tileReady.call(this,e,n,s)}});function Cs(e,n){return new Ze(e,n)}var zs=Ze.extend({defaultWmsParams:{service:"WMS",request:"GetMap",layers:"",styles:"",format:"image/jpeg",transparent:!1,version:"1.1.1"},options:{crs:null,uppercase:!1},initialize:function(e,n){this._url=e;var s=h({},this.defaultWmsParams);for(var a in n)a in this.options||(s[a]=n[a]);n=N(this,n);var u=n.detectRetina&&b.retina?2:1,c=this.getTileSize();s.width=c.x*u,s.height=c.y*u,this.wmsParams=s},onAdd:function(e){this._crs=this.options.crs||e.options.crs,this._wmsVersion=parseFloat(this.wmsParams.version);var n=this._wmsVersion>=1.3?"crs":"srs";this.wmsParams[n]=this._crs.code,Ze.prototype.onAdd.call(this,e)},getTileUrl:function(e){var n=this._tileCoordsToNwSe(e),s=this._crs,a=wt(s.project(n[0]),s.project(n[1])),u=a.min,c=a.max,d=(this._wmsVersion>=1.3&&this._crs===xs?[u.y,u.x,c.y,c.x]:[u.x,u.y,c.x,c.y]).join(","),p=Ze.prototype.getTileUrl.call(this,e);return p+_t(this.wmsParams,p,this.options.uppercase)+(this.options.uppercase?"&BBOX=":"&bbox=")+d},setParams:function(e,n){return h(this.wmsParams,e),n||this.redraw(),this}});function vh(e,n){return new zs(e,n)}Ze.WMS=zs,Cs.wms=vh;var te=Nt.extend({options:{padding:.1},initialize:function(e){N(this,e),_(this),this._layers=this._layers||{}},onAdd:function(){this._container||(this._initContainer(),Z(this._container,"leaflet-zoom-animated")),this.getPane().appendChild(this._container),this._update(),this.on("update",this._updatePaths,this)},onRemove:function(){this.off("update",this._updatePaths,this),this._destroyContainer()},getEvents:function(){var e={viewreset:this._reset,zoom:this._onZoom,moveend:this._update,zoomend:this._onZoomEnd};return this._zoomAnimated&&(e.zoomanim=this._onAnimZoom),e},_onAnimZoom:function(e){this._updateTransform(e.center,e.zoom)},_onZoom:function(){this._updateTransform(this._map.getCenter(),this._map.getZoom())},_updateTransform:function(e,n){var s=this._map.getZoomScale(n,this._zoom),a=this._map.getSize().multiplyBy(.5+this.options.padding),u=this._map.project(this._center,n),c=a.multiplyBy(-s).add(u).subtract(this._map._getNewPixelOrigin(e,n));b.any3d?pe(this._container,c,s):ht(this._container,c)},_reset:function(){this._update(),this._updateTransform(this._center,this._zoom);for(var e in this._layers)this._layers[e]._reset()},_onZoomEnd:function(){for(var e in this._layers)this._layers[e]._project()},_updatePaths:function(){for(var e in this._layers)this._layers[e]._update()},_update:function(){var e=this.options.padding,n=this._map.getSize(),s=this._map.containerPointToLayerPoint(n.multiplyBy(-e)).round();this._bounds=new tt(s,s.add(n.multiplyBy(1+e*2)).round()),this._center=this._map.getCenter(),this._zoom=this._map.getZoom()}}),ks=te.extend({options:{tolerance:0},getEvents:function(){var e=te.prototype.getEvents.call(this);return e.viewprereset=this._onViewPreReset,e},_onViewPreReset:function(){this._postponeUpdatePaths=!0},onAdd:function(){te.prototype.onAdd.call(this),this._draw()},_initContainer:function(){var e=this._container=document.createElement("canvas");E(e,"mousemove",this._onMouseMove,this),E(e,"click dblclick mousedown mouseup contextmenu",this._onClick,this),E(e,"mouseout",this._handleMouseOut,this),e._leaflet_disable_events=!0,this._ctx=e.getContext("2d")},_destroyContainer:function(){yt(this._redrawRequest),delete this._ctx,et(this._container),J(this._container),delete this._container},_updatePaths:function(){if(!this._postponeUpdatePaths){var e;this._redrawBounds=null;for(var n in this._layers)e=this._layers[n],e._update();this._redraw()}},_update:function(){if(!(this._map._animatingZoom&&this._bounds)){te.prototype._update.call(this);var e=this._bounds,n=this._container,s=e.getSize(),a=b.retina?2:1;ht(n,e.min),n.width=a*s.x,n.height=a*s.y,n.style.width=s.x+"px",n.style.height=s.y+"px",b.retina&&this._ctx.scale(2,2),this._ctx.translate(-e.min.x,-e.min.y),this.fire("update")}},_reset:function(){te.prototype._reset.call(this),this._postponeUpdatePaths&&(this._postponeUpdatePaths=!1,this._updatePaths())},_initPath:function(e){this._updateDashArray(e),this._layers[_(e)]=e;var n=e._order={layer:e,prev:this._drawLast,next:null};this._drawLast&&(this._drawLast.next=n),this._drawLast=n,this._drawFirst=this._drawFirst||this._drawLast},_addPath:function(e){this._requestRedraw(e)},_removePath:function(e){var n=e._order,s=n.next,a=n.prev;s?s.prev=a:this._drawLast=a,a?a.next=s:this._drawFirst=s,delete e._order,delete this._layers[_(e)],this._requestRedraw(e)},_updatePath:function(e){this._extendRedrawBounds(e),e._project(),e._update(),this._requestRedraw(e)},_updateStyle:function(e){this._updateDashArray(e),this._requestRedraw(e)},_updateDashArray:function(e){if(typeof e.options.dashArray=="string"){var n=e.options.dashArray.split(/[, ]+/),s=[],a,u;for(u=0;u')}}catch{}return function(e){return document.createElement("<"+e+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),yh={_initContainer:function(){this._container=W("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(te.prototype._update.call(this),this.fire("update"))},_initPath:function(e){var n=e._container=ai("shape");Z(n,"leaflet-vml-shape "+(this.options.className||"")),n.coordsize="1 1",e._path=ai("path"),n.appendChild(e._path),this._updateStyle(e),this._layers[_(e)]=e},_addPath:function(e){var n=e._container;this._container.appendChild(n),e.options.interactive&&e.addInteractiveTarget(n)},_removePath:function(e){var n=e._container;et(n),e.removeInteractiveTarget(n),delete this._layers[_(e)]},_updateStyle:function(e){var n=e._stroke,s=e._fill,a=e.options,u=e._container;u.stroked=!!a.stroke,u.filled=!!a.fill,a.stroke?(n||(n=e._stroke=ai("stroke")),u.appendChild(n),n.weight=a.weight+"px",n.color=a.color,n.opacity=a.opacity,a.dashArray?n.dashStyle=Mt(a.dashArray)?a.dashArray.join(" "):a.dashArray.replace(/( *, *)/g," "):n.dashStyle="",n.endcap=a.lineCap.replace("butt","flat"),n.joinstyle=a.lineJoin):n&&(u.removeChild(n),e._stroke=null),a.fill?(s||(s=e._fill=ai("fill")),u.appendChild(s),s.color=a.fillColor||a.color,s.opacity=a.fillOpacity):s&&(u.removeChild(s),e._fill=null)},_updateCircle:function(e){var n=e._point.round(),s=Math.round(e._radius),a=Math.round(e._radiusY||s);this._setPath(e,e._empty()?"M0 0":"AL "+n.x+","+n.y+" "+s+","+a+" 0,"+65535*360)},_setPath:function(e,n){e._path.v=n},_bringToFront:function(e){Ce(e._container)},_bringToBack:function(e){ze(e._container)}},qi=b.vml?ai:Bo,hi=te.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){et(this._container),J(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){if(!(this._map._animatingZoom&&this._bounds)){te.prototype._update.call(this);var e=this._bounds,n=e.getSize(),s=this._container;(!this._svgSize||!this._svgSize.equals(n))&&(this._svgSize=n,s.setAttribute("width",n.x),s.setAttribute("height",n.y)),ht(s,e.min),s.setAttribute("viewBox",[e.min.x,e.min.y,n.x,n.y].join(" ")),this.fire("update")}},_initPath:function(e){var n=e._path=qi("path");e.options.className&&Z(n,e.options.className),e.options.interactive&&Z(n,"leaflet-interactive"),this._updateStyle(e),this._layers[_(e)]=e},_addPath:function(e){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(e._path),e.addInteractiveTarget(e._path)},_removePath:function(e){et(e._path),e.removeInteractiveTarget(e._path),delete this._layers[_(e)]},_updatePath:function(e){e._project(),e._update()},_updateStyle:function(e){var n=e._path,s=e.options;n&&(s.stroke?(n.setAttribute("stroke",s.color),n.setAttribute("stroke-opacity",s.opacity),n.setAttribute("stroke-width",s.weight),n.setAttribute("stroke-linecap",s.lineCap),n.setAttribute("stroke-linejoin",s.lineJoin),s.dashArray?n.setAttribute("stroke-dasharray",s.dashArray):n.removeAttribute("stroke-dasharray"),s.dashOffset?n.setAttribute("stroke-dashoffset",s.dashOffset):n.removeAttribute("stroke-dashoffset")):n.setAttribute("stroke","none"),s.fill?(n.setAttribute("fill",s.fillColor||s.color),n.setAttribute("fill-opacity",s.fillOpacity),n.setAttribute("fill-rule",s.fillRule||"evenodd")):n.setAttribute("fill","none"))},_updatePoly:function(e,n){this._setPath(e,Io(e._parts,n))},_updateCircle:function(e){var n=e._point,s=Math.max(Math.round(e._radius),1),a=Math.max(Math.round(e._radiusY),1)||s,u="a"+s+","+a+" 0 1,0 ",c=e._empty()?"M0 0":"M"+(n.x-s)+","+n.y+u+s*2+",0 "+u+-s*2+",0 ";this._setPath(e,c)},_setPath:function(e,n){e._path.setAttribute("d",n)},_bringToFront:function(e){Ce(e._path)},_bringToBack:function(e){ze(e._path)}});b.vml&&hi.include(yh);function Os(e){return b.svg||b.vml?new hi(e):null}D.include({getRenderer:function(e){var n=e.options.renderer||this._getPaneRenderer(e.options.pane)||this.options.renderer||this._renderer;return n||(n=this._renderer=this._createRenderer()),this.hasLayer(n)||this.addLayer(n),n},_getPaneRenderer:function(e){if(e==="overlayPane"||e===void 0)return!1;var n=this._paneRenderers[e];return n===void 0&&(n=this._createRenderer({pane:e}),this._paneRenderers[e]=n),n},_createRenderer:function(e){return this.options.preferCanvas&&Es(e)||Os(e)}});var As=Oe.extend({initialize:function(e,n){Oe.prototype.initialize.call(this,this._boundsToLatLngs(e),n)},setBounds:function(e){return this.setLatLngs(this._boundsToLatLngs(e))},_boundsToLatLngs:function(e){return e=at(e),[e.getSouthWest(),e.getNorthWest(),e.getNorthEast(),e.getSouthEast()]}});function wh(e,n){return new As(e,n)}hi.create=qi,hi.pointsToPath=Io,$t.geometryToLayer=Ri,$t.coordsToLatLng=qn,$t.coordsToLatLngs=Di,$t.latLngToCoords=Gn,$t.latLngsToCoords=Hi,$t.getFeature=Ae,$t.asFeature=Fi,D.mergeOptions({boxZoom:!0});var Zs=Ut.extend({initialize:function(e){this._map=e,this._container=e._container,this._pane=e._panes.overlayPane,this._resetStateTimeout=0,e.on("unload",this._destroy,this)},addHooks:function(){E(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){J(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){et(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){this._resetStateTimeout!==0&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(e){if(!e.shiftKey||e.which!==1&&e.button!==1)return!1;this._clearDeferredResetState(),this._resetState(),$e(),Cn(),this._startPoint=this._map.mouseEventToContainerPoint(e),E(document,{contextmenu:ye,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(e){this._moved||(this._moved=!0,this._box=W("div","leaflet-zoom-box",this._container),Z(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(e);var n=new tt(this._point,this._startPoint),s=n.getSize();ht(this._box,n.min),this._box.style.width=s.x+"px",this._box.style.height=s.y+"px"},_finish:function(){this._moved&&(et(this._box),ot(this._container,"leaflet-crosshair")),ti(),zn(),J(document,{contextmenu:ye,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(e){if(!(e.which!==1&&e.button!==1)&&(this._finish(),!!this._moved)){this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(f(this._resetState,this),0);var n=new xt(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point));this._map.fitBounds(n).fire("boxzoomend",{boxZoomBounds:n})}},_onKeyDown:function(e){e.keyCode===27&&(this._finish(),this._clearDeferredResetState(),this._resetState())}});D.addInitHook("addHandler","boxZoom",Zs),D.mergeOptions({doubleClickZoom:!0});var Bs=Ut.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(e){var n=this._map,s=n.getZoom(),a=n.options.zoomDelta,u=e.originalEvent.shiftKey?s-a:s+a;n.options.doubleClickZoom==="center"?n.setZoom(u):n.setZoomAround(e.containerPoint,u)}});D.addInitHook("addHandler","doubleClickZoom",Bs),D.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0});var Is=Ut.extend({addHooks:function(){if(!this._draggable){var e=this._map;this._draggable=new le(e._mapPane,e._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),e.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),e.on("zoomend",this._onZoomEnd,this),e.whenReady(this._onZoomEnd,this))}Z(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){ot(this._map._container,"leaflet-grab"),ot(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var e=this._map;if(e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity){var n=at(this._map.options.maxBounds);this._offsetLimit=wt(this._map.latLngToContainerPoint(n.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(n.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))}else this._offsetLimit=null;e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(e){if(this._map.options.inertia){var n=this._lastTime=+new Date,s=this._lastPos=this._draggable._absPos||this._draggable._newPos;this._positions.push(s),this._times.push(n),this._prunePositions(n)}this._map.fire("move",e).fire("drag",e)},_prunePositions:function(e){for(;this._positions.length>1&&e-this._times[0]>50;)this._positions.shift(),this._times.shift()},_onZoomEnd:function(){var e=this._map.getSize().divideBy(2),n=this._map.latLngToLayerPoint([0,0]);this._initialWorldOffset=n.subtract(e).x,this._worldWidth=this._map.getPixelWorldBounds().getSize().x},_viscousLimit:function(e,n){return e-(e-n)*this._viscosity},_onPreDragLimit:function(){if(!(!this._viscosity||!this._offsetLimit)){var e=this._draggable._newPos.subtract(this._draggable._startPos),n=this._offsetLimit;e.xn.max.x&&(e.x=this._viscousLimit(e.x,n.max.x)),e.y>n.max.y&&(e.y=this._viscousLimit(e.y,n.max.y)),this._draggable._newPos=this._draggable._startPos.add(e)}},_onPreDragWrap:function(){var e=this._worldWidth,n=Math.round(e/2),s=this._initialWorldOffset,a=this._draggable._newPos.x,u=(a-n+s)%e+n-s,c=(a+n+s)%e-n-s,d=Math.abs(u+s)0?c:-c))-n;this._delta=0,this._startTime=null,d&&(e.options.scrollWheelZoom==="center"?e.setZoom(n+d):e.setZoomAround(this._lastMousePos,n+d))}});D.addInitHook("addHandler","scrollWheelZoom",Rs);var xh=600;D.mergeOptions({tapHold:b.touchNative&&b.safari&&b.mobile,tapTolerance:15});var Ds=Ut.extend({addHooks:function(){E(this._map._container,"touchstart",this._onDown,this)},removeHooks:function(){J(this._map._container,"touchstart",this._onDown,this)},_onDown:function(e){if(clearTimeout(this._holdTimeout),e.touches.length===1){var n=e.touches[0];this._startPos=this._newPos=new k(n.clientX,n.clientY),this._holdTimeout=setTimeout(f(function(){this._cancel(),this._isTapValid()&&(E(document,"touchend",mt),E(document,"touchend touchcancel",this._cancelClickPrevent),this._simulateEvent("contextmenu",n))},this),xh),E(document,"touchend touchcancel contextmenu",this._cancel,this),E(document,"touchmove",this._onMove,this)}},_cancelClickPrevent:function e(){J(document,"touchend",mt),J(document,"touchend touchcancel",e)},_cancel:function(){clearTimeout(this._holdTimeout),J(document,"touchend touchcancel contextmenu",this._cancel,this),J(document,"touchmove",this._onMove,this)},_onMove:function(e){var n=e.touches[0];this._newPos=new k(n.clientX,n.clientY)},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_simulateEvent:function(e,n){var s=new MouseEvent(e,{bubbles:!0,cancelable:!0,view:window,screenX:n.screenX,screenY:n.screenY,clientX:n.clientX,clientY:n.clientY});s._simulated=!0,n.target.dispatchEvent(s)}});D.addInitHook("addHandler","tapHold",Ds),D.mergeOptions({touchZoom:b.touch,bounceAtZoomLimits:!0});var Hs=Ut.extend({addHooks:function(){Z(this._map._container,"leaflet-touch-zoom"),E(this._map._container,"touchstart",this._onTouchStart,this)},removeHooks:function(){ot(this._map._container,"leaflet-touch-zoom"),J(this._map._container,"touchstart",this._onTouchStart,this)},_onTouchStart:function(e){var n=this._map;if(!(!e.touches||e.touches.length!==2||n._animatingZoom||this._zooming)){var s=n.mouseEventToContainerPoint(e.touches[0]),a=n.mouseEventToContainerPoint(e.touches[1]);this._centerPoint=n.getSize()._divideBy(2),this._startLatLng=n.containerPointToLatLng(this._centerPoint),n.options.touchZoom!=="center"&&(this._pinchStartLatLng=n.containerPointToLatLng(s.add(a)._divideBy(2))),this._startDist=s.distanceTo(a),this._startZoom=n.getZoom(),this._moved=!1,this._zooming=!0,n._stop(),E(document,"touchmove",this._onTouchMove,this),E(document,"touchend touchcancel",this._onTouchEnd,this),mt(e)}},_onTouchMove:function(e){if(!(!e.touches||e.touches.length!==2||!this._zooming)){var n=this._map,s=n.mouseEventToContainerPoint(e.touches[0]),a=n.mouseEventToContainerPoint(e.touches[1]),u=s.distanceTo(a)/this._startDist;if(this._zoom=n.getScaleZoom(u,this._startZoom),!n.options.bounceAtZoomLimits&&(this._zoomn.getMaxZoom()&&u>1)&&(this._zoom=n._limitZoom(this._zoom)),n.options.touchZoom==="center"){if(this._center=this._startLatLng,u===1)return}else{var c=s._add(a)._divideBy(2)._subtract(this._centerPoint);if(u===1&&c.x===0&&c.y===0)return;this._center=n.unproject(n.project(this._pinchStartLatLng,this._zoom).subtract(c),this._zoom)}this._moved||(n._moveStart(!0,!1),this._moved=!0),yt(this._animRequest);var d=f(n._move,n,this._center,this._zoom,{pinch:!0,round:!1},void 0);this._animRequest=gt(d,this,!0),mt(e)}},_onTouchEnd:function(){if(!this._moved||!this._zooming){this._zooming=!1;return}this._zooming=!1,yt(this._animRequest),J(document,"touchmove",this._onTouchMove,this),J(document,"touchend touchcancel",this._onTouchEnd,this),this._map.options.zoomAnimation?this._map._animateZoom(this._center,this._map._limitZoom(this._zoom),!0,this._map.options.zoomSnap):this._map._resetView(this._center,this._map._limitZoom(this._zoom))}});D.addInitHook("addHandler","touchZoom",Hs),D.BoxZoom=Zs,D.DoubleClickZoom=Bs,D.Drag=Is,D.Keyboard=Ns,D.ScrollWheelZoom=Rs,D.TapHold=Ds,D.TouchZoom=Hs,o.Bounds=tt,o.Browser=b,o.CRS=Yt,o.Canvas=ks,o.Circle=Vn,o.CircleMarker=Ni,o.Class=zt,o.Control=It,o.DivIcon=Ss,o.DivOverlay=Vt,o.DomEvent=Da,o.DomUtil=Na,o.Draggable=le,o.Evented=Ke,o.FeatureGroup=Xt,o.GeoJSON=$t,o.GridLayer=ri,o.Handler=Ut,o.Icon=Ee,o.ImageOverlay=Wi,o.LatLng=q,o.LatLngBounds=xt,o.Layer=Nt,o.LayerGroup=ke,o.LineUtil=Qa,o.Map=D,o.Marker=Ii,o.Mixin=Ga,o.Path=ce,o.Point=k,o.PolyUtil=ja,o.Polygon=Oe,o.Polyline=Qt,o.Popup=Ui,o.PosAnimation=us,o.Projection=$a,o.Rectangle=As,o.Renderer=te,o.SVG=hi,o.SVGOverlay=Ms,o.TileLayer=Ze,o.Tooltip=Vi,o.Transformation=pn,o.Util=je,o.VideoOverlay=Ts,o.bind=f,o.bounds=wt,o.canvas=Es,o.circle=ah,o.circleMarker=rh,o.control=ni,o.divIcon=ph,o.extend=h,o.featureGroup=nh,o.geoJSON=bs,o.geoJson=lh,o.gridLayer=gh,o.icon=oh,o.imageOverlay=ch,o.latLng=R,o.latLngBounds=at,o.layerGroup=ih,o.map=Ha,o.marker=sh,o.point=C,o.polygon=uh,o.polyline=hh,o.popup=_h,o.rectangle=wh,o.setOptions=N,o.stamp=_,o.svg=Os,o.svgOverlay=dh,o.tileLayer=Cs,o.tooltip=mh,o.transformation=Je,o.version=r,o.videoOverlay=fh;var Ph=window.L;o.noConflict=function(){return window.L=Ph,this},window.L=o})}(ci,ci.exports)),ci.exports}var ra=il();const nl=Oh(ra),ul=Eh({__proto__:null,default:nl},[ra]);export{ol as a,rl as b,al as c,hl as d,Oh as g,ul as l,sl as m}; diff --git a/fittrackee/emails/exceptions.py b/fittrackee/emails/exceptions.py index 262cc7033..b754f9d88 100644 --- a/fittrackee/emails/exceptions.py +++ b/fittrackee/emails/exceptions.py @@ -1 +1,2 @@ -class InvalidEmailUrlScheme(Exception): ... +class InvalidEmailUrlScheme(Exception): + pass diff --git a/fittrackee/emails/tasks.py b/fittrackee/emails/tasks.py index 8be6608b3..1bd2e9ccf 100644 --- a/fittrackee/emails/tasks.py +++ b/fittrackee/emails/tasks.py @@ -61,3 +61,93 @@ def data_export_email(user: Dict, email_data: Dict) -> None: recipient=user['email'], data=email_data, ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def user_suspension_email(user: Dict, email_data: Dict) -> None: + email_service.send( + template='user_suspension', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def user_unsuspension_email(user: Dict, email_data: Dict) -> None: + email_service.send( + template='user_unsuspension', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def user_warning_email(user: Dict, email_data: Dict) -> None: + email_service.send( + template='user_warning', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def user_warning_lifting_email(user: Dict, email_data: Dict) -> None: + email_service.send( + template='user_warning_lifting', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def comment_suspension_email(user: Dict, email_data: Dict) -> None: + email_service.send( + template='comment_suspension', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def comment_unsuspension_email(user: Dict, email_data: Dict) -> None: + email_service.send( + template='comment_unsuspension', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def workout_suspension_email(user: Dict, email_data: Dict) -> None: + email_service.send( + template='workout_suspension', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def workout_unsuspension_email(user: Dict, email_data: Dict) -> None: + email_service.send( + template='workout_unsuspension', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) + + +@dramatiq.actor(queue_name='fittrackee_emails') +def appeal_rejected_email(user: Dict, email_data: Dict) -> None: + email_service.send( + template='appeal_rejected', + lang=user['language'], + recipient=user['email'], + data=email_data, + ) diff --git a/fittrackee/emails/templates/appeal_rejected/body.html b/fittrackee/emails/templates/appeal_rejected/body.html new file mode 100644 index 000000000..a8021d580 --- /dev/null +++ b/fittrackee/emails/templates/appeal_rejected/body.html @@ -0,0 +1,39 @@ +{% extends "layout.html" %} +{% block title %}{{ _('Warning for %(username)s lifted', username=username) }}{% endblock %} +{% block preheader %}{{ _('Your appeal has been rejected') }}.{% endblock %} +{% block content %}

    {% if action_type == "user_suspension" %}{{ _('Your appeal on your account suspension has been rejected') }}{% elif action_type == "user_warning" %}{{ _('Your appeal on your warning has been rejected') }}{% else %}{{ _('Your appeal on the following content suspension has been rejected') }}{% endif %}.

    + {% if action_type != "user_suspension" and comment_url %} +

    {{ _('Comment:') }}

    + + + + + + + + + + + {% endif %} + {% if action_type != "user_suspension" and workout_url %} +

    {{ _('Workout:') }}

    + + + + + + + + {% if map %} + + {% endif %} + + + + {% endif %}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/appeal_rejected/body.txt b/fittrackee/emails/templates/appeal_rejected/body.txt new file mode 100644 index 000000000..ddd6080b6 --- /dev/null +++ b/fittrackee/emails/templates/appeal_rejected/body.txt @@ -0,0 +1,9 @@ +{% extends "layout.txt" %}{% block content %}{% if action_type == "user_suspension" %}{{ _('Your appeal on your account suspension has been rejected') }}{% elif action_type == "user_warning" %}{{ _('Your appeal on your warning has been rejected') }}{% else %}{{ _('Your appeal on the following content suspension has been rejected') }}{% endif %}.{% if action_type != "user_suspension" and comment_url %} + +{{ _('Comment:') }} {{ text }} + +{{ _('Link:') }} {{ comment_url }}{% endif %}{% if action_type != "user_suspension" and workout_url %} + +{{ _('Workout:') }} {{ title }} + +{{ _('Link:') }} {{ workout_url }}{% endif %}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/appeal_rejected/subject.txt b/fittrackee/emails/templates/appeal_rejected/subject.txt new file mode 100644 index 000000000..567cf2a5e --- /dev/null +++ b/fittrackee/emails/templates/appeal_rejected/subject.txt @@ -0,0 +1 @@ +FitTrackee - {{ _('Appeal rejected') }} \ No newline at end of file diff --git a/fittrackee/emails/templates/comment_suspension/body.html b/fittrackee/emails/templates/comment_suspension/body.html new file mode 100644 index 000000000..344420ecb --- /dev/null +++ b/fittrackee/emails/templates/comment_suspension/body.html @@ -0,0 +1,41 @@ +{% extends "layout.html" %} +{% block title %}{{ _('Your comment has been suspended') }}{% endblock %} +{% block preheader %}{{ _('Your comment has been suspended, it is no longer visible') }}.{% endblock %} +{% block content %}

    {{ _('Your comment has been suspended, it is no longer visible') }}.

    + {% if reason %}

    {{ _('Reason:') }} {{ reason }}

    {% endif %} + + + + + + + + + + + +

    {{ _('If you think this is an error, you can appeal:') }}

    + + + + + {% endblock %} +{% block url_to_paste %} + + + + {% endblock %} diff --git a/fittrackee/emails/templates/comment_suspension/body.txt b/fittrackee/emails/templates/comment_suspension/body.txt new file mode 100644 index 000000000..42c0012fe --- /dev/null +++ b/fittrackee/emails/templates/comment_suspension/body.txt @@ -0,0 +1,8 @@ +{% extends "layout.txt" %}{% block content %}{{ _('Your comment has been suspended, it is no longer visible') }}. + +{% if reason %}{{ _('Reason:') }} {{ reason }} + +{% endif %}{{ _('Comment:') }} {{ text }} + +{{ _('If you think this is an error, you can appeal:') }} +{{ comment_url }}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/comment_suspension/subject.txt b/fittrackee/emails/templates/comment_suspension/subject.txt new file mode 100644 index 000000000..d2d3702ff --- /dev/null +++ b/fittrackee/emails/templates/comment_suspension/subject.txt @@ -0,0 +1 @@ +FitTrackee - {{ _('Your comment has been suspended') }} \ No newline at end of file diff --git a/fittrackee/emails/templates/comment_unsuspension/body.html b/fittrackee/emails/templates/comment_unsuspension/body.html new file mode 100644 index 000000000..1286cecde --- /dev/null +++ b/fittrackee/emails/templates/comment_unsuspension/body.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} +{% block title %}{{ _('The suspension on your comment has been lifted') }}{% endblock %} +{% block preheader %}{{ _('The suspension on your comment has been lifted, it is visible again') }}.{% endblock %} +{% block content %}

    {{ _('The suspension on your comment has been lifted, it is visible again') }}.

    + {% if reason %}

    {{ _('Reason:') }} {{ reason }}

    {% endif %} + + + + + + + + + + + {% endblock %} diff --git a/fittrackee/emails/templates/comment_unsuspension/body.txt b/fittrackee/emails/templates/comment_unsuspension/body.txt new file mode 100644 index 000000000..d424740e5 --- /dev/null +++ b/fittrackee/emails/templates/comment_unsuspension/body.txt @@ -0,0 +1,7 @@ +{% extends "layout.txt" %}{% block content %}{{ _('The suspension on your comment has been lifted, it is visible again') }}. + +{% if reason %}{{ _('Reason:') }} {{ reason }} + +{% endif %}{{ _('Comment:') }} {{ text }} + +{{ _('Link:') }} {{ comment_url }}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/comment_unsuspension/subject.txt b/fittrackee/emails/templates/comment_unsuspension/subject.txt new file mode 100644 index 000000000..5ca1680da --- /dev/null +++ b/fittrackee/emails/templates/comment_unsuspension/subject.txt @@ -0,0 +1 @@ +FitTrackee - {{ _('The suspension on your comment has been lifted') }} \ No newline at end of file diff --git a/fittrackee/emails/templates/layout.html b/fittrackee/emails/templates/layout.html index 41fb87528..748427a41 100644 --- a/fittrackee/emails/templates/layout.html +++ b/fittrackee/emails/templates/layout.html @@ -141,13 +141,30 @@ background-color: #FFFFFF; } - .body-action { - width: 100%; + .body-action, .body-content { margin: 30px auto; + width: 100%; padding: 0; + } + + .body-action { text-align: center; } + .body-content td { + padding: 2.5px; + } + .body-content .content-date { + font-size: 0.9em; + font-style: italic; + } + .body-content .map { + width: 100%; + } + .body-content .user-picture { + max-width: 30px; + } + .body-sub { margin-top: 25px; padding-top: 25px; @@ -218,8 +235,8 @@

    {{ _('Hi %(username)s,', username=username) }}

    {% if operating_system and browser_name %}{{ _('For security, this request was received from a %(operating_system)s device using %(browser_name)s.', operating_system=operating_system, browser_name=browser_name) }} {% endif %}{% block not_initiated %}{% endblock %}

    -

    {{ _('Thanks,') }} -
    {{ _('The FitTrackee Team') }}

    +

    {% if not without_user_action %}{{ _('Thanks,') }} +
    {% endif %}{{ _('The FitTrackee Team') }}

    {% block url_to_paste %}{% endblock %}
    diff --git a/fittrackee/emails/templates/layout.txt b/fittrackee/emails/templates/layout.txt index 22c5d88a9..e93b8c959 100644 --- a/fittrackee/emails/templates/layout.txt +++ b/fittrackee/emails/templates/layout.txt @@ -2,6 +2,6 @@ {% block content %}{% endblock %} -{{ _('Thanks,') }} -{{ _('The FitTrackee Team') }} +{% if not without_user_action %}{{ _('Thanks,') }} +{% endif %}{{ _('The FitTrackee Team') }} {{fittrackee_url}} diff --git a/fittrackee/emails/templates/user_suspension/body.html b/fittrackee/emails/templates/user_suspension/body.html new file mode 100644 index 000000000..d4204e981 --- /dev/null +++ b/fittrackee/emails/templates/user_suspension/body.html @@ -0,0 +1,28 @@ +{% extends "layout.html" %} +{% block title %}{{ _('Your account has been suspended') }}{% endblock %} +{% block preheader %}{{ _('Your account has been suspended') }}. {{ _('You can no longer use your account and your profile is no longer accessible') }}.{% endblock %} +{% block content %}

    {{ _('Your account has been suspended') }}.

    +

    {{ _('You can no longer use your account and your profile is no longer accessible') }}. {{ _('You can still log in to request an export of your data or delete your account') }}.

    + {% if reason %}

    {{ _('Reason:') }} {{ reason }}

    {% endif %} +

    {{ _('If you think this is an error, you can appeal:') }}

    + + + + + {% endblock %} +{% block url_to_paste %} + + + + {% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_suspension/body.txt b/fittrackee/emails/templates/user_suspension/body.txt new file mode 100644 index 000000000..e6f544955 --- /dev/null +++ b/fittrackee/emails/templates/user_suspension/body.txt @@ -0,0 +1,6 @@ +{% extends "layout.txt" %}{% block content %}{{ _('Your account has been suspended') }}. +{{ _('You can no longer use your account and your profile is no longer accessible') }}. {{ _('You can still log in to request an export of your data or delete your account') }}. + +{% if reason %}{{ _('Reason:') }} {{ reason }} + +{% endif %}{{ _('If you think this is an error, you can appeal:') }} {{ appeal_url }}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_suspension/subject.txt b/fittrackee/emails/templates/user_suspension/subject.txt new file mode 100644 index 000000000..9234fe20c --- /dev/null +++ b/fittrackee/emails/templates/user_suspension/subject.txt @@ -0,0 +1 @@ +FitTrackee - {{ _('Your account has been suspended') }} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_unsuspension/body.html b/fittrackee/emails/templates/user_unsuspension/body.html new file mode 100644 index 000000000..af4a55f80 --- /dev/null +++ b/fittrackee/emails/templates/user_unsuspension/body.html @@ -0,0 +1,5 @@ +{% extends "layout.html" %} +{% block title %}{{ _('Your account has been reactivated') }}{% endblock %} +{% block preheader %}{{ _('Your account has been reactivated') }}. {{ _('You can now use all the features on FitTrackee') }}.{% endblock %} +{% block content %}

    {{ _('Your account has been reactivated') }}. {{ _('You can now use all the features on FitTrackee') }}.

    + {% if reason %}

    {{ _('Reason:') }} {{ reason }}

    {% endif %}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_unsuspension/body.txt b/fittrackee/emails/templates/user_unsuspension/body.txt new file mode 100644 index 000000000..db8c35dc6 --- /dev/null +++ b/fittrackee/emails/templates/user_unsuspension/body.txt @@ -0,0 +1,4 @@ +{% extends "layout.txt" %}{% block content %}{{ _('Your account has been reactivated') }}. +{{ _('You can now use all the features on FitTrackee') }}.{% if reason %} + +{{ _('Reason:') }} {{ reason }}{% endif %}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_unsuspension/subject.txt b/fittrackee/emails/templates/user_unsuspension/subject.txt new file mode 100644 index 000000000..f68697065 --- /dev/null +++ b/fittrackee/emails/templates/user_unsuspension/subject.txt @@ -0,0 +1 @@ +FitTrackee - {{ _('Your account has been reactivated') }} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_warning/body.html b/fittrackee/emails/templates/user_warning/body.html new file mode 100644 index 000000000..a763a5d65 --- /dev/null +++ b/fittrackee/emails/templates/user_warning/body.html @@ -0,0 +1,62 @@ +{% extends "layout.html" %} +{% block title %}{{ _('Warning for %(username)s', username=username) }}{% endblock %} +{% block preheader %}{{ _('You received a warning') }}.{% endblock %} +{% block content %}

    {{ _('You received a warning') }}.

    + {% if reason %}

    {{ _('Reason:') }} {{ reason }}

    {% endif %} + {% if comment_url %} +

    {{ _('Comment:') }}

    + + + + + + + + + + + {% endif %} + {% if workout_url %} +

    {{ _('Workout:') }}

    + + + + + + + + {% if map %} + + {% endif %} + + + + {% endif %} +

    {{ _('If you think this is an error, you can appeal:') }}

    + + + + + {% endblock %} + {% block url_to_paste %} + + + + {% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_warning/body.txt b/fittrackee/emails/templates/user_warning/body.txt new file mode 100644 index 000000000..c0318187d --- /dev/null +++ b/fittrackee/emails/templates/user_warning/body.txt @@ -0,0 +1,14 @@ +{% extends "layout.txt" %}{% block content %}{{ _('You received a warning') }}. + +{% if reason %}{{ _('Reason:') }} {{ reason }} + +{% endif %}{% if comment_url %}{{ _('Comment:') }} {{ text }} + +{{ _('Link:') }} {{ comment_url }} + +{% endif %}{% if workout_url %}{{ _('Workout:') }} {{ title }} + +{{ _('Link:') }} {{ workout_url }} + +{% endif %}{{ _('If you think this is an error, you can appeal:') }} +{{ appeal_url }}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_warning/subject.txt b/fittrackee/emails/templates/user_warning/subject.txt new file mode 100644 index 000000000..3cfacf10e --- /dev/null +++ b/fittrackee/emails/templates/user_warning/subject.txt @@ -0,0 +1 @@ +FitTrackee - {{ _('Warning for %(username)s', username=username) }} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_warning_lifting/body.html b/fittrackee/emails/templates/user_warning_lifting/body.html new file mode 100644 index 000000000..b96393a98 --- /dev/null +++ b/fittrackee/emails/templates/user_warning_lifting/body.html @@ -0,0 +1,39 @@ +{% extends "layout.html" %} +{% block title %}{{ _('Warning for %(username)s lifted', username=username) }}{% endblock %} +{% block preheader %}{{ _('Your warning has been lifted') }}.{% endblock %} +{% block content %}

    {{ _('Your warning has been lifted') }}.

    + {% if comment_url %} +

    {{ _('Comment:') }}

    + + + + + + + + + + + {% endif %} + {% if workout_url %} +

    {{ _('Workout:') }}

    + + + + + + + + {% if map %} + + {% endif %} + + + + {% endif %}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_warning_lifting/body.txt b/fittrackee/emails/templates/user_warning_lifting/body.txt new file mode 100644 index 000000000..061bc38df --- /dev/null +++ b/fittrackee/emails/templates/user_warning_lifting/body.txt @@ -0,0 +1,9 @@ +{% extends "layout.txt" %}{% block content %}{{ _('Your warning has been lifted') }}.{% if comment_url %} + +{{ _('Comment:') }} {{ text }} + +{{ _('Link:') }} {{ comment_url }}{% endif %}{% if workout_url %} + +{{ _('Workout:') }} {{ title }} + +{{ _('Link:') }} {{ workout_url }}{% endif %}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/user_warning_lifting/subject.txt b/fittrackee/emails/templates/user_warning_lifting/subject.txt new file mode 100644 index 000000000..0875b631a --- /dev/null +++ b/fittrackee/emails/templates/user_warning_lifting/subject.txt @@ -0,0 +1 @@ +FitTrackee - {{ _('Warning for %(username)s lifted', username=username) }} \ No newline at end of file diff --git a/fittrackee/emails/templates/workout_suspension/body.html b/fittrackee/emails/templates/workout_suspension/body.html new file mode 100644 index 000000000..6a56231ca --- /dev/null +++ b/fittrackee/emails/templates/workout_suspension/body.html @@ -0,0 +1,44 @@ +{% extends "layout.html" %} +{% block title %}{{ _('Your workout has been suspended') }}{% endblock %} +{% block preheader %}{{ _('Your workout has been suspended, it is no longer visible') }}.{% endblock %} +{% block content %}

    {{ _('Your workout has been suspended, it is no longer visible') }}.

    + {% if reason %}

    {{ _('Reason:') }} {{ reason }}

    {% endif %} + + + + + + + + {% if map %} + + {% endif %} + + + + +

    {{ _('If you think this is an error, you can appeal:') }}

    + + + + + {% endblock %} + {% block url_to_paste %} + + + + {% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/workout_suspension/body.txt b/fittrackee/emails/templates/workout_suspension/body.txt new file mode 100644 index 000000000..a60169f8c --- /dev/null +++ b/fittrackee/emails/templates/workout_suspension/body.txt @@ -0,0 +1,8 @@ +{% extends "layout.txt" %}{% block content %}{{ _('Your workout has been suspended, it is no longer visible') }}. + +{% if reason %}{{ _('Reason:') }} {{ reason }} + +{% endif %}{{ _('Workout:') }} {{ title }} + +{{ _('If you think this is an error, you can appeal:') }} +{{ workout_url }}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/workout_suspension/subject.txt b/fittrackee/emails/templates/workout_suspension/subject.txt new file mode 100644 index 000000000..5b7e3e595 --- /dev/null +++ b/fittrackee/emails/templates/workout_suspension/subject.txt @@ -0,0 +1 @@ +FitTrackee - {{ _('Your workout has been suspended') }} \ No newline at end of file diff --git a/fittrackee/emails/templates/workout_unsuspension/body.html b/fittrackee/emails/templates/workout_unsuspension/body.html new file mode 100644 index 000000000..bb13573f9 --- /dev/null +++ b/fittrackee/emails/templates/workout_unsuspension/body.html @@ -0,0 +1,22 @@ +{% extends "layout.html" %} +{% block title %}{{ _('The suspension on your workout has been lifted') }}{% endblock %} +{% block preheader %}{{ _('The suspension on your workout has been lifted, it is visible again') }}.{% endblock %} +{% block content %}

    {{ _('The suspension on your workout has been lifted, it is visible again') }}.

    + {% if reason %}

    {{ _('Reason:') }} {{ reason }}

    {% endif %} + + + + + + + + {% if map %} + + {% endif %} + + + + {% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/workout_unsuspension/body.txt b/fittrackee/emails/templates/workout_unsuspension/body.txt new file mode 100644 index 000000000..015388b5b --- /dev/null +++ b/fittrackee/emails/templates/workout_unsuspension/body.txt @@ -0,0 +1,7 @@ +{% extends "layout.txt" %}{% block content %}{{ _('The suspension on your workout has been lifted, it is visible again') }}. + +{% if reason %}{{ _('Reason:') }} {{ reason }} + +{% endif %}{{ _('Workout:') }} {{ title }} + +{{ _('Link:') }} {{ workout_url }}{% endblock %} \ No newline at end of file diff --git a/fittrackee/emails/templates/workout_unsuspension/subject.txt b/fittrackee/emails/templates/workout_unsuspension/subject.txt new file mode 100644 index 000000000..2ace2557a --- /dev/null +++ b/fittrackee/emails/templates/workout_unsuspension/subject.txt @@ -0,0 +1 @@ +FitTrackee - {{ _('The suspension on your workout has been lifted') }} \ No newline at end of file diff --git a/fittrackee/emails/translations/bg/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/bg/LC_MESSAGES/messages.mo index 0f51d6c6a..69ac918c2 100644 Binary files a/fittrackee/emails/translations/bg/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/bg/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/bg/LC_MESSAGES/messages.po b/fittrackee/emails/translations/bg/LC_MESSAGES/messages.po index 962a7950f..a04ba5298 100644 --- a/fittrackee/emails/translations/bg/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/bg/LC_MESSAGES/messages.po @@ -7,20 +7,19 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-08-28 08:19+0200\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2024-08-30 09:09+0000\n" "Last-Translator: Marto \n" -"Language-Team: Bulgarian \n" "Language: bg\n" +"Language-Team: Bulgarian \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 5.7.1-dev\n" "Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -29,7 +28,7 @@ msgstr "Здравей %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -40,12 +39,12 @@ msgstr "" "От съображения за сигурност тази заявка е получена от устройство " "%(operating_system)s, използващо %(browser_name)s." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Благодаря," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "Екипът на FitTrackee" @@ -81,24 +80,148 @@ msgid "" "If this account creation wasn't initiated by you, please ignore this " "email." msgstr "" -"Ако създаването на този акаунт не е поискано от Вас, моля игнорирайте този " -"имейл." +"Ако създаването на този акаунт не е поискано от Вас, моля игнорирайте " +"този имейл." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." msgstr "" -"Ако виждате проблем с бутона отдолу, копирайте URL-а и го отворете нов таб " -"на браузъра." +"Ако виждате проблем с бутона отдолу, копирайте URL-а и го отворете нов " +"таб на браузъра." #: fittrackee/emails/templates/account_confirmation/body.txt:2 msgid "Use the link below to confirm your address email." msgstr "Използвайте линка отдолу за да потвърдите имейл адреса си." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -129,8 +252,8 @@ 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 @@ -154,9 +277,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 "" -"Ако тази промяна на имейла не е инициирана от вас, моля, променете паролата " -"си незабавно или се свържете с вашия администратор, ако акаунтът ви е " -"заключен." +"Ако тази промяна на имейла не е инициирана от вас, моля, променете " +"паролата си незабавно или се свържете с вашия администратор, ако акаунтът" +" ви е заключен." #: fittrackee/emails/templates/email_update_to_new_email/body.html:2 #: fittrackee/emails/templates/email_update_to_new_email/subject.txt:1 @@ -173,7 +296,8 @@ msgid "" "You recently requested to change your email address for your FitTrackee " "account." msgstr "" -"Наскоро поискахте промяна на вашия имейл адрес за акаунтът ви в FitTrackee." +"Наскоро поискахте промяна на вашия имейл адрес за акаунтът ви в " +"FitTrackee." #: fittrackee/emails/templates/email_update_to_new_email/body.html:4 msgid "Use the button below to confirm this address." @@ -183,8 +307,8 @@ msgstr "Използвайте бутона по-долу, за да потвъ #: 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 "" -"Ако тази промяна на имейла не е инициирана от вас, моля, игнорирайте този " -"имейл." +"Ако тази промяна на имейла не е инициирана от вас, моля, игнорирайте този" +" имейл." #: fittrackee/emails/templates/email_update_to_new_email/body.txt:2 msgid "Use the link below to confirm this address." @@ -225,8 +349,8 @@ msgid "" "Use this link to reset your password. The link is only valid for " "%(expiration_delay)s." msgstr "" -"Използвайте тази връзка, за да нулирате паролата си. Връзката е валидна само " -"за %(expiration_delay)s." +"Използвайте тази връзка, за да нулирате паролата си. Връзката е валидна " +"само за %(expiration_delay)s." #: fittrackee/emails/templates/password_reset_request/body.html:4 #: fittrackee/emails/templates/password_reset_request/body.txt:1 @@ -251,9 +375,86 @@ msgstr "Нулирайте паролата си" #: fittrackee/emails/templates/password_reset_request/body.html:20 #: fittrackee/emails/templates/password_reset_request/body.txt:7 msgid "If you did not request a password reset, please ignore this email." -msgstr "" -"Ако не сте поискали нулиране на паролата си, моля, игнорирайте този имейл." +msgstr "Ако не сте поискали нулиране на паролата си, моля, игнорирайте този имейл." #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "Use the link below to reset it." msgstr "Използвайте връзката по-долу, за да я нулирате." + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/cs/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/cs/LC_MESSAGES/messages.mo index 72073b99a..493cfc555 100644 Binary files a/fittrackee/emails/translations/cs/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/cs/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/cs/LC_MESSAGES/messages.po b/fittrackee/emails/translations/cs/LC_MESSAGES/messages.po index fbf48e248..e31e5bbb5 100644 --- a/fittrackee/emails/translations/cs/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/cs/LC_MESSAGES/messages.po @@ -7,20 +7,19 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-02-10 08:42+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2024-05-12 18:01+0000\n" "Last-Translator: Jozef Mlich \n" -"Language-Team: Czech \n" "Language: cs\n" +"Language-Team: Czech \n" +"Plural-Forms: nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2);\n" -"X-Generator: Weblate 5.5.4\n" -"Generated-By: Babel 2.14.0\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -29,7 +28,7 @@ msgstr "Ahoj %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -37,15 +36,15 @@ msgid "" "For security, this request was received from a %(operating_system)s " "device using %(browser_name)s." msgstr "" -"Kvůli bezpečnosti: Tento požadavek přišel ze zařízení s %(operating_system)s " -"a prohlížeče %(browser_name)s." +"Kvůli bezpečnosti: Tento požadavek přišel ze zařízení s " +"%(operating_system)s a prohlížeče %(browser_name)s." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Díky," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "Tým FitTrackee" @@ -80,24 +79,147 @@ msgstr "Ověřte Váš email" msgid "" "If this account creation wasn't initiated by you, please ignore this " "email." -msgstr "" -"Pokud jste nepožadovali vytvoření účtu, tak prosím ignorujte tento email." +msgstr "Pokud jste nepožadovali vytvoření účtu, tak prosím ignorujte tento email." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." msgstr "" -"Pokud máte potíže s tlačítkem výše, zkopírujte URL níže do Vašeho webového " -"prohlížeče." +"Pokud máte potíže s tlačítkem výše, zkopírujte URL níže do Vašeho " +"webového prohlížeče." #: fittrackee/emails/templates/account_confirmation/body.txt:2 msgid "Use the link below to confirm your address email." msgstr "Použijte odkaz níže pro potvrzení Vaší emailové adresy." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -128,8 +250,8 @@ msgid "" "If you did not request the export, please change your password " "immediately or contact your administrator if your account is locked." msgstr "" -"Pokud jste o export nepožádali, okamžitě si změňte heslo nebo kontaktujte " -"svého správce, pokud je váš účet uzamčen." +"Pokud jste o export nepožádali, okamžitě si změňte heslo nebo kontaktujte" +" svého správce, pokud je váš účet uzamčen." #: fittrackee/emails/templates/email_update_to_current_email/body.html:2 #: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1 @@ -146,7 +268,8 @@ msgid "" "You recently requested to change your email address for your FitTrackee " "account to:" msgstr "" -"Nedávno jste požádali o změnu e-mailové adresy pro svůj účet FitTrackee na:" +"Nedávno jste požádali o změnu e-mailové adresy pro svůj účet FitTrackee " +"na:" #: fittrackee/emails/templates/email_update_to_current_email/body.html:18 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:4 @@ -171,8 +294,7 @@ msgstr "Pomocí tohoto odkazu potvrďte změnu e-mailu." msgid "" "You recently requested to change your email address for your FitTrackee " "account." -msgstr "" -"Nedávno jste požádali o změnu e-mailové adresy pro svůj účet FitTrackee." +msgstr "Nedávno jste požádali o změnu e-mailové adresy pro svůj účet FitTrackee." #: fittrackee/emails/templates/email_update_to_new_email/body.html:4 msgid "Use the button below to confirm this address." @@ -182,7 +304,8 @@ msgstr "Pro potvrzení této adresy použijte tlačítko níže." #: 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 "" -"Pokud jste tuto změnu e-mailu neiniciovali vy, ignorujte prosím tento e-mail." +"Pokud jste tuto změnu e-mailu neiniciovali vy, ignorujte prosím tento " +"e-mail." #: fittrackee/emails/templates/email_update_to_new_email/body.txt:2 msgid "Use the link below to confirm this address." @@ -209,8 +332,8 @@ msgid "" "password immediately or contact your administrator if your account is " "locked." msgstr "" -"Pokud jste tuto změnu hesla neiniciovali vy, okamžitě si heslo změňte, nebo " -"pokud je váš účet uzamčen, kontaktujte svého správce." +"Pokud jste tuto změnu hesla neiniciovali vy, okamžitě si heslo změňte, " +"nebo pokud je váš účet uzamčen, kontaktujte svého správce." #: fittrackee/emails/templates/password_reset_request/body.html:2 #: fittrackee/emails/templates/password_reset_request/subject.txt:1 @@ -239,8 +362,7 @@ msgstr "Pro obnovu použijte tlačítko níže." #: fittrackee/emails/templates/password_reset_request/body.txt:2 #, python-format msgid "This password reset link is only valid for %(expiration_delay)s." -msgstr "" -"Tento odkaz na obnovu hesla je platný pouze po dobu %(expiration_delay)s." +msgstr "Tento odkaz na obnovu hesla je platný pouze po dobu %(expiration_delay)s." #: fittrackee/emails/templates/password_reset_request/body.html:13 #: fittrackee/emails/templates/password_reset_request/body.txt:4 @@ -255,3 +377,81 @@ msgstr "Pokud jste o obnovu hesla nepožádali, ignorujte prosím tento e-mail." #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "Use the link below to reset it." msgstr "Pro obnovu použijte odkaz níže." + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/de/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/de/LC_MESSAGES/messages.mo index 0aa50613e..8536c9a8c 100644 Binary files a/fittrackee/emails/translations/de/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/de/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/de/LC_MESSAGES/messages.po b/fittrackee/emails/translations/de/LC_MESSAGES/messages.po index f637d168e..26b075f05 100644 --- a/fittrackee/emails/translations/de/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/de/LC_MESSAGES/messages.po @@ -7,20 +7,19 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-04 10:33+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2023-03-05 11:17+0000\n" "Last-Translator: qwerty287 \n" -"Language-Team: German \n" "Language: de\n" +"Language-Team: German \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.16.2-dev\n" -"Generated-By: Babel 2.11.0\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -29,7 +28,7 @@ msgstr "Hallo %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -40,12 +39,12 @@ msgstr "" "Zur Sicherheit: Diese Anfrage wurde von einem %(operating_system)s Gerät " "mit %(browser_name)s ausgelöst." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Danke" -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "Dein FitTrackee-Team" @@ -85,9 +84,13 @@ msgstr "" "E-Mail bitte." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." @@ -99,6 +102,126 @@ 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/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -129,8 +252,8 @@ msgid "" "If you did not request the export, please change your password " "immediately or contact your administrator if your account is locked." msgstr "" -"Wenn du den Export nicht angefragt hast, ändere dein Passwort sofort oder " -"frage deinen Administrator, ob dein Account gesperrt ist." +"Wenn du den Export nicht angefragt hast, ändere dein Passwort sofort oder" +" frage deinen Administrator, ob dein Account gesperrt ist." #: fittrackee/emails/templates/email_update_to_current_email/body.html:2 #: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1 @@ -264,3 +387,81 @@ 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." + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/en/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/en/LC_MESSAGES/messages.mo index 213c34d95..69451018c 100644 Binary files a/fittrackee/emails/translations/en/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/en/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/en/LC_MESSAGES/messages.po b/fittrackee/emails/translations/en/LC_MESSAGES/messages.po index 23829c503..1f5119674 100644 --- a/fittrackee/emails/translations/en/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/en/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-04 10:33+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2022-07-02 18:25+0200\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -16,9 +16,9 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.11.0\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -27,7 +27,7 @@ msgstr "Hi %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -38,12 +38,12 @@ msgstr "" "For security, this request was received from a %(operating_system)s " "device using %(browser_name)s." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Thanks," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "The FitTrackee Team" @@ -83,9 +83,13 @@ msgstr "" "email." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." @@ -97,6 +101,126 @@ msgstr "" msgid "Use the link below to confirm your address email." msgstr "Use the link below to confirm your address email." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "Warning for %(username)s lifted" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "Your appeal has been rejected" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "Your appeal on your account suspension has been rejected" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "Your appeal on your warning has been rejected" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "Your appeal on the following content suspension has been rejected" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "Comment:" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "Workout:" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "Link:" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "Appeal rejected" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "Your comment has been suspended" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "Your comment has been suspended, it is no longer visible" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "Reason:" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "If you think this is an error, you can appeal:" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "Appeal" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "The suspension on your comment has been lifted" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +msgstr "The suspension on your comment has been lifted, it is visible again" + #: 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" @@ -256,3 +380,84 @@ msgstr "If you did not request a password reset, please ignore this email." msgid "Use the link below to reset it." msgstr "Use the link below to reset it." +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "Your account has been suspended" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" +"You can no longer use your account and your profile is no longer " +"accessible" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" +"You can still log in to request an export of your data or delete your " +"account" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "Your account has been reactivated" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "You can now use all the features on FitTrackee" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "Warning for %(username)s" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "You received a warning" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "Your warning has been lifted" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "Your workout has been suspended" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "Your workout has been suspended, it is no longer visible" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "The suspension on your workout has been lifted" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "The suspension on your workout has been lifted, it is visible again" + diff --git a/fittrackee/emails/translations/es/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/es/LC_MESSAGES/messages.mo index 6b3edfc5a..23f86c298 100644 Binary files a/fittrackee/emails/translations/es/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/es/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/es/LC_MESSAGES/messages.po b/fittrackee/emails/translations/es/LC_MESSAGES/messages.po index 16a49b6cf..8d161138a 100644 --- a/fittrackee/emails/translations/es/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/es/LC_MESSAGES/messages.po @@ -7,20 +7,19 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-04 10:33+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2023-03-15 13:40+0000\n" "Last-Translator: gallegonovato \n" -"Language-Team: Spanish \n" "Language: es\n" +"Language-Team: Spanish \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.16.2-dev\n" -"Generated-By: Babel 2.12.1\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -29,7 +28,7 @@ msgstr "Hola %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -40,12 +39,12 @@ msgstr "" "Por seguridad, esta solicitud se ha recibido desde un dispositivo " "%(operating_system)s usando %(browser_name)s." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Gracias," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "El Equipo FitTrackee" @@ -81,23 +80,148 @@ msgid "" "If this account creation wasn't initiated by you, please ignore this " "email." msgstr "" -"Si no has iniciado la creación de esta cuenta, por favor ignora este correo." +"Si no has iniciado la creación de esta cuenta, por favor ignora este " +"correo." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." msgstr "" -"Si tienes problemas con el botón superior, copia y pega la URL inferior en " -"tu navegador web." +"Si tienes problemas con el botón superior, copia y pega la URL inferior " +"en tu navegador web." #: fittrackee/emails/templates/account_confirmation/body.txt:2 msgid "Use the link below to confirm your address email." msgstr "Usa la dirección inferior para confirmar tu dirección de correo." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -128,8 +252,8 @@ msgid "" "If you did not request the export, please change your password " "immediately or contact your administrator if your account is locked." msgstr "" -"Si no has solicitado la exportación, cambia inmediatamente la contraseña o " -"contacta con la administración si tu cuenta está bloqueada." +"Si no has solicitado la exportación, cambia inmediatamente la contraseña " +"o contacta con la administración si tu cuenta está bloqueada." #: fittrackee/emails/templates/email_update_to_current_email/body.html:2 #: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1 @@ -146,8 +270,8 @@ msgid "" "You recently requested to change your email address for your FitTrackee " "account to:" msgstr "" -"Recientemente has solicitado cambiar tu dirección de correo en tu instancia " -"FitTrackee a:" +"Recientemente has solicitado cambiar tu dirección de correo en tu " +"instancia FitTrackee a:" #: fittrackee/emails/templates/email_update_to_current_email/body.html:18 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:4 @@ -155,8 +279,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 "" -"Si no has solicitado este cambio de email, por favor cambia inmediatamente " -"tu contraseña o contacta con la administración si tu cuenta está bloqueada." +"Si no has solicitado este cambio de email, por favor cambia " +"inmediatamente tu contraseña o contacta con la administración si tu " +"cuenta está bloqueada." #: fittrackee/emails/templates/email_update_to_new_email/body.html:2 #: fittrackee/emails/templates/email_update_to_new_email/subject.txt:1 @@ -210,8 +335,9 @@ msgid "" "password immediately or contact your administrator if your account is " "locked." msgstr "" -"Si no has iniciado este cambio de contraseña, por favor cambia la contraseña " -"ahora mismo o contacta con la administración si la cuenta está bloqueada." +"Si no has iniciado este cambio de contraseña, por favor cambia la " +"contraseña ahora mismo o contacta con la administración si la cuenta está" +" bloqueada." #: fittrackee/emails/templates/password_reset_request/body.html:2 #: fittrackee/emails/templates/password_reset_request/subject.txt:1 @@ -224,14 +350,15 @@ msgid "" "Use this link to reset your password. The link is only valid for " "%(expiration_delay)s." msgstr "" -"Usa este enlace para cambiar tu contraseña. Este enlace será válido durante " -"%(expiration_delay)s." +"Usa este enlace para cambiar tu contraseña. Este enlace será válido " +"durante %(expiration_delay)s." #: fittrackee/emails/templates/password_reset_request/body.html:4 #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "You recently requested to reset your password for your FitTrackee account." msgstr "" -"Recientemente has solicitado cambiar la contraseña de tu cuenta FitTrackee." +"Recientemente has solicitado cambiar la contraseña de tu cuenta " +"FitTrackee." #: fittrackee/emails/templates/password_reset_request/body.html:4 msgid "Use the button below to reset it." @@ -258,3 +385,81 @@ msgstr "Si no has solicitado restablecer la contraseña, ignora este email." #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "Use the link below to reset it." msgstr "Usa el enlace inferior para restablecerla." + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "Tu contraseña ha sido cambiada." + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/eu/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/eu/LC_MESSAGES/messages.mo index 3ea498dca..7188b1539 100644 Binary files a/fittrackee/emails/translations/eu/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/eu/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/eu/LC_MESSAGES/messages.po b/fittrackee/emails/translations/eu/LC_MESSAGES/messages.po index 50900282f..4abd9253f 100644 --- a/fittrackee/emails/translations/eu/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/eu/LC_MESSAGES/messages.po @@ -7,20 +7,19 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-02-10 08:42+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2024-02-10 12:33+0000\n" "Last-Translator: Mikel Larreategi \n" -"Language-Team: Basque \n" "Language: eu\n" +"Language-Team: Basque \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 5.4-dev\n" -"Generated-By: Babel 2.14.0\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -29,7 +28,7 @@ msgstr "Kaixo %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -40,12 +39,12 @@ msgstr "" "Segurtasunagatik, eskaera hau %(browser_name)s nabigatzailea duen " "%(operating_system)s gailu batetik jaso dela jakinarazten dizugu." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Eskerrik asko," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "FitTrackee taldea" @@ -83,9 +82,13 @@ msgid "" msgstr "Kontu honen sorrera zuk ez baduzu eskatu, ez egin kasu mezu honi." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." @@ -97,6 +100,126 @@ msgstr "" msgid "Use the link below to confirm your address email." msgstr "Erabili beheko esteka zure eposta helbidea baieztatzeko." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -127,8 +250,8 @@ msgid "" "If you did not request the export, please change your password " "immediately or contact your administrator if your account is locked." msgstr "" -"Zuk ez baduzu esportazioa eskatu, aldatu berehala zure pasahitza edo jarri " -"kontaktuan kudeatzailearekin zure kontua blokeatuta badago." +"Zuk ez baduzu esportazioa eskatu, aldatu berehala zure pasahitza edo " +"jarri kontaktuan kudeatzailearekin zure kontua blokeatuta badago." #: fittrackee/emails/templates/email_update_to_current_email/body.html:2 #: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1 @@ -145,8 +268,8 @@ msgid "" "You recently requested to change your email address for your FitTrackee " "account to:" msgstr "" -"Zure FitTrackee kontuko eposta helbidea aldatzeko eskatu duzu. Berria hauxe " -"da:" +"Zure FitTrackee kontuko eposta helbidea aldatzeko eskatu duzu. Berria " +"hauxe da:" #: fittrackee/emails/templates/email_update_to_current_email/body.html:18 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:4 @@ -154,8 +277,8 @@ 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 "" -"Eposta aldaketa hau zuk ez baduzu eskatu, aldatu berehala zure pasahitza edo " -"jarri kontaktuan kudeatzailearekin zure kontua blokeatuta badago." +"Eposta aldaketa hau zuk ez baduzu eskatu, aldatu berehala zure pasahitza " +"edo jarri kontaktuan kudeatzailearekin zure kontua blokeatuta badago." #: fittrackee/emails/templates/email_update_to_new_email/body.html:2 #: fittrackee/emails/templates/email_update_to_new_email/subject.txt:1 @@ -207,8 +330,9 @@ msgid "" "password immediately or contact your administrator if your account is " "locked." msgstr "" -"Pasahitz aldaketa hau zuk eskatu ez baduzu, aldatu zure pasahitza berehala " -"edo jarri kontaktuan kudeatzailearekin zure kontua blokeatuta badago." +"Pasahitz aldaketa hau zuk eskatu ez baduzu, aldatu zure pasahitza " +"berehala edo jarri kontaktuan kudeatzailearekin zure kontua blokeatuta " +"badago." #: fittrackee/emails/templates/password_reset_request/body.html:2 #: fittrackee/emails/templates/password_reset_request/subject.txt:1 @@ -221,14 +345,13 @@ msgid "" "Use this link to reset your password. The link is only valid for " "%(expiration_delay)s." msgstr "" -"Erabili esteka hau zure pasahitza berrezartzeko. Esteka %(expiration_delay)s " -"barru iraungiko da." +"Erabili esteka hau zure pasahitza berrezartzeko. Esteka " +"%(expiration_delay)s barru iraungiko da." #: fittrackee/emails/templates/password_reset_request/body.html:4 #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "You recently requested to reset your password for your FitTrackee account." -msgstr "" -"Zure FitTrackee kontuko pasahitza berrezartzeko eskaera egin duzu berriki." +msgstr "Zure FitTrackee kontuko pasahitza berrezartzeko eskaera egin duzu berriki." #: fittrackee/emails/templates/password_reset_request/body.html:4 msgid "Use the button below to reset it." @@ -253,3 +376,81 @@ msgstr "Zuk ez baduzu pasahitza berrezartzea eskatu, ez egin kasu mezu honi." #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "Use the link below to reset it." msgstr "Erabili beheko esteka berrezartzeko." + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/fr/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/fr/LC_MESSAGES/messages.mo index 7955dddec..296d936b9 100644 Binary files a/fittrackee/emails/translations/fr/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/fr/LC_MESSAGES/messages.po b/fittrackee/emails/translations/fr/LC_MESSAGES/messages.po index 004bfe43c..bddaf9e18 100644 --- a/fittrackee/emails/translations/fr/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/fr/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-04 10:33+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2022-07-04 21:17+0000\n" "Last-Translator: J. Lavoie \n" "Language: fr\n" @@ -17,9 +17,9 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.11.0\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -28,7 +28,7 @@ msgstr "Bonjour %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -39,12 +39,12 @@ msgstr "" "Pour vérification, cette demande a été reçue à partir d'un appareil sous " "%(operating_system)s, utilisant le navigateur %(browser_name)s." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Merci," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "L'équipe FitTrackee" @@ -84,9 +84,13 @@ msgstr "" "ignorer ce courriel." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." @@ -98,6 +102,126 @@ msgstr "" 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/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "Avertissement levé pour %(username)s" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "Votre appel a été rejeté" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "Votre appel sur la suspension de votre compte a été rejeté" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "Votre appel sur votre avertissement été rejeté" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "Votre appel sur la suspension du contenu suivant a été rejeté" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "Commentaire :" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "Séance :" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "Lien :" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "Appel rejeté" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "Votre commentaire a été suspendu" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "Votre commentaire a été suspendu, il n'est plus visible" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "Raison :" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "Si vous pensez qu'il s'agit d'une erreur, vous pouvez faire appel :" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "Faire appel" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "La suspension de votre commentaire a été levée" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +msgstr "La suspension de votre commentaire a été levée, il est visible à nouveau" + #: 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" @@ -128,9 +252,9 @@ msgid "" "If you did not request the export, please change your password " "immediately or contact your administrator if your account is locked." msgstr "" -"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é." +"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 @@ -267,3 +391,84 @@ msgstr "" msgid "Use the link below to reset it." msgstr "Cliquez sur le lien ci-dessous pour le réinitialiser." +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "Votre compte a été suspendu" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" +"Vous ne pouvez plus utiliser votre compte et votre profil n'est plus " +"accessible" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" +"Vous pouvez toujours vous connecter pour demander un export de vos " +"données ou supprimer votre compte" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "Votre compte a été réactivé" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "Vous pouvez désormais utiliser toutes les fonctionnalités de FitTrackee" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "Avertissement pour %(username)s" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "Vous avez reçu un avertissement" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "Votre avertissement a été levé" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "Votre séance a été suspendue" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "Votre séance a été suspendue, elle n'est plus visible" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "La suspension de votre séance a été levée" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "La suspension de votre séance a été levée, elle est visible à nouveau" + diff --git a/fittrackee/emails/translations/gl/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/gl/LC_MESSAGES/messages.mo index 0d8ece08c..5b1dad7d3 100644 Binary files a/fittrackee/emails/translations/gl/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/gl/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/gl/LC_MESSAGES/messages.po b/fittrackee/emails/translations/gl/LC_MESSAGES/messages.po index 7ded8afe2..24cfe0aff 100644 --- a/fittrackee/emails/translations/gl/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/gl/LC_MESSAGES/messages.po @@ -7,20 +7,19 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-04 10:33+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2024-07-05 13:09+0000\n" "Last-Translator: \"josé m.\" \n" -"Language-Team: Galician \n" "Language: gl\n" +"Language-Team: Galician \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 5.7-dev\n" -"Generated-By: Babel 2.12.1\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -29,7 +28,7 @@ msgstr "Ola %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -40,12 +39,12 @@ msgstr "" "Por seguridade, recibiuse a solicitude desde un dispositivo " "%(operating_system)s usando %(browser_name)s." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Grazas," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "O Equipo FitTrackee" @@ -80,13 +79,16 @@ msgstr "Verifica o enderezo de correo" msgid "" "If this account creation wasn't initiated by you, please ignore this " "email." -msgstr "" -"Se non iniciaches ti a creación da conta, ignora este correo electrónico." +msgstr "Se non iniciaches ti a creación da conta, ignora este correo electrónico." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." @@ -98,6 +100,126 @@ msgstr "" msgid "Use the link below to confirm your address email." msgstr "Usa a ligazón inferior para confirmar o teu enderezo de correo." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -230,8 +352,7 @@ msgstr "" #: fittrackee/emails/templates/password_reset_request/body.html:4 #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "You recently requested to reset your password for your FitTrackee account." -msgstr "" -"Recentemente solicitaches cambiar o contrasinal da túa conta FitTrackee." +msgstr "Recentemente solicitaches cambiar o contrasinal da túa conta FitTrackee." #: fittrackee/emails/templates/password_reset_request/body.html:4 msgid "Use the button below to reset it." @@ -258,3 +379,81 @@ msgstr "Se non solicitaches restablecer o contrasinal, ignora este correo." #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "Use the link below to reset it." msgstr "Usa a ligazón inferior para restablecelo." + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/it/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/it/LC_MESSAGES/messages.mo index b1c6ada7b..fb8f915cc 100644 Binary files a/fittrackee/emails/translations/it/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/it/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/it/LC_MESSAGES/messages.po b/fittrackee/emails/translations/it/LC_MESSAGES/messages.po index dfaad04f6..90286c268 100644 --- a/fittrackee/emails/translations/it/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/it/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-04 10:33+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2022-12-12 19:48+0000\n" "Last-Translator: Donato Perruso \n" "Language: it\n" @@ -17,9 +17,9 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.11.0\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -28,7 +28,7 @@ msgstr "Ciao %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -39,12 +39,12 @@ msgstr "" "Per sicurezza, questa richiesta è stata ricevuta da %(operating_system)s " "utilizzando il browser %(browser_name)s." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Grazie," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "Il Team FitTrackee" @@ -84,9 +84,13 @@ msgstr "" "ignora quest'email." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." @@ -98,6 +102,126 @@ msgstr "" msgid "Use the link below to confirm your address email." msgstr "Usa il link qui sotto per confermare la tua email." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -260,3 +384,80 @@ msgstr "" msgid "Use the link below to reset it." msgstr "Usa il link qui sotto per resettarla." +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/nb/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/nb/LC_MESSAGES/messages.mo index 1466549af..849dfa25a 100644 Binary files a/fittrackee/emails/translations/nb/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/nb/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/nb/LC_MESSAGES/messages.po b/fittrackee/emails/translations/nb/LC_MESSAGES/messages.po index a125e01a0..baf71b104 100644 --- a/fittrackee/emails/translations/nb/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/nb/LC_MESSAGES/messages.po @@ -7,20 +7,20 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-04 10:33+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2023-10-18 04:01+0000\n" "Last-Translator: Allan Nordhøy \n" -"Language-Team: Norwegian Bokmål \n" "Language: nb\n" +"Language-Team: Norwegian Bokmål " +"\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 5.1\n" -"Generated-By: Babel 2.11.0\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -29,7 +29,7 @@ msgstr "Hei %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -37,15 +37,15 @@ msgid "" "For security, this request was received from a %(operating_system)s " "device using %(browser_name)s." msgstr "" -"Av sikkerhetshensyn kan det nevnes at denne forespørselen ble mottatt fra en " -"%(operating_system)s-enhet som bruker %(browser_name)s." +"Av sikkerhetshensyn kan det nevnes at denne forespørselen ble mottatt fra" +" en %(operating_system)s-enhet som bruker %(browser_name)s." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Takk," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "FitTrackee-laget" @@ -65,7 +65,6 @@ msgid "You have created an account on FitTrackee." msgstr "Du har opprettet en FitTrackee-konto." #: fittrackee/emails/templates/account_confirmation/body.html:4 -#, fuzzy msgid "Use the button below to confirm your address email." msgstr "Bruk knappen nedenfor for å bekrefte din e-postadresse." @@ -84,9 +83,13 @@ msgid "" msgstr "" #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." @@ -96,6 +99,126 @@ msgstr "" msgid "Use the link below to confirm your address email." msgstr "" +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -152,7 +275,6 @@ msgstr "" #: fittrackee/emails/templates/email_update_to_new_email/body.html:2 #: fittrackee/emails/templates/email_update_to_new_email/subject.txt:1 -#, fuzzy msgid "Confirm email change" msgstr "Bekreft e-postbytte" @@ -168,7 +290,6 @@ msgid "" msgstr "" #: fittrackee/emails/templates/email_update_to_new_email/body.html:4 -#, fuzzy msgid "Use the button below to confirm this address." msgstr "Bekreft denne adressen med knappen nedenfor." @@ -187,7 +308,6 @@ msgid "Password changed" msgstr "Passord endret" #: fittrackee/emails/templates/password_change/body.html:3 -#, fuzzy msgid "Your password has been changed." msgstr "Passord endret." @@ -244,3 +364,81 @@ msgstr "" #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "Use the link below to reset it." msgstr "Bruk lenken nedenfor for å tilbakestille det." + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/nl/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/nl/LC_MESSAGES/messages.mo index 1615a64e6..4441e84e4 100644 Binary files a/fittrackee/emails/translations/nl/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/nl/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/nl/LC_MESSAGES/messages.po b/fittrackee/emails/translations/nl/LC_MESSAGES/messages.po index 168a1ec22..aafed929a 100644 --- a/fittrackee/emails/translations/nl/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/nl/LC_MESSAGES/messages.po @@ -7,20 +7,19 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-04 10:33+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2023-03-05 07:12+0000\n" "Last-Translator: bjornclauw \n" -"Language-Team: Dutch \n" "Language: nl\n" +"Language-Team: Dutch \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.16.2-dev\n" -"Generated-By: Babel 2.11.0\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -29,7 +28,7 @@ msgstr "Dag %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -40,12 +39,12 @@ msgstr "" "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.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Bedankt," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "Het FitTrackee Team" @@ -83,9 +82,13 @@ msgid "" msgstr "Indien u deze account niet hebt aangemaakt, gelieve deze email te negeren." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." @@ -97,6 +100,126 @@ msgstr "" msgid "Use the link below to confirm your address email." msgstr "Gebruik de onderstaande link om uw email adres te bevestigen." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -127,9 +250,9 @@ msgid "" "If you did not request the export, please change your password " "immediately or contact your administrator if your account is locked." msgstr "" -"Als u deze export niet hebt aangevraagd, gelieve uw wachtwoord onmiddelijk " -"te veranderen of neem contact op met uw administrator als uw account is " -"vergrendeld." +"Als u deze export niet hebt aangevraagd, gelieve uw wachtwoord " +"onmiddelijk te veranderen of neem contact op met uw administrator als uw " +"account is vergrendeld." #: fittrackee/emails/templates/email_update_to_current_email/body.html:2 #: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1 @@ -265,3 +388,81 @@ msgstr "" #: 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." + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/pl/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/pl/LC_MESSAGES/messages.mo index e89e45213..65c2a69bc 100644 Binary files a/fittrackee/emails/translations/pl/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/pl/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/pl/LC_MESSAGES/messages.po b/fittrackee/emails/translations/pl/LC_MESSAGES/messages.po index 9af116104..8477d99f1 100644 --- a/fittrackee/emails/translations/pl/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/pl/LC_MESSAGES/messages.po @@ -7,21 +7,20 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2023-03-04 10:33+0100\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2023-06-17 20:51+0000\n" "Last-Translator: Mariusz \n" -"Language-Team: Polish \n" "Language: pl\n" +"Language-Team: Polish \n" +"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && " +"(n%100<10 || n%100>=20) ? 1 : 2;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " -"|| n%100>=20) ? 1 : 2;\n" -"X-Generator: Weblate 4.18.1\n" -"Generated-By: Babel 2.12.1\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -30,7 +29,7 @@ msgstr "Cześć %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -41,12 +40,12 @@ msgstr "" "Dla bezpieczeństwa, to żądanie zostało wysłane z %(operating_system)s " "używającego %(browser_name)s." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Dzięki," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "Ekipa FitTrackee" @@ -84,20 +83,144 @@ msgid "" msgstr "Jeśli nie zlecałeś(-aś) utworzenia konta, proszę zignoruj tę wiadomość." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." msgstr "" -"Jeśli masz problem z powyższym przyciskiem, skopiuj i wklej poniższy adres " -"do swojej przeglądarki." +"Jeśli masz problem z powyższym przyciskiem, skopiuj i wklej poniższy " +"adres do swojej przeglądarki." #: fittrackee/emails/templates/account_confirmation/body.txt:2 msgid "Use the link below to confirm your address email." msgstr "Użyj poniższego linka by potwierdzić swój adres e-mail." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -146,7 +269,8 @@ msgid "" "You recently requested to change your email address for your FitTrackee " "account to:" msgstr "" -"Zleciłeś(-aś) niedawno zmianę swojego adresu e-mail do konta FitTrackee na:" +"Zleciłeś(-aś) niedawno zmianę swojego adresu e-mail do konta FitTrackee " +"na:" #: fittrackee/emails/templates/email_update_to_current_email/body.html:18 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:4 @@ -154,8 +278,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 "" -"Jeśli nie zlecałeś(-aś) zmiany adresu e-mail, zmień hasło do swojego konta " -"lub skontaktuj się z administratorem, jeśli twoje konto jest zablokowane." +"Jeśli nie zlecałeś(-aś) zmiany adresu e-mail, zmień hasło do swojego " +"konta lub skontaktuj się z administratorem, jeśli twoje konto jest " +"zablokowane." #: fittrackee/emails/templates/email_update_to_new_email/body.html:2 #: fittrackee/emails/templates/email_update_to_new_email/subject.txt:1 @@ -171,8 +296,7 @@ msgstr "Użyj tego linku aby potwierdzić zmianę adresu e-mail." msgid "" "You recently requested to change your email address for your FitTrackee " "account." -msgstr "" -"Zleciłeś(-aś) niedawno zmianę swojego adresu e-mail do konta FitTrackee." +msgstr "Zleciłeś(-aś) niedawno zmianę swojego adresu e-mail do konta FitTrackee." #: fittrackee/emails/templates/email_update_to_new_email/body.html:4 msgid "Use the button below to confirm this address." @@ -182,7 +306,8 @@ msgstr "Użyj poniższego przycisku aby potwierdzić ten adres." #: 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 "" -"Jeśli nie zlecałeś(-aś) zmiany adresu e-mail, zignoruj proszę tę wiadomość." +"Jeśli nie zlecałeś(-aś) zmiany adresu e-mail, zignoruj proszę tę " +"wiadomość." #: fittrackee/emails/templates/email_update_to_new_email/body.txt:2 msgid "Use the link below to confirm this address." @@ -254,3 +379,81 @@ msgstr "Jeśli nie zlecałeś(-aś) resetu hasła, zignoruj proszę tę wiadomo #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "Use the link below to reset it." msgstr "Użyj poniższego linku aby je zresetować." + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/pt/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/pt/LC_MESSAGES/messages.mo index 7e8fef411..fc1a3a7b7 100644 Binary files a/fittrackee/emails/translations/pt/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/pt/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/pt/LC_MESSAGES/messages.po b/fittrackee/emails/translations/pt/LC_MESSAGES/messages.po index c2fea6537..1a22d2d29 100644 --- a/fittrackee/emails/translations/pt/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/pt/LC_MESSAGES/messages.po @@ -7,20 +7,19 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-04-28 13:39+0200\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2024-05-20 18:32+0000\n" "Last-Translator: Ricardo Madeira \n" -"Language-Team: Portuguese \n" "Language: pt\n" +"Language-Team: Portuguese \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 5.6-dev\n" -"Generated-By: Babel 2.15.0\n" +"Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -29,7 +28,7 @@ msgstr "Olá %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -40,12 +39,12 @@ msgstr "" "Por segurança, esta solicitação foi recebida de um dispositivo com " "%(operating_system)s usando %(browser_name)s." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Obrigado," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "A equipe FitTrackee" @@ -83,20 +82,144 @@ msgid "" msgstr "Se você ainda não começou a criar esta conta, ignore este e-mail." #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." msgstr "" -"Se estiver com problemas com o botão superior, copie e cole o URL inferior " -"em seu navegador." +"Se estiver com problemas com o botão superior, copie e cole o URL " +"inferior em seu navegador." #: fittrackee/emails/templates/account_confirmation/body.txt:2 msgid "Use the link below to confirm your address email." msgstr "Use o endereço abaixo para confirmar seu endereço de e-mail." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -127,8 +250,8 @@ msgid "" "If you did not request the export, please change your password " "immediately or contact your administrator if your account is locked." msgstr "" -"Caso não tenha solicitado a exportação, altere imediatamente sua senha ou " -"entre em contato com a administração caso sua conta esteja bloqueada." +"Caso não tenha solicitado a exportação, altere imediatamente sua senha ou" +" entre em contato com a administração caso sua conta esteja bloqueada." #: fittrackee/emails/templates/email_update_to_current_email/body.html:2 #: fittrackee/emails/templates/email_update_to_current_email/subject.txt:1 @@ -145,8 +268,8 @@ msgid "" "You recently requested to change your email address for your FitTrackee " "account to:" msgstr "" -"Recentemente, solicitou a alteração do seu endereço de e-mail na conta do " -"FitTrackee para:" +"Recentemente, solicitou a alteração do seu endereço de e-mail na conta do" +" FitTrackee para:" #: fittrackee/emails/templates/email_update_to_current_email/body.html:18 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:4 @@ -154,8 +277,8 @@ 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 "" -"Se não solicitou esta alteração do e-mail, altere sua senha imediatamente ou " -"entre em contato com a administração caso sua conta esteja bloqueada." +"Se não solicitou esta alteração do e-mail, altere sua senha imediatamente" +" ou entre em contato com a administração caso sua conta esteja bloqueada." #: fittrackee/emails/templates/email_update_to_new_email/body.html:2 #: fittrackee/emails/templates/email_update_to_new_email/subject.txt:1 @@ -209,8 +332,8 @@ msgid "" "password immediately or contact your administrator if your account is " "locked." msgstr "" -"Se ainda não iniciou esta alteração de senha, altere a senha agora mesmo ou " -"entre em contato com a administração se a conta estiver bloqueada." +"Se ainda não iniciou esta alteração de senha, altere a senha agora mesmo " +"ou entre em contato com a administração se a conta estiver bloqueada." #: fittrackee/emails/templates/password_reset_request/body.html:2 #: fittrackee/emails/templates/password_reset_request/subject.txt:1 @@ -239,8 +362,7 @@ msgstr "Use o botão inferior para redefini-lo." #: fittrackee/emails/templates/password_reset_request/body.txt:2 #, python-format msgid "This password reset link is only valid for %(expiration_delay)s." -msgstr "" -"Este link de redefinição de senha só será válido por %(expiration_delay)s." +msgstr "Este link de redefinição de senha só será válido por %(expiration_delay)s." #: fittrackee/emails/templates/password_reset_request/body.html:13 #: fittrackee/emails/templates/password_reset_request/body.txt:4 @@ -255,3 +377,81 @@ msgstr "Se não solicitou uma redefinição de senha, ignore este e-mail." #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "Use the link below to reset it." msgstr "Use o link abaixo para redefini-lo." + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/emails/translations/ru/LC_MESSAGES/messages.mo b/fittrackee/emails/translations/ru/LC_MESSAGES/messages.mo index 2bac46e75..a0934acf9 100644 Binary files a/fittrackee/emails/translations/ru/LC_MESSAGES/messages.mo and b/fittrackee/emails/translations/ru/LC_MESSAGES/messages.mo differ diff --git a/fittrackee/emails/translations/ru/LC_MESSAGES/messages.po b/fittrackee/emails/translations/ru/LC_MESSAGES/messages.po index 23dba9310..5f0bc1cf2 100644 --- a/fittrackee/emails/translations/ru/LC_MESSAGES/messages.po +++ b/fittrackee/emails/translations/ru/LC_MESSAGES/messages.po @@ -7,21 +7,20 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-08-28 08:19+0200\n" +"POT-Creation-Date: 2024-11-01 19:45+0100\n" "PO-Revision-Date: 2024-10-06 23:29+0000\n" "Last-Translator: Shura \n" -"Language-Team: Russian \n" "Language: ru\n" +"Language-Team: Russian \n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " -"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -"X-Generator: Weblate 5.8-dev\n" "Generated-By: Babel 2.16.0\n" -#: fittrackee/emails/templates/layout.html:215 +#: fittrackee/emails/templates/layout.html:232 #: fittrackee/emails/templates/layout.txt:1 #, python-format msgid "Hi %(username)s," @@ -30,7 +29,7 @@ msgstr "Привет %(username)s," #: fittrackee/emails/templates/account_confirmation/body.txt:6 #: fittrackee/emails/templates/email_update_to_current_email/body.txt:3 #: fittrackee/emails/templates/email_update_to_new_email/body.txt:6 -#: fittrackee/emails/templates/layout.html:218 +#: fittrackee/emails/templates/layout.html:235 #: fittrackee/emails/templates/password_change/body.txt:3 #: fittrackee/emails/templates/password_reset_request/body.txt:6 #, python-format @@ -38,15 +37,15 @@ msgid "" "For security, this request was received from a %(operating_system)s " "device using %(browser_name)s." msgstr "" -"Для безопасности, этот запрос был получен через %(browser_name), работающий " -"под управлением %(operating_system)." +"Для безопасности, этот запрос был получен через %(browser_name), " +"работающий под управлением %(operating_system)." -#: fittrackee/emails/templates/layout.html:221 +#: fittrackee/emails/templates/layout.html:238 #: fittrackee/emails/templates/layout.txt:5 msgid "Thanks," msgstr "Спасибо," -#: fittrackee/emails/templates/layout.html:222 +#: fittrackee/emails/templates/layout.html:239 #: fittrackee/emails/templates/layout.txt:6 msgid "The FitTrackee Team" msgstr "Команда FitTrackee" @@ -84,9 +83,13 @@ msgid "" msgstr "" #: fittrackee/emails/templates/account_confirmation/body.html:22 +#: fittrackee/emails/templates/comment_suspension/body.html:37 #: 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 +#: fittrackee/emails/templates/user_suspension/body.html:24 +#: fittrackee/emails/templates/user_warning/body.html:58 +#: fittrackee/emails/templates/workout_suspension/body.html:40 msgid "" "If you're having trouble with the button above, copy and paste the URL " "below into your web browser." @@ -96,6 +99,126 @@ msgstr "" msgid "Use the link below to confirm your address email." msgstr "Используйте ссылку ниже, чтобы подтвердить ваш адрес электронной почты." +#: fittrackee/emails/templates/appeal_rejected/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/body.html:2 +#: fittrackee/emails/templates/user_warning_lifting/subject.txt:1 +#, python-format +msgid "Warning for %(username)s lifted" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:3 +msgid "Your appeal has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your account suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on your warning has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:4 +#: fittrackee/emails/templates/appeal_rejected/body.txt:1 +msgid "Your appeal on the following content suspension has been rejected" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:6 +#: fittrackee/emails/templates/appeal_rejected/body.txt:3 +#: fittrackee/emails/templates/comment_suspension/body.txt:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:5 +#: fittrackee/emails/templates/user_warning/body.html:7 +#: fittrackee/emails/templates/user_warning/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.html:6 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:3 +msgid "Comment:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.html:22 +#: fittrackee/emails/templates/appeal_rejected/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.html:23 +#: fittrackee/emails/templates/user_warning/body.txt:9 +#: fittrackee/emails/templates/user_warning_lifting/body.html:22 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:7 +#: fittrackee/emails/templates/workout_suspension/body.txt:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:5 +msgid "Workout:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/body.txt:5 +#: fittrackee/emails/templates/appeal_rejected/body.txt:9 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:7 +#: fittrackee/emails/templates/user_warning/body.txt:11 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:5 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:9 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:7 +msgid "Link:" +msgstr "" + +#: fittrackee/emails/templates/appeal_rejected/subject.txt:1 +msgid "Appeal rejected" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:2 +#: fittrackee/emails/templates/comment_suspension/subject.txt:1 +msgid "Your comment has been suspended" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:3 +#: fittrackee/emails/templates/comment_suspension/body.html:4 +#: fittrackee/emails/templates/comment_suspension/body.txt:1 +msgid "Your comment has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:5 +#: fittrackee/emails/templates/comment_suspension/body.txt:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:5 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:3 +#: fittrackee/emails/templates/user_suspension/body.html:6 +#: fittrackee/emails/templates/user_suspension/body.txt:4 +#: fittrackee/emails/templates/user_unsuspension/body.html:5 +#: fittrackee/emails/templates/user_unsuspension/body.txt:4 +#: fittrackee/emails/templates/user_warning/body.html:5 +#: fittrackee/emails/templates/user_warning/body.txt:3 +#: fittrackee/emails/templates/workout_suspension/body.html:5 +#: fittrackee/emails/templates/workout_suspension/body.txt:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:5 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:3 +msgid "Reason:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:20 +#: fittrackee/emails/templates/comment_suspension/body.txt:7 +#: fittrackee/emails/templates/user_suspension/body.html:7 +#: fittrackee/emails/templates/user_suspension/body.txt:6 +#: fittrackee/emails/templates/user_warning/body.html:41 +#: fittrackee/emails/templates/user_warning/body.txt:13 +#: fittrackee/emails/templates/workout_suspension/body.html:23 +#: fittrackee/emails/templates/workout_suspension/body.txt:7 +msgid "If you think this is an error, you can appeal:" +msgstr "" + +#: fittrackee/emails/templates/comment_suspension/body.html:27 +#: fittrackee/emails/templates/user_suspension/body.html:14 +#: fittrackee/emails/templates/user_warning/body.html:48 +#: fittrackee/emails/templates/workout_suspension/body.html:30 +msgid "Appeal" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:2 +#: fittrackee/emails/templates/comment_unsuspension/subject.txt:1 +msgid "The suspension on your comment has been lifted" +msgstr "" + +#: fittrackee/emails/templates/comment_unsuspension/body.html:3 +#: fittrackee/emails/templates/comment_unsuspension/body.html:4 +#: fittrackee/emails/templates/comment_unsuspension/body.txt:1 +msgid "The suspension on your comment has been lifted, it is visible again" +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" @@ -237,8 +360,87 @@ msgstr "Сбросить пароль" #: fittrackee/emails/templates/password_reset_request/body.txt:7 msgid "If you did not request a password reset, please ignore this email." msgstr "" -"Если вы не запрашивали сброс пароля, пожалуйста, проигнорируйте это письмо." +"Если вы не запрашивали сброс пароля, пожалуйста, проигнорируйте это " +"письмо." #: fittrackee/emails/templates/password_reset_request/body.txt:1 msgid "Use the link below to reset it." msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:2 +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:4 +#: fittrackee/emails/templates/user_suspension/body.txt:1 +#: fittrackee/emails/templates/user_suspension/subject.txt:1 +msgid "Your account has been suspended" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:3 +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can no longer use your account and your profile is no longer " +"accessible" +msgstr "" + +#: fittrackee/emails/templates/user_suspension/body.html:5 +#: fittrackee/emails/templates/user_suspension/body.txt:2 +msgid "" +"You can still log in to request an export of your data or delete your " +"account" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:2 +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:1 +#: fittrackee/emails/templates/user_unsuspension/subject.txt:1 +msgid "Your account has been reactivated" +msgstr "" + +#: fittrackee/emails/templates/user_unsuspension/body.html:3 +#: fittrackee/emails/templates/user_unsuspension/body.html:4 +#: fittrackee/emails/templates/user_unsuspension/body.txt:2 +msgid "You can now use all the features on FitTrackee" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:2 +#: fittrackee/emails/templates/user_warning/subject.txt:1 +#, python-format +msgid "Warning for %(username)s" +msgstr "" + +#: fittrackee/emails/templates/user_warning/body.html:3 +#: fittrackee/emails/templates/user_warning/body.html:4 +#: fittrackee/emails/templates/user_warning/body.txt:1 +msgid "You received a warning" +msgstr "" + +#: fittrackee/emails/templates/user_warning_lifting/body.html:3 +#: fittrackee/emails/templates/user_warning_lifting/body.html:4 +#: fittrackee/emails/templates/user_warning_lifting/body.txt:1 +msgid "Your warning has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:2 +#: fittrackee/emails/templates/workout_suspension/subject.txt:1 +msgid "Your workout has been suspended" +msgstr "" + +#: fittrackee/emails/templates/workout_suspension/body.html:3 +#: fittrackee/emails/templates/workout_suspension/body.html:4 +#: fittrackee/emails/templates/workout_suspension/body.txt:1 +msgid "Your workout has been suspended, it is no longer visible" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:2 +#: fittrackee/emails/templates/workout_unsuspension/subject.txt:1 +msgid "The suspension on your workout has been lifted" +msgstr "" + +#: fittrackee/emails/templates/workout_unsuspension/body.html:3 +#: fittrackee/emails/templates/workout_unsuspension/body.html:4 +#: fittrackee/emails/templates/workout_unsuspension/body.txt:1 +msgid "The suspension on your workout has been lifted, it is visible again" +msgstr "" + diff --git a/fittrackee/equipments/equipment_types.py b/fittrackee/equipments/equipment_types.py index a5aa5ae54..4e527a501 100644 --- a/fittrackee/equipments/equipment_types.py +++ b/fittrackee/equipments/equipment_types.py @@ -12,6 +12,7 @@ handle_error_and_return_response, ) from fittrackee.users.models import User +from fittrackee.users.roles import UserRole from ..equipments.models import EquipmentType @@ -19,10 +20,12 @@ @equipment_types_blueprint.route('/equipment-types', methods=['GET']) -@require_auth(scopes=['equipments:read']) +@require_auth(scopes=['equipments:read'], allow_suspended_user=True) def get_equipment_types(auth_user: User) -> Dict: """ - Get all types of equipment + Get all types of equipment. + + Suspended user can access this endpoint. **Scope**: ``equipments:read`` @@ -144,7 +147,7 @@ def get_equipment_types(auth_user: User) -> Dict: equipment_types = ( EquipmentType.query.filter( EquipmentType.is_active == True # noqa - if not auth_user.admin + if not auth_user.has_admin_rights else True ) .order_by(EquipmentType.id) @@ -154,7 +157,7 @@ def get_equipment_types(auth_user: User) -> Dict: for equipment_type in equipment_types: equipment_types_data.append( equipment_type.serialize( - is_admin=auth_user.admin, + is_admin=auth_user.has_admin_rights, ) ) return { @@ -248,7 +251,9 @@ def get_equipment_type( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``equipment_type not found`` """ @@ -256,14 +261,17 @@ def get_equipment_type( id=equipment_type_id ).first() if equipment_type: - if equipment_type.is_active is False and not auth_user.admin: + if ( + equipment_type.is_active is False + and not auth_user.has_admin_rights + ): return DataNotFoundErrorResponse('equipment_types') return { 'status': 'success', 'data': { 'equipment_types': [ equipment_type.serialize( - is_admin=auth_user.admin, + is_admin=auth_user.has_admin_rights, ) ] }, @@ -274,17 +282,17 @@ def get_equipment_type( @equipment_types_blueprint.route( '/equipment-types/', methods=['PATCH'] ) -@require_auth(scopes=['equipments:write'], as_admin=True) +@require_auth(scopes=['equipments:write'], role=UserRole.ADMIN) def update_equipment_type( auth_user: User, equipment_type_id: int ) -> Union[Dict, HttpResponse]: """ Update a type of equipment to (de)activate it. - Authenticated user must be an admin. - **Scope**: ``equipments:write`` + **Minimum role**: Administrator + **Example request**: .. sourcecode:: http @@ -327,7 +335,9 @@ def update_equipment_type( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``equipment_type not found`` :statuscode 500: ``error, please try again or contact the administrator`` @@ -349,7 +359,7 @@ def update_equipment_type( 'data': { 'equipment_types': [ equipment_type.serialize( - is_admin=auth_user.admin, + is_admin=auth_user.has_admin_rights, ) ] }, diff --git a/fittrackee/equipments/equipments.py b/fittrackee/equipments/equipments.py index 3d53fae81..4f55809e5 100644 --- a/fittrackee/equipments/equipments.py +++ b/fittrackee/equipments/equipments.py @@ -68,12 +68,14 @@ def handle_default_sports( @equipments_blueprint.route('/equipments', methods=['GET']) -@require_auth(scopes=['equipments:read']) +@require_auth(scopes=['equipments:read'], allow_suspended_user=True) def get_equipments(auth_user: User) -> Dict: """ Get all user equipments. Only the equipment owner can see his equipment. + Suspended user can access this endpoint. + **Scope**: ``equipments:read`` **Example request**: @@ -150,7 +152,8 @@ def get_equipments(auth_user: User) -> Dict: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` + :statuscode 403: + - ``you do not have permissions`` """ params = request.args.copy() @@ -176,7 +179,7 @@ def get_equipments(auth_user: User) -> Dict: @equipments_blueprint.route( '/equipments/', methods=['GET'] ) -@require_auth(scopes=['equipments:read']) +@require_auth(scopes=['equipments:read'], allow_suspended_user=True) def get_equipment_by_id( auth_user: User, equipment_short_id: str ) -> Union[Dict, HttpResponse]: @@ -184,6 +187,8 @@ def get_equipment_by_id( Get an equipment item. Only the equipment owner can see his equipment. + Suspended user can access this endpoint. + **Scope**: ``equipments:read`` **Example request**: @@ -251,7 +256,8 @@ def get_equipment_by_id( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` + :statuscode 403: + - ``you do not have permissions`` :statuscode 404: ``equipment not found`` """ @@ -343,7 +349,9 @@ def post_equipment(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``equipment not found`` :statuscode 500: ``Error during equipment save`` """ @@ -540,7 +548,9 @@ def update_equipment( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``equipment not found`` :statuscode 500: ``Error during equipment update`` @@ -778,7 +788,9 @@ def refresh_equipment( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``equipment not found`` :statuscode 500: ``Error during equipment save`` """ @@ -881,6 +893,7 @@ def delete_equipment( - ``invalid token, please log in again`` :statuscode 403: - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` - ``you cannot delete equipment that has workouts associated with it without 'force' parameter`` :statuscode 404: ``equipment not found`` diff --git a/fittrackee/equipments/exceptions.py b/fittrackee/equipments/exceptions.py index 2975471a1..dc81b90de 100644 --- a/fittrackee/equipments/exceptions.py +++ b/fittrackee/equipments/exceptions.py @@ -8,4 +8,5 @@ def __init__( self.equipment_id = equipment_short_id -class InvalidEquipmentsException(Exception): ... +class InvalidEquipmentsException(Exception): + pass diff --git a/fittrackee/languages.py b/fittrackee/languages.py new file mode 100644 index 000000000..8c0c1cb60 --- /dev/null +++ b/fittrackee/languages.py @@ -0,0 +1,18 @@ +LANGUAGES_DATE_STRING = { + "bg": "d MMM yyyy", # Bulgarian + "cs": "d. MMM yyyy", # Czech + "de": "d. MMM yyyy", # German + "en": "MMM. d, yyyy", # English + "es": "d MMM yyyy", # Spanish + "eu": "yyyy MMM d", # Basque + "fr": "d MMM yyyy", # French + "gl": "d MMM yyyy", # Galician + "it": "d MMM yyyy", # Italian + "nb": "d. MMM yyyy", # Norwegian Bokmål + "nl": "d MMM. yyyy", # Dutch + "pl": "d MMM yyyy", # Polish + "pt": "d MMM yyyy", # Portuguese + "ru": "d MMM yyyy", # Russian +} + +SUPPORTED_LANGUAGES = list(LANGUAGES_DATE_STRING.keys()) diff --git a/fittrackee/migrations/versions/42_aa7802092404_init_social_features.py b/fittrackee/migrations/versions/42_aa7802092404_init_social_features.py new file mode 100644 index 000000000..8a8abfe87 --- /dev/null +++ b/fittrackee/migrations/versions/42_aa7802092404_init_social_features.py @@ -0,0 +1,554 @@ +"""init social features + +Revision ID: aa7802092404 +Revises: 4d51a4ca8001 +Create Date: 2023-04-13 11:28:53.769936 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +from fittrackee.users.roles import UserRole + +# revision identifiers, used by Alembic. +revision = 'aa7802092404' +down_revision = '6988082918f8' +branch_labels = None +depends_on = None + + +visibility_levels = postgresql.ENUM( + 'PUBLIC', + 'FOLLOWERS_AND_REMOTE', # for a next version, not used for now + 'FOLLOWERS', + 'PRIVATE', + name='visibility_levels', +) + + +def upgrade(): + visibility_levels.create(op.get_bind()) + + op.create_table( + 'follow_requests', + sa.Column('follower_user_id', sa.Integer(), nullable=False), + sa.Column('followed_user_id', sa.Integer(), nullable=False), + sa.Column('is_approved', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ['followed_user_id'], + ['users.id'], + ), + sa.ForeignKeyConstraint( + ['follower_user_id'], + ['users.id'], + ), + sa.PrimaryKeyConstraint('follower_user_id', 'followed_user_id'), + ) + op.create_table( + 'comments', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('uuid', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('workout_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('modification_date', sa.DateTime(), nullable=True), + sa.Column('text', sa.String(), nullable=False), + sa.Column('suspended_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint( + ['workout_id'], ['workouts.id'], ondelete='SET NULL' + ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('uuid'), + ) + + op.add_column( + 'comments', + sa.Column( + 'text_visibility', + visibility_levels, + server_default='PRIVATE', + nullable=False, + ), + ) + + with op.batch_alter_table('comments', schema=None) as batch_op: + batch_op.create_index( + batch_op.f('ix_comments_user_id'), ['user_id'], unique=False + ) + batch_op.create_index( + batch_op.f('ix_comments_workout_id'), ['workout_id'], unique=False + ) + + op.create_table( + 'workout_likes', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('workout_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint( + ['workout_id'], ['workouts.id'], ondelete='CASCADE' + ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint( + 'user_id', 'workout_id', name='user_id_workout_id_unique' + ), + ) + op.create_table( + 'comment_likes', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('comment_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ['comment_id'], ['comments.id'], ondelete='CASCADE' + ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint( + 'user_id', 'comment_id', name='user_id_comment_id_unique' + ), + ) + op.create_table( + 'mentions', + sa.Column('comment_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ['comment_id'], ['comments.id'], ondelete='CASCADE' + ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('comment_id', 'user_id'), + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + 'manually_approves_followers', sa.Boolean(), nullable=True + ) + ) + batch_op.add_column( + sa.Column( + 'hide_profile_in_users_directory', sa.Boolean(), nullable=True + ) + ) + batch_op.add_column( + sa.Column( + 'workouts_visibility', + visibility_levels, + server_default='PRIVATE', + nullable=True, + ) + ) + batch_op.add_column( + sa.Column( + 'map_visibility', + visibility_levels, + server_default='PRIVATE', + nullable=True, + ) + ) + batch_op.add_column( + sa.Column('suspended_at', sa.DateTime(), nullable=True) + ) + batch_op.add_column(sa.Column('role', sa.Integer(), nullable=True)) + user_helper = sa.Table( + 'users', + sa.MetaData(), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('admin', sa.String(length=10), nullable=False), + ) + connection = op.get_bind() + for user in connection.execute(user_helper.select()): + op.execute(f""" + UPDATE users + SET manually_approves_followers = True, + hide_profile_in_users_directory = True, + workouts_visibility = 'PRIVATE', + map_visibility = 'PRIVATE', + role = + CASE + WHEN {user.admin} IS TRUE THEN {UserRole.ADMIN.value} + ELSE {UserRole.USER.value} + END + WHERE users.id = {user.id} + """) + op.alter_column('users', 'manually_approves_followers', nullable=False) + op.alter_column('users', 'hide_profile_in_users_directory', nullable=False) + op.alter_column('users', 'workouts_visibility', nullable=False) + op.alter_column('users', 'map_visibility', nullable=False) + op.alter_column('users', 'role', nullable=False) + op.create_check_constraint( + 'ck_users_role', + 'users', + f"role IN ({', '.join(UserRole.db_values())})", + ) + op.drop_column('users', 'admin') + + with op.batch_alter_table('workouts', schema=None) as batch_op: + batch_op.add_column( + sa.Column( + 'workout_visibility', + visibility_levels, + server_default='PRIVATE', + nullable=True, + ) + ) + batch_op.add_column( + sa.Column( + 'map_visibility', + visibility_levels, + server_default='PRIVATE', + nullable=True, + ) + ) + batch_op.add_column( + sa.Column('suspended_at', sa.DateTime(), nullable=True) + ) + op.execute( + "UPDATE workouts " + "SET workout_visibility = 'PRIVATE', " + " map_visibility = 'PRIVATE' " + ) + op.alter_column('workouts', 'workout_visibility', nullable=False) + op.alter_column('workouts', 'map_visibility', nullable=False) + + op.create_table( + 'notifications', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('from_user_id', sa.Integer(), nullable=True), + sa.Column('to_user_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('marked_as_read', sa.Boolean(), nullable=False), + sa.Column('event_object_id', sa.Integer(), nullable=True), + sa.Column('event_type', sa.String(length=50), nullable=False), + sa.ForeignKeyConstraint( + ['from_user_id'], ['users.id'], ondelete='CASCADE' + ), + sa.ForeignKeyConstraint( + ['to_user_id'], ['users.id'], ondelete='CASCADE' + ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint( + 'from_user_id', + 'to_user_id', + 'event_type', + 'event_object_id', + name='users_event_unique', + ), + ) + with op.batch_alter_table('notifications', schema=None) as batch_op: + batch_op.create_index( + batch_op.f('ix_notifications_from_user_id'), + ['from_user_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_notifications_to_user_id'), + ['to_user_id'], + unique=False, + ) + + op.create_table( + 'blocked_users', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('by_user_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ['by_user_id'], ['users.id'], ondelete='CASCADE' + ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint( + 'user_id', 'by_user_id', name='blocked_users_unique' + ), + ) + with op.batch_alter_table('blocked_users', schema=None) as batch_op: + batch_op.create_index( + batch_op.f('ix_blocked_users_by_user_id'), + ['by_user_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_blocked_users_user_id'), ['user_id'], unique=False + ) + + op.create_table( + 'reports', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('resolved_at', sa.DateTime(), nullable=True), + sa.Column('reported_by', sa.Integer(), nullable=True), + sa.Column('reported_comment_id', sa.Integer(), nullable=True), + sa.Column('reported_user_id', sa.Integer(), nullable=True), + sa.Column('reported_workout_id', sa.Integer(), nullable=True), + sa.Column('resolved_by', sa.Integer(), nullable=True), + sa.Column('resolved', sa.Boolean(), nullable=False), + sa.Column('object_type', sa.String(length=50), nullable=False), + sa.Column('note', sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ['reported_by'], ['users.id'], ondelete='SET NULL' + ), + sa.ForeignKeyConstraint( + ['reported_comment_id'], ['comments.id'], ondelete='SET NULL' + ), + sa.ForeignKeyConstraint( + ['reported_user_id'], ['users.id'], ondelete='SET NULL' + ), + sa.ForeignKeyConstraint( + ['reported_workout_id'], ['workouts.id'], ondelete='SET NULL' + ), + sa.ForeignKeyConstraint( + ['resolved_by'], ['users.id'], ondelete='SET NULL' + ), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('reports', schema=None) as batch_op: + batch_op.create_index( + batch_op.f('ix_reports_reported_by'), ['reported_by'], unique=False + ) + batch_op.create_index( + batch_op.f('ix_reports_reported_comment_id'), + ['reported_comment_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_reports_reported_user_id'), + ['reported_user_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_reports_reported_workout_id'), + ['reported_workout_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_reports_resolved_by'), ['resolved_by'], unique=False + ) + batch_op.create_index( + batch_op.f('ix_reports_object_type'), ['object_type'], unique=False + ) + + op.create_table( + 'report_comments', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('report_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('comment', sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ['report_id'], ['reports.id'], ondelete='CASCADE' + ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('report_comments', schema=None) as batch_op: + batch_op.create_index( + batch_op.f('ix_report_comments_report_id'), + ['report_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_report_comments_user_id'), ['user_id'], unique=False + ) + + with op.batch_alter_table('records', schema=None) as batch_op: + batch_op.create_index( + 'workout_records', ['workout_id', 'record_type'], unique=False + ) + + op.create_table( + 'report_actions', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('uuid', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('moderator_id', sa.Integer(), nullable=True), + sa.Column('report_id', sa.Integer(), nullable=False), + sa.Column('comment_id', sa.Integer(), nullable=True), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('workout_id', sa.Integer(), nullable=True), + sa.Column('action_type', sa.String(length=50), nullable=False), + sa.Column('reason', sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ['moderator_id'], ['users.id'], ondelete='SET NULL' + ), + sa.ForeignKeyConstraint( + ['report_id'], ['reports.id'], ondelete='CASCADE' + ), + sa.ForeignKeyConstraint( + ['comment_id'], ['comments.id'], ondelete='SET NULL' + ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint( + ['workout_id'], ['workouts.id'], ondelete='SET NULL' + ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('uuid'), + ) + with op.batch_alter_table('report_actions', schema=None) as batch_op: + batch_op.create_index( + batch_op.f('ix_report_actions_moderator_id'), + ['moderator_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_report_actions_report_id'), + ['report_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_report_actions_comment_id'), + ['comment_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_report_actions_user_id'), ['user_id'], unique=False + ) + batch_op.create_index( + batch_op.f('ix_report_actions_workout_id'), + ['workout_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_report_actions_created_at'), + ['created_at'], + unique=False, + ) + + op.create_table( + 'report_action_appeals', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('uuid', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('action_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('moderator_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('approved', sa.Boolean(), nullable=True), + sa.Column('text', sa.String(), nullable=False), + sa.Column('reason', sa.String(), nullable=True), + sa.ForeignKeyConstraint( + ['action_id'], ['report_actions.id'], ondelete='CASCADE' + ), + sa.ForeignKeyConstraint( + ['moderator_id'], ['users.id'], ondelete='SET NULL' + ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint( + 'action_id', 'user_id', name='action_id_user_id_unique' + ), + sa.UniqueConstraint('uuid'), + ) + with op.batch_alter_table('report_action_appeals', schema=None) as batch_op: + batch_op.create_index( + batch_op.f('ix_report_action_appeals_action_id'), + ['action_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_report_action_appeals_moderator_id'), + ['moderator_id'], + unique=False, + ) + batch_op.create_index( + batch_op.f('ix_report_action_appeals_user_id'), + ['user_id'], + unique=False, + ) + + +def downgrade(): + with op.batch_alter_table('report_action_appeals', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_report_action_appeals_user_id')) + batch_op.drop_index( + batch_op.f('ix_report_action_appeals_moderator_id') + ) + batch_op.drop_index(batch_op.f('ix_report_action_appeals_action_id')) + + op.drop_table('report_action_appeals') + + with op.batch_alter_table('report_actions', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_report_actions_created_at')) + batch_op.drop_index(batch_op.f('ix_report_actions_user_id')) + batch_op.drop_index(batch_op.f('ix_report_actions_report_id')) + batch_op.drop_index(batch_op.f('ix_report_actions_moderator_id')) + + op.drop_table('report_actions') + + with op.batch_alter_table('records', schema=None) as batch_op: + batch_op.drop_index('workout_records') + + with op.batch_alter_table('report_comments', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_report_comments_user_id')) + batch_op.drop_index(batch_op.f('ix_report_comments_report_id')) + + op.drop_table('report_comments') + with op.batch_alter_table('reports', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_reports_object_type')) + batch_op.drop_index(batch_op.f('ix_reports_resolved_by')) + batch_op.drop_index(batch_op.f('ix_reports_reported_workout_id')) + batch_op.drop_index(batch_op.f('ix_reports_reported_user_id')) + batch_op.drop_index(batch_op.f('ix_reports_reported_comment_id')) + batch_op.drop_index(batch_op.f('ix_reports_reported_by')) + + op.drop_table('reports') + + with op.batch_alter_table('blocked_users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_blocked_users_user_id')) + batch_op.drop_index(batch_op.f('ix_blocked_users_by_user_id')) + + op.drop_table('blocked_users') + + with op.batch_alter_table('notifications', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_notifications_to_user_id')) + batch_op.drop_index(batch_op.f('ix_notifications_from_user_id')) + op.drop_table('notifications') + + with op.batch_alter_table('workouts', schema=None) as batch_op: + batch_op.drop_column('map_visibility') + batch_op.drop_column('workout_visibility') + batch_op.drop_column('suspended_at') + + op.add_column('users', sa.Column('admin', sa.Boolean(), nullable=True)) + batch_op.drop_constraint(batch_op.f('ck_users_role')) + + user_helper = sa.Table( + 'users', + sa.MetaData(), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('role', sa.Integer(), nullable=False), + ) + connection = op.get_bind() + for user in connection.execute(user_helper.select()): + op.execute(f""" + UPDATE users + SET admin = {user.role} <> {UserRole.USER.value} + WHERE users.id = {user.id} + """) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('admin', nullable=False) + batch_op.drop_column('role') + batch_op.drop_column('suspended_at') + batch_op.drop_column('map_visibility') + batch_op.drop_column('workouts_visibility') + batch_op.drop_column('hide_profile_in_users_directory') + batch_op.drop_column('manually_approves_followers') + + op.drop_table('mentions') + op.drop_table('comment_likes') + op.drop_table('workout_likes') + with op.batch_alter_table('comments', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_comments_workout_id')) + batch_op.drop_index(batch_op.f('ix_comments_user_id')) + + op.drop_table('comments') + op.drop_table('follow_requests') + visibility_levels.drop(op.get_bind()) diff --git a/fittrackee/oauth2/client.py b/fittrackee/oauth2/client.py index 43a6cd654..fe47957c5 100644 --- a/fittrackee/oauth2/client.py +++ b/fittrackee/oauth2/client.py @@ -12,8 +12,14 @@ 'application:write', 'equipments:read', 'equipments:write', + 'follow:read', + 'follow:write', + 'notifications:read', + 'notifications:write', 'profile:read', 'profile:write', + 'reports:read', + 'reports:write', 'users:read', 'users:write', 'workouts:read', diff --git a/fittrackee/oauth2/exceptions.py b/fittrackee/oauth2/exceptions.py index 4d6429e44..0daf2b9a7 100644 --- a/fittrackee/oauth2/exceptions.py +++ b/fittrackee/oauth2/exceptions.py @@ -1 +1,2 @@ -class InvalidOAuth2Scopes(Exception): ... +class InvalidOAuth2Scopes(Exception): + pass diff --git a/fittrackee/oauth2/resource_protector.py b/fittrackee/oauth2/resource_protector.py index 4a3e6ebdb..f2597d619 100644 --- a/fittrackee/oauth2/resource_protector.py +++ b/fittrackee/oauth2/resource_protector.py @@ -13,6 +13,7 @@ UnauthorizedErrorResponse, ) from fittrackee.users.models import User +from fittrackee.users.roles import UserRole class CustomResourceProtector(ResourceProtector): @@ -21,60 +22,86 @@ def __call__( scopes: Union[str, List, None] = None, optional: bool = False, *, - as_admin: bool = False, + role: Union[UserRole, None] = None, + optional_auth_user: bool = False, + allow_suspended_user: bool = False, ) -> Callable: def wrapper(f: Callable) -> Callable: @wraps(f) def decorated(*args: Any, **kwargs: Any) -> Callable: auth_user = None auth_header = request.headers.get('Authorization') - if not auth_header: + if not optional_auth_user and not auth_header: return UnauthorizedErrorResponse( 'provide a valid auth token' ) - # First-party application (Fittrackee front-end) - # in this case, scopes will be ignored - auth_token = auth_header.split(' ')[1] - resp = User.decode_auth_token(auth_token) - if isinstance(resp, int): - auth_user = User.query.filter_by(id=resp).first() + if auth_header: + # First-party application (Fittrackee front-end) + # in this case, scopes will be ignored + auth_token = auth_header.split(' ')[1] + resp = User.decode_auth_token(auth_token) + if isinstance(resp, int): + auth_user = User.query.filter_by(id=resp).first() - # Third-party applications - if not auth_user: - current_token = None - try: - current_token = self.acquire_token(scopes) - except MissingAuthorizationError as error: - self.raise_error_response(error) - except OAuth2Error as error: - self.raise_error_response(error) - except RequestEntityTooLarge: - file_type = '' - if request.endpoint in [ - 'auth.edit_picture', - 'workouts.post_workout', - ]: - file_type = ( - 'picture' - if request.endpoint == 'auth.edit_picture' - else 'workout' + # Third-party applications + if not auth_user and scopes: + current_token = None + try: + current_token = self.acquire_token(scopes) + except ( + MissingAuthorizationError, + OAuth2Error, + ) as error: + self.raise_error_response(error) + except RequestEntityTooLarge: + file_type = '' + if request.endpoint in [ + 'auth.edit_picture', + 'workouts.post_workout', + ]: + file_type = ( + 'picture' + if request.endpoint == 'auth.edit_picture' + else 'workout' + ) + return PayloadTooLargeErrorResponse( + file_type=file_type, + file_size=request.content_length, + max_size=current_app.config[ + 'MAX_CONTENT_LENGTH' + ], ) - return PayloadTooLargeErrorResponse( - file_type=file_type, - file_size=request.content_length, - max_size=current_app.config['MAX_CONTENT_LENGTH'], + auth_user = ( + None + if current_token is None + else current_token.user ) - auth_user = ( - None if current_token is None else current_token.user - ) - if not auth_user or not auth_user.is_active: + if ( + not optional_auth_user + and not auth_user + or (auth_user and not auth_user.is_active) + ): return UnauthorizedErrorResponse( 'provide a valid auth token' ) - if as_admin and not auth_user.admin: - return ForbiddenErrorResponse() + + if auth_user and ( + ( + allow_suspended_user is False + and auth_user.suspended_at is not None + ) + or (role and auth_user.role < role.value) + ): + return ForbiddenErrorResponse( + 'you do not have permissions' + + ( + ', your account is suspended' + if auth_user and auth_user.suspended_at + else '' + ) + ) return f(auth_user, *args, **kwargs) return decorated diff --git a/fittrackee/oauth2/routes.py b/fittrackee/oauth2/routes.py index 269e136c9..9f35dc7fa 100644 --- a/fittrackee/oauth2/routes.py +++ b/fittrackee/oauth2/routes.py @@ -36,7 +36,7 @@ def is_errored(url: str) -> Optional[str]: @oauth2_blueprint.route('/oauth/apps', methods=['GET']) -@require_auth() +@require_auth(allow_suspended_user=True) def get_clients(auth_user: User) -> Dict: """ Get OAuth2 clients (apps) for authenticated user with pagination @@ -45,6 +45,8 @@ def get_clients(auth_user: User) -> Dict: This endpoint is only accessible by FitTrackee client (first-party application). + Suspended user can access this endpoint. + **Example request**: - without parameters: @@ -187,6 +189,8 @@ def create_client(auth_user: User) -> Union[HttpResponse, Tuple[Dict, int]]: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` """ client_metadata = request.get_json() if not client_metadata: @@ -248,7 +252,7 @@ def get_client( @oauth2_blueprint.route( '/oauth/apps/', methods=['GET'] ) -@require_auth() +@require_auth(allow_suspended_user=True) def get_client_by_client_id( auth_user: User, client_client_id: str ) -> Union[Dict, HttpResponse]: @@ -258,6 +262,8 @@ def get_client_by_client_id( This endpoint is only accessible by FitTrackee client (first-party application). + Suspended user can access this endpoint. + **Example request**: .. sourcecode:: http @@ -321,7 +327,7 @@ def get_client_by_client_id( @oauth2_blueprint.route('/oauth/apps//by_id', methods=['GET']) -@require_auth() +@require_auth(allow_suspended_user=True) def get_client_by_id( auth_user: User, client_id: int ) -> Union[Dict, HttpResponse]: @@ -331,6 +337,8 @@ def get_client_by_id( This endpoint is only accessible by FitTrackee client (first-party application). + Suspended user can access this endpoint. + **Example request**: .. sourcecode:: http @@ -392,7 +400,7 @@ def get_client_by_id( @oauth2_blueprint.route('/oauth/apps/', methods=['DELETE']) -@require_auth() +@require_auth(allow_suspended_user=True) def delete_client( auth_user: User, client_id: int ) -> Union[Tuple[Dict, int], HttpResponse]: @@ -402,6 +410,8 @@ def delete_client( This endpoint is only accessible by FitTrackee client (first-party application). + Suspended user can access this endpoint. + **Example request**: .. sourcecode:: http @@ -441,7 +451,7 @@ def delete_client( @oauth2_blueprint.route('/oauth/apps//revoke', methods=['POST']) -@require_auth() +@require_auth(allow_suspended_user=True) def revoke_client_tokens( auth_user: User, client_id: int ) -> Union[Dict, HttpResponse]: @@ -451,6 +461,8 @@ def revoke_client_tokens( This endpoint is only accessible by FitTrackee client (first-party application). + Suspended user can access this endpoint. + **Example request**: .. sourcecode:: http @@ -541,6 +553,8 @@ def authorize(auth_user: User) -> Union[HttpResponse, Dict]: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` """ data = request.form if ( diff --git a/fittrackee/reports/__init__.py b/fittrackee/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/reports/exceptions.py b/fittrackee/reports/exceptions.py new file mode 100644 index 000000000..4d418b01d --- /dev/null +++ b/fittrackee/reports/exceptions.py @@ -0,0 +1,48 @@ +class InvalidReportActionAppealException(Exception): + pass + + +class InvalidReportActionAppealUserException(Exception): + pass + + +class InvalidReportActionException(Exception): + pass + + +class InvalidReportException(Exception): + pass + + +class InvalidReporterException(Exception): + pass + + +class ReportActionAppealForbiddenException(Exception): + pass + + +class ReportActionForbiddenException(Exception): + pass + + +class ReportCommentForbiddenException(Exception): + pass + + +class ReportForbiddenException(Exception): + pass + + +class ReportNotFoundException(Exception): + pass + + +class SuspendedObjectException(Exception): + def __init__(self, object_type: str) -> None: + super().__init__(f'{object_type} already suspended') + self.object_type = object_type + + +class UserWarningExistsException(Exception): + pass diff --git a/fittrackee/reports/models.py b/fittrackee/reports/models.py new file mode 100644 index 000000000..314beaeeb --- /dev/null +++ b/fittrackee/reports/models.py @@ -0,0 +1,657 @@ +from datetime import datetime +from typing import Dict, Optional, Union +from uuid import uuid4 + +from sqlalchemy.dialects import postgresql +from sqlalchemy.engine.base import Connection +from sqlalchemy.event import listens_for +from sqlalchemy.orm import Mapper, Session + +from fittrackee import BaseModel, db +from fittrackee.comments.exceptions import CommentForbiddenException +from fittrackee.comments.models import Comment +from fittrackee.users.models import Notification, User +from fittrackee.users.roles import UserRole +from fittrackee.utils import encode_uuid +from fittrackee.workouts.exceptions import WorkoutForbiddenException +from fittrackee.workouts.models import Workout + +from .exceptions import ( + InvalidReportActionAppealException, + InvalidReportActionAppealUserException, + InvalidReportActionException, + InvalidReporterException, + InvalidReportException, + ReportActionAppealForbiddenException, + ReportActionForbiddenException, + ReportCommentForbiddenException, + ReportForbiddenException, +) + +REPORT_OBJECT_TYPES = [ + "comment", + "user", + "workout", +] +REPORT_ACTION_TYPES = [ + "report_reopening", + "report_resolution", +] +USER_ACTION_TYPES = [ + "user_suspension", + "user_unsuspension", + "user_warning", +] +ALL_USER_ACTION_TYPES = USER_ACTION_TYPES + [ + "user_warning_lifting", +] +COMMENT_ACTION_TYPES = [ + "comment_suspension", + "comment_unsuspension", +] +WORKOUT_ACTION_TYPES = [ + "workout_suspension", + "workout_unsuspension", +] +OBJECTS_ACTION_TYPES = ( + COMMENT_ACTION_TYPES + ALL_USER_ACTION_TYPES + WORKOUT_ACTION_TYPES +) +ALL_ACTION_TYPES = REPORT_ACTION_TYPES + OBJECTS_ACTION_TYPES + + +class Report(BaseModel): + __tablename__ = 'reports' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, nullable=True) + resolved_at = db.Column(db.DateTime, nullable=True) + reported_by = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='SET NULL'), + index=True, + nullable=True, + ) + reported_comment_id = db.Column( + db.Integer, + db.ForeignKey('comments.id', ondelete='SET NULL'), + index=True, + nullable=True, + ) + reported_user_id = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='SET NULL'), + index=True, + nullable=True, + ) + reported_workout_id = db.Column( + db.Integer, + db.ForeignKey('workouts.id', ondelete='SET NULL'), + index=True, + nullable=True, + ) + resolved_by = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='SET NULL'), + index=True, + nullable=True, + ) + resolved = db.Column(db.Boolean, default=False, nullable=False) + object_type = db.Column(db.String(50), nullable=False, index=True) + note = db.Column(db.String(), nullable=False) + + reported_comment = db.relationship( + 'Comment', + lazy=True, + backref=db.backref('comment_reports', lazy='select'), + ) + reported_user = db.relationship( + 'User', + primaryjoin=reported_user_id == User.id, + backref=db.backref('user_reports', lazy='select'), + ) + reported_workout = db.relationship( + 'Workout', + lazy=True, + backref=db.backref('workouts_reports', lazy='select'), + ) + reporter = db.relationship( + 'User', + primaryjoin=reported_by == User.id, + backref=db.backref( + 'user_own_reports', + lazy='select', + single_parent=True, + ), + ) + resolver = db.relationship( + 'User', + primaryjoin=resolved_by == User.id, + backref=db.backref( + 'user_resolved_reports', + lazy='select', + single_parent=True, + ), + ) + comments = db.relationship( + 'ReportComment', + backref=db.backref('report', lazy='select'), + ) + report_actions = db.relationship( + 'ReportAction', + lazy=True, + backref=db.backref('report', lazy='select', single_parent=True), + order_by='ReportAction.created_at.asc()', + ) + + @property + def reported_object(self) -> Union[Comment, None, User, Workout]: + # util method, used by tests + if self.object_type == "comment": + return self.reported_comment + if self.object_type == "user": + return self.reported_user + if self.object_type == "workout": + return self.reported_workout + return None + + @property + def is_reported_user_warned(self) -> bool: + return ( + ReportAction.query.filter_by( + action_type="user_warning", + report_id=self.id, + user_id=self.reported_user_id, + ).first() + is not None + ) + + def __init__( + self, + note: str, + reported_by: int, + reported_object: Union[Comment, User, Workout], + created_at: Optional[datetime] = None, + ): + object_type = reported_object.__class__.__name__.lower() + if object_type not in REPORT_OBJECT_TYPES: + raise InvalidReportException() + user_id = ( + reported_object.id + if object_type == "user" + else reported_object.user_id + ) + if user_id == reported_by: + raise InvalidReporterException() + + self.created_at = created_at if created_at else datetime.utcnow() + self.note = note + self.object_type = object_type + self.reported_by = reported_by + self.reported_comment_id = ( + reported_object.id if object_type == "comment" else None + ) + self.reported_user_id = user_id + self.reported_workout_id = ( + reported_object.id if object_type == "workout" else None + ) + self.resolved = False + + def serialize(self, current_user: User, full: bool = False) -> Dict: + if ( + not current_user.has_moderator_rights + and self.reported_by != current_user.id + ): + raise ReportForbiddenException() + + try: + reported_comment = ( + self.reported_comment.serialize(current_user, for_report=True) + if self.reported_comment_id + else None + ) + except CommentForbiddenException: + reported_comment = '_COMMENT_UNAVAILABLE_' + + try: + reported_workout = ( + self.reported_workout.serialize( + user=current_user, for_report=True + ) + if self.reported_workout + else None + ) + except WorkoutForbiddenException: + reported_workout = '_WORKOUT_UNAVAILABLE_' + + report = { + "created_at": self.created_at, + "id": self.id, + "is_reported_user_warned": self.is_reported_user_warned, + "note": self.note, + "object_type": self.object_type, + "reported_by": ( + self.reporter.serialize(current_user=current_user) + if self.reported_by + else None + ), + "reported_comment": reported_comment, + "reported_user": ( + self.reported_user.serialize(current_user=current_user) + if self.reported_user_id + else None + ), + "reported_workout": reported_workout, + "resolved": self.resolved, + "resolved_at": self.resolved_at, + } + if current_user.has_moderator_rights: + if full: + report["report_actions"] = [ + action.serialize(current_user, full=False) + for action in self.report_actions + ] + report["comments"] = [ + comment.serialize(current_user) + for comment in self.comments + ] + report["resolved_by"] = ( + None + if self.resolved_by is None + else self.resolver.serialize(current_user=current_user) + ) + report["updated_at"] = self.updated_at + return report + + +@listens_for(Report, 'after_insert') +def on_report_insert( + mapper: Mapper, connection: Connection, new_report: Report +) -> None: + @listens_for(db.Session, 'after_flush', once=True) + def receive_after_flush(session: Session, context: Connection) -> None: + from fittrackee.users.models import Notification, User + + for admin in User.query.filter( + User.role >= UserRole.MODERATOR.value, + User.id != new_report.reported_by, + User.is_active == True, # noqa + ).all(): + notification = Notification( + from_user_id=new_report.reported_by, + to_user_id=admin.id, + created_at=new_report.created_at, + event_type='report', + event_object_id=new_report.id, + ) + session.add(notification) + + +class ReportComment(BaseModel): + __tablename__ = 'report_comments' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + report_id = db.Column( + db.Integer, + db.ForeignKey('reports.id', ondelete='CASCADE'), + index=True, + nullable=True, + ) + user_id = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='CASCADE'), + index=True, + nullable=True, + ) + comment = db.Column(db.String(), nullable=False) + + user = db.relationship( + 'User', + backref=db.backref('user_report_comments', lazy='select'), + ) + + def __init__( + self, + report_id: int, + user_id: int, + comment: str, + created_at: Optional[datetime] = None, + ): + self.created_at = created_at if created_at else datetime.utcnow() + self.comment = comment + self.report_id = report_id + self.user_id = user_id + + def serialize(self, current_user: User) -> Dict: + if not current_user.has_moderator_rights: + raise ReportCommentForbiddenException() + return { + "created_at": self.created_at, + "comment": self.comment, + "id": self.id, + "report_id": self.report_id, + "user": self.user.serialize(current_user=current_user), + } + + +class ReportAction(BaseModel): + __tablename__ = "report_actions" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + uuid = db.Column( + postgresql.UUID(as_uuid=True), + default=uuid4, + unique=True, + nullable=False, + ) + created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True) + moderator_id = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + index=True, + nullable=True, # to keep log if an admin is deleted + ) + report_id = db.Column( + db.Integer, + db.ForeignKey("reports.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + user_id = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="CASCADE"), + index=True, + nullable=True, + ) + workout_id = db.Column( + db.Integer, + db.ForeignKey('workouts.id', ondelete='SET NULL'), + index=True, + nullable=True, + ) + comment_id = db.Column( + db.Integer, + db.ForeignKey('comments.id', ondelete='SET NULL'), + index=True, + nullable=True, + ) + action_type = db.Column(db.String(50), nullable=False) + reason = db.Column(db.String(), nullable=True) + + moderator = db.relationship( + "User", + primaryjoin=moderator_id == User.id, + lazy="joined", + single_parent=True, + ) + user = db.relationship( + "User", + primaryjoin=user_id == User.id, + lazy="joined", + single_parent=True, + ) + appeal = db.relationship( + "ReportActionAppeal", + uselist=False, + backref=db.backref("action", lazy='joined', single_parent=True), + ) + comment = db.relationship( + 'Comment', + lazy=True, + backref=db.backref('comment_report_action', lazy='select'), + ) + workout = db.relationship( + 'Workout', + lazy=True, + backref=db.backref('workout_report_action', lazy='select'), + ) + + def __init__( + self, + action_type: str, + moderator_id: int, + report_id: int, + *, + user_id: Optional[int] = None, + comment_id: Optional[int] = None, + workout_id: Optional[int] = None, + reason: Optional[str] = None, + created_at: Optional[datetime] = None, + ): + if action_type not in ALL_ACTION_TYPES: + raise InvalidReportActionException() + if action_type in OBJECTS_ACTION_TYPES and not user_id: + raise InvalidReportActionException() + if action_type in WORKOUT_ACTION_TYPES and not workout_id: + raise InvalidReportActionException() + if action_type in COMMENT_ACTION_TYPES and not comment_id: + raise InvalidReportActionException() + + self.action_type = action_type + self.moderator_id = moderator_id + self.created_at = created_at if created_at else datetime.utcnow() + self.comment_id = ( + comment_id if action_type in COMMENT_ACTION_TYPES else None + ) + self.reason = reason + self.report_id = report_id + self.user_id = user_id if action_type in OBJECTS_ACTION_TYPES else None + self.workout_id = ( + workout_id if action_type in WORKOUT_ACTION_TYPES else None + ) + + @property + def short_id(self) -> str: + return encode_uuid(self.uuid) + + def serialize(self, current_user: User, full: bool = True) -> Dict: + if ( + not current_user.has_moderator_rights + and current_user.id != self.user_id + ): + raise ReportActionForbiddenException() + action = { + "action_type": self.action_type, + "appeal": ( + self.appeal.serialize(current_user) if self.appeal else None + ), + "created_at": self.created_at, + "id": self.short_id, + "reason": self.reason, + } + + if current_user.has_moderator_rights: + action["report_id"] = self.report_id + action["moderator"] = self.moderator.serialize( + current_user=current_user + ) + action["user"] = ( + self.user.serialize(current_user=current_user) + if self.user + else None + ) + if not full: + return action + + if current_user.has_moderator_rights: + action = { + **action, + "comment": ( + self.comment.serialize(user=current_user, for_report=True) + if self.comment_id + else None + ), + "workout": ( + self.workout.serialize(user=current_user, for_report=True) + if self.workout_id + else None + ), + } + else: + action["comment"] = ( + self.comment.serialize(user=current_user) + if self.comment_id + else None + ) + action["workout"] = ( + self.workout.serialize(user=current_user) + if self.workout_id + else None + ) + + return action + + +class ReportActionAppeal(BaseModel): + __tablename__ = "report_action_appeals" + __table_args__ = ( + db.UniqueConstraint( + 'action_id', 'user_id', name='action_id_user_id_unique' + ), + ) + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + uuid = db.Column( + postgresql.UUID(as_uuid=True), + default=uuid4, + unique=True, + nullable=False, + ) + action_id = db.Column( + db.Integer, + db.ForeignKey("report_actions.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + user_id = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="CASCADE"), + index=True, + nullable=False, + ) + moderator_id = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + index=True, + nullable=True, + ) + created_at = db.Column( + db.DateTime, default=datetime.utcnow, nullable=False + ) + updated_at = db.Column(db.DateTime) + approved = db.Column(db.Boolean, nullable=True) + text = db.Column(db.String(), nullable=False) + reason = db.Column(db.String(), nullable=True) + + moderator = db.relationship( + "User", + primaryjoin=moderator_id == User.id, + lazy="joined", + single_parent=True, + ) + user = db.relationship( + "User", + primaryjoin=user_id == User.id, + lazy="joined", + single_parent=True, + ) + + def __init__( + self, + action_id: int, + user_id: int, + text: str, + created_at: Optional[datetime] = None, + ): + action = ReportAction.query.filter_by(id=action_id).first() + if action.action_type not in [ + "comment_suspension", + "user_suspension", + "user_warning", + "workout_suspension", + ]: + raise InvalidReportActionAppealException() + if action.user_id != user_id: + raise InvalidReportActionAppealUserException() + self.action_id = action_id + self.created_at = created_at if created_at else datetime.utcnow() + self.text = text + self.user_id = user_id + + @property + def short_id(self) -> str: + return encode_uuid(self.uuid) + + def serialize(self, current_user: User) -> Dict: + if ( + not current_user.has_moderator_rights + and current_user.id != self.user_id + ): + raise ReportActionAppealForbiddenException() + appeal = { + "approved": self.approved, + "created_at": self.created_at, + "id": self.short_id, + "reason": self.reason, + "text": self.text, + "updated_at": self.updated_at, + } + if current_user.has_moderator_rights: + appeal["moderator"] = ( + self.moderator.serialize(current_user=current_user) + if self.moderator + else None + ) + appeal["user"] = self.user.serialize(current_user=current_user) + return appeal + + +@listens_for(ReportAction, 'after_insert') +def on_report_action_insert( + mapper: Mapper, connection: Connection, new_action: ReportAction +) -> None: + @listens_for(db.Session, 'after_flush', once=True) + def receive_after_flush(session: Session, context: Connection) -> None: + if ( + new_action.action_type + in COMMENT_ACTION_TYPES + + WORKOUT_ACTION_TYPES + + ["user_warning", "user_warning_lifting"] + ): + notification = Notification( + from_user_id=new_action.moderator_id, + to_user_id=new_action.user_id, + created_at=new_action.created_at, + event_type=new_action.action_type, + event_object_id=new_action.id, + ) + session.add(notification) + + +@listens_for(ReportActionAppeal, 'after_insert') +def on_report_action_appeal_insert( + mapper: Mapper, connection: Connection, new_appeal: ReportActionAppeal +) -> None: + @listens_for(db.Session, 'after_flush', once=True) + def receive_after_flush(session: Session, context: Connection) -> None: + from fittrackee.users.models import Notification, User + + report_action = ReportAction.query.filter_by( + id=new_appeal.action_id + ).first() + if report_action: + for admin in User.query.filter( + User.role >= UserRole.MODERATOR.value, + User.id != new_appeal.user_id, + User.is_active == True, # noqa + ).all(): + notification = Notification( + from_user_id=new_appeal.user_id, + to_user_id=admin.id, + created_at=new_appeal.created_at, + event_type=( + 'user_warning_appeal' + if report_action.action_type == 'user_warning' + else 'suspension_appeal' + ), + event_object_id=new_appeal.id, + ) + session.add(notification) diff --git a/fittrackee/reports/reports.py b/fittrackee/reports/reports.py new file mode 100644 index 000000000..0caeffbfa --- /dev/null +++ b/fittrackee/reports/reports.py @@ -0,0 +1,950 @@ +from typing import Dict, Tuple, Union + +from flask import Blueprint, current_app, request +from sqlalchemy import asc, desc, exc, nullslast + +from fittrackee import db +from fittrackee.comments.exceptions import CommentForbiddenException +from fittrackee.oauth2.server import require_auth +from fittrackee.responses import ( + HttpResponse, + InvalidPayloadErrorResponse, + NotFoundErrorResponse, + handle_error_and_return_response, +) +from fittrackee.users.exceptions import ( + UserAlreadyReactivatedException, + UserAlreadySuspendedException, + UserNotFoundException, +) +from fittrackee.users.models import User +from fittrackee.users.roles import UserRole +from fittrackee.utils import decode_short_id +from fittrackee.workouts.exceptions import WorkoutForbiddenException + +from .exceptions import ( + InvalidReportActionException, + InvalidReporterException, + InvalidReportException, + ReportNotFoundException, + SuspendedObjectException, + UserWarningExistsException, +) +from .models import ( + OBJECTS_ACTION_TYPES, + REPORT_OBJECT_TYPES, + Report, + ReportActionAppeal, +) +from .reports_email_service import ( + ReportEmailService, +) +from .reports_service import ReportService + +reports_blueprint = Blueprint('reports', __name__) + +REPORTS_PER_PAGE = 10 +report_service = ReportService() + + +@reports_blueprint.route("/reports", methods=["POST"]) +@require_auth(scopes=["reports:write"]) +def create_report(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Create a report. + + **Scope**: ``reports:write`` + + **Example request**: + + .. sourcecode:: http + + POST /api/reports HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 CREATED + Content-Type: application/json + + : Union[Tuple[Dict, int], HttpResponse]: + """ + Get reports. + + **Scope**: ``reports:read`` + + **Minimum role**: Moderator + + **Example requests**: + + - without parameters: + + .. sourcecode:: http + + GET /api/reports/ HTTP/1.1 + + - with some query parameters: + + .. sourcecode:: http + + GET /api/reports?page=1&order=desc&order_by=created_at HTTP/1.1 + + **Example responses**: + + - returning at least one report: + + .. sourcecode:: http + + HTTP/1.1 200 SUCCESS + Content-Type: application/json + + { + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "reports": [ + { + "created_at": "Sun, 01 Dec 2024 18:17:30 GMT", + "id": 1, + "is_reported_user_warned": false, + "note": "", + "object_type": "user", + "reported_by": { + "created_at": "Sun, 01 Dec 2024 17:27:56 GMT", + "email": "moderator@example.com", + "followers": 0, + "following": 0, + "is_active": true, + "nb_workouts": 0, + "picture": false, + "role": "moderator", + "suspended_at": null, + "username": "moderator" + }, + "reported_comment": null, + "reported_user": { + "blocked": false, + "created_at": "Sun, 01 Dec 2024 17:27:49 GMT", + "email": "sam@example.com", + "followers": 0, + "following": 0, + "follows": "false", + "is_active": true, + "is_followed_by": "false", + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, + "reported_workout": null, + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "updated_at": null + } + ], + "status": "success" + } + + - returning no reports + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 0, + "total": 0 + }, + "reports": [], + "status": "success" + } + + :query integer object_type: reported content type (``comment``, ``user`` + or ``workout``) + :query string order: sorting order: ``asc``, ``desc`` (default: ``desc``) + :query string order_by: sorting criteria: ``created_at`` or ``updated_at`` + :query integer page: page if using pagination (default: 1) + :query boolean reporter: reporter username + :query boolean resolved: filter on report status + + :reqheader Authorization: OAuth 2.0 Bearer Token + + :statuscode 200: ``success`` + :statuscode 400: + - ``invalid payload`` + - ``invalid 'order_by'`` + :statuscode 401: + - ``provide a valid auth token`` + - ``signature expired, please log in again`` + - ``invalid token, please log in again`` + """ + params = request.args.copy() + page = int(params.get("page", 1)) + object_type = params.get("object_type") + resolved = params.get("resolved", "").lower() + column = params.get("order_by", "created_at") + if column not in ["created_at", "updated_at"]: + return InvalidPayloadErrorResponse("invalid 'order_by'") + report_column = getattr(Report, column) + order = params.get("order", "desc") + order_clauses = [ + asc(report_column) if order == "asc" else desc(report_column) + ] + if column == "updated_at": + order_clauses = [nullslast(order_clauses[0])] + if column != "created_at": + order_clauses.append(Report.created_at.desc()) + reporter_username = params.get("reporter", "") + reporter = ( + User.query.filter(User.username == reporter_username).first() + if reporter_username + else None + ) + + reports_pagination = ( + Report.query.filter( + Report.object_type == object_type if object_type else True, + ( + Report.resolved == True # noqa + if resolved == "true" + else ( + Report.resolved == False # noqa + if resolved == "false" + else True + ) + ), + ( + Report.reported_by == auth_user.id + if auth_user.role < UserRole.MODERATOR.value + else ( + Report.reported_by == reporter.id + if reporter and reporter_username + else True + ) + ), + ) + .order_by(*order_clauses) + .paginate(page=page, per_page=REPORTS_PER_PAGE, error_out=False) + ) + reports = reports_pagination.items + return { + "status": "success", + "reports": [report.serialize(auth_user) for report in reports], + "pagination": { + 'has_next': reports_pagination.has_next, + 'has_prev': reports_pagination.has_prev, + 'page': reports_pagination.page, + 'pages': reports_pagination.pages, + 'total': reports_pagination.total, + }, + }, 200 + + +@reports_blueprint.route("/reports/", methods=["GET"]) +@require_auth(scopes=["reports:read"], role=UserRole.MODERATOR) +def get_report( + auth_user: User, report_id: int +) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Get report. + + **Scope**: ``reports:read`` + + **Minimum role**: Moderator + + **Example request**: + + .. sourcecode:: http + + GET /api/reports/1 HTTP/1.1 + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 SUCCESS + Content-Type: application/json + + { + "report": { + "comments": [], + "created_at": "Sun, 01 Dec 2024 18:17:30 GMT", + "id": 1, + "is_reported_user_warned": false, + "note": "", + "object_type": "user", + "report_actions": [], + "reported_by": { + "created_at": "Sun, 01 Dec 2024 17:27:56 GMT", + "email": "moderator@example.com", + "followers": 0, + "following": 0, + "is_active": true, + "nb_workouts": 0, + "picture": false, + "role": "moderator", + "suspended_at": null, + "username": "moderator" + }, + "reported_comment": null, + "reported_user": { + "blocked": false, + "created_at": "Sun, 01 Dec 2024 17:27:49 GMT", + "email": "sam@example.com", + "followers": 0, + "following": 0, + "follows": "false", + "is_active": true, + "is_followed_by": "false", + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, + "reported_workout": null, + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "updated_at": null + }, + "status": "success" + } + + :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: + - ``report not found`` + """ + report = Report.query.filter_by(id=report_id).first() + if not report or ( + not auth_user.has_moderator_rights + and report.reported_by != auth_user.id + ): + return NotFoundErrorResponse(f"report not found (id: {report_id})") + + return { + "status": "success", + "report": report.serialize(auth_user, full=True), + }, 200 + + +@reports_blueprint.route("/reports/", methods=["PATCH"]) +@require_auth(scopes=["reports:write"], role=UserRole.MODERATOR) +def update_report( + auth_user: User, report_id: int +) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Update report. + + **Scope**: ``reports:write`` + + **Minimum role**: Moderator + + **Example request**: + + .. sourcecode:: http + + PATCH /api/reports/1 HTTP/1.1 + + **Example response** (report on user profile): + + .. sourcecode:: http + + HTTP/1.1 200 SUCCESS + Content-Type: application/json + + { + "report": { + "comments": [ + { + "comment": "", + "created_at": "Sun, 01 Dec 2024 18:21:38 GMT", + "id": 1, + "report_id": 1, + "user": { + "created_at": "Sun, 01 Dec 2024 17:27:56 GMT", + "email": "moderator@example.com", + "followers": 0, + "following": 0, + "is_active": true, + "nb_workouts": 0, + "picture": false, + "role": "moderator", + "suspended_at": null, + "username": "moderator" + } + } + ], + "created_at": "Sun, 01 Dec 2024 18:17:30 GMT", + "id": 1, + "is_reported_user_warned": false, + "note": "", + "object_type": "user", + "report_actions": [], + "reported_by": { + "created_at": "Sun, 01 Dec 2024 17:27:56 GMT", + "email": "moderator@example.com", + "followers": 0, + "following": 0, + "is_active": true, + "nb_workouts": 0, + "picture": false, + "role": "moderator", + "suspended_at": null, + "username": "moderator" + }, + "reported_comment": null, + "reported_user": { + "blocked": false, + "created_at": "Sun, 01 Dec 2024 17:27:49 GMT", + "email": "sam@example.com", + "followers": 0, + "following": 0, + "follows": "false", + "is_active": true, + "is_followed_by": "false", + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, + "reported_workout": null, + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "updated_at": "Sun, 01 Dec 2024 18:21:38 GMT" + }, + "status": "success" + } + + :param string report_id: report id + + :/actions", methods=["POST"]) +@require_auth(scopes=["reports:write"], role=UserRole.MODERATOR) +def create_action( + auth_user: User, report_id: int +) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Create report action. + + **Scope**: ``reports:write`` + + **Minimum role**: Moderator + + **Example request**: + + .. sourcecode:: http + + POST /api/reports/1/actions HTTP/1.1 + + **Example response** (report on user profile): + + .. sourcecode:: http + + HTTP/1.1 201 SUCCESS + Content-Type: application/json + + { + "report": { + "comments": [ + { + "comment": "", + "created_at": "Sun, 01 Dec 2024 18:21:38 GMT", + "id": 1, + "report_id": 1, + "user": { + "created_at": "Sun, 01 Dec 2024 17:27:56 GMT", + "email": "moderator@example.com", + "followers": 0, + "following": 0, + "is_active": true, + "nb_workouts": 0, + "picture": false, + "role": "moderator", + "suspended_at": null, + "username": "moderator" + } + } + ], + "created_at": "Sun, 01 Dec 2024 18:17:30 GMT", + "id": 1, + "is_reported_user_warned": false, + "note": "", + "object_type": "user", + "report_actions": [ + { + "action_type": "user_warning", + "appeal": null, + "created_at": "Wed, 04 Dec 2024 09:12:25 GMT", + "id": "Hv9KwVDtBHhyfvML7PHovq", + "moderator": { + "created_at": "Sun, 01 Dec 2024 17:27:56 GMT", + "email": "moderator@example.com", + "followers": 0, + "following": 0, + "is_active": true, + "nb_workouts": 0, + "picture": false, + "role": "moderator", + "suspended_at": null, + "username": "moderator" + }, + "reason": "", + "report_id": 1, + "user": { + "blocked": false, + "created_at": "Sun, 01 Dec 2024 17:27:49 GMT", + "email": "sam@example.com", + "followers": 0, + "following": 0, + "follows": "false", + "is_active": true, + "is_followed_by": "false", + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + } + } + ], + "reported_by": { + "created_at": "Sun, 01 Dec 2024 17:27:56 GMT", + "email": "moderator@example.com", + "followers": 0, + "following": 0, + "is_active": true, + "nb_workouts": 0, + "picture": false, + "role": "moderator", + "suspended_at": null, + "username": "moderator" + }, + "reported_comment": null, + "reported_user": { + "blocked": false, + "created_at": "Sun, 01 Dec 2024 17:27:49 GMT", + "email": "sam@example.com", + "followers": 0, + "following": 0, + "follows": "false", + "is_active": true, + "is_followed_by": "false", + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, + "reported_workout": null, + "resolved": false, + "resolved_at": null, + "resolved_by": null, + "updated_at": "Sun, 01 Dec 2024 18:21:38 GMT" + }, + "status": "success" + } + + :param string report_id: report id + + :', methods=["PATCH"]) +@require_auth(scopes=['users:write'], role=UserRole.MODERATOR) +def process_appeal( + auth_user: User, appeal_id: str +) -> Union[Dict, HttpResponse]: + """ + Process appeal. + + **Scope**: ``users:write`` + + **Minimum role**: Moderator + + **Example request**: + + .. sourcecode:: http + + POST /api/appeals/Z2Ze5qZrnMVmnDejPphASk HTTP/1.1 + + **Example response** (report on user profile): + + .. sourcecode:: http + + HTTP/1.1 201 SUCCESS + Content-Type: application/json + + { + "appeal": { + "approved": true, + "created_at": "Wed, 04 Dec 2024 09:29:18 GMT", + "id": "Z2Ze5qZrnMVmnDejPphASk", + "moderator": { + "created_at": "Sun, 01 Dec 2024 17:27:56 GMT", + "email": "moderator@example.com", + "followers": 0, + "following": 0, + "is_active": true, + "nb_workouts": 0, + "picture": false, + "role": "moderator", + "suspended_at": null, + "username": "moderator" + }, + "reason": "", + "text": "", + "updated_at": "Wed, 04 Dec 2024 09:30:21 GMT", + "user": { + "blocked": false, + "created_at": "Sun, 01 Dec 2024 17:27:49 GMT", + "email": "sam@example.com", + "followers": 0, + "following": 0, + "follows": "false", + "is_active": true, + "is_followed_by": "false", + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + } + }, + "status": "success" + } + + :param string appeal_id: appeal id + + : Union[Tuple[Dict, int], HttpResponse]: + """ + Get if unresolved reports exist. + + **Scope**: ``reports:read`` + + **Minimum role**: Moderator + + **Example request**: + + .. sourcecode:: http + + POST /api/reports/unresolved HTTP/1.1 + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 SUCCESS + Content-Type: application/json + + { + "status": "success", + "unresolved": true + } + + :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 403: + - ``you do not have permissions`` + """ + unresolved_reports = Report.query.filter( + Report.resolved == False # noqa + ).count() + return { + "status": "success", + "unresolved": unresolved_reports > 0, + }, 200 diff --git a/fittrackee/reports/reports_email_service.py b/fittrackee/reports/reports_email_service.py new file mode 100644 index 000000000..4afc6b4e9 --- /dev/null +++ b/fittrackee/reports/reports_email_service.py @@ -0,0 +1,304 @@ +from typing import Dict, Optional, Tuple + +from flask import current_app + +from fittrackee.comments.models import Comment +from fittrackee.emails.tasks import ( + appeal_rejected_email, + comment_suspension_email, + comment_unsuspension_email, + user_suspension_email, + user_unsuspension_email, + user_warning_email, + user_warning_lifting_email, + workout_suspension_email, + workout_unsuspension_email, +) +from fittrackee.users.models import User +from fittrackee.users.utils.language import get_language +from fittrackee.utils import get_date_string_for_user +from fittrackee.workouts.models import Workout + +from .exceptions import InvalidReportActionException +from .models import Report, ReportAction + + +class ReportEmailService: + @staticmethod + def _get_email_data( + report: Report, + reason: Optional[str] = None, + with_user_image: bool = False, + ) -> Tuple[Dict, Dict, str]: + fittrackee_url = current_app.config["UI_URL"] + reported_user: User = report.reported_user + user_data = { + "language": get_language(reported_user.language), + "email": reported_user.email, + } + email_data = { + "username": reported_user.username, + "fittrackee_url": fittrackee_url, + "reason": reason, + } + if with_user_image: + email_data["user_image_url"] = ( + f"{fittrackee_url}/api/users/{reported_user.username}/picture" + if reported_user.picture + else f"{fittrackee_url}/img/user.png" + ) + return user_data, email_data, fittrackee_url + + @staticmethod + def _get_comment_email_data( + email_data: Dict, + comment: Comment, + reported_user: User, + fittrackee_url: str, + ) -> Dict: + comment_email_data = { + **email_data, + "created_at": get_date_string_for_user( + comment.created_at, reported_user + ), + "text": comment.text, + } + if comment.workout_id: + comment_email_data["comment_url"] = ( + f"{fittrackee_url}/workouts/{comment.workout.short_id}" + f"/comments/{comment.short_id}" + ) + else: + comment_email_data["comment_url"] = ( + f"{fittrackee_url}/comments/{comment.short_id}" + ) + + return comment_email_data + + @staticmethod + def _get_workout_email_data( + email_data: Dict, + workout: Workout, + reported_user: User, + fittrackee_url: str, + ) -> Dict: + return { + **email_data, + "map": ( + f"{fittrackee_url}/api/workouts/map/{workout.map_id}" + if workout.map_id + else None + ), + "title": workout.title, + "workout_date": get_date_string_for_user( + workout.workout_date, reported_user + ), + "workout_url": f"{fittrackee_url}/workouts/{workout.short_id}", + } + + def _send_user_suspension_email( + self, + *, + report: Report, + reason: Optional[str], + report_action: Optional[ReportAction], + ) -> None: + user_data, email_data, fittrackee_url = self._get_email_data( + report, reason + ) + email_data["appeal_url"] = f"{fittrackee_url}/profile/suspension" + user_suspension_email.send(user_data, email_data) + + def _send_user_unsuspension_email( + self, + *, + report: Report, + reason: Optional[str], + report_action: Optional[ReportAction], + ) -> None: + user_data, email_data, _ = self._get_email_data(report, reason) + email_data["without_user_action"] = True + user_unsuspension_email.send(user_data, email_data) + + def _send_user_warning_email( + self, + *, + report: Report, + reason: Optional[str], + report_action: Optional[ReportAction], + ) -> None: + user_data, email_data, fittrackee_url = self._get_email_data( + report, reason, with_user_image=True + ) + if not report_action: + raise InvalidReportActionException("invalid action action") + + if report.reported_comment_id: + email_data = self._get_comment_email_data( + email_data, + report.reported_comment, + report.reported_user, + fittrackee_url, + ) + elif report.reported_workout_id: + email_data = self._get_workout_email_data( + email_data, + report.reported_workout, + report.reported_user, + fittrackee_url, + ) + email_data["appeal_url"] = ( + f"{fittrackee_url}/profile/moderation/" + f"sanctions/{report_action.short_id}" + ) + user_warning_email.send(user_data, email_data) + + def _send_user_warning_lifting_email( + self, + *, + report: Report, + reason: Optional[str], + report_action: Optional[ReportAction], + ) -> None: + user_data, email_data, fittrackee_url = self._get_email_data( + report, reason, with_user_image=True + ) + if not report_action: + raise InvalidReportActionException("invalid action action") + + if report.reported_comment_id: + email_data = self._get_comment_email_data( + email_data, + report.reported_comment, + report.reported_user, + fittrackee_url, + ) + elif report.reported_workout_id: + email_data = self._get_workout_email_data( + email_data, + report.reported_workout, + report.reported_user, + fittrackee_url, + ) + email_data["without_user_action"] = True + user_warning_lifting_email.send(user_data, email_data) + + def _send_comment_suspension_email( + self, + *, + report: Report, + reason: Optional[str], + report_action: Optional[ReportAction], + ) -> None: + user_data, email_data, fittrackee_url = self._get_email_data( + report, reason, with_user_image=True + ) + email_data = self._get_comment_email_data( + email_data, + report.reported_comment, + report.reported_user, + fittrackee_url, + ) + comment_suspension_email.send(user_data, email_data) + + def _send_comment_unsuspension_email( + self, + *, + report: Report, + reason: Optional[str], + report_action: Optional[ReportAction], + ) -> None: + user_data, email_data, fittrackee_url = self._get_email_data( + report, reason, with_user_image=True + ) + email_data = self._get_comment_email_data( + email_data, + report.reported_comment, + report.reported_user, + fittrackee_url, + ) + email_data["without_user_action"] = True + comment_unsuspension_email.send(user_data, email_data) + + def _send_workout_suspension_email( + self, + *, + report: Report, + reason: Optional[str], + report_action: Optional[ReportAction], + ) -> None: + user_data, email_data, fittrackee_url = self._get_email_data( + report, reason, with_user_image=True + ) + email_data = self._get_workout_email_data( + email_data, + report.reported_workout, + report.reported_user, + fittrackee_url, + ) + workout_suspension_email.send(user_data, email_data) + + def _send_workout_unsuspension_email( + self, + *, + report: Report, + reason: Optional[str], + report_action: Optional[ReportAction], + ) -> None: + user_data, email_data, fittrackee_url = self._get_email_data( + report, reason, with_user_image=True + ) + email_data = self._get_workout_email_data( + email_data, + report.reported_workout, + report.reported_user, + fittrackee_url, + ) + email_data["without_user_action"] = True + workout_unsuspension_email.send(user_data, email_data) + + def _send_appeal_rejected_email( + self, + *, + report: Report, + reason: Optional[str], + report_action: Optional[ReportAction], + ) -> None: + user_data, email_data, fittrackee_url = self._get_email_data( + report, reason, with_user_image=True + ) + + if not report_action: + raise InvalidReportActionException("invalid action action") + + if report.reported_comment_id: + email_data = self._get_comment_email_data( + email_data, + report.reported_comment, + report.reported_user, + fittrackee_url, + ) + elif report.reported_workout_id: + email_data = self._get_workout_email_data( + email_data, + report.reported_workout, + report.reported_user, + fittrackee_url, + ) + email_data["without_user_action"] = True + email_data["action_type"] = report_action.action_type + appeal_rejected_email.send(user_data, email_data) + + def send_report_action_email( + self, + report: Report, + action_type: str, + reason: Optional[str], + report_action: Optional[ + ReportAction + ] = None, # needed only for user warning and appeal + ) -> None: + send_email_func = getattr(self, f"_send_{action_type}_email") + send_email_func( + report=report, reason=reason, report_action=report_action + ) diff --git a/fittrackee/reports/reports_service.py b/fittrackee/reports/reports_service.py new file mode 100644 index 000000000..4e50cd673 --- /dev/null +++ b/fittrackee/reports/reports_service.py @@ -0,0 +1,339 @@ +from datetime import datetime +from typing import Dict, Optional, Union + +from sqlalchemy import func + +from fittrackee import db +from fittrackee.comments.models import Comment +from fittrackee.comments.utils import get_comment +from fittrackee.users.exceptions import UserNotFoundException +from fittrackee.users.models import User +from fittrackee.users.users_service import UserManagerService +from fittrackee.workouts.models import Workout +from fittrackee.workouts.utils.workouts import get_workout + +from .exceptions import ( + InvalidReportActionException, + InvalidReportException, + ReportNotFoundException, + SuspendedObjectException, + UserWarningExistsException, +) +from .models import ( + ALL_USER_ACTION_TYPES, + COMMENT_ACTION_TYPES, + WORKOUT_ACTION_TYPES, + Report, + ReportAction, + ReportActionAppeal, + ReportComment, +) + + +class ReportService: + @staticmethod + def create_report( + *, + reporter: User, + note: str, + object_id: str, + object_type: str, + ) -> Report: + if object_type == "comment": + target_object = get_comment(object_id, reporter) + elif object_type == "workout": + target_object = get_workout(object_id, reporter) + else: # object_type == "user" + target_object = User.query.filter( + func.lower(User.username) == func.lower(object_id), + ).first() + if not target_object or not target_object.is_active: + raise UserNotFoundException() + + if target_object and target_object.suspended_at: + raise SuspendedObjectException(object_type) + + existing_unresolved_report = Report.query.filter_by( + reported_by=reporter.id, + resolved=False, + object_type=object_type, + **{f"reported_{object_type}_id": target_object.id}, + ).first() + if existing_unresolved_report: + raise InvalidReportException("a report already exists") + + new_report = Report( + note=note, + reported_by=reporter.id, + reported_object=target_object, + ) + db.session.add(new_report) + db.session.commit() + + return new_report + + @staticmethod + def update_report( + *, + report_id: int, + moderator: User, + report_comment: str, + resolved: Optional[bool] = None, + ) -> Report: + report = Report.query.filter_by(id=report_id).first() + if not report: + raise ReportNotFoundException() + previous_resolved = report.resolved + + new_report_comment = ReportComment( + comment=report_comment, + report_id=report_id, + user_id=moderator.id, + ) + db.session.add(new_report_comment) + + now = datetime.utcnow() + report.updated_at = now + report_action = None + if resolved is not None: + report.resolved = resolved + if resolved is True and report.resolved_by is None: + report.resolved_at = now + report.resolved_by = moderator.id + report_action = ReportAction( + report_id=report.id, + moderator_id=moderator.id, + action_type="report_resolution", + created_at=now, + ) + if resolved is False: + report.resolved_at = None + report.resolved_by = None + if previous_resolved is True: + report_action = ReportAction( + report_id=report.id, + moderator_id=moderator.id, + action_type="report_reopening", + created_at=now, + ) + + if report_action: + db.session.add(report_action) + + db.session.commit() + + return report + + @staticmethod + def create_report_action( + *, + report: Report, + moderator: User, + action_type: str, + reason: Optional[str] = None, + data: Dict, + ) -> Optional[ReportAction]: + reported_user: User = report.reported_user + + # if reported user has been deleted after report creation + if not reported_user: + raise InvalidReportActionException("invalid 'username'") + + now = datetime.utcnow() + report_action = None + if action_type in ALL_USER_ACTION_TYPES: + username = data.get("username") + if not username: + raise InvalidReportActionException("'username' is missing") + if username != reported_user.username: + raise InvalidReportActionException("invalid 'username'") + + if action_type.startswith("user_warning"): + user = User.query.filter_by(username=username).first() + + existing_report_action = ReportAction.query.filter_by( + action_type=action_type, + report_id=report.id, + user_id=user.id, + ).first() + if existing_report_action: + raise UserWarningExistsException("user already warned") + + report_action = ReportAction( + moderator_id=moderator.id, + action_type=action_type, + created_at=now, + report_id=report.id, + reason=reason, + user_id=user.id, + ) + if report.reported_comment_id: + report_action.comment_id = report.reported_comment_id + elif report.reported_workout_id: + report_action.workout_id = report.reported_workout_id + db.session.add(report_action) + else: + user_manager_service = UserManagerService( + username=username, moderator_id=moderator.id + ) + user, _, _, _ = user_manager_service.update( + suspended=action_type == "user_suspension", + report_id=report.id, + reason=reason, + ) + if action_type == "user_unsuspension": + appeal = ( + ReportActionAppeal.query.join(ReportAction) + .filter( + ReportAction.report_id == report.id, + ReportAction.user_id == user.id, + ReportAction.action_type == "user_suspension", + ) + .first() + ) + if appeal: + appeal.approved = None + appeal.updated_at = datetime.utcnow() + db.session.flush() + + elif action_type in COMMENT_ACTION_TYPES + WORKOUT_ACTION_TYPES: + object_type = action_type.split("_")[0] + object_type_column = f"{object_type}_id" + object_id = data.get(object_type_column) + if not object_id: + raise InvalidReportActionException( + f"'{object_type_column}' is missing" + ) + reported_object: Union[Comment, Workout] = getattr( + report, f"reported_{object_type}" + ) + if not reported_object or reported_object.short_id != object_id: + raise InvalidReportActionException( + f"invalid '{object_type_column}'" + ) + + report_action = ReportAction( + moderator_id=moderator.id, + action_type=action_type, + created_at=now, + report_id=report.id, + reason=reason, + user_id=reported_object.user_id, + **{object_type_column: reported_object.id}, + ) + db.session.add(report_action) + + if "_suspension" in action_type: + if reported_object.suspended_at: + raise InvalidReportActionException( + f"{object_type} already suspended" + ) + reported_object.suspended_at = now + + else: + if reported_object.suspended_at is None: + raise InvalidReportActionException( + f"{object_type} already reactivated" + ) + reported_object.suspended_at = None + appeal = ( + ReportActionAppeal.query.join(ReportAction) + .filter( + ReportAction.report_id == report.id, + ReportAction.user_id == reported_object.user_id, + ReportAction.action_type + == f"{object_type}_suspension", + ) + .first() + ) + if appeal: + appeal.approved = None + appeal.updated_at = datetime.utcnow() + db.session.flush() + db.session.flush() + else: + raise InvalidReportActionException("invalid action type") + return report_action + + @staticmethod + def process_appeal( + appeal: ReportActionAppeal, moderator: User, data: Dict + ) -> Optional[ReportAction]: + appeal.moderator_id = moderator.id + appeal.approved = data["approved"] + appeal.reason = data["reason"] + appeal.updated_at = datetime.utcnow() + + action = appeal.action + content = None + content_type = '' + if action.action_type.startswith("comment_"): + content = Comment.query.filter_by(id=action.comment_id).first() + content_type = "comment" + elif action.action_type.startswith("workout_"): + content = Workout.query.filter_by(id=action.workout_id).first() + content_type = "workout" + + new_report_action = None + if data["approved"]: + if action.action_type == "user_suspension": + if not appeal.user.suspended_at: + raise InvalidReportActionException( + "user account has already been reactivated" + ) + + user_manager_service = UserManagerService( + username=appeal.user.username, + moderator_id=moderator.id, + ) + user, _, _, new_report_action = user_manager_service.update( + suspended=False, report_id=appeal.action.report_id + ) + if action.action_type == "user_warning": + new_report_action = ReportAction( + moderator_id=moderator.id, + action_type="user_warning_lifting", + created_at=datetime.utcnow(), + report_id=action.report_id, + user_id=action.user_id, + ) + db.session.add(new_report_action) + if ( + action.action_type + in ["comment_suspension", "workout_suspension"] + and content + ): + if not content.suspended_at: + raise InvalidReportActionException( + f"{content_type} already reactivated" + ) + content_id = {f"{content_type}_id": content.id} + new_report_action = ReportAction( + moderator_id=moderator.id, + action_type=f"{content_type}_unsuspension", + created_at=datetime.utcnow(), + report_id=action.report_id, + user_id=action.user_id, + **content_id, + ) + db.session.add(new_report_action) + content.suspended_at = None + else: + if ( + action.action_type == "user_suspension" + and not appeal.user.suspended_at + ): + if not appeal.user.suspended_at: + raise InvalidReportActionException( + "user account has been reactivated after appeal" + ) + if ( + action.action_type + in ["comment_suspension", "workout_suspension"] + and content + and not content.suspended_at + ): + raise InvalidReportActionException( + f"{content_type} has been reactivated after appeal" + ) + return new_report_action diff --git a/fittrackee/responses.py b/fittrackee/responses.py index ae3f6063b..040c86e9a 100644 --- a/fittrackee/responses.py +++ b/fittrackee/responses.py @@ -139,7 +139,7 @@ def __init__( class InternalServerErrorResponse(GenericErrorResponse): def __init__( self, message: Optional[str] = None, status: Optional[str] = None - ): + ) -> None: message = ( 'error, please try again or contact the administrator' if message is None diff --git a/fittrackee/tests/application/test_app_config_api.py b/fittrackee/tests/application/test_app_config_api.py index b3e3fb5da..a75c830a4 100644 --- a/fittrackee/tests/application/test_app_config_api.py +++ b/fittrackee/tests/application/test_app_config_api.py @@ -1,10 +1,10 @@ import json from datetime import datetime from typing import Optional -from unittest.mock import Mock, patch import pytest from flask import Flask +from time_machine import travel from fittrackee import db from fittrackee.application.models import AppConfig @@ -23,14 +23,15 @@ def test_it_gets_application_config_for_unauthenticated_user( response = client.get('/api/config') - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert data['data'] == jsonify_dict(app_config.serialize()) def test_it_gets_application_config( self, app: Flask, user_1: User ) -> None: + app_config = AppConfig.query.first() client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) @@ -40,9 +41,28 @@ def test_it_gets_application_config( 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'] == jsonify_dict(app_config.serialize()) + + def test_it_gets_application_config_when_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + app_config = AppConfig.query.first() + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + '/api/config', + 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'] == jsonify_dict(app_config.serialize()) def test_it_returns_error_if_application_has_no_config( self, app_no_config: Flask, user_1_admin: User @@ -425,10 +445,7 @@ def test_it_updates_privacy_policy( 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) + with travel(privacy_policy_date, tick=False): response = client.patch( '/api/config', content_type='application/json', diff --git a/fittrackee/tests/application/test_app_config_model.py b/fittrackee/tests/application/test_app_config_model.py index d278f3e5d..24f2780f7 100644 --- a/fittrackee/tests/application/test_app_config_model.py +++ b/fittrackee/tests/application/test_app_config_model.py @@ -14,7 +14,7 @@ class TestConfigModel: def test_application_config( self, app: Flask, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setenv('WEATHER_API_PROVIDER', 'darksky') + monkeypatch.setenv('WEATHER_API_PROVIDER', 'visualcrossing') app_config = AppConfig.query.first() app_config.admin_contact = 'admin@example.com' @@ -48,7 +48,7 @@ def test_application_config( == app_config.map_attribution ) assert serialized_app_config['version'] == VERSION - assert serialized_app_config['weather_provider'] == 'darksky' + assert serialized_app_config['weather_provider'] == 'visualcrossing' def test_it_returns_registration_disabled_when_users_count_exceeds_limit( self, app: Flask, user_1: User, user_2: User @@ -71,7 +71,7 @@ def test_it_returns_email_sending_disabled_when_no_email_url_provided( @pytest.mark.parametrize( 'input_weather_api_provider, expected_weather_provider', [ - ('darksky', 'darksky'), + ('darksky', None), # removed provider ('Visualcrossing', 'visualcrossing'), ('invalid_provider', None), ('', None), @@ -96,7 +96,7 @@ def test_it_returns_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() + privacy_policy_date = datetime.utcnow() app_config.privacy_policy = privacy_policy app_config.privacy_policy_date = privacy_policy_date diff --git a/fittrackee/tests/application/test_config.py b/fittrackee/tests/application/test_config.py index 299f8aa73..8e7140b26 100644 --- a/fittrackee/tests/application/test_config.py +++ b/fittrackee/tests/application/test_config.py @@ -2,6 +2,8 @@ from flask import Flask +from fittrackee import VERSION + class TestDevelopmentConfig: def test_debug_is_enabled(self, app: Flask) -> None: @@ -23,6 +25,11 @@ def test_sqlalchemy_is_configured_to_use_dev_database( 'DATABASE_URL' ) + def test_it_returns_application_version(self, app: Flask) -> None: + app.config.from_object('fittrackee.config.DevelopmentConfig') + + assert app.config['VERSION'] == VERSION + class TestTestingConfig: def test_debug_is_enabled(self, app: Flask) -> None: @@ -48,6 +55,11 @@ def test_sqlalchemy_is_configured_to_use_testing_database( else '' ) + def test_it_returns_application_version(self, app: Flask) -> None: + app.config.from_object('fittrackee.config.TestingConfig') + + assert app.config['VERSION'] == VERSION + class TestProductionConfig: def test_debug_is_disabled(self, app: Flask) -> None: @@ -68,3 +80,8 @@ def test_sqlalchemy_is_configured_to_use_testing_database( assert app.config['SQLALCHEMY_DATABASE_URI'] == os.environ.get( 'DATABASE_TEST_URL' ) + + def test_it_returns_application_version(self, app: Flask) -> None: + app.config.from_object('fittrackee.config.ProductionConfig') + + assert app.config['VERSION'] == VERSION diff --git a/fittrackee/tests/comments/__init__.py b/fittrackee/tests/comments/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/tests/comments/mixins.py b/fittrackee/tests/comments/mixins.py new file mode 100644 index 000000000..7a5d03e45 --- /dev/null +++ b/fittrackee/tests/comments/mixins.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional + +from fittrackee import db +from fittrackee.comments.models import Comment +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Workout + +from ..mixins import RandomMixin + + +class CommentMixin(RandomMixin): + def create_comment( + self, + user: User, + workout: Workout, + /, + text: Optional[str] = None, + text_visibility: VisibilityLevel = VisibilityLevel.PRIVATE, + created_at: Optional[datetime] = None, + with_mentions: bool = True, + ) -> Comment: + text = self.random_string() if text is None else text + comment = Comment( + user_id=user.id, + workout_id=workout.id, + text=text, + text_visibility=text_visibility, + created_at=created_at, + ) + db.session.add(comment) + db.session.flush() + if with_mentions: + comment.create_mentions() + db.session.commit() + return comment diff --git a/fittrackee/tests/comments/test_comment_likes_model.py b/fittrackee/tests/comments/test_comment_likes_model.py new file mode 100644 index 000000000..171dfa565 --- /dev/null +++ b/fittrackee/tests/comments/test_comment_likes_model.py @@ -0,0 +1,69 @@ +from datetime import datetime + +from flask import Flask +from time_machine import travel + +from fittrackee import db +from fittrackee.comments.models import CommentLike +from fittrackee.users.models import User +from fittrackee.workouts.models import Sport, Workout + +from .mixins import CommentMixin + + +class TestCommentLikeModel(CommentMixin): + def test_workout_likes_model( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + created_at = datetime.utcnow() + + like = CommentLike( + user_id=user_2.id, + comment_id=comment.id, + created_at=created_at, + ) + + assert like.user_id == user_2.id + assert like.comment_id == comment.id + assert like.created_at == created_at + + def test_created_date_is_initialized_on_creation_when_not_provided( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + now = datetime.utcnow() + with travel(now, tick=False): + like = CommentLike(user_id=user_2.id, comment_id=comment.id) + + assert like.user_id == user_2.id + assert like.comment_id == comment.id + assert like.created_at == now + + def test_it_deletes_comment_like_on_user_delete( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + like = CommentLike(user_id=user_2.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + like_id = like.id + + db.session.delete(user_2) + + assert CommentLike.query.filter_by(id=like_id).first() is None diff --git a/fittrackee/tests/comments/test_comments_api.py b/fittrackee/tests/comments/test_comments_api.py new file mode 100644 index 000000000..ecf44c05f --- /dev/null +++ b/fittrackee/tests/comments/test_comments_api.py @@ -0,0 +1,2629 @@ +import json +from datetime import datetime +from typing import Dict, List + +import pytest +from flask import Flask +from time_machine import travel +from werkzeug import Response + +from fittrackee import db +from fittrackee.comments.models import Comment, Mention +from fittrackee.reports.models import ReportActionAppeal +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ..mixins import ApiTestCaseMixin, BaseTestMixin, ReportMixin +from ..utils import OAUTH_SCOPES, jsonify_dict +from .mixins import CommentMixin + + +class TestPostWorkoutComment(CommentMixin, ApiTestCaseMixin, BaseTestMixin): + def test_it_returns_error_if_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client = app.test_client() + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + ) + + self.assert_401(response) + + def test_it_returns_400_when_text_is_missing( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_400(response) + + def test_it_returns_400_when_visibility_is_missing( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_domain(), + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_400(response) + + def test_it_returns_404_when_workout_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + workout_short_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout not found (id: {workout_short_id})", + ) + + @pytest.mark.parametrize( + 'input_workout_visibility', + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_returns_404_when_user_can_not_access_workout( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_workout_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout not found (id: {workout_cycling_user_2.short_id})", + ) + + def test_it_returns_404_when_blocked_user_comments_a_workout( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout not found (id: {workout_cycling_user_2.short_id})", + ) + + def test_it_returns_500_when_data_is_invalid( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=self.random_string(), + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_500(response, 'Error during comment save.', 'fail') + assert Comment.query.all() == [] + + def test_it_returns_201_when_comment_is_created( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment_text = self.random_string() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=comment_text, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + new_comment = Comment.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).first() + assert data['comment'] == jsonify_dict(new_comment.serialize(user_1)) + + def test_it_creates_comment_with_wider_visibility_than_workout( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + comment_text = self.random_string() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=comment_text, + text_visibility=VisibilityLevel.PUBLIC, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + new_comment = Comment.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).first() + assert data['comment'] == jsonify_dict(new_comment.serialize(user_1)) + + def test_it_returns_403_when_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment_text = self.random_string() + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=comment_text, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_sanitizes_text_before_storing_in_database( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment_text = " Hello" + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=comment_text, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + assert response.status_code == 201 + new_comment = Comment.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_1.id + ).first() + assert new_comment.text == " Hello" + + def test_it_creates_mention( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=f"@{user_3.username}", + text_visibility=VisibilityLevel.PUBLIC, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + new_comment = Comment.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).first() + assert ( + Mention.query.filter_by( + comment_id=new_comment.id, user_id=user_3.id + ).first() + is not None + ) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + client_scope: str, + can_access: bool, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS, + workout_id=workout_cycling_user_2.short_id, + ) + ), + headers=dict( + Authorization=f"Bearer {access_token}", + ), + ) + + self.assert_response_scope(response, can_access) + + +class TestGetWorkoutCommentAsUser( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + def test_it_returns_404_when_workout_comment_does_not_exist( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_comment_short_id = self.random_short_id() + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{workout_comment_short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {workout_comment_short_id})", + ) + + @pytest.mark.parametrize( + 'input_text_visibility', + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_returns_404_when_comment_visibility_does_not_allow_access( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_text_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + def test_it_returns_comment_when_visibility_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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['comment'] == jsonify_dict(comment.serialize(user_1)) + + def test_it_returns_404_when_comment_author_is_blocked( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_1.blocks_user(user_3) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + def test_it_returns_404_when_user_is_blocked( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_3.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + def test_it_returns_comment_when_workout_is_deleted( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + db.session.delete(workout_cycling_user_2) + db.session.commit() + + response = client.get( + f"/api/comments/{comment.short_id}", + 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['comment'] == jsonify_dict(comment.serialize(user_1)) + + def test_it_returns_comment_when_workout_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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['comment'] == jsonify_dict(comment.serialize(user_1)) + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_403(response) + + def test_it_returns_suspended_comment( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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['comment'] == jsonify_dict(comment.serialize()) + + +class TestGetWorkoutCommentAsFollower( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + def test_it_returns_404_when_comment_visibility_does_not_allow_access( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + user_2.approves_follow_request_from(user_3) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + @pytest.mark.parametrize( + 'input_text_visibility', + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PUBLIC], + ) + def test_it_returns_comment_when_visibility_allows_access( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_text_visibility: VisibilityLevel, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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['comment'] == jsonify_dict(comment.serialize(user_1)) + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_403(response) + + def test_it_returns_404_when_comment_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + user_2.approves_follow_request_from(user_3) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + ) + comment.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + +class TestGetWorkoutCommentAsOwner( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + @pytest.mark.parametrize( + 'input_text_visibility', + [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_returns_comment_when_visibility_allows_access( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + input_text_visibility: VisibilityLevel, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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['comment'] == jsonify_dict(comment.serialize(user_1)) + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_403(response) + + def test_it_returns_suspended_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + ) + comment.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + 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['comment'] == jsonify_dict(comment.serialize(user_1)) + + +class TestGetWorkoutCommentAsUnauthenticatedUser( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + @pytest.mark.parametrize( + 'input_text_visibility', + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_returns_404_when_comment_visibility_does_not_allow_access( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + input_text_visibility: VisibilityLevel, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + client = app.test_client() + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + def test_it_returns_suspended_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.utcnow() + db.session.commit() + client = app.test_client() + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['comment'] == jsonify_dict(comment.serialize()) + + def test_it_returns_comment_when_visibility_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client = app.test_client() + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['comment'] == jsonify_dict(comment.serialize()) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + client_scope: str, + can_access: bool, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + + response = client.get( + f"/api/comments/{comment.short_id}", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {access_token}", + ), + ) + + self.assert_response_scope(response, can_access) + + +class GetWorkoutCommentsTestCase( + CommentMixin, ApiTestCaseMixin, BaseTestMixin +): + @staticmethod + def assert_comments_response( + response: Response, expected_comments: List + ) -> None: + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert 'success' in data['status'] + assert data['data']['comments'] == expected_comments + + +class TestGetWorkoutCommentsAsUser(GetWorkoutCommentsTestCase): + def test_it_returns_404_when_workout_does_not_exist( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + ) -> None: + workout_short_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_404_with_message( + response, + f"workout not found (id: {workout_short_id})", + ) + + def test_it_returns_empty_list_when_no_comments( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response(response, expected_comments=[]) + + @pytest.mark.parametrize( + 'input_text_visibility', + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_does_not_return_comment_when_visibility_does_not_allow_it( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_text_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response(response, expected_comments=[]) + + def test_it_returns_comment_when_visibility_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response( + response, + expected_comments=[jsonify_dict(comment.serialize(user_1))], + ) + + def test_it_does_not_return_comment_when_author_is_blocked( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_1.blocks_user(user_3) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response(response, expected_comments=[]) + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_403(response) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + client_scope: str, + can_access: bool, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {access_token}", + ), + ) + + self.assert_response_scope(response, can_access) + + +class TestGetWorkoutCommentsAsFollower(GetWorkoutCommentsTestCase): + def test_it_does_not_return_comment_when_visibility_does_not_allow_it( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + user_2.approves_follow_request_from(user_3) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response(response, expected_comments=[]) + + @pytest.mark.parametrize( + 'input_text_visibility', + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PUBLIC], + ) + def test_it_returns_comment_when_visibility_allows_access( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_text_visibility: VisibilityLevel, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response( + response, + expected_comments=[jsonify_dict(comment.serialize(user_1))], + ) + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_403(response) + + +class TestGetWorkoutCommentsAsOwner(GetWorkoutCommentsTestCase): + @pytest.mark.parametrize( + 'input_text_visibility', + [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_returns_comment_when_visibility_allows_access( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + input_text_visibility: VisibilityLevel, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_comments_response( + response, + expected_comments=[jsonify_dict(comment.serialize(user_1))], + ) + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_403(response) + + +class TestGetWorkoutCommentsAsUnauthenticatedUser(GetWorkoutCommentsTestCase): + @pytest.mark.parametrize( + 'input_text_visibility', + [VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS], + ) + def test_it_does_not_return_comment_when_visibility_does_not_allow_it( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + input_text_visibility: VisibilityLevel, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + client = app.test_client() + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + ) + + self.assert_comments_response(response, expected_comments=[]) + + def test_it_returns_comment_when_visibility_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client = app.test_client() + + response = client.get( + f"/api/workouts/{workout_cycling_user_1.short_id}/comments", + content_type="application/json", + ) + + self.assert_comments_response( + response, + expected_comments=[jsonify_dict(comment.serialize(user_1))], + ) + + +class TestGetWorkoutComments(GetWorkoutCommentsTestCase): + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + for _ in range(7): + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + self.assert_403(response) + + def test_it_returns_all_comments( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + for _ in range(7): + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + content_type="application/json", + headers=dict( + Authorization=f"Bearer {auth_token}", + ), + ) + + data = json.loads(response.data.decode()) + assert len(data['data']['comments']) == 7 + + def test_it_returns_only_comments_user_can_access( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + # user 3 + visible_comments = [ + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + ] + for visibility_levels in [ + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ]: + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=visibility_levels, + ) + for visibility_levels in [ + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ]: + # user_1 is mentioned in private comment + visible_comments.append( + self.create_comment( + user_3, + workout_cycling_user_2, + text=f"@{user_1.username}", + text_visibility=visibility_levels, + ) + ) + # user 2 followed by user 1 + for visibility_levels in [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS, + ]: + visible_comments.append( + self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=visibility_levels, + ) + ) + self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + ) + # user 1 + for visibility_levels in [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ]: + visible_comments.append( + self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=visibility_levels, + ) + ) + # user_4 blocks user_1 + self.create_comment( + user_4, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_4.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"/api/workouts/{workout_cycling_user_2.short_id}/comments", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert data['data']['comments'] == [ + jsonify_dict(comment.serialize(user_1)) + for comment in visible_comments + ] + + +class TestDeleteWorkoutComment(ApiTestCaseMixin, BaseTestMixin, CommentMixin): + def test_it_returns_error_if_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + ) + client = app.test_client() + + response = client.delete( + f"/api/comments/{comment.short_id}", + content_type="application/json", + ) + + self.assert_401(response) + + def test_it_returns_404_if_comment_does_not_exist( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment_short_id = self.random_short_id() + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f"/api/comments/{comment_short_id}", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment_short_id})", + ) + + @pytest.mark.parametrize( + 'input_visibility', + [ + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_returns_404_if_comment_is_not_visible_to_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=input_visibility, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + def test_it_returns_403_if_user_is_not_comment_owner( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_deletes_workout_comment( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert Comment.query.first() is None + + def test_it_deletes_mentions( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text=f"@{user_3.username}", + text_visibility=VisibilityLevel.PUBLIC, + ) + comment_id = comment.id + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert Mention.query.filter_by(comment_id=comment_id).all() == [] + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + client_scope: str, + can_access: bool, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + ) + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.delete( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestPatchWorkoutComment(ApiTestCaseMixin, BaseTestMixin, CommentMixin): + def test_it_returns_error_if_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + ) + client = app.test_client() + + response = client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + ) + + self.assert_401(response) + + def test_it_returns_404_if_comment_does_not_exist( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment_short_id = self.random_short_id() + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f"/api/comments/{comment_short_id}", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment_short_id})", + ) + + @pytest.mark.parametrize( + 'input_visibility', + [ + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_returns_404_if_comment_is_not_visible_to_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=input_visibility, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment.short_id})", + ) + + def test_it_returns_403_if_user_is_not_comment_owner( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps( + dict( + text=self.random_string(), + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_400_when_text_is_missing( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps({}), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response) + + def test_it_returns_493_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_updates_workout_comment_text( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + updated_text = self.random_string() + + response = client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps(dict(text=updated_text)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert comment.text == updated_text + assert comment.text_visibility == VisibilityLevel.PUBLIC + assert comment.modification_date is not None + + def test_it_sanitizes_text_before_storing_in_database( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + updated_text = " Hello" + + client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps(dict(text=updated_text)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert comment.text == " Hello" + + def test_it_updates_mentions_to_remove_mention( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text=f"@{user_3.username}", + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + new_comment = Comment.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).first() + assert Mention.query.filter_by(comment_id=new_comment.id).all() == [] + + def test_it_updates_mentions_to_add_mention( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + client.patch( + f"/api/comments/{comment.short_id}", + content_type="application/json", + data=json.dumps(dict(text=f"@{user_3.username}")), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + new_comment = Comment.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).first() + assert ( + Mention.query.filter_by( + comment_id=new_comment.id, user_id=user_3.id + ).first() + is not None + ) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + client_scope: str, + can_access: bool, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + ) + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.patch( + f"/api/comments/{comment.short_id}", + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestPostWorkoutCommentSuspensionAppeal( + ApiTestCaseMixin, BaseTestMixin, ReportMixin, CommentMixin +): + def test_it_returns_error_if_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + client = app.test_client() + + response = client.post( + f"/api/comments/{comment.short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + ) + + self.assert_401(response) + + def test_it_returns_404_if_comment_does_not_exist( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + comment_short_id = self.random_short_id() + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/comments/{comment_short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f"workout comment not found (id: {comment_short_id})", + ) + + def test_it_returns_403_if_user_is_not_comment_owner( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/comments/{comment.short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_400_if_comment_is_not_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/comments/{comment.short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400( + response, error_message="workout comment is not suspended" + ) + + def test_it_returns_400_if_suspended_comment_has_no_report_action( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/comments/{comment.short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400( + response, error_message="workout comment has no suspension" + ) + + @pytest.mark.parametrize( + 'input_data', [{}, {"text": ""}, {"comment": "some text"}] + ) + def test_it_returns_400_when_appeal_text_is_missing( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_data: Dict, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.utcnow() + self.create_report_comment_action(user_2_admin, user_1, comment) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/comments/{comment.short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(input_data), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, 'no text provided') + + def test_user_can_appeal_comment_suspension( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.utcnow() + action = self.create_report_comment_action( + user_2_admin, user_1, comment + ) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + text = self.random_string() + now = datetime.utcnow() + + with travel(now, tick=False): + response = client.post( + f"/api/comments/{comment.short_id}/suspension/appeal", + content_type='application/json', + data=json.dumps(dict(text=text)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 201 + assert response.json == {"status": "success"} + appeal = ReportActionAppeal.query.filter_by( + action_id=action.id + ).first() + assert appeal.moderator_id is None + assert appeal.approved is None + assert appeal.created_at == now + assert appeal.user_id == user_1.id + assert appeal.updated_at is None + + def test_user_can_appeal_comment_suspension_only_once( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.utcnow() + action = self.create_report_comment_action( + user_2_admin, user_1, comment + ) + db.session.flush() + appeal = ReportActionAppeal( + action_id=action.id, + user_id=user_1.id, + text=self.random_string(), + ) + db.session.add(appeal) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/comments/{comment.short_id}/suspension/appeal", + content_type='application/json', + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, error_message='you can appeal only once') + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + client_scope: str, + can_access: bool, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + ) + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + f"/api/comments/{comment.short_id}/suspension/appeal", + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) diff --git a/fittrackee/tests/comments/test_comments_likes_api_post.py b/fittrackee/tests/comments/test_comments_likes_api_post.py new file mode 100644 index 000000000..165c63c3f --- /dev/null +++ b/fittrackee/tests/comments/test_comments_likes_api_post.py @@ -0,0 +1,584 @@ +import json +from datetime import datetime + +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.comments.models import CommentLike +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ..mixins import ApiTestCaseMixin, BaseTestMixin +from ..utils import OAUTH_SCOPES +from .mixins import CommentMixin + + +class TestCommentLikePost(CommentMixin, ApiTestCaseMixin, BaseTestMixin): + route = '/api/comments/{comment_uuid}/like' + + def test_it_returns_error_if_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + client = app.test_client() + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id) + ) + + self.assert_401(response) + + def test_it_returns_404_when_workout_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.post( + self.route.format(comment_uuid=self.random_short_id()), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_404_when_comment_does_not_exist( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(comment_uuid=self.random_short_id()), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_404_when_comment_visibility_is_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + user_2.approves_follow_request_from(user_1) + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_404_when_user_is_not_follower( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_400_when_comment_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + @pytest.mark.parametrize( + 'input_desc,input_workout_level', + [ + ('workout visibility: follower', VisibilityLevel.FOLLOWERS), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_creates_workout_like( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_desc: str, + input_workout_level: VisibilityLevel, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_level + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + 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['comment']['id'] == comment.short_id + assert ( + CommentLike.query.filter_by( + user_id=user_1.id, comment_id=comment.id + ).first() + is not None + ) + assert comment.likes.all() == [user_1] + + def test_it_does_not_return_error_when_like_already_exists( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + like = CommentLike(user_id=user_1.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + 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['comment']['id'] == comment.short_id + assert ( + CommentLike.query.filter_by( + user_id=user_1.id, comment_id=comment.id + ).first() + is not None + ) + assert comment.likes.all() == [user_1] + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + client_scope: str, + can_access: bool, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestCommentUndoLikePost(CommentMixin, ApiTestCaseMixin, BaseTestMixin): + route = '/api/comments/{comment_uuid}/like/undo' + + def test_it_returns_error_if_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + ) + client = app.test_client() + + response = client.post( + self.route.format(comment_uuid=comment.short_id) + ) + + self.assert_401(response) + + def test_it_returns_404_when_workout_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.post( + self.route.format( + workout_uuid=self.random_short_id(), + comment_uuid=self.random_short_id(), + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_404_when_comment_does_not_exist( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(comment_uuid=self.random_short_id()), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_404_when_comment_visibility_is_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + user_2.approves_follow_request_from(user_1) + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_404_when_user_is_not_follower( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + like = CommentLike(user_id=user_1.id, comment_id=comment.id) + db.session.add(like) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + @pytest.mark.parametrize( + 'input_desc,input_workout_level', + [ + ('workout visibility: follower', VisibilityLevel.FOLLOWERS), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_removes_comment_like( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_desc: str, + input_workout_level: VisibilityLevel, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_level + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + like = CommentLike(user_id=user_1.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + 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['comment']['id'] == comment.short_id + assert ( + CommentLike.query.filter_by( + user_id=user_1.id, comment_id=comment.id + ).first() + is None + ) + assert workout_cycling_user_2.likes.all() == [] + + def test_it_does_not_return_error_when_no_existing_like( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_1.send_follow_request_to(user_3) + user_3.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + 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['comment']['id'] == comment.short_id + assert ( + CommentLike.query.filter_by( + user_id=user_1.id, comment_id=comment.id + ).first() + is None + ) + assert comment.likes.all() == [] + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + client_scope: str, + can_access: bool, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + self.route.format(comment_uuid=comment.short_id), + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) diff --git a/fittrackee/tests/comments/test_comments_models.py b/fittrackee/tests/comments/test_comments_models.py new file mode 100644 index 000000000..a494db3c1 --- /dev/null +++ b/fittrackee/tests/comments/test_comments_models.py @@ -0,0 +1,1097 @@ +from datetime import datetime + +import pytest +from flask import Flask +from time_machine import travel + +from fittrackee import db +from fittrackee.comments.exceptions import CommentForbiddenException +from fittrackee.comments.models import Comment, CommentLike, Mention +from fittrackee.users.models import FollowRequest, User +from fittrackee.utils import encode_uuid +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ..mixins import ReportMixin +from .mixins import CommentMixin + + +class TestWorkoutCommentModel(ReportMixin, CommentMixin): + def test_comment_model( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + text = self.random_string() + created_at = datetime.utcnow() + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text=text, + created_at=created_at, + ) + + assert comment.user_id == user_1.id + assert comment.workout_id == workout_cycling_user_1.id + assert comment.text == text + assert comment.created_at == created_at + assert comment.modification_date is None + assert comment.text_visibility == VisibilityLevel.PRIVATE + assert comment.suspended_at is None + + def test_created_date_is_initialized_on_creation_when_not_provided( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + now = datetime.utcnow() + with travel(now, tick=False): + comment = self.create_comment(user_1, workout_cycling_user_1) + + assert comment.created_at == now + + def test_short_id_returns_encoded_comment_uuid( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + + assert comment.short_id == encode_uuid(comment.uuid) + + def test_it_returns_string_representation( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_1, + workout_cycling_user_1, + ) + + assert str(comment) == f'' + + def test_it_deletes_comment_on_user_delete( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_2, + workout_cycling_user_1, + ) + comment_id = comment.id + + db.session.delete(user_2) + + assert Comment.query.filter_by(id=comment_id).first() is None + + def test_it_does_not_delete_comment_on_workout_delete( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment( + user_2, + workout_cycling_user_1, + ) + comment_id = comment.id + + db.session.delete(workout_cycling_user_1) + + assert Comment.query.filter_by(id=comment_id).first() is not None + + def test_suspension_action_is_none_when_no_suspension( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_2, workout_cycling_user_1) + + assert comment.suspension_action is None + + def test_suspension_action_is_last_suspension_action_when_comment_is_suspended( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_2, workout_cycling_user_1) + expected_report_action = self.create_report_comment_actions( + user_1_admin, user_2, comment + ) + comment.suspended_at = datetime.utcnow() + + assert comment.suspension_action == expected_report_action + + def test_suspension_action_is_none_when_comment_is_unsuspended( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_2, workout_cycling_user_1) + self.create_report_comment_action( + user_1_admin, user_2, comment, "comment_suspension" + ) + self.create_report_comment_action( + user_1_admin, user_2, comment, "comment_unsuspension" + ) + + assert comment.suspension_action is None + + +class TestWorkoutCommentModelSerializeForCommentOwner( + ReportMixin, CommentMixin +): + @pytest.mark.parametrize('suspended', [True, False]) + @pytest.mark.parametrize( + 'input_visibility', + [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_serializes_owner_comment( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + suspended: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = input_visibility + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=input_visibility, + ) + if suspended: + comment.suspended_at = datetime.utcnow() + suspended_at = { + "suspended": True, + "suspended_at": comment.suspended_at, + } + else: + suspended_at = {"suspended_at": None} + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': workout_cycling_user_1.short_id, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + **suspended_at, + } + + def test_it_serializes_owner_comment_when_workout_is_deleted( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment(user_1, workout_cycling_user_2) + db.session.delete(workout_cycling_user_2) + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': None, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'suspended_at': comment.suspended_at, + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + def test_it_serializes_owner_comment_when_workout_is_not_visible( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PRIVATE + comment = self.create_comment(user_1, workout_cycling_user_2) + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': None, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'suspended_at': comment.suspended_at, + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + def test_it_serializes_owner_comment_when_comment_is_suspended( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PRIVATE + comment = self.create_comment(user_1, workout_cycling_user_2) + expected_report_action = self.create_report_comment_actions( + user_2_admin, user_1, comment + ) + comment.suspended_at = datetime.utcnow() + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': None, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'suspended': True, + 'suspended_at': comment.suspended_at, + 'suspension': expected_report_action.serialize(user_1, full=False), + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + +class TestWorkoutCommentModelSerializeForFollower(CommentMixin): + def test_it_raises_error_when_user_does_not_follow_comment_owner( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + + with pytest.raises(CommentForbiddenException): + comment.serialize(user_2) + + def test_it_raises_error_when_comment_is_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PRIVATE, + ) + + with pytest.raises(CommentForbiddenException): + comment.serialize(user_2) + + @pytest.mark.parametrize( + 'input_visibility', [VisibilityLevel.FOLLOWERS, VisibilityLevel.PUBLIC] + ) + def test_it_serializes_comment_for_follower_when_privacy_allows_it( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + input_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = input_visibility + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=input_visibility, + ) + + serialized_comment = comment.serialize(user_2) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': workout_cycling_user_1.short_id, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + def test_it_serializes_comment_when_workout_is_not_visible( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = VisibilityLevel.PRIVATE + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + db.session.delete(workout_cycling_user_1) + + serialized_comment = comment.serialize(user_2) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': None, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + +class TestWorkoutCommentModelSerializeForUser(CommentMixin): + @pytest.mark.parametrize( + 'input_visibility', + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_raises_error_when_comment_is_not_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=input_visibility, + ) + + with pytest.raises(CommentForbiddenException): + comment.serialize(user_2) + + def test_it_serializes_comment_when_comment_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + # TODO: mentions + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + + serialized_comment = comment.serialize(user_2) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': workout_cycling_user_1.short_id, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + def test_it_serializes_comment_when_workout_is_deleted( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + db.session.delete(workout_cycling_user_1) + db.session.commit() + + serialized_comment = comment.serialize(user_2) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': None, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + def test_it_serializes_comment_when_workout_is_not_visible( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PRIVATE + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + + serialized_comment = comment.serialize(user_2) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': None, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + +class TestWorkoutCommentModelSerializeForModerator(CommentMixin): + @pytest.mark.parametrize( + 'input_visibility', + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_raises_error_when_comment_is_visible( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=input_visibility, + ) + + with pytest.raises(CommentForbiddenException): + comment.serialize(user_1_moderator) + + @pytest.mark.parametrize('suspended', [True, False]) + @pytest.mark.parametrize( + 'input_visibility', + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_serializes_comment_when_report_flag_is_true( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_visibility: VisibilityLevel, + suspended: bool, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text=f"@{user_2.username}", + text_visibility=input_visibility, + with_mentions=True, + ) + if suspended: + comment.suspended_at = datetime.utcnow() + suspended_at = { + "suspended": True, + "suspended_at": comment.suspended_at, + } + else: + suspended_at = {"suspended_at": None} + + serialized_comment = comment.serialize( + user_1_moderator, for_report=True + ) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_3.serialize(), + 'workout_id': workout_cycling_user_2.short_id, + 'text': comment.text, + 'text_html': comment.handle_mentions()[0], + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [user_2.serialize()], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + **suspended_at, + } + + def test_it_does_not_return_content_when_comment_is_suspended( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text=f"@{user_2.username}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=True, + ) + comment.suspended_at = datetime.utcnow() + + serialized_comment = comment.serialize(user_1_moderator) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_3.serialize(), + 'workout_id': workout_cycling_user_2.short_id, + 'text': None, + 'text_html': None, + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'suspended': True, + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + +class TestWorkoutCommentModelSerializeForAdmin(CommentMixin): + def test_it_raises_error_when_comment_is_visible( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + + with pytest.raises(CommentForbiddenException): + comment.serialize(user_1_admin) + + def test_it_serializes_comment_when_report_flag_is_true( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text=f"@{user_2.username}", + text_visibility=VisibilityLevel.FOLLOWERS, + with_mentions=True, + ) + comment.suspended_at = datetime.utcnow() + + serialized_comment = comment.serialize(user_1_admin, for_report=True) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_3.serialize(), + 'workout_id': workout_cycling_user_2.short_id, + 'text': comment.text, + 'text_html': comment.handle_mentions()[0], + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [user_2.serialize()], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + 'suspended': True, + 'suspended_at': comment.suspended_at, + } + + +class TestWorkoutCommentModelSerializeForUnauthenticatedUser(CommentMixin): + @pytest.mark.parametrize( + 'input_visibility', + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_raises_error_when_comment_is_not_public( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=input_visibility, + ) + + with pytest.raises(CommentForbiddenException): + comment.serialize() + + def test_it_serializes_comment_when_comment_is_public( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + # TODO: mentions + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + + serialized_comment = comment.serialize() + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': workout_cycling_user_1.short_id, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + def test_it_serializes_comment_when_workout_is_not_visible( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PRIVATE + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + + serialized_comment = comment.serialize() + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': None, + 'text': comment.text, + 'text_html': comment.text, # no mention + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + +class TestWorkoutCommentModelWithMentions(CommentMixin): + def test_it_returns_empty_set_when_no_mentions( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=self.random_string(), + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=False, + ) + + _, mentioned_users = comment.create_mentions() + + assert mentioned_users == set() + + def test_it_does_not_create_mentions_when_mentioned_user_does_not_exist( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{self.random_string()} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=False, + ) + + comment.create_mentions() + + assert Mention.query.filter_by().first() is None + + def test_it_returns_empty_set_when_mentioned_user_does_not_exist( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{self.random_string()} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=False, + ) + + _, mentioned_users = comment.create_mentions() + + assert mentioned_users == set() + + def test_it_creates_mentions_when_mentioned_user_exists( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_3.username} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=False, + ) + + comment.create_mentions() + + mention = Mention.query.first() + assert mention.comment_id == comment.id + assert mention.user_id == user_3.id + + def test_it_returns_mentioned_user( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + mention = f"@{user_3.username}" + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"{mention} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=False, + ) + + _, mentioned_users = comment.create_mentions() + + assert mentioned_users == {user_3} + + +class TestWorkoutCommentModelSerializeForMentions(CommentMixin): + def test_it_serializes_comment_with_mentions_as_link( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_3.username} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + ) + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment["text"] == comment.text + assert serialized_comment["text_html"] == comment.handle_mentions()[0] + + def test_it_serializes_comment_with_mentioned_users( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text=( + f"@{user_3.username} {self.random_string()} @{user_1.username}" + ), + text_visibility=VisibilityLevel.PUBLIC, + ) + + serialized_comment = comment.serialize(user_2) + + assert len(serialized_comment["mentions"]) == 2 + assert user_1.serialize() in serialized_comment["mentions"] + assert user_3.serialize() in serialized_comment["mentions"] + + +class TestWorkoutCommentModelSerializeForMentionedUser(CommentMixin): + @pytest.mark.parametrize( + 'input_visibility', + [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_serializes_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility: VisibilityLevel, + ) -> None: + # user_2 does not follow user_1 + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text=f"@{user_2.username} {self.random_string()}", + text_visibility=input_visibility, + ) + + serialized_comment = comment.serialize(user_2) + + assert serialized_comment == { + 'id': comment.short_id, + 'user': user_1.serialize(), + 'workout_id': workout_cycling_user_1.short_id, + 'text': comment.text, + 'text_html': comment.handle_mentions()[0], + 'text_visibility': comment.text_visibility, + 'created_at': comment.created_at, + 'mentions': [user_2.serialize()], + 'modification_date': comment.modification_date, + 'likes_count': 0, + 'liked': False, + } + + +class TestWorkoutCommentModelSerializeWithLikes(CommentMixin): + def test_it_returns_like_count( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + for user in [user_1, user_3]: + like = CommentLike(user_id=user.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment["likes_count"] == 2 + + def test_it_returns_if_user_liked_comment( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + like = CommentLike(user_id=user_1.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment["liked"] is True + + def test_it_returns_if_user_did_not_like_comment( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + like = CommentLike(user_id=user_2.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + + serialized_comment = comment.serialize(user_1) + + assert serialized_comment["liked"] is False + + def test_it_returns_if_likes_info_for_unauthenticated_user( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + like = CommentLike(user_id=user_2.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + + serialized_comment = comment.serialize() + + assert serialized_comment["likes_count"] == 1 + assert serialized_comment["liked"] is False diff --git a/fittrackee/tests/comments/test_comments_utils_visibility.py b/fittrackee/tests/comments/test_comments_utils_visibility.py new file mode 100644 index 000000000..191b98a23 --- /dev/null +++ b/fittrackee/tests/comments/test_comments_utils_visibility.py @@ -0,0 +1,232 @@ +import pytest +from flask import Flask + +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel, can_view +from fittrackee.workouts.models import Sport, Workout + +from .mixins import CommentMixin + + +class TestCanViewComment(CommentMixin): + @pytest.mark.parametrize( + 'input_description,input_text_visibility', + [ + ( + f'comment visibility: {VisibilityLevel.PRIVATE.value}', + VisibilityLevel.PRIVATE, + ), + ( + f'comment visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ( + f'comment visibility: {VisibilityLevel.PUBLIC.value}', + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_comment_owner_can_view_his_comment( + self, + input_description: str, + input_text_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=input_text_visibility, + ) + + assert can_view(comment, 'text_visibility', user_1) is True + + def test_follower_can_not_view_comment_when_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PRIVATE, + ) + + assert can_view(comment, 'text_visibility', user_1) is False + + @pytest.mark.parametrize( + 'input_description,input_text_visibility', + [ + ( + f'comment visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ( + f'comment visibility: {VisibilityLevel.PUBLIC.value}', + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_follower_can_view_comment_when_public_or_follower_only( + self, + input_description: str, + input_text_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + + assert can_view(comment, 'text_visibility', user_1) is True + + @pytest.mark.parametrize( + 'input_description,input_text_visibility', + [ + ( + f'comment visibility: {VisibilityLevel.PRIVATE.value}', + VisibilityLevel.PRIVATE, + ), + ( + f'comment visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ], + ) + def test_another_user_can_not_view_comment_when_private_or_follower_only( + self, + input_description: str, + input_text_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + + assert can_view(comment, 'text_visibility', user_1) is False + + def test_another_user_can_view_comment_when_private_and_user_mentioned( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text=f"@{user_1.username}", + text_visibility=VisibilityLevel.PRIVATE, + ) + + assert can_view(comment, 'text_visibility', user_1) is True + + def test_another_user_can_view_comment_when_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + + assert can_view(comment, 'text_visibility', user_1) is True + + def test_another_user_can_not_view_comment_when_public_and_user_blocked( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_2.blocks_user(user_1) + + assert can_view(comment, 'text_visibility', user_1) is False + + @pytest.mark.parametrize( + 'input_description,input_text_visibility', + [ + ( + f'comment visibility: {VisibilityLevel.PRIVATE.value}', + VisibilityLevel.PRIVATE, + ), + ( + f'comment visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ], + ) + def test_comment_can_not_viewed_when_no_user_and_private_or_follower_only_visibility( # noqa + self, + input_description: str, + input_text_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=input_text_visibility, + ) + + assert can_view(comment, 'text_visibility') is False + + def test_comment_can_be_viewed_when_public_and_no_user_provided( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + + assert can_view(comment, 'text_visibility') is True diff --git a/fittrackee/tests/comments/test_mentions_models.py b/fittrackee/tests/comments/test_mentions_models.py new file mode 100644 index 000000000..ae999620c --- /dev/null +++ b/fittrackee/tests/comments/test_mentions_models.py @@ -0,0 +1,184 @@ +from datetime import datetime + +import pytest +from flask import Flask +from time_machine import travel + +from fittrackee import db +from fittrackee.comments.exceptions import CommentForbiddenException +from fittrackee.comments.models import Mention +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from .mixins import CommentMixin + +ALL_VISIBILITIES = [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, +] + + +class TestMentionModel(CommentMixin): + def test_mention_model( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + created_at = datetime.utcnow() + + mention = Mention( + comment_id=comment.id, user_id=user_1.id, created_at=created_at + ) + + assert mention.user_id == user_1.id + assert mention.comment_id == comment.id + assert mention.created_at == created_at + + def test_created_date_is_initialized_on_creation_when_not_provided( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + now = datetime.utcnow() + with travel(now, tick=False): + mention = Mention(comment_id=comment.id, user_id=user_1.id) + + assert mention.created_at == now + + def test_it_deletes_mention_on_user_delete( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + mention = Mention(comment_id=comment.id, user_id=user_1.id) + db.session.add(mention) + db.session.commit() + deleted_user_id = user_2.id + + db.session.delete(user_2) + + assert ( + Mention.query.filter_by( + user_id=deleted_user_id, comment_id=comment.id + ).first() + is None + ) + + def test_it_deletes_mention_on_comment_delete( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + mention = Mention(comment_id=comment.id, user_id=user_1.id) + db.session.add(mention) + db.session.commit() + deleted_comment_id = comment.id + + db.session.delete(comment) + + assert ( + Mention.query.filter_by( + user_id=user_2.id, comment_id=deleted_comment_id + ).first() + is None + ) + + +class TestCommentWithMentionSerializeVisibility(CommentMixin): + @pytest.mark.parametrize('workout_visibility', ALL_VISIBILITIES) + def test_public_comment_is_visible_to_all_users( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = workout_visibility + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text=f"@{user_2.username} {self.random_string()}", + text_visibility=VisibilityLevel.PUBLIC, + ) + + comment.serialize(user_1) # author + comment.serialize(user_2) # mentioned user + comment.serialize(user_3) # user + comment.serialize() # unauthenticated user + + @pytest.mark.parametrize('workout_visibility', ALL_VISIBILITIES) + def test_comment_for_followers_is_visible_to_followers_and_mentioned_users( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + workout_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = workout_visibility + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text=f"@{user_3.username} {self.random_string()}", + text_visibility=VisibilityLevel.FOLLOWERS, + ) + + assert comment.serialize(user_1) # author + assert comment.serialize(user_2) # follower + assert comment.serialize(user_3) # mentioned user + with pytest.raises(CommentForbiddenException): + assert comment.serialize(user_4) # user + assert comment.serialize() # unauthenticated user + + @pytest.mark.parametrize('workout_visibility', ALL_VISIBILITIES) + def test_private_comment_is_only_visible_to_author_and_mentioned_user( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + workout_visibility: VisibilityLevel, + ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = workout_visibility + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text=f"@{user_3.username} {self.random_string()}", + text_visibility=VisibilityLevel.PRIVATE, + ) + + assert comment.serialize(user_1) # author + assert comment.serialize(user_3) # mentioned user + with pytest.raises(CommentForbiddenException): + assert comment.serialize(user_2) # follower + assert comment.serialize(user_4) # user + assert comment.serialize() # unauthenticated user diff --git a/fittrackee/tests/comments/test_utils.py b/fittrackee/tests/comments/test_utils.py new file mode 100644 index 000000000..9346aac59 --- /dev/null +++ b/fittrackee/tests/comments/test_utils.py @@ -0,0 +1,73 @@ +from flask import Flask + +from fittrackee.comments.utils import handle_mentions +from fittrackee.tests.utils import random_string +from fittrackee.users.models import User + + +class TestGetMentionedUsers: + def test_it_returns_empty_dict_when_no_mentions(self, app: Flask) -> None: + text = random_string() + + _, mentioned_users = handle_mentions(text) + + assert mentioned_users == set() + + def test_it_returns_unchanged_text_when_no_mentions( + self, app: Flask + ) -> None: + text = ' '.join([random_string()] * 5) + + linkified_text, _ = handle_mentions(text) + + assert linkified_text == text + + def test_it_returns_empty_dict_when_user_not_found_by_username( + self, app: Flask + ) -> None: + text = f"@{random_string()} {random_string()}" + + _, mentioned_users = handle_mentions(text) + + assert mentioned_users == set() + + def test_it_returns_unchanged_text_when_user_not_found_by_username( + self, app: Flask + ) -> None: + text = f"@{random_string()} {random_string()}" + + linkified_text, _ = handle_mentions(text) + + assert linkified_text == text + + def test_it_returns_user_when_mentioned_by_username( + self, app: Flask, user_1: User + ) -> None: + text = f"@{user_1.username} {random_string()}" + + _, mentioned_users = handle_mentions(text) + + assert mentioned_users == {user_1} + + def test_it_returns_text_with_link_when_user_found_by_username( + self, app: Flask, user_1: User + ) -> None: + text = f"@{user_1.username} {random_string()}" + + linkified_text, _ = handle_mentions(text) + + assert linkified_text == text.replace( + f"@{user_1.username}", + f'@{user_1.username}', + ) + + def test_it_returns_deduplicated_user_when_mentioned_twice( + self, app: Flask, user_1: User + ) -> None: + mention = f"@{user_1.username} " * 2 + text = f"{mention} {random_string()}" + + _, mentioned_users = handle_mentions(text) + + assert mentioned_users == {user_1} diff --git a/fittrackee/tests/emails/template_results/email_appeal_rejected_on_comment_suspension.py b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_comment_suspension.py new file mode 100644 index 000000000..1d70fac9d --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_comment_suspension.py @@ -0,0 +1,155 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +Your appeal on the following content suspension has been rejected. + +Comment: comment text + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Votre appel sur la suspension du contenu suivant a été rejeté. + +Commentaire : comment text + +Lien : http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your appeal has been rejected. + + + + + + """ + +expected_fr_html_body = """ + Votre appel a été rejeté. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_suspension.py b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_suspension.py new file mode 100644 index 000000000..6a2697aa7 --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_suspension.py @@ -0,0 +1,117 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +Your appeal on your account suspension has been rejected. + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Votre appel sur la suspension de votre compte a été rejeté. + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your appeal has been rejected. + + + + + + """ + +expected_fr_html_body = """ + Votre appel a été rejeté. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_warning.py b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_warning.py new file mode 100644 index 000000000..22aced0cc --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_warning.py @@ -0,0 +1,117 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +Your appeal on your warning has been rejected. + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Votre appel sur votre avertissement été rejeté. + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your appeal has been rejected. + + + + + + """ + +expected_fr_html_body = """ + Votre appel a été rejeté. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_warning_for_comment.py b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_warning_for_comment.py new file mode 100644 index 000000000..2072e87bc --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_warning_for_comment.py @@ -0,0 +1,155 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +Your appeal on your warning has been rejected. + +Comment: comment text + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Votre appel sur votre avertissement été rejeté. + +Commentaire : comment text + +Lien : http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your appeal has been rejected. + + + + + + """ + +expected_fr_html_body = """ + Votre appel a été rejeté. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_warning_for_workout.py b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_warning_for_workout.py new file mode 100644 index 000000000..035fa4245 --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_user_warning_for_workout.py @@ -0,0 +1,161 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +Your appeal on your warning has been rejected. + +Workout: workout title + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Votre appel sur votre avertissement été rejeté. + +Séance : workout title + +Lien : http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your appeal has been rejected. + + + + + + """ + +expected_fr_html_body = """ + Votre appel a été rejeté. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_appeal_rejected_on_workout_suspension.py b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_workout_suspension.py new file mode 100644 index 000000000..5431acab6 --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_appeal_rejected_on_workout_suspension.py @@ -0,0 +1,161 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +Your appeal on the following content suspension has been rejected. + +Workout: workout title + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Votre appel sur la suspension du contenu suivant a été rejeté. + +Séance : workout title + +Lien : http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your appeal has been rejected. + + + + + + """ + +expected_fr_html_body = """ + Votre appel a été rejeté. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_comment_suspension.py b/fittrackee/tests/emails/template_results/email_comment_suspension.py new file mode 100644 index 000000000..0f82abc4e --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_comment_suspension.py @@ -0,0 +1,302 @@ +# flake8: noqa + +expected_en_text_body = """Hi test, + +Your comment has been suspended, it is no longer visible. + +Reason: some reason + +Comment: comment text + +If you think this is an error, you can appeal: +http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_en_text_body_without_reason = """Hi test, + +Your comment has been suspended, it is no longer visible. + +Comment: comment text + +If you think this is an error, you can appeal: +http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour test, + +Votre commentaire a été suspendu, il n'est plus visible. + +Raison : some reason + +Commentaire : comment text + +Si vous pensez qu'il s'agit d'une erreur, vous pouvez faire appel : +http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +Merci, +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your comment has been suspended, it is no longer visible. + + + + + + """ + +expected_fr_html_body = """ + Votre commentaire a été suspendu, il n'est plus visible. + + + + + + """ + +expected_en_html_body_without_reason = """ + Your comment has been suspended, it is no longer visible. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_comment_unsuspension.py b/fittrackee/tests/emails/template_results/email_comment_unsuspension.py new file mode 100644 index 000000000..fbfe4fa8f --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_comment_unsuspension.py @@ -0,0 +1,230 @@ +# flake8: noqa + +expected_en_text_body = """Hi test, + +The suspension on your comment has been lifted, it is visible again. + +Reason: some reason + +Comment: comment text + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +The FitTrackee Team +http://localhost""" + +expected_en_text_body_without_reason = """Hi test, + +The suspension on your comment has been lifted, it is visible again. + +Comment: comment text + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour test, + +La suspension de votre commentaire a été levée, il est visible à nouveau. + +Raison : some reason + +Commentaire : comment text + +Lien : http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + The suspension on your comment has been lifted, it is visible again. + + + + + + """ + +expected_fr_html_body = """ + La suspension de votre commentaire a été levée, il est visible à nouveau. + + + + + + """ + +expected_en_html_body_without_reason = """ + The suspension on your comment has been lifted, it is visible again. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_user_suspension.py b/fittrackee/tests/emails/template_results/email_user_suspension.py new file mode 100644 index 000000000..c6b24f39e --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_user_suspension.py @@ -0,0 +1,257 @@ +# flake8: noqa + +expected_en_text_body = """Hi test, + +Your account has been suspended. +You can no longer use your account and your profile is no longer accessible. You can still log in to request an export of your data or delete your account. + +Reason: some reason + +If you think this is an error, you can appeal: http://localhost/profile/suspension + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_en_text_body_without_reason = """Hi test, + +Your account has been suspended. +You can no longer use your account and your profile is no longer accessible. You can still log in to request an export of your data or delete your account. + +If you think this is an error, you can appeal: http://localhost/profile/suspension + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour test, + +Votre compte a été suspendu. +Vous ne pouvez plus utiliser votre compte et votre profil n'est plus accessible. Vous pouvez toujours vous connecter pour demander un export de vos données ou supprimer votre compte. + +Raison : some reason + +Si vous pensez qu'il s'agit d'une erreur, vous pouvez faire appel : http://localhost/profile/suspension + +Merci, +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your account has been suspended. You can no longer use your account and your profile is no longer accessible. + + + + + + """ + +expected_fr_html_body = """ + Votre compte a été suspendu. Vous ne pouvez plus utiliser votre compte et votre profil n'est plus accessible. + + + + + + """ + +expected_en_html_body_without_reason = """ + Your account has been suspended. You can no longer use your account and your profile is no longer accessible. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_user_unsuspension.py b/fittrackee/tests/emails/template_results/email_user_unsuspension.py new file mode 100644 index 000000000..e0709a468 --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_user_unsuspension.py @@ -0,0 +1,179 @@ +# flake8: noqa + +expected_en_text_body = """Hi test, + +Your account has been reactivated. +You can now use all the features on FitTrackee. + +Reason: some reason + +The FitTrackee Team +http://localhost""" + +expected_en_text_body_without_reason = """Hi test, + +Your account has been reactivated. +You can now use all the features on FitTrackee. + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour test, + +Votre compte a été réactivé. +Vous pouvez désormais utiliser toutes les fonctionnalités de FitTrackee. + +Raison : some reason + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your account has been reactivated. You can now use all the features on FitTrackee. + + + + + + """ + +expected_fr_html_body = """ + Votre compte a été réactivé. Vous pouvez désormais utiliser toutes les fonctionnalités de FitTrackee. + + + + + + """ + +expected_en_html_body_without_reason = """ + Your account has been reactivated. You can now use all the features on FitTrackee. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_user_warning_lifting_on_comment.py b/fittrackee/tests/emails/template_results/email_user_warning_lifting_on_comment.py new file mode 100644 index 000000000..a163236a0 --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_user_warning_lifting_on_comment.py @@ -0,0 +1,155 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +Your warning has been lifted. + +Comment: comment text + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Votre avertissement a été levé. + +Commentaire : comment text + +Lien : http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your warning has been lifted. + + + + + + """ + +expected_fr_html_body = """ + Votre avertissement a été levé. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_user_warning_lifting_on_user.py b/fittrackee/tests/emails/template_results/email_user_warning_lifting_on_user.py new file mode 100644 index 000000000..19de011a0 --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_user_warning_lifting_on_user.py @@ -0,0 +1,117 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +Your warning has been lifted. + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Votre avertissement a été levé. + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your warning has been lifted. + + + + + + """ + +expected_fr_html_body = """ + Votre avertissement a été levé. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_user_warning_lifting_on_workout.py b/fittrackee/tests/emails/template_results/email_user_warning_lifting_on_workout.py new file mode 100644 index 000000000..0bc8639b4 --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_user_warning_lifting_on_workout.py @@ -0,0 +1,228 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +Your warning has been lifted. + +Workout: workout title + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Votre avertissement a été levé. + +Séance : workout title + +Lien : http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your warning has been lifted. + + + + + + """ + +expected_fr_html_body = """ + Votre avertissement a été levé. + + + + + + """ + +expected_en_html_body_without_map = """ + Your warning has been lifted. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_user_warning_on_comment.py b/fittrackee/tests/emails/template_results/email_user_warning_on_comment.py new file mode 100644 index 000000000..d8d9d1875 --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_user_warning_on_comment.py @@ -0,0 +1,317 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +You received a warning. + +Reason: some reason + +Comment: comment text + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +If you think this is an error, you can appeal: +http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_en_text_body_without_reason = """Hi Test, + +You received a warning. + +Comment: comment text + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +If you think this is an error, you can appeal: +http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Vous avez reçu un avertissement. + +Raison : some reason + +Commentaire : comment text + +Lien : http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/comments/ZxB8qgyrcSY6ynNzerfirW + +Si vous pensez qu'il s'agit d'une erreur, vous pouvez faire appel : +http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal + +Merci, +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + You received a warning. + + + + + + """ + +expected_fr_html_body = """ + Vous avez reçu un avertissement. + + + + + + """ + +expected_en_html_body_without_reason = """ + You received a warning. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_user_warning_on_user.py b/fittrackee/tests/emails/template_results/email_user_warning_on_user.py new file mode 100644 index 000000000..bf255a9b5 --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_user_warning_on_user.py @@ -0,0 +1,260 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +You received a warning. + +Reason: some reason + +If you think this is an error, you can appeal: +http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_en_text_body_without_reason = """Hi Test, + +You received a warning. + +If you think this is an error, you can appeal: +http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Vous avez reçu un avertissement. + +Raison : some reason + +Si vous pensez qu'il s'agit d'une erreur, vous pouvez faire appel : +http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal + +Merci, +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + You received a warning. + + + + + + """ + +expected_fr_html_body = """ + Vous avez reçu un avertissement. + + + + + + """ + +expected_en_html_body_without_reason = """ + You received a warning. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_user_warning_on_workout.py b/fittrackee/tests/emails/template_results/email_user_warning_on_workout.py new file mode 100644 index 000000000..7279b9b2a --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_user_warning_on_workout.py @@ -0,0 +1,416 @@ +# flake8: noqa + +expected_en_text_body = """Hi Test, + +You received a warning. + +Reason: some reason + +Workout: workout title + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +If you think this is an error, you can appeal: +http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_en_text_body_without_reason = """Hi Test, + +You received a warning. + +Workout: workout title + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +If you think this is an error, you can appeal: +http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour Test, + +Vous avez reçu un avertissement. + +Raison : some reason + +Séance : workout title + +Lien : http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +Si vous pensez qu'il s'agit d'une erreur, vous pouvez faire appel : +http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal + +Merci, +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + You received a warning. + + + + + + """ + +expected_fr_html_body = """ + Vous avez reçu un avertissement. + + + + + + """ + +expected_en_html_body_without_reason = """ + You received a warning. + + + + + + """ + +expected_en_html_body_without_map = """ + You received a warning. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_workout_suspension.py b/fittrackee/tests/emails/template_results/email_workout_suspension.py new file mode 100644 index 000000000..56216d6d3 --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_workout_suspension.py @@ -0,0 +1,398 @@ +# flake8: noqa + +expected_en_text_body = """Hi test, + +Your workout has been suspended, it is no longer visible. + +Reason: some reason + +Workout: workout title + +If you think this is an error, you can appeal: +http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_en_text_body_without_reason = """Hi test, + +Your workout has been suspended, it is no longer visible. + +Workout: workout title + +If you think this is an error, you can appeal: +http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +Thanks, +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour test, + +Votre séance a été suspendue, elle n'est plus visible. + +Raison : some reason + +Séance : workout title + +Si vous pensez qu'il s'agit d'une erreur, vous pouvez faire appel : +http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +Merci, +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + Your workout has been suspended, it is no longer visible. + + + + + + """ + +expected_fr_html_body = """ + Votre séance a été suspendue, elle n'est plus visible. + + + + + + """ + +expected_en_html_body_without_reason = """ + Your workout has been suspended, it is no longer visible. + + + + + + """ + +expected_en_html_body_without_map = """ + Your workout has been suspended, it is no longer visible. + + + + + + """ diff --git a/fittrackee/tests/emails/template_results/email_workout_unsuspension.py b/fittrackee/tests/emails/template_results/email_workout_unsuspension.py new file mode 100644 index 000000000..d91e8e9bb --- /dev/null +++ b/fittrackee/tests/emails/template_results/email_workout_unsuspension.py @@ -0,0 +1,304 @@ +# flake8: noqa + +expected_en_text_body = """Hi test, + +The suspension on your workout has been lifted, it is visible again. + +Reason: some reason + +Workout: workout title + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +The FitTrackee Team +http://localhost""" + +expected_en_text_body_without_reason = """Hi test, + +The suspension on your workout has been lifted, it is visible again. + +Workout: workout title + +Link: http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +The FitTrackee Team +http://localhost""" + +expected_fr_text_body = """Bonjour test, + +La suspension de votre séance a été levée, elle est visible à nouveau. + +Raison : some reason + +Séance : workout title + +Lien : http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/ + +L'équipe FitTrackee +http://localhost""" + +expected_en_html_body = """ + The suspension on your workout has been lifted, it is visible again. + + + + + + """ + +expected_fr_html_body = """ + La suspension de votre séance a été levée, elle est visible à nouveau. + + + + + + """ + +expected_en_html_body_without_reason = """ + The suspension on your workout has been lifted, it is visible again. + + + + + + """ + +expected_en_html_body_without_map = """ + The suspension on your workout has been lifted, it is visible again. + + + + + + """ diff --git a/fittrackee/tests/emails/test_email_appeal_rejected_on_comment_suspension.py b/fittrackee/tests/emails/test_email_appeal_rejected_on_comment_suspension.py new file mode 100644 index 000000000..ee7af4c3c --- /dev/null +++ b/fittrackee/tests/emails/test_email_appeal_rejected_on_comment_suspension.py @@ -0,0 +1,100 @@ +from typing import Dict, Optional, Union + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_appeal_rejected_on_comment_suspension import ( + expected_en_html_body, + expected_en_text_body, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForAppealRejectedOnCommentSuspension: + template_name = "appeal_rejected" + EMAIL_DATA: Dict[str, Union[Optional[str], bool]] = { + "action_type": "comment_suspension", + "fittrackee_url": "http://localhost", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + "without_user_action": True, + "created_at": "07/14/2024 - 07:32:47", + "comment_url": ( + "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/" + "comments/ZxB8qgyrcSY6ynNzerfirW" + ), + "text": "comment text", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Appeal rejected'), + ('fr', 'FitTrackee - Appel rejeté'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body diff --git a/fittrackee/tests/emails/test_email_appeal_rejected_on_user_suspension.py b/fittrackee/tests/emails/test_email_appeal_rejected_on_user_suspension.py new file mode 100644 index 000000000..f1826a85b --- /dev/null +++ b/fittrackee/tests/emails/test_email_appeal_rejected_on_user_suspension.py @@ -0,0 +1,94 @@ +from typing import Dict, Optional, Union + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_appeal_rejected_on_user_suspension import ( + expected_en_html_body, + expected_en_text_body, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForAppealRejectedOnUserSuspension: + template_name = "appeal_rejected" + EMAIL_DATA: Dict[str, Union[Optional[str], bool]] = { + "action_type": "user_suspension", + "fittrackee_url": "http://localhost", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + "without_user_action": True, + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Appeal rejected'), + ('fr', 'FitTrackee - Appel rejeté'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body diff --git a/fittrackee/tests/emails/test_email_appeal_rejected_on_user_warning.py b/fittrackee/tests/emails/test_email_appeal_rejected_on_user_warning.py new file mode 100644 index 000000000..de2a6a252 --- /dev/null +++ b/fittrackee/tests/emails/test_email_appeal_rejected_on_user_warning.py @@ -0,0 +1,94 @@ +from typing import Dict, Optional, Union + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_appeal_rejected_on_user_warning import ( + expected_en_html_body, + expected_en_text_body, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForAppealRejectedOnUserWarning: + template_name = "appeal_rejected" + EMAIL_DATA: Dict[str, Union[Optional[str], bool]] = { + "action_type": "user_warning", + "fittrackee_url": "http://localhost", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + "without_user_action": True, + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Appeal rejected'), + ('fr', 'FitTrackee - Appel rejeté'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body diff --git a/fittrackee/tests/emails/test_email_appeal_rejected_on_user_warning_for_comment.py b/fittrackee/tests/emails/test_email_appeal_rejected_on_user_warning_for_comment.py new file mode 100644 index 000000000..ea0d6b0f2 --- /dev/null +++ b/fittrackee/tests/emails/test_email_appeal_rejected_on_user_warning_for_comment.py @@ -0,0 +1,100 @@ +from typing import Dict, Optional, Union + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_appeal_rejected_on_user_warning_for_comment import ( # noqa + expected_en_html_body, + expected_en_text_body, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForAppealRejectedOnUserWarningForComment: + template_name = "appeal_rejected" + EMAIL_DATA: Dict[str, Union[Optional[str], bool]] = { + "action_type": "user_warning", + "fittrackee_url": "http://localhost", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + "without_user_action": True, + "created_at": "07/14/2024 - 07:32:47", + "comment_url": ( + "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/" + "comments/ZxB8qgyrcSY6ynNzerfirW" + ), + "text": "comment text", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Appeal rejected'), + ('fr', 'FitTrackee - Appel rejeté'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body diff --git a/fittrackee/tests/emails/test_email_appeal_rejected_on_user_warning_for_workout.py b/fittrackee/tests/emails/test_email_appeal_rejected_on_user_warning_for_workout.py new file mode 100644 index 000000000..cb10538b2 --- /dev/null +++ b/fittrackee/tests/emails/test_email_appeal_rejected_on_user_warning_for_workout.py @@ -0,0 +1,98 @@ +from typing import Dict, Optional, Union + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_appeal_rejected_on_user_warning_for_workout import ( # noqa + expected_en_html_body, + expected_en_text_body, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForAppealRejectedOnUserWarningForWorkout: + template_name = "appeal_rejected" + EMAIL_DATA: Dict[str, Union[Optional[str], bool]] = { + "action_type": "user_warning", + "fittrackee_url": "http://localhost", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + "without_user_action": True, + "map": "http://localhost/workouts/map/ZxB8qgyrcSY6ynNzerfirW", + "title": "workout title", + "workout_date": "07/14/2024 - 07:32:47", + "workout_url": "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Appeal rejected'), + ('fr', 'FitTrackee - Appel rejeté'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body diff --git a/fittrackee/tests/emails/test_email_appeal_rejected_on_workout_suspension.py b/fittrackee/tests/emails/test_email_appeal_rejected_on_workout_suspension.py new file mode 100644 index 000000000..f286c8c4a --- /dev/null +++ b/fittrackee/tests/emails/test_email_appeal_rejected_on_workout_suspension.py @@ -0,0 +1,98 @@ +from typing import Dict, Optional, Union + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_appeal_rejected_on_workout_suspension import ( + expected_en_html_body, + expected_en_text_body, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForAppealRejectedOnWorkoutSuspension: + template_name = "appeal_rejected" + EMAIL_DATA: Dict[str, Union[Optional[str], bool]] = { + "action_type": "workout_suspension", + "fittrackee_url": "http://localhost", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + "without_user_action": True, + "map": "http://localhost/workouts/map/ZxB8qgyrcSY6ynNzerfirW", + "title": "workout title", + "workout_date": "07/14/2024 - 07:32:47", + "workout_url": "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Appeal rejected'), + ('fr', 'FitTrackee - Appel rejeté'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body diff --git a/fittrackee/tests/emails/test_email_comment_suspension.py b/fittrackee/tests/emails/test_email_comment_suspension.py new file mode 100644 index 000000000..8ab075e5d --- /dev/null +++ b/fittrackee/tests/emails/test_email_comment_suspension.py @@ -0,0 +1,138 @@ +from typing import Dict, Optional + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_comment_suspension import ( + expected_en_html_body, + expected_en_html_body_without_reason, + expected_en_text_body, + expected_en_text_body_without_reason, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForCommentSuspension: + template_name = "comment_suspension" + EMAIL_DATA: Dict[str, Optional[str]] = { + "comment_url": ( + "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/" + "comments/ZxB8qgyrcSY6ynNzerfirW" + ), + "created_at": "07/14/2024 - 07:32:47", + "fittrackee_url": "http://localhost", + "reason": "some reason", + "text": "comment text", + "user_image_url": "http://localhost/img/user.png", + "username": "test", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Your comment has been suspended'), + ('fr', 'FitTrackee - Votre commentaire a été suspendu'), + ], + ) + 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( + self.template_name, 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( + self.template_name, 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( + self.template_name, '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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body + + def test_it_gets_text_body_without_reason( + self, + app: Flask, + ) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + text_body = email_template.get_content( + self.template_name, "en", 'body.txt', self.EMAIL_DATA + ) + + assert text_body == expected_en_text_body_without_reason + + def test_it_gets_en_html_body_without_reason(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_reason in html_body diff --git a/fittrackee/tests/emails/test_email_comment_unsuspension.py b/fittrackee/tests/emails/test_email_comment_unsuspension.py new file mode 100644 index 000000000..7f45ded7c --- /dev/null +++ b/fittrackee/tests/emails/test_email_comment_unsuspension.py @@ -0,0 +1,143 @@ +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_comment_unsuspension import ( + expected_en_html_body, + expected_en_html_body_without_reason, + expected_en_text_body, + expected_en_text_body_without_reason, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForCommentReactivation: + template_name = "comment_unsuspension" + EMAIL_DATA = { + "comment_url": ( + "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/" + "comments/ZxB8qgyrcSY6ynNzerfirW" + ), + "created_at": "07/14/2024 - 07:32:47", + "fittrackee_url": "http://localhost", + "reason": "some reason", + "text": "comment text", + "user_image_url": "http://localhost/img/user.png", + "username": "test", + "without_user_action": True, + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ( + 'en', + 'FitTrackee - The suspension on your comment has been lifted', + ), + ( + 'fr', + 'FitTrackee - La suspension de votre commentaire a été levé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( + self.template_name, 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body + + def test_it_gets_text_body_without_reason( + self, + app: Flask, + ) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + text_body = email_template.get_content( + self.template_name, "en", 'body.txt', self.EMAIL_DATA + ) + + assert text_body == expected_en_text_body_without_reason + + def test_it_gets_en_html_body_without_reason(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_reason in html_body diff --git a/fittrackee/tests/emails/test_email_service.py b/fittrackee/tests/emails/test_email_service.py index 3b7f59e43..79f3185b5 100644 --- a/fittrackee/tests/emails/test_email_service.py +++ b/fittrackee/tests/emails/test_email_service.py @@ -9,7 +9,7 @@ from fittrackee.emails.email import EmailMessage from fittrackee.emails.exceptions import InvalidEmailUrlScheme -from ..mixins import CallArgsMixin +from ..mixins import BaseTestMixin from .template_results.password_reset_request import expected_en_text_body @@ -55,7 +55,7 @@ def test_it_calls_make_msgid_with_sender_domain(self) -> None: make_msgid_mock.assert_called_once_with(domain=sender.split("@")[-1]) -class TestEmailServiceUrlParser(CallArgsMixin): +class TestEmailServiceUrlParser(BaseTestMixin): def test_it_raises_error_if_url_scheme_is_invalid(self) -> None: url = 'stmp://username:password@localhost:587' with pytest.raises(InvalidEmailUrlScheme): @@ -147,7 +147,7 @@ def test_it_parses_email_url_with_encoded_username(self) -> None: assert parsed_email['use_ssl'] is True -class TestEmailServiceSend(CallArgsMixin): +class TestEmailServiceSend(BaseTestMixin): email_data = { 'expiration_delay': '3 seconds', 'username': 'test', diff --git a/fittrackee/tests/emails/test_email_user_suspension.py b/fittrackee/tests/emails/test_email_user_suspension.py new file mode 100644 index 000000000..11aa02aff --- /dev/null +++ b/fittrackee/tests/emails/test_email_user_suspension.py @@ -0,0 +1,132 @@ +from typing import Dict, Optional + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_user_suspension import ( + expected_en_html_body, + expected_en_html_body_without_reason, + expected_en_text_body, + expected_en_text_body_without_reason, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForUserSuspension: + template_name = "user_suspension" + EMAIL_DATA: Dict[str, Optional[str]] = { + "appeal_url": "http://localhost/profile/suspension", + "fittrackee_url": "http://localhost", + "reason": "some reason", + "username": "test", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Your account has been suspended'), + ('fr', 'FitTrackee - Votre compte a été suspendu'), + ], + ) + 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( + self.template_name, 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body + + def test_it_gets_text_body_without_reason( + self, + app: Flask, + ) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + text_body = email_template.get_content( + self.template_name, "en", 'body.txt', self.EMAIL_DATA + ) + + assert text_body == expected_en_text_body_without_reason + + def test_it_gets_en_html_body_without_reason(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_reason in html_body diff --git a/fittrackee/tests/emails/test_email_user_unsuspension.py b/fittrackee/tests/emails/test_email_user_unsuspension.py new file mode 100644 index 000000000..ca9e7bcbb --- /dev/null +++ b/fittrackee/tests/emails/test_email_user_unsuspension.py @@ -0,0 +1,130 @@ +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_user_unsuspension import ( + expected_en_html_body, + expected_en_html_body_without_reason, + expected_en_text_body, + expected_en_text_body_without_reason, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForUserReactivation: + template_name = "user_unsuspension" + EMAIL_DATA = { + "fittrackee_url": "http://localhost", + "reason": "some reason", + "username": "test", + "without_user_action": True, + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Your account has been reactivated'), + ('fr', 'FitTrackee - Votre compte a été réactivé'), + ], + ) + 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( + self.template_name, 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body + + def test_it_gets_text_body_without_reason( + self, + app: Flask, + ) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + text_body = email_template.get_content( + self.template_name, "en", 'body.txt', self.EMAIL_DATA + ) + + assert text_body == expected_en_text_body_without_reason + + def test_it_gets_en_html_body_without_reason(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_reason in html_body diff --git a/fittrackee/tests/emails/test_email_user_warning_lifting_on_comment.py b/fittrackee/tests/emails/test_email_user_warning_lifting_on_comment.py new file mode 100644 index 000000000..a2ee95b9c --- /dev/null +++ b/fittrackee/tests/emails/test_email_user_warning_lifting_on_comment.py @@ -0,0 +1,99 @@ +from typing import Dict, Optional, Union + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_user_warning_lifting_on_comment import ( + expected_en_html_body, + expected_en_text_body, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForUserWarningLiftingOnCommentReport: + template_name = "user_warning_lifting" + EMAIL_DATA: Dict[str, Union[Optional[str], bool]] = { + "comment_url": ( + "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/" + "comments/ZxB8qgyrcSY6ynNzerfirW" + ), + "created_at": "07/14/2024 - 07:32:47", + "fittrackee_url": "http://localhost", + "text": "comment text", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + "without_user_action": True, + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Warning for Test lifted'), + ('fr', 'FitTrackee - Avertissement levé pour Test'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body diff --git a/fittrackee/tests/emails/test_email_user_warning_lifting_on_user.py b/fittrackee/tests/emails/test_email_user_warning_lifting_on_user.py new file mode 100644 index 000000000..89c97dd1a --- /dev/null +++ b/fittrackee/tests/emails/test_email_user_warning_lifting_on_user.py @@ -0,0 +1,93 @@ +from typing import Dict, Optional, Union + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_user_warning_lifting_on_user import ( + expected_en_html_body, + expected_en_text_body, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForUserWarningLiftingOnUserReport: + template_name = "user_warning_lifting" + EMAIL_DATA: Dict[str, Union[Optional[str], bool]] = { + "fittrackee_url": "http://localhost", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + "without_user_action": True, + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Warning for Test lifted'), + ('fr', 'FitTrackee - Avertissement levé pour Test'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body diff --git a/fittrackee/tests/emails/test_email_user_warning_lifting_on_workout.py b/fittrackee/tests/emails/test_email_user_warning_lifting_on_workout.py new file mode 100644 index 000000000..a05daa892 --- /dev/null +++ b/fittrackee/tests/emails/test_email_user_warning_lifting_on_workout.py @@ -0,0 +1,116 @@ +from typing import Dict, Optional, Union + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_user_warning_lifting_on_workout import ( + expected_en_html_body, + expected_en_html_body_without_map, + expected_en_text_body, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForUserWarningLiftingOnWorkoutReport: + template_name = "user_warning_lifting" + EMAIL_DATA: Dict[str, Union[Optional[str], bool]] = { + "created_at": "07/14/2024 - 07:32:47", + "fittrackee_url": "http://localhost", + "map": "http://localhost/workouts/map/ZxB8qgyrcSY6ynNzerfirW", + "title": "workout title", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + "without_user_action": True, + "workout_date": "07/14/2024 - 07:32:47", + "workout_url": "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Warning for Test lifted'), + ('fr', 'FitTrackee - Avertissement levé pour Test'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body + + def test_it_gets_en_html_body_without_map(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "map": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_map in html_body diff --git a/fittrackee/tests/emails/test_email_user_warning_on_comment.py b/fittrackee/tests/emails/test_email_user_warning_on_comment.py new file mode 100644 index 000000000..88c88e617 --- /dev/null +++ b/fittrackee/tests/emails/test_email_user_warning_on_comment.py @@ -0,0 +1,139 @@ +from typing import Dict, Optional + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_user_warning_on_comment import ( + expected_en_html_body, + expected_en_html_body_without_reason, + expected_en_text_body, + expected_en_text_body_without_reason, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForUserWarningOnCommentReport: + template_name = "user_warning" + EMAIL_DATA: Dict[str, Optional[str]] = { + "appeal_url": "http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal", + "comment_url": ( + "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/" + "comments/ZxB8qgyrcSY6ynNzerfirW" + ), + "created_at": "07/14/2024 - 07:32:47", + "fittrackee_url": "http://localhost", + "reason": "some reason", + "text": "comment text", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Warning for Test'), + ('fr', 'FitTrackee - Avertissement pour Test'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body + + def test_it_gets_text_body_without_reason( + self, + app: Flask, + ) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + text_body = email_template.get_content( + self.template_name, "en", 'body.txt', self.EMAIL_DATA + ) + + assert text_body == expected_en_text_body_without_reason + + def test_it_gets_en_html_body_without_reason(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_reason in html_body diff --git a/fittrackee/tests/emails/test_email_user_warning_on_user.py b/fittrackee/tests/emails/test_email_user_warning_on_user.py new file mode 100644 index 000000000..6ea8a5eab --- /dev/null +++ b/fittrackee/tests/emails/test_email_user_warning_on_user.py @@ -0,0 +1,133 @@ +from typing import Dict, Optional + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_user_warning_on_user import ( + expected_en_html_body, + expected_en_html_body_without_reason, + expected_en_text_body, + expected_en_text_body_without_reason, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForUserWarningOnUserReport: + template_name = "user_warning" + EMAIL_DATA: Dict[str, Optional[str]] = { + "appeal_url": "http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal", + "fittrackee_url": "http://localhost", + "reason": "some reason", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Warning for Test'), + ('fr', 'FitTrackee - Avertissement pour Test'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body + + def test_it_gets_text_body_without_reason( + self, + app: Flask, + ) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + text_body = email_template.get_content( + self.template_name, "en", 'body.txt', self.EMAIL_DATA + ) + + assert text_body == expected_en_text_body_without_reason + + def test_it_gets_en_html_body_without_reason(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_reason in html_body diff --git a/fittrackee/tests/emails/test_email_user_warning_on_workout.py b/fittrackee/tests/emails/test_email_user_warning_on_workout.py new file mode 100644 index 000000000..192f43a47 --- /dev/null +++ b/fittrackee/tests/emails/test_email_user_warning_on_workout.py @@ -0,0 +1,156 @@ +from typing import Dict, Optional + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_user_warning_on_workout import ( + expected_en_html_body, + expected_en_html_body_without_map, + expected_en_html_body_without_reason, + expected_en_text_body, + expected_en_text_body_without_reason, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForUserWarningOnWorkoutReport: + template_name = "user_warning" + EMAIL_DATA: Dict[str, Optional[str]] = { + "appeal_url": "http://localhost/profile/warning/mf2FVvtogeMCoJV4yS8Cnp/appeal", + "created_at": "07/14/2024 - 07:32:47", + "fittrackee_url": "http://localhost", + "map": "http://localhost/workouts/map/ZxB8qgyrcSY6ynNzerfirW", + "reason": "some reason", + "title": "workout title", + "user_image_url": "http://localhost/img/user.png", + "username": "Test", + "workout_date": "07/14/2024 - 07:32:47", + "workout_url": "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Warning for Test'), + ('fr', 'FitTrackee - Avertissement pour Test'), + ], + ) + 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( + self.template_name, lang, 'subject.txt', self.EMAIL_DATA + ) + + 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body + + def test_it_gets_text_body_without_reason( + self, + app: Flask, + ) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + text_body = email_template.get_content( + self.template_name, "en", 'body.txt', self.EMAIL_DATA + ) + + assert text_body == expected_en_text_body_without_reason + + def test_it_gets_en_html_body_without_reason(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_reason in html_body + + def test_it_gets_en_html_body_without_map(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "map": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_map in html_body diff --git a/fittrackee/tests/emails/test_email_workout_suspension.py b/fittrackee/tests/emails/test_email_workout_suspension.py new file mode 100644 index 000000000..023081a46 --- /dev/null +++ b/fittrackee/tests/emails/test_email_workout_suspension.py @@ -0,0 +1,155 @@ +from typing import Dict, Optional + +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_workout_suspension import ( + expected_en_html_body, + expected_en_html_body_without_map, + expected_en_html_body_without_reason, + expected_en_text_body, + expected_en_text_body_without_reason, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForWorkoutSuspension: + template_name = "workout_suspension" + EMAIL_DATA: Dict[str, Optional[str]] = { + "fittrackee_url": "http://localhost", + "map": "http://localhost/workouts/map/ZxB8qgyrcSY6ynNzerfirW", + "reason": "some reason", + "text": "comment text", + "title": "workout title", + "user_image_url": "http://localhost/img/user.png", + "username": "test", + "workout_date": "07/14/2024 - 07:32:47", + "workout_url": "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ('en', 'FitTrackee - Your workout has been suspended'), + ('fr', 'FitTrackee - Votre séance a été suspendue'), + ], + ) + 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( + self.template_name, 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body + + def test_it_gets_text_body_without_reason( + self, + app: Flask, + ) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + text_body = email_template.get_content( + self.template_name, "en", 'body.txt', self.EMAIL_DATA + ) + + assert text_body == expected_en_text_body_without_reason + + def test_it_gets_en_html_body_without_reason(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_reason in html_body + + def test_it_gets_en_html_body_without_map(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "map": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_map in html_body diff --git a/fittrackee/tests/emails/test_email_workout_unsuspension.py b/fittrackee/tests/emails/test_email_workout_unsuspension.py new file mode 100644 index 000000000..55ce83b90 --- /dev/null +++ b/fittrackee/tests/emails/test_email_workout_unsuspension.py @@ -0,0 +1,157 @@ +import pytest +from flask import Flask + +from fittrackee.emails.email import EmailTemplate + +from .template_results.email_workout_unsuspension import ( + expected_en_html_body, + expected_en_html_body_without_map, + expected_en_html_body_without_reason, + expected_en_text_body, + expected_en_text_body_without_reason, + expected_fr_html_body, + expected_fr_text_body, +) + + +class TestEmailTemplateForWorkoutReactivation: + template_name = "workout_unsuspension" + EMAIL_DATA = { + "fittrackee_url": "http://localhost", + "map": "http://localhost/workouts/map/ZxB8qgyrcSY6ynNzerfirW", + "reason": "some reason", + "text": "comment text", + "title": "workout title", + "user_image_url": "http://localhost/img/user.png", + "username": "test", + "without_user_action": True, + "workout_date": "07/14/2024 - 07:32:47", + "workout_url": "http://localhost/workouts/CVsE8ERggQcHc7PcCdwGHC/", + } + + @pytest.mark.parametrize( + 'lang, expected_subject', + [ + ( + 'en', + 'FitTrackee - The suspension on your workout has been lifted', + ), + ('fr', 'FitTrackee - La suspension de votre séance a été levé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( + self.template_name, 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( + self.template_name, 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'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body in html_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'], + ) + + html_body = email_template.get_content( + self.template_name, 'fr', 'body.html', self.EMAIL_DATA + ) + + assert expected_fr_html_body in html_body + + def test_it_gets_text_body_without_reason( + self, + app: Flask, + ) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + text_body = email_template.get_content( + self.template_name, "en", 'body.txt', self.EMAIL_DATA + ) + + assert text_body == expected_en_text_body_without_reason + + def test_it_gets_en_html_body_without_reason(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "reason": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_reason in html_body + + def test_it_gets_en_html_body_without_map(self, app: Flask) -> None: + self.EMAIL_DATA = { + **self.EMAIL_DATA, + "map": None, + } + email_template = EmailTemplate( + app.config['TEMPLATES_FOLDER'], + app.config['TRANSLATIONS_FOLDER'], + app.config['LANGUAGES'], + ) + + html_body = email_template.get_content( + self.template_name, 'en', 'body.html', self.EMAIL_DATA + ) + + assert expected_en_html_body_without_map in html_body diff --git a/fittrackee/tests/equipments/test_equipment_types_api.py b/fittrackee/tests/equipments/test_equipment_types_api.py index 9e7517520..284304a80 100644 --- a/fittrackee/tests/equipments/test_equipment_types_api.py +++ b/fittrackee/tests/equipments/test_equipment_types_api.py @@ -1,4 +1,5 @@ import json +from datetime import datetime import pytest from flask import Flask @@ -82,6 +83,35 @@ def test_it_gets_all_equipment_types_admin_user( equipment_type_2_bike.serialize(is_admin=True) ) + def test_suspended_ser_can_get_all_equipment_types( + self, + app: Flask, + user_1: User, + equipment_type_1_shoe: EquipmentType, + equipment_type_1_shoe_inactive: EquipmentType, + equipment_type_2_bike: EquipmentType, + ) -> None: + user_1.suspended_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/equipment-types', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert 'success' in data['status'] + assert len(data['data']['equipment_types']) == 2 + assert data['data']['equipment_types'][0] == jsonify_dict( + equipment_type_1_shoe.serialize(is_admin=False) + ) + assert data['data']['equipment_types'][1] == jsonify_dict( + equipment_type_2_bike.serialize(is_admin=False) + ) + @pytest.mark.parametrize( 'client_scope, can_access', {**OAUTH_SCOPES, 'equipments:read': True}.items(), diff --git a/fittrackee/tests/equipments/test_equipments_api.py b/fittrackee/tests/equipments/test_equipments_api.py index 9fa1e198e..6d22ddcee 100644 --- a/fittrackee/tests/equipments/test_equipments_api.py +++ b/fittrackee/tests/equipments/test_equipments_api.py @@ -1,5 +1,5 @@ import json -from datetime import timedelta +from datetime import datetime, timedelta from typing import Tuple import pytest @@ -31,6 +31,24 @@ def test_it_returns_error_if_user_is_not_authenticated( self.assert_401(response) + def test_it_does_not_return_error_if_user_suspended( + self, app: Flask, user_1: User + ) -> None: + user_1.suspended_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/equipments', + 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']['equipments'] == [] + def test_it_gets_all_user_equipments( self, app: Flask, @@ -192,6 +210,30 @@ def test_it_gets_equipment_by_id( jsonify_dict(equipment_bike_user_1.serialize()) ] + def test_suspended_user_can_get_equipment( + self, + app: Flask, + user_1: User, + equipment_bike_user_1: Equipment, + equipment_shoes_user_1: Equipment, + ) -> None: + user_1.suspended_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/equipments/{equipment_bike_user_1.short_id}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert 'success' in data['status'] + assert data['data']['equipments'] == [ + jsonify_dict(equipment_bike_user_1.serialize()) + ] + def test_it_returns_error_when_equipment_does_not_exist( self, app: Flask, diff --git a/fittrackee/tests/fixtures/fixtures_app.py b/fittrackee/tests/fixtures/fixtures_app.py index 8c0616542..78214d553 100644 --- a/fittrackee/tests/fixtures/fixtures_app.py +++ b/fittrackee/tests/fixtures/fixtures_app.py @@ -134,7 +134,7 @@ def app_with_3_users_max(monkeypatch: pytest.MonkeyPatch) -> Generator: @pytest.fixture -def app_no_config() -> Generator: +def app_no_config(monkeypatch: pytest.MonkeyPatch) -> Generator: yield from get_app(with_config=False) diff --git a/fittrackee/tests/fixtures/fixtures_emails.py b/fittrackee/tests/fixtures/fixtures_emails.py index f40894ab0..04ae1015c 100644 --- a/fittrackee/tests/fixtures/fixtures_emails.py +++ b/fittrackee/tests/fixtures/fixtures_emails.py @@ -58,3 +58,75 @@ def account_confirmation_email_mock() -> Iterator[MagicMock]: def data_export_email_mock() -> Iterator[MagicMock]: with patch('fittrackee.users.export_data.data_export_email') as mock: yield mock + + +@pytest.fixture() +def user_suspension_email_mock() -> Iterator[MagicMock]: + with patch( + 'fittrackee.reports.reports_email_service.user_suspension_email' + ) as mock: + yield mock + + +@pytest.fixture() +def user_unsuspension_email_mock() -> Iterator[MagicMock]: + with patch( + 'fittrackee.reports.reports_email_service.user_unsuspension_email' + ) as mock: + yield mock + + +@pytest.fixture() +def user_warning_email_mock() -> Iterator[MagicMock]: + with patch( + 'fittrackee.reports.reports_email_service.user_warning_email' + ) as mock: + yield mock + + +@pytest.fixture() +def user_warning_lifting_email_mock() -> Iterator[MagicMock]: + with patch( + 'fittrackee.reports.reports_email_service.user_warning_lifting_email' + ) as mock: + yield mock + + +@pytest.fixture() +def comment_suspension_email_mock() -> Iterator[MagicMock]: + with patch( + 'fittrackee.reports.reports_email_service.comment_suspension_email' + ) as mock: + yield mock + + +@pytest.fixture() +def comment_unsuspension_email_mock() -> Iterator[MagicMock]: + with patch( + 'fittrackee.reports.reports_email_service.comment_unsuspension_email' + ) as mock: + yield mock + + +@pytest.fixture() +def workout_suspension_email_mock() -> Iterator[MagicMock]: + with patch( + 'fittrackee.reports.reports_email_service.workout_suspension_email' + ) as mock: + yield mock + + +@pytest.fixture() +def workout_unsuspension_email_mock() -> Iterator[MagicMock]: + with patch( + 'fittrackee.reports.reports_email_service.workout_unsuspension_email' + ) as mock: + yield mock + + +@pytest.fixture() +def appeal_rejected_email_mock() -> Iterator[MagicMock]: + with patch( + 'fittrackee.reports.reports_email_service.appeal_rejected_email' + ) as mock: + yield mock diff --git a/fittrackee/tests/fixtures/fixtures_users.py b/fittrackee/tests/fixtures/fixtures_users.py index c59f13c29..5c16e2830 100644 --- a/fittrackee/tests/fixtures/fixtures_users.py +++ b/fittrackee/tests/fixtures/fixtures_users.py @@ -3,16 +3,18 @@ import pytest from fittrackee import db -from fittrackee.users.models import User, UserSportPreference +from fittrackee.users.models import FollowRequest, User, UserSportPreference +from fittrackee.users.roles import UserRole from fittrackee.workouts.models import Sport -from ..utils import random_string +from ..utils import generate_follow_request, random_string @pytest.fixture() def user_1() -> User: user = User(username='test', email='test@test.com', password='12345678') user.is_active = True + user.hide_profile_in_users_directory = False user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() @@ -23,6 +25,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.hide_profile_in_users_directory = False user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() @@ -34,7 +37,8 @@ def user_1_admin() -> User: admin = User( username='admin', email='admin@example.com', password='12345678' ) - admin.admin = True + admin.role = UserRole.ADMIN.value + admin.hide_profile_in_users_directory = False admin.is_active = True admin.accepted_policy = datetime.datetime.utcnow() db.session.add(admin) @@ -42,6 +46,36 @@ def user_1_admin() -> User: return admin +@pytest.fixture() +def user_1_moderator() -> User: + moderator = User( + username='moderator', + email='moderator@example.com', + password='12345678', + ) + moderator.role = UserRole.MODERATOR.value + moderator.hide_profile_in_users_directory = False + moderator.is_active = True + moderator.accepted_policy = datetime.datetime.utcnow() + db.session.add(moderator) + db.session.commit() + return moderator + + +@pytest.fixture() +def user_1_owner() -> User: + owner = User( + username='owner', email='owner@example.com', password='12345678' + ) + owner.role = UserRole.OWNER.value + owner.hide_profile_in_users_directory = False + owner.is_active = True + owner.accepted_policy = datetime.datetime.utcnow() + db.session.add(owner) + db.session.commit() + return owner + + @pytest.fixture() def user_1_full() -> User: user = User(username='test', email='test@test.com', password='12345678') @@ -53,6 +87,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.hide_profile_in_users_directory = False user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() @@ -70,6 +105,7 @@ def user_1_raw_speed() -> User: user.timezone = 'America/New_York' user.birth_date = datetime.datetime.strptime('01/01/1980', '%d/%m/%Y') user.is_active = True + user.hide_profile_in_users_directory = False user.use_raw_gpx_speed = True user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) @@ -82,6 +118,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.hide_profile_in_users_directory = False user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() @@ -92,6 +129,19 @@ def user_1_paris() -> User: def user_2() -> User: user = User(username='toto', email='toto@toto.com', password='12345678') user.is_active = True + user.hide_profile_in_users_directory = False + user.accepted_policy = datetime.datetime.utcnow() + db.session.add(user) + db.session.commit() + return user + + +@pytest.fixture() +def user_2_owner() -> User: + user = User(username='toto', email='toto@toto.com', password='12345678') + user.is_active = True + user.hide_profile_in_users_directory = False + user.role = UserRole.OWNER.value user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() @@ -102,7 +152,20 @@ def user_2() -> User: def user_2_admin() -> User: user = User(username='toto', email='toto@toto.com', password='12345678') user.is_active = True - user.admin = True + user.hide_profile_in_users_directory = False + user.role = UserRole.ADMIN.value + user.accepted_policy = datetime.datetime.utcnow() + db.session.add(user) + db.session.commit() + return user + + +@pytest.fixture() +def user_2_moderator() -> User: + user = User(username='toto', email='toto@toto.com', password='12345678') + user.is_active = True + user.hide_profile_in_users_directory = False + user.role = UserRole.MODERATOR.value user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) db.session.commit() @@ -113,6 +176,20 @@ def user_2_admin() -> User: def user_3() -> User: user = User(username='sam', email='sam@test.com', password='12345678') user.is_active = True + user.hide_profile_in_users_directory = False + user.weekm = True + user.accepted_policy = datetime.datetime.utcnow() + db.session.add(user) + db.session.commit() + return user + + +@pytest.fixture() +def user_3_admin() -> User: + user = User(username='sam', email='sam@test.com', password='12345678') + user.is_active = True + user.hide_profile_in_users_directory = False + user.role = UserRole.ADMIN.value user.weekm = True user.accepted_policy = datetime.datetime.utcnow() db.session.add(user) @@ -120,6 +197,17 @@ def user_3() -> User: return user +@pytest.fixture() +def user_4() -> User: + user = User(username='john', email='john@doe.com', password='12345678') + user.is_active = True + user.hide_profile_in_users_directory = False + user.weekm = True + db.session.add(user) + db.session.commit() + return user + + @pytest.fixture() def inactive_user() -> User: user = User( @@ -132,6 +220,22 @@ def inactive_user() -> User: return user +@pytest.fixture() +def suspended_user() -> User: + user = User( + username='suspended_user', + email='suspended_user@example.com', + password='12345678', + ) + user.is_active = True + user.hide_profile_in_users_directory = False + user.accepted_policy = datetime.datetime.utcnow() + user.suspended_at = datetime.datetime.utcnow() + db.session.add(user) + db.session.commit() + return user + + @pytest.fixture() def user_1_sport_1_preference( user_1: User, @@ -191,3 +295,31 @@ def user_2_sport_2_preference( db.session.add(user_sport) db.session.commit() return user_sport + + +@pytest.fixture() +def follow_request_from_user_1_to_user_2( + user_1: User, user_2: User +) -> FollowRequest: + return generate_follow_request(user_1, user_2) + + +@pytest.fixture() +def follow_request_from_user_2_to_user_1( + user_1: User, user_2: User +) -> FollowRequest: + return generate_follow_request(user_2, user_1) + + +@pytest.fixture() +def follow_request_from_user_3_to_user_1( + user_1: User, user_3: User +) -> FollowRequest: + return generate_follow_request(user_3, user_1) + + +@pytest.fixture() +def follow_request_from_user_3_to_user_2( + user_2: User, user_3: User +) -> FollowRequest: + return generate_follow_request(user_3, user_2) diff --git a/fittrackee/tests/fixtures/fixtures_workouts.py b/fittrackee/tests/fixtures/fixtures_workouts.py index b9e4081db..c04237361 100644 --- a/fittrackee/tests/fixtures/fixtures_workouts.py +++ b/fittrackee/tests/fixtures/fixtures_workouts.py @@ -1,6 +1,6 @@ import datetime from io import BytesIO -from typing import Generator, List +from typing import Generator, Iterator, List from unittest.mock import Mock, patch from uuid import uuid4 @@ -24,6 +24,19 @@ byte_image = byte_io.getvalue() +@pytest.fixture(autouse=True) +def update_records_patch(request: pytest.FixtureRequest) -> Iterator[None]: + # allows to disable record creation/update on tests where + # records are not needed + if 'disable_autouse_update_records_patch' in request.keywords: + yield + else: + with patch( + 'fittrackee.workouts.models.update_records', return_value=None + ): + yield + + @pytest.fixture(scope='session', autouse=True) def static_map_get_mock() -> Generator: # to avoid unnecessary requests calls through staticmap diff --git a/fittrackee/tests/mixins.py b/fittrackee/tests/mixins.py index 129823c24..56d4b9bec 100644 --- a/fittrackee/tests/mixins.py +++ b/fittrackee/tests/mixins.py @@ -1,6 +1,8 @@ import json import time +from datetime import datetime from typing import Dict, List, Optional, Tuple, Union +from unittest.mock import Mock from urllib.parse import parse_qs from uuid import uuid4 @@ -10,10 +12,13 @@ from werkzeug.test import TestResponse from fittrackee import db +from fittrackee.comments.models import Comment from fittrackee.oauth2.client import create_oauth2_client from fittrackee.oauth2.models import OAuth2Client, OAuth2Token +from fittrackee.reports.models import Report, ReportAction, ReportActionAppeal from fittrackee.users.models import User from fittrackee.utils import encode_uuid +from fittrackee.workouts.models import Workout from .custom_asserts import ( assert_errored_response, @@ -21,12 +26,43 @@ ) from .utils import ( TEST_OAUTH_CLIENT_METADATA, + get_date_string, random_email, random_int, random_string, ) +class BaseTestMixin: + """call args are returned differently between Python 3.7 and 3.7+""" + + @staticmethod + def get_args(call_args: Tuple) -> Tuple: + if len(call_args) == 2: + args, _ = call_args + else: + _, args, _ = call_args + return args + + @staticmethod + def get_kwargs(call_args: Tuple) -> Dict: + if len(call_args) == 2: + _, kwargs = call_args + else: + _, _, kwargs = call_args + return kwargs + + def assert_call_args_keys_equal( + self, mock: Mock, expected_keys: List + ) -> None: + args_list = self.get_kwargs(mock.call_args) + assert list(args_list.keys()) == expected_keys + + @staticmethod + def assert_dict_contains_subset(container: Dict, subset: Dict) -> None: + assert subset.items() <= container.items() + + class RandomMixin: @staticmethod def random_string( @@ -45,13 +81,23 @@ def random_email() -> str: return random_email() @staticmethod - def random_int(min_val: int = 0, max_val: int = 999999) -> int: - return random_int(min_val, max_val) + def random_int(min_value: int = 0, max_value: int = 999999) -> int: + return random_int(min_value, max_value) @staticmethod def random_short_id() -> str: return encode_uuid(uuid4()) + @staticmethod + def get_date_string( + *, + date_format: Optional[str] = None, + date: Optional[datetime] = None, + ) -> str: + return get_date_string( + date_format if date_format else '%a, %d %b %Y %H:%M:%S GMT', date + ) + class OAuth2Mixin(RandomMixin): @staticmethod @@ -305,22 +351,173 @@ def assert_response_scope( else: self.assert_insufficient_scope(response) + @staticmethod + def assert_return_not_found( + url: str, client: FlaskClient, auth_token: str, message: str + ) -> None: + response = client.post( + url, + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) -class CallArgsMixin: - """call args are returned differently between Python 3.7 and 3.7+""" + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == message - @staticmethod - def get_args(call_args: Tuple) -> Tuple: - if len(call_args) == 2: - args, _ = call_args - else: - _, args, _ = call_args - return args + def assert_return_user_not_found( + self, url: str, client: FlaskClient, auth_token: str + ) -> None: + self.assert_return_not_found( + url, client, auth_token, 'user does not exist' + ) + + +class ReportMixin(RandomMixin): + def create_report( + self, + *, + reporter: User, + reported_object: Union[Comment, User, Workout], + note: Optional[str] = None, + ) -> Report: + report = Report( + note=note if note else self.random_string(), + reported_by=reporter.id, + reported_object=reported_object, + ) + db.session.add(report) + db.session.commit() + return report + + def create_user_report(self, reporter: User, user: User) -> Report: + return self.create_report(reporter=reporter, reported_object=user) @staticmethod - def get_kwargs(call_args: Tuple) -> Dict: - if len(call_args) == 2: - _, kwargs = call_args - else: - _, _, kwargs = call_args - return kwargs + def create_report_action( + moderator: User, + user: User, + report_id: int, + *, + action_type: Optional[str] = None, + comment_id: Optional[int] = None, + workout_id: Optional[int] = None, + ) -> ReportAction: + report_action = ReportAction( + moderator_id=moderator.id, + action_type=action_type if action_type else "user_suspension", + comment_id=( + comment_id + if ( + comment_id + and action_type + and action_type.startswith("comment_") + ) + else None + ), + report_id=report_id, + user_id=user.id, + workout_id=( + workout_id + if ( + workout_id + and action_type + and action_type.startswith("workout_") + ) + else None + ), + ) + db.session.add(report_action) + db.session.commit() + return report_action + + def create_report_user_action( + self, + admin: User, + user: User, + action_type: str = "user_suspension", + report_id: Optional[int] = None, + ) -> ReportAction: + report_id = ( + report_id if report_id else self.create_user_report(admin, user).id + ) + report_action = self.create_report_action( + admin, user, action_type=action_type, report_id=report_id + ) + user.suspended_at = ( + datetime.utcnow() if action_type == "user_suspension" else None + ) + db.session.commit() + return report_action + + def create_report_workout_action( + self, + admin: User, + user: User, + workout: Workout, + action_type: str = "workout_suspension", + ) -> ReportAction: + report_action = ReportAction( + action_type=action_type, + moderator_id=admin.id, + report_id=self.create_report( + reporter=admin, reported_object=workout + ).id, + workout_id=workout.id, + user_id=user.id, + ) + db.session.add(report_action) + return report_action + + def create_report_comment_action( + self, + admin: User, + user: User, + comment: Comment, + action_type: str = "comment_suspension", + ) -> ReportAction: + report_action = ReportAction( + action_type=action_type, + moderator_id=admin.id, + comment_id=comment.id, + report_id=self.create_report( + reporter=admin, reported_object=comment + ).id, + user_id=user.id, + ) + db.session.add(report_action) + comment.suspended_at = ( + datetime.utcnow() if action_type == "comment_suspension" else None + ) + return report_action + + def create_report_comment_actions( + self, admin: User, user: User, comment: Comment + ) -> ReportAction: + for n in range(2): + action_type = ( + "comment_suspension" if n % 2 == 0 else "comment_unsuspension" + ) + report_action = self.create_report_comment_action( + admin, user, comment, action_type + ) + db.session.add(report_action) + report_action = self.create_report_comment_action( + admin, user, comment, "comment_suspension" + ) + db.session.add(report_action) + return report_action + + def create_action_appeal( + self, action_id: int, user: User, with_commit: bool = True + ) -> ReportActionAppeal: + report_action_appeal = ReportActionAppeal( + action_id=action_id, + user_id=user.id, + text=self.random_string(), + ) + db.session.add(report_action_appeal) + if with_commit: + db.session.commit() + return report_action_appeal diff --git a/fittrackee/tests/oauth2/test_oauth2_routes.py b/fittrackee/tests/oauth2/test_oauth2_routes.py index b188d8c59..018ffbc12 100644 --- a/fittrackee/tests/oauth2/test_oauth2_routes.py +++ b/fittrackee/tests/oauth2/test_oauth2_routes.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from typing import Dict, List, Optional, Tuple, Union from unittest.mock import patch @@ -127,6 +128,22 @@ def test_it_creates_oauth_client(self, app: Flask, user_1: User) -> None: oauth_client = OAuth2Client.query.first() assert oauth_client is not None + def test_suspended_user_can_not_create_oauth_client( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + self.route, + data=json.dumps(TEST_OAUTH_CLIENT_METADATA), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + def test_it_returns_serialized_oauth_client( self, app: Flask, user_1: User ) -> None: @@ -216,6 +233,30 @@ def test_it_returns_error_not_authenticated( self.assert_401(response) + def test_it_returns_error_when_user_is_suspended( + self, app: Flask, user_1: User + ) -> None: + oauth_client = self.create_oauth2_client(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + user_1.suspended_at = datetime.utcnow() + + response = client.post( + self.route, + data={ + 'client_id': oauth_client.client_id, + 'response_type': 'code', + 'confirm': True, + }, + headers=dict( + Authorization=f'Bearer {auth_token}', + content_type='multipart/form-data', + ), + ) + + self.assert_403(response) + def test_it_returns_error_when_client_id_is_missing( self, app: Flask, user_1: User ) -> None: @@ -610,6 +651,26 @@ def test_it_returns_access_token(self, app: Flask, user_1: User) -> None: self.assert_token_is_returned(response) + def test_it_returns_access_token_when_user_is_suspended( + self, app: Flask, user_1: User + ) -> None: + oauth_client, code = self.create_authorized_oauth_client(app, user_1) + client = app.test_client() + user_1.suspended_at = datetime.utcnow() + + response = client.post( + self.route, + data={ + 'client_id': oauth_client.client_id, + 'client_secret': oauth_client.client_secret, + 'grant_type': 'authorization_code', + 'code': code, + }, + headers=dict(content_type='multipart/form-data'), + ) + + self.assert_token_is_returned(response) + class TestOAuthIssueAccessTokenWithCodeChallenge(OAuthIssueTokenTestCase): route = '/api/oauth/token' @@ -740,6 +801,31 @@ def test_it_returns_new_token_when_grant_is_refresh_token( ).first() assert new_token is not None + def test_it_returns_new_token_when_grant_is_refresh_token_and_user_is_suspended( # noqa + self, app: Flask, user_1: User + ) -> None: + oauth_client, token = self.generate_token(app, user_1) + client = app.test_client() + user_1.suspended_at = datetime.utcnow() + + response = client.post( + self.route, + data={ + 'client_id': oauth_client.client_id, + 'client_secret': oauth_client.client_secret, + 'grant_type': 'refresh_token', + 'refresh_token': token['refresh_token'], + }, + headers=dict(content_type='multipart/form-data'), + ) + + data = self.assert_token_is_returned(response) + assert data['access_token'] != token['access_token'] + new_token = OAuth2Token.query.filter_by( + access_token=token['access_token'] + ).first() + assert new_token is not None + def test_it_revokes_old_token(self, app: Flask, user_1: User) -> None: oauth_client, token = self.generate_token(app, user_1) client = app.test_client() @@ -788,6 +874,33 @@ def test_it_revokes_user_token(self, app: Flask, user_1: User) -> None: ).first() assert token.access_token_revoked_at is not None + def test_it_revokes_user_token_when_user_is_suspended( + self, app: Flask, user_1: User + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token(app, user_1) + user_1.suspended_at = datetime.utcnow() + + response = client.post( + self.route, + data={ + 'client_id': oauth_client.client_id, + 'client_secret': oauth_client.client_secret, + 'token': access_token, + }, + headers=dict(content_type='multipart/form-data'), + ) + + assert response.status_code == 200 + token = OAuth2Token.query.filter_by( + client_id=oauth_client.client_id + ).first() + assert token.access_token_revoked_at is not None + class TestOAuthGetClients(ApiTestCaseMixin): route = '/api/oauth/apps' @@ -819,6 +932,31 @@ def test_it_returns_empty_list_when_no_clients( assert data['status'] == 'success' assert data['data']['clients'] == [] + def test_it_returns_apps_when_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + self.create_oauth2_client(suspended_user) + + response = client.get( + self.route, + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['clients']) == 1 + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 1, + } + def test_it_returns_pagination(self, app: Flask, user_1: User) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -978,6 +1116,33 @@ def test_it_returns_user_oauth_client( == TEST_OAUTH_CLIENT_METADATA['client_uri'] ) + def test_it_returns_user_oauth_client_when_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + client_description = self.random_string() + oauth_client = self.create_oauth2_client( + suspended_user, + metadata={ + **TEST_OAUTH_CLIENT_METADATA, + 'client_description': client_description, + }, + ) + client_id = oauth_client.id + client_client_id = oauth_client.client_id + + response = client.get( + self.route.format(client_id=client_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['data']['client']['client_id'] == client_client_id + def test_it_does_not_return_oauth_client_from_another_user( self, app: Flask, user_1: User, user_2: User ) -> None: @@ -1069,6 +1234,32 @@ def test_it_returns_user_oauth_client( == TEST_OAUTH_CLIENT_METADATA['client_uri'] ) + def test_it_returns_user_oauth_client_when_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + client_description = self.random_string() + oauth_client = self.create_oauth2_client( + suspended_user, + metadata={ + **TEST_OAUTH_CLIENT_METADATA, + 'client_description': client_description, + }, + ) + client_client_id = oauth_client.client_id + + response = client.get( + self.route.format(client_id=client_client_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['data']['client']['client_id'] == client_client_id + def test_it_does_not_return_oauth_client_from_another_user( self, app: Flask, user_1: User, user_2: User ) -> None: @@ -1135,6 +1326,25 @@ def test_it_deletes_user_oauth_client( deleted_client = OAuth2Client.query.filter_by(id=client_id).first() assert deleted_client is None + def test_it_deletes_user_oauth_client_when_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + oauth_client = self.create_oauth2_client(suspended_user) + client_id = oauth_client.id + + response = client.delete( + self.route.format(client_id=client_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + deleted_client = OAuth2Client.query.filter_by(id=client_id).first() + assert deleted_client is None + def test_it_deletes_user_authorized_oauth_client( self, app: Flask, user_1: User ) -> None: @@ -1269,6 +1479,26 @@ def test_it_revokes_all_client_tokens( for token in tokens: assert token.is_revoked() + def test_it_revokes_client_tokens_when_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + oauth_client = self.create_oauth2_client(suspended_user) + token = self.create_oauth2_token(oauth_client) + + response = client.post( + self.route.format(client_id=oauth_client.id), + 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 token.is_revoked() + def test_it_does_not_revoke_another_client_token( self, app: Flask, user_1: User ) -> None: diff --git a/fittrackee/tests/reports/__init__.py b/fittrackee/tests/reports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fittrackee/tests/reports/mixins.py b/fittrackee/tests/reports/mixins.py new file mode 100644 index 000000000..bff65c297 --- /dev/null +++ b/fittrackee/tests/reports/mixins.py @@ -0,0 +1,58 @@ +from fittrackee.reports.models import Report +from fittrackee.reports.reports_service import ReportService +from fittrackee.tests.comments.mixins import CommentMixin +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Workout + + +class ReportServiceCreateReportActionMixin(CommentMixin): + def create_report_for_user( + self, + report_service: ReportService, + reporter: User, + reported_user: User, + ) -> Report: + report = report_service.create_report( + reporter=reporter, + object_id=reported_user.username, + object_type="user", + note=self.random_string(), + ) + return report + + def create_report_for_comment( + self, + report_service: ReportService, + reporter: User, + commenter: User, + workout: Workout, + ) -> Report: + workout.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + commenter, + workout, + text_visibility=VisibilityLevel.PUBLIC, + ) + report = report_service.create_report( + reporter=reporter, + object_id=comment.short_id, + object_type="comment", + note=self.random_string(), + ) + return report + + def create_report_for_workout( + self, + report_service: ReportService, + reporter: User, + workout: Workout, + ) -> Report: + workout.workout_visibility = VisibilityLevel.PUBLIC + report = report_service.create_report( + reporter=reporter, + object_id=workout.short_id, + object_type="workout", + note=self.random_string(), + ) + return report diff --git a/fittrackee/tests/reports/test_report_actions_model.py b/fittrackee/tests/reports/test_report_actions_model.py new file mode 100644 index 000000000..d093ce532 --- /dev/null +++ b/fittrackee/tests/reports/test_report_actions_model.py @@ -0,0 +1,1278 @@ +from datetime import datetime +from typing import Dict + +import pytest +from flask import Flask +from time_machine import travel + +from fittrackee import db +from fittrackee.reports.exceptions import ( + InvalidReportActionAppealException, + InvalidReportActionAppealUserException, + InvalidReportActionException, + ReportActionAppealForbiddenException, + ReportActionForbiddenException, +) +from fittrackee.reports.models import ( + ALL_USER_ACTION_TYPES, + COMMENT_ACTION_TYPES, + REPORT_ACTION_TYPES, + WORKOUT_ACTION_TYPES, + ReportAction, + ReportActionAppeal, +) +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ..comments.mixins import CommentMixin +from ..mixins import ReportMixin + + +class ReportActionTestCase(ReportMixin): + pass + + +class TestReportActionModel(ReportActionTestCase): + def test_it_raises_error_when_action_type_is_invalid( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + with pytest.raises(InvalidReportActionException): + ReportAction( + moderator_id=user_1_admin.id, + action_type=self.random_string(), + report_id=self.create_user_report(user_1_admin, user_2).id, + user_id=user_1_admin.id, + ) + + +class TestReportActionForReportModel(ReportActionTestCase): + @pytest.mark.parametrize("input_action_type", REPORT_ACTION_TYPES) + def test_it_creates_report_report_action_for_a_given_type( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + input_action_type: str, + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + created_at = datetime.utcnow() + + report_action = ReportAction( + action_type=input_action_type, + moderator_id=user_1_admin.id, + created_at=created_at, + report_id=report.id, + ) + db.session.add(report_action) + db.session.commit() + + assert report_action.action_type == input_action_type + assert report_action.moderator_id == user_1_admin.id + assert report_action.comment_id is None + assert report_action.created_at == created_at + assert report_action.reason is None + assert report_action.report_id == report.id + assert report_action.user_id is None + assert report_action.workout is None + + def test_it_creates_report_action_without_given_date( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + now = datetime.utcnow() + action_type = "report_resolution" + + with travel(now, tick=False): + report_action = ReportAction( + action_type=action_type, + moderator_id=user_1_admin.id, + report_id=report.id, + ) + db.session.add(report_action) + db.session.commit() + + assert report_action.action_type == action_type + assert report_action.moderator_id == user_1_admin.id + assert report_action.comment_id is None + assert report_action.created_at == now + assert report_action.reason is None + assert report_action.report_id == report.id + assert report_action.user_id is None + assert report_action.workout is None + + def test_it_does_not_store_user_id_when_action_is_for_report( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + now = datetime.utcnow() + report = self.create_report(reporter=user_2, reported_object=user_3) + action_type = "report_resolution" + + with travel(now, tick=False): + report_action = ReportAction( + action_type=action_type, + moderator_id=user_1_admin.id, + report_id=report.id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + assert report_action.action_type == action_type + assert report_action.moderator_id == user_1_admin.id + assert report_action.comment_id is None + assert report_action.created_at == now + assert report_action.reason is None + assert report_action.report_id == report.id + assert report_action.user_id is None + assert report_action.workout is None + + def test_it_creates_report_action_with_given_reason( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + reason = self.random_short_id() + + report_action = ReportAction( + action_type="report_resolution", + moderator_id=user_1_admin.id, + report_id=report.id, + reason=reason, + ) + db.session.add(report_action) + db.session.commit() + + assert report_action.reason == reason + + +class TestReportActionForUserModel(ReportActionTestCase): + @pytest.mark.parametrize("input_action_type", ALL_USER_ACTION_TYPES) + def test_it_raises_error_when_no_user_given_for_user_report_action( + self, + app: Flask, + user_1_admin: User, + user_2: User, + input_action_type: str, + ) -> None: + with pytest.raises(InvalidReportActionException): + ReportAction( + action_type=input_action_type, + moderator_id=user_1_admin.id, + created_at=datetime.utcnow(), + report_id=self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id, + ) + + def test_it_creates_user_report_action( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + created_at = datetime.utcnow() + + report_action = ReportAction( + action_type="user_suspension", + moderator_id=user_1_admin.id, + created_at=created_at, + report_id=report.id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + assert report_action.action_type == "user_suspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.comment_id is None + assert report_action.created_at == created_at + assert report_action.reason is None + assert report_action.report_id == report.id + assert report_action.user_id == user_2.id + assert report_action.workout is None + + def test_it_sets_none_for_moderator_id_when_admin_user_is_deleted( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + report_id = self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id + action_type = "user_suspension" + now = datetime.utcnow() + report_action = ReportAction( + action_type=action_type, + moderator_id=user_1_admin.id, + created_at=now, + report_id=report_id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + db.session.delete(user_1_admin) + db.session.commit() + + assert report_action.action_type == action_type + assert report_action.moderator_id is None + assert report_action.comment_id is None + assert report_action.created_at == now + assert report_action.reason is None + assert report_action.report_id == report_id + assert report_action.user_id == user_2.id + assert report_action.workout is None + + def test_it_deletes_report_action_when_user_is_deleted( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + report_action = ReportAction( + action_type="user_suspension", + moderator_id=user_1_admin.id, + report_id=self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + db.session.delete(user_2) + db.session.commit() + + assert ReportAction.query.first() is None + + +class TestReportActionForWorkoutModel(ReportActionTestCase): + @pytest.mark.parametrize("input_action_type", WORKOUT_ACTION_TYPES) + def test_it_raises_error_when_no_workout_id_given_for_workout_report_action( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + input_action_type: str, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + with pytest.raises(InvalidReportActionException): + ReportAction( + action_type=input_action_type, + moderator_id=user_1_admin.id, + created_at=datetime.utcnow(), + report_id=self.create_report( + reporter=user_1_admin, + reported_object=workout_cycling_user_2, + ).id, + user_id=user_2.id, + ) + + @pytest.mark.parametrize("input_action_type", WORKOUT_ACTION_TYPES) + def test_it_raises_error_when_no_user_id_given_for_workout_report_action( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_action_type: str, + ) -> None: + with pytest.raises(InvalidReportActionException): + ReportAction( + action_type=input_action_type, + moderator_id=user_1_admin.id, + created_at=datetime.utcnow(), + report_id=self.create_report( + reporter=user_1_admin, + reported_object=workout_cycling_user_2, + ).id, + workout_id=workout_cycling_user_2.id, + ) + + def test_it_creates_workout_report_action( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_id = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ).id + created_at = datetime.utcnow() + + report_action = ReportAction( + action_type="workout_suspension", + moderator_id=user_1_admin.id, + created_at=created_at, + report_id=report_id, + user_id=user_2.id, + workout_id=workout_cycling_user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + assert report_action.action_type == "workout_suspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.comment_id is None + assert report_action.created_at == created_at + assert report_action.reason is None + assert report_action.report_id == report_id + assert report_action.user_id == user_2.id + assert report_action.workout_id == workout_cycling_user_2.id + + def test_it_sets_none_for_moderator_id_when_admin_user_is_deleted( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_id = self.create_report( + reporter=user_1_admin, reported_object=workout_cycling_user_2 + ).id + created_at = datetime.utcnow() + report_action = ReportAction( + action_type="workout_suspension", + moderator_id=user_1_admin.id, + created_at=created_at, + report_id=report_id, + user_id=user_2.id, + workout_id=workout_cycling_user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + db.session.delete(user_1_admin) + db.session.commit() + + assert report_action.action_type == "workout_suspension" + assert report_action.moderator_id is None + assert report_action.comment_id is None + assert report_action.created_at == created_at + assert report_action.reason is None + assert report_action.report_id == report_id + assert report_action.user_id == user_2.id + assert report_action.workout_id == workout_cycling_user_2.id + + def test_it_sets_none_for_workout_id_when_workout_is_deleted( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_id = self.create_report( + reporter=user_1_admin, reported_object=workout_cycling_user_2 + ).id + created_at = datetime.utcnow() + report_action = ReportAction( + action_type="workout_suspension", + moderator_id=user_1_admin.id, + created_at=created_at, + report_id=report_id, + user_id=user_2.id, + workout_id=workout_cycling_user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + db.session.delete(workout_cycling_user_2) + db.session.commit() + + assert report_action.action_type == "workout_suspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.comment_id is None + assert report_action.created_at == created_at + assert report_action.reason is None + assert report_action.report_id == report_id + assert report_action.user_id == user_2.id + assert report_action.workout_id is None + + +class TestReportActionForCommentsModel(CommentMixin, ReportActionTestCase): + @pytest.mark.parametrize("input_action_type", COMMENT_ACTION_TYPES) + def test_it_raises_error_when_no_comment_id_given_for_comment_report_action( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + input_action_type: str, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + report_id = self.create_report( + reporter=user_2, reported_object=comment + ).id + with pytest.raises(InvalidReportActionException): + ReportAction( + action_type=input_action_type, + moderator_id=user_1_admin.id, + created_at=datetime.utcnow(), + report_id=report_id, + user_id=user_2.id, + ) + + @pytest.mark.parametrize("input_action_type", COMMENT_ACTION_TYPES) + def test_it_raises_error_when_no_user_id_given_for_comment_report_action( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_action_type: str, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + with pytest.raises(InvalidReportActionException): + ReportAction( + action_type=input_action_type, + moderator_id=user_1_admin.id, + comment_id=comment.id, + report_id=self.create_report( + reporter=user_2, reported_object=comment + ).id, + created_at=datetime.utcnow(), + ) + + def test_it_creates_comment_report_action( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + report_id = self.create_report( + reporter=user_2, reported_object=comment + ).id + created_at = datetime.utcnow() + + report_action = ReportAction( + action_type="comment_suspension", + moderator_id=user_1_admin.id, + comment_id=comment.id, + created_at=created_at, + report_id=report_id, + user_id=user_3.id, + ) + db.session.add(report_action) + db.session.commit() + + assert report_action.action_type == "comment_suspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.created_at == created_at + assert report_action.comment_id == comment.id + assert report_action.reason is None + assert report_action.report_id == report_id + assert report_action.user_id == user_3.id + assert report_action.workout_id is None + + def test_it_sets_none_for_moderator_id_when_admin_user_is_deleted( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + report_id = self.create_report( + reporter=user_2, reported_object=comment + ).id + created_at = datetime.utcnow() + report_action = ReportAction( + action_type="comment_suspension", + moderator_id=user_1_admin.id, + comment_id=comment.id, + created_at=created_at, + report_id=report_id, + user_id=user_3.id, + ) + db.session.add(report_action) + db.session.commit() + + db.session.delete(user_1_admin) + db.session.commit() + + assert report_action.action_type == "comment_suspension" + assert report_action.moderator_id is None + assert report_action.comment_id == comment.id + assert report_action.created_at == created_at + assert report_action.reason is None + assert report_action.report_id == report_id + assert report_action.user_id == user_3.id + assert report_action.workout_id is None + + def test_it_sets_none_for_comment_id_when_comment_is_deleted( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + report_id = self.create_report( + reporter=user_2, reported_object=comment + ).id + created_at = datetime.utcnow() + report_action = ReportAction( + action_type="comment_suspension", + moderator_id=user_1_admin.id, + comment_id=comment.id, + created_at=created_at, + report_id=report_id, + user_id=user_3.id, + ) + db.session.add(report_action) + db.session.commit() + + db.session.delete(comment) + db.session.commit() + + assert report_action.action_type == "comment_suspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.created_at == created_at + assert report_action.comment_id is None + assert report_action.reason is None + assert report_action.report_id == report_id + assert report_action.user_id == user_3.id + assert report_action.workout_id is None + + +class TestReportActionSerializer(CommentMixin, ReportActionTestCase): + def test_it_returns_minimal_serialized_report_action( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="user_suspension", + report_id=self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + serialized_action = report_action.serialize(user_1_admin, full=False) + + assert serialized_action == { + 'moderator': user_1_admin.serialize(current_user=user_1_admin), + 'appeal': None, + 'action_type': report_action.action_type, + 'created_at': report_action.created_at, + 'id': report_action.short_id, + 'reason': None, + 'report_id': report_action.id, + 'user': user_2.serialize(current_user=user_1_admin), + } + + @pytest.mark.parametrize('input_full_argument', [{}, {"full": True}]) + def test_it_returns_full_serialized_user_report_action( + self, + app: Flask, + user_1_admin: User, + user_2: User, + input_full_argument: Dict, + ) -> None: + report_id = self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="user_suspension", + report_id=report_id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + serialized_action = report_action.serialize( + user_1_admin, **input_full_argument + ) + + assert serialized_action['action_type'] == report_action.action_type + assert serialized_action['moderator'] == user_1_admin.serialize( + current_user=user_1_admin + ) + assert serialized_action['comment'] is None + assert serialized_action['created_at'] == report_action.created_at + assert serialized_action['id'] == report_action.short_id + assert serialized_action['reason'] is None + assert serialized_action['report_id'] == report_id + assert serialized_action['user'] == user_2.serialize( + current_user=user_1_admin + ) + assert serialized_action['workout'] is None + + def test_it_returns_serialized_report_action_with_appeal_for_admin( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + report_id = self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="user_suspension", + report_id=report_id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.flush() + appeal = ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=self.random_string(), + ) + db.session.add(appeal) + db.session.commit() + + serialized_action = report_action.serialize(user_1_admin) + + assert serialized_action['action_type'] == report_action.action_type + assert serialized_action['moderator'] == user_1_admin.serialize( + current_user=user_1_admin + ) + assert serialized_action['appeal'] == appeal.serialize(user_1_admin) + assert serialized_action['created_at'] == report_action.created_at + assert serialized_action['comment'] is None + assert serialized_action['id'] == report_action.short_id + assert serialized_action['reason'] is None + assert serialized_action['report_id'] == report_id + assert serialized_action['user'] == user_2.serialize( + current_user=user_1_admin + ) + assert serialized_action['workout'] is None + + def test_it_serialized_user_action_for_user( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="user_suspension", + report_id=self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + serialized_action = report_action.serialize(user_2) + + assert serialized_action == { + "action_type": report_action.action_type, + "appeal": None, + "comment": None, + "created_at": report_action.created_at, + "reason": report_action.reason, + "id": report_action.short_id, + "workout": None, + } + + def test_it_serialized_action_with_appeal_for_user( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="user_suspension", + report_id=self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.flush() + appeal = ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=self.random_string(), + ) + db.session.add(appeal) + db.session.commit() + + serialized_action = report_action.serialize(user_2) + + assert serialized_action == { + "action_type": report_action.action_type, + "appeal": appeal.serialize(user_2), + "comment": None, + "created_at": report_action.created_at, + "id": report_action.short_id, + "reason": report_action.reason, + "workout": None, + } + + def test_it_raises_error_when_user_is_not_action_user( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="user_suspension", + report_id=self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id, + user_id=user_2.id, + ) + + with pytest.raises(ReportActionForbiddenException): + report_action.serialize(user_3) + + @pytest.mark.parametrize('input_action_type', REPORT_ACTION_TYPES) + def test_it_raises_error_when_user_has_no_admin_rights_and_action_is_report_related( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + input_action_type: str, + ) -> None: + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type=input_action_type, + report_id=self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id, + ) + + with pytest.raises(ReportActionForbiddenException): + report_action.serialize(user_2) + + def test_it_returns_serialized_workout_report_action_for_user( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="workout_suspension", + user_id=user_2.id, + report_id=self.create_report( + reporter=user_1_admin, reported_object=workout_cycling_user_2 + ).id, + workout_id=workout_cycling_user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + serialized_action = report_action.serialize(user_2) + + assert serialized_action == { + "action_type": report_action.action_type, + "appeal": None, + "comment": None, + "created_at": report_action.created_at, + "id": report_action.short_id, + "reason": report_action.reason, + "workout": workout_cycling_user_2.serialize(user=user_2), + } + + def test_it_returns_serialized_workout_report_action_for_admin( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_id = self.create_report( + reporter=user_1_admin, reported_object=workout_cycling_user_2 + ).id + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="workout_suspension", + report_id=report_id, + user_id=user_2.id, + workout_id=workout_cycling_user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + serialized_action = report_action.serialize(user_1_admin) + + assert serialized_action['action_type'] == report_action.action_type + assert serialized_action['moderator'] == user_1_admin.serialize( + current_user=user_1_admin + ) + assert serialized_action['comment'] is None + assert serialized_action['created_at'] == report_action.created_at + assert serialized_action['id'] == report_action.short_id + assert serialized_action['reason'] is None + assert serialized_action['report_id'] == report_id + assert serialized_action['user'] == user_2.serialize( + current_user=user_1_admin + ) + assert serialized_action[ + 'workout' + ] == workout_cycling_user_2.serialize( + user=user_1_admin, for_report=True + ) + + def test_it_returns_serialized_comment_report_action_for_user( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="comment_suspension", + report_id=self.create_report( + reporter=user_1_admin, reported_object=comment + ).id, + user_id=user_3.id, + comment_id=comment.id, + ) + db.session.add(report_action) + db.session.commit() + + serialized_action = report_action.serialize(user_3) + + assert serialized_action == { + "action_type": report_action.action_type, + "appeal": None, + "comment": comment.serialize(user=user_3), + "created_at": report_action.created_at, + "id": report_action.short_id, + "reason": report_action.reason, + "workout": None, + } + + def test_it_returns_serialized_comment_report_action_for_admin( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + report_id = self.create_report( + reporter=user_1_admin, reported_object=comment + ).id + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="comment_suspension", + comment_id=comment.id, + report_id=report_id, + user_id=user_3.id, + ) + db.session.add(report_action) + db.session.commit() + + serialized_action = report_action.serialize(user_1_admin) + + assert serialized_action['action_type'] == report_action.action_type + assert serialized_action['moderator'] == user_1_admin.serialize( + current_user=user_1_admin + ) + assert serialized_action['comment'] == comment.serialize( + user_1_admin, for_report=True + ) + assert serialized_action['created_at'] == report_action.created_at + assert serialized_action['id'] == report_action.short_id + assert serialized_action['reason'] is None + assert serialized_action['report_id'] == report_id + assert serialized_action['user'] == user_3.serialize( + current_user=user_1_admin + ) + assert serialized_action['workout'] is None + + +class TestReportActionAppealModel(CommentMixin, ReportActionTestCase): + def test_it_raises_error_when_user_is_not_report_action_user( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + ) -> None: + report_action = self.create_report_user_action(user_1_admin, user_2) + + with pytest.raises(InvalidReportActionAppealUserException): + ReportActionAppeal( + action_id=report_action.id, + user_id=user_3.id, + text=self.random_string(), + ) + + @pytest.mark.parametrize( + 'input_action_type', ['user_unsuspension', 'user_warning_lifting'] + ) + def test_it_raises_error_when_user_action_is_invalid( + self, + app: Flask, + user_1_admin: User, + user_2: User, + input_action_type: str, + ) -> None: + report_action = self.create_report_user_action( + user_1_admin, user_2, action_type=input_action_type + ) + + with pytest.raises(InvalidReportActionAppealException): + ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=self.random_string(), + ) + + def test_it_creates_appeal_for_user_suspension_action( + self, + app: Flask, + user_1_admin: User, + user_2: User, + ) -> None: + appeal_text = self.random_string() + report_action = self.create_report_user_action(user_1_admin, user_2) + created_at = datetime.utcnow() + + appeal = ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=appeal_text, + created_at=created_at, + ) + + assert appeal.action_id == report_action.id + assert appeal.moderator_id is None + assert appeal.approved is None + assert appeal.created_at == created_at + assert appeal.reason is None + assert appeal.updated_at is None + assert appeal.user_id == user_2.id + + def test_it_creates_appeal_for_user_warning_action( + self, + app: Flask, + user_1_admin: User, + user_2: User, + ) -> None: + appeal_text = self.random_string() + report_action = self.create_report_user_action( + user_1_admin, user_2, action_type="user_warning" + ) + created_at = datetime.utcnow() + + appeal = ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=appeal_text, + created_at=created_at, + ) + + assert appeal.action_id == report_action.id + assert appeal.moderator_id is None + assert appeal.approved is None + assert appeal.created_at == created_at + assert appeal.reason is None + assert appeal.updated_at is None + assert appeal.user_id == user_2.id + + def test_it_raises_error_when_workout_action_is_invalid( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report_action = self.create_report_workout_action( + user_1_admin, + user_2, + workout_cycling_user_2, + action_type="workout_unsuspension", + ) + db.session.add(report_action) + db.session.flush() + + with pytest.raises(InvalidReportActionAppealException): + ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=self.random_string(), + ) + + def test_it_creates_appeal_for_workout_suspension_action( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_action = self.create_report_workout_action( + user_1_admin, user_2, workout_cycling_user_2 + ) + db.session.add(report_action) + appeal_text = self.random_string() + created_at = datetime.utcnow() + db.session.flush() + + appeal = ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=appeal_text, + created_at=created_at, + ) + + assert appeal.action_id == report_action.id + assert appeal.moderator_id is None + assert appeal.approved is None + assert appeal.created_at == created_at + assert appeal.reason is None + assert appeal.updated_at is None + assert appeal.user_id == user_2.id + + def test_it_raises_error_when_comment_action_is_invalid( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + report_action = self.create_report_comment_action( + user_1_admin, user_2, comment, action_type="comment_unsuspension" + ) + db.session.add(report_action) + db.session.flush() + + with pytest.raises(InvalidReportActionAppealException): + ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=self.random_string(), + ) + + def test_it_creates_appeal_for_comment_suspension_action( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + report_action = self.create_report_comment_action( + user_1_admin, user_2, comment + ) + db.session.add(report_action) + appeal_text = self.random_string() + created_at = datetime.utcnow() + db.session.flush() + + appeal = ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=appeal_text, + created_at=created_at, + ) + + assert appeal.action_id == report_action.id + assert appeal.moderator_id is None + assert appeal.approved is None + assert appeal.created_at == created_at + assert appeal.reason is None + assert appeal.updated_at is None + assert appeal.user_id == user_2.id + + def test_it_creates_appeal_for_a_given_action_without_creation_date( + self, + app: Flask, + user_1_admin: User, + user_2: User, + ) -> None: + appeal_text = self.random_string() + report_action = self.create_report_user_action(user_1_admin, user_2) + now = datetime.utcnow() + + with travel(now, tick=False): + appeal = ReportActionAppeal( + action_id=report_action.id, user_id=user_2.id, text=appeal_text + ) + + assert appeal.action_id == report_action.id + assert appeal.moderator_id is None + assert appeal.approved is None + assert appeal.created_at == now + assert appeal.reason is None + assert appeal.updated_at is None + assert appeal.user_id == user_2.id + + def test_it_deletes_appeal_on_user_deletion( + self, + app: Flask, + user_1_admin: User, + user_2: User, + ) -> None: + appeal_text = self.random_string() + report_action = self.create_report_user_action(user_1_admin, user_2) + appeal = ReportActionAppeal( + action_id=report_action.id, user_id=user_2.id, text=appeal_text + ) + db.session.add(appeal) + db.session.commit() + + db.session.delete(user_2) + db.session.commit() + + assert ReportActionAppeal.query.first() is None + + def test_it_does_not_delete_appeal_on_admin_user_deletion( + self, + app: Flask, + user_1_admin: User, + user_2: User, + ) -> None: + appeal_text = self.random_string() + report_action = self.create_report_user_action(user_1_admin, user_2) + appeal = ReportActionAppeal( + action_id=report_action.id, user_id=user_2.id, text=appeal_text + ) + db.session.add(appeal) + db.session.commit() + + db.session.delete(user_1_admin) + db.session.commit() + + assert ( + ReportActionAppeal.query.filter_by( + action_id=report_action.id + ).first() + is not None + ) + + +class TestReportActionAppealSerializer(ReportActionTestCase): + def test_it_returns_serialized_appeal_for_admin( + self, app: Flask, user_1_admin: User, user_2_admin: User, user_3: User + ) -> None: + report_action = self.create_report_user_action(user_1_admin, user_3) + appeal = ReportActionAppeal( + action_id=report_action.id, + user_id=user_3.id, + text=self.random_string(), + ) + db.session.add(appeal) + db.session.flush() + + serialized_appeal = appeal.serialize(user_2_admin) + + assert serialized_appeal["moderator"] is None + assert serialized_appeal["approved"] is None + assert serialized_appeal["created_at"] == appeal.created_at + assert serialized_appeal["id"] == appeal.short_id + assert serialized_appeal["text"] == appeal.text + assert serialized_appeal["reason"] is None + assert serialized_appeal["user"] == user_3.serialize( + current_user=user_2_admin + ) + assert serialized_appeal["updated_at"] is None + + def test_it_returns_serialized_appeal_for_appeal_user( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + report_action = self.create_report_user_action(user_1_admin, user_2) + appeal = ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=self.random_string(), + ) + db.session.add(appeal) + db.session.flush() + + serialized_appeal = appeal.serialize(user_2) + + assert serialized_appeal == { + "approved": None, + "created_at": appeal.created_at, + "id": appeal.short_id, + "reason": appeal.reason, + "text": appeal.text, + "updated_at": None, + } + + def test_it_raises_error_when_user_is_not_appeal_user( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_action = self.create_report_user_action(user_1_admin, user_2) + appeal = ReportActionAppeal( + action_id=report_action.id, + user_id=user_2.id, + text=self.random_string(), + ) + + with pytest.raises(ReportActionAppealForbiddenException): + appeal.serialize(user_3) diff --git a/fittrackee/tests/reports/test_reports_api.py b/fittrackee/tests/reports/test_reports_api.py new file mode 100644 index 000000000..934e21986 --- /dev/null +++ b/fittrackee/tests/reports/test_reports_api.py @@ -0,0 +1,4064 @@ +import json +from datetime import datetime, timedelta +from typing import Dict, List +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask +from time_machine import travel + +from fittrackee import db +from fittrackee.comments.models import Comment +from fittrackee.reports.models import ( + USER_ACTION_TYPES, + Report, + ReportAction, + ReportActionAppeal, + ReportComment, +) +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ..comments.mixins import CommentMixin +from ..mixins import ApiTestCaseMixin, BaseTestMixin, ReportMixin +from ..utils import OAUTH_SCOPES, jsonify_dict + + +class ReportTestCase( + CommentMixin, ReportMixin, ApiTestCaseMixin, BaseTestMixin +): + route = "/api/reports" + + def create_reports( + self, + user_2: User, + user_3: User, + user_4: User, + workout_cycling_user_2: Workout, + ) -> List[Report]: + reports = [self.create_report(reporter=user_2, reported_object=user_4)] + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + reports.append( + self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + ) + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + reports.append( + self.create_report(reporter=user_2, reported_object=comment) + ) + return reports + + +class TestPostReport(ReportTestCase): + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=self.random_short_id(), + object_type="comment", + ) + ), + ) + + self.assert_401(response) + + def test_it_returns_400_when_object_type_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( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=self.random_short_id(), + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response) + + def test_it_returns_400_when_object_type_is_invalid( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=self.random_short_id(), + object_type=self.random_string(), + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response) + + def test_it_returns_400_when_note_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( + self.route, + content_type="application/json", + data=json.dumps( + dict( + object_id=self.random_short_id(), + object_type="comment", + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response) + + def test_it_returns_400_when_object_id_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( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_type="comment", + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response) + + @pytest.mark.parametrize( + "client_scope, can_access", + {**OAUTH_SCOPES, "reports:write": True}.items(), + ) + def test_expected_scopes_are_defined( + self, app: Flask, user_1: User, client_scope: str, can_access: bool + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=self.random_short_id(), + object_type="comment", + ) + ), + headers=dict( + Authorization=f"Bearer {access_token}", + ), + ) + + self.assert_response_scope(response, can_access) + + +class TestPostCommentReport(ReportTestCase): + object_type = "comment" + + def test_it_returns_404_when_comment_is_not_found( + self, app: Flask, user_1: User + ) -> None: + comment_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=comment_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_message( + response, + f"comment not found (id: {comment_id})", + ) + + def test_it_returns_400_when_user_is_comment_author( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=comment.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "users can not report their own comments") + + def test_it_returns_400_when_comment_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=comment.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "comment already suspended") + + def test_it_returns_400_when_report_already_exist( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_report(reporter=user_1, reported_object=comment) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=comment.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "a report already exists") + + def test_it_creates_report_for_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + report_note = self.random_string() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=report_note, + object_id=comment.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 201 + assert response.json == {"status": "created"} + new_report = Report.query.filter_by(reported_by=user_1.id).first() + assert new_report.note == report_note + assert new_report.object_type == self.object_type + assert new_report.reported_by == user_1.id + assert new_report.reported_comment_id == comment.id + assert new_report.reported_user_id == user_2.id + assert new_report.reported_workout_id is None + assert new_report.resolved is False + assert new_report.resolved_at is None + assert new_report.resolved_by is None + assert new_report.updated_at is None + + def test_it_creates_report_for_comment_when_user_report_exists_for_same_user( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_report(reporter=user_1, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + report_note = self.random_string() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=report_note, + object_id=comment.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 201 + assert response.json == {"status": "created"} + new_report = Report.query.filter_by( + reported_by=user_1.id, object_type="comment" + ).first() + assert new_report.note == report_note + assert new_report.object_type == self.object_type + assert new_report.reported_by == user_1.id + assert new_report.reported_comment_id == comment.id + assert new_report.reported_user_id == user_2.id + assert new_report.reported_workout_id is None + assert new_report.resolved is False + assert new_report.resolved_at is None + assert new_report.resolved_by is None + assert new_report.updated_at is None + + def test_it_creates_report_for_comment_when_workout_report_exists_for_same_user( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_report( + reporter=user_1, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + report_note = self.random_string() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=report_note, + object_id=comment.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 201 + assert response.json == {"status": "created"} + new_report = Report.query.filter_by( + reported_by=user_1.id, object_type="comment" + ).first() + assert new_report.note == report_note + assert new_report.object_type == self.object_type + assert new_report.reported_by == user_1.id + assert new_report.reported_comment_id == comment.id + assert new_report.reported_user_id == user_2.id + assert new_report.reported_workout_id is None + assert new_report.resolved is False + assert new_report.resolved_at is None + assert new_report.resolved_by is None + assert new_report.updated_at is None + + +class TestPostWorkoutReport(ReportTestCase): + object_type = "workout" + + def test_it_returns_404_when_workout_is_not_found( + self, app: Flask, user_1: User + ) -> None: + workout_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=workout_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_message( + response, + f"workout not found (id: {workout_id})", + ) + + def test_it_returns_400_when_user_is_workout_owner( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=workout_cycling_user_1.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "users can not report their own workouts") + + def test_it_returns_400_when_workout_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + report_note = self.random_string() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=report_note, + object_id=workout_cycling_user_2.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "workout already suspended") + + def test_it_returns_400_when_report_already_exist( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + self.create_report( + reporter=user_1, reported_object=workout_cycling_user_2 + ) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=workout_cycling_user_2.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "a report already exists") + + def test_it_creates_report_for_workout( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + report_note = self.random_string() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=report_note, + object_id=workout_cycling_user_2.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 201 + assert response.json == {"status": "created"} + new_report = Report.query.filter_by(reported_by=user_1.id).first() + assert new_report.note == report_note + assert new_report.object_type == self.object_type + assert new_report.reported_by == user_1.id + assert new_report.reported_comment_id is None + assert new_report.reported_user_id == user_2.id + assert new_report.reported_workout_id == workout_cycling_user_2.id + assert new_report.resolved is False + assert new_report.resolved_at is None + assert new_report.resolved_by is None + assert new_report.updated_at is None + + def test_it_creates_report_for_workout_when_user_report_exists_for_same_user( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + self.create_report(reporter=user_1, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + report_note = self.random_string() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=report_note, + object_id=workout_cycling_user_2.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 201 + assert response.json == {"status": "created"} + new_report = Report.query.filter_by( + reported_by=user_1.id, object_type="workout" + ).first() + assert new_report.note == report_note + assert new_report.object_type == self.object_type + assert new_report.reported_by == user_1.id + assert new_report.reported_comment_id is None + assert new_report.reported_user_id == user_2.id + assert new_report.reported_workout_id == workout_cycling_user_2.id + assert new_report.resolved is False + assert new_report.resolved_at is None + assert new_report.resolved_by is None + assert new_report.updated_at is None + + def test_it_creates_report_for_workout_when_comment_report_exists_for_same_user( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_report(reporter=user_1, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + report_note = self.random_string() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=report_note, + object_id=workout_cycling_user_2.short_id, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 201 + assert response.json == {"status": "created"} + new_report = Report.query.filter_by( + reported_by=user_1.id, object_type="workout" + ).first() + assert new_report.note == report_note + assert new_report.object_type == self.object_type + assert new_report.reported_by == user_1.id + assert new_report.reported_comment_id is None + assert new_report.reported_user_id == user_2.id + assert new_report.reported_workout_id == workout_cycling_user_2.id + assert new_report.resolved is False + assert new_report.resolved_at is None + assert new_report.resolved_by is None + assert new_report.updated_at is None + + +class TestPostUserReport(ReportTestCase): + object_type = "user" + + def test_it_returns_404_when_user_is_not_found( + self, app: Flask, user_1: User + ) -> None: + username = self.random_string() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=username, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_message( + response, + f"user not found (username: {username})", + ) + + def test_it_returns_400_when_user_is_reported_user( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=user_1.username, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "users can not report their own profile") + + def test_it_returns_400_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + report_note = self.random_string() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=report_note, + object_id=user_2.username, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "user already suspended") + + def test_it_returns_400_when_user_report_already_exist( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + self.create_report(reporter=user_1, reported_object=user_2) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=self.random_string(), + object_id=user_2.username, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "a report already exists") + + def test_it_creates_report_for_user( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + report_note = self.random_string() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=report_note, + object_id=user_2.username, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 201 + assert response.json == {"status": "created"} + new_report = Report.query.filter_by(reported_by=user_1.id).first() + assert new_report.note == report_note + assert new_report.object_type == self.object_type + assert new_report.reported_by == user_1.id + assert new_report.reported_comment_id is None + assert new_report.reported_user_id == user_2.id + assert new_report.reported_workout_id is None + assert new_report.resolved is False + assert new_report.resolved_at is None + assert new_report.resolved_by is None + assert new_report.updated_at is None + + def test_it_creates_report_for_user_when_content_report_exists_for_same_user( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + self.create_report( + reporter=user_1, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + report_note = self.random_string() + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps( + dict( + note=report_note, + object_id=user_2.username, + object_type=self.object_type, + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 201 + assert response.json == {"status": "created"} + new_report = Report.query.filter_by( + reported_by=user_1.id, object_type="user" + ).first() + assert new_report.note == report_note + assert new_report.object_type == self.object_type + assert new_report.reported_by == user_1.id + assert new_report.reported_comment_id is None + assert new_report.reported_user_id == user_2.id + assert new_report.reported_workout_id is None + assert new_report.resolved is False + assert new_report.resolved_at is None + assert new_report.resolved_by is None + assert new_report.updated_at is None + + +class TestGetReportsAsModerator(ReportTestCase): + def test_it_returns_empty_list_when_no_reports( + self, app: Flask, user_1_moderator: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route, + 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["reports"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_returns_all_reports( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route, + 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 len(data["reports"]) == 3 + assert data["reports"][0] == jsonify_dict( + reports[2].serialize(user_1_moderator) + ) + assert data["reports"][1] == jsonify_dict( + reports[1].serialize(user_1_moderator) + ) + assert data["reports"][2] == jsonify_dict( + reports[0].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 3, + } + + def test_it_returns_reports_when_reported_object_not_visible_to_moderator( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PRIVATE + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route, + 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 len(data["reports"]) == 1 + assert data["reports"][0] == jsonify_dict( + report.serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + @pytest.mark.parametrize( + "input_object_type, input_index", + [("comment", 2), ("user", 0), ("workout", 1)], + ) + def test_it_returns_reports_for_a_given_type( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_object_type: str, + input_index: int, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?object_type={input_object_type}", + 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 len(data["reports"]) == 1 + assert data["reports"][0] == jsonify_dict( + reports[input_index].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + @pytest.mark.parametrize( + "input_object_type, input_index", + [("comment", 2), ("user", 0), ("workout", 1)], + ) + def test_it_returns_report_when_reported_object_is_deleted( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_object_type: str, + input_index: int, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + db.session.delete(reports[input_index].reported_object) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?object_type={input_object_type}", + 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 len(data["reports"]) == 1 + assert data["reports"][0] == jsonify_dict( + reports[input_index].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + def test_it_returns_report_when_reporter_is_deleted( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + db.session.delete(user_2) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route, + 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 len(data["reports"]) == 1 + assert data["reports"][0] == jsonify_dict( + report.serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + def test_it_returns_only_unresolved_reports( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + reports[1].resolved = True + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?resolved=false", + 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 len(data["reports"]) == 2 + assert data["reports"][0] == jsonify_dict( + reports[2].serialize(user_1_moderator) + ) + assert data["reports"][1] == jsonify_dict( + reports[0].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 2, + } + + def test_it_returns_only_resolved_reports( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + reports[1].resolved = True + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?resolved=true", + 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 len(data["reports"]) == 1 + assert data["reports"][0] == jsonify_dict( + reports[1].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + @pytest.mark.parametrize( + "input_params", + [ + "order_by=created_at", + "order_by=created_at&order=desc", + ], + ) + def test_it_returns_reports_ordered_by_created_at_descending( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_params: str, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?{input_params}", + 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 len(data["reports"]) == 3 + assert data["reports"][0] == jsonify_dict( + reports[2].serialize(user_1_moderator) + ) + assert data["reports"][1] == jsonify_dict( + reports[1].serialize(user_1_moderator) + ) + assert data["reports"][2] == jsonify_dict( + reports[0].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 3, + } + + def test_it_returns_reports_ordered_by_created_at_ascending( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?order_by=created_at&order=asc", + 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 len(data["reports"]) == 3 + assert data["reports"][0] == jsonify_dict( + reports[0].serialize(user_1_moderator) + ) + assert data["reports"][1] == jsonify_dict( + reports[1].serialize(user_1_moderator) + ) + assert data["reports"][2] == jsonify_dict( + reports[2].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 3, + } + + @pytest.mark.parametrize( + "input_params", + ["order_by=updated_at", "order_by=updated_at&order=desc"], + ) + def test_it_returns_reports_ordered_by_updated_at_descending( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_params: str, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + now = datetime.utcnow() + reports[1].updated_at = now + reports[0].updated_at = now + timedelta(minutes=1) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?{input_params}", + 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 len(data["reports"]) == 3 + assert data["reports"][0] == jsonify_dict( + reports[0].serialize(user_1_moderator) + ) + assert data["reports"][1] == jsonify_dict( + reports[1].serialize(user_1_moderator) + ) + assert data["reports"][2] == jsonify_dict( + reports[2].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 3, + } + + @pytest.mark.parametrize( + "input_order_by", + [ + "id", + "reported_comment_id", + "reported_user_id", + "reported_workout_id", + "note", + "invalid", + ], + ) + def test_it_returns_error_if_order_by_is_invalid( + self, app: Flask, user_1_moderator: User, input_order_by: str + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?order_by={input_order_by}", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "invalid 'order_by'") + + def test_it_returns_reports_ordered_by_update_at_ascending( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + now = datetime.utcnow() + reports[1].updated_at = now + reports[0].updated_at = now + timedelta(minutes=1) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?order_by=updated_at&order=asc", + 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 len(data["reports"]) == 3 + assert data["reports"][0] == jsonify_dict( + reports[1].serialize(user_1_moderator) + ) + assert data["reports"][1] == jsonify_dict( + reports[0].serialize(user_1_moderator) + ) + assert data["reports"][2] == jsonify_dict( + reports[2].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 3, + } + + @patch("fittrackee.reports.reports.REPORTS_PER_PAGE", 2) + def test_it_returns_first_page( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?page=1", + 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 len(data["reports"]) == 2 + assert data["reports"][0] == jsonify_dict( + reports[2].serialize(user_1_moderator) + ) + assert data["reports"][1] == jsonify_dict( + reports[1].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": True, + "has_prev": False, + "page": 1, + "pages": 2, + "total": 3, + } + + @patch("fittrackee.reports.reports.REPORTS_PER_PAGE", 2) + def test_it_returns_last_page( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?page=2", + 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 len(data["reports"]) == 1 + assert data["reports"][0] == jsonify_dict( + reports[0].serialize(user_1_moderator) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": True, + "page": 2, + "pages": 2, + "total": 3, + } + + def test_it_returns_reports_for_a_given_reporter( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + f"{self.route}?reporter={user_3.username}", + 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["reports"] == [ + jsonify_dict(reports[1].serialize(user_1_moderator)) + ] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + +class TestGetReportsAsAdmin(ReportTestCase): + def test_it_returns_reports( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + reports = self.create_reports( + user_2, user_3, user_4, workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + self.route, + 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 len(data["reports"]) == 3 + assert data["reports"][0] == jsonify_dict( + reports[2].serialize(user_1_admin) + ) + assert data["reports"][1] == jsonify_dict( + reports[1].serialize(user_1_admin) + ) + assert data["reports"][2] == jsonify_dict( + reports[0].serialize(user_1_admin) + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 3, + } + + +class TestGetReportsAsUser(ReportTestCase): + def test_it_does_not_return_reports( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + self.create_reports(user_2, user_3, user_4, workout_cycling_user_2) + self.create_report( + reporter=user_1, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + +class TestGetReportsAsUnauthenticatedUser(ReportTestCase): + def test_it_returns_401_when_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.get(self.route, content_type="application/json") + + self.assert_401(response) + + +class TestGetReportsOAuth2Scopes(ReportTestCase): + @pytest.mark.parametrize( + "client_scope, can_access", + {**OAUTH_SCOPES, "reports:read": True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1_moderator: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1_moderator, scope=client_scope + ) + + response = client.get( + self.route, + content_type="application/json", + headers=dict( + Authorization=f"Bearer {access_token}", + ), + ) + + self.assert_response_scope(response, can_access) + + +class GetReportTestCase(ReportTestCase): + route = "/api/reports/{report_id}" + + +class TestGetReportAsModerator(GetReportTestCase): + def test_it_returns_404_when_report_does_not_exist( + self, app: Flask, user_1_moderator: User + ) -> None: + report_id = self.random_int() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route.format(report_id=report_id), + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_message( + response, f"report not found (id: {report_id})" + ) + + def test_it_returns_report_from_authenticated_user( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + report = self.create_report( + reporter=user_1_moderator, reported_object=user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route.format(report_id=report.id), + 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["report"] == jsonify_dict( + report.serialize(user_1_moderator, full=True) + ) + + def test_it_returns_report_from_another_user( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route.format(report_id=report.id), + 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["report"] == jsonify_dict( + report.serialize(user_1_moderator, full=True) + ) + + +class TestGetReportAsAdmin(GetReportTestCase): + def test_it_returns_report( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + self.route.format(report_id=report.id), + 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["report"] == jsonify_dict( + report.serialize(user_1_admin, full=True) + ) + + +class TestGetReportAsUser(GetReportTestCase): + def test_it_does_not_return_report( + self, app: Flask, user_1: User, user_2: User + ) -> None: + report = self.create_report(reporter=user_1, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(report_id=report.id), + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + +class TestGetReportAsUnauthenticatedUser(GetReportTestCase): + def test_it_returns_401_when_user_is_not_authenticated( + self, app: Flask, user_1: User, user_2: User + ) -> None: + report = self.create_report(reporter=user_1, reported_object=user_2) + client = app.test_client() + + response = client.get( + self.route.format(report_id=report.id), + content_type="application/json", + ) + + self.assert_401(response) + + +class TestGetReportOAuth2Scopes(GetReportTestCase): + @pytest.mark.parametrize( + "client_scope, can_access", + {**OAUTH_SCOPES, "reports:read": True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1_admin: User, + user_2: User, + client_scope: str, + can_access: bool, + ) -> None: + report = self.create_report( + reporter=user_1_admin, reported_object=user_2 + ) + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1_admin, scope=client_scope + ) + + response = client.get( + self.route.format(report_id=report.id), + content_type="application/json", + headers=dict( + Authorization=f"Bearer {access_token}", + ), + ) + + self.assert_response_scope(response, can_access) + + +class TestPatchReport(ReportTestCase): + route = "/api/reports/{report_id}" + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.patch( + self.route.format(report_id=self.random_int()), + content_type="application/json", + data=json.dumps( + dict( + comment=self.random_string(), + ) + ), + ) + + self.assert_401(response) + + def test_it_returns_error_if_user_has_no_moderation_rights( + self, app: Flask, user_1: User, user_2: User + ) -> None: + report = self.create_report(reporter=user_1, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + self.route.format(report_id=report.id), + content_type="application/json", + data=json.dumps( + dict( + comment=self.random_string(), + ) + ), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_returns_404_when_no_report( + self, app: Flask, user_1_moderator: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + report_id = self.random_int() + + response = client.patch( + self.route.format(report_id=report_id), + content_type="application/json", + data=json.dumps(dict(comment=self.random_string())), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_message( + response, f"report not found (id: {report_id})" + ) + + def test_it_returns_400_when_comment_is_missing( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.patch( + self.route.format(report_id=report.id), + content_type="application/json", + data='{}', + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response) + + def test_it_adds_a_comment( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + now = datetime.utcnow() + comment = self.random_string() + + with travel(now, tick=False): + response = client.patch( + self.route.format(report_id=report.id), + content_type="application/json", + data=json.dumps(dict(comment=comment)), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + assert data["report"]["resolved"] is False + assert data["report"]["resolved_at"] is None + assert data["report"]["updated_at"] == self.get_date_string(date=now) + assert len(data["report"]["comments"]) == 1 + assert data["report"]["comments"][0]["comment"] == comment + + def test_it_resolves_a_report( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + report_comment = ReportComment( + comment=self.random_string(), + report_id=report.id, + user_id=user_1_moderator.id, + ) + db.session.add(report_comment) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + now = datetime.utcnow() + comment = self.random_string() + + with travel(now, tick=False): + response = client.patch( + self.route.format(report_id=report.id), + content_type="application/json", + data=json.dumps(dict(comment=comment, resolved=True)), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + assert data["report"]["resolved"] is True + date_string = self.get_date_string(date=now) + assert data["report"]["resolved_at"] == date_string + assert data["report"]["resolved_by"] == jsonify_dict( + user_1_moderator.serialize(current_user=user_1_moderator) + ) + assert data["report"]["updated_at"] == date_string + assert len(data["report"]["comments"]) == 2 + assert data["report"]["comments"][1]["comment"] == comment + + def test_it_marks_a_report_as_unresolved( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + report.resolved = True + report.resolved_at = datetime.utcnow() + report.resolved_by = user_1_moderator.id + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + now = datetime.utcnow() + comment = self.random_string() + + with travel(now, tick=False): + response = client.patch( + self.route.format(report_id=report.id), + content_type="application/json", + data=json.dumps(dict(comment=comment, resolved=False)), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + assert data["report"]["resolved"] is False + assert data["report"]["resolved_at"] is None + assert data["report"]["resolved_by"] is None + assert data["report"]["updated_at"] == self.get_date_string(date=now) + assert len(data["report"]["comments"]) == 1 + assert data["report"]["comments"][0]["comment"] == comment + + def test_it_adds_comment_one_resolved_report( + self, + app: Flask, + user_1_moderator: User, + user_2_admin: User, + user_3: User, + ) -> None: + report = self.create_report( + reporter=user_3, reported_object=user_2_admin + ) + report.resolved = True + resolved_time = datetime.utcnow() + report.resolved_at = resolved_time + report.resolved_by = user_2_admin.id + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + comment_time = datetime.utcnow() + comment = self.random_string() + + with travel(comment_time, tick=False): + response = client.patch( + self.route.format(report_id=report.id), + content_type="application/json", + data=json.dumps(dict(comment=comment)), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + assert data["report"]["resolved"] is True + assert data["report"]["resolved_at"] == self.get_date_string( + date=resolved_time + ) + assert data["report"]["resolved_by"] == jsonify_dict( + user_2_admin.serialize(current_user=user_1_moderator) + ) + assert data["report"]["updated_at"] == self.get_date_string( + date=comment_time + ) + assert len(data["report"]["comments"]) == 1 + assert data["report"]["comments"][0]["comment"] == comment + + +class TestPostReportAction(ReportTestCase): + route = "/api/reports/{report_id}/actions" + + def test_it_returns_401_if_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + ) -> None: + client = app.test_client() + + response = client.post( + self.route.format(report_id=self.random_int()), + content_type="application/json", + json={ + "action_type": "user_suspension", + "username": user_1.username, + }, + ) + + self.assert_401(response) + + def test_it_returns_403_if_user_has_no_moderation_rights( + self, app: Flask, user_1: User, user_2: User + ) -> None: + report = self.create_report(reporter=user_1, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_suspension", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_returns_404_when_no_report( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + report_id = self.random_int() + + response = client.post( + self.route.format(report_id=report_id), + content_type="application/json", + json={ + "action_type": "user_suspension", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_message( + response, f"report not found (id: {report_id})" + ) + + def test_it_returns_400_when_action_type_is_missing( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response) + + def test_it_returns_400_when_action_type_is_invalid( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": self.random_string(), + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "invalid 'action_type'") + + +class TestPostReportActionForUserAction(ReportTestCase): + route = "/api/reports/{report_id}/actions" + + def test_it_returns_400_when_action_type_is_user_warning_lifting( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + """'user_warning_lifting' is created on appeal processing""" + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_warning_lifting", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "invalid 'action_type'") + + def test_it_returns_400_when_username_is_missing_on_user_report_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={"action_type": "user_suspension"}, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "'username' is missing") + + def test_it_returns_400_when_username_is_invalid_on_user_report_action( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_suspension", + "username": self.random_string(), + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "invalid 'username'") + + def test_it_returns_400_when_user_is_deleted( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + username = user_2.username + db.session.delete(user_2) + db.session.commit() + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={"action_type": "user_suspension", "username": username}, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "invalid 'username'") + + def test_it_suspends_user( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + now = datetime.utcnow() + + with travel(now, tick=False): + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_suspension", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + assert ( + User.query.filter_by(username=user_2.username).first().suspended_at + == now + ) + + def test_it_returns_400_when_when_user_already_suspended( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_suspension", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "user account already suspended") + + def test_it_returns_400_when_when_user_already_warned( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + self.create_report_action( + user_1_moderator, + user_2, + action_type="user_warning", + report_id=report.id, + ) + db.session.commit() + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_warning", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "user already warned") + + def test_it_reactivates_user( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_unsuspension", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + assert ( + User.query.filter_by(username=user_2.username).first().suspended_at + is None + ) + + @pytest.mark.parametrize('input_action_type', USER_ACTION_TYPES) + def test_it_creates_report_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + input_action_type: str, + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + if input_action_type == "user_unsuspension": + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": input_action_type, + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + assert ( + ReportAction.query.filter_by( + moderator_id=user_1_moderator.id, + user_id=user_2.id, + action_type=input_action_type, + ).first() + is not None + ) + + def test_it_returns_report( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + report_comment = ReportComment( + comment=self.random_string(), + report_id=report.id, + user_id=user_1_moderator.id, + ) + db.session.add(report_comment) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_suspension", + "username": user_2.username, + "reason": self.random_string(), + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + updated_report = Report.query.filter_by(id=report.id).first() + assert data["report"] == jsonify_dict( + updated_report.serialize(user_1_moderator, full=True) + ) + + def test_it_does_not_enable_registration_on_user_suspension( + self, + app_with_3_users_max: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + report = self.create_report( + reporter=user_1_moderator, reported_object=user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app_with_3_users_max, user_1_moderator.email + ) + + client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_suspension", + "username": user_2.username, + "reason": self.random_string(), + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + response = client.post( + '/api/auth/register', + data=json.dumps( + dict( + username=self.random_string(), + email=self.random_email(), + password=self.random_string(), + password_conf=self.random_string(), + accepted_policy=True, + ) + ), + content_type='application/json', + ) + + self.assert_403(response, 'error, registration is disabled') + + def test_it_sends_an_email_on_user_suspension( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_suspension_email_mock: MagicMock, + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_suspension", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + user_suspension_email_mock.send.assert_called_once() + + def test_it_sends_an_email_on_user_reactivation( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_unsuspension_email_mock: MagicMock, + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_unsuspension", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + user_unsuspension_email_mock.send.assert_called_once() + + def test_it_sends_an_email_on_user_warning( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_warning_email_mock: MagicMock, + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_warning", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + user_warning_email_mock.send.assert_called_once() + + def test_it_does_not_send_when_email_sending_is_disabled( + self, + app_wo_email_activation: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_suspension_email_mock: MagicMock, + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + client, auth_token = self.get_test_client_and_auth_token( + app_wo_email_activation, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "user_suspension", + "username": user_2.username, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + user_suspension_email_mock.send.assert_not_called() + + +class TestPostReportActionForWorkoutAction(ReportTestCase): + route = "/api/reports/{report_id}/actions" + + def test_it_returns_400_when_workout_id_is_missing_on_workout_report_action( # noqa + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={"action_type": "workout_suspension"}, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "'workout_id' is missing") + + def test_it_returns_400_when_workout_is_invalid_on_workout_report_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "workout_suspension", + "workout_id": self.random_short_id(), + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "invalid 'workout_id'") + + def test_it_returns_400_when_workout_is_deleted( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + short_id = workout_cycling_user_2.short_id + db.session.delete(workout_cycling_user_2) + db.session.commit() + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={"action_type": "workout_suspension", "workout_id": short_id}, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "invalid 'workout_id'") + + def test_it_suspends_workout_by_setting_moderation_date( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + now = datetime.utcnow() + + with travel(now, tick=False): + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "workout_suspension", + "workout_id": workout_cycling_user_2.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + assert ( + Workout.query.filter_by(id=workout_cycling_user_2.id) + .first() + .suspended_at + == now + ) + + def test_it_returns_400_when_when_workout_already_suspended( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.commit() + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "workout_suspension", + "workout_id": workout_cycling_user_2.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400( + response, + "workout already suspended", + ) + + def test_it_unsuspends_workout( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "workout_unsuspension", + "workout_id": workout_cycling_user_2.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + assert ( + Workout.query.filter_by(id=workout_cycling_user_2.id) + .first() + .suspended_at + is None + ) + + def test_it_creates_report_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "workout_suspension", + "workout_id": workout_cycling_user_2.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + assert ( + ReportAction.query.filter_by( + moderator_id=user_1_moderator.id, user_id=user_2.id + ).first() + is not None + ) + + def test_it_returns_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "workout_suspension", + "workout_id": workout_cycling_user_2.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + updated_report = Report.query.filter_by(id=report.id).first() + assert data["report"] == jsonify_dict( + updated_report.serialize(user_1_moderator, full=True) + ) + + def test_it_sends_an_email_on_workout_suspension( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_suspension_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "workout_suspension", + "workout_id": workout_cycling_user_2.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + workout_suspension_email_mock.send.assert_called_once() + + def test_it_sends_an_email_on_workout_unsuspension( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_unsuspension_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "workout_unsuspension", + "workout_id": workout_cycling_user_2.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + workout_unsuspension_email_mock.send.assert_called_once() + + def test_it_does_not_send_an_email_when_email_sending_is_disabled( + self, + app_wo_email_activation: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_suspension_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app_wo_email_activation, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "workout_suspension", + "workout_id": workout_cycling_user_2.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + workout_suspension_email_mock.send.assert_not_called() + + +class TestPostReportActionForCommentAction(ReportTestCase): + route = "/api/reports/{report_id}/actions" + + def test_it_returns_400_when_comment_id_is_missing_on_comment_report_action( # noqa + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, workout_cycling_user_2, VisibilityLevel.PUBLIC + ) + report = self.create_report(reporter=user_2, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={"action_type": "comment_suspension"}, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "'comment_id' is missing") + + def test_it_returns_400_when_comment_is_invalid_on_comment_report_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, workout_cycling_user_2, VisibilityLevel.PUBLIC + ) + report = self.create_report(reporter=user_2, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "comment_suspension", + "comment_id": self.random_short_id(), + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "invalid 'comment_id'") + + def test_it_returns_400_when_comment_is_deleted( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, workout_cycling_user_2, VisibilityLevel.PUBLIC + ) + report = self.create_report(reporter=user_2, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + short_id = comment.short_id + db.session.delete(comment) + db.session.commit() + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={"action_type": "comment_suspension", "comment_id": short_id}, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400(response, "invalid 'comment_id'") + + def test_it_suspends_comment_by_setting_moderation_date( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, workout_cycling_user_2, VisibilityLevel.PUBLIC + ) + report = self.create_report(reporter=user_2, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + now = datetime.utcnow() + + with travel(now, tick=False): + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "comment_suspension", + "comment_id": comment.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + assert ( + Comment.query.filter_by(id=comment.id).first().suspended_at == now + ) + + def test_it_returns_400_when_when_comment_already_suspended( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, workout_cycling_user_2, VisibilityLevel.PUBLIC + ) + report = self.create_report(reporter=user_2, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + comment.suspended_at = datetime.utcnow() + db.session.commit() + now = datetime.utcnow() + + with travel(now, tick=False): + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "comment_suspension", + "comment_id": comment.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_400( + response, + "comment already suspended", + ) + + def test_it_unsuspends_comment( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, workout_cycling_user_2, VisibilityLevel.PUBLIC + ) + report = self.create_report(reporter=user_2, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + comment.suspended_at = datetime.utcnow() + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "comment_unsuspension", + "comment_id": comment.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + assert ( + Comment.query.filter_by(id=comment.id).first().suspended_at is None + ) + + def test_it_creates_report_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, workout_cycling_user_2, VisibilityLevel.PUBLIC + ) + report = self.create_report(reporter=user_2, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "comment_suspension", + "comment_id": comment.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + assert ( + ReportAction.query.filter_by( + moderator_id=user_1_moderator.id, user_id=user_3.id + ).first() + is not None + ) + + def test_it_returns_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, workout_cycling_user_2, VisibilityLevel.PUBLIC + ) + report = self.create_report(reporter=user_2, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + now = datetime.utcnow() + + with travel(now, tick=False): + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "comment_suspension", + "comment_id": comment.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + updated_report = Report.query.filter_by(id=report.id).first() + assert data["report"] == jsonify_dict( + updated_report.serialize(user_1_moderator, full=True) + ) + + def test_it_sends_an_email_on_comment_suspension( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + comment_suspension_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text=f"@{user_2.username}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=True, + ) + report = self.create_report(reporter=user_2, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "comment_suspension", + "comment_id": comment.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + comment_suspension_email_mock.send.assert_called_once() + + def test_it_sends_an_email_on_comment_unsuspension( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + comment_unsuspension_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text=f"@{user_2.username}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=True, + ) + report = self.create_report(reporter=user_2, reported_object=comment) + comment.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "comment_unsuspension", + "comment_id": comment.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + comment_unsuspension_email_mock.send.assert_called_once() + + def test_it_does_not_send_email_when_email_sending_id_disabled( + self, + app_wo_email_activation: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + comment_suspension_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text=f"@{user_2.username}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=True, + ) + report = self.create_report(reporter=user_2, reported_object=comment) + client, auth_token = self.get_test_client_and_auth_token( + app_wo_email_activation, user_1_moderator.email + ) + + response = client.post( + self.route.format(report_id=report.id), + content_type="application/json", + json={ + "action_type": "comment_suspension", + "comment_id": comment.short_id, + }, + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + comment_suspension_email_mock.send.assert_not_called() + + +class TestProcessReportActionAppeal( + CommentMixin, ReportMixin, ApiTestCaseMixin +): + route = '/api/appeals/{appeal_id}' + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.patch( + self.route.format(appeal_id=self.random_short_id()), + data=json.dumps(dict(approved=False)), + content_type="application/json", + ) + + self.assert_401(response) + + def test_it_returns_403_when_user_has_no_moderation_rights( + self, app: Flask, user_1: User + ) -> None: + appeal_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + self.route.format(appeal_id=appeal_id), + data=json.dumps(dict(approved=False)), + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_404_if_appeal_does_not_exist( + self, app: Flask, user_1_moderator: User + ) -> None: + appeal_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.patch( + self.route.format(appeal_id=appeal_id), + data=json.dumps(dict(approved=False)), + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f"appeal not found (id: {appeal_id})", + ) + + @pytest.mark.parametrize( + "input_data", [{"approved": True}, {"reason": "foo"}, {}] + ) + def test_it_returns_error_when_data_are_missing( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + input_data: Dict, + ) -> None: + suspension_action = self.create_report_user_action( + user_1_moderator, user_2 + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.patch( + self.route.format(appeal_id=appeal.short_id), + data=json.dumps(input_data), + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response) + + def test_it_returns_400_when_user_already_unsuspended( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + suspension_action = self.create_report_user_action( + user_1_moderator, user_2 + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + user_2.suspended_at = None + db.session.commit() + + response = client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": True, "reason": "ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, "user account has already been reactivated") + + @pytest.mark.parametrize( + "input_data", + [ + {"approved": True, "reason": "ok"}, + {"approved": False, "reason": "not ok"}, + ], + ) + def test_it_processes_user_suspension_appeal( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + input_data: Dict, + ) -> None: + suspension_action = self.create_report_user_action( + user_1_moderator, user_2 + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.patch( + self.route.format(appeal_id=appeal.short_id), + data=json.dumps(input_data), + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert response.json == { + "status": "success", + "appeal": jsonify_dict(appeal.serialize(user_1_moderator)), + } + appeal = ReportActionAppeal.query.filter_by(id=appeal.id).first() + assert appeal.approved is input_data["approved"] + assert appeal.reason == input_data["reason"] + + def test_it_sends_an_email_when_appeal_on_user_suspension_is_approved( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_unsuspension_email_mock: MagicMock, + ) -> None: + report = self.create_report( + reporter=user_1_moderator, reported_object=user_2 + ) + suspension_action = self.create_report_user_action( + user_1_moderator, user_2, report_id=report.id + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": True, "reason": "ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + user_unsuspension_email_mock.send.assert_called_once() + + def test_it_sends_an_email_when_appeal_on_user_suspension_is_rejected( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + appeal_rejected_email_mock: MagicMock, + ) -> None: + report = self.create_report( + reporter=user_1_moderator, reported_object=user_2 + ) + suspension_action = self.create_report_user_action( + user_1_moderator, user_2, report_id=report.id + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": False, "reason": "not ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + appeal_rejected_email_mock.send.assert_called_once() + + @pytest.mark.parametrize( + "input_data", + [ + {"approved": True, "reason": "ok"}, + {"approved": False, "reason": "not ok"}, + ], + ) + def test_it_processes_user_warning_appeal( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + input_data: Dict, + ) -> None: + report = self.create_report( + reporter=user_1_moderator, reported_object=user_2 + ) + warning_action = self.create_report_user_action( + user_1_moderator, user_2, "user_warning", report.id + ) + appeal = self.create_action_appeal(warning_action.id, user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.patch( + self.route.format(appeal_id=appeal.short_id), + data=json.dumps(input_data), + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert response.json == { + "status": "success", + "appeal": jsonify_dict(appeal.serialize(user_1_moderator)), + } + appeal = ReportActionAppeal.query.filter_by(id=appeal.id).first() + assert appeal.approved is input_data["approved"] + assert appeal.reason == input_data["reason"] + + def test_it_sends_an_email_when_appeal_on_user_warning_is_approved( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_warning_lifting_email_mock: MagicMock, + ) -> None: + report = self.create_report( + reporter=user_1_moderator, reported_object=user_2 + ) + warning_action = self.create_report_user_action( + user_1_moderator, user_2, "user_warning", report.id + ) + appeal = self.create_action_appeal(warning_action.id, user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": True, "reason": "ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + user_warning_lifting_email_mock.send.assert_called_once() + + def test_it_sends_an_email_when_appeal_on_user_warning_is_rejected( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + appeal_rejected_email_mock: MagicMock, + ) -> None: + report = self.create_report( + reporter=user_1_moderator, reported_object=user_2 + ) + warning_action = self.create_report_user_action( + user_1_moderator, user_2, "user_warning", report.id + ) + appeal = self.create_action_appeal(warning_action.id, user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": False, "reason": "not ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + appeal_rejected_email_mock.send.assert_called_once() + + @pytest.mark.parametrize( + "input_data", + [ + {"approved": True, "reason": "ok"}, + {"approved": False, "reason": "not ok"}, + ], + ) + def test_it_processes_comment_suspension_appeal( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_data: Dict, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + suspension_action = self.create_report_comment_action( + user_1_moderator, user_3, comment + ) + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_3) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.patch( + self.route.format(appeal_id=appeal.short_id), + data=json.dumps(input_data), + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert response.json == { + "status": "success", + "appeal": jsonify_dict(appeal.serialize(user_1_moderator)), + } + appeal = ReportActionAppeal.query.filter_by(id=appeal.id).first() + assert appeal.approved is input_data["approved"] + assert appeal.reason == input_data["reason"] + + def test_it_sends_an_email_when_appeal_on_comment_suspension_is_approved( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + comment_unsuspension_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + suspension_action = self.create_report_comment_action( + user_1_moderator, user_3, comment + ) + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_3) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": True, "reason": "ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + comment_unsuspension_email_mock.send.assert_called_once() + + def test_it_sends_an_email_when_appeal_on_comment_suspension_is_rejected( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + appeal_rejected_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + suspension_action = self.create_report_comment_action( + user_1_moderator, user_3, comment + ) + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_3) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": False, "reason": "not ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + appeal_rejected_email_mock.send.assert_called_once() + + def test_it_returns_400_when_comment_already_unsuspended( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + suspension_action = self.create_report_comment_action( + user_1_moderator, user_3, comment + ) + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_3) + db.session.flush() + comment.suspended_at = None + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": True, "reason": "ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, "comment already reactivated") + + @pytest.mark.parametrize( + "input_data", + [ + {"approved": True, "reason": "ok"}, + {"approved": False, "reason": "not ok"}, + ], + ) + def test_it_processes_workout_suspension_appeal( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_data: Dict, + ) -> None: + suspension_action = self.create_report_workout_action( + user_1_moderator, user_2, workout_cycling_user_2 + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_2) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + now = datetime.utcnow() + + with travel(now, tick=False): + response = client.patch( + self.route.format(appeal_id=appeal.short_id), + data=json.dumps(input_data), + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert response.json == { + "status": "success", + "appeal": jsonify_dict(appeal.serialize(user_1_moderator)), + } + appeal = ReportActionAppeal.query.filter_by(id=appeal.id).first() + assert appeal.approved is input_data["approved"] + assert appeal.reason == input_data["reason"] + + def test_it_sends_an_email_when_appeal_on_workout_suspension_is_approved( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_unsuspension_email_mock: MagicMock, + ) -> None: + suspension_action = self.create_report_workout_action( + user_1_moderator, user_2, workout_cycling_user_2 + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_2) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": True, "reason": "ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + workout_unsuspension_email_mock.send.assert_called_once() + + def test_it_sends_an_email_when_appeal_on_workout_suspension_is_rejected( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + appeal_rejected_email_mock: MagicMock, + ) -> None: + suspension_action = self.create_report_workout_action( + user_1_moderator, user_2, workout_cycling_user_2 + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_2) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": False, "reason": "not ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + appeal_rejected_email_mock.send.assert_called_once() + + def test_it_returns_400_when_workout_already_unsuspended( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + suspension_action = self.create_report_workout_action( + user_1_moderator, user_2, workout_cycling_user_2 + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_2) + db.session.commit() + workout_cycling_user_2.suspended_at = None + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.patch( + self.route.format(appeal_id=appeal.short_id), + json={"approved": True, "reason": "ok"}, + content_type="application/json", + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, "workout already reactivated") + + @pytest.mark.parametrize( + "client_scope, can_access", + {**OAUTH_SCOPES, "users:write": True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + client_scope: str, + can_access: bool, + ) -> None: + appeal_id = self.random_short_id() + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1_moderator, scope=client_scope + ) + + response = client.patch( + self.route.format(appeal_id=appeal_id), + data=json.dumps(dict(approved=False, reason="OK")), + content_type="application/json", + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestGetReportsUnresolved(ReportTestCase): + route = "/api/reports/unresolved" + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.get(self.route, content_type="application/json") + + self.assert_401(response) + + def test_it_returns_error_if_user_has_no_moderation_rights( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_returns_false_when_no_reports( + self, app: Flask, user_1_moderator: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route, + 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["unresolved"] is False + + def test_it_returns_false_when_reports_are_resolved( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + for reported_user in [user_2, user_3]: + report = self.create_report( + reporter=user_1_moderator, reported_object=reported_user + ) + report.resolved = True + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route, + 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["unresolved"] is False + + def test_it_returns_true_when_unresolved_reports_exist( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + for reported_user in [user_2, user_3]: + report = self.create_report( + reporter=user_1_moderator, reported_object=reported_user + ) + if reported_user.id == user_3.id: + report.resolved = True + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route, + 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["unresolved"] is True + + @pytest.mark.parametrize( + "client_scope, can_access", + {**OAUTH_SCOPES, "reports:read": True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1_moderator: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1_moderator, scope=client_scope + ) + + response = client.get( + self.route, + content_type="application/json", + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) diff --git a/fittrackee/tests/reports/test_reports_email_service.py b/fittrackee/tests/reports/test_reports_email_service.py new file mode 100644 index 000000000..43ee0365d --- /dev/null +++ b/fittrackee/tests/reports/test_reports_email_service.py @@ -0,0 +1,1125 @@ +from datetime import datetime +from typing import Dict +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.reports.reports_email_service import ReportEmailService +from fittrackee.reports.reports_service import ReportService +from fittrackee.users.models import User +from fittrackee.utils import get_date_string_for_user +from fittrackee.workouts.models import Sport, Workout + +from ..mixins import ReportMixin +from .mixins import ReportServiceCreateReportActionMixin + + +class TestReportEmailServiceForUserSuspension( + ReportServiceCreateReportActionMixin +): + @pytest.mark.parametrize('input_reason', [{}, {"reason": "foo"}]) + def test_it_sends_an_email_on_user_suspension( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_suspension_email_mock: MagicMock, + input_reason: Dict, + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "user_suspension", input_reason.get("reason") + ) + + user_suspension_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'username': user_3.username, + 'fittrackee_url': app.config['UI_URL'], + 'appeal_url': f'{app.config["UI_URL"]}/profile/suspension', + 'reason': input_reason.get('reason'), + }, + ) + + +class TestReportEmailServiceForUserReactivation( + ReportServiceCreateReportActionMixin +): + @pytest.mark.parametrize('input_reason', [{}, {"reason": "foo"}]) + def test_it_sends_an_email_on_user_reactivation( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_unsuspension_email_mock: MagicMock, + input_reason: Dict, + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + user_3.suspended_at = datetime.utcnow() + db.session.flush() + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "user_unsuspension", input_reason.get("reason") + ) + + user_unsuspension_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'username': user_3.username, + 'fittrackee_url': app.config['UI_URL'], + 'reason': input_reason.get('reason'), + 'without_user_action': True, + }, + ) + + +class TestReportEmailServiceForUserWarning( + ReportServiceCreateReportActionMixin +): + @pytest.mark.parametrize('input_reason', [{}, {"reason": "foo"}]) + def test_it_sends_an_email_on_user_warning_for_user_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_warning_email_mock: MagicMock, + input_reason: Dict, + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + user_3.suspended_at = datetime.utcnow() + db.session.flush() + report_email_service = ReportEmailService() + user_warning = report_service.create_report_action( + report=report, + moderator=user_1_moderator, + action_type="user_warning", + reason=None, + data={"username": user_3.username}, + ) + db.session.flush() + + report_email_service.send_report_action_email( + report, "user_warning", input_reason.get("reason"), user_warning + ) + + user_warning_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'username': user_3.username, + 'fittrackee_url': app.config['UI_URL'], + 'appeal_url': ( + f'{app.config["UI_URL"]}/profile/moderation/sanctions' + f'/{user_warning.short_id}' # type:ignore + ), + 'reason': input_reason.get('reason'), + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + }, + ) + + def test_it_sends_an_email_on_user_warning_for_comment_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + user_warning_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + user_warning = report_service.create_report_action( + report=report, + moderator=user_1_moderator, + action_type="user_warning", + reason=None, + data={"username": user_3.username}, + ) + db.session.flush() + + report_email_service.send_report_action_email( + report, "user_warning", None, user_warning + ) + + user_warning_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'appeal_url': ( + f'{app.config["UI_URL"]}/profile/moderation/sanctions' + f'/{user_warning.short_id}' # type:ignore + ), + 'comment_url': ( + f'{app.config["UI_URL"]}/workouts' + f'/{workout_cycling_user_2.short_id}' + f'/comments/{report.reported_comment.short_id}' + ), + 'created_at': get_date_string_for_user( + report.reported_comment.created_at, user_3 + ), + 'fittrackee_url': app.config['UI_URL'], + 'reason': None, + 'text': report.reported_comment.handle_mentions()[0], + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_3.username, + }, + ) + + def test_it_sends_an_email_on_user_warning_for_comment_report_when_workout_is_deleted( # noqa + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + user_warning_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + user_warning = report_service.create_report_action( + report=report, + moderator=user_1_moderator, + action_type="user_warning", + reason=None, + data={"username": user_3.username}, + ) + db.session.delete(workout_cycling_user_2) + db.session.flush() + + report_email_service.send_report_action_email( + report, "user_warning", None, user_warning + ) + + user_warning_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'appeal_url': ( + f'{app.config["UI_URL"]}/profile/moderation/sanctions' + f'/{user_warning.short_id}' # type:ignore + ), + 'comment_url': ( + f'{app.config["UI_URL"]}/comments' + f'/{report.reported_comment.short_id}' + ), + 'created_at': get_date_string_for_user( + report.reported_comment.created_at, user_3 + ), + 'fittrackee_url': app.config['UI_URL'], + 'reason': None, + 'text': report.reported_comment.handle_mentions()[0], + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_3.username, + }, + ) + + def test_it_sends_an_email_on_user_warning_for_workout_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + user_warning_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + user_warning = report_service.create_report_action( + report=report, + moderator=user_1_moderator, + action_type="user_warning", + reason=None, + data={"username": user_2.username}, + ) + db.session.flush() + + report_email_service.send_report_action_email( + report, "user_warning", None, user_warning + ) + + user_warning_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'appeal_url': ( + f'{app.config["UI_URL"]}/profile/moderation/sanctions' + f'/{user_warning.short_id}' # type:ignore + ), + 'fittrackee_url': app.config['UI_URL'], + 'map': None, + 'reason': None, + 'title': workout_cycling_user_2.title, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_2.username, + 'workout_date': get_date_string_for_user( + workout_cycling_user_2.workout_date, user_2 + ), + 'workout_url': ( + f'{app.config["UI_URL"]}/workouts/' + f'{workout_cycling_user_2.short_id}' + ), + }, + ) + + def test_it_sends_an_email_on_user_warning_for_workout_with_gpx_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + user_warning_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.map_id = self.random_short_id() + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + user_warning = report_service.create_report_action( + report=report, + moderator=user_1_moderator, + action_type="user_warning", + reason=None, + data={"username": user_2.username}, + ) + db.session.flush() + + report_email_service.send_report_action_email( + report, "user_warning", None, user_warning + ) + + user_warning_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'appeal_url': ( + f'{app.config["UI_URL"]}/profile/moderation/sanctions' + f'/{user_warning.short_id}' # type:ignore + ), + 'fittrackee_url': app.config['UI_URL'], + 'map': ( + f'{app.config["UI_URL"]}/api/workouts/map' + f'/{workout_cycling_user_2.map_id}' + ), + 'reason': None, + 'title': workout_cycling_user_2.title, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_2.username, + 'workout_date': get_date_string_for_user( + workout_cycling_user_2.workout_date, user_2 + ), + 'workout_url': ( + f'{app.config["UI_URL"]}/workouts/' + f'{workout_cycling_user_2.short_id}' + ), + }, + ) + + +class TestReportEmailServiceForUserWarningLifting( + ReportServiceCreateReportActionMixin +): + def test_it_sends_an_email_on_user_warning_for_user_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_warning_lifting_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + user_3.suspended_at = datetime.utcnow() + db.session.flush() + report_email_service = ReportEmailService() + user_warning = report_service.create_report_action( + report=report, + moderator=user_1_moderator, + action_type="user_warning_lifting", + reason=None, + data={"username": user_3.username}, + ) + db.session.flush() + + report_email_service.send_report_action_email( + report, "user_warning_lifting", None, user_warning + ) + + user_warning_lifting_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'username': user_3.username, + 'fittrackee_url': app.config['UI_URL'], + 'reason': None, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'without_user_action': True, + }, + ) + + def test_it_sends_an_email_on_user_warning_for_comment_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + user_warning_lifting_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + user_warning = report_service.create_report_action( + report=report, + moderator=user_1_moderator, + action_type="user_warning_lifting", + reason=None, + data={"username": user_3.username}, + ) + db.session.flush() + + report_email_service.send_report_action_email( + report, "user_warning_lifting", None, user_warning + ) + + user_warning_lifting_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'comment_url': ( + f'{app.config["UI_URL"]}/workouts' + f'/{workout_cycling_user_2.short_id}' + f'/comments/{report.reported_comment.short_id}' + ), + 'created_at': get_date_string_for_user( + report.reported_comment.created_at, user_3 + ), + 'fittrackee_url': app.config['UI_URL'], + 'reason': None, + 'text': report.reported_comment.handle_mentions()[0], + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_3.username, + 'without_user_action': True, + }, + ) + + def test_it_sends_an_email_on_user_warning_for_workout_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + user_warning_lifting_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + user_warning = report_service.create_report_action( + report=report, + moderator=user_1_moderator, + action_type="user_warning_lifting", + reason=None, + data={"username": user_2.username}, + ) + db.session.flush() + + report_email_service.send_report_action_email( + report, "user_warning_lifting", None, user_warning + ) + + user_warning_lifting_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'fittrackee_url': app.config['UI_URL'], + 'map': None, + 'reason': None, + 'title': workout_cycling_user_2.title, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_2.username, + 'workout_date': get_date_string_for_user( + workout_cycling_user_2.workout_date, user_2 + ), + 'workout_url': ( + f'{app.config["UI_URL"]}/workouts/' + f'{workout_cycling_user_2.short_id}' + ), + 'without_user_action': True, + }, + ) + + def test_it_sends_an_email_on_user_warning_for_workout_with_gpx_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + user_warning_lifting_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.map_id = self.random_short_id() + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + user_warning = report_service.create_report_action( + report=report, + moderator=user_1_moderator, + action_type="user_warning_lifting", + reason=None, + data={"username": user_2.username}, + ) + db.session.flush() + + report_email_service.send_report_action_email( + report, "user_warning_lifting", None, user_warning + ) + + user_warning_lifting_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'fittrackee_url': app.config['UI_URL'], + 'map': ( + f'{app.config["UI_URL"]}/api/workouts/map' + f'/{workout_cycling_user_2.map_id}' + ), + 'reason': None, + 'title': workout_cycling_user_2.title, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_2.username, + 'workout_date': get_date_string_for_user( + workout_cycling_user_2.workout_date, user_2 + ), + 'workout_url': ( + f'{app.config["UI_URL"]}/workouts/' + f'{workout_cycling_user_2.short_id}' + ), + 'without_user_action': True, + }, + ) + + +class TestReportEmailServiceForComment(ReportServiceCreateReportActionMixin): + @pytest.mark.parametrize('input_reason', [{}, {"reason": "foo"}]) + def test_it_sends_an_email_on_comment_suspension( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_reason: Dict, + comment_suspension_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "comment_suspension", input_reason.get("reason") + ) + + comment_suspension_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'comment_url': ( + f'{app.config["UI_URL"]}/workouts' + f'/{workout_cycling_user_2.short_id}' + f'/comments/{report.reported_comment.short_id}' + ), + 'created_at': get_date_string_for_user( + report.reported_comment.created_at, user_3 + ), + 'fittrackee_url': app.config['UI_URL'], + 'reason': input_reason.get('reason'), + 'text': report.reported_comment.handle_mentions()[0], + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_3.username, + }, + ) + + def test_it_sends_an_email_on_comment_suspension_when_workout_is_deleted( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + comment_suspension_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + db.session.delete(workout_cycling_user_2) + db.session.flush() + + report_email_service.send_report_action_email( + report, "comment_suspension", None + ) + + comment_suspension_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'comment_url': ( + f'{app.config["UI_URL"]}/comments' + f'/{report.reported_comment.short_id}' + ), + 'created_at': get_date_string_for_user( + report.reported_comment.created_at, user_3 + ), + 'fittrackee_url': app.config['UI_URL'], + 'reason': None, + 'text': report.reported_comment.handle_mentions()[0], + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_3.username, + }, + ) + + @pytest.mark.parametrize('input_reason', [{}, {"reason": "foo"}]) + def test_it_sends_an_email_on_comment_reactivation( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + comment_unsuspension_email_mock: MagicMock, + input_reason: Dict, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report.reported_comment.suspended_at = datetime.utcnow() + db.session.flush() + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "comment_unsuspension", input_reason.get("reason") + ) + + comment_unsuspension_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'comment_url': ( + f'{app.config["UI_URL"]}/workouts' + f'/{workout_cycling_user_2.short_id}' + f'/comments/{report.reported_comment.short_id}' + ), + 'created_at': get_date_string_for_user( + report.reported_comment.created_at, user_3 + ), + 'fittrackee_url': app.config['UI_URL'], + 'reason': input_reason.get('reason'), + 'text': report.reported_comment.handle_mentions()[0], + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_3.username, + 'without_user_action': True, + }, + ) + + def test_it_sends_an_email_on_comment_reactivation_when_workout_is_deleted( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + comment_unsuspension_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report.reported_comment.suspended_at = datetime.utcnow() + db.session.delete(workout_cycling_user_2) + db.session.flush() + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "comment_unsuspension", None + ) + + comment_unsuspension_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'comment_url': ( + f'{app.config["UI_URL"]}/comments' + f'/{report.reported_comment.short_id}' + ), + 'created_at': get_date_string_for_user( + report.reported_comment.created_at, user_3 + ), + 'fittrackee_url': app.config['UI_URL'], + 'reason': None, + 'text': report.reported_comment.handle_mentions()[0], + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_3.username, + 'without_user_action': True, + }, + ) + + +class TestReportEmailServiceForWorkout(ReportServiceCreateReportActionMixin): + @pytest.mark.parametrize('input_reason', [{}, {"reason": "foo"}]) + def test_it_sends_an_email_on_workout_suspension( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_reason: Dict, + workout_suspension_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "workout_suspension", input_reason.get("reason") + ) + + workout_suspension_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'fittrackee_url': app.config['UI_URL'], + 'map': None, + 'reason': input_reason.get('reason'), + 'title': workout_cycling_user_2.title, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_2.username, + 'workout_date': get_date_string_for_user( + workout_cycling_user_2.workout_date, user_2 + ), + 'workout_url': ( + f'{app.config["UI_URL"]}/workouts/' + f'{workout_cycling_user_2.short_id}' + ), + }, + ) + + def test_it_sends_an_email_on_workout_with_gpx_suspension( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + user_suspension_email_mock: MagicMock, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_suspension_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.map_id = self.random_short_id() + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "workout_suspension", None + ) + + workout_suspension_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'fittrackee_url': app.config['UI_URL'], + 'map': ( + f'{app.config["UI_URL"]}/api/workouts/map' + f'/{workout_cycling_user_2.map_id}' + ), + 'reason': None, + 'title': workout_cycling_user_2.title, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_2.username, + 'workout_date': get_date_string_for_user( + workout_cycling_user_2.workout_date, user_2 + ), + 'workout_url': ( + f'{app.config["UI_URL"]}/workouts/' + f'{workout_cycling_user_2.short_id}' + ), + }, + ) + + @pytest.mark.parametrize('input_reason', [{}, {"reason": "foo"}]) + def test_it_sends_an_email_on_workout_reactivation( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_unsuspension_email_mock: MagicMock, + input_reason: Dict, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.flush() + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "workout_unsuspension", input_reason.get("reason") + ) + + workout_unsuspension_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'fittrackee_url': app.config['UI_URL'], + 'map': None, + 'reason': input_reason.get('reason'), + 'title': workout_cycling_user_2.title, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_2.username, + 'without_user_action': True, + 'workout_date': get_date_string_for_user( + workout_cycling_user_2.workout_date, user_2 + ), + 'workout_url': ( + f'{app.config["UI_URL"]}/workouts/' + f'{workout_cycling_user_2.short_id}' + ), + }, + ) + + def test_it_sends_an_email_on_workout_with_gpx_reactivation( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_unsuspension_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.map_id = self.random_short_id() + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.flush() + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "workout_unsuspension", None + ) + + workout_unsuspension_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'fittrackee_url': app.config['UI_URL'], + 'map': ( + f'{app.config["UI_URL"]}/api/workouts/map' + f'/{workout_cycling_user_2.map_id}' + ), + 'reason': None, + 'title': workout_cycling_user_2.title, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'username': user_2.username, + 'without_user_action': True, + 'workout_date': get_date_string_for_user( + workout_cycling_user_2.workout_date, user_2 + ), + 'workout_url': ( + f'{app.config["UI_URL"]}/workouts/' + f'{workout_cycling_user_2.short_id}' + ), + }, + ) + + +class TestReportEmailServiceForAppealRejected( + ReportServiceCreateReportActionMixin, ReportMixin +): + @pytest.mark.parametrize( + 'input_action_type', ["user_suspension", "user_warning"] + ) + def test_it_sends_an_email_for_user_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + appeal_rejected_email_mock: MagicMock, + input_action_type: str, + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + report_action = self.create_report_user_action( + user_1_moderator, user_3, input_action_type, report.id + ) + db.session.flush() + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "appeal_rejected", None, report_action + ) + + appeal_rejected_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'username': user_3.username, + 'fittrackee_url': app.config['UI_URL'], + 'reason': None, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'without_user_action': True, + 'action_type': input_action_type, + }, + ) + + def test_it_sends_an_email_on_workout_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + appeal_rejected_email_mock: MagicMock, + ) -> None: + workout_cycling_user_2.map_id = self.random_short_id() + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.flush() + report_action = self.create_report_workout_action( + user_1_moderator, user_3, workout_cycling_user_2 + ) + db.session.flush() + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "appeal_rejected", None, report_action + ) + + appeal_rejected_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'username': user_2.username, + 'fittrackee_url': app.config['UI_URL'], + 'reason': None, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'without_user_action': True, + 'action_type': report_action.action_type, + 'map': ( + f'{app.config["UI_URL"]}/api/workouts/map' + f'/{workout_cycling_user_2.map_id}' + ), + 'title': workout_cycling_user_2.title, + 'workout_date': get_date_string_for_user( + workout_cycling_user_2.workout_date, user_2 + ), + 'workout_url': ( + f'{app.config["UI_URL"]}/workouts/' + f'{workout_cycling_user_2.short_id}' + ), + }, + ) + + def test_it_sends_an_email_on_comment_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + appeal_rejected_email_mock: MagicMock, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report_action = self.create_report_comment_action( + user_1_moderator, user_3, report.reported_comment + ) + db.session.flush() + report_email_service = ReportEmailService() + + report_email_service.send_report_action_email( + report, "appeal_rejected", None, report_action + ) + + appeal_rejected_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_3.email, + }, + { + 'username': user_3.username, + 'fittrackee_url': app.config['UI_URL'], + 'reason': None, + 'user_image_url': f'{app.config["UI_URL"]}/img/user.png', + 'without_user_action': True, + 'action_type': report_action.action_type, + 'comment_url': ( + f'{app.config["UI_URL"]}/workouts' + f'/{workout_cycling_user_2.short_id}' + f'/comments/{report.reported_comment.short_id}' + ), + 'created_at': get_date_string_for_user( + report.reported_comment.created_at, user_3 + ), + 'text': report.reported_comment.handle_mentions()[0], + }, + ) diff --git a/fittrackee/tests/reports/test_reports_model.py b/fittrackee/tests/reports/test_reports_model.py new file mode 100644 index 000000000..dccb1529d --- /dev/null +++ b/fittrackee/tests/reports/test_reports_model.py @@ -0,0 +1,1218 @@ +from datetime import datetime + +import pytest +from flask import Flask +from time_machine import travel + +from fittrackee import db +from fittrackee.reports.exceptions import ( + InvalidReporterException, + InvalidReportException, + ReportCommentForbiddenException, + ReportForbiddenException, +) +from fittrackee.reports.models import Report, ReportAction, ReportComment +from fittrackee.tests.comments.mixins import CommentMixin +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ..mixins import RandomMixin, ReportMixin + + +class TestReportModel(CommentMixin, RandomMixin): + def test_it_raises_exception_when_reported_object_is_invalid( + self, app: Flask, user_1: User, sport_1_cycling: Sport + ) -> None: + with pytest.raises(InvalidReportException): + Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=sport_1_cycling, + ) + + def test_it_creates_report_for_a_comment( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + report_created_at = datetime.utcnow() + report_note = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + + report = Report( + created_at=report_created_at, + note=report_note, + reported_by=user_1.id, + reported_object=comment, + ) + + assert report.created_at == report_created_at + assert report.is_reported_user_warned is False + assert report.note == report_note + assert report.object_type == 'comment' + assert report.reported_by == user_1.id + assert report.reported_comment_id == comment.id + assert report.reported_user_id == user_2.id + assert report.reported_workout_id is None + assert report.resolved is False + assert report.resolved_at is None + assert report.resolved_by is None + assert report.updated_at is None + + def test_reported_comment_can_be_deleted( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + report_created_at = datetime.utcnow() + report_note = self.random_string() + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + report = Report( + created_at=report_created_at, + note=report_note, + reported_by=user_1.id, + reported_object=comment, + ) + db.session.add(report) + db.session.commit() + + db.session.delete(comment) + db.session.commit() + + updated_report = Report.query.first() + assert updated_report.created_at == report_created_at + assert updated_report.is_reported_user_warned is False + assert updated_report.note == report_note + assert updated_report.object_type == 'comment' + assert updated_report.reported_by == user_1.id + assert updated_report.reported_comment_id is None + assert updated_report.reported_user_id == user_2.id + assert updated_report.reported_workout_id is None + assert updated_report.resolved is False + assert updated_report.resolved_at is None + assert updated_report.resolved_by is None + assert updated_report.updated_at is None + + def test_it_creates_report_for_a_user( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + report_created_at = datetime.utcnow() + report_note = self.random_string() + + report = Report( + created_at=report_created_at, + note=report_note, + reported_by=user_1.id, + reported_object=user_2, + ) + + assert report.created_at == report_created_at + assert report.is_reported_user_warned is False + assert report.note == report_note + assert report.object_type == 'user' + assert report.reported_by == user_1.id + assert report.reported_comment_id is None + assert report.reported_user_id == user_2.id + assert report.reported_workout_id is None + assert report.resolved is False + assert report.resolved_at is None + assert report.resolved_by is None + assert report.updated_at is None + + def test_reported_user_can_be_deleted( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + report_created_at = datetime.utcnow() + report_note = self.random_string() + report = Report( + created_at=report_created_at, + note=report_note, + reported_by=user_1.id, + reported_object=user_2, + ) + db.session.add(report) + db.session.commit() + + db.session.delete(user_2) + db.session.commit() + + updated_report = Report.query.first() + assert updated_report.created_at == report_created_at + assert updated_report.is_reported_user_warned is False + assert updated_report.note == report_note + assert updated_report.object_type == 'user' + assert updated_report.reported_by == user_1.id + assert updated_report.reported_comment_id is None + assert updated_report.reported_user_id is None + assert updated_report.reported_workout_id is None + assert updated_report.resolved is False + assert updated_report.resolved_at is None + assert updated_report.resolved_by is None + assert updated_report.updated_at is None + + def test_reporter_can_be_deleted( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + report_created_at = datetime.utcnow() + report_note = self.random_string() + report = Report( + created_at=report_created_at, + note=report_note, + reported_by=user_1.id, + reported_object=user_2, + ) + db.session.add(report) + db.session.commit() + + db.session.delete(user_1) + db.session.commit() + + updated_report = Report.query.first() + assert updated_report.created_at == report_created_at + assert updated_report.is_reported_user_warned is False + assert updated_report.note == report_note + assert updated_report.object_type == 'user' + assert updated_report.reported_by is None + assert updated_report.reported_comment_id is None + assert updated_report.reported_user_id == user_2.id + assert updated_report.reported_workout_id is None + assert updated_report.resolved is False + assert updated_report.resolved_at is None + assert updated_report.resolved_by is None + assert updated_report.updated_at is None + + def test_it_creates_report_for_a_workout( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report_created_at = datetime.utcnow() + report_note = self.random_string() + + report = Report( + created_at=report_created_at, + note=report_note, + reported_by=user_1.id, + reported_object=workout_cycling_user_2, + ) + + assert report.created_at == report_created_at + assert report.is_reported_user_warned is False + assert report.note == report_note + assert report.object_type == 'workout' + assert report.reported_by == user_1.id + assert report.reported_comment_id is None + assert report.reported_user_id == user_2.id + assert report.reported_workout_id == workout_cycling_user_2.id + assert report.resolved is False + assert report.resolved_at is None + assert report.resolved_by is None + assert report.updated_at is None + + def test_reported_workout_can_be_deleted( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report_created_at = datetime.utcnow() + report_note = self.random_string() + report = Report( + created_at=report_created_at, + note=report_note, + reported_by=user_1.id, + reported_object=workout_cycling_user_2, + ) + db.session.add(report) + db.session.commit() + + db.session.delete(workout_cycling_user_2) + db.session.commit() + + updated_report = Report.query.first() + assert updated_report.created_at == report_created_at + assert updated_report.is_reported_user_warned is False + assert updated_report.note == report_note + assert updated_report.object_type == 'workout' + assert updated_report.reported_by == user_1.id + assert updated_report.reported_comment_id is None + assert updated_report.reported_user_id == user_2.id + assert updated_report.reported_workout_id is None + assert updated_report.resolved is False + assert updated_report.resolved_at is None + assert updated_report.resolved_by is None + assert updated_report.updated_at is None + + def test_it_creates_report_without_date( + self, app: Flask, user_1: User, user_2: User + ) -> None: + now = datetime.utcnow() + report_note = self.random_string() + + with travel(now, tick=False): + report = Report( + note=report_note, + reported_by=user_1.id, + reported_object=user_2, + ) + + assert report.created_at == now + assert report.is_reported_user_warned is False + assert report.note == report_note + assert report.object_type == 'user' + assert report.reported_by == user_1.id + assert report.reported_comment_id is None + assert report.reported_user_id == user_2.id + assert report.reported_workout_id is None + assert report.resolved is False + assert report.resolved_at is None + assert report.resolved_by is None + assert report.updated_at is None + + def test_it_raises_exception_when_reported_user_is_reporter( + self, app: Flask, user_1: User + ) -> None: + with pytest.raises(InvalidReporterException): + Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=user_1, + ) + + def test_is_reported_user_warned_is_true_when_user_warning_action_exists( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_3.id, + reported_object=user_2, + ) + db.session.add(report) + report_action = ReportAction( + moderator_id=user_1_moderator.id, + action_type="user_warning", + report_id=report.id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.flush() + + assert report.is_reported_user_warned is True + + +class TestReportSerializerAsUser(CommentMixin, RandomMixin): + def test_it_raises_exception_when_user_is_not_reporter( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + user_3: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + report = Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=comment, + ) + db.session.add(report) + db.session.commit() + + with pytest.raises(ReportForbiddenException): + report.serialize(user_3) + + def test_it_returns_serialized_object_for_comment_report( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + report = Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=comment, + ) + db.session.add(report) + db.session.commit() + + serialized_report = report.serialize(user_1) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "comment", + "reported_by": user_1.serialize(current_user=user_1), + "reported_comment": comment.serialize(user_1), + "reported_user": user_2.serialize(current_user=user_1), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + } + + def test_it_returns_serialized_object_report_when_comment_is_not_visible( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + user_2: User, + ) -> None: + # in case visibility changed + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + report = Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=comment, + ) + db.session.add(report) + comment.text_visibility = VisibilityLevel.FOLLOWERS + db.session.commit() + + serialized_report = report.serialize(user_1) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "comment", + "reported_by": user_1.serialize(current_user=user_1), + "reported_comment": "_COMMENT_UNAVAILABLE_", + "reported_user": user_2.serialize(current_user=user_1), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + } + + def test_it_returns_serialized_report_when_comment_is_deleted( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + report = Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=comment, + ) + db.session.add(report) + db.session.commit() + db.session.delete(comment) + db.session.commit() + + serialized_report = report.serialize(user_1) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "comment", + "reported_by": user_1.serialize(current_user=user_1), + "reported_comment": None, + "reported_user": user_2.serialize(current_user=user_1), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + } + + def test_it_returns_serialized_object_for_user_report( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=user_2, + ) + db.session.add(report) + db.session.commit() + + serialized_report = report.serialize(user_1) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "user", + "reported_by": user_1.serialize(current_user=user_1), + "reported_comment": None, + "reported_user": user_2.serialize(current_user=user_1), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + } + + def test_it_returns_serialized_report_when_user_is_deleted( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=user_2, + ) + db.session.add(report) + db.session.commit() + db.session.delete(user_2) + db.session.commit() + + serialized_report = report.serialize(user_1) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "user", + "reported_by": user_1.serialize(current_user=user_1), + "reported_comment": None, + "reported_user": None, + "reported_workout": None, + "resolved": False, + "resolved_at": None, + } + + def test_it_returns_serialized_object_for_workout_report( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=workout_cycling_user_2, + ) + db.session.add(report) + db.session.commit() + + serialized_report = report.serialize(user_1) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "workout", + "reported_by": user_1.serialize(current_user=user_1), + "reported_comment": None, + "reported_user": user_2.serialize(current_user=user_1), + "reported_workout": workout_cycling_user_2.serialize( + user=user_1, for_report=True + ), + "resolved": False, + "resolved_at": None, + } + + def test_it_returns_serialized_object_for_report_when_workout_is_not_visible( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + # in case visibility changed + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=workout_cycling_user_2, + ) + db.session.add(report) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + db.session.commit() + + serialized_report = report.serialize(user_1) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "workout", + "reported_by": user_1.serialize(current_user=user_1), + "reported_comment": None, + "reported_user": user_2.serialize(current_user=user_1), + "reported_workout": '_WORKOUT_UNAVAILABLE_', + "resolved": False, + "resolved_at": None, + } + + def test_it_returns_serialized_report_when_workout_is_deleted( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = Report( + note=self.random_string(), + reported_by=user_1.id, + reported_object=workout_cycling_user_2, + ) + db.session.add(report) + db.session.commit() + db.session.delete(workout_cycling_user_2) + db.session.commit() + + serialized_report = report.serialize(user_1) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "workout", + "reported_by": user_1.serialize(current_user=user_1), + "reported_comment": None, + "reported_user": user_2.serialize(current_user=user_1), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + } + + def test_it_returns_serialized_object_without_report_comments( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_2.id, + reported_object=user_3, + ) + db.session.add(report) + db.session.flush() + report_comment = ReportComment( + report_id=report.id, + user_id=user_1_moderator.id, + comment=self.random_string(), + ) + db.session.add(report_comment) + db.session.commit() + + serialized_report = report.serialize(user_2) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "user", + "reported_by": user_2.serialize(current_user=user_2), + "reported_comment": None, + "reported_user": user_3.serialize(current_user=user_2), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + } + + +class TestMinimalReportSerializerAsModerator(CommentMixin, RandomMixin): + def test_it_returns_serialized_comment_when_comment_is_not_visible( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_3) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + report = Report( + note=self.random_string(), + reported_by=user_2.id, + reported_object=comment, + ) + db.session.add(report) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "comment", + "reported_by": user_2.serialize(current_user=user_1_moderator), + "reported_comment": comment.serialize( + user_1_moderator, for_report=True + ), + "reported_user": user_3.serialize(current_user=user_1_moderator), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + def test_it_returns_serialized_report_when_reported_comment_is_deleted( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + report = Report( + note=self.random_string(), + reported_by=user_2.id, + reported_object=comment, + ) + db.session.add(report) + db.session.commit() + db.session.delete(comment) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "comment", + "reported_by": user_2.serialize(current_user=user_1_moderator), + "reported_comment": None, + "reported_user": user_3.serialize(current_user=user_1_moderator), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + def test_it_returns_serialized_report_when_reported_user_is_deleted( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_3.id, + reported_object=user_2, + ) + db.session.add(report) + db.session.commit() + db.session.delete(user_2) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "user", + "reported_by": user_3.serialize(current_user=user_1_moderator), + "reported_comment": None, + "reported_user": None, + "reported_workout": None, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + def test_it_returns_serialized_report_when_reporter_is_deleted( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_3.id, + reported_object=user_2, + ) + db.session.add(report) + db.session.commit() + db.session.delete(user_3) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "user", + "reported_by": None, + "reported_comment": None, + "reported_user": user_2.serialize(current_user=user_1_moderator), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + def test_it_returns_serialized_report_when_workout_is_not_visible( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_3) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + report = Report( + note=self.random_string(), + reported_by=user_3.id, + reported_object=workout_cycling_user_2, + ) + db.session.add(report) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "workout", + "reported_by": user_3.serialize(current_user=user_1_moderator), + "reported_comment": None, + "reported_user": user_2.serialize(current_user=user_1_moderator), + "reported_workout": workout_cycling_user_2.serialize( + user=user_1_moderator, for_report=True + ), + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + def test_it_returns_serialized_report_when_reported_workout_is_deleted( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report = Report( + note=self.random_string(), + reported_by=user_3.id, + reported_object=workout_cycling_user_2, + ) + db.session.add(report) + db.session.commit() + db.session.delete(workout_cycling_user_2) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "workout", + "reported_by": user_3.serialize(current_user=user_1_moderator), + "reported_comment": None, + "reported_user": user_2.serialize(current_user=user_1_moderator), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + def test_it_returns_serialized_object_when_no_report_comments( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_2.id, + reported_object=user_3, + ) + db.session.add(report) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "user", + "reported_by": user_2.serialize(current_user=user_1_moderator), + "reported_comment": None, + "reported_user": user_3.serialize(current_user=user_1_moderator), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + def test_it_does_not_return_serialized_object_with_report_comments_when_flag_is_false( # noqa + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_2.id, + reported_object=user_3, + ) + db.session.add(report) + db.session.flush() + report_comment = ReportComment( + report_id=report.id, + user_id=user_1_moderator.id, + comment=self.random_string(), + ) + db.session.add(report_comment) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator, full=False) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "user", + "reported_by": user_2.serialize(current_user=user_1_moderator), + "reported_comment": None, + "reported_user": user_3.serialize(current_user=user_1_moderator), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + def test_it_does_not_return_serialized_object_with_report_actions_when_flag_is_false( # noqa + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_2.id, + reported_object=user_3, + ) + db.session.add(report) + db.session.flush() + report_action = ReportAction( + action_type="user_suspension", + moderator_id=user_1_moderator.id, + report_id=report.id, + user_id=user_3.id, + ) + db.session.add(report_action) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator, full=False) + + assert serialized_report == { + "created_at": report.created_at, + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "user", + "reported_by": user_2.serialize(current_user=user_1_moderator), + "reported_comment": None, + "reported_user": user_3.serialize(current_user=user_1_moderator), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + +class TestFullReportSerializerAsModerator(CommentMixin, RandomMixin): + def test_it_returns_serialized_object_with_report_comments( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_2.id, + reported_object=user_3, + ) + db.session.add(report) + db.session.flush() + report_comment = ReportComment( + report_id=report.id, + user_id=user_1_moderator.id, + comment=self.random_string(), + ) + db.session.add(report_comment) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator, full=True) + + assert serialized_report == { + "report_actions": [], + "created_at": report.created_at, + "comments": [report_comment.serialize(user_1_moderator)], + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "user", + "reported_by": user_2.serialize(current_user=user_1_moderator), + "reported_comment": None, + "reported_user": user_3.serialize(current_user=user_1_moderator), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + def test_it_returns_serialized_object_with_report_actions( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + ) -> None: + report = Report( + note=self.random_string(), + reported_by=user_2.id, + reported_object=user_3, + ) + db.session.add(report) + db.session.flush() + report_action_1 = ReportAction( + action_type="user_suspension", + moderator_id=user_1_moderator.id, + report_id=report.id, + user_id=user_3.id, + ) + db.session.add(report_action_1) + report_action_2 = ReportAction( + action_type="report_resolution", + moderator_id=user_1_moderator.id, + report_id=report.id, + ) + db.session.add(report_action_2) + db.session.commit() + + serialized_report = report.serialize(user_1_moderator, full=True) + + assert serialized_report == { + "report_actions": [ + report_action_1.serialize(user_1_moderator, full=False), + report_action_2.serialize(user_1_moderator, full=False), + ], + "created_at": report.created_at, + "comments": [], + "id": report.id, + "is_reported_user_warned": report.is_reported_user_warned, + "note": report.note, + "object_type": "user", + "reported_by": user_2.serialize(current_user=user_1_moderator), + "reported_comment": None, + "reported_user": user_3.serialize(current_user=user_1_moderator), + "reported_workout": None, + "resolved": False, + "resolved_at": None, + "resolved_by": None, + "updated_at": None, + } + + +class ReportCommentTestCase(CommentMixin, ReportMixin): + pass + + +class TestReportCommentModel(ReportCommentTestCase): + def test_it_creates_report_for_a_comment( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + created_at = datetime.utcnow() + comment = self.random_string() + + report_comment = ReportComment( + report_id=report.id, + user_id=user_1_moderator.id, + comment=comment, + created_at=created_at, + ) + db.session.add(report_comment) + db.session.commit() + + assert report_comment.created_at == created_at + assert report_comment.comment == comment + assert report_comment.report_id == report.id + assert report_comment.user_id == user_1_moderator.id + + def test_it_creates_report_for_a_comment_without_date( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + comment = self.random_string() + now = datetime.utcnow() + + with travel(now, tick=False): + report_comment = ReportComment( + report_id=report.id, + user_id=user_1_moderator.id, + comment=comment, + ) + db.session.add(report_comment) + db.session.commit() + + assert report_comment.created_at == now + assert report_comment.comment == comment + assert report_comment.report_id == report.id + assert report_comment.user_id == user_1_moderator.id + + +class TestReportCommentSerializer(ReportCommentTestCase): + def test_it_raises_exception_when_user_has_no_admin_rights( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + report_comment = ReportComment( + report_id=report.id, + user_id=user_1_moderator.id, + comment=self.random_string(), + ) + + with pytest.raises(ReportCommentForbiddenException): + report_comment.serialize(user_2) + + def test_it_returns_serialized_report_comment( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + comment = self.random_string() + report_comment = ReportComment( + report_id=report.id, + user_id=user_1_moderator.id, + comment=comment, + ) + db.session.add(report_comment) + db.session.commit() + + serialized_comment = report_comment.serialize(user_1_moderator) + + assert serialized_comment['created_at'] == report_comment.created_at + assert serialized_comment['comment'] == report_comment.comment + assert serialized_comment['id'] == report_comment.id + assert serialized_comment['report_id'] == report.id + assert serialized_comment['user'] == user_1_moderator.serialize( + current_user=user_1_moderator + ) diff --git a/fittrackee/tests/reports/test_reports_service.py b/fittrackee/tests/reports/test_reports_service.py new file mode 100644 index 000000000..e698a48b7 --- /dev/null +++ b/fittrackee/tests/reports/test_reports_service.py @@ -0,0 +1,2310 @@ +from datetime import datetime, timedelta +from typing import Dict + +import pytest +from flask import Flask +from time_machine import travel + +from fittrackee import db +from fittrackee.comments.exceptions import CommentForbiddenException +from fittrackee.comments.models import Comment +from fittrackee.reports.exceptions import ( + InvalidReportActionException, + InvalidReporterException, + InvalidReportException, + ReportNotFoundException, + SuspendedObjectException, + UserWarningExistsException, +) +from fittrackee.reports.models import ( + ReportAction, + ReportActionAppeal, + ReportComment, +) +from fittrackee.reports.reports_service import ReportService +from fittrackee.tests.comments.mixins import CommentMixin +from fittrackee.users.exceptions import ( + UserAlreadyReactivatedException, + UserAlreadySuspendedException, + UserNotFoundException, +) +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.exceptions import WorkoutForbiddenException +from fittrackee.workouts.models import Sport, Workout + +from ..mixins import RandomMixin, ReportMixin +from .mixins import ReportServiceCreateReportActionMixin + + +class TestReportServiceCreateForComment(CommentMixin): + def test_it_raises_exception_when_reported_comment_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + report_service = ReportService() + + with pytest.raises(CommentForbiddenException): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=self.random_short_id(), + object_type="comment", + ) + + def test_it_raises_exception_when_reported_comment_is_not_visible( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + report_service = ReportService() + + with pytest.raises(CommentForbiddenException): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=comment.short_id, + object_type="comment", + ) + + def test_it_raises_exception_when_reporter_is_comment_author( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + report_service = ReportService() + + with pytest.raises(InvalidReporterException): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=comment.short_id, + object_type="comment", + ) + + def test_it_creates_report_for_comment( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + note = self.random_string() + now = datetime.utcnow() + report_service = ReportService() + # report from another user + report_service.create_report( + reporter=user_3, + note=self.random_string(), + object_id=comment.short_id, + object_type="comment", + ) + # resolved report from same user + report = report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=comment.short_id, + object_type="comment", + ) + report.resolved = True + + with travel(now, tick=False): + comment_report = report_service.create_report( + reporter=user_2, + note=note, + object_id=comment.short_id, + object_type="comment", + ) + + assert comment_report.created_at == now + assert comment_report.note == note + assert comment_report.object_type == "comment" + assert comment_report.reported_by == user_2.id + assert comment_report.reported_comment_id == comment.id + assert comment_report.reported_workout_id is None + assert comment_report.reported_user_id == user_1.id + assert comment_report.resolved is False + assert comment_report.resolved_at is None + assert comment_report.resolved_by is None + assert comment_report.updated_at is None + + def test_it_raises_error_when_report_from_the_same_user_already_exists( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + report_service = ReportService() + report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=comment.short_id, + object_type="comment", + ) + + with pytest.raises( + InvalidReportException, match='a report already exists' + ): + report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=comment.short_id, + object_type="comment", + ) + + def test_it_raises_error_when_comment_is_already_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment.suspended_at = datetime.utcnow() + report_service = ReportService() + + with pytest.raises( + SuspendedObjectException, match='comment already suspended' + ): + report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=comment.short_id, + object_type="comment", + ) + + +class TestReportServiceCreateForWorkout(RandomMixin): + def test_it_raises_exception_when_reported_workout_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + report_service = ReportService() + + with pytest.raises(WorkoutForbiddenException): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=self.random_short_id(), + object_type="workout", + ) + + def test_it_raises_exception_when_reported_workout_is_not_visible( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + report_service = ReportService() + + with pytest.raises(WorkoutForbiddenException): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=workout_cycling_user_2.short_id, + object_type="workout", + ) + + def test_it_raises_exception_when_reporter_is_workout_owner( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + report_service = ReportService() + + with pytest.raises(InvalidReporterException): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=workout_cycling_user_1.short_id, + object_type="workout", + ) + + def test_it_creates_report_for_workout( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + note = self.random_string() + now = datetime.utcnow() + report_service = ReportService() + # report from another user + report_service.create_report( + reporter=user_3, + note=note, + object_id=workout_cycling_user_2.short_id, + object_type="workout", + ) + # resolved report from same user + report = report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=workout_cycling_user_2.short_id, + object_type="workout", + ) + report.resolved = True + + with travel(now, tick=False): + workout_report = report_service.create_report( + reporter=user_1, + note=note, + object_id=workout_cycling_user_2.short_id, + object_type="workout", + ) + + assert workout_report.created_at == now + assert workout_report.note == note + assert workout_report.object_type == "workout" + assert workout_report.reported_by == user_1.id + assert workout_report.reported_comment_id is None + assert workout_report.reported_workout_id == workout_cycling_user_2.id + assert workout_report.reported_user_id == user_2.id + assert workout_report.resolved is False + assert workout_report.resolved_at is None + assert workout_report.resolved_by is None + assert workout_report.updated_at is None + + def test_it_raises_error_when_report_from_the_same_user_already_exists( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + report_service = ReportService() + # report from same user + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=workout_cycling_user_2.short_id, + object_type="workout", + ) + + with pytest.raises( + InvalidReportException, match='a report already exists' + ): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=workout_cycling_user_2.short_id, + object_type="workout", + ) + + def test_it_raises_error_when_workout_is_already_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_2.suspended_at = datetime.utcnow() + report_service = ReportService() + + with pytest.raises( + SuspendedObjectException, match='workout already suspended' + ): + report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=workout_cycling_user_2.short_id, + object_type="workout", + ) + + +class TestReportServiceCreateForUser(RandomMixin): + def test_it_raises_exception_when_reported_user_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + report_service = ReportService() + + with pytest.raises(UserNotFoundException): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=self.random_string(), + object_type="user", + ) + + def test_it_raises_exception_when_reported_user_is_inactive( + self, app: Flask, user_1: User, inactive_user: User + ) -> None: + report_service = ReportService() + + with pytest.raises(UserNotFoundException): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=inactive_user.username, + object_type="user", + ) + + def test_it_raises_exception_when_reporter_is_reported_user( + self, app: Flask, user_1: User + ) -> None: + report_service = ReportService() + + with pytest.raises(InvalidReporterException): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=user_1.username, + object_type="user", + ) + + def test_it_creates_report_for_user( + self, app: Flask, user_1: User, user_2: User, user_3: User + ) -> None: + note = self.random_string() + now = datetime.utcnow() + report_service = ReportService() + # report from another user + report_service.create_report( + reporter=user_3, + note=note, + object_id=user_2.username, + object_type="user", + ) + # resolved report from same user + report = report_service.create_report( + reporter=user_1, + note=note, + object_id=user_2.username, + object_type="user", + ) + report.resolved = True + + with travel(now, tick=False): + user_report = report_service.create_report( + reporter=user_1, + note=note, + object_id=user_2.username, + object_type="user", + ) + + assert user_report.created_at == now + assert user_report.note == note + assert user_report.object_type == "user" + assert user_report.reported_by == user_1.id + assert user_report.reported_comment_id is None + assert user_report.reported_workout_id is None + assert user_report.reported_user_id == user_2.id + assert user_report.resolved is False + assert user_report.resolved_at is None + assert user_report.resolved_by is None + assert user_report.updated_at is None + + def test_it_raises_error_when_report_from_the_same_user_already_exists( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + # report from same user + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=user_2.username, + object_type="user", + ) + + with pytest.raises( + InvalidReportException, match='a report already exists' + ): + report_service.create_report( + reporter=user_1, + note=self.random_string(), + object_id=user_2.username, + object_type="user", + ) + + def test_it_raises_error_when_user_is_already_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + user_1.suspended_at = datetime.utcnow() + report_service = ReportService() + + with pytest.raises( + SuspendedObjectException, match='user already suspended' + ): + report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=user_1.username, + object_type="user", + ) + + +class TestReportServiceUpdate(CommentMixin): + def test_it_raises_exception_when_report_does_not_exist( + self, app: Flask, user_1_admin: User + ) -> None: + report_service = ReportService() + + with pytest.raises(ReportNotFoundException): + report_service.update_report( + report_id=self.random_int(), + moderator=user_1_admin, + report_comment=self.random_string(), + ) + + def test_it_updates_report_when_adding_comment( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + note = self.random_string() + report = report_service.create_report( + reporter=user_2, + note=note, + object_id=user_3.username, + object_type="user", + ) + created_at = report.created_at + now = datetime.utcnow() + + with travel(now, tick=False): + updated_report = report_service.update_report( + report_id=report.id, + moderator=user_1_admin, + report_comment=self.random_string(), + ) + + assert updated_report.created_at == created_at + assert updated_report.note == note + assert updated_report.object_type == "user" + assert updated_report.reported_by == user_2.id + assert updated_report.reported_comment_id is None + assert updated_report.reported_workout_id is None + assert updated_report.reported_user_id == user_3.id + assert updated_report.resolved is False + assert updated_report.resolved_at is None + assert updated_report.resolved_by is None + assert updated_report.updated_at == now + + def test_it_creates_a_report_comment_when_it_updates_report( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=user_3.username, + object_type="user", + ) + comment = self.random_string() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.update_report( + report_id=report.id, + moderator=user_1_admin, + report_comment=comment, + ) + + report_comment = ReportComment.query.filter_by( + report_id=report.id + ).first() + assert report_comment.comment == comment + assert report_comment.created_at == now + assert report_comment.user_id == user_1_admin.id + + def test_it_does_not_create_report_action_on_report_comment( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=user_3.username, + object_type="user", + ) + + report_service.update_report( + report_id=report.id, + moderator=user_1_admin, + report_comment=self.random_string(), + ) + + assert ReportAction.query.filter_by(report_id=report.id).count() == 0 + + def test_it_marks_report_as_resolved( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + note = self.random_string() + report = report_service.create_report( + reporter=user_2, + note=note, + object_id=user_3.username, + object_type="user", + ) + created_at = report.created_at + now = datetime.utcnow() + + with travel(now, tick=False): + updated_report = report_service.update_report( + report_id=report.id, + moderator=user_1_admin, + report_comment=self.random_string(), + resolved=True, + ) + + assert updated_report.created_at == created_at + assert updated_report.note == note + assert updated_report.object_type == "user" + assert updated_report.reported_by == user_2.id + assert updated_report.reported_comment_id is None + assert updated_report.reported_workout_id is None + assert updated_report.reported_user_id == user_3.id + assert updated_report.resolved is True + assert updated_report.resolved_at == now + assert updated_report.resolved_by == user_1_admin.id + assert updated_report.updated_at == now + + def test_it_creates_report_action_when_resolving_report( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=user_3.username, + object_type="user", + ) + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.update_report( + report_id=report.id, + moderator=user_1_admin, + report_comment=self.random_string(), + resolved=True, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "report_resolution" + assert report_action.moderator_id == user_1_admin.id + assert report_action.created_at == now + assert report_action.report_id == report.id + + def test_it_marks_report_as_unresolved( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + note = self.random_string() + report = report_service.create_report( + reporter=user_2, + note=note, + object_id=user_3.username, + object_type="user", + ) + created_at = report.created_at + report.resolved = True + report.resolved_at = datetime.utcnow() + report.resolved_by = user_1_admin.id + now = datetime.utcnow() + + with travel(now, tick=False): + updated_report = report_service.update_report( + report_id=report.id, + moderator=user_1_admin, + report_comment=self.random_string(), + resolved=False, + ) + + assert updated_report.created_at == created_at + assert updated_report.note == note + assert updated_report.object_type == "user" + assert updated_report.reported_by == user_2.id + assert updated_report.reported_comment_id is None + assert updated_report.reported_workout_id is None + assert updated_report.reported_user_id == user_3.id + assert updated_report.resolved is False + assert updated_report.resolved_at is None + assert updated_report.resolved_by is None + assert updated_report.updated_at == now + + def test_it_creates_report_action_when_make_report_as_unresolved( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=user_3.username, + object_type="user", + ) + report.resolved = True + report.resolved_at = datetime.utcnow() + report.resolved_by = user_1_admin.id + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.update_report( + report_id=report.id, + moderator=user_1_admin, + report_comment=self.random_string(), + resolved=False, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "report_reopening" + assert report_action.moderator_id == user_1_admin.id + assert report_action.created_at == now + assert report_action.report_id == report.id + + def test_it_does_not_create_report_action_when_already_unresolved( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = report_service.create_report( + reporter=user_2, + note=self.random_string(), + object_id=user_3.username, + object_type="user", + ) + + with travel(datetime.utcnow(), tick=False): + report_service.update_report( + report_id=report.id, + moderator=user_1_admin, + report_comment=self.random_string(), + resolved=False, + ) + + assert ReportAction.query.filter_by(report_id=report.id).count() == 0 + + def test_it_updates_resolved_report_when_adding_comment( + self, app: Flask, user_1_admin: User, user_2_admin: User, user_3: User + ) -> None: + report_service = ReportService() + note = self.random_string() + report = report_service.create_report( + reporter=user_2_admin, + note=note, + object_id=user_3.username, + object_type="user", + ) + created_at = report.created_at + resolved_time = datetime.utcnow() + + # resolved by user_1_admin + with travel(resolved_time, tick=False): + report_service.update_report( + report_id=report.id, + moderator=user_1_admin, + report_comment=self.random_string(), + resolved=True, + ) + + # comment added by user_2_admin + comment_time = resolved_time + timedelta(minutes=10) + with travel(comment_time, tick=False): + updated_report = report_service.update_report( + report_id=report.id, + moderator=user_1_admin, + report_comment=self.random_string(), + ) + + assert updated_report.created_at == created_at + assert updated_report.note == note + assert updated_report.object_type == "user" + assert updated_report.reported_by == user_2_admin.id + assert updated_report.reported_comment_id is None + assert updated_report.reported_workout_id is None + assert updated_report.reported_user_id == user_3.id + assert updated_report.resolved is True + assert updated_report.resolved_at == resolved_time + assert updated_report.resolved_by == user_1_admin.id + assert updated_report.updated_at == comment_time + + +class TestReportServiceCreateReportAction( + ReportServiceCreateReportActionMixin +): + def test_it_raises_exception_when_reported_user_does_not_exist( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + db.session.delete(user_3) + db.session.flush() + + with pytest.raises( + InvalidReportActionException, match="invalid 'username'" + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_suspension", + data={"username": user_3.username}, + ) + + def test_it_raises_exception_when_report_action_is_invalid( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + + with pytest.raises( + InvalidReportActionException, match="invalid action type" + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type=self.random_string(), + data={"username": user_3.username}, + ) + + +class TestReportServiceCreateReportActionForUser( + ReportServiceCreateReportActionMixin, ReportMixin +): + def test_it_raises_exception_when_username_is_missing( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + + with pytest.raises( + InvalidReportActionException, match="'username' is missing" + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_suspension", + data={}, + ) + + def test_it_raises_exception_when_username_does_not_match_reported_user_username( # noqa + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + + with pytest.raises( + InvalidReportActionException, match="invalid 'username'" + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_suspension", + data={"username": self.random_string()}, + ) + + def test_it_suspends_user( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_suspension", + reason=None, + data={"username": user_3.username}, + ) + + assert ( + User.query.filter_by(username=user_3.username).first().suspended_at + == now + ) + + def test_it_raises_exception_when_user_already_suspended( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + user_3.suspended_at = datetime.utcnow() + db.session.flush() + + with pytest.raises( + UserAlreadySuspendedException, + match="user account already suspended", + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_suspension", + reason=None, + data={"username": user_3.username}, + ) + + def test_it_reactivates_user( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + user_3.suspended_at = datetime.utcnow() + db.session.flush() + + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_unsuspension", + reason=None, + data={"username": user_3.username}, + ) + + assert ( + User.query.filter_by(username=user_3.username).first().suspended_at + is None + ) + + def test_it_raises_exception_when_user_already_reactivated( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + db.session.flush() + + with pytest.raises( + UserAlreadyReactivatedException, + match="user account already reactivated", + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_unsuspension", + reason=None, + data={"username": user_3.username}, + ) + + def test_it_updates_existing_appeal_on_user_unsuspension( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + user_4: User, + ) -> None: + report_service = ReportService() + user_suspension = self.create_report_user_action(user_1_admin, user_3) + appeal = self.create_action_appeal(user_suspension.id, user_3) + another_user_suspension = self.create_report_user_action( + user_1_admin, user_4 + ) + another_user_appeal = self.create_action_appeal( + another_user_suspension.id, user_4 + ) + db.session.add(another_user_suspension) + db.session.flush() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=user_suspension.report, + moderator=user_1_admin, + action_type="user_unsuspension", + reason=None, + data={"username": user_3.username}, + ) + + assert appeal.updated_at == now + assert appeal.approved is None + assert another_user_appeal.updated_at is None + assert another_user_appeal.approved is None + + @pytest.mark.parametrize('input_reason', [{}, {"reason": "some reason"}]) + def test_it_creates_report_action_for_user_suspension( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + input_reason: Dict, + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_suspension", + reason=input_reason.get("reason"), + data={"username": user_3.username}, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "user_suspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.created_at == now + assert report_action.comment_id is None + assert report_action.reason == input_reason.get("reason") + assert report_action.report_id == report.id + assert report_action.user_id == user_3.id + assert report_action.workout_id is None + + def test_it_creates_report_action_for_user_reactivation( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + user_3.suspended_at = datetime.utcnow() + db.session.flush() + + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_unsuspension", + data={"username": user_3.username}, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "user_unsuspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.comment_id is None + assert report_action.reason is None + assert report_action.report_id == report.id + assert report_action.user_id == user_3.id + assert report_action.workout_id is None + + def test_it_creates_report_action_for_user_warning_on_user_report( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_warning", + data={"username": user_3.username}, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "user_warning" + assert report_action.moderator_id == user_1_admin.id + assert report_action.comment_id is None + assert report_action.reason is None + assert report_action.report_id == report.id + assert report_action.user_id == user_3.id + assert report_action.workout_id is None + + def test_it_creates_report_action_for_user_warning_on_comment_report( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_warning", + data={"username": user_3.username}, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "user_warning" + assert report_action.moderator_id == user_1_admin.id + assert report_action.comment_id == report.reported_comment_id + assert report_action.reason is None + assert report_action.report_id == report.id + assert report_action.user_id == user_3.id + assert report_action.workout_id is None + + def test_it_creates_report_action_for_user_warning_on_workout_report( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_warning", + data={"username": user_2.username}, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "user_warning" + assert report_action.moderator_id == user_1_admin.id + assert report_action.comment_id is None + assert report_action.reason is None + assert report_action.report_id == report.id + assert report_action.user_id == user_2.id + assert report_action.workout_id == report.reported_workout_id + + def test_it_raises_exception_when_user_warning_already_exists_for_report( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_warning", + data={"username": user_3.username}, + ) + + with pytest.raises( + UserWarningExistsException, + match="user already warned", + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_warning", + data={"username": user_3.username}, + ) + + def test_it_creates_report_action_for_user_warning_when_warning_exists_for_another_report( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + user_4: User, + ) -> None: + report_service = ReportService() + report = self.create_report_for_user( + report_service, reporter=user_2, reported_user=user_3 + ) + another_report = self.create_report_for_user( + report_service, reporter=user_4, reported_user=user_3 + ) + report_service.create_report_action( + report=another_report, + moderator=user_1_admin, + action_type="user_warning", + data={"username": user_3.username}, + ) + + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="user_warning", + data={"username": user_3.username}, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "user_warning" + assert report_action.moderator_id == user_1_admin.id + assert report_action.report_id == report.id + assert report_action.comment_id is None + assert report_action.reason is None + assert report_action.user_id == user_3.id + assert report_action.workout_id is None + + +class TestReportServiceCreateReportActionForComment( + ReportServiceCreateReportActionMixin, ReportMixin +): + def test_it_raises_exception_when_report_is_invalid( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + + with pytest.raises( + InvalidReportActionException, match="'comment_id' is missing" + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="comment_suspension", + data={}, + ) + + def test_it_raises_exception_when_comment_id_does_not_match_reported_object_id( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + + with pytest.raises( + InvalidReportActionException, match="invalid 'comment_id'" + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="comment_suspension", + data={"comment_id": self.random_short_id()}, + ) + + def test_it_raises_error_when_comment_is_already_suspended( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report.reported_comment.suspended_at = datetime.utcnow() + db.session.flush() + + with pytest.raises( + InvalidReportActionException, + match=("comment already suspended"), + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="comment_suspension", + data={"comment_id": report.reported_comment.short_id}, + ) + + def test_it_suspends_comment( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="comment_suspension", + data={"comment_id": report.reported_comment.short_id}, + ) + + assert ( + Comment.query.filter_by(id=report.reported_comment_id) + .first() + .suspended_at + == now + ) + + @pytest.mark.parametrize('input_reason', [{}, {"reason": "some reason"}]) + def test_it_creates_report_action_for_comment_suspension( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_reason: Dict, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + reason=input_reason.get("reason"), + action_type="comment_suspension", + data={"comment_id": report.reported_comment.short_id}, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "comment_suspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.created_at == now + assert report_action.comment_id == report.reported_comment_id + assert report_action.reason == input_reason.get("reason") + assert report_action.user_id == user_3.id + assert report_action.workout_id is None + + def test_it_unsuspends_comment( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report.reported_comment.suspended_at = datetime.utcnow() + db.session.flush() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="comment_unsuspension", + data={"comment_id": report.reported_comment.short_id}, + ) + + assert ( + Comment.query.filter_by(id=report.reported_comment_id) + .first() + .suspended_at + is None + ) + + def test_it_raises_error_when_comment_is_already_reactivated( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + db.session.flush() + + with pytest.raises( + InvalidReportActionException, + match=("comment already reactivated"), + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="comment_unsuspension", + data={"comment_id": report.reported_comment.short_id}, + ) + + def test_it_creates_report_action_for_comment_unsuspension( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + report.reported_comment.suspended_at = datetime.utcnow() + db.session.flush() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="comment_unsuspension", + data={"comment_id": report.reported_comment.short_id}, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "comment_unsuspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.created_at == now + assert report_action.comment_id == report.reported_comment_id + assert report_action.reason is None + assert report_action.user_id == user_3.id + assert report_action.workout_id is None + + def test_it_updates_existing_appeal_on_comment_unsuspension( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + reporter=user_2, + commenter=user_3, + workout=workout_cycling_user_2, + ) + comment_suspension = self.create_report_action( + user_1_admin, + user_3, + report.id, + action_type="comment_suspension", + comment_id=report.reported_comment_id, + ) + db.session.add(comment_suspension) + report.reported_comment.suspended_at = datetime.utcnow() + appeal = self.create_action_appeal(comment_suspension.id, user_3) + db.session.flush() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="comment_unsuspension", + data={"comment_id": report.reported_comment.short_id}, + ) + + assert appeal.updated_at == now + assert appeal.approved is None + + +class TestReportServiceCreateReportActionForWorkout( + ReportServiceCreateReportActionMixin, ReportMixin +): + def test_it_raises_exception_when_report_is_invalid( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_comment( + report_service, + commenter=user_3, + reporter=user_2, + workout=workout_cycling_user_2, + ) + + with pytest.raises( + InvalidReportActionException, match="'workout_id' is missing" + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="workout_suspension", + data={}, + ) + + def test_it_raises_exception_when_workout_id_does_not_match_reported_object_id( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + + with pytest.raises( + InvalidReportActionException, match="invalid 'workout_id'" + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="workout_suspension", + data={"workout_id": self.random_short_id()}, + ) + + def test_it_raises_error_when_workout_is_already_suspended( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + report.reported_workout.suspended_at = datetime.utcnow() + db.session.flush() + + with pytest.raises( + InvalidReportActionException, + match=("workout already suspended"), + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="workout_suspension", + data={"workout_id": report.reported_workout.short_id}, + ) + + def test_it_suspends_workout( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="workout_suspension", + data={"workout_id": report.reported_workout.short_id}, + ) + + assert ( + Workout.query.filter_by(id=report.reported_workout_id) + .first() + .suspended_at + == now + ) + + @pytest.mark.parametrize('input_reason', [{}, {"reason": "some reason"}]) + def test_it_creates_report_action_for_workout_suspension( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_reason: Dict, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + reason=input_reason.get("reason"), + action_type="workout_suspension", + data={"workout_id": report.reported_workout.short_id}, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "workout_suspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.created_at == now + assert report_action.comment_id is None + assert report_action.reason == input_reason.get("reason") + assert report_action.user_id == user_2.id + assert report_action.workout_id == report.reported_workout_id + + def test_it_unsuspends_workout( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + report.reported_workout.suspended_at = datetime.utcnow() + db.session.flush() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="workout_unsuspension", + data={"workout_id": report.reported_workout.short_id}, + ) + + assert ( + Workout.query.filter_by(id=report.reported_workout_id) + .first() + .suspended_at + is None + ) + + def test_it_raises_error_when_workout_is_already_reactivated( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + db.session.flush() + + with pytest.raises( + InvalidReportActionException, + match=("workout already reactivated"), + ): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="workout_unsuspension", + data={"workout_id": report.reported_workout.short_id}, + ) + + def test_it_creates_report_action_for_workout_unsuspension( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + report.reported_workout.suspended_at = datetime.utcnow() + db.session.flush() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="workout_unsuspension", + data={"workout_id": report.reported_workout.short_id}, + ) + + report_action = ReportAction.query.filter_by( + report_id=report.id + ).first() + assert report_action.action_type == "workout_unsuspension" + assert report_action.moderator_id == user_1_admin.id + assert report_action.created_at == now + assert report_action.comment_id is None + assert report_action.reason is None + assert report_action.user_id == user_2.id + assert report_action.workout_id is report.reported_workout_id + + def test_it_updates_existing_appeal_on_workout_unsuspension( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + report_service = ReportService() + report = self.create_report_for_workout( + report_service, + reporter=user_3, + workout=workout_cycling_user_2, + ) + workout_suspension = self.create_report_action( + user_1_admin, + user_2, + report.id, + action_type="workout_suspension", + workout_id=report.reported_workout_id, + ) + db.session.add(workout_suspension) + report.reported_workout.suspended_at = datetime.utcnow() + appeal = self.create_action_appeal(workout_suspension.id, user_2) + db.session.flush() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.create_report_action( + report=report, + moderator=user_1_admin, + action_type="workout_unsuspension", + data={"workout_id": report.reported_workout.short_id}, + ) + + assert appeal.updated_at == now + assert appeal.approved is None + + +class TestReportServiceProcessAppeal( + ReportServiceCreateReportActionMixin, ReportMixin +): + @pytest.mark.parametrize( + "input_data", + [ + {"approved": True, "reason": "ok"}, + {"approved": False, "reason": "not ok"}, + ], + ) + def test_it_processes_user_suspension_appeal( + self, app: Flask, user_1_admin: User, user_2: User, input_data: Dict + ) -> None: + suspension_action = self.create_report_user_action( + user_1_admin, user_2 + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + report_service = ReportService() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data=input_data, + ) + + updated_appeal = ReportActionAppeal.query.filter_by( + id=appeal.id + ).first() + assert updated_appeal.moderator_id == user_1_admin.id + assert updated_appeal.approved is input_data["approved"] + assert updated_appeal.reason == input_data["reason"] + assert updated_appeal.updated_at == now + + def test_it_unsuspends_user_when_appeal_is_approved( + self, + app: Flask, + user_1_admin: User, + user_2: User, + ) -> None: + suspension_action = self.create_report_user_action( + user_1_admin, user_2 + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + report_service = ReportService() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": True, "reason": "ok"}, + ) + + assert user_2.suspended_at is None + + def test_it_creates_unsuspended_user_action_when_appeal_is_approved( + self, + app: Flask, + user_1_admin: User, + user_2: User, + ) -> None: + suspension_action = self.create_report_user_action( + user_1_admin, user_2 + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + report_service = ReportService() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": True, "reason": "ok"}, + ) + + assert ( + ReportAction.query.filter_by( + report_id=suspension_action.report_id, + action_type="user_unsuspension", + moderator_id=user_1_admin.id, + user_id=user_2.id, + reason=None, + created_at=now, + ).first() + is not None + ) + + def test_it_raises_error_on_appeal_approval_when_user_is_already_reactivated( # noqa + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + suspension_action = self.create_report_user_action( + user_1_admin, user_2 + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + user_2.suspended_at = None + report_service = ReportService() + + with pytest.raises( + InvalidReportActionException, + match="user account has already been reactivated", + ): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": True, "reason": "ok"}, + ) + + def test_it_raises_error_on_appeal_reject_when_user_has_been_reactivated( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + suspension_action = self.create_report_user_action( + user_1_admin, user_2 + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + user_2.suspended_at = None + report_service = ReportService() + + with pytest.raises( + InvalidReportActionException, + match="user account has been reactivated after appeal", + ): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": False, "reason": "not ok"}, + ) + + @pytest.mark.parametrize( + "input_data", + [ + {"approved": True, "reason": "ok"}, + {"approved": False, "reason": "not ok"}, + ], + ) + def test_it_processes_user_warning_appeal( + self, app: Flask, user_1_admin: User, user_2: User, input_data: Dict + ) -> None: + warning_action = self.create_report_user_action( + user_1_admin, user_2, "user_warning" + ) + appeal = self.create_action_appeal(warning_action.id, user_2) + report_service = ReportService() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data=input_data, + ) + + updated_appeal = ReportActionAppeal.query.filter_by( + id=appeal.id + ).first() + assert updated_appeal.moderator_id == user_1_admin.id + assert updated_appeal.approved is input_data["approved"] + assert updated_appeal.reason == input_data["reason"] + assert updated_appeal.updated_at == now + + def test_it_creates_user_warning_lifting_action_when_appeal_is_approved( + self, + app: Flask, + user_1_admin: User, + user_2: User, + ) -> None: + warning_action = self.create_report_user_action( + user_1_admin, user_2, "user_warning" + ) + appeal = self.create_action_appeal(warning_action.id, user_2) + report_service = ReportService() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": True, "reason": "ok"}, + ) + + assert ( + ReportAction.query.filter_by( + report_id=warning_action.report_id, + action_type="user_warning_lifting", + moderator_id=user_1_admin.id, + user_id=user_2.id, + reason=None, + created_at=now, + ).first() + is not None + ) + + @pytest.mark.parametrize( + "input_data", + [ + {"approved": True, "reason": "ok"}, + {"approved": False, "reason": "not ok"}, + ], + ) + def test_it_processes_comment_suspension_appeal( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_data: Dict, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + suspension_action = self.create_report_comment_action( + user_1_admin, user_3, comment + ) + comment_suspended_at = comment.suspended_at + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_3) + db.session.commit() + report_service = ReportService() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data=input_data, + ) + + updated_appeal = ReportActionAppeal.query.filter_by( + id=appeal.id + ).first() + assert updated_appeal.moderator_id == user_1_admin.id + assert updated_appeal.approved is input_data["approved"] + assert updated_appeal.reason == input_data["reason"] + assert updated_appeal.updated_at == now + assert comment.suspended_at == ( + None if input_data["approved"] else comment_suspended_at + ) + + def test_it_creates_unsuspended_comment_action_when_appeal_is_approved( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + suspension_action = self.create_report_comment_action( + user_1_admin, user_3, comment + ) + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_3) + db.session.commit() + report_service = ReportService() + reason = self.random_string() + + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": True, "reason": reason}, + ) + + assert ( + ReportAction.query.filter_by( + moderator_id=user_1_admin.id, + action_type="comment_unsuspension", + comment_id=comment.id, + report_id=suspension_action.report_id, + user_id=user_3.id, + ).first() + is not None + ) + + def test_it_raises_error_on_appeal_approval_when_comment_is_already_reactivated( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + suspension_action = self.create_report_comment_action( + user_1_admin, user_3, comment + ) + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_3) + db.session.flush() + comment.suspended_at = None + db.session.commit() + report_service = ReportService() + + with pytest.raises( + InvalidReportActionException, + match="comment already reactivated", + ): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": True, "reason": "ok"}, + ) + + def test_it_raises_error_on_appeal_reject_when_comment_has_been_reactivated( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + suspension_action = self.create_report_comment_action( + user_1_admin, user_3, comment + ) + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_3) + db.session.flush() + comment.suspended_at = None + db.session.commit() + report_service = ReportService() + + with pytest.raises( + InvalidReportActionException, + match="comment has been reactivated after appeal", + ): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": False, "reason": "not ok"}, + ) + + @pytest.mark.parametrize( + "input_data", + [ + {"approved": True, "reason": "ok"}, + {"approved": False, "reason": "not ok"}, + ], + ) + def test_it_processes_workout_suspension_appeal( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_data: Dict, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + suspension_action = self.create_report_workout_action( + user_1_admin, user_2, workout_cycling_user_2 + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + workout_suspended_at = workout_cycling_user_2.suspended_at + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_2) + db.session.commit() + report_service = ReportService() + now = datetime.utcnow() + + with travel(now, tick=False): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data=input_data, + ) + + updated_appeal = ReportActionAppeal.query.filter_by( + id=appeal.id + ).first() + assert updated_appeal.moderator_id == user_1_admin.id + assert updated_appeal.approved is input_data["approved"] + assert updated_appeal.reason == input_data["reason"] + assert updated_appeal.updated_at == now + assert workout_cycling_user_2.suspended_at == ( + None if input_data["approved"] else workout_suspended_at + ) + + def test_it_creates_unsuspended_workout_action_when_appeal_is_approved( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + suspension_action = self.create_report_workout_action( + user_1_admin, user_2, workout_cycling_user_2 + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_2) + db.session.commit() + report_service = ReportService() + reason = self.random_string() + + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": True, "reason": reason}, + ) + + assert ( + ReportAction.query.filter_by( + action_type="workout_unsuspension", + moderator_id=user_1_admin.id, + reason=None, + report_id=suspension_action.report_id, + user_id=user_2.id, + workout_id=workout_cycling_user_2.id, + ).first() + is not None + ) + + def test_it_raises_error_on_appeal_approval_when_workout_is_already_reactivated( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + suspension_action = self.create_report_workout_action( + user_1_admin, user_2, workout_cycling_user_2 + ) + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_2) + db.session.commit() + report_service = ReportService() + + with pytest.raises( + InvalidReportActionException, + match="workout already reactivated", + ): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": True, "reason": "ok"}, + ) + + def test_it_raises_error_on_appeal_reject_when_workout_has_been_reactivated( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + suspension_action = self.create_report_workout_action( + user_1_admin, user_2, workout_cycling_user_2 + ) + db.session.flush() + appeal = self.create_action_appeal(suspension_action.id, user_2) + db.session.commit() + report_service = ReportService() + + with pytest.raises( + InvalidReportActionException, + match="workout has been reactivated after appeal", + ): + report_service.process_appeal( + appeal=appeal, + moderator=user_1_admin, + data={"approved": False, "reason": "not ok"}, + ) diff --git a/fittrackee/tests/test_utils.py b/fittrackee/tests/test_utils.py index 124a74ea5..2d24b1262 100644 --- a/fittrackee/tests/test_utils.py +++ b/fittrackee/tests/test_utils.py @@ -1,10 +1,17 @@ +from datetime import datetime from typing import Union import pytest +from flask import Flask from fittrackee.files import display_readable_file_size from fittrackee.request import UserAgent -from fittrackee.utils import get_readable_duration +from fittrackee.users.models import User +from fittrackee.utils import ( + clean_input, + get_date_string_for_user, + get_readable_duration, +) class TestDisplayReadableFileSize: @@ -67,3 +74,104 @@ def test_it_returns_operating_system(self) -> None: def test_it_returns_other_as_os_when_empty_string_provided(self) -> None: user_agent = UserAgent('') assert user_agent.platform == 'Other' + + +class TestSanitizeInput: + @pytest.mark.parametrize( + 'input_comment', + [ + 'just a text\nfor "test"', + 'link: http://www.example.com', + 'link: example', + '

    just a
    test

    ', + ], + ) + def test_clean_input_remains_unchanged( + self, app: Flask, input_comment: str + ) -> None: + assert clean_input(input_comment) == input_comment + + @pytest.mark.parametrize( + 'input_comment, expected_comment', + [ + ("", ""), + ("
    test
    ", "test"), + ("
    test", "test"), + ("just a
    test", "just a
    test"), + ("

    test", "

    test

    "), + ('

    test

    ', "

    test

    "), + ('

    test

    ', "

    test

    "), + ( + 'link: example', + 'link: example', + ), + ( + '', + 'example', + ), + ( + '@Sam nice!', + '@Sam nice!', + ), + ], + ) + def test_it_removes_disallowed_tags( + self, app: Flask, input_comment: str, expected_comment: str + ) -> None: + assert clean_input(input_comment) == expected_comment + + +class TestGetDateStringForUser: + @pytest.mark.parametrize( + 'language, date_format, timezone, expected_date_string', + [ + ('en', 'MM/dd/yyyy', 'America/New_York', '07/14/2024 - 07:32:47'), + ('fr', 'dd/MM/yyyy', 'Europe/Paris', '14/07/2024 - 13:32:47'), + (None, 'yyyy-MM-dd', 'Europe/Paris', '2024-07-14 - 13:32:47'), + ('en', 'MM/dd/yyyy', None, '07/14/2024 - 13:32:47'), + ('en', None, 'Europe/Paris', '07/14/2024 - 13:32:47'), + ( + 'en', + 'date_string', + 'America/New_York', + 'Jul. 14, 2024 - 07:32:47', + ), + ('cs', 'date_string', 'Europe/Paris', '14. čvc 2024 - 13:32:47'), + ('de', 'date_string', 'Europe/Paris', '14. Juli 2024 - 13:32:47'), + ('en', 'date_string', 'Europe/Paris', 'Jul. 14, 2024 - 13:32:47'), + ('es', 'date_string', 'Europe/Paris', '14 jul 2024 - 13:32:47'), + ('eu', 'date_string', 'Europe/Paris', '2024 uzt. 14 - 13:32:47'), + ('fr', 'date_string', 'Europe/Paris', '14 juil. 2024 - 13:32:47'), + ('gl', 'date_string', 'Europe/Paris', '14 xul. 2024 - 13:32:47'), + ('it', 'date_string', 'Europe/Paris', '14 lug 2024 - 13:32:47'), + ('nb', 'date_string', 'Europe/Paris', '14. juli 2024 - 13:32:47'), + ('nl', 'date_string', 'Europe/Paris', '14 jul. 2024 - 13:32:47'), + ('pl', 'date_string', 'Europe/Paris', '14 lip 2024 - 13:32:47'), + ('pt', 'date_string', 'Europe/Paris', '14 jul. 2024 - 13:32:47'), + (None, 'date_string', 'Europe/Paris', 'Jul. 14, 2024 - 13:32:47'), + ], + ) + def test_it_returns_date_string_with_user_preferences( + self, + app: Flask, + user_1: 'User', + language: Union[str, None], + date_format: str, + timezone: Union[str, None], + expected_date_string: str, + ) -> None: + naive_datetime = datetime( + year=2024, month=7, day=14, hour=11, minute=32, second=47 + ) + user_1.language = language + user_1.date_format = date_format + user_1.timezone = timezone + + date_string = get_date_string_for_user(naive_datetime, user_1) + + assert date_string == expected_date_string diff --git a/fittrackee/tests/test_visibility_levels.py b/fittrackee/tests/test_visibility_levels.py new file mode 100644 index 000000000..1632f9776 --- /dev/null +++ b/fittrackee/tests/test_visibility_levels.py @@ -0,0 +1,57 @@ +import pytest + +from fittrackee.visibility_levels import VisibilityLevel, get_map_visibility + + +class TestMapVisibility: + @pytest.mark.parametrize( + 'input_map_visibility', + [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_returns_map_visibility_when_workout_visibility_is_public( + self, + input_map_visibility: VisibilityLevel, + ) -> None: + assert ( + get_map_visibility(input_map_visibility, VisibilityLevel.PUBLIC) + == input_map_visibility + ) + + @pytest.mark.parametrize( + 'input_map_visibility, expected_map_visibility', + [ + (VisibilityLevel.PUBLIC, VisibilityLevel.FOLLOWERS), + (VisibilityLevel.FOLLOWERS, VisibilityLevel.FOLLOWERS), + (VisibilityLevel.PRIVATE, VisibilityLevel.PRIVATE), + ], + ) + def test_it_returns_map_visibility_when_workout_visibility_is_followers_only( # noqa + self, + input_map_visibility: VisibilityLevel, + expected_map_visibility: VisibilityLevel, + ) -> None: + assert ( + get_map_visibility(input_map_visibility, VisibilityLevel.FOLLOWERS) + == expected_map_visibility + ) + + @pytest.mark.parametrize( + 'input_map_visibility', + [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_it_returns_map_visibility_when_workout_visibility_is_private( + self, + input_map_visibility: VisibilityLevel, + ) -> None: + assert ( + get_map_visibility(input_map_visibility, VisibilityLevel.PRIVATE) + == VisibilityLevel.PRIVATE + ) diff --git a/fittrackee/tests/users/test_auth_api.py b/fittrackee/tests/users/test_auth_api.py index 100248bc9..69429e4d9 100644 --- a/fittrackee/tests/users/test_auth_api.py +++ b/fittrackee/tests/users/test_auth_api.py @@ -1,7 +1,7 @@ import json from datetime import datetime, timedelta from io import BytesIO -from typing import Optional, Union +from typing import Dict, Optional, Union from unittest.mock import MagicMock, Mock, patch import pytest @@ -11,17 +11,21 @@ from fittrackee import db from fittrackee.equipments.models import Equipment +from fittrackee.reports.models import ReportActionAppeal +from fittrackee.tests.comments.mixins import CommentMixin from fittrackee.users.models import ( BlacklistedToken, + Notification, User, UserDataExport, UserSportPreference, UserSportPreferenceEquipment, ) from fittrackee.users.utils.token import get_user_token -from fittrackee.workouts.models import Sport +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout -from ..mixins import ApiTestCaseMixin +from ..mixins import ApiTestCaseMixin, ReportMixin from ..utils import OAUTH_SCOPES, jsonify_dict USER_AGENT = ( @@ -163,6 +167,7 @@ def test_it_returns_error_if_user_already_exists_with_same_username( self, app: Flask, user_1: User, text_transformation: str ) -> None: client = app.test_client() + response = client.post( '/api/auth/register', data=json.dumps( @@ -332,8 +337,7 @@ def test_it_creates_user_with_inactive_account( 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) + with travel(accepted_policy_date, tick=False): client.post( '/api/auth/register', data=json.dumps( @@ -394,11 +398,11 @@ def test_it_calls_account_confirmation_email_when_payload_is_valid( }, { 'username': username, - 'fittrackee_url': 'http://0.0.0.0:5000', + 'fittrackee_url': app.config["UI_URL"], 'operating_system': 'Linux', 'browser_name': 'Firefox', 'account_confirmation_url': ( - 'http://0.0.0.0:5000/account-confirmation' + f'{app.config["UI_URL"]}/account-confirmation' f'?token={expected_token}' ), }, @@ -481,6 +485,44 @@ def test_it_does_not_call_account_confirmation_email_if_user_already_exists( # account_confirmation_email_mock.send.assert_not_called() + def test_it_creates_notifications_for_admins_on_registration( + self, + app: Flask, + user_1_admin: User, + user_2_admin: User, + user_3: User, + account_confirmation_email_mock: Mock, + ) -> None: + email = self.random_email() + client = app.test_client() + + client.post( + '/api/auth/register', + data=json.dumps( + dict( + username=self.random_string(), + email=email, + password=self.random_string(), + accepted_policy=True, + ) + ), + content_type='application/json', + ) + + new_user = User.query.filter_by(email=email).first() + notification = Notification.query.filter_by( + event_type='account_creation', event_object_id=new_user.id + ).all() + assert len(notification) == 2 + for notification in notification: + assert notification.created_at == new_user.created_at + assert notification.from_user_id == new_user.id + assert notification.event_object_id == new_user.id + assert notification.to_user_id in [ + user_1_admin.id, + user_2_admin.id, + ] + class TestUserLogin(ApiTestCaseMixin): def test_it_returns_error_if_payload_is_empty(self, app: Flask) -> None: @@ -621,7 +663,28 @@ def test_it_returns_user(self, app: Flask, user_1: User) -> None: assert response.status_code == 200 data = json.loads(response.data.decode()) assert data['status'] == 'success' - assert data['data'] == jsonify_dict(user_1.serialize(user_1)) + assert data['data'] == jsonify_dict( + user_1.serialize(current_user=user_1, light=False) + ) + + def test_it_returns_suspended_user( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + '/api/auth/profile', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['data'] == jsonify_dict( + suspended_user.serialize(current_user=suspended_user, light=False) + ) @pytest.mark.parametrize( 'client_scope, can_access', @@ -710,7 +773,44 @@ def test_it_updates_user_profile(self, app: Flask, user_1: User) -> None: data = json.loads(response.data.decode()) assert data['status'] == 'success' assert data['message'] == 'user profile updated' - assert data['data'] == jsonify_dict(user_1.serialize(user_1)) + assert data['data'] == jsonify_dict( + user_1.serialize(current_user=user_1, light=False) + ) + + def test_it_updates_suspended_user_profile( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + first_name = self.random_string() + last_name = self.random_string() + location = self.random_string() + bio = self.random_string() + birth_date = '1980-01-01' + + response = client.post( + '/api/auth/profile/edit', + content_type='application/json', + data=json.dumps( + dict( + first_name=first_name, + last_name=last_name, + location=location, + bio=bio, + birth_date=birth_date, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'user profile updated' + assert data['data'] == jsonify_dict( + suspended_user.serialize(current_user=suspended_user, light=False) + ) @pytest.mark.parametrize( 'client_scope, can_access', @@ -1042,7 +1142,7 @@ def test_it_calls_email_updated_to_current_email_send_when_new_email_provided( }, { 'username': user_1.username, - 'fittrackee_url': 'http://0.0.0.0:5000', + 'fittrackee_url': app.config["UI_URL"], 'operating_system': 'Linux', 'browser_name': 'Firefox', 'new_email_address': new_email, @@ -1084,11 +1184,12 @@ def test_it_calls_email_updated_to_new_email_send_when_new_email_provided( }, { 'username': user_1.username, - 'fittrackee_url': 'http://0.0.0.0:5000', + 'fittrackee_url': app.config["UI_URL"], 'operating_system': 'Linux', 'browser_name': 'Firefox', 'email_confirmation_url': ( - f'http://0.0.0.0:5000/email-update?token={expected_token}' + f'{app.config["UI_URL"]}/email-update' + f'?token={expected_token}' ), }, ) @@ -1182,6 +1283,38 @@ def test_it_updates_auth_user_password_when_new_password_provided( assert data['message'] == 'user account updated' assert current_hashed_password != user_1.password + def test_it_updates_password_when_user_is_suspended( + self, + app: Flask, + suspended_user: User, + email_updated_to_current_address_mock: MagicMock, + email_updated_to_new_address_mock: MagicMock, + password_change_email_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + current_hashed_password = suspended_user.password + + response = client.patch( + '/api/auth/profile/edit/account', + content_type='application/json', + data=json.dumps( + dict( + email=suspended_user.email, + password='12345678', + new_password=self.random_string(), + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'user account updated' + assert current_hashed_password != suspended_user.password + def test_new_password_is_hashed( self, app: Flask, @@ -1244,7 +1377,7 @@ def test_it_calls_password_change_email_when_new_password_provided( }, { 'username': user_1.username, - 'fittrackee_url': 'http://0.0.0.0:5000', + 'fittrackee_url': app.config["UI_URL"], 'operating_system': 'Linux', 'browser_name': 'Firefox', }, @@ -1465,6 +1598,10 @@ def test_it_updates_user_preferences( use_dark_mode=True, use_raw_gpx_speed=True, date_format='yyyy-MM-dd', + map_visibility='followers_only', + workouts_visibility='public', + manually_approves_followers=False, + hide_profile_in_users_directory=False, ) ), headers=dict(Authorization=f'Bearer {auth_token}'), @@ -1483,6 +1620,96 @@ def test_it_updates_user_preferences( assert data['data']['date_format'] == 'yyyy-MM-dd' assert data['data']['weekm'] is True assert data['data']['use_dark_mode'] is True + assert data['data']['manually_approves_followers'] is False + assert data['data']['hide_profile_in_users_directory'] is False + + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility', + [ + (VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE), + (VisibilityLevel.PUBLIC, VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_updates_user_preferences_with_valid_map_visibility( + self, + app: Flask, + user_1: User, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + '/api/auth/profile/edit/preferences', + content_type='application/json', + data=json.dumps( + dict( + timezone='America/New_York', + weekm=True, + language='fr', + imperial_units=True, + display_ascent=True, + date_format='MM/dd/yyyy', + map_visibility=input_map_visibility.value, + start_elevation_at_zero=False, + use_raw_gpx_speed=False, + workouts_visibility=input_workout_visibility.value, + manually_approves_followers=True, + hide_profile_in_users_directory=True, + use_dark_mode=None, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['data']['map_visibility'] == input_workout_visibility.value + assert ( + data['data']['workouts_visibility'] + == input_workout_visibility.value + ) + + def test_it_updates_user_preferences_when_user_is_suspended( + self, + app: Flask, + suspended_user: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + '/api/auth/profile/edit/preferences', + content_type='application/json', + data=json.dumps( + dict( + timezone='America/New_York', + weekm=True, + language='fr', + imperial_units=True, + display_ascent=True, + date_format='MM/dd/yyyy', + map_visibility=VisibilityLevel.PUBLIC.value, + start_elevation_at_zero=False, + use_raw_gpx_speed=False, + workouts_visibility=VisibilityLevel.PUBLIC.value, + manually_approves_followers=True, + hide_profile_in_users_directory=True, + use_dark_mode=None, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['data']['map_visibility'] == VisibilityLevel.PUBLIC.value + assert ( + data['data']['workouts_visibility'] == VisibilityLevel.PUBLIC.value + ) @pytest.mark.parametrize( 'client_scope, can_access', @@ -1599,6 +1826,30 @@ def test_it_returns_error_if_color_is_invalid( self.assert_400(response, 'invalid hexadecimal color') + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + suspended_user: User, + sport_2_running: Sport, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + '/api/auth/profile/edit/sports', + content_type='application/json', + data=json.dumps( + dict( + sport_id=sport_2_running.id, + color='#000000', + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + @pytest.mark.parametrize( 'input_color', ['#000000', '#FFF'], @@ -2000,6 +2251,23 @@ def test_it_resets_sport_preferences( is None ) + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + suspended_user: User, + sport_1_cycling: Sport, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.delete( + f'/api/auth/profile/reset/sports/{sport_1_cycling.id}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + def test_it_does_not_raise_error_if_sport_preferences_do_not_exist( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: @@ -2173,6 +2441,44 @@ def test_it_updates_user_picture(self, app: Flask, user_1: User) -> None: assert 'avatar.png' not in user_1.picture assert 'avatar2.png' in user_1.picture + def test_suspended_user_can_update_picture( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = 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}', + ), + ) + + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'user picture updated' + assert response.status_code == 200 + assert 'avatar.png' in suspended_user.picture + + response = client.post( + '/api/auth/picture', + data=dict(file=(BytesIO(b'avatar2'), 'avatar2.png')), + headers=dict( + content_type='multipart/form-data', + Authorization=f'Bearer {auth_token}', + ), + ) + + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'user picture updated' + assert response.status_code == 200 + assert 'avatar.png' not in suspended_user.picture + assert 'avatar2.png' in suspended_user.picture + @pytest.mark.parametrize( 'client_scope, can_access', {**OAUTH_SCOPES, 'profile:write': True}.items(), @@ -2202,37 +2508,97 @@ def test_expected_scopes_are_defined( self.assert_response_scope(response, can_access) -class TestRegistrationConfiguration(ApiTestCaseMixin): - def test_it_returns_error_if_it_exceeds_max_users( - self, - app_with_3_users_max: Flask, - user_1_admin: User, - user_2: User, - user_3: User, - ) -> None: - client = app_with_3_users_max.test_client() +class TestUserDeletePicture(ApiTestCaseMixin): + def test_user_can_delete_picture(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/register', - data=json.dumps( - dict( - username=self.random_string(), - email=self.random_email(), - password=self.random_string(), - ) + '/api/auth/picture', + data=dict(file=(BytesIO(b'avatar'), 'avatar.png')), + headers=dict( + content_type='multipart/form-data', + Authorization=f'Bearer {auth_token}', ), - content_type='application/json', ) - self.assert_403(response, 'error, registration is disabled') + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'user picture updated' + assert response.status_code == 200 + assert 'avatar.png' in user_1.picture - def test_it_disables_registration_on_user_registration( - self, - app_with_3_users_max: Flask, - user_1_admin: User, - user_2: User, - ) -> None: - client = app_with_3_users_max.test_client() + response = client.delete( + '/api/auth/picture', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert user_1.picture is None + + def test_suspended_user_can_delete_picture( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = 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}', + ), + ) + + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'user picture updated' + assert response.status_code == 200 + assert 'avatar.png' in suspended_user.picture + + response = client.delete( + '/api/auth/picture', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert suspended_user.picture is None + + +class TestRegistrationConfiguration(ApiTestCaseMixin): + def test_it_returns_error_if_it_exceeds_max_users( + self, + app_with_3_users_max: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + ) -> None: + client = app_with_3_users_max.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_403(response, 'error, registration is disabled') + + def test_it_disables_registration_on_user_registration( + self, + app_with_3_users_max: Flask, + user_1_admin: User, + user_2: User, + ) -> None: + client = app_with_3_users_max.test_client() client.post( '/api/auth/register', data=json.dumps( @@ -2350,6 +2716,22 @@ def test_it_requests_password_reset_when_user_exists( assert data['status'] == 'success' assert data['message'] == 'password reset request processed' + def test_it_requests_password_reset_when_user_is_suspended( + self, app: Flask, suspended_user: User, user_reset_password_email: Mock + ) -> None: + client = app.test_client() + + response = client.post( + '/api/auth/password/reset-request', + data=json.dumps(dict(email=suspended_user.email)), + content_type='application/json', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'password reset request processed' + def test_it_calls_reset_password_email_when_user_exists( self, app: Flask, user_1: User, reset_password_email: Mock ) -> None: @@ -2373,9 +2755,9 @@ def test_it_calls_reset_password_email_when_user_exists( 'expiration_delay': 'a minute', 'username': user_1.username, 'password_reset_url': ( - f'http://0.0.0.0:5000/password-reset?token={token}' + f'{app.config["UI_URL"]}/password-reset?token={token}' ), - 'fittrackee_url': 'http://0.0.0.0:5000', + 'fittrackee_url': app.config["UI_URL"], 'operating_system': 'Linux', 'browser_name': 'Firefox', }, @@ -2559,6 +2941,31 @@ def test_it_updates_password( assert data['status'] == 'success' assert data['message'] == 'password updated' + def test_it_updates_password_when_user_is_suspended( + self, + app: Flask, + suspended_user: User, + password_change_email_mock: MagicMock, + ) -> None: + token = get_user_token(suspended_user.id, password_reset=True) + client = app.test_client() + + response = client.post( + '/api/auth/password/update', + data=json.dumps( + dict( + token=token, + password=self.random_string(), + ) + ), + content_type='application/json', + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'password updated' + def test_it_sends_email_after_successful_update( self, app: Flask, @@ -2588,7 +2995,7 @@ def test_it_sends_email_after_successful_update( }, { 'username': user_1.username, - 'fittrackee_url': 'http://0.0.0.0:5000', + 'fittrackee_url': app.config["UI_URL"], 'operating_system': 'Linux', 'browser_name': 'Firefox', }, @@ -2839,11 +3246,11 @@ def test_it_calls_account_confirmation_email_if_user_is_inactive( }, { 'username': inactive_user.username, - 'fittrackee_url': 'http://0.0.0.0:5000', + 'fittrackee_url': app.config["UI_URL"], 'operating_system': 'Linux', 'browser_name': 'Firefox', 'account_confirmation_url': ( - 'http://0.0.0.0:5000/account-confirmation' + f'{app.config["UI_URL"]}/account-confirmation' f'?token={expected_token}' ), }, @@ -2882,7 +3289,7 @@ def test_it_returns_error_when_token_is_invalid(self, app: Flask) -> None: '/api/auth/logout', headers=dict(Authorization='Bearer invalid') ) - self.assert_invalid_token(response) + self.assert_401(response) def test_it_returns_error_when_token_is_expired( self, app: Flask, user_1: User @@ -2897,7 +3304,7 @@ def test_it_returns_error_when_token_is_expired( headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_invalid_token(response) + self.assert_401(response) def test_user_can_logout(self, app: Flask, user_1: User) -> None: client, auth_token = self.get_test_client_and_auth_token( @@ -2914,6 +3321,23 @@ def test_user_can_logout(self, app: Flask, user_1: User) -> None: assert data['message'] == 'successfully logged out' assert response.status_code == 200 + def test_suspended_user_can_logout( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + '/api/auth/logout', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert data['message'] == 'successfully logged out' + assert response.status_code == 200 + def test_token_is_blacklisted_on_logout( self, app: Flask, user_1: User ) -> None: @@ -2943,7 +3367,7 @@ def test_it_returns_error_if_token_is_already_blacklisted( headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_invalid_token(response) + self.assert_401(response) class TestUserPrivacyPolicyUpdate(ApiTestCaseMixin): @@ -2997,6 +3421,27 @@ def test_it_updates_accepted_policy( assert response.status_code == 200 assert user_1.accepted_policy_date == accepted_policy_date + def test_it_suspended_user_can_accept_policy( + self, + app: Flask, + suspended_user: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + accepted_policy_date = datetime.utcnow() + + with travel(accepted_policy_date, tick=False): + 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 suspended_user.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, @@ -3201,6 +3646,30 @@ def test_it_returns_new_request_if_previous_request_has_expired( export_request_id=data_export_request.id ) + def test_suspended_user_can_request_data_export( + self, + export_data_mock: Mock, + app: Flask, + suspended_user: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + '/api/auth/account/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=suspended_user.id + ).first() + assert data["status"] == "success" + assert data["request"] == jsonify_dict(data_export_request.serialize()) + class TestGetUserDataExportRequest(ApiTestCaseMixin): def test_it_returns_none_if_no_request( @@ -3281,6 +3750,35 @@ def test_it_returns_existing_request_for_authenticated_user( completed_export_request.serialize() ) + def test_suspended_user_can_get_data_export_info( + self, + app: Flask, + suspended_user: User, + ) -> None: + export_expiration = app.config["DATA_EXPORT_EXPIRATION"] + completed_export_request = UserDataExport( + user_id=suspended_user.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, suspended_user.email + ) + + response = client.get( + '/api/auth/account/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( @@ -3362,3 +3860,797 @@ def test_it_calls_send_from_directory_if_request_exist( mimetype='application/zip', as_attachment=True, ) + + def test_suspended_user_can_download_data_export( + self, + app: Flask, + suspended_user: User, + ) -> None: + archive_file_name = self.random_string() + export_request = UserDataExport(user_id=suspended_user.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, suspended_user.email + ) + with patch('fittrackee.users.auth.send_from_directory') as mock: + mock.return_value = 'file' + + client.get( + f'/api/auth/account/export/{archive_file_name}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + mock.assert_called_once_with( + f"{app.config['UPLOAD_FOLDER']}/exports/{suspended_user.id}", + archive_file_name, + mimetype='application/zip', + as_attachment=True, + ) + + +class TestGetBlockedUsers(ApiTestCaseMixin): + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.get( + "/api/auth/blocked-users", + content_type="application/json", + ) + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + "/api/auth/blocked-users", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_returns_empty_list_when_no_blocked_users( + 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/blocked-users", + 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["blocked_users"] == [] + assert data["pagination"] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 0, + 'total': 0, + } + + def test_it_returns_blocked_users( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + user_1.blocks_user(user_2) + user_3.blocks_user(user_1) + user_1.blocks_user(user_4) + + response = client.get( + "/api/auth/blocked-users", + 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["blocked_users"] == [ + jsonify_dict(user_4.serialize(current_user=user_1)), + jsonify_dict(user_2.serialize(current_user=user_1)), + ] + assert data["pagination"] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 2, + } + + @patch('fittrackee.users.auth.BLOCKED_USERS_PER_PAGE', 1) + def test_it_returns_first( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + user_1.blocks_user(user_2) + user_3.blocks_user(user_1) + user_1.blocks_user(user_4) + + response = client.get( + "/api/auth/blocked-users?page=1", + 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["blocked_users"] == [ + jsonify_dict(user_4.serialize(current_user=user_1)), + ] + assert data["pagination"] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 2, + } + + @patch('fittrackee.users.auth.BLOCKED_USERS_PER_PAGE', 1) + def test_it_returns_last_page( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + user_1.blocks_user(user_2) + user_3.blocks_user(user_1) + user_1.blocks_user(user_4) + + response = client.get( + "/api/auth/blocked-users?page=2", + 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["blocked_users"] == [ + jsonify_dict(user_2.serialize(current_user=user_1)), + ] + assert data["pagination"] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 2, + } + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'profile:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, app: Flask, user_1: User, client_scope: str, can_access: bool + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + "/api/auth/blocked-users", + content_type="application/json", + headers=dict(Authorization=f"Bearer {access_token}"), + ) + + self.assert_response_scope(response, can_access) + + +class UserSuspensionTestCase(ReportMixin, ApiTestCaseMixin): + pass + + +class TestGetUserSuspension(UserSuspensionTestCase): + route = "/api/auth/account/suspension" + + def test_it_returns_error_when_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.get( + self.route, + content_type="application/json", + ) + + self.assert_401(response) + + def test_it_returns_404_when_user_is_not_suspended( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + "user account is not suspended", + ) + + def test_it_returns_user_suspension( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + action = self.create_report_user_action(user_1_admin, user_2) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.get( + self.route, + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert response.json == { + "status": "success", + "user_suspension": jsonify_dict(action.serialize(user_2)), + } + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'profile:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, app: Flask, user_1: User, client_scope: str, can_access: bool + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + self.route.format(action_short_id=self.random_short_id()), + content_type='application/json', + headers=dict(Authorization=f"Bearer {access_token}"), + ) + + self.assert_response_scope(response, can_access) + + +class TestPostUserSuspensionAppeal(UserSuspensionTestCase): + route = "/api/auth/account/suspension/appeal" + + def test_it_returns_error_when_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.post( + self.route, + data=json.dumps(dict(text=self.random_string())), + content_type="application/json", + ) + + self.assert_401(response) + + def test_it_returns_404_when_when_user_is_not_suspended( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type='application/json', + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + "user account is not suspended", + ) + + @pytest.mark.parametrize( + 'input_data', [{}, {"text": ""}, {"comment": "some text"}] + ) + def test_it_returns_400_when_no_text_provided( + self, app: Flask, user_1_admin: User, user_2: User, input_data: Dict + ) -> None: + self.create_report_user_action(user_1_admin, user_2) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.post( + self.route, + content_type='application/json', + data=json.dumps(input_data), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, 'no text provided') + + def test_user_can_appeal_user_suspension( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + action = self.create_report_user_action(user_1_admin, user_2) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + text = self.random_string() + now = datetime.utcnow() + + with travel(now, tick=False): + response = client.post( + self.route, + content_type='application/json', + data=json.dumps(dict(text=text)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 201 + assert response.json == {"status": "success"} + appeal = ReportActionAppeal.query.filter_by( + action_id=action.id + ).first() + assert appeal.moderator_id is None + assert appeal.approved is None + assert appeal.created_at == now + assert appeal.user_id == user_2.id + assert appeal.updated_at is None + + def test_user_can_appeal_user_suspension_only_once( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + action = self.create_report_user_action(user_1_admin, user_2) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + appeal = ReportActionAppeal( + action_id=action.id, + user_id=user_2.id, + text=self.random_string(), + ) + db.session.add(appeal) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + text = self.random_string() + + response = client.post( + self.route, + content_type='application/json', + data=json.dumps(dict(text=text)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, error_message='you can appeal only once') + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'profile:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, app: Flask, user_1: User, client_scope: str, can_access: bool + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + self.route.format(action_short_id=self.random_short_id()), + content_type='application/json', + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f"Bearer {access_token}"), + ) + + self.assert_response_scope(response, can_access) + + +class TestGetUserSanction(UserSuspensionTestCase, CommentMixin): + route = "/api/auth/account/sanctions/{action_short_id}" + + def test_it_returns_error_when_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.get( + self.route.format(action_short_id=self.random_short_id()), + content_type="application/json", + ) + + self.assert_401(response) + + def test_it_returns_404_when_sanction_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( + self.route.format(action_short_id=self.random_short_id()), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + "no sanction found", + ) + + def test_it_returns_404_when_sanction_is_for_another_user( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + action = self.create_report_user_action( + user_1_admin, user_3, action_type="user_warning" + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.get( + self.route.format(action_short_id=action.short_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + "no sanction found", + ) + + def test_it_returns_user_warning( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + action = self.create_report_user_action( + user_1_admin, user_2, action_type="user_warning" + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.get( + self.route.format(action_short_id=action.short_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert response.json == { + "status": "success", + "sanction": jsonify_dict(action.serialize(user_2, full=True)), + } + + def test_it_returns_user_suspension( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + action = self.create_report_user_action( + user_1_admin, user_2, action_type="user_suspension" + ) + user_2.suspended_at = None + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.get( + self.route.format(action_short_id=action.short_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert response.json == { + "status": "success", + "sanction": jsonify_dict(action.serialize(user_2, full=True)), + } + + def test_it_returns_user_suspension_when_user_is_suspended( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + action = self.create_report_user_action( + user_1_admin, user_2, action_type="user_suspension" + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.get( + self.route.format(action_short_id=action.short_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert response.json == { + "status": "success", + "sanction": jsonify_dict(action.serialize(user_2, full=True)), + } + + def test_it_returns_workout_suspension( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + action = self.create_report_workout_action( + user_1_admin, user_2, workout_cycling_user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.get( + self.route.format(action_short_id=action.short_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert response.json == { + "status": "success", + "sanction": jsonify_dict(action.serialize(user_2, full=True)), + } + + def test_it_returns_comment_suspension( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + comment = self.create_comment(user_2, workout_cycling_user_2) + action = self.create_report_comment_action( + user_1_admin, user_2, comment + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.get( + self.route.format(action_short_id=action.short_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert response.json == { + "status": "success", + "sanction": jsonify_dict(action.serialize(user_2, full=True)), + } + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'profile:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, app: Flask, user_1: User, client_scope: str, can_access: bool + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + self.route.format(action_short_id=self.random_short_id()), + content_type='application/json', + headers=dict(Authorization=f"Bearer {access_token}"), + ) + + self.assert_response_scope(response, can_access) + + +class TestPostUserSanctionAppeal(UserSuspensionTestCase): + route = "/api/auth/account/sanctions/{action_short_id}/appeal" + + def test_it_returns_error_when_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.post( + self.route.format(action_short_id=self.random_short_id()), + data=json.dumps(dict(text=self.random_string())), + content_type="application/json", + ) + + self.assert_401(response) + + def test_it_returns_404_when_when_no_sanction( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(action_short_id=self.random_short_id()), + content_type='application/json', + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + "no sanction found", + ) + + @pytest.mark.parametrize( + 'input_data', [{}, {"text": ""}, {"comment": "some text"}] + ) + def test_it_returns_400_when_no_text_provided( + self, app: Flask, user_1_admin: User, user_2: User, input_data: Dict + ) -> None: + action = self.create_report_user_action( + user_1_admin, user_2, action_type="user_warning" + ) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.post( + self.route.format(action_short_id=action.short_id), + content_type='application/json', + data=json.dumps(input_data), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, 'no text provided') + + def test_user_can_appeal_sanction( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + action = self.create_report_user_action( + user_1_admin, user_2, action_type="user_warning" + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + text = self.random_string() + now = datetime.utcnow() + + with travel(now, tick=False): + response = client.post( + self.route.format(action_short_id=action.short_id), + content_type='application/json', + data=json.dumps(dict(text=text)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 201 + assert response.json == {"status": "success"} + appeal = ReportActionAppeal.query.filter_by( + action_id=action.id + ).first() + assert appeal.moderator_id is None + assert appeal.approved is None + assert appeal.created_at == now + assert appeal.user_id == user_2.id + assert appeal.updated_at is None + + def test_user_can_appeal_sanction_only_once( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + action = self.create_report_user_action( + user_1_admin, user_2, action_type="user_warning" + ) + appeal = ReportActionAppeal( + action_id=action.id, + user_id=user_2.id, + text=self.random_string(), + ) + db.session.add(appeal) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + text = self.random_string() + + response = client.post( + self.route.format(action_short_id=action.short_id), + content_type='application/json', + data=json.dumps(dict(text=text)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, error_message='you can appeal only once') + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'profile:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1_admin: User, + user_2: User, + client_scope: str, + can_access: bool, + ) -> None: + action = self.create_report_user_action( + user_1_admin, user_2, action_type="user_warning" + ) + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_2, scope=client_scope + ) + + response = client.post( + self.route.format(action_short_id=action.short_id), + content_type='application/json', + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f"Bearer {access_token}"), + ) + + self.assert_response_scope(response, can_access) diff --git a/fittrackee/tests/users/test_users_api.py b/fittrackee/tests/users/test_users_api.py index 0443c7538..bb71b4cec 100644 --- a/fittrackee/tests/users/test_users_api.py +++ b/fittrackee/tests/users/test_users_api.py @@ -1,6 +1,7 @@ import json from datetime import datetime, timedelta from io import BytesIO +from typing import Tuple from unittest.mock import MagicMock, patch import pytest @@ -9,111 +10,123 @@ from fittrackee import db from fittrackee.equipments.models import Equipment +from fittrackee.reports.models import Report, ReportAction +from fittrackee.tests.comments.mixins import CommentMixin from fittrackee.users.models import ( + FollowRequest, + Notification, User, UserDataExport, UserSportPreference, UserSportPreferenceEquipment, ) +from fittrackee.users.roles import UserRole from fittrackee.utils import get_readable_duration from fittrackee.workouts.models import Sport, Workout -from ..mixins import ApiTestCaseMixin +from ..mixins import ApiTestCaseMixin, ReportMixin from ..utils import OAUTH_SCOPES, jsonify_dict -class TestGetUser(ApiTestCaseMixin): - def test_it_returns_error_if_user_has_no_admin_rights( - self, app: Flask, user_1: User, user_2: User +class TestGetUserAsAdmin(ApiTestCaseMixin): + def test_it_returns_error_if_user_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/users/{user_2.username}', + '/api/users/not_existing', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_403(response) + self.assert_404_with_entity(response, 'user') - def test_user_can_access_his_profile( - self, app: Flask, user_1: User, user_2: User + def test_it_gets_single_user_without_workouts( + self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) response = client.get( - f'/api/users/{user_1.username}', + f'/api/users/{user_2.username}', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert data['status'] == 'success' assert len(data['data']['users']) == 1 - user = data['data']['users'][0] - assert user['username'] == user_1.username + assert data['data']['users'][0] == jsonify_dict( + user_2.serialize(current_user=user_1_admin, light=False) + ) - def test_it_gets_inactive_user( - self, app: Flask, user_1_admin: User, inactive_user: User + def test_it_gets_single_user_with_workouts( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - f'/api/users/{inactive_user.username}', + f'/api/users/{user_2.username}', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert data['status'] == 'success' assert len(data['data']['users']) == 1 - user = data['data']['users'][0] - assert user == jsonify_dict(inactive_user.serialize(user_1_admin)) + assert data['data']['users'][0] == jsonify_dict( + user_2.serialize(current_user=user_1_admin, light=False) + ) - def test_it_gets_single_user_without_workouts( - self, app: Flask, user_1_admin: User, user_2: User + def test_it_gets_authenticated_user( + self, + app: Flask, + user_1_admin: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - f'/api/users/{user_2.username}', + f'/api/users/{user_1_admin.username}', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert data['status'] == 'success' assert len(data['data']['users']) == 1 - user = data['data']['users'][0] - assert user == jsonify_dict(user_2.serialize(user_1_admin)) + assert data['data']['users'][0] == jsonify_dict( + user_1_admin.serialize(current_user=user_1_admin, light=False) + ) - def test_it_gets_single_user_with_workouts( - self, - app: Flask, - user_1: User, - user_2_admin: User, - sport_1_cycling: Sport, - sport_2_running: Sport, - workout_cycling_user_1: Workout, - workout_running_user_1: Workout, + def test_it_gets_inactive_user( + self, app: Flask, user_1_admin: User, inactive_user: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_2_admin.email + app, user_1_admin.email ) response = client.get( - f'/api/users/{user_1.username}', + f'/api/users/{inactive_user.username}', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -123,22 +136,32 @@ def test_it_gets_single_user_with_workouts( assert data['status'] == 'success' assert len(data['data']['users']) == 1 user = data['data']['users'][0] - assert user == jsonify_dict(user_1.serialize(user_2_admin)) + assert user == jsonify_dict( + inactive_user.serialize(current_user=user_1_admin, light=False) + ) - def test_it_returns_error_if_user_does_not_exist( - self, app: Flask, user_1_admin: User + def test_it_gets_hidden_user( + self, app: Flask, user_1_admin: User, user_2: User ) -> None: + user_2.hide_profile_in_users_directory = True client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users/not_existing', + f'/api/users/{user_2.username}', content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_404_with_entity(response, 'user') + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + user = data['data']['users'][0] + assert user == jsonify_dict( + user_2.serialize(current_user=user_1_admin, light=False) + ) @pytest.mark.parametrize( 'client_scope, can_access', @@ -169,329 +192,311 @@ def test_expected_scopes_are_defined( self.assert_response_scope(response, can_access) -class TestGetUsers(ApiTestCaseMixin): - def test_it_returns_error_if_user_has_no_admin_rights( - self, app: Flask, user_1: User, user_2: User +class TestGetUserAsUser(ApiTestCaseMixin): + def test_it_returns_error_if_user_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( - '/api/users', + '/api/users/not_existing', + content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_403(response) + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] - def test_it_get_users_list_regardless_their_account_status( - self, app: Flask, user_1_admin: User, inactive_user: User, user_3: User + def test_it_does_not_get_inactive_user( + self, app: Flask, user_1: User, inactive_user: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email + app, user_1.email ) response = client.get( - '/api/users', + f'/api/users/{inactive_user.username}', + content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == 404 data = json.loads(response.data.decode()) - assert response.status_code == 200 - assert 'success' in data['status'] - assert len(data['data']['users']) == 3 - assert data['data']['users'][0] == jsonify_dict( - user_1_admin.serialize(user_1_admin) - ) - assert data['data']['users'][1] == jsonify_dict( - inactive_user.serialize(user_1_admin) - ) - assert data['data']['users'][2] == jsonify_dict( - user_3.serialize(user_1_admin) - ) - assert data['pagination'] == { - 'has_next': False, - 'has_prev': False, - 'page': 1, - 'pages': 1, - 'total': 3, - } + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] - @patch('fittrackee.users.users.USER_PER_PAGE', 2) - def test_it_gets_first_page_on_users_list( - self, - app: Flask, - user_1_admin: User, - user_2: User, - user_3: User, + def test_it_gets_single_user_without_workouts( + self, app: Flask, user_1: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email + app, user_1.email ) response = client.get( - '/api/users?page=1', + f'/api/users/{user_2.username}', + content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 - assert 'success' in data['status'] - assert len(data['data']['users']) == 2 - assert data['pagination'] == { - 'has_next': True, - 'has_prev': False, - 'page': 1, - 'pages': 2, - 'total': 3, - } + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + assert data['data']['users'][0] == jsonify_dict( + user_2.serialize(current_user=user_1, light=False) + ) - @patch('fittrackee.users.users.USER_PER_PAGE', 2) - def test_it_gets_next_page_on_users_list( + def test_it_gets_single_user_with_workouts( self, app: Flask, - user_1_admin: User, + user_1: User, user_2: User, - user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email + app, user_1.email ) response = client.get( - '/api/users?page=2', + f'/api/users/{user_2.username}', + content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 - assert 'success' in data['status'] + data = json.loads(response.data.decode()) + assert data['status'] == 'success' assert len(data['data']['users']) == 1 - assert data['pagination'] == { - 'has_next': False, - 'has_prev': True, - 'page': 2, - 'pages': 2, - 'total': 3, - } + assert data['data']['users'][0] == jsonify_dict( + user_2.serialize(current_user=user_1, light=False) + ) - def test_it_gets_empty_next_page_on_users_list( + def test_it_gets_authenticated_user( self, app: Flask, - user_1_admin: User, - user_2: User, - user_3: User, + user_1: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email + app, user_1.email ) response = client.get( - '/api/users?page=2', + f'/api/users/{user_1.username}', + content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 - assert 'success' in data['status'] - assert len(data['data']['users']) == 0 - assert data['pagination'] == { - 'has_next': False, - 'has_prev': True, - 'page': 2, - 'pages': 1, - 'total': 3, - } + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + assert data['data']['users'][0] == jsonify_dict( + user_1.serialize(current_user=user_1, light=False) + ) - def test_it_gets_user_list_with_2_per_page( - self, - app: Flask, - user_1_admin: User, - user_2: User, - user_3: User, + def test_it_gets_hidden_user( + self, app: Flask, user_1: User, user_2: User ) -> None: + user_2.hide_profile_in_users_directory = True client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email + app, user_1.email ) response = client.get( - '/api/users?per_page=2', + f'/api/users/{user_2.username}', + content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) data = json.loads(response.data.decode()) assert response.status_code == 200 - assert 'success' in data['status'] - assert len(data['data']['users']) == 2 - assert data['pagination'] == { - 'has_next': True, - 'has_prev': False, - 'page': 1, - 'pages': 2, - 'total': 3, - } + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + user = data['data']['users'][0] + assert user == jsonify_dict( + user_2.serialize(current_user=user_1, light=False) + ) - def test_it_gets_next_page_on_user_list_with_2_per_page( - self, - app: Flask, - user_1_admin: User, - user_2: User, - user_3: User, + +class TestGetUserAsSuspendedUser(ApiTestCaseMixin): + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, user_1: User, suspended_user: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email + app, suspended_user.email ) response = client.get( - '/api/users?page=2&per_page=2', + f'/api/users/{user_1.username}', + content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) + self.assert_403(response) + + +class TestGetUserAsUnauthenticatedUser(ApiTestCaseMixin): + def test_it_returns_error_if_user_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.get( + '/api/users/not_existing', + content_type='application/json', + ) + + assert response.status_code == 404 data = json.loads(response.data.decode()) - assert response.status_code == 200 - assert 'success' in data['status'] - assert len(data['data']['users']) == 1 - assert data['pagination'] == { - 'has_next': False, - 'has_prev': True, - 'page': 2, - 'pages': 2, - 'total': 3, - } + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] - def test_it_gets_users_list_ordered_by_username( - self, app: Flask, user_1_admin: User, user_2: User, user_3: User + def test_it_does_not_get_inactive_user( + self, app: Flask, user_1_admin: User, inactive_user: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=username', - headers=dict(Authorization=f'Bearer {auth_token}'), + f'/api/users/{inactive_user.username}', + content_type='application/json', ) + assert response.status_code == 404 data = json.loads(response.data.decode()) - assert response.status_code == 200 - assert 'success' in data['status'] - assert len(data['data']['users']) == 3 - assert 'admin' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'toto' in data['data']['users'][2]['username'] - assert data['pagination'] == { - 'has_next': False, - 'has_prev': False, - 'page': 1, - 'pages': 1, - 'total': 3, - } + assert 'not found' in data['status'] + assert 'user does not exist' in data['message'] - def test_it_gets_users_list_ordered_by_username_ascending( - self, app: Flask, user_1_admin: User, user_2: User, user_3: User + def test_it_gets_single_user_without_workouts( + self, app: Flask, user_2: User ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email - ) + client = app.test_client() response = client.get( - '/api/users?order_by=username&order=asc', - headers=dict(Authorization=f'Bearer {auth_token}'), + f'/api/users/{user_2.username}', + content_type='application/json', ) data = json.loads(response.data.decode()) assert response.status_code == 200 - assert 'success' in data['status'] - assert len(data['data']['users']) == 3 - assert 'admin' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'toto' in data['data']['users'][2]['username'] - assert data['pagination'] == { - 'has_next': False, - 'has_prev': False, - 'page': 1, - 'pages': 1, - 'total': 3, - } + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + assert data['data']['users'][0] == jsonify_dict( + user_2.serialize(light=False) + ) - def test_it_gets_users_list_ordered_by_username_descending( - self, app: Flask, user_1_admin: User, user_2: User, user_3: User + def test_it_gets_single_user_with_workouts( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email + client = app.test_client() + + response = client.get( + f'/api/users/{user_1.username}', + content_type='application/json', + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + assert data['data']['users'][0] == jsonify_dict( + user_1.serialize(light=False) ) + def test_it_gets_hidden_user(self, app: Flask, user_1: User) -> None: + user_1.hide_profile_in_users_directory = True + client = app.test_client() + response = client.get( - '/api/users?order_by=username&order=desc', - headers=dict(Authorization=f'Bearer {auth_token}'), + f'/api/users/{user_1.username}', + content_type='application/json', ) data = json.loads(response.data.decode()) assert response.status_code == 200 - assert 'success' in data['status'] - assert len(data['data']['users']) == 3 - assert 'toto' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'admin' in data['data']['users'][2]['username'] - assert data['pagination'] == { - 'has_next': False, - 'has_prev': False, - 'page': 1, - 'pages': 1, - 'total': 3, - } + assert data['status'] == 'success' + assert len(data['data']['users']) == 1 + user = data['data']['users'][0] + assert user == jsonify_dict(user_1.serialize(light=False)) - def test_it_gets_users_list_ordered_by_creation_date( - self, app: Flask, user_2: User, user_3: User, user_1_admin: User + +class TestGetUsersAsAdmin(ApiTestCaseMixin): + def test_it_gets_users_list_without_inactive_hidden_and_suspended_users( + self, + app: Flask, + user_1_admin: User, + inactive_user: User, + user_2: User, + user_3: User, + user_4: User, ) -> None: - user_2.created_at = datetime.utcnow() - timedelta(days=1) - user_3.created_at = datetime.utcnow() - timedelta(hours=1) - user_1_admin.created_at = datetime.utcnow() + user_2.hide_profile_in_users_directory = True + user_4.suspended_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=created_at', + '/api/users', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 3 - assert 'toto' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'admin' in data['data']['users'][2]['username'] + assert len(data['data']['users']) == 2 + assert data['data']['users'][0] == jsonify_dict( + user_1_admin.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][1] == jsonify_dict( + user_3.serialize(current_user=user_1_admin) + ) assert data['pagination'] == { 'has_next': False, 'has_prev': False, 'page': 1, 'pages': 1, - 'total': 3, + 'total': 2, } - def test_it_gets_users_list_ordered_by_creation_date_ascending( - self, app: Flask, user_2: User, user_3: User, user_1_admin: User + def test_it_gets_users_list_regardless_their_account_status( + self, app: Flask, user_1_admin: User, inactive_user: User, user_3: User ) -> None: - user_2.created_at = datetime.utcnow() - timedelta(days=1) - user_3.created_at = datetime.utcnow() - timedelta(hours=1) - user_1_admin.created_at = datetime.utcnow() + inactive_user.hide_profile_in_users_directory = False client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=created_at&order=asc', + '/api/users?with_inactive=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'toto' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'admin' in data['data']['users'][2]['username'] + assert data['data']['users'][0] == jsonify_dict( + user_1_admin.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][1] == jsonify_dict( + inactive_user.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][2] == jsonify_dict( + user_3.serialize(current_user=user_1_admin) + ) assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -500,28 +505,32 @@ def test_it_gets_users_list_ordered_by_creation_date_ascending( 'total': 3, } - def test_it_gets_users_list_ordered_by_creation_date_descending( - self, app: Flask, user_2: User, user_3: User, user_1_admin: User + def test_it_gets_users_list_regardless_their_hidden_profile_preference( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: - user_2.created_at = datetime.utcnow() - timedelta(days=1) - user_3.created_at = datetime.utcnow() - timedelta(hours=1) - user_1_admin.created_at = datetime.utcnow() + user_2.hide_profile_in_users_directory = True client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=created_at&order=desc', + '/api/users?with_hidden=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'admin' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'toto' in data['data']['users'][2]['username'] + assert data['data']['users'][0] == jsonify_dict( + user_1_admin.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][1] == jsonify_dict( + user_3.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][2] == jsonify_dict( + user_2.serialize(current_user=user_1_admin) + ) assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -530,25 +539,32 @@ def test_it_gets_users_list_ordered_by_creation_date_descending( 'total': 3, } - def test_it_gets_users_list_ordered_by_admin_rights( - self, app: Flask, user_2: User, user_1_admin: User, user_3: User + def test_it_gets_users_list_regardless_suspended_status( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: + user_2.suspended_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=admin', + '/api/users?with_suspended=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'toto' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'admin' in data['data']['users'][2]['username'] + assert data['data']['users'][0] == jsonify_dict( + user_1_admin.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][1] == jsonify_dict( + user_3.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][2] == jsonify_dict( + user_2.serialize(current_user=user_1_admin) + ) assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -557,253 +573,245 @@ def test_it_gets_users_list_ordered_by_admin_rights( 'total': 3, } - def test_it_gets_users_list_ordered_by_admin_rights_ascending( - self, app: Flask, user_2: User, user_1_admin: User, user_3: User + def test_it_gets_following_user_when_profile_is_hidden( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, ) -> None: + user_2.hide_profile_in_users_directory = True + user_3.hide_profile_in_users_directory = True + user_1_admin.send_follow_request_to(user_2) + user_2.approves_follow_request_from(user_1_admin) + db.session.commit() client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=admin&order=asc', + '/api/users?with_following=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) data = json.loads(response.data.decode()) assert response.status_code == 200 - assert 'success' in data['status'] - assert len(data['data']['users']) == 3 - assert 'toto' in data['data']['users'][0]['username'] - assert 'sam' in data['data']['users'][1]['username'] - assert 'admin' in data['data']['users'][2]['username'] - assert data['pagination'] == { - 'has_next': False, - 'has_prev': False, - 'page': 1, - 'pages': 1, - 'total': 3, - } + assert data['status'] == 'success' + assert len(data['data']['users']) == 2 + assert data['data']['users'][0] == jsonify_dict( + user_1_admin.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][1] == jsonify_dict( + user_2.serialize(current_user=user_1_admin) + ) - def test_it_gets_users_list_ordered_by_admin_rights_descending( - self, app: Flask, user_2: User, user_3: User, user_1_admin: User + def test_it_gets_all_users( + self, + app: Flask, + user_1_admin: User, + inactive_user: User, + user_2: User, + user_3: User, ) -> None: + user_2.hide_profile_in_users_directory = True + user_3.suspended_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=admin&order=desc', + '/api/users?with_inactive=true' + '&with_hidden=true&with_suspended=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 3 - assert 'admin' in data['data']['users'][0]['username'] - assert 'toto' in data['data']['users'][1]['username'] - assert 'sam' in data['data']['users'][2]['username'] + assert len(data['data']['users']) == 4 + assert data['data']['users'][0] == jsonify_dict( + user_1_admin.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][1] == jsonify_dict( + inactive_user.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][2] == jsonify_dict( + user_3.serialize(current_user=user_1_admin) + ) + assert data['data']['users'][3] == jsonify_dict( + user_2.serialize(current_user=user_1_admin) + ) assert data['pagination'] == { 'has_next': False, 'has_prev': False, 'page': 1, 'pages': 1, - 'total': 3, + 'total': 4, } - def test_it_gets_users_list_ordered_by_workouts_count( + +class TestGetUsersPaginationAsAdmin(ApiTestCaseMixin): + @patch('fittrackee.users.users.USERS_PER_PAGE', 2) + def test_it_gets_first_page_on_users_list( self, app: Flask, user_1_admin: User, user_2: User, user_3: User, - sport_1_cycling: Sport, - workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=workouts_count', + '/api/users?page=1', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 3 - assert 'admin' in data['data']['users'][0]['username'] - assert 0 == data['data']['users'][0]['nb_workouts'] - assert 'sam' in data['data']['users'][1]['username'] - assert 0 == data['data']['users'][1]['nb_workouts'] - assert 'toto' in data['data']['users'][2]['username'] - assert 1 == data['data']['users'][2]['nb_workouts'] + assert len(data['data']['users']) == 2 assert data['pagination'] == { - 'has_next': False, + 'has_next': True, 'has_prev': False, 'page': 1, - 'pages': 1, + 'pages': 2, 'total': 3, } - def test_it_gets_users_list_ordered_by_workouts_count_ascending( + @patch('fittrackee.users.users.USERS_PER_PAGE', 2) + def test_it_gets_next_page_on_users_list( self, app: Flask, user_1_admin: User, user_2: User, user_3: User, - sport_1_cycling: Sport, - workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=workouts_count&order=asc', + '/api/users?page=2', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 3 - assert 'admin' in data['data']['users'][0]['username'] - assert 0 == data['data']['users'][0]['nb_workouts'] - assert 'sam' in data['data']['users'][1]['username'] - assert 0 == data['data']['users'][1]['nb_workouts'] - assert 'toto' in data['data']['users'][2]['username'] - assert 1 == data['data']['users'][2]['nb_workouts'] + assert len(data['data']['users']) == 1 assert data['pagination'] == { 'has_next': False, - 'has_prev': False, - 'page': 1, - 'pages': 1, + 'has_prev': True, + 'page': 2, + 'pages': 2, 'total': 3, } - def test_it_gets_users_list_ordered_by_account_status( + def test_it_gets_empty_next_page_on_users_list( self, app: Flask, user_1_admin: User, - inactive_user: User, + user_2: User, + user_3: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=is_active', + '/api/users?page=2', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 2 - assert data['data']['users'][0]['username'] == inactive_user.username - assert not data['data']['users'][0]['is_active'] - assert data['data']['users'][1]['username'] == user_1_admin.username - assert data['data']['users'][1]['is_active'] + assert len(data['data']['users']) == 0 assert data['pagination'] == { 'has_next': False, - 'has_prev': False, - 'page': 1, + 'has_prev': True, + 'page': 2, 'pages': 1, - 'total': 2, + 'total': 3, } - def test_it_gets_users_list_ordered_by_account_status_ascending( + def test_it_gets_user_list_with_2_per_page( self, app: Flask, user_1_admin: User, - inactive_user: User, + user_2: User, + user_3: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=is_active&order=asc', + '/api/users?per_page=2', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 2 - assert data['data']['users'][0]['username'] == inactive_user.username - assert not data['data']['users'][0]['is_active'] - assert data['data']['users'][1]['username'] == user_1_admin.username - assert data['data']['users'][1]['is_active'] assert data['pagination'] == { - 'has_next': False, + 'has_next': True, 'has_prev': False, 'page': 1, - 'pages': 1, - 'total': 2, + 'pages': 2, + 'total': 3, } - def test_it_gets_users_list_ordered_by_account_status_descending( + def test_it_gets_next_page_on_user_list_with_2_per_page( self, app: Flask, user_1_admin: User, - inactive_user: User, + user_2: User, + user_3: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=is_active&order=desc', + '/api/users?page=2&per_page=2', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 2 - assert data['data']['users'][0]['username'] == user_1_admin.username - assert data['data']['users'][0]['is_active'] - assert data['data']['users'][1]['username'] == inactive_user.username - assert not data['data']['users'][1]['is_active'] + assert len(data['data']['users']) == 1 assert data['pagination'] == { 'has_next': False, - 'has_prev': False, - 'page': 1, - 'pages': 1, - 'total': 2, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 3, } - def test_it_gets_users_list_ordered_by_workouts_count_descending( - self, - app: Flask, - user_1_admin: User, - user_2: User, - user_3: User, - sport_1_cycling: Sport, - workout_cycling_user_2: Workout, + def test_it_gets_users_list_ordered_by_username( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=workouts_count&order=desc', + '/api/users?order_by=username', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 3 - assert 'toto' in data['data']['users'][0]['username'] - assert 1 == data['data']['users'][0]['nb_workouts'] - assert 'admin' in data['data']['users'][1]['username'] - assert 0 == data['data']['users'][1]['nb_workouts'] - assert 'sam' in data['data']['users'][2]['username'] - assert 0 == data['data']['users'][2]['nb_workouts'] + assert 'admin' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'toto' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -812,7 +820,7 @@ def test_it_gets_users_list_ordered_by_workouts_count_descending( 'total': 3, } - def test_it_gets_users_list_filtering_on_username( + def test_it_gets_users_list_ordered_by_username_ascending( self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( @@ -820,24 +828,26 @@ def test_it_gets_users_list_filtering_on_username( ) response = client.get( - '/api/users?q=toto', + '/api/users?order_by=username&order=asc', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 1 - assert 'toto' in data['data']['users'][0]['username'] + assert len(data['data']['users']) == 3 + assert 'admin' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'toto' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, 'has_prev': False, 'page': 1, 'pages': 1, - 'total': 1, + 'total': 3, } - def test_it_returns_username_matching_query( + def test_it_gets_users_list_ordered_by_username_descending( self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( @@ -845,574 +855,667 @@ def test_it_returns_username_matching_query( ) response = client.get( - '/api/users?q=oto', + '/api/users?order_by=username&order=desc', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 1 + assert len(data['data']['users']) == 3 assert 'toto' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'admin' in data['data']['users'][2]['username'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } - def test_it_filtering_on_username_is_case_insensitive( - self, app: Flask, user_1_admin: User, user_2: User, user_3: User + def test_it_gets_users_list_ordered_by_creation_date( + self, app: Flask, user_2: User, user_3: User, user_1_admin: User ) -> None: + user_2.created_at = datetime.utcnow() - timedelta(days=1) + user_3.created_at = datetime.utcnow() - timedelta(hours=1) + user_1_admin.created_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?q=TOTO', + '/api/users?order_by=created_at', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 1 + assert len(data['data']['users']) == 3 assert 'toto' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'admin' in data['data']['users'][2]['username'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } - def test_it_returns_empty_users_list_filtering_on_username( - self, app: Flask, user_1_admin: User, user_2: User, user_3: User + def test_it_gets_users_list_ordered_by_creation_date_ascending( + self, app: Flask, user_2: User, user_3: User, user_1_admin: User ) -> None: + user_2.created_at = datetime.utcnow() - timedelta(days=1) + user_3.created_at = datetime.utcnow() - timedelta(hours=1) + user_1_admin.created_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?q=not_existing', + '/api/users?order_by=created_at&order=asc', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 0 + assert len(data['data']['users']) == 3 + assert 'toto' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'admin' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, 'has_prev': False, 'page': 1, - 'pages': 0, - 'total': 0, + 'pages': 1, + 'total': 3, } - def test_it_users_list_with_complex_query( - self, app: Flask, user_1_admin: User, user_2: User, user_3: User + def test_it_gets_users_list_ordered_by_creation_date_descending( + self, app: Flask, user_2: User, user_3: User, user_1_admin: User ) -> None: + user_2.created_at = datetime.utcnow() - timedelta(days=1) + user_3.created_at = datetime.utcnow() - timedelta(hours=1) + user_1_admin.created_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) response = client.get( - '/api/users?order_by=username&order=desc&page=2&per_page=2', + '/api/users?order_by=created_at&order=desc', headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 200 + data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 1 + assert len(data['data']['users']) == 3 assert 'admin' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'toto' in data['data']['users'][2]['username'] assert data['pagination'] == { 'has_next': False, - 'has_prev': True, - 'page': 2, - 'pages': 2, + 'has_prev': False, + 'page': 1, + 'pages': 1, 'total': 3, } - @pytest.mark.parametrize( - 'client_scope, can_access', - {**OAUTH_SCOPES, 'users:read': True}.items(), - ) - def test_expected_scopes_are_defined( - self, - app: Flask, - user_1_admin: User, - client_scope: str, - can_access: bool, + def test_it_gets_users_list_ordered_by_role( + self, app: Flask, user_2: User, user_1_admin: User, user_3: User ) -> None: - ( - client, - oauth_client, - access_token, - _, - ) = self.create_oauth2_client_and_issue_token( - app, user_1_admin, scope=client_scope + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email ) response = client.get( - '/api/users', - content_type='application/json', - headers=dict(Authorization=f'Bearer {access_token}'), + '/api/users?order_by=role', + headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_response_scope(response, can_access) - - -class TestGetUserPicture(ApiTestCaseMixin): - def test_it_return_error_if_user_has_no_picture( - self, app: Flask, user_1: User - ) -> None: - client = app.test_client() - - response = client.get(f'/api/users/{user_1.username}/picture') - - self.assert_404_with_message(response, 'No picture.') - - def test_it_returns_error_if_user_does_not_exist( - self, app: Flask, user_1: User - ) -> None: - client = app.test_client() - - response = client.get('/api/users/not_existing/picture') - - self.assert_404_with_entity(response, 'user') - + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 3 + assert 'sam' in data['data']['users'][0]['username'] + assert 'toto' in data['data']['users'][1]['username'] + assert 'admin' in data['data']['users'][2]['username'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } -class TestUpdateUser(ApiTestCaseMixin): - def test_it_returns_error_if_payload_is_empty( - self, app: Flask, user_1_admin: User, user_2: User + def test_it_gets_users_list_ordered_by_role_ascending( + self, app: Flask, user_2: User, user_1_admin: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict()), + response = client.get( + '/api/users?order_by=role&order=asc', headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_400(response) + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 3 + assert 'sam' in data['data']['users'][0]['username'] + assert 'toto' in data['data']['users'][1]['username'] + assert 'admin' in data['data']['users'][2]['username'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } - def test_it_returns_error_if_payload_for_admin_rights_is_invalid( - self, app: Flask, user_1_admin: User, user_2: User + def test_it_gets_users_list_ordered_by_role_descending( + self, app: Flask, user_2: User, user_3: User, user_1_admin: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(admin="")), + response = client.get( + '/api/users?order_by=role&order=desc', headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 500 + assert response.status_code == 200 data = json.loads(response.data.decode()) - assert 'error' in data['status'] - assert ( - 'error, please try again or contact the administrator' - in data['message'] - ) - - def test_it_returns_error_if_user_can_not_change_admin_rights( - 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.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(admin=True)), - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - self.assert_403(response) + assert 'success' in data['status'] + assert len(data['data']['users']) == 3 + assert 'admin' in data['data']['users'][0]['username'] + assert 'sam' in data['data']['users'][1]['username'] + assert 'toto' in data['data']['users'][2]['username'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } - def test_it_adds_admin_rights_to_a_user( - self, app: Flask, user_1_admin: User, user_2: User + def test_it_gets_users_list_ordered_by_workouts_count( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(admin=True)), + response = client.get( + '/api/users?order_by=workouts_count', headers=dict(Authorization=f'Bearer {auth_token}'), ) assert response.status_code == 200 data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 1 - user = data['data']['users'][0] - assert user['email'] == 'toto@toto.com' - assert user['admin'] is True + assert len(data['data']['users']) == 3 + assert 'admin' in data['data']['users'][0]['username'] + assert 0 == data['data']['users'][0]['nb_workouts'] + assert 'sam' in data['data']['users'][1]['username'] + assert 0 == data['data']['users'][1]['nb_workouts'] + assert 'toto' in data['data']['users'][2]['username'] + assert 1 == data['data']['users'][2]['nb_workouts'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } - def test_it_removes_admin_rights_to_a_user( - self, app: Flask, user_1_admin: User, user_2: User + def test_it_gets_users_list_ordered_by_workouts_count_ascending( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(admin=False)), + response = client.get( + '/api/users?order_by=workouts_count&order=asc', headers=dict(Authorization=f'Bearer {auth_token}'), ) assert response.status_code == 200 data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 1 - - user = data['data']['users'][0] - assert user['email'] == 'toto@toto.com' - assert user['admin'] is False + assert len(data['data']['users']) == 3 + assert 'admin' in data['data']['users'][0]['username'] + assert 0 == data['data']['users'][0]['nb_workouts'] + assert 'sam' in data['data']['users'][1]['username'] + assert 0 == data['data']['users'][1]['nb_workouts'] + assert 'toto' in data['data']['users'][2]['username'] + assert 1 == data['data']['users'][2]['nb_workouts'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } - def test_it_does_not_send_email_when_only_admin_rights_update( + def test_it_gets_users_list_ordered_by_account_status( self, app: Flask, user_1_admin: User, - user_2: User, - user_password_change_email_mock: MagicMock, - user_reset_password_email: MagicMock, + inactive_user: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(admin=True)), + response = client.get( + '/api/users?order_by=is_active&' + 'with_inactive=true&with_hidden=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) + data = json.loads(response.data.decode()) assert response.status_code == 200 - user_password_change_email_mock.send.assert_not_called() - user_reset_password_email.send.assert_not_called() + assert 'success' in data['status'] + assert len(data['data']['users']) == 2 + assert data['data']['users'][0]['username'] == inactive_user.username + assert not data['data']['users'][0]['is_active'] + assert data['data']['users'][1]['username'] == user_1_admin.username + assert data['data']['users'][1]['is_active'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 2, + } - def test_it_resets_user_password( - self, app: Flask, user_1_admin: User, user_2: User + def test_it_gets_users_list_ordered_by_account_status_ascending( + self, + app: Flask, + user_1_admin: User, + inactive_user: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - user_2_password = user_2.password - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(reset_password=True)), + response = client.get( + '/api/users?order_by=is_active&order=asc&' + 'with_inactive=true&with_hidden=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) + data = json.loads(response.data.decode()) assert response.status_code == 200 - assert user_2.password != user_2_password + assert 'success' in data['status'] + assert len(data['data']['users']) == 2 + assert data['data']['users'][0]['username'] == inactive_user.username + assert not data['data']['users'][0]['is_active'] + assert data['data']['users'][1]['username'] == user_1_admin.username + assert data['data']['users'][1]['is_active'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 2, + } - def test_it_calls_password_change_email_when_password_reset_is_successful( + def test_it_gets_users_list_ordered_by_account_status_descending( self, app: Flask, user_1_admin: User, - user_2: User, - user_password_change_email_mock: MagicMock, + inactive_user: User, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(reset_password=True)), + response = client.get( + '/api/users?order_by=is_active&order=desc&' + 'with_inactive=true&with_hidden=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) + data = json.loads(response.data.decode()) assert response.status_code == 200 - user_password_change_email_mock.send.assert_called_once_with( - { - 'language': 'en', - 'email': user_2.email, - }, - { - 'username': user_2.username, - 'fittrackee_url': 'http://0.0.0.0:5000', - }, - ) + assert 'success' in data['status'] + assert len(data['data']['users']) == 2 + assert data['data']['users'][0]['username'] == user_1_admin.username + assert data['data']['users'][0]['is_active'] + assert data['data']['users'][1]['username'] == inactive_user.username + assert not data['data']['users'][1]['is_active'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 2, + } - def test_it_does_not_call_password_change_email_when_email_sending_is_disabled( # noqa + def test_it_gets_users_list_ordered_by_suspension_date_with_default_order( self, - app_wo_email_activation: Flask, + app: Flask, user_1_admin: User, user_2: User, - user_password_change_email_mock: MagicMock, + user_3: User, ) -> None: + # default order is ascending + user_2.suspended_at = datetime.utcnow() - timedelta(days=2) + user_3.suspended_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( - app_wo_email_activation, user_1_admin.email + app, user_1_admin.email ) - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(reset_password=True)), + response = client.get( + '/api/users?order_by=suspended_at&with_suspended=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) + data = json.loads(response.data.decode()) assert response.status_code == 200 - user_password_change_email_mock.send.assert_not_called() + assert 'success' in data['status'] + assert len(data['data']['users']) == 3 + assert data['data']['users'][0]['username'] == user_2.username + assert data['data']['users'][0]['suspended_at'] is not None + assert data['data']['users'][1]['username'] == user_3.username + assert data['data']['users'][1]['suspended_at'] is not None + assert data['data']['users'][2]['username'] == user_1_admin.username + assert data['data']['users'][2]['suspended_at'] is None + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } - def test_it_calls_reset_password_email_when_password_reset_is_successful( + def test_it_gets_users_list_ordered_by_suspension_date_ascending( self, app: Flask, user_1_admin: User, user_2: User, - user_reset_password_email: MagicMock, + user_3: User, ) -> None: + user_2.suspended_at = datetime.utcnow() - timedelta(days=2) + user_3.suspended_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - with patch( - 'fittrackee.users.users.User.encode_password_reset_token', - return_value='xxx', - ): - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(reset_password=True)), - headers=dict(Authorization=f'Bearer {auth_token}'), - ) + response = client.get( + '/api/users?order_by=suspended_at&order=asc&' + 'with_suspended=true', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + data = json.loads(response.data.decode()) assert response.status_code == 200 - user_reset_password_email.send.assert_called_once_with( - { - 'language': 'en', - 'email': user_2.email, - }, - { - 'expiration_delay': get_readable_duration( - app.config['PASSWORD_TOKEN_EXPIRATION_SECONDS'], - 'en', - ), - 'username': user_2.username, - 'password_reset_url': ( - 'http://0.0.0.0:5000/password-reset?token=xxx' - ), - 'fittrackee_url': 'http://0.0.0.0:5000', - }, - ) + assert 'success' in data['status'] + assert len(data['data']['users']) == 3 + assert data['data']['users'][0]['username'] == user_2.username + assert data['data']['users'][0]['suspended_at'] is not None + assert data['data']['users'][1]['username'] == user_3.username + assert data['data']['users'][1]['suspended_at'] is not None + assert data['data']['users'][2]['username'] == user_1_admin.username + assert data['data']['users'][2]['suspended_at'] is None + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } - def test_it_does_not_call_reset_password_email_when_email_sending_is_disabled( # noqa + def test_it_gets_users_list_ordered_by_suspension_date_descending( self, - app_wo_email_activation: Flask, + app: Flask, user_1_admin: User, user_2: User, - user_reset_password_email: MagicMock, + user_3: User, ) -> None: + user_2.suspended_at = datetime.utcnow() - timedelta(days=2) + user_3.suspended_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( - app_wo_email_activation, user_1_admin.email + app, user_1_admin.email ) - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(reset_password=True)), + response = client.get( + '/api/users?order_by=suspended_at&order=desc&with_suspended=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) + data = json.loads(response.data.decode()) assert response.status_code == 200 - user_reset_password_email.send.assert_not_called() - - def test_it_returns_error_when_updating_email_with_invalid_address( - self, app: Flask, user_1_admin: User, user_2: User - ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email - ) - - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(new_email=self.random_string())), - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - self.assert_400(response, 'valid email must be provided') + assert 'success' in data['status'] + assert len(data['data']['users']) == 3 + assert data['data']['users'][0]['username'] == user_3.username + assert data['data']['users'][0]['suspended_at'] is not None + assert data['data']['users'][1]['username'] == user_2.username + assert data['data']['users'][1]['suspended_at'] is not None + assert data['data']['users'][2]['username'] == user_1_admin.username + assert data['data']['users'][2]['suspended_at'] is None + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } - def test_it_returns_error_when_new_email_is_same_as_current_email( - self, app: Flask, user_1_admin: User, user_2: User + def test_it_gets_users_list_ordered_by_workouts_count_descending( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(new_email=user_2.email)), + response = client.get( + '/api/users?order_by=workouts_count&order=desc', headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_400( - response, 'new email must be different than current email' - ) + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 3 + assert 'toto' in data['data']['users'][0]['username'] + assert 1 == data['data']['users'][0]['nb_workouts'] + assert 'admin' in data['data']['users'][1]['username'] + assert 0 == data['data']['users'][1]['nb_workouts'] + assert 'sam' in data['data']['users'][2]['username'] + assert 0 == data['data']['users'][2]['nb_workouts'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } - def test_it_does_not_send_email_when_error_on_updating_email( - self, - app: Flask, - user_1_admin: User, - user_2: User, - user_email_updated_to_new_address_mock: MagicMock, + def test_it_gets_users_list_filtering_on_username( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(new_email=self.random_string())), + response = client.get( + f'/api/users?q={user_2.username}', headers=dict(Authorization=f'Bearer {auth_token}'), ) - user_email_updated_to_new_address_mock.send.assert_not_called() + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 1 + assert user_2.username in data['data']['users'][0]['username'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 1, + } - def test_it_updates_user_email_to_confirm_when_email_sending_is_enabled( - self, app: Flask, user_1_admin: User, user_2: User + def test_it_returns_username_matching_query( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - new_email = 'new.' + user_2.email - user_2_email = user_2.email - user_2_confirmation_token = user_2.confirmation_token - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(new_email=new_email)), + response = client.get( + f'/api/users?q={user_2.username[1:]}', headers=dict(Authorization=f'Bearer {auth_token}'), ) + data = json.loads(response.data.decode()) assert response.status_code == 200 - assert user_2.email == user_2_email - assert user_2.email_to_confirm == new_email - assert user_2.confirmation_token != user_2_confirmation_token + assert 'success' in data['status'] + assert len(data['data']['users']) == 1 + assert user_2.username in data['data']['users'][0]['username'] - def test_it_updates_user_email_when_email_sending_is_disabled( - self, app_wo_email_activation: Flask, user_1_admin: User, user_2: User + def test_it_filtering_on_username_is_case_insensitive( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_wo_email_activation, user_1_admin.email + app, user_1_admin.email ) - new_email = 'new.' + user_2.email - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(new_email=new_email)), + response = client.get( + f'/api/users?q={user_2.username.upper()}', headers=dict(Authorization=f'Bearer {auth_token}'), ) + data = json.loads(response.data.decode()) assert response.status_code == 200 - assert user_2.email == new_email - assert user_2.email_to_confirm is None - assert user_2.confirmation_token is None + assert 'success' in data['status'] + assert len(data['data']['users']) == 1 + assert user_2.username in data['data']['users'][0]['username'] - def test_it_calls_email_updated_to_new_address_when_password_reset_is_successful( # noqa - self, - app: Flask, - user_1_admin: User, - user_2: User, - user_email_updated_to_new_address_mock: MagicMock, + def test_it_does_not_return_inactive_user_by_default( + self, app: Flask, user_1_admin: User, user_2: User, inactive_user: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - new_email = 'new.' + user_2.email - expected_token = self.random_string() - with patch('secrets.token_urlsafe', return_value=expected_token): - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(new_email=new_email)), - headers=dict(Authorization=f'Bearer {auth_token}'), - ) + response = client.get( + f'/api/users?q={inactive_user.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) assert response.status_code == 200 - user_email_updated_to_new_address_mock.send.assert_called_once_with( - { - 'language': 'en', - 'email': new_email, - }, - { - 'username': user_2.username, - 'fittrackee_url': 'http://0.0.0.0:5000', - 'email_confirmation_url': ( - f'http://0.0.0.0:5000/email-update?token={expected_token}' - ), - }, - ) + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 0 + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 0, + 'total': 0, + } - def test_it_does_not_call_email_updated_to_new_address_when_email_sending_is_disabled( # noqa - self, - app_wo_email_activation: Flask, - user_1_admin: User, - user_2: User, - user_email_updated_to_new_address_mock: MagicMock, + def test_it_returns_inactive_user( + self, app: Flask, user_1_admin: User, user_2: User, inactive_user: User ) -> None: + inactive_user.hide_profile_in_users_directory = False client, auth_token = self.get_test_client_and_auth_token( - app_wo_email_activation, user_1_admin.email + app, user_1_admin.email ) - new_email = 'new.' + user_2.email - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(new_email=new_email)), + response = client.get( + f'/api/users?q={inactive_user.username}&with_inactive=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) assert response.status_code == 200 - user_email_updated_to_new_address_mock.send.assert_not_called() + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 1 + assert 'inactive' in data['data']['users'][0]['username'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 1, + } - def test_it_activates_user_account( - self, app: Flask, user_1_admin: User, inactive_user: User + @pytest.mark.parametrize( + 'input_desc, input_username', + [ + ('not existing user', 'not_existing'), + ('user account format', '@sam@example.com'), + ], + ) + def test_it_returns_empty_users_list_filtering_on_username( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + input_desc: str, + input_username: str, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - response = client.patch( - f'/api/users/{inactive_user.username}', - content_type='application/json', - data=json.dumps(dict(activate=True)), + response = client.get( + f'/api/users?q={input_username}', headers=dict(Authorization=f'Bearer {auth_token}'), ) assert response.status_code == 200 data = json.loads(response.data.decode()) assert 'success' in data['status'] - assert len(data['data']['users']) == 1 - user = data['data']['users'][0] - assert user['email'] == inactive_user.email - assert user['is_active'] is True - assert inactive_user.confirmation_token is None + assert len(data['data']['users']) == 0 + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 0, + 'total': 0, + } - def test_it_can_only_activate_user_account( - self, app: Flask, user_1_admin: User, user_2: User + def test_it_returns_users_list_with_complex_query( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - response = client.patch( - f'/api/users/{user_2.username}', - content_type='application/json', - data=json.dumps(dict(activate=False)), + response = client.get( + '/api/users?order_by=username&order=desc&page=2&per_page=2', headers=dict(Authorization=f'Bearer {auth_token}'), ) @@ -1420,20 +1523,23 @@ def test_it_can_only_activate_user_account( data = json.loads(response.data.decode()) assert 'success' in data['status'] assert len(data['data']['users']) == 1 - user = data['data']['users'][0] - assert user['email'] == user_2.email - assert user['is_active'] is True - assert user_2.confirmation_token is None + assert 'admin' in data['data']['users'][0]['username'] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 3, + } @pytest.mark.parametrize( 'client_scope, can_access', - {**OAUTH_SCOPES, 'users:write': True}.items(), + {**OAUTH_SCOPES, 'users:read': True}.items(), ) def test_expected_scopes_are_defined( self, app: Flask, user_1_admin: User, - user_2: User, client_scope: str, can_access: bool, ) -> None: @@ -1446,8 +1552,8 @@ def test_expected_scopes_are_defined( app, user_1_admin, scope=client_scope ) - response = client.patch( - f'/api/users/{user_2.username}', + response = client.get( + '/api/users', content_type='application/json', headers=dict(Authorization=f'Bearer {access_token}'), ) @@ -1455,330 +1561,1923 @@ def test_expected_scopes_are_defined( self.assert_response_scope(response, can_access) -class TestDeleteUser(ApiTestCaseMixin): - def test_user_can_delete_its_own_account( - self, app: Flask, user_1: User +class TestGetUsersAsModerator(ApiTestCaseMixin): + @pytest.mark.parametrize( + 'input_description, input_params', + [ + ("without params", ""), + ("with inactive users", "?with_inactive=true"), + ("with hidden users", "?with_hidden=true"), + ("with suspended users", "?with_suspended=true"), + ( + "all params", + "?with_hidden=true&with_inactive=true&with_suspended=true", + ), + ], + ) + def test_it_gets_users_list_without_inactive_hidden_or_suspended_users( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + inactive_user: User, + user_3: User, + user_4: User, + input_description: str, + input_params: str, ) -> None: + user_2.hide_profile_in_users_directory = True + user_4.suspended_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_moderator.email ) - response = client.delete( - f'/api/users/{user_1.username}', + response = client.get( + f'/api/users{input_params}', headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 204 - assert User.query.first() is None - - def test_user_with_workout_can_delete_its_own_account( - self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str - ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - client.post( - '/api/workouts', - data=dict( - file=(BytesIO(str.encode(gpx_file)), 'example.gpx'), - data='{"sport_id": 1}', - ), - headers=dict( - content_type='multipart/form-data', - Authorization=f'Bearer {auth_token}', - ), + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 2 + assert data['data']['users'][0] == jsonify_dict( + user_1_moderator.serialize(current_user=user_1_moderator) ) - - response = client.delete( - f'/api/users/{user_1.username}', - headers=dict(Authorization=f'Bearer {auth_token}'), + assert data['data']['users'][1] == jsonify_dict( + user_3.serialize(current_user=user_1_moderator) ) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 2, + } - assert response.status_code == 204 - assert User.query.first() is None - - def test_user_with_equipment_can_delete_its_own_account( + def test_it_gets_following_user_when_profile_is_hidden( self, app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_w_shoes_equipment: Workout, + user_1_moderator: User, + user_2: User, + user_3: User, ) -> None: + user_2.hide_profile_in_users_directory = True + user_3.hide_profile_in_users_directory = True + user_1_moderator.send_follow_request_to(user_2) + user_2.approves_follow_request_from(user_1_moderator) + db.session.commit() client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_moderator.email ) - response = client.delete( - f'/api/users/{user_1.username}', + response = client.get( + '/api/users?with_following=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 204 - assert User.query.first() is None + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert data['status'] == 'success' + assert len(data['data']['users']) == 2 + assert data['data']['users'][0] == jsonify_dict( + user_1_moderator.serialize(current_user=user_1_moderator) + ) + assert data['data']['users'][1] == jsonify_dict( + user_2.serialize(current_user=user_1_moderator) + ) - def test_user_with_preferences_can_delete_its_own_account( + +class TestGetUsersAsUser(ApiTestCaseMixin): + @pytest.mark.parametrize( + 'input_description, input_params', + [ + ("without params", ""), + ("with inactive users", "?with_inactive=true"), + ("with hidden users", "?with_hidden=true"), + ("with suspended users", "?with_suspended=true"), + ( + "all params", + "?with_hidden=true&with_inactive=true&with_suspended=true", + ), + ], + ) + def test_it_gets_users_list_without_inactive_hidden_or_suspended_users( self, app: Flask, user_1: User, - sport_1_cycling: Sport, - user_1_sport_1_preference: UserSportPreference, + user_2: User, + inactive_user: User, + user_3: User, + user_4: User, + input_description: str, + input_params: str, ) -> None: + user_2.hide_profile_in_users_directory = True + user_4.suspended_at = datetime.utcnow() client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) - response = client.delete( - f'/api/users/{user_1.username}', + response = client.get( + f'/api/users{input_params}', headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 204 - assert User.query.first() is None + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 2 + assert data['data']['users'][0] == jsonify_dict( + user_3.serialize(current_user=user_1) + ) + assert data['data']['users'][1] == jsonify_dict( + user_1.serialize(current_user=user_1) + ) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 2, + } - def test_user_with_default_equipment_can_delete_its_own_account( + def test_it_gets_following_user_when_profile_is_hidden( self, app: Flask, user_1: User, - sport_1_cycling: Sport, - user_1_sport_1_preference: UserSportPreference, - equipment_bike_user_1: Equipment, + user_2: User, + user_3: User, ) -> None: - db.session.execute( - insert(UserSportPreferenceEquipment).values( - [ - { - "equipment_id": equipment_bike_user_1.id, - "sport_id": user_1_sport_1_preference.sport_id, - "user_id": user_1_sport_1_preference.user_id, - } - ] - ) - ) + user_2.hide_profile_in_users_directory = True + user_3.hide_profile_in_users_directory = True + user_1.send_follow_request_to(user_2) + user_2.approves_follow_request_from(user_1) db.session.commit() client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) - response = client.delete( - f'/api/users/{user_1.username}', + response = client.get( + '/api/users?with_following=true', headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 204 - assert User.query.first() is None + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert data['status'] == 'success' + assert len(data['data']['users']) == 2 + assert data['data']['users'][0] == jsonify_dict( + user_1.serialize(current_user=user_1) + ) + assert data['data']['users'][1] == jsonify_dict( + user_2.serialize(current_user=user_1) + ) - def test_user_with_picture_can_delete_its_own_account( - self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str + def test_it_gets_users_list_with_workouts( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + sport_2_running: Sport, + workout_running_user_1: Workout, + workout_cycling_user_2: Workout, ) -> None: 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( - f'/api/users/{user_1.username}', + response = client.get( + '/api/users', headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 204 - assert User.query.first() is None + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 3 + assert data['data']['users'][0] == jsonify_dict( + user_3.serialize(current_user=user_1) + ) - def test_user_with_export_request_can_delete_its_own_account( - self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str + assert data['data']['users'][1] == jsonify_dict( + user_1.serialize(current_user=user_1) + ) + assert data['data']['users'][2] == jsonify_dict( + user_2.serialize(current_user=user_1) + ) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 3, + } + + +class TestGetUsersAsSuspendedUser(ApiTestCaseMixin): + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, suspended_user: User ) -> 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}', - ), + app, suspended_user.email ) - response = client.delete( - f'/api/users/{user_1.username}', + response = client.get( + '/api/users', + content_type='application/json', headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 204 - assert User.query.first() is None + self.assert_403(response) - def test_user_can_not_delete_another_user_account( + +class TestGetUsersAsUnauthenticatedUser(ApiTestCaseMixin): + def test_it_returns_error_if_user_is_not_authenticated( self, app: Flask, user_1: User, user_2: User + ) -> None: + client = app.test_client() + + response = client.get( + '/api/users', + content_type='application/json', + ) + + self.assert_401(response) + + +class TestGetUserPicture(ApiTestCaseMixin): + def test_it_return_error_if_user_has_no_picture( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.get(f'/api/users/{user_1.username}/picture') + + self.assert_404_with_message(response, 'No picture.') + + def test_it_returns_error_if_user_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.get('/api/users/not_existing/picture') + + self.assert_404_with_entity(response, 'user') + + +class TestUpdateUser(ReportMixin, ApiTestCaseMixin): + def test_it_returns_error_if_auth_user_has_no_admin_rights( + self, app: Flask, user_1: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) - response = client.delete( - f'/api/users/{user_2.username}', + response = client.patch( + f'/api/users/{user_1.username}', + content_type='application/json', + data=json.dumps(dict(role="admin")), headers=dict(Authorization=f'Bearer {auth_token}'), ) self.assert_403(response) - def test_it_returns_error_when_deleting_non_existing_user( - self, app: Flask, user_1: User + def test_it_returns_error_if_payload_is_empty( + self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email + app, user_1_admin.email ) - response = client.delete( - '/api/users/not_existing', + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict()), headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_404_with_entity(response, 'user') + self.assert_400(response) - def test_admin_can_delete_another_user_account( + def test_it_returns_error_if_payload_for_role_is_invalid( self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - deleted_user_id = user_2.id - response = client.delete( + response = client.patch( f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(role="")), headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 204 - assert User.query.filter_by(id=deleted_user_id).first() is None + self.assert_400(response, "invalid role") - def test_admin_can_delete_its_own_account( - self, app: Flask, user_1_admin: User, user_2_admin: User + @pytest.mark.parametrize('input_role', ["admin", "moderator"]) + def test_it_updates_user_role( + self, app: Flask, user_1_admin: User, user_2: User, input_role: str ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - deleted_user_id = user_1_admin.id - response = client.delete( - f'/api/users/{user_1_admin.username}', + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(role=input_role)), headers=dict(Authorization=f'Bearer {auth_token}'), ) - assert response.status_code == 204 - assert User.query.filter_by(id=deleted_user_id).first() is None + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 1 + user = data['data']['users'][0] + assert user['email'] == user_2.email + assert user['role'] == UserRole[input_role.upper()].name.lower() - def test_admin_can_not_delete_its_own_account_if_no_other_admin( + def test_it_returns_error_when_setting_owner_role_to_user( self, app: Flask, user_1_admin: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1_admin.email ) - response = client.delete( - f'/api/users/{user_1_admin.username}', + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(role="owner")), headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_403( - response, - 'you can not delete your account, no other user has admin rights', + self.assert_400( + response, "'owner' can not be set via API, please user CLI instead" ) - def test_it_enables_registration_after_user_delete_when_users_count_is_below_limit( # noqa + @pytest.mark.parametrize('input_role', ["admin", "user"]) + def test_it_updates_role_for_moderator( self, - app_with_3_users_max: Flask, + app: Flask, + user_1_admin: User, + user_2_moderator: User, + input_role: str, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + f'/api/users/{user_2_moderator.username}', + content_type='application/json', + data=json.dumps(dict(role=input_role)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 1 + user = data['data']['users'][0] + assert user['email'] == user_2_moderator.email + assert user['role'] == UserRole[input_role.upper()].name.lower() + + def test_it_returns_error_when_setting_owner_role_to_moderator( + self, app: Flask, user_1_admin: User, user_2_moderator: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + f'/api/users/{user_2_moderator.username}', + content_type='application/json', + data=json.dumps(dict(role="owner")), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400( + response, "'owner' can not be set via API, please user CLI instead" + ) + + @pytest.mark.parametrize('input_role', ["moderator", "user"]) + def test_it_updates_role_for_admin_user( + self, + app: Flask, + user_1_admin: User, + user_2_admin: User, + input_role: str, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + f'/api/users/{user_2_admin.username}', + content_type='application/json', + data=json.dumps(dict(role=input_role)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 1 + user = data['data']['users'][0] + assert user['email'] == user_2_admin.email + assert user['role'] == UserRole[input_role.upper()].name.lower() + + def test_it_returns_error_when_setting_owner_role_to_admin( + self, app: Flask, user_1_admin: User, user_2_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + f'/api/users/{user_2_admin.username}', + content_type='application/json', + data=json.dumps(dict(role="owner")), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400( + response, "'owner' can not be set via API, please user CLI instead" + ) + + def test_it_returns_error_when_modifying_owner_role( + self, app: Flask, user_1_owner: User, user_2_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_2_admin.email + ) + + response = client.patch( + f'/api/users/{user_1_owner.username}', + content_type='application/json', + data=json.dumps(dict(role="admin")), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, "user with owner rights can not be modified") + + def test_it_does_not_send_email_when_only_role_update( + self, + app: Flask, user_1_admin: User, user_2: User, - user_3: User, + user_password_change_email_mock: MagicMock, + user_reset_password_email: MagicMock, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_3_users_max, user_1_admin.email + app, user_1_admin.email ) - client.delete( + + response = client.patch( f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(role="moderator")), headers=dict(Authorization=f'Bearer {auth_token}'), ) - response = client.post( - '/api/auth/register', - data=json.dumps( - dict( - username=self.random_string(), - email=self.random_email(), - password=self.random_string(), - accepted_policy=True, - ) - ), + assert response.status_code == 200 + user_password_change_email_mock.send.assert_not_called() + user_reset_password_email.send.assert_not_called() + + def test_it_resets_user_password( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + user_2_password = user_2.password + + response = client.patch( + f'/api/users/{user_2.username}', content_type='application/json', + data=json.dumps(dict(reset_password=True)), + headers=dict(Authorization=f'Bearer {auth_token}'), ) assert response.status_code == 200 + assert user_2.password != user_2_password - def test_it_does_not_enable_registration_on_user_delete_when_users_count_is_not_below_limit( # noqa + def test_it_calls_password_change_email_when_password_reset_is_successful( self, - app_with_3_users_max: Flask, + app: Flask, user_1_admin: User, user_2: User, - user_3: User, - user_1_paris: User, + user_password_change_email_mock: MagicMock, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app_with_3_users_max, user_1_admin.email + app, user_1_admin.email ) - client.delete( + response = client.patch( f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(reset_password=True)), headers=dict(Authorization=f'Bearer {auth_token}'), ) - response = client.post( - '/api/auth/register', - data=json.dumps( - dict( - username='justatest', - email='test@test.com', - password='12345678', - password_conf='12345678', - accepted_policy=True, - ) - ), + + assert response.status_code == 200 + user_password_change_email_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'username': user_2.username, + 'fittrackee_url': app.config["UI_URL"], + }, + ) + + def test_it_does_not_call_password_change_email_when_email_sending_is_disabled( # noqa + self, + app_wo_email_activation: Flask, + user_1_admin: User, + user_2: User, + user_password_change_email_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_wo_email_activation, user_1_admin.email + ) + + response = client.patch( + f'/api/users/{user_2.username}', content_type='application/json', + data=json.dumps(dict(reset_password=True)), + headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_403(response, 'error, registration is disabled') + assert response.status_code == 200 + user_password_change_email_mock.send.assert_not_called() - @pytest.mark.parametrize( - 'client_scope, can_access', - {**OAUTH_SCOPES, 'users:write': True}.items(), - ) - def test_expected_scopes_are_defined( + def test_it_calls_reset_password_email_when_password_reset_is_successful( self, app: Flask, user_1_admin: User, user_2: User, - client_scope: str, - can_access: bool, + user_reset_password_email: MagicMock, ) -> None: - ( - client, - oauth_client, - access_token, - _, - ) = self.create_oauth2_client_and_issue_token( - app, user_1_admin, scope=client_scope + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email ) - response = client.delete( + with patch( + 'fittrackee.users.users.User.encode_password_reset_token', + return_value='xxx', + ): + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(reset_password=True)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + user_reset_password_email.send.assert_called_once_with( + { + 'language': 'en', + 'email': user_2.email, + }, + { + 'expiration_delay': get_readable_duration( + app.config['PASSWORD_TOKEN_EXPIRATION_SECONDS'], + 'en', + ), + 'username': user_2.username, + 'password_reset_url': ( + f'{app.config["UI_URL"]}/password-reset?token=xxx' + ), + 'fittrackee_url': app.config["UI_URL"], + }, + ) + + def test_it_does_not_call_reset_password_email_when_email_sending_is_disabled( # noqa + self, + app_wo_email_activation: Flask, + user_1_admin: User, + user_2: User, + user_reset_password_email: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_wo_email_activation, user_1_admin.email + ) + + response = client.patch( f'/api/users/{user_2.username}', content_type='application/json', - headers=dict(Authorization=f'Bearer {access_token}'), + data=json.dumps(dict(reset_password=True)), + headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_response_scope(response, can_access) + assert response.status_code == 200 + user_reset_password_email.send.assert_not_called() + + def test_it_returns_error_when_updating_email_with_invalid_address( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(new_email=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, 'valid email must be provided') + + def test_it_returns_error_when_new_email_is_same_as_current_email( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(new_email=user_2.email)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400( + response, 'new email must be different than current email' + ) + + def test_it_does_not_send_email_when_error_on_updating_email( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_email_updated_to_new_address_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(new_email=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + user_email_updated_to_new_address_mock.send.assert_not_called() + + def test_it_updates_user_email_to_confirm_when_email_sending_is_enabled( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + new_email = 'new.' + user_2.email + user_2_email = user_2.email + user_2_confirmation_token = user_2.confirmation_token + + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(new_email=new_email)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert user_2.email == user_2_email + assert user_2.email_to_confirm == new_email + assert user_2.confirmation_token != user_2_confirmation_token + + def test_it_updates_user_email_when_email_sending_is_disabled( + self, app_wo_email_activation: Flask, user_1_admin: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_wo_email_activation, user_1_admin.email + ) + new_email = 'new.' + user_2.email + + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(new_email=new_email)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + assert user_2.email == new_email + assert user_2.email_to_confirm is None + assert user_2.confirmation_token is None + + def test_it_calls_email_updated_to_new_address_when_password_reset_is_successful( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_email_updated_to_new_address_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + new_email = 'new.' + user_2.email + expected_token = self.random_string() + + with patch('secrets.token_urlsafe', return_value=expected_token): + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(new_email=new_email)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + user_email_updated_to_new_address_mock.send.assert_called_once_with( + { + 'language': 'en', + 'email': new_email, + }, + { + 'username': user_2.username, + 'fittrackee_url': app.config["UI_URL"], + 'email_confirmation_url': ( + f'{app.config["UI_URL"]}/email-update' + f'?token={expected_token}' + ), + }, + ) + + def test_it_does_not_call_email_updated_to_new_address_when_email_sending_is_disabled( # noqa + self, + app_wo_email_activation: Flask, + user_1_admin: User, + user_2: User, + user_email_updated_to_new_address_mock: MagicMock, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_wo_email_activation, user_1_admin.email + ) + new_email = 'new.' + user_2.email + + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(new_email=new_email)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + user_email_updated_to_new_address_mock.send.assert_not_called() + + def test_it_activates_user_account( + self, app: Flask, user_1_admin: User, inactive_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + f'/api/users/{inactive_user.username}', + content_type='application/json', + data=json.dumps(dict(activate=True)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 1 + user = data['data']['users'][0] + assert user['email'] == inactive_user.email + assert user['is_active'] is True + assert inactive_user.confirmation_token is None + + def test_it_deactivates_user_account( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + data=json.dumps(dict(activate=False)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['users']) == 1 + user = data['data']['users'][0] + assert user['email'] == user_2.email + assert user['is_active'] is False + + def test_a_user_can_not_deactivate_his_own_user_account( + self, app: Flask, user_1_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.patch( + f'/api/users/{user_1_admin.username}', + content_type='application/json', + data=json.dumps(dict(activate=False)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'users:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1_admin: User, + user_2: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1_admin, scope=client_scope + ) + + response = client.patch( + f'/api/users/{user_2.username}', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestDeleteUser(ReportMixin, ApiTestCaseMixin): + def test_user_can_delete_its_own_account( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f'/api/users/{user_1.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert User.query.first() is None + + def test_suspended_user_can_delete_its_own_account( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.delete( + f'/api/users/{suspended_user.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + + def test_user_with_workout_can_delete_its_own_account( + self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + client.post( + '/api/workouts', + data=dict( + file=(BytesIO(str.encode(gpx_file)), 'example.gpx'), + data='{"sport_id": 1}', + ), + headers=dict( + content_type='multipart/form-data', + Authorization=f'Bearer {auth_token}', + ), + ) + + response = client.delete( + f'/api/users/{user_1.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert User.query.first() is None + + def test_user_with_equipment_can_delete_its_own_account( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_w_shoes_equipment: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f'/api/users/{user_1.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert User.query.first() is None + + def test_user_with_preferences_can_delete_its_own_account( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + user_1_sport_1_preference: UserSportPreference, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f'/api/users/{user_1.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert User.query.first() is None + + def test_user_with_default_equipment_can_delete_its_own_account( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + user_1_sport_1_preference: UserSportPreference, + equipment_bike_user_1: Equipment, + ) -> None: + db.session.execute( + insert(UserSportPreferenceEquipment).values( + [ + { + "equipment_id": equipment_bike_user_1.id, + "sport_id": user_1_sport_1_preference.sport_id, + "user_id": user_1_sport_1_preference.user_id, + } + ] + ) + ) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f'/api/users/{user_1.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert User.query.first() is None + + def test_user_with_picture_can_delete_its_own_account( + self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str + ) -> None: + 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( + f'/api/users/{user_1.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert User.query.first() is None + + 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_id=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( + f'/api/users/{user_1.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert User.query.first() is None + + def test_user_with_notifications_can_delete_its_own_account( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + 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( + f'/api/users/{user_1.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert ( + FollowRequest.query.filter_by(followed_user_id=user_1.id).first() + is None + ) + assert Notification.query.first() is None + + def test_user_with_reports_can_delete_its_own_account( + self, + app: Flask, + user_1: User, + user_2_admin: User, + user_3: User, + user_4: User, + ) -> None: + report_from_user_3 = self.create_report( + reporter=user_3, reported_object=user_1 + ) + report_from_user_1 = self.create_report( + reporter=user_1, reported_object=user_4 + ) + 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( + f'/api/users/{user_1.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert ( + FollowRequest.query.filter_by(followed_user_id=user_1.id).first() + is None + ) + assert set(Report.query.all()) == { + report_from_user_3, + report_from_user_1, + } + assert ( + Notification.query.filter_by(to_user_id=user_2_admin.id).first() + is not None + ) + + def test_user_can_not_delete_another_user_account( + 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.delete( + f'/api/users/{user_2.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_moderator_can_not_delete_another_user_account( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.delete( + f'/api/users/{user_2.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_error_when_deleting_non_existing_user( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + '/api/users/not_existing', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_entity(response, 'user') + + def test_admin_can_delete_another_user_account( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + deleted_user_id = user_2.id + + response = client.delete( + f'/api/users/{user_2.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert User.query.filter_by(id=deleted_user_id).first() is None + + def test_admin_can_delete_its_own_account( + self, app: Flask, user_1_admin: User, user_2_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + deleted_user_id = user_1_admin.id + + response = client.delete( + f'/api/users/{user_1_admin.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert User.query.filter_by(id=deleted_user_id).first() is None + + def test_admin_can_not_delete_owner_account( + self, + app: Flask, + user_1_owner: User, + user_2_admin: User, + user_3_admin: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_2_admin.email + ) + + response = client.delete( + f'/api/users/{user_1_owner.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403( + response, + 'you can not delete owner account', + ) + + def test_admin_can_not_delete_its_own_account_if_no_other_user_with_admin_rights( # noqa + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.delete( + f'/api/users/{user_1_admin.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403( + response, + 'you can not delete your account, no other user has admin rights', + ) + + def test_owner_can_delete_his_own_account( + self, app: Flask, user_1_owner: User, user_2_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_owner.email + ) + + response = client.delete( + f'/api/users/{user_1_owner.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 204 + assert User.query.filter_by(id=user_1_owner.id).first() is None + + def test_owner_can_not_delete_his_own_account_if_no_other_user_with_admin_rights( # noqa + self, app: Flask, user_1_owner: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_owner.email + ) + + response = client.delete( + f'/api/users/{user_1_owner.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403( + response, + 'you can not delete your account, no other user has admin rights', + ) + + 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, + user_2: User, + user_3: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_3_users_max, user_1_admin.email + ) + client.delete( + f'/api/users/{user_2.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + response = client.post( + '/api/auth/register', + data=json.dumps( + dict( + username=self.random_string(), + email=self.random_email(), + password=self.random_string(), + accepted_policy=True, + ) + ), + content_type='application/json', + ) + + assert response.status_code == 200 + + 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, + user_2: User, + user_3: User, + user_1_paris: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app_with_3_users_max, user_1_admin.email + ) + + client.delete( + f'/api/users/{user_2.username}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + response = client.post( + '/api/auth/register', + data=json.dumps( + dict( + username='justatest', + email='test@test.com', + password='12345678', + password_conf='12345678', + accepted_policy=True, + ) + ), + content_type='application/json', + ) + + self.assert_403(response, 'error, registration is disabled') + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'users:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1_admin: User, + user_2: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1_admin, scope=client_scope + ) + + response = client.delete( + f'/api/users/{user_2.username}', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestBlockUser(ApiTestCaseMixin): + route = '/api/users/{username}/block' + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.post( + self.route.format(username=user_1.username), + content_type="application/json", + ) + + self.assert_401(response) + + def test_it_returns_error_when_user_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.post( + self.route.format(username=self.random_string()), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_entity(response, 'user') + + def test_it_returns_error_when_user_is_suspended( + self, app: Flask, user_1: User, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + self.route.format(username=user_1.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_blocks_user( + 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.post( + self.route.format(username=user_2.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert user_2.is_blocked_by(user_1) + + def test_it_removes_follow_request_if_exists( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(username=user_2.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert user_1.is_followed_by(user_2) == 'false' + + def test_user_can_not_block_itself(self, app: Flask, user_1: User) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(username=user_1.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'users:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1_admin: User, + user_2: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1_admin, scope=client_scope + ) + + response = client.delete( + f'/api/users/{user_2.username}', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestUnBlockUser(ApiTestCaseMixin): + route = '/api/users/{username}/unblock' + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.post( + self.route.format(username=user_1.username), + content_type="application/json", + ) + + self.assert_401(response) + + def test_it_returns_error_when_user_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.post( + self.route.format(username=self.random_string()), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_entity(response, 'user') + + def test_it_returns_error_when_user_is_suspended( + self, app: Flask, user_1: User, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + self.route.format(username=user_1.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_unblocks_user( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_1.blocks_user(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(username=user_2.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert user_2.is_blocked_by(user_1) is False + + def test_it_does_not_return_error_if_user_is_not_block( + 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.post( + self.route.format(username=user_2.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['status'] == 'success' + assert user_2.is_blocked_by(user_1) is False + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'users:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1_admin: User, + user_2: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1_admin, scope=client_scope + ) + + response = client.delete( + f'/api/users/{user_2.username}', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestGetUserSanctions(ApiTestCaseMixin, ReportMixin, CommentMixin): + route = '/api/users/{username}/sanctions' + + def create_report_actions( + self, *, admin: User, auth_user: User, workout: Workout + ) -> Tuple[ReportAction, ReportAction, ReportAction]: + user_action = self.create_report_user_action( + admin, auth_user, action_type="user_warning" + ) + workout_action = self.create_report_workout_action( + admin, auth_user, workout + ) + comment = self.create_comment(auth_user, workout) + comment_action = self.create_report_comment_action( + admin, auth_user, comment + ) + return user_action, workout_action, comment_action + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.get( + self.route.format(username=user_1.username), + content_type="application/json", + ) + + self.assert_401(response) + + def test_it_returns_error_when_user_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( + self.route.format(username=self.random_string()), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_entity(response, 'user') + + def test_it_returns_error_when_user_is_not_authenticated_user( + 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( + self.route.format(username=user_2.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_empty_list_when_no_report_actions( + self, app: Flask, user_1: User, user_2_admin: User, user_3: User + ) -> None: + self.create_report_user_action( + user_2_admin, user_3, action_type="user_warning" + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(username=user_1.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['sanctions']) == 0 + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 0, + 'total': 0, + } + + def test_it_does_not_return_error_when_user_is_suspended( + self, app: Flask, user_1: User, user_2_admin: User + ) -> None: + self.create_report_user_action(user_2_admin, user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(username=user_1.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['sanctions']) == 1 + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 1, + } + + @patch('fittrackee.users.users.ACTIONS_PER_PAGE', 2) + @pytest.mark.parametrize('input_params', ["", "?page=1"]) + def test_it_returns_report_actions_first_page( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_params: str, + ) -> None: + _, workout_action, comment_action = self.create_report_actions( + admin=user_2_admin, + auth_user=user_1, + workout=workout_cycling_user_1, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(username=user_1.username) + input_params, + 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']['sanctions'] == [ + jsonify_dict( + comment_action.serialize(current_user=user_1, full=False) + ), + jsonify_dict( + workout_action.serialize(current_user=user_1, full=False) + ), + ] + assert data['pagination'] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 3, + } + + @patch('fittrackee.users.users.ACTIONS_PER_PAGE', 2) + def test_it_returns_report_actions_page_2( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + user_action, _, _ = self.create_report_actions( + admin=user_2_admin, + auth_user=user_1, + workout=workout_cycling_user_1, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(username=user_1.username) + "?page=2", + 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']['sanctions'] == [ + jsonify_dict( + user_action.serialize(current_user=user_1, full=False) + ), + ] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 3, + } + + def test_it_returns_report_actions_when_author_is_moderator( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + ) -> None: + action = self.create_report_user_action(user_1_moderator, user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + self.route.format(username=user_2.username), + 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']['sanctions'] == [ + jsonify_dict( + action.serialize(current_user=user_1_moderator, full=False) + ), + ] + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 1, + } + + @pytest.mark.parametrize( + 'input_action_type', ["report_reopening", "report_resolution"] + ) + def test_it_does_not_return_report_related_action( + self, + app: Flask, + user_1: User, + user_2_admin: User, + input_action_type: str, + ) -> None: + report = self.create_report( + reporter=user_2_admin, reported_object=user_1 + ) + self.create_report_action( + moderator=user_2_admin, + user=user_1, + action_type=input_action_type, + report_id=report.id, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(username=user_1.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['sanctions']) == 0 + + @pytest.mark.parametrize( + 'input_action_type', ["user_unsuspension", "user_warning_lifting"] + ) + def test_it_does_not_return_user_report_action_that_is_not_a_sanction( + self, + app: Flask, + user_1: User, + user_2_admin: User, + input_action_type: str, + ) -> None: + self.create_report_user_action( + user_2_admin, user_1, action_type=input_action_type + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(username=user_1.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['sanctions']) == 0 + + def test_it_does_not_return_workout_unsuspension( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + self.create_report_workout_action( + user_2_admin, + user_1, + workout_cycling_user_1, + "workout_unsuspension", + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(username=user_1.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['sanctions']) == 0 + + def test_it_does_not_return_comment_unsuspension( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + self.create_report_comment_action( + user_2_admin, user_1, comment, "comment_unsuspension" + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(username=user_1.username), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['sanctions']) == 0 diff --git a/fittrackee/tests/users/test_users_commands.py b/fittrackee/tests/users/test_users_commands.py index 9a298242c..6e118e79c 100644 --- a/fittrackee/tests/users/test_users_commands.py +++ b/fittrackee/tests/users/test_users_commands.py @@ -1,11 +1,14 @@ import secrets from unittest.mock import patch +import pytest from click.testing import CliRunner from flask import Flask -from fittrackee.cli import users_cli +from fittrackee import bcrypt, db +from fittrackee.cli import cli from fittrackee.users.models import User +from fittrackee.users.roles import UserRole from ..utils import random_email, random_string @@ -17,8 +20,9 @@ def test_it_displays_error_when_user_exists_with_same_username( runner = CliRunner() result = runner.invoke( - users_cli, + cli, [ + "users", "create", user_1.username, "--email", @@ -28,6 +32,7 @@ def test_it_displays_error_when_user_exists_with_same_username( ], ) + assert result.exit_code == 0 assert ( result.output == 'Error(s) occurred:\nsorry, that username is already taken\n' @@ -39,8 +44,9 @@ def test_it_displays_error_when_user_exists_with_same_email( runner = CliRunner() result = runner.invoke( - users_cli, + cli, [ + "users", "create", random_string(), "--email", @@ -50,47 +56,62 @@ def test_it_displays_error_when_user_exists_with_same_email( ], ) + assert result.exit_code == 0 assert result.output == ( 'Error(s) occurred:\n' 'This user already exists. No action done.\n' ) - def test_it_displays_success_message_when_user_is_created( + def test_it_creates_user( self, app: Flask, ) -> None: username = random_string() + email = random_email() + password = random_string() runner = CliRunner() result = runner.invoke( - users_cli, + cli, [ + "users", "create", username, "--email", - random_email(), + email, "--password", - random_string(), + password, ], ) + assert result.exit_code == 0 assert f"User '{username}' created.\n" in result.output + user = User.query.filter_by(username=username).first() + assert user.is_active is True + assert user.email == email + assert bcrypt.check_password_hash(user.password, password) def test_it_displays_password_when_password_is_not_provided( self, app: Flask, ) -> None: username = random_string() + email = random_email() password = random_string() runner = CliRunner() with patch.object(secrets, 'token_urlsafe', return_value=password): result = runner.invoke( - users_cli, - ["create", username, "--email", random_email()], + cli, + ["users", "create", username, "--email", email], ) + assert result.exit_code == 0 assert f"The user password is: {password}\n" in result.output + user = User.query.filter_by(username=username).first() + assert user.is_active is True + assert user.email == email + assert bcrypt.check_password_hash(user.password, password) def test_it_creates_user_with_default_language( self, app: Flask, user_1: User @@ -99,8 +120,8 @@ def test_it_creates_user_with_default_language( runner = CliRunner() result = runner.invoke( - users_cli, - ["create", username, "--email", random_email()], + cli, + ["users", "create", username, "--email", random_email()], ) user = User.query.filter_by(username=username).first() @@ -118,8 +139,9 @@ def test_it_creates_user_with_provided_language( runner = CliRunner() result = runner.invoke( - users_cli, + cli, [ + "users", "create", username, "--email", @@ -143,8 +165,9 @@ def test_it_creates_user_with_default_language_when_not_supported( runner = CliRunner() result = runner.invoke( - users_cli, + cli, [ + "users", "create", username, "--email", @@ -163,131 +186,340 @@ def test_it_creates_user_with_default_language_when_not_supported( class TestCliUserUpdate: - def test_it_displays_error_when_user_does_not_exist( - self, app: Flask - ) -> None: + def test_it_returns_error_when_missing_user_name(self, app: Flask) -> None: + runner = CliRunner() + + result = runner.invoke(cli, ['users', 'update']) + + assert result.exit_code == 2 + assert "Error: Missing argument 'USERNAME'." in result.output + + def test_it_display_error_when_user_not_found(self, app: Flask) -> None: username = random_string() runner = CliRunner() - result = runner.invoke( - users_cli, - ["update", username], - ) + result = runner.invoke(cli, ['users', 'update', username]) - assert result.output == ( - f"User '{username}' not found.\n" - f"Check the provided user name (case sensitive).\n" - ) + assert result.exit_code == 0 + assert f"User '{username}' not found." in result.output - def test_it_displays_no_updates_when_no_option_provided( + def test_it_does_not_update_user_when_no_options_provided( self, app: Flask, user_1: User ) -> None: runner = CliRunner() + previous_email = user_1.email + previous_password = user_1.password - result = runner.invoke( - users_cli, - ["update", user_1.username], - ) + result = runner.invoke(cli, ['users', 'update', user_1.username]) - assert result.output == 'No updates.\n' + assert result.exit_code == 0 + assert "No updates." in result.output + db.session.refresh(user_1) + assert user_1.role == UserRole.USER.value + assert user_1.is_active is True + assert user_1.email == previous_email + assert user_1.password == previous_password - def test_it_displays_error_updated_when_user_not_found( - self, app: Flask, user_1: User - ) -> None: + def test_it_sets_admin_rights(self, app: Flask, user_1: User) -> None: runner = CliRunner() + previous_email = user_1.email + previous_password = user_1.password - result = runner.invoke( - users_cli, - ["update", user_1.username.upper(), '--set-admin', "true"], - ) + with app.app_context(): + result = runner.invoke( + cli, + ['users', 'update', user_1.username, '--set-admin', 'true'], + ) - assert result.output == ( - f"User '{user_1.username.upper()}' not found.\n" - f"Check the provided user name (case sensitive).\n" - ) + assert result.exit_code == 0 + db.session.refresh(user_1) + assert f"User '{user_1.username}' updated." in result.output + assert user_1.role == UserRole.ADMIN.value + # unchanged values + assert user_1.is_active is True + assert user_1.email == previous_email + assert user_1.password == previous_password - def test_it_displays_user_updated_when_setting_admin_rights( + def test_it_displays_warning_when_using_deprecated_set_admin( self, app: Flask, user_1: User ) -> None: runner = CliRunner() - result = runner.invoke( - users_cli, - ["update", user_1.username, '--set-admin', "true"], - ) + with app.app_context(): + result = runner.invoke( + cli, + ['users', 'update', user_1.username, '--set-admin', 'true'], + ) - assert result.output == f"User '{user_1.username}' updated.\n" + assert result.exit_code == 0 + assert ( + "WARNING: --set-admin is deprecated. " + "Please use --set-role option instead." + ) in result.stdout - def test_it_displays_user_updated_when_removing_admin_rights( + def test_it_removes_admin_rights( self, app: Flask, user_1_admin: User ) -> None: runner = CliRunner() + previous_email = user_1_admin.email + previous_password = user_1_admin.password - result = runner.invoke( - users_cli, - ["update", user_1_admin.username, '--set-admin', "false"], - ) + with app.app_context(): + result = runner.invoke( + cli, + [ + 'users', + 'update', + user_1_admin.username, + '--set-admin', + 'false', + ], + ) - assert result.output == f"User '{user_1_admin.username}' updated.\n" + assert result.exit_code == 0 + db.session.refresh(user_1_admin) + assert f"User '{user_1_admin.username}' updated." in result.output + assert user_1_admin.role == UserRole.USER.value + # unchanged values + assert user_1_admin.is_active is True + assert user_1_admin.email == previous_email + assert user_1_admin.password == previous_password - def test_it_displays_user_updated_when_activating_user( + def test_it_activates_user_when_adding_admin_rights( self, app: Flask, inactive_user: User ) -> None: runner = CliRunner() + previous_email = inactive_user.email + previous_password = inactive_user.password - result = runner.invoke( - users_cli, - ["update", inactive_user.username, '--activate'], - ) + with app.app_context(): + result = runner.invoke( + cli, + [ + 'users', + 'update', + inactive_user.username, + '--set-admin', + 'true', + ], + ) - assert result.output == f"User '{inactive_user.username}' updated.\n" + assert result.exit_code == 0 + db.session.refresh(inactive_user) + assert f"User '{inactive_user.username}' updated." in result.output + assert inactive_user.role == UserRole.ADMIN.value + assert inactive_user.is_active is True + # unchanged values + assert inactive_user.email == previous_email + assert inactive_user.password == previous_password - def test_it_displays_password_when_resetting_password( - self, app: Flask, user_1: User - ) -> None: - password = random_string() + def test_it_sets_role(self, app: Flask, user_1: User) -> None: runner = CliRunner() + previous_email = user_1.email + previous_password = user_1.password - with patch.object(secrets, 'token_urlsafe', return_value=password): + with app.app_context(): result = runner.invoke( - users_cli, - ["update", user_1.username, '--reset-password'], + cli, + ['users', 'update', user_1.username, '--set-role', 'admin'], ) - assert result.output == ( - f"User '{user_1.username}' updated.\n" - f"The new password is: {password}\n" - ) + assert result.exit_code == 0 + db.session.refresh(user_1) + assert f"User '{user_1.username}' updated." in result.output + assert user_1.role == UserRole.ADMIN.value + # unchanged values + assert user_1.is_active is True + assert user_1.email == previous_email + assert user_1.password == previous_password + + @pytest.mark.parametrize( + 'input_role,input_active', + [ + ('user', False), + ('moderator', True), + ('admin', True), + ('owner', True), + ], + ) + def test_it_activates_user_only_when_role_is_not_user( + self, + app: Flask, + inactive_user: User, + input_role: str, + input_active: bool, + ) -> None: + runner = CliRunner() - def test_it_displays_user_updated_when_updating_email( - self, app: Flask, inactive_user: User + with app.app_context(): + runner.invoke( + cli, + [ + 'users', + 'update', + inactive_user.username, + '--set-role', + input_role, + ], + ) + + db.session.refresh(inactive_user) + assert inactive_user.role == UserRole[input_role.upper()].value + assert inactive_user.is_active == input_active + + def test_it_displays_error_when_set_admin_and_set_role_are_used_together( + self, + app: Flask, + user_1: User, ) -> None: - new_email = random_email() runner = CliRunner() - result = runner.invoke( - users_cli, - ["update", inactive_user.username, '--update-email', new_email], - ) + with app.app_context(): + result = runner.invoke( + cli, + [ + 'users', + 'update', + user_1.username, + '--set-role', + 'admin', + '--set-admin', + 'true', + ], + ) + + assert result.exit_code == 1 + assert ( + "--set-admin and --set-role can not be used together." + in result.stdout + ) + + def test_it_activates_user(self, app: Flask, inactive_user: User) -> None: + runner = CliRunner() + previous_email = inactive_user.email + previous_password = inactive_user.password + + with app.app_context(): + result = runner.invoke( + cli, + [ + 'users', + 'update', + inactive_user.username, + '--activate', + ], + ) + + assert result.exit_code == 0 + db.session.refresh(inactive_user) + assert f"User '{inactive_user.username}' updated." in result.output + assert inactive_user.is_active is True + # unchanged values + assert inactive_user.role == UserRole.USER.value + assert inactive_user.email == previous_email + assert inactive_user.password == previous_password + + def test_it_resets_password(self, app: Flask, user_1: User) -> None: + runner = CliRunner() + previous_email = user_1.email + new_password = random_string() + + with ( + app.app_context(), + patch.object(secrets, 'token_urlsafe', return_value=new_password), + ): + result = runner.invoke( + cli, + [ + 'users', + 'update', + user_1.username, + '--reset-password', + ], + ) + + assert result.exit_code == 0 + db.session.refresh(user_1) + assert f"User '{user_1.username}' updated." in result.output + assert f"The new password is: {new_password}" in result.output + assert bcrypt.check_password_hash(user_1.password, new_password) + # unchanged values + assert user_1.role == UserRole.USER.value + assert user_1.is_active is True + assert user_1.email == previous_email + + def test_it_updates_email(self, app: Flask, user_1: User) -> None: + runner = CliRunner() + previous_password = user_1.password + new_email = random_email() + + with app.app_context(): + result = runner.invoke( + cli, + [ + 'users', + 'update', + user_1.username, + '--update-email', + new_email, + ], + ) - assert result.output == f"User '{inactive_user.username}' updated.\n" + assert result.exit_code == 0 + db.session.refresh(user_1) + assert f"User '{user_1.username}' updated." in result.output + assert user_1.email == new_email + # unchanged values + assert user_1.role == UserRole.USER.value + assert user_1.is_active is True + assert user_1.password == previous_password def test_it_displays_error_when_email_is_invalid( - self, app: Flask, inactive_user: User + self, app: Flask, user_1: User ) -> None: runner = CliRunner() - result = runner.invoke( - users_cli, - [ - "update", - inactive_user.username, - '--update-email', - random_string(), - ], - ) + with app.app_context(): + result = runner.invoke( + cli, + [ + 'users', + 'update', + user_1.username, + '--update-email', + random_string(), + ], + ) + assert result.exit_code == 0 assert ( result.output == "An error occurred: valid email must be provided\n" ) + + def test_it_updates_user(self, app: Flask, inactive_user: User) -> None: + runner = CliRunner() + previous_password = inactive_user.password + new_email = random_email() + + with app.app_context(): + result = runner.invoke( + cli, + [ + 'users', + 'update', + inactive_user.username, + '--update-email', + new_email, + '--set-role', + 'admin', + ], + ) + + assert result.exit_code == 0 + db.session.refresh(inactive_user) + assert f"User '{inactive_user.username}' updated." in result.output + assert inactive_user.email == new_email + assert inactive_user.role == UserRole.ADMIN.value + assert inactive_user.is_active is True + assert inactive_user.password == previous_password diff --git a/fittrackee/tests/users/test_users_export_data.py b/fittrackee/tests/users/test_users_export_data.py index cc6e4c6de..026349ef1 100644 --- a/fittrackee/tests/users/test_users_export_data.py +++ b/fittrackee/tests/users/test_users_export_data.py @@ -8,6 +8,7 @@ from fittrackee import db from fittrackee.equipments.models import Equipment +from fittrackee.tests.comments.mixins import CommentMixin from fittrackee.users.export_data import ( UserDataExporter, clean_user_data_export, @@ -15,14 +16,14 @@ generate_user_data_archives, ) from fittrackee.users.models import User, UserDataExport +from fittrackee.visibility_levels import VisibilityLevel 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: +class TestUserDataExporterGetUserInfos: def test_it_return_serialized_user_as_info_info( self, app: Flask, user_1: User ) -> None: @@ -30,8 +31,10 @@ def test_it_return_serialized_user_as_info_info( user_data = exporter.get_user_info() - assert user_data == user_1.serialize(user_1) + assert user_data == user_1.serialize(current_user=user_1) + +class TestUserDataExporterGetUserWorkoutsData: def test_it_returns_empty_list_when_user_has_no_workouts( self, app: Flask, user_1: User ) -> None: @@ -82,6 +85,14 @@ def test_it_returns_data_for_workout_without_gpx( 'notes': workout_cycling_user_1.notes, 'equipments': [], 'description': None, + 'liked': workout_cycling_user_1.liked_by(user_1), + 'likes_count': workout_cycling_user_1.likes.count(), + 'map_visibility': ( + workout_cycling_user_1.calculated_map_visibility.value + ), + 'workout_visibility': ( + workout_cycling_user_1.workout_visibility.value + ), } ] @@ -127,9 +138,32 @@ def test_it_returns_data_for_workout_with_gpx( 'notes': workout.notes, 'equipments': [], 'description': None, + 'liked': workout.liked_by(user_1), + 'likes_count': workout.likes.count(), + 'map_visibility': workout.calculated_map_visibility.value, + 'workout_visibility': workout.workout_visibility.value, } ] + 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 + ] + + +class TestUserDataExporterGetUserEquipmentsData: def test_it_returns_empty_list_when_no_data_for_equipments( self, app: Flask, @@ -157,23 +191,74 @@ def test_it_returns_data_for_equipments( assert equipments_data == [equipment_bike_user_1.serialize()] - def test_it_stores_only_user_workouts( + +class TestUserDataExporterGetUserCommentsData(CommentMixin): + def test_it_returns_empty_list_when_user_has_no_comments( + self, app: Flask, user_1: User + ) -> None: + exporter = UserDataExporter(user_1) + + comments_data = exporter.get_user_comments_data() + + assert comments_data == [] + + def test_it_returns_user_comment( self, app: Flask, user_1: User, user_2: User, sport_1_cycling: Sport, workout_cycling_user_1: Workout, - workout_cycling_user_2: Workout, ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment(user_1, workout_cycling_user_1) + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) exporter = UserDataExporter(user_1) - workouts_data = exporter.get_user_workouts_data() + comments_data = exporter.get_user_comments_data() - assert [data["id"] for data in workouts_data] == [ - workout_cycling_user_1.short_id + assert comments_data == [ + { + 'created_at': comment.created_at, + 'id': comment.short_id, + 'modification_date': comment.modification_date, + 'text': comment.text, + 'text_visibility': comment.text_visibility.value, + 'workout_id': workout_cycling_user_1.short_id, + }, + ] + + def test_it_returns_user_comment_without_workout( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + db.session.delete(workout_cycling_user_1) + db.session.commit() + exporter = UserDataExporter(user_1) + + comments_data = exporter.get_user_comments_data() + + assert comments_data == [ + { + 'created_at': comment.created_at, + 'id': comment.short_id, + 'modification_date': comment.modification_date, + 'text': comment.text, + 'text_visibility': comment.text_visibility.value, + 'workout_id': None, + }, ] + +class TestUserDataExporterExportData: def test_export_data_generates_json_file_in_user_directory( self, app: Flask, @@ -211,11 +296,11 @@ def test_it_returns_json_file_path( assert file_path == os.path.join(user_directory, f"{file_name}.json") -class TestUserDataExporterArchive(CallArgsMixin): +class TestUserDataExporterGenerateArchive: @patch.object(secrets, 'token_urlsafe') @patch.object(UserDataExporter, 'export_data') @patch('fittrackee.users.export_data.ZipFile') - def test_it_calls_export_data_twice( + def test_it_gets_data_for_each_type( self, zipfile_mock: Mock, export_data: Mock, @@ -233,6 +318,7 @@ def test_it_calls_export_data_twice( call(exporter.get_user_info(), 'user_data'), call(exporter.get_user_workouts_data(), 'workouts_data'), call(exporter.get_user_equipments_data(), 'equipments_data'), + call(exporter.get_user_comments_data(), 'comments_data'), ] ) @@ -281,6 +367,7 @@ def test_it_calls_zipfile_for_each_json_file( call('user_info'), call('workouts_data'), call('equipments_data'), + call('comments_data'), ] exporter.generate_archive() @@ -292,6 +379,7 @@ def test_it_calls_zipfile_for_each_json_file( call(call('user_info'), 'user_data.json'), call(call('workouts_data'), 'user_workouts_data.json'), call(call('equipments_data'), 'user_equipments_data.json'), + call(call('comments_data'), 'user_comments_data.json'), ] ) # fmt: on @@ -577,8 +665,8 @@ def test_it_calls_data_export_email_when_export_is_successful( }, { 'username': user_1.username, - 'account_url': 'http://0.0.0.0:5000/profile/edit/account', - 'fittrackee_url': 'http://0.0.0.0:5000', + 'account_url': 'https://example.com/profile/edit/account', + 'fittrackee_url': 'https://example.com', }, ) diff --git a/fittrackee/tests/users/test_users_follow_api.py b/fittrackee/tests/users/test_users_follow_api.py new file mode 100644 index 000000000..6aab44cf6 --- /dev/null +++ b/fittrackee/tests/users/test_users_follow_api.py @@ -0,0 +1,316 @@ +import json +from datetime import datetime + +import pytest +from flask import Flask + +from fittrackee.users.models import FollowRequest, User + +from ..mixins import ApiTestCaseMixin +from ..utils import OAUTH_SCOPES, random_string + + +class TestFollow(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( + f'/api/users/{user_1.username}/follow', + content_type='application/json', + ) + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, user_1: User, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + f'/api/users/{user_1.username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + self.assert_403(response) + + def test_it_returns_error_if_target_user_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.post( + f'/api/users/{random_string()}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_returns_error_if_target_user_has_already_rejected_request( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_1_to_user_2.is_approved = False + follow_request_from_user_1_to_user_2.updated_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f'/api/users/{user_2.username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 403 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'you do not have permissions' + + def test_it_returns_error_if_target_user_has_blocked_user( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f'/api/users/{user_2.username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == 'you can not follow this user' + + def test_it_creates_follow_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.post( + f'/api/users/{user_2.username}/follow', + 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['message'] + == f"Follow request to user '{user_2.username}' is sent." + ) + + def test_it_returns_success_if_follow_request_already_exists( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f'/api/users/{user_2.username}/follow', + 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['message'] + == f"Follow request to user '{user_2.username}' is sent." + ) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'follow:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + user_2: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + f'/api/users/{user_2.username}/follow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestUnfollow(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( + f'/api/users/{user_1.username}/unfollow', + content_type='application/json', + ) + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, user_1: User, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + f'/api/users/{user_1.username}/unfollow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + self.assert_403(response) + + def test_it_returns_error_if_target_user_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.post( + f'/api/users/{random_string()}/unfollow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_returns_error_if_follow_request_does_not_exist( + 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.post( + f'/api/users/{user_2.username}/unfollow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'relationship does not exist' + + def test_it_removes_follow_request( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f'/api/users/{user_2.username}/unfollow', + 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['message'] == ( + "Undo for a follow request to user " + f"'{user_2.username}' is sent." + ) + assert user_1.following.count() == 0 + + def test_it_returns_error_when_user_is_blocked( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f'/api/users/{user_2.username}/unfollow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message(response, 'relationship does not exist') + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'follow:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + user_2: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + f'/api/users/{user_2.username}/unfollow', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) diff --git a/fittrackee/tests/users/test_users_follow_request_api.py b/fittrackee/tests/users/test_users_follow_request_api.py new file mode 100644 index 000000000..53ec3214b --- /dev/null +++ b/fittrackee/tests/users/test_users_follow_request_api.py @@ -0,0 +1,581 @@ +import json +from datetime import datetime +from unittest.mock import patch + +import pytest +from flask import Flask +from flask.testing import FlaskClient + +from fittrackee.users.models import FollowRequest, User + +from ..mixins import ApiTestCaseMixin +from ..utils import OAUTH_SCOPES, random_string + + +class TestGetFollowRequest(ApiTestCaseMixin): + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.get( + '/api/follow-requests', + content_type='application/json', + ) + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + '/api/follow-requests', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + self.assert_403(response) + + def test_it_returns_empty_list_if_no_follow_request( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/follow-requests', + 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['data']['follow_requests'] == [] + + def test_it_returns_current_user_pending_follow_requests( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/follow-requests', + 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 len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['username'] == 'sam' + assert '@context' not in data['data']['follow_requests'][0] + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'follow:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, app: Flask, user_1: User, client_scope: str, can_access: bool + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + '/api/follow-requests', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestGetFollowRequestPagination(ApiTestCaseMixin): + def test_it_returns_pagination( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/follow-requests', + 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 len(data['data']['follow_requests']) == 2 + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 2, + } + + @patch('fittrackee.users.follow_requests.FOLLOW_REQUESTS_PER_PAGE', 1) + def test_it_returns_second_page( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/follow-requests?page=2', + 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 len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['username'] == 'sam' + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 2, + } + + @patch('fittrackee.users.follow_requests.MAX_FOLLOW_REQUESTS_PER_PAGE', 1) + def test_it_returns_max_follow_request_per_page( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/follow-requests?per_page=10', + 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 len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['username'] == 'toto' + assert data['pagination'] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 2, + } + + def test_it_returns_follow_requests_with_descending_order( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/follow-requests?order=desc', + 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 len(data['data']['follow_requests']) == 2 + assert data['data']['follow_requests'][0]['username'] == 'sam' + assert data['data']['follow_requests'][1]['username'] == 'toto' + + def test_it_returns_one_request_per_page( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/follow-requests?per_page=1', + 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 len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['username'] == 'toto' + assert data['pagination'] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 2, + } + + def test_it_returns_second_page_with_one_request_per_page_with_descending_order( # noqa + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/follow-requests?page=2&per_page=1&order=desc', + 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 len(data['data']['follow_requests']) == 1 + assert data['data']['follow_requests'][0]['username'] == 'toto' + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 2, + } + + +class FollowRequestTestCase(ApiTestCaseMixin): + def assert_it_returns_follow_request_not_found( + self, + client: FlaskClient, + auth_token: str, + user_name: str, + action: str, + ) -> None: + url = f'/api/follow-requests/{user_name}/{action}' + self.assert_return_not_found( + url, client, auth_token, 'Follow request does not exist.' + ) + + @staticmethod + def assert_it_returns_follow_request_already_processed( + client: FlaskClient, + auth_token: str, + user_name: str, + action: str, + ) -> None: + url = f'/api/follow-requests/{user_name}/{action}' + response = client.post( + url, + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 400 + data = json.loads(response.data.decode()) + assert data['status'] == 'error' + assert data['message'] == ( + f"Follow request from user '{user_name}' already {action}ed." + ) + + @staticmethod + def assert_it_returns_follow_request_processed( + client: FlaskClient, + auth_token: str, + user_name: str, + action: str, + ) -> None: + url = f'/api/follow-requests/{user_name}/{action}' + response = client.post( + url, + 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['message'] == ( + f"Follow request from user '{user_name}' is {action}ed." + ) + + +class TestAcceptFollowRequest(FollowRequestTestCase): + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.post( + f'/api/follow-requests/{user_1.username}/accept', + content_type='application/json', + ) + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.suspended_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f'/api/follow-requests/{user_1.username}/accept', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + self.assert_403(response) + + def test_it_raises_error_if_target_user_does_not_exist( + self, + app: Flask, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + self.assert_return_user_not_found( + f'/api/follow-requests/{random_string()}/accept', + client, + auth_token, + ) + + def test_it_raises_error_if_follow_request_does_not_exist( + self, app: Flask, user_1: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + self.assert_it_returns_follow_request_not_found( + client, auth_token, user_2.username, 'accept' + ) + + def test_it_raises_error_if_follow_request_already_accepted( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + self.assert_it_returns_follow_request_already_processed( + client, auth_token, user_2.username, 'accept' + ) + + def test_it_accepts_follow_request( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + self.assert_it_returns_follow_request_processed( + client, auth_token, user_2.username, 'accept' + ) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'follow:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + f'/api/follow-requests/{user_2.username}/accept', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestRejectFollowRequest(FollowRequestTestCase): + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.post( + f'/api/follow-requests/{user_1.username}/reject', + content_type='application/json', + ) + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.suspended_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f'/api/follow-requests/{user_1.username}/reject', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + self.assert_403(response) + + def test_it_raises_error_if_target_user_does_not_exist( + self, + app: Flask, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + self.assert_return_user_not_found( + f'/api/follow-requests/{random_string()}/reject', + client, + auth_token, + ) + + def test_it_raises_error_if_follow_request_does_not_exist( + self, app: Flask, user_1: User, user_2: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + self.assert_it_returns_follow_request_not_found( + client, auth_token, user_2.username, 'reject' + ) + + def test_it_raises_error_if_follow_request_already_rejected( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + self.assert_it_returns_follow_request_already_processed( + client, auth_token, user_2.username, 'reject' + ) + + def test_it_rejects_follow_request( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + self.assert_it_returns_follow_request_processed( + client, auth_token, user_2.username, 'reject' + ) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'follow:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + f'/api/follow-requests/{user_2.username}/reject', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) diff --git a/fittrackee/tests/users/test_users_followers_api.py b/fittrackee/tests/users/test_users_followers_api.py new file mode 100644 index 000000000..8f9999d49 --- /dev/null +++ b/fittrackee/tests/users/test_users_followers_api.py @@ -0,0 +1,744 @@ +import json +from datetime import datetime +from typing import List +from unittest.mock import patch + +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.users.models import FollowRequest, User + +from ..mixins import ApiTestCaseMixin +from ..utils import OAUTH_SCOPES, random_string + + +class FollowersAsUserTestCase(ApiTestCaseMixin): + @staticmethod + def approves_follow_requests( + follows_requests: List[FollowRequest], + ) -> None: + for follows_request in follows_requests: + follows_request.is_approved = True + follows_request.updated_at = datetime.utcnow() + + +class TestFollowersAsUnauthenticatedUser(ApiTestCaseMixin): + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.get( + f'/api/users/{user_1.username}/followers', + content_type='application/json', + ) + + self.assert_401(response) + + +class TestFollowersAsSuspendedUser(ApiTestCaseMixin): + def test_it_returns_error_if_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_1.suspended_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_2.username}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestFollowersAsUser(FollowersAsUserTestCase): + def test_it_returns_404_if_user_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/users/{random_string()}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_returns_empty_list_if_no_followers( + 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/users/{user_1.username}/followers', + 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['data']['followers'] == [] + + def test_it_returns_followers( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_2_to_user_1, + follow_request_from_user_3_to_user_1, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_1.username}/followers', + 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 len(data['data']['followers']) == 2 + assert data['data']['followers'][0]['username'] == user_3.username + assert data['data']['followers'][1]['username'] == user_2.username + assert 'email' not in data['data']['followers'][0] + assert 'email' not in data['data']['followers'][1] + + def test_it_does_not_return_suspended_followers( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + self.approves_follow_requests([follow_request_from_user_2_to_user_1]) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_1.username}/followers', + 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 len(data['data']['followers']) == 0 + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'follow:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, app: Flask, user_1: User, client_scope: str, can_access: bool + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + f'/api/users/{user_1.username}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestFollowersAsAdmin(FollowersAsUserTestCase): + def test_it_returns_404_if_user_does_not_exist( + self, app: Flask, user_1_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{random_string()}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_returns_empty_list_if_no_followers( + self, app: Flask, user_1_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_1_admin.username}/followers', + 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['data']['followers'] == [] + + def test_it_returns_followers( + self, + app: Flask, + user_1_admin: User, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_1_to_user_2, + follow_request_from_user_3_to_user_2, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_2.username}/followers', + 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 len(data['data']['followers']) == 2 + assert data['data']['followers'][0]['email'] == user_3.email + assert data['data']['followers'][1]['email'] == user_1.email + + def test_it_does_not_return_suspended_followers( + self, + app: Flask, + user_1_admin: User, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_1_to_user_2, + follow_request_from_user_3_to_user_2, + ] + ) + user_3.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_2.username}/followers', + 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 len(data['data']['followers']) == 1 + assert data['data']['followers'][0]['email'] == user_1.email + + +class TestFollowersPagination(FollowersAsUserTestCase): + def test_it_returns_pagination_info( + 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( + f'/api/users/{user_2.username}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 0, + 'total': 0, + } + + @patch('fittrackee.users.users.USERS_PER_PAGE', 1) + def test_it_returns_first_page_on_followers_list( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_1_to_user_2, + follow_request_from_user_3_to_user_2, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_2.username}/followers', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 2, + } + + @patch('fittrackee.users.users.USERS_PER_PAGE', 1) + def test_it_returns_page_2_on_followers_list( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_1_to_user_2, + follow_request_from_user_3_to_user_2, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_2.username}/followers?page=2', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 2, + } + + +class TestFollowingAsUnauthenticatedUser(ApiTestCaseMixin): + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask, user_1: User + ) -> None: + client = app.test_client() + + response = client.get( + f'/api/users/{user_1.username}/following', + content_type='application/json', + ) + + self.assert_401(response) + + +class TestFollowingAsSuspendedUser(ApiTestCaseMixin): + def test_it_returns_error_if_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_1.suspended_at = datetime.utcnow() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_2.username}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestFollowingAsUser(FollowersAsUserTestCase): + def test_it_returns_404_if_user_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/users/{random_string()}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_returns_empty_list_if_no_following_users( + 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/users/{user_1.username}/following', + 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['data']['following'] == [] + + def test_it_returns_following_users( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_3_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_3_to_user_2, + follow_request_from_user_3_to_user_1, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_3.username}/following', + 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 len(data['data']['following']) == 2 + assert data['data']['following'][0]['username'] == user_1.username + assert data['data']['following'][1]['username'] == user_2.username + assert 'email' in data['data']['following'][0] + assert 'email' not in data['data']['following'][1] + + def test_it_does_not_return_suspended_following( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_3_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_3_to_user_2, + follow_request_from_user_3_to_user_1, + ] + ) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_3.username}/following', + 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 len(data['data']['following']) == 1 + assert data['data']['following'][0]['username'] == user_1.username + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'follow:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, app: Flask, user_1: User, client_scope: str, can_access: bool + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + f'/api/users/{user_1.username}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestFollowingAsAdmin(FollowersAsUserTestCase): + def test_it_returns_404_if_user_does_not_exist( + self, app: Flask, user_1_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{random_string()}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert data['status'] == 'not found' + assert data['message'] == 'user does not exist' + + def test_it_returns_empty_list_if_no_following_users( + self, app: Flask, user_1_admin: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_1_admin.username}/following', + 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['data']['following'] == [] + + def test_it_returns_following_users( + self, + app: Flask, + user_1_admin: User, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_3_to_user_2, + follow_request_from_user_3_to_user_1, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_3.username}/following', + 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 len(data['data']['following']) == 2 + assert data['data']['following'][0]['email'] == user_1.email + assert data['data']['following'][1]['email'] == user_2.email + + def test_it_does_not_return_suspended_following( + self, + app: Flask, + user_1_admin: User, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_3_to_user_2, + follow_request_from_user_3_to_user_1, + ] + ) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + f'/api/users/{user_3.username}/following', + 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 len(data['data']['following']) == 1 + assert data['data']['following'][0]['email'] == user_1.email + + +class TestFollowingPagination(FollowersAsUserTestCase): + def test_it_returns_pagination_info( + 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( + f'/api/users/{user_2.username}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 0, + 'total': 0, + } + + @patch('fittrackee.users.users.USERS_PER_PAGE', 1) + def test_it_returns_first_page_on_following_list( + self, + app: Flask, + user_1: User, + user_3: User, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_3_to_user_1, + follow_request_from_user_3_to_user_2, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_3.username}/following', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 2, + } + + @patch('fittrackee.users.users.USERS_PER_PAGE', 1) + def test_it_returns_page_2_on_followers_list( + self, + app: Flask, + user_1: User, + user_3: User, + follow_request_from_user_3_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + self.approves_follow_requests( + [ + follow_request_from_user_3_to_user_1, + follow_request_from_user_3_to_user_2, + ] + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/users/{user_3.username}/following?page=2', + content_type='application/json', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 2, + } diff --git a/fittrackee/tests/users/test_users_model.py b/fittrackee/tests/users/test_users_model.py index 490544c0b..e08277d8f 100644 --- a/fittrackee/tests/users/test_users_model.py +++ b/fittrackee/tests/users/test_users_model.py @@ -1,24 +1,38 @@ +import re from datetime import datetime, timedelta from typing import Dict import pytest from flask import Flask from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.exc import IntegrityError from time_machine import travel from fittrackee import db from fittrackee.equipments.models import Equipment +from fittrackee.reports.models import ReportAction +from fittrackee.tests.comments.mixins import CommentMixin from fittrackee.tests.utils import random_int, random_string -from fittrackee.users.exceptions import UserNotFoundException +from fittrackee.users.exceptions import ( + BlockUserException, + FollowRequestAlreadyProcessedError, + NotExistingFollowRequestError, +) from fittrackee.users.models import ( BlacklistedToken, + BlockedUser, + FollowRequest, + Notification, User, UserDataExport, UserSportPreference, UserSportPreferenceEquipment, ) +from fittrackee.users.roles import UserRole from fittrackee.workouts.models import Sport, Workout +from ..mixins import RandomMixin, ReportMixin + class TestUserModel: def test_it_returns_username_in_string_value( @@ -27,13 +41,78 @@ def test_it_returns_username_in_string_value( assert '' == str(user_1) +class TestUserModelRole(RandomMixin): + def test_role_is_user_by_default(self, app: Flask) -> None: + user = User( + username=self.random_string(), + email=self.random_email(), + password=self.random_string(), + ) + db.session.add(user) + db.session.commit() + + assert user.role == UserRole.USER.value + assert user.has_moderator_rights is False + assert user.has_admin_rights is False + + def test_user_has_moderator_rights(self, app: Flask) -> None: + user = User( + username=self.random_string(), + email=self.random_email(), + password=self.random_string(), + ) + db.session.add(user) + db.session.flush() + + user.role = UserRole.MODERATOR.value + db.session.commit() + + assert user.has_moderator_rights is True + assert user.has_admin_rights is False + + def test_user_has_admin_rights(self, app: Flask) -> None: + user = User( + username=self.random_string(), + email=self.random_email(), + password=self.random_string(), + ) + db.session.add(user) + db.session.flush() + + user.role = UserRole.ADMIN.value + db.session.commit() + + assert user.has_moderator_rights is True + assert user.has_admin_rights is True + + def test_it_raises_error_when_role_is_auth_user(self, app: Flask) -> None: + user = User( + username=self.random_string(), + email=self.random_email(), + password=self.random_string(), + ) + db.session.add(user) + db.session.flush() + + with pytest.raises( + IntegrityError, + match=re.escape( + '(psycopg2.errors.CheckViolation) new row for relation ' + '"users" violates check constraint "ck_users_role"' + ), + ): + user.role = UserRole.AUTH_USER.value + db.session.commit() + + class UserModelAssertMixin: @staticmethod def assert_user_account(serialized_user: Dict, user: User) -> None: - assert serialized_user['admin'] == user.admin - assert serialized_user['email_to_confirm'] == user.email_to_confirm + assert serialized_user['role'] == UserRole(user.role).name.lower() assert serialized_user['is_active'] == user.is_active assert serialized_user['username'] == user.username + assert serialized_user['suspended_at'] is None + assert "suspension_report_id" not in serialized_user @staticmethod def assert_user_profile(serialized_user: Dict, user: User) -> None: @@ -59,21 +138,24 @@ class TestUserSerializeAsAuthUser(UserModelAssertMixin): def test_it_returns_user_account_infos( self, app: Flask, user_1: User ) -> None: - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) self.assert_user_account(serialized_user, user_1) + assert serialized_user['email'] == user_1.email + assert serialized_user['email_to_confirm'] == user_1.email_to_confirm def test_it_returns_user_profile_infos( self, app: Flask, user_1: User ) -> None: - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) self.assert_user_profile(serialized_user, user_1) + assert 'blocked' not in serialized_user def test_it_returns_user_preferences( self, app: Flask, user_1: User ) -> None: - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['imperial_units'] == user_1.imperial_units assert serialized_user['language'] == user_1.language @@ -86,9 +168,22 @@ def test_it_returns_user_preferences( ) assert serialized_user['use_raw_gpx_speed'] == user_1.use_raw_gpx_speed assert serialized_user['use_dark_mode'] == user_1.use_dark_mode + assert ( + serialized_user['workouts_visibility'] + == user_1.workouts_visibility + ) + assert serialized_user['map_visibility'] == user_1.map_visibility + assert ( + serialized_user['manually_approves_followers'] + == user_1.manually_approves_followers + ) + assert ( + serialized_user['hide_profile_in_users_directory'] + == user_1.hide_profile_in_users_directory + ) def test_it_returns_workouts_infos(self, app: Flask, user_1: User) -> None: - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) self.assert_workouts_keys_are_present(serialized_user) @@ -98,7 +193,7 @@ def test_it_returns_user_did_not_accept_default_privacy_policy( # default privacy policy app.config['privacy_policy_date'] = None user_1.accepted_policy_date = None - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['accepted_privacy_policy'] is False @@ -108,7 +203,7 @@ def test_it_returns_user_did_accept_default_privacy_policy( # default privacy policy app.config['privacy_policy_date'] = None user_1.accepted_policy_date = datetime.utcnow() - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['accepted_privacy_policy'] is True @@ -118,7 +213,7 @@ def test_it_returns_user_did_not_accept_last_policy( user_1.accepted_policy_date = datetime.utcnow() # custom privacy policy app.config['privacy_policy_date'] = datetime.utcnow() - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['accepted_privacy_policy'] is False @@ -128,37 +223,66 @@ def test_it_returns_user_did_accept_last_policy( # custom privacy policy app.config['privacy_policy_date'] = datetime.utcnow() user_1.accepted_policy_date = datetime.utcnow() - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) 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 + self, app: Flask, user_1: User ) -> None: - serialized_user = user_2.serialize(user_1_admin) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert 'confirmation_token' not in serialized_user + def test_it_does_return_reports_info( + self, app: Flask, user_1: User + ) -> None: + serialized_user = user_1.serialize(current_user=user_1, light=False) + + assert "created_reports_count" not in serialized_user + assert "reported_count" not in serialized_user + assert serialized_user["sanctions_count"] == 0 + + def test_it_does_return_reports_info_when_user_has_admin_rights( + self, app: Flask, user_1_admin: User + ) -> None: + serialized_user = user_1_admin.serialize( + current_user=user_1_admin, light=False + ) + + assert serialized_user["created_reports_count"] == 0 + assert serialized_user["reported_count"] == 0 + assert serialized_user["sanctions_count"] == 0 + -class TestUserSerializeAsAdmin(UserModelAssertMixin): +class TestUserSerializeAsAdmin(UserModelAssertMixin, ReportMixin): def test_it_returns_user_account_infos( self, app: Flask, user_1_admin: User, user_2: User ) -> None: - serialized_user = user_2.serialize(user_1_admin) + serialized_user = user_2.serialize( + current_user=user_1_admin, light=False + ) self.assert_user_account(serialized_user, user_2) + assert serialized_user['email'] == user_2.email + assert serialized_user['email_to_confirm'] == user_2.email_to_confirm def test_it_returns_user_profile_infos( self, app: Flask, user_1_admin: User, user_2: User ) -> None: - serialized_user = user_2.serialize(user_1_admin) + serialized_user = user_2.serialize( + current_user=user_1_admin, light=False + ) self.assert_user_profile(serialized_user, user_1_admin) + assert serialized_user["blocked"] is False def test_it_does_return_user_preferences( self, app: Flask, user_1_admin: User, user_2: User ) -> None: - serialized_user = user_2.serialize(user_1_admin) + serialized_user = user_2.serialize( + current_user=user_1_admin, light=False + ) assert 'imperial_units' not in serialized_user assert 'language' not in serialized_user @@ -167,28 +291,213 @@ def test_it_does_return_user_preferences( assert 'start_elevation_at_zero' not in serialized_user assert 'use_raw_gpx_speed' not in serialized_user assert 'use_dark_mode' not in serialized_user + assert 'workouts_visibility' not in serialized_user + assert 'map_visibility' not in serialized_user + assert 'manually_approves_followers' not in serialized_user + assert 'hide_profile_in_users_directory' not in serialized_user def test_it_returns_workouts_infos( self, app: Flask, user_1_admin: User, user_2: User ) -> None: - serialized_user = user_2.serialize(user_1_admin) + serialized_user = user_2.serialize( + current_user=user_1_admin, light=False + ) 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) + serialized_user = user_2.serialize( + current_user=user_1_admin, light=False + ) 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: - serialized_user = user_2.serialize(user_1_admin) + serialized_user = user_2.serialize( + current_user=user_1_admin, light=False + ) + + assert 'confirmation_token' not in serialized_user + + def test_it_returns_reports_info( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + self.create_user_report(user_2, user_3) + self.create_user_report(user_1_admin, user_2) + self.create_report_user_action(user_1_admin, user_2) + + serialized_user = user_2.serialize( + current_user=user_1_admin, light=False + ) + + assert serialized_user['created_reports_count'] == 1 + assert serialized_user['reported_count'] == 2 + assert serialized_user['sanctions_count'] == 1 + + +class TestUserSerializeAsModerator(UserModelAssertMixin, ReportMixin): + def test_it_returns_user_account_infos( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + serialized_user = user_2.serialize( + current_user=user_1_moderator, light=False + ) + + self.assert_user_account(serialized_user, user_2) + assert serialized_user['email'] == user_2.email + assert 'email_to_confirm' not in serialized_user + + def test_it_returns_user_profile_infos( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + serialized_user = user_2.serialize( + current_user=user_1_moderator, light=False + ) + + self.assert_user_profile(serialized_user, user_1_moderator) + assert serialized_user["blocked"] is False + + def test_it_does_return_user_preferences( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + serialized_user = user_2.serialize( + current_user=user_1_moderator, light=False + ) + + assert 'imperial_units' not in serialized_user + assert 'language' not in serialized_user + assert 'timezone' not in serialized_user + assert 'weekm' not in serialized_user + assert 'start_elevation_at_zero' not in serialized_user + assert 'use_raw_gpx_speed' not in serialized_user + assert 'use_dark_mode' not in serialized_user + assert 'workouts_visibility' not in serialized_user + assert 'map_visibility' not in serialized_user + assert 'manually_approves_followers' not in serialized_user + assert 'hide_profile_in_users_directory' not in serialized_user + + def test_it_returns_workouts_infos( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + serialized_user = user_2.serialize( + current_user=user_1_moderator, light=False + ) + + self.assert_workouts_keys_are_present(serialized_user) + + def test_it_does_not_return_accepted_privacy_policy_date( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + serialized_user = user_2.serialize( + current_user=user_1_moderator, light=False + ) + + assert 'accepted_privacy_policy' not in serialized_user + + def test_it_does_not_return_confirmation_token( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + serialized_user = user_2.serialize( + current_user=user_1_moderator, light=False + ) + + assert 'confirmation_token' not in serialized_user + + def test_it_returns_reports_info( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + self.create_user_report(user_2, user_3) + self.create_user_report(user_1_moderator, user_2) + self.create_report_user_action(user_1_moderator, user_2) + + serialized_user = user_2.serialize( + current_user=user_1_moderator, light=False + ) + + assert serialized_user['created_reports_count'] == 1 + assert serialized_user['reported_count'] == 2 + assert serialized_user['sanctions_count'] == 1 + + +class TestUserSerializeAsUser(UserModelAssertMixin): + def test_it_returns_user_account_infos( + self, app: Flask, user_1: User, user_2: User + ) -> None: + serialized_user = user_2.serialize(current_user=user_1, light=False) + + assert serialized_user['role'] == UserRole(user_2.role).name.lower() + assert serialized_user['username'] == user_2.username + assert 'email' not in serialized_user + assert 'email_to_confirm' not in serialized_user + assert 'is_active' not in serialized_user + + def test_it_returns_user_profile_infos( + self, app: Flask, user_1: User, user_2: User + ) -> None: + serialized_user = user_2.serialize(current_user=user_1, light=False) + + self.assert_user_profile(serialized_user, user_1) + assert serialized_user["blocked"] is False + + def test_it_does_return_user_preferences( + self, app: Flask, user_1: User, user_2: User + ) -> None: + serialized_user = user_2.serialize(current_user=user_1, light=False) + + assert 'imperial_units' not in serialized_user + assert 'language' not in serialized_user + assert 'timezone' not in serialized_user + assert 'weekm' not in serialized_user + assert 'workouts_visibility' not in serialized_user + assert 'map_visibility' not in serialized_user + assert 'manually_approves_followers' not in serialized_user + assert 'hide_profile_in_users_directory' not in serialized_user + + def test_it_returns_workouts_infos( + self, app: Flask, user_1: User, user_2: User + ) -> None: + serialized_user = user_2.serialize(current_user=user_1, light=False) + + self.assert_workouts_keys_are_present(serialized_user) + + def test_it_does_not_return_confirmation_token( + self, app: Flask, user_1: User, user_2: User + ) -> None: + serialized_user = user_2.serialize(current_user=user_1, light=False) assert 'confirmation_token' not in serialized_user + def test_it_does_return_reports_info( + self, app: Flask, user_1: User, user_2: User + ) -> None: + serialized_user = user_2.serialize(current_user=user_1, light=False) + + assert "created_reports_count" not in serialized_user + assert "reported_count" not in serialized_user + assert "sanctions_count" not in serialized_user + + +class TestUserSerializeAsUnauthenticatedUser(UserModelAssertMixin): + def test_it_returns_limited_user_infos_by_default( + self, app: Flask, user_1: User + ) -> None: + serialized_user = user_1.serialize(light=False) + + assert serialized_user == { + 'created_at': user_1.created_at, + 'followers': user_1.followers.count(), + 'following': user_1.following.count(), + 'nb_workouts': user_1.workouts_count, + 'picture': user_1.picture is not None, + 'role': UserRole(user_1.role).name.lower(), + 'suspended_at': user_1.suspended_at, + 'username': user_1.username, + } + class TestInactiveUserSerialize(UserModelAssertMixin): def test_it_returns_is_active_to_false_for_inactive_user( @@ -196,26 +505,21 @@ def test_it_returns_is_active_to_false_for_inactive_user( app: Flask, inactive_user: User, ) -> None: - serialized_user = inactive_user.serialize(inactive_user) + serialized_user = inactive_user.serialize( + current_user=inactive_user, light=False + ) assert serialized_user['is_active'] is False -class TestUserSerializeAsRegularUser(UserModelAssertMixin): - def test_user_model_as_regular_user( - self, app: Flask, user_1: User, user_2: User - ) -> None: - with pytest.raises(UserNotFoundException): - user_2.serialize(user_1) - - +@pytest.mark.disable_autouse_update_records_patch class TestUserRecords(UserModelAssertMixin): def test_it_returns_empty_list_when_no_workouts( self, app: Flask, user_1: User, ) -> None: - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['records'] == [] @@ -223,20 +527,21 @@ def test_it_returns_user_records( self, app: Flask, user_1: User, + user_2: User, sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert len(serialized_user['records']) == 4 - assert serialized_user['records'][0]['record_type'] - assert serialized_user['records'][0]['sport_id'] == sport_1_cycling.id - assert serialized_user['records'][0]['user'] == user_1.username - assert serialized_user['records'][0]['value'] > 0 - assert ( - serialized_user['records'][0]['workout_id'] - == workout_cycling_user_1.short_id + records = sorted( + serialized_user['records'], key=lambda r: r['record_type'] ) - assert serialized_user['records'][0]['workout_date'] + assert records[0]['record_type'] == 'AS' + assert records[0]['sport_id'] == sport_1_cycling.id + assert records[0]['user'] == user_1.username + assert records[0]['value'] > 0 + assert records[0]['workout_id'] == workout_cycling_user_1.short_id + assert records[0]['workout_date'] def test_it_returns_totals_when_user_has_workout_without_ascent( self, @@ -245,7 +550,7 @@ def test_it_returns_totals_when_user_has_workout_without_ascent( sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['total_ascent'] == 0 assert serialized_user['total_distance'] == 10 assert serialized_user['total_duration'] == '1:00:00' @@ -258,7 +563,7 @@ def test_it_returns_totals_when_user_has_workout_with_ascent( workout_cycling_user_1: Workout, ) -> None: workout_cycling_user_1.ascent = 100 - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['total_ascent'] == 100 assert serialized_user['total_distance'] == 10 assert serialized_user['total_duration'] == '1:00:00' @@ -273,7 +578,7 @@ def test_it_returns_totals_when_user_has_multiple_workouts( workout_running_user_1: Workout, ) -> None: workout_cycling_user_1.ascent = 100 - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['total_ascent'] == 100 assert serialized_user['total_distance'] == 22 assert serialized_user['total_duration'] == '2:40:00' @@ -285,7 +590,7 @@ def test_it_returns_infos_when_no_workouts( app: Flask, user_1: User, ) -> None: - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['nb_sports'] == 0 assert serialized_user['nb_workouts'] == 0 @@ -300,7 +605,7 @@ def test_it_returns_infos_when_only_one_workout_exists( sport_1_cycling: Sport, workout_cycling_user_1: Workout, ) -> None: - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['nb_sports'] == 1 assert serialized_user['nb_workouts'] == 1 @@ -322,7 +627,7 @@ def test_it_returns_infos_when_several_sports( workout_cycling_user_1: Workout, workout_running_user_1: Workout, ) -> None: - serialized_user = user_1.serialize(user_1) + serialized_user = user_1.serialize(current_user=user_1, light=False) assert serialized_user['nb_sports'] == 2 assert serialized_user['nb_workouts'] == 2 @@ -484,3 +789,927 @@ def test_it_returns_errored_export(self, app: Flask, user_1: User) -> None: assert serialized_data_export["status"] == "errored" assert serialized_data_export["file_name"] is None assert serialized_data_export["file_size"] is None + + +class TestFollowRequestModel: + def test_follow_request_model( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + assert '' == str( + follow_request_from_user_1_to_user_2 + ) + + serialized_follow_request = ( + follow_request_from_user_1_to_user_2.serialize() + ) + assert serialized_follow_request['from_user'] == user_1.serialize() + assert serialized_follow_request['to_user'] == user_2.serialize() + + def test_it_deletes_follow_request_on_followed_user_delete( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + followed_user_id = user_2.id + + db.session.delete(user_2) + + assert ( + FollowRequest.query.filter_by( + follower_user_id=user_1.id, + followed_user_id=followed_user_id, + ).first() + is None + ) + + def test_it_deletes_follow_request_on_follower_user_delete( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follower_user_id = user_1.id + + db.session.delete(user_1) + + assert ( + FollowRequest.query.filter_by( + follower_user_id=follower_user_id, + followed_user_id=user_2.id, + ).first() + is None + ) + + +class TestUserFollowingModel: + def test_user_2_sends_follow_requests_to_user_1( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + follow_request = user_2.send_follow_request_to(user_1) + + assert follow_request in user_2.sent_follow_requests.all() + + def test_user_1_receives_follow_requests_from_user_2( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + follow_request = user_2.send_follow_request_to(user_1) + + assert follow_request in user_1.received_follow_requests.all() + + def test_user_has_pending_follow_request( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + assert ( + follow_request_from_user_2_to_user_1 + in user_1.pending_follow_requests + ) + + def test_user_has_no_pending_follow_request( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + assert user_1.pending_follow_requests == [] + + def test_user_approves_follow_request( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + follow_request = user_1.approves_follow_request_from(user_2) + + assert follow_request.is_approved + assert user_1.pending_follow_requests == [ + follow_request_from_user_3_to_user_1 + ] + + def test_user_refuses_follow_request( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request = user_1.rejects_follow_request_from(user_2) + + assert not follow_request.is_approved + assert user_1.pending_follow_requests == [] + + def test_it_raises_error_if_follow_request_does_not_exist( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + with pytest.raises(NotExistingFollowRequestError): + user_1.approves_follow_request_from(user_2) + + def test_it_raises_error_if_user_approves_follow_request_already_processed( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + + with pytest.raises(FollowRequestAlreadyProcessedError): + user_1.approves_follow_request_from(user_2) + + def test_follow_request_is_automatically_accepted_if_manually_approved_if_false( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_1.manually_approves_followers = False + follow_request = user_2.send_follow_request_to(user_1) + + assert follow_request in user_2.sent_follow_requests.all() + assert follow_request.is_approved is True + assert follow_request.updated_at is not None + + +class TestUserUnfollowModel: + def test_it_raises_error_if_follow_request_does_not_exist( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + with pytest.raises(NotExistingFollowRequestError): + user_1.unfollows(user_2) + + def test_user_1_unfollows_user_2( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_1_to_user_2.is_approved = True + follow_request_from_user_1_to_user_2.updated_at = datetime.utcnow() + + user_1.unfollows(user_2) + + assert user_1.following.count() == 0 + assert user_2.followers.count() == 0 + + def test_it_removes_pending_follow_request( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_1.unfollows(user_2) + + assert user_1.sent_follow_requests.all() == [] + + +class TestUserFollowers: + def test_it_returns_empty_list_if_no_followers( + self, + app: Flask, + user_1: User, + ) -> None: + assert user_1.followers.all() == [] + + def test_it_returns_empty_list_if_follow_request_is_not_approved( + self, + app: Flask, + user_1: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + assert user_1.followers.all() == [] + + def test_it_returns_follower_if_follow_request_is_approved( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.is_approved = True + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + + assert user_1.followers.all() == [user_2] + + def test_it_does_return_suspended_follower( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_2_to_user_1.is_approved = True + follow_request_from_user_2_to_user_1.updated_at = datetime.utcnow() + user_2.suspended_at = datetime.utcnow() + + assert user_1.followers.all() == [] + + +class TestUserFollowing: + def test_it_returns_empty_list_if_no_following( + self, + app: Flask, + user_1: User, + ) -> None: + assert user_1.following.all() == [] + + def test_it_returns_empty_list_if_follow_request_is_not_approved( + self, + app: Flask, + user_1: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + assert user_1.following.all() == [] + + def test_it_returns_following_if_follow_request_is_approved( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_1_to_user_2.is_approved = True + follow_request_from_user_1_to_user_2.updated_at = datetime.utcnow() + + assert user_1.following.all() == [user_2] + + def test_it_does_not_return_suspended_following_user( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + follow_request_from_user_1_to_user_2.is_approved = True + follow_request_from_user_1_to_user_2.updated_at = datetime.utcnow() + user_2.suspended_at = datetime.utcnow() + + assert user_1.following.all() == [] + + +class TestUserFollowRequestStatus(UserModelAssertMixin): + def test_it_returns_user_1_and_user_2_dont_not_follow_each_other( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + serialized_user = user_2.serialize(current_user=user_1, light=False) + + assert serialized_user['followers'] == 0 + assert serialized_user['following'] == 0 + assert serialized_user['follows'] == 'false' + assert serialized_user['is_followed_by'] == 'false' + + def test_status_when_follow_request_is_pending( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_1.send_follow_request_to(user_2) + + serialized_user = user_2.serialize(current_user=user_1, light=False) + + assert serialized_user['followers'] == 0 + assert serialized_user['following'] == 0 + assert serialized_user['follows'] == 'false' + assert serialized_user['is_followed_by'] == 'pending' + + def test_status_when_user_rejects_follow_request( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_1.send_follow_request_to(user_2) + user_2.rejects_follow_request_from(user_1) + + serialized_user = user_2.serialize(current_user=user_1, light=False) + + assert serialized_user['followers'] == 0 + assert serialized_user['following'] == 0 + assert serialized_user['follows'] == 'false' + assert serialized_user['is_followed_by'] == 'pending' + + def test_followed_user_status_when_follow_request_is_approved( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_1.send_follow_request_to(user_2) + user_2.approves_follow_request_from(user_1) + + serialized_user = user_2.serialize(current_user=user_1, light=False) + + assert serialized_user['followers'] == 1 + assert serialized_user['following'] == 0 + assert serialized_user['follows'] == 'false' + assert serialized_user['is_followed_by'] == 'true' + + def test_follower_status_when_follow_request_is_approved( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + user_1.send_follow_request_to(user_2) + user_2.approves_follow_request_from(user_1) + + serialized_user = user_1.serialize(current_user=user_2, light=False) + + assert serialized_user['followers'] == 0 + assert serialized_user['following'] == 1 + assert serialized_user['follows'] == 'true' + assert serialized_user['is_followed_by'] == 'false' + + +class TestUserLinkifyMention: + def test_it_returns_linkified_mention_with_username( + self, + app: Flask, + user_1: User, + ) -> None: + assert user_1.linkify_mention() == ( + f'@{user_1.username}' + ) + + +class TestBlocksUser: + def test_it_blocks_user( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_1.blocks_user(user_2) + + assert ( + BlockedUser.query.filter_by( + user_id=user_2.id, + by_user_id=user_1.id, + ).first() + is not None + ) + serialized_user = user_2.serialize(current_user=user_1, light=False) + assert serialized_user['blocked'] is True + + def test_it_inits_created_at( + self, app: Flask, user_1: User, user_2: User + ) -> None: + now = datetime.utcnow() + with travel(now, tick=False): + user_1.blocks_user(user_2) + + blocked_user = BlockedUser.query.filter_by( + user_id=user_2.id, + by_user_id=user_1.id, + ).first() + assert blocked_user.created_at == now + + def test_it_does_not_raises_error_when_user_is_already_blocked( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_1.blocks_user(user_2) + + user_1.blocks_user(user_2) + + assert ( + BlockedUser.query.filter_by( + user_id=user_2.id, + by_user_id=user_1.id, + ).first() + is not None + ) + + def test_user_can_not_block_itself(self, app: Flask, user_1: User) -> None: + with pytest.raises(BlockUserException): + user_1.blocks_user(user_1) + + def test_it_blocks_a_follower( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + + user_1.blocks_user(user_2) + + assert ( + BlockedUser.query.filter_by( + user_id=user_2.id, + by_user_id=user_1.id, + ).first() + is not None + ) + serialized_user = user_2.serialize(current_user=user_1, light=False) + assert serialized_user['blocked'] is True + + def test_it_deletes_follow_request_when_a_follow_request_exists( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.blocks_user(user_2) + + assert ( + FollowRequest.query.filter_by( + follower_user_id=user_2.id, + followed_user_id=user_1.id, + ).first() + is None + ) + + def test_it_deletes_follow_request_notification_when_a_follow_request_exists( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.blocks_user(user_2) + + assert ( + Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + ).first() + is None + ) + + def test_it_deletes_follow_request_when_user_blocks_a_follower( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + + user_1.blocks_user(user_2) + + assert ( + FollowRequest.query.filter_by( + follower_user_id=user_2.id, + followed_user_id=user_1.id, + ).first() + is None + ) + + def test_it_deletes_follow_request_notification_when_user_blocks_a_follower( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + + user_1.blocks_user(user_2) + + assert ( + Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + ).first() + is None + ) + + +class TestUnBlocksUser: + def test_it_unblocks_user( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_1.blocks_user(user_2) + + user_1.unblocks_user(user_2) + + assert ( + BlockedUser.query.filter_by( + user_id=user_2.id, + by_user_id=user_1.id, + ).first() + is None + ) + serialized_user = user_2.serialize(current_user=user_1, light=False) + assert serialized_user['blocked'] is False + + def test_it_does_not_raises_error_when_user_is_not_blocked( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_1.unblocks_user(user_2) + + assert ( + BlockedUser.query.filter_by( + user_id=user_2.id, + by_user_id=user_1.id, + ).first() + is None + ) + + +class TestIsBlockedBy: + def test_it_returns_false_when_user_is_not_blocked( + self, app: Flask, user_1: User, user_2: User + ) -> None: + assert user_1.is_blocked_by(user_2) is False + + def test_it_returns_true_when_user_is_blocked( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_2.blocks_user(user_1) + + user_1.is_blocked_by(user_2) + + assert user_1.is_blocked_by(user_2) is True + + +class TestBlockedUsers: + def test_it_returns_empty_list_when_no_user_blocked( + self, app: Flask, user_1: User + ) -> None: + assert user_1.blocked_users.all() == [] + + def test_it_returns_blocked_users( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + ) -> None: + user_1.blocks_user(user_2) + user_1.blocks_user(user_4) + + assert set(user_1.get_blocked_user_ids()) == {user_2.id, user_4.id} + + +class TestBlockedByUsers: + def test_it_returns_empty_list_when_not_blocked( + self, app: Flask, user_1: User + ) -> None: + assert user_1.blocked_by_users.all() == [] + + def test_it_returns_blocked_by_users_ids( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + user_4: User, + ) -> None: + user_2.blocks_user(user_1) + user_3.blocks_user(user_1) + + assert set(user_1.get_blocked_by_user_ids()) == {user_2.id, user_3.id} + + +class TestUsersWithSuspensions(ReportMixin): + def test_suspension_action_is_none_when_no_suspension_for_given_user( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + action_type = "user_suspension" + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type=action_type, + report_id=self.create_report_user_action( + user_1_admin, user_2, action_type + ).id, + user_id=user_2.id, + ) + db.session.add(report_action) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + + assert user_3.suspension_action is None + + def test_suspension_action_is_last_suspension_action_when_user_is_suspended( # noqa + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + report_id = self.create_user_report(user_1_admin, user_2).id + for n in range(2): + action_type = ( + "user_suspension" if n % 2 == 0 else "user_unsuspension" + ) + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type=action_type, + report_id=report_id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.flush() + expected_report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type="user_suspension", + report_id=report_id, + user_id=user_2.id, + ) + user_2.suspended_at = datetime.utcnow() + db.session.add(expected_report_action) + db.session.commit() + + assert user_2.suspension_action == expected_report_action + + def test_suspension_action_is_none_when_user_is_unsuspended( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + report_id = self.create_report( + reporter=user_1_admin, reported_object=user_2 + ).id + for n in range(2): + action_type = ( + "user_suspension" if n % 2 == 0 else "user_unsuspension" + ) + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type=action_type, + report_id=report_id, + user_id=user_2.id, + ) + db.session.add(report_action) + db.session.commit() + + assert user_2.suspension_action is None + + def test_serializer_returns_related_report_id_when_user_is_admin( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + action_type = "user_suspension" + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type=action_type, + report_id=self.create_report_user_action( + user_1_admin, user_2, action_type + ).id, + user_id=user_2.id, + ) + db.session.add(report_action) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + + serialized_user = user_2.serialize(current_user=user_1_admin) + + assert ( + serialized_user["suspension_report_id"] == report_action.report_id + ) + + def test_serializer_does_not_return_related_report_id_when_user_is_not_admin( # noqa + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + action_type = "user_suspension" + report_action = ReportAction( + moderator_id=user_1_admin.id, + action_type=action_type, + report_id=self.create_report_user_action( + user_1_admin, user_2, action_type + ).id, + user_id=user_2.id, + ) + db.session.add(report_action) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + + serialized_user = user_2.serialize(current_user=user_3) + + assert "suspension_report_id" not in serialized_user + + +class TestUserLightSerializer(UserModelAssertMixin): + def test_it_returns_limited_user_infos_by_default( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + serialized_user = user_2.serialize(current_user=user_1_admin) + + assert serialized_user == { + 'blocked': user_2.is_blocked_by(user_1_admin), + 'created_at': user_2.created_at, + 'email': user_2.email, + 'followers': user_2.followers.count(), + 'following': user_2.following.count(), + 'follows': user_2.follows(user_1_admin), + 'is_active': True, + 'is_followed_by': user_2.is_followed_by(user_1_admin), + 'nb_workouts': user_2.workouts_count, + 'picture': user_2.picture is not None, + 'role': UserRole(user_2.role).name.lower(), + 'suspended_at': None, + 'username': user_2.username, + } + + def test_it_returns_limited_user_infos_as_admin( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + serialized_user = user_2.serialize( + current_user=user_1_admin, light=True + ) + + assert serialized_user == { + 'blocked': user_2.is_blocked_by(user_1_admin), + 'created_at': user_2.created_at, + 'email': user_2.email, + 'followers': user_2.followers.count(), + 'following': user_2.following.count(), + 'follows': user_2.follows(user_1_admin), + 'is_active': True, + 'is_followed_by': user_2.is_followed_by(user_1_admin), + 'nb_workouts': user_2.workouts_count, + 'picture': user_2.picture is not None, + 'role': UserRole(user_2.role).name.lower(), + 'suspended_at': None, + 'username': user_2.username, + } + + def test_it_returns_limited_user_infos_as_user( + self, app: Flask, user_1: User, user_2: User + ) -> None: + serialized_user = user_2.serialize(current_user=user_1, light=True) + + assert serialized_user == { + 'blocked': user_2.is_blocked_by(user_1), + 'created_at': user_2.created_at, + 'followers': user_2.followers.count(), + 'following': user_2.following.count(), + 'follows': user_2.follows(user_1), + 'is_followed_by': user_2.is_followed_by(user_1), + 'nb_workouts': user_2.workouts_count, + 'picture': user_2.picture is not None, + 'role': UserRole(user_2.role).name.lower(), + 'suspended_at': user_2.suspended_at, + 'username': user_2.username, + } + + def test_it_returns_limited_user_infos_as_unauthenticated_user( + self, app: Flask, user_1_admin: User, user_2: User + ) -> None: + serialized_user = user_2.serialize(current_user=None, light=True) + + assert serialized_user == { + 'created_at': user_2.created_at, + 'followers': user_2.followers.count(), + 'following': user_2.following.count(), + 'nb_workouts': user_2.workouts_count, + 'picture': user_2.picture is not None, + 'role': UserRole(user_2.role).name.lower(), + 'suspended_at': user_2.suspended_at, + 'username': user_2.username, + } + + +class TestUserAllReportsCount(ReportMixin, CommentMixin): + def test_it_returns_0_when_user_has_not_been_reported( + self, app: Flask, user_1: User + ) -> None: + assert user_1.all_reports_count == { + "created_reports_count": 0, + "reported_count": 0, + "sanctions_count": 0, + } + + def test_it_returns_number_of_reports( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + self.create_user_report(user_1_admin, user_2) + self.create_user_report(user_2, user_3) + self.create_user_report(user_3, user_2) + self.create_report_user_action(user_1_admin, user_2) + + assert user_2.all_reports_count == { + "created_reports_count": 1, + "reported_count": 3, + "sanctions_count": 1, + } + + @pytest.mark.parametrize( + 'input_action_type', ["user_unsuspension", "user_warning_lifting"] + ) + def test_it_does_not_count_user_report_action_that_is_not_a_sanction( + self, + app: Flask, + user_1: User, + user_2_admin: User, + input_action_type: str, + ) -> None: + self.create_report_user_action( + user_2_admin, user_1, action_type=input_action_type + ) + + assert user_1.all_reports_count["sanctions_count"] == 0 + + def test_it_does_not_count_workout_unsuspension( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + self.create_report_workout_action( + user_2_admin, + user_1, + workout_cycling_user_1, + "workout_unsuspension", + ) + + assert user_1.all_reports_count["sanctions_count"] == 0 + + def test_it_does_not_count_comment_unsuspension( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + self.create_report_comment_action( + user_2_admin, user_1, comment, "comment_unsuspension" + ) + + assert user_1.all_reports_count["sanctions_count"] == 0 + + +class TestUserSanctionsCount(ReportMixin, CommentMixin): + def test_it_returns_sanctions_count( + self, app: Flask, user_1_admin: User, user_2: User, user_3: User + ) -> None: + self.create_report_user_action(user_1_admin, user_2) + self.create_report_user_action(user_1_admin, user_3) + + assert user_2.sanctions_count == 1 + + @pytest.mark.parametrize( + 'input_action_type', ["user_unsuspension", "user_warning_lifting"] + ) + def test_it_does_not_count_user_report_action_that_is_not_a_sanction( + self, + app: Flask, + user_1: User, + user_2_admin: User, + input_action_type: str, + ) -> None: + self.create_report_user_action( + user_2_admin, user_1, action_type=input_action_type + ) + + assert user_1.sanctions_count == 0 + + def test_it_does_not_count_workout_unsuspension( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + self.create_report_workout_action( + user_2_admin, + user_1, + workout_cycling_user_1, + "workout_unsuspension", + ) + + assert user_1.sanctions_count == 0 + + def test_it_does_not_count_comment_unsuspension( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.create_comment(user_1, workout_cycling_user_1) + self.create_report_comment_action( + user_2_admin, user_1, comment, "comment_unsuspension" + ) + + assert user_1.sanctions_count == 0 diff --git a/fittrackee/tests/users/test_users_notifications_api.py b/fittrackee/tests/users/test_users_notifications_api.py new file mode 100644 index 000000000..1ebe110cb --- /dev/null +++ b/fittrackee/tests/users/test_users_notifications_api.py @@ -0,0 +1,1932 @@ +import json +from datetime import datetime +from unittest.mock import patch + +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.comments.models import CommentLike +from fittrackee.users.models import FollowRequest, Notification, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout, WorkoutLike + +from ..comments.mixins import CommentMixin +from ..mixins import ApiTestCaseMixin, ReportMixin +from ..utils import OAUTH_SCOPES, jsonify_dict + + +class TestUserNotifications(CommentMixin, ReportMixin, ApiTestCaseMixin): + route = "/api/notifications" + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.get(self.route, content_type="application/json") + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + self.route, + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_returns_empty_list_when_no_notifications( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_returns_notifications_by_default_ordered_by_descending_creation_date( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_3_notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + follow_request_from_user_3_notification.marked_as_read = True + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + follow_request_from_user_2_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + assert data["status"] == "success" + assert data["notifications"] == [ + jsonify_dict(follow_request_from_user_3_notification.serialize()), + jsonify_dict(follow_request_from_user_2_notification.serialize()), + ] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 2, + } + + def test_it_returns_only_unread_notifications( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_3_notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + follow_request_from_user_3_notification.marked_as_read = True + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"{self.route}?status=unread", + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + follow_request_from_user_2_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + assert data["status"] == "success" + assert data["notifications"] == [ + jsonify_dict(follow_request_from_user_2_notification.serialize()), + ] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + def test_it_returns_only_read_notifications( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + follow_request_from_user_3_notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + follow_request_from_user_3_notification.marked_as_read = True + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"{self.route}?status=read", + 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["notifications"] == [ + jsonify_dict(follow_request_from_user_3_notification.serialize()), + ] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + @patch("fittrackee.users.notifications.DEFAULT_NOTIFICATION_PER_PAGE", 2) + def test_it_returns_given_page( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"{self.route}?page=2", + 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" + follow_request_from_user_2_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + assert data["notifications"] == [ + jsonify_dict(follow_request_from_user_2_notification.serialize()), + ] + assert data["pagination"] == { + "has_next": False, + "has_prev": True, + "page": 2, + "pages": 2, + "total": 3, + } + + @patch("fittrackee.users.notifications.DEFAULT_NOTIFICATION_PER_PAGE", 2) + def test_it_returns_notifications_in_given_order_page( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"{self.route}?order=asc", + 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" + follow_request_from_user_2_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + follow_request_from_user_3_notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + assert data["notifications"] == [ + jsonify_dict(follow_request_from_user_2_notification.serialize()), + jsonify_dict(follow_request_from_user_3_notification.serialize()), + ] + assert data["pagination"] == { + "has_next": True, + "has_prev": False, + "page": 1, + "pages": 2, + "total": 3, + } + + def test_it_returns_notifications_for_a_given_type( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + user_1.approves_follow_request_from(user_3) + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"{self.route}?type=follow_request", + 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" + follow_request_from_user_2_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + assert data["notifications"] == [ + jsonify_dict(follow_request_from_user_2_notification.serialize()), + ] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + def test_it_does_not_return_workout_like_notification_from_blocked_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + user_1.blocks_user(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_comment_like_notification_from_blocked_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + like = CommentLike(user_id=user_2.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + user_1.blocks_user(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_comment_notification_from_blocked_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_1.blocks_user(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_mention_notification_from_blocked_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_1.username}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=True, + ) + user_1.blocks_user(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_returns_workout_like_notification_when_author_blocks_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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" + like_from_user_2_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_type="workout_like", + ).first() + assert data["notifications"] == [ + jsonify_dict(like_from_user_2_notification.serialize()), + ] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + def test_it_returns_comment_like_notification_when_author_blocks_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + like = CommentLike(user_id=user_2.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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" + like_from_user_2_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_type="comment_like", + ).first() + assert data["notifications"] == [ + jsonify_dict(like_from_user_2_notification.serialize()), + ] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + def test_it_does_not_return_comment_notification_when_author_blocks_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_mention_notification_when_author_blocks_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_1.username}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=True, + ) + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_follow_request_from_suspended_user( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_accepted_follow_from_suspended_user( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_workout_like_notification_from_suspended_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_comment_like_notification_from_suspended_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_1, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + like = CommentLike(user_id=user_2.id, comment_id=comment.id) + db.session.add(like) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_comment_notification_from_suspended_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_mention_notification_from_suspended_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_1.username}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=True, + ) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_does_not_return_comment_notification_when_user_does_not_follow_author_anymore( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + # comment without mention + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + user_1.unfollows(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notifications"] == [] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 0, + "total": 0, + } + + def test_it_returns_report_notification( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + ) -> None: + self.create_user_report(user_2, user_3) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + self.route, + 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" + report_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1_admin.id, + event_type="report", + ).first() + assert data["notifications"] == [ + jsonify_dict(report_notification.serialize()) + ] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + def test_it_returns_report_notification_from_suspended_user( + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + ) -> None: + self.create_user_report(user_2, user_3) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + self.route, + 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" + report_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1_admin.id, + event_type="report", + ).first() + assert data["notifications"] == [ + jsonify_dict(report_notification.serialize()) + ] + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 1, + } + + def test_it_returns_suspension_appeal_notification( + self, + app: Flask, + user_1_admin: User, + user_2_admin: User, + user_3: User, + ) -> None: + report_action = self.create_report_user_action(user_2_admin, user_3) + self.create_action_appeal(report_action.id, user_3) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + self.route, + 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" + appeal_notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=user_1_admin.id, + event_type="suspension_appeal", + ).first() + assert ( + jsonify_dict(appeal_notification.serialize()) + in data["notifications"] + ) + assert data["pagination"] == { + "has_next": False, + "has_prev": False, + "page": 1, + "pages": 1, + "total": 2, + } + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'notifications:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + self.route, + content_type="application/json", + headers=dict(Authorization=f"Bearer {access_token}"), + ) + + self.assert_response_scope(response, can_access) + + +class TestUserNotificationPatch(ApiTestCaseMixin): + route = "/api/notifications/{notification_id}" + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.patch( + self.route.format(notification_id=self.random_int()), + content_type="application/json", + data=json.dumps(dict(read_status=True)), + ) + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + notification_id = self.random_int() + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.patch( + self.route.format(notification_id=notification_id), + content_type="application/json", + data=json.dumps(dict(read_status=True)), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_returns_404_if_notification_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + notification_id = self.random_int() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + self.route.format(notification_id=notification_id), + content_type="application/json", + data=json.dumps(dict(read_status=True)), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_message( + response, f"notification not found (id: {notification_id})" + ) + + def test_it_returns_404_if_auth_user_is_not_notification_recipient( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + notification = Notification.query.first() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + self.route.format(notification_id=notification.id), + content_type="application/json", + data=json.dumps(dict(read_status=True)), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_404_with_message( + response, f"notification not found (id: {notification.id})" + ) + + @pytest.mark.parametrize('input_read_status', [True, False]) + def test_it_updates_notification_status( + self, + app: Flask, + user_1: User, + user_3: User, + follow_request_from_user_3_to_user_1: FollowRequest, + input_read_status: bool, + ) -> None: + notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + if not input_read_status: + notification.marked_as_read = True + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + self.route.format(notification_id=notification.id), + content_type="application/json", + data=json.dumps(dict(read_status=input_read_status)), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + assert data["notification"] == jsonify_dict(notification.serialize()) + assert notification.marked_as_read is input_read_status + + def test_it_return_error_when_read_status_is_invalid( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + ) -> None: + notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + self.route.format(notification_id=notification.id), + content_type="application/json", + data=json.dumps(dict(read_status=self.random_string())), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_500(response) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'notifications:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.patch( + self.route.format(notification_id=self.random_int()), + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestUserNotificationsStatus(CommentMixin, ReportMixin, ApiTestCaseMixin): + route = "/api/notifications/unread" + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.get(self.route, content_type="application/json") + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + self.route, + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_returns_unread_as_false_when_no_notifications( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["unread"] is False + + def test_it_returns_unread_as_true_when_user_has_unread_notifications( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["unread"] is True + + def test_it_returns_unread_as_true_when_user_has_unread_report_notification( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + user_3: User, + ) -> None: + self.create_user_report(user_2, user_3) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + self.route, + 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["unread"] is True + + def test_it_returns_unread_as_true_when_user_has_unread_suspension_notification( # noqa + self, + app: Flask, + user_1_admin: User, + user_2_admin: User, + user_3: User, + ) -> None: + report_action = self.create_report_user_action(user_2_admin, user_3) + Notification.query.update({Notification.marked_as_read: True}) + db.session.commit() + self.create_action_appeal(report_action.id, user_3) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + self.route, + 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["unread"] is True + + def test_it_returns_unread_as_false_when_all_notifications_has_been_read( + self, + app: Flask, + user_1: User, + user_2: User, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + notification = Notification.query.first() + notification.marked_as_read = True + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["unread"] is False + + def test_it_returns_unread_as_false_when_notification_is_from_blocked_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + user_1.blocks_user(user_2) + + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["unread"] is False + + def test_it_returns_unread_as_false_when_notification_is_from_suspended_user( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.flush() + user_2.suspended_at = datetime.utcnow() + db.session.commit() + + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["unread"] is False + + def test_it_returns_unread_as_false_when_when_author_blocks_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + user_2.blocks_user(user_1) + + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["unread"] is False + + def test_it_returns_unread_as_false_when_user_does_not_follow_comment_author_anymore( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + # comment without mention + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + user_1.unfollows(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["unread"] is False + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'notifications:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1_admin: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1_admin, scope=client_scope + ) + + response = client.get( + self.route, + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestUserNotificationsMarkAllAsRead(ApiTestCaseMixin): + route = "/api/notifications/mark-all-as-read" + + @staticmethod + def assert_follow_notification_status( + follow_request: FollowRequest, status: bool + ) -> None: + follow_request_notification = Notification.query.filter_by( + from_user_id=follow_request.follower_user_id, + to_user_id=follow_request.followed_user_id, + event_type="follow_request", + ).first() + assert follow_request_notification.marked_as_read is status + + @staticmethod + def assert_workout_like_notification_status( + like: WorkoutLike, status: bool + ) -> None: + like_notification = Notification.query.filter_by( + from_user_id=like.user_id, + to_user_id=like.workout.user_id, + event_type="workout_like", + ).first() + assert like_notification.marked_as_read is status + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.post(self.route, content_type="application/json") + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + self.route, + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + self.assert_403(response) + + def test_it_does_not_return_error_when_no_notifications( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + 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" + + def test_it_marks_all_user_notifications_as_read( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_2_to_user_1: FollowRequest, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_1: FollowRequest, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + follow_request_from_user_3_notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=user_1.id, + event_type="follow_request", + ).first() + follow_request_from_user_3_notification.marked_as_read = True + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + 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" + self.assert_follow_notification_status( + follow_request_from_user_2_to_user_1, status=True + ) + self.assert_follow_notification_status( + follow_request_from_user_1_to_user_2, status=False + ) + self.assert_follow_notification_status( + follow_request_from_user_3_to_user_1, status=True + ) + self.assert_workout_like_notification_status(like, status=True) + + def test_it_marks_as_read_only_user_notifications_matching_provided_type( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps(dict(type="workout_like")), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + self.assert_follow_notification_status( + follow_request_from_user_2_to_user_1, status=False + ) + self.assert_workout_like_notification_status(like, status=True) + + @pytest.mark.parametrize('input_type', ['invalid_type', '']) + def test_it_does_not_mark_as_read_when_provided_type_is_invalid( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + input_type: str, + ) -> None: + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route, + content_type="application/json", + data=json.dumps(dict(type=input_type)), + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert data["status"] == "success" + self.assert_follow_notification_status( + follow_request_from_user_2_to_user_1, status=False + ) + self.assert_workout_like_notification_status(like, status=False) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'notifications:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + self.route, + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestUserNotificationTypes(CommentMixin, ReportMixin, ApiTestCaseMixin): + route = "/api/notifications/types" + + def test_it_returns_error_if_user_is_not_authenticated( + self, app: Flask + ) -> None: + client = app.test_client() + + response = client.get(self.route, content_type="application/json") + + self.assert_401(response) + + def test_it_returns_error_if_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + self.route, + content_type="application/json", + headers=dict(Authorization=f"Bearer {auth_token}"), + ) + + self.assert_403(response) + + def test_it_returns_empty_list_when_no_notifications( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route, + 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["notification_types"] == [] + + @pytest.mark.parametrize('input_params', ['', '?status=all']) + def test_it_returns_all_users_notifications_types( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_1_to_user_2: FollowRequest, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_params: str, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_1.username}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=True, + ) + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + like_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_type="workout_like", + ).first() + like_notification.marked_as_read = True + db.session.commit() + + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"{self.route}{input_params}", + 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 set(data["notification_types"]) == { + "workout_comment", + "workout_like", + } + + def test_it_returns_only_unread_notifications( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_1_to_user_2: FollowRequest, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_1.username}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=True, + ) + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + like_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_type="workout_like", + ).first() + like_notification.marked_as_read = True + db.session.commit() + + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"{self.route}?status=unread", + 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["notification_types"] == [ + "workout_comment", + ] + + def test_it_returns_only_read_notifications( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + follow_request_from_user_1_to_user_2: FollowRequest, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text=f"@{user_1.username}", + text_visibility=VisibilityLevel.PUBLIC, + with_mentions=True, + ) + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + like_notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_type="workout_like", + ).first() + like_notification.marked_as_read = True + db.session.commit() + + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f"{self.route}?status=read", + 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["notification_types"] == [ + "workout_like", + ] diff --git a/fittrackee/tests/users/test_users_notifications_model.py b/fittrackee/tests/users/test_users_notifications_model.py new file mode 100644 index 000000000..bb690ecd4 --- /dev/null +++ b/fittrackee/tests/users/test_users_notifications_model.py @@ -0,0 +1,1520 @@ +from datetime import datetime +from typing import Optional + +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.comments.models import Comment, CommentLike, Mention +from fittrackee.reports.models import ( + COMMENT_ACTION_TYPES, + WORKOUT_ACTION_TYPES, + Report, +) +from fittrackee.tests.comments.mixins import CommentMixin +from fittrackee.users.exceptions import InvalidNotificationTypeException +from fittrackee.users.models import FollowRequest, Notification, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout, WorkoutLike + +from ..mixins import ReportMixin +from ..utils import random_int, random_string + + +class NotificationTestCase: + @staticmethod + def create_mention(user: User, comment: Comment) -> Mention: + mention = Mention(comment.id, user.id) + db.session.add(mention) + db.session.commit() + return mention + + @staticmethod + def comment_workout( + user: User, + workout: Workout, + text: Optional[str] = None, + text_visibility: Optional[VisibilityLevel] = None, + ) -> Comment: + comment = Comment( + user_id=user.id, + workout_id=workout.id, + text=random_string() if text is None else text, + text_visibility=( + text_visibility if text_visibility else VisibilityLevel.PUBLIC + ), + ) + db.session.add(comment) + db.session.commit() + return comment + + @staticmethod + def like_comment(user: User, comment: Comment) -> CommentLike: + like = CommentLike(user_id=user.id, comment_id=comment.id) + db.session.add(like) + db.session.commit() + return like + + @staticmethod + def like_workout(user: User, workout: Workout) -> WorkoutLike: + like = WorkoutLike(user_id=user.id, workout_id=workout.id) + db.session.add(like) + db.session.commit() + return like + + +class TestNotification: + def test_it_raises_exception_when_type_is_invalid( + self, + app: Flask, + user_1: User, + user_2: User, + ) -> None: + with pytest.raises(InvalidNotificationTypeException): + Notification( + from_user_id=user_1, + to_user_id=user_2, + created_at=datetime.utcnow(), + event_type=random_string(), + event_object_id=random_int(), + ) + + +class TestNotificationForFollowRequest: + def test_it_creates_notification_on_follow_request( + self, app: Flask, user_1: User, user_2: User + ) -> None: + follow_request = user_1.send_follow_request_to(user_2) + + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + ).first() + assert notification.created_at == follow_request.created_at + assert notification.marked_as_read is False + assert notification.event_type == 'follow_request' + assert notification.event_object_id is None + + def test_it_creates_notification_on_follow_when_user_automatically_approves_request( # noqa + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_2.manually_approves_followers = False + follow_request = user_1.send_follow_request_to(user_2) + + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + ).first() + assert notification.created_at == follow_request.created_at + assert notification.marked_as_read is False + assert notification.event_type == 'follow' + assert notification.event_object_id is None + + def test_it_updates_notification_when_user_approves_follow_request( + self, app: Flask, user_1: User, user_2: User + ) -> None: + follow_request = user_1.send_follow_request_to(user_2) + user_2.approves_follow_request_from(user_1) + + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + ).first() + assert notification.created_at == follow_request.created_at + assert notification.marked_as_read is False + assert notification.event_type == 'follow' + assert notification.event_object_id is None + + def test_it_deletes_notification_when_user_rejects_follow_request( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_1.send_follow_request_to(user_2) + user_2.rejects_follow_request_from(user_1) + + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + ).first() + assert notification is None + + def test_it_deletes_notification_when_user_deletes_follow_request( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_1.send_follow_request_to(user_2) + user_1.unfollows(user_2) + + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + ).first() + assert notification is None + + def test_it_deletes_notification_when_user_unfollows( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_1.send_follow_request_to(user_2) + user_2.approves_follow_request_from(user_1) + user_1.unfollows(user_2) + + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + ).first() + assert notification is None + + def test_it_updates_notification_read_status_when_user_approves_follow_request( # noqa + self, app: Flask, user_1: User, user_2: User + ) -> None: + follow_request = user_1.send_follow_request_to(user_2) + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + ).first() + notification.marked_as_read = True + user_2.approves_follow_request_from(user_1) + + assert notification.created_at == follow_request.created_at + assert notification.marked_as_read is False + assert notification.event_type == 'follow' + assert notification.event_object_id is None + + @pytest.mark.parametrize('manually_approves_followers', [True, False]) + def test_it_deletes_notification_on_follow_request_delete( + self, + app: Flask, + user_1: User, + user_2: User, + manually_approves_followers: bool, + ) -> None: + follow_request = user_1.send_follow_request_to(user_2) + db.session.delete(follow_request) + + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + ).first() + assert notification is None + + def test_it_serializes_follow_request_notification( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_1.send_follow_request_to(user_2) + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] == { + **user_1.serialize(), + "follows": user_1.follows(user_2), + "is_followed_by": user_1.is_followed_by(user_2), + } + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == "follow_request" + assert "report_action" not in serialized_notification + assert "comment" not in serialized_notification + assert "report" not in serialized_notification + assert "workout" not in serialized_notification + + def test_it_serializes_follow_notification( + self, app: Flask, user_1: User, user_2: User + ) -> None: + user_2.manually_approves_followers = False + user_1.send_follow_request_to(user_2) + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=user_2.id, + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] == { + **user_1.serialize(), + "follows": user_1.follows(user_2), + "is_followed_by": user_1.is_followed_by(user_2), + } + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == "follow" + assert "report_action" not in serialized_notification + assert "comment" not in serialized_notification + assert "report" not in serialized_notification + assert "workout" not in serialized_notification + + +class TestNotificationForWorkoutLike(NotificationTestCase): + def test_it_creates_notification_on_workout_like( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_cycling_user_1: Workout, + ) -> None: + like = self.like_workout(user_2, workout_cycling_user_1) + + notification = Notification.query.filter_by( + from_user_id=like.user_id, + to_user_id=workout_cycling_user_1.user_id, + event_object_id=workout_cycling_user_1.id, + ).first() + assert notification.created_at == like.created_at + assert notification.marked_as_read is False + assert notification.event_type == 'workout_like' + + def test_it_deletes_notification_on_workout_like_delete( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_cycling_user_1: Workout, + ) -> None: + like = self.like_workout(user_2, workout_cycling_user_1) + + db.session.delete(like) + + notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=workout_cycling_user_1.user_id, + event_object_id=workout_cycling_user_1.id, + event_type='workout_like', + ).first() + assert notification is None + + def test_it_does_not_create_notification_when_user_likes_his_own_workout( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_cycling_user_1: Workout, + ) -> None: + self.like_workout(user_1, workout_cycling_user_1) + + notification = Notification.query.filter_by( + from_user_id=user_1.id, + to_user_id=workout_cycling_user_1.user_id, + event_object_id=workout_cycling_user_1.id, + ).first() + assert notification is None + + def test_it_does_not_raise_error_when_user_unlike_his_own_workout( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_cycling_user_1: Workout, + ) -> None: + like = self.like_workout(user_1, workout_cycling_user_1) + + db.session.delete(like) + + def test_it_serializes_workout_like_notification( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + workout_cycling_user_1: Workout, + ) -> None: + self.like_workout(user_2, workout_cycling_user_1) + notification = Notification.query.filter_by( + event_object_id=workout_cycling_user_1.id, + event_type='workout_like', + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] == user_2.serialize( + current_user=user_1 + ) + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == "workout_like" + assert serialized_notification[ + "workout" + ] == workout_cycling_user_1.serialize(user=user_1) + assert "report_action" not in serialized_notification + assert "comment" not in serialized_notification + assert "report" not in serialized_notification + + +class TestNotificationForWorkoutComment(NotificationTestCase): + def test_it_creates_notification_on_workout_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.comment_workout( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + + notification = Notification.query.filter_by( + from_user_id=comment.user_id, + to_user_id=workout_cycling_user_1.user_id, + event_object_id=comment.id, + ).first() + assert notification.created_at == comment.created_at + assert notification.marked_as_read is False + assert notification.event_type == 'workout_comment' + + def test_it_deletes_notification_on_workout_comment_delete( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.comment_workout( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + comment_id = comment.id + + db.session.delete(comment) + + notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=workout_cycling_user_1.user_id, + event_object_id=comment_id, + event_type='workout_comment', + ).first() + assert notification is None + + def test_it_does_not_create_notification_when_user_comments_his_own_workout( # noqa + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_1, workout_cycling_user_1) + + notification = Notification.query.filter_by( + from_user_id=comment.user_id, + to_user_id=workout_cycling_user_1.user_id, + event_object_id=comment.id, + ).first() + assert notification is None + + @pytest.mark.parametrize( + 'workout_visibility, text_visibility', + [ + (VisibilityLevel.FOLLOWERS, VisibilityLevel.FOLLOWERS), + (VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE), # no mention + ], + ) + def test_it_does_not_create_notification_when_visibility_does_not_allowed_it( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_visibility: VisibilityLevel, + text_visibility: VisibilityLevel, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + # user_1 does not follow user_2, user_2 follows user_1 + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = workout_visibility + comment = self.comment_workout( + user_2, workout_cycling_user_1, text_visibility=text_visibility + ) + + notification = Notification.query.filter_by( + from_user_id=comment.user_id, + to_user_id=workout_cycling_user_1.user_id, + event_object_id=comment.id, + ).first() + assert notification is None + + def test_it_does_not_raise_error_when_user_unlike_his_own_workout( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_1, workout_cycling_user_1) + + db.session.delete(comment) + + def test_it_serializes_workout_comment_notification( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.comment_workout(user_2, workout_cycling_user_1) + notification = Notification.query.filter_by( + event_object_id=comment.id, + event_type='workout_comment', + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification["comment"] == comment.serialize(user_1) + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] == user_2.serialize( + current_user=user_1 + ) + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == "workout_comment" + assert "report_action" not in serialized_notification + assert "report" not in serialized_notification + assert "workout" not in serialized_notification + + +class TestNotificationForWorkoutReportAction( + NotificationTestCase, ReportMixin +): + @pytest.mark.parametrize("input_report_action", WORKOUT_ACTION_TYPES) + def test_it_creates_notification_on_comment_report_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_report_action: str, + ) -> None: + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + + report_action = self.create_report_action( + user_1_moderator, + user_2, + action_type=input_report_action, + report_id=report.id, + workout_id=workout_cycling_user_2.id, + ) + + notification = Notification.query.filter_by( + from_user_id=user_1_moderator.id, + to_user_id=user_2.id, + event_object_id=workout_cycling_user_2.id, + ).first() + assert notification.created_at == report_action.created_at + assert notification.marked_as_read is False + assert notification.event_type == input_report_action + + @pytest.mark.parametrize("input_report_action", WORKOUT_ACTION_TYPES) + def test_it_serializes_comment_action_notification( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_report_action: str, + ) -> None: + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + report_action = self.create_report_action( + user_1_moderator, + user_2, + action_type=input_report_action, + report_id=report.id, + workout_id=workout_cycling_user_2.id, + ) + notification = Notification.query.filter_by( + from_user_id=user_1_moderator.id, + to_user_id=user_2.id, + event_object_id=workout_cycling_user_2.id, + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification[ + "report_action" + ] == report_action.serialize(user_2) + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] is None + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == input_report_action + assert serialized_notification[ + "workout" + ] == workout_cycling_user_2.serialize(user=user_2) + assert "comment" not in serialized_notification + assert "report" not in serialized_notification + + +class TestNotificationForCommentLike(NotificationTestCase): + def test_it_creates_notification_on_comment_like( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_2, workout_cycling_user_1) + like = self.like_comment(user_3, comment) + + notification = Notification.query.filter_by( + from_user_id=like.user_id, + to_user_id=comment.user_id, + event_object_id=like.id, + ).first() + assert notification.created_at == like.created_at + assert notification.marked_as_read is False + assert notification.event_type == 'comment_like' + + def test_it_deletes_notification_on_comment_like_delete( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_2, workout_cycling_user_1) + like = self.like_comment(user_3, comment) + like_id = like.id + + db.session.delete(like) + + notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=comment.user_id, + event_object_id=like_id, + event_type='comment_like', + ).first() + assert notification is None + + def test_it_does_not_create_notification_when_user_likes_to_his_comment( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_1, workout_cycling_user_1) + like = self.like_comment(user_1, comment) + + notification = Notification.query.filter_by( + from_user_id=like.user_id, + to_user_id=comment.user_id, + event_object_id=like.id, + ).first() + assert notification is None + + def test_it_does_not_raise_error_when_user_unlikes_on_his_own_comment( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_1, workout_cycling_user_1) + like = self.like_comment(user_1, comment) + + db.session.delete(like) + + def test_it_serializes_comment_like_notification( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout(user_1, workout_cycling_user_1) + like = self.like_comment(user_2, comment) + notification = Notification.query.filter_by( + event_object_id=like.id, + event_type='comment_like', + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["comment"] == comment.serialize(user_1) + assert serialized_notification["from"] == user_2.serialize( + current_user=user_1 + ) + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == "comment_like" + assert "report_action" not in serialized_notification + assert "report" not in serialized_notification + assert "workout" not in serialized_notification + + +class TestNotificationForCommentReportAction( + NotificationTestCase, ReportMixin +): + @pytest.mark.parametrize("input_report_action", COMMENT_ACTION_TYPES) + def test_it_creates_notification_on_comment_report_action( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_report_action: str, + ) -> None: + comment = self.comment_workout(user_3, workout_cycling_user_2) + report = self.create_report(reporter=user_2, reported_object=comment) + + report_action = self.create_report_action( + user_1_moderator, + user_3, + action_type=input_report_action, + report_id=report.id, + comment_id=comment.id, + ) + + notification = Notification.query.filter_by( + from_user_id=user_1_moderator.id, + to_user_id=user_3.id, + event_object_id=comment.id, + ).first() + assert notification.created_at == report_action.created_at + assert notification.marked_as_read is False + assert notification.event_type == input_report_action + + @pytest.mark.parametrize("input_report_action", COMMENT_ACTION_TYPES) + def test_it_serializes_comment_action_notification( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_report_action: str, + ) -> None: + comment = self.comment_workout(user_3, workout_cycling_user_2) + report = self.create_report(reporter=user_2, reported_object=comment) + report_action = self.create_report_action( + user_1_moderator, + user_3, + action_type=input_report_action, + report_id=report.id, + comment_id=comment.id, + ) + notification = Notification.query.filter_by( + from_user_id=user_1_moderator.id, + to_user_id=user_3.id, + event_object_id=comment.id, + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification[ + "report_action" + ] == report_action.serialize(user_3) + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["comment"] == comment.serialize(user_3) + assert serialized_notification["from"] is None + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == input_report_action + assert "report" not in serialized_notification + assert "workout" not in serialized_notification + + +class TestNotificationForMention(NotificationTestCase): + def test_it_creates_notification_on_mention( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout( + user_2, workout_cycling_user_1, text=f"@{user_3.username}" + ) + mention = self.create_mention(user_3, comment) + + notification = Notification.query.filter_by( + from_user_id=comment.user_id, + to_user_id=user_3.id, + event_object_id=comment.id, + ).first() + assert notification.created_at == mention.created_at + assert notification.marked_as_read is False + assert notification.event_type == 'mention' + + def test_it_does_not_create_notification_when_mentioned_user_is_workout_owner( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + """comment is visible to user""" + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.comment_workout( + user_2, + workout_cycling_user_1, + text=f"@{user_1.username}", + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_mention(user_1, comment) + + notifications = Notification.query.filter_by( + from_user_id=comment.user_id, + to_user_id=user_1.id, + ).all() + assert len(notifications) == 1 + assert notifications[0].created_at == comment.created_at + assert notifications[0].marked_as_read is False + assert notifications[0].event_type == 'workout_comment' + + @pytest.mark.parametrize( + 'input_visibility_level', + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE], + ) + def test_it_creates_notification_when_mentioned_user_is_workout_owner( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_visibility_level: VisibilityLevel, + ) -> None: + """comment is visible to user thanks to the mention""" + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.comment_workout( + user_2, + workout_cycling_user_1, + text=f"@{user_1.username}", + text_visibility=input_visibility_level, + ) + mention = self.create_mention(user_1, comment) + + notifications = Notification.query.filter_by( + from_user_id=comment.user_id, + to_user_id=user_1.id, + ).all() + assert len(notifications) == 1 + assert notifications[0].created_at == mention.created_at + assert notifications[0].marked_as_read is False + assert notifications[0].event_type == 'mention' + + def test_it_deletes_notification_on_mention_delete( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout( + user_2, workout_cycling_user_1, text=f"@{user_3.username}" + ) + mention = self.create_mention(user_3, comment) + comment_id = comment.id + + db.session.delete(mention) + + notification = Notification.query.filter_by( + from_user_id=comment.user_id, + to_user_id=user_3.id, + event_object_id=comment_id, + event_type='mention', + ).first() + assert notification is None + + def test_it_does_not_create_notification_on_own_mention( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout( + user_2, workout_cycling_user_1, text=f"@{user_2.username}" + ) + self.create_mention(user_2, comment) + + notification = Notification.query.filter_by( + from_user_id=comment.user_id, + to_user_id=user_2.id, + event_object_id=comment.id, + event_type='mention', + ).first() + assert notification is None + + def test_it_serializes_mention_notification( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.comment_workout( + user_1, workout_cycling_user_1, text=f"@{user_2.username}" + ) + self.create_mention(user_2, comment) + notification = Notification.query.filter_by( + from_user_id=comment.user_id, + to_user_id=user_2.id, + event_object_id=comment.id, + event_type='mention', + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification["comment"] == comment.serialize(user_2) + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] == user_1.serialize( + current_user=user_2 + ) + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == "mention" + assert "report_action" not in serialized_notification + assert "report" not in serialized_notification + assert "workout" not in serialized_notification + + +class TestMultipleNotificationsForComment(NotificationTestCase): + def test_it_deletes_all_notifications_on_comment_with_mention_and_like_delete( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + comment = self.comment_workout( + user_2, workout_cycling_user_1, text=f"@{user_3.username}" + ) + comment_id = comment.id + self.create_mention(user_3, comment) + self.like_comment(user_3, comment) + + db.session.delete(comment) + + # workout_comment notification is deleted + assert ( + Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1.id, + event_object_id=comment_id, + event_type="workout_comment", + ).first() + is None + ) + # mention notification is deleted + assert ( + Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_3.id, + event_object_id=comment_id, + event_type="mention", + ).first() + is None + ) + # like notification is deleted + assert ( + Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_3.id, + event_object_id=comment_id, + event_type="comment_like", + ).first() + is None + ) + + +class TestNotificationForReport(NotificationTestCase): + def test_it_does_not_create_notifications_when_no_admin( + self, app: Flask, user_1: User, user_2: User + ) -> None: + report = Report( + note=random_string(), + reported_by=user_1.id, + reported_object=user_2, + ) + db.session.add(report) + db.session.commit() + + notification = Notification.query.filter_by( + event_type='report', event_object_id=report.id + ).first() + + assert notification is None + + def test_it_does_not_create_notification_when_admin_is_reporter( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + report = Report( + note=random_string(), + reported_by=user_1_moderator.id, + reported_object=user_2, + ) + db.session.add(report) + db.session.commit() + + notification = Notification.query.filter_by( + event_type='report', event_object_id=report.id + ).first() + + assert notification is None + + def test_it_does_not_create_notification_when_admin_is_inactive( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = Report( + note=random_string(), + reported_by=user_3.id, + reported_object=user_2, + ) + db.session.add(report) + user_1_moderator.is_active = False + db.session.commit() + + notification = Notification.query.filter_by( + event_type='report', event_object_id=report.id + ).first() + assert notification is None + + def test_it_creates_notification_on_report_creation( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = Report( + note=random_string(), + reported_by=user_3.id, + reported_object=user_2, + ) + db.session.add(report) + db.session.commit() + + notification = Notification.query.filter_by( + from_user_id=user_3.id, + to_user_id=user_1_moderator.id, + ).first() + assert notification.created_at == report.created_at + assert notification.marked_as_read is False + assert notification.event_type == 'report' + assert notification.event_object_id == report.id + + def test_it_creates_notifications_for_all_admins_and_moderators( + self, + app: Flask, + user_1_moderator: User, + user_2_admin: User, + user_3: User, + user_4: User, + ) -> None: + report = Report( + note=random_string(), + reported_by=user_3.id, + reported_object=user_4, + ) + db.session.add(report) + db.session.commit() + + notifications = Notification.query.filter_by( + from_user_id=user_3.id, + ).all() + assert len(notifications) == 2 + assert {notifications[0].to_user_id, notifications[1].to_user_id} == { + user_1_moderator.id, + user_2_admin.id, + } + + def test_it_serializes_report_notification( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + comment = self.comment_workout(user_3, workout_cycling_user_2) + report = Report( + note=random_string(), + reported_by=user_2.id, + reported_object=comment, + ) + db.session.add(report) + db.session.commit() + notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1_moderator.id, + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] == user_2.serialize( + current_user=user_1_moderator + ) + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["report"] == report.serialize( + user_1_moderator + ) + assert serialized_notification["type"] == "report" + + +class TestNotificationForSuspensionAppeal(CommentMixin, ReportMixin): + def test_it_does_not_create_notification_when_admin_is_inactive( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + suspension_action = self.create_report_user_action( + user_1_moderator, user_2 + ) + self.create_action_appeal( + suspension_action.id, user_2, with_commit=False + ) + user_1_moderator.is_active = False + db.session.commit() + + notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1_moderator.id, + ).first() + assert notification is None + + def test_it_creates_notification_on_user_appeal( + self, app: Flask, user_1_moderator: User, user_2: User + ) -> None: + suspension_action = self.create_report_user_action( + user_1_moderator, user_2 + ) + appeal = self.create_action_appeal(suspension_action.id, user_2) + + notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1_moderator.id, + ).first() + assert notification.created_at == appeal.created_at + assert notification.marked_as_read is False + assert notification.event_type == "suspension_appeal" + assert notification.event_object_id == appeal.id + + def test_it_creates_notification_on_workout_suspension_appeal( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_suspension = self.create_report_workout_action( + user_1_moderator, user_2, workout_cycling_user_2 + ) + db.session.add(workout_suspension) + db.session.flush() + + # appeal = ReportActionAppeal( + # workout_suspension.id, user_2.id, self.random_string() + # ) + + appeal = self.create_action_appeal(workout_suspension.id, user_2) + db.session.add(appeal) + db.session.commit() + + notifications = Notification.query.filter_by( + event_type='suspension_appeal' + ).all() + assert len(notifications) == 1 + assert notifications[0].from_user_id == user_2.id + assert notifications[0].to_user_id == user_1_moderator.id + assert notifications[0].event_object_id == workout_suspension.id + + def test_it_creates_notification_on_comment_suspension_appeal( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + comment_suspension = self.create_report_comment_action( + user_1_moderator, user_2, comment + ) + db.session.add(comment_suspension) + db.session.flush() + + # appeal = ReportActionAppeal( + # comment_suspension.id, user_2.id, self.random_string() + # ) + appeal = self.create_action_appeal(comment_suspension.id, user_2) + db.session.add(appeal) + db.session.commit() + + notifications = Notification.query.filter_by( + event_type='suspension_appeal' + ).all() + assert len(notifications) == 1 + assert notifications[0].from_user_id == user_2.id + assert notifications[0].to_user_id == user_1_moderator.id + assert notifications[0].event_object_id == comment_suspension.id + + def test_it_serializes_suspension_appeal_notification( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_3, reported_object=user_2) + suspension_action = self.create_report_user_action( + user_1_moderator, user_2, report_id=report.id + ) + self.create_action_appeal(suspension_action.id, user_2) + notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1_moderator.id, + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] == user_2.serialize( + current_user=user_1_moderator + ) + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["report"] == report.serialize( + user_1_moderator + ) + assert serialized_notification["type"] == "suspension_appeal" + + +class TestNotificationForUserWarning(NotificationTestCase, ReportMixin): + @pytest.mark.parametrize( + 'input_action_type', ['user_warning', 'user_warning_lifting'] + ) + def test_it_creates_notification_on_user_action_on_user_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_action_type: str, + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + + report_action = self.create_report_action( + user_1_moderator, + user_3, + action_type=input_action_type, + report_id=report.id, + ) + + notification = Notification.query.filter_by( + from_user_id=user_1_moderator.id, + to_user_id=user_3.id, + ).first() + assert notification.created_at == report_action.created_at + assert notification.marked_as_read is False + assert notification.event_type == input_action_type + + @pytest.mark.parametrize( + 'input_action_type', ['user_warning', 'user_warning_lifting'] + ) + def test_it_serializes_user_action_notification_on_user_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_action_type: str, + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + report_action = self.create_report_action( + user_1_moderator, + user_3, + action_type=input_action_type, + report_id=report.id, + ) + notification = Notification.query.filter_by( + from_user_id=user_1_moderator.id, + to_user_id=user_3.id, + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification[ + "report_action" + ] == report_action.serialize(user_3) + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] is None + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == input_action_type + assert "comment" not in serialized_notification + assert "report" not in serialized_notification + assert "workout" not in serialized_notification + + @pytest.mark.parametrize( + 'input_action_type', ['user_warning', 'user_warning_lifting'] + ) + def test_it_creates_notification_on_user_action_on_workout_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_action_type: str, + ) -> None: + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + + report_action = self.create_report_action( + user_1_moderator, + user_2, + action_type=input_action_type, + report_id=report.id, + ) + + notification = Notification.query.filter_by( + from_user_id=user_1_moderator.id, + to_user_id=user_2.id, + ).first() + assert notification.created_at == report_action.created_at + assert notification.marked_as_read is False + assert notification.event_object_id == workout_cycling_user_2.id + assert notification.event_type == input_action_type + + @pytest.mark.parametrize( + 'input_action_type', ['user_warning', 'user_warning_lifting'] + ) + def test_it_serializes_user_action_notification_on_workout_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_action_type: str, + ) -> None: + report = self.create_report( + reporter=user_3, reported_object=workout_cycling_user_2 + ) + report_action = self.create_report_action( + user_1_moderator, + user_2, + action_type=input_action_type, + report_id=report.id, + ) + notification = Notification.query.filter_by( + from_user_id=user_1_moderator.id, + to_user_id=user_2.id, + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification[ + "report_action" + ] == report_action.serialize(user_2) + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] is None + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == input_action_type + assert serialized_notification[ + "workout" + ] == workout_cycling_user_2.serialize(user=user_2) + assert "comment" not in serialized_notification + assert "report" not in serialized_notification + + @pytest.mark.parametrize( + 'input_action_type', ['user_warning', 'user_warning_lifting'] + ) + def test_it_creates_notification_on_user_action_on_comment_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_action_type: str, + ) -> None: + comment = self.comment_workout(user_3, workout_cycling_user_2) + report = self.create_report(reporter=user_2, reported_object=comment) + + report_action = self.create_report_action( + user_1_moderator, + user_3, + action_type=input_action_type, + report_id=report.id, + ) + + notification = Notification.query.filter_by( + from_user_id=user_1_moderator.id, + to_user_id=user_3.id, + ).first() + assert notification.created_at == report_action.created_at + assert notification.marked_as_read is False + assert notification.event_object_id == comment.id + assert notification.event_type == input_action_type + + @pytest.mark.parametrize( + 'input_action_type', ['user_warning', 'user_warning_lifting'] + ) + def test_it_serializes_user_action_notification_on_comment_report( + self, + app: Flask, + user_1_moderator: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_action_type: str, + ) -> None: + comment = self.comment_workout(user_3, workout_cycling_user_2) + report = self.create_report(reporter=user_2, reported_object=comment) + report_action = self.create_report_action( + user_1_moderator, + user_3, + action_type=input_action_type, + report_id=report.id, + ) + notification = Notification.query.filter_by( + from_user_id=user_1_moderator.id, + to_user_id=user_3.id, + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification[ + "report_action" + ] == report_action.serialize(user_3) + assert serialized_notification["comment"] == comment.serialize(user_3) + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] is None + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["type"] == input_action_type + assert "report" not in serialized_notification + assert "workout" not in serialized_notification + + +class TestNotificationForUserWarningAppeal(NotificationTestCase, ReportMixin): + def test_it_does_not_create_notification_when_admin_is_inactive( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + report_action = self.create_report_action( + user_1_moderator, + user_2, + action_type="user_warning", + report_id=report.id, + ) + self.create_action_appeal(report_action.id, user_2, with_commit=False) + user_1_moderator.is_active = False + db.session.commit() + + notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1_moderator.id, + event_type='user_warning_appeal', + ).first() + assert notification is None + + def test_it_creates_notification_on_appeal( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + report_action = self.create_report_action( + user_1_moderator, + user_2, + action_type="user_warning", + report_id=report.id, + ) + appeal = self.create_action_appeal(report_action.id, user_2) + + notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1_moderator.id, + event_type='user_warning_appeal', + ).first() + assert notification.created_at == appeal.created_at + assert notification.marked_as_read is False + assert notification.event_object_id == appeal.id + + def test_it_serializes_user_warning_appeal_notification( + self, app: Flask, user_1_moderator: User, user_2: User, user_3: User + ) -> None: + report = self.create_report(reporter=user_2, reported_object=user_3) + report_action = self.create_report_action( + user_1_moderator, + user_2, + action_type="user_warning", + report_id=report.id, + ) + self.create_action_appeal(report_action.id, user_2) + notification = Notification.query.filter_by( + from_user_id=user_2.id, + to_user_id=user_1_moderator.id, + event_type='user_warning_appeal', + ).first() + + serialized_notification = notification.serialize() + + assert serialized_notification["created_at"] == notification.created_at + assert serialized_notification["from"] == user_2.serialize( + current_user=user_1_moderator + ) + assert serialized_notification["id"] == notification.id + assert serialized_notification["marked_as_read"] is False + assert serialized_notification["report"] == report.serialize( + user_1_moderator + ) + assert serialized_notification["type"] == "user_warning_appeal" diff --git a/fittrackee/tests/users/test_users_service.py b/fittrackee/tests/users/test_users_service.py new file mode 100644 index 000000000..8660e0a04 --- /dev/null +++ b/fittrackee/tests/users/test_users_service.py @@ -0,0 +1,834 @@ +from datetime import datetime + +import pytest +from flask import Flask +from time_machine import travel + +from fittrackee import bcrypt, db +from fittrackee.reports.models import Report, ReportAction +from fittrackee.users.exceptions import ( + InvalidEmailException, + InvalidUserRole, + MissingAdminIdException, + MissingReportIdException, + UserAlreadySuspendedException, + UserCreationException, + UserNotFoundException, +) +from fittrackee.users.models import Notification, User +from fittrackee.users.roles import UserRole +from fittrackee.users.users_service import UserManagerService + +from ..mixins import ReportMixin +from ..utils import random_email, random_string + + +class TestUserManagerServiceUserUpdate(ReportMixin): + @staticmethod + def generate_user_report(admin: User, user: User) -> Report: + report = Report( + note=random_string(), + reported_by=admin.id, + reported_object=user, + ) + db.session.add(report) + db.session.flush() + return report + + def test_it_raises_exception_if_user_does_not_exist( + self, app: Flask + ) -> None: + user_manager_service = UserManagerService(username=random_string()) + + with pytest.raises(UserNotFoundException): + user_manager_service.update() + + def test_it_does_not_update_user_when_no_args_provided( + self, app: Flask, user_1: User + ) -> None: + user_manager_service = UserManagerService(username=user_1.username) + + _, user_updated, _, _ = user_manager_service.update() + + assert user_updated is False + + def test_it_returns_user(self, app: Flask, user_1: User) -> None: + user_manager_service = UserManagerService(username=user_1.username) + + user, _, _, _ = user_manager_service.update() + + assert user == user_1 + + @pytest.mark.parametrize( + 'input_role', ['user', 'moderator', 'admin', 'owner'] + ) + def test_it_sets_role_for_a_given_user( + self, app: Flask, user_1: User, input_role: str + ) -> None: + user_manager_service = UserManagerService(username=user_1.username) + + user_manager_service.update(role=input_role) + + assert user_1.role == UserRole[input_role.upper()].value + + def test_it_raises_exception_when_role_is_invalid( + self, app: Flask, user_1: User + ) -> None: + user_manager_service = UserManagerService(username=user_1.username) + + with pytest.raises(InvalidUserRole): + user_manager_service.update(role="invalid") + + def test_it_keeps_moderation_notifications_when_role_changed_from_moderator_to_admin( # noqa + self, + app: Flask, + user_1_admin: User, + user_2_admin: User, + user_3: User, + ) -> None: + # report creation (only for admin and moderator) + report = self.create_user_report(user_1_admin, user_3) + report_notification = Notification.query.filter_by( + from_user_id=user_1_admin.id, + to_user_id=user_2_admin.id, + event_type='report', + event_object_id=report.id, + ).first() + # follow request + follow_request = user_1_admin.send_follow_request_to(user_1_admin) + follow_request_notification = Notification( + from_user_id=user_1_admin.id, + to_user_id=user_2_admin.id, + event_type='follow_request', + created_at=follow_request.created_at, + ) + db.session.add(follow_request_notification) + db.session.commit() + user_manager_service = UserManagerService( + username=user_2_admin.username + ) + + user_manager_service.update(role="admin") + + notifications = Notification.query.filter_by( + to_user_id=user_2_admin.id + ).all() + assert set(notifications) == { + follow_request_notification, + report_notification, + } + + def test_it_deletes_moderation_notifications_when_role_changed_from_moderator_to_user( # noqa + self, + app: Flask, + user_1_admin: User, + user_2_moderator: User, + user_3: User, + ) -> None: + # report creation (only for admin and moderator) + self.create_user_report(user_1_admin, user_3) + # follow request + follow_request = user_1_admin.send_follow_request_to(user_1_admin) + follow_request_notification = Notification( + from_user_id=user_1_admin.id, + to_user_id=user_2_moderator.id, + event_type='follow_request', + created_at=follow_request.created_at, + ) + db.session.add(follow_request_notification) + db.session.commit() + user_manager_service = UserManagerService( + username=user_2_moderator.username + ) + + user_manager_service.update(role="user") + + notifications = Notification.query.filter_by( + to_user_id=user_2_moderator.id + ).all() + assert set(notifications) == {follow_request_notification} + + def test_it_deletes_administration_notifications_when_role_changed_from_admin_to_moderator( # noqa + self, + app: Flask, + user_1_admin: User, + user_2_admin: User, + user_3: User, + ) -> None: + # user registration (only for admin) + user_creation_notification = Notification( + from_user_id=user_3.id, + to_user_id=user_2_admin.id, + event_type='account_creation', + created_at=user_3.created_at, + ) + db.session.add(user_creation_notification) + # report creation (only for admin and moderator) + report = self.create_user_report(user_1_admin, user_3) + report_notification = Notification.query.filter_by( + from_user_id=user_1_admin.id, + to_user_id=user_2_admin.id, + event_type='report', + event_object_id=report.id, + ).first() + # follow request + follow_request = user_1_admin.send_follow_request_to(user_1_admin) + follow_request_notification = Notification( + from_user_id=user_1_admin.id, + to_user_id=user_2_admin.id, + event_type='follow_request', + created_at=follow_request.created_at, + ) + db.session.add(follow_request_notification) + user_manager_service = UserManagerService( + username=user_2_admin.username + ) + + user_manager_service.update(role="moderator") + + notifications = Notification.query.filter_by( + to_user_id=user_2_admin.id + ).all() + assert set(notifications) == { + follow_request_notification, + report_notification, + } + + def test_it_deletes_admin_and_moderation_notifications_when_role_changed_from_admin_to_user( # noqa + self, + app: Flask, + user_1_admin: User, + user_2_admin: User, + user_3: User, + ) -> None: + # user registration (only for admin) + user_creation_notification = Notification( + from_user_id=user_3.id, + to_user_id=user_2_admin.id, + event_type='account_creation', + created_at=user_3.created_at, + ) + db.session.add(user_creation_notification) + # report creation (only for admin and moderator) + self.create_user_report(user_1_admin, user_3) + # follow request + follow_request = user_1_admin.send_follow_request_to(user_1_admin) + follow_request_notification = Notification( + from_user_id=user_1_admin.id, + to_user_id=user_2_admin.id, + event_type='follow_request', + created_at=follow_request.created_at, + ) + db.session.add(follow_request_notification) + user_manager_service = UserManagerService( + username=user_2_admin.username + ) + + user_manager_service.update(role="user") + + notifications = Notification.query.filter_by( + to_user_id=user_2_admin.id + ).all() + assert notifications == [follow_request_notification] + + def test_it_deletes_administration_notifications_when_role_changed_from_owner_to_moderator( # noqa + self, + app: Flask, + user_1_admin: User, + user_2_owner: User, + user_3: User, + ) -> None: + # user registration (only for admin) + user_creation_notification = Notification( + from_user_id=user_3.id, + to_user_id=user_2_owner.id, + event_type='account_creation', + created_at=user_3.created_at, + ) + db.session.add(user_creation_notification) + # report creation (only for admin and moderator) + report = self.create_user_report(user_1_admin, user_3) + report_notification = Notification.query.filter_by( + from_user_id=user_1_admin.id, + to_user_id=user_2_owner.id, + event_type='report', + event_object_id=report.id, + ).first() + # follow request + follow_request = user_1_admin.send_follow_request_to(user_1_admin) + follow_request_notification = Notification( + from_user_id=user_1_admin.id, + to_user_id=user_2_owner.id, + event_type='follow_request', + created_at=follow_request.created_at, + ) + db.session.add(follow_request_notification) + user_manager_service = UserManagerService( + username=user_2_owner.username + ) + + user_manager_service.update(role="moderator") + + notifications = Notification.query.filter_by( + to_user_id=user_2_owner.id + ).all() + assert set(notifications) == { + follow_request_notification, + report_notification, + } + + def test_it_deletes_admin_and_moderation_notifications_when_role_changed_from_owner_to_user( # noqa + self, + app: Flask, + user_1_admin: User, + user_2_owner: User, + user_3: User, + ) -> None: + # user registration (only for admin) + user_creation_notification = Notification( + from_user_id=user_3.id, + to_user_id=user_2_owner.id, + event_type='account_creation', + created_at=user_3.created_at, + ) + db.session.add(user_creation_notification) + # report creation (only for admin and moderator) + self.create_user_report(user_1_admin, user_3) + # follow request + follow_request = user_1_admin.send_follow_request_to(user_1_admin) + follow_request_notification = Notification( + from_user_id=user_1_admin.id, + to_user_id=user_2_owner.id, + event_type='follow_request', + created_at=follow_request.created_at, + ) + db.session.add(follow_request_notification) + user_manager_service = UserManagerService( + username=user_2_owner.username + ) + + user_manager_service.update(role="user") + + notifications = Notification.query.filter_by( + to_user_id=user_2_owner.id + ).all() + assert notifications == [follow_request_notification] + + def test_it_keeps_all_notifications_when_role_changed_from_owner_to_admin( + self, + app: Flask, + user_1_admin: User, + user_2_owner: User, + user_3: User, + ) -> None: + # user registration (only for admin) + user_creation_notification = Notification( + from_user_id=user_3.id, + to_user_id=user_2_owner.id, + event_type='account_creation', + created_at=user_3.created_at, + ) + db.session.add(user_creation_notification) + # report creation (only for admin and moderator) + report = self.create_user_report(user_1_admin, user_3) + report_notification = Notification.query.filter_by( + from_user_id=user_1_admin.id, + to_user_id=user_2_owner.id, + event_type='report', + event_object_id=report.id, + ).first() + # follow request + follow_request = user_1_admin.send_follow_request_to(user_1_admin) + follow_request_notification = Notification( + from_user_id=user_1_admin.id, + to_user_id=user_2_owner.id, + event_type='follow_request', + created_at=follow_request.created_at, + ) + db.session.add(follow_request_notification) + user_manager_service = UserManagerService( + username=user_2_owner.username + ) + + user_manager_service.update(role="admin") + + notifications = Notification.query.filter_by( + to_user_id=user_2_owner.id + ).all() + assert set(notifications) == { + user_creation_notification, + follow_request_notification, + report_notification, + } + + def test_it_return_updated_user_flag_to_true( + self, app: Flask, user_1: User + ) -> None: + user_manager_service = UserManagerService(username=user_1.username) + + _, user_updated, _, _ = user_manager_service.update(role='admin') + + assert user_updated is True + + def test_it_does_not_raise_exception_when_user_has_already_admin_right( + self, app: Flask, user_1_admin: User + ) -> None: + user_manager_service = UserManagerService( + username=user_1_admin.username + ) + + _, user_updated, _, _ = user_manager_service.update(role='admin') + + assert user_1_admin.role == UserRole.ADMIN.value + assert user_updated is True + + @pytest.mark.parametrize('input_activate', [True, False]) + def test_it_activates_admin_account_if_user_is_inactive_regardless_activate_value( # noqa + self, app: Flask, inactive_user: User, input_activate: bool + ) -> None: + user_manager_service = UserManagerService( + username=inactive_user.username + ) + + _, user_updated, _, _ = user_manager_service.update( + role='admin', activate=input_activate + ) + + assert inactive_user.role == UserRole.ADMIN.value + assert inactive_user.is_active is True + assert inactive_user.confirmation_token is None + assert user_updated is True + + def test_it_activates_given_user_account( + self, app: Flask, inactive_user: User + ) -> None: + user_manager_service = UserManagerService( + username=inactive_user.username + ) + + _, user_updated, _, _ = user_manager_service.update(activate=True) + + assert inactive_user.is_active is True + assert user_updated is True + + def test_it_deactivates_given_user_account( + self, app: Flask, user_1: User + ) -> None: + user_manager_service = UserManagerService(username=user_1.username) + + _, user_updated, _, _ = user_manager_service.update(activate=False) + + assert user_1.is_active is False + assert user_1.confirmation_token is None + assert user_updated is True + + def test_it_empties_confirmation_token( + self, app: Flask, inactive_user: User + ) -> None: + user_manager_service = UserManagerService( + username=inactive_user.username + ) + + _, user_updated, _, _ = user_manager_service.update(activate=True) + + assert inactive_user.confirmation_token is None + assert user_updated is True + + def test_it_does_not_raise_error_if_user_account_already_activated( + self, app: Flask, user_1: User + ) -> None: + user_manager_service = UserManagerService(username=user_1.username) + + _, user_updated, _, _ = user_manager_service.update(activate=True) + + assert user_1.is_active is True + assert user_updated is True + + def test_it_resets_user_password(self, app: Flask, user_1: User) -> None: + previous_password = user_1.password + user_manager_service = UserManagerService(username=user_1.username) + + _, user_updated, _, _ = user_manager_service.update( + reset_password=True + ) + + assert user_1.password != previous_password + assert user_updated is True + + def test_new_password_is_encrypted(self, app: Flask, user_1: User) -> None: + user_manager_service = UserManagerService(username=user_1.username) + + _, user_updated, new_password, _ = user_manager_service.update( + reset_password=True + ) + + assert bcrypt.check_password_hash(user_1.password, new_password) + assert user_updated is True + + def test_it_raises_exception_if_provided_email_is_invalid( + self, app: Flask, user_1: User + ) -> None: + user_manager_service = UserManagerService(username=user_1.username) + with pytest.raises( + InvalidEmailException, match='valid email must be provided' + ): + user_manager_service.update(new_email=random_string()) + + def test_it_raises_exception_if_provided_email_is_current_user_email( + self, app: Flask, user_1: User + ) -> None: + user_manager_service = UserManagerService(username=user_1.username) + with pytest.raises( + InvalidEmailException, + match='new email must be different than current email', + ): + user_manager_service.update(new_email=user_1.email) + + def test_it_updates_user_email_to_confirm( + self, app: Flask, user_1: User + ) -> None: + new_email = random_email() + current_email = user_1.email + user_manager_service = UserManagerService(username=user_1.username) + + _, user_updated, _, _ = user_manager_service.update( + new_email=new_email + ) + + assert user_1.email == current_email + assert user_1.email_to_confirm == new_email + assert user_1.confirmation_token is not None + assert user_updated is True + + def test_it_updates_user_email(self, app: Flask, user_1: User) -> None: + new_email = random_email() + user_manager_service = UserManagerService(username=user_1.username) + + _, user_updated, _, _ = user_manager_service.update( + new_email=new_email, with_confirmation=False + ) + + assert user_1.email == new_email + assert user_1.email_to_confirm is None + assert user_1.confirmation_token is None + assert user_updated is True + + @pytest.mark.parametrize( + 'input_suspended', ["user_suspension", "user_unsuspension"] + ) + def test_it_raises_error_when_report_id_not_provided_on_suspension_status_update( # noqa + self, + app: Flask, + user_1: User, + user_2_admin: User, + input_suspended: bool, + ) -> None: + user_manager_service = UserManagerService( + username=user_1.username, + moderator_id=user_2_admin.id, + ) + + with pytest.raises( + MissingReportIdException, + ): + user_manager_service.update(suspended=input_suspended) + + @pytest.mark.parametrize( + 'input_suspended', ["user_suspension", "user_unsuspension"] + ) + def test_it_raises_error_when_admin_id_not_provided_on_suspension_status_update( # noqa + self, + app: Flask, + user_1: User, + user_2_admin: User, + input_suspended: bool, + ) -> None: + report = self.generate_user_report(user_2_admin, user_1) + user_manager_service = UserManagerService( + username=user_1.username, + ) + + with pytest.raises( + MissingAdminIdException, + ): + user_manager_service.update( + suspended=input_suspended, report_id=report.id + ) + + def test_it_raises_error_when_user_is_already_suspended( + self, app: Flask, user_1: User, user_2_admin: User + ) -> None: + report = self.generate_user_report(user_2_admin, user_1) + user_1.suspended_at = datetime.utcnow() + user_manager_service = UserManagerService( + username=user_1.username, + moderator_id=user_2_admin.id, + ) + + with pytest.raises( + UserAlreadySuspendedException, + match="user account already suspended", + ): + user_manager_service.update(suspended=True, report_id=report.id) + + def test_it_suspends_user( + self, app: Flask, user_1: User, user_2_admin: User + ) -> None: + report = self.generate_user_report(user_2_admin, user_1) + user_manager_service = UserManagerService( + username=user_1.username, moderator_id=user_2_admin.id + ) + now = datetime.utcnow() + + with travel(now, tick=False): + _, user_updated, _, _ = user_manager_service.update( + suspended=True, report_id=report.id + ) + + assert user_1.is_active is True + assert user_1.suspended_at == now + assert user_updated is True + + def test_it_removes_admin_right_when_user_is_suspended( + self, app: Flask, user_1_admin: User, user_2_admin: User + ) -> None: + report = self.generate_user_report(user_2_admin, user_1_admin) + user_manager_service = UserManagerService( + username=user_1_admin.username, + moderator_id=user_2_admin.id, + ) + now = datetime.utcnow() + + with travel(now, tick=False): + _, user_updated, _, _ = user_manager_service.update( + suspended=True, report_id=report.id + ) + + assert user_1_admin.role == UserRole.USER.value + assert user_1_admin.is_active is True + assert user_1_admin.suspended_at == now + assert user_updated is True + + def test_it_unsuspends_user( + self, app: Flask, user_1: User, user_2_admin: User + ) -> None: + report = self.generate_user_report(user_2_admin, user_1) + user_1.suspended_at = datetime.utcnow() + user_manager_service = UserManagerService( + username=user_1.username, + moderator_id=user_2_admin.id, + ) + + _, user_updated, _, _ = user_manager_service.update( + suspended=False, report_id=report.id + ) + + assert user_1.is_active is True + assert user_1.suspended_at is None + assert user_updated is True + + def test_it_does_not_update_suspended_when_suspended_is_none( + self, app: Flask, user_1: User, user_2_admin: User + ) -> None: + report = self.generate_user_report(user_2_admin, user_1) + suspended_at = datetime.utcnow() + user_1.suspended_at = suspended_at + user_manager_service = UserManagerService( + username=user_1.username, + moderator_id=user_2_admin.id, + ) + + _, user_updated, _, _ = user_manager_service.update( + suspended=None, report_id=report.id + ) + + assert user_1.is_active is True + assert user_1.suspended_at == suspended_at + assert user_updated is False + + @pytest.mark.parametrize( + 'input_suspended, expected_action_action', + [(True, "user_suspension"), (False, "user_unsuspension")], + ) + def test_it_creates_report_action_when_updated_suspended_status( + self, + app: Flask, + user_1_admin: User, + user_2: User, + input_suspended: bool, + expected_action_action: str, + ) -> None: + reason = random_string() + report = self.generate_user_report(user_1_admin, user_2) + user_manager_service = UserManagerService( + moderator_id=user_1_admin.id, username=user_2.username + ) + if input_suspended is False: + user_2.suspended_at = datetime.utcnow() + db.session.commit() + now = datetime.utcnow() + + with travel(now, tick=False): + user_manager_service.update( + suspended=input_suspended, + report_id=report.id, + reason=reason, + ) + + report_action = ReportAction.query.filter_by( + moderator_id=user_1_admin.id, + action_type=expected_action_action, + user_id=user_2.id, + ).first() + assert report_action.created_at == now + assert report_action.reason == reason + assert report_action.report_id == report.id + + +class TestUserManagerServiceUserCreation: + def test_it_raises_exception_if_provided_username_is_invalid( + self, app: Flask + ) -> None: + user_manager_service = UserManagerService(username='.admin') + with pytest.raises( + UserCreationException, + match=( + 'username: only alphanumeric characters and ' + 'the underscore character "_" allowed\n' + ), + ): + user_manager_service.create(email=random_email()) + + def test_it_raises_exception_if_a_user_exists_with_same_username( + self, app: Flask, user_1: User + ) -> None: + user_manager_service = UserManagerService(username=user_1.username) + with pytest.raises( + UserCreationException, + match='sorry, that username is already taken', + ): + user_manager_service.create(email=random_email()) + + def test_it_raises_exception_if_provided_email_is_invalid( + self, app: Flask + ) -> None: + user_manager_service = UserManagerService(username=random_string()) + with pytest.raises( + UserCreationException, match='valid email must be provided' + ): + user_manager_service.create(email=random_string()) + + def test_it_raises_exception_if_a_user_exists_with_same_email( + self, app: Flask, user_1: User + ) -> None: + user_manager_service = UserManagerService(username=random_string()) + with pytest.raises( + UserCreationException, + match='This user already exists. No action done.', + ): + user_manager_service.create(email=user_1.email) + + def test_it_creates_user_with_provided_password(self, app: Flask) -> None: + username = random_string() + email = random_email() + password = random_string() + user_manager_service = UserManagerService(username=username) + + new_user, user_password = user_manager_service.create(email, password) + + assert new_user + assert new_user.username == username + assert new_user.email == email + assert bcrypt.check_password_hash(new_user.password, password) + assert user_password == password + + def test_it_creates_user_when_password_is_not_provided( + self, app: Flask + ) -> None: + username = random_string() + email = random_email() + user_manager_service = UserManagerService(username=username) + + new_user, user_password = user_manager_service.create(email) + + assert new_user + assert new_user.username == username + assert new_user.email == email + assert bcrypt.check_password_hash(new_user.password, user_password) + + def test_it_creates_when_registration_is_not_enabled( + self, + app_with_3_users_max: Flask, + user_1: User, + user_2: User, + user_3: User, + ) -> None: + username = random_string() + email = random_email() + user_manager_service = UserManagerService(username=username) + + new_user, user_password = user_manager_service.create(email) + + assert new_user + assert new_user.username == username + assert new_user.email == email + assert bcrypt.check_password_hash(new_user.password, user_password) + + def test_created_user_is_inactive(self, app: Flask) -> None: + username = random_string() + user_manager_service = UserManagerService(username=username) + + new_user, _ = user_manager_service.create(email=random_email()) + + assert new_user + assert new_user.is_active is False + assert new_user.confirmation_token is not None + + def test_created_user_has_user_role(self, app: Flask) -> None: + username = random_string() + user_manager_service = UserManagerService(username=username) + + new_user, _ = user_manager_service.create(email=random_email()) + + assert new_user + assert new_user.role == UserRole.USER.value + + def test_created_user_does_not_accept_privacy_policy( + self, app: Flask + ) -> None: + username = random_string() + user_manager_service = UserManagerService(username=username) + + new_user, _ = user_manager_service.create(email=random_email()) + + assert new_user + assert new_user.accepted_policy_date is None + + def test_created_user_timezone_is_europe_paris(self, app: Flask) -> None: + username = random_string() + user_manager_service = UserManagerService(username=username) + + new_user, _ = user_manager_service.create(email=random_email()) + + assert new_user + assert new_user.timezone == 'Europe/Paris' + + def test_created_user_date_format_is_MM_dd_yyyy( # noqa + self, app: Flask + ) -> None: + username = random_string() + user_manager_service = UserManagerService(username=username) + + new_user, _ = user_manager_service.create(email=random_email()) + + assert new_user + assert new_user.date_format == 'MM/dd/yyyy' + + def test_created_user_language_is_en(self, app: Flask) -> None: + username = random_string() + user_manager_service = UserManagerService(username=username) + + new_user, _ = user_manager_service.create(email=random_email()) + + assert new_user + assert new_user.language == 'en' diff --git a/fittrackee/tests/users/test_users_utils.py b/fittrackee/tests/users/test_users_utils.py index ac723077b..edddbf8b2 100644 --- a/fittrackee/tests/users/test_users_utils.py +++ b/fittrackee/tests/users/test_users_utils.py @@ -2,22 +2,17 @@ from calendar import timegm from datetime import datetime, timedelta from typing import Dict, Optional -from unittest.mock import Mock, patch +from unittest.mock import patch import jwt import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from flask import Flask +from time_machine import travel -from fittrackee import bcrypt, db -from fittrackee.users.exceptions import ( - InvalidEmailException, - UserCreationException, - UserNotFoundException, -) +from fittrackee import db from fittrackee.users.models import BlacklistedToken, User -from fittrackee.users.utils.admin import UserManagerService from fittrackee.users.utils.controls import ( check_password, check_username, @@ -30,314 +25,7 @@ get_user_token, ) -from ..utils import random_email, random_int, random_string - - -class TestUserManagerServiceUserUpdate: - def test_it_raises_exception_if_user_does_not_exist( - self, app: Flask - ) -> None: - user_manager_service = UserManagerService(username=random_string()) - - with pytest.raises(UserNotFoundException): - user_manager_service.update() - - def test_it_does_not_update_user_when_no_args_provided( - self, app: Flask, user_1: User - ) -> None: - user_manager_service = UserManagerService(username=user_1.username) - - _, user_updated, _ = user_manager_service.update() - - assert user_updated is False - - def test_it_returns_user(self, app: Flask, user_1: User) -> None: - user_manager_service = UserManagerService(username=user_1.username) - - user, _, _ = user_manager_service.update() - - assert user == user_1 - - def test_it_sets_admin_right_for_a_given_user( - self, app: Flask, user_1: User - ) -> None: - user_manager_service = UserManagerService(username=user_1.username) - - user_manager_service.update(is_admin=True) - - assert user_1.admin is True - - def test_it_return_updated_user_flag_to_true( - self, app: Flask, user_1: User - ) -> None: - user_manager_service = UserManagerService(username=user_1.username) - - _, user_updated, _ = user_manager_service.update(is_admin=True) - - assert user_updated is True - - def test_it_does_not_raise_exception_when_user_has_already_admin_right( - self, app: Flask, user_1_admin: User - ) -> None: - user_manager_service = UserManagerService( - username=user_1_admin.username - ) - - user_manager_service.update(is_admin=True) - - assert user_1_admin.admin is True - - def test_it_activates_account_if_user_is_inactive( - self, app: Flask, inactive_user: User - ) -> None: - user_manager_service = UserManagerService( - username=inactive_user.username - ) - - user_manager_service.update(is_admin=True) - - assert inactive_user.admin is True - assert inactive_user.is_active is True - assert inactive_user.confirmation_token is None - - def test_it_activates_given_user_account( - self, app: Flask, inactive_user: User - ) -> None: - user_manager_service = UserManagerService( - username=inactive_user.username - ) - - user_manager_service.update(activate=True) - - assert inactive_user.is_active is True - - def test_it_empties_confirmation_token( - self, app: Flask, inactive_user: User - ) -> None: - user_manager_service = UserManagerService( - username=inactive_user.username - ) - - user_manager_service.update(activate=True) - - assert inactive_user.confirmation_token is None - - def test_it_does_not_raise_error_if_user_account_already_activated( - self, app: Flask, user_1: User - ) -> None: - user_manager_service = UserManagerService(username=user_1.username) - - user_manager_service.update(activate=True) - - assert user_1.is_active is True - - def test_it_resets_user_password(self, app: Flask, user_1: User) -> None: - previous_password = user_1.password - user_manager_service = UserManagerService(username=user_1.username) - - user_manager_service.update(reset_password=True) - - assert user_1.password != previous_password - - def test_new_password_is_encrypted(self, app: Flask, user_1: User) -> None: - user_manager_service = UserManagerService(username=user_1.username) - - _, _, new_password = user_manager_service.update(reset_password=True) - - assert bcrypt.check_password_hash(user_1.password, new_password) - - def test_it_raises_exception_if_provided_email_is_invalid( - self, app: Flask, user_1: User - ) -> None: - user_manager_service = UserManagerService(username=user_1.username) - with pytest.raises( - InvalidEmailException, match='valid email must be provided' - ): - user_manager_service.update(new_email=random_string()) - - def test_it_raises_exception_if_provided_email_is_current_user_email( - self, app: Flask, user_1: User - ) -> None: - user_manager_service = UserManagerService(username=user_1.username) - with pytest.raises( - InvalidEmailException, - match='new email must be different than current email', - ): - user_manager_service.update(new_email=user_1.email) - - def test_it_updates_user_email_to_confirm( - self, app: Flask, user_1: User - ) -> None: - new_email = random_email() - current_email = user_1.email - user_manager_service = UserManagerService(username=user_1.username) - - user_manager_service.update(new_email=new_email) - - assert user_1.email == current_email - assert user_1.email_to_confirm == new_email - assert user_1.confirmation_token is not None - - def test_it_updates_user_email(self, app: Flask, user_1: User) -> None: - new_email = random_email() - user_manager_service = UserManagerService(username=user_1.username) - - user_manager_service.update( - new_email=new_email, with_confirmation=False - ) - - assert user_1.email == new_email - assert user_1.email_to_confirm is None - assert user_1.confirmation_token is None - - -class TestUserManagerServiceUserCreation: - def test_it_raises_exception_if_provided_username_is_invalid( - self, app: Flask - ) -> None: - user_manager_service = UserManagerService(username='.admin') - with pytest.raises( - UserCreationException, - match=( - 'username: only alphanumeric characters and ' - 'the underscore character "_" allowed\n' - ), - ): - user_manager_service.create(email=random_email()) - - def test_it_raises_exception_if_a_user_exists_with_same_username( - self, app: Flask, user_1: User - ) -> None: - user_manager_service = UserManagerService(username=user_1.username) - with pytest.raises( - UserCreationException, - match='sorry, that username is already taken', - ): - user_manager_service.create(email=random_email()) - - def test_it_raises_exception_if_provided_email_is_invalid( - self, app: Flask - ) -> None: - user_manager_service = UserManagerService(username=random_string()) - with pytest.raises( - UserCreationException, match='valid email must be provided' - ): - user_manager_service.create(email=random_string()) - - def test_it_raises_exception_if_a_user_exists_with_same_email( - self, app: Flask, user_1: User - ) -> None: - user_manager_service = UserManagerService(username=random_string()) - with pytest.raises( - UserCreationException, - match='This user already exists. No action done.', - ): - user_manager_service.create(email=user_1.email) - - def test_it_creates_user_with_provided_password(self, app: Flask) -> None: - username = random_string() - email = random_email() - password = random_string() - user_manager_service = UserManagerService(username=username) - - new_user, user_password = user_manager_service.create(email, password) - - assert new_user - assert new_user.username == username - assert new_user.email == email - assert bcrypt.check_password_hash(new_user.password, password) - assert user_password == password - - def test_it_creates_user_when_password_is_not_provided( - self, app: Flask - ) -> None: - username = random_string() - email = random_email() - user_manager_service = UserManagerService(username=username) - - new_user, user_password = user_manager_service.create(email) - - assert new_user - assert new_user.username == username - assert new_user.email == email - assert bcrypt.check_password_hash(new_user.password, user_password) - - def test_it_creates_when_registration_is_not_enabled( - self, - app_with_3_users_max: Flask, - user_1: User, - user_2: User, - user_3: User, - ) -> None: - username = random_string() - email = random_email() - user_manager_service = UserManagerService(username=username) - - new_user, user_password = user_manager_service.create(email) - - assert new_user - assert new_user.username == username - assert new_user.email == email - assert bcrypt.check_password_hash(new_user.password, user_password) - - def test_created_user_is_inactive(self, app: Flask) -> None: - username = random_string() - user_manager_service = UserManagerService(username=username) - - new_user, _ = user_manager_service.create(email=random_email()) - - assert new_user - assert new_user.is_active is False - assert new_user.confirmation_token is not None - - def test_created_user_has_no_admin_rights(self, app: Flask) -> None: - username = random_string() - user_manager_service = UserManagerService(username=username) - - new_user, _ = user_manager_service.create(email=random_email()) - - assert new_user - assert new_user.admin is False - - def test_created_user_does_not_accept_privacy_policy( - self, app: Flask - ) -> None: - username = random_string() - user_manager_service = UserManagerService(username=username) - - new_user, _ = user_manager_service.create(email=random_email()) - - assert new_user - assert new_user.accepted_policy_date is None - - def test_created_user_timezone_is_europe_paris(self, app: Flask) -> None: - username = random_string() - user_manager_service = UserManagerService(username=username) - - new_user, _ = user_manager_service.create(email=random_email()) - - assert new_user - assert new_user.timezone == 'Europe/Paris' - - def test_created_user_date_format_is_MM_dd_yyyy( # noqa - self, app: Flask - ) -> None: - username = random_string() - user_manager_service = UserManagerService(username=username) - - new_user, _ = user_manager_service.create(email=random_email()) - - assert new_user - assert new_user.date_format == 'MM/dd/yyyy' - - def test_created_user_language_is_en(self, app: Flask) -> None: - username = random_string() - user_manager_service = UserManagerService(username=username) - - new_user, _ = user_manager_service.create(email=random_email()) - - assert new_user - assert new_user.language == 'en' +from ..utils import random_int, random_string class TestIsValidEmail: @@ -527,9 +215,7 @@ def test_token_contains_timestamp_of_when_it_is_issued( ) -> None: user_id = 1 iat = datetime.utcnow() - with patch('fittrackee.users.utils.token.datetime') as datetime_mock: - datetime_mock.utcnow = Mock(return_value=iat) - + with travel(iat, tick=False): token = get_user_token( user_id=user_id, password_reset=input_password_reset ) @@ -546,9 +232,7 @@ def test_token_contains_timestamp_of_when_it_expired( days=app.config['TOKEN_EXPIRATION_DAYS'], seconds=app.config['TOKEN_EXPIRATION_SECONDS'], ) - with patch('fittrackee.users.utils.token.datetime') as datetime_mock: - datetime_mock.utcnow = Mock(return_value=iat) - + with travel(iat, tick=False): token = get_user_token(user_id=user_id) decoded_token = self.decode_token(app, token) @@ -565,9 +249,7 @@ def test_password_token_contains_timestamp_of_when_it_expired( days=0.0, seconds=app.config['PASSWORD_TOKEN_EXPIRATION_SECONDS'], ) - with patch('fittrackee.users.utils.token.datetime') as datetime_mock: - datetime_mock.utcnow = Mock(return_value=iat) - + with travel(iat, tick=False): token = get_user_token(user_id=user_id, password_reset=True) decoded_token = self.decode_token(app, token) @@ -579,8 +261,7 @@ def test_password_token_contains_timestamp_of_when_it_expired( class TestDecodeUserToken: @staticmethod def generate_token(user_id: int, now: datetime) -> str: - with patch('fittrackee.users.utils.token.datetime') as datetime_mock: - datetime_mock.utcnow = Mock(return_value=now) + with travel(now, tick=False): token = get_user_token(user_id) return token diff --git a/fittrackee/tests/utils.py b/fittrackee/tests/utils.py index 269213dc2..e5bbaf6df 100644 --- a/fittrackee/tests/utils.py +++ b/fittrackee/tests/utils.py @@ -1,11 +1,15 @@ import random import string -from json import loads -from typing import Dict, Optional +from datetime import datetime +from json import dumps, loads +from typing import Dict, Optional, Union from uuid import uuid4 from flask import json as flask_json +from requests import Response +from fittrackee import db +from fittrackee.users.models import FollowRequest, User from fittrackee.utils import encode_uuid @@ -28,21 +32,53 @@ def random_string( def random_domain() -> str: - return random_string(prefix='https://', suffix='.com') + return random_string(suffix='.social') + + +def get_date_string( + date_format: str, + date: Optional[datetime] = None, +) -> str: + date = date if date else datetime.utcnow() + return date.strftime(date_format) def random_email() -> str: return random_string(suffix='@example.com') -def random_int(min_val: int = 0, max_val: int = 999999) -> int: - return random.randint(min_val, max_val) +def random_int(min_value: int = 0, max_value: int = 999999) -> int: + return random.randint(min_value, max_value) def random_short_id() -> str: return encode_uuid(uuid4()) +def generate_response( + content: Optional[Union[str, Dict]] = None, + status_code: Optional[int] = None, +) -> Response: + content = content if content else {} + response = Response() + response._content = ( + dumps(content).encode() + if isinstance(content, dict) + else content.encode() + ) + response.status_code = status_code if status_code else 200 + return response + + +def generate_follow_request(follower: User, followed: User) -> FollowRequest: + follow_request = FollowRequest( + follower_user_id=follower.id, followed_user_id=followed.id + ) + db.session.add(follow_request) + db.session.commit() + return follow_request + + def jsonify_dict(data: Dict) -> Dict: return loads(flask_json.dumps(data)) @@ -58,8 +94,14 @@ def jsonify_dict(data: Dict) -> Dict: "application:write": False, "equipments:read": False, "equipments:write": False, + "follow:read": False, + "follow:write": False, + "notifications:read": False, + "notifications:write": False, "profile:read": False, "profile:write": False, + "reports:read": False, + "reports:write": False, "users:read": False, "users:write": False, "workouts:read": False, diff --git a/fittrackee/tests/workouts/mixins.py b/fittrackee/tests/workouts/mixins.py new file mode 100644 index 000000000..bda3516ff --- /dev/null +++ b/fittrackee/tests/workouts/mixins.py @@ -0,0 +1,8 @@ +import pytest + +from ..mixins import ApiTestCaseMixin + + +@pytest.mark.disable_autouse_update_records_patch +class WorkoutApiTestCaseMixin(ApiTestCaseMixin): + pass diff --git a/fittrackee/tests/workouts/test_records_api.py b/fittrackee/tests/workouts/test_records_api.py index 57bbe240b..9c13eb468 100644 --- a/fittrackee/tests/workouts/test_records_api.py +++ b/fittrackee/tests/workouts/test_records_api.py @@ -10,7 +10,24 @@ from ..utils import OAUTH_SCOPES +@pytest.mark.disable_autouse_update_records_patch class TestGetRecords(ApiTestCaseMixin): + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + suspended_user: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + '/api/records', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + def test_it_gets_records_for_authenticated_user( self, app: Flask, diff --git a/fittrackee/tests/workouts/test_records_model.py b/fittrackee/tests/workouts/test_records_model.py index 022f77fb0..3264c34a2 100644 --- a/fittrackee/tests/workouts/test_records_model.py +++ b/fittrackee/tests/workouts/test_records_model.py @@ -1,11 +1,13 @@ import datetime +import pytest from flask import Flask from fittrackee.users.models import User from fittrackee.workouts.models import Record, Sport, Workout +@pytest.mark.disable_autouse_update_records_patch class TestRecordModel: def test_record_model( self, diff --git a/fittrackee/tests/workouts/test_sports_api.py b/fittrackee/tests/workouts/test_sports_api.py index 370055e1a..f8c0159d8 100644 --- a/fittrackee/tests/workouts/test_sports_api.py +++ b/fittrackee/tests/workouts/test_sports_api.py @@ -18,15 +18,26 @@ class TestGetSports(ApiTestCaseMixin): - def test_it_returns_error_if_user_is_not_authenticated( + def test_test_it_gets_all_sports_when_not_authenticated( self, app: Flask, + sport_1_cycling: Sport, + sport_2_running: Sport, ) -> None: client = app.test_client() response = client.get('/api/sports') - self.assert_401(response) + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert 'success' in data['status'] + assert len(data['data']['sports']) == 2 + assert data['data']['sports'][0] == jsonify_dict( + sport_1_cycling.serialize() + ) + assert data['data']['sports'][1] == jsonify_dict( + sport_2_running.serialize() + ) @pytest.mark.parametrize( 'input_check_workouts', @@ -60,6 +71,33 @@ def test_it_gets_all_sports( sport_2_running.serialize() ) + def test_it_gets_all_sports_when_user_is_suspended( + self, + app: Flask, + suspended_user: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + '/api/sports', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert 'success' in data['status'] + assert len(data['data']['sports']) == 2 + assert data['data']['sports'][0] == jsonify_dict( + sport_1_cycling.serialize() + ) + assert data['data']['sports'][1] == jsonify_dict( + sport_2_running.serialize() + ) + def test_it_gets_all_sports_with_inactive_one( self, app: Flask, @@ -251,6 +289,20 @@ def test_it_gets_a_sport( sport_1_cycling.serialize() ) + def test_it_returns_error_when_user_is_suspended( + self, app: Flask, suspended_user: User, sport_1_cycling: Sport + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + f'/api/sports/{sport_1_cycling.id}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + def test_it_gets_a_sport_with_preferences( self, app: Flask, diff --git a/fittrackee/tests/workouts/test_stats_api.py b/fittrackee/tests/workouts/test_stats_api.py index 9fef1ea6b..fe35ba537 100644 --- a/fittrackee/tests/workouts/test_stats_api.py +++ b/fittrackee/tests/workouts/test_stats_api.py @@ -37,6 +37,20 @@ def test_it_returns_error_if_user_is_authenticated_authenticated( self.assert_403(response) + def test_it_returns_error_when_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + f'/api/stats/{suspended_user.username}/by_time', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + def test_it_gets_no_stats_when_user_has_no_workouts( self, app: Flask, user_1: User ) -> None: @@ -54,7 +68,7 @@ def test_it_gets_no_stats_when_user_has_no_workouts( assert 'success' in data['status'] assert data['data']['statistics'] == {} - def test_it_returns_error_when_user_does_not_exists( + def test_it_returns_error_when_user_does_not_exist( self, app: Flask, user_1: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( @@ -1039,6 +1053,20 @@ def test_it_returns_error_if_user_is_authenticated_authenticated( self.assert_403(response) + def test_it_returns_error_when_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + f'/api/stats/{suspended_user.username}/by_sport', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + def test_it_gets_stats_by_sport( self, app: Flask, @@ -1388,11 +1416,25 @@ def test_it_returns_error_if_user_is_not_authenticated( self.assert_401(response) + def test_it_returns_error_when_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + '/api/stats/all', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + def test_it_returns_all_stats_when_users_have_no_workouts( - self, app: Flask, user_1_admin: User, user_2: User + self, app: Flask, user_1_moderator: User, user_2: User ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email + app, user_1_moderator.email ) response = client.get( @@ -1408,10 +1450,30 @@ def test_it_returns_all_stats_when_users_have_no_workouts( assert data['data']['users'] == 2 assert 'uploads_dir_size' in data['data'] + def test_it_does_not_count_inactive_user( + self, app: Flask, user_1_moderator: User, inactive_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_moderator.email + ) + + response = client.get( + '/api/stats/all', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert 'success' in data['status'] + assert data['data']['workouts'] == 0 + assert data['data']['sports'] == 0 + assert data['data']['users'] == 1 + assert 'uploads_dir_size' in data['data'] + def test_it_gets_app_all_stats_with_workouts( self, app: Flask, - user_1_admin: User, + user_1_moderator: User, user_2: User, user_3: User, sport_1_cycling: Sport, @@ -1421,7 +1483,7 @@ def test_it_gets_app_all_stats_with_workouts( workout_running_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( - app, user_1_admin.email + app, user_1_moderator.email ) response = client.get( @@ -1437,7 +1499,31 @@ def test_it_gets_app_all_stats_with_workouts( assert data['data']['users'] == 3 assert 'uploads_dir_size' in data['data'] - def test_it_returns_error_if_user_has_no_admin_rights( + def test_it_returns_stats_if_user_has_admin_rights( + self, + app: Flask, + user_1_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1_admin.email + ) + + response = client.get( + '/api/stats/all', + 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']['workouts'] == 1 + assert data['data']['sports'] == 1 + assert data['data']['users'] == 1 + assert 'uploads_dir_size' in data['data'] + + def test_it_returns_error_if_user_has_no_moderator_rights( self, app: Flask, user_1: User, diff --git a/fittrackee/tests/workouts/test_timeline_api.py b/fittrackee/tests/workouts/test_timeline_api.py new file mode 100644 index 000000000..b0a63f144 --- /dev/null +++ b/fittrackee/tests/workouts/test_timeline_api.py @@ -0,0 +1,596 @@ +import json +from datetime import datetime + +import pytest +from flask import Flask +from werkzeug.test import TestResponse + +from fittrackee import db +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout + +from ..utils import OAUTH_SCOPES, jsonify_dict +from .mixins import WorkoutApiTestCaseMixin + + +class GetUserTimelineTestCase(WorkoutApiTestCaseMixin): + @staticmethod + def assert_no_workout_returned(response: TestResponse) -> None: + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 0 + + @staticmethod + def assert_workout_returned( + response: TestResponse, workout: Workout + ) -> None: + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert data['data']['workouts'][0]['id'] == workout.short_id + + +class TestGetUserTimeline(GetUserTimelineTestCase): + def test_it_returns_401_if_no_authentication(self, app: Flask) -> None: + client = app.test_client() + + response = client.get('/api/timeline') + + assert response.status_code == 401 + + def test_it_returns_403_when_user_is_suspended( + self, app: Flask, suspended_user: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_empty_list_when_no_workouts( + self, app: Flask, user_1: User + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_no_workout_returned(response) + + def test_it_gets_minimal_workout( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert data['data']['workouts'][0] == jsonify_dict( + workout_cycling_user_1.serialize(user=user_1) + ) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, app: Flask, user_1: User, client_scope: str, can_access: bool + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + '/api/timeline', + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestGetUserTimelineForAuthUserWorkouts(GetUserTimelineTestCase): + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_authenticated_user_workout( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_workout_returned(response, workout_cycling_user_1) + + def test_it_returns_authenticated_user_suspended_workout( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_workout_returned(response, workout_cycling_user_1) + + +class TestGetUserTimelineForFollowedUserWorkouts(GetUserTimelineTestCase): + def test_it_does_not_return_followed_user_private_workout( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PRIVATE + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_no_workout_returned(response) + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_followed_user_workout_when_visibility_allows_it( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_workout_returned(response, workout_cycling_user_2) + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_does_not_return_workout_from_blocked_user( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_visibility + user_1.blocks_user(user_2) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_no_workout_returned(response) + + def test_blocked_user_can_not_get_workout_in_timeline( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_no_workout_returned(response) + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_does_not_return_followed_user_suspended_workouts( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_visibility + workout_cycling_user_2.suspended_at = datetime.utcnow() + user_1.blocks_user(user_2) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_workout_returned(response, workout_cycling_user_1) + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_does_not_return_followed_user_workout_map_when_private( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_visibility + workout_cycling_user_2.map_visibility = VisibilityLevel.PRIVATE + workout_cycling_user_2.map_id = self.random_string() + workout_cycling_user_2.map = self.random_string() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert data['data']['workouts'][0]['map'] is None + + @pytest.mark.parametrize( + 'input_desc,input_visibility', + [ + ( + 'workout and map visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout and map visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_followed_user_workout_map_when_visibility_allows_it( + self, + input_desc: str, + input_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_visibility + workout_cycling_user_2.map_visibility = input_visibility + map_id = self.random_string() + workout_cycling_user_2.map_id = map_id + workout_cycling_user_2.map = self.random_string() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert data['data']['workouts'][0]['map'] == map_id + + +class TestGetUserTimelineForNotFollowedUserWorkouts(GetUserTimelineTestCase): + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ( + 'workout visibility: private', + VisibilityLevel.PRIVATE, + ), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_does_not_return_workout( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_no_workout_returned(response) + + +class TestGetUserTimelinePagination(WorkoutApiTestCaseMixin): + def test_it_returns_pagination_when_no_workouts( + self, + app: Flask, + user_1: User, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 0, + 'total': 0, + } + + def test_it_returns_pagination_when_one_workout_returned( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 1, + } + + def test_it_gets_first_page( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + seven_workouts_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert len(data['data']['workouts']) == 5 + assert data['pagination'] == { + 'has_next': True, + 'has_prev': False, + 'page': 1, + 'pages': 2, + 'total': 7, + } + + def test_it_gets_second_page( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + seven_workouts_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline?page=2', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert len(data['data']['workouts']) == 2 + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 2, + 'pages': 2, + 'total': 7, + } + + def test_it_gets_empty_third_page( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + seven_workouts_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline?page=3', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert len(data['data']['workouts']) == 0 + assert data['pagination'] == { + 'has_next': False, + 'has_prev': True, + 'page': 3, + 'pages': 2, + 'total': 7, + } + + def test_it_returns_workouts_ordered_by_workout_date_descending( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + seven_workouts_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/timeline', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert ( + 'Wed, 09 May 2018 00:00:00 GMT' + == data['data']['workouts'][0]['workout_date'] + ) + assert ( + 'Mon, 01 Jan 2018 00:00:00 GMT' + == data['data']['workouts'][4]['workout_date'] + ) diff --git a/fittrackee/tests/workouts/test_utils/test_weather_service.py b/fittrackee/tests/workouts/test_utils/test_weather_service.py index 176d98610..cdd9b7fd2 100644 --- a/fittrackee/tests/workouts/test_utils/test_weather_service.py +++ b/fittrackee/tests/workouts/test_utils/test_weather_service.py @@ -7,7 +7,7 @@ import requests from gpxpy.gpx import GPXTrackPoint -from fittrackee.tests.mixins import CallArgsMixin +from fittrackee.tests.mixins import BaseTestMixin from fittrackee.tests.utils import random_string from fittrackee.workouts.utils.weather.visual_crossing import VisualCrossing from fittrackee.workouts.utils.weather.weather_service import WeatherService @@ -46,7 +46,7 @@ } -class WeatherTestCase: +class WeatherTestCase(BaseTestMixin): api_key = random_string() @staticmethod @@ -88,7 +88,7 @@ def test_it_returns_rounded_time( ) -class TestVisualCrossingGetWeather(WeatherTestCase, CallArgsMixin): +class TestVisualCrossingGetWeather(WeatherTestCase): @staticmethod def get_response() -> Mock: response_mock = Mock() diff --git a/fittrackee/tests/workouts/test_utils/test_workouts.py b/fittrackee/tests/workouts/test_utils/test_workouts.py index 7ae061ebf..34d309495 100644 --- a/fittrackee/tests/workouts/test_utils/test_workouts.py +++ b/fittrackee/tests/workouts/test_utils/test_workouts.py @@ -1,17 +1,21 @@ from datetime import datetime, timedelta from statistics import mean -from typing import List, Union +from typing import List, Optional, Union import pytest import pytz from flask import Flask from gpxpy.gpxfield import SimpleTZ -from fittrackee.users.models import User +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.exceptions import WorkoutForbiddenException from fittrackee.workouts.models import Sport, Workout from fittrackee.workouts.utils.workouts import ( create_segment, get_average_speed, + get_ordered_workouts, + get_workout, get_workout_datetime, ) @@ -148,3 +152,399 @@ def test_it_removes_microseconds( ) assert segment.duration.microseconds == 0 + + +class TestGetOrderedWorkouts: + def test_it_returns_empty_list_when_no_workouts_provided( + self, + app: Flask, + ) -> None: + ordered_workouts = get_ordered_workouts([], limit=3) + + assert ordered_workouts == [] + + def test_it_returns_last_workouts_depending_on_limit( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + seven_workouts_user_1: List[Workout], + ) -> None: + ordered_workouts = get_ordered_workouts(seven_workouts_user_1, limit=3) + + assert ordered_workouts == [ + seven_workouts_user_1[6], + seven_workouts_user_1[5], + seven_workouts_user_1[3], + ] + + def test_it_returns_all_workouts_when_below_limit( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + ordered_workouts = get_ordered_workouts( + [workout_cycling_user_1, workout_running_user_1], limit=3 + ) + + assert ordered_workouts == [ + workout_running_user_1, + workout_cycling_user_1, + ] + + +class GetWorkoutTestCase: + @staticmethod + def assert_workout_is_returned( + workout: Workout, user: Optional[User], allow_admin: bool + ) -> None: + workout = get_workout( + workout_short_id=workout.short_id, + auth_user=user, + allow_admin=allow_admin, + ) + + assert workout.id == workout.id + + @staticmethod + def assert_raises_forbidden_exception( + workout: Workout, user: Optional[User], allow_admin: bool + ) -> None: + with pytest.raises(WorkoutForbiddenException): + get_workout( + workout_short_id=workout.short_id, + auth_user=user, + allow_admin=allow_admin, + ) + + +class TestGetWorkoutForPublicWorkout(GetWorkoutTestCase): + visibility_level = VisibilityLevel.PUBLIC + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_returns_workout_when_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_workout_is_returned( + workout_cycling_user_1, None, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_returns_workout_when_user_is_not_a_follower( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_workout_is_returned( + workout_cycling_user_1, user_2, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_returns_workout_when_user_is_a_follower( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + input_allow_admin: bool, + ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_workout_is_returned( + workout_cycling_user_1, user_2, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_returns_workout_when_user_is_owner( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_workout_is_returned( + workout_cycling_user_1, user_1, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_raises_exception_when_user_is_blocked_by_owner( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + user_1.blocks_user(user_2) + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_raises_forbidden_exception( + workout_cycling_user_1, user_2, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_returns_workout_when_user_has_admin_right( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_workout_is_returned( + workout_cycling_user_1, user_2_admin, input_allow_admin + ) + + +class TestGetWorkoutForFollowerOnlyWorkout(GetWorkoutTestCase): + visibility_level = VisibilityLevel.FOLLOWERS + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_raises_exception_when_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_raises_forbidden_exception( + workout_cycling_user_1, None, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_raises_exception_when_user_is_not_a_follower( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_raises_forbidden_exception( + workout_cycling_user_1, user_2, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_returns_workout_when_user_is_a_follower( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + input_allow_admin: bool, + ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_workout_is_returned( + workout_cycling_user_1, user_2, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_returns_workout_when_user_is_owner( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_workout_is_returned( + workout_cycling_user_1, user_1, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_raises_exception_when_user_is_blocked_by_owner( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + user_1.blocks_user(user_2) + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_raises_forbidden_exception( + workout_cycling_user_1, user_2, input_allow_admin + ) + + def test_it_raises_exception_when_user_has_admin_right_and_flag_is_false( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_raises_forbidden_exception( + workout_cycling_user_1, user_2_admin, False + ) + + def test_it_returns_workout_when_user_has_admin_right_and_flag_is_true( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_workout_is_returned( + workout_cycling_user_1, user_2_admin, True + ) + + +class TestGetWorkoutForPrivateWorkout(GetWorkoutTestCase): + visibility_level = VisibilityLevel.PRIVATE + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_raises_exception_when_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_raises_forbidden_exception( + workout_cycling_user_1, None, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_raises_exception_when_user_is_not_a_follower( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_raises_forbidden_exception( + workout_cycling_user_1, user_2, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_raises_exception_when_user_is_a_follower( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, + input_allow_admin: bool, + ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_raises_forbidden_exception( + workout_cycling_user_1, user_2, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_returns_workout_when_user_is_owner( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_workout_is_returned( + workout_cycling_user_1, user_1, input_allow_admin + ) + + @pytest.mark.parametrize('input_allow_admin', [True, False]) + def test_it_raises_exception_when_user_is_blocked_by_owner( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_allow_admin: bool, + ) -> None: + user_1.blocks_user(user_2) + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_raises_forbidden_exception( + workout_cycling_user_1, user_2, input_allow_admin + ) + + def test_it_raises_exception_when_user_has_admin_right_and_flag_is_false( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_raises_forbidden_exception( + workout_cycling_user_1, user_2_admin, False + ) + + def test_it_returns_workout_when_user_has_admin_right_and_flag_is_true( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = self.visibility_level + + self.assert_workout_is_returned( + workout_cycling_user_1, user_2_admin, True + ) diff --git a/fittrackee/tests/workouts/test_workout_likes_model.py b/fittrackee/tests/workouts/test_workout_likes_model.py new file mode 100644 index 000000000..8661286da --- /dev/null +++ b/fittrackee/tests/workouts/test_workout_likes_model.py @@ -0,0 +1,69 @@ +from datetime import datetime + +from flask import Flask +from time_machine import travel + +from fittrackee import db +from fittrackee.users.models import User +from fittrackee.workouts.models import Sport, Workout, WorkoutLike + + +class TestWorkoutLikeModel: + def test_workout_likes_model( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + created_at = datetime.utcnow() + + like = WorkoutLike( + user_id=user_2.id, + workout_id=workout_cycling_user_1.id, + created_at=created_at, + ) + + assert like.user_id == user_2.id + assert like.workout_id == workout_cycling_user_1.id + assert like.created_at == created_at + + def test_created_date_is_initialized_on_creation_when_not_provided( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + now = datetime.utcnow() + with travel(now, tick=False): + like = WorkoutLike( + user_id=user_2.id, + workout_id=workout_cycling_user_1.id, + ) + + assert like.user_id == user_2.id + assert like.workout_id == workout_cycling_user_1.id + assert like.created_at == now + + def test_it_deletes_workout_like_on_user_delete( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + like = WorkoutLike( + user_id=user_2.id, + workout_id=workout_cycling_user_1.id, + ) + db.session.add(like) + db.session.commit() + like_id = like.id + + db.session.delete(user_2) + + assert WorkoutLike.query.filter_by(id=like_id).first() is None diff --git a/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py b/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py new file mode 100644 index 000000000..2111a7ff7 --- /dev/null +++ b/fittrackee/tests/workouts/test_workouts_api_0_get_workout.py @@ -0,0 +1,2478 @@ +import json +from datetime import datetime +from typing import List +from unittest.mock import mock_open, patch + +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.tests.comments.mixins import CommentMixin +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout, WorkoutSegment + +from ..utils import OAUTH_SCOPES, jsonify_dict +from .mixins import WorkoutApiTestCaseMixin + + +class GetWorkoutGpxAsFollowerMixin: + @staticmethod + def init_test_data( + workout: Workout, + map_visibility: VisibilityLevel, + follower: User, + followed: User, + ) -> None: + workout.gpx = 'file.gpx' + workout.workout_visibility = VisibilityLevel.FOLLOWERS + workout.map_visibility = map_visibility + followed.approves_follow_request_from(follower) + + +class GetWorkoutGpxPublicVisibilityMixin: + @staticmethod + def init_test_data( + workout: Workout, map_visibility: VisibilityLevel + ) -> None: + workout.gpx = 'file.gpx' + workout.workout_visibility = VisibilityLevel.PUBLIC + workout.map_visibility = map_visibility + + +class GetWorkoutTestCase(WorkoutApiTestCaseMixin): + route = '/api/workouts/{workout_uuid}' + + +class TestGetWorkoutAsWorkoutOwner(GetWorkoutTestCase): + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_gets_owner_workout( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert data['data']['workouts'][0] == jsonify_dict( + workout_cycling_user_1.serialize(user=user_1, light=False) + ) + + def test_it_gets_owner_suspended_workout( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert data['data']['workouts'][0] == jsonify_dict( + workout_cycling_user_1.serialize(user=user_1, light=False) + ) + + +class TestGetWorkoutAsFollower(CommentMixin, GetWorkoutTestCase): + def test_it_returns_404_when_workout_visibility_is_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404(response) + assert len(data['data']['workouts']) == 0 + + def test_it_returns_404_when_workout_is_suspended_and_no_user_comments( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + follow_request_from_user_3_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + user_2.approves_follow_request_from(user_3) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + workout_cycling_user_2.suspended_at = datetime.utcnow() + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404(response) + assert len(data['data']['workouts']) == 0 + + def test_it_returns_404_when_workout_is_suspended_and_auth_user_commented_workout( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + workout_cycling_user_2.suspended_at = datetime.utcnow() + self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert data['data']['workouts'][0] == jsonify_dict( + workout_cycling_user_2.serialize(user=user_1) + ) + + @pytest.mark.parametrize( + 'input_desc,input_workout_level', + [ + ('workout visibility: follower', VisibilityLevel.FOLLOWERS), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_followed_user_workout( + self, + input_desc: str, + input_workout_level: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_level + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_2.serialize() + ) + assert ( + data['data']['workouts'][0]['map_visibility'] + == VisibilityLevel.PRIVATE.value + ) + assert ( + data['data']['workouts'][0]['workout_visibility'] + == input_workout_level + ) + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutAsUser(CommentMixin, GetWorkoutTestCase): + def test_it_returns_404_if_workout_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + short_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404(response) + assert len(data['data']['workouts']) == 0 + + def test_it_returns_404_when_workout_is_suspended_and_no_user_comments( + self, + app: Flask, + user_1: User, + user_2: User, + user_3: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_2.suspended_at = datetime.utcnow() + self.create_comment( + user_3, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404(response) + assert len(data['data']['workouts']) == 0 + + def test_it_returns_404_when_workout_is_suspended_and_auth_user_commented_workout( # noqa + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_2.suspended_at = datetime.utcnow() + self.create_comment( + user_1, + workout_cycling_user_2, + text_visibility=VisibilityLevel.PUBLIC, + ) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert data['data']['workouts'][0] == jsonify_dict( + workout_cycling_user_2.serialize(user=user_1) + ) + + @pytest.mark.parametrize( + 'input_desc,input_workout_level', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE), + ('workout visibility: follower', VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_returns_404_when_workout_visibility_is_not_public( + self, + input_desc: str, + input_workout_level: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_level + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404(response) + assert len(data['data']['workouts']) == 0 + + def test_it_returns_404_when_user_is_blocked_workout_owner( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + user_2.blocks_user(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404(response) + assert len(data['data']['workouts']) == 0 + + def test_it_returns_another_user_workout_when_visibility_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_2.serialize() + ) + assert ( + data['data']['workouts'][0]['map_visibility'] + == VisibilityLevel.PRIVATE.value + ) + assert ( + data['data']['workouts'][0]['workout_visibility'] + == VisibilityLevel.PUBLIC.value + ) + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutAsUnauthenticatedUser(GetWorkoutTestCase): + def test_it_returns_404_if_workout_does_not_exist( + self, app: Flask + ) -> None: + short_id = self.random_short_id() + client = app.test_client() + + response = client.get( + self.route.format(workout_uuid=short_id), + ) + + data = self.assert_404(response) + assert len(data['data']['workouts']) == 0 + + def test_it_returns_404_when_workout_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.suspended_at = datetime.utcnow() + db.session.commit() + client = app.test_client() + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + ) + + data = self.assert_404(response) + assert len(data['data']['workouts']) == 0 + + @pytest.mark.parametrize( + 'input_desc,input_workout_level', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE), + ('workout visibility: follower', VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_returns_404_when_workout_visibility_is_not_public( + self, + input_desc: str, + input_workout_level: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_level + client = app.test_client() + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + ) + + data = self.assert_404(response) + assert len(data['data']['workouts']) == 0 + + def test_it_returns_a_user_workout_when_visibility_is_public( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + client = app.test_client() + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() + ) + assert ( + data['data']['workouts'][0]['map_visibility'] + == VisibilityLevel.PRIVATE.value + ) + assert ( + data['data']['workouts'][0]['workout_visibility'] + == VisibilityLevel.PUBLIC.value + ) + + +class GetWorkoutGpxTestCase(WorkoutApiTestCaseMixin): + route = '/api/workouts/{workout_uuid}/gpx' + + +class TestGetWorkoutGpxAsWorkoutOwner(GetWorkoutGpxTestCase): + def test_it_returns_404_if_workout_have_no_gpx( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert 'not found' in data['status'] + assert ( + 'no gpx file for this workout (id: ' + f'{workout_cycling_user_1.short_id})' + ) in data['message'] + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + gpx_content = self.random_string() + workout_cycling_user_1.gpx = 'file.gpx' + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + with patch( + 'builtins.open', new_callable=mock_open, read_data=gpx_content + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_owner_workout_gpx( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + gpx_content = self.random_string() + workout_cycling_user_1.gpx = 'file.gpx' + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + with patch( + 'builtins.open', new_callable=mock_open, read_data=gpx_content + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id + ), + 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']['gpx'] == gpx_content + + +class TestGetWorkoutGpxAsFollower( + GetWorkoutGpxTestCase, GetWorkoutGpxAsFollowerMixin +): + def test_it_returns_404_when_map_visibility_is_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + self.init_test_data( + workout_cycling_user_2, VisibilityLevel.PRIVATE, user_1, user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=self.random_string(), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_2.short_id + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_2.short_id})', + ) + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ('map visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_followed_user_workout_gpx( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + self.init_test_data( + workout_cycling_user_2, input_map_visibility, user_1, user_2 + ) + gpx_content = self.random_string() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + with patch( + 'builtins.open', new_callable=mock_open, read_data=gpx_content + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_2.short_id + ), + 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']['gpx'] == gpx_content + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + self.init_test_data( + workout_cycling_user_2, VisibilityLevel.FOLLOWERS, user_1, user_2 + ) + gpx_content = self.random_string() + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + with patch( + 'builtins.open', new_callable=mock_open, read_data=gpx_content + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_2.short_id + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutGpxAsUser( + GetWorkoutGpxTestCase, GetWorkoutGpxPublicVisibilityMixin +): + def test_it_returns_404_if_workout_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + random_short_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=random_short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 404 + data = json.loads(response.data.decode()) + assert 'not found' in data['status'] + assert f'workout not found (id: {random_short_id})' in data['message'] + assert data['data']['gpx'] == '' + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: private', VisibilityLevel.PRIVATE), + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_returns_404_when_map_visibility_is_not_public( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + self.init_test_data(workout_cycling_user_2, input_map_visibility) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=self.random_string(), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_2.short_id + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_2.short_id})', + ) + + def test_it_returns_gpx_when_map_visibility_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + self.init_test_data(workout_cycling_user_2, VisibilityLevel.PUBLIC) + gpx_content = self.random_string() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + with patch( + 'builtins.open', new_callable=mock_open, read_data=gpx_content + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_2.short_id + ), + 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']['gpx'] == gpx_content + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + self.init_test_data(workout_cycling_user_2, VisibilityLevel.PUBLIC) + gpx_content = self.random_string() + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + with patch( + 'builtins.open', new_callable=mock_open, read_data=gpx_content + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_2.short_id + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutGpxAsUnauthenticatedUser( + GetWorkoutGpxTestCase, GetWorkoutGpxPublicVisibilityMixin +): + def test_it_returns_404_if_workout_does_not_exist( + self, app: Flask + ) -> None: + random_short_id = self.random_short_id() + client = app.test_client() + + response = client.get( + self.route.format(workout_uuid=random_short_id), + ) + + data = self.assert_404_with_message( + response, f'workout not found (id: {random_short_id})' + ) + assert data['data']['gpx'] == '' + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: private', VisibilityLevel.PRIVATE), + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_returns_404_when_map_visibility_is_not_public( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + self.init_test_data(workout_cycling_user_1, input_map_visibility) + client = app.test_client() + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=self.random_string(), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id + ), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_1.short_id})', + ) + + def test_it_returns_gpx_when_map_visibility_is_public( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + gpx_content = self.random_string() + self.init_test_data(workout_cycling_user_1, VisibilityLevel.PUBLIC) + client = app.test_client() + with patch( + 'builtins.open', new_callable=mock_open, read_data=gpx_content + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id + ), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert data['data']['gpx'] == gpx_content + + +class GetGetWorkoutChartDataTestCase(WorkoutApiTestCaseMixin): + route = '/api/workouts/{workout_uuid}/chart_data' + + +class TestGetWorkoutChartDataAsWorkoutOwner(GetGetWorkoutChartDataTestCase): + def test_it_returns_404_if_workout_have_no_chart_data( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_short_id = workout_cycling_user_1.short_id + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 404 + assert 'not found' in data['status'] + assert ( + f'no gpx file for this workout (id: {workout_short_id})' + in data['message'] + ) + + def test_it_returns_500_if_a_workout_has_invalid_gpx_pathname( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.gpx = 'some path' + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_500(response) + + def test_it_returns_owner_workout_chart_data( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + chart_data: List = [] + workout_cycling_user_1.gpx = 'file.gpx' + with ( + patch('builtins.open', new_callable=mock_open), + patch( + 'fittrackee.workouts.workouts.get_chart_data', + return_value=chart_data, + ), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id + ), + 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']['chart_data'] == chart_data + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.gpx = 'file.gpx' + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutChartDataAsFollower( + GetGetWorkoutChartDataTestCase, GetWorkoutGpxAsFollowerMixin +): + def test_it_returns_404_when_map_visibility_is_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + self.init_test_data( + workout_cycling_user_2, VisibilityLevel.PRIVATE, user_1, user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_2.short_id})', + ) + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ('map visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_chart_data_for_followed_user_workout( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + self.init_test_data( + workout_cycling_user_2, input_map_visibility, user_1, user_2 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + chart_data: List = [] + workout_cycling_user_2.gpx = 'file.gpx' + with ( + patch('builtins.open', new_callable=mock_open), + patch( + 'fittrackee.workouts.workouts.get_chart_data', + return_value=chart_data, + ), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_2.short_id + ), + 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']['chart_data'] == chart_data + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + workout_cycling_user_2.gpx = 'file.gpx' + self.init_test_data( + workout_cycling_user_2, VisibilityLevel.FOLLOWERS, user_1, user_2 + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutChartDataAsUser( + GetGetWorkoutChartDataTestCase, GetWorkoutGpxPublicVisibilityMixin +): + def test_it_returns_404_if_workout_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + random_short_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=random_short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404_with_message( + response, f'workout not found (id: {random_short_id})' + ) + assert data['data']['chart_data'] == '' + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: private', VisibilityLevel.PRIVATE), + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_returns_404_when_map_visibility_is_not_public( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + self.init_test_data(workout_cycling_user_2, input_map_visibility) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_2.short_id})', + ) + + def test_it_returns_chart_data_when_map_visibility_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + self.init_test_data(workout_cycling_user_2, VisibilityLevel.PUBLIC) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + chart_data: List = [] + workout_cycling_user_2.gpx = 'file.gpx' + with ( + patch('builtins.open', new_callable=mock_open), + patch( + 'fittrackee.workouts.workouts.get_chart_data', + return_value=chart_data, + ), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_2.short_id + ), + 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']['chart_data'] == chart_data + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + self.init_test_data(workout_cycling_user_2, VisibilityLevel.PUBLIC) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + workout_cycling_user_2.gpx = 'file.gpx' + user_1.suspended_at = datetime.utcnow() + db.session.commit() + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutChartDataAsUnauthenticatedUser( + GetGetWorkoutChartDataTestCase, GetWorkoutGpxPublicVisibilityMixin +): + def test_it_returns_404_if_workout_does_not_exist( + self, app: Flask + ) -> None: + random_short_id = self.random_short_id() + client = app.test_client() + + response = client.get( + self.route.format(workout_uuid=random_short_id), + ) + + data = self.assert_404_with_message( + response, f'workout not found (id: {random_short_id})' + ) + assert data['data']['chart_data'] == '' + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: private', VisibilityLevel.PRIVATE), + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_returns_404_when_map_visibility_is_not_public( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + self.init_test_data(workout_cycling_user_1, input_map_visibility) + client = app.test_client() + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_1.short_id})', + ) + + def test_it_returns_chart_data_when_map_visibility_is_public( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + chart_data: List = [] + self.init_test_data(workout_cycling_user_1, VisibilityLevel.PUBLIC) + client = app.test_client() + with ( + patch('builtins.open', new_callable=mock_open), + patch( + 'fittrackee.workouts.workouts.get_chart_data', + return_value=chart_data, + ), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id + ), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert data['data']['chart_data'] == chart_data + + +class GetWorkoutSegmentGpxTestCase(WorkoutApiTestCaseMixin): + route = '/api/workouts/{workout_uuid}/gpx/segment/{segment_id}' + + +class TestGetWorkoutSegmentGpxAsWorkoutOwner(GetWorkoutSegmentGpxTestCase): + def test_it_returns_404_if_workout_have_no_gpx( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_short_id = workout_cycling_user_1.short_id + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, f'no gpx file for this workout (id: {workout_short_id})' + ) + + def test_it_returns_404_if_segment_does_not_exist( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + gpx_file_with_segments: str, + ) -> None: + workout_cycling_user_1.gpx = 'file.gpx' + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=gpx_file_with_segments, + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, + segment_id=100, + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message(response, "No segment with id '100'") + + def test_it_gets_segment_gpx_for_owner_workout( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + gpx_file_with_segments: str, + ) -> None: + workout_cycling_user_1.gpx = 'file.gpx' + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=gpx_file_with_segments, + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert '' in data['data']['gpx'] + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + gpx_file_with_segments: str, + ) -> None: + workout_cycling_user_1.gpx = 'file.gpx' + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutSegmentGpxAsFollower( + GetWorkoutSegmentGpxTestCase, GetWorkoutGpxAsFollowerMixin +): + def test_it_returns_404_when_map_visibility_is_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + follow_request_from_user_2_to_user_1: FollowRequest, + gpx_file_with_segments: str, + ) -> None: + self.init_test_data( + workout_cycling_user_1, VisibilityLevel.PRIVATE, user_2, user_1 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=gpx_file_with_segments, + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_1.short_id})', + ) + assert data['data']['gpx'] == '' + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ('map visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_segment_gpx_for_followed_user_workout( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + follow_request_from_user_2_to_user_1: FollowRequest, + gpx_file_with_segments: str, + ) -> None: + self.init_test_data( + workout_cycling_user_1, input_map_visibility, user_2, user_1 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=gpx_file_with_segments, + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert '' in data['data']['gpx'] + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + follow_request_from_user_2_to_user_1: FollowRequest, + gpx_file_with_segments: str, + ) -> None: + self.init_test_data( + workout_cycling_user_1, VisibilityLevel.FOLLOWERS, user_2, user_1 + ) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=gpx_file_with_segments, + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutSegmentGpxAsUser( + GetWorkoutSegmentGpxTestCase, GetWorkoutGpxPublicVisibilityMixin +): + def test_it_returns_404_if_workout_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + random_short_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=random_short_id, segment_id=1), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404_with_message( + response, f'workout not found (id: {random_short_id})' + ) + assert data['data']['gpx'] == '' + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: private', VisibilityLevel.PRIVATE), + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_returns_404_when_map_visibility_is_not_public( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + gpx_file_with_segments: str, + ) -> None: + self.init_test_data(workout_cycling_user_1, input_map_visibility) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=gpx_file_with_segments, + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_1.short_id})', + ) + + def test_it_returns_segment_gpx_when_map_visibility_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + gpx_file_with_segments: str, + ) -> None: + self.init_test_data(workout_cycling_user_1, VisibilityLevel.PUBLIC) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=gpx_file_with_segments, + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert '' in data['data']['gpx'] + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + gpx_file_with_segments: str, + ) -> None: + self.init_test_data(workout_cycling_user_1, VisibilityLevel.PUBLIC) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutSegmentGpxAsUnauthenticatedUser( + GetWorkoutSegmentGpxTestCase, GetWorkoutGpxPublicVisibilityMixin +): + def test_it_returns_404_if_workout_does_not_exist( + self, app: Flask + ) -> None: + random_short_id = self.random_short_id() + client = app.test_client() + + response = client.get( + self.route.format(workout_uuid=random_short_id, segment_id=1), + ) + + data = self.assert_404_with_message( + response, f'workout not found (id: {random_short_id})' + ) + assert data['data']['gpx'] == '' + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: private', VisibilityLevel.PRIVATE), + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_returns_404_when_map_visibility_is_not_public( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + gpx_file_with_segments: str, + ) -> None: + self.init_test_data(workout_cycling_user_1, input_map_visibility) + client = app.test_client() + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=gpx_file_with_segments, + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + ) + + data = self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_1.short_id})', + ) + assert data['data']['gpx'] == '' + + def test_it_returns_segment_gpx_when_map_visibility_is_public( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + gpx_file_with_segments: str, + ) -> None: + self.init_test_data(workout_cycling_user_1, VisibilityLevel.PUBLIC) + client = app.test_client() + with patch( + 'builtins.open', + new_callable=mock_open, + read_data=gpx_file_with_segments, + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert '' in data['data']['gpx'] + + +class GetWorkoutSegmentChartDataTestCase(WorkoutApiTestCaseMixin): + route = '/api/workouts/{workout_uuid}/chart_data/segment/{segment_id}' + + +class TestGetWorkoutSegmentChartDataAsWorkoutOwner( + GetWorkoutSegmentChartDataTestCase +): + def test_it_returns_404_if_workout_have_no_chart_data( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_short_id = workout_cycling_user_1.short_id + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_short_id, segment_id=1), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, f'no gpx file for this workout (id: {workout_short_id})' + ) + + def test_it_returns_500_if_workout_has_invalid_gpx_pathname( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.gpx = 'some path' + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_500(response) + + def test_it_returns_chart_data( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + chart_data: List = [] + workout_cycling_user_1.gpx = 'file.gpx' + with ( + patch('builtins.open', new_callable=mock_open), + patch( + 'fittrackee.workouts.workouts.get_chart_data', + return_value=chart_data, + ), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + 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']['chart_data'] == chart_data + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + ) -> None: + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutSegmentChartDataAsFollower( + GetWorkoutSegmentChartDataTestCase, GetWorkoutGpxAsFollowerMixin +): + def test_it_returns_404_when_map_visibility_is_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + self.init_test_data( + workout_cycling_user_1, VisibilityLevel.PRIVATE, user_2, user_1 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_1.short_id})', + ) + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ('map visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_chart_data_for_follower_user_workout( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + self.init_test_data( + workout_cycling_user_1, input_map_visibility, user_2, user_1 + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + chart_data: List = [] + workout_cycling_user_1.gpx = 'file.gpx' + with ( + patch('builtins.open', new_callable=mock_open), + patch( + 'fittrackee.workouts.workouts.get_chart_data', + return_value=chart_data, + ), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + 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']['chart_data'] == chart_data + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + workout_cycling_user_1.gpx = 'file.gpx' + self.init_test_data( + workout_cycling_user_1, VisibilityLevel.FOLLOWERS, user_2, user_1 + ) + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + chart_data: List = [] + + with ( + patch('builtins.open', new_callable=mock_open), + patch( + 'fittrackee.workouts.workouts.get_chart_data', + return_value=chart_data, + ), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutSegmentChartDataAsUser( + GetWorkoutSegmentChartDataTestCase, GetWorkoutGpxPublicVisibilityMixin +): + def test_it_returns_404_if_workout_does_not_exist( + self, app: Flask, user_1: User + ) -> None: + random_short_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=random_short_id, segment_id=1), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404_with_message( + response, f'workout not found (id: {random_short_id})' + ) + assert data['data']['chart_data'] == '' + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: private', VisibilityLevel.PRIVATE), + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_returns_404_when_map_visibility_is_not_public( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + ) -> None: + self.init_test_data(workout_cycling_user_1, input_map_visibility) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_1.short_id})', + ) + + def test_it_returns_chart_data_when_map_visibility_is_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + ) -> None: + chart_data: List = [] + self.init_test_data(workout_cycling_user_1, VisibilityLevel.PUBLIC) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + workout_cycling_user_1.gpx = 'file.gpx' + with ( + patch('builtins.open', new_callable=mock_open), + patch( + 'fittrackee.workouts.workouts.get_chart_data', + return_value=chart_data, + ), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + 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']['chart_data'] == chart_data + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + ) -> None: + chart_data: List = [] + self.init_test_data(workout_cycling_user_1, VisibilityLevel.PUBLIC) + workout_cycling_user_1.gpx = 'file.gpx' + user_2.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + with ( + patch('builtins.open', new_callable=mock_open), + patch( + 'fittrackee.workouts.workouts.get_chart_data', + return_value=chart_data, + ), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + +class TestGetWorkoutSegmentChartDataAsUnauthenticatedUser( + GetWorkoutSegmentChartDataTestCase, GetWorkoutGpxPublicVisibilityMixin +): + def test_it_returns_404_if_workout_does_not_exist( + self, app: Flask + ) -> None: + random_short_id = self.random_short_id() + client = app.test_client() + + response = client.get( + self.route.format(workout_uuid=random_short_id, segment_id=1), + ) + + data = self.assert_404_with_message( + response, f'workout not found (id: {random_short_id})' + ) + assert data['data']['chart_data'] == '' + + @pytest.mark.parametrize( + 'input_desc,input_map_visibility', + [ + ('map visibility: private', VisibilityLevel.PRIVATE), + ('map visibility: followers_only', VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_returns_404_when_map_visibility_is_not_public( + self, + input_desc: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + ) -> None: + self.init_test_data(workout_cycling_user_1, input_map_visibility) + client = app.test_client() + + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_1.short_id})', + ) + + def test_it_returns_chart_data_when_map_visibility_is_public( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + workout_cycling_user_1_segment: WorkoutSegment, + ) -> None: + chart_data: List = [] + self.init_test_data(workout_cycling_user_1, VisibilityLevel.PUBLIC) + client = app.test_client() + with ( + patch('builtins.open', new_callable=mock_open), + patch( + 'fittrackee.workouts.workouts.get_chart_data', + return_value=chart_data, + ), + ): + response = client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id, segment_id=1 + ), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert data['data']['chart_data'] == chart_data + + +class TestGetWorkoutMap(WorkoutApiTestCaseMixin): + def test_it_returns_404_if_workout_has_no_map(self, app: Flask) -> None: + client = app.test_client() + response = client.get( + f'/api/workouts/map/{self.random_string()}', + ) + + self.assert_404_with_message(response, 'Map does not exist') + + def test_it_calls_send_from_directory_if_workout_has_map( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + map_id = self.random_string() + map_file_path = self.random_string() + workout_cycling_user_1.map_id = map_id + workout_cycling_user_1.map = map_file_path + client = app.test_client() + with patch( + 'fittrackee.workouts.workouts.send_from_directory', + return_value='file', + ) as mock: + response = client.get( + f'/api/workouts/map/{map_id}', + ) + + assert response.status_code == 200 + mock.assert_called_once_with( + app.config['UPLOAD_FOLDER'], map_file_path + ) + + def test_it_returns_404_if_map_file_not_found( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + map_ip = self.random_string() + workout_cycling_user_1.map = self.random_string() + workout_cycling_user_1.map_id = map_ip + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + f'/api/workouts/map/{map_ip}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message(response, 'Map file does not exist') + + +class TestWorkoutScope(WorkoutApiTestCaseMixin): + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:read': True}.items(), + ) + @pytest.mark.parametrize( + 'endpoint', + [ + '/api/workouts/{workout_short_id}', + '/api/workouts/{workout_short_id}/gpx', + '/api/workouts/{workout_short_id}/chart_data', + '/api/workouts/{workout_short_id}/gpx/segment/1', + '/api/workouts/{workout_short_id}/chart_data/segment/1', + ], + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + client_scope: str, + can_access: bool, + endpoint: str, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + endpoint.format(workout_short_id=workout_cycling_user_1.short_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class DownloadWorkoutGpxTestCase(WorkoutApiTestCaseMixin): + route = '/api/workouts/{workout_uuid}/gpx/download' + + +class TestDownloadWorkoutGpxAsWorkoutOwner(DownloadWorkoutGpxTestCase): + def test_it_returns_404_if_workout_does_not_have_gpx( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message(response, 'no gpx file for workout') + + def test_it_calls_send_from_directory_if_workout_has_gpx( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + gpx_file_path = 'file.gpx' + workout_cycling_user_1.gpx = 'file.gpx' + with patch( + 'fittrackee.workouts.workouts.send_from_directory', + return_value='file', + ) as mock: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + client.get( + self.route.format( + workout_uuid=workout_cycling_user_1.short_id + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + mock.assert_called_once_with( + app.config['UPLOAD_FOLDER'], + gpx_file_path, + mimetype='application/gpx+xml', + as_attachment=True, + ) + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.gpx = 'file.gpx' + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:read': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + content_type='application/json', + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestDownloadWorkoutGpxAsFollower(DownloadWorkoutGpxTestCase): + def test_it_returns_404_for_followed_user_workout( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.gpx = 'file.gpx' + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_2.map_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_2.short_id})', + ) + + +class TestDownloadWorkoutGpxAsUser( + DownloadWorkoutGpxTestCase, GetWorkoutGpxPublicVisibilityMixin +): + def test_it_returns_404_if_workout_does_not_exist( + self, + app: Flask, + user_1: User, + ) -> None: + random_short_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=random_short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, f'workout not found (id: {random_short_id})' + ) + + def test_it_returns_404_for_another_user_workout( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + self.init_test_data(workout_cycling_user_2, VisibilityLevel.PUBLIC) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404_with_message( + response, + f'workout not found (id: {workout_cycling_user_2.short_id})', + ) + + +class TestDownloadWorkoutGpxAsUnauthenticatedUser( + DownloadWorkoutGpxTestCase, GetWorkoutGpxPublicVisibilityMixin +): + def test_it_returns_404( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + self.init_test_data(workout_cycling_user_1, VisibilityLevel.PUBLIC) + client = app.test_client() + + response = client.get( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + ) + + self.assert_401(response) diff --git a/fittrackee/tests/workouts/test_workouts_api_0_get.py b/fittrackee/tests/workouts/test_workouts_api_0_get_workouts.py similarity index 73% rename from fittrackee/tests/workouts/test_workouts_api_0_get.py rename to fittrackee/tests/workouts/test_workouts_api_0_get_workouts.py index b0dd03c92..8480ac09c 100644 --- a/fittrackee/tests/workouts/test_workouts_api_0_get.py +++ b/fittrackee/tests/workouts/test_workouts_api_0_get_workouts.py @@ -1,8 +1,7 @@ import json -from datetime import timedelta +from datetime import datetime, timedelta from typing import List from unittest.mock import patch -from uuid import uuid4 import pytest from flask import Flask @@ -12,19 +11,34 @@ from fittrackee.users.models import User from fittrackee.workouts.models import Sport, Workout -from ..mixins import ApiTestCaseMixin from ..utils import OAUTH_SCOPES, jsonify_dict +from .mixins import WorkoutApiTestCaseMixin -class TestGetWorkouts(ApiTestCaseMixin): - def test_it_returns_error_if_user_is_not_authenticated( - self, app: Flask +class TestGetWorkouts(WorkoutApiTestCaseMixin): + def test_it_gets_minimal_workout( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, ) -> None: - client = app.test_client() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) - response = client.get('/api/workouts') + response = client.get( + '/api/workouts', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) - self.assert_401(response) + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert data['data']['workouts'][0] == jsonify_dict( + workout_cycling_user_1.serialize(user=user_1) + ) def test_it_gets_all_workouts_for_authenticated_user( self, @@ -50,12 +64,29 @@ def test_it_gets_all_workouts_for_authenticated_user( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['workouts']) == 2 - assert data['data']['workouts'][0] == jsonify_dict( - workout_running_user_1.serialize() + assert 'creation_date' in data['data']['workouts'][0] + assert ( + 'Mon, 02 Apr 2018 00:00:00 GMT' + == data['data']['workouts'][0]['workout_date'] + ) + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() ) - assert data['data']['workouts'][1] == jsonify_dict( - workout_cycling_user_1.serialize() + assert 2 == data['data']['workouts'][0]['sport_id'] + assert 12.0 == data['data']['workouts'][0]['distance'] + assert '1:40:00' == data['data']['workouts'][0]['duration'] + + assert 'creation_date' in data['data']['workouts'][1] + assert ( + 'Mon, 01 Jan 2018 00:00:00 GMT' + == data['data']['workouts'][1]['workout_date'] + ) + assert data['data']['workouts'][1]['user'] == jsonify_dict( + user_1.serialize() ) + assert 1 == data['data']['workouts'][1]['sport_id'] + assert 10.0 == data['data']['workouts'][1]['distance'] + assert '1:00:00' == data['data']['workouts'][1]['duration'] assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -102,7 +133,67 @@ def test_it_returns_401_if_user_is_not_authenticated( response = client.get('/api/workouts') - self.assert_401(response, 'provide a valid auth token') + data = json.loads(response.data.decode()) + assert response.status_code == 401 + assert 'error' in data['status'] + assert 'provide a valid auth token' in data['message'] + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + suspended_user: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.get( + '/api/workouts', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_gets_only_unsuspended_workouts_for_authenticated_user( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + sport_2_running: Sport, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + workout_cycling_user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.get( + '/api/workouts', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = json.loads(response.data.decode()) + assert response.status_code == 200 + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['id'] + == workout_running_user_1.short_id + ) + assert data['pagination'] == { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 1, + 'total': 1, + } @pytest.mark.parametrize( 'client_scope, can_access', @@ -133,13 +224,13 @@ def test_expected_scopes_are_defined( self.assert_response_scope(response, can_access) -class TestGetWorkoutsWithPagination(ApiTestCaseMixin): +class TestGetWorkoutsWithPagination(WorkoutApiTestCaseMixin): def test_it_gets_workouts_with_default_pagination( self, app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -154,22 +245,18 @@ def test_it_gets_workouts_with_default_pagination( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['workouts']) == 5 - assert data['data']['workouts'][0] == jsonify_dict( - seven_workouts_user_1[6].serialize() - ) - assert data['data']['workouts'][1] == jsonify_dict( - seven_workouts_user_1[5].serialize() - ) - assert data['data']['workouts'][2] == jsonify_dict( - seven_workouts_user_1[3].serialize() - ) - assert data['data']['workouts'][3] == jsonify_dict( - seven_workouts_user_1[4].serialize() + assert 'creation_date' in data['data']['workouts'][0] + assert ( + 'Wed, 09 May 2018 00:00:00 GMT' + == data['data']['workouts'][0]['workout_date'] ) - assert data['data']['workouts'][4] == jsonify_dict( - seven_workouts_user_1[2].serialize() + assert '1:00:00' == data['data']['workouts'][0]['duration'] + assert 'creation_date' in data['data']['workouts'][4] + assert ( + 'Mon, 01 Jan 2018 00:00:00 GMT' + == data['data']['workouts'][4]['workout_date'] ) - + assert '0:17:04' == data['data']['workouts'][4]['duration'] assert data['pagination'] == { 'has_next': True, 'has_prev': False, @@ -183,7 +270,7 @@ def test_it_gets_first_page( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -198,21 +285,18 @@ def test_it_gets_first_page( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['workouts']) == 5 - assert data['data']['workouts'][0] == jsonify_dict( - seven_workouts_user_1[6].serialize() - ) - assert data['data']['workouts'][1] == jsonify_dict( - seven_workouts_user_1[5].serialize() - ) - assert data['data']['workouts'][2] == jsonify_dict( - seven_workouts_user_1[3].serialize() - ) - assert data['data']['workouts'][3] == jsonify_dict( - seven_workouts_user_1[4].serialize() + assert 'creation_date' in data['data']['workouts'][0] + assert ( + 'Wed, 09 May 2018 00:00:00 GMT' + == data['data']['workouts'][0]['workout_date'] ) - assert data['data']['workouts'][4] == jsonify_dict( - seven_workouts_user_1[2].serialize() + assert '1:00:00' == data['data']['workouts'][0]['duration'] + assert 'creation_date' in data['data']['workouts'][4] + assert ( + 'Mon, 01 Jan 2018 00:00:00 GMT' + == data['data']['workouts'][4]['workout_date'] ) + assert '0:17:04' == data['data']['workouts'][4]['duration'] assert data['pagination'] == { 'has_next': True, 'has_prev': False, @@ -226,7 +310,7 @@ def test_it_gets_second_page( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -241,12 +325,18 @@ def test_it_gets_second_page( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['workouts']) == 2 - assert data['data']['workouts'][0] == jsonify_dict( - seven_workouts_user_1[1].serialize() + assert 'creation_date' in data['data']['workouts'][0] + assert ( + 'Thu, 01 Jun 2017 00:00:00 GMT' + == data['data']['workouts'][0]['workout_date'] ) - assert data['data']['workouts'][1] == jsonify_dict( - seven_workouts_user_1[0].serialize() + assert '0:57:36' == data['data']['workouts'][0]['duration'] + assert 'creation_date' in data['data']['workouts'][1] + assert ( + 'Mon, 20 Mar 2017 00:00:00 GMT' + == data['data']['workouts'][1]['workout_date'] ) + assert '0:17:04' == data['data']['workouts'][1]['duration'] assert data['pagination'] == { 'has_next': False, 'has_prev': True, @@ -260,7 +350,7 @@ def test_it_gets_empty_third_page( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -288,7 +378,7 @@ def test_it_returns_error_on_invalid_page_value( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -299,7 +389,13 @@ def test_it_returns_error_on_invalid_page_value( headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_500(response) + data = json.loads(response.data.decode()) + assert response.status_code == 500 + assert 'error' in data['status'] + assert ( + 'error, please try again or contact the administrator' + in data['message'] + ) @patch('fittrackee.workouts.workouts.MAX_WORKOUTS_PER_PAGE', 6) def test_it_gets_max_workouts_per_page_if_per_page_exceeds_max( @@ -307,7 +403,7 @@ def test_it_gets_max_workouts_per_page_if_per_page_exceeds_max( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -322,11 +418,13 @@ def test_it_gets_max_workouts_per_page_if_per_page_exceeds_max( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['workouts']) == 6 - assert data['data']['workouts'][0] == jsonify_dict( - seven_workouts_user_1[6].serialize() + assert ( + 'Wed, 09 May 2018 00:00:00 GMT' + == data['data']['workouts'][0]['workout_date'] ) - assert data['data']['workouts'][5] == jsonify_dict( - seven_workouts_user_1[1].serialize() + assert ( + 'Thu, 01 Jun 2017 00:00:00 GMT' + == data['data']['workouts'][5]['workout_date'] ) assert data['pagination'] == { 'has_next': True, @@ -342,7 +440,7 @@ def test_it_gets_given_number_of_workouts_per_page( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -357,14 +455,13 @@ def test_it_gets_given_number_of_workouts_per_page( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['workouts']) == 3 - assert data['data']['workouts'][0] == jsonify_dict( - seven_workouts_user_1[6].serialize() - ) - assert data['data']['workouts'][1] == jsonify_dict( - seven_workouts_user_1[5].serialize() + assert ( + 'Wed, 09 May 2018 00:00:00 GMT' + == data['data']['workouts'][0]['workout_date'] ) - assert data['data']['workouts'][2] == jsonify_dict( - seven_workouts_user_1[3].serialize() + assert ( + 'Fri, 23 Feb 2018 10:00:00 GMT' + == data['data']['workouts'][2]['workout_date'] ) assert data['pagination'] == { 'has_next': True, @@ -375,13 +472,13 @@ def test_it_gets_given_number_of_workouts_per_page( } -class TestGetWorkoutsWithOrder(ApiTestCaseMixin): +class TestGetWorkoutsWithOrder(WorkoutApiTestCaseMixin): def test_it_gets_workouts_with_default_order( self, app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -417,7 +514,7 @@ def test_it_gets_workouts_with_ascending_order( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -453,7 +550,7 @@ def test_it_gets_workouts_with_descending_order( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -485,13 +582,13 @@ def test_it_gets_workouts_with_descending_order( } -class TestGetWorkoutsWithOrderBy(ApiTestCaseMixin): +class TestGetWorkoutsWithOrderBy(WorkoutApiTestCaseMixin): def test_it_gets_workouts_ordered_by_workout_date( self, app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -527,7 +624,7 @@ def test_it_gets_workouts_ordered_by_distance( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -607,7 +704,7 @@ def test_it_gets_workouts_ordered_by_average_speed( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -633,7 +730,7 @@ def test_it_gets_workouts_ordered_by_average_speed( } -class TestGetWorkoutsWithFilters(ApiTestCaseMixin): +class TestGetWorkoutsWithFilters(WorkoutApiTestCaseMixin): def test_it_gets_workouts_with_date_filter( self, app: Flask, @@ -661,10 +758,13 @@ def test_it_gets_workouts_with_date_filter( 'Fri, 23 Feb 2018 10:00:00 GMT' == data['data']['workouts'][0]['workout_date'] ) + assert '0:10:00' == data['data']['workouts'][0]['duration'] + assert 'creation_date' in data['data']['workouts'][1] assert ( 'Fri, 23 Feb 2018 00:00:00 GMT' == data['data']['workouts'][1]['workout_date'] ) + assert '0:16:40' == data['data']['workouts'][1]['duration'] assert data['pagination'] == { 'has_next': False, 'has_prev': False, @@ -678,7 +778,7 @@ def test_it_gets_no_workouts_with_date_filter( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -706,7 +806,7 @@ def test_if_gets_workouts_with_date_filter_from( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -743,7 +843,7 @@ def test_it_gets_workouts_with_date_filter_to( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -779,7 +879,7 @@ def test_it_gets_workouts_with_distance_filter( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -815,7 +915,7 @@ def test_it_gets_workouts_with_duration_filter( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -847,7 +947,7 @@ def test_it_gets_workouts_with_average_speed_filter( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -915,7 +1015,7 @@ def test_it_gets_workouts_with_sport_filter( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, sport_2_running: Sport, workout_running_user_1: Workout, ) -> None: @@ -1196,13 +1296,13 @@ def test_it_returns_all_workouts_when_description_filter_is_empty_string( assert workouts[1]['id'] == workout_cycling_user_1.short_id -class TestGetWorkoutsWithFiltersAndPagination(ApiTestCaseMixin): +class TestGetWorkoutsWithFiltersAndPagination(WorkoutApiTestCaseMixin): def test_it_gets_page_2_with_date_filter( self, app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -1238,7 +1338,7 @@ def test_it_get_page_2_with_date_filter_and_ascending_order( app: Flask, user_1: User, sport_1_cycling: Sport, - seven_workouts_user_1: List[Workout], + seven_workouts_user_1: Workout, ) -> None: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -1300,371 +1400,3 @@ def test_it_gets_all_workouts_with_title_filter( 'pages': 2, 'total': 7, } - - -class TestGetWorkout(ApiTestCaseMixin): - def test_it_gets_a_workout( - self, - app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_cycling_user_1: Workout, - ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/{workout_cycling_user_1.short_id}', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - data = json.loads(response.data.decode()) - assert response.status_code == 200 - assert 'success' in data['status'] - assert len(data['data']['workouts']) == 1 - assert data['data']['workouts'][0] == jsonify_dict( - workout_cycling_user_1.serialize() - ) - - def test_it_returns_403_if_workout_belongs_to_a_different_user( - self, - app: Flask, - user_1: User, - user_2: User, - sport_1_cycling: Sport, - workout_cycling_user_2: Workout, - ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/{workout_cycling_user_2.short_id}', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - self.assert_403(response) - - def test_it_returns_404_if_workout_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/workouts/{self.random_short_id()}', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - data = self.assert_404(response) - assert len(data['data']['workouts']) == 0 - - def test_it_returns_404_on_getting_gpx_if_workout_does_not_exist( - self, app: Flask, user_1: User - ) -> None: - random_short_id = self.random_short_id() - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/{random_short_id}/gpx', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - data = self.assert_404_with_message( - response, f'workout not found (id: {random_short_id})' - ) - assert data['data']['gpx'] == '' - - def test_it_returns_404_on_getting_chart_data_if_workout_does_not_exist( - self, app: Flask, user_1: User - ) -> None: - random_short_id = self.random_short_id() - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/{random_short_id}/chart_data', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - data = self.assert_404_with_message( - response, f'workout not found (id: {random_short_id})' - ) - assert data['data']['chart_data'] == '' - - def test_it_returns_404_on_getting_gpx_if_workout_have_no_gpx( - self, - app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_cycling_user_1: Workout, - ) -> None: - workout_short_id = workout_cycling_user_1.short_id - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/{workout_short_id}/gpx', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - self.assert_404_with_message( - response, f'no gpx file for this workout (id: {workout_short_id})' - ) - - def test_it_returns_404_if_workout_have_no_chart_data( - self, - app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_cycling_user_1: Workout, - ) -> None: - workout_short_id = workout_cycling_user_1.short_id - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/{workout_short_id}/chart_data', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - self.assert_404_with_message( - response, f'no gpx file for this workout (id: {workout_short_id})' - ) - - def test_it_returns_500_on_getting_gpx_if_a_workout_has_invalid_gpx_pathname( # noqa - self, - app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_cycling_user_1: Workout, - ) -> None: - workout_cycling_user_1.gpx = "some path" - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/{workout_cycling_user_1.short_id}/gpx', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - data = self.assert_500(response) - assert 'data' not in data - - def test_it_returns_500_on_getting_chart_data_if_a_workout_has_invalid_gpx_pathname( # noqa - self, - app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_cycling_user_1: Workout, - ) -> None: - workout_cycling_user_1.gpx = 'some path' - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/{workout_cycling_user_1.short_id}/chart_data', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - data = self.assert_500(response) - assert 'data' not in data - - def test_it_returns_404_if_workout_has_no_map( - 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/workouts/map/{uuid4().hex}', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - self.assert_404_with_message(response, 'Map does not exist') - - def test_it_returns_404_if_map_file_not_found( - self, - app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_cycling_user_1: Workout, - ) -> None: - map_ip = self.random_string() - workout_cycling_user_1.map = self.random_string() - workout_cycling_user_1.map_id = map_ip - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/map/{map_ip}', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - self.assert_404_with_message(response, 'Map file does not exist') - - @pytest.mark.parametrize( - 'client_scope, can_access', - {**OAUTH_SCOPES, 'workouts:read': True}.items(), - ) - @pytest.mark.parametrize( - 'endpoint', - [ - '/api/workouts/{workout_short_id}', - '/api/workouts/{workout_short_id}/gpx', - '/api/workouts/{workout_short_id}/chart_data', - '/api/workouts/{workout_short_id}/gpx/segment/1', - '/api/workouts/{workout_short_id}/chart_data/segment/1', - ], - ) - def test_expected_scopes_are_defined( - self, - app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_cycling_user_1: Workout, - client_scope: str, - can_access: bool, - endpoint: str, - ) -> None: - ( - client, - oauth_client, - access_token, - _, - ) = self.create_oauth2_client_and_issue_token( - app, user_1, scope=client_scope - ) - - response = client.get( - endpoint.format(workout_short_id=workout_cycling_user_1.short_id), - content_type='application/json', - headers=dict(Authorization=f'Bearer {access_token}'), - ) - - self.assert_response_scope(response, can_access) - - -class TestDownloadWorkoutGpx(ApiTestCaseMixin): - def test_it_returns_404_if_workout_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/workouts/{self.random_short_id()}/gpx/download', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - self.assert_404_with_message(response, 'workout not found') - - def test_it_returns_404_if_workout_does_not_have_gpx( - self, - app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_cycling_user_1: Workout, - ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/{workout_cycling_user_1.short_id}/gpx/download', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - self.assert_404_with_message(response, 'no gpx file for workout') - - def test_it_returns_404_if_workout_belongs_to_a_different_user( - self, - app: Flask, - user_1: User, - user_2: User, - sport_1_cycling: Sport, - workout_cycling_user_2: Workout, - ) -> None: - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - response = client.get( - f'/api/workouts/{workout_cycling_user_2.short_id}/gpx/download', - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - self.assert_404_with_message(response, 'workout not found') - - def test_it_calls_send_from_directory_if_workout_has_gpx( - self, - app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_cycling_user_1: Workout, - ) -> None: - gpx_file_path = 'file.gpx' - workout_cycling_user_1.gpx = gpx_file_path - with patch('fittrackee.workouts.workouts.send_from_directory') as mock: - mock.return_value = 'file' - client, auth_token = self.get_test_client_and_auth_token( - app, user_1.email - ) - - client.get( - ( - f'/api/workouts/{workout_cycling_user_1.short_id}/' - 'gpx/download' - ), - headers=dict(Authorization=f'Bearer {auth_token}'), - ) - - mock.assert_called_once_with( - app.config['UPLOAD_FOLDER'], - gpx_file_path, - mimetype='application/gpx+xml', - as_attachment=True, - ) - - @pytest.mark.parametrize( - 'client_scope, can_access', - {**OAUTH_SCOPES, 'workouts:read': True}.items(), - ) - def test_expected_scopes_are_defined( - self, - app: Flask, - user_1: User, - sport_1_cycling: Sport, - workout_cycling_user_1: Workout, - client_scope: str, - can_access: bool, - ) -> None: - ( - client, - oauth_client, - access_token, - _, - ) = self.create_oauth2_client_and_issue_token( - app, user_1, scope=client_scope - ) - - response = client.get( - f'/api/workouts/{workout_cycling_user_1.short_id}/gpx/download', - content_type='application/json', - headers=dict(Authorization=f'Bearer {access_token}'), - ) - - self.assert_response_scope(response, can_access) diff --git a/fittrackee/tests/workouts/test_workouts_api_1_post.py b/fittrackee/tests/workouts/test_workouts_api_1_post.py index 793dd01fc..486cc16dd 100644 --- a/fittrackee/tests/workouts/test_workouts_api_1_post.py +++ b/fittrackee/tests/workouts/test_workouts_api_1_post.py @@ -8,14 +8,17 @@ import pytest from flask import Flask from sqlalchemy.dialects.postgresql import insert +from time_machine import travel from fittrackee import VERSION, db from fittrackee.equipments.models import Equipment +from fittrackee.reports.models import ReportActionAppeal from fittrackee.users.models import ( User, UserSportPreference, UserSportPreferenceEquipment, ) +from fittrackee.visibility_levels import VisibilityLevel from fittrackee.workouts.models import ( DESCRIPTION_MAX_CHARACTERS, NOTES_MAX_CHARACTERS, @@ -24,17 +27,20 @@ Workout, ) -from ..mixins import ApiTestCaseMixin, CallArgsMixin +from ..mixins import BaseTestMixin, ReportMixin from ..utils import OAUTH_SCOPES, jsonify_dict +from .mixins import WorkoutApiTestCaseMixin -def assert_workout_data_with_gpx(data: Dict) -> None: +def assert_workout_data_with_gpx(data: Dict, user: User) -> None: assert 'creation_date' in data['data']['workouts'][0] assert ( 'Tue, 13 Mar 2018 12:44:45 GMT' == data['data']['workouts'][0]['workout_date'] ) - assert 'test' == data['data']['workouts'][0]['user'] + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user.serialize() + ) assert 1 == data['data']['workouts'][0]['sport_id'] assert '0:04:10' == data['data']['workouts'][0]['duration'] assert data['data']['workouts'][0]['ascent'] == 0.4 @@ -72,14 +78,14 @@ def assert_workout_data_with_gpx(data: Dict) -> None: assert len(records) == 5 assert records[0]['sport_id'] == 1 assert records[0]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[0]['record_type'] == 'MS' + assert records[0]['record_type'] == 'AS' assert records[0]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[0]['value'] == 5.12 + assert records[0]['value'] == 4.61 assert records[1]['sport_id'] == 1 assert records[1]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[1]['record_type'] == 'LD' + assert records[1]['record_type'] == 'FD' assert records[1]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[1]['value'] == '0:04:10' + assert records[1]['value'] == 0.32 assert records[2]['sport_id'] == 1 assert records[2]['workout_id'] == data['data']['workouts'][0]['id'] assert records[2]['record_type'] == 'HA' @@ -87,23 +93,25 @@ def assert_workout_data_with_gpx(data: Dict) -> None: assert records[2]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' assert records[3]['sport_id'] == 1 assert records[3]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[3]['record_type'] == 'FD' + assert records[3]['record_type'] == 'LD' assert records[3]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[3]['value'] == 0.32 + assert records[3]['value'] == '0:04:10' assert records[4]['sport_id'] == 1 assert records[4]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[4]['record_type'] == 'AS' + assert records[4]['record_type'] == 'MS' assert records[4]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[4]['value'] == 4.61 + assert records[4]['value'] == 5.12 -def assert_workout_data_with_gpx_segments(data: Dict) -> None: +def assert_workout_data_with_gpx_segments(data: Dict, user: User) -> None: assert 'creation_date' in data['data']['workouts'][0] assert ( 'Tue, 13 Mar 2018 12:44:45 GMT' == data['data']['workouts'][0]['workout_date'] ) - assert 'test' == data['data']['workouts'][0]['user'] + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user.serialize() + ) assert 1 == data['data']['workouts'][0]['sport_id'] assert '0:04:10' == data['data']['workouts'][0]['duration'] assert data['data']['workouts'][0]['ascent'] == 0.4 @@ -155,37 +163,40 @@ def assert_workout_data_with_gpx_segments(data: Dict) -> None: assert len(records) == 5 assert records[0]['sport_id'] == 1 assert records[0]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[0]['record_type'] == 'MS' + assert records[0]['record_type'] == 'AS' assert records[0]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[0]['value'] == 5.25 + assert records[0]['value'] == 4.59 assert records[1]['sport_id'] == 1 assert records[1]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[1]['record_type'] == 'LD' + assert records[1]['record_type'] == 'FD' assert records[1]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[1]['value'] == '0:03:55' + assert records[1]['value'] == 0.3 assert records[2]['sport_id'] == 1 assert records[2]['workout_id'] == data['data']['workouts'][0]['id'] assert records[2]['record_type'] == 'HA' assert records[2]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' + assert records[2]['value'] == 0.4 assert records[3]['sport_id'] == 1 assert records[3]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[3]['record_type'] == 'FD' + assert records[3]['record_type'] == 'LD' assert records[3]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[3]['value'] == 0.3 + assert records[3]['value'] == '0:03:55' assert records[4]['sport_id'] == 1 assert records[4]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[4]['record_type'] == 'AS' + assert records[4]['record_type'] == 'MS' assert records[4]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[4]['value'] == 4.59 + assert records[4]['value'] == 5.25 -def assert_workout_data_wo_gpx(data: Dict) -> None: +def assert_workout_data_wo_gpx(data: Dict, user: User) -> None: assert 'creation_date' in data['data']['workouts'][0] assert ( data['data']['workouts'][0]['workout_date'] == 'Tue, 15 May 2018 14:05:00 GMT' ) - assert data['data']['workouts'][0]['user'] == 'test' + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user.serialize() + ) assert data['data']['workouts'][0]['sport_id'] == 1 assert data['data']['workouts'][0]['duration'] == '1:00:00' assert ( @@ -214,22 +225,22 @@ def assert_workout_data_wo_gpx(data: Dict) -> None: assert len(records) == 4 assert records[0]['sport_id'] == 1 assert records[0]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[0]['record_type'] == 'MS' + assert records[0]['record_type'] == 'AS' assert records[0]['workout_date'] == 'Tue, 15 May 2018 14:05:00 GMT' assert records[0]['value'] == 10.0 assert records[1]['sport_id'] == 1 assert records[1]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[1]['record_type'] == 'LD' + assert records[1]['record_type'] == 'FD' assert records[1]['workout_date'] == 'Tue, 15 May 2018 14:05:00 GMT' - assert records[1]['value'] == '1:00:00' + assert records[1]['value'] == 10.0 assert records[2]['sport_id'] == 1 assert records[2]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[2]['record_type'] == 'FD' + assert records[2]['record_type'] == 'LD' assert records[2]['workout_date'] == 'Tue, 15 May 2018 14:05:00 GMT' - assert records[2]['value'] == 10.0 + assert records[2]['value'] == '1:00:00' assert records[3]['sport_id'] == 1 assert records[3]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[3]['record_type'] == 'AS' + assert records[3]['record_type'] == 'MS' assert records[3]['workout_date'] == 'Tue, 15 May 2018 14:05:00 GMT' assert records[3]['value'] == 10.0 @@ -252,7 +263,7 @@ def assert_files_are_deleted( ) -class TestPostWorkoutWithGpx(ApiTestCaseMixin, CallArgsMixin): +class TestPostWorkoutWithGpx(WorkoutApiTestCaseMixin, BaseTestMixin): def test_it_returns_error_if_user_is_not_authenticated( self, app: Flask, sport_1_cycling: Sport, gpx_file: str ) -> None: @@ -269,6 +280,31 @@ def test_it_returns_error_if_user_is_not_authenticated( self.assert_401(response) + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + suspended_user: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + '/api/workouts', + data=dict( + file=(BytesIO(str.encode(gpx_file)), 'example.gpx'), + data='{"sport_id": 1}', + ), + headers=dict( + content_type='multipart/form-data', + Authorization=f'Bearer {auth_token}', + ), + ) + + self.assert_403(response) + def test_it_adds_a_workout_with_gpx_file( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: @@ -288,12 +324,12 @@ def test_it_adds_a_workout_with_gpx_file( ), ) - data = json.loads(response.data.decode()) assert response.status_code == 201 + data = json.loads(response.data.decode()) assert 'created' in data['status'] assert len(data['data']['workouts']) == 1 assert 'just a workout' == data['data']['workouts'][0]['title'] - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) def test_it_adds_a_workout_with_gpx_file_and_title_exceeding_limits( self, @@ -328,7 +364,7 @@ def test_it_adds_a_workout_with_gpx_file_and_title_exceeding_limits( assert ( len(data['data']['workouts'][0]['title']) == TITLE_MAX_CHARACTERS ) - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) def test_it_adds_a_workout_with_gpx_file_raw_speed( self, @@ -448,7 +484,7 @@ def test_it_creates_workout_with_expecting_map_path( == f"workouts/1/2018-03-13_12-44-45_1_{expected_suffix}.png" ) - def test_it_adds_a_workout_with_gpx_without_name( + def test_it_adds_a_workout_without_name( self, app: Flask, user_1: User, @@ -471,15 +507,15 @@ def test_it_adds_a_workout_with_gpx_without_name( ), ) - data = json.loads(response.data.decode()) assert response.status_code == 201 + data = json.loads(response.data.decode()) assert 'created' in data['status'] assert len(data['data']['workouts']) == 1 assert ( f'{sport_1_cycling.label} - 2018-03-13 12:44:45' == data['data']['workouts'][0]['title'] ) - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) def test_it_adds_a_workout_with_provided_title( self, @@ -511,7 +547,7 @@ def test_it_adds_a_workout_with_provided_title( assert len(data['data']['workouts']) == 1 assert data['data']['workouts'][0]['title'] == title - def test_it_adds_a_workout_with_gpx_without_name_timezone( + def test_it_adds_a_workout_when_user_has_specified_timezone( self, app: Flask, user_1: User, @@ -535,15 +571,15 @@ def test_it_adds_a_workout_with_gpx_without_name_timezone( ), ) - data = json.loads(response.data.decode()) assert response.status_code == 201 + data = json.loads(response.data.decode()) assert 'created' in data['status'] assert len(data['data']['workouts']) == 1 assert ( f'{sport_1_cycling.label} - 2018-03-13 13:44:45' == data['data']['workouts'][0]['title'] ) - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) @pytest.mark.parametrize('input_user_timezone', [None, 'Europe/Paris']) def test_it_adds_a_workout_with_gpx_with_offset( @@ -578,7 +614,7 @@ def test_it_adds_a_workout_with_gpx_with_offset( assert response.status_code == 201 assert 'created' in data['status'] assert len(data['data']['workouts']) == 1 - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) def test_it_adds_a_workout_without_elevation( self, @@ -652,7 +688,7 @@ def test_it_returns_400_when_quotes_are_not_escaped_in_notes( ('notes with special characters', "test \n'workout'©"), ], ) - def test_it_adds_a_workout_with_gpx_notes( + def test_it_adds_a_workout_with_notes( self, input_description: str, input_notes: str, @@ -676,9 +712,9 @@ def test_it_adds_a_workout_with_gpx_notes( Authorization=f'Bearer {auth_token}', ), ) - data = json.loads(response.data.decode()) assert response.status_code == 201 + data = json.loads(response.data.decode()) assert 'created' in data['status'] assert len(data['data']['workouts']) == 1 assert data['data']['workouts'][0]['notes'] == input_notes @@ -1207,6 +1243,158 @@ def test_it_does_not_add_inactive_default_equipment_when_no_equipment_ids_provid assert equipment_bike_user_1.total_duration == timedelta() assert equipment_bike_user_1.total_moving == timedelta() + @pytest.mark.parametrize( + 'input_desc,input_visibility', + [ + ('private', VisibilityLevel.PRIVATE), + ('followers_only', VisibilityLevel.FOLLOWERS), + ('public', VisibilityLevel.PUBLIC), + ], + ) + def test_workout_is_created_with_user_privacy_parameters_when_no_provided( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + input_desc: str, + input_visibility: VisibilityLevel, + ) -> None: + user_1.map_visibility = input_visibility + user_1.workouts_visibility = input_visibility + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + '/api/workouts', + data=dict( + file=(BytesIO(str.encode(gpx_file)), 'example.gpx'), + data='{"sport_id": 1}', + ), + headers=dict( + content_type='multipart/form-data', + Authorization=f'Bearer {auth_token}', + ), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert 'created' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['map_visibility'] + == user_1.map_visibility.value + ) + assert ( + data['data']['workouts'][0]['workout_visibility'] + == user_1.workouts_visibility.value + ) + + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility', + [ + (VisibilityLevel.FOLLOWERS, VisibilityLevel.PUBLIC), + (VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS), + ], + ) + def test_workout_is_created_with_provided_privacy_parameters( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + '/api/workouts', + data=dict( + file=(BytesIO(str.encode(gpx_file)), 'example.gpx'), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{input_map_visibility.value}", ' + f'"workout_visibility": ' + f'"{input_workout_visibility.value}"}}' + ), + ), + headers=dict( + content_type='multipart/form-data', + Authorization=f'Bearer {auth_token}', + ), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert 'created' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['map_visibility'] + == input_map_visibility.value + ) + assert ( + data['data']['workouts'][0]['workout_visibility'] + == input_workout_visibility.value + ) + + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility', + [ + (VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE), + (VisibilityLevel.PUBLIC, VisibilityLevel.FOLLOWERS), + ], + ) + def test_workout_is_created_with_valid_privacy_parameters_when_provided( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + ) -> None: + """ + when workout visibility is stricter, map visibility is initialised + with workout visibility value + """ + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + '/api/workouts', + data=dict( + file=(BytesIO(str.encode(gpx_file)), 'example.gpx'), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{input_map_visibility.value}", ' + f'"workout_visibility": ' + f'"{input_workout_visibility.value}"}}' + ), + ), + headers=dict( + content_type='multipart/form-data', + Authorization=f'Bearer {auth_token}', + ), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert 'created' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['map_visibility'] + == input_workout_visibility.value + ) + assert ( + data['data']['workouts'][0]['workout_visibility'] + == input_workout_visibility.value + ) + def test_it_calls_configured_tile_server_for_static_map_when_default_static_map_to_false( # noqa self, app: Flask, @@ -1278,6 +1466,7 @@ def test_it_calls_default_tile_server_for_static_map_when_default_static_map_to_ client, auth_token = self.get_test_client_and_auth_token( app_default_static_map, user_1.email ) + client.post( '/api/workouts', data=dict( @@ -1322,7 +1511,6 @@ def test_it_calls_static_map_with_fittrackee_user_agent_when_default_static_map_ ) call_kwargs = self.get_kwargs(static_map_get_mock.call_args) - assert call_kwargs['headers'] == { 'User-Agent': f'FitTrackee v{VERSION}' } @@ -1452,7 +1640,7 @@ def test_it_returns_400_if_sport_id_is_not_provided( self.assert_400(response) - def test_it_returns_500_if_sport_id_does_not_exists( + def test_it_returns_500_if_sport_id_does_not_exist( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: client, auth_token = self.get_test_client_and_auth_token( @@ -1491,7 +1679,7 @@ def test_returns_400_if_no_gpx_file_is_provided( self.assert_400(response, 'no file part', 'fail') - def test_it_returns_error_if_file_size_exceeds_limit( + def test_it_returns_error_when_file_size_exceeds_limit( self, app_with_max_file_size: Flask, user_1: User, @@ -1645,7 +1833,7 @@ def test_expected_scopes_are_defined( self.assert_response_scope(response, can_access) -class TestPostWorkoutWithoutGpx(ApiTestCaseMixin): +class TestPostWorkoutWithoutGpx(WorkoutApiTestCaseMixin): def test_it_returns_error_if_user_is_not_authenticated( self, app: Flask, sport_1_cycling: Sport, gpx_file: str ) -> None: @@ -1688,11 +1876,34 @@ def test_it_adds_a_workout_without_gpx( headers=dict(Authorization=f'Bearer {auth_token}'), ) - data = json.loads(response.data.decode()) assert response.status_code == 201 + data = json.loads(response.data.decode()) assert 'created' in data['status'] assert len(data['data']['workouts']) == 1 - assert_workout_data_wo_gpx(data) + assert_workout_data_wo_gpx(data, user_1) + + def test_it_returns_error_when_user_is_suspended( + self, app: Flask, suspended_user: User, sport_1_cycling: Sport + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, suspended_user.email + ) + + response = client.post( + '/api/workouts/no_gpx', + content_type='application/json', + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date='2018-05-15 14:05', + distance=10, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) def test_it_adds_a_workout_without_gpx_and_title( self, app: Flask, user_1: User, sport_1_cycling: Sport @@ -2436,6 +2647,100 @@ def test_it_returns_400_when_multiple_equipments_are_provided( self.assert_400(response, "only one equipment can be added") + @pytest.mark.parametrize( + 'input_desc,input_visibility', + [ + ('private', VisibilityLevel.PRIVATE), + ('followers_only', VisibilityLevel.FOLLOWERS), + ('public', VisibilityLevel.PUBLIC), + ], + ) + def test_workout_is_created_with_user_privacy_parameters_when_no_provided( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + input_desc: str, + input_visibility: VisibilityLevel, + ) -> None: + user_1.map_visibility = input_visibility + user_1.workouts_visibility = input_visibility + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + '/api/workouts/no_gpx', + content_type='application/json', + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date='2018-05-15 14:05', + distance=10, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert 'created' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['map_visibility'] + == user_1.map_visibility.value + ) + assert ( + data['data']['workouts'][0]['workout_visibility'] + == user_1.workouts_visibility.value + ) + + @pytest.mark.parametrize( + 'input_workout_visibility', + [ + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ], + ) + def test_workout_is_created_with_provided_privacy_parameters( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + input_workout_visibility: VisibilityLevel, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + '/api/workouts/no_gpx', + content_type='application/json', + data=json.dumps( + dict( + sport_id=1, + duration=3600, + workout_date='2018-05-15 14:05', + distance=10, + workout_visibility=input_workout_visibility.value, + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert 'created' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['workout_visibility'] + == input_workout_visibility.value + ) + @pytest.mark.parametrize( 'client_scope, can_access', {**OAUTH_SCOPES, 'workouts:write': True}.items(), @@ -2468,12 +2773,12 @@ def test_expected_scopes_are_defined( self.assert_response_scope(response, can_access) -class TestPostWorkoutWithZipArchive(ApiTestCaseMixin): +class TestPostWorkoutWithZipArchive(WorkoutApiTestCaseMixin): def test_it_adds_workouts_with_zip_archive( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: - file_path = os.path.join(app.root_path, 'tests/files/gpx_test.zip') # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file + file_path = os.path.join(app.root_path, 'tests/files/gpx_test.zip') with open(file_path, 'rb') as zip_file: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -2490,8 +2795,8 @@ def test_it_adds_workouts_with_zip_archive( ), ) - data = json.loads(response.data.decode()) assert response.status_code == 201 + data = json.loads(response.data.decode()) assert 'created' in data['status'] assert len(data['data']['workouts']) == 3 assert 'creation_date' in data['data']['workouts'][0] @@ -2499,7 +2804,9 @@ def test_it_adds_workouts_with_zip_archive( 'Tue, 13 Mar 2018 12:44:45 GMT' == data['data']['workouts'][0]['workout_date'] ) - assert 'test' == data['data']['workouts'][0]['user'] + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() + ) assert 1 == data['data']['workouts'][0]['sport_id'] assert '0:04:10' == data['data']['workouts'][0]['duration'] assert data['data']['workouts'][0]['ascent'] == 0.4 @@ -2536,11 +2843,14 @@ def test_it_adds_workouts_with_zip_archive( def test_it_returns_400_if_folder_is_present_in_zip_archive( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: + # 'gpx_test_folder.zip' contains 3 gpx files (same data) and 1 non-gpx + # file in a folder file_path = os.path.join( app.root_path, 'tests/files/gpx_test_folder.zip' ) - # 'gpx_test_folder.zip' contains 3 gpx files (same data) and 1 non-gpx - # file in a folder + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) with open(file_path, 'rb') as zip_file: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -2564,10 +2874,13 @@ def test_it_returns_400_if_folder_is_present_in_zip_archive( def test_it_returns_500_if_one_file_in_zip_archive_is_invalid( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: + # 'gpx_test_incorrect.zip' contains 2 gpx files, one is incorrect file_path = os.path.join( app.root_path, 'tests/files/gpx_test_incorrect.zip' ) - # 'gpx_test_incorrect.zip' contains 2 gpx files, one is incorrect + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) with open(file_path, 'rb') as zip_file: client, auth_token = self.get_test_client_and_auth_token( app, user_1.email @@ -2626,10 +2939,10 @@ def test_it_returns_error_if_archive_size_exceeds_limit( user_1: User, sport_1_cycling: Sport, ) -> None: + # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file file_path = os.path.join( app_with_max_zip_file_size.root_path, 'tests/files/gpx_test.zip' ) - # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file with open(file_path, 'rb') as zip_file: client, auth_token = self.get_test_client_and_auth_token( app_with_max_zip_file_size, user_1.email @@ -2653,25 +2966,86 @@ def test_it_returns_error_if_archive_size_exceeds_limit( ) assert 'data' not in data - def test_it_returns_error_if_a_file_from_archive_size_exceeds_limit( + @pytest.mark.parametrize( + 'input_desc,input_visibility', + [ + ('private', VisibilityLevel.PRIVATE), + ('followers_only', VisibilityLevel.FOLLOWERS), + ('public', VisibilityLevel.PUBLIC), + ], + ) + def test_workouts_are_created_with_user_privacy_parameters_when_no_provided( # noqa self, - app_with_max_file_size: Flask, + app: Flask, user_1: User, sport_1_cycling: Sport, + input_desc: str, + input_visibility: VisibilityLevel, ) -> None: - file_path = os.path.join( - app_with_max_file_size.root_path, 'tests/files/gpx_test.zip' + file_path = os.path.join(app.root_path, 'tests/files/gpx_test.zip') + user_1.map_visibility = input_visibility + user_1.workouts_visibility = input_visibility + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email ) - # 'gpx_test.zip' contains 3 gpx files (same data) and 1 non-gpx file + with open(file_path, 'rb') as zip_file: - client, auth_token = self.get_test_client_and_auth_token( - app_with_max_file_size, user_1.email + response = client.post( + '/api/workouts', + data=dict( + file=(zip_file, 'gpx_test.zip'), data='{"sport_id": 1}' + ), + headers=dict( + content_type='multipart/form-data', + Authorization=f'Bearer {auth_token}', + ), ) + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert 'created' in data['status'] + assert len(data['data']['workouts']) == 3 + for n in range(3): + assert ( + data['data']['workouts'][n]['map_visibility'] + == user_1.map_visibility.value + ) + assert ( + data['data']['workouts'][n]['workout_visibility'] + == user_1.workouts_visibility.value + ) + + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility', + [ + (VisibilityLevel.FOLLOWERS, VisibilityLevel.PUBLIC), + (VisibilityLevel.PRIVATE, VisibilityLevel.FOLLOWERS), + ], + ) + def test_workouts_are_created_with_provided_privacy_parameters( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + ) -> None: + file_path = os.path.join(app.root_path, 'tests/files/gpx_test.zip') + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + with open(file_path, 'rb') as zip_file: response = client.post( '/api/workouts', data=dict( - file=(zip_file, 'gpx_test.zip'), data='{"sport_id": 1}' + file=(zip_file, 'gpx_test.zip'), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{input_map_visibility.value}", ' + f'"workout_visibility": ' + f'"{input_workout_visibility.value}"}}' + ), ), headers=dict( content_type='multipart/form-data', @@ -2679,13 +3053,19 @@ def test_it_returns_error_if_a_file_from_archive_size_exceeds_limit( ), ) - data = self.assert_400( - response, - 'at least one file in zip archive exceeds size limit, ' - 'please check the archive', - 'fail', + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert 'created' in data['status'] + assert len(data['data']['workouts']) == 3 + for n in range(3): + assert ( + data['data']['workouts'][n]['map_visibility'] + == input_map_visibility.value + ) + assert ( + data['data']['workouts'][n]['workout_visibility'] + == input_workout_visibility.value ) - assert 'data' not in data def test_it_cleans_uploaded_file_on_error( self, app: Flask, user_1: User, sport_1_cycling: Sport @@ -2818,8 +3198,64 @@ def test_it_adds_a_workout_with_default_sport_equipments_when_no_equipment_ids_p jsonify_dict(equipment_bike_user_1.serialize()) ] + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility', + [ + (VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE), + (VisibilityLevel.PUBLIC, VisibilityLevel.FOLLOWERS), + ], + ) + def test_workouts_are_created_with_valid_privacy_parameters_when_provided( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + ) -> None: + """ + when workout visibility is stricter, map visibility is initialised + with workout visibility value + """ + file_path = os.path.join(app.root_path, 'tests/files/gpx_test.zip') + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + with open(file_path, 'rb') as zip_file: + response = client.post( + '/api/workouts', + data=dict( + file=(zip_file, 'gpx_test.zip'), + data=( + f'{{"sport_id": 1, "map_visibility": ' + f'"{input_map_visibility.value}", ' + f'"workout_visibility": ' + f'"{input_workout_visibility.value}"}}' + ), + ), + headers=dict( + content_type='multipart/form-data', + Authorization=f'Bearer {auth_token}', + ), + ) + + assert response.status_code == 201 + data = json.loads(response.data.decode()) + assert 'created' in data['status'] + assert len(data['data']['workouts']) == 3 + for n in range(3): + assert ( + data['data']['workouts'][n]['map_visibility'] + == input_workout_visibility.value + ) + assert ( + data['data']['workouts'][n]['workout_visibility'] + == input_workout_visibility.value + ) + -class TestPostAndGetWorkoutWithGpx(ApiTestCaseMixin): +class TestPostAndGetWorkoutWithGpx(WorkoutApiTestCaseMixin): def workout_assertion( self, app: Flask, user_1: User, gpx_file: str, with_segments: bool ) -> None: @@ -2844,9 +3280,9 @@ def workout_assertion( assert len(data['data']['workouts']) == 1 assert 'just a workout' == data['data']['workouts'][0]['title'] if with_segments: - assert_workout_data_with_gpx_segments(data) + assert_workout_data_with_gpx_segments(data, user_1) else: - assert_workout_data_with_gpx(data) + assert_workout_data_with_gpx(data, user_1) map_id = data['data']['workouts'][0]['map'] workout_short_id = data['data']['workouts'][0]['id'] @@ -3024,7 +3460,7 @@ def test_it_gets_segment_chart_data_for_a_workout_created_with_gpx( 'time': 'Tue, 13 Mar 2018 12:44:45 GMT', } - def test_it_returns_403_on_getting_chart_data_if_workout_belongs_to_another_user( # noqa + def test_it_returns_404_on_getting_chart_data_if_workout_belongs_to_another_user( # noqa self, app: Flask, user_1: User, @@ -3057,7 +3493,7 @@ def test_it_returns_403_on_getting_chart_data_if_workout_belongs_to_another_user headers=dict(Authorization=f'Bearer {auth_token}'), ) - self.assert_403(response) + self.assert_404(response) def test_it_returns_500_on_invalid_segment_id( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str @@ -3117,7 +3553,7 @@ def test_it_returns_404_if_segment_id_does_not_exist( assert 'data' not in data -class TestPostAndGetWorkoutWithoutGpx(ApiTestCaseMixin): +class TestPostAndGetWorkoutWithoutGpx(WorkoutApiTestCaseMixin): def test_it_add_and_gets_a_workout_wo_gpx( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: @@ -3149,7 +3585,7 @@ def test_it_add_and_gets_a_workout_wo_gpx( assert response.status_code == 200 assert 'success' in data['status'] assert len(data['data']['workouts']) == 1 - assert_workout_data_wo_gpx(data) + assert_workout_data_wo_gpx(data, user_1) def test_it_adds_and_gets_a_workout_wo_gpx_notes( self, app: Flask, user_1: User, sport_1_cycling: Sport @@ -3186,7 +3622,7 @@ def test_it_adds_and_gets_a_workout_wo_gpx_notes( assert 'new test with notes' == data['data']['workouts'][0]['notes'] -class TestPostAndGetWorkoutUsingTimezones(ApiTestCaseMixin): +class TestPostAndGetWorkoutUsingTimezones(WorkoutApiTestCaseMixin): def test_it_add_and_gets_a_workout_wo_gpx_with_timezone( self, app: Flask, user_1: User, sport_1_cycling: Sport ) -> None: @@ -3344,3 +3780,238 @@ def test_it_adds_and_gets_workouts_date_filter_with_timezone_paris( f'{sport_1_cycling.label} - 2018-01-01 00:00:00' == data['data']['workouts'][1]['title'] ) + + +class TestPostWorkoutSuspensionAppeal( + WorkoutApiTestCaseMixin, ReportMixin, BaseTestMixin +): + def test_it_returns_error_if_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client = app.test_client() + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + ) + + self.assert_401(response) + + def test_it_returns_404_if_workout_does_not_exist( + self, + app: Flask, + user_1: User, + ) -> None: + workout_short_id = self.random_short_id() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404(response) + assert len(data['data']['workouts']) == 0 + + def test_it_returns_403_if_user_is_not_workout_owner( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_2.short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_400_if_workout_is_not_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, error_message="workout is not suspended") + + def test_it_returns_400_if_suspended_workout_has_no_report_action( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, error_message="workout has no suspension") + + @pytest.mark.parametrize( + 'input_data', [{}, {"text": ""}, {"comment": "some text"}] + ) + def test_it_returns_400_when_appeal_text_is_missing( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_data: Dict, + ) -> None: + workout_cycling_user_1.suspended_at = datetime.utcnow() + self.create_report_workout_action( + user_2_admin, user_1, workout_cycling_user_1 + ) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/suspension/appeal", + content_type="application/json", + data=json.dumps(input_data), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, 'no text provided') + + def test_user_can_appeal_comment_suspension( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.suspended_at = datetime.utcnow() + action = self.create_report_workout_action( + user_2_admin, user_1, workout_cycling_user_1 + ) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + text = self.random_string() + now = datetime.utcnow() + + with travel(now, tick=False): + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/suspension/appeal", + content_type='application/json', + data=json.dumps(dict(text=text)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 201 + assert response.json == {"status": "success"} + appeal = ReportActionAppeal.query.filter_by( + action_id=action.id + ).first() + assert appeal.moderator_id is None + assert appeal.approved is None + assert appeal.created_at == now + assert appeal.user_id == user_1.id + assert appeal.updated_at is None + + def test_user_can_appeal_comment_suspension_only_once( + self, + app: Flask, + user_1: User, + user_2_admin: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.suspended_at = datetime.utcnow() + action = self.create_report_workout_action( + user_2_admin, user_1, workout_cycling_user_1 + ) + db.session.flush() + appeal = ReportActionAppeal( + action_id=action.id, + user_id=user_1.id, + text=self.random_string(), + ) + db.session.add(appeal) + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/suspension/appeal", + content_type='application/json', + data=json.dumps(dict(text=self.random_string())), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_400(response, error_message='you can appeal only once') + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + f"/api/workouts/{workout_cycling_user_1.short_id}/suspension/appeal", + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) diff --git a/fittrackee/tests/workouts/test_workouts_api_2_patch.py b/fittrackee/tests/workouts/test_workouts_api_2_patch.py index 1c36d5aa9..fd014cc19 100644 --- a/fittrackee/tests/workouts/test_workouts_api_2_patch.py +++ b/fittrackee/tests/workouts/test_workouts_api_2_patch.py @@ -1,5 +1,5 @@ import json -from datetime import timedelta +from datetime import datetime, timedelta from typing import Dict from uuid import uuid4 @@ -8,8 +8,9 @@ from fittrackee import db from fittrackee.equipments.models import Equipment -from fittrackee.users.models import User +from fittrackee.users.models import FollowRequest, User from fittrackee.utils import decode_short_id +from fittrackee.visibility_levels import VisibilityLevel from fittrackee.workouts.models import ( DESCRIPTION_MAX_CHARACTERS, NOTES_MAX_CHARACTERS, @@ -18,18 +19,22 @@ Workout, ) -from ..mixins import ApiTestCaseMixin from ..utils import OAUTH_SCOPES, jsonify_dict +from .mixins import WorkoutApiTestCaseMixin from .utils import post_a_workout -def assert_workout_data_with_gpx(data: Dict, sport_id: int) -> None: +def assert_workout_data_with_gpx( + data: Dict, sport_id: int, user: User +) -> None: assert 'creation_date' in data['data']['workouts'][0] assert ( 'Tue, 13 Mar 2018 12:44:45 GMT' == data['data']['workouts'][0]['workout_date'] ) - assert 'test' == data['data']['workouts'][0]['user'] + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user.serialize() + ) assert '0:04:10' == data['data']['workouts'][0]['duration'] assert data['data']['workouts'][0]['ascent'] == 0.4 assert data['data']['workouts'][0]['ave_speed'] == 4.61 @@ -47,31 +52,32 @@ def assert_workout_data_with_gpx(data: Dict, sport_id: int) -> None: assert len(records) == 5 assert records[0]['sport_id'] == sport_id assert records[0]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[0]['record_type'] == 'MS' + assert records[0]['record_type'] == 'AS' assert records[0]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[0]['value'] == 5.12 + assert records[0]['value'] == 4.61 assert records[1]['sport_id'] == sport_id assert records[1]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[1]['record_type'] == 'LD' + assert records[1]['record_type'] == 'FD' assert records[1]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[1]['value'] == '0:04:10' + assert records[1]['value'] == 0.32 assert records[2]['sport_id'] == sport_id assert records[2]['workout_id'] == data['data']['workouts'][0]['id'] assert records[2]['record_type'] == 'HA' assert records[2]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' + assert records[2]['value'] == 0.4 assert records[3]['sport_id'] == sport_id assert records[3]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[3]['record_type'] == 'FD' + assert records[3]['record_type'] == 'LD' assert records[3]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[3]['value'] == 0.32 + assert records[3]['value'] == '0:04:10' assert records[4]['sport_id'] == sport_id assert records[4]['workout_id'] == data['data']['workouts'][0]['id'] - assert records[4]['record_type'] == 'AS' + assert records[4]['record_type'] == 'MS' assert records[4]['workout_date'] == 'Tue, 13 Mar 2018 12:44:45 GMT' - assert records[4]['value'] == 4.61 + assert records[4]['value'] == 5.12 -class TestEditWorkoutWithGpx(ApiTestCaseMixin): +class TestEditWorkoutWithGpx(WorkoutApiTestCaseMixin): def test_it_updates_sport_and_title_for_a_workout_with_gpx( self, app: Flask, @@ -96,7 +102,7 @@ def test_it_updates_sport_and_title_for_a_workout_with_gpx( assert len(data['data']['workouts']) == 1 assert sport_2_running.id == data['data']['workouts'][0]['sport_id'] assert data['data']['workouts'][0]['title'] == 'Workout test' - assert_workout_data_with_gpx(data, sport_2_running.id) + assert_workout_data_with_gpx(data, sport_2_running.id, user_1) def test_it_updates_title_when_it_exceeds_max_limit( self, @@ -294,16 +300,73 @@ def test_it_empties_workout_description( assert len(data['data']['workouts']) == 1 assert data['data']['workouts'][0]['description'] == '' - def test_it_raises_403_when_editing_a_workout_from_different_user( + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + 403, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_followed_user_user( self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + expected_status_code: int, app: Flask, user_1: User, user_2: User, sport_1_cycling: Sport, - sport_2_running: Sport, gpx_file: str, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: - _, workout_short_id = post_a_workout(app, gpx_file) + user_1.approves_follow_request_from(user_2) + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.patch( + f'/api/workouts/{workout_short_id}', + content_type='application/json', + data=json.dumps(dict(sport_id=2, title='Workout test')), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + 404, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_different_user( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + expected_status_code: int, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) client, auth_token = self.get_test_client_and_auth_token( app, user_2.email ) @@ -315,6 +378,62 @@ def test_it_raises_403_when_editing_a_workout_from_different_user( headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_401_when_no_authenticated( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) + client = app.test_client() + + response = client.patch( + f'/api/workouts/{workout_short_id}', + content_type='application/json', + data=json.dumps(dict(sport_id=2, title="Workout test")), + ) + + assert response.status_code == 401 + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + token, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=VisibilityLevel.PRIVATE + ) + client = app.test_client() + user_1.suspended_at = datetime.utcnow() + db.session.commit() + + response = client.patch( + f'/api/workouts/{workout_short_id}', + content_type='application/json', + data=json.dumps(dict(sport_id=2, title="Workout test")), + headers=dict(Authorization=f'Bearer {token}'), + ) + self.assert_403(response) def test_it_updates_sport( @@ -341,7 +460,7 @@ def test_it_updates_sport( assert len(data['data']['workouts']) == 1 assert sport_2_running.id == data['data']['workouts'][0]['sport_id'] assert data['data']['workouts'][0]['title'] == 'just a workout' - assert_workout_data_with_gpx(data, sport_2_running.id) + assert_workout_data_with_gpx(data, sport_2_running.id, user_1) def test_it_returns_400_if_payload_is_empty( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str @@ -731,7 +850,7 @@ def test_expected_scopes_are_defined( self.assert_response_scope(response, can_access) -class TestEditWorkoutWithoutGpx(ApiTestCaseMixin): +class TestEditWorkoutWithoutGpx(WorkoutApiTestCaseMixin): def test_it_updates_a_workout_wo_gpx( self, app: Flask, @@ -770,7 +889,9 @@ def test_it_updates_a_workout_wo_gpx( data['data']['workouts'][0]['workout_date'] == 'Tue, 15 May 2018 15:05:00 GMT' ) - assert data['data']['workouts'][0]['user'] == 'test' + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() + ) assert data['data']['workouts'][0]['sport_id'] == sport_2_running.id assert data['data']['workouts'][0]['duration'] == '1:00:00' assert data['data']['workouts'][0]['title'] == 'Workout test' @@ -794,22 +915,22 @@ def test_it_updates_a_workout_wo_gpx( assert len(records) == 4 assert records[0]['sport_id'] == sport_2_running.id assert records[0]['workout_id'] == workout_short_id - assert records[0]['record_type'] == 'MS' + assert records[0]['record_type'] == 'AS' assert records[0]['workout_date'] == 'Tue, 15 May 2018 15:05:00 GMT' assert records[0]['value'] == 8.0 assert records[1]['sport_id'] == sport_2_running.id assert records[1]['workout_id'] == workout_short_id - assert records[1]['record_type'] == 'LD' + assert records[1]['record_type'] == 'FD' assert records[1]['workout_date'] == 'Tue, 15 May 2018 15:05:00 GMT' - assert records[1]['value'] == '1:00:00' + assert records[1]['value'] == 8.0 assert records[2]['sport_id'] == sport_2_running.id assert records[2]['workout_id'] == workout_short_id - assert records[2]['record_type'] == 'FD' + assert records[2]['record_type'] == 'LD' assert records[2]['workout_date'] == 'Tue, 15 May 2018 15:05:00 GMT' - assert records[2]['value'] == 8.0 + assert records[2]['value'] == '1:00:00' assert records[3]['sport_id'] == sport_2_running.id assert records[3]['workout_id'] == workout_short_id - assert records[3]['record_type'] == 'AS' + assert records[3]['record_type'] == 'MS' assert records[3]['workout_date'] == 'Tue, 15 May 2018 15:05:00 GMT' assert records[3]['value'] == 8.0 @@ -891,14 +1012,77 @@ def test_it_updates_title_when_it_exceeds_max_limit( assert len(data['data']['workouts']) == 1 assert data['data']['workouts'][0]['title'] == title[:-1] + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + 403, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC, 403), + ], + ) + def test_returns_403_when_editing_a_workout_wo_gpx_from_followed_user( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + expected_status_code: int, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f'/api/workouts/{workout_cycling_user_2.short_id}', + content_type='application/json', + data=json.dumps( + dict( + sport_id=2, + duration=3600, + workout_date='2018-05-15 15:05', + distance=8, + title='Workout test', + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + 404, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC, 403), + ], + ) def test_it_returns_403_when_editing_a_workout_wo_gpx_from_different_user( self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + expected_status_code: int, app: Flask, user_1: User, user_2: User, sport_1_cycling: Sport, workout_cycling_user_2: Workout, ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_visibility client, auth_token = self.get_test_client_and_auth_token( app, user_1.email ) @@ -918,6 +1102,74 @@ def test_it_returns_403_when_editing_a_workout_wo_gpx_from_different_user( headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_401_when_no_authenticated( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client = app.test_client() + + response = client.patch( + f'/api/workouts/{workout_cycling_user_1.short_id}', + content_type='application/json', + data=json.dumps( + dict( + sport_id=2, + duration=3600, + workout_date='2018-05-15 15:05', + distance=8, + title='Workout test', + ) + ), + ) + + assert response.status_code == 401 + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f'/api/workouts/{workout_cycling_user_1.short_id}', + content_type='application/json', + data=json.dumps( + dict( + sport_id=2, + duration=3600, + workout_date='2018-05-15 15:05', + distance=8, + title='Workout test', + ) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + self.assert_403(response) def test_it_updates_a_workout_wo_gpx_with_timezone( @@ -957,7 +1209,9 @@ def test_it_updates_a_workout_wo_gpx_with_timezone( data['data']['workouts'][0]['workout_date'] == 'Tue, 15 May 2018 13:05:00 GMT' ) - assert data['data']['workouts'][0]['user'] == 'test' + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1_paris.serialize() + ) assert data['data']['workouts'][0]['sport_id'] == sport_2_running.id assert data['data']['workouts'][0]['duration'] == '1:00:00' assert data['data']['workouts'][0]['title'] == 'Workout test' @@ -977,22 +1231,22 @@ def test_it_updates_a_workout_wo_gpx_with_timezone( assert len(records) == 4 assert records[0]['sport_id'] == sport_2_running.id assert records[0]['workout_id'] == workout_short_id - assert records[0]['record_type'] == 'MS' + assert records[0]['record_type'] == 'AS' assert records[0]['workout_date'] == 'Tue, 15 May 2018 13:05:00 GMT' assert records[0]['value'] == 8.0 assert records[1]['sport_id'] == sport_2_running.id assert records[1]['workout_id'] == workout_short_id - assert records[1]['record_type'] == 'LD' + assert records[1]['record_type'] == 'FD' assert records[1]['workout_date'] == 'Tue, 15 May 2018 13:05:00 GMT' - assert records[1]['value'] == '1:00:00' + assert records[1]['value'] == 8.0 assert records[2]['sport_id'] == sport_2_running.id assert records[2]['workout_id'] == workout_short_id - assert records[2]['record_type'] == 'FD' + assert records[2]['record_type'] == 'LD' assert records[2]['workout_date'] == 'Tue, 15 May 2018 13:05:00 GMT' - assert records[2]['value'] == 8.0 + assert records[2]['value'] == '1:00:00' assert records[3]['sport_id'] == sport_2_running.id assert records[3]['workout_id'] == workout_short_id - assert records[3]['record_type'] == 'AS' + assert records[3]['record_type'] == 'MS' assert records[3]['workout_date'] == 'Tue, 15 May 2018 13:05:00 GMT' assert records[3]['value'] == 8.0 @@ -1025,7 +1279,9 @@ def test_it_updates_only_sport_and_distance_a_workout_wo_gpx( data['data']['workouts'][0]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' ) - assert data['data']['workouts'][0]['user'] == 'test' + assert data['data']['workouts'][0]['user'] == jsonify_dict( + user_1.serialize() + ) assert data['data']['workouts'][0]['sport_id'] == sport_2_running.id assert data['data']['workouts'][0]['duration'] == '1:00:00' assert data['data']['workouts'][0]['title'] is None @@ -1045,22 +1301,22 @@ def test_it_updates_only_sport_and_distance_a_workout_wo_gpx( assert len(records) == 4 assert records[0]['sport_id'] == sport_2_running.id assert records[0]['workout_id'] == workout_short_id - assert records[0]['record_type'] == 'MS' + assert records[0]['record_type'] == 'AS' assert records[0]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' assert records[0]['value'] == 20.0 assert records[1]['sport_id'] == sport_2_running.id assert records[1]['workout_id'] == workout_short_id - assert records[1]['record_type'] == 'LD' + assert records[1]['record_type'] == 'FD' assert records[1]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' - assert records[1]['value'] == '1:00:00' + assert records[1]['value'] == 20.0 assert records[2]['sport_id'] == sport_2_running.id assert records[2]['workout_id'] == workout_short_id - assert records[2]['record_type'] == 'FD' + assert records[2]['record_type'] == 'LD' assert records[2]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' - assert records[2]['value'] == 20.0 + assert records[2]['value'] == '1:00:00' assert records[3]['sport_id'] == sport_2_running.id assert records[3]['workout_id'] == workout_short_id - assert records[3]['record_type'] == 'AS' + assert records[3]['record_type'] == 'MS' assert records[3]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' assert records[3]['value'] == 20.0 @@ -1096,27 +1352,27 @@ def test_it_updates_ascent_and_descent_values( assert len(records) == 5 assert records[0]['sport_id'] == sport_1_cycling.id assert records[0]['workout_id'] == workout_short_id - assert records[0]['record_type'] == 'HA' + assert records[0]['record_type'] == 'AS' assert records[0]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' - assert records[0]['value'] == ascent + assert records[0]['value'] == 10.0 assert records[1]['sport_id'] == sport_1_cycling.id assert records[1]['workout_id'] == workout_short_id - assert records[1]['record_type'] == 'MS' + assert records[1]['record_type'] == 'FD' assert records[1]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' assert records[1]['value'] == 10.0 assert records[2]['sport_id'] == sport_1_cycling.id assert records[2]['workout_id'] == workout_short_id - assert records[2]['record_type'] == 'LD' + assert records[2]['record_type'] == 'HA' assert records[2]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' - assert records[2]['value'] == '1:00:00' + assert records[2]['value'] == ascent assert records[3]['sport_id'] == sport_1_cycling.id assert records[3]['workout_id'] == workout_short_id - assert records[3]['record_type'] == 'FD' + assert records[3]['record_type'] == 'LD' assert records[3]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' - assert records[3]['value'] == 10.0 + assert records[3]['value'] == '1:00:00' assert records[4]['sport_id'] == sport_1_cycling.id assert records[4]['workout_id'] == workout_short_id - assert records[4]['record_type'] == 'AS' + assert records[4]['record_type'] == 'MS' assert records[4]['workout_date'] == 'Mon, 01 Jan 2018 00:00:00 GMT' assert records[4]['value'] == 10.0 @@ -1383,3 +1639,206 @@ def test_it_updates_equipment_totals( assert equipment_shoes_user_1.total_moving == timedelta( seconds=new_duration ) + + +class TestUpdateVisibility(WorkoutApiTestCaseMixin): + @pytest.mark.parametrize( + 'input_description,input_workout_visibility', + [ + ('private', VisibilityLevel.PRIVATE), + ('followers_only', VisibilityLevel.FOLLOWERS), + ('public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_updates_workout_visibility_for_workout_without_gpx( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_description: str, + input_workout_visibility: VisibilityLevel, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f'/api/workouts/{workout_cycling_user_1.short_id}', + content_type='application/json', + data=json.dumps( + dict(workout_visibility=input_workout_visibility.value) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['workout_visibility'] + == input_workout_visibility.value + ) + + @pytest.mark.parametrize( + 'input_description,input_map_visibility', + [ + ('private', VisibilityLevel.PRIVATE), + ('followers_only', VisibilityLevel.FOLLOWERS), + ('public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_does_not_update_map_visibility_for_workout_without_gpx( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_description: str, + input_map_visibility: VisibilityLevel, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f'/api/workouts/{workout_cycling_user_1.short_id}', + content_type='application/json', + data=json.dumps(dict(map_visibility=input_map_visibility.value)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['map_visibility'] + == workout_cycling_user_1.map_visibility.value + ) + + @pytest.mark.parametrize( + 'input_description,input_workout_visibility', + [ + ('private', VisibilityLevel.PRIVATE), + ('followers_only', VisibilityLevel.FOLLOWERS), + ('public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_updates_workout_visibility_for_workout_with_gpx( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_description: str, + input_workout_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.gpx = 'file.gpx' + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f'/api/workouts/{workout_cycling_user_1.short_id}', + content_type='application/json', + data=json.dumps( + dict(workout_visibility=input_workout_visibility.value) + ), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['workout_visibility'] + == input_workout_visibility.value + ) + + @pytest.mark.parametrize( + 'input_description,input_map_visibility', + [ + ('private', VisibilityLevel.PRIVATE), + ('followers_only', VisibilityLevel.FOLLOWERS), + ('public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_updates_map_visibility_for_workout_with_gpx( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_description: str, + input_map_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = input_map_visibility + workout_cycling_user_1.gpx = 'file.gpx' + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f'/api/workouts/{workout_cycling_user_1.short_id}', + content_type='application/json', + data=json.dumps(dict(map_visibility=input_map_visibility.value)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['map_visibility'] + == input_map_visibility.value + ) + assert ( + workout_cycling_user_1.map_visibility.value + == input_map_visibility.value + ) + + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility', + [ + (VisibilityLevel.FOLLOWERS, VisibilityLevel.PRIVATE), + (VisibilityLevel.PUBLIC, VisibilityLevel.FOLLOWERS), + ], + ) + def test_it_updates_valid_map_visibility_for_workout_with_gpx( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + workout_cycling_user_1.gpx = 'file.gpx' + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.patch( + f'/api/workouts/{workout_cycling_user_1.short_id}', + content_type='application/json', + data=json.dumps(dict(map_visibility=input_map_visibility.value)), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['map_visibility'] + == input_workout_visibility.value + ) + assert ( + workout_cycling_user_1.map_visibility.value + == input_workout_visibility.value + ) diff --git a/fittrackee/tests/workouts/test_workouts_api_3_delete.py b/fittrackee/tests/workouts/test_workouts_api_3_delete.py index 358b72063..41a4c7067 100644 --- a/fittrackee/tests/workouts/test_workouts_api_3_delete.py +++ b/fittrackee/tests/workouts/test_workouts_api_3_delete.py @@ -1,16 +1,18 @@ -from datetime import timedelta +from datetime import datetime, timedelta import pytest from flask import Flask from fittrackee import db from fittrackee.equipments.models import Equipment -from fittrackee.users.models import User +from fittrackee.users.models import FollowRequest, User from fittrackee.utils import decode_short_id +from fittrackee.visibility_levels import VisibilityLevel from fittrackee.workouts.models import Sport, Workout -from ..mixins import ApiTestCaseMixin +from ..comments.mixins import CommentMixin from ..utils import OAUTH_SCOPES +from .mixins import WorkoutApiTestCaseMixin from .utils import post_a_workout @@ -19,7 +21,7 @@ def get_gpx_filepath(workout_id: int) -> str: return workout.gpx -class TestDeleteWorkoutWithGpx(ApiTestCaseMixin): +class TestDeleteWorkoutWithGpx(CommentMixin, WorkoutApiTestCaseMixin): def test_it_deletes_a_workout_with_gpx( self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str ) -> None: @@ -102,15 +104,71 @@ def test_it_deletes_a_workout_with_several_equipments( ) assert equipment_shoes_user_1.total_moving == timedelta() - def test_it_returns_403_when_deleting_a_workout_from_different_user( + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + 403, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_followed_user_user( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + expected_status_code: int, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + gpx_file: str, + follow_request_from_user_2_to_user_1: FollowRequest, + ) -> None: + user_1.approves_follow_request_from(user_2) + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.delete( + f'/api/workouts/{workout_short_id}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + 404, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_different_user( self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + expected_status_code: int, app: Flask, user_1: User, user_2: User, sport_1_cycling: Sport, gpx_file: str, ) -> None: - _, workout_short_id = post_a_workout(app, gpx_file) + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) client, auth_token = self.get_test_client_and_auth_token( app, user_2.email ) @@ -120,6 +178,58 @@ def test_it_returns_403_when_deleting_a_workout_from_different_user( headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_401_when_no_authenticated( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + _, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=input_workout_visibility + ) + client = app.test_client() + + response = client.delete( + f'/api/workouts/{workout_short_id}', + ) + + assert response.status_code == 401 + + def test_it_returns_403_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + auth_token, workout_short_id = post_a_workout( + app, gpx_file, workout_visibility=VisibilityLevel.PRIVATE + ) + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client = app.test_client() + + response = client.delete( + f'/api/workouts/{workout_short_id}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + self.assert_403(response) def test_it_returns_404_if_workout_does_not_exist( @@ -156,6 +266,32 @@ def test_a_workout_with_gpx_can_be_deleted_if_gpx_file_is_invalid( assert response.status_code == 204 + def test_it_deletes_a_workout_with_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + gpx_file: str, + ) -> None: + token, workout_short_id = post_a_workout(app, gpx_file) + workout = Workout.query.filter_by( + uuid=decode_short_id(workout_short_id) + ).first() + workout.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, workout, text_visibility=VisibilityLevel.PUBLIC + ) + client = app.test_client() + + response = client.delete( + f'/api/workouts/{workout_short_id}', + headers=dict(Authorization=f'Bearer {token}'), + ) + assert response.status_code == 204 + assert Workout.query.first() is None + assert comment.workout_id is None + def test_a_workout_with_gpx_can_be_deleted_if_map_file_is_invalid( self, app: Flask, @@ -209,7 +345,7 @@ def test_expected_scopes_are_defined( self.assert_response_scope(response, can_access) -class TestDeleteWorkoutWithoutGpx(ApiTestCaseMixin): +class TestDeleteWorkoutWithoutGpx(CommentMixin, WorkoutApiTestCaseMixin): def test_it_deletes_a_workout_wo_gpx( self, app: Flask, @@ -253,14 +389,77 @@ def test_it_deletes_a_workout_with_equipment( assert equipment_bike_user_1.total_duration == timedelta() assert equipment_bike_user_1.total_moving == timedelta() - def test_it_returns_403_when_deleting_a_workout_from_different_user( + def test_it_returns_404_when_deleting_a_workout_from_different_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.delete( + f'/api/workouts/{workout_cycling_user_1.short_id}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + data = self.assert_404(response) + assert 'not found' in data['status'] + + def test_it_deletes_a_workout_with_comment( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + comment = self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + response = client.delete( + f'/api/workouts/{workout_cycling_user_1.short_id}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + assert response.status_code == 204 + assert Workout.query.first() is None + assert comment.workout_id is None + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + 403, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_followed_user_user( self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + expected_status_code: int, app: Flask, user_1: User, user_2: User, sport_1_cycling: Sport, workout_cycling_user_1: Workout, + follow_request_from_user_2_to_user_1: FollowRequest, ) -> None: + user_1.approves_follow_request_from(user_2) + workout_cycling_user_1.workout_visibility = input_workout_visibility client, auth_token = self.get_test_client_and_auth_token( app, user_2.email ) @@ -270,4 +469,87 @@ def test_it_returns_403_when_deleting_a_workout_from_different_user( headers=dict(Authorization=f'Bearer {auth_token}'), ) + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility,expected_status_code', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE, 404), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + 404, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC, 403), + ], + ) + def test_it_returns_error_when_deleting_workout_from_different_user( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + expected_status_code: int, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + client, auth_token = self.get_test_client_and_auth_token( + app, user_2.email + ) + + response = client.delete( + f'/api/workouts/{workout_cycling_user_1.short_id}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == expected_status_code + + @pytest.mark.parametrize( + 'input_desc,input_workout_visibility', + [ + ('workout visibility: private', VisibilityLevel.PRIVATE), + ( + 'workout visibility: followers_only', + VisibilityLevel.FOLLOWERS, + ), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_returns_401_when_no_authenticated( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client = app.test_client() + + response = client.delete( + f'/api/workouts/{workout_cycling_user_1.short_id}', + ) + + assert response.status_code == 401 + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.delete( + f'/api/workouts/{workout_cycling_user_1.short_id}', + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + self.assert_403(response) diff --git a/fittrackee/tests/workouts/test_workouts_likes_api_post.py b/fittrackee/tests/workouts/test_workouts_likes_api_post.py new file mode 100644 index 000000000..3261cf164 --- /dev/null +++ b/fittrackee/tests/workouts/test_workouts_likes_api_post.py @@ -0,0 +1,448 @@ +import json +from datetime import datetime + +import pytest +from flask import Flask + +from fittrackee import db +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.models import Sport, Workout, WorkoutLike + +from ..mixins import ApiTestCaseMixin, BaseTestMixin +from ..utils import OAUTH_SCOPES, jsonify_dict + + +class TestWorkoutLikePost(ApiTestCaseMixin, BaseTestMixin): + route = '/api/workouts/{workout_uuid}/like' + + def test_it_returns_error_if_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client = app.test_client() + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_1.short_id) + ) + + self.assert_401(response) + + def test_it_return_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + user_1.suspended_at = datetime.utcnow() + db.session.commit() + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_404_when_workout_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.post( + self.route.format(workout_uuid=self.random_short_id()), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_404_when_workout_visibility_is_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PRIVATE + user_2.approves_follow_request_from(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_404_when_user_is_not_follower( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + @pytest.mark.parametrize( + 'input_desc,input_workout_level', + [ + ('workout visibility: follower', VisibilityLevel.FOLLOWERS), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_creates_workout_like( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_desc: str, + input_workout_level: VisibilityLevel, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_level + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + 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']['workouts'] == [ + jsonify_dict( + workout_cycling_user_2.serialize(user=user_1, light=False) + ) + ] + assert ( + WorkoutLike.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).first() + is not None + ) + assert workout_cycling_user_2.likes.all() == [user_1] + + def test_it_does_not_return_error_when_like_already_exists( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + like = WorkoutLike( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ) + db.session.add(like) + db.session.commit() + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['id'] + == workout_cycling_user_2.short_id + ) + assert ( + WorkoutLike.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).first() + is not None + ) + assert workout_cycling_user_2.likes.all() == [user_1] + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) + + +class TestWorkoutUndoLikePost(ApiTestCaseMixin, BaseTestMixin): + route = '/api/workouts/{workout_uuid}/like/undo' + + def test_it_returns_error_if_user_is_not_authenticated( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + client = app.test_client() + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_1.short_id) + ) + + self.assert_401(response) + + def test_it_returns_error_when_user_is_suspended( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + like = WorkoutLike( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ) + db.session.add(like) + db.session.flush() + user_1.suspended_at = datetime.utcnow() + db.session.commit() + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_403(response) + + def test_it_returns_404_when_workout_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.post( + self.route.format(workout_uuid=self.random_short_id()), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_404_when_workout_visibility_is_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PRIVATE + user_2.approves_follow_request_from(user_1) + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + def test_it_returns_404_when_user_is_not_follower( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + self.assert_404(response) + + @pytest.mark.parametrize( + 'input_desc,input_workout_level', + [ + ('workout visibility: follower', VisibilityLevel.FOLLOWERS), + ('workout visibility: public', VisibilityLevel.PUBLIC), + ], + ) + def test_it_removes_workout_like( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + input_desc: str, + input_workout_level: VisibilityLevel, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_level + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + like = WorkoutLike( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ) + db.session.add(like) + db.session.commit() + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + 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']['workouts'] == [ + jsonify_dict( + workout_cycling_user_2.serialize(user=user_1, light=False) + ) + ] + assert ( + WorkoutLike.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).first() + is None + ) + assert workout_cycling_user_2.likes.all() == [] + + def test_it_does_not_return_error_when_no_existing_like( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + client, auth_token = self.get_test_client_and_auth_token( + app, user_1.email + ) + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_2.short_id), + headers=dict(Authorization=f'Bearer {auth_token}'), + ) + + assert response.status_code == 200 + data = json.loads(response.data.decode()) + assert 'success' in data['status'] + assert len(data['data']['workouts']) == 1 + assert ( + data['data']['workouts'][0]['id'] + == workout_cycling_user_2.short_id + ) + assert ( + WorkoutLike.query.filter_by( + user_id=user_1.id, workout_id=workout_cycling_user_2.id + ).first() + is None + ) + assert workout_cycling_user_2.likes.all() == [] + + @pytest.mark.parametrize( + 'client_scope, can_access', + {**OAUTH_SCOPES, 'workouts:write': True}.items(), + ) + def test_expected_scopes_are_defined( + self, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + client_scope: str, + can_access: bool, + ) -> None: + ( + client, + oauth_client, + access_token, + _, + ) = self.create_oauth2_client_and_issue_token( + app, user_1, scope=client_scope + ) + + response = client.post( + self.route.format(workout_uuid=workout_cycling_user_1.short_id), + headers=dict(Authorization=f'Bearer {access_token}'), + ) + + self.assert_response_scope(response, can_access) diff --git a/fittrackee/tests/workouts/test_workouts_model.py b/fittrackee/tests/workouts/test_workouts_model.py index 348e8584d..eb261c7b3 100644 --- a/fittrackee/tests/workouts/test_workouts_model.py +++ b/fittrackee/tests/workouts/test_workouts_model.py @@ -1,18 +1,41 @@ -from datetime import timedelta +from datetime import datetime, timedelta +from typing import List, Optional import pytest from flask import Flask from fittrackee import db from fittrackee.equipments.models import Equipment +from fittrackee.tests.comments.mixins import CommentMixin from fittrackee.users.models import User from fittrackee.utils import encode_uuid -from fittrackee.workouts.models import Sport, Workout +from fittrackee.visibility_levels import VisibilityLevel +from fittrackee.workouts.exceptions import WorkoutForbiddenException +from fittrackee.workouts.models import Sport, Workout, WorkoutLike +from ..mixins import ReportMixin from ..utils import random_string +from .utils import add_follower -class TestWorkoutModel: +@pytest.mark.disable_autouse_update_records_patch +class WorkoutModelTestCase(ReportMixin): + @staticmethod + def update_workout( + workout: Workout, + map_id: Optional[str] = None, + gpx_path: Optional[str] = None, + bounds: Optional[List[float]] = None, + ) -> Workout: + workout.map_id = map_id + workout.map = random_string() if map_id is None else map_id + workout.gpx = random_string() if gpx_path is None else gpx_path + workout.bounds = [1.0, 2.0, 3.0, 4.0] if bounds is None else bounds + workout.pauses = timedelta(minutes=15) + return workout + + +class TestWorkoutModelForOwner(WorkoutModelTestCase): def test_sport_label_and_date_are_in_string_value( self, app: Flask, @@ -38,7 +61,49 @@ def test_short_id_returns_encoded_workout_uuid( workout_cycling_user_1.uuid ) - def test_serialize_for_workout_without_gpx( + def test_suspension_action_is_none_when_no_suspension( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + assert workout_cycling_user_1.suspension_action is None + + def test_suspension_action_is_last_suspension_action_when_comment_is_suspended( # noqa + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + expected_report_action = self.create_report_workout_action( + user_1_admin, user_2, workout_cycling_user_2 + ) + workout_cycling_user_2.suspended_at = datetime.utcnow() + + assert ( + workout_cycling_user_2.suspension_action == expected_report_action + ) + + def test_suspension_action_is_none_when_comment_is_unsuspended( + self, + app: Flask, + user_1_admin: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + self.create_report_workout_action( + user_1_admin, user_2, workout_cycling_user_2 + ) + workout_cycling_user_2.suspended_at = None + + assert workout_cycling_user_2.suspension_action is None + + def test_it_serializes_workout_without_gpx( self, app: Flask, sport_1_cycling: Sport, @@ -47,40 +112,47 @@ def test_serialize_for_workout_without_gpx( ) -> None: workout = workout_cycling_user_1 - serialized_workout = workout.serialize() - assert serialized_workout['ascent'] is None - assert serialized_workout['ave_speed'] == float(workout.ave_speed) - assert serialized_workout['bounds'] == [] - assert 'creation_date' in serialized_workout - assert serialized_workout['descent'] is None - assert serialized_workout['description'] is None - assert serialized_workout['distance'] == float(workout.distance) - assert serialized_workout['duration'] == str(workout.duration) - assert serialized_workout['id'] == workout.short_id - assert serialized_workout['equipments'] == [] - assert serialized_workout['map'] is None - assert serialized_workout['max_alt'] is None - assert serialized_workout['max_speed'] == float(workout.max_speed) - assert serialized_workout['min_alt'] is None - assert serialized_workout['modification_date'] is None - assert serialized_workout['moving'] == str(workout.moving) - assert serialized_workout['next_workout'] is None - assert serialized_workout['notes'] is None - assert serialized_workout['pauses'] is None - assert serialized_workout['previous_workout'] is None - assert serialized_workout['records'] == [ - record.serialize() for record in workout.records - ] - assert serialized_workout['segments'] == [] - assert serialized_workout['sport_id'] == workout.sport_id - assert serialized_workout['title'] == workout.title - assert serialized_workout['user'] == workout.user.username - assert serialized_workout['weather_end'] is None - assert serialized_workout['weather_start'] is None - assert serialized_workout['with_gpx'] is False - assert str(serialized_workout['workout_date']) == '2018-01-01 00:00:00' + serialized_workout = workout.serialize(user=user_1, light=False) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout.ave_speed), + 'bounds': [], + 'creation_date': workout.creation_date, + 'descent': None, + 'description': None, + 'distance': float(workout.distance), + 'duration': str(workout.duration), + 'id': workout.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout.max_speed), + 'min_alt': None, + 'modification_date': None, + 'moving': str(workout.moving), + 'next_workout': None, + 'notes': None, + 'pauses': None, + 'previous_workout': None, + 'records': [record.serialize() for record in workout.records], + 'segments': [], + 'sport_id': workout.sport_id, + 'suspended': False, + 'suspended_at': None, + 'title': None, + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout.workout_date, + 'workout_visibility': workout.workout_visibility.value, + 'with_gpx': False, + } - def test_serialize_for_workout_without_gpx_and_with_ascent_and_descent( + def test_it_serializes_workout_without_gpx_and_with_ascent_and_descent( self, app: Flask, sport_1_cycling: Sport, @@ -91,40 +163,47 @@ def test_serialize_for_workout_without_gpx_and_with_ascent_and_descent( workout.ascent = 0 workout.descent = 10 - serialized_workout = workout.serialize() - assert serialized_workout['ascent'] == workout.ascent - assert serialized_workout['ave_speed'] == float(workout.ave_speed) - assert serialized_workout['bounds'] == [] - assert 'creation_date' in serialized_workout - assert serialized_workout['descent'] == workout.descent - assert serialized_workout['description'] is None - assert serialized_workout['distance'] == float(workout.distance) - assert serialized_workout['duration'] == str(workout.duration) - assert serialized_workout['equipments'] == [] - assert serialized_workout['id'] == workout.short_id - assert serialized_workout['map'] is None - assert serialized_workout['max_alt'] is None - assert serialized_workout['max_speed'] == float(workout.max_speed) - assert serialized_workout['min_alt'] is None - assert serialized_workout['modification_date'] is not None - assert serialized_workout['moving'] == str(workout.moving) - assert serialized_workout['next_workout'] is None - assert serialized_workout['notes'] is None - assert serialized_workout['pauses'] is None - assert serialized_workout['previous_workout'] is None - assert serialized_workout['records'] == [ - record.serialize() for record in workout.records - ] - assert serialized_workout['segments'] == [] - assert serialized_workout['sport_id'] == workout.sport_id - assert serialized_workout['title'] == workout.title - assert serialized_workout['user'] == workout.user.username - assert serialized_workout['weather_end'] is None - assert serialized_workout['weather_start'] is None - assert serialized_workout['with_gpx'] is False - assert str(serialized_workout['workout_date']) == '2018-01-01 00:00:00' + serialized_workout = workout.serialize(user=user_1, light=False) + + assert serialized_workout == { + 'ascent': float(workout.ascent), + 'ave_speed': float(workout.ave_speed), + 'bounds': [], + 'creation_date': workout.creation_date, + 'descent': float(workout.descent), + 'description': None, + 'distance': float(workout.distance), + 'duration': str(workout.duration), + 'id': workout.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout.max_speed), + 'min_alt': None, + 'modification_date': workout.modification_date, + 'moving': str(workout.moving), + 'next_workout': None, + 'notes': None, + 'pauses': None, + 'previous_workout': None, + 'records': [record.serialize() for record in workout.records], + 'segments': [], + 'sport_id': workout.sport_id, + 'suspended': False, + 'suspended_at': None, + 'title': None, + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout.workout_date, + 'workout_visibility': workout.workout_visibility.value, + 'with_gpx': False, + } - def test_serialize_for_workout_with_gpx( + def test_it_serializes_workout_with_gpx( self, app: Flask, sport_1_cycling: Sport, @@ -132,48 +211,359 @@ def test_serialize_for_workout_with_gpx( workout_cycling_user_1: Workout, workout_cycling_user_1_segment: Workout, ) -> None: - workout = workout_cycling_user_1 - workout.bounds = [1, 2, 3, 4] - workout.gpx = random_string() - workout.map = random_string() - workout.pauses = timedelta(minutes=15) + workout = self.update_workout(workout_cycling_user_1) + workout.ascent = 0 + workout.descent = 10 - serialized_workout = workout.serialize() - assert serialized_workout['ascent'] is None - assert serialized_workout['ave_speed'] == float(workout.ave_speed) - assert serialized_workout['bounds'] == [ - float(bound) for bound in workout.bounds - ] - assert 'creation_date' in serialized_workout - assert serialized_workout['descent'] is None - assert serialized_workout['description'] is None - assert serialized_workout['distance'] == float(workout.distance) - assert serialized_workout['duration'] == str(workout.duration) - assert serialized_workout['equipments'] == [] - assert serialized_workout['id'] == workout.short_id - assert serialized_workout['map'] is None - assert serialized_workout['max_alt'] is None - assert serialized_workout['max_speed'] == float(workout.max_speed) - assert serialized_workout['min_alt'] is None - assert serialized_workout['modification_date'] is not None - assert serialized_workout['moving'] == str(workout.moving) - assert serialized_workout['next_workout'] is None - assert serialized_workout['notes'] is None - assert serialized_workout['pauses'] == str(workout.pauses) - assert serialized_workout['previous_workout'] is None - assert serialized_workout['records'] == [ - record.serialize() for record in workout.records - ] - assert serialized_workout['segments'] == [ - segment.serialize() for segment in workout.segments - ] - assert serialized_workout['sport_id'] == workout.sport_id - assert serialized_workout['title'] == workout.title - assert serialized_workout['user'] == workout.user.username - assert serialized_workout['weather_end'] is None - assert serialized_workout['weather_start'] is None - assert serialized_workout['with_gpx'] is True - assert str(serialized_workout['workout_date']) == '2018-01-01 00:00:00' + serialized_workout = workout.serialize(user=user_1, light=False) + + assert serialized_workout == { + 'ascent': float(workout.ascent), + 'ave_speed': float(workout.ave_speed), + 'bounds': workout.bounds, + 'creation_date': workout.creation_date, + 'descent': float(workout.descent), + 'description': None, + 'distance': float(workout.distance), + 'duration': str(workout.duration), + 'id': workout.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout.max_speed), + 'min_alt': None, + 'modification_date': workout.modification_date, + 'moving': str(workout.moving), + 'next_workout': None, + 'notes': None, + 'pauses': str(workout.pauses), + 'previous_workout': None, + 'records': [record.serialize() for record in workout.records], + 'segments': [segment.serialize() for segment in workout.segments], + 'sport_id': workout.sport_id, + 'suspended': False, + 'suspended_at': None, + 'title': None, + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout.workout_date, + 'workout_visibility': workout.workout_visibility.value, + 'with_gpx': True, + } + + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility,' + 'expected_map_visibility', + [ + ( + VisibilityLevel.PRIVATE, + VisibilityLevel.PRIVATE, + VisibilityLevel.PRIVATE, + ), + ( + VisibilityLevel.FOLLOWERS, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.FOLLOWERS, + ), + ( + VisibilityLevel.PUBLIC, + VisibilityLevel.PUBLIC, + VisibilityLevel.PUBLIC, + ), + ( + VisibilityLevel.PUBLIC, + VisibilityLevel.PRIVATE, + VisibilityLevel.PRIVATE, + ), + ( + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + VisibilityLevel.PRIVATE, + ), + ( + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.FOLLOWERS, + ), + ( + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + VisibilityLevel.FOLLOWERS, + ), + ( + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PRIVATE, + ), + ( + VisibilityLevel.PRIVATE, + VisibilityLevel.PUBLIC, + VisibilityLevel.PRIVATE, + ), + ], + ) + def test_workout_visibility_overrides_map_visibility_when_stricter( + self, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + expected_map_visibility: VisibilityLevel, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.map_visibility = input_map_visibility + workout_cycling_user_1.workout_visibility = input_workout_visibility + + assert ( + workout_cycling_user_1.calculated_map_visibility + == expected_map_visibility + ) + serialized_workout = workout_cycling_user_1.serialize( + user=user_1, light=False + ) + + assert ( + serialized_workout['map_visibility'] + == expected_map_visibility.value + ) + + @pytest.mark.parametrize( + "input_workout_visibility", + [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_serializes_suspended_workout( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2_admin: User, + workout_cycling_user_1: Workout, + input_workout_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + workout_cycling_user_1.suspended_at = datetime.utcnow() + expected_report_action = self.create_report_workout_action( + user_2_admin, user_1, workout_cycling_user_1 + ) + + serialized_workout = workout_cycling_user_1.serialize( + user=user_1, light=False + ) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_1.ave_speed), + 'bounds': [], + 'creation_date': workout_cycling_user_1.creation_date, + 'descent': None, + 'description': None, + 'distance': float(workout_cycling_user_1.distance), + 'duration': str(workout_cycling_user_1.duration), + 'id': workout_cycling_user_1.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout_cycling_user_1.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_1.max_speed), + 'min_alt': None, + 'modification_date': workout_cycling_user_1.modification_date, + 'moving': str(workout_cycling_user_1.moving), + 'next_workout': None, + 'notes': None, + 'pauses': None, + 'previous_workout': None, + 'records': [ + record.serialize() for record in workout_cycling_user_1.records + ], + 'segments': [], + 'sport_id': workout_cycling_user_1.sport_id, + 'suspended': True, + 'suspended_at': workout_cycling_user_1.suspended_at, + 'suspension': expected_report_action.serialize(user_1, full=False), + 'title': None, + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_1.workout_date, + 'workout_visibility': ( + workout_cycling_user_1.workout_visibility.value + ), + 'with_gpx': False, + } + + def test_it_serializes_minimal_workout( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + ) -> None: + serialized_workout = workout_cycling_user_1.serialize( + user=user_1, light=True + ) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_1.ave_speed), + 'bounds': [], + 'creation_date': None, + 'descent': None, + 'distance': float(workout_cycling_user_1.distance), + 'duration': str(workout_cycling_user_1.duration), + 'id': workout_cycling_user_1.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout_cycling_user_1.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_1.max_speed), + 'min_alt': None, + 'modification_date': workout_cycling_user_1.modification_date, + 'moving': str(workout_cycling_user_1.moving), + 'next_workout': None, + 'notes': '', + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_1.sport_id, + 'suspended': False, + 'suspended_at': None, + 'title': None, + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_1.workout_date, + 'workout_visibility': ( + workout_cycling_user_1.workout_visibility.value + ), + 'with_gpx': False, + } + + def test_it_serializes_minimal_workout_with_gpx( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + ) -> None: + workout = self.update_workout(workout_cycling_user_1) + workout.ascent = 0 + workout.descent = 10 + + serialized_workout = workout_cycling_user_1.serialize( + user=user_1, light=True + ) + + assert serialized_workout == { + 'ascent': float(workout_cycling_user_1.ascent), + 'ave_speed': float(workout_cycling_user_1.ave_speed), + 'bounds': [], + 'creation_date': None, + 'descent': float(workout_cycling_user_1.descent), + 'distance': float(workout_cycling_user_1.distance), + 'duration': str(workout_cycling_user_1.duration), + 'id': workout_cycling_user_1.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout_cycling_user_1.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_1.max_speed), + 'min_alt': None, + 'modification_date': None, + 'moving': str(workout_cycling_user_1.moving), + 'next_workout': None, + 'notes': '', + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_1.sport_id, + 'suspended': False, + 'suspended_at': None, + 'title': None, + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_1.workout_date, + 'workout_visibility': ( + workout_cycling_user_1.workout_visibility.value + ), + 'with_gpx': True, + } + + def test_it_serializes_minimal_suspended_workout( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2_admin: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.suspended_at = datetime.utcnow() + self.create_report_workout_action( + user_2_admin, user_1, workout_cycling_user_1 + ) + + serialized_workout = workout_cycling_user_1.serialize( + user=user_1, light=True + ) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_1.ave_speed), + 'bounds': [], + 'creation_date': None, + 'descent': None, + 'distance': float(workout_cycling_user_1.distance), + 'duration': str(workout_cycling_user_1.duration), + 'id': workout_cycling_user_1.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout_cycling_user_1.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_1.max_speed), + 'min_alt': None, + 'modification_date': None, + 'moving': str(workout_cycling_user_1.moving), + 'next_workout': None, + 'notes': '', + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_1.sport_id, + 'suspended': True, + 'suspended_at': workout_cycling_user_1.suspended_at, + 'suspension': workout_cycling_user_1.suspension_action.serialize( # type: ignore # noqa + current_user=user_1, full=False + ), + 'title': None, + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_1.workout_date, + 'workout_visibility': ( + workout_cycling_user_1.workout_visibility.value + ), + 'with_gpx': False, + } def test_workout_segment_model( self, @@ -198,7 +588,9 @@ def test_it_returns_previous_workout( workout_cycling_user_1: Workout, workout_running_user_1: Workout, ) -> None: - serialized_workout = workout_running_user_1.serialize() + serialized_workout = workout_running_user_1.serialize( + user=user_1, light=False + ) assert ( serialized_workout['previous_workout'] @@ -214,7 +606,9 @@ def test_it_returns_next_workout( workout_cycling_user_1: Workout, workout_running_user_1: Workout, ) -> None: - serialized_workout = workout_cycling_user_1.serialize() + serialized_workout = workout_cycling_user_1.serialize( + user=user_1, light=False + ) assert ( serialized_workout['next_workout'] @@ -231,7 +625,9 @@ def test_it_returns_equipments( ) -> None: workout_cycling_user_1.equipments = [equipment_bike_user_1] - serialized_workout = workout_cycling_user_1.serialize() + serialized_workout = workout_cycling_user_1.serialize( + user=user_1, light=False + ) assert serialized_workout['equipments'] == [ equipment_bike_user_1.serialize() @@ -332,3 +728,1379 @@ def test_it_updates_equipments_totals( assert equipment.total_duration == timedelta() assert equipment.total_moving == timedelta() assert equipment.total_workouts == 0 + + def test_it_returns_likes_count( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + user_3: User, + workout_cycling_user_1: Workout, + ) -> None: + for user in [user_2, user_3]: + like = WorkoutLike( + user_id=user.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + + serialized_workout = workout_cycling_user_1.serialize( + user=user_1, light=False + ) + + assert serialized_workout['likes_count'] == 2 + + def test_it_returns_if_workout_is_not_liked_by_user( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + + serialized_workout = workout_cycling_user_1.serialize( + user=user_1, light=False + ) + + assert serialized_workout['liked'] is False + + def test_it_returns_if_workout_is_liked_by_user( + self, + app: Flask, + sport_1_cycling: Sport, + sport_2_running: Sport, + user_1: User, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + like = WorkoutLike( + user_id=user_1.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + + serialized_workout = workout_cycling_user_1.serialize( + user=user_1, light=False + ) + + assert serialized_workout['liked'] is True + + +class TestWorkoutModelAsFollower(CommentMixin, WorkoutModelTestCase): + def test_it_raises_exception_when_workout_visibility_is_private( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PRIVATE + add_follower(user_1, user_2) + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_1.serialize(user=user_2, light=False) + + def test_serializer_does_not_return_notes( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.notes = random_string() + workout_cycling_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + add_follower(user_1, user_2) + serialized_workout = workout_cycling_user_1.serialize( + user=user_2, light=False + ) + + assert serialized_workout['notes'] is None + + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility', + [ + ( + VisibilityLevel.FOLLOWERS, + VisibilityLevel.FOLLOWERS, + ), + ( + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ), + ( + VisibilityLevel.PUBLIC, + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_serializer_returns_map_related_data( + self, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + workout_cycling_user_1.map_visibility = input_map_visibility + add_follower(user_1, user_2) + workout = self.update_workout( + workout_cycling_user_1, map_id=random_string() + ) + + serialized_workout = workout.serialize(user=user_2, light=False) + + assert serialized_workout['map'] == workout.map + assert serialized_workout['bounds'] == workout.bounds + assert serialized_workout['with_gpx'] is True + assert serialized_workout['map_visibility'] == input_map_visibility + assert ( + serialized_workout['workout_visibility'] + == input_workout_visibility + ) + assert serialized_workout['segments'] == [] + + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility', + [ + ( + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + ), + ( + VisibilityLevel.PRIVATE, + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_serializer_does_not_return_map_related_data( + self, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + workout_cycling_user_1.map_visibility = input_map_visibility + add_follower(user_1, user_2) + workout = self.update_workout(workout_cycling_user_1) + + serialized_workout = workout.serialize(user=user_2, light=False) + + assert serialized_workout['map'] is None + assert serialized_workout['bounds'] == [] + assert serialized_workout['with_gpx'] is False + assert serialized_workout['map_visibility'] == input_map_visibility + assert ( + serialized_workout['workout_visibility'] + == input_workout_visibility + ) + assert serialized_workout['segments'] == [] + + def test_serializer_does_not_return_next_workout( + self, + app: Flask, + sport_1_cycling: Sport, + sport_2_running: Sport, + user_1: User, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + user_2: User, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + add_follower(user_1, user_2) + serialized_workout = workout_cycling_user_1.serialize( + user=user_2, light=False + ) + + assert serialized_workout['next_workout'] is None + + def test_serializer_does_not_return_previous_workout( + self, + app: Flask, + sport_1_cycling: Sport, + sport_2_running: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + workout_running_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + add_follower(user_1, user_2) + + serialized_workout = workout_running_user_1.serialize( + user=user_2, light=False + ) + + assert serialized_workout['previous_workout'] is None + + def test_serializer_does_not_return_suspended_at( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + add_follower(user_1, user_2) + + serialized_workout = workout_cycling_user_1.serialize( + user=user_2, light=False + ) + + assert 'suspended_at' not in serialized_workout + + @pytest.mark.parametrize( + "input_workout_visibility", + [VisibilityLevel.FOLLOWERS, VisibilityLevel.PUBLIC], + ) + @pytest.mark.parametrize("input_for_report", [True, False]) + def test_it_raises_exception_when_workout_is_suspended( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + user_3: User, + workout_cycling_user_1: Workout, + input_workout_visibility: VisibilityLevel, + input_for_report: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + add_follower(user_1, user_2) + add_follower(user_1, user_3) + self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + workout_cycling_user_1.suspended_at = datetime.utcnow() + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_1.serialize( + user=user_2, for_report=input_for_report, light=False + ) + + def test_serialize_returns_suspended_workout_when_user_commented_workout( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2_admin: User, + user_3: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + add_follower(user_1, user_3) + self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + self.create_report_workout_action( + user_2_admin, user_1, workout_cycling_user_1 + ) + workout_cycling_user_1.suspended_at = datetime.utcnow() + + serialized_workout = workout_cycling_user_1.serialize( + user=user_3, light=False + ) + + assert serialized_workout == { + 'ascent': None, + 'bounds': [], + 'creation_date': None, + 'descent': None, + 'distance': None, + 'duration': None, + 'equipments': [], + 'id': workout_cycling_user_1.short_id, + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': None, + 'max_alt': None, + 'min_alt': None, + 'modification_date': None, + 'moving': None, + 'next_workout': None, + 'notes': '', + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_1.sport_id, + 'suspended': True, + 'title': '', + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'with_gpx': False, + 'workout_date': workout_cycling_user_1.workout_date, + 'workout_visibility': ( + workout_cycling_user_1.workout_visibility.value + ), + } + + def test_serializer_does_not_return_equipments( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + equipment_bike_user_1: Equipment, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + workout_cycling_user_1.equipments = [equipment_bike_user_1] + add_follower(user_1, user_2) + + serialized_workout = workout_cycling_user_1.serialize( + user=user_2, light=False + ) + + assert serialized_workout["equipments"] == [] + + def test_it_serializes_minimal_workout( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2_admin: User, + user_3: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.FOLLOWERS + add_follower(user_1, user_3) + self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.FOLLOWERS, + ) + + serialized_workout = workout_cycling_user_1.serialize( + user=user_3, light=True + ) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_1.ave_speed), + 'bounds': [], + 'creation_date': None, + 'descent': None, + 'distance': float(workout_cycling_user_1.distance), + 'duration': str(workout_cycling_user_1.duration), + 'id': workout_cycling_user_1.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout_cycling_user_1.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_1.max_speed), + 'min_alt': None, + 'modification_date': None, + 'moving': str(workout_cycling_user_1.moving), + 'next_workout': None, + 'notes': '', + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_1.sport_id, + 'suspended': False, + 'title': None, + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_1.workout_date, + 'workout_visibility': ( + workout_cycling_user_1.workout_visibility.value + ), + 'with_gpx': False, + } + + +class TestWorkoutModelAsUser(CommentMixin, WorkoutModelTestCase): + @pytest.mark.parametrize( + 'input_desc, input_workout_visibility', + [ + ('visibility: follower', VisibilityLevel.FOLLOWERS), + ('visibility: private', VisibilityLevel.PRIVATE), + ], + ) + def test_it_raises_exception_when_workout_visibility_is_not_public( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_1.serialize(user=user_2, light=False) + + def test_serializer_does_not_return_notes( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.notes = random_string() + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + + serialized_workout = workout_cycling_user_1.serialize( + user=user_2, light=False + ) + + assert serialized_workout['notes'] is None + + def test_serializer_returns_map_related_data_when_visibility_is_public( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.map_visibility = VisibilityLevel.PUBLIC + workout = self.update_workout( + workout_cycling_user_1, map_id=random_string() + ) + + serialized_workout = workout.serialize(user=user_2, light=False) + + assert serialized_workout['map'] == workout.map + assert serialized_workout['bounds'] == workout.bounds + assert serialized_workout['with_gpx'] is True + assert serialized_workout['map_visibility'] == VisibilityLevel.PUBLIC + assert ( + serialized_workout['workout_visibility'] == VisibilityLevel.PUBLIC + ) + assert serialized_workout['segments'] == [] + + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility', + [ + ( + VisibilityLevel.PRIVATE, + VisibilityLevel.PUBLIC, + ), + ( + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_serializer_does_not_return_map_related_data( + self, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + workout_cycling_user_1.map_visibility = input_map_visibility + workout = self.update_workout(workout_cycling_user_1) + + serialized_workout = workout.serialize(user=user_2, light=False) + + assert serialized_workout['map'] is None + assert serialized_workout['bounds'] == [] + assert serialized_workout['with_gpx'] is False + assert serialized_workout['map_visibility'] == VisibilityLevel.PRIVATE + assert ( + serialized_workout['workout_visibility'] + == input_workout_visibility + ) + assert serialized_workout['segments'] == [] + + def test_serializer_does_not_return_next_workout( + self, + app: Flask, + sport_1_cycling: Sport, + sport_2_running: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + + serialized_workout = workout_cycling_user_1.serialize( + user=user_2, light=False + ) + + assert serialized_workout['next_workout'] is None + + def test_serializer_does_not_return_previous_workout( + self, + app: Flask, + sport_1_cycling: Sport, + sport_2_running: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + workout_running_user_1.workout_visibility = VisibilityLevel.PUBLIC + + serialized_workout = workout_running_user_1.serialize( + user=user_2, light=False + ) + + assert serialized_workout['previous_workout'] is None + + def test_serializer_does_not_return_suspended_at( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + + serialized_workout = workout_cycling_user_1.serialize( + user=user_2, light=False + ) + + assert 'suspended_at' not in serialized_workout + + @pytest.mark.parametrize("input_for_report", [True, False]) + def test_it_raises_exception_when_workout_is_suspended( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + user_3: User, + workout_cycling_user_1: Workout, + input_for_report: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.suspended_at = datetime.utcnow() + self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_1.serialize( + user=user_2, for_report=input_for_report, light=False + ) + + def test_serialize_returns_suspended_workout_when_user_commented_workout( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2_admin: User, + user_3: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + self.create_report_workout_action( + user_2_admin, user_1, workout_cycling_user_1 + ) + workout_cycling_user_1.suspended_at = datetime.utcnow() + + serialized_workout = workout_cycling_user_1.serialize( + user=user_3, light=False + ) + + assert serialized_workout == { + 'ascent': None, + 'bounds': [], + 'creation_date': None, + 'descent': None, + 'distance': None, + 'duration': None, + 'equipments': [], + 'id': workout_cycling_user_1.short_id, + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': None, + 'max_alt': None, + 'min_alt': None, + 'modification_date': None, + 'moving': None, + 'next_workout': None, + 'notes': '', + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_1.sport_id, + 'suspended': True, + 'title': '', + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'with_gpx': False, + 'workout_date': workout_cycling_user_1.workout_date, + 'workout_visibility': workout_cycling_user_1.workout_visibility, + } + + def test_serializer_does_not_return_equipments( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + equipment_bike_user_1: Equipment, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.equipments = [equipment_bike_user_1] + + serialized_workout = workout_cycling_user_1.serialize( + user=user_2, light=False + ) + + assert serialized_workout["equipments"] == [] + + def test_it_serializes_minimal_workout( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2_admin: User, + user_3: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + + serialized_workout = workout_cycling_user_1.serialize( + user=user_3, light=True + ) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_1.ave_speed), + 'bounds': [], + 'creation_date': None, + 'descent': None, + 'distance': float(workout_cycling_user_1.distance), + 'duration': str(workout_cycling_user_1.duration), + 'id': workout_cycling_user_1.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout_cycling_user_1.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_1.max_speed), + 'min_alt': None, + 'modification_date': None, + 'moving': str(workout_cycling_user_1.moving), + 'next_workout': None, + 'notes': '', + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_1.sport_id, + 'suspended': False, + 'title': None, + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_1.workout_date, + 'workout_visibility': ( + workout_cycling_user_1.workout_visibility.value + ), + 'with_gpx': False, + } + + +class TestWorkoutModelAsUnauthenticatedUser( + CommentMixin, WorkoutModelTestCase +): + @pytest.mark.parametrize( + 'input_desc, input_workout_visibility', + [ + ('visibility: follower', VisibilityLevel.FOLLOWERS), + ('visibility: private', VisibilityLevel.PRIVATE), + ], + ) + def test_it_raises_exception_when_workout_visibility_is_not_public( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_1.serialize() + + def test_serializer_does_not_return_notes( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.notes = random_string() + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + + serialized_workout = workout_cycling_user_1.serialize(light=False) + + assert serialized_workout['notes'] is None + + def test_serializer_returns_map_related_data_when_visibility_is_public( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.map_visibility = VisibilityLevel.PUBLIC + workout = self.update_workout( + workout_cycling_user_1, map_id=random_string() + ) + + serialized_workout = workout.serialize(light=False) + + assert serialized_workout['map'] == workout.map + assert serialized_workout['bounds'] == workout.bounds + assert serialized_workout['with_gpx'] is True + assert serialized_workout['map_visibility'] == VisibilityLevel.PUBLIC + assert ( + serialized_workout['workout_visibility'] == VisibilityLevel.PUBLIC + ) + assert serialized_workout['segments'] == [] + + @pytest.mark.parametrize( + 'input_map_visibility,input_workout_visibility', + [ + ( + VisibilityLevel.PRIVATE, + VisibilityLevel.PUBLIC, + ), + ( + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_serializer_does_not_return_map_related_data( + self, + input_map_visibility: VisibilityLevel, + input_workout_visibility: VisibilityLevel, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + workout_cycling_user_1.map_visibility = input_map_visibility + workout = self.update_workout(workout_cycling_user_1) + + serialized_workout = workout.serialize() + + assert serialized_workout['map'] is None + assert serialized_workout['bounds'] == [] + assert serialized_workout['with_gpx'] is False + assert serialized_workout['map_visibility'] == VisibilityLevel.PRIVATE + assert ( + serialized_workout['workout_visibility'] + == input_workout_visibility + ) + assert serialized_workout['segments'] == [] + + def test_serializer_does_not_return_next_workout( + self, + app: Flask, + sport_1_cycling: Sport, + sport_2_running: Sport, + user_1: User, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + + serialized_workout = workout_cycling_user_1.serialize() + + assert serialized_workout['next_workout'] is None + + def test_serializer_does_not_return_previous_workout( + self, + app: Flask, + sport_1_cycling: Sport, + sport_2_running: Sport, + user_1: User, + workout_cycling_user_1: Workout, + workout_running_user_1: Workout, + ) -> None: + workout_running_user_1.workout_visibility = VisibilityLevel.PUBLIC + + serialized_workout = workout_running_user_1.serialize() + + assert serialized_workout['previous_workout'] is None + + def test_it_returns_likes_count( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + like = WorkoutLike( + user_id=user_2.id, workout_id=workout_cycling_user_1.id + ) + db.session.add(like) + db.session.commit() + + serialized_workout = workout_cycling_user_1.serialize(light=False) + + assert serialized_workout['liked'] is False + assert serialized_workout['likes_count'] == 1 + + def test_serializer_does_not_return_suspended_at( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + + serialized_workout = workout_cycling_user_1.serialize() + + assert 'suspended_at' not in serialized_workout + + @pytest.mark.parametrize("input_for_report", [True, False]) + def test_it_raises_exception_when_workout_is_suspended( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2: User, + workout_cycling_user_1: Workout, + input_for_report: bool, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_2, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + workout_cycling_user_1.suspended_at = datetime.utcnow() + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_1.serialize(for_report=input_for_report) + + def test_serializer_does_not_return_equipments( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + workout_cycling_user_1: Workout, + equipment_bike_user_1: Equipment, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + workout_cycling_user_1.equipments = [equipment_bike_user_1] + + serialized_workout = workout_cycling_user_1.serialize(light=False) + + assert serialized_workout["equipments"] == [] + + def test_it_serializes_minimal_workout( + self, + app: Flask, + sport_1_cycling: Sport, + user_1: User, + user_2_admin: User, + user_3: User, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = VisibilityLevel.PUBLIC + self.create_comment( + user_3, + workout_cycling_user_1, + text_visibility=VisibilityLevel.PUBLIC, + ) + + serialized_workout = workout_cycling_user_1.serialize(light=True) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_1.ave_speed), + 'bounds': [], + 'creation_date': None, + 'descent': None, + 'distance': float(workout_cycling_user_1.distance), + 'duration': str(workout_cycling_user_1.duration), + 'id': workout_cycling_user_1.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout_cycling_user_1.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_1.max_speed), + 'min_alt': None, + 'modification_date': None, + 'moving': str(workout_cycling_user_1.moving), + 'next_workout': None, + 'notes': '', + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_1.sport_id, + 'suspended': False, + 'title': None, + 'user': user_1.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_1.workout_date, + 'workout_visibility': ( + workout_cycling_user_1.workout_visibility.value + ), + 'with_gpx': False, + } + + +class TestWorkoutModelAsModerator(WorkoutModelTestCase): + @pytest.mark.parametrize( + 'input_desc, input_workout_visibility', + [ + ('visibility: follower', VisibilityLevel.FOLLOWERS), + ('visibility: private', VisibilityLevel.PRIVATE), + ], + ) + def test_it_raises_exception_when_workout_is_not_visible( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + sport_1_cycling: Sport, + user_1_moderator: User, + user_2: User, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_visibility + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_2.serialize( + user=user_1_moderator, light=False + ) + + @pytest.mark.parametrize( + 'input_desc, input_workout_visibility', + [ + ('visibility: follower', VisibilityLevel.FOLLOWERS), + ('visibility: private', VisibilityLevel.PRIVATE), + ], + ) + def test_it_returns_workout_when_report_flag_is_true( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + sport_1_cycling: Sport, + user_1_moderator: User, + user_2: User, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_visibility + + serialized_workout = workout_cycling_user_2.serialize( + user=user_1_moderator, for_report=True, light=False + ) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_2.ave_speed), + 'bounds': [], + 'creation_date': workout_cycling_user_2.creation_date, + 'descent': None, + 'description': None, + 'distance': float(workout_cycling_user_2.distance), + 'duration': str(workout_cycling_user_2.duration), + 'id': workout_cycling_user_2.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout_cycling_user_2.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_2.max_speed), + 'min_alt': None, + 'modification_date': workout_cycling_user_2.modification_date, + 'moving': str(workout_cycling_user_2.moving), + 'next_workout': None, + 'notes': None, + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_2.sport_id, + 'suspended': False, + 'suspended_at': None, + 'title': None, + 'user': user_2.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_2.workout_date, + 'workout_visibility': ( + workout_cycling_user_2.workout_visibility.value + ), + 'with_gpx': False, + } + + @pytest.mark.parametrize( + 'input_desc, input_workout_visibility', + [ + ('visibility: follower', VisibilityLevel.FOLLOWERS), + ('visibility: private', VisibilityLevel.PRIVATE), + ], + ) + def test_it_returns_workout_with_map_when_report_flag_is_true( + self, + input_desc: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + sport_1_cycling: Sport, + user_1_moderator: User, + user_2: User, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.map_visibility = input_workout_visibility + workout_cycling_user_2.workout_visibility = input_workout_visibility + map_id = random_string() + workout_cycling_user_2 = self.update_workout( + workout_cycling_user_2, map_id=map_id + ) + + serialized_workout = workout_cycling_user_2.serialize( + user=user_1_moderator, for_report=True, light=False + ) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_2.ave_speed), + 'bounds': workout_cycling_user_2.bounds, + 'creation_date': workout_cycling_user_2.creation_date, + 'descent': None, + 'description': None, + 'distance': float(workout_cycling_user_2.distance), + 'duration': str(workout_cycling_user_2.duration), + 'id': workout_cycling_user_2.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': map_id, + 'map_visibility': workout_cycling_user_2.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_2.max_speed), + 'min_alt': None, + 'modification_date': workout_cycling_user_2.modification_date, + 'moving': str(workout_cycling_user_2.moving), + 'next_workout': None, + 'notes': None, + 'pauses': str(workout_cycling_user_2.pauses), + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_2.sport_id, + 'suspended': False, + 'suspended_at': None, + 'title': None, + 'user': user_2.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_2.workout_date, + 'workout_visibility': ( + workout_cycling_user_2.workout_visibility.value + ), + 'with_gpx': True, + } + + @pytest.mark.parametrize( + "input_workout_visibility", + [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_raises_exception_when_workout_is_suspended( + self, + app: Flask, + sport_1_cycling: Sport, + user_1_moderator: User, + user_2: User, + workout_cycling_user_2: Workout, + input_workout_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_visibility + workout_cycling_user_2.suspended_at = datetime.utcnow() + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_2.serialize( + user=user_1_moderator, light=False + ) + + @pytest.mark.parametrize( + "input_workout_visibility", + [ + VisibilityLevel.PRIVATE, + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ], + ) + def test_it_serializes_suspended_workout_for_report( + self, + app: Flask, + sport_1_cycling: Sport, + user_1_moderator: User, + user_2: User, + workout_cycling_user_2: Workout, + input_workout_visibility: VisibilityLevel, + ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_visibility + workout_cycling_user_2.suspended_at = datetime.utcnow() + expected_report_action = self.create_report_workout_action( + user_1_moderator, user_2, workout_cycling_user_2 + ) + + serialized_workout = workout_cycling_user_2.serialize( + user=user_1_moderator, for_report=True, light=False + ) + + assert ( + serialized_workout["suspended_at"] + == workout_cycling_user_2.suspended_at + ) + assert serialized_workout[ + "suspension" + ] == expected_report_action.serialize( + current_user=user_1_moderator, # type: ignore + full=False, + ) + + def test_it_serializes_minimal_workout( + self, + app: Flask, + sport_1_cycling: Sport, + user_1_moderator: User, + user_2: User, + workout_cycling_user_2: Workout, + ) -> None: + serialized_workout = workout_cycling_user_2.serialize( + user=user_1_moderator, for_report=True, light=True + ) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_2.ave_speed), + 'bounds': [], + 'creation_date': None, + 'descent': None, + 'distance': float(workout_cycling_user_2.distance), + 'duration': str(workout_cycling_user_2.duration), + 'id': workout_cycling_user_2.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout_cycling_user_2.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_2.max_speed), + 'min_alt': None, + 'modification_date': None, + 'moving': str(workout_cycling_user_2.moving), + 'next_workout': None, + 'notes': '', + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_2.sport_id, + 'suspended': False, + 'suspended_at': None, + 'title': None, + 'user': user_2.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_2.workout_date, + 'workout_visibility': ( + workout_cycling_user_2.workout_visibility.value + ), + 'with_gpx': False, + } + + +class TestWorkoutModelAsAdmin(WorkoutModelTestCase): + def test_it_raises_exception_when_workout_is_not_visible( + self, + app: Flask, + sport_1_cycling: Sport, + user_1_admin: User, + user_2: User, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + + with pytest.raises(WorkoutForbiddenException): + workout_cycling_user_2.serialize(user=user_1_admin, light=False) + + def test_it_returns_workout_when_report_flag_is_true( + self, + app: Flask, + sport_1_cycling: Sport, + user_1_admin: User, + user_2: User, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + + serialized_workout = workout_cycling_user_2.serialize( + user=user_1_admin, for_report=True, light=False + ) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_2.ave_speed), + 'bounds': [], + 'creation_date': workout_cycling_user_2.creation_date, + 'descent': None, + 'description': None, + 'distance': float(workout_cycling_user_2.distance), + 'duration': str(workout_cycling_user_2.duration), + 'id': workout_cycling_user_2.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': None, + 'map_visibility': workout_cycling_user_2.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_2.max_speed), + 'min_alt': None, + 'modification_date': workout_cycling_user_2.modification_date, + 'moving': str(workout_cycling_user_2.moving), + 'next_workout': None, + 'notes': None, + 'pauses': None, + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_2.sport_id, + 'suspended': False, + 'suspended_at': None, + 'title': None, + 'user': user_2.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_2.workout_date, + 'workout_visibility': ( + workout_cycling_user_2.workout_visibility.value + ), + 'with_gpx': False, + } + + def test_it_returns_workout_with_map_when_report_flag_is_true( + self, + app: Flask, + sport_1_cycling: Sport, + user_1_admin: User, + user_2: User, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.map_visibility = VisibilityLevel.FOLLOWERS + workout_cycling_user_2.workout_visibility = VisibilityLevel.FOLLOWERS + map_id = random_string() + workout_cycling_user_2 = self.update_workout( + workout_cycling_user_2, map_id=map_id + ) + + serialized_workout = workout_cycling_user_2.serialize( + user=user_1_admin, for_report=True, light=False + ) + + assert serialized_workout == { + 'ascent': None, + 'ave_speed': float(workout_cycling_user_2.ave_speed), + 'bounds': workout_cycling_user_2.bounds, + 'creation_date': workout_cycling_user_2.creation_date, + 'descent': None, + 'description': None, + 'distance': float(workout_cycling_user_2.distance), + 'duration': str(workout_cycling_user_2.duration), + 'id': workout_cycling_user_2.short_id, + 'equipments': [], + 'liked': False, + 'likes_count': 0, + 'map': map_id, + 'map_visibility': workout_cycling_user_2.map_visibility.value, + 'max_alt': None, + 'max_speed': float(workout_cycling_user_2.max_speed), + 'min_alt': None, + 'modification_date': workout_cycling_user_2.modification_date, + 'moving': str(workout_cycling_user_2.moving), + 'next_workout': None, + 'notes': None, + 'pauses': str(workout_cycling_user_2.pauses), + 'previous_workout': None, + 'records': [], + 'segments': [], + 'sport_id': workout_cycling_user_2.sport_id, + 'suspended': False, + 'suspended_at': None, + 'title': None, + 'user': user_2.serialize(), + 'weather_end': None, + 'weather_start': None, + 'workout_date': workout_cycling_user_2.workout_date, + 'workout_visibility': ( + workout_cycling_user_2.workout_visibility.value + ), + 'with_gpx': True, + } diff --git a/fittrackee/tests/workouts/test_workouts_utils_visibility.py b/fittrackee/tests/workouts/test_workouts_utils_visibility.py new file mode 100644 index 000000000..13b2fa2c1 --- /dev/null +++ b/fittrackee/tests/workouts/test_workouts_utils_visibility.py @@ -0,0 +1,353 @@ +import pytest +from flask import Flask + +from fittrackee.users.models import FollowRequest, User +from fittrackee.visibility_levels import VisibilityLevel, can_view +from fittrackee.workouts.models import Sport, Workout + + +class TestCanViewWorkout: + @pytest.mark.parametrize( + 'input_description,input_workout_visibility', + [ + ( + f'workout visibility: {VisibilityLevel.PRIVATE.value}', + VisibilityLevel.PRIVATE, + ), + ( + f'workout visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ( + f'workout visibility: {VisibilityLevel.PUBLIC.value}', + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_workout_owner_can_view_his_workout( + self, + input_description: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.workout_visibility = input_workout_visibility + + assert ( + can_view(workout_cycling_user_1, 'workout_visibility', user_1) + is True + ) + + def test_follower_can_not_view_workout_when_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = VisibilityLevel.PRIVATE + + assert ( + can_view(workout_cycling_user_2, 'workout_visibility', user_1) + is False + ) + + @pytest.mark.parametrize( + 'input_description,input_workout_visibility', + [ + ( + f'workout visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ( + f'workout visibility: {VisibilityLevel.PUBLIC.value}', + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_follower_can_view_workout_when_public_or_follower_only( + self, + input_description: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.workout_visibility = input_workout_visibility + + assert ( + can_view(workout_cycling_user_2, 'workout_visibility', user_1) + is True + ) + + @pytest.mark.parametrize( + 'input_description,input_workout_visibility', + [ + ( + f'workout visibility: {VisibilityLevel.PRIVATE.value}', + VisibilityLevel.PRIVATE, + ), + ( + f'workout visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ], + ) + def test_another_user_can_not_view_workout_when_private_or_follower_only( + self, + input_description: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_visibility + + assert ( + can_view(workout_cycling_user_2, 'workout_visibility', user_1) + is False + ) + + def test_another_user_can_not_view_workout_when_public_and_user_blocked( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + user_2.blocks_user(user_1) + + assert ( + can_view(workout_cycling_user_2, 'workout_visibility', user_1) + is False + ) + + def test_another_user_can_view_workout_when_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + + assert ( + can_view(workout_cycling_user_2, 'workout_visibility', user_1) + is True + ) + + @pytest.mark.parametrize( + 'input_description,input_workout_visibility', + [ + ( + f'workout visibility: {VisibilityLevel.PRIVATE.value}', + VisibilityLevel.PRIVATE, + ), + ( + f'workout visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ], + ) + def test_workout_can_not_viewed_when_no_user_and_private_or_follower_only_visibility( # noqa + self, + input_description: str, + input_workout_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = input_workout_visibility + + assert can_view(workout_cycling_user_2, 'workout_visibility') is False + + def test_workout_can_be_viewed_when_public_and_no_user_provided( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.workout_visibility = VisibilityLevel.PUBLIC + + assert can_view(workout_cycling_user_2, 'workout_visibility') is True + + +class TestCanViewWorkoutMap: + @pytest.mark.parametrize( + 'input_description,input_map_visibility', + [ + ( + f'map visibility: {VisibilityLevel.PRIVATE.value}', + VisibilityLevel.PRIVATE, + ), + ( + f'map visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ( + f'map visibility: {VisibilityLevel.PUBLIC.value}', + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_workout_owner_can_view_his_workout_map( + self, + input_description: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + sport_1_cycling: Sport, + workout_cycling_user_1: Workout, + ) -> None: + workout_cycling_user_1.map_visibility = input_map_visibility + + assert ( + can_view(workout_cycling_user_1, 'map_visibility', user_1) is True + ) + + def test_follower_can_not_view_workout_map_when_private( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.map_visibility = VisibilityLevel.PRIVATE + + assert ( + can_view(workout_cycling_user_2, 'map_visibility', user_1) is False + ) + + @pytest.mark.parametrize( + 'input_description,input_map_visibility', + [ + ( + f'map visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ( + f'map visibility: {VisibilityLevel.PUBLIC.value}', + VisibilityLevel.PUBLIC, + ), + ], + ) + def test_follower_can_view_workout_map_when_public_or_follower_only( + self, + input_description: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + follow_request_from_user_1_to_user_2: FollowRequest, + ) -> None: + user_2.approves_follow_request_from(user_1) + workout_cycling_user_2.map_visibility = input_map_visibility + + assert ( + can_view(workout_cycling_user_2, 'map_visibility', user_1) is True + ) + + @pytest.mark.parametrize( + 'input_description,input_map_visibility', + [ + ( + f'map visibility: {VisibilityLevel.PRIVATE.value}', + VisibilityLevel.PRIVATE, + ), + ( + f'map visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ], + ) + def test_another_user_can_not_view_workout_map_when_private_or_follower_only( # noqa + self, + input_description: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.map_visibility = input_map_visibility + + assert ( + can_view(workout_cycling_user_2, 'map_visibility', user_1) is False + ) + + def test_another_user_can_view_workout_map_when_public( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.map_visibility = VisibilityLevel.PUBLIC + + assert ( + can_view(workout_cycling_user_2, 'map_visibility', user_1) is True + ) + + @pytest.mark.parametrize( + 'input_description,input_map_visibility', + [ + ( + f'map visibility: {VisibilityLevel.PRIVATE.value}', + VisibilityLevel.PRIVATE, + ), + ( + f'map visibility: {VisibilityLevel.FOLLOWERS.value}', + VisibilityLevel.FOLLOWERS, + ), + ], + ) + def test_map_can_not_viewed_when_no_user_and_private_or_follower_only_visibility( # noqa + self, + input_description: str, + input_map_visibility: VisibilityLevel, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.map_visibility = input_map_visibility + + assert can_view(workout_cycling_user_2, 'map_visibility') is False + + def test_workout_can_be_viewed_when_public_and_no_user_provided( + self, + app: Flask, + user_1: User, + user_2: User, + sport_1_cycling: Sport, + workout_cycling_user_2: Workout, + ) -> None: + workout_cycling_user_2.map_visibility = VisibilityLevel.PUBLIC + + assert can_view(workout_cycling_user_2, 'map_visibility') is True diff --git a/fittrackee/tests/workouts/utils.py b/fittrackee/tests/workouts/utils.py index 3715ec38e..a9c4a20d2 100644 --- a/fittrackee/tests/workouts/utils.py +++ b/fittrackee/tests/workouts/utils.py @@ -4,12 +4,16 @@ from flask import Flask +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel + def post_a_workout( app: Flask, gpx_file: str, notes: Optional[str] = None, description: Optional[str] = None, + workout_visibility: Optional[VisibilityLevel] = None, ) -> Tuple[str, str]: client = app.test_client() resp_login = client.post( @@ -23,6 +27,8 @@ def post_a_workout( workout_data += f', "notes": "{notes}"' if description is not None: workout_data += f', "description": "{description}"' + if workout_visibility is not None: + workout_data += f', "workout_visibility": "{workout_visibility.value}"' workout_data += '}' response = client.post( '/api/workouts', @@ -36,3 +42,8 @@ def post_a_workout( ) data = json.loads(response.data.decode()) return token, data['data']['workouts'][0]['id'] + + +def add_follower(user: User, follower: User) -> None: + follower.send_follow_request_to(user) + user.approves_follow_request_from(follower) diff --git a/fittrackee/users/auth.py b/fittrackee/users/auth.py index 5b836cef1..fe188220f 100644 --- a/fittrackee/users/auth.py +++ b/fittrackee/users/auth.py @@ -44,19 +44,24 @@ get_error_response_if_file_is_invalid, handle_error_and_return_response, ) -from fittrackee.utils import get_readable_duration +from fittrackee.users.users_service import UserManagerService +from fittrackee.utils import decode_short_id, get_readable_duration +from fittrackee.visibility_levels import VisibilityLevel, get_map_visibility from fittrackee.workouts.models import Sport +from ..reports.models import ReportAction, ReportActionAppeal from .exceptions import UserControlsException, UserCreationException from .models import ( BlacklistedToken, + BlockedUser, + Notification, User, UserDataExport, UserSportPreference, UserSportPreferenceEquipment, ) +from .roles import UserRole from .tasks import export_data -from .utils.admin import UserManagerService from .utils.controls import check_password, is_valid_email from .utils.language import get_language from .utils.token import decode_user_token @@ -65,18 +70,19 @@ 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' +BLOCKED_USERS_PER_PAGE = 5 def send_account_confirmation_email(user: User) -> None: if current_app.config['CAN_SEND_EMAILS']: - ui_url = current_app.config['UI_URL'] + fittrackee_url = current_app.config['UI_URL'] email_data = { 'username': user.username, - 'fittrackee_url': ui_url, + 'fittrackee_url': fittrackee_url, 'operating_system': request.user_agent.platform, # type: ignore # noqa 'browser_name': request.user_agent.browser, # type: ignore 'account_confirmation_url': ( - f'{ui_url}/account-confirmation' + f'{fittrackee_url}/account-confirmation' f'?token={user.confirmation_token}' ), } @@ -180,9 +186,19 @@ def register_user() -> Union[Tuple[Dict, int], HttpResponse]: if new_user: new_user.language = language new_user.accepted_policy_date = datetime.datetime.utcnow() - db.session.add(new_user) + for admin in User.query.filter( + User.role == UserRole.ADMIN.value, + User.is_active == True, # noqa + ).all(): + notification = Notification( + from_user_id=new_user.id, + to_user_id=admin.id, + created_at=new_user.created_at, + event_type='account_creation', + event_object_id=new_user.id, + ) + db.session.add(notification) db.session.commit() - send_account_confirmation_email(new_user) return {'status': 'success'}, 200 @@ -274,13 +290,15 @@ def login_user() -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/profile', methods=['GET']) -@require_auth(scopes=['profile:read']) +@require_auth(scopes=['profile:read'], allow_suspended_user=True) def get_authenticated_user_profile( auth_user: User, ) -> Union[Dict, HttpResponse]: """ Get authenticated user info (profile, account, preferences). + Suspended user can access this endpoint. + **Scope**: ``profile:read`` **Example request**: @@ -309,11 +327,16 @@ def get_authenticated_user_profile( "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "followers": 0, + "following": 0, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -377,7 +400,8 @@ def get_authenticated_user_profile( "use_dark_mode": null, "use_raw_gpx_speed": false, "username": "sam", - "weekm": false + "weekm": false, + "workouts_visibility": "private" }, "status": "success" } @@ -390,15 +414,20 @@ def get_authenticated_user_profile( - ``signature expired, please log in again`` - ``invalid token, please log in again`` """ - return {'status': 'success', 'data': auth_user.serialize(auth_user)} + return { + 'status': 'success', + 'data': auth_user.serialize(current_user=auth_user, light=False), + } @auth_blueprint.route('/auth/profile/edit', methods=['POST']) -@require_auth(scopes=['profile:write']) +@require_auth(scopes=['profile:write'], allow_suspended_user=True) def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: """ Edit authenticated user profile. + Suspended user can access this endpoint. + **Scope**: ``profile:write`` **Example request**: @@ -427,11 +456,16 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "followers": 0, + "following": 0, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -496,6 +530,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: "use_raw_gpx_speed": false, "username": "sam" "weekm": true, + "workouts_visibility": "private" }, "message": "user profile updated", "status": "success" @@ -550,7 +585,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: return { 'status': 'success', 'message': 'user profile updated', - 'data': auth_user.serialize(auth_user), + 'data': auth_user.serialize(current_user=auth_user, light=False), } # handler errors @@ -559,7 +594,7 @@ def edit_user(auth_user: User) -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/profile/edit/account', methods=['PATCH']) -@require_auth(scopes=['profile:write']) +@require_auth(scopes=['profile:write'], allow_suspended_user=True) def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: """ Update authenticated user email and password. @@ -572,6 +607,8 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: - one to the current address to inform user - another one to the new address to confirm it. + Suspended user can access this endpoint. + **Scope**: ``profile:write`` **Example request**: @@ -600,11 +637,14 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "followers_only", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -669,6 +709,7 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: "use_raw_gpx_speed": false, "username": "sam" "weekm": true, + "workouts_visibility": "private" }, "message": "user account updated", "status": "success" @@ -734,14 +775,14 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: db.session.commit() if current_app.config['CAN_SEND_EMAILS']: - ui_url = current_app.config['UI_URL'] + fittrackee_url = current_app.config['UI_URL'] user_data = { 'language': get_language(auth_user.language), 'email': auth_user.email, } data = { 'username': auth_user.username, - 'fittrackee_url': ui_url, + 'fittrackee_url': fittrackee_url, 'operating_system': request.user_agent.platform, 'browser_name': request.user_agent.browser, } @@ -763,7 +804,7 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: **data, **{ 'email_confirmation_url': ( - f'{ui_url}/email-update' + f'{fittrackee_url}/email-update' f'?token={auth_user.confirmation_token}' ) }, @@ -777,7 +818,7 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: return { 'status': 'success', 'message': 'user account updated', - 'data': auth_user.serialize(auth_user), + 'data': auth_user.serialize(current_user=auth_user, light=False), } except (exc.IntegrityError, exc.OperationalError, ValueError) as e: @@ -785,7 +826,7 @@ def update_user_account(auth_user: User) -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/profile/edit/preferences', methods=['POST']) -@require_auth(scopes=['profile:write']) +@require_auth(scopes=['profile:write'], allow_suspended_user=True) def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: """ Edit authenticated user preferences. @@ -801,6 +842,8 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: - ``d MMM yyyy`` for ``es``, ``fr``, ``gl``, ``it`` and ``nl`` locales - ``do MMM yyyy`` for ``de`` and ``nb`` locales + Suspended user can access this endpoint. + **Scope**: ``profile:write`` **Example request**: @@ -829,11 +872,16 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: "email": "sam@example.com", "email_to_confirm": null, "first_name": null, + "followers": 0, + "following": 0, + "hide_profile_in_users_directory": true, "imperial_units": false, "is_active": true, "language": "en", "last_name": null, "location": null, + "manually_approves_followers": false, + "map_visibility": "followers_only", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -898,6 +946,7 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: "use_raw_gpx_speed": true, "username": "sam" "weekm": true, + "workouts_visibility": "public" }, "message": "user preferences updated", "status": "success" @@ -905,14 +954,22 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: : Union[Dict, HttpResponse]: user_mandatory_data = { 'date_format', 'display_ascent', + 'hide_profile_in_users_directory', 'imperial_units', 'language', + 'manually_approves_followers', + 'map_visibility', 'start_elevation_at_zero', 'timezone', 'use_dark_mode', 'use_raw_gpx_speed', 'weekm', + 'workouts_visibility', } if not post_data or not post_data.keys() >= user_mandatory_data: return InvalidPayloadErrorResponse() @@ -951,6 +1012,12 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: use_dark_mode = post_data.get('use_dark_mode') timezone = post_data.get('timezone') weekm = post_data.get('weekm') + map_visibility = post_data.get('map_visibility') + workouts_visibility = post_data.get('workouts_visibility') + manually_approves_followers = post_data.get('manually_approves_followers') + hide_profile_in_users_directory = post_data.get( + 'hide_profile_in_users_directory' + ) try: auth_user.date_format = date_format @@ -962,12 +1029,20 @@ def edit_user_preferences(auth_user: User) -> Union[Dict, HttpResponse]: auth_user.use_dark_mode = use_dark_mode auth_user.use_raw_gpx_speed = use_raw_gpx_speed auth_user.weekm = weekm + auth_user.workouts_visibility = VisibilityLevel(workouts_visibility) + auth_user.map_visibility = get_map_visibility( + VisibilityLevel(map_visibility), auth_user.workouts_visibility + ) + auth_user.manually_approves_followers = manually_approves_followers + auth_user.hide_profile_in_users_directory = ( + hide_profile_in_users_directory + ) db.session.commit() return { 'status': 'success', 'message': 'user preferences updated', - 'data': auth_user.serialize(auth_user), + 'data': auth_user.serialize(current_user=auth_user, light=False), } # handler errors @@ -1036,6 +1111,8 @@ def edit_user_sport_preferences( - ``equipment with id does not exist`` - ``invalid equipment id for sport`` - ``equipment with id is inactive`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``sport does not exist`` :statuscode 500: ``error, please try again or contact the administrator`` """ @@ -1177,6 +1254,8 @@ def reset_user_sport_preferences( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``sport does not exist`` :statuscode 500: ``error, please try again or contact the administrator`` """ @@ -1200,11 +1279,13 @@ def reset_user_sport_preferences( @auth_blueprint.route('/auth/picture', methods=['POST']) -@require_auth(scopes=['profile:write']) +@require_auth(scopes=['profile:write'], allow_suspended_user=True) def edit_picture(auth_user: User) -> Union[Dict, HttpResponse]: """ Update authenticated user picture. + Suspended user can access this endpoint. + **Scope**: ``profile:write`` **Example request**: @@ -1289,11 +1370,13 @@ def edit_picture(auth_user: User) -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/picture', methods=['DELETE']) -@require_auth(scopes=['profile:write']) +@require_auth(scopes=['profile:write'], allow_suspended_user=True) def del_picture(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: """ Delete authenticated user picture. + Suspended user can access this endpoint. + **Scope**: ``profile:write`` **Example request**: @@ -1377,7 +1460,7 @@ def request_password_reset() -> Union[Dict, HttpResponse]: user = User.query.filter(User.email == email).first() if user: password_reset_token = user.encode_password_reset_token(user.id) - ui_url = current_app.config['UI_URL'] + fittrackee_url = current_app.config['UI_URL'] user_language = get_language(user.language) email_data = { 'expiration_delay': get_readable_duration( @@ -1386,9 +1469,9 @@ def request_password_reset() -> Union[Dict, HttpResponse]: ), 'username': user.username, 'password_reset_url': ( - f'{ui_url}/password-reset?token={password_reset_token}' # noqa + f'{fittrackee_url}/password-reset?token={password_reset_token}' # noqa ), - 'fittrackee_url': ui_url, + 'fittrackee_url': fittrackee_url, 'operating_system': request.user_agent.platform, # type: ignore 'browser_name': request.user_agent.browser, # type: ignore } @@ -1667,12 +1750,14 @@ def resend_account_confirmation_email() -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/logout', methods=['POST']) -@require_auth() +@require_auth(allow_suspended_user=True) def logout_user(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: """ User logout. If a valid token is provided, it will be blacklisted. + Suspended user can access this endpoint. + **Example request**: .. sourcecode:: http @@ -1733,16 +1818,18 @@ def logout_user(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: @auth_blueprint.route('/auth/account/privacy-policy', methods=['POST']) -@require_auth() +@require_auth(allow_suspended_user=True) def accept_privacy_policy(auth_user: User) -> Union[Dict, HttpResponse]: """ The authenticated user accepts the privacy policy. + Suspended user can access this endpoint. + **Example request**: .. sourcecode:: http - POST /auth/account/privacy-policy HTTP/1.1 + POST /api/auth/account/privacy-policy HTTP/1.1 Content-Type: application/json **Example response**: @@ -1784,16 +1871,18 @@ def accept_privacy_policy(auth_user: User) -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/account/export/request', methods=['POST']) -@require_auth() +@require_auth(allow_suspended_user=True) def request_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]: """ Request a data export for authenticated user. + Suspended user can access this endpoint. + **Example request**: .. sourcecode:: http - POST /auth/account/export/request HTTP/1.1 + POST /api/auth/account/export/request HTTP/1.1 Content-Type: application/json **Example response**: @@ -1857,7 +1946,7 @@ def request_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]: @auth_blueprint.route('/auth/account/export', methods=['GET']) -@require_auth() +@require_auth(allow_suspended_user=True) def get_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]: """ Get a data export info for authenticated user if a request exists. @@ -1868,11 +1957,13 @@ def get_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]: - export status (``in_progress``, ``successful`` and ``errored``) - file name and size (in bytes) when export is successful + Suspended user can access this endpoint. + **Example request**: .. sourcecode:: http - GET /auth/account/export HTTP/1.1 + GET /api/auth/account/export HTTP/1.1 Content-Type: application/json **Example response**: @@ -1926,18 +2017,20 @@ def get_user_data_export(auth_user: User) -> Union[Dict, HttpResponse]: @auth_blueprint.route( '/auth/account/export/', methods=['GET'] ) -@require_auth() +@require_auth(allow_suspended_user=True) def download_data_export( auth_user: User, file_name: str ) -> Union[Response, HttpResponse]: """ - Download a data export archive + Download a data export archive. + + Suspended user can access this endpoint. **Example request**: .. sourcecode:: http - GET /auth/account/export/download/archive_rgjsR3fHr5Yp.zip HTTP/1.1 + GET /api/auth/account/export/download/archive_rgjsR3fHr5Yp.zip HTTP/1.1 Content-Type: application/json **Example response**: @@ -1976,3 +2069,406 @@ def download_data_export( mimetype='application/zip', as_attachment=True, ) + + +@auth_blueprint.route('/auth/blocked-users', methods=['GET']) +@require_auth(scopes=['profile:read']) +def get_blocked_users(auth_user: User) -> Union[Dict, HttpResponse]: + """ + Get blocked users by authenticated user + + **Scope**: ``profile:read`` + + **Example requests**: + + - without parameters: + + .. sourcecode:: http + + GET /api/auth/blocked-users HTTP/1.1 + + - with parameters: + + .. sourcecode:: http + + GET /api/auth/blocked-users?page=1 + HTTP/1.1 + + **Example responses**: + + - with blocked users: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "blocked_users": [ + { + "blocked": true, + "created_at": "Sun, 01 Dec 2024 17:27:49 GMT", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + } + ], + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "status": "success" + } + + - no blocked users: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "blocked_users": [], + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 0, + "total": 0 + }, + "status": "success" + } + + :query integer page: page if using pagination (default: 1) + + :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 403: + - ``you do not have permissions, your account is suspended`` + """ + params = request.args.copy() + try: + page = int(params.get('page', 1)) + except ValueError: + page = 1 + + paginated_relations = ( + User.query.join(BlockedUser, User.id == BlockedUser.user_id) + .filter(BlockedUser.by_user_id == auth_user.id) + .order_by(BlockedUser.created_at.desc()) + .paginate(page=page, per_page=BLOCKED_USERS_PER_PAGE, error_out=False) + ) + return { + "status": "success", + "blocked_users": [ + user.serialize(current_user=auth_user) + for user in paginated_relations.items + ], + "pagination": { + "has_next": paginated_relations.has_next, + "has_prev": paginated_relations.has_prev, + "page": paginated_relations.page, + "pages": paginated_relations.pages, + "total": paginated_relations.total, + }, + } + + +@auth_blueprint.route("/auth/account/suspension", methods=["GET"]) +@require_auth(scopes=['profile:read'], allow_suspended_user=True) +def get_user_suspension( + auth_user: User, +) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Get suspension if exists for authenticated user. + + Suspended user can access this endpoint. + + **Scope**: ``profile:read`` + + **Example request**: + + .. sourcecode:: http + + GET /api/auth/account/suspension HTTP/1.1 + + **Example responses**: + + - suspension exists: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success", + "user_suspension": { + "action_type": "user_suspension", + "appeal": null, + "comment": null, + "created_at": "Wed, 04 Dec 2024 10:45:13 GMT", + "id": "mmy3qPL3vcFuKJGfFBnCJV", + "reason": "", + "workout": null + } + } + + - no suspension: + + .. sourcecode:: http + + HTTP/1.1 404 NOT FOUND + Content-Type: application/json + + { + "status": "not found", + "message": "user account is not suspended" + } + + :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: ``user account is not suspended`` + """ + if auth_user.suspended_at is None or auth_user.suspension_action is None: + return NotFoundErrorResponse("user account is not suspended") + + return { + "status": "success", + "user_suspension": auth_user.suspension_action.serialize(auth_user), + }, 200 + + +@auth_blueprint.route( + "/auth/account/suspension/appeal", + methods=["POST"], +) +@require_auth(scopes=['profile:write'], allow_suspended_user=True) +def appeal_user_suspension( + auth_user: User, +) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Appeal suspension for authenticated user. + + Suspended user can access this endpoint. + + **Scope**: ``profile:write`` + + **Example request**: + + .. sourcecode:: http + + POST /api/auth/account/suspension/appeal HTTP/1.1 + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 CREATED + Content-Type: application/json + + { + "status": "success" + } + + :reqheader Authorization: OAuth 2.0 Bearer Token + + :", methods=["GET"] +) +@require_auth(scopes=['profile:read'], allow_suspended_user=True) +def get_user_sanction( + auth_user: User, action_short_id: str +) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Get sanction for authenticated user. + + Suspended user can access this endpoint. + + **Scope**: ``profile:read`` + + **Example request**: + + .. sourcecode:: http + + GET /api/auth/account/sanctions/mmy3qPL3vcFuKJGfFBnCJV HTTP/1.1 + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 SUCCESS + Content-Type: application/json + + { + "sanction": { + "action_type": "user_suspension", + "appeal": { + "approved": null, + "created_at": "Wed, 04 Dec 2024 10:49:00 GMT", + "id": "7pDujhCVHyA4hv29JZQNgg", + "reason": null, + "text": "", + "updated_at": null + }, + "comment": null, + "created_at": "Wed, 04 Dec 2024 10:45:13 GMT", + "id": "mmy3qPL3vcFuKJGfFBnCJV", + "reason": "", + "workout": null + }, + "status": "success" + } + + :param string action_short_id: suspension id + + :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: ``no sanction found`` + """ + sanction = ReportAction.query.filter_by( + uuid=decode_short_id(action_short_id), user_id=auth_user.id + ).first() + + if not sanction: + return NotFoundErrorResponse("no sanction found") + + return { + "status": "success", + "sanction": sanction.serialize(current_user=auth_user, full=True), + }, 200 + + +@auth_blueprint.route( + "/auth/account/sanctions//appeal", + methods=["POST"], +) +@require_auth(scopes=['profile:write']) +def appeal_user_sanction( + auth_user: User, action_short_id: str +) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Appeal a sanction + + **Scope**: ``profile:write`` + + **Example request**: + + .. sourcecode:: http + + POST /api/auth/account/sanctions/6dxczvMrhkAR72shUz9Pwd/appeal HTTP/1.1 + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 CREATED + Content-Type: application/json + + { + "status": "success" + } + + :param string action_short_id: sanction id + + : None: """Manage given user account.""" with app.app_context(): + role = None + if set_admin is not None: + click.echo( + "WARNING: --set-admin is deprecated. " + "Please use --set-role option instead." + ) + role = 'admin' if set_admin else 'user' + if set_admin is not None and set_role is not None: + raise click.ClickException( + "--set-admin and --set-role can not be used together.", + ) + + if set_role: + role = set_role + try: user_manager_service = UserManagerService(username) - _, is_user_updated, password = user_manager_service.update( - is_admin=set_admin, + _, is_user_updated, password, _ = user_manager_service.update( + role=role, with_confirmation=False, - activate=activate, + activate=activate if activate else None, reset_password=reset_password, new_email=update_email, ) diff --git a/fittrackee/users/constants.py b/fittrackee/users/constants.py new file mode 100644 index 000000000..e8396446d --- /dev/null +++ b/fittrackee/users/constants.py @@ -0,0 +1,3 @@ +USER_DATE_FORMAT = 'MM/dd/yyyy' +USER_LANGUAGE = 'en' +USER_TIMEZONE = 'Europe/Paris' diff --git a/fittrackee/users/exceptions.py b/fittrackee/users/exceptions.py index e84a74eb8..60fa07465 100644 --- a/fittrackee/users/exceptions.py +++ b/fittrackee/users/exceptions.py @@ -1,10 +1,61 @@ -class InvalidEmailException(Exception): ... +class BlockUserException(Exception): + pass -class UserControlsException(Exception): ... +class FollowRequestAlreadyProcessedError(Exception): + pass -class UserCreationException(Exception): ... +class FollowRequestAlreadyRejectedError(Exception): + pass -class UserNotFoundException(Exception): ... +class InvalidEmailException(Exception): + pass + + +class InvalidNotificationTypeException(Exception): + pass + + +class InvalidUserRole(Exception): + def __init__(self) -> None: + super().__init__('invalid role') + + +class MissingAdminIdException(Exception): + pass + + +class MissingReportIdException(Exception): + pass + + +class NotExistingFollowRequestError(Exception): + pass + + +class OwnerException(Exception): + pass + + +class UserAlreadyReactivatedException(Exception): + def __init__(self) -> None: + super().__init__("user account already reactivated") + + +class UserAlreadySuspendedException(Exception): + def __init__(self) -> None: + super().__init__("user account already suspended") + + +class UserControlsException(Exception): + pass + + +class UserCreationException(Exception): + pass + + +class UserNotFoundException(Exception): + pass diff --git a/fittrackee/users/export_data.py b/fittrackee/users/export_data.py index f0bce49d5..d0391a000 100644 --- a/fittrackee/users/export_data.py +++ b/fittrackee/users/export_data.py @@ -35,12 +35,14 @@ def __init__(self, user: User) -> None: ) def get_user_info(self) -> Dict: - return self.user.serialize(self.user) + return self.user.serialize(current_user=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 = workout.get_workout_data( + self.user, additional_data=True, light=False + ) workout_data["sport_label"] = workout.sport.label workout_data["gpx"] = ( workout.gpx.split('/')[-1] if workout.gpx else None @@ -48,6 +50,25 @@ def get_user_workouts_data(self) -> List[Dict]: workouts_data.append(workout_data) return workouts_data + def get_user_comments_data(self) -> List[Dict]: + comments_data = [] + for comment in self.user.comments: + comments_data.append( + { + 'created_at': comment.created_at, + 'id': comment.short_id, + 'modification_date': comment.modification_date, + 'text': comment.text, + 'text_visibility': comment.text_visibility.value, + 'workout_id': ( + comment.workout.short_id + if comment.workout_id + else None + ), + } + ) + return comments_data + def get_user_equipments_data(self) -> List[Dict]: return [equipment.serialize() for equipment in self.user.equipments] @@ -70,6 +91,9 @@ def generate_archive(self) -> Tuple[Optional[str], Optional[str]]: equipments_data_file_name = self.export_data( self.get_user_equipments_data(), "equipments_data" ) + comments_data_file_name = self.export_data( + self.get_user_comments_data(), "comments_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: @@ -80,6 +104,9 @@ def generate_archive(self) -> Tuple[Optional[str], Optional[str]]: zip_object.write( equipments_data_file_name, "user_equipments_data.json" ) + zip_object.write( + comments_data_file_name, "user_comments_data.json" + ) if self.user.picture: picture_path = get_absolute_file_path(self.user.picture) if os.path.isfile(picture_path): @@ -130,11 +157,11 @@ def export_user_data(export_request_id: int) -> None: db.session.commit() if current_app.config['CAN_SEND_EMAILS']: - ui_url = current_app.config['UI_URL'] + fittrackee_url = current_app.config['UI_URL'] email_data = { 'username': user.username, - 'fittrackee_url': ui_url, - 'account_url': f'{ui_url}/profile/edit/account', + 'fittrackee_url': fittrackee_url, + 'account_url': f'{fittrackee_url}/profile/edit/account', } user_data = { 'language': get_language(user.language), diff --git a/fittrackee/users/follow_requests.py b/fittrackee/users/follow_requests.py new file mode 100644 index 000000000..2ffb7cb5c --- /dev/null +++ b/fittrackee/users/follow_requests.py @@ -0,0 +1,276 @@ +from typing import Dict, Union + +from flask import Blueprint, request +from sqlalchemy import asc, desc, func + +from fittrackee import appLog +from fittrackee.oauth2.server import require_auth +from fittrackee.responses import ( + HttpResponse, + InvalidPayloadErrorResponse, + NotFoundErrorResponse, + UserNotFoundErrorResponse, +) + +from .exceptions import ( + FollowRequestAlreadyProcessedError, + NotExistingFollowRequestError, +) +from .models import FollowRequest, User + +follow_requests_blueprint = Blueprint('follow_requests', __name__) + +FOLLOW_REQUESTS_PER_PAGE = 10 +MAX_FOLLOW_REQUESTS_PER_PAGE = 50 + + +@follow_requests_blueprint.route('/follow-requests', methods=['GET']) +@require_auth(scopes=['follow:read']) +def get_follow_requests(auth_user: User) -> Dict: + """ + Get follow requests to process, received by authenticated user. + + **Scope**: ``follow:read`` + + **Example requests**: + + - without parameters + + .. sourcecode:: http + + GET /api/follow-requests/ HTTP/1.1 + + - with some query parameters + + .. sourcecode:: http + + GET /api/follow-requests?page=1&order=desc HTTP/1.1 + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "follow_requests": [ + { + "admin": false, + "bio": null, + "birth_date": null, + "created_at": "Thu, 02 Dec 2021 17:50:48 GMT", + "first_name": null, + "followers": 1, + "following": 1, + "last_name": null, + "location": null, + "nb_sports": 0, + "nb_workouts": 0, + "picture": false, + "records": [], + "sports_list": [], + "total_distance": 0.0, + "total_duration": "0:00:00", + "username": "Sam" + } + ] + }, + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "status": "success" + } + + + :query integer page: page if using pagination (default: 1) + :query integer per_page: number of follow requests per page + (default: 10, max: 50) + :query string order: sorting order (default: ``asc``) + + :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 403: + - ``you do not have permissions, your account is suspended`` + :statuscode 500: + + """ + params = request.args.copy() + page = int(params.get('page', 1)) + per_page = int(params.get('per_page', FOLLOW_REQUESTS_PER_PAGE)) + order = params.get('order', 'asc') + if per_page > MAX_FOLLOW_REQUESTS_PER_PAGE: + per_page = MAX_FOLLOW_REQUESTS_PER_PAGE + follow_requests_pagination = ( + FollowRequest.query.filter_by( + followed_user_id=auth_user.id, + updated_at=None, + ) + .order_by( + asc(FollowRequest.created_at) + if order == 'asc' + else desc(FollowRequest.created_at) + ) + .paginate(page=page, per_page=per_page, error_out=False) + ) + follow_requests = follow_requests_pagination.items + return { + 'status': 'success', + 'data': { + 'follow_requests': [ + follow_request.serialize()['from_user'] + for follow_request in follow_requests + ] + }, + 'pagination': { + 'has_next': follow_requests_pagination.has_next, + 'has_prev': follow_requests_pagination.has_prev, + 'page': follow_requests_pagination.page, + 'pages': follow_requests_pagination.pages, + 'total': follow_requests_pagination.total, + }, + } + + +def process_follow_request( + auth_user: User, user_name: str, action: str +) -> Union[Dict, HttpResponse]: + from_user = User.query.filter( + func.lower(User.username) == func.lower(user_name), + ).first() + if not from_user: + appLog.error( + f'Error when accepting follow request: {user_name} does not exist' + ) + return UserNotFoundErrorResponse() + + try: + if action == 'accept': + auth_user.approves_follow_request_from(from_user) + else: # action == 'reject' + auth_user.rejects_follow_request_from(from_user) + except NotExistingFollowRequestError: + return NotFoundErrorResponse(message='Follow request does not exist.') + except FollowRequestAlreadyProcessedError: + return InvalidPayloadErrorResponse( + message=( + f"Follow request from user '{user_name}' already {action}ed." + ) + ) + + return { + 'status': 'success', + 'message': f"Follow request from user '{user_name}' is {action}ed.", + } + + +@follow_requests_blueprint.route( + '/follow-requests//accept', methods=['POST'] +) +@require_auth(scopes=['follow:write']) +def accept_follow_request( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + """ + Accept a follow request from user. + + **Scope**: ``follow:write`` + + **Example requests**: + + .. sourcecode:: http + + POST /api/follow-requests/Sam/accept HTTP/1.1 + + **Example responses**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success", + "message": "Follow request from user 'Sam' is accepted.", + } + + :param string user_name: user name + + :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 400: + - ``Follow request from user 'user_name' already accepted.`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` + :statuscode 404: + - ``user does not exist`` + - ``Follow request does not exist.`` + + """ + return process_follow_request(auth_user, user_name, 'accept') + + +@follow_requests_blueprint.route( + '/follow-requests//reject', methods=['POST'] +) +@require_auth(scopes=['follow:write']) +def reject_follow_request( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + """ + Reject a follow request from user. + + **Scope**: ``follow:write`` + + **Example requests**: + + .. sourcecode:: http + + POST /api/follow-requests/Sam/reject HTTP/1.1 + + **Example responses**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success", + "message": "Follow request from user 'Sam' is rejected.", + } + + :param string user_name: user name + + :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 400: + - ``Follow request from user 'user_name' already rejected.`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` + :statuscode 404: + - ``user does not exist`` + - ``Follow request does not exist.`` + + """ + return process_follow_request(auth_user, user_name, 'reject') diff --git a/fittrackee/users/models.py b/fittrackee/users/models.py index 1fc4b0110..ff4719bca 100644 --- a/fittrackee/users/models.py +++ b/fittrackee/users/models.py @@ -1,27 +1,219 @@ import os from datetime import datetime -from typing import Any, Dict, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union import jwt from flask import current_app from sqlalchemy import and_, func +from sqlalchemy.dialects.postgresql import insert 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.orm.session import Session, object_session +from sqlalchemy.schema import CheckConstraint +from sqlalchemy.sql import text from sqlalchemy.sql.expression import select +from sqlalchemy.types import Enum -from fittrackee import appLog, bcrypt, db +from fittrackee import BaseModel, appLog, bcrypt, db +from fittrackee.comments.models import Comment from fittrackee.files import get_absolute_file_path +from fittrackee.visibility_levels import VisibilityLevel from fittrackee.workouts.models import Workout -from .exceptions import UserNotFoundException -from .roles import UserRole +from .exceptions import ( + BlockUserException, + FollowRequestAlreadyProcessedError, + FollowRequestAlreadyRejectedError, + InvalidNotificationTypeException, + NotExistingFollowRequestError, +) +from .roles import ( + UserRole, + has_admin_rights, + has_moderator_rights, + is_auth_user, +) from .utils.token import decode_user_token, get_user_token -BaseModel: DeclarativeMeta = db.Model +if TYPE_CHECKING: + from fittrackee.reports.models import ReportAction + +USER_LINK_TEMPLATE = ( + '' + '{username}' +) + +ADMINISTRATOR_NOTIFICATION_TYPES = [ + 'account_creation', +] + +MODERATOR_NOTIFICATION_TYPES = [ + 'report', + 'suspension_appeal', + 'user_warning_appeal', +] + +NOTIFICATION_TYPES = ( + ADMINISTRATOR_NOTIFICATION_TYPES + + MODERATOR_NOTIFICATION_TYPES + + [ + 'comment_like', + 'comment_suspension', + 'comment_unsuspension', + 'follow', + 'follow_request', + 'mention', + 'user_warning', + 'user_warning_lifting', + 'workout_comment', + 'workout_like', + 'workout_suspension', + 'workout_unsuspension', + ] +) + + +class FollowRequest(BaseModel): + """Follow request between two users""" + + __tablename__ = 'follow_requests' + follower_user_id = db.Column( + db.Integer, + db.ForeignKey('users.id'), + primary_key=True, + ) + followed_user_id = db.Column( + db.Integer, + db.ForeignKey('users.id'), + primary_key=True, + ) + is_approved = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column( + db.DateTime, nullable=False, default=datetime.utcnow + ) + updated_at = db.Column(db.DateTime, nullable=True) + + def __repr__(self) -> str: + return ( + f'' + ) + + def __init__(self, follower_user_id: int, followed_user_id: int): + self.follower_user_id = follower_user_id + self.followed_user_id = followed_user_id + + def is_rejected(self) -> bool: + return not self.is_approved and self.updated_at is not None + + def serialize(self) -> Dict: + return { + 'from_user': self.from_user.serialize(), + 'to_user': self.to_user.serialize(), + } + + +@listens_for(FollowRequest, 'after_insert') +def on_follow_request_insert( + mapper: Mapper, connection: Connection, new_follow_request: FollowRequest +) -> None: + @listens_for(db.Session, 'after_flush', once=True) + def receive_after_flush(session: Session, context: Connection) -> None: + notification = Notification( + from_user_id=new_follow_request.follower_user_id, + to_user_id=new_follow_request.followed_user_id, + created_at=new_follow_request.created_at, + event_type=( + 'follow' + if new_follow_request.is_approved + else 'follow_request' + ), + ) + session.add(notification) + + +@listens_for(FollowRequest, 'after_update') +def on_follow_request_update( + mapper: Mapper, connection: Connection, follow_request: FollowRequest +) -> None: + if object_session(follow_request).is_modified(follow_request): + + @listens_for(db.Session, 'after_flush', once=True) + def receive_after_flush(session: Session, context: Connection) -> None: + if follow_request.is_approved: + notification_table = Notification.__table__ + connection.execute( + notification_table.update() + .where( + notification_table.c.from_user_id + == follow_request.follower_user_id, + notification_table.c.to_user_id + == follow_request.followed_user_id, + notification_table.c.event_type == 'follow_request', + ) + .values( + event_type='follow', + marked_as_read=False, + ) + ) + if ( + not follow_request.is_approved + and follow_request.updated_at is not None + ): + Notification.query.filter_by( + from_user_id=follow_request.follower_user_id, + to_user_id=follow_request.followed_user_id, + event_type='follow_request', + ).delete() + + +@listens_for(FollowRequest, 'after_delete') +def on_follow_request_delete( + mapper: Mapper, connection: Connection, old_follow_request: FollowRequest +) -> None: + @listens_for(db.Session, 'after_flush', once=True) + def receive_after_flush(session: Session, context: Any) -> None: + Notification.query.filter( + Notification.from_user_id == old_follow_request.follower_user_id, + Notification.to_user_id == old_follow_request.followed_user_id, + Notification.event_type.in_(['follow', 'follow_request']), + ).delete() + + +class BlockedUser(BaseModel): + __tablename__ = 'blocked_users' + __table_args__ = ( + db.UniqueConstraint( + 'user_id', 'by_user_id', name='blocked_users_unique' + ), + ) + + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + user_id = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='CASCADE'), + index=True, + ) + by_user_id = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='CASCADE'), + index=True, + ) + created_at = db.Column(db.DateTime, nullable=False) + + def __init__( + self, + user_id: int, + by_user_id: int, + created_at: Optional[datetime] = None, + ): + self.user_id = user_id + self.by_user_id = by_user_id + self.created_at = ( + datetime.utcnow() if created_at is None else created_at + ) class User(BaseModel): @@ -31,7 +223,6 @@ class User(BaseModel): email = db.Column(db.String(255), unique=True, nullable=False) password = db.Column(db.String(255), nullable=False) created_at = db.Column(db.DateTime, nullable=False) - admin = db.Column(db.Boolean, default=False, nullable=False) first_name = db.Column(db.String(80), nullable=True) last_name = db.Column(db.String(80), nullable=True) birth_date = db.Column(db.DateTime, nullable=True) @@ -40,7 +231,7 @@ class User(BaseModel): picture = db.Column(db.String(255), nullable=True) timezone = db.Column(db.String(50), nullable=True) date_format = db.Column(db.String(50), nullable=True) - # does the week start Monday? + # weekm: does the week start Monday? weekm = db.Column(db.Boolean, default=False, nullable=False) language = db.Column(db.String(50), nullable=True) imperial_units = db.Column(db.Boolean, default=False, nullable=False) @@ -54,11 +245,37 @@ class User(BaseModel): ) use_raw_gpx_speed = db.Column(db.Boolean, default=False, nullable=False) use_dark_mode = db.Column(db.Boolean, default=False, nullable=True) + manually_approves_followers = db.Column( + db.Boolean, default=True, nullable=False + ) + hide_profile_in_users_directory = db.Column( + db.Boolean, default=True, nullable=False + ) + workouts_visibility = db.Column( + Enum(VisibilityLevel, name='visibility_levels'), + server_default='PRIVATE', + nullable=False, + ) + map_visibility = db.Column( + Enum(VisibilityLevel, name='visibility_levels'), + server_default='PRIVATE', + nullable=False, + ) + suspended_at = db.Column(db.DateTime, nullable=True) + role = db.Column( + db.Integer, + CheckConstraint( + f"role IN ({', '.join(UserRole.db_values())})", + name='ck_users_role', + ), + nullable=False, + default=UserRole.USER.value, + ) workouts = db.relationship( 'Workout', lazy=True, - backref=db.backref('user', lazy='joined', single_parent=True), + backref=db.backref('user', lazy='select', single_parent=True), ) records = db.relationship( 'Record', @@ -70,6 +287,70 @@ class User(BaseModel): lazy='select', backref=db.backref('user', lazy='select', single_parent=True), ) + received_follow_requests = db.relationship( + FollowRequest, + backref='to_user', + primaryjoin=id == FollowRequest.followed_user_id, + lazy='dynamic', + cascade='all, delete-orphan', + ) + sent_follow_requests = db.relationship( + FollowRequest, + backref='from_user', + primaryjoin=id == FollowRequest.follower_user_id, + lazy='dynamic', + cascade='all, delete-orphan', + ) + followers = db.relationship( + 'User', + secondary='follow_requests', + primaryjoin=and_( + id == FollowRequest.followed_user_id, + FollowRequest.is_approved == True, # noqa + ), + secondaryjoin=and_( + id == FollowRequest.follower_user_id, + suspended_at == None, # noqa + ), + lazy='dynamic', + viewonly=True, + ) + following = db.relationship( + 'User', + secondary='follow_requests', + primaryjoin=and_( + id == FollowRequest.follower_user_id, + FollowRequest.is_approved == True, # noqa + ), + secondaryjoin=and_( + id == FollowRequest.followed_user_id, + suspended_at == None, # noqa + ), + lazy='dynamic', + viewonly=True, + ) + comments = db.relationship( + 'Comment', + lazy=True, + backref=db.backref( + 'user', + lazy='select', + single_parent=True, + ), + cascade='all, delete-orphan', + ) + blocked_users = db.relationship( + 'BlockedUser', + primaryjoin=id == BlockedUser.by_user_id, + lazy='dynamic', + viewonly=True, + ) + blocked_by_users = db.relationship( + 'BlockedUser', + primaryjoin=id == BlockedUser.user_id, + lazy='dynamic', + viewonly=True, + ) def __repr__(self) -> str: return f'' @@ -138,6 +419,14 @@ def generate_password_hash(new_password: str) -> str: def get_user_id(self) -> int: return self.id + @property + def has_moderator_rights(self) -> bool: + return has_moderator_rights(UserRole(self.role)) + + @property + def has_admin_rights(self) -> bool: + return has_admin_rights(UserRole(self.role)) + @hybrid_property def workouts_count(self) -> int: return Workout.query.filter(Workout.user_id == self.id).count() @@ -150,20 +439,271 @@ def workouts_count(self) -> int: .label('workouts_count') ) - def serialize(self, current_user: 'User') -> Dict: - role = ( - UserRole.AUTH_USER - if current_user.id == self.id - else UserRole.ADMIN - if current_user.admin - else UserRole.USER + @property + def pending_follow_requests(self) -> FollowRequest: + return self.received_follow_requests.filter_by(updated_at=None).all() + + def send_follow_request_to(self, target: 'User') -> FollowRequest: + existing_follow_request = FollowRequest.query.filter_by( + follower_user_id=self.id, followed_user_id=target.id + ).first() + if existing_follow_request: + if existing_follow_request.is_rejected(): + raise FollowRequestAlreadyRejectedError() + return existing_follow_request + + follow_request = FollowRequest( + follower_user_id=self.id, followed_user_id=target.id + ) + db.session.add(follow_request) + if not target.manually_approves_followers: + follow_request.is_approved = True + follow_request.updated_at = datetime.utcnow() + db.session.commit() + + return follow_request + + def unfollows(self, target: 'User') -> None: + existing_follow_request = FollowRequest.query.filter_by( + follower_user_id=self.id, followed_user_id=target.id + ).first() + if not existing_follow_request: + raise NotExistingFollowRequestError() + + db.session.delete(existing_follow_request) + db.session.commit() + return None + + def undoes_follow(self, follower: 'User') -> None: + existing_follow_request = FollowRequest.query.filter_by( + followed_user_id=self.id, follower_user_id=follower.id + ).first() + if not existing_follow_request: + raise NotExistingFollowRequestError() + db.session.delete(existing_follow_request) + db.session.commit() + return None + + def _processes_follow_request_from( + self, user: 'User', approved: bool + ) -> FollowRequest: + follow_request = FollowRequest.query.filter_by( + follower_user_id=user.id, followed_user_id=self.id + ).first() + if not follow_request: + raise NotExistingFollowRequestError() + if follow_request.updated_at is not None: + raise FollowRequestAlreadyProcessedError() + follow_request.is_approved = approved + follow_request.updated_at = datetime.utcnow() + db.session.commit() + return follow_request + + def approves_follow_request_from(self, user: 'User') -> FollowRequest: + follow_request = self._processes_follow_request_from( + user=user, approved=True + ) + return follow_request + + def rejects_follow_request_from(self, user: 'User') -> FollowRequest: + follow_request = self._processes_follow_request_from( + user=user, approved=False ) + return follow_request - if role == UserRole.USER: - raise UserNotFoundException() + @staticmethod + def follow_request_status(follow_request: FollowRequest) -> str: + if follow_request is None: + return 'false' + if follow_request.is_approved: + return 'true' + return 'pending' + + def is_followed_by(self, user: 'User') -> str: + follow_request = FollowRequest.query.filter_by( + follower_user_id=user.id, followed_user_id=self.id + ).first() + return self.follow_request_status(follow_request) + + def follows(self, user: 'User') -> str: + follow_request = FollowRequest.query.filter_by( + follower_user_id=self.id, followed_user_id=user.id + ).first() + return self.follow_request_status(follow_request) + + def get_following_user_ids(self) -> List: + return [following.id for following in self.following] + + def get_user_url(self) -> str: + """Return user url on user interface""" + return f"{current_app.config['UI_URL']}/users/{self.username}" + + def linkify_mention(self) -> str: + return USER_LINK_TEMPLATE.format( + profile_url=self.get_user_url(), username=f"@{self.username}" + ) + + def blocks_user(self, user: 'User') -> None: + if self.id == user.id: + raise BlockUserException() + + db.session.execute( + insert(BlockedUser) + .values( + user_id=user.id, + by_user_id=self.id, + created_at=datetime.utcnow(), + ) + .on_conflict_do_nothing() + ) + follow_request = FollowRequest.query.filter_by( + follower_user_id=user.id, + followed_user_id=self.id, + ).first() + if follow_request: + db.session.delete(follow_request) + db.session.commit() + + def unblocks_user(self, user: 'User') -> None: + BlockedUser.query.filter_by( + user_id=user.id, by_user_id=self.id + ).delete() + db.session.commit() + + def is_blocked_by(self, user: 'User') -> bool: + return ( + BlockedUser.query.filter_by( + user_id=self.id, by_user_id=user.id + ).first() + is not None + ) + + def get_blocked_user_ids(self) -> List: + return [ + blocked_user.user_id for blocked_user in self.blocked_users.all() + ] + + def get_blocked_by_user_ids(self) -> List: + return [ + blocked_user.by_user_id + for blocked_user in self.blocked_by_users.all() + ] + + @property + def suspension_action(self) -> Optional['ReportAction']: + if self.suspended_at is None: + return None + + from fittrackee.reports.models import ReportAction + + return ( + ReportAction.query.filter( + ReportAction.user_id == self.id, + ReportAction.action_type == "user_suspension", + ) + .order_by(ReportAction.created_at.desc()) + .first() + ) + + @property + def sanctions_count(self) -> int: + from fittrackee.reports.models import ReportAction + + return ( + ReportAction.query.filter( + ReportAction.user_id == self.id, + ReportAction.action_type.not_in( + [ + "comment_unsuspension", + "user_unsuspension", + "user_warning_lifting", + "workout_unsuspension", + ] + ), + ) + .order_by(ReportAction.created_at.desc()) + .count() + ) + + @property + def all_reports_count(self) -> Dict[str, int]: + query = """ + SELECT ( + SELECT COUNT(*) AS created_reports_count + FROM reports + WHERE reports.reported_by = :user_id + ), + ( + SELECT COUNT(*) AS reported_count + FROM reports + WHERE reports.reported_user_id = :user_id + ), + ( + SELECT COUNT(*) AS sanctions_count + FROM report_actions + WHERE report_actions.user_id = :user_id + AND report_actions.action_type NOT IN ( + 'comment_unsuspension', + 'user_unsuspension', + 'user_warning_lifting', + 'workout_unsuspension' + ) + );""" + result = db.session.execute(text(query), {'user_id': self.id}).first() + return { + "created_reports_count": result[0], + "reported_count": result[1], + "sanctions_count": result[2], + } + + def serialize( + self, + *, + current_user: Optional['User'] = None, + light: bool = True, + ) -> Dict: + if current_user is None: + role = None + else: + role = ( + UserRole.AUTH_USER + if current_user.id == self.id + else UserRole(current_user.role) + ) + + serialized_user = { + 'created_at': self.created_at, + 'followers': self.followers.count(), + 'following': self.following.count(), + 'nb_workouts': self.workouts_count, + 'picture': self.picture is not None, + 'role': UserRole(self.role).name.lower(), + 'suspended_at': self.suspended_at, + 'username': self.username, + } + if is_auth_user(role) or has_moderator_rights(role): + serialized_user['is_active'] = self.is_active + serialized_user['email'] = self.email + if ( + has_moderator_rights(role) + and self.suspended_at + and self.suspension_action + ): + serialized_user['suspension_report_id'] = ( + self.suspension_action.report_id + ) + + if current_user is not None and not is_auth_user(role): + serialized_user['follows'] = self.follows(current_user) + serialized_user['is_followed_by'] = self.is_followed_by( + current_user + ) + serialized_user['blocked'] = self.is_blocked_by(current_user) + + if light or not role: + return serialized_user sports = [] - total = (0, '0:00:00', 0) if self.workouts_count > 0: # type: ignore sports = ( db.session.query(Workout.sport_id) @@ -172,40 +712,56 @@ def serialize(self, current_user: 'User') -> Dict: .order_by(Workout.sport_id) .all() ) - total = ( - db.session.query( - func.sum(Workout.distance), - func.sum(Workout.duration), - func.sum(Workout.ascent), - ) - .filter(Workout.user_id == self.id) - .first() - ) serialized_user = { - 'admin': self.admin, + **serialized_user, 'bio': self.bio, 'birth_date': self.birth_date, - 'created_at': self.created_at, - 'email': self.email, - 'email_to_confirm': self.email_to_confirm, 'first_name': self.first_name, - 'is_active': self.is_active, 'last_name': self.last_name, 'location': self.location, - 'nb_sports': len(sports), - 'nb_workouts': self.workouts_count, - 'picture': self.picture is not None, - 'records': [record.serialize() for record in self.records], - 'sports_list': [ - sport for sportslist in sports for sport in sportslist - ], - 'total_ascent': float(total[2]) if total[2] else 0.0, - 'total_distance': float(total[0]), - 'total_duration': str(total[1]), - 'username': self.username, } - if role == UserRole.AUTH_USER: + + if role is not None: + total = (0, '0:00:00', 0) + if self.workouts_count > 0: # type: ignore + total = ( + db.session.query( + func.sum(Workout.distance), + func.sum(Workout.duration), + func.sum(Workout.ascent), + ) + .filter(Workout.user_id == self.id) + .first() + ) + + serialized_user['nb_sports'] = len(sports) + serialized_user['records'] = [ + record.serialize() for record in self.records + ] + serialized_user['sports_list'] = [ + sport for sportslist in sports for sport in sportslist + ] + serialized_user['total_ascent'] = ( + float(total[2]) if total[2] else 0.0 + ) + serialized_user['total_distance'] = float(total[0]) + serialized_user['total_duration'] = str(total[1]) + + if is_auth_user(role) or has_admin_rights(role): + serialized_user['email_to_confirm'] = self.email_to_confirm + + if current_user and has_moderator_rights(UserRole(current_user.role)): + reports_count = self.all_reports_count + serialized_user['created_reports_count'] = reports_count[ + 'created_reports_count' + ] + serialized_user['reported_count'] = reports_count['reported_count'] + serialized_user['sanctions_count'] = reports_count[ + 'sanctions_count' + ] + + if is_auth_user(role): accepted_privacy_policy = False if self.accepted_policy_date: accepted_privacy_policy = ( @@ -227,6 +783,15 @@ def serialize(self, current_user: 'User') -> Dict: 'use_dark_mode': self.use_dark_mode, 'use_raw_gpx_speed': self.use_raw_gpx_speed, 'weekm': self.weekm, + 'map_visibility': self.map_visibility.value, + 'workouts_visibility': self.workouts_visibility.value, + 'manually_approves_followers': ( + self.manually_approves_followers + ), + 'hide_profile_in_users_directory': ( + self.hide_profile_in_users_directory + ), + 'sanctions_count': self.sanctions_count, }, } @@ -394,3 +959,164 @@ def receive_after_flush(session: Session, context: Any) -> None: os.remove(get_absolute_file_path(file_path)) except OSError: appLog.error('archive found when deleting export request') + + +class Notification(BaseModel): + __tablename__ = 'notifications' + __table_args__ = ( + db.UniqueConstraint( + 'from_user_id', + 'to_user_id', + 'event_type', + 'event_object_id', + name='users_event_unique', + ), + ) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + from_user_id = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='CASCADE'), + index=True, + ) + to_user_id = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='CASCADE'), + index=True, + ) + created_at = db.Column(db.DateTime, nullable=False) + marked_as_read = db.Column(db.Boolean, nullable=False, default=False) + event_object_id = db.Column(db.Integer, nullable=True) + event_type = db.Column(db.String(50), nullable=False) + + def __init__( + self, + from_user_id: int, + to_user_id: int, + created_at: datetime, + event_type: str, + event_object_id: Optional[int] = None, + ): + if event_type not in NOTIFICATION_TYPES: + raise InvalidNotificationTypeException() + self.from_user_id = from_user_id + self.to_user_id = to_user_id + self.created_at = created_at + self.event_type = event_type + self.event_object_id = event_object_id + + def serialize(self) -> Dict: + serialized_notification = { + "created_at": self.created_at, + "id": self.id, + "marked_as_read": self.marked_as_read, + "type": self.event_type, + } + + if self.event_type in ["follow", "follow_request"]: + follow_request = FollowRequest.query.filter_by( + follower_user_id=self.from_user_id, + followed_user_id=self.to_user_id, + ).first() + from_user = follow_request.from_user + to_user = follow_request.to_user + return { + **serialized_notification, + "from": { + **from_user.serialize(), + "follows": from_user.follows(to_user), + "is_followed_by": from_user.is_followed_by(to_user), + }, + } + + if self.event_type in [ + "comment_suspension", + "comment_unsuspension", + "user_warning", + "user_warning_lifting", + "workout_suspension", + "workout_unsuspension", + ]: + from_user = None + else: + from_user = User.query.filter_by(id=self.from_user_id).first() + to_user = User.query.filter_by(id=self.to_user_id).first() + serialized_notification = { + **serialized_notification, + "from": ( + from_user.serialize(current_user=to_user) + if from_user + else None + ), + } + + if self.event_type == "workout_like": + workout = Workout.query.filter_by(id=self.event_object_id).first() + serialized_notification["workout"] = workout.serialize( + user=to_user + ) + + if self.event_type in [ + "comment_like", + "mention", + "workout_comment", + ]: + comment = Comment.query.filter_by(id=self.event_object_id).first() + serialized_notification["comment"] = comment.serialize( + user=to_user + ) + + if self.event_type in [ + "report", + "suspension_appeal", + "user_warning_appeal", + ]: + from fittrackee.reports.models import Report, ReportActionAppeal + + if self.event_type in ["suspension_appeal", "user_warning_appeal"]: + appeal = ReportActionAppeal.query.filter_by( + id=self.event_object_id + ).first() + report = Report.query.filter_by( + id=appeal.action.report_id + ).first() + else: + report = Report.query.filter_by( + id=self.event_object_id + ).first() + serialized_notification["report"] = report.serialize( + current_user=to_user + ) + + if self.event_type in [ + "comment_suspension", + "comment_unsuspension", + "user_warning", + "user_warning_lifting", + "workout_suspension", + "workout_unsuspension", + ]: + from fittrackee.reports.models import Report, ReportAction + + report_action = ReportAction.query.filter_by( + id=self.event_object_id + ).first() + serialized_notification["report_action"] = report_action.serialize( + current_user=to_user + ) + report = Report.query.filter_by(id=report_action.report_id).first() + if report.object_type == "comment": + comment = Comment.query.filter_by( + id=report.reported_comment_id + ).first() + serialized_notification["comment"] = comment.serialize( + user=to_user + ) + elif report.object_type == "workout": + workout = Workout.query.filter_by( + id=report.reported_workout_id + ).first() + serialized_notification["workout"] = workout.serialize( + user=to_user + ) + + return serialized_notification diff --git a/fittrackee/users/notifications.py b/fittrackee/users/notifications.py new file mode 100644 index 000000000..d7e4fdbba --- /dev/null +++ b/fittrackee/users/notifications.py @@ -0,0 +1,530 @@ +from typing import Dict, Union + +from flask import Blueprint, request +from sqlalchemy import and_, asc, desc, exc, or_ + +from fittrackee import db +from fittrackee.comments.models import Comment +from fittrackee.oauth2.server import require_auth +from fittrackee.responses import ( + HttpResponse, + NotFoundErrorResponse, + handle_error_and_return_response, +) +from fittrackee.visibility_levels import VisibilityLevel + +from .models import Notification, User + +notifications_blueprint = Blueprint("notifications", __name__) + + +DEFAULT_NOTIFICATION_PER_PAGE = 5 + + +@notifications_blueprint.route('/notifications', methods=['GET']) +@require_auth(scopes=["notifications:read"]) +def get_auth_user_notifications(auth_user: User) -> Dict: + """ + Get authenticated user notifications. + + **Scope**: ``notifications:read`` + + **Example requests**: + + - without parameters: + + .. sourcecode:: http + + GET /api/notifications HTTP/1.1 + + - with some query parameters: + + .. sourcecode:: http + + GET /api/notifications?page=2&status=unread HTTP/1.1 + + **Example responses**: + + - returning at least one notification: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "notifications": [ + { + "created_at": "Wed, 04 Dec 2024 10:06:35 GMT", + "from": { + "created_at": "Wed, 04 Dec 2024 09:07:08 GMT", + "followers": 0, + "following": 0, + "follows": "pending", + "is_followed_by": "false", + "nb_workouts": 0, + "picture": true, + "role": "admin", + "suspended_at": null, + "username": "admin" + }, + "id": 22, + "marked_as_read": false, + "type": "follow_request" + } + ], + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "status": "success" + } + + - returning no notifications + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "notifications": [], + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 0, + "total": 0 + }, + "status": "success" + } + + :query integer page: page if using pagination (default: 1) + :query string order: sorting order: ``asc``, ``desc`` (default: ``desc``) + :query string status: notification read status (``read``, ``unread``) + + :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 403: + - ``you do not have permissions, your account is suspended`` + """ + params = request.args.copy() + page = int(params.get('page', 1)) + order = params.get('order', 'desc') + status = params.get('status') + marked_as_read = None + if status == 'read': + marked_as_read = True + if status == 'unread': + marked_as_read = False + event_type = params.get('type') + + blocked_users = auth_user.get_blocked_user_ids() + blocked_by_users = auth_user.get_blocked_by_user_ids() + following_ids = auth_user.get_following_user_ids() + + notifications_pagination = ( + Notification.query.join( + User, + Notification.from_user_id == User.id, + ) + .outerjoin( + Comment, + Notification.event_object_id == Comment.id, + ) + .filter( + Notification.to_user_id == auth_user.id, + Notification.from_user_id.not_in(blocked_users), + ( + Notification.marked_as_read == marked_as_read + if marked_as_read is not None + else True + ), + ( + or_( + ( + and_( + ( + or_( + Notification.event_type + != "workout_comment", + and_( + Notification.event_type + == "workout_comment", + Notification.from_user_id.not_in( + blocked_by_users + ), + or_( + Comment.text_visibility + == VisibilityLevel.PUBLIC, + and_( + Comment.text_visibility + == VisibilityLevel.FOLLOWERS, + Notification.from_user_id.in_( + following_ids + ), + ), + ), + ), + ) + ), + User.suspended_at == None, # noqa + ) + ), + ( + Notification.event_type.in_( + ['report', 'suspension_appeal'] + ) + ), + ) + ), + Notification.event_type == event_type if event_type else True, + ) + .order_by( + asc(Notification.created_at) + if order == 'asc' + else desc(Notification.created_at) + ) + .paginate( + page=page, per_page=DEFAULT_NOTIFICATION_PER_PAGE, error_out=False + ) + ) + notifications = notifications_pagination.items + + return { + "status": "success", + "notifications": [ + notification.serialize() for notification in notifications + ], + "pagination": { + "has_next": notifications_pagination.has_next, + "has_prev": notifications_pagination.has_prev, + "page": notifications_pagination.page, + "pages": notifications_pagination.pages, + "total": notifications_pagination.total, + }, + } + + +@notifications_blueprint.route( + "/notifications/", methods=["PATCH"] +) +@require_auth(scopes=["notifications:write"]) +def update_user_notifications( + auth_user: User, notification_id: int +) -> Union[Dict, HttpResponse]: + """ + Update authenticated user notification read status. + + **Scope**: ``notifications:write`` + + **Example request**: + + .. sourcecode:: http + + PATCH /api/notifications/22 HTTP/1.1 + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "notification": { + "created_at": "Wed, 04 Dec 2024 10:06:35 GMT", + "from": { + "created_at": "Wed, 04 Dec 2024 09:07:08 GMT", + "followers": 0, + "following": 0, + "follows": "pending", + "is_followed_by": "false", + "nb_workouts": 0, + "picture": true, + "role": "admin", + "suspended_at": null, + "username": "admin" + }, + "id": 22, + "marked_as_read": true, + "type": "follow_request" + }, + "status": "success" + } + + :param string notification_id: notification id + + : Dict: + """ + Get if unread notifications exist for authenticated user. + + **Scope**: ``notifications:read`` + + **Example request**: + + .. sourcecode:: http + + GET /api/notifications/unread HTTP/1.1 + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success", + "unread": false + } + + :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 403: + - ``you do not have permissions, your account is suspended`` + """ + unread_notifications = ( + Notification.query.join( + User, + Notification.from_user_id == User.id, + ) + .outerjoin( + Comment, + Notification.event_object_id == Comment.id, + ) + .filter( + Notification.to_user_id == auth_user.id, + Notification.from_user_id.not_in(auth_user.get_blocked_user_ids()), + ( + or_( + ( + and_( + ( + or_( + Notification.event_type + != "workout_comment", + and_( + Notification.event_type + == "workout_comment", + Notification.from_user_id.not_in( + auth_user.get_blocked_by_user_ids() + ), + or_( + Comment.text_visibility + == VisibilityLevel.PUBLIC, + and_( + Comment.text_visibility + == VisibilityLevel.FOLLOWERS, + Notification.from_user_id.in_( + auth_user.get_following_user_ids() + ), + ), + ), + ), + ) + ), + User.suspended_at == None, # noqa + ) + ), + ( + Notification.event_type.in_( + ['report', 'suspension_appeal'] + ) + ), + ) + ), + Notification.marked_as_read == False, # noqa + ) + .count() + ) + return { + "status": "success", + "unread": unread_notifications > 0, + } + + +@notifications_blueprint.route( + "/notifications/mark-all-as-read", methods=["POST"] +) +@require_auth(scopes=["notifications:write"]) +def mark_all_as_read(auth_user: User) -> Union[Dict, HttpResponse]: + """ + Mark all authenticated user notifications as read. + + **Scope**: ``notifications:write`` + + **Example request**: + + .. sourcecode:: http + + POST /api/notifications/mark-all-as-read HTTP/1.1 + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success" + } + + : Dict: + """ + Get types of notifications received by authenticated user. + + **Scope**: ``notifications:read`` + + **Example requests**: + + - without parameters: + + .. sourcecode:: http + + GET /api/notifications/types HTTP/1.1 + + - with query parameter: + + .. sourcecode:: http + + GET /api/notifications/types?status=unread HTTP/1.1 + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "notification_types": [ + "mention" + ], + "status": "success" + } + + :query string status: notification read status (``read``, ``unread``) + + :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 403: + - ``you do not have permissions, your account is suspended`` + """ + status = request.args.copy().get('status') + marked_as_read = None + if status == 'read': + marked_as_read = True + if status == 'unread': + marked_as_read = False + notification_types = ( + db.session.query(Notification.event_type) + .filter( + Notification.to_user_id == auth_user.id, + True + if marked_as_read is None + else Notification.marked_as_read == marked_as_read, + ) + .all() + ) + return { + "notification_types": [ + notification_type[0] for notification_type in notification_types + ], + "status": "success", + } diff --git a/fittrackee/users/roles.py b/fittrackee/users/roles.py index 3cfb7b75b..ab3bdd4bd 100644 --- a/fittrackee/users/roles.py +++ b/fittrackee/users/roles.py @@ -1,7 +1,38 @@ from enum import Enum +from typing import List, Optional class UserRole(Enum): - ADMIN = 'admin' - AUTH_USER = 'auth_user' - USER = 'user' + OWNER = 100 + ADMIN = 50 + MODERATOR = 30 + AUTH_USER = 20 # only for app (not used in database) + USER = 10 + + @classmethod + def values(cls) -> List[int]: + return [e.value for e in cls] + + @classmethod + def db_values(cls) -> List[str]: + return [str(e.value) for e in cls if e.name != 'AUTH_USER'] + + @classmethod + def db_choices(cls) -> List[str]: + return [e.name.lower() for e in cls if e.name != 'AUTH_USER'] + + +def has_role_rights(role: Optional[UserRole], expected_role: UserRole) -> bool: + return role is not None and role.value >= expected_role.value + + +def has_moderator_rights(role: Optional[UserRole]) -> bool: + return has_role_rights(role, UserRole.MODERATOR) + + +def has_admin_rights(role: Optional[UserRole]) -> bool: + return has_role_rights(role, UserRole.ADMIN) + + +def is_auth_user(role: Optional[UserRole]) -> bool: + return role is not None and role == UserRole.AUTH_USER diff --git a/fittrackee/users/users.py b/fittrackee/users/users.py index 8002760e2..67ed38c18 100644 --- a/fittrackee/users/users.py +++ b/fittrackee/users/users.py @@ -1,11 +1,11 @@ import os import shutil -from typing import Any, Dict, Tuple, Union +from typing import Any, Dict, Optional, Tuple, Union from flask import Blueprint, current_app, request, send_file -from sqlalchemy import asc, desc, exc +from sqlalchemy import and_, asc, desc, exc, func, nullslast, or_ -from fittrackee import db, limiter +from fittrackee import appLog, db, limiter from fittrackee.emails.tasks import ( email_updated_to_new_address, password_change_email, @@ -25,22 +25,123 @@ from fittrackee.utils import get_readable_duration from fittrackee.workouts.models import Record, Workout, WorkoutSegment -from .exceptions import InvalidEmailException, UserNotFoundException -from .models import User, UserDataExport, UserSportPreference -from .utils.admin import UserManagerService +from ..reports.models import ReportAction +from .exceptions import ( + BlockUserException, + FollowRequestAlreadyRejectedError, + InvalidEmailException, + InvalidUserRole, + NotExistingFollowRequestError, + OwnerException, + UserNotFoundException, +) +from .models import FollowRequest, User, UserDataExport, UserSportPreference +from .roles import UserRole +from .users_service import UserManagerService from .utils.language import get_language users_blueprint = Blueprint('users', __name__) -USER_PER_PAGE = 10 +ACTIONS_PER_PAGE = 5 +USERS_PER_PAGE = 10 +EMPTY_USERS_RESPONSE = { + 'status': 'success', + 'data': {'users': []}, + 'pagination': { + 'has_next': False, + 'has_prev': False, + 'page': 1, + 'pages': 0, + 'total': 0, + }, +} + + +def _get_value_depending_on_user_rights( + params: Dict, key: str, auth_user: Optional[User] +) -> str: + value = params.get(key, 'false').lower() + if not auth_user or not auth_user.has_admin_rights: + value = 'false' + return value + + +def get_users_list(auth_user: User) -> Dict: + params = request.args.copy() + + query = params.get('q') + page = int(params.get('page', 1)) + per_page = int(params.get('per_page', USERS_PER_PAGE)) + if per_page > 50: + per_page = 50 + column = params.get('order_by', 'username') + user_column = getattr(User, column) + order = params.get('order', 'asc') + order_clauses = [asc(user_column) if order == 'asc' else desc(user_column)] + if column != 'username': + order_clauses.append(User.username.asc()) + if column == "suspended_at": + order_clauses = [nullslast(order_clauses[0])] + with_inactive = _get_value_depending_on_user_rights( + params, 'with_inactive', auth_user + ) + with_hidden_users = _get_value_depending_on_user_rights( + params, 'with_hidden', auth_user + ) + with_suspended_users = _get_value_depending_on_user_rights( + params, 'with_suspended', auth_user + ) + with_following = params.get('with_following', 'false').lower() + following_user_ids = ( + auth_user.get_following_user_ids() if with_following == 'true' else [] + ) + + users_pagination = ( + User.query.filter( + User.username.ilike('%' + query + '%') if query else True, + ( + True if with_inactive == 'true' else User.is_active == True # noqa + ), + or_( + True + if with_hidden_users == 'true' + else User.hide_profile_in_users_directory == False, # noqa + and_( + User.id.in_(following_user_ids), + User.hide_profile_in_users_directory == True, # noqa + ), + ), + ( + True + if with_suspended_users == 'true' + else User.suspended_at == None # noqa + ), + ) + .order_by(*order_clauses) + .paginate(page=page, per_page=per_page, error_out=False) + ) + users = users_pagination.items + return { + 'status': 'success', + 'data': { + 'users': [user.serialize(current_user=auth_user) for user in users] + }, + 'pagination': { + 'has_next': users_pagination.has_next, + 'has_prev': users_pagination.has_prev, + 'page': users_pagination.page, + 'pages': users_pagination.pages, + 'total': users_pagination.total, + }, + } @users_blueprint.route('/users', methods=['GET']) -@require_auth(scopes=['users:read'], as_admin=True) +@require_auth(scopes=['users:read']) def get_users(auth_user: User) -> Dict: """ - Get all users (regardless their account status), if authenticated user - has admin rights. + Get all users. + If authenticated user has admin rights, users email is returned. It returns user preferences only for authenticated user. @@ -79,11 +180,13 @@ def get_users(auth_user: User) -> Dict: "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "admin@example.com", "first_name": null, - "is_admin": true, - "imperial_units": false, - "language": "en", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -139,11 +242,10 @@ def get_users(auth_user: User) -> Dict: 4, 6 ], - "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", "username": "admin", - "weekm": false + "workouts_visibility": "private" }, { "admin": false, @@ -152,19 +254,22 @@ def get_users(auth_user: User) -> Dict: "created_at": "Sat, 20 Jul 2019 11:27:03 GMT", "email": "sam@example.com", "first_name": null, - "is_admin": false, - "language": "fr", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 0, "nb_workouts": 0, "picture": false, "records": [], "sports_list": [], - "timezone": "Europe/Paris", "total_distance": 0, "total_duration": "0:00:00", - "username": "sam" + "username": "sam", + "workouts_visibility": "private" } ] }, @@ -176,8 +281,19 @@ def get_users(auth_user: User) -> Dict: :query string q: query on user name :query string order: sorting order: ``asc``, ``desc`` (default: ``asc``) :query string order_by: sorting criteria: ``username``, ``created_at``, - ``workouts_count``, ``admin``, ``is_active`` + ``workouts_count``, ``role``, ``is_active`` (default: ``username``) + :query boolean with_following: returns hidden users followed by user if + true + :query boolean with_hidden_users: returns hidden users if ``true`` (only if + authenticated user has administration rights - for users + administration) + :query boolean with_inactive: returns inactive users if ``true`` (only if + authenticated user has administration rights - for users + administration) + :query boolean with_suspended: returns suspended users if ``true`` (only if + authenticated user has administration rights - for users + administration) :reqheader Authorization: OAuth 2.0 Bearer Token @@ -186,49 +302,26 @@ def get_users(auth_user: User) -> Dict: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` """ - params = request.args.copy() - page = int(params.get('page', 1)) - per_page = int(params.get('per_page', USER_PER_PAGE)) - if per_page > 50: - per_page = 50 - user_column = getattr(User, params.get('order_by', 'username')) - order = params.get('order', 'asc') - query = params.get('q') - users_pagination = ( - User.query.filter( - User.username.ilike('%' + query + '%') if query else True, - ) - .order_by(asc(user_column) if order == 'asc' else desc(user_column)) - .paginate(page=page, per_page=per_page, error_out=False) - ) - users = users_pagination.items - return { - 'status': 'success', - 'data': {'users': [user.serialize(auth_user) for user in users]}, - 'pagination': { - 'has_next': users_pagination.has_next, - 'has_prev': users_pagination.has_prev, - 'page': users_pagination.page, - 'pages': users_pagination.pages, - 'total': users_pagination.total, - }, - } + return get_users_list(auth_user) @users_blueprint.route('/users/', methods=['GET']) -@require_auth(scopes=['users:read']) +@require_auth(scopes=['users:read'], optional_auth_user=True) def get_single_user( - auth_user: User, user_name: str + auth_user: Optional[User], user_name: str ) -> Union[Dict, HttpResponse]: """ - Get single user details. Only user with admin rights can get other users - details. + Get single user details. + If a user is authenticated, it returns relationships. + If authenticated user has admin rights, user email is returned. It returns user preferences only for authenticated user. - **Scope**: ``users:read`` + **Scope**: ``users:read`` for Oauth 2.0 client **Example request**: @@ -239,6 +332,8 @@ def get_single_user( **Example response**: + - when a user is authenticated: + .. sourcecode:: http HTTP/1.1 200 OK @@ -253,11 +348,13 @@ def get_single_user( "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "admin@example.com", "first_name": null, - "imperial_units": false, - "is_admin": true, - "language": "en", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", "last_name": null, "location": null, + "map_visibility": "private", "nb_sports": 3, "nb_workouts": 6, "picture": false, @@ -313,10 +410,42 @@ def get_single_user( 4, 6 ], - "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", - "username": "admin" + "username": "admin", + "workouts_visibility": "private" + } + ], + "status": "success" + } + + - when no authentication: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": [ + { + "admin": true, + "bio": null, + "birth_date": null, + "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", + "email": "admin@example.com", + "first_name": null, + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", + "last_name": null, + "location": null, + "map_visibility": "private", + "nb_workouts": 6, + "picture": false, + "username": "admin", + "workouts_visibility": "private" } ], "status": "success" @@ -324,27 +453,36 @@ def get_single_user( :param integer user_name: user name - :reqheader Authorization: OAuth 2.0 Bearer Token + :reqheader Authorization: OAuth 2.0 Bearer Token if user is authenticated :statuscode 200: ``success`` :statuscode 401: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 404: - ``user does not exist`` """ - if user_name != auth_user.username and not auth_user.admin: - return ForbiddenErrorResponse() - try: - user = User.query.filter_by(username=user_name).first() + user = User.query.filter( + func.lower(User.username) == func.lower(user_name), + ).first() if user: + if ( + not auth_user or not auth_user.has_admin_rights + ) and not user.is_active: + return UserNotFoundErrorResponse() return { 'status': 'success', - 'data': {'users': [user.serialize(auth_user)]}, + 'data': { + 'users': [ + user.serialize(current_user=auth_user, light=False) + ] + }, } - except ValueError: + except (ValueError, UserNotFoundException): pass return UserNotFoundErrorResponse() @@ -377,19 +515,23 @@ def get_picture(user_name: str) -> Any: """ try: - user = User.query.filter_by(username=user_name).first() + user = User.query.filter( + func.lower(User.username) == func.lower(user_name), + ).first() if not user: return UserNotFoundErrorResponse() if user.picture is not None: picture_path = get_absolute_file_path(user.picture) return send_file(picture_path) + except UserNotFoundException: + return UserNotFoundErrorResponse() except Exception: # nosec pass return NotFoundErrorResponse('No picture.') @users_blueprint.route('/users/', methods=['PATCH']) -@require_auth(scopes=['users:write'], as_admin=True) +@require_auth(scopes=['users:write'], role=UserRole.ADMIN) def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: """ Update user account. @@ -399,11 +541,12 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: if sending enabled) - update user email (and send email to new user email, if sending enabled) - activate account for an inactive user - - Only user with admin rights can modify another user. + - deactivate account after report. **Scope**: ``users:write`` + **Minimum role**: Administrator + **Example request**: .. sourcecode:: http @@ -427,11 +570,13 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: "created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "email": "admin@example.com", "first_name": null, - "imperial_units": false, - "is_active": true, - "language": "en", + "followers": 0, + "following": 0, + "follows": "false", + "is_followed_by": "false", "last_name": null, "location": null, + "map_visibility": "private", "nb_workouts": 6, "nb_sports": 3, "picture": false, @@ -487,10 +632,10 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: 4, 6 ], - "timezone": "Europe/Paris", "total_distance": 67.895, "total_duration": "6:50:27", - "username": "admin" + "username": "admin", + "workouts_visibility": "private" } ], "status": "success" @@ -498,8 +643,9 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: :param string user_name: user name - : Union[Dict, HttpResponse]: :statuscode 200: ``success`` :statuscode 400: - ``invalid payload`` + - ``invalid role`` - ``valid email must be provided`` - ``new email must be different than current email`` :statuscode 401: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` - :statuscode 404: ``user does not exist`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` + :statuscode 404: + - ``user does not exist`` :statuscode 500: ``error, please try again or contact the administrator`` """ user_data = request.get_json() if not user_data: return InvalidPayloadErrorResponse() + activate = user_data.get('activate') + if activate is False and user_name == auth_user.username: + return ForbiddenErrorResponse() + + role = user_data.get('role') + if role == 'owner': + return InvalidPayloadErrorResponse( + "'owner' can not be set via API, please user CLI instead" + ) + try: reset_password = user_data.get('reset_password', False) new_email = user_data.get('new_email') - user_manager_service = UserManagerService(username=user_name) - user, _, _ = user_manager_service.update( - is_admin=user_data.get('admin'), - activate=user_data.get('activate', False), + user_manager_service = UserManagerService( + username=user_name, moderator_id=auth_user.id + ) + user, _, _, _ = user_manager_service.update( + role=role, + activate=user_data.get('activate'), reset_password=reset_password, new_email=new_email, with_confirmation=current_app.config['CAN_SEND_EMAILS'], + raise_error_on_owner=True, ) if current_app.config['CAN_SEND_EMAILS']: user_language = get_language(user.language) - ui_url = current_app.config['UI_URL'] + fittrackee_url = current_app.config['UI_URL'] if reset_password: user_data = { 'language': user_language, @@ -546,7 +709,7 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: user_data, { 'username': user.username, - 'fittrackee_url': ui_url, + 'fittrackee_url': fittrackee_url, }, ) password_reset_token = user.encode_password_reset_token( @@ -563,10 +726,10 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: ), 'username': user.username, 'password_reset_url': ( - f'{ui_url}/password-reset?' + f'{fittrackee_url}/password-reset?' f'token={password_reset_token}' ), - 'fittrackee_url': ui_url, + 'fittrackee_url': fittrackee_url, }, ) @@ -577,9 +740,9 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: } email_data = { 'username': user.username, - 'fittrackee_url': ui_url, + 'fittrackee_url': fittrackee_url, 'email_confirmation_url': ( - f'{ui_url}/email-update' + f'{fittrackee_url}/email-update' f'?token={user.confirmation_token}' ), } @@ -587,18 +750,20 @@ def update_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: return { 'status': 'success', - 'data': {'users': [user.serialize(auth_user)]}, + 'data': { + 'users': [user.serialize(current_user=auth_user, light=False)] + }, } except UserNotFoundException: return UserNotFoundErrorResponse() - except InvalidEmailException as e: + except (InvalidEmailException, InvalidUserRole, OwnerException) as e: return InvalidPayloadErrorResponse(str(e)) - except exc.StatementError as e: + except (TypeError, exc.StatementError) as e: return handle_error_and_return_response(e, db=db) @users_blueprint.route('/users/', methods=['DELETE']) -@require_auth(scopes=['users:write']) +@require_auth(scopes=['users:write'], allow_suspended_user=True) def delete_user( auth_user: User, user_name: str ) -> Union[Tuple[Dict, int], HttpResponse]: @@ -607,8 +772,11 @@ def delete_user( A user can only delete his own account. - An admin can delete all accounts except his account if he's the only - one admin. + A user with admin rights can delete all accounts except his account if + he is the only user with admin rights. + Only owner can delete his own account. + + Suspended user can access this endpoint. **Scope**: ``users:write`` @@ -638,20 +806,26 @@ def delete_user( :statuscode 403: - ``you do not have permissions`` - ``you can not delete your account, no other user has admin rights`` - :statuscode 404: ``user does not exist`` + :statuscode 404: + - ``user does not exist`` :statuscode 500: ``error, please try again or contact the administrator`` """ try: - user = User.query.filter_by(username=user_name).first() + user = User.query.filter( + func.lower(User.username) == func.lower(user_name), + ).first() if not user: return UserNotFoundErrorResponse() + if user.id != auth_user.id and user.role == UserRole.OWNER.value: + return ForbiddenErrorResponse('you can not delete owner account') - if user.id != auth_user.id and not auth_user.admin: + if user.id != auth_user.id and not auth_user.has_admin_rights: return ForbiddenErrorResponse() if ( - user.admin is True - and User.query.filter_by(admin=True).count() == 1 + user.has_admin_rights is True + and User.query.filter(User.role >= UserRole.ADMIN.value).count() + == 1 ): return ForbiddenErrorResponse( 'you can not delete your account, ' @@ -701,3 +875,722 @@ def delete_user( OSError, ) as e: return handle_error_and_return_response(e, db=db) + + +@users_blueprint.route('/users//follow', methods=['POST']) +@require_auth(scopes=['follow:write']) +def follow_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: + """ + Send a follow request to a user. + + **Scope**: ``follow:write`` + + **Example request**: + + .. sourcecode:: http + + POST /api/users/john_doe/follow HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success", + "message": "Follow request to user 'john_doe' is sent.", + } + + + :param string user_name: user name + + :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`` + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` + :statuscode 404: + - ``user does not exist`` + :statuscode 500: ``error, please try again or contact the administrator`` + + """ + successful_response_dict = { + 'status': 'success', + 'message': f"Follow request to user '{user_name}' is sent.", + } + + target_user = User.query.filter( + func.lower(User.username) == func.lower(user_name), + ).first() + if not target_user: + appLog.error( + f'Error when following a user: user {user_name} not found' + ) + return UserNotFoundErrorResponse() + + if auth_user.is_blocked_by(target_user): + return InvalidPayloadErrorResponse("you can not follow this user") + + try: + auth_user.send_follow_request_to(target_user) + except FollowRequestAlreadyRejectedError: + return ForbiddenErrorResponse() + return successful_response_dict + + +@users_blueprint.route('/users//unfollow', methods=['POST']) +@require_auth(scopes=['follow:write']) +def unfollow_user( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + """ + Unfollow a user. + + **Scope**: ``follow:write`` + + **Example request**: + + .. sourcecode:: http + + POST /api/users/john_doe/unfollow HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success", + "message": "Undo for a follow request to user 'john_doe' is sent.", + } + + + :param string user_name: user name + + :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 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` + :statuscode 404: + - ``user does not exist`` + :statuscode 500: ``error, please try again or contact the administrator`` + + """ + successful_response_dict = { + 'status': 'success', + 'message': f"Undo for a follow request to user '{user_name}' is sent.", + } + + target_user = User.query.filter( + func.lower(User.username) == func.lower(user_name), + ).first() + if not target_user: + appLog.error( + f'Error when following a user: user {user_name} not found' + ) + return UserNotFoundErrorResponse() + + try: + auth_user.unfollows(target_user) + except NotExistingFollowRequestError: + return NotFoundErrorResponse(message='relationship does not exist') + return successful_response_dict + + +def get_user_relationships( + auth_user: User, user_name: str, relation: str +) -> Union[Dict, HttpResponse]: + params = request.args.copy() + try: + page = int(params.get('page', 1)) + except ValueError: + page = 1 + + user = User.query.filter( + func.lower(User.username) == func.lower(user_name), + ).first() + if not user: + return UserNotFoundErrorResponse() + + relations_object = ( + user.followers if relation == 'followers' else user.following + ) + + paginated_relations = relations_object.order_by( + FollowRequest.updated_at.desc() + ).paginate(page=page, per_page=USERS_PER_PAGE, error_out=False) + + return { + 'status': 'success', + 'data': { + relation: [ + user.serialize(current_user=auth_user) + for user in paginated_relations.items + ] + }, + 'pagination': { + 'has_next': paginated_relations.has_next, + 'has_prev': paginated_relations.has_prev, + 'page': paginated_relations.page, + 'pages': paginated_relations.pages, + 'total': paginated_relations.total, + }, + } + + +@users_blueprint.route('/users//followers', methods=['GET']) +@require_auth(scopes=['follow:read']) +def get_followers( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + """ + Get user followers. + If the authenticated user has admin rights, it returns following users with + additional field 'email' + + **Scope**: ``follow:read`` + + **Example request**: + + - without parameters + + .. sourcecode:: http + + GET /api/users/sam/followers HTTP/1.1 + Content-Type: application/json + + - with page parameter + + .. sourcecode:: http + + GET /api/users/sam/followers?page=1 HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "followers": [ + { + "admin": false, + "bio": null, + "birth_date": null, + "created_at": "Thu, 02 Dec 2021 17:50:48 GMT", + "first_name": null, + "followers": 1, + "following": 1, + "follows": "true", + "is_followed_by": "false", + "last_name": null, + "location": null, + "map_visibility": "followers_only", + "nb_sports": 0, + "nb_workouts": 0, + "picture": false, + "records": [], + "sports_list": [], + "total_distance": 0.0, + "total_duration": "0:00:00", + "username": "JohnDoe", + "workouts_visibility": "followers_only" + } + ] + }, + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "status": "success" + } + + :param string user_name: user name + + :query integer page: page if using pagination (default: 1) + + :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 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` + :statuscode 404: + - ``user does not exist`` + + """ + return get_user_relationships(auth_user, user_name, 'followers') + + +@users_blueprint.route('/users//following', methods=['GET']) +@require_auth(scopes=['follow:read']) +def get_following( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + """ + Get user following. + If the authenticate user has admin rights, it returns following users with + additional field 'email' + + **Scope**: ``follow:read`` + + **Example request**: + + - without parameters + + .. sourcecode:: http + + GET /api/users/sam/following HTTP/1.1 + Content-Type: application/json + + - with page parameter + + .. sourcecode:: http + + GET /api/users/sam/following?page=1 HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "following": [ + { + "admin": false, + "bio": null, + "birth_date": null, + "created_at": "Thu, 02 Dec 2021 17:50:48 GMT", + "first_name": null, + "followers": 1, + "following": 1, + "follows": "false", + "is_followed_by": "true", + "last_name": null, + "location": null, + "map_visibility": "followers_only", + "nb_sports": 0, + "nb_workouts": 0, + "picture": false, + "records": [], + "sports_list": [], + "total_distance": 0.0, + "total_duration": "0:00:00", + "username": "JohnDoe", + "workouts_visibility": "followers_only" + } + ] + }, + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "status": "success" + } + + :param string user_name: user name + + :query integer page: page if using pagination (default: 1) + + :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 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` + :statuscode 404: + - ``user does not exist`` + + """ + return get_user_relationships(auth_user, user_name, 'following') + + +@users_blueprint.route('/users//block', methods=['POST']) +@require_auth(scopes=['users:write']) +def block_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: + """ + Block a user + + **Scope**: ``users:write`` + + **Example request**: + + .. sourcecode:: http + + GET /api/users/sam/block HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success" + } + + :param string user_name: user name + + :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 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` + :statuscode 404: + - ``user not found`` + """ + target_user = User.query.filter( + func.lower(User.username) == func.lower(user_name), + ).first() + if not target_user: + appLog.error(f"Error: user {user_name} not found") + return UserNotFoundErrorResponse() + + try: + auth_user.blocks_user(target_user) + # delete follow request is exists (approved or pending) + FollowRequest.query.filter_by( + follower_user_id=target_user.id, followed_user_id=auth_user.id + ).delete() + db.session.commit() + + except BlockUserException: + return InvalidPayloadErrorResponse() + + return {"status": "success"} + + +@users_blueprint.route('/users//unblock', methods=['POST']) +@require_auth(scopes=['users:write']) +def unblock_user(auth_user: User, user_name: str) -> Union[Dict, HttpResponse]: + """ + Unblock a user + + **Scope**: ``users:write`` + + **Example request**: + + .. sourcecode:: http + + GET /api/users/sam/unblock HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "status": "success" + } + + :param string user_name: user name + + :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 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` + :statuscode 404: + - ``user not found`` + + """ + target_user = User.query.filter( + func.lower(User.username) == func.lower(user_name), + ).first() + if not target_user: + appLog.error(f"Error: user {user_name} not found") + return UserNotFoundErrorResponse() + + auth_user.unblocks_user(target_user) + + return {"status": "success"} + + +@users_blueprint.route('/users//sanctions', methods=['GET']) +@require_auth(scopes=['users:read'], allow_suspended_user=True) +def get_user_sanctions( + auth_user: User, user_name: str +) -> Union[Dict, HttpResponse]: + """ + Get user sanctions. + + It returns sanctions only if: + - user name is authenticated user username + - user has moderation rights. + + Suspended user can access this endpoint. + + **Scope**: ``users:read`` + + **Example requests**: + + - without parameters: + + .. sourcecode:: http + + GET /api/users/Sam/sanctions HTTP/1.1 + + - with parameters: + + .. sourcecode:: http + + GET /api/users/Sam/sanctions?page=2 HTTP/1.1 + + **Example responses**: + + - if sanctions exist (response with moderation rights) + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "sanctions": [ + { + "action_type": "workout_suspension", + "appeal": { + "approved": null, + "created_at": "Wed, 04 Dec 2024 11:00:04 GMT", + "id": "2ULe2hWhSnYCS2VHbsikB9", + "moderator": null, + "reason": null, + "text": "", + "updated_at": null, + "user": { + "blocked": false, + "created_at": "Wed, 04 Dec 2024 09:07:06 GMT", + "email": "sam@example.com", + "followers": 0, + "following": 0, + "follows": false, + "is_active": true, + "is_followed_by": false, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + } + }, + "created_at": "Wed, 04 Dec 2024 10:59:45 GMT", + "id": "6dxczvMrhkAR72shUz9Pwd", + "moderator": { + "blocked": false, + "created_at": "Wed, 01 Mar 2023 12:31:17 GMT", + "email": "admin@example.com", + "followers": 0, + "following": 0, + "follows": "false", + "is_active": true, + "is_followed_by": "false", + "nb_workouts": 0, + "picture": true, + "role": "admin", + "suspended_at": null, + "username": "admin" + }, + "reason": "", + "report_id": 2, + "user": { + "blocked": false, + "created_at": "Sun, 01 Dec 2024 17:27:49 GMT", + "email": "sam@example.com", + "followers": 0, + "following": 0, + "follows": "false", + "is_active": true, + "is_followed_by": "false", + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + } + } + ] + }, + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "status": "success" + } + + - if sanctions exist (response for authenticated user) + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "sanctions": [ + { + "action_type": "workout_suspension", + "appeal": { + "approved": null, + "created_at": "Wed, 04 Dec 2024 16:50:55 GMT", + "id": "kcj6hdGQqPKaaKQmfQj8Jv", + "reason": null, + "text": "", + "updated_at": null + }, + "created_at": "Wed, 04 Dec 2024 16:50:44 GMT", + "id": "6nvxvAyoh9Zkr8RMXhu54T", + "reason": "" + } + ] + }, + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 1, + "total": 1 + }, + "status": "success" + } + + - no sanctions + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "sanctions": [] + }, + "pagination": { + "has_next": false, + "has_prev": false, + "page": 1, + "pages": 0, + "total": 0 + }, + "status": "success" + } + + :param string user_name: user name + + :query integer page: page if using pagination (default: 1) + + :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 403: + - ``you do not have permissions`` + :statuscode 404: + - ``user not found`` + + """ + user = User.query.filter( + func.lower(User.username) == func.lower(user_name), + ).first() + if not user: + appLog.error(f"Error: user {user_name} not found") + return UserNotFoundErrorResponse() + + if user.id != auth_user.id and not auth_user.has_moderator_rights: + return ForbiddenErrorResponse() + + params = request.args.copy() + page = int(params.get('page', 1)) + + paginated_sanctions = ( + ReportAction.query.filter( + ReportAction.user_id == user.id, + ReportAction.action_type.not_in( + [ + "comment_unsuspension", + "user_unsuspension", + "user_warning_lifting", + "workout_unsuspension", + ] + ), + ) + .order_by(ReportAction.created_at.desc()) + .paginate(page=page, per_page=ACTIONS_PER_PAGE, error_out=False) + ) + + return { + 'status': 'success', + 'data': { + 'sanctions': [ + sanctions.serialize(current_user=auth_user, full=False) + for sanctions in paginated_sanctions.items + ] + }, + 'pagination': { + 'has_next': paginated_sanctions.has_next, + 'has_prev': paginated_sanctions.has_prev, + 'page': paginated_sanctions.page, + 'pages': paginated_sanctions.pages, + 'total': paginated_sanctions.total, + }, + } diff --git a/fittrackee/users/utils/admin.py b/fittrackee/users/users_service.py similarity index 50% rename from fittrackee/users/utils/admin.py rename to fittrackee/users/users_service.py index 0fd376b28..6de1e1ce3 100644 --- a/fittrackee/users/utils/admin.py +++ b/fittrackee/users/users_service.py @@ -1,23 +1,38 @@ import secrets +from datetime import datetime from typing import Optional, Tuple from sqlalchemy import func from fittrackee import db - -from ..exceptions import ( +from fittrackee.reports.models import ReportAction +from fittrackee.users.constants import USER_DATE_FORMAT, USER_TIMEZONE +from fittrackee.users.exceptions import ( InvalidEmailException, + InvalidUserRole, + MissingAdminIdException, + MissingReportIdException, + OwnerException, + UserAlreadyReactivatedException, + UserAlreadySuspendedException, UserControlsException, UserCreationException, UserNotFoundException, ) -from ..models import User -from ..utils.controls import is_valid_email, register_controls +from fittrackee.users.models import ( + ADMINISTRATOR_NOTIFICATION_TYPES, + MODERATOR_NOTIFICATION_TYPES, + Notification, + User, +) +from fittrackee.users.roles import UserRole +from fittrackee.users.utils.controls import is_valid_email, register_controls class UserManagerService: - def __init__(self, username: str): + def __init__(self, username: str, moderator_id: Optional[int] = None): self.username = username + self.moderator_id = moderator_id def _get_user(self) -> User: user = User.query.filter_by(username=self.username).first() @@ -25,15 +40,11 @@ def _get_user(self) -> User: raise UserNotFoundException() return user - def _update_admin_rights(self, user: User, is_admin: bool) -> None: - user.admin = is_admin - if is_admin: - self._activate_user(user) - @staticmethod - def _activate_user(user: User) -> None: - user.is_active = True - user.confirmation_token = None + def _update_active_status(user: User, active_status: bool) -> None: + user.is_active = active_status + if active_status: + user.confirmation_token = None @staticmethod def _reset_user_password(user: User) -> str: @@ -59,22 +70,52 @@ def _update_user_email( def update( self, - is_admin: Optional[bool] = None, - activate: bool = False, + role: Optional[str] = None, + activate: Optional[bool] = None, reset_password: bool = False, new_email: Optional[str] = None, with_confirmation: bool = True, - ) -> Tuple[User, bool, Optional[str]]: + suspended: Optional[bool] = None, + report_id: Optional[int] = None, + reason: Optional[str] = None, + raise_error_on_owner: bool = False, + ) -> Tuple[User, bool, Optional[str], Optional[ReportAction]]: user_updated = False new_password = None + report_action = None user = self._get_user() - if is_admin is not None: - self._update_admin_rights(user, is_admin) + if user.role == UserRole.OWNER.value and raise_error_on_owner: + raise OwnerException("user with owner rights can not be modified") + + if suspended is not None: + if self.moderator_id is None: + raise MissingAdminIdException() + if report_id is None: + raise MissingReportIdException() + + if role is not None: + if role not in UserRole.db_choices(): + raise InvalidUserRole() + user.role = UserRole[role.upper()].value + if user.role >= UserRole.MODERATOR.value: + activate = True + if user.role < UserRole.MODERATOR.value: + db.session.query(Notification).filter( + Notification.to_user_id == user.id, + Notification.event_type.in_(MODERATOR_NOTIFICATION_TYPES), + ).delete() + if user.role < UserRole.ADMIN.value: + db.session.query(Notification).filter( + Notification.to_user_id == user.id, + Notification.event_type.in_( + ADMINISTRATOR_NOTIFICATION_TYPES + ), + ).delete() user_updated = True - if activate: - self._activate_user(user) + if activate is not None: + self._update_active_status(user, activate) user_updated = True if reset_password: @@ -85,8 +126,33 @@ def update( self._update_user_email(user, new_email, with_confirmation) user_updated = True + now = datetime.utcnow() + if suspended is True: + if user.suspended_at: + raise UserAlreadySuspendedException() + user.suspended_at = now + user.role = UserRole.USER.value + user_updated = True + if suspended is False: + if user.suspended_at is None: + raise UserAlreadyReactivatedException() + user.suspended_at = None + user_updated = True + if self.moderator_id and report_id and suspended is not None: + report_action = ReportAction( + moderator_id=self.moderator_id, + action_type=( + "user_suspension" if suspended else "user_unsuspension" + ), + created_at=now, + report_id=report_id, + reason=reason, + user_id=user.id, + ) + db.session.add(report_action) + db.session.commit() - return user, user_updated, new_password + return user, user_updated, new_password, report_action def create_user( self, @@ -123,8 +189,8 @@ def create_user( return None, None new_user = User(username=self.username, email=email, password=password) - new_user.timezone = 'Europe/Paris' - new_user.date_format = 'MM/dd/yyyy' + new_user.timezone = USER_TIMEZONE + new_user.date_format = USER_DATE_FORMAT new_user.confirmation_token = secrets.token_urlsafe(30) db.session.add(new_user) db.session.flush() diff --git a/fittrackee/users/utils/controls.py b/fittrackee/users/utils/controls.py index ffd0ad83e..15969cbdd 100644 --- a/fittrackee/users/utils/controls.py +++ b/fittrackee/users/utils/controls.py @@ -27,7 +27,7 @@ def check_username(username: str) -> str: If not, it returns error messages """ ret = '' - if not (2 < len(username) < 31): + if not 2 < len(username) < 31: ret += 'username: 3 to 30 characters required\n' if not re.match(r'^[a-zA-Z0-9_]+$', username): ret += ( diff --git a/fittrackee/users/utils/language.py b/fittrackee/users/utils/language.py index a9b42a00a..f9225a190 100644 --- a/fittrackee/users/utils/language.py +++ b/fittrackee/users/utils/language.py @@ -2,9 +2,11 @@ from flask import current_app +from fittrackee.users.constants import USER_LANGUAGE + 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' + language = USER_LANGUAGE return language diff --git a/fittrackee/utils.py b/fittrackee/utils.py index 20c31f579..fdf3bdd72 100644 --- a/fittrackee/utils.py +++ b/fittrackee/utils.py @@ -1,13 +1,46 @@ import time -from datetime import timedelta -from typing import Optional +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Optional from uuid import UUID import humanize +import nh3 +import pytz import shortuuid +from babel.dates import format_datetime from sqlalchemy.sql import text from fittrackee import db +from fittrackee.languages import LANGUAGES_DATE_STRING +from fittrackee.users.constants import USER_DATE_FORMAT, USER_TIMEZONE +from fittrackee.users.utils.language import get_language + +if TYPE_CHECKING: + from fittrackee.users.models import User + + +def get_date_string_for_user(date_to_format: datetime, user: "User") -> str: + """ + Note: date_to_format is a naive datetime + """ + user_language = get_language(user.language) + user_timezone = user.timezone if user.timezone else USER_TIMEZONE + user_date_format = ( + user.date_format if user.date_format else USER_DATE_FORMAT + ) + + date_format = ( + LANGUAGES_DATE_STRING[user_language] + if user_date_format == "date_string" + else user_date_format + ) + return format_datetime( + pytz.utc.localize(date_to_format).astimezone( + pytz.timezone(user_timezone) + ), + format=f"{date_format} - HH:mm:ss", + locale=user_language, + ) def get_readable_duration(duration: int, locale: Optional[str] = None) -> str: @@ -46,3 +79,12 @@ def decode_short_id(short_id: str) -> UUID: Return UUID from a short id string """ return shortuuid.decode(short_id) + + +def clean_input(text: str) -> str: + # HTML sanitization + return nh3.clean( + text, + tags={"a", "br", "p", "span"}, + attributes={"a": {"href", "target"}}, + ) diff --git a/fittrackee/visibility_levels.py b/fittrackee/visibility_levels.py new file mode 100644 index 000000000..4a91fe469 --- /dev/null +++ b/fittrackee/visibility_levels.py @@ -0,0 +1,78 @@ +from enum import Enum +from typing import TYPE_CHECKING, Optional, Union + +if TYPE_CHECKING: + from fittrackee.comments.models import Comment + from fittrackee.users.models import User + from fittrackee.workouts.models import Workout + + +class VisibilityLevel(str, Enum): # to make enum serializable + PUBLIC = 'public' + FOLLOWERS = 'followers_only' # only followers + PRIVATE = 'private' # in case of comments, for mentioned users only + + +def get_map_visibility( + map_visibility: VisibilityLevel, workout_visibility: VisibilityLevel +) -> VisibilityLevel: + # workout visibility overrides map visibility, when stricter + if workout_visibility == VisibilityLevel.PRIVATE or ( + workout_visibility == VisibilityLevel.FOLLOWERS + and map_visibility == VisibilityLevel.PUBLIC + ): + return workout_visibility + return map_visibility + + +def can_view( + target_object: Union['Workout', 'Comment'], + visibility: str, + user: Optional['User'] = None, + for_report: bool = False, +) -> bool: + owner = target_object.user + if user and ( + user.id == owner.id or (user.has_moderator_rights and for_report) + ): + return True + + if ( + target_object.__class__.__name__ == "Workout" + and target_object.suspended_at + ): + if not user: + return False + + from fittrackee.comments.models import Comment + + user_comments_count = Comment.query.filter_by( + workout_id=target_object.id, user_id=user.id + ).count() + if user_comments_count == 0: + return False + + if target_object.__getattribute__( + visibility + ) == VisibilityLevel.PUBLIC and ( + not user or not user.is_blocked_by(owner) + ): + return True + + if not user: + return False + + if ( + target_object.__class__.__name__ == "Comment" + and user in target_object.mentioned_users.all() + ): + return True + + if ( + target_object.__getattribute__(visibility) == VisibilityLevel.FOLLOWERS + and user in owner.followers.all() + ): + return True + + # visibility level is private + return False diff --git a/fittrackee/workouts/constants.py b/fittrackee/workouts/constants.py new file mode 100644 index 000000000..0faa32746 --- /dev/null +++ b/fittrackee/workouts/constants.py @@ -0,0 +1 @@ +WORKOUT_DATE_FORMAT = '%Y-%m-%d %H:%M' diff --git a/fittrackee/workouts/decorators.py b/fittrackee/workouts/decorators.py new file mode 100644 index 000000000..a6adfc322 --- /dev/null +++ b/fittrackee/workouts/decorators.py @@ -0,0 +1,43 @@ +from functools import wraps +from typing import TYPE_CHECKING, Any, Callable, Optional + +from fittrackee.responses import ( + DataNotFoundErrorResponse, + ForbiddenErrorResponse, + NotFoundErrorResponse, +) + +from .exceptions import WorkoutForbiddenException +from .utils.workouts import get_workout + +if TYPE_CHECKING: + from fittrackee.users.models import User + + +def check_workout(only_owner: bool = True, as_data: bool = True) -> Callable: + def decorator_check_workout(f: Callable) -> Callable: + @wraps(f) + def wrapper_check_workout(*args: Any, **kwargs: Any) -> Callable: + auth_user: Optional['User'] = args[0] + workout_short_id = kwargs["workout_short_id"] + try: + workout = get_workout(workout_short_id, auth_user) + except WorkoutForbiddenException: + return ( + DataNotFoundErrorResponse('workouts') + if as_data + else NotFoundErrorResponse( + f"workout not found (id: {workout_short_id})" + ) + ) + + if only_owner and ( + not auth_user or auth_user.id != workout.user.id + ): + return ForbiddenErrorResponse() + + return f(auth_user, workout, **kwargs) + + return wrapper_check_workout + + return decorator_check_workout diff --git a/fittrackee/workouts/exceptions.py b/fittrackee/workouts/exceptions.py index 97db35b88..78bfab3d5 100644 --- a/fittrackee/workouts/exceptions.py +++ b/fittrackee/workouts/exceptions.py @@ -1,10 +1,18 @@ from fittrackee.exceptions import GenericException -class InvalidGPXException(GenericException): ... +class InvalidGPXException(GenericException): + pass -class WorkoutException(GenericException): ... +class WorkoutException(GenericException): + pass -class WorkoutGPXException(GenericException): ... +class WorkoutGPXException(GenericException): + pass + + +class WorkoutForbiddenException(GenericException): + def __init__(self) -> None: + super().__init__('error', 'you do not have permissions') diff --git a/fittrackee/workouts/models.py b/fittrackee/workouts/models.py index b630a62dd..a1e0bb17f 100644 --- a/fittrackee/workouts/models.py +++ b/fittrackee/workouts/models.py @@ -7,26 +7,60 @@ from sqlalchemy.dialects import postgresql 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, object_session from sqlalchemy.sql.expression import nulls_last from sqlalchemy.types import JSON, Enum -from fittrackee import appLog, db +from fittrackee import BaseModel, appLog, db +from fittrackee.comments.models import Comment from fittrackee.equipments.models import WorkoutEquipment from fittrackee.files import get_absolute_file_path from fittrackee.utils import encode_uuid +from fittrackee.visibility_levels import ( + VisibilityLevel, + can_view, + get_map_visibility, +) +from .exceptions import WorkoutForbiddenException from .utils.convert import convert_in_duration, convert_value_to_integer if TYPE_CHECKING: from sqlalchemy.orm.attributes import AttributeEvent from fittrackee.equipments.models import Equipment + from fittrackee.users.models import User + + from ..reports.models import ReportAction + + +EMPTY_MINIMAL_WORKOUT_VALUES: Dict = { + 'title': '', + 'moving': None, + 'distance': None, + 'duration': None, + 'min_alt': None, + 'max_alt': None, + 'descent': None, + 'ascent': None, + 'map_visibility': None, +} +EMPTY_WORKOUT_VALUES: Dict = { + 'creation_date': None, + 'modification_date': None, + 'pauses': None, + 'equipments': [], + 'records': [], + 'segments': [], + 'weather_start': None, + 'weather_end': None, + 'notes': '', + 'likes_count': 0, + 'liked': False, +} -BaseModel: DeclarativeMeta = db.Model record_types = [ 'AS', # 'Best Average Speed' 'FD', # 'Farthest Distance' @@ -230,6 +264,17 @@ class Workout(BaseModel): description = db.Column( db.String(DESCRIPTION_MAX_CHARACTERS), nullable=True ) + workout_visibility = db.Column( + Enum(VisibilityLevel, name='visibility_levels'), + server_default='PRIVATE', + nullable=False, + ) + map_visibility = db.Column( + Enum(VisibilityLevel, name='visibility_levels'), + server_default='PRIVATE', + nullable=False, + ) + suspended_at = db.Column(db.DateTime, nullable=True) segments = db.relationship( 'WorkoutSegment', @@ -242,10 +287,24 @@ class Workout(BaseModel): lazy=True, cascade='all, delete', backref=db.backref('workout', lazy='joined', single_parent=True), + order_by='Record.record_type.asc()', ) equipments = db.relationship( 'Equipment', secondary=WorkoutEquipment, back_populates='workouts' ) + comments = db.relationship( + Comment, + lazy=True, + backref=db.backref('workout', lazy='select', single_parent=True), + ) + likes = db.relationship( + "User", + secondary="workout_likes", + primaryjoin="Workout.id == WorkoutLike.workout_id", + secondaryjoin="WorkoutLike.user_id == User.id", + lazy="dynamic", + viewonly=True, + ) def __str__(self) -> str: return f'' @@ -268,169 +327,323 @@ def __init__( def short_id(self) -> str: return encode_uuid(self.uuid) - def get_workout_data(self) -> Dict: - return { + @property + def calculated_map_visibility(self) -> VisibilityLevel: + return get_map_visibility(self.map_visibility, self.workout_visibility) + + def liked_by(self, user: 'User') -> bool: + return user in self.likes.all() + + @property + def suspension_action(self) -> Optional['ReportAction']: + if self.suspended_at is None: + return None + + from fittrackee.reports.models import ReportAction + + return ( + ReportAction.query.filter( + ReportAction.workout_id == self.id, + ReportAction.action_type == "workout_suspension", + ) + .order_by(ReportAction.created_at.desc()) + .first() + ) + + def get_workout_data( + self, + user: Optional['User'], + *, + can_see_map_data: Optional[bool] = None, + for_report: bool = False, + additional_data: bool = False, + light: bool = True, + ) -> Dict: + """ + Used by Workout serializer and data export + + - can_see_map_data: if user can see map related data + - for_report: privacy levels are overridden on report + - additional_data is False when: + - workout is not suspended + - user is workout owner + or workout is displayed in report and current user has moderation + rights + - light: when true, only workouts data needed for workout lists + and timeline + """ + for_report = ( + for_report and user is not None and user.has_moderator_rights + ) + if can_see_map_data is None: + can_see_map_data = can_view( + self, + "calculated_map_visibility", + user=user, + for_report=for_report, + ) + + workout_data = { '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': None if self.duration is None else str(self.duration), - 'pauses': str(self.pauses) if self.pauses else None, + 'workout_visibility': self.workout_visibility.value, + } + + if not additional_data: + return { + **workout_data, + **EMPTY_MINIMAL_WORKOUT_VALUES, + **EMPTY_WORKOUT_VALUES, + } + + workout_data = { + **workout_data, + **EMPTY_WORKOUT_VALUES, + 'title': self.title, 'moving': None if self.moving is None else str(self.moving), 'distance': ( None if self.distance is None else float(self.distance) ), + 'duration': ( + None if self.duration is None else str(self.duration) + ), + 'ave_speed': ( + None if self.ave_speed is None else float(self.ave_speed) + ), + 'max_speed': ( + None if self.max_speed is None else float(self.max_speed) + ), 'min_alt': None if self.min_alt is None else float(self.min_alt), 'max_alt': None if self.max_alt is None else float(self.max_alt), 'descent': None if self.descent is None else float(self.descent), 'ascent': None if self.ascent is None else float(self.ascent), - 'max_speed': ( - None if self.max_speed is None else float(self.max_speed) - ), - 'ave_speed': ( - None if self.ave_speed is None else float(self.ave_speed) + 'map_visibility': ( + self.calculated_map_visibility.value + if can_see_map_data + else VisibilityLevel.PRIVATE ), + } + + if light: + return workout_data + + return { + **workout_data, + 'creation_date': self.creation_date, + 'modification_date': self.modification_date, + 'pauses': str(self.pauses) if self.pauses else None, 'equipments': [ equipment.serialize() for equipment in self.equipments - ], - 'records': [record.serialize() for record in self.records], - 'segments': [segment.serialize() for segment in self.segments], + ] + if user and user.id == self.user_id + else [], + 'records': ( + [] + if for_report + else [record.serialize() for record in self.records] + ), + 'segments': ( + [segment.serialize() for segment in self.segments] + if can_see_map_data + else [] + ), 'weather_start': self.weather_start, 'weather_end': self.weather_end, - 'notes': self.notes, + 'notes': ( + self.notes if user and user.id == self.user_id else None + ), 'description': self.description, + 'likes_count': self.likes.count(), + 'liked': self.liked_by(user) if user else False, } - 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 - distance_from = params.get('distance_from') if params else None - distance_to = params.get('distance_to') if params else None - duration_from = params.get('duration_from') if params else None - duration_to = params.get('duration_to') if params else None - ave_speed_from = params.get('ave_speed_from') if params else None - ave_speed_to = params.get('ave_speed_to') if params else None - max_speed_from = params.get('max_speed_from') if params else None - max_speed_to = params.get('max_speed_to') if params else None - sport_id = params.get('sport_id') if params else None - previous_workout = ( - Workout.query.filter( - Workout.id != self.id, - Workout.user_id == self.user_id, - Workout.sport_id == sport_id if sport_id else True, - Workout.workout_date <= self.workout_date, - ( - Workout.workout_date - >= datetime.datetime.strptime(date_from, '%Y-%m-%d') - if date_from - else True - ), - ( - Workout.workout_date - <= datetime.datetime.strptime(date_to, '%Y-%m-%d') - if date_to - else True - ), - ( - Workout.distance >= float(distance_from) - if distance_from - else True - ), - ( - Workout.distance <= float(distance_to) - if distance_to - else True - ), - ( - Workout.duration >= convert_in_duration(duration_from) - if duration_from - else True - ), - ( - Workout.duration <= convert_in_duration(duration_to) - if duration_to - else True - ), - ( - Workout.ave_speed >= float(ave_speed_from) - if ave_speed_from - else True - ), - ( - Workout.ave_speed <= float(ave_speed_to) - if ave_speed_to - else True - ), - ( - Workout.max_speed >= float(max_speed_from) - if max_speed_from - else True - ), - ( - Workout.max_speed <= float(max_speed_to) - if max_speed_to - else True - ), - ) - .order_by(Workout.workout_date.desc()) - .first() + def serialize( + self, + *, + user: Optional['User'] = None, + params: Optional[Dict] = None, + for_report: bool = False, + light: bool = True, # for workouts list and timeline + ) -> Dict: + for_report = ( + for_report and user is not None and user.has_moderator_rights ) - next_workout = ( - Workout.query.filter( - Workout.id != self.id, - Workout.user_id == self.user_id, - Workout.sport_id == sport_id if sport_id else True, - Workout.workout_date >= self.workout_date, - ( - Workout.workout_date - >= datetime.datetime.strptime(date_from, '%Y-%m-%d') - if date_from - else True - ), - ( - Workout.workout_date - <= datetime.datetime.strptime(date_to, '%Y-%m-%d') - if date_to - else True - ), - ( - Workout.distance >= float(distance_from) - if distance_from - else True - ), - ( - Workout.distance <= float(distance_to) - if distance_to - else True - ), - ( - Workout.duration >= convert_in_duration(duration_from) - if duration_from - else True - ), - ( - Workout.duration <= convert_in_duration(duration_to) - if duration_to - else True - ), - ( - Workout.ave_speed >= float(ave_speed_from) - if ave_speed_from - else True - ), - ( - Workout.ave_speed <= float(ave_speed_to) - if ave_speed_to - else True - ), - ) - .order_by(Workout.workout_date.asc()) - .first() + if not can_view( + self, "workout_visibility", user=user, for_report=for_report + ): + raise WorkoutForbiddenException() + can_see_map_data = can_view( + self, "calculated_map_visibility", user=user, for_report=for_report + ) + is_owner = user is not None and user.id == self.user_id + is_workout_suspended = self.suspended_at is not None + additional_data = not is_workout_suspended or for_report or is_owner + + workout = self.get_workout_data( + user, + can_see_map_data=can_see_map_data, + for_report=for_report, + additional_data=additional_data, + light=light, + ) + + workout["map"] = ( + self.map_id + if self.map and can_see_map_data and additional_data + else None ) + workout["with_gpx"] = ( + self.gpx is not None and can_see_map_data and additional_data + ) + workout["suspended"] = is_workout_suspended + workout["user"] = self.user.serialize() + + if is_owner or for_report: + workout["suspended_at"] = self.suspended_at + if self.suspension_action: + workout["suspension"] = self.suspension_action.serialize( + current_user=user, # type: ignore + full=False, + ) + + if light: + workout["next_workout"] = None + workout["previous_workout"] = None + workout["bounds"] = [] + return workout + + if is_owner: + date_from = params.get('from') if params else None + date_to = params.get('to') if params else None + distance_from = params.get('distance_from') if params else None + distance_to = params.get('distance_to') if params else None + duration_from = params.get('duration_from') if params else None + duration_to = params.get('duration_to') if params else None + ave_speed_from = params.get('ave_speed_from') if params else None + ave_speed_to = params.get('ave_speed_to') if params else None + max_speed_from = params.get('max_speed_from') if params else None + max_speed_to = params.get('max_speed_to') if params else None + sport_id = params.get('sport_id') if params else None + previous_workout = ( + Workout.query.filter( + Workout.id != self.id, + Workout.user_id == self.user_id, + Workout.sport_id == sport_id if sport_id else True, + Workout.workout_date <= self.workout_date, + ( + Workout.workout_date + >= datetime.datetime.strptime(date_from, '%Y-%m-%d') + if date_from + else True + ), + ( + Workout.workout_date + <= datetime.datetime.strptime(date_to, '%Y-%m-%d') + if date_to + else True + ), + ( + Workout.distance >= float(distance_from) + if distance_from + else True + ), + ( + Workout.distance <= float(distance_to) + if distance_to + else True + ), + ( + Workout.duration >= convert_in_duration(duration_from) + if duration_from + else True + ), + ( + Workout.duration <= convert_in_duration(duration_to) + if duration_to + else True + ), + ( + Workout.ave_speed >= float(ave_speed_from) + if ave_speed_from + else True + ), + ( + Workout.ave_speed <= float(ave_speed_to) + if ave_speed_to + else True + ), + ( + Workout.max_speed >= float(max_speed_from) + if max_speed_from + else True + ), + ( + Workout.max_speed <= float(max_speed_to) + if max_speed_to + else True + ), + ) + .order_by(Workout.workout_date.desc()) + .first() + ) + next_workout = ( + Workout.query.filter( + Workout.id != self.id, + Workout.user_id == self.user_id, + Workout.sport_id == sport_id if sport_id else True, + Workout.workout_date >= self.workout_date, + ( + Workout.workout_date + >= datetime.datetime.strptime(date_from, '%Y-%m-%d') + if date_from + else True + ), + ( + Workout.workout_date + <= datetime.datetime.strptime(date_to, '%Y-%m-%d') + if date_to + else True + ), + ( + Workout.distance >= float(distance_from) + if distance_from + else True + ), + ( + Workout.distance <= float(distance_to) + if distance_to + else True + ), + ( + Workout.duration >= convert_in_duration(duration_from) + if duration_from + else True + ), + ( + Workout.duration <= convert_in_duration(duration_to) + if duration_to + else True + ), + ( + Workout.ave_speed >= float(ave_speed_from) + if ave_speed_from + else True + ), + ( + Workout.ave_speed <= float(ave_speed_to) + if ave_speed_to + else True + ), + ) + .order_by(Workout.workout_date.asc()) + .first() + ) + + else: + next_workout = None + previous_workout = None - workout = self.get_workout_data() workout["next_workout"] = ( next_workout.short_id if next_workout else None ) @@ -438,11 +651,10 @@ def serialize(self, params: Optional[Dict] = None) -> Dict: previous_workout.short_id if previous_workout else None ) workout["bounds"] = ( - [float(bound) for bound in self.bounds] if self.bounds else [] + [float(bound) for bound in self.bounds] + if self.bounds and can_see_map_data and additional_data + 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 @@ -604,6 +816,7 @@ class Record(BaseModel): db.UniqueConstraint( 'user_id', 'sport_id', 'record_type', name='user_sports_records' ), + db.Index('workout_records', 'workout_id', 'record_type'), ) id = db.Column(db.Integer, primary_key=True, autoincrement=True) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) @@ -687,3 +900,80 @@ def receive_after_flush(session: Session, context: Any) -> None: ) new_record.value = record_data['record_value'] # type: ignore session.add(new_record) + + +class WorkoutLike(BaseModel): + __tablename__ = 'workout_likes' + __table_args__ = ( + db.UniqueConstraint( + 'user_id', 'workout_id', name='user_id_workout_id_unique' + ), + ) + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + created_at = db.Column(db.DateTime, nullable=False) + user_id = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='CASCADE'), + nullable=False, + ) + workout_id = db.Column( + db.Integer, + db.ForeignKey('workouts.id', ondelete="CASCADE"), + nullable=False, + ) + + user = db.relationship("User", lazy=True) + workout = db.relationship("Workout", lazy=True) + + def __init__( + self, + user_id: int, + workout_id: int, + created_at: Optional[datetime.datetime] = None, + ) -> None: + self.user_id = user_id + self.workout_id = workout_id + self.created_at = ( + datetime.datetime.utcnow() if created_at is None else created_at + ) + + +@listens_for(WorkoutLike, 'after_insert') +def on_workout_like_insert( + mapper: Mapper, connection: Connection, new_workout_like: WorkoutLike +) -> None: + @listens_for(db.Session, 'after_flush', once=True) + def receive_after_flush(session: Session, context: Connection) -> None: + from fittrackee.users.models import Notification + + workout = Workout.query.filter_by( + id=new_workout_like.workout_id + ).first() + if new_workout_like.user_id != workout.user_id: + notification = Notification( + from_user_id=new_workout_like.user_id, + to_user_id=workout.user_id, + created_at=new_workout_like.created_at, + event_type='workout_like', + event_object_id=workout.id, + ) + session.add(notification) + + +@listens_for(WorkoutLike, 'after_delete') +def on_workout_like_delete( + mapper: Mapper, connection: Connection, old_workout_like: WorkoutLike +) -> None: + @listens_for(db.Session, 'after_flush', once=True) + def receive_after_flush(session: Session, context: Any) -> None: + from fittrackee.users.models import Notification + + workout = Workout.query.filter_by( + id=old_workout_like.workout_id + ).first() + Notification.query.filter_by( + from_user_id=old_workout_like.user_id, + to_user_id=workout.user_id, + event_type='workout_like', + event_object_id=workout.id, + ).delete() diff --git a/fittrackee/workouts/records.py b/fittrackee/workouts/records.py index 81d92ebd2..7ff01810d 100644 --- a/fittrackee/workouts/records.py +++ b/fittrackee/workouts/records.py @@ -115,6 +115,8 @@ def get_records(auth_user: User) -> Dict: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` """ records = ( diff --git a/fittrackee/workouts/sports.py b/fittrackee/workouts/sports.py index be6c06c19..9973af410 100644 --- a/fittrackee/workouts/sports.py +++ b/fittrackee/workouts/sports.py @@ -12,6 +12,7 @@ handle_error_and_return_response, ) from fittrackee.users.models import User, UserSportPreference +from fittrackee.users.roles import UserRole from .models import Sport @@ -19,10 +20,16 @@ @sports_blueprint.route('/sports', methods=['GET']) -@require_auth(scopes=['workouts:read']) +@require_auth( + scopes=['workouts:read'], + optional_auth_user=True, + allow_suspended_user=True, +) def get_sports(auth_user: User) -> Dict: """ - Get all sports + Get all sports. + + Suspended user can access this endpoint. **Scope**: ``workouts:read`` @@ -169,7 +176,7 @@ def get_sports(auth_user: User) -> Dict: :query boolean check_workouts: check if sport has workouts - :reqheader Authorization: OAuth 2.0 Bearer Token + :reqheader Authorization: OAuth 2.0 Bearer Token if user is authenticated :statuscode 200: ``success`` :statuscode 401: @@ -181,18 +188,24 @@ def get_sports(auth_user: User) -> Dict: params = request.args.copy() check_workouts = params.get('check_workouts', 'false').lower() == 'true' - sport_preferences = { - p.sport_id: p - for p in UserSportPreference.query.filter_by( - user_id=auth_user.id - ).all() - } + sport_preferences = ( + { + p.sport_id: p + for p in UserSportPreference.query.filter_by( + user_id=auth_user.id + ).all() + } + if auth_user + else {} + ) sports = Sport.query.order_by(Sport.id).all() sports_data = [] for sport in sports: sports_data.append( sport.serialize( - check_workouts=auth_user.admin and check_workouts, + check_workouts=( + auth_user and auth_user.has_admin_rights and check_workouts + ), sport_preferences=( sport_preferences[sport.id].serialize() if sport.id in sport_preferences @@ -269,6 +282,8 @@ def get_sport(auth_user: User, sport_id: int) -> Union[Dict, HttpResponse]: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``sport not found`` """ @@ -295,7 +310,7 @@ def get_sport(auth_user: User, sport_id: int) -> Union[Dict, HttpResponse]: @sports_blueprint.route('/sports/', methods=['PATCH']) -@require_auth(scopes=['workouts:write'], as_admin=True) +@require_auth(scopes=['workouts:write'], role=UserRole.ADMIN) def update_sport(auth_user: User, sport_id: int) -> Union[Dict, HttpResponse]: """ Update a sport. @@ -304,6 +319,8 @@ def update_sport(auth_user: User, sport_id: int) -> Union[Dict, HttpResponse]: **Scope**: ``workouts:write`` + **Minimum role**: Administrator + **Example request**: .. sourcecode:: http @@ -358,12 +375,15 @@ def update_sport(auth_user: User, sport_id: int) -> Union[Dict, HttpResponse]: :reqheader Authorization: OAuth 2.0 Bearer Token :statuscode 200: sport updated - :statuscode 400: ``invalid payload`` + :statuscode 400: + - ``invalid payload`` :statuscode 401: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``sport not found`` :statuscode 500: ``error, please try again or contact the administrator`` diff --git a/fittrackee/workouts/stats.py b/fittrackee/workouts/stats.py index 4ea3eecfd..79c9c2239 100644 --- a/fittrackee/workouts/stats.py +++ b/fittrackee/workouts/stats.py @@ -15,6 +15,7 @@ handle_error_and_return_response, ) from fittrackee.users.models import User +from fittrackee.users.roles import UserRole from .models import Sport, Workout from .utils.uploads import get_upload_dir_size @@ -190,7 +191,10 @@ def get_workouts_by_time( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 404: ``user does not exist`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` + :statuscode 404: + - ``user does not exist`` """ try: @@ -436,6 +440,8 @@ def get_workouts_by_sport( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 404: - ``user does not exist`` - ``sport does not exist`` @@ -522,13 +528,15 @@ def get_workouts_by_sport( @stats_blueprint.route('/stats/all', methods=['GET']) -@require_auth(scopes=['workouts:read'], as_admin=True) +@require_auth(scopes=['workouts:read'], role=UserRole.MODERATOR) def get_application_stats(auth_user: User) -> Dict: """ Get all application statistics. **Scope**: ``workouts:read`` + **Minimum role**: Moderator + **Example requests**: .. sourcecode:: http @@ -560,11 +568,13 @@ def get_application_stats(auth_user: User) -> Dict: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` """ total_workouts = Workout.query.filter().count() - nb_users = User.query.filter().count() + nb_users = User.query.filter(User.is_active == True).count() # noqa nb_sports = ( db.session.query(func.count(Workout.sport_id)) .group_by(Workout.sport_id) diff --git a/fittrackee/workouts/timeline.py b/fittrackee/workouts/timeline.py new file mode 100644 index 000000000..ed5a2a249 --- /dev/null +++ b/fittrackee/workouts/timeline.py @@ -0,0 +1,222 @@ +from typing import Dict, Union + +from flask import Blueprint, request +from sqlalchemy import and_, or_ + +from fittrackee.oauth2.server import require_auth +from fittrackee.responses import HttpResponse, handle_error_and_return_response +from fittrackee.users.models import User +from fittrackee.visibility_levels import VisibilityLevel + +from .models import Workout + +timeline_blueprint = Blueprint('timeline', __name__) + +DEFAULT_WORKOUTS_PER_PAGE = 5 + + +@timeline_blueprint.route('/timeline', methods=['GET']) +@require_auth(scopes=['workouts:read']) +def get_user_timeline(auth_user: User) -> Union[Dict, HttpResponse]: + """ + Get workouts visible to authenticated user. + + **Scope**: ``workouts:read`` + + **Example requests**: + + - without parameters: + + .. sourcecode:: http + + GET /api/timeline HTTP/1.1 + + - with some query parameters: + + .. sourcecode:: http + + GET /api/timeline?page=2 HTTP/1.1 + + **Example responses**: + + - returning at least one workout: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "workouts": [ + { + "ascent": null, + "ave_speed": 10.0, + "bounds": [], + "creation_date": "Sun, 14 Jul 2019 13:51:01 GMT", + "descent": null, + "description": null, + "distance": 10.0, + "duration": "0:17:04", + "equipments": [], + "id": "kjxavSTUrJvoAh2wvCeGEF", + "map": null, + "max_alt": null, + "max_speed": 10.0, + "min_alt": null, + "modification_date": null, + "moving": "0:17:04", + "next_workout": 3, + "notes": null, + "pauses": null, + "previous_workout": null, + "records": [ + { + "id": 4, + "record_type": "MS", + "sport_id": 1, + "user": "admin", + "value": 10.0, + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_id": "kjxavSTUrJvoAh2wvCeGEF" + }, + { + "id": 13, + "record_type": "HA", + "sport_id": 1, + "user": "Sam", + "value": 43.97, + "workout_date": "Sun, 07 Jul 2019 08:00:00 GMT", + "workout_id": "hvYBqYBRa7wwXpaStWR4V2" + }, + { + "id": 3, + "record_type": "LD", + "sport_id": 1, + "user": "admin", + "value": "0:17:04", + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_id": "kjxavSTUrJvoAh2wvCeGEF" + }, + { + "id": 2, + "record_type": "FD", + "sport_id": 1, + "user": "admin", + "value": 10.0, + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_id": "kjxavSTUrJvoAh2wvCeGEF" + }, + { + "id": 1, + "record_type": "AS", + "sport_id": 1, + "user": "admin", + "value": 10.0, + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_id": "kjxavSTUrJvoAh2wvCeGEF" + } + ], + "segments": [], + "sport_id": 1, + "title": null, + "user": "admin", + "weather_end": null, + "weather_start": null, + "with_gpx": false, + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + } + ] + }, + "status": "success" + } + + - returning no workouts + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "workouts": [] + }, + "status": "success" + } + + :query integer page: page if using pagination (default: 1) + + :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 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` + :statuscode 500: ``error, please try again or contact the administrator`` + + """ + try: + params = request.args.copy() + page = int(params.get('page', 1)) + following_ids = auth_user.get_following_user_ids() + blocked_users = auth_user.get_blocked_user_ids() + blocked_by_users = auth_user.get_blocked_by_user_ids() + workouts_pagination = ( + Workout.query.join( + User, + Workout.user_id == User.id, + ) + .filter( + or_( + # get all authenticated user workouts + Workout.user_id == auth_user.id, + # gel followed users workouts, that are not suspended + # and user is not blocked + and_( + Workout.suspended_at == None, # noqa + and_( + Workout.user_id.in_(following_ids), + Workout.user_id.not_in( + blocked_users + blocked_by_users + ), + Workout.workout_visibility.in_( + [ + VisibilityLevel.FOLLOWERS, + VisibilityLevel.PUBLIC, + ] + ), + ), + ), + ), + User.suspended_at == None, # noqa + ) + .order_by( + Workout.workout_date.desc(), + ) + .paginate( + page=page, per_page=DEFAULT_WORKOUTS_PER_PAGE, error_out=False + ) + ) + workouts = workouts_pagination.items + return { + 'status': 'success', + 'data': { + 'workouts': [ + workout.serialize(user=auth_user) for workout in workouts + ] + }, + 'pagination': { + 'has_next': workouts_pagination.has_next, + 'has_prev': workouts_pagination.has_prev, + 'page': workouts_pagination.page, + 'pages': workouts_pagination.pages, + 'total': workouts_pagination.total, + }, + } + except Exception as e: + return handle_error_and_return_response(e) diff --git a/fittrackee/workouts/utils/visibility.py b/fittrackee/workouts/utils/visibility.py deleted file mode 100644 index f4a2ff466..000000000 --- a/fittrackee/workouts/utils/visibility.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Optional - -from fittrackee.responses import ForbiddenErrorResponse, HttpResponse - - -def can_view_workout( - auth_user_id: int, workout_user_id: int -) -> Optional[HttpResponse]: - """ - Return error response if user has no right to view workout - """ - if auth_user_id != workout_user_id: - return ForbiddenErrorResponse() - return None diff --git a/fittrackee/workouts/utils/workouts.py b/fittrackee/workouts/utils/workouts.py index 33a8ad214..1c7444e00 100644 --- a/fittrackee/workouts/utils/workouts.py +++ b/fittrackee/workouts/utils/workouts.py @@ -14,8 +14,19 @@ from fittrackee import appLog, db from fittrackee.files import get_absolute_file_path from fittrackee.users.models import User, UserSportPreference +from fittrackee.utils import decode_short_id +from fittrackee.visibility_levels import ( + VisibilityLevel, + can_view, + get_map_visibility, +) -from ..exceptions import InvalidGPXException, WorkoutException +from ..constants import WORKOUT_DATE_FORMAT +from ..exceptions import ( + InvalidGPXException, + WorkoutException, + WorkoutForbiddenException, +) from ..models import ( DESCRIPTION_MAX_CHARACTERS, NOTES_MAX_CHARACTERS, @@ -50,7 +61,7 @@ def get_workout_datetime( if workout_date.tzinfo is None: naive_workout_date = workout_date if user_timezone and with_timezone: - pytz.utc.localize(naive_workout_date) + # pytz.utc.localize(naive_workout_date) workout_date_with_user_tz = pytz.utc.localize( naive_workout_date ).astimezone(pytz.timezone(user_timezone)) @@ -120,7 +131,7 @@ def create_workout( workout_date=( gpx_data['start'] if gpx_data else workout_data['workout_date'] ), - date_str_format=None if gpx_data else '%Y-%m-%d %H:%M', + date_str_format=None if gpx_data else WORKOUT_DATE_FORMAT, user_timezone=user.timezone, with_timezone=True, ) @@ -162,6 +173,16 @@ def create_workout( description[:DESCRIPTION_MAX_CHARACTERS] if description else None ) + new_workout.workout_visibility = VisibilityLevel( + workout_data.get('workout_visibility', user.workouts_visibility.value) + ) + new_workout.map_visibility = get_map_visibility( + VisibilityLevel( + workout_data.get('map_visibility', user.map_visibility.value) + ), + new_workout.workout_visibility, + ) + if title is not None and title != '': new_workout.title = title[:TITLE_MAX_CHARACTERS] else: @@ -254,11 +275,15 @@ def edit_workout( ] if workout_data.get('equipments_list') is not None: workout.equipments = workout_data.get('equipments_list') + if workout_data.get('workout_visibility') is not None: + workout.workout_visibility = VisibilityLevel( + workout_data.get('workout_visibility') + ) if not workout.gpx: if workout_data.get('workout_date'): workout.workout_date, _ = get_workout_datetime( workout_date=workout_data.get('workout_date', ''), - date_str_format='%Y-%m-%d %H:%M', + date_str_format=WORKOUT_DATE_FORMAT, user_timezone=auth_user.timezone, ) @@ -281,6 +306,15 @@ def edit_workout( if 'descent' in workout_data: workout.descent = workout_data.get('descent') + + else: + if workout_data.get('map_visibility') is not None: + map_visibility = VisibilityLevel( + workout_data.get('map_visibility') + ) + workout.map_visibility = get_map_visibility( + map_visibility, workout.workout_visibility + ) return workout @@ -528,3 +562,22 @@ def get_average_speed( / total_workouts, 2, ) + + +def get_ordered_workouts(workouts: List[Workout], limit: int) -> List[Workout]: + return sorted( + workouts, key=lambda workout: workout.workout_date, reverse=True + )[:limit] + + +def get_workout( + workout_short_id: str, auth_user: Optional[User], allow_admin: bool = False +) -> Workout: + workout_uuid = decode_short_id(workout_short_id) + workout = Workout.query.filter(Workout.uuid == workout_uuid).first() + if not workout or ( + not can_view(workout, 'workout_visibility', auth_user) + and not (allow_admin and auth_user and auth_user.has_admin_rights) + ): + raise WorkoutForbiddenException() + return workout diff --git a/fittrackee/workouts/workouts.py b/fittrackee/workouts/workouts.py index 342a9bfa8..3efcf3af1 100644 --- a/fittrackee/workouts/workouts.py +++ b/fittrackee/workouts/workouts.py @@ -13,6 +13,7 @@ send_from_directory, ) from sqlalchemy import asc, desc, exc +from sqlalchemy.exc import IntegrityError from werkzeug.exceptions import NotFound, RequestEntityTooLarge from werkzeug.utils import secure_filename @@ -41,15 +42,17 @@ ) from fittrackee.users.models import User, UserSportPreference from fittrackee.utils import decode_short_id +from fittrackee.visibility_levels import can_view -from .models import Sport, Workout, WorkoutEquipment +from ..reports.models import ReportActionAppeal +from .decorators import check_workout +from .models import Sport, Workout, WorkoutEquipment, WorkoutLike from .utils.convert import convert_in_duration from .utils.gpx import ( WorkoutGPXException, extract_segment_from_gpx_file, get_chart_data, ) -from .utils.visibility import can_view_workout from .utils.workouts import ( WorkoutException, create_workout, @@ -63,6 +66,7 @@ DEFAULT_WORKOUTS_PER_PAGE = 5 MAX_WORKOUTS_PER_PAGE = 100 +MAX_WORKOUTS_TO_SEND = 5 @workouts_blueprint.route('/workouts', methods=['GET']) @@ -110,7 +114,10 @@ def get_workouts(auth_user: User) -> Union[Dict, HttpResponse]: "duration": "0:17:04", "equipments": [], "id": "kjxavSTUrJvoAh2wvCeGEF", + "liked": false, + "likes_count": 0, "map": null, + "map_visibility": "private", "max_alt": null, "max_speed": 10.0, "min_alt": null, @@ -169,12 +176,24 @@ def get_workouts(auth_user: User) -> Union[Dict, HttpResponse]: ], "segments": [], "sport_id": 1, + "suspended": false, + "suspended_at": null, "title": null, - "user": "admin", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "weather_end": null, "weather_start": null, "with_gpx": false, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_visibility": "private" } ] }, @@ -215,8 +234,8 @@ def get_workouts(auth_user: User) -> Union[Dict, HttpResponse]: :query string order_by: sorting criteria: ``ave_speed``, ``distance``, ``duration``, ``workout_date`` (default: ``workout_date``) - :query string equipment_id: equipment id (if 'none', only workouts without - equipments will be returned) + :query string equipment_id: equipment id (if ``none``, only workouts + without equipments will be returned) :query string notes: any part (or all) of the workout notes, notes matching is case-insensitive :query string description: any part of the workout description; @@ -230,6 +249,8 @@ def get_workouts(auth_user: User) -> Union[Dict, HttpResponse]: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 500: ``error, please try again or contact the administrator`` """ @@ -341,6 +362,7 @@ def get_workouts(auth_user: User) -> Union[Dict, HttpResponse]: else True ) ), + Workout.suspended_at == None, # noqa ) .order_by( ( @@ -356,7 +378,10 @@ def get_workouts(auth_user: User) -> Union[Dict, HttpResponse]: return { 'status': 'success', 'data': { - 'workouts': [workout.serialize(params) for workout in workouts] + 'workouts': [ + workout.serialize(user=auth_user, params=params) + for workout in workouts + ] }, 'pagination': { 'has_next': workouts_pagination.has_next, @@ -373,15 +398,14 @@ def get_workouts(auth_user: User) -> Union[Dict, HttpResponse]: @workouts_blueprint.route( '/workouts/', methods=['GET'] ) -@require_auth(scopes=['workouts:read']) +@require_auth(scopes=['workouts:read'], optional_auth_user=True) +@check_workout(only_owner=False) def get_workout( - auth_user: User, workout_short_id: str + auth_user: Optional[User], workout: Workout, workout_short_id: str ) -> Union[Dict, HttpResponse]: """ Get a workout. - **Scope**: ``workouts:read`` - **Example request**: .. sourcecode:: http @@ -411,7 +435,10 @@ def get_workout( "duration": "0:45:00", "equipments": [], "id": "kjxavSTUrJvoAh2wvCeGEF", + "liked": false, + "likes_count": 0, "map": null, + "map_visibility": "private", "max_alt": null, "max_speed": 16, "min_alt": null, @@ -424,12 +451,24 @@ def get_workout( "records": [], "segments": [], "sport_id": 1, + "suspended": false, + "suspended_at": null, "title": "biking on sunday morning", - "user": "admin", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "weather_end": null, "weather_start": null, "with_gpx": false, - "workout_date": "Sun, 07 Jul 2019 07:00:00 GMT" + "workout_date": "Sun, 07 Jul 2019 07:00:00 GMT", + "workout_visibility": "private" } ] }, @@ -452,50 +491,45 @@ def get_workout( :param string workout_short_id: workout short id - :reqheader Authorization: OAuth 2.0 Bearer Token + :reqheader Authorization: OAuth 2.0 Bearer Token for workout with + ``private`` or ``followers_only`` visibility :statuscode 200: ``success`` :statuscode 401: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` - :statuscode 403: ``you do not have permissions`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``workout not found`` """ - workout_uuid = decode_short_id(workout_short_id) - workout = Workout.query.filter_by(uuid=workout_uuid).first() - if not workout: - return DataNotFoundErrorResponse('workouts') - - error_response = can_view_workout(auth_user.id, workout.user_id) - if error_response: - return error_response - return { 'status': 'success', - 'data': {'workouts': [workout.serialize()]}, + 'data': {'workouts': [workout.serialize(user=auth_user, light=False)]}, } def get_workout_data( - auth_user: User, + auth_user: Optional[User], workout_short_id: str, data_type: str, segment_id: Optional[int] = None, ) -> Union[Dict, HttpResponse]: - """Get data from a workout gpx file""" + """Get data from workout gpx file""" + not_found_response = DataNotFoundErrorResponse( + data_type=data_type, + message=f'workout not found (id: {workout_short_id})', + ) workout_uuid = decode_short_id(workout_short_id) workout = Workout.query.filter_by(uuid=workout_uuid).first() if not workout: - return DataNotFoundErrorResponse( - data_type=data_type, - message=f'workout not found (id: {workout_short_id})', - ) + return not_found_response + + if not can_view(workout, 'calculated_map_visibility', auth_user): + return not_found_response - error_response = can_view_workout(auth_user.id, workout.user_id) - if error_response: - return error_response if not workout.gpx or workout.gpx == '': return NotFoundErrorResponse( f'no gpx file for this workout (id: {workout_short_id})' @@ -545,15 +579,13 @@ def get_workout_data( @workouts_blueprint.route( '/workouts//gpx', methods=['GET'] ) -@require_auth(scopes=['workouts:read']) +@require_auth(scopes=['workouts:read'], optional_auth_user=True) def get_workout_gpx( - auth_user: User, workout_short_id: str + auth_user: Optional[User], workout_short_id: str ) -> Union[Dict, HttpResponse]: """ Get gpx file for a workout displayed on map with Leaflet. - **Scope**: ``workouts:read`` - **Example request**: .. sourcecode:: http @@ -578,13 +610,17 @@ def get_workout_gpx( :param string workout_short_id: workout short id - :reqheader Authorization: OAuth 2.0 Bearer Token + :reqheader Authorization: OAuth 2.0 Bearer Token for workout with + ``private`` or ``followers_only`` map visibility :statuscode 200: ``success`` :statuscode 401: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: - ``workout not found`` - ``no gpx file for this workout`` @@ -597,15 +633,13 @@ def get_workout_gpx( @workouts_blueprint.route( '/workouts//chart_data', methods=['GET'] ) -@require_auth(scopes=['workouts:read']) +@require_auth(scopes=['workouts:read'], optional_auth_user=True) def get_workout_chart_data( - auth_user: User, workout_short_id: str + auth_user: Optional[User], workout_short_id: str ) -> Union[Dict, HttpResponse]: """ Get chart data from a workout gpx file, to display it with Chart.js. - **Scope**: ``workouts:read`` - **Example request**: .. sourcecode:: http @@ -649,13 +683,17 @@ def get_workout_chart_data( :param string workout_short_id: workout short id - :reqheader Authorization: OAuth 2.0 Bearer Token + :reqheader Authorization: OAuth 2.0 Bearer Token for workout with + ``private`` or ``followers_only`` map visibility :statuscode 200: ``success`` :statuscode 401: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: - ``workout not found`` - ``no gpx file for this workout`` @@ -669,20 +707,18 @@ def get_workout_chart_data( '/workouts//gpx/segment/', methods=['GET'], ) -@require_auth(scopes=['workouts:read']) +@require_auth(scopes=['workouts:read'], optional_auth_user=True) def get_segment_gpx( - auth_user: User, workout_short_id: str, segment_id: int + auth_user: Optional[User], workout_short_id: str, segment_id: int ) -> Union[Dict, HttpResponse]: """ Get gpx file for a workout segment displayed on map with Leaflet. - **Scope**: ``workouts:read`` - **Example request**: .. sourcecode:: http - GET /api/workouts/kjxavSTUrJvoAh2wvCeGEF/gpx/segment/0 HTTP/1.1 + GET /api/workouts/kjxavSTUrJvoAh2wvCeGEF/gpx/segment/1 HTTP/1.1 Content-Type: application/json **Example response**: @@ -703,7 +739,8 @@ def get_segment_gpx( :param string workout_short_id: workout short id :param integer segment_id: segment id - :reqheader Authorization: OAuth 2.0 Bearer Token + :reqheader Authorization: OAuth 2.0 Bearer Token for workout with + ``private`` or ``followers_only`` map visibility :statuscode 200: ``success`` :statuscode 400: ``no gpx file for this workout`` @@ -711,6 +748,9 @@ def get_segment_gpx( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``workout not found`` :statuscode 500: ``error, please try again or contact the administrator`` @@ -723,20 +763,18 @@ def get_segment_gpx( '', methods=['GET'], ) -@require_auth(scopes=['workouts:read']) +@require_auth(scopes=['workouts:read'], optional_auth_user=True) def get_segment_chart_data( - auth_user: User, workout_short_id: str, segment_id: int + auth_user: Optional[User], workout_short_id: str, segment_id: int ) -> Union[Dict, HttpResponse]: """ Get chart data from a workout gpx file, to display it with Chart.js. - **Scope**: ``workouts:read`` - **Example request**: .. sourcecode:: http - GET /api/workouts/kjxavSTUrJvoAh2wvCeGEF/chart/segment/0 HTTP/1.1 + GET /api/workouts/kjxavSTUrJvoAh2wvCeGEF/chart/segment/1 HTTP/1.1 Content-Type: application/json **Example response**: @@ -776,7 +814,8 @@ def get_segment_chart_data( :param string workout_short_id: workout short id :param integer segment_id: segment id - :reqheader Authorization: OAuth 2.0 Bearer Token + :reqheader Authorization: OAuth 2.0 Bearer Token for workout with + ``private`` or ``followers_only`` map visibility :statuscode 200: ``success`` :statuscode 400: ``no gpx file for this workout`` @@ -784,6 +823,9 @@ def get_segment_chart_data( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``workout not found`` :statuscode 500: ``error, please try again or contact the administrator`` @@ -825,6 +867,8 @@ def download_workout_gpx( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 404: - ``workout not found`` - ``no gpx file for workout`` @@ -880,6 +924,8 @@ def get_map(map_id: int) -> Union[HttpResponse, Response]: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``map does not exist`` :statuscode 500: ``error, please try again or contact the administrator`` @@ -966,76 +1012,120 @@ def post_workout(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: HTTP/1.1 201 CREATED Content-Type: application/json - { + { "data": { "workouts": [ { - "ascent": null, - "ave_speed": 10.0, - "bounds": [], + "ascent": 435.621, + "ave_speed": 13.14, + "bounds": [ + 43.93706, + 4.517587, + 43.981933, + 4.560627 + ], "creation_date": "Sun, 14 Jul 2019 13:51:01 GMT", - "descent": null, + "descent": 427.499, "description": null, - "distance": 10.0, - "duration": "0:17:04", + "distance": 23.478, + "duration": "2:08:35", "equipments": [], - "id": "kjxavSTUrJvoAh2wvCeGEF", - "map": null, - "max_alt": null, - "max_speed": 10.0, - "min_alt": null, + "id": "PsjeeXbJZ2JJNQcTCPxVvF", + "liked": false, + "likes_count": 0, + "map": "ac075ec36dc25dcc20c270d2005f0398", + "map_visibility": "private", + "max_alt": 158.41, + "max_speed": 25.59, + "min_alt": 55.03, "modification_date": null, - "moving": "0:17:04", - "next_workout": 3, - "notes": null, - "pauses": null, - "previous_workout": null, + "moving": "1:47:11", + "next_workout": "Kd5wyhwLtVozw6o3AU5M4J", + "notes": "", + "pauses": "0:20:32", + "previous_workout": "HgzYFXgvWKCEpdq3vYk67q", "records": [ { - "id": 4, - "record_type": "MS", - "sport_id": 1, - "user": "admin", - "value": 10., - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", - "workout_id": "kjxavSTUrJvoAh2wvCeGEF" + "id": 6, + "record_type": "AS", + "sport_id": 4, + "user": "Sam", + "value": 13.14, + "workout_date": "Tue, 26 Apr 2016 14:42:30 GMT", + "workout_id": "PsjeeXbJZ2JJNQcTCPxVvF" }, { - "id": 3, + "id": 7, + "record_type": "FD", + "sport_id": 4, + "user": "Sam", + "value": 23.478, + "workout_date": "Tue, 26 Apr 2016 14:42:30 GMT", + "workout_id": "PsjeeXbJZ2JJNQcTCPxVvF" + }, + { + "id": 9, "record_type": "LD", - "sport_id": 1, - "user": "admin", - "value": "0:17:04", - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", - "workout_id": "kjxavSTUrJvoAh2wvCeGEF", + "sport_id": 4, + "user": "Sam", + "value": "1:47:11", + "workout_date": "Tue, 26 Apr 2016 14:42:30 GMT", + "workout_id": "PsjeeXbJZ2JJNQcTCPxVvF" }, { - "id": 2, - "record_type": "FD", - "sport_id": 1, - "user": "admin", - "value": 10.0, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", - "workout_id": "kjxavSTUrJvoAh2wvCeGEF" + "id": 10, + "record_type": "MS", + "sport_id": 4, + "user": "Sam", + "value": 25.59, + "workout_date": "Tue, 26 Apr 2016 14:42:30 GMT", + "workout_id": "PsjeeXbJZ2JJNQcTCPxVvF" }, { - "id": 1, - "record_type": "AS", - "sport_id": 1, - "user": "admin", - "value": 10.0, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", - "workout_id": "kjxavSTUrJvoAh2wvCeGEF" + "id": 8, + "record_type": "HA", + "sport_id": 4, + "user": "Sam", + "value": 435.621, + "workout_date": "Tue, 26 Apr 2016 14:42:30 GMT", + "workout_id": "PsjeeXbJZ2JJNQcTCPxVvF" } ], - "segments": [], - "sport_id": 1, - "title": null, - "user": "admin", + "segments": [ + { + "ascent": 435.621, + "ave_speed": 13.14, + "descent": 427.499, + "distance": 23.478, + "duration": "2:08:35", + "max_alt": 158.41, + "max_speed": 25.59, + "min_alt": 55.03, + "moving": "1:47:11", + "pauses": "0:20:32", + "segment_id": 0, + "workout_id": "PsjeeXbJZ2JJNQcTCPxVvF" + } + ], + "sport_id": 4, + "suspended": false, + "suspended_at": null, + "title": "VTT dans le Gard", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 3, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "weather_end": null, "weather_start": null, - "with_gpx": false, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + "with_gpx": true, + "workout_date": "Tue, 26 Apr 2016 14:42:30 GMT", + "workout_visibility": "private" } ] }, @@ -1079,6 +1169,8 @@ def post_workout(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 413: ``error during picture update: file size exceeds 1.0MB`` :statuscode 500: ``error, please try again or contact the administrator`` @@ -1138,7 +1230,8 @@ def post_workout(auth_user: User) -> Union[Tuple[Dict, int], HttpResponse]: 'status': 'created', 'data': { 'workouts': [ - new_workout.serialize() for new_workout in new_workouts + new_workout.serialize(user=auth_user, light=False) + for new_workout in new_workouts ] }, } @@ -1193,8 +1286,12 @@ def post_workout_no_gpx( "description": null, "distance": 10.0, "duration": "0:17:04", + "id": "Kd5wyhwLtVozw6o3AU5M4J", + "liked": false, + "likes_count": 0, "equipments": [], "map": null, + "map_visibility": "private", "max_alt": null, "max_speed": 10.0, "min_alt": null, @@ -1244,13 +1341,25 @@ def post_workout_no_gpx( ], "segments": [], "sport_id": 1, + "suspended": false, + "suspended_at": null, "title": null, - "user": "admin", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "uuid": "kjxavSTUrJvoAh2wvCeGEF" "weather_end": null, "weather_start": null, "with_gpx": false, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_visibility": "private" } ] }, @@ -1292,6 +1401,8 @@ def post_workout_no_gpx( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 500: ``error, please try again or contact the administrator`` """ @@ -1355,7 +1466,11 @@ def post_workout_no_gpx( return ( { 'status': 'created', - 'data': {'workouts': [new_workout.serialize()]}, + 'data': { + 'workouts': [ + new_workout.serialize(user=auth_user, light=False) + ] + }, }, 201, ) @@ -1373,8 +1488,9 @@ def post_workout_no_gpx( '/workouts/', methods=['PATCH'] ) @require_auth(scopes=['workouts:write']) +@check_workout() def update_workout( - auth_user: User, workout_short_id: str + auth_user: User, workout: Workout, workout_short_id: str ) -> Union[Dict, HttpResponse]: """ Update a workout. @@ -1385,7 +1501,7 @@ def update_workout( .. sourcecode:: http - PATCH /api/workouts/1 HTTP/1.1 + PATCH /api/workouts/2oRDfncv6vpRkfp3yrCYHt HTTP/1.1 Content-Type: application/json **Example response**: @@ -1408,7 +1524,11 @@ def update_workout( "distance": 10.0, "duration": "0:17:04", "equipments": [], + "id": "2oRDfncv6vpRkfp3yrCYHt", + "liked": false, + "likes_count": 0, "map": null, + "map_visibility": "private", "max_alt": null, "max_speed": 10.0, "min_alt": null, @@ -1458,13 +1578,25 @@ def update_workout( ], "segments": [], "sport_id": 1, + "suspended": false, + "suspended_at": null, "title": null, - "user": "admin", + "user": { + "created_at": "Sun, 31 Dec 2017 09:00:00 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, "uuid": "kjxavSTUrJvoAh2wvCeGEF" "weather_end": null, "weather_start": null, "with_gpx": false, - "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT" + "workout_date": "Mon, 01 Jan 2018 00:00:00 GMT", + "workout_visibility": "private" } ] }, @@ -1483,19 +1615,23 @@ def update_workout( (only for workout without gpx) :', methods=['DELETE'] ) @require_auth(scopes=['workouts:write']) +@check_workout() def delete_workout( - auth_user: User, workout_short_id: str + auth_user: User, workout: Workout, workout_short_id: str ) -> Union[Tuple[Dict, int], HttpResponse]: """ Delete a workout. @@ -1637,20 +1770,13 @@ def delete_workout( - ``provide a valid auth token`` - ``signature expired, please log in again`` - ``invalid token, please log in again`` + :statuscode 403: + - ``you do not have permissions, your account is suspended`` :statuscode 404: ``workout not found`` :statuscode 500: ``error, please try again or contact the administrator`` """ - try: - workout_uuid = decode_short_id(workout_short_id) - workout = Workout.query.filter_by(uuid=workout_uuid).first() - if not workout: - return DataNotFoundErrorResponse('workouts') - error_response = can_view_workout(auth_user.id, workout.user_id) - if error_response: - return error_response - # update equipments totals workout.equipments = [] db.session.flush() @@ -1665,3 +1791,293 @@ def delete_workout( OSError, ) as e: return handle_error_and_return_response(e, db=db) + + +@workouts_blueprint.route( + '/workouts//like', methods=['POST'] +) +@require_auth(scopes=['workouts:write']) +@check_workout(only_owner=False) +def like_workout( + auth_user: User, workout: Workout, workout_short_id: str +) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Add a "like" to a workout. + + **Scope**: ``workouts:write`` + + **Example request**: + + .. sourcecode:: http + + POST /api/workouts/HgzYFXgvWKCEpdq3vYk67q/like HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "workouts": [ + { + "ascent": 231.208, + "ave_speed": 13.12, + "bounds": [], + "creation_date": "Wed, 04 Dec 2024 09:18:26 GMT", + "descent": 234.208, + "description": null, + "distance": 23.41, + "duration": "3:32:27", + "equipments": [], + "id": "HgzYFXgvWKCEpdq3vYk67q", + "liked": true, + "likes_count": 1, + "map": null, + "map_visibility": "private", + "max_alt": 104.44, + "max_speed": 25.59, + "min_alt": 19.0, + "modification_date": "Wed, 04 Dec 2024 16:45:14 GMT", + "moving": "1:47:04", + "next_workout": null, + "notes": null, + "pauses": "1:23:51", + "previous_workout": null, + "records": [], + "segments": [], + "sport_id": 1, + "suspended": false, + "title": "Cycling (Sport) - 2016-04-26 16:42:27", + "user": { + "created_at": "Sun, 24 Nov 2024 16:52:14 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, + "weather_end": null, + "weather_start": null, + "with_gpx": false, + "workout_date": "Tue, 26 Apr 2016 14:42:27 GMT", + "workout_visibility": "public" + } + ] + }, + "status": "success" + } + + :param string workout_short_id: workout short id + + :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 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` + :statuscode 404: ``comment not found`` + """ + try: + like = WorkoutLike(user_id=auth_user.id, workout_id=workout.id) + db.session.add(like) + db.session.commit() + + except IntegrityError: + db.session.rollback() + return { + 'status': 'success', + 'data': {'workouts': [workout.serialize(user=auth_user, light=False)]}, + }, 200 + + +@workouts_blueprint.route( + '/workouts//like/undo', methods=['POST'] +) +@require_auth(scopes=['workouts:write']) +@check_workout(only_owner=False) +def undo_workout_like( + auth_user: User, workout: Workout, workout_short_id: str +) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Remove workout "like". + + **Scope**: ``workouts:write`` + + **Example request**: + + .. sourcecode:: http + + POST /api/workouts/HgzYFXgvWKCEpdq3vYk67q/like/undo HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "data": { + "workouts": [ + { + "ascent": 231.208, + "ave_speed": 13.12, + "bounds": [], + "creation_date": "Wed, 04 Dec 2024 09:18:26 GMT", + "descent": 234.208, + "description": null, + "distance": 23.41, + "duration": "3:32:27", + "equipments": [], + "id": "HgzYFXgvWKCEpdq3vYk67q", + "liked": false, + "likes_count": 0, + "map": null, + "map_visibility": "private", + "max_alt": 104.44, + "max_speed": 25.59, + "min_alt": 19.0, + "modification_date": "Wed, 04 Dec 2024 16:45:14 GMT", + "moving": "1:47:04", + "next_workout": null, + "notes": null, + "pauses": "1:23:51", + "previous_workout": null, + "records": [], + "segments": [], + "sport_id": 1, + "suspended": false, + "title": "Cycling (Sport) - 2016-04-26 16:42:27", + "user": { + "created_at": "Sun, 24 Nov 2024 16:52:14 GMT", + "followers": 0, + "following": 0, + "nb_workouts": 1, + "picture": false, + "role": "user", + "suspended_at": null, + "username": "Sam" + }, + "weather_end": null, + "weather_start": null, + "with_gpx": false, + "workout_date": "Tue, 26 Apr 2016 14:42:27 GMT", + "workout_visibility": "public" + } + ] + }, + "status": "success" + } + + :param string workout_short_id: workout short id + + :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 403: + - ``you do not have permissions`` + - ``you do not have permissions, your account is suspended`` + :statuscode 404: ``comment not found`` + """ + like = WorkoutLike.query.filter_by( + user_id=auth_user.id, workout_id=workout.id + ).first() + if like: + db.session.delete(like) + db.session.commit() + + return { + 'status': 'success', + 'data': {'workouts': [workout.serialize(user=auth_user, light=False)]}, + }, 200 + + +@workouts_blueprint.route( + "/workouts//suspension/appeal", + methods=["POST"], +) +@require_auth(scopes=["workouts:write"]) +@check_workout(only_owner=True) +def appeal_workout_suspension( + auth_user: User, workout: Workout, workout_short_id: str +) -> Union[Tuple[Dict, int], HttpResponse]: + """ + Appeal workout suspension. + + Only workout author can appeal the suspension. + + **Scope**: ``workouts:write`` + + **Example request**: + + .. sourcecode:: http + + POST /api/workouts/2oRDfncv6vpRkfp3yrCYHt/suspension/appeal HTTP/1.1 + Content-Type: application/json + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 CREATED + Content-Type: application/json + + { + "status": "success" + } + + :param string workout_short_id: workout short id + + :reqheader Authorization: OAuth 2.0 Bearer Token + + :statuscode 201: appeal created + :statuscode 400: + - ``no text provided`` + - ``you can appeal only once`` + - ``workout is not suspended`` + - ``workout has no suspension`` + :statuscode 401: + - ``provide a valid auth token`` + - ``signature expired, please log in again`` + - ``invalid token, please log in again`` + :statuscode 403: ``you do not have permissions`` + :statuscode 404: ``workout not found`` + :statuscode 500: ``error, please try again or contact the administrator`` + """ + if not workout.suspended_at: + return InvalidPayloadErrorResponse("workout is not suspended") + suspension_action = workout.suspension_action + if not suspension_action: + return InvalidPayloadErrorResponse("workout has no suspension") + + text = request.get_json().get("text") + if not text: + return InvalidPayloadErrorResponse("no text provided") + + try: + appeal = ReportActionAppeal( + action_id=suspension_action.id, user_id=auth_user.id, text=text + ) + db.session.add(appeal) + db.session.commit() + return {"status": "success"}, 201 + + except exc.IntegrityError: + return InvalidPayloadErrorResponse("you can appeal only once") + except (exc.OperationalError, ValueError) as e: + return handle_error_and_return_response(e, db=db) diff --git a/fittrackee_client/.eslintrc.cjs b/fittrackee_client/.eslintrc.cjs index 002dd5373..3b4b67bbd 100644 --- a/fittrackee_client/.eslintrc.cjs +++ b/fittrackee_client/.eslintrc.cjs @@ -35,6 +35,7 @@ module.exports = { }, }, ], + 'no-console': ['warn', { allow: ['error'] }], 'vue/component-name-in-template-casing': ['error', 'PascalCase'], 'vue/define-props-declaration': ['error', 'type-based'], 'vue/multi-word-component-names': 'off', diff --git a/fittrackee_client/public/img/user.png b/fittrackee_client/public/img/user.png new file mode 100644 index 000000000..b0e5ebd0b Binary files /dev/null and b/fittrackee_client/public/img/user.png differ diff --git a/fittrackee_client/src/App.vue b/fittrackee_client/src/App.vue index a7a533ab5..178740ea7 100644 --- a/fittrackee_client/src/App.vue +++ b/fittrackee_client/src/App.vue @@ -29,34 +29,24 @@ diff --git a/fittrackee_client/src/components/Administration/AdminEquipmentTypes.vue b/fittrackee_client/src/components/Administration/AdminEquipmentTypes.vue index 982187c92..7382646a3 100644 --- a/fittrackee_client/src/components/Administration/AdminEquipmentTypes.vue +++ b/fittrackee_client/src/components/Administration/AdminEquipmentTypes.vue @@ -101,16 +101,16 @@ import type { ComputedRef } from 'vue' import { useI18n } from 'vue-i18n' - import { EQUIPMENTS_STORE, ROOT_STORE } from '@/store/constants' - import type { - IEquipmentError, - ITranslatedEquipmentType, - } from '@/types/equipments' + import useApp from '@/composables/useApp' + import { EQUIPMENTS_STORE } from '@/store/constants' + import type { ITranslatedEquipmentType } from '@/types/equipments' import { useStore } from '@/use/useStore' import { translateEquipmentTypes } from '@/utils/equipments' - const { t } = useI18n() const store = useStore() + const { t } = useI18n() + + const { errorMessages } = useApp() const translatedEquipmentTypes: ComputedRef = computed(() => @@ -119,10 +119,6 @@ t ) ) - const errorMessages: ComputedRef = - computed(() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]) - - onBeforeMount(() => loadEquipmentTypes()) function loadEquipmentTypes() { store.dispatch(EQUIPMENTS_STORE.ACTIONS.GET_EQUIPMENT_TYPES) @@ -133,6 +129,8 @@ isActive, }) } + + onBeforeMount(() => loadEquipmentTypes()) diff --git a/fittrackee_client/src/components/Administration/AdminReportActionAppeal.vue b/fittrackee_client/src/components/Administration/AdminReportActionAppeal.vue new file mode 100644 index 000000000..e509159f4 --- /dev/null +++ b/fittrackee_client/src/components/Administration/AdminReportActionAppeal.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/fittrackee_client/src/components/Administration/AdminReports.vue b/fittrackee_client/src/components/Administration/AdminReports.vue new file mode 100644 index 000000000..5f13ad4a6 --- /dev/null +++ b/fittrackee_client/src/components/Administration/AdminReports.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/fittrackee_client/src/components/Administration/AdminSports.vue b/fittrackee_client/src/components/Administration/AdminSports.vue index ae85ab75c..d6f12ccd3 100644 --- a/fittrackee_client/src/components/Administration/AdminSports.vue +++ b/fittrackee_client/src/components/Administration/AdminSports.vue @@ -83,26 +83,17 @@ diff --git a/fittrackee_client/src/components/Administration/AppStatsCards.vue b/fittrackee_client/src/components/Administration/AppStatsCards.vue index deabf8bbc..355026c51 100644 --- a/fittrackee_client/src/components/Administration/AppStatsCards.vue +++ b/fittrackee_client/src/components/Administration/AppStatsCards.vue @@ -3,7 +3,7 @@ () - const { appStatistics } = toRefs(props) + const uploadDirSize = computed(() => getReadableFileSize(appStatistics.value.uploads_dir_size) ) diff --git a/fittrackee_client/src/components/Administration/UserAdminReports.vue b/fittrackee_client/src/components/Administration/UserAdminReports.vue new file mode 100644 index 000000000..ad32f1cea --- /dev/null +++ b/fittrackee_client/src/components/Administration/UserAdminReports.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/fittrackee_client/src/components/Comment/Comment.vue b/fittrackee_client/src/components/Comment/Comment.vue new file mode 100644 index 000000000..ab29a3eda --- /dev/null +++ b/fittrackee_client/src/components/Comment/Comment.vue @@ -0,0 +1,444 @@ + + + + + diff --git a/fittrackee_client/src/components/Comment/CommentActionAppeal.vue b/fittrackee_client/src/components/Comment/CommentActionAppeal.vue new file mode 100644 index 000000000..800e484fd --- /dev/null +++ b/fittrackee_client/src/components/Comment/CommentActionAppeal.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/fittrackee_client/src/components/Comment/CommentEdition.vue b/fittrackee_client/src/components/Comment/CommentEdition.vue new file mode 100644 index 000000000..464e8fe82 --- /dev/null +++ b/fittrackee_client/src/components/Comment/CommentEdition.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/fittrackee_client/src/components/Comment/CommentForUser.vue b/fittrackee_client/src/components/Comment/CommentForUser.vue new file mode 100644 index 000000000..0775fbd25 --- /dev/null +++ b/fittrackee_client/src/components/Comment/CommentForUser.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/fittrackee_client/src/components/Comment/Comments.vue b/fittrackee_client/src/components/Comment/Comments.vue new file mode 100644 index 000000000..3d7a395aa --- /dev/null +++ b/fittrackee_client/src/components/Comment/Comments.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/fittrackee_client/src/components/Common/ActionAppeal.vue b/fittrackee_client/src/components/Common/ActionAppeal.vue new file mode 100644 index 000000000..5fa7d17af --- /dev/null +++ b/fittrackee_client/src/components/Common/ActionAppeal.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/fittrackee_client/src/components/Common/AlertMessage.vue b/fittrackee_client/src/components/Common/AlertMessage.vue index 26436dd32..527c8773f 100644 --- a/fittrackee_client/src/components/Common/AlertMessage.vue +++ b/fittrackee_client/src/components/Common/AlertMessage.vue @@ -1,20 +1,34 @@ diff --git a/fittrackee_client/src/components/Common/Images/EquipmentTypeImage/index.vue b/fittrackee_client/src/components/Common/Images/EquipmentTypeImage/index.vue index 8fe010507..bdf2f7c58 100644 --- a/fittrackee_client/src/components/Common/Images/EquipmentTypeImage/index.vue +++ b/fittrackee_client/src/components/Common/Images/EquipmentTypeImage/index.vue @@ -14,8 +14,7 @@ diff --git a/fittrackee_client/src/components/Common/Images/SportImage/index.vue b/fittrackee_client/src/components/Common/Images/SportImage/index.vue index e8786ec12..b7756775f 100644 --- a/fittrackee_client/src/components/Common/Images/SportImage/index.vue +++ b/fittrackee_client/src/components/Common/Images/SportImage/index.vue @@ -28,7 +28,7 @@ diff --git a/fittrackee_client/src/components/Common/Modal.vue b/fittrackee_client/src/components/Common/Modal.vue index e96b53483..54c7e5743 100644 --- a/fittrackee_client/src/components/Common/Modal.vue +++ b/fittrackee_client/src/components/Common/Modal.vue @@ -16,8 +16,14 @@