mirror of
https://github.com/joaovitoriasilva/endurain.git
synced 2026-01-10 08:17:59 -05:00
Merge branch 'pre-release'
This commit is contained in:
10
.env.example
10
.env.example
@@ -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
6
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
388
backend/app/alembic/versions/v0_15_0_migration.py
Normal file
388
backend/app/alembic/versions/v0_15_0_migration.py
Normal 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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
61
backend/app/migrations/migration_6.py
Normal file
61
backend/app/migrations/migration_6.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
17
backend/app/server_settings/utils.py
Normal file
17
backend/app/server_settings/utils.py
Normal 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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
0
backend/app/sign_up_tokens/__init__.py
Normal file
0
backend/app/sign_up_tokens/__init__.py
Normal file
264
backend/app/sign_up_tokens/crud.py
Normal file
264
backend/app/sign_up_tokens/crud.py
Normal 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
|
||||
254
backend/app/sign_up_tokens/email_messages.py
Normal file
254
backend/app/sign_up_tokens/email_messages.py
Normal 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 didn’t create an Endurain account, please ignore this email.</p>
|
||||
|
||||
<p>If the button above doesn’t 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 didn’t 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 doesn’t 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 doesn’t 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
|
||||
41
backend/app/sign_up_tokens/models.py
Normal file
41
backend/app/sign_up_tokens/models.py
Normal 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")
|
||||
201
backend/app/sign_up_tokens/router.py
Normal file
201
backend/app/sign_up_tokens/router.py
Normal 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
|
||||
19
backend/app/sign_up_tokens/schema.py
Normal file
19
backend/app/sign_up_tokens/schema.py
Normal 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
|
||||
188
backend/app/sign_up_tokens/utils.py
Normal file
188
backend/app/sign_up_tokens/utils.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
868
backend/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
115
crowdin.yml
115
crowdin.yml
@@ -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%"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ module.exports = {
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
'@vue/eslint-config-prettier/skip-formatting'
|
||||
],
|
||||
parserOptions: {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
2134
frontend/app/package-lock.json
generated
2134
frontend/app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
|
||||
80
frontend/app/src/components/Settings/SettingsImportZone.vue
Normal file
80
frontend/app/src/components/Settings/SettingsImportZone.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
1
frontend/app/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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
Reference in New Issue
Block a user