Merge branch 'pre-release'

This commit is contained in:
João Vitória Silva
2025-10-06 21:08:39 +01:00
355 changed files with 9624 additions and 1680 deletions

View File

@@ -2,15 +2,17 @@
# These are just the variable you have to set to be up and running.
# There is many more variable you could set. Check them out here: https://docs.endurain.com/getting-started/advanced-started/#supported-environment-variables
DB_PASSWORD=changeme
POSTGRES_PASSWORD=changeme
DB_PASSWORD=changeme # Set a strong password here. Check if there are no trailing whitespaces in the beginning and end. Must be the same as POSTGRES_PASSWORD
POSTGRES_PASSWORD=changeme # Must be the same as DB_PASSWORD
SECRET_KEY=changeme
FERNET_KEY=changeme
TZ=Europe/Lisbon
ENDURAIN_HOST=https://endurain.example.com
BEHIND_PROXY=true
POSTGRES_DB=endurain
POSTGRES_USER=endurain
POSTGRES_DB=endurain # If you change this, you also have to change DB_DATABASE
# DB_DATABASE=endurain # Uncomment and set it to the same as POSTGRES_DB if you change it
POSTGRES_USER=endurain # If you change this, you also have to change DB_USER
# DB_USER=endurain # Uncomment and set it to the same as POSTGRES_USER if you change it
PGDATA=/var/lib/postgresql/data/pgdata
# Email configuration (for password reset functionality)

6
.gitignore vendored
View File

@@ -41,12 +41,18 @@ backend/app/data/activity_files/*.tcx
backend/app/data/activity_files/bulk_import/*.gpx
backend/app/data/activity_files/bulk_import/*.fit
backend/app/data/activity_files/bulk_import/*.tcx
backend/app/data/activity_files/bulk_import/*.gz
backend/app/data/activity_files/bulk_import/*.csv
backend/app/data/activity_files/bulk_import/import_errors/*.gpx
backend/app/data/activity_files/bulk_import/import_errors/*.fit
backend/app/data/activity_files/bulk_import/import_errors/*.tcx
backend/app/data/activity_files/bulk_import/import_errors/*.gz
backend/app/data/activity_files/bulk_import/import_errors/*.csv
backend/app/data/activity_files/processed/*.gpx
backend/app/data/activity_files/processed/*.fit
backend/app/data/activity_files/processed/*.tcx
backend/app/data/activity_files/processed/*.gz
backend/app/data/activity_files/processed/*.csv
backend/data/activity_files/
# Frontend

View File

@@ -29,7 +29,7 @@
Endurain is a self-hosted fitness tracking service designed to give users full control over their data and hosting environment. It's similar to Strava but focused on privacy and customization. Built with:
- **Frontend:** Vue.js, Notivue and Bootstrap CSS
- **Backend:** Python FastAPI, Alembic, SQLAlchemy, stravalib and python-garminconnect for Strava and Garmin Connect integration, gpxpy, tcxreader and fitdecode for .gpx, .tcx and .fit file import respectively
- **Backend:** Python FastAPI, Alembic, SQLAlchemy, Apprise, stravalib and python-garminconnect for Strava and Garmin Connect integration, gpxpy, tcxreader and fitdecode for .gpx, .tcx and .fit file import respectively
- **Database:** MariaDB or PostgreSQL for efficient data management
- **Observability:** Jaeger for basic tracing and monitoring
- **Integrations:** Supports Strava and Garmin Connect. Manual upload of activities using .gpx, .tcx and .fit files are also supported

View File

@@ -11,7 +11,7 @@ import core.logger as core_logger
import notifications.utils as notifications_utils
import server_settings.crud as server_settings_crud
import server_settings.utils as server_settings_utils
import websocket.schema as websocket_schema
@@ -901,10 +901,10 @@ def get_activity_by_id_from_user_id_or_has_visibility(
def get_activity_by_id_if_is_public(activity_id: int, db: Session):
try:
# Check if public sharable links are enabled in server settings
server_settings = server_settings_crud.get_server_settings(db)
server_settings = server_settings_utils.get_server_settings(db)
# Return None if public sharable links are disabled
if not server_settings or not server_settings.public_shareable_links:
if not server_settings.public_shareable_links:
return None
# Get the activities from the database

View File

@@ -82,7 +82,10 @@ ACTIVITY_ID_TO_NAME = {
33: "Surf",
34: "Track run",
35: "E-Bike ride",
36: "E-Mountain Bike ride"
36: "E-Mountain Bike ride",
37: "Ice Skate",
38: "Soccer",
39: "Padel",
# Add other mappings as needed based on the full list in define_activity_type comments if required
# "AlpineSki",
# "BackcountrySki",
@@ -189,21 +192,25 @@ ACTIVITY_NAME_TO_ID.update(
"windsurfing": 30,
"indoor_walking": 31,
"stand_up_paddleboarding": 32,
"StandUpPaddling": 32,
"Surfing": 33,
"standuppaddling": 32,
"surfing": 33,
"track running": 34,
"trackrun": 34,
"track": 34,
"EBikeRide": 35,
"ebikeride": 35,
"e_bike": 35,
"ebike": 35,
"e_bike_ride": 35,
"ebikeride": 35,
"EMountainBikeRide": 36,
"emountainbikeride": 36,
"e_bike_mountain": 36,
"ebikemountain": 36,
"e_bike_mountain_ride": 36,
"ebikemountainride": 36,
"iceskate": 37,
"soccer": 38,
"padel": 39,
"padelball": 39,
"paddelball": 39,
}
)

View File

@@ -5,7 +5,7 @@ from sqlalchemy.exc import IntegrityError
import activities.activity_exercise_titles.models as activity_exercise_titles_models
import activities.activity_exercise_titles.schema as activity_exercise_titles_schema
import server_settings.crud as server_settings_crud
import server_settings.utils as server_settings_utils
import core.logger as core_logger
@@ -38,10 +38,10 @@ def get_activity_exercise_titles(db: Session):
def get_public_activity_exercise_titles(db: Session):
try:
# Check if public sharable links are enabled in server settings
server_settings = server_settings_crud.get_server_settings(db)
server_settings = server_settings_utils.get_server_settings(db)
# Return None if public sharable links are disabled
if not server_settings or not server_settings.public_shareable_links:
if not server_settings.public_shareable_links:
return None
# Get the activity exercise titles from the database

View File

@@ -9,7 +9,7 @@ import activities.activity_laps.models as activity_laps_models
import activities.activity_laps.schema as activity_laps_schema
import activities.activity_laps.utils as activity_laps_utils
import server_settings.crud as server_settings_crud
import server_settings.utils as server_settings_utils
import core.logger as core_logger
@@ -136,10 +136,10 @@ def get_public_activity_laps(activity_id: int, db: Session):
return None
# Check if public sharable links are enabled in server settings
server_settings = server_settings_crud.get_server_settings(db)
server_settings = server_settings_utils.get_server_settings(db)
# Return None if public sharable links are disabled
if not server_settings or not server_settings.public_shareable_links:
if not server_settings.public_shareable_links:
return None
# Get the activity laps from the database

View File

@@ -6,10 +6,9 @@ import activities.activity.models as activity_models
import activities.activity.crud as activity_crud
import activities.activity_sets.models as activity_sets_models
import activities.activity_sets.schema as activity_sets_schema
import activities.activity_sets.utils as activity_sets_utils
import server_settings.crud as server_settings_crud
import server_settings.utils as server_settings_utils
import core.logger as core_logger
@@ -138,10 +137,10 @@ def get_public_activity_sets(activity_id: int, db: Session):
return None
# Check if public sharable links are enabled in server settings
server_settings = server_settings_crud.get_server_settings(db)
server_settings = server_settings_utils.get_server_settings(db)
# Return None if public sharable links are disabled
if not server_settings or not server_settings.public_shareable_links:
if not server_settings.public_shareable_links:
return None
# Get the activity sets from the database

View File

@@ -11,7 +11,7 @@ import activities.activity.crud as activity_crud
import activities.activity.models as activity_models
import activities.activity.schema as activities_schema
import server_settings.crud as server_settings_crud
import server_settings.utils as server_settings_utils
import users.user.crud as users_crud
@@ -167,10 +167,10 @@ def get_activities_streams(
def get_public_activity_streams(activity_id: int, db: Session):
try:
# Check if public sharable links are enabled in server settings
server_settings = server_settings_crud.get_server_settings(db)
server_settings = server_settings_utils.get_server_settings(db)
# Return None if public sharable links are disabled
if not server_settings or not server_settings.public_shareable_links:
if not server_settings.public_shareable_links:
return None
activity = activity_crud.get_activity_by_id_if_is_public(activity_id, db)
@@ -440,10 +440,10 @@ def transform_activity_streams_hr(activity_stream, activity, db):
def get_public_activity_stream_by_type(activity_id: int, stream_type: int, db: Session):
try:
# Check if public sharable links are enabled in server settings
server_settings = server_settings_crud.get_server_settings(db)
server_settings = server_settings_utils.get_server_settings(db)
# Return None if public sharable links are disabled
if not server_settings or not server_settings.public_shareable_links:
if not server_settings.public_shareable_links:
return None
activity = activity_crud.get_activity_by_id_if_is_public(activity_id, db)

View File

@@ -8,7 +8,7 @@ import activities.activity.crud as activity_crud
import activities.activity_workout_steps.models as activity_workout_steps_models
import activities.activity_workout_steps.schema as activity_workout_steps_schema
import server_settings.crud as server_settings_crud
import server_settings.utils as server_settings_utils
import core.logger as core_logger
@@ -127,10 +127,10 @@ def get_public_activity_workout_steps(activity_id: int, db: Session):
return None
# Check if public sharable links are enabled in server settings
server_settings = server_settings_crud.get_server_settings(db)
server_settings = server_settings_utils.get_server_settings(db)
# Return None if public sharable links are disabled
if not server_settings or not server_settings.public_shareable_links:
if not server_settings.public_shareable_links:
return None
# Get the activity workout steps from the database

View File

@@ -21,6 +21,7 @@ import health_targets.models
import migrations.models
import notifications.models
import password_reset_tokens.models
import sign_up_tokens.models
import server_settings.models
import session.models
import users.user.models

View File

@@ -0,0 +1,388 @@
"""v0.15.0 migration
Revision ID: 3c4d5e6f7a8b
Revises: 86b2e24e227e
Create Date: 2025-01-01 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3c4d5e6f7a8b"
down_revision: Union[str, None] = "86b2e24e227e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add new columns to server_settings table
op.add_column(
"server_settings",
sa.Column(
"signup_enabled",
sa.Boolean(),
nullable=True,
default=False,
comment="Allow user sign-up registration (true - yes, false - no)",
),
)
op.execute(
"""
UPDATE server_settings
SET signup_enabled = false
WHERE signup_enabled IS NULL;
"""
)
op.alter_column(
"server_settings",
"signup_enabled",
nullable=False,
comment="Allow user sign-up registration (true - yes, false - no)",
existing_type=sa.Boolean(),
)
op.add_column(
"server_settings",
sa.Column(
"signup_require_admin_approval",
sa.Boolean(),
nullable=True,
default=True,
comment="Require admin approval for new sign-ups (true - yes, false - no)",
),
)
op.execute(
"""
UPDATE server_settings
SET signup_require_admin_approval = false
WHERE signup_require_admin_approval IS NULL;
"""
)
op.alter_column(
"server_settings",
"signup_require_admin_approval",
nullable=False,
comment="Require admin approval for new sign-ups (true - yes, false - no)",
existing_type=sa.Boolean(),
)
op.add_column(
"server_settings",
sa.Column(
"signup_require_email_verification",
sa.Boolean(),
nullable=True,
default=True,
comment="Require email verification for new sign-ups (true - yes, false - no)",
),
)
op.execute(
"""
UPDATE server_settings
SET signup_require_email_verification = false
WHERE signup_require_email_verification IS NULL;
"""
)
op.alter_column(
"server_settings",
"signup_require_email_verification",
nullable=False,
comment="Require email verification for new sign-ups (true - yes, false - no)",
existing_type=sa.Boolean(),
)
# Add new columns to users table
op.add_column(
"users",
sa.Column(
"email_verified",
sa.Boolean(),
nullable=True,
default=False,
comment="Whether the user's email address has been verified (true - yes, false - no)",
),
)
op.execute(
"""
UPDATE users
SET email_verified = true
WHERE email_verified IS NULL;
"""
)
op.alter_column(
"users",
"email_verified",
nullable=False,
comment="Whether the user's email address has been verified (true - yes, false - no)",
existing_type=sa.Boolean(),
)
op.add_column(
"users",
sa.Column(
"pending_admin_approval",
sa.Boolean(),
nullable=True,
default=False,
comment="Whether the user is pending admin approval for activation (true - yes, false - no)",
),
)
op.execute(
"""
UPDATE users
SET pending_admin_approval = false
WHERE pending_admin_approval IS NULL;
"""
)
op.alter_column(
"users",
"pending_admin_approval",
nullable=False,
comment="Whether the user is pending admin approval for activation (true - yes, false - no)",
existing_type=sa.Boolean(),
)
op.add_column(
"users",
sa.Column(
"active",
sa.Boolean(),
nullable=True,
default=False,
comment="Whether the user is active (true - yes, false - no)",
),
)
op.execute(
"""
UPDATE users
SET active = true
WHERE is_active = 1;
UPDATE users
SET active = false
WHERE is_active = 2;
"""
)
op.alter_column(
"users",
"active",
nullable=False,
comment="Whether the user is active (true - yes, false - no)",
existing_type=sa.Boolean(),
)
op.drop_column("users", "is_active")
# Add new columns to gear table
op.add_column(
"gear",
sa.Column(
"active",
sa.Boolean(),
nullable=True,
default=True,
comment="Whether the gear is active (true - yes, false - no)",
),
)
op.execute(
"""
UPDATE gear
SET active = true
WHERE is_active = 1;
UPDATE gear
SET active = false
WHERE is_active = 0;
"""
)
op.alter_column(
"gear",
"active",
nullable=False,
comment="Whether the gear is active (true - yes, false - no)",
existing_type=sa.Boolean(),
)
op.drop_column("gear", "is_active")
# Add new columns to gear component table
op.add_column(
"gear_components",
sa.Column(
"active",
sa.Boolean(),
nullable=True,
default=True,
comment="Whether the gear component is active (true - yes, false - no)",
),
)
op.execute(
"""
UPDATE gear_components
SET active = true
WHERE is_active = true;
UPDATE gear_components
SET active = false
WHERE is_active = false;
"""
)
op.alter_column(
"gear_components",
"active",
nullable=False,
comment="Whether the gear component is active (true - yes, false - no)",
existing_type=sa.Boolean(),
)
op.drop_column("gear_components", "is_active")
# Sign up tokens table
op.create_table(
"sign_up_tokens",
sa.Column(
"id",
sa.String(length=64),
nullable=False,
),
sa.Column(
"user_id",
sa.Integer(),
nullable=False,
comment="User ID that the sign up token belongs to",
),
sa.Column(
"token_hash",
sa.String(length=128),
nullable=False,
comment="Hashed sign up token",
),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
comment="Token creation date (datetime)",
),
sa.Column(
"expires_at",
sa.DateTime(),
nullable=False,
comment="Token expiration date (datetime)",
),
sa.Column(
"used",
sa.Boolean(),
nullable=False,
comment="Token usage status (False - unused, True - used)",
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_sign_up_tokens_user_id"),
"sign_up_tokens",
["user_id"],
unique=False,
)
# Add the new entry to the migrations table
op.execute("""
INSERT INTO migrations (id, name, description, executed) VALUES
(6, 'v0.15.0', 'Lowercase user usernames', false);
""")
def downgrade() -> None:
# Remove the entry from the migrations table
op.execute("""
DELETE FROM migrations
WHERE id = 6;
""")
# Drop sign up tokens table
op.drop_index(
op.f("ix_sign_up_tokens_user_id"), table_name="sign_up_tokens"
)
op.drop_table("sign_up_tokens")
# Remove columns from gear_components table
op.add_column(
"gear_components",
sa.Column(
"is_active",
sa.Boolean(),
nullable=True,
default=True,
comment="Is gear component active",
),
)
op.execute(
"""
UPDATE gear_components
SET is_active = true
WHERE active = true;
UPDATE gear_components
SET is_active = false
WHERE active = false;
"""
)
op.alter_column(
"gear_components",
"is_active",
nullable=False,
comment="Is gear component active",
existing_type=sa.Boolean(),
)
op.drop_column("gear_components", "active")
# Remove columns from gear table
op.add_column(
"gear",
sa.Column(
"is_active",
sa.Integer(),
nullable=True,
default=1,
comment="Is gear active (0 - not active, 1 - active)",
),
)
op.execute(
"""
UPDATE gear
SET is_active = 1
WHERE active = true;
UPDATE gear
SET is_active = 0
WHERE active = false;
"""
)
op.alter_column(
"gear",
"is_active",
nullable=False,
comment="Is gear active (0 - not active, 1 - active)",
existing_type=sa.Integer(),
)
op.drop_column("gear", "active")
# Remove columns from users table
op.add_column(
"users",
sa.Column(
"is_active",
sa.Integer(),
nullable=True,
default=1,
comment="Is user active (1 - active, 2 - not active)",
),
)
op.execute(
"""
UPDATE users
SET is_active = 1
WHERE active = true;
UPDATE users
SET is_active = 2
WHERE active = false;
"""
)
op.alter_column(
"users",
"is_active",
nullable=False,
comment="Is user active (1 - active, 2 - not active)",
existing_type=sa.Integer(),
)
op.drop_column("users", "active")
op.drop_column("users", "pending_admin_approval")
op.drop_column("users", "email_verified")
# Remove columns from server_settings table
op.drop_column("server_settings", "signup_require_email_verification")
op.drop_column("server_settings", "signup_require_admin_approval")
op.drop_column("server_settings", "signup_enabled")

View File

@@ -1,4 +1,4 @@
import os
import os, secrets, hashlib
import apprise
from typing import List
@@ -216,4 +216,20 @@ def get_email_service():
return email_service
def generate_token_and_hash() -> tuple[str, str]:
"""
Generates a secure random token and its SHA-256 hash.
Returns:
tuple[str, str]: A tuple containing the generated token and its SHA-256 hash.
"""
# Generate a random 32-byte token
token = secrets.token_urlsafe(32)
# Create a hash of the token for database storage
token_hash = hashlib.sha256(token.encode()).hexdigest()
return token, token_hash
email_service = AppriseService()

View File

@@ -4,7 +4,7 @@ import threading
import core.logger as core_logger
# Constant related to version
API_VERSION = "v0.14.2"
API_VERSION = "v0.15.0"
LICENSE_NAME = "GNU Affero General Public License v3.0 or later"
LICENSE_IDENTIFIER = "AGPL-3.0-or-later"
LICENSE_URL = "https://spdx.org/licenses/AGPL-3.0-or-later.html"
@@ -22,6 +22,7 @@ ACTIVITY_MEDIA_DIR = os.getenv("ACTIVITY_MEDIA_DIR", f"{DATA_DIR}/activity_media
FILES_PROCESSED_DIR = f"{FILES_DIR}/processed"
FILES_BULK_IMPORT_DIR = f"{FILES_DIR}/bulk_import"
FILES_BULK_IMPORT_IMPORT_ERRORS_DIR = f"{FILES_BULK_IMPORT_DIR}/import_errors"
STRAVA_BULK_IMPORT_BIKES_FILE = "bikes.csv"
ENVIRONMENT = os.getenv("ENVIRONMENT", "production").lower()
TZ = os.getenv("TZ", "UTC")
REVERSE_GEO_PROVIDER = os.getenv("REVERSE_GEO_PROVIDER", "nominatim").lower()

View File

@@ -2,6 +2,7 @@ import logging
import core.config as core_config
def setup_main_logger():
"""
Sets up the main application logger and attaches a file handler to it, as well as to the Alembic and APScheduler loggers.
@@ -53,7 +54,9 @@ def get_main_logger():
return logging.getLogger("main_logger")
def print_to_log(message: str, log_level: str = "info", exc: Exception = None, context = None):
def print_to_log(
message: str, log_level: str = "info", exc: Exception = None, context=None
):
"""
Logs a message at the specified log level using the main logger.
@@ -76,6 +79,24 @@ def print_to_log(message: str, log_level: str = "info", exc: Exception = None, c
main_logger.debug(message)
def print_to_console(message: str, log_level: str = "info"):
"""
Prints a message to the console only (without logging to file).
Args:
message (str): The message to print.
log_level (str, optional): The log level to display ('info', 'error', 'warning', 'debug'). Defaults to "info".
"""
if log_level == "info":
print(f"INFO: {message}")
elif log_level == "error":
print(f"ERROR: {message}")
elif log_level == "warning":
print(f"WARNING: {message}")
elif log_level == "debug":
print(f"DEBUG: {message}")
def print_to_log_and_console(
message: str, log_level: str = "info", exc: Exception = None
):

View File

@@ -30,6 +30,7 @@ import server_settings.public_router as server_settings_public_router
import server_settings.router as server_settings_router
import session.router as session_router
import session.security as session_security
import sign_up_tokens.router as sign_up_tokens_router
import strava.router as strava_router
import users.user.router as users_router
import users.user_goals.router as user_goals_router
@@ -162,6 +163,11 @@ router.include_router(
prefix=core_config.ROOT_PATH,
tags=["sessions"],
)
router.include_router(
sign_up_tokens_router.router,
prefix=core_config.ROOT_PATH,
tags=["sign_up_tokens"],
)
router.include_router(
strava_router.router,
prefix=core_config.ROOT_PATH + "/strava",

View File

@@ -9,6 +9,8 @@ import garmin.health_utils as garmin_health_utils
import password_reset_tokens.utils as password_reset_tokens_utils
import sign_up_tokens.utils as sign_up_tokens_utils
import core.logger as core_logger
# scheduler = BackgroundScheduler()
@@ -60,6 +62,14 @@ def start_scheduler():
"delete invalid password reset tokens from the database",
)
add_scheduler_job(
sign_up_tokens_utils.delete_invalid_tokens_from_db,
"interval",
60,
[],
"delete invalid sign-up tokens from the database",
)
def add_scheduler_job(func, interval, minutes, args, description):
try:

View File

@@ -772,6 +772,8 @@ def parse_frame_session(frame):
activity_type = "indoor_ride"
elif activity_type == "cycling" and sub_sport == "mixed_surface":
activity_type = "mixed_surface_ride"
elif activity_type == 64 and sub_sport == 85:
activity_type = "padel"
else:
activity_type = sub_sport

View File

@@ -63,7 +63,7 @@ def process_gear(gear, user_id: int, db: Session) -> gears_schema.Gear | None:
nickname=gear["displayName"] if gear["displayName"] else gear["customMakeModel"],
gear_type=1 if gear["gearTypeName"] == "Bike" else 2,
user_id=user_id,
is_active=1 if gear["gearStatusName"] == "active" else 0,
active=True if gear["gearStatusName"] == "active" else False,
garminconnect_gear_id=gear["uuid"],
)

View File

@@ -111,9 +111,23 @@ def get_gear_user(user_id: int, db: Session) -> list[gears_schema.Gear] | None:
def get_gear_user_contains_nickname(
user_id: int, nickname: str, db: Session
) -> list[gears_schema.Gear] | None:
"""
Retrieve a list of gear objects for a given user where the gear's nickname contains the specified substring.
Args:
user_id (int): The ID of the user whose gear is being queried.
nickname (str): The substring to search for within gear nicknames. URL-encoded strings are supported.
db (Session): The SQLAlchemy database session.
Returns:
list[gears_schema.Gear] | None: A list of gear objects matching the criteria, or None if no gear is found.
Raises:
HTTPException: If an unexpected error occurs during the database query or processing.
"""
try:
# Unquote the nickname and change "+" to whitespace
parsed_nickname = unquote(nickname).replace("+", " ").lower()
parsed_nickname = unquote(nickname).replace("+", " ").lower().strip()
# Get the gear by user ID and nickname from the database
gears = (
@@ -151,9 +165,28 @@ def get_gear_user_contains_nickname(
def get_gear_user_by_nickname(
user_id: int, nickname: str, db: Session
) -> gears_schema.Gear | None:
"""
Retrieve a gear belonging to a user by its nickname.
This function attempts to find a gear in the database that matches the given user ID and nickname.
The nickname is URL-decoded, "+" characters are replaced with spaces, and the result is lowercased and stripped of whitespace before querying.
If a matching gear is found, it is serialized and returned; otherwise, None is returned.
In case of any exception, an error is logged and an HTTP 500 Internal Server Error is raised.
Args:
user_id (int): The ID of the user who owns the gear.
nickname (str): The nickname of the gear to retrieve.
db (Session): The SQLAlchemy database session.
Returns:
gears_schema.Gear | None: The serialized gear object if found, otherwise None.
Raises:
HTTPException: If an internal server error occurs during the process.
"""
try:
# Unquote the nickname and change "+" to whitespace
parsed_nickname = unquote(nickname).replace("+", " ").lower()
parsed_nickname = unquote(nickname).replace("+", " ").lower().strip()
# Get the gear by user ID and nickname from the database
gear = (
@@ -296,19 +329,54 @@ def get_gear_by_garminconnect_id_from_user_id(
def create_multiple_gears(gears: list[gears_schema.Gear], user_id: int, db: Session):
try:
# Filter out None values from the gears list
valid_gears = [gear for gear in gears if gear is not None]
# Create a list of gear objects
new_gears = [
gears_utils.transform_schema_gear_to_model_gear(gear, user_id)
for gear in valid_gears
# 1) Filter out None and gears without a usable nickname
valid_gears = [
gear
for gear in (gears or [])
if gear is not None
and getattr(gear, "nickname", None)
and str(gear.nickname).replace("+", " ").strip()
]
# Add the gears to the database
db.add_all(new_gears)
db.commit()
# 2) De-dupe within the valid_gears payload (case-insensitive, trimmed)
seen = set()
deduped: list[gears_schema.Gear] = []
for gear in valid_gears:
nickname_normalized = str(gear.nickname).replace("+", " ").lower().strip()
if nickname_normalized not in seen:
seen.add(nickname_normalized)
deduped.append(gear)
else:
core_logger.print_to_log_and_console(
f"Duplicate nickname '{gear.nickname}' in request for user {user_id}, skipping",
"warning",
)
# 3) Skip any that already exist for this user
gears_to_create: list[gears_schema.Gear] = []
for gear in deduped:
gear_check = get_gear_user_by_nickname(user_id, gear.nickname, db)
if gear_check is not None:
core_logger.print_to_log_and_console(
f"Gear with nickname '{gear.nickname}' already exists for user {user_id}, skipping",
"warning",
)
else:
gears_to_create.append(gear)
# 4) Persist any remaining
if gears_to_create:
new_gears = [
gears_utils.transform_schema_gear_to_model_gear(gear, user_id)
for gear in gears_to_create
]
db.add_all(new_gears)
db.commit()
except HTTPException as http_err:
# If an HTTPException is raised, re-raise it
raise http_err
except IntegrityError as integrity_error:
# Rollback the transaction
db.rollback()
@@ -316,9 +384,8 @@ def create_multiple_gears(gears: list[gears_schema.Gear], user_id: int, db: Sess
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Duplicate entry error. Check if nickname, strava_gear_id or garminconnect_gear_id are unique",
detail="Duplicate entry error. Check if strava_gear_id or garminconnect_gear_id are unique",
) from integrity_error
except Exception as err:
# Rollback the transaction
db.rollback()
@@ -337,9 +404,7 @@ def create_multiple_gears(gears: list[gears_schema.Gear], user_id: int, db: Sess
def create_gear(gear: gears_schema.Gear, user_id: int, db: Session):
try:
gear_check = get_gear_user_by_nickname(
user_id, gear.nickname, db
)
gear_check = get_gear_user_by_nickname(user_id, gear.nickname, db)
if gear_check is not None:
# If the gear already exists, raise an HTTPException with a 409 Conflict status code
@@ -403,8 +468,8 @@ def edit_gear(gear_id: int, gear: gears_schema.Gear, db: Session):
db_gear.gear_type = gear.gear_type
if gear.created_at is not None:
db_gear.created_at = gear.created_at
if gear.is_active is not None:
db_gear.is_active = gear.is_active
if gear.active is not None:
db_gear.active = gear.active
if gear.initial_kms is not None:
db_gear.initial_kms = gear.initial_kms
if gear.purchase_value is not None:

View File

@@ -5,6 +5,7 @@ from sqlalchemy import (
DateTime,
ForeignKey,
DECIMAL,
Boolean,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
@@ -45,8 +46,8 @@ class Gear(Base):
default=func.now(),
comment="Gear creation date (DateTime)",
)
is_active = Column(
Integer, nullable=False, comment="Is gear active (0 - not active, 1 - active)"
active = Column(
Boolean, nullable=False, comment="Whether the gear is active (true - yes, false - no)"
)
initial_kms = Column(
DECIMAL(precision=11, scale=2),

View File

@@ -8,7 +8,7 @@ class Gear(BaseModel):
gear_type: int
user_id: int | None = None
created_at: str | None = None
is_active: int | None = None
active: bool | None = None
initial_kms: float | None = None
purchase_value: float | None = None
strava_gear_id: str | None = None

View File

@@ -1,7 +1,13 @@
from sqlalchemy import func
from urllib.parse import unquote
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
import session.security as session_security
import core.database as core_database
import gears.gear.models as gears_models
import gears.gear.crud as gears_crud
import gears.gear.schema as gears_schema
# Global gear type integer to gear name mapping (ID to name)
@@ -37,6 +43,7 @@ GEAR_NAME_TO_ID.update(
}
)
def transform_schema_gear_to_model_gear(
gear: gears_schema.Gear, user_id: int
) -> gears_models.Gear:
@@ -50,16 +57,20 @@ def transform_schema_gear_to_model_gear(
# Create a new gear object
new_gear = gears_models.Gear(
brand=(
unquote(gear.brand).replace("+", " ") if gear.brand is not None else None
unquote(gear.brand).replace("+", " ").strip()
if gear.brand is not None
else None
),
model=(
unquote(gear.model).replace("+", " ") if gear.model is not None else None
unquote(gear.model).replace("+", " ").strip()
if gear.model is not None
else None
),
nickname=unquote(gear.nickname).replace("+", " "),
nickname=unquote(gear.nickname).replace("+", " ").strip(),
gear_type=gear.gear_type,
user_id=user_id,
created_at=created_date,
is_active=gear.is_active,
active=gear.active,
initial_kms=gear.initial_kms,
purchase_value=gear.purchase_value,
strava_gear_id=gear.strava_gear_id,

View File

@@ -117,7 +117,7 @@ def create_gear_component(
brand=gear_component.brand,
model=gear_component.model,
purchase_date=gear_component.purchase_date,
is_active=True,
active=True,
expected_kms=gear_component.expected_kms,
purchase_value=gear_component.purchase_value,
)

View File

@@ -59,8 +59,11 @@ class GearComponents(Base):
nullable=True,
comment="Gear component retired date (DateTime)",
)
is_active = Column(
Boolean, nullable=False, default=False, comment="Is gear component active"
active = Column(
Boolean,
nullable=False,
default=False,
comment="Whether the gear component is active (true - yes, false - no)",
)
expected_kms = Column(
Integer,

View File

@@ -97,7 +97,7 @@ class GearComponents(BaseModel):
model (str): Model name or number of the gear component.
purchase_date (str): Date when the component was purchased (ISO format recommended).
retired_date (str | None): Date when the component was retired, if applicable.
is_active (bool | None): Indicates if the component is currently active.
active (bool | None): Indicates if the component is currently active.
expected_kms (int | None): Expected kilometers the component should last.
purchase_value (float | None): Purchase value of the component.
"""
@@ -109,7 +109,7 @@ class GearComponents(BaseModel):
model: str
purchase_date: str
retired_date: str | None = None
is_active: bool | None = None
active: bool | None = None
expected_kms: int | None = None
purchase_value: float | None = None

View File

@@ -84,7 +84,7 @@ def parse_gpx_file(
for track in gpx.tracks:
# Set activity name, description, and type if available
activity_name = track.name if track.name else gpx.name if gpx.name else "Workout"
activity_description = track.description if track.description else gpx.description if gpx.description else None
activity_description = track.description if track.description else gpx.description if gpx.description else None
activity_type = track.type if track.type else "Workout"
if track.segments:

View File

@@ -23,6 +23,8 @@ import strava.utils as strava_utils
import password_reset_tokens.utils as password_reset_tokens_utils
import sign_up_tokens.utils as sign_up_tokens_utils
from core.routes import router as api_router
@@ -68,6 +70,12 @@ async def startup_event():
)
password_reset_tokens_utils.delete_invalid_tokens_from_db()
# Delete invalid sign-up tokens
core_logger.print_to_log_and_console(
"Deleting invalid sign-up tokens from the database"
)
sign_up_tokens_utils.delete_invalid_tokens_from_db()
def shutdown_event():
# Log the shutdown event
@@ -161,4 +169,4 @@ core_tracing.setup_tracing(app)
app.add_event_handler("startup", startup_event)
# Register the shutdown event handler
app.add_event_handler("shutdown", shutdown_event)
app.add_event_handler("shutdown", shutdown_event)

View File

@@ -0,0 +1,61 @@
from sqlalchemy.orm import Session
import core.logger as core_logger
import migrations.crud as migrations_crud
import users.user.crud as user_crud
import users.user.schema as users_schema
def process_migration_6(db: Session):
core_logger.print_to_log_and_console("Started migration 6")
users_processed_with_no_errors = True
try:
users = user_crud.get_all_users(db)
except Exception as err:
core_logger.print_to_log_and_console(
f"Migration 6 - Error fetching users: {err}",
"error",
exc=err,
)
users_processed_with_no_errors = False
if users:
for user in users:
try:
user.username = user.username.lower()
if user.birthdate:
user.birthdate = user.birthdate.isoformat()
user_converted = users_schema.UserRead.model_validate(user)
user_crud.edit_user(user.id, user_converted, db)
except Exception as err:
core_logger.print_to_log_and_console(
f"Migration 6 - Error processing user {user.id}: {err}",
"error",
exc=err,
)
users_processed_with_no_errors = False
continue
# Mark migration as executed
if users_processed_with_no_errors:
try:
migrations_crud.set_migration_as_executed(6, db)
except Exception as err:
core_logger.print_to_log_and_console(
f"Migration 6 - Failed to set migration as executed: {err}",
"error",
exc=err,
)
return
else:
core_logger.print_to_log_and_console(
"Migration 6 failed to process all users. Will try again later.",
"error",
)
core_logger.print_to_log_and_console("Finished migration 6")

View File

@@ -6,6 +6,7 @@ import migrations.migration_2 as migrations_migration_2
import migrations.migration_3 as migrations_migration_3
import migrations.migration_4 as migrations_migration_4
import migrations.migration_5 as migrations_migration_5
import migrations.migration_6 as migrations_migration_6
import core.logger as core_logger
@@ -39,3 +40,7 @@ def check_migrations_not_executed(db: Session):
if migration.id == 5:
# Execute the migration
migrations_migration_5.process_migration_5(db)
if migration.id == 6:
# Execute the migration
migrations_migration_6.process_migration_6(db)

View File

@@ -3,3 +3,4 @@ TYPE_NEW_ACTIVITY = 1
TYPE_DUPLICATE_ACTIVITY = 2
TYPE_NEW_FOLLOWER_REQUEST = 11
TYPE_NEW_FOLLOWER_REQUEST_ACCEPTED = 12
TYPE_ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST = 101

View File

@@ -8,7 +8,9 @@ import notifications.constants as notifications_constants
import notifications.crud as notifications_crud
import notifications.schema as notifications_schema
import users.user.crud as user_crud
import users.user.crud as users_crud
import users.user.models as users_models
import users.user.utils as users_utils
import websocket.utils as websocket_utils
import websocket.schema as websocket_schema
@@ -117,7 +119,7 @@ async def create_new_follower_request_notification(
db: Session,
):
try:
user = user_crud.get_user_by_id(user_id, db)
user = users_crud.get_user_by_id(user_id, db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -172,7 +174,7 @@ async def create_accepted_follower_request_notification(
db: Session,
):
try:
user = user_crud.get_user_by_id(user_id, db)
user = users_crud.get_user_by_id(user_id, db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -198,9 +200,7 @@ async def create_accepted_follower_request_notification(
"message": "NEW_FOLLOWER_REQUEST_ACCEPTED_NOTIFICATION",
"notification_id": notification.id,
}
await websocket_utils.notify_frontend(
user_id, websocket_manager, json_data
)
await websocket_utils.notify_frontend(user_id, websocket_manager, json_data)
# Return the serialized notification
return notification
@@ -218,3 +218,49 @@ async def create_accepted_follower_request_notification(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
async def create_admin_new_sign_up_approval_request_notification(
user: users_models.User,
websocket_manager: websocket_schema.WebSocketManager,
db: Session,
):
try:
admins = users_utils.get_admin_users(db)
# Send notification to all admin users
for admin in admins:
# Create a notification for the new sign up request
notification = notifications_crud.create_notification(
notifications_schema.Notification(
user_id=admin.id,
type=notifications_constants.TYPE_ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST,
options={
"user_id": user.id,
"user_name": user.name,
"user_username": user.username,
},
),
db,
)
# Notify the frontend about the new sign up request
json_data = {
"message": "ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST_NOTIFICATION",
"notification_id": notification.id,
}
await websocket_utils.notify_frontend(admin.id, websocket_manager, json_data)
except HTTPException as http_err:
raise http_err
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in create_admin_new_sign_up_approval_request_notification: {err}",
"error",
exc=err,
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err

View File

@@ -78,6 +78,29 @@ def create_password_reset_token(
def get_password_reset_token_by_hash(
token_hash: str, db: Session
) -> password_reset_tokens_models.PasswordResetToken | None:
"""
Retrieve an unused, unexpired PasswordResetToken that matches the provided token hash.
Parameters:
token_hash (str): The hashed token value to look up.
db (Session): SQLAlchemy Session used to query the password_reset_tokens table.
Returns:
password_reset_tokens_models.PasswordResetToken | None:
The matching PasswordResetToken instance if an unused token exists and its
expires_at is strictly in the future (compared to datetime.now(timezone.utc));
otherwise None when no valid token is found.
Raises:
HTTPException:
Raises an HTTPException with status_code 500 if an unexpected error occurs
while querying the database. The underlying exception is logged before
the HTTPException is raised.
Notes:
- The function filters tokens by token_hash, used == False, and expires_at > now (UTC).
- Side effects: unexpected errors are logged via core_logger.print_to_log.
"""
try:
# Get the token from the database
db_token = (

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, ConfigDict
from datetime import datetime
@@ -10,7 +10,9 @@ class PasswordResetToken(BaseModel):
expires_at: datetime
used: bool
model_config = {"from_attributes": True}
model_config = ConfigDict(
from_attributes=True, extra="forbid", validate_assignment=True
)
class PasswordResetRequest(BaseModel):

View File

@@ -1,12 +1,10 @@
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from fastapi import (
HTTPException,
status,
)
from uuid import uuid4
import hashlib
from sqlalchemy.orm import Session
@@ -22,34 +20,6 @@ import core.logger as core_logger
from core.database import SessionLocal
def generate_password_reset_token() -> tuple[str, str]:
"""
Generate a URL-safe password reset token and its SHA-256 hash for storage.
Returns:
tuple[str, str]: A tuple (token, token_hash) where:
- token: a URL-safe, cryptographically secure random token suitable for
inclusion in password reset links (this raw token is intended to be
sent to the user).
- token_hash: the hexadecimal SHA-256 hash of the token, suitable for
storing in a database instead of the raw token.
Notes:
- Do not store or log the raw token; store only the hash (token_hash).
- When validating a presented token, compute its SHA-256 hex digest and
compare it to the stored token_hash using a constant-time comparison to
mitigate timing attacks (e.g., secrets.compare_digest).
- Consider associating an expiration timestamp and single-use semantics with
the token to limit its validity window.
- Token generation relies on the `secrets` module for cryptographic randomness.
"""
# Generate a random 32-byte token
token = secrets.token_urlsafe(32)
# Create a hash of the token for database storage
token_hash = hashlib.sha256(token.encode()).hexdigest()
return token, token_hash
def create_password_reset_token(user_id: int, db: Session) -> str:
"""
Create and persist a password reset token for a user and return the plain token.
@@ -93,7 +63,7 @@ def create_password_reset_token(user_id: int, db: Session) -> str:
# Send `token` to the user's email. Do not store the plaintext token in persistent storage.
"""
# Generate token and hash
token, token_hash = generate_password_reset_token()
token, token_hash = core_apprise.generate_token_and_hash()
# Create token object
reset_token = password_reset_tokens_schema.PasswordResetToken(
@@ -123,7 +93,7 @@ async def send_password_reset_email(
2. Attempts to locate the user record for the given email in the provided DB session.
- For security (to avoid user enumeration), if the user does not exist the function
returns True and does not indicate existence to the caller.
3. Verifies the located user is active (expects user.is_active == 1).
3. Verifies the located user is active.
- If the user is inactive the function returns True for the same security reason.
4. Creates a password reset token and persists it via create_password_reset_token.
5. Constructs a frontend reset URL using the email_service.frontend_host and the token.
@@ -175,7 +145,7 @@ async def send_password_reset_email(
return True
# Check if user is active
if user.is_active != 1:
if not user.active:
# Don't reveal if user is inactive for security
return True

View File

@@ -208,7 +208,7 @@ async def upload_profile_image(
@router.put("")
async def edit_user(
user_attributtes: users_schema.User,
user_attributtes: users_schema.UserRead,
token_user_id: Annotated[
int,
Depends(session_security.get_user_id_from_access_token),
@@ -222,7 +222,7 @@ async def edit_user(
Edits the attributes of an existing user in the database.
Args:
user_attributtes (users_schema.User): The updated user attributes to be saved.
user_attributtes (users_schema.UserRead): The updated user attributes to be saved.
token_user_id (int): The ID of the user extracted from the access token.
db (Session): The database session dependency.
@@ -836,7 +836,7 @@ async def import_profile_data(
"photo_path"
] = f"data/user_images/{token_user_id}.{extension}"
# convert user data to User schema
user = users_schema.User(**results["user_data"])
user = users_schema.UserRead(**results["user_data"])
# Update user
users_crud.edit_user(token_user_id, user, db)
counts["user"] += 1

View File

@@ -1,10 +1,5 @@
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from urllib.parse import unquote
import session.security as session_security
import server_settings.schema as server_settings_schema
import server_settings.models as server_settings_models
@@ -37,13 +32,17 @@ def get_server_settings(db: Session):
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error: {err}",
) from err
def edit_server_settings(server_settings: server_settings_schema.ServerSettings, db: Session):
def edit_server_settings(
server_settings: server_settings_schema.ServerSettingsEdit, db: Session
):
try:
# Get the server_settings from the database
db_server_settings = (
db.query(server_settings_models.ServerSettings).filter(server_settings_models.ServerSettings.id == 1).first()
db.query(server_settings_models.ServerSettings)
.filter(server_settings_models.ServerSettings.id == 1)
.first()
)
if db_server_settings is None:
@@ -70,7 +69,9 @@ def edit_server_settings(server_settings: server_settings_schema.ServerSettings,
db.rollback()
# Log the exception
core_logger.print_to_log(f"Error in edit_server_settings: {err}", "error", exc=err)
core_logger.print_to_log(
f"Error in edit_server_settings: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(

View File

@@ -42,5 +42,23 @@ class ServerSettings(Base):
default=25,
comment="Number of records per page in lists",
)
signup_enabled = Column(
Boolean,
nullable=False,
default=False,
comment="Allow user sign-up registration (true - yes, false - no)",
)
signup_require_admin_approval = Column(
Boolean,
nullable=False,
default=True,
comment="Require admin approval for new sign-ups (true - yes, false - no)",
)
signup_require_email_verification = Column(
Boolean,
nullable=False,
default=True,
comment="Require email verification for new sign-ups (true - yes, false - no)",
)
__table_args__ = (CheckConstraint("id = 1", name="single_row_check"),)

View File

@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
import server_settings.schema as server_settings_schema
import server_settings.crud as server_settings_crud
import server_settings.utils as server_settings_utils
import core.database as core_database
@@ -12,7 +12,7 @@ import core.database as core_database
router = APIRouter()
@router.get("", response_model=server_settings_schema.ServerSettings)
@router.get("", response_model=server_settings_schema.ServerSettingsReadPublic)
async def read_public_server_settings(
db: Annotated[
Session,
@@ -20,4 +20,9 @@ async def read_public_server_settings(
],
):
# Get the server_settings from the database
return server_settings_crud.get_server_settings(db)
server_settings = server_settings_utils.get_server_settings(db)
delattr(server_settings, "signup_require_admin_approval")
delattr(server_settings, "signup_require_email_verification")
return server_settings

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
import server_settings.schema as server_settings_schema
import server_settings.crud as server_settings_crud
import server_settings.utils as server_settings_utils
import session.security as session_security
@@ -17,7 +18,7 @@ import core.config as core_config
router = APIRouter()
@router.get("", response_model=server_settings_schema.ServerSettings)
@router.get("", response_model=server_settings_schema.ServerSettingsRead)
async def read_server_settings(
check_scopes: Annotated[
Callable,
@@ -29,12 +30,12 @@ async def read_server_settings(
],
):
# Get the server_settings from the database
return server_settings_crud.get_server_settings(db)
return server_settings_utils.get_server_settings(db)
@router.put("", response_model=server_settings_schema.ServerSettings)
@router.put("", response_model=server_settings_schema.ServerSettingsRead)
async def edit_server_settings(
server_settings_attributtes: server_settings_schema.ServerSettings,
server_settings_attributtes: server_settings_schema.ServerSettingsEdit,
check_scopes: Annotated[
Callable,
Security(session_security.check_scopes, scopes=["server_settings:write"]),

View File

@@ -1,15 +1,91 @@
from pydantic import BaseModel
from enum import IntEnum
from pydantic import BaseModel, StrictInt, ConfigDict
class Units(IntEnum):
"""
An enumeration representing measurement units.
Attributes:
METRIC (int): Metric system (e.g., meters, kilograms).
IMPERIAL (int): Imperial system (e.g., miles, pounds).
"""
METRIC = 1
IMPERIAL = 2
class Currency(IntEnum):
"""
An enumeration representing supported currencies.
Attributes:
EURO (int): Represents the Euro currency.
DOLLAR (int): Represents the US Dollar currency.
POUND (int): Represents the British Pound currency.
"""
EURO = 1
DOLLAR = 2
POUND = 3
class ServerSettings(BaseModel):
id: int
units: int
"""
Represents the configuration settings for a server.
Attributes:
id (StrictInt): Unique identifier for the server settings.
units (Units): Measurement units used by the server.
public_shareable_links (bool): Indicates if public shareable links are enabled.
public_shareable_links_user_info (bool): Indicates if user information is included in public shareable links.
login_photo_set (bool): Specifies if a login photo has been set.
currency (Currency): Currency used by the server.
num_records_per_page (int): Number of records displayed per page.
signup_enabled (bool): Indicates if user signup is enabled.
"""
id: StrictInt
units: Units
public_shareable_links: bool
public_shareable_links_user_info: bool
login_photo_set: bool
currency: int
currency: Currency
num_records_per_page: int
signup_enabled: bool
model_config = {
"from_attributes": True
}
model_config = ConfigDict(
from_attributes=True,
extra="forbid",
validate_assignment=True,
use_enum_values=True,
)
class ServerSettingsEdit(ServerSettings):
"""
Extends ServerSettings with additional fields for user signup configuration.
Attributes:
signup_require_admin_approval (bool): Indicates if new user signups require admin approval.
signup_require_email_verification (bool): Indicates if new user signups require email verification.
"""
signup_require_admin_approval: bool
signup_require_email_verification: bool
class ServerSettingsRead(ServerSettingsEdit):
"""
Represents a read-only view of server settings, inheriting all fields and validation from ServerSettingsEdit.
This class is typically used for serializing server settings data for API responses.
"""
class ServerSettingsReadPublic(ServerSettings):
"""
A public-facing schema for reading server settings.
This class inherits all fields and behaviors from `ServerSettings` and is intended
for use cases where only public server settings should be exposed.
"""

View File

@@ -0,0 +1,17 @@
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
import server_settings.crud as server_settings_crud
def get_server_settings(db: Session):
server_settings = server_settings_crud.get_server_settings(db)
if not server_settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Server settings not found",
headers={"WWW-Authenticate": "Bearer"},
)
return server_settings

View File

@@ -2,7 +2,9 @@ import os
# JWT Token constants
JWT_ALGORITHM = os.environ.get("ALGORITHM", "HS256")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = int(
os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "15")
)
JWT_REFRESH_TOKEN_EXPIRE_DAYS = int(os.environ.get("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
JWT_SECRET_KEY = os.environ.get("SECRET_KEY")
@@ -11,9 +13,14 @@ USERS_REGULAR_SCOPES = ["profile", "users:read"]
USERS_ADMIN_SCOPES = ["users:write", "sessions:read", "sessions:write"]
GEARS_SCOPES = ["gears:read", "gears:write"]
ACTIVITIES_SCOPES = ["activities:read", "activities:write"]
HEALTH_SCOPES = ["health:read", "health:write", "health_targets:read", "health_targets:write"]
SERVER_SETTINGS_REGULAR_SCOPES = ["server_settings:read"]
SERVER_SETTINGS_ADMIN_SCOPES = ["server_settings:write"]
HEALTH_SCOPES = [
"health:read",
"health:write",
"health_targets:read",
"health_targets:write",
]
SERVER_SETTINGS_REGULAR_SCOPES = []
SERVER_SETTINGS_ADMIN_SCOPES = ["server_settings:read", "server_settings:write"]
SCOPES_DICT = {
"profile": "Privileges over user's own profile",
"users:read": "Read privileges over users",
@@ -33,11 +40,13 @@ SCOPES_DICT = {
}
# Constants related to user access types
REGULAR_ACCESS = 1
REGULAR_ACCESS_SCOPES = USERS_REGULAR_SCOPES + GEARS_SCOPES + ACTIVITIES_SCOPES + HEALTH_SCOPES + SERVER_SETTINGS_REGULAR_SCOPES
ADMIN_ACCESS = 2
ADMIN_ACCESS_SCOPES = REGULAR_ACCESS_SCOPES + USERS_ADMIN_SCOPES + SERVER_SETTINGS_ADMIN_SCOPES
# Constants related to user active status
USER_ACTIVE = 1
USER_NOT_ACTIVE = 2
REGULAR_ACCESS_SCOPES = (
USERS_REGULAR_SCOPES
+ GEARS_SCOPES
+ ACTIVITIES_SCOPES
+ HEALTH_SCOPES
+ SERVER_SETTINGS_REGULAR_SCOPES
)
ADMIN_ACCESS_SCOPES = (
REGULAR_ACCESS_SCOPES + USERS_ADMIN_SCOPES + SERVER_SETTINGS_ADMIN_SCOPES
)

View File

@@ -50,27 +50,29 @@ async def login_for_access_token(
if profile_utils.is_mfa_enabled_for_user(user.id, db):
# Store the user for pending MFA verification
pending_mfa_store.add_pending_login(form_data.username, user.id)
# Return MFA required response
if client_type == "web":
response.status_code = status.HTTP_202_ACCEPTED
return session_schema.MFARequiredResponse(
mfa_required=True,
username=form_data.username,
message="MFA verification required"
message="MFA verification required",
)
if client_type == "mobile":
return {
"mfa_required": True,
"username": form_data.username,
"message": "MFA verification required"
"message": "MFA verification required",
}
# If no MFA required, proceed with normal login
return await complete_login(response, request, user, client_type, db)
async def complete_login(response: Response, request: Request, user, client_type: str, db: Session):
async def complete_login(
response: Response, request: Request, user, client_type: str, db: Session
):
# Create the tokens
access_token, refresh_token, csrf_token = session_utils.create_tokens(user)
@@ -122,31 +124,29 @@ async def verify_mfa_and_login(
if not user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No pending MFA login found for this username"
detail="No pending MFA login found for this username",
)
# Verify the MFA code
if not profile_utils.verify_user_mfa(user_id, mfa_request.mfa_code, db):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid MFA code"
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MFA code"
)
# Get the user and complete login
user = users_crud.get_user_by_id(user_id, db)
if not user:
pending_mfa_store.delete_pending_login(mfa_request.username)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
# Check if the user is still active
users_utils.check_user_is_active(user)
# Clean up pending login
pending_mfa_store.delete_pending_login(mfa_request.username)
# Complete the login
return await complete_login(response, request, user, client_type, db)

View File

@@ -76,6 +76,8 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"/api/v1/mfa/verify",
"/api/v1/password-reset/request",
"/api/v1/password-reset/confirm",
"/api/v1/sign-up/request",
"/api/v1/sign-up/confirm"
]
async def dispatch(self, request: Request, call_next):

View File

@@ -112,7 +112,7 @@ def validate_token_expiration(token: Annotated[str, Depends(oauth2_scheme)]) ->
# Validate token expiration
claims_requests.validate(payload.claims)
except jwt.JWTClaimsError as claims_err:
except jwt.InvalidClaimError as claims_err:
core_logger.print_to_log(
f"JWT claims validation error: {claims_err}",
"error",

View File

@@ -22,7 +22,7 @@ import users.user.schema as users_schema
def create_session_object(
user: users_schema.User,
user: users_schema.UserRead,
request: Request,
refresh_token: str,
refresh_token_exp: datetime,
@@ -92,9 +92,9 @@ def authenticate_user(username: str, password: str, db: Session):
return user
def create_tokens(user: users_schema.User):
def create_tokens(user: users_schema.UserRead):
# Check user access level and set scopes accordingly
if user.access_type == session_constants.REGULAR_ACCESS:
if user.access_type == users_schema.UserAccessType.REGULAR:
scopes = session_constants.REGULAR_ACCESS_SCOPES
else:
scopes = session_constants.ADMIN_ACCESS_SCOPES
@@ -165,7 +165,7 @@ def create_response_with_tokens(
def create_session(
user: users_schema.User,
user: users_schema.UserRead,
request: Request,
refresh_token: str,
db: Session,

View File

View File

@@ -0,0 +1,264 @@
from fastapi import HTTPException, status
from sqlalchemy import and_
from sqlalchemy.orm import Session
from datetime import datetime, timezone
import sign_up_tokens.schema as sign_up_tokens_schema
import sign_up_tokens.models as sign_up_tokens_models
import core.logger as core_logger
def get_sign_up_token_by_hash(
token_hash: str, db: Session
) -> sign_up_tokens_models.SignUpToken | None:
"""
Retrieve an unused, unexpired SignUpToken matching the provided token hash.
Parameters
----------
token_hash : str
The hashed token value to look up in the database.
db : Session
The SQLAlchemy Session used to perform the query.
Returns
-------
sign_up_tokens_models.SignUpToken | None
The SignUpToken model instance if a matching token exists, is not marked as used,
and has an expires_at timestamp later than the current UTC time. Returns None when
no valid token is found.
Raises
------
HTTPException
If an unexpected error occurs during the database query, the exception is logged
and an HTTPException with status code 500 (Internal Server Error) is raised.
Notes
-----
- The function filters tokens by: token_hash equality, used == False, and expires_at > now (UTC).
- Any caught exception is logged via core_logger.print_to_log before raising the HTTPException.
"""
try:
# Get the token from the database
db_token = (
db.query(sign_up_tokens_models.SignUpToken)
.filter(
and_(
sign_up_tokens_models.SignUpToken.token_hash == token_hash,
sign_up_tokens_models.SignUpToken.used == False,
sign_up_tokens_models.SignUpToken.expires_at
> datetime.now(timezone.utc),
)
)
.first()
)
# Return the token (can be None if not found)
return db_token
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in get_sign_up_token_by_hash: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def create_sign_up_token(
token: sign_up_tokens_schema.SignUpToken, db: Session
) -> sign_up_tokens_models.SignUpToken:
"""
Create and persist a sign-up token in the database.
Parameters
----------
token : sign_up_tokens_schema.SignUpToken
Schema object containing the token data to store. Expected fields include
id, user_id, token_hash, created_at, expires_at, and used.
db : Session
SQLAlchemy session used for the database transaction.
Returns
-------
sign_up_tokens_models.SignUpToken
The persisted SignUpToken model instance refreshed from the database so
any DB-generated values (defaults, timestamps, etc.) are populated.
Side effects
------------
- Adds a new SignUpToken row to the database and commits the transaction on success.
- Calls db.refresh() to populate the returned model with persisted state.
- On error, rolls back the transaction and logs the exception via core_logger.print_to_log().
Raises
------
HTTPException
An HTTPException with status_code 500 (Internal Server Error) is raised if
any exception occurs during creation. The original exception is chained
to the raised HTTPException.
"""
try:
# Create a new sign up token
db_token = sign_up_tokens_models.SignUpToken(
id=token.id,
user_id=token.user_id,
token_hash=token.token_hash,
created_at=token.created_at,
expires_at=token.expires_at,
used=token.used,
)
# Add the token to the database
db.add(db_token)
db.commit()
db.refresh(db_token)
# Return the token
return db_token
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in create_sign_up_token: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def mark_sign_up_token_used(
token_id: str, db: Session
) -> sign_up_tokens_models.SignUpToken | None:
"""
Mark a sign-up token as used.
This function looks up a SignUpToken by its ID and, if found, sets its 'used'
attribute to True and commits the change to the provided SQLAlchemy session.
If no token with the given ID exists, the function returns None and does not
modify the database.
Args:
token_id (str): The unique identifier of the sign-up token to mark as used.
db (Session): An active SQLAlchemy Session used to query and persist changes.
Returns:
sign_up_tokens_models.SignUpToken | None: The updated SignUpToken instance
if it was found (with 'used' set to True), otherwise None.
Raises:
HTTPException: If an unexpected error occurs while accessing or committing
to the database, the function rolls back the transaction, logs the error,
and raises an HTTPException with status code 500 (Internal Server Error).
Side effects:
- Commits the transaction when a token is found and updated.
- Rolls back the transaction on exception.
- Logs exceptions via core_logger.print_to_log.
Notes:
- The caller is responsible for providing a managed Session. This function
performs commit/rollback and therefore affects session state.
- For concurrent scenarios, consider appropriate locking or transactional
isolation to avoid race conditions when marking tokens as used.
"""
try:
# Get the token from the database
db_token = (
db.query(sign_up_tokens_models.SignUpToken)
.filter(sign_up_tokens_models.SignUpToken.id == token_id)
.first()
)
if db_token:
# Mark the token as used
db_token.used = True
db.commit()
return db_token
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in mark_sign_up_token_used: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def delete_expired_sign_up_tokens(db: Session):
"""
Delete expired SignUpToken records from the database.
This function deletes all SignUpToken rows whose `expires_at` is earlier than
the current UTC time. It performs the deletion using the provided SQLAlchemy
Session, commits the transaction on success, and returns the number of rows
deleted. If an error occurs, the transaction is rolled back, the error is
logged, and an HTTPException with a 500 status code is raised (the original
exception is preserved for chaining).
Args:
db (Session): An active SQLAlchemy Session used to execute the delete and
commit operations.
Returns:
int: The number of SignUpToken records removed by the operation.
Raises:
HTTPException: Raised with status_code=500 if any unexpected error occurs
during deletion or commit.
Side effects and notes:
- Permanently removes matching rows from the database.
- Commits the transaction on success; rolls back the transaction on error.
- Uses UTC-aware comparison (datetime.now(timezone.utc)) to evaluate expiration.
- Uses a bulk query-level delete; such bulk operations may bypass ORM-level
cascades, event hooks, and may not synchronize in-memory objects in the
session. If the session holds SignUpToken instances, consider session
synchronization or expiring/refreshing those objects after the operation.
"""
try:
# Delete expired tokens
num_deleted = (
db.query(sign_up_tokens_models.SignUpToken)
.filter(
sign_up_tokens_models.SignUpToken.expires_at
< datetime.now(timezone.utc)
)
.delete()
)
# Commit the transaction
db.commit()
return num_deleted
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in delete_expired_sign_up_tokens: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err

View File

@@ -0,0 +1,254 @@
import core.apprise as core_apprise
def get_signup_confirmation_email_en(
user_name: str, signup_link: str, email_service: core_apprise.AppriseService
) -> tuple:
"""
Return the subject, HTML body, and plain-text body for an English sign-up confirmation email.
Args:
user_name (str): The recipient's display name inserted into the greeting.
signup_link (str): The URL the user will follow to confirm their sign-up; inserted into the CTA button
and included as a plain link for clients that do not render the button.
email_service (core_apprise.AppriseService): Notification service instance used to obtain service
metadata (e.g., `frontend_host`) for the email footer.
Returns:
tuple[str, str, str]: A 3-tuple containing:
- subject: The email subject line.
- html_content: The full HTML email content (string) including inline styles, logo, a prominent
"Confirm Account" button linking to `signup_link`, a security notice about a 24-hour expiry, and
a footer referencing `email_service.frontend_host`.
- text_content: A plain-text alternative suitable for clients that do not render HTML, containing
the greeting, confirmation instructions, the raw `signup_link`, expiry notice, and sign-off.
Notes:
- The function only constructs and returns strings; it does not send emails or perform network I/O.
- Calling code should ensure `signup_link` and `user_name` are properly validated/sanitized as needed.
- The HTML is crafted with inline styles for broad email-client compatibility.
"""
subject = "Endurain - Confirm your account"
html_content = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{subject}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f4f4f4;">
<div style="background-color: #ffffff; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">
<div style="text-align: center; margin-bottom: 30px;">
<div style="font-size: 34px; font-weight: bold; margin-bottom: 10px; display: flex; align-items: center; justify-content: center; gap: 10px;">
<img src="https://github.com/joaovitoriasilva/endurain/blob/0e17fafe450b66eda7982311e6f94cee44316684/frontend/app/public/logo/logo.svg?raw=true"
alt="Endurain logo" style="height: 32px; width: auto;">
<span>Endurain</span>
</div>
<h3 style="margin: 0;">Confirm your account</h3>
</div>
<div style="margin-bottom: 30px;">
<p>Hi {user_name},</p>
<p>Thank you for signing up for Endurain! Please confirm your account by clicking the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{signup_link}" style="background-color: #198754; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Confirm Account</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Security notice:</strong> This confirmation link will expire in 24 hours.
</div>
<p>If you didnt create an Endurain account, please ignore this email.</p>
<p>If the button above doesnt work, you can copy and paste the following link into your browser:</p>
<p style="word-break: break-all; color: #198754;">{signup_link}</p>
</div>
<div style="text-align: center; font-size: 12px; color: #666; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
<p>Best regards,<br>The Endurain team</p>
<p>Visit Endurain at: <a style="color: #198754;" href="{email_service.frontend_host}">{email_service.frontend_host}</a> -
Source code at: <a style="color: #198754;"
href="https://github.com/joaovitoriasilva/endurain">GitHub</a></p>
</div>
</div>
</body>
</html>
""".strip()
# Create text version
text_content = f"""
Hi {user_name},
Thank you for signing up for Endurain!
Please confirm your account by clicking the following link:
{signup_link}
This confirmation link will expire in 24 hours.
If you didnt create an Endurain account, please ignore this email.
Best regards,
The Endurain team
""".strip()
return subject, html_content, text_content
def get_admin_signup_notification_email_en(
user_name: str,
sign_up_user_name: str,
sign_up_user_username: str,
email_service: core_apprise.AppriseService,
) -> tuple:
subject = "Endurain - New user sign-up pending approval"
html_content = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{subject}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f4f4f4;">
<div style="background-color: #ffffff; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">
<div style="text-align: center; margin-bottom: 30px;">
<div style="font-size: 34px; font-weight: bold; margin-bottom: 10px; display: flex; align-items: center; justify-content: center; gap: 10px;">
<img src="https://github.com/joaovitoriasilva/endurain/blob/0e17fafe450b66eda7982311e6f94cee44316684/frontend/app/public/logo/logo.svg?raw=true"
alt="Endurain logo" style="height: 32px; width: auto;">
<span>Endurain</span>
</div>
<h3 style="margin: 0;">New sign-up requires approval</h3>
</div>
<div style="margin-bottom: 30px;">
<p>Hello {user_name},</p>
<p>A new user has signed up and is awaiting approval:</p>
<div style="background-color: #e9ecef; border: 1px solid #ccc; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>User:</strong> {sign_up_user_name}
</div>
<p>Please log in to the Endurain admin panel to review and approve this request.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{email_service.frontend_host}/settings?tab=users&username={sign_up_user_username}" style="background-color: #198754; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Go to Admin Panel</a>
</div>
<p>If the button above doesnt work, you can copy and paste the following link into your browser:</p>
<p style="word-break: break-all; color: #198754;">{email_service.frontend_host}/settings?tab=users&username={sign_up_user_username}</p>
</div>
<div style="text-align: center; font-size: 12px; color: #666; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
<p>Best regards,<br>The Endurain system</p>
<p>Visit Endurain at: <a style="color: #198754;" href="{email_service.frontend_host}">{email_service.frontend_host}</a> -
Source code at: <a style="color: #198754;"
href="https://github.com/joaovitoriasilva/endurain">GitHub</a></p>
</div>
</div>
</body>
</html>
""".strip()
text_content = f"""
Hello {user_name},
A new user has signed up and is awaiting approval.
User: {sign_up_user_name}
Please log in to the Endurain admin panel to review and approve this request:
{email_service.frontend_host}/settings?tab=users&username={sign_up_user_username}
Best regards,
The Endurain system
""".strip()
return subject, html_content, text_content
def get_user_signup_approved_email_en(
sign_up_user_name: str,
sign_up_user_username: str,
email_service: core_apprise.AppriseService,
) -> tuple:
subject = "Endurain - Your account has been approved"
html_content = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{subject}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f4f4f4;">
<div style="background-color: #ffffff; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">
<div style="text-align: center; margin-bottom: 30px;">
<div style="font-size: 34px; font-weight: bold; margin-bottom: 10px; display: flex; align-items: center; justify-content: center; gap: 10px;">
<img src="https://github.com/joaovitoriasilva/endurain/blob/0e17fafe450b66eda7982311e6f94cee44316684/frontend/app/public/logo/logo.svg?raw=true"
alt="Endurain logo" style="height: 32px; width: auto;">
<span>Endurain</span>
</div>
<h3 style="margin: 0;">Your account is now active</h3>
</div>
<div style="margin-bottom: 30px;">
<p>Hello {sign_up_user_name},</p>
<p>Good news! Your account has been approved and is now active.</p>
<div style="background-color: #e9ecef; border: 1px solid #ccc; padding: 15px; border-radius: 5px; margin: 20px 0;">
<strong>Username:</strong> {sign_up_user_username}
</div>
<p>You can now log in and start using Endurain:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{email_service.frontend_host}/login" style="background-color: #198754; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Log in to Endurain</a>
</div>
<p>If the button above doesnt work, you can copy and paste the following link into your browser:</p>
<p style="word-break: break-all; color: #198754;">{email_service.frontend_host}/login</p>
</div>
<div style="text-align: center; font-size: 12px; color: #666; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
<p>Best regards,<br>The Endurain team</p>
<p>Visit Endurain at: <a style="color: #198754;" href="{email_service.frontend_host}">{email_service.frontend_host}</a> -
Source code at: <a style="color: #198754;"
href="https://github.com/joaovitoriasilva/endurain">GitHub</a></p>
</div>
</div>
</body>
</html>
""".strip()
text_content = f"""
Hello {sign_up_user_name},
Good news! Your account has been approved and is now active.
Username: {sign_up_user_username}
You can now log in and start using Endurain:
{email_service.frontend_host}/login
Best regards,
The Endurain team
""".strip()
return subject, html_content, text_content

View File

@@ -0,0 +1,41 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
ForeignKey,
Boolean,
)
from sqlalchemy.orm import relationship
from core.database import Base
class SignUpToken(Base):
__tablename__ = "sign_up_tokens"
id = Column(String(length=64), nullable=False, primary_key=True)
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="User ID that the sign-up token belongs to",
)
token_hash = Column(
String(length=128), nullable=False, comment="Hashed sign-up token"
)
created_at = Column(
DateTime, nullable=False, comment="Token creation date (datetime)"
)
expires_at = Column(
DateTime, nullable=False, comment="Token expiration date (datetime)"
)
used = Column(
Boolean,
nullable=False,
default=False,
comment="Token usage status (False - unused, True - used)",
)
# Define a relationship to the User model
user = relationship("User", back_populates="sign_up_tokens")

View File

@@ -0,0 +1,201 @@
from typing import Annotated
from fastapi import (
APIRouter,
Depends,
HTTPException,
status,
)
from sqlalchemy.orm import Session
import users.user.crud as users_crud
import users.user.utils as users_utils
import users.user.schema as users_schema
import users.user_integrations.crud as user_integrations_crud
import users.user_default_gear.crud as user_default_gear_crud
import users.user_privacy_settings.crud as users_privacy_settings_crud
import notifications.utils as notifications_utils
import health_targets.crud as health_targets_crud
import sign_up_tokens.utils as sign_up_tokens_utils
import sign_up_tokens.schema as sign_up_tokens_schema
import server_settings.utils as server_settings_utils
import core.database as core_database
import core.apprise as core_apprise
import websocket.schema as websocket_schema
# Define the API router
router = APIRouter()
@router.post("/sign-up/request", status_code=201)
async def signup(
user: users_schema.UserSignup,
email_service: Annotated[
core_apprise.AppriseService,
Depends(core_apprise.get_email_service),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
"""
Handle user sign-up: create the user and related default data, enforce server sign-up policies,
and trigger ancillary actions such as sending verification/approval emails and notifying admins.
Parameters
- user (users_schema.UserSignup): The payload containing the user's sign-up information.
- email_service (core_apprise.AppriseService): Injected email service used to send
verification and admin approval emails.
- websocket_manager (websocket_schema.WebSocketManager): Injected manager used to send
real-time notifications (e.g., admin approval requests).
- db (Session): Database session/connection used to create the user and related records.
Behavior and side effects
- Reads server settings to determine whether sign-up is enabled and whether email verification
and/or admin approval are required.
- If sign-up is disabled, raises an HTTPException(403).
- Creates the user record and several related default records in the database, including:
- user integrations
- user privacy settings
- user health targets
- user default gear
- Depending on server settings:
- If email verification is required (and admin approval is not required):
- Attempts to send an email with verification instructions to the created user.
- Adds the "email_verification_required" flag to the returned response and updates
the human-readable message to reflect email sending success or failure.
- Note: account creation still occurs even if sending the verification email fails.
- If admin approval is required:
- Adds the "admin_approval_required" flag to the returned response and updates
the human-readable message to indicate the account is pending approval.
- Sends an admin-approval email and creates a real-time admin notification via
the websocket manager.
- If neither email verification nor admin approval is required:
- Updates the human-readable message to inform the user they can now log in.
Return
- dict: A dictionary containing at least a "message" key describing the result.
Additional keys may be present:
- "email_verification_required" (bool): Present when email verification must be completed.
- "admin_approval_required" (bool): Present when admin approval is required.
Raises
- HTTPException: Raised with status code 403 when server sign-up is disabled.
- Any exceptions raised by the underlying CRUD utilities, email service, notification utilities,
or database session may propagate (e.g., for transaction rollback or upstream error handling).
Notes
- This is an async FastAPI route handler intended to be used with dependency injection.
- The function performs persistent writes and external I/O (sending emails, pushing notifications);
callers and tests should account for these side effects (e.g., by using transactions, fakes, or mocks).
"""
# Get server settings to check if signup is enabled
server_settings = server_settings_utils.get_server_settings(db)
# Check if signup is enabled
if not server_settings.signup_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User sign-up is not enabled on this server",
)
# Create the user in the database
created_user = users_crud.create_signup_user(user, server_settings, db)
# Create the user integrations in the database
user_integrations_crud.create_user_integrations(created_user.id, db)
# Create the user privacy settings
users_privacy_settings_crud.create_user_privacy_settings(created_user.id, db)
# Create the user health targets
health_targets_crud.create_health_targets(created_user.id, db)
# Create the user default gear
user_default_gear_crud.create_user_default_gear(created_user.id, db)
# Return appropriate response based on server configuration
response_data = {"message": "User created successfully."}
if server_settings.signup_require_email_verification:
# Send the sign-up email
success = await sign_up_tokens_utils.send_sign_up_email(
created_user, email_service, db
)
if success:
response_data["message"] = (
response_data["message"] + " Email sent with verification instructions."
)
else:
response_data["message"] = (
response_data["message"]
+ " Failed to send verification email. Please contact support."
)
response_data["email_verification_required"] = True
if server_settings.signup_require_admin_approval:
response_data["message"] = (
response_data["message"] + " Account is pending admin approval."
)
response_data["admin_approval_required"] = True
if (
not server_settings.signup_require_email_verification
and not server_settings.signup_require_admin_approval
):
response_data["message"] = response_data["message"] + " You can now log in."
return response_data
@router.post("/sign-up/confirm")
async def verify_email(
confirm_data: sign_up_tokens_schema.SignUpConfirm,
email_service: Annotated[
core_apprise.AppriseService,
Depends(core_apprise.get_email_service),
],
websocket_manager: Annotated[
websocket_schema.WebSocketManager,
Depends(websocket_schema.get_websocket_manager),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
# Get server settings
server_settings = server_settings_utils.get_server_settings(db)
if not server_settings.signup_require_email_verification:
raise HTTPException(
status_code=status.HTTP_412_PRECONDITION_FAILED,
detail="Email verification is not enabled",
)
# Verify the email
user_id = sign_up_tokens_utils.use_sign_up_token(confirm_data.token, db)
users_crud.verify_user_email(user_id, server_settings, db)
if email_service.is_configured():
user = users_crud.get_user_by_id(user_id, db)
await sign_up_tokens_utils.send_sign_up_admin_approval_email(
user, email_service, db
)
await notifications_utils.create_admin_new_sign_up_approval_request_notification(
user, websocket_manager, db
)
# Return appropriate response based on server configuration
response_data = {"message": "Email verified successfully."}
if server_settings.signup_require_admin_approval:
response_data["message"] += " Your account is now pending admin approval."
response_data["admin_approval_required"] = True
else:
response_data["message"] += " You can now log in."
return response_data

View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime
class SignUpToken(BaseModel):
id: str
user_id: int
token_hash: str
created_at: datetime
expires_at: datetime
used: bool
model_config = ConfigDict(
from_attributes=True, extra="forbid", validate_assignment=True
)
class SignUpConfirm(BaseModel):
token: str

View File

@@ -0,0 +1,188 @@
from datetime import datetime, timedelta, timezone
from fastapi import (
HTTPException,
status,
)
from uuid import uuid4
import hashlib
from sqlalchemy.orm import Session
import sign_up_tokens.email_messages as sign_up_tokens_email_messages
import sign_up_tokens.schema as sign_up_tokens_schema
import sign_up_tokens.crud as sign_up_tokens_crud
import users.user.crud as users_crud
import users.user.models as users_models
import users.user.utils as users_utils
import core.apprise as core_apprise
import core.logger as core_logger
from core.database import SessionLocal
def create_sign_up_token(user_id: int, db: Session) -> str:
# Generate token and hash
token, token_hash = core_apprise.generate_token_and_hash()
# Create token object
reset_token = sign_up_tokens_schema.SignUpToken(
id=str(uuid4()),
user_id=user_id,
token_hash=token_hash,
created_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc)
+ timedelta(hours=24), # 24 hour expiration
used=0,
)
# Save to database
sign_up_tokens_crud.create_sign_up_token(reset_token, db)
# Return the plain token (not the hash)
return token
async def send_sign_up_email(
user: users_models.User, email_service: core_apprise.AppriseService, db: Session
) -> bool:
# Check if email service is configured
if not email_service.is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Email service is not configured",
)
# Generate sign up token
token = create_sign_up_token(user.id, db)
# Generate reset link
reset_link = f"{email_service.frontend_host}/verify-email?token={token}"
# use default email message in English
subject, html_content, text_content = (
sign_up_tokens_email_messages.get_signup_confirmation_email_en(
user.name, reset_link, email_service
)
)
# Send email
return await email_service.send_email(
to_emails=[user.email],
subject=subject,
html_content=html_content,
text_content=text_content,
)
async def send_sign_up_admin_approval_email(
user: users_models.User, email_service: core_apprise.AppriseService, db: Session
) -> None:
# Check if email service is configured
if not email_service.is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Email service is not configured",
)
admins = users_utils.get_admin_users(db)
# Send email to all admin users
for admin in admins:
# use default email message in English
subject, html_content, text_content = (
sign_up_tokens_email_messages.get_admin_signup_notification_email_en(
admin.name, user.name, user.username, email_service
)
)
# Send email
await email_service.send_email(
to_emails=[admin.email],
subject=subject,
html_content=html_content,
text_content=text_content,
)
async def send_sign_up_approval_email(
user_id: int, email_service: core_apprise.AppriseService, db: Session
) -> bool:
# Check if email service is configured
if not email_service.is_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Email service is not configured",
)
# Get user info
user = users_crud.get_user_by_id(user_id, db)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# use default email message in English
subject, html_content, text_content = (
sign_up_tokens_email_messages.get_user_signup_approved_email_en(
user.name, user.username, email_service
)
)
# Send email
return await email_service.send_email(
to_emails=[user.email],
subject=subject,
html_content=html_content,
text_content=text_content,
)
def use_sign_up_token(token: str, db: Session) -> int:
# Hash the provided token to find the database record
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Look up the token in the database
db_token = sign_up_tokens_crud.get_sign_up_token_by_hash(token_hash, db)
if not db_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired sign up token",
)
try:
# Mark token as used
sign_up_tokens_crud.mark_sign_up_token_used(db_token.id, db)
# Return the associated user ID
return db_token.user_id
except HTTPException as http_err:
raise http_err
except Exception as err:
core_logger.print_to_log(f"Error in use_sign_up_token: {err}", "error", exc=err)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def delete_invalid_tokens_from_db():
# Create a new database session
db = SessionLocal()
try:
# Get num tokens deleted
num_deleted = sign_up_tokens_crud.delete_expired_sign_up_tokens(db)
# Log the number of deleted tokens
if num_deleted > 0:
core_logger.print_to_log_and_console(
f"Deleted {num_deleted} expired sign up tokens", "info"
)
finally:
# Ensure the session is closed after use
db.close()

View File

@@ -1,7 +1,10 @@
import os
import csv
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from stravalib.client import Client
import core.config as core_config
import core.logger as core_logger
import strava.utils as strava_utils
@@ -9,6 +12,7 @@ import strava.athlete_utils as strava_athlete_utils
import gears.gear.schema as gears_schema
import gears.gear.crud as gears_crud
import gears.gear.utils as gears_utils
import activities.activity.schema as activities_schema
import activities.activity.crud as activities_crud
@@ -32,7 +36,7 @@ def get_strava_gear(gear_id: str, strava_client: Client):
raise HTTPException(
status_code=status.HTTP_424_FAILED_DEPENDENCY,
detail="Not able to fetch Strava gear",
)
) from err
if strava_gear is None:
raise HTTPException(
@@ -80,7 +84,7 @@ def fetch_and_process_gear(strava_client: Client, user_id: int, db: Session) ->
def process_gear(
gear, type: str, user_id: int, strava_client: Client, db: Session
gear, gear_type: str, user_id: int, strava_client: Client, db: Session
) -> gears_schema.Gear | None:
# Get the gear by strava id from user id
gear_db = gears_crud.get_gear_by_strava_id_from_user_id(gear.id, user_id, db)
@@ -99,9 +103,9 @@ def process_gear(
brand=strava_gear.brand_name,
model=strava_gear.model_name,
nickname=strava_gear.name,
gear_type=1 if type == "bike" else 2,
gear_type=1 if gear_type == "bike" else 2,
user_id=user_id,
is_active=1,
active=True,
strava_gear_id=gear.id,
)
@@ -109,11 +113,7 @@ def process_gear(
def iterate_over_activities_and_set_gear(
activity: activities_schema.Activity,
gears: list[gears_schema.Gear],
counter: int,
user_id: int,
db: Session,
activity: activities_schema.Activity, gears: list[gears_schema.Gear], counter: int
) -> dict:
# Iterate over gears and set gear if applicable
@@ -151,9 +151,7 @@ def set_activities_gear(user_id: int, db: Session) -> int:
# iterate over activities and set gear if applicable
for activity in activities:
parsed_activity = iterate_over_activities_and_set_gear(
activity, gears, counter, user_id, db
)
parsed_activity = iterate_over_activities_and_set_gear(activity, gears, counter)
counter = parsed_activity["counter"]
activities_parsed.append(parsed_activity["activity"])
@@ -202,3 +200,95 @@ def get_user_gear(user_id: int):
)
finally:
db.close()
def iterate_over_bikes_csv() -> dict:
"""
Parses a Strava bikes CSV file and returns its contents as a dictionary.
The function looks for a CSV file specified by configuration settings, reads it, and constructs a dictionary where each key is the "Bike Name" from the CSV, and the value is a dictionary of the bike's attributes as provided in the CSV row.
Returns:
dict: A dictionary mapping bike names to their corresponding data from the CSV file.
Raises:
HTTPException: If the CSV file is missing, has invalid headers, or cannot be parsed.
"""
# CSV file location
bulk_import_dir = core_config.FILES_BULK_IMPORT_DIR
bikes_file_name = core_config.STRAVA_BULK_IMPORT_BIKES_FILE
bikes_file_path = os.path.join(bulk_import_dir, bikes_file_name)
# Get file and parse it
bikes_dict = {}
try:
if os.path.isfile(bikes_file_path):
core_logger.print_to_log_and_console(
f"{bikes_file_name} exists in the {bulk_import_dir} directory. Starting to process file."
)
with open(bikes_file_path, "r", encoding="utf-8") as bike_file:
bikes_csv = csv.DictReader(bike_file)
for row in bikes_csv:
if (
("Bike Name" not in row)
or ("Bike Brand" not in row)
or ("Bike Model" not in row)
):
core_logger.print_to_log_and_console(
f"Aborting bikes import: Proper headers not found in {bikes_file_name}. File should have 'Bike Name', 'Bike Brand', and 'Bike Model'."
)
raise HTTPException(
status_code=status.HTTP_424_FAILED_DEPENDENCY,
detail="Invalid file. Proper headers not found in Strava bikes CSV file.",
)
bikes_dict[row["Bike Name"]] = row
core_logger.print_to_log_and_console(
f"Strava bike gear csv file parsed and gear dictionary created. File was {len(bikes_dict)} rows long, ignoring header row."
)
return bikes_dict
core_logger.print_to_log_and_console(
f"No {bikes_file_name} file located in the {bulk_import_dir} directory."
)
raise HTTPException(
status_code=status.HTTP_424_FAILED_DEPENDENCY,
detail="No Strava bikes CSV file found for import.",
)
except HTTPException as http_err:
raise http_err
except Exception as err:
core_logger.print_to_log_and_console(
f"Error attempting to open {bikes_file_path} file: {err}", "error"
)
raise HTTPException(
status_code=status.HTTP_424_FAILED_DEPENDENCY,
detail="Error parsing Strava bikes CSV file.",
) from err
def transform_csv_bike_gear_to_schema_gear(
bikes_dict: dict, token_user_id: int
) -> list[gears_schema.Gear]:
"""
Transforms a dictionary of bike data (parsed from CSV) into a list of Gear schema objects.
Args:
bikes_dict (dict): A dictionary where each key is a bike nickname and each value is a dictionary
containing bike attributes such as "Bike Brand" and "Bike Model".
token_user_id (int): The user ID to associate with each Gear object.
Returns:
list[gears_schema.Gear]: A list of Gear schema objects created from the input bike data.
"""
gears = []
for bike in bikes_dict:
new_gear = gears_schema.Gear(
user_id=token_user_id,
brand=bikes_dict[bike]["Bike Brand"],
model=bikes_dict[bike]["Bike Model"],
nickname=bike,
gear_type=gears_utils.GEAR_NAME_TO_ID["bike"],
active=True,
strava_gear_id=None,
)
gears.append(new_gear)
return gears

View File

@@ -1,3 +1,4 @@
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated, Callable
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks, Security
@@ -10,14 +11,17 @@ import session.security as session_security
import users.user_integrations.crud as user_integrations_crud
import gears.gear.crud as gears_crud
import gears.gear.utils as gears_utils
import activities.activity.crud as activities_crud
import activities.activity.utils as activities_utils
import strava.gear_utils as strava_gear_utils
import strava.activity_utils as strava_activity_utils
import strava.utils as strava_utils
import strava.schema as strava_schema
import core.config as core_config
import core.cryptography as core_cryptography
import core.logger as core_logger
import core.database as core_database
@@ -67,8 +71,12 @@ async def strava_link(
# Exchange code for token
tokens = strava_client.exchange_code_for_token(
client_id=core_cryptography.decrypt_token_fernet(user_integrations.strava_client_id),
client_secret=core_cryptography.decrypt_token_fernet(user_integrations.strava_client_secret),
client_id=core_cryptography.decrypt_token_fernet(
user_integrations.strava_client_id
),
client_secret=core_cryptography.decrypt_token_fernet(
user_integrations.strava_client_secret
),
code=code,
)
@@ -85,9 +93,7 @@ async def strava_link(
)
# Clean up by setting Strava
user_integrations_crud.unlink_strava_account(
user_integrations.user_id, db
)
user_integrations_crud.unlink_strava_account(user_integrations.user_id, db)
# Raise an HTTPException with appropriate status code
raise HTTPException(
@@ -140,7 +146,7 @@ async def strava_retrieve_activities_days(
}
@router.get("/gear", status_code=202)
@router.get("/gear", status_code=201)
async def strava_retrieve_gear(
validate_access_token: Annotated[
Callable,
@@ -171,6 +177,59 @@ async def strava_retrieve_gear(
}
@router.post("/import/bikes", status_code=201)
async def import_bikes_from_strava_export(
token_user_id: Annotated[
int,
Depends(session_security.get_user_id_from_access_token),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
try:
# Log beginning of bike import
core_logger.print_to_log("Entering bike importing function")
# Get bikes from Strava export CSV file
bikes_dict = strava_gear_utils.iterate_over_bikes_csv()
# Transform bikes dict to list of Gear schema objects
if bikes_dict:
bikes = strava_gear_utils.transform_csv_bike_gear_to_schema_gear(
bikes_dict, token_user_id
)
# Add bikes to the database
if bikes:
gears_crud.create_multiple_gears(bikes, token_user_id, db)
# Define variables for moving the bikes file
processed_dir = core_config.FILES_PROCESSED_DIR
bulk_import_dir = core_config.FILES_BULK_IMPORT_DIR
bikes_file_name = core_config.STRAVA_BULK_IMPORT_BIKES_FILE
bikes_file_path = os.path.join(bulk_import_dir, bikes_file_name)
# Move the bikes file to the processed directory
activities_utils.move_file(processed_dir, bikes_file_name, bikes_file_path)
# Log completion of bike import
core_logger.print_to_log_and_console("Bike import complete.")
except HTTPException as http_err:
raise http_err
except Exception as err:
# Log the exception
core_logger.print_to_log_and_console(
f"Error in import_bikes_from_strava_export: {err}", "error"
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
@router.put("/client")
async def strava_set_user_client(
client: strava_schema.StravaClient,

View File

@@ -12,16 +12,20 @@ import users.user.models as users_models
import health_data.utils as health_data_utils
import server_settings.crud as server_settings_crud
import sign_up_tokens.utils as sign_up_tokens_utils
import server_settings.utils as server_settings_utils
import server_settings.schema as server_settings_schema
import core.logger as core_logger
import core.apprise as core_apprise
def authenticate_user(username: str, db: Session):
try:
user = (
db.query(users_models.User)
.filter(users_models.User.username == username)
.filter(users_models.User.username == username.lower())
.first()
)
@@ -85,7 +89,6 @@ def get_users_with_pagination(db: Session, page_number: int = 1, num_records: in
# Return the users
return users
except Exception as err:
# Log the exception
core_logger.print_to_log(
@@ -167,7 +170,9 @@ def get_user_by_email(email: str, db: Session):
try:
# Get the user from the database
user = (
db.query(users_models.User).filter(users_models.User.email == email).first()
db.query(users_models.User)
.filter(users_models.User.email == email.lower())
.first()
)
# If the user was not found, return None
@@ -218,12 +223,11 @@ def get_user_by_id(user_id: int, db: Session):
def get_user_by_id_if_is_public(user_id: int, db: Session):
try:
# Check if public sharable links are enabled in server settings
server_settings = server_settings_crud.get_server_settings(db)
server_settings = server_settings_utils.get_server_settings(db)
# Return None if public sharable links are disabled
if (
not server_settings
or not server_settings.public_shareable_links
not server_settings.public_shareable_links
or not server_settings.public_shareable_links_user_info
):
return None
@@ -254,8 +258,36 @@ def get_user_by_id_if_is_public(user_id: int, db: Session):
) from err
def get_users_admin(db: Session):
try:
# Get the users from the database and format the birthdate
users = [
users_utils.format_user_birthdate(user)
for user in db.query(users_models.User)
.filter(users_models.User.access_type == 2)
.all()
]
# If the users were not found, return None
if not users:
return None
# Return the users
return users
except Exception as err:
# Log the exception
core_logger.print_to_log(f"Error in get_users_admin: {err}", "error", exc=err)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def create_user(user: users_schema.UserCreate, db: Session):
try:
user.username = user.username.lower()
user.email = user.email.lower()
# Create a new user
db_user = users_models.User(
**user.model_dump(exclude={"password"}),
@@ -292,7 +324,7 @@ def create_user(user: users_schema.UserCreate, db: Session):
) from err
def edit_user(user_id: int, user: users_schema.User, db: Session):
def edit_user(user_id: int, user: users_schema.UserRead, db: Session):
try:
# Get the user from the database
db_user = (
@@ -313,6 +345,8 @@ def edit_user(user_id: int, user: users_schema.User, db: Session):
# Delete the user photo in the filesystem
users_utils.delete_user_photo_filesystem(db_user.id)
user.username = user.username.lower()
# Dictionary of the fields to update if they are not None
user_data = user.model_dump(exclude_unset=True)
# Iterate over the fields and update the db_user dynamically
@@ -354,6 +388,165 @@ def edit_user(user_id: int, user: users_schema.User, db: Session):
) from err
def approve_user(
user_id: int, db: Session
):
"""
Approve a user by ID.
Fetches the user with the given user_id from the provided SQLAlchemy Session.
If the user exists and their email is verified, marks the user as approved by
setting `pending_admin_approval` to False and `active` to True, then commits
the transaction.
Parameters:
user_id (int): The primary key of the user to approve.
db (Session): SQLAlchemy Session used for querying and committing changes.
Raises:
HTTPException: 404 Not Found if no user with the given ID exists. The
raised exception includes a "WWW-Authenticate: Bearer" header.
HTTPException: 400 Bad Request if the user exists but their email has not
been verified.
HTTPException: 500 Internal Server Error for any unexpected error during
processing; the function will rollback the transaction and log the
original exception before raising this error.
Side effects:
- Updates the user object by setting `pending_admin_approval = False` and
`active = True`.
- Commits the DB transaction on success.
- Rolls back the DB transaction and logs the error via
`core_logger.print_to_log` on unexpected failures.
Returns:
None
Notes:
- The function expects the `users_models.User` model to be importable and the
provided `db` to be a working SQLAlchemy session.
- The original exception is chained to the re-raised 500 HTTPException to
preserve context for debugging.
"""
try:
# Get the user from the database
db_user = (
db.query(users_models.User).filter(users_models.User.id == user_id).first()
)
if db_user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
if not db_user.email_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User email is not verified",
)
db_user.pending_admin_approval = False
db_user.active = True
# Commit the transaction
db.commit()
except HTTPException as http_err:
raise http_err
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(f"Error in approve_user: {err}", "error", exc=err)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def verify_user_email(
user_id: int,
server_settings: server_settings_schema.ServerSettingsRead,
db: Session,
):
"""
Verify a user's email and update their account status in the database.
Parameters
----------
user_id : int
The primary key of the user to verify.
server_settings : server_settings_schema.ServerSettingsRead
Server configuration used to determine whether admin approval is required
(controls whether the account should be activated immediately).
db : Session
SQLAlchemy session used to query and persist changes to the database.
Returns
-------
None
Side effects
------------
- Marks the user's email as verified (sets db_user.email_verified = True).
- If server_settings.signup_require_admin_approval is False:
- Clears pending admin approval (db_user.pending_admin_approval = False).
- Activates the user account (db_user.active = True).
- Commits the transaction on success.
- On unexpected errors, rolls back the transaction and logs the exception via core_logger.print_to_log.
Raises
------
HTTPException
- 404 Not Found: if no user exists with the provided user_id.
- Re-raises any HTTPException raised during processing.
- 500 Internal Server Error: for unexpected exceptions encountered while updating the database.
Notes
-----
- The function queries users_models.User for the given user_id.
- The caller is responsible for managing the lifecycle of the provided DB session.
"""
try:
# Get the user from the database
db_user = (
db.query(users_models.User).filter(users_models.User.id == user_id).first()
)
if db_user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
db_user.email_verified = True
if not server_settings.signup_require_admin_approval:
db_user.pending_admin_approval = False
db_user.active = True
# Commit the transaction
db.commit()
except HTTPException as http_err:
raise http_err
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(f"Error in verify_user_email: {err}", "error", exc=err)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def edit_user_password(user_id: int, password: str, db: Session):
try:
# Get the user from the database
@@ -548,7 +741,7 @@ def disable_user_mfa(user_id: int, db: Session):
Returns:
None
Raises:
HTTPException:
HTTPException:
- 404 Not Found if the user does not exist.
- 500 Internal Server Error for any other failure; in this case the
transaction is rolled back and the error is logged.
@@ -568,7 +761,7 @@ def disable_user_mfa(user_id: int, db: Session):
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
db_user.mfa_enabled = False
db_user.mfa_secret = None
db.commit()
@@ -580,3 +773,98 @@ def disable_user_mfa(user_id: int, db: Session):
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def create_signup_user(
user: users_schema.UserSignup,
server_settings,
db: Session,
):
"""
Creates a new user during the signup process, handling email verification and admin approval requirements.
Args:
user (users_schema.UserSignup): The user signup data containing user details.
server_settings: Server configuration settings that determine signup requirements.
db (Session): SQLAlchemy database session.
Returns:
users_models.User: The newly created user object.
Raises:
HTTPException:
- 409 Conflict if the email or username is not unique.
- 500 Internal Server Error for any other exceptions.
"""
try:
# Determine user status based on server settings
active = True
email_verified = False
pending_admin_approval = False
if server_settings.signup_require_email_verification:
email_verified = False
active = False # Inactive until email verified
if server_settings.signup_require_admin_approval:
pending_admin_approval = True
active = False # Inactive until approved
# If both email verification and admin approval are disabled, user is immediately active
if (
not server_settings.signup_require_email_verification
and not server_settings.signup_require_admin_approval
):
active = True
email_verified = True
# Create a new user
db_user = users_models.User(
name=user.name,
username=user.username.lower(),
email=user.email.lower(),
city=user.city,
birthdate=user.birthdate,
preferred_language=user.preferred_language,
gender=user.gender,
units=user.units,
height=user.height,
access_type=users_schema.UserAccessType.REGULAR,
active=active,
first_day_of_week=user.first_day_of_week,
currency=user.currency,
email_verified=email_verified,
pending_admin_approval=pending_admin_approval,
password=session_security.hash_password(user.password),
)
# Add the user to the database
db.add(db_user)
db.commit()
db.refresh(db_user)
# Return user
return db_user
except IntegrityError as integrity_error:
# Rollback the transaction
db.rollback()
# Raise an HTTPException with a 409 Conflict status code
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Duplicate entry error. Check if email and username are unique",
) from integrity_error
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in create_signup_user: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err

View File

@@ -1,10 +1,4 @@
from sqlalchemy import (
Column,
Integer,
String,
Date,
Boolean
)
from sqlalchemy import Column, Integer, String, Date, Boolean
from sqlalchemy.orm import relationship
from core.database import Base
@@ -62,8 +56,11 @@ class User(Base):
Integer, nullable=False, comment="User type (one digit)(1 - user, 2 - admin)"
)
photo_path = Column(String(length=250), nullable=True, comment="User photo path")
is_active = Column(
Integer, nullable=False, comment="Is user active (1 - active, 2 - not active)"
active = Column(
Boolean,
nullable=False,
default=True,
comment="Whether the user is active (true - yes, false - no)",
)
first_day_of_week = Column(
Integer,
@@ -88,6 +85,18 @@ class User(Base):
nullable=True,
comment="User MFA secret for TOTP generation (encrypted at rest)",
)
email_verified = Column(
Boolean,
nullable=False,
default=False,
comment="Whether the user's email address has been verified (true - yes, false - no)",
)
pending_admin_approval = Column(
Boolean,
nullable=False,
default=False,
comment="Whether the user is pending admin approval for activation (true - yes, false - no)",
)
# Define a relationship to UsersSessions model
users_sessions = relationship(
@@ -101,6 +110,12 @@ class User(Base):
back_populates="user",
cascade="all, delete-orphan",
)
# Define a relationship to SignUpToken model
sign_up_tokens = relationship(
"SignUpToken",
back_populates="user",
cascade="all, delete-orphan",
)
# Define a relationship to UsersIntegrations model
users_integrations = relationship(
"UsersIntegrations",

View File

@@ -13,7 +13,7 @@ import core.database as core_database
router = APIRouter()
@router.get("/id/{user_id}", response_model=users_schema.User)
@router.get("/id/{user_id}", response_model=users_schema.UserRead)
async def read_users_id(
user_id: int,
validate_id: Annotated[Callable, Depends(users_dependencies.validate_user_id)],

View File

@@ -16,8 +16,10 @@ import users.user_privacy_settings.crud as users_privacy_settings_crud
import health_targets.crud as health_targets_crud
import sign_up_tokens.utils as sign_up_tokens_utils
import session.security as session_security
import core.apprise as core_apprise
import core.database as core_database
import core.dependencies as core_dependencies
@@ -40,7 +42,7 @@ async def read_users_number(
@router.get(
"/page_number/{page_number}/num_records/{num_records}",
response_model=list[users_schema.User] | None,
response_model=list[users_schema.UserRead] | None,
)
async def read_users_all_pagination(
page_number: int,
@@ -62,7 +64,7 @@ async def read_users_all_pagination(
@router.get(
"/username/contains/{username}",
response_model=list[users_schema.User] | None,
response_model=list[users_schema.UserRead] | None,
)
async def read_users_contain_username(
username: str,
@@ -80,7 +82,7 @@ async def read_users_contain_username(
@router.get(
"/username/{username}",
response_model=users_schema.User | None,
response_model=users_schema.UserRead | None,
)
async def read_users_username(
username: str,
@@ -98,7 +100,7 @@ async def read_users_username(
@router.get(
"/email/{email}",
response_model=users_schema.User | None,
response_model=users_schema.UserRead | None,
)
async def read_users_email(
email: str,
@@ -114,7 +116,7 @@ async def read_users_email(
return users_crud.get_user_by_email(email, db)
@router.get("/id/{user_id}", response_model=users_schema.User)
@router.get("/id/{user_id}", response_model=users_schema.UserRead)
async def read_users_id(
user_id: int,
validate_id: Annotated[Callable, Depends(users_dependencies.validate_user_id)],
@@ -130,7 +132,7 @@ async def read_users_id(
return users_crud.get_user_by_id(user_id, db)
@router.post("", response_model=users_schema.User, status_code=201)
@router.post("", response_model=users_schema.UserRead, status_code=201)
async def create_user(
user: users_schema.UserCreate,
check_scopes: Annotated[
@@ -184,7 +186,7 @@ async def upload_user_image(
async def edit_user(
user_id: int,
validate_id: Annotated[Callable, Depends(users_dependencies.validate_user_id)],
user_attributtes: users_schema.User,
user_attributtes: users_schema.UserRead,
check_scopes: Annotated[
Callable, Security(session_security.check_scopes, scopes=["users:write"])
],
@@ -200,6 +202,32 @@ async def edit_user(
return {"detail": f"User ID {user_attributtes.id} updated successfully"}
@router.put("/{user_id}/approve")
async def approve_user(
user_id: int,
validate_id: Annotated[Callable, Depends(users_dependencies.validate_user_id)],
check_scopes: Annotated[
Callable, Security(session_security.check_scopes, scopes=["users:write"])
],
email_service: Annotated[
core_apprise.AppriseService,
Depends(core_apprise.get_email_service),
],
db: Annotated[
Session,
Depends(core_database.get_db),
],
):
# Approve the user in the database
users_crud.approve_user(user_id, db)
# Send approval email
await sign_up_tokens_utils.send_sign_up_approval_email(user_id, email_service, db)
# Return success message
return {"message": f"User ID {user_id} approved successfully."}
@router.put("/{user_id}/password")
async def edit_user_password(
user_id: int,

View File

@@ -1,10 +1,107 @@
from pydantic import BaseModel, EmailStr, field_validator
from enum import Enum, IntEnum
from pydantic import BaseModel, EmailStr, field_validator, StrictInt, ConfigDict
import re
import server_settings.schema as server_settings_schema
class Gender(IntEnum):
"""
An enumeration representing the gender of a user.
Attributes:
MALE (int): Represents male gender.
FEMALE (int): Represents female gender.
UNSPECIFIED (int): Represents unspecified or undisclosed gender.
"""
MALE = 1
FEMALE = 2
UNSPECIFIED = 3
class Language(Enum):
"""
An enumeration representing supported languages for the application.
Members:
CATALAN: Catalan language code ("ca-ES").
CHINESE_SIMPLIFIED: Simplified Chinese language code ("cn").
CHINESE_TRADITIONAL: Traditional Chinese language code ("tw").
DUTCH: Dutch language code ("nl").
GERMAN: German language code ("de").
FRENCH: French language code ("fr").
SPANISH: Spanish language code ("es").
PORTUGUESE: Portuguese language code ("pt").
ENGLISH_USA: US English language code ("us").
"""
CATALAN = "ca"
CHINESE_SIMPLIFIED = "cn"
CHINESE_TRADITIONAL = "tw"
DUTCH = "nl"
GERMAN = "de"
FRENCH = "fr"
SPANISH = "es"
PORTUGUESE = "pt"
ENGLISH_USA = "us"
class WeekDay(IntEnum):
"""
An enumeration representing the days of the week.
Attributes:
SUNDAY (int): Represents Sunday (0).
MONDAY (int): Represents Monday (1).
TUESDAY (int): Represents Tuesday (2).
WEDNESDAY (int): Represents Wednesday (3).
THURSDAY (int): Represents Thursday (4).
FRIDAY (int): Represents Friday (5).
SATURDAY (int): Represents Saturday (6).
"""
SUNDAY = 0
MONDAY = 1
TUESDAY = 2
WEDNESDAY = 3
THURSDAY = 4
FRIDAY = 5
SATURDAY = 6
class UserAccessType(IntEnum):
"""
Enumeration representing different types of user access levels.
Attributes:
REGULAR (int): Standard user with regular access permissions.
ADMIN (int): User with administrative access permissions.
"""
REGULAR = 1
ADMIN = 2
PASSWORD_REGEX = r"^(?=.*[A-Z])(?=.*\d)(?=.*[ !\"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])[A-Za-z\d !\"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]{8,}$"
def validate_password(value: str) -> str:
"""
Validates that the provided password meets the required complexity.
Args:
value (str): The password string to validate.
Raises:
ValueError: If the password does not meet the following criteria:
- At least 8 characters long
- Includes an uppercase letter
- Includes a number
- Includes a special character
Returns:
str: The validated password string.
"""
if not re.match(PASSWORD_REGEX, value):
raise ValueError(
"Password must be at least 8 characters long, include an uppercase letter, a number, and a special character."
@@ -12,37 +109,40 @@ def validate_password(value: str) -> str:
return value
class User(BaseModel):
id: int | None = None
class UserBase(BaseModel):
name: str
username: str
email: EmailStr
city: str | None = None
birthdate: str | None = None
preferred_language: str
gender: int
units: int
preferred_language: Language = Language.ENGLISH_USA
gender: Gender = Gender.MALE
units: server_settings_schema.Units = server_settings_schema.Units.METRIC
height: int | None = None
access_type: int
first_day_of_week: WeekDay = WeekDay.MONDAY
currency: server_settings_schema.Currency = server_settings_schema.Currency.EURO
model_config = ConfigDict(use_enum_values=True)
class User(UserBase):
access_type: UserAccessType
photo_path: str | None = None
is_active: int
first_day_of_week: int = 1
currency: int
active: bool
mfa_enabled: bool = False
mfa_secret: str | None = None
email_verified: bool = False
pending_admin_approval: bool = False
model_config = {"from_attributes": True}
model_config = ConfigDict(
from_attributes=True, extra="forbid", validate_assignment=True
)
class UserRead(User):
id: StrictInt
class UserCreate(User):
password: str
@field_validator("password")
def validate_password_field(cls, value):
return validate_password(value)
class UserMe(User):
class UserMe(UserRead):
is_strava_linked: int | None = None
is_garminconnect_linked: int | None = None
default_activity_visibility: int | None = None
@@ -60,6 +160,22 @@ class UserMe(User):
hide_activity_gear: bool | None = None
class UserSignup(UserBase):
password: str
@field_validator("password")
def validate_password_field(cls, value):
return validate_password(value)
class UserCreate(User):
password: str
@field_validator("password")
def validate_password_field(cls, value):
return validate_password(value)
class UserEditPassword(BaseModel):
password: str

View File

@@ -6,8 +6,6 @@ from sqlalchemy.orm import Session
import shutil
import session.constants as session_constants
import users.user.crud as users_crud
import users.user.schema as users_schema
@@ -15,16 +13,50 @@ import core.logger as core_logger
import core.config as core_config
def check_user_is_active(user: users_schema.User) -> None:
if user.is_active == session_constants.USER_NOT_ACTIVE:
def check_user_is_active(user: users_schema.UserRead) -> None:
"""
Checks if the given user is active.
Raises:
HTTPException: If the user is not active, raises an HTTP 403 Forbidden exception
with a detail message "Inactive user" and a "WWW-Authenticate" header.
Args:
user (users_schema.UserRead): The user object to check.
"""
if not user.active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user",
headers={"WWW-Authenticate": "Bearer"},
)
def get_admin_users(db: Session):
admins = users_crud.get_users_admin(db)
if not admins:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No admin users found",
)
return admins
def delete_user_photo_filesystem(user_id: int):
"""
Deletes all photo files associated with a user from the filesystem.
This function searches for files in the directory specified by `core_config.USER_IMAGES_DIR`
that match the given `user_id` with any file extension, and removes them.
Args:
user_id (int): The ID of the user whose photo files should be deleted.
Returns:
None
"""
# Define the pattern to match files with the specified name regardless of the extension
folder = core_config.USER_IMAGES_DIR
file = f"{user_id}.*"
@@ -39,11 +71,39 @@ def delete_user_photo_filesystem(user_id: int):
def format_user_birthdate(user):
user.birthdate = user.birthdate if isinstance(user.birthdate, str) else user.birthdate.isoformat() if user.birthdate else None
"""
Formats the birthdate attribute of a user object to an ISO 8601 string if it is a date/datetime object.
If the birthdate is already a string or None, it remains unchanged.
Args:
user: An object with a 'birthdate' attribute, which can be a string, date/datetime object, or None.
Returns:
The user object with the 'birthdate' attribute formatted as an ISO 8601 string, string, or None.
"""
user.birthdate = (
user.birthdate
if isinstance(user.birthdate, str)
else user.birthdate.isoformat() if user.birthdate else None
)
return user
async def save_user_image(user_id: int, file: UploadFile, db: Session):
"""
Saves a user's image to the server and updates the user's photo path in the database.
Args:
user_id (int): The ID of the user whose image is being saved.
file (UploadFile): The uploaded image file.
db (Session): The database session.
Returns:
Any: The result of updating the user's photo path in the database.
Raises:
HTTPException: If an error occurs during the image saving process, raises a 500 Internal Server Error.
"""
try:
upload_dir = core_config.USER_IMAGES_DIR
os.makedirs(upload_dir, exist_ok=True)

View File

@@ -1,6 +1,6 @@
from typing import Annotated, Callable
from typing import Annotated
from fastapi import APIRouter, Depends, Security
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
import users.user_default_gear.schema as user_default_gear_schema
@@ -40,4 +40,6 @@ async def edit_user_default_gear(
],
):
# Update the user default gear in the database
return user_default_gear_crud.edit_user_default_gear(user_default_gear, token_user_id, db)
return user_default_gear_crud.edit_user_default_gear(
user_default_gear, token_user_id, db
)

View File

@@ -28,7 +28,7 @@ def get_user_default_gear_by_activity_type(
return user_default_gear.virtual_ride_gear_id
elif activity_type == 9:
return user_default_gear.ows_gear_id
elif activity_type == 11 or activity_type == 31:
elif activity_type in (11, 31):
return user_default_gear.walk_gear_id
elif activity_type == 12:
return user_default_gear.hike_gear_id

View File

@@ -1,11 +1,9 @@
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
import users.user_goals.schema as user_goals_schema
import users.user_goals.models as user_goals_models
import users.user_goals.utils as user_goals_utils
import core.logger as core_logger
@@ -46,50 +44,6 @@ def get_user_goals_by_user_id(
) from err
def calculate_user_goals(
user_id: int, date: str | None, db: Session
) -> List[user_goals_schema.UserGoalProgress] | None:
"""
Calculates the progress of all goals for a given user on a specified date.
Args:
user_id (int): The ID of the user whose goals are to be calculated.
date (str | None): The date for which to calculate goal progress, in "YYYY-MM-DD" format. If None, uses the current date.
db (Session): The SQLAlchemy database session.
Returns:
List[user_goals_schema.UserGoalProgress] | None:
A list of UserGoalProgress objects representing the progress of each goal, or None if no goals are found.
Raises:
HTTPException: If an error occurs during calculation or database access.
"""
if not date:
date = datetime.now().strftime("%Y-%m-%d")
try:
goals = get_user_goals_by_user_id(user_id, db)
if not goals:
return None
return [
user_goals_utils.calculate_goal_progress_by_activity_type(goal, date, db)
for goal in goals
]
except HTTPException as http_err:
raise http_err
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in calculate_user_goals: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def create_user_goal(user_id: int, user_goal: user_goals_schema.UserGoalCreate, db: Session):
"""
Creates a new user goal for a specific user, activity type, and interval.

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
import users.user_goals.dependencies as user_goals_dependencies
import users.user_goals.schema as user_goals_schema
import users.user_goals.crud as user_goals_crud
import users.user_goals.utils as user_goals_utils
import session.security as session_security
@@ -53,7 +54,7 @@ async def get_user_goals_results(
Returns:
Any: The calculated user goals results for the specified user.
"""
return user_goals_crud.calculate_user_goals(token_user_id, None, db)
return user_goals_utils.calculate_user_goals(token_user_id, None, db)
@router.post("", response_model=user_goals_schema.UserGoalRead, status_code=201)

View File

@@ -1,21 +1,82 @@
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from typing import List
from activities.activity.utils import ACTIVITY_ID_TO_NAME
import users.user_goals.schema as user_goals_schema
import users.user_goals.models as user_goals_models
import users.user_goals.crud as user_goals_crud
import activities.activity.crud as activity_crud
import core.logger as core_logger
def calculate_user_goals(
user_id: int, date: str | None, db: Session
) -> List[user_goals_schema.UserGoalProgress] | None:
"""
Calculates the progress of all goals for a given user on a specified date.
Args:
user_id (int): The ID of the user whose goals are to be calculated.
date (str | None): The date for which to calculate goal progress, in "YYYY-MM-DD" format. If None, uses the current date.
db (Session): The SQLAlchemy database session.
Returns:
List[user_goals_schema.UserGoalProgress] | None:
A list of UserGoalProgress objects representing the progress of each goal, or None if no goals are found.
Raises:
HTTPException: If an error occurs during calculation or database access.
"""
if not date:
date = datetime.now().strftime("%Y-%m-%d")
try:
goals = user_goals_crud.get_user_goals_by_user_id(user_id, db)
if not goals:
return None
return [
calculate_goal_progress_by_activity_type(goal, date, db)
for goal in goals
]
except HTTPException as http_err:
raise http_err
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in calculate_user_goals: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal Server Error",
) from err
def calculate_goal_progress_by_activity_type(
goal: user_goals_models.UserGoal,
date: str,
db: Session,
) -> user_goals_schema.UserGoalProgress | None:
) -> user_goals_schema.UserGoalProgress | None:
"""
Calculates the progress of a user's goal for a specific activity type within a given time interval.
This function determines the progress of a goal (calories, distance, elevation, duration, or number of activities)
based on the user's activities of a specified type (run, bike, swim, walk) within the interval defined by the goal.
It fetches relevant activities from the database, aggregates the required metrics, and computes the percentage
completion of the goal.
Args:
goal (user_goals_models.UserGoal): The user goal object containing goal details and parameters.
date (str): The reference date (in 'YYYY-MM-DD' format) to determine the interval for progress calculation.
db (Session): The SQLAlchemy database session used for querying activities.
Returns:
user_goals_schema.UserGoalProgress | None: An object containing progress details for the goal, or None if no activities are found.
Raises:
HTTPException: If an error occurs during processing or database access.
"""
try:
start_date, end_date = get_start_end_date_by_interval(goal.interval, date)

868
backend/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "endurain"
version = "0.14.2"
version = "0.15.0"
description = "Endurain API for the Endurain app"
authors = ["João Vitória Silva <8648976+joaovitoriasilva@users.noreply.github.com>"]
readme = "README.md"

View File

@@ -1,37 +1,78 @@
files:
- source: /frontend/app/src/i18n/us/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/%file_name%.%file_extension%
- source: /frontend/app/src/i18n/us/components/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/%file_name%.%file_extension%
- source: /frontend/app/src/i18n/us/gears/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/gears/%file_name%.%file_extension%
- source: /frontend/app/src/i18n/us/strava/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/strava/%file_name%.%file_extension%
- source: /frontend/app/src/i18n/us/components/activities/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/activities/%original_file_name%
- source: /frontend/app/src/i18n/us/components/activities/modals/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/activities/modals/%original_file_name%
- source: /frontend/app/src/i18n/us/components/followers/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/followers/%original_file_name%
- source: /frontend/app/src/i18n/us/components/gears/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/gears/%original_file_name%
- source: /frontend/app/src/i18n/us/components/health/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/health/%original_file_name%
- source: /frontend/app/src/i18n/us/components/health/healthWeightZone/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/health/healthWeightZone/%original_file_name%
- source: /frontend/app/src/i18n/us/components/navbar/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/navbar/%original_file_name%
- source: /frontend/app/src/i18n/us/components/notifications/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/notifications/%original_file_name%
- source: /frontend/app/src/i18n/us/components/settings/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/settings/%original_file_name%
- source: /frontend/app/src/i18n/us/components/users/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/users/%original_file_name%
- source: /frontend/app/src/i18n/us/components/settings/settingsGeneralZone/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/settings/settingsGeneralZone/%original_file_name%
- source: /frontend/app/src/i18n/us/components/settings/settingsIntegrationsZone/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/settings/settingsIntegrationsZone/%original_file_name%
- source: /frontend/app/src/i18n/us/components/settings/settingsUsersZone/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/settings/settingsUsersZone/%original_file_name%
- source: /frontend/app/src/i18n/us/components/settings/settingsUserSessionsZone/*.json
translation: /frontend/app/src/i18n/%two_letters_code%/components/settings/settingsUserSessionsZone/%original_file_name%
"project_id": "654570"
"preserve_hierarchy": true
"files": [
{
"source": "/frontend/app/src/i18n/us/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/gears/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/gears/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/strava/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/strava/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/activities/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/activities/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/activities/modals/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/activities/modals/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/followers/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/followers/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/gears/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/gears/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/health/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/health/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/health/healthWeightZone/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/health/healthWeightZone/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/navbar/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/navbar/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/notifications/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/notifications/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/settings/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/settings/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/users/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/users/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/settings/settingsGeneralZone/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/settings/settingsGeneralZone/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/settings/settingsIntegrationsZone/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/settings/settingsIntegrationsZone/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/settings/settingsUsersZone/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/settings/settingsUsersZone/%original_file_name%"
},
{
"source": "/frontend/app/src/i18n/us/components/settings/settingsUserSessionsZone/*.json",
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/settings/settingsUserSessionsZone/%original_file_name%"
}
]

View File

@@ -27,5 +27,5 @@ services:
timeout: 5s
retries: 5
volumes:
- /opt/endurain/postgres:/var/lib/postgresql/data
- <local_path>/endurain/postgres:/var/lib/postgresql/data
restart: unless-stopped

View File

@@ -150,6 +150,91 @@ The API is reachable under `/api/v1`. Below are some example endpoints. All endp
| **Activity Upload** | `/activities/create/upload` | .gpx, .tcx, .gz or .fit file |
| **Set Weight** | `/health/weight` | JSON {'weight': <number>, 'created_at': `yyyy-MM-dd`} |
### MFA Authentication Flow
When Multi-Factor Authentication (MFA) is enabled for a user, the authentication process requires two steps:
#### Step 1: Initial Login Request
Make a standard login request to `/token`:
**Request:**
```http
POST /api/v1/token
Content-Type: application/x-www-form-urlencoded
X-Client-Type: web|mobile
username=user@example.com&password=userpassword
```
**Response (when MFA is enabled):**
- **Web clients**: HTTP 202 Accepted
```json
{
"mfa_required": true,
"username": "example",
"message": "MFA verification required"
}
```
- **Mobile clients**: HTTP 200 OK
```json
{
"mfa_required": true,
"username": "example",
"message": "MFA verification required"
}
```
#### Step 2: MFA Verification
Complete the login by providing the MFA code to `/mfa/verify`:
**Request:**
```http
POST /api/v1/mfa/verify
Content-Type: application/json
X-Client-Type: web|mobile
{
"username": "user@example.com",
"mfa_code": "123456"
}
```
**Response (successful verification):**
- **Web clients**: Tokens are set as HTTP-only cookies
```json
{
"session_id": "unique_session_id"
}
```
- **Mobile clients**: Tokens are returned in response body
```json
{
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"session_id": "unique_session_id"
}
```
#### Error Handling
- **No pending MFA login**: HTTP 400 Bad Request
```json
{
"detail": "No pending MFA login found for this username"
}
```
- **Invalid MFA code**: HTTP 401 Unauthorized
```json
{
"detail": "Invalid MFA code"
}
```
#### Important Notes
- The pending MFA login session is temporary and will expire if not completed within a reasonable time
- After successful MFA verification, the pending login is automatically cleaned up
- The user must still be active at the time of MFA verification
- If no MFA is enabled for the user, the standard single-step authentication flow applies
## Supported activity types
The table bellow details the activity types supported by Endurain.
@@ -180,6 +265,7 @@ The table bellow details the activity types supported by Endurain.
| Alpine ski | 15 |
| Nordic Ski | 16 |
| Snowboard | 17 |
| Ice Skate | 37 |
| Transition | 18 |
| Strength Training | 19 |
| Crossfit | 20 |
@@ -189,9 +275,11 @@ The table bellow details the activity types supported by Endurain.
| Squash | 24 |
| Racquetball | 25 |
| Pickleball | 26 |
| Padel | 39 |
| Windsurf | 30 |
| Stand up paddling | 32 |
| Surf | 33 |
| Soccer | 38 |
## Supported gear types

View File

@@ -88,8 +88,8 @@ Docker image uses a non-root user, so ensure target folders are not owned by roo
## Bulk import and file upload
To perform a bulk import:
- Place .fit, .tcx, .gz and/or .gpx files into the activity_files/bulk_import folder. Create the folder if needed.
- In the "Settings" menu select "Integrations".
- Place .fit, .tcx, .gz and/or .gpx files into the data/activity_files/bulk_import folder. Create the folder if needed.
- In the "Settings" menu select "Import".
- Click "Import" next to "Bulk Import".
.fit files are preferred. I noticed that Strava/Garmin Connect process of converting .fit to .gpx introduces additional data to the activity file leading to minor variances in the data, like for example additional
@@ -99,8 +99,33 @@ meters in distance and elevation gain. Some notes:
- GEOCODES API has a limit of 1 Request/Second on the free plan, so if you have a large number of files, it might not be possible to import all in the same action
- The bulk import currently only imports data present in the .fit, .tcx or .gpx files - no metadata or other media are imported.
## Importing information from a Strava bulk export (BETA)
Strava allows users to create a bulk export of their historical activity on the site. This information is stored in a zip file, primarily as .csv files, GPS recording files (e.g., .gpx, .fit), and media files (e.g., .jpg, .png).
### Importing gear from a Strava bulk import
At the present time, importing bikes from a Strava bulk export is implemented as a beta feature - use with caution. Components of bikes are not imported - just the bikes themselves. There is no mechanism present to undo an import.
To perform an import of bikes:
- Place the bikes.csv file from a Strava bulk export into the data/activity_files/bulk_import folder. Create the folder if needed;
- In the "Settings" menu select "Import";
- Click "Import Strava Bikes" next to "Strava gear import";
- Upon successful import, the bikes.csv file is copied to /data/activity_files/processed folder;
- Status messages about the import, including why any gear was not imported, can be found in the logs.
Ensure the file is named "bikes.csv" and has a header row with at least the fields 'Bike Name', 'Bike Brand', and 'Bike Model'.
Note: All "+" characters will be stripped from all fields on import, along with any beginning and ending space (" ") characters.
### Importing other items from a Strava bulk import
Importing of shoes is under development in October 2025.
Importing activity metadata and media is under development in October 2025.
## Image personalization
It is possible (v0.10.0 or higher) to personalize the login image in the login page. To do that, map the data/server_images directory for image persistence on container updates and:
- Set the image in the server settings zone of the settings page
- A square image is expected. Default one uses 1000px vs 1000px
- A square image is expected. Default one uses 1000px vs 1000px

View File

@@ -6,6 +6,7 @@ module.exports = {
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting'
],
parserOptions: {

View File

@@ -1,8 +0,0 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,16 @@
{
"name": "endurain",
"version": "0.14.2",
"version": "0.15.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "run-p type-check \"build-only {@}\" --",
"build-only": "vite build",
"preview": "vite preview",
"test:unit": "vitest",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
@@ -32,16 +34,25 @@
"devDependencies": {
"@rushstack/eslint-patch": "^1.10.4",
"@types/bootstrap": "^5.2.10",
"@types/jsdom": "^27.0.0",
"@types/leaflet": "^1.9.14",
"@types/node": "^24.6.1",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.1.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.15.0",
"eslint-plugin-vue": "^10.3.0",
"jsdom": "^26.0.0",
"jsdom": "^27.0.0",
"npm-run-all2": "^8.0.4",
"typescript": "^5.9.3",
"vite": "^6.1.0",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^3.0.5",
"vue-eslint-parser": "^10.2.0"
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^3.1.0"
}
}

View File

@@ -161,6 +161,9 @@
<option value="17">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption17') }}
</option>
<option value="37">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption37') }}
</option>
<hr />
<option value="21">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption21') }}
@@ -180,6 +183,9 @@
<option value="26">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption26') }}
</option>
<option value="39">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption39') }}
</option>
<hr />
<option value="30">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption30') }}
@@ -190,6 +196,10 @@
<option value="33">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption33') }}
</option>
<hr />
<option value="38">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption38') }}
</option>
</select>
<!-- visibility fields -->
<label for="activityVisibilityEdit"

View File

@@ -23,7 +23,7 @@
<a class="link-body-emphasis" href="https://discord.gg/6VUjUq2uZR"
><font-awesome-icon :icon="['fab', 'fa-discord']"
/></a>
v0.14.2
v0.15.0
</p>
<p class="text-center text-muted">
<img

View File

@@ -268,7 +268,7 @@
class="form-select"
name="gearComponentIsActiveAddEdit"
:disabled="newEditGearComponentRetiredDate"
v-model="newEditGearComponentIsActive"
v-model="newEditGearComponentActive"
required
>
<option :value="true">{{ $t('generalItems.yes') }}</option>
@@ -359,7 +359,7 @@ const newEditGearComponentExpectedDistanceMiles = ref(null)
const newEditGearComponentExpectedTime = ref(null)
const newEditGearComponentPurchaseValue = ref(null)
const newEditGearComponentRetiredDate = ref(null)
const newEditGearComponentIsActive = ref(true)
const newEditGearComponentActive = ref(true)
onMounted(() => {
newEditGearComponentUserId.value = props.gear.user_id
@@ -384,7 +384,7 @@ onMounted(() => {
}
newEditGearComponentPurchaseValue.value = props.gearComponent.purchase_value
newEditGearComponentRetiredDate.value = props.gearComponent.retired_date
newEditGearComponentIsActive.value = props.gearComponent.is_active
newEditGearComponentActive.value = props.gearComponent.active
} else {
if (props.gear.gear_type === 1) {
newEditGearComponentType.value = 'back_break_oil'
@@ -400,10 +400,10 @@ onMounted(() => {
function updateIsActiveBasedOnRetiredDate() {
if (newEditGearComponentRetiredDate.value && newEditGearComponentRetiredDate.value !== '') {
newEditGearComponentIsActive.value = false
newEditGearComponentActive.value = false
} else {
newEditGearComponentRetiredDate.value = null
newEditGearComponentIsActive.value = true
newEditGearComponentActive.value = true
}
}
@@ -479,7 +479,7 @@ async function submitEditGearComponentForm() {
retired_date: newEditGearComponentRetiredDate.value
? newEditGearComponentRetiredDate.value
: null,
is_active: newEditGearComponentIsActive.value,
active: newEditGearComponentActive.value,
expected_kms: expected_kms,
purchase_value: newEditGearComponentPurchaseValue.value
}

View File

@@ -116,7 +116,7 @@
/></a>
</span>
<div v-if="gearComponent.is_active == 0" class="d-flex justify-content-end">
<div v-if="gearComponent.active == false" class="d-flex justify-content-end">
<span class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis">
{{ $t('gearComponentListComponent.gearComponentListGearComponentIsInactiveBadge') }}
</span>

View File

@@ -119,22 +119,22 @@
v-model="newEditGearCreatedDate"
required
/>
<!-- gear is_active fields -->
<label for="gearIsActiveAddEdit"
<!-- gear active fields -->
<label for="gearActiveAddEdit"
><b
>* {{ $t('gearsAddEditGearModalComponent.addEditGearModalAddIsActiveLabel') }}</b
></label
>
<select
class="form-select"
name="gearIsActiveAddEdit"
v-model="newEditGearIsActive"
name="gearActiveAddEdit"
v-model="newEditGearActive"
required
>
<option value="1">
<option value="true">
{{ $t('gearsAddEditGearModalComponent.addEditGearModalAddIsActiveOption1') }}
</option>
<option value="0">
<option value="false">
{{ $t('gearsAddEditGearModalComponent.addEditGearModalAddIsActiveOption0') }}
</option>
</select>
@@ -268,7 +268,7 @@ const newEditGearModel = ref('')
const newEditGearNickname = ref('')
const newEditGearType = ref(1)
const newEditGearCreatedDate = ref(new Date().toISOString().split('T')[0])
const newEditGearIsActive = ref(1)
const newEditGearActive = ref(true)
const newEditGearInitialKms = ref(0)
const newEditGearInitialMiles = ref(0)
const newEditGearPurchaseValue = ref(0)
@@ -314,7 +314,7 @@ onMounted(() => {
newEditGearNickname.value = props.gear.nickname
newEditGearType.value = props.gear.gear_type
newEditGearCreatedDate.value = props.gear.created_at
newEditGearIsActive.value = props.gear.is_active
newEditGearActive.value = props.gear.active
newEditGearInitialKms.value = props.gear.initial_kms
if (props.gear.initial_kms && props.gear.initial_kms !== 0) {
newEditGearInitialMiles.value = kmToMiles(props.gear.initial_kms)
@@ -332,7 +332,7 @@ async function submitAddGearForm() {
nickname: newEditGearNickname.value,
gear_type: newEditGearType.value,
created_at: newEditGearCreatedDate.value,
is_active: newEditGearIsActive.value,
active: newEditGearActive.value,
initial_kms: newEditGearInitialKms.value,
purchase_value: newEditGearPurchaseValue.value
}
@@ -344,7 +344,7 @@ async function submitAddGearForm() {
newEditGearNickname.value = ''
newEditGearType.value = 1
newEditGearCreatedDate.value = new Date().toISOString().split('T')[0]
newEditGearIsActive.value = 1
newEditGearActive.value = true
newEditGearInitialKms.value = 0
newEditGearInitialMiles.value = 0
newEditGearPurchaseValue.value = 0
@@ -372,7 +372,7 @@ async function submitEditGearForm() {
nickname: newEditGearNickname.value,
gear_type: newEditGearType.value,
created_at: newEditGearCreatedDate.value,
is_active: newEditGearIsActive.value,
active: newEditGearActive.value,
initial_kms: newEditGearInitialKms.value,
purchase_value: newEditGearPurchaseValue.value
}

View File

@@ -111,7 +111,7 @@
</span>
<span
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis align-middle d-none d-sm-inline"
v-if="gear.is_active == 0"
v-if="gear.active == false"
>{{ $t('gearsListComponent.gearListGearIsInactiveBadge') }}</span
>

View File

@@ -141,6 +141,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
// import the stores
import { useAuthStore } from '@/stores/authStore'
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
// Import Notivue push
import { push } from 'notivue'
@@ -153,12 +154,14 @@ import NavbarNotificationsComponent from '@/components/Navbar/NavbarNotification
// Composables
const router = useRouter()
const authStore = useAuthStore()
const serverSettingsStore = useServerSettingsStore()
const { locale, t } = useI18n()
// Methods
async function handleLogout() {
try {
await authStore.logoutUser(router, locale)
serverSettingsStore.setServerSettingsOnLogout()
} catch (error) {
push.error(`${t('navbarComponent.errorLogout')} - ${error}`)
}

View File

@@ -38,6 +38,8 @@ import { useI18n } from 'vue-i18n'
const { locale, t } = useI18n()
const languages = computed(() => [
{ value: 'ca', label: t('generalItems.languageOption2') },
{ value: 'cn', label: t('generalItems.languageOption8') },
{ value: 'tw', label: t('generalItems.languageOption9') },
{ value: 'de', label: t('generalItems.languageOption4') },
{ value: 'fr', label: t('generalItems.languageOption5') },
{ value: 'nl', label: t('generalItems.languageOption6') },

View File

@@ -56,6 +56,12 @@
v-else-if="notification.type === 12"
@notificationRead="markNotificationAsRead"
/>
<AdminNewSignUpApprovalRequestNotificationComponent
:notification="notification"
:showDropdown="showDropdown"
v-else-if="notification.type === 101"
@notificationRead="markNotificationAsRead"
/>
</li>
<li v-if="totalPages > 1 && totalPages > pageNumber">
<a class="dropdown-item" @click="setPageNumber">Load more...</a>
@@ -75,6 +81,7 @@ import { notifications } from '@/services/notificationsService'
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
import { useAuthStore } from '@/stores/authStore'
import AdminNewSignUpApprovalRequestNotificationComponent from '@/components/Notifications/AdminNewSignUpApprovalRequestNotificationComponent.vue'
import NewAcceptedRequestNotificationComponent from '@/components/Notifications/NewAcceptedRequestNotificationComponent.vue'
import NewActivityNotificationComponent from '@/components/Notifications/NewActivityNotificationComponent.vue'
import NewActivityDuplicateStartTimeNotificationComponent from '@/components/Notifications/NewActivityDuplicateStartTimeNotificationComponent.vue'
@@ -177,7 +184,8 @@ onMounted(async () => {
(data.message === 'NEW_ACTIVITY_NOTIFICATION' ||
data.message === 'NEW_DUPLICATE_ACTIVITY_START_TIME_NOTIFICATION' ||
data.message === 'NEW_FOLLOWER_REQUEST_NOTIFICATION' ||
data.message === 'NEW_FOLLOWER_REQUEST_ACCEPTED_NOTIFICATION')
data.message === 'NEW_FOLLOWER_REQUEST_ACCEPTED_NOTIFICATION' ||
data.message === 'ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST_NOTIFICATION')
) {
await fetchNotificationById(data.notification_id)
}

View File

@@ -20,10 +20,10 @@
@click="changeTheme(theme.value)"
:aria-pressed="themeStore.theme === theme.value ? 'true' : 'false'"
>
<span v-if="theme.label == 'Dark'" class="me-1"
<span v-if="theme.value == 'dark'" class="me-1"
><font-awesome-icon :icon="['fas', 'moon']"
/></span>
<span v-else-if="theme.label == 'Light'"
<span v-else-if="theme.value == 'light'"
><font-awesome-icon :icon="['fas', 'sun']"
/></span>
<span v-else><font-awesome-icon :icon="['fas', 'circle-half-stroke']" /></span>

View File

@@ -0,0 +1,53 @@
<template>
<router-link
class="dropdown-item link-body-emphasis text-wrap"
:to="{
name: 'settings',
query: { tab: 'users', username: notification.options['user_username'] }
}"
>
<span
><b>{{ $t('adminNewSignUpApprovalRequestNotificationComponent.title') }}</b></span
>
<br />
<span class="fw-lighter">
{{ notification.options['user_name'] }} - @{{ notification.options['user_username']
}}{{ $t('adminNewSignUpApprovalRequestNotificationComponent.subTitle') }}
</span>
</router-link>
</template>
<script setup>
import { computed, watch } from 'vue'
// Importing the i18n
import { useI18n } from 'vue-i18n'
import { notifications } from '@/services/notificationsService'
const { t } = useI18n()
const emit = defineEmits(['notificationRead'])
const props = defineProps({
notification: {
type: Object,
required: true
},
showDropdown: {
type: Boolean,
required: true
}
})
const dropdownState = computed(() => {
return props.showDropdown
})
function markNotificationAsRead() {
if (props.notification.read === false && props.showDropdown === true) {
notifications.markNotificationAsRead(props.notification.id)
emit('notificationRead', props.notification.id)
}
}
// Watch the page number variable.
watch(dropdownState, markNotificationAsRead, { immediate: false })
</script>

View File

@@ -26,6 +26,8 @@ export default {
const { locale, t } = useI18n()
const languages = computed(() => [
{ value: 'ca', label: t('generalItems.languageOption2') },
{ value: 'cn', label: t('generalItems.languageOption8') },
{ value: 'tw', label: t('generalItems.languageOption9') },
{ value: 'de', label: t('generalItems.languageOption4') },
{ value: 'fr', label: t('generalItems.languageOption5') },
{ value: 'nl', label: t('generalItems.languageOption6') },

View File

@@ -0,0 +1,80 @@
<template>
<div class="col">
<div class="bg-body-tertiary rounded p-3 shadow-sm">
<!-- list zone -->
<ul class="list-group list-group-flush">
<!-- bulk import zone -->
<li class="list-group-item d-flex justify-content-between bg-body-tertiary px-0">
<div class="d-flex align-items-center">
<font-awesome-icon :icon="['fas', 'file-import']" size="2x" />
<div class="ms-3">
<div class="fw-bold">
{{ $t('settingsImportZone.bulkImportIntegrationTitle') }}
</div>
{{ $t('settingsImportZone.bulkImportIntegrationBody') }}
</div>
</div>
<div class="d-flex align-items-center">
<!-- import button -->
<a href="#" class="btn btn-primary" role="button" @click="submitBulkImport"
>{{ $t('settingsImportZone.buttonBulkImport') }}
</a>
</div>
</li>
<!-- Strava bulk-export import zone -->
<li class="list-group-item d-flex justify-content-between bg-body-tertiary px-0">
<div class="d-flex align-items-center">
<font-awesome-icon :icon="['fas', 'file-import']" size="2x" />
<div class="ms-3">
<div class="fw-bold">
{{ $t('settingsImportZone.stravaGearImportTitle') }}
{{ $t('generalItems.betaTag') }}
</div>
{{ $t('settingsImportZone.stravaGearImportBody') }}
</div>
</div>
<div class="d-flex align-items-center">
<!-- import button -->
<a href="#" class="btn btn-primary" role="button" @click="submitStravaBikesImport"
>{{ $t('settingsImportZone.stravaGearImportbuttonBikes') }}
</a>
</div>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
// Import Notivue push
import { push } from 'notivue'
// Importing the services
import { activities } from '@/services/activitiesService'
import { strava as stravaService } from '@/services/stravaService'
const { t } = useI18n()
async function submitBulkImport() {
try {
await activities.bulkImportActivities()
// Show the loading alert.
push.info(t('settingsImportZone.loadingMessageBulkImport'))
} catch (error) {
// If there is an error, show the error alert.
push.error(`${t('settingsImportZone.errorMessageUnableToImportActivities')} - ${error}`)
}
}
async function submitStravaBikesImport() {
// Set the loading message
const notification = push.promise(t('settingsImportZone.loadingMessageStravaBikesImport'))
try {
await stravaService.importBikes()
// Resolve the loading message with a success message
notification.resolve(t('settingsImportZone.successMessageStravaBikesImport'))
} catch (error) {
// Reject the loading message with an error message
notification.reject(`${t('settingsImportZone.errorMessageUnableToImportBikes')} - ${error}`)
}
}
</script>

View File

@@ -86,24 +86,6 @@
</div>
</div>
</li>
<!-- bulk import zone -->
<li class="list-group-item d-flex justify-content-between bg-body-tertiary px-0">
<div class="d-flex align-items-center">
<font-awesome-icon :icon="['fas', 'file-import']" size="2x" />
<div class="ms-3">
<div class="fw-bold">
{{ $t('settingsIntegrationsZone.bulkImportIntegrationTitle') }}
</div>
{{ $t('settingsIntegrationsZone.bulkImportIntegrationBody') }}
</div>
</div>
<div class="d-flex align-items-center">
<!-- import button -->
<a href="#" class="btn btn-primary" role="button" @click="submitBulkImport">{{
$t('settingsIntegrationsZone.buttonBulkImport')
}}</a>
</div>
</li>
<!-- Garmin Connect zone -->
<li class="list-group-item d-flex justify-content-between bg-body-tertiary px-0 pb-0">
<div class="d-flex align-items-center">
@@ -378,15 +360,6 @@ async function buttonStravaUnlink() {
}
}
async function submitBulkImport() {
try {
await activities.bulkImportActivities()
push.info(t('settingsIntegrationsZone.loadingMessageBulkImport'))
} catch (error) {
push.error(`${t('settingsIntegrationsZone.errorMessageUnableToImportActivities')} - ${error}`)
}
}
async function submitRetrieveGarminConnectActivitiesDays(days) {
try {
const endDate = new Date()

View File

@@ -1,139 +1,205 @@
<template>
<div class="col">
<form class="bg-body-tertiary rounded p-3 shadow-sm">
<!-- Defaults -->
<h4>{{ $t('settingsServerSettingsZoneComponent.defaultsTitle') }}</h4>
<!-- Units -->
<label>{{ $t('settingsServerSettingsZoneComponent.unitsLabel') }}</label>
<select class="form-select" name="serverSettingsUnits" v-model="units" required>
<option value="1">{{ $t('settingsServerSettingsZoneComponent.unitsMetric') }}</option>
<option value="2">{{ $t('settingsServerSettingsZoneComponent.unitsImperial') }}</option>
</select>
<!-- Currency -->
<label class="mt-1">{{ $t('settingsServerSettingsZoneComponent.currencyLabel') }}</label>
<select class="form-select" name="serverSettingsCurrency" v-model="currency" required>
<option value="1">{{ $t('generalItems.currencyEuro') }}</option>
<option value="2">{{ $t('generalItems.currencyDollar') }}</option>
<option value="3">{{ $t('generalItems.currencyPound') }}</option>
</select>
<!-- Num records per list -->
<label class="mt-1">{{ $t('settingsServerSettingsZoneComponent.numRecordsLabel') }}</label>
<select
class="form-select"
name="serverSettingsNumRecordsPerPage"
v-model="numRecordsPerPage"
required
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<LoadingComponent v-if="isLoading" />
<div v-else>
<label>{{ $t('settingsServerSettingsZoneComponent.unitsLabel') }}</label>
<select class="form-select" name="serverSettingsUnits" v-model="units" required>
<option value="1">{{ $t('settingsServerSettingsZoneComponent.unitsMetric') }}</option>
<option value="2">{{ $t('settingsServerSettingsZoneComponent.unitsImperial') }}</option>
</select>
<!-- Currency -->
<label class="mt-1">{{ $t('settingsServerSettingsZoneComponent.currencyLabel') }}</label>
<select class="form-select" name="serverSettingsCurrency" v-model="currency" required>
<option value="1">{{ $t('generalItems.currencyEuro') }}</option>
<option value="2">{{ $t('generalItems.currencyDollar') }}</option>
<option value="3">{{ $t('generalItems.currencyPound') }}</option>
</select>
<!-- Num records per list -->
<label class="mt-1">{{ $t('settingsServerSettingsZoneComponent.numRecordsLabel') }}</label>
<select
class="form-select"
name="serverSettingsNumRecordsPerPage"
v-model="numRecordsPerPage"
required
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<hr />
<!-- Sign-up -->
<h4>{{ $t('settingsServerSettingsZoneComponent.signupTitle') }}</h4>
<LoadingComponent v-if="isLoading" />
<div v-else>
<label class="form-label" for="serverSettingsSignUpEnabledSelect">{{
$t('settingsServerSettingsZoneComponent.enabledLabel')
}}</label>
<select
class="form-select"
name="serverSettingsSignUpEnabledSelect"
v-model="signUp"
required
>
<option value="false">
{{ $t('generalItems.false') }}
</option>
<option value="true">
{{ $t('generalItems.true') }}
</option>
</select>
<!-- requires admin approval -->
<label class="form-label" for="serverSettingsAdminApprovalSelect">{{
$t('settingsServerSettingsZoneComponent.adminApprovalLabel')
}}</label>
<select
class="form-select"
name="serverSettingsAdminApprovalSelect"
v-model="adminApproval"
required
>
<option value="false">
{{ $t('generalItems.false') }}
</option>
<option value="true">
{{ $t('generalItems.true') }}
</option>
</select>
<!-- requires email confirmation -->
<label class="form-label" for="serverSettingsEmailConfirmationSelect">{{
$t('settingsServerSettingsZoneComponent.emailConfirmationLabel')
}}</label>
<select
class="form-select"
name="serverSettingsEmailConfirmationSelect"
v-model="emailConfirmation"
required
>
<option value="false">
{{ $t('generalItems.false') }}
</option>
<option value="true">
{{ $t('generalItems.true') }}
</option>
</select>
</div>
<hr />
<!-- Public shareable links -->
<h4 class="mt-4">
{{ $t('settingsServerSettingsZoneComponent.publicShareableLinksLabel') }}
</h4>
<label class="form-label" for="serverSettingsPublicShareableLinksEnabledSelect">{{
$t('settingsServerSettingsZoneComponent.publicShareableLinksEnabledLabel')
}}</label>
<select
class="form-select"
name="serverSettingsPublicShareableLinksEnabledSelect"
v-model="publicShareableLinks"
required
>
<option value="false">
{{ $t('settingsServerSettingsZoneComponent.publicShareableLinksFalse') }}
</option>
<option value="true">
{{ $t('settingsServerSettingsZoneComponent.publicShareableLinksTrue') }}
</option>
</select>
<div class="alert alert-warning mt-2" role="alert">
<font-awesome-icon :icon="['fas', 'triangle-exclamation']" />
<span class="ms-2">{{
$t(
'settingsServerSettingsZoneComponent.serverSettingsPublicShareableLinksEnabledWarningAlert'
)
}}</span>
</div>
<!-- Public shareable user info -->
<label class="form-label" for="serverSettingsPublicShareableLinksShowUserInfo">{{
$t('settingsServerSettingsZoneComponent.publicShareableLinksShowUserInfoLabel')
}}</label>
<select
class="form-select"
name="serverSettingsPublicShareableLinksShowUserInfo"
v-model="publicShareableLinksUserInfo"
required
>
<option value="false">
{{ $t('settingsServerSettingsZoneComponent.publicShareableLinksFalse') }}
</option>
<option value="true">
{{ $t('settingsServerSettingsZoneComponent.publicShareableLinksTrue') }}
</option>
</select>
<div class="alert alert-warning mt-2" role="alert">
<font-awesome-icon :icon="['fas', 'triangle-exclamation']" />
<span class="ms-2">{{
$t(
'settingsServerSettingsZoneComponent.serverSettingsPublicShareableLinksShowUserWarningAlert'
)
}}</span>
<LoadingComponent v-if="isLoading" />
<div v-else>
<label class="form-label" for="serverSettingsPublicShareableLinksEnabledSelect">{{
$t('settingsServerSettingsZoneComponent.enabledLabel')
}}</label>
<select
class="form-select"
name="serverSettingsPublicShareableLinksEnabledSelect"
v-model="publicShareableLinks"
required
>
<option value="false">
{{ $t('generalItems.false') }}
</option>
<option value="true">
{{ $t('generalItems.true') }}
</option>
</select>
<div class="alert alert-warning mt-2" role="alert">
<font-awesome-icon :icon="['fas', 'triangle-exclamation']" />
<span class="ms-2">{{
$t(
'settingsServerSettingsZoneComponent.serverSettingsPublicShareableLinksEnabledWarningAlert'
)
}}</span>
</div>
<!-- Public shareable user info -->
<label class="form-label" for="serverSettingsPublicShareableLinksShowUserInfo">{{
$t('settingsServerSettingsZoneComponent.publicShareableLinksShowUserInfoLabel')
}}</label>
<select
class="form-select"
name="serverSettingsPublicShareableLinksShowUserInfo"
v-model="publicShareableLinksUserInfo"
required
>
<option value="false">
{{ $t('generalItems.false') }}
</option>
<option value="true">
{{ $t('generalItems.true') }}
</option>
</select>
<div class="alert alert-warning mt-2" role="alert">
<font-awesome-icon :icon="['fas', 'triangle-exclamation']" />
<span class="ms-2">{{
$t(
'settingsServerSettingsZoneComponent.serverSettingsPublicShareableLinksShowUserWarningAlert'
)
}}</span>
</div>
</div>
<hr />
<!-- Login photo set -->
<h4 class="mt-4">{{ $t('settingsServerSettingsZoneComponent.photosLabel') }}</h4>
<div class="row">
<div class="col">
<label class="form-label" for="serverSettingsLoginPhotoLabel">{{
$t('settingsServerSettingsZoneComponent.loginPhotoLabel')
}}</label>
<!-- add login photo button -->
<a
class="w-100 btn btn-primary shadow-sm"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#addLoginPhotoModal"
v-if="!loginPhotoSet"
>
{{ $t('settingsServerSettingsZoneComponent.buttonAddPhoto') }}
</a>
<LoadingComponent v-if="isLoading" />
<div v-else>
<div class="row">
<div class="col">
<label class="form-label" for="serverSettingsLoginPhotoLabel">{{
$t('settingsServerSettingsZoneComponent.loginPhotoLabel')
}}</label>
<!-- add login photo button -->
<a
class="w-100 btn btn-primary shadow-sm"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#addLoginPhotoModal"
v-if="!loginPhotoSet"
>
{{ $t('settingsServerSettingsZoneComponent.buttonAddPhoto') }}
</a>
<!-- Delete login photo section -->
<a
class="w-100 btn btn-danger"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#deleteLoginPhotoModal"
v-else
>{{ $t('settingsServerSettingsZoneComponent.buttonDeleteLoginPhoto') }}</a
>
<!-- Delete login photo section -->
<a
class="w-100 btn btn-danger"
href="#"
role="button"
data-bs-toggle="modal"
data-bs-target="#deleteLoginPhotoModal"
v-else
>{{ $t('settingsServerSettingsZoneComponent.buttonDeleteLoginPhoto') }}</a
>
<!-- Modal add login photo -->
<ModalComponentUploadFile
modalId="addLoginPhotoModal"
:title="$t('settingsServerSettingsZoneComponent.loginPhotoLabel')"
:fileFieldLabel="$t('settingsServerSettingsZoneComponent.logonPhotoAddLabel')"
filesAccepted=".png"
actionButtonType="success"
:actionButtonText="$t('settingsServerSettingsZoneComponent.loginPhotoLabel')"
@fileToEmitAction="submitUploadFileForm"
/>
<!-- Modal add login photo -->
<ModalComponentUploadFile
modalId="addLoginPhotoModal"
:title="$t('settingsServerSettingsZoneComponent.loginPhotoLabel')"
:fileFieldLabel="$t('settingsServerSettingsZoneComponent.logonPhotoAddLabel')"
filesAccepted=".png"
actionButtonType="success"
:actionButtonText="$t('settingsServerSettingsZoneComponent.loginPhotoLabel')"
@fileToEmitAction="submitUploadFileForm"
/>
<!-- Modal delete login photo -->
<ModalComponent
modalId="deleteLoginPhotoModal"
:title="t('settingsServerSettingsZoneComponent.buttonDeleteLoginPhoto')"
:body="`${t('settingsServerSettingsZoneComponent.modalDeleteLoginPhotoBody')}`"
actionButtonType="danger"
:actionButtonText="t('settingsServerSettingsZoneComponent.buttonDeleteLoginPhoto')"
@submitAction="submitDeleteLoginPhoto"
/>
<!-- Modal delete login photo -->
<ModalComponent
modalId="deleteLoginPhotoModal"
:title="t('settingsServerSettingsZoneComponent.buttonDeleteLoginPhoto')"
:body="`${t('settingsServerSettingsZoneComponent.modalDeleteLoginPhotoBody')}`"
actionButtonType="danger"
:actionButtonText="t('settingsServerSettingsZoneComponent.buttonDeleteLoginPhoto')"
@submitAction="submitDeleteLoginPhoto"
/>
</div>
</div>
</div>
</form>
@@ -141,15 +207,17 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, watch, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
import { serverSettings } from '@/services/serverSettingsService'
import { push } from 'notivue'
import ModalComponent from '@/components/Modals/ModalComponent.vue'
import ModalComponentUploadFile from '@/components/Modals/ModalComponentUploadFile.vue'
import LoadingComponent from '@/components/GeneralComponents/LoadingComponent.vue'
const { t } = useI18n()
const isLoading = ref(true)
const serverSettingsStore = useServerSettingsStore()
const units = ref(serverSettingsStore.serverSettings.units)
const currency = ref(serverSettingsStore.serverSettings.currency)
@@ -159,6 +227,9 @@ const publicShareableLinksUserInfo = ref(
serverSettingsStore.serverSettings.public_shareable_links_user_info
)
const loginPhotoSet = ref(serverSettingsStore.serverSettings.login_photo_set)
const signUp = ref(serverSettingsStore.serverSettings.signup_enabled)
const adminApproval = ref(serverSettingsStore.serverSettings.signup_require_admin_approval)
const emailConfirmation = ref(serverSettingsStore.serverSettings.signup_require_email_verification)
async function updateServerSettings() {
const data = {
@@ -168,7 +239,10 @@ async function updateServerSettings() {
num_records_per_page: numRecordsPerPage.value,
public_shareable_links: publicShareableLinks.value,
public_shareable_links_user_info: publicShareableLinksUserInfo.value,
login_photo_set: loginPhotoSet.value
login_photo_set: loginPhotoSet.value,
signup_enabled: signUp.value,
signup_require_admin_approval: adminApproval.value,
signup_require_email_verification: emailConfirmation.value
}
try {
// Update the server settings in the DB
@@ -228,10 +302,45 @@ const submitDeleteLoginPhoto = async () => {
}
}
onMounted(async () => {
try {
const settings = await serverSettings.getServerSettings()
serverSettingsStore.setServerSettings(settings)
// Update local state
units.value = serverSettingsStore.serverSettings.units
currency.value = serverSettingsStore.serverSettings.currency
numRecordsPerPage.value = serverSettingsStore.serverSettings.num_records_per_page
publicShareableLinks.value = serverSettingsStore.serverSettings.public_shareable_links
publicShareableLinksUserInfo.value =
serverSettingsStore.serverSettings.public_shareable_links_user_info
loginPhotoSet.value = serverSettingsStore.serverSettings.login_photo_set
signUp.value = serverSettingsStore.serverSettings.signup_enabled
adminApproval.value = serverSettingsStore.serverSettings.signup_require_admin_approval
emailConfirmation.value = serverSettingsStore.serverSettings.signup_require_email_verification
await nextTick()
isLoading.value = false
} catch (error) {
push.error(`${t('settingsServerSettingsZoneComponent.errorFetchingServerSettings')} - ${error}`)
isLoading.value = false
}
})
watch(
[units, currency, numRecordsPerPage, publicShareableLinks, publicShareableLinksUserInfo],
[
units,
currency,
numRecordsPerPage,
publicShareableLinks,
publicShareableLinksUserInfo,
signUp,
adminApproval,
emailConfirmation
],
async () => {
await updateServerSettings()
if (isLoading.value == false) {
await updateServerSettings()
}
},
{ immediate: false }
)

View File

@@ -84,6 +84,17 @@
<span class="ms-1">{{ $t('settingsSideBar.integrationsSection') }}</span>
</a>
</li>
<li class="nav-item">
<a
href="#"
class="nav-link link-body-emphasis"
:class="{ active: activeSection === 'import' }"
@click.prevent="changeActive('import')"
>
<font-awesome-icon :icon="['fas', 'fa-upload']" />
<span class="ms-1">{{ $t('settingsSideBar.importSection') }}</span>
</a>
</li>
</ul>
</div>
</template>

View File

@@ -149,6 +149,12 @@
<span v-if="authStore.user.preferred_language == 'ca'">{{
$t('generalItems.languageOption2')
}}</span>
<span v-if="authStore.user.preferred_language == 'cn'">{{
$t('generalItems.languageOption8')
}}</span>
<span v-if="authStore.user.preferred_language == 'tw'">{{
$t('generalItems.languageOption9')
}}</span>
<span v-if="authStore.user.preferred_language == 'de'">{{
$t('generalItems.languageOption4')
}}</span>

View File

@@ -35,7 +35,7 @@
</div>
</div>
<div>
<LoadingComponent v-if="isLoading" />
<LoadingComponent class="mt-3" v-if="isLoading" />
<div v-else>
<!-- Checking if usersArray is loaded and has length -->
<div class="mt-3" v-if="usersArray && usersArray.length">
@@ -68,6 +68,7 @@
:user="user"
@userDeleted="updateUserList"
@editedUser="editUserList"
@approvedUser="approvedUserList"
/>
</ul>
@@ -92,6 +93,7 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { push } from 'notivue'
import { debounce } from 'lodash'
@@ -104,6 +106,7 @@ import { users } from '@/services/usersService'
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
const { t } = useI18n()
const route = useRoute()
const serverSettingsStore = useServerSettingsStore()
const isLoading = ref(false)
const isUsersUpdatingLoading = ref(false)
@@ -122,9 +125,13 @@ const performSearch = debounce(async () => {
return
}
try {
isLoading.value = true
usersArray.value = await users.getUserContainsUsername(searchUsername.value)
usersNumber.value = usersArray.value.length
} catch (error) {
push.error(`${t('settingsUsersZone.errorFetchingUsers')} - ${error}`)
} finally {
isLoading.value = false
}
}, 500)
@@ -168,14 +175,25 @@ function editUserList(editedUser) {
usersArray.value[index] = editedUser
}
function approvedUserList(userID) {
const index = usersArray.value.findIndex((user) => user.id === userID)
usersArray.value[index].pending_admin_approval = false
usersArray.value[index].email_verified = true
usersArray.value[index].active = true
}
function setIsLoadingNewUser(state) {
isLoadingNewUser.value = state
}
onMounted(async () => {
isLoading.value = true
await fetchUsers()
isLoading.value = false
if (route.query.username) {
searchUsername.value = route.query.username
} else {
await fetchUsers()
isLoading.value = false
}
})
watch(searchUsername, performSearch, { immediate: false })

View File

@@ -204,13 +204,13 @@
required
>
<option :value="1">
{{ $t('usersAddEditUserModalComponent.addEditUserModalGenderOption1') }}
{{ $t('generalItems.genderMale') }}
</option>
<option :value="2">
{{ $t('usersAddEditUserModalComponent.addEditUserModalGenderOption2') }}
{{ $t('generalItems.genderFemale') }}
</option>
<option :value="3">
{{ $t('usersAddEditUserModalComponent.addEditUserModalGenderOption3') }}
{{ $t('generalItems.genderUnspecified') }}
</option>
</select>
<!-- units fields -->
@@ -250,18 +250,18 @@
}})</b
></label
>
<input
class="form-control"
type="number"
name="userHeightAddEditCms"
:placeholder="
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder') +
' (' +
$t('generalItems.unitsCm') +
')'
"
v-model="newEditUserHeightCms"
/>
<div class="input-group">
<input
class="form-control"
type="number"
name="userHeightAddEditCms"
:placeholder="
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder')
"
v-model="newEditUserHeightCms"
/>
<span class="input-group-text">{{ $t('generalItems.unitsCm') }}</span>
</div>
</div>
<div v-else>
<label for="userHeightAddEditFeetInches"
@@ -279,17 +279,14 @@
aria-describedby="validationFeetFeedback"
name="userHeightAddEditFeet"
:placeholder="
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder') +
' (' +
$t('generalItems.unitsFeet') +
')'
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder')
"
v-model="newEditUserHeightFeet"
min="0"
max="10"
step="1"
/>
<span class="input-group-text"></span>
<span class="input-group-text">{{ $t('generalItems.unitsFeet') }}</span>
<input
class="form-control"
:class="{ 'is-invalid': !isInchesValid }"
@@ -297,17 +294,14 @@
aria-describedby="validationInchesFeedback"
name="userHeightAddEditInches"
:placeholder="
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder') +
' (' +
$t('generalItems.unitsInches') +
')'
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder')
"
v-model="newEditUserHeightInches"
min="0"
max="11"
step="1"
/>
<span class="input-group-text"></span>
<span class="input-group-text">{{ $t('generalItems.unitsInches') }}</span>
<div id="validationFeetFeedback" class="invalid-feedback" v-if="!isFeetValid">
{{ $t('usersAddEditUserModalComponent.addEditUserModalFeetValidationLabel') }}
</div>
@@ -332,6 +326,8 @@
required
>
<option value="ca">{{ $t('generalItems.languageOption2') }}</option>
<option value="cn">{{ $t('generalItems.languageOption8') }}</option>
<option value="tw">{{ $t('generalItems.languageOption9') }}</option>
<option value="de">{{ $t('generalItems.languageOption4') }}</option>
<option value="fr">{{ $t('generalItems.languageOption5') }}</option>
<option value="nl">{{ $t('generalItems.languageOption6') }}</option>
@@ -383,24 +379,24 @@
</option>
</select>
</div>
<!-- user is_active fields -->
<!-- user active fields -->
<div v-if="action != 'profile'">
<label for="userIsActiveAddEdit"
<label for="userActiveAddEdit"
><b
>* {{ $t('usersAddEditUserModalComponent.addEditUserModalIsActiveLabel') }}</b
></label
>
<select
class="form-select"
name="userIsActiveAddEdit"
v-model="newEditUserIsActive"
name="userActiveAddEdit"
v-model="newEditUserActive"
required
>
<option :value="1">
{{ $t('usersAddEditUserModalComponent.addEditUserModalIsActiveOption1') }}
<option :value="true">
{{ $t('generalItems.yes') }}
</option>
<option :value="2">
{{ $t('usersAddEditUserModalComponent.addEditUserModalIsActiveOption2') }}
<option :value="false">
{{ $t('generalItems.no') }}
</option>
</select>
</div>
@@ -508,7 +504,7 @@ const isInchesValid = computed(
)
const newEditUserPreferredLanguage = ref('us')
const newEditUserAccessType = ref(1)
const newEditUserIsActive = ref(1)
const newEditUserActive = ref(true)
const newEditUserPhotoPath = ref(null)
const isUsernameExists = ref(true)
const isEmailExists = ref(true)
@@ -545,7 +541,7 @@ if (props.user) {
newEditUserPreferredLanguage.value = props.user.preferred_language
newEditUserFirstDayOfWeek.value = props.user.first_day_of_week
newEditUserAccessType.value = props.user.access_type
newEditUserIsActive.value = props.user.is_active
newEditUserActive.value = props.user.active
newEditUserPhotoPath.value = props.user.photo_path
if (props.user.height) {
const { feet, inches } = cmToFeetInches(props.user.height)
@@ -641,8 +637,8 @@ async function submitAddUserForm() {
if (isPasswordValid.value) {
const data = {
name: newEditUserName.value,
username: newEditUserUsername.value,
email: newEditUserEmail.value,
username: newEditUserUsername.value.toLowerCase(),
email: newEditUserEmail.value.toLowerCase(),
city: newEditUserCity.value,
birthdate: newEditUserBirthDate.value,
preferred_language: newEditUserPreferredLanguage.value,
@@ -653,7 +649,7 @@ async function submitAddUserForm() {
access_type: newEditUserAccessType.value,
photo_path: null,
first_day_of_week: newEditUserFirstDayOfWeek.value,
is_active: newEditUserIsActive.value,
active: newEditUserActive.value,
password: newUserPassword.value
}
const createdUser = await users.createUser(data)
@@ -684,9 +680,9 @@ async function submitEditUserForm() {
try {
const data = {
id: props.user.id,
username: newEditUserUsername.value,
username: newEditUserUsername.value.toLowerCase(),
name: newEditUserName.value,
email: newEditUserEmail.value,
email: newEditUserEmail.value.toLowerCase(),
city: newEditUserCity.value,
birthdate: newEditUserBirthDate.value,
gender: newEditUserGender.value,
@@ -697,7 +693,7 @@ async function submitEditUserForm() {
first_day_of_week: newEditUserFirstDayOfWeek.value,
access_type: newEditUserAccessType.value,
photo_path: newEditUserPhotoPath.value,
is_active: newEditUserIsActive.value
active: newEditUserActive.value
}
if (newEditUserPhotoFile.value) {
try {

View File

@@ -25,10 +25,15 @@
>{{ $t('usersListComponent.userListUserIsAdminBadge') }}</span
>
<span
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis d-none d-sm-inline"
v-if="user.is_active == 2"
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
v-if="user.active == false"
>{{ $t('usersListComponent.userListUserIsInactiveBadge') }}</span
>
<span
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis d-none d-sm-inline"
v-if="user.email_verified == false"
>{{ $t('usersListComponent.userListUserHasUnverifiedEmailBadge') }}</span
>
<!-- button toggle user details -->
<a
@@ -44,6 +49,50 @@
<font-awesome-icon :icon="['fas', 'caret-up']" v-else />
</a>
<!-- approve sign-up button -->
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#approveSignUpModal${user.id}`"
v-if="user.pending_admin_approval && user.email_verified"
><font-awesome-icon :icon="['fas', 'fa-check']"
/></a>
<!-- approve sign up modal -->
<ModalComponent
:modalId="`approveSignUpModal${user.id}`"
:title="t('usersListComponent.modalApproveSignUpTitle')"
:body="`${t('usersListComponent.modalApproveSignUpBody')}<b>${user.username}</b>?`"
:actionButtonType="`success`"
:actionButtonText="t('usersListComponent.modalApproveSignUpTitle')"
@submitAction="submitApproveSignUp"
v-if="user.pending_admin_approval && user.email_verified"
/>
<!-- reject sign-up button -->
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#rejectSignUpModal${user.id}`"
v-if="user.pending_admin_approval && user.email_verified"
><font-awesome-icon :icon="['fas', 'fa-xmark']"
/></a>
<!-- reject sign up modal -->
<ModalComponent
:modalId="`rejectSignUpModal${user.id}`"
:title="t('usersListComponent.modalRejectSignUpTitle')"
:body="`${t('usersListComponent.modalRejectSignUpBody1')}<b>${user.username}</b>? ${t('usersListComponent.modalRejectSignUpBody2')}`"
:actionButtonType="`danger`"
:actionButtonText="t('usersListComponent.modalRejectSignUpTitle')"
@submitAction="submitDeleteUser"
v-if="user.pending_admin_approval && user.email_verified"
/>
<!-- change user password button -->
<a
class="btn btn-link btn-lg link-body-emphasis"
@@ -106,9 +155,7 @@
@sessionDeleted="updateSessionListDeleted"
/>
</div>
<div v-else>
<NoItemsFoundComponents />
</div>
<NoItemsFoundComponents :show-shadow="false" v-else />
</div>
</li>
</template>
@@ -134,7 +181,7 @@ const props = defineProps({
required: true
}
})
const emit = defineEmits(['userDeleted', 'editedUser'])
const emit = defineEmits(['userDeleted', 'editedUser', 'approvedUser'])
const { t } = useI18n()
const authStore = useAuthStore()
const userDetails = ref(false)
@@ -168,6 +215,17 @@ async function updateSessionListDeleted(sessionDeletedId) {
}
}
async function submitApproveSignUp() {
const notification = push.promise(t('usersListComponent.processingApproval'))
try {
await users.approveUser(props.user.id)
notification.resolve(t('usersListComponent.userApproveSuccessMessage'))
emit('approvedUser', props.user.id)
} catch (error) {
notification.reject(`${t('usersListComponent.userApproveErrorMessage')} - ${error}`)
}
}
onMounted(async () => {
userSessions.value = await session.getUserSessions(props.user.id)
isLoading.value = false

View File

@@ -43,7 +43,7 @@
}}{{ goal.goal_activities_number }} {{ $t('userGoalsStatsComponent.activities') }}</span
>
<span v-if="goal.goal_type === 3"
>{{ formatDistanceRaw(t, goal.total_distance, authStore.user.units)
>{{ formatDistanceRaw(t, goal.total_distance, authStore.user.units, false)
}}{{ $t('generalItems.ofWithSpaces')
}}{{ formatDistanceRaw(t, goal.goal_distance, authStore.user.units) }}</span
>

1
frontend/app/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -35,5 +35,8 @@
"trackRun": "Cursa en pista",
"ebikeRide": "Passeig amb E-Bike",
"ebikeMountainRide": "Passeig amb E-Bike de muntanya",
"iceSkate": "Patinatge sobre gel",
"soccer": "Futbol",
"padel": "Pàdel",
"labelWorkout": " entrenament"
}

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