Updating with v0.15 code, updating docs to reflect new shoe import procedure.

This commit is contained in:
Marc @PDev1 server
2025-10-07 20:15:18 +00:00
361 changed files with 9922 additions and 2102 deletions

View File

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

135
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,135 @@
# Endurain Fitness Tracking Application
Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
## Working Effectively
Endurain is a self-hosted fitness tracking application built with Vue.js frontend, Python FastAPI backend, and supports PostgreSQL/MariaDB databases. The primary development approach uses Docker, but frontend-only development is supported.
### Prerequisites and Environment Setup
- Requires Node.js (tested with v20.19.4) for frontend development
- Backend requires Python 3.13 (system may have 3.12, use Docker for backend)
- Docker required for full-stack development and building
- Poetry for Python dependency management (if developing backend locally)
### Quick Start Development Setup
1. Clone repository: `git clone https://github.com/joaovitoriasilva/endurain.git`
2. Navigate to project root
3. Choose development approach:
- **Frontend Only**: See Frontend Development section below
- **Full Stack**: Use Docker development setup
### Frontend Development (Recommended for UI changes)
- Navigate to frontend directory: `cd frontend/app`
- Install dependencies: `npm install` -- takes 21 seconds
- Build frontend: `npm run build` -- takes 9 seconds
- Start dev server: `npm run dev` -- runs on port 5173 (or 5174 if occupied)
- Format code: `npm run format` -- takes 6 seconds, ALWAYS run before committing
- **Note**: ESLint configuration needs migration to flat config format, lint command currently fails
- **Note**: No unit tests exist yet (`npm run test:unit` exits with "No test files found")
### Docker Development (Full Stack)
- Build unified image: `docker build -f docker/Dockerfile -t unified-image .`
- **CRITICAL WARNING**: Docker build may fail due to SSL certificate issues in CI environments
- **NEVER CANCEL**: Docker builds can take 15+ minutes. NEVER CANCEL. Set timeout to 60+ minutes.
- Create docker-compose.yml based on docker-compose.yml.example
- Start services: `docker compose up -d`
- Stop services: `docker compose down`
### Backend Development (Advanced)
- Backend uses Python 3.13 with Poetry for dependency management
- Located in `backend/` directory with `pyproject.toml`
- **Important**: Local backend development requires Python 3.13, use Docker if system has 3.12
- Install Poetry if needed: `pip install poetry`
- Install dependencies: `poetry install`
## Validation
### Manual Testing Validation
- **CRITICAL**: Always test login page loads correctly at `http://localhost:5173/login` or `http://localhost:5174/login`
- Verify that the Endurain logo, username/password fields, and "Sign in" button appear correctly
- Check that the footer displays version information and integration badges
- **Screenshot validation**: The application should look like a clean, modern fitness tracking login page with blue "Sign in" button and Strava/Garmin Connect compatibility badges
### Docker Validation
- If Docker builds fail with SSL errors, document the limitation but do not skip validation
- Test that built image starts correctly (even if SSL prevents building in CI)
### Pre-commit Validation
- ALWAYS run `npm run format` in frontend/app before committing
- Verify frontend builds successfully with `npm run build`
- Check that development server starts without errors
## Common Tasks
### File Locations and Structure
```
Repository root:
├── frontend/app/ # Vue.js frontend application
│ ├── package.json # Frontend dependencies and scripts
│ ├── src/ # Vue.js source code
│ ├── dist/ # Built frontend (after npm run build)
│ └── vite.config.js # Vite build configuration
├── backend/ # Python FastAPI backend
│ ├── pyproject.toml # Python dependencies (Poetry)
│ └── app/ # FastAPI application code
├── docker/
│ ├── Dockerfile # Multi-stage Docker build
│ └── start.sh # Container entrypoint script
├── docs/ # Documentation
├── .github/workflows/ # CI/CD pipelines
├── docker-compose.yml.example
└── .env.example
```
### Key Commands and Timing (VALIDATED)
- `npm install` (frontend): 5-21 seconds (depends on cache)
- `npm run build` (frontend): 9 seconds
- `npm run format` (frontend): 2-6 seconds (formats all source files)
- `npm run dev` (frontend): starts in ~1 second, runs on port 5173/5174
- Docker builds: **15+ minutes, NEVER CANCEL, set 60+ minute timeouts**
### Frequently Modified Files
- Frontend components: `frontend/app/src/components/`
- Vue.js views: `frontend/app/src/views/`
- Frontend services: `frontend/app/src/services/`
- Frontend utilities: `frontend/app/src/utils/`
- API backend: `backend/app/`
### Known Issues and Limitations
- ESLint requires migration to flat config format (currently fails)
- Docker builds may fail with SSL certificate errors in CI environments
- Backend requires Python 3.13 (may not match system Python 3.12)
- No unit tests currently exist for frontend
- Frontend lint command fails, use format command instead
### Environment Variables and Configuration
- Frontend uses VITE_ENDURAIN_HOST environment variable
- Create `.env.local` in frontend/app with: `VITE_ENDURAIN_HOST=http://localhost:8080`
- Backend configuration via environment variables (see .env.example)
- Default credentials: admin/admin
### CI/CD Information
- GitHub Actions workflows in `.github/workflows/`
- Docker image builds on release and manual trigger
- Multi-architecture builds (linux/amd64, linux/arm64)
- Published to GitHub Container Registry
## Development Workflow
1. Make changes to frontend files in `frontend/app/src/`
2. Test with `npm run dev` to verify changes work
3. Run `npm run format` to format code
4. Build with `npm run build` to ensure production build works
5. For backend changes, use Docker development setup
6. Always test manually by accessing the application in browser
## Architecture Notes
- **Frontend**: Vue.js 3 with Vite, Bootstrap CSS, Chart.js, Leaflet maps
- **Backend**: Python FastAPI with SQLAlchemy, Alembic migrations
- **Database**: PostgreSQL or MariaDB support
- **Integrations**: Strava and Garmin Connect APIs
- **File Support**: .gpx, .tcx, .fit file imports
- **Authentication**: JWT tokens with 15-minute access tokens
- **Deployment**: Docker containers with multi-stage builds
Always prioritize frontend development workflow for UI changes and use Docker for full-stack development.

View File

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

View File

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

View File

@@ -81,6 +81,11 @@ ACTIVITY_ID_TO_NAME = {
32: "Stand up paddling",
33: "Surf",
34: "Track run",
35: "E-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",
@@ -187,11 +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,
"e_bike": 35,
"ebike": 35,
"e_bike_ride": 35,
"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,
}
)
@@ -780,7 +799,7 @@ def calculate_activity_distances(activities: list[activities_schema.Activity]):
for activity in activities:
if activity.activity_type in [1, 2, 3, 34]:
run += activity.distance
elif activity.activity_type in [4, 5, 6, 7, 27, 28, 29]:
elif activity.activity_type in [4, 5, 6, 7, 27, 28, 29, 35, 36]:
bike += activity.distance
elif activity.activity_type in [8, 9]:
swim += activity.distance

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import threading
import core.logger as core_logger
# Constant related to version
API_VERSION = "v0.14.0"
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"

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ async def fetch_and_process_activities_by_dates(
try:
# Fetch Garmin Connect activities for the specified date range
garmin_activities = garminconnect_client.get_activities_by_date(
start_date.date(), end_date.date()
str(start_date.date()), str(end_date.date())
)
except Exception as err:
core_logger.print_to_log(

View File

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

View File

@@ -27,7 +27,7 @@ def fetch_and_process_bc_by_dates(
try:
# Fetch Garmin Connect body composition data for the specified date range
garmin_bc = garminconnect_client.get_body_composition(
start_date.date(), end_date.date()
str(start_date.date()), str(end_date.date())
)
except Exception as err:
# Log an informational event if no body composition were found

View File

@@ -111,6 +111,20 @@ 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().strip()
@@ -151,6 +165,25 @@ 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().strip()
@@ -302,7 +335,7 @@ def create_multiple_gears(gears: list[gears_schema.Gear], user_id: int, db: Sess
for gear in (gears or [])
if gear is not None
and getattr(gear, "nickname", None)
and str(gear.nickname).replace("+", " ").strip()
and str(gear.nickname).replace("+", " ").strip()
]
# 2) De-dupe within the valid_gears payload (case-insensitive, trimmed)
@@ -434,8 +467,8 @@ def edit_gear(gear_id: int, gear: gears_schema.Gear, db: Session):
db_gear.gear_type = gear.gear_type
if gear.created_at is not None:
db_gear.created_at = gear.created_at
if gear.is_active is not None:
db_gear.is_active = gear.is_active
if gear.active is not None:
db_gear.active = gear.active
if gear.initial_kms is not None:
db_gear.initial_kms = gear.initial_kms
if gear.purchase_value is not None:

View File

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

View File

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

View File

@@ -70,7 +70,7 @@ def transform_schema_gear_to_model_gear(
gear_type=gear.gear_type,
user_id=user_id,
created_at=created_date,
is_active=gear.is_active,
active=gear.active,
initial_kms=gear.initial_kms,
purchase_value=gear.purchase_value,
strava_gear_id=gear.strava_gear_id,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -105,7 +105,7 @@ def process_gear(
nickname=strava_gear.name,
gear_type=1 if gear_type == "bike" else 2,
user_id=user_id,
is_active=1,
active=True,
strava_gear_id=gear.id,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,89 @@
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)
# Define activity type mappings
TYPE_MAP = {
user_goals_schema.ActivityType.RUN: [1, 2, 3, 34],
user_goals_schema.ActivityType.BIKE: [4, 5, 6, 7, 27, 28, 29],
user_goals_schema.ActivityType.BIKE: [4, 5, 6, 7, 27, 28, 29, 35, 36],
user_goals_schema.ActivityType.SWIM: [8, 9],
user_goals_schema.ActivityType.WALK: [11, 12],
}
@@ -61,6 +122,9 @@ def calculate_goal_progress_by_activity_type(
total_activities_number = len(activities)
percentage_completed = (total_activities_number / goal.goal_activities_number) * 100
if percentage_completed > 100:
percentage_completed = 100
# Create and return the progress object
return user_goals_schema.UserGoalProgress(
goal_id=goal.id,
@@ -72,8 +136,8 @@ def calculate_goal_progress_by_activity_type(
percentage_completed=round(percentage_completed),
total_calories=total_calories,
total_activities_number=total_activities_number,
total_distance=total_distance,
total_elevation=total_elevation,
total_distance=round(total_distance),
total_elevation=round(total_elevation),
total_duration=total_duration,
goal_calories=goal.goal_calories,
goal_activities_number=goal.goal_activities_number,

1393
backend/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -103,8 +103,6 @@ meters in distance and elevation gain. Some notes:
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
### Importing gear from a Strava bulk export
#### Bike import
@@ -114,7 +112,7 @@ 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
- Upon successful import, the bikes.csv file is moved 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'.
@@ -127,7 +125,7 @@ To perform an import of shoes:
- Place the shoes.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 "Shoes import" next to "Strava gear import".
- Upon successful import, the shoes.csv file is copied to /data/activity_files/processed folder
- Upon successful import, the shoes.csv file is moved 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 "shoes.csv" and has a header row with at least the fields 'Shoe Name', 'Shoe Brand', and 'Shoe Model'.
@@ -140,9 +138,9 @@ NOTE: There is currently no mechanism to undo a gear import.
All gear will be imported as active, as Strava does not export the active/inactive status of the gear.
Note that Endurain does not allow the "+" character in gear field names, and thus all +'s will removed from all fields on import. All beginning and ending space characters (" ") will be removed on import as well.
Note that Endurain does not allow the "+" character in gear field names, and thus +'s will removed from all fields and replaced with spaces (" ") on import. All beginning and ending space characters (" ") will be removed on import as well.
Endurain does not allow duplicate gear nicknames, taking case into account (e.g., "Ilves" and "ilves" would be allowed). Gear with duplicate nicknames will not be imported (i.e., only the first item with a given nickname will be imported).
Endurain does not allow duplicate gear nicknames, case insensitively (e.g., "Ilves" and "ilves" would not be allowed) and regardless of gear type (e.g., "Ilves" the bike and "ilves" the shoe would not be allowed). Gear with duplicate nicknames will not be imported (i.e., only the first item with a given nickname will be imported).
The import routine checks for duplicate items, and should not import duplicates. Thus it should be safe to re-import the same file mulitple times. However, due to the renaming of un-named shoes, repeated imports of the same shoe file will create duplicate entries of any unnamed shoes present.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -97,15 +97,21 @@
<option value="27">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption27') }}
</option>
<option value="28">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption28') }}
</option>
<option value="29">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption29') }}
</option>
<option value="35">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption35') }}
</option>
<option value="36">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption36') }}
</option>
<option value="7">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption7') }}
</option>
<option value="28">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption28') }}
</option>
<hr />
<option value="8">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption8') }}
@@ -155,6 +161,9 @@
<option value="17">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption17') }}
</option>
<option value="37">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption37') }}
</option>
<hr />
<option value="21">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption21') }}
@@ -174,6 +183,9 @@
<option value="26">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption26') }}
</option>
<option value="39">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption39') }}
</option>
<hr />
<option value="30">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption30') }}
@@ -184,6 +196,10 @@
<option value="33">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption33') }}
</option>
<hr />
<option value="38">
{{ $t('editActivityModalComponent.modalEditActivityTypeOption38') }}
</option>
</select>
<!-- visibility fields -->
<label for="activityVisibilityEdit"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,31 +9,28 @@
<font-awesome-icon :icon="['fas', 'file-import']" size="2x" />
<div class="ms-3">
<div class="fw-bold">
{{ $t("settingsImportZone.bulkImportIntegrationTitle") }}
{{ $t('settingsImportZone.bulkImportIntegrationTitle') }}
</div>
{{ $t("settingsImportZone.bulkImportIntegrationBody") }}
{{ $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 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" />
<font-awesome-icon :icon="['fas', 'file-import']" size="2x" />
<div class="ms-3">
<div class="fw-bold">
{{ $t("settingsImportZone.stravaGearImportTitle") }}
{{ $t('settingsImportZone.stravaGearImportTitle') }}
{{ $t('generalItems.betaTag') }}
</div>
{{ $t("settingsImportZone.stravaGearImportBody") }}
{{ $t('settingsImportZone.stravaGearImportBody') }}
</div>
</div>
<div class="d-flex align-items-center">
@@ -61,40 +58,36 @@
</template>
<script setup>
import { useI18n } from "vue-i18n";
import { useI18n } from 'vue-i18n'
// Import Notivue push
import { push } from "notivue";
import { push } from 'notivue'
// Importing the services
import { activities } from "@/services/activitiesService";
import { strava as stravaService } from "@/services/stravaService";
import { activities } from '@/services/activitiesService'
import { strava as stravaService } from '@/services/stravaService'
const { t } = useI18n();
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}`,
);
}
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}`,
);
}
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}`)
}
}
async function submitStravaShoesImport() {
// Set the loading message

View File

@@ -308,9 +308,11 @@ const newPasswordRepeat = ref('')
const regex =
/^(?=.*[A-Z])(?=.*\d)(?=.*[ !\"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])[A-Za-z\d !"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]{8,}$/
const isNewPasswordValid = computed(() => {
if (!newPassword.value) return true
return regex.test(newPassword.value)
})
const isNewPasswordRepeatValid = computed(() => {
if (!newPasswordRepeat.value) return true
return regex.test(newPasswordRepeat.value)
})
const isPasswordMatch = computed(() => newPassword.value === newPasswordRepeat.value)

View File

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

View File

@@ -85,14 +85,14 @@
</a>
</li>
<li class="nav-item">
<a
href="#"
class="nav-link link-body-emphasis"
:class="{ active: activeSection === 'import' }"
<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>
<span class="ms-1">{{ $t('settingsSideBar.importSection') }}</span>
</a>
</li>
</ul>

View File

@@ -277,7 +277,8 @@ import {
milesToMeters,
kmToMeters,
metersToMiles,
metersToFeet
metersToFeet,
metersToKm
} from '@/utils/unitsUtils'
import { userGoals as userGoalsService } from '@/services/userGoalsService'
@@ -316,10 +317,14 @@ if (props.goal) {
newEditGoalType.value = props.goal.goal_type
newEditGoalCalories.value = props.goal.goal_calories
newEditGoalActivitiesNumber.value = props.goal.goal_activities_number
newEditGoalDistanceMetric.value = props.goal.goal_distance
newEditGoalDistanceImperial.value = metersToMiles(props.goal.goal_distance)
newEditGoalElevationMetric.value = props.goal.goal_elevation
newEditGoalElevationImperial.value = metersToFeet(props.goal.goal_elevation)
if (props.goal.goal_distance) {
newEditGoalDistanceMetric.value = Math.round(metersToKm(props.goal.goal_distance))
newEditGoalDistanceImperial.value = Math.round(metersToMiles(props.goal.goal_distance))
}
if (props.goal.goal_elevation) {
newEditGoalElevationMetric.value = props.goal.goal_elevation
newEditGoalElevationImperial.value = metersToFeet(props.goal.goal_elevation)
}
newEditGoalDuration.value = props.goal.goal_duration
}
}

View File

@@ -126,7 +126,6 @@
</template>
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '@/stores/authStore'
import { useI18n } from 'vue-i18n'
import { push } from 'notivue'

View File

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

View File

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

View File

@@ -204,13 +204,13 @@
required
>
<option :value="1">
{{ $t('usersAddEditUserModalComponent.addEditUserModalGenderOption1') }}
{{ $t('generalItems.genderMale') }}
</option>
<option :value="2">
{{ $t('usersAddEditUserModalComponent.addEditUserModalGenderOption2') }}
{{ $t('generalItems.genderFemale') }}
</option>
<option :value="3">
{{ $t('usersAddEditUserModalComponent.addEditUserModalGenderOption3') }}
{{ $t('generalItems.genderUnspecified') }}
</option>
</select>
<!-- units fields -->
@@ -250,18 +250,18 @@
}})</b
></label
>
<input
class="form-control"
type="number"
name="userHeightAddEditCms"
:placeholder="
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder') +
' (' +
$t('generalItems.unitsCm') +
')'
"
v-model="newEditUserHeightCms"
/>
<div class="input-group">
<input
class="form-control"
type="number"
name="userHeightAddEditCms"
:placeholder="
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder')
"
v-model="newEditUserHeightCms"
/>
<span class="input-group-text">{{ $t('generalItems.unitsCm') }}</span>
</div>
</div>
<div v-else>
<label for="userHeightAddEditFeetInches"
@@ -279,17 +279,14 @@
aria-describedby="validationFeetFeedback"
name="userHeightAddEditFeet"
:placeholder="
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder') +
' (' +
$t('generalItems.unitsFeet') +
')'
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder')
"
v-model="newEditUserHeightFeet"
min="0"
max="10"
step="1"
/>
<span class="input-group-text"></span>
<span class="input-group-text">{{ $t('generalItems.unitsFeet') }}</span>
<input
class="form-control"
:class="{ 'is-invalid': !isInchesValid }"
@@ -297,17 +294,14 @@
aria-describedby="validationInchesFeedback"
name="userHeightAddEditInches"
:placeholder="
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder') +
' (' +
$t('generalItems.unitsInches') +
')'
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder')
"
v-model="newEditUserHeightInches"
min="0"
max="11"
step="1"
/>
<span class="input-group-text"></span>
<span class="input-group-text">{{ $t('generalItems.unitsInches') }}</span>
<div id="validationFeetFeedback" class="invalid-feedback" v-if="!isFeetValid">
{{ $t('usersAddEditUserModalComponent.addEditUserModalFeetValidationLabel') }}
</div>
@@ -332,6 +326,8 @@
required
>
<option value="ca">{{ $t('generalItems.languageOption2') }}</option>
<option value="cn">{{ $t('generalItems.languageOption8') }}</option>
<option value="tw">{{ $t('generalItems.languageOption9') }}</option>
<option value="de">{{ $t('generalItems.languageOption4') }}</option>
<option value="fr">{{ $t('generalItems.languageOption5') }}</option>
<option value="nl">{{ $t('generalItems.languageOption6') }}</option>
@@ -383,24 +379,24 @@
</option>
</select>
</div>
<!-- user is_active fields -->
<!-- user active fields -->
<div v-if="action != 'profile'">
<label for="userIsActiveAddEdit"
<label for="userActiveAddEdit"
><b
>* {{ $t('usersAddEditUserModalComponent.addEditUserModalIsActiveLabel') }}</b
></label
>
<select
class="form-select"
name="userIsActiveAddEdit"
v-model="newEditUserIsActive"
name="userActiveAddEdit"
v-model="newEditUserActive"
required
>
<option :value="1">
{{ $t('usersAddEditUserModalComponent.addEditUserModalIsActiveOption1') }}
<option :value="true">
{{ $t('generalItems.yes') }}
</option>
<option :value="2">
{{ $t('usersAddEditUserModalComponent.addEditUserModalIsActiveOption2') }}
<option :value="false">
{{ $t('generalItems.no') }}
</option>
</select>
</div>
@@ -508,16 +504,18 @@ 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)
const isEmailValid = computed(() => {
if (!newEditUserEmail.value) return true
const emailRegex = /^[^\s@]{1,}@[^\s@]{2,}\.[^\s@]{2,}$/
return emailRegex.test(newEditUserEmail.value)
})
const newUserPassword = ref('')
const isPasswordValid = computed(() => {
if (!newUserPassword.value) return true
const regex =
/^(?=.*[A-Z])(?=.*\d)(?=.*[ !\"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])[A-Za-z\d !"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]{8,}$/
return regex.test(newUserPassword.value)
@@ -543,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)
@@ -639,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,
@@ -651,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)
@@ -682,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,
@@ -695,7 +693,7 @@ async function submitEditUserForm() {
first_day_of_week: newEditUserFirstDayOfWeek.value,
access_type: newEditUserAccessType.value,
photo_path: newEditUserPhotoPath.value,
is_active: newEditUserIsActive.value
active: newEditUserActive.value
}
if (newEditUserPhotoFile.value) {
try {

View File

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

View File

@@ -43,7 +43,7 @@
}}{{ goal.goal_activities_number }} {{ $t('userGoalsStatsComponent.activities') }}</span
>
<span v-if="goal.goal_type === 3"
>{{ formatDistanceRaw(t, goal.total_distance, authStore.user.units)
>{{ formatDistanceRaw(t, goal.total_distance, authStore.user.units, false)
}}{{ $t('generalItems.ofWithSpaces')
}}{{ formatDistanceRaw(t, goal.goal_distance, authStore.user.units) }}</span
>
@@ -68,7 +68,11 @@
aria-valuemin="0"
aria-valuemax="100"
>
<div class="progress-bar" :style="{ width: goal.percentage_completed + '%' }">
<div
class="progress-bar"
:class="{ 'bg-success': goal.percentage_completed === 100 }"
:style="{ width: goal.percentage_completed + '%' }"
>
{{ goal.percentage_completed }}%
</div>
</div>

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