mirror of
https://github.com/joaovitoriasilva/endurain.git
synced 2026-01-09 15:57:59 -05:00
Updating with v0.15 code, updating docs to reflect new shoe import procedure.
This commit is contained in:
10
.env.example
10
.env.example
@@ -2,15 +2,17 @@
|
||||
# These are just the variable you have to set to be up and running.
|
||||
# There is many more variable you could set. Check them out here: https://docs.endurain.com/getting-started/advanced-started/#supported-environment-variables
|
||||
|
||||
DB_PASSWORD=changeme
|
||||
POSTGRES_PASSWORD=changeme
|
||||
DB_PASSWORD=changeme # Set a strong password here. Check if there are no trailing whitespaces in the beginning and end. Must be the same as POSTGRES_PASSWORD
|
||||
POSTGRES_PASSWORD=changeme # Must be the same as DB_PASSWORD
|
||||
SECRET_KEY=changeme
|
||||
FERNET_KEY=changeme
|
||||
TZ=Europe/Lisbon
|
||||
ENDURAIN_HOST=https://endurain.example.com
|
||||
BEHIND_PROXY=true
|
||||
POSTGRES_DB=endurain
|
||||
POSTGRES_USER=endurain
|
||||
POSTGRES_DB=endurain # If you change this, you also have to change DB_DATABASE
|
||||
# DB_DATABASE=endurain # Uncomment and set it to the same as POSTGRES_DB if you change it
|
||||
POSTGRES_USER=endurain # If you change this, you also have to change DB_USER
|
||||
# DB_USER=endurain # Uncomment and set it to the same as POSTGRES_USER if you change it
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
|
||||
# Email configuration (for password reset functionality)
|
||||
|
||||
135
.github/copilot-instructions.md
vendored
Normal file
135
.github/copilot-instructions.md
vendored
Normal 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.
|
||||
@@ -29,7 +29,7 @@
|
||||
Endurain is a self-hosted fitness tracking service designed to give users full control over their data and hosting environment. It's similar to Strava but focused on privacy and customization. Built with:
|
||||
|
||||
- **Frontend:** Vue.js, Notivue and Bootstrap CSS
|
||||
- **Backend:** Python FastAPI, Alembic, SQLAlchemy, stravalib and python-garminconnect for Strava and Garmin Connect integration, gpxpy, tcxreader and fitdecode for .gpx, .tcx and .fit file import respectively
|
||||
- **Backend:** Python FastAPI, Alembic, SQLAlchemy, Apprise, stravalib and python-garminconnect for Strava and Garmin Connect integration, gpxpy, tcxreader and fitdecode for .gpx, .tcx and .fit file import respectively
|
||||
- **Database:** MariaDB or PostgreSQL for efficient data management
|
||||
- **Observability:** Jaeger for basic tracing and monitoring
|
||||
- **Integrations:** Supports Strava and Garmin Connect. Manual upload of activities using .gpx, .tcx and .fit files are also supported
|
||||
|
||||
@@ -11,7 +11,7 @@ import core.logger as core_logger
|
||||
|
||||
import notifications.utils as notifications_utils
|
||||
|
||||
import server_settings.crud as server_settings_crud
|
||||
import server_settings.utils as server_settings_utils
|
||||
|
||||
import websocket.schema as websocket_schema
|
||||
|
||||
@@ -901,10 +901,10 @@ def get_activity_by_id_from_user_id_or_has_visibility(
|
||||
def get_activity_by_id_if_is_public(activity_id: int, db: Session):
|
||||
try:
|
||||
# Check if public sharable links are enabled in server settings
|
||||
server_settings = server_settings_crud.get_server_settings(db)
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
|
||||
# Return None if public sharable links are disabled
|
||||
if not server_settings or not server_settings.public_shareable_links:
|
||||
if not server_settings.public_shareable_links:
|
||||
return None
|
||||
|
||||
# Get the activities from the database
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
import activities.activity_exercise_titles.models as activity_exercise_titles_models
|
||||
import activities.activity_exercise_titles.schema as activity_exercise_titles_schema
|
||||
|
||||
import server_settings.crud as server_settings_crud
|
||||
import server_settings.utils as server_settings_utils
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
@@ -38,10 +38,10 @@ def get_activity_exercise_titles(db: Session):
|
||||
def get_public_activity_exercise_titles(db: Session):
|
||||
try:
|
||||
# Check if public sharable links are enabled in server settings
|
||||
server_settings = server_settings_crud.get_server_settings(db)
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
|
||||
# Return None if public sharable links are disabled
|
||||
if not server_settings or not server_settings.public_shareable_links:
|
||||
if not server_settings.public_shareable_links:
|
||||
return None
|
||||
|
||||
# Get the activity exercise titles from the database
|
||||
|
||||
@@ -9,7 +9,7 @@ import activities.activity_laps.models as activity_laps_models
|
||||
import activities.activity_laps.schema as activity_laps_schema
|
||||
import activities.activity_laps.utils as activity_laps_utils
|
||||
|
||||
import server_settings.crud as server_settings_crud
|
||||
import server_settings.utils as server_settings_utils
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
@@ -136,10 +136,10 @@ def get_public_activity_laps(activity_id: int, db: Session):
|
||||
return None
|
||||
|
||||
# Check if public sharable links are enabled in server settings
|
||||
server_settings = server_settings_crud.get_server_settings(db)
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
|
||||
# Return None if public sharable links are disabled
|
||||
if not server_settings or not server_settings.public_shareable_links:
|
||||
if not server_settings.public_shareable_links:
|
||||
return None
|
||||
|
||||
# Get the activity laps from the database
|
||||
|
||||
@@ -6,10 +6,9 @@ import activities.activity.models as activity_models
|
||||
import activities.activity.crud as activity_crud
|
||||
|
||||
import activities.activity_sets.models as activity_sets_models
|
||||
import activities.activity_sets.schema as activity_sets_schema
|
||||
import activities.activity_sets.utils as activity_sets_utils
|
||||
|
||||
import server_settings.crud as server_settings_crud
|
||||
import server_settings.utils as server_settings_utils
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
@@ -138,10 +137,10 @@ def get_public_activity_sets(activity_id: int, db: Session):
|
||||
return None
|
||||
|
||||
# Check if public sharable links are enabled in server settings
|
||||
server_settings = server_settings_crud.get_server_settings(db)
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
|
||||
# Return None if public sharable links are disabled
|
||||
if not server_settings or not server_settings.public_shareable_links:
|
||||
if not server_settings.public_shareable_links:
|
||||
return None
|
||||
|
||||
# Get the activity sets from the database
|
||||
|
||||
@@ -11,7 +11,7 @@ import activities.activity.crud as activity_crud
|
||||
import activities.activity.models as activity_models
|
||||
import activities.activity.schema as activities_schema
|
||||
|
||||
import server_settings.crud as server_settings_crud
|
||||
import server_settings.utils as server_settings_utils
|
||||
|
||||
import users.user.crud as users_crud
|
||||
|
||||
@@ -167,10 +167,10 @@ def get_activities_streams(
|
||||
def get_public_activity_streams(activity_id: int, db: Session):
|
||||
try:
|
||||
# Check if public sharable links are enabled in server settings
|
||||
server_settings = server_settings_crud.get_server_settings(db)
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
|
||||
# Return None if public sharable links are disabled
|
||||
if not server_settings or not server_settings.public_shareable_links:
|
||||
if not server_settings.public_shareable_links:
|
||||
return None
|
||||
|
||||
activity = activity_crud.get_activity_by_id_if_is_public(activity_id, db)
|
||||
@@ -440,10 +440,10 @@ def transform_activity_streams_hr(activity_stream, activity, db):
|
||||
def get_public_activity_stream_by_type(activity_id: int, stream_type: int, db: Session):
|
||||
try:
|
||||
# Check if public sharable links are enabled in server settings
|
||||
server_settings = server_settings_crud.get_server_settings(db)
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
|
||||
# Return None if public sharable links are disabled
|
||||
if not server_settings or not server_settings.public_shareable_links:
|
||||
if not server_settings.public_shareable_links:
|
||||
return None
|
||||
|
||||
activity = activity_crud.get_activity_by_id_if_is_public(activity_id, db)
|
||||
|
||||
@@ -8,7 +8,7 @@ import activities.activity.crud as activity_crud
|
||||
import activities.activity_workout_steps.models as activity_workout_steps_models
|
||||
import activities.activity_workout_steps.schema as activity_workout_steps_schema
|
||||
|
||||
import server_settings.crud as server_settings_crud
|
||||
import server_settings.utils as server_settings_utils
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
@@ -127,10 +127,10 @@ def get_public_activity_workout_steps(activity_id: int, db: Session):
|
||||
return None
|
||||
|
||||
# Check if public sharable links are enabled in server settings
|
||||
server_settings = server_settings_crud.get_server_settings(db)
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
|
||||
# Return None if public sharable links are disabled
|
||||
if not server_settings or not server_settings.public_shareable_links:
|
||||
if not server_settings.public_shareable_links:
|
||||
return None
|
||||
|
||||
# Get the activity workout steps from the database
|
||||
|
||||
@@ -21,6 +21,7 @@ import health_targets.models
|
||||
import migrations.models
|
||||
import notifications.models
|
||||
import password_reset_tokens.models
|
||||
import sign_up_tokens.models
|
||||
import server_settings.models
|
||||
import session.models
|
||||
import users.user.models
|
||||
|
||||
388
backend/app/alembic/versions/v0_15_0_migration.py
Normal file
388
backend/app/alembic/versions/v0_15_0_migration.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""v0.15.0 migration
|
||||
|
||||
Revision ID: 3c4d5e6f7a8b
|
||||
Revises: 86b2e24e227e
|
||||
Create Date: 2025-01-01 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "3c4d5e6f7a8b"
|
||||
down_revision: Union[str, None] = "86b2e24e227e"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new columns to server_settings table
|
||||
op.add_column(
|
||||
"server_settings",
|
||||
sa.Column(
|
||||
"signup_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
default=False,
|
||||
comment="Allow user sign-up registration (true - yes, false - no)",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE server_settings
|
||||
SET signup_enabled = false
|
||||
WHERE signup_enabled IS NULL;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"server_settings",
|
||||
"signup_enabled",
|
||||
nullable=False,
|
||||
comment="Allow user sign-up registration (true - yes, false - no)",
|
||||
existing_type=sa.Boolean(),
|
||||
)
|
||||
op.add_column(
|
||||
"server_settings",
|
||||
sa.Column(
|
||||
"signup_require_admin_approval",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
default=True,
|
||||
comment="Require admin approval for new sign-ups (true - yes, false - no)",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE server_settings
|
||||
SET signup_require_admin_approval = false
|
||||
WHERE signup_require_admin_approval IS NULL;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"server_settings",
|
||||
"signup_require_admin_approval",
|
||||
nullable=False,
|
||||
comment="Require admin approval for new sign-ups (true - yes, false - no)",
|
||||
existing_type=sa.Boolean(),
|
||||
)
|
||||
op.add_column(
|
||||
"server_settings",
|
||||
sa.Column(
|
||||
"signup_require_email_verification",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
default=True,
|
||||
comment="Require email verification for new sign-ups (true - yes, false - no)",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE server_settings
|
||||
SET signup_require_email_verification = false
|
||||
WHERE signup_require_email_verification IS NULL;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"server_settings",
|
||||
"signup_require_email_verification",
|
||||
nullable=False,
|
||||
comment="Require email verification for new sign-ups (true - yes, false - no)",
|
||||
existing_type=sa.Boolean(),
|
||||
)
|
||||
# Add new columns to users table
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"email_verified",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
default=False,
|
||||
comment="Whether the user's email address has been verified (true - yes, false - no)",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET email_verified = true
|
||||
WHERE email_verified IS NULL;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"users",
|
||||
"email_verified",
|
||||
nullable=False,
|
||||
comment="Whether the user's email address has been verified (true - yes, false - no)",
|
||||
existing_type=sa.Boolean(),
|
||||
)
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"pending_admin_approval",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
default=False,
|
||||
comment="Whether the user is pending admin approval for activation (true - yes, false - no)",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET pending_admin_approval = false
|
||||
WHERE pending_admin_approval IS NULL;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"users",
|
||||
"pending_admin_approval",
|
||||
nullable=False,
|
||||
comment="Whether the user is pending admin approval for activation (true - yes, false - no)",
|
||||
existing_type=sa.Boolean(),
|
||||
)
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"active",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
default=False,
|
||||
comment="Whether the user is active (true - yes, false - no)",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET active = true
|
||||
WHERE is_active = 1;
|
||||
UPDATE users
|
||||
SET active = false
|
||||
WHERE is_active = 2;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"users",
|
||||
"active",
|
||||
nullable=False,
|
||||
comment="Whether the user is active (true - yes, false - no)",
|
||||
existing_type=sa.Boolean(),
|
||||
)
|
||||
op.drop_column("users", "is_active")
|
||||
# Add new columns to gear table
|
||||
op.add_column(
|
||||
"gear",
|
||||
sa.Column(
|
||||
"active",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
default=True,
|
||||
comment="Whether the gear is active (true - yes, false - no)",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE gear
|
||||
SET active = true
|
||||
WHERE is_active = 1;
|
||||
UPDATE gear
|
||||
SET active = false
|
||||
WHERE is_active = 0;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"gear",
|
||||
"active",
|
||||
nullable=False,
|
||||
comment="Whether the gear is active (true - yes, false - no)",
|
||||
existing_type=sa.Boolean(),
|
||||
)
|
||||
op.drop_column("gear", "is_active")
|
||||
# Add new columns to gear component table
|
||||
op.add_column(
|
||||
"gear_components",
|
||||
sa.Column(
|
||||
"active",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
default=True,
|
||||
comment="Whether the gear component is active (true - yes, false - no)",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE gear_components
|
||||
SET active = true
|
||||
WHERE is_active = true;
|
||||
UPDATE gear_components
|
||||
SET active = false
|
||||
WHERE is_active = false;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"gear_components",
|
||||
"active",
|
||||
nullable=False,
|
||||
comment="Whether the gear component is active (true - yes, false - no)",
|
||||
existing_type=sa.Boolean(),
|
||||
)
|
||||
op.drop_column("gear_components", "is_active")
|
||||
# Sign up tokens table
|
||||
op.create_table(
|
||||
"sign_up_tokens",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="User ID that the sign up token belongs to",
|
||||
),
|
||||
sa.Column(
|
||||
"token_hash",
|
||||
sa.String(length=128),
|
||||
nullable=False,
|
||||
comment="Hashed sign up token",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
comment="Token creation date (datetime)",
|
||||
),
|
||||
sa.Column(
|
||||
"expires_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
comment="Token expiration date (datetime)",
|
||||
),
|
||||
sa.Column(
|
||||
"used",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
comment="Token usage status (False - unused, True - used)",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_sign_up_tokens_user_id"),
|
||||
"sign_up_tokens",
|
||||
["user_id"],
|
||||
unique=False,
|
||||
)
|
||||
# Add the new entry to the migrations table
|
||||
op.execute("""
|
||||
INSERT INTO migrations (id, name, description, executed) VALUES
|
||||
(6, 'v0.15.0', 'Lowercase user usernames', false);
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove the entry from the migrations table
|
||||
op.execute("""
|
||||
DELETE FROM migrations
|
||||
WHERE id = 6;
|
||||
""")
|
||||
# Drop sign up tokens table
|
||||
op.drop_index(
|
||||
op.f("ix_sign_up_tokens_user_id"), table_name="sign_up_tokens"
|
||||
)
|
||||
op.drop_table("sign_up_tokens")
|
||||
# Remove columns from gear_components table
|
||||
op.add_column(
|
||||
"gear_components",
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
default=True,
|
||||
comment="Is gear component active",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE gear_components
|
||||
SET is_active = true
|
||||
WHERE active = true;
|
||||
UPDATE gear_components
|
||||
SET is_active = false
|
||||
WHERE active = false;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"gear_components",
|
||||
"is_active",
|
||||
nullable=False,
|
||||
comment="Is gear component active",
|
||||
existing_type=sa.Boolean(),
|
||||
)
|
||||
op.drop_column("gear_components", "active")
|
||||
# Remove columns from gear table
|
||||
op.add_column(
|
||||
"gear",
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
default=1,
|
||||
comment="Is gear active (0 - not active, 1 - active)",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE gear
|
||||
SET is_active = 1
|
||||
WHERE active = true;
|
||||
UPDATE gear
|
||||
SET is_active = 0
|
||||
WHERE active = false;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"gear",
|
||||
"is_active",
|
||||
nullable=False,
|
||||
comment="Is gear active (0 - not active, 1 - active)",
|
||||
existing_type=sa.Integer(),
|
||||
)
|
||||
op.drop_column("gear", "active")
|
||||
# Remove columns from users table
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
default=1,
|
||||
comment="Is user active (1 - active, 2 - not active)",
|
||||
),
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET is_active = 1
|
||||
WHERE active = true;
|
||||
UPDATE users
|
||||
SET is_active = 2
|
||||
WHERE active = false;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"users",
|
||||
"is_active",
|
||||
nullable=False,
|
||||
comment="Is user active (1 - active, 2 - not active)",
|
||||
existing_type=sa.Integer(),
|
||||
)
|
||||
op.drop_column("users", "active")
|
||||
op.drop_column("users", "pending_admin_approval")
|
||||
op.drop_column("users", "email_verified")
|
||||
|
||||
# Remove columns from server_settings table
|
||||
op.drop_column("server_settings", "signup_require_email_verification")
|
||||
op.drop_column("server_settings", "signup_require_admin_approval")
|
||||
op.drop_column("server_settings", "signup_enabled")
|
||||
@@ -1,4 +1,4 @@
|
||||
import os
|
||||
import os, secrets, hashlib
|
||||
import apprise
|
||||
from typing import List
|
||||
|
||||
@@ -216,4 +216,20 @@ def get_email_service():
|
||||
return email_service
|
||||
|
||||
|
||||
def generate_token_and_hash() -> tuple[str, str]:
|
||||
"""
|
||||
Generates a secure random token and its SHA-256 hash.
|
||||
|
||||
Returns:
|
||||
tuple[str, str]: A tuple containing the generated token and its SHA-256 hash.
|
||||
"""
|
||||
# Generate a random 32-byte token
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# Create a hash of the token for database storage
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
return token, token_hash
|
||||
|
||||
|
||||
email_service = AppriseService()
|
||||
|
||||
@@ -4,7 +4,7 @@ import threading
|
||||
import core.logger as core_logger
|
||||
|
||||
# Constant related to version
|
||||
API_VERSION = "v0.14.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"
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
import core.config as core_config
|
||||
|
||||
|
||||
def setup_main_logger():
|
||||
"""
|
||||
Sets up the main application logger and attaches a file handler to it, as well as to the Alembic and APScheduler loggers.
|
||||
@@ -53,7 +54,9 @@ def get_main_logger():
|
||||
return logging.getLogger("main_logger")
|
||||
|
||||
|
||||
def print_to_log(message: str, log_level: str = "info", exc: Exception = None, context = None):
|
||||
def print_to_log(
|
||||
message: str, log_level: str = "info", exc: Exception = None, context=None
|
||||
):
|
||||
"""
|
||||
Logs a message at the specified log level using the main logger.
|
||||
|
||||
@@ -76,6 +79,24 @@ def print_to_log(message: str, log_level: str = "info", exc: Exception = None, c
|
||||
main_logger.debug(message)
|
||||
|
||||
|
||||
def print_to_console(message: str, log_level: str = "info"):
|
||||
"""
|
||||
Prints a message to the console only (without logging to file).
|
||||
|
||||
Args:
|
||||
message (str): The message to print.
|
||||
log_level (str, optional): The log level to display ('info', 'error', 'warning', 'debug'). Defaults to "info".
|
||||
"""
|
||||
if log_level == "info":
|
||||
print(f"INFO: {message}")
|
||||
elif log_level == "error":
|
||||
print(f"ERROR: {message}")
|
||||
elif log_level == "warning":
|
||||
print(f"WARNING: {message}")
|
||||
elif log_level == "debug":
|
||||
print(f"DEBUG: {message}")
|
||||
|
||||
|
||||
def print_to_log_and_console(
|
||||
message: str, log_level: str = "info", exc: Exception = None
|
||||
):
|
||||
|
||||
@@ -30,6 +30,7 @@ import server_settings.public_router as server_settings_public_router
|
||||
import server_settings.router as server_settings_router
|
||||
import session.router as session_router
|
||||
import session.security as session_security
|
||||
import sign_up_tokens.router as sign_up_tokens_router
|
||||
import strava.router as strava_router
|
||||
import users.user.router as users_router
|
||||
import users.user_goals.router as user_goals_router
|
||||
@@ -162,6 +163,11 @@ router.include_router(
|
||||
prefix=core_config.ROOT_PATH,
|
||||
tags=["sessions"],
|
||||
)
|
||||
router.include_router(
|
||||
sign_up_tokens_router.router,
|
||||
prefix=core_config.ROOT_PATH,
|
||||
tags=["sign_up_tokens"],
|
||||
)
|
||||
router.include_router(
|
||||
strava_router.router,
|
||||
prefix=core_config.ROOT_PATH + "/strava",
|
||||
|
||||
@@ -9,6 +9,8 @@ import garmin.health_utils as garmin_health_utils
|
||||
|
||||
import password_reset_tokens.utils as password_reset_tokens_utils
|
||||
|
||||
import sign_up_tokens.utils as sign_up_tokens_utils
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
# scheduler = BackgroundScheduler()
|
||||
@@ -60,6 +62,14 @@ def start_scheduler():
|
||||
"delete invalid password reset tokens from the database",
|
||||
)
|
||||
|
||||
add_scheduler_job(
|
||||
sign_up_tokens_utils.delete_invalid_tokens_from_db,
|
||||
"interval",
|
||||
60,
|
||||
[],
|
||||
"delete invalid sign-up tokens from the database",
|
||||
)
|
||||
|
||||
|
||||
def add_scheduler_job(func, interval, minutes, args, description):
|
||||
try:
|
||||
|
||||
@@ -772,6 +772,8 @@ def parse_frame_session(frame):
|
||||
activity_type = "indoor_ride"
|
||||
elif activity_type == "cycling" and sub_sport == "mixed_surface":
|
||||
activity_type = "mixed_surface_ride"
|
||||
elif activity_type == 64 and sub_sport == 85:
|
||||
activity_type = "padel"
|
||||
else:
|
||||
activity_type = sub_sport
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
DECIMAL,
|
||||
Boolean,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
@@ -45,8 +46,8 @@ class Gear(Base):
|
||||
default=func.now(),
|
||||
comment="Gear creation date (DateTime)",
|
||||
)
|
||||
is_active = Column(
|
||||
Integer, nullable=False, comment="Is gear active (0 - not active, 1 - active)"
|
||||
active = Column(
|
||||
Boolean, nullable=False, comment="Whether the gear is active (true - yes, false - no)"
|
||||
)
|
||||
initial_kms = Column(
|
||||
DECIMAL(precision=11, scale=2),
|
||||
|
||||
@@ -8,7 +8,7 @@ class Gear(BaseModel):
|
||||
gear_type: int
|
||||
user_id: int | None = None
|
||||
created_at: str | None = None
|
||||
is_active: int | None = None
|
||||
active: bool | None = None
|
||||
initial_kms: float | None = None
|
||||
purchase_value: float | None = None
|
||||
strava_gear_id: str | None = None
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -117,7 +117,7 @@ def create_gear_component(
|
||||
brand=gear_component.brand,
|
||||
model=gear_component.model,
|
||||
purchase_date=gear_component.purchase_date,
|
||||
is_active=True,
|
||||
active=True,
|
||||
expected_kms=gear_component.expected_kms,
|
||||
purchase_value=gear_component.purchase_value,
|
||||
)
|
||||
|
||||
@@ -59,8 +59,11 @@ class GearComponents(Base):
|
||||
nullable=True,
|
||||
comment="Gear component retired date (DateTime)",
|
||||
)
|
||||
is_active = Column(
|
||||
Boolean, nullable=False, default=False, comment="Is gear component active"
|
||||
active = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Whether the gear component is active (true - yes, false - no)",
|
||||
)
|
||||
expected_kms = Column(
|
||||
Integer,
|
||||
|
||||
@@ -97,7 +97,7 @@ class GearComponents(BaseModel):
|
||||
model (str): Model name or number of the gear component.
|
||||
purchase_date (str): Date when the component was purchased (ISO format recommended).
|
||||
retired_date (str | None): Date when the component was retired, if applicable.
|
||||
is_active (bool | None): Indicates if the component is currently active.
|
||||
active (bool | None): Indicates if the component is currently active.
|
||||
expected_kms (int | None): Expected kilometers the component should last.
|
||||
purchase_value (float | None): Purchase value of the component.
|
||||
"""
|
||||
@@ -109,7 +109,7 @@ class GearComponents(BaseModel):
|
||||
model: str
|
||||
purchase_date: str
|
||||
retired_date: str | None = None
|
||||
is_active: bool | None = None
|
||||
active: bool | None = None
|
||||
expected_kms: int | None = None
|
||||
purchase_value: float | None = None
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ def parse_gpx_file(
|
||||
for track in gpx.tracks:
|
||||
# Set activity name, description, and type if available
|
||||
activity_name = track.name if track.name else gpx.name if gpx.name else "Workout"
|
||||
activity_description = track.description if track.description else gpx.description if gpx.description else None
|
||||
activity_description = track.description if track.description else gpx.description if gpx.description else None
|
||||
activity_type = track.type if track.type else "Workout"
|
||||
|
||||
if track.segments:
|
||||
|
||||
@@ -23,6 +23,8 @@ import strava.utils as strava_utils
|
||||
|
||||
import password_reset_tokens.utils as password_reset_tokens_utils
|
||||
|
||||
import sign_up_tokens.utils as sign_up_tokens_utils
|
||||
|
||||
from core.routes import router as api_router
|
||||
|
||||
|
||||
@@ -68,6 +70,12 @@ async def startup_event():
|
||||
)
|
||||
password_reset_tokens_utils.delete_invalid_tokens_from_db()
|
||||
|
||||
# Delete invalid sign-up tokens
|
||||
core_logger.print_to_log_and_console(
|
||||
"Deleting invalid sign-up tokens from the database"
|
||||
)
|
||||
sign_up_tokens_utils.delete_invalid_tokens_from_db()
|
||||
|
||||
|
||||
def shutdown_event():
|
||||
# Log the shutdown event
|
||||
@@ -161,4 +169,4 @@ core_tracing.setup_tracing(app)
|
||||
app.add_event_handler("startup", startup_event)
|
||||
|
||||
# Register the shutdown event handler
|
||||
app.add_event_handler("shutdown", shutdown_event)
|
||||
app.add_event_handler("shutdown", shutdown_event)
|
||||
|
||||
61
backend/app/migrations/migration_6.py
Normal file
61
backend/app/migrations/migration_6.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
import migrations.crud as migrations_crud
|
||||
|
||||
import users.user.crud as user_crud
|
||||
import users.user.schema as users_schema
|
||||
|
||||
|
||||
def process_migration_6(db: Session):
|
||||
core_logger.print_to_log_and_console("Started migration 6")
|
||||
|
||||
users_processed_with_no_errors = True
|
||||
|
||||
try:
|
||||
users = user_crud.get_all_users(db)
|
||||
except Exception as err:
|
||||
core_logger.print_to_log_and_console(
|
||||
f"Migration 6 - Error fetching users: {err}",
|
||||
"error",
|
||||
exc=err,
|
||||
)
|
||||
users_processed_with_no_errors = False
|
||||
|
||||
if users:
|
||||
for user in users:
|
||||
try:
|
||||
user.username = user.username.lower()
|
||||
if user.birthdate:
|
||||
user.birthdate = user.birthdate.isoformat()
|
||||
user_converted = users_schema.UserRead.model_validate(user)
|
||||
|
||||
user_crud.edit_user(user.id, user_converted, db)
|
||||
except Exception as err:
|
||||
core_logger.print_to_log_and_console(
|
||||
f"Migration 6 - Error processing user {user.id}: {err}",
|
||||
"error",
|
||||
exc=err,
|
||||
)
|
||||
users_processed_with_no_errors = False
|
||||
continue
|
||||
|
||||
# Mark migration as executed
|
||||
if users_processed_with_no_errors:
|
||||
try:
|
||||
migrations_crud.set_migration_as_executed(6, db)
|
||||
except Exception as err:
|
||||
core_logger.print_to_log_and_console(
|
||||
f"Migration 6 - Failed to set migration as executed: {err}",
|
||||
"error",
|
||||
exc=err,
|
||||
)
|
||||
return
|
||||
else:
|
||||
core_logger.print_to_log_and_console(
|
||||
"Migration 6 failed to process all users. Will try again later.",
|
||||
"error",
|
||||
)
|
||||
|
||||
core_logger.print_to_log_and_console("Finished migration 6")
|
||||
@@ -6,6 +6,7 @@ import migrations.migration_2 as migrations_migration_2
|
||||
import migrations.migration_3 as migrations_migration_3
|
||||
import migrations.migration_4 as migrations_migration_4
|
||||
import migrations.migration_5 as migrations_migration_5
|
||||
import migrations.migration_6 as migrations_migration_6
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
@@ -39,3 +40,7 @@ def check_migrations_not_executed(db: Session):
|
||||
if migration.id == 5:
|
||||
# Execute the migration
|
||||
migrations_migration_5.process_migration_5(db)
|
||||
|
||||
if migration.id == 6:
|
||||
# Execute the migration
|
||||
migrations_migration_6.process_migration_6(db)
|
||||
|
||||
@@ -3,3 +3,4 @@ TYPE_NEW_ACTIVITY = 1
|
||||
TYPE_DUPLICATE_ACTIVITY = 2
|
||||
TYPE_NEW_FOLLOWER_REQUEST = 11
|
||||
TYPE_NEW_FOLLOWER_REQUEST_ACCEPTED = 12
|
||||
TYPE_ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST = 101
|
||||
|
||||
@@ -8,7 +8,9 @@ import notifications.constants as notifications_constants
|
||||
import notifications.crud as notifications_crud
|
||||
import notifications.schema as notifications_schema
|
||||
|
||||
import users.user.crud as user_crud
|
||||
import users.user.crud as users_crud
|
||||
import users.user.models as users_models
|
||||
import users.user.utils as users_utils
|
||||
|
||||
import websocket.utils as websocket_utils
|
||||
import websocket.schema as websocket_schema
|
||||
@@ -117,7 +119,7 @@ async def create_new_follower_request_notification(
|
||||
db: Session,
|
||||
):
|
||||
try:
|
||||
user = user_crud.get_user_by_id(user_id, db)
|
||||
user = users_crud.get_user_by_id(user_id, db)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -172,7 +174,7 @@ async def create_accepted_follower_request_notification(
|
||||
db: Session,
|
||||
):
|
||||
try:
|
||||
user = user_crud.get_user_by_id(user_id, db)
|
||||
user = users_crud.get_user_by_id(user_id, db)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -198,9 +200,7 @@ async def create_accepted_follower_request_notification(
|
||||
"message": "NEW_FOLLOWER_REQUEST_ACCEPTED_NOTIFICATION",
|
||||
"notification_id": notification.id,
|
||||
}
|
||||
await websocket_utils.notify_frontend(
|
||||
user_id, websocket_manager, json_data
|
||||
)
|
||||
await websocket_utils.notify_frontend(user_id, websocket_manager, json_data)
|
||||
|
||||
# Return the serialized notification
|
||||
return notification
|
||||
@@ -218,3 +218,49 @@ async def create_accepted_follower_request_notification(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
async def create_admin_new_sign_up_approval_request_notification(
|
||||
user: users_models.User,
|
||||
websocket_manager: websocket_schema.WebSocketManager,
|
||||
db: Session,
|
||||
):
|
||||
try:
|
||||
admins = users_utils.get_admin_users(db)
|
||||
|
||||
# Send notification to all admin users
|
||||
for admin in admins:
|
||||
# Create a notification for the new sign up request
|
||||
notification = notifications_crud.create_notification(
|
||||
notifications_schema.Notification(
|
||||
user_id=admin.id,
|
||||
type=notifications_constants.TYPE_ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST,
|
||||
options={
|
||||
"user_id": user.id,
|
||||
"user_name": user.name,
|
||||
"user_username": user.username,
|
||||
},
|
||||
),
|
||||
db,
|
||||
)
|
||||
|
||||
# Notify the frontend about the new sign up request
|
||||
json_data = {
|
||||
"message": "ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST_NOTIFICATION",
|
||||
"notification_id": notification.id,
|
||||
}
|
||||
await websocket_utils.notify_frontend(admin.id, websocket_manager, json_data)
|
||||
except HTTPException as http_err:
|
||||
raise http_err
|
||||
except Exception as err:
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in create_admin_new_sign_up_approval_request_notification: {err}",
|
||||
"error",
|
||||
exc=err,
|
||||
)
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
@@ -78,6 +78,29 @@ def create_password_reset_token(
|
||||
def get_password_reset_token_by_hash(
|
||||
token_hash: str, db: Session
|
||||
) -> password_reset_tokens_models.PasswordResetToken | None:
|
||||
"""
|
||||
Retrieve an unused, unexpired PasswordResetToken that matches the provided token hash.
|
||||
|
||||
Parameters:
|
||||
token_hash (str): The hashed token value to look up.
|
||||
db (Session): SQLAlchemy Session used to query the password_reset_tokens table.
|
||||
|
||||
Returns:
|
||||
password_reset_tokens_models.PasswordResetToken | None:
|
||||
The matching PasswordResetToken instance if an unused token exists and its
|
||||
expires_at is strictly in the future (compared to datetime.now(timezone.utc));
|
||||
otherwise None when no valid token is found.
|
||||
|
||||
Raises:
|
||||
HTTPException:
|
||||
Raises an HTTPException with status_code 500 if an unexpected error occurs
|
||||
while querying the database. The underlying exception is logged before
|
||||
the HTTPException is raised.
|
||||
|
||||
Notes:
|
||||
- The function filters tokens by token_hash, used == False, and expires_at > now (UTC).
|
||||
- Side effects: unexpected errors are logged via core_logger.print_to_log.
|
||||
"""
|
||||
try:
|
||||
# Get the token from the database
|
||||
db_token = (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, ConfigDict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ class PasswordResetToken(BaseModel):
|
||||
expires_at: datetime
|
||||
used: bool
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True, extra="forbid", validate_assignment=True
|
||||
)
|
||||
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import secrets
|
||||
import hashlib
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import (
|
||||
HTTPException,
|
||||
status,
|
||||
)
|
||||
from uuid import uuid4
|
||||
import hashlib
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -22,34 +20,6 @@ import core.logger as core_logger
|
||||
from core.database import SessionLocal
|
||||
|
||||
|
||||
def generate_password_reset_token() -> tuple[str, str]:
|
||||
"""
|
||||
Generate a URL-safe password reset token and its SHA-256 hash for storage.
|
||||
Returns:
|
||||
tuple[str, str]: A tuple (token, token_hash) where:
|
||||
- token: a URL-safe, cryptographically secure random token suitable for
|
||||
inclusion in password reset links (this raw token is intended to be
|
||||
sent to the user).
|
||||
- token_hash: the hexadecimal SHA-256 hash of the token, suitable for
|
||||
storing in a database instead of the raw token.
|
||||
Notes:
|
||||
- Do not store or log the raw token; store only the hash (token_hash).
|
||||
- When validating a presented token, compute its SHA-256 hex digest and
|
||||
compare it to the stored token_hash using a constant-time comparison to
|
||||
mitigate timing attacks (e.g., secrets.compare_digest).
|
||||
- Consider associating an expiration timestamp and single-use semantics with
|
||||
the token to limit its validity window.
|
||||
- Token generation relies on the `secrets` module for cryptographic randomness.
|
||||
"""
|
||||
# Generate a random 32-byte token
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# Create a hash of the token for database storage
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
return token, token_hash
|
||||
|
||||
|
||||
def create_password_reset_token(user_id: int, db: Session) -> str:
|
||||
"""
|
||||
Create and persist a password reset token for a user and return the plain token.
|
||||
@@ -93,7 +63,7 @@ def create_password_reset_token(user_id: int, db: Session) -> str:
|
||||
# Send `token` to the user's email. Do not store the plaintext token in persistent storage.
|
||||
"""
|
||||
# Generate token and hash
|
||||
token, token_hash = generate_password_reset_token()
|
||||
token, token_hash = core_apprise.generate_token_and_hash()
|
||||
|
||||
# Create token object
|
||||
reset_token = password_reset_tokens_schema.PasswordResetToken(
|
||||
@@ -123,7 +93,7 @@ async def send_password_reset_email(
|
||||
2. Attempts to locate the user record for the given email in the provided DB session.
|
||||
- For security (to avoid user enumeration), if the user does not exist the function
|
||||
returns True and does not indicate existence to the caller.
|
||||
3. Verifies the located user is active (expects user.is_active == 1).
|
||||
3. Verifies the located user is active.
|
||||
- If the user is inactive the function returns True for the same security reason.
|
||||
4. Creates a password reset token and persists it via create_password_reset_token.
|
||||
5. Constructs a frontend reset URL using the email_service.frontend_host and the token.
|
||||
@@ -175,7 +145,7 @@ async def send_password_reset_email(
|
||||
return True
|
||||
|
||||
# Check if user is active
|
||||
if user.is_active != 1:
|
||||
if not user.active:
|
||||
# Don't reveal if user is inactive for security
|
||||
return True
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ async def upload_profile_image(
|
||||
|
||||
@router.put("")
|
||||
async def edit_user(
|
||||
user_attributtes: users_schema.User,
|
||||
user_attributtes: users_schema.UserRead,
|
||||
token_user_id: Annotated[
|
||||
int,
|
||||
Depends(session_security.get_user_id_from_access_token),
|
||||
@@ -222,7 +222,7 @@ async def edit_user(
|
||||
Edits the attributes of an existing user in the database.
|
||||
|
||||
Args:
|
||||
user_attributtes (users_schema.User): The updated user attributes to be saved.
|
||||
user_attributtes (users_schema.UserRead): The updated user attributes to be saved.
|
||||
token_user_id (int): The ID of the user extracted from the access token.
|
||||
db (Session): The database session dependency.
|
||||
|
||||
@@ -836,7 +836,7 @@ async def import_profile_data(
|
||||
"photo_path"
|
||||
] = f"data/user_images/{token_user_id}.{extension}"
|
||||
# convert user data to User schema
|
||||
user = users_schema.User(**results["user_data"])
|
||||
user = users_schema.UserRead(**results["user_data"])
|
||||
# Update user
|
||||
users_crud.edit_user(token_user_id, user, db)
|
||||
counts["user"] += 1
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from urllib.parse import unquote
|
||||
|
||||
import session.security as session_security
|
||||
|
||||
import server_settings.schema as server_settings_schema
|
||||
import server_settings.models as server_settings_models
|
||||
@@ -37,13 +32,17 @@ def get_server_settings(db: Session):
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error: {err}",
|
||||
) from err
|
||||
|
||||
|
||||
def edit_server_settings(server_settings: server_settings_schema.ServerSettings, db: Session):
|
||||
|
||||
def edit_server_settings(
|
||||
server_settings: server_settings_schema.ServerSettingsEdit, db: Session
|
||||
):
|
||||
try:
|
||||
# Get the server_settings from the database
|
||||
db_server_settings = (
|
||||
db.query(server_settings_models.ServerSettings).filter(server_settings_models.ServerSettings.id == 1).first()
|
||||
db.query(server_settings_models.ServerSettings)
|
||||
.filter(server_settings_models.ServerSettings.id == 1)
|
||||
.first()
|
||||
)
|
||||
|
||||
if db_server_settings is None:
|
||||
@@ -70,7 +69,9 @@ def edit_server_settings(server_settings: server_settings_schema.ServerSettings,
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(f"Error in edit_server_settings: {err}", "error", exc=err)
|
||||
core_logger.print_to_log(
|
||||
f"Error in edit_server_settings: {err}", "error", exc=err
|
||||
)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
|
||||
@@ -42,5 +42,23 @@ class ServerSettings(Base):
|
||||
default=25,
|
||||
comment="Number of records per page in lists",
|
||||
)
|
||||
signup_enabled = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Allow user sign-up registration (true - yes, false - no)",
|
||||
)
|
||||
signup_require_admin_approval = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="Require admin approval for new sign-ups (true - yes, false - no)",
|
||||
)
|
||||
signup_require_email_verification = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="Require email verification for new sign-ups (true - yes, false - no)",
|
||||
)
|
||||
|
||||
__table_args__ = (CheckConstraint("id = 1", name="single_row_check"),)
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import server_settings.schema as server_settings_schema
|
||||
import server_settings.crud as server_settings_crud
|
||||
import server_settings.utils as server_settings_utils
|
||||
|
||||
import core.database as core_database
|
||||
|
||||
@@ -12,7 +12,7 @@ import core.database as core_database
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=server_settings_schema.ServerSettings)
|
||||
@router.get("", response_model=server_settings_schema.ServerSettingsReadPublic)
|
||||
async def read_public_server_settings(
|
||||
db: Annotated[
|
||||
Session,
|
||||
@@ -20,4 +20,9 @@ async def read_public_server_settings(
|
||||
],
|
||||
):
|
||||
# Get the server_settings from the database
|
||||
return server_settings_crud.get_server_settings(db)
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
|
||||
delattr(server_settings, "signup_require_admin_approval")
|
||||
delattr(server_settings, "signup_require_email_verification")
|
||||
|
||||
return server_settings
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
import server_settings.schema as server_settings_schema
|
||||
import server_settings.crud as server_settings_crud
|
||||
import server_settings.utils as server_settings_utils
|
||||
|
||||
import session.security as session_security
|
||||
|
||||
@@ -17,7 +18,7 @@ import core.config as core_config
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=server_settings_schema.ServerSettings)
|
||||
@router.get("", response_model=server_settings_schema.ServerSettingsRead)
|
||||
async def read_server_settings(
|
||||
check_scopes: Annotated[
|
||||
Callable,
|
||||
@@ -29,12 +30,12 @@ async def read_server_settings(
|
||||
],
|
||||
):
|
||||
# Get the server_settings from the database
|
||||
return server_settings_crud.get_server_settings(db)
|
||||
return server_settings_utils.get_server_settings(db)
|
||||
|
||||
|
||||
@router.put("", response_model=server_settings_schema.ServerSettings)
|
||||
@router.put("", response_model=server_settings_schema.ServerSettingsRead)
|
||||
async def edit_server_settings(
|
||||
server_settings_attributtes: server_settings_schema.ServerSettings,
|
||||
server_settings_attributtes: server_settings_schema.ServerSettingsEdit,
|
||||
check_scopes: Annotated[
|
||||
Callable,
|
||||
Security(session_security.check_scopes, scopes=["server_settings:write"]),
|
||||
|
||||
@@ -1,15 +1,91 @@
|
||||
from pydantic import BaseModel
|
||||
from enum import IntEnum
|
||||
from pydantic import BaseModel, StrictInt, ConfigDict
|
||||
|
||||
|
||||
class Units(IntEnum):
|
||||
"""
|
||||
An enumeration representing measurement units.
|
||||
|
||||
Attributes:
|
||||
METRIC (int): Metric system (e.g., meters, kilograms).
|
||||
IMPERIAL (int): Imperial system (e.g., miles, pounds).
|
||||
"""
|
||||
|
||||
METRIC = 1
|
||||
IMPERIAL = 2
|
||||
|
||||
|
||||
class Currency(IntEnum):
|
||||
"""
|
||||
An enumeration representing supported currencies.
|
||||
|
||||
Attributes:
|
||||
EURO (int): Represents the Euro currency.
|
||||
DOLLAR (int): Represents the US Dollar currency.
|
||||
POUND (int): Represents the British Pound currency.
|
||||
"""
|
||||
|
||||
EURO = 1
|
||||
DOLLAR = 2
|
||||
POUND = 3
|
||||
|
||||
|
||||
class ServerSettings(BaseModel):
|
||||
id: int
|
||||
units: int
|
||||
"""
|
||||
Represents the configuration settings for a server.
|
||||
|
||||
Attributes:
|
||||
id (StrictInt): Unique identifier for the server settings.
|
||||
units (Units): Measurement units used by the server.
|
||||
public_shareable_links (bool): Indicates if public shareable links are enabled.
|
||||
public_shareable_links_user_info (bool): Indicates if user information is included in public shareable links.
|
||||
login_photo_set (bool): Specifies if a login photo has been set.
|
||||
currency (Currency): Currency used by the server.
|
||||
num_records_per_page (int): Number of records displayed per page.
|
||||
signup_enabled (bool): Indicates if user signup is enabled.
|
||||
"""
|
||||
|
||||
id: StrictInt
|
||||
units: Units
|
||||
public_shareable_links: bool
|
||||
public_shareable_links_user_info: bool
|
||||
login_photo_set: bool
|
||||
currency: int
|
||||
currency: Currency
|
||||
num_records_per_page: int
|
||||
signup_enabled: bool
|
||||
|
||||
model_config = {
|
||||
"from_attributes": True
|
||||
}
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="forbid",
|
||||
validate_assignment=True,
|
||||
use_enum_values=True,
|
||||
)
|
||||
|
||||
|
||||
class ServerSettingsEdit(ServerSettings):
|
||||
"""
|
||||
Extends ServerSettings with additional fields for user signup configuration.
|
||||
|
||||
Attributes:
|
||||
signup_require_admin_approval (bool): Indicates if new user signups require admin approval.
|
||||
signup_require_email_verification (bool): Indicates if new user signups require email verification.
|
||||
"""
|
||||
|
||||
signup_require_admin_approval: bool
|
||||
signup_require_email_verification: bool
|
||||
|
||||
|
||||
class ServerSettingsRead(ServerSettingsEdit):
|
||||
"""
|
||||
Represents a read-only view of server settings, inheriting all fields and validation from ServerSettingsEdit.
|
||||
This class is typically used for serializing server settings data for API responses.
|
||||
"""
|
||||
|
||||
|
||||
class ServerSettingsReadPublic(ServerSettings):
|
||||
"""
|
||||
A public-facing schema for reading server settings.
|
||||
|
||||
This class inherits all fields and behaviors from `ServerSettings` and is intended
|
||||
for use cases where only public server settings should be exposed.
|
||||
"""
|
||||
|
||||
17
backend/app/server_settings/utils.py
Normal file
17
backend/app/server_settings/utils.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import server_settings.crud as server_settings_crud
|
||||
|
||||
|
||||
def get_server_settings(db: Session):
|
||||
server_settings = server_settings_crud.get_server_settings(db)
|
||||
|
||||
if not server_settings:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Server settings not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return server_settings
|
||||
@@ -2,7 +2,9 @@ import os
|
||||
|
||||
# JWT Token constants
|
||||
JWT_ALGORITHM = os.environ.get("ALGORITHM", "HS256")
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = int(
|
||||
os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "15")
|
||||
)
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS = int(os.environ.get("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||
JWT_SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
|
||||
@@ -11,9 +13,14 @@ USERS_REGULAR_SCOPES = ["profile", "users:read"]
|
||||
USERS_ADMIN_SCOPES = ["users:write", "sessions:read", "sessions:write"]
|
||||
GEARS_SCOPES = ["gears:read", "gears:write"]
|
||||
ACTIVITIES_SCOPES = ["activities:read", "activities:write"]
|
||||
HEALTH_SCOPES = ["health:read", "health:write", "health_targets:read", "health_targets:write"]
|
||||
SERVER_SETTINGS_REGULAR_SCOPES = ["server_settings:read"]
|
||||
SERVER_SETTINGS_ADMIN_SCOPES = ["server_settings:write"]
|
||||
HEALTH_SCOPES = [
|
||||
"health:read",
|
||||
"health:write",
|
||||
"health_targets:read",
|
||||
"health_targets:write",
|
||||
]
|
||||
SERVER_SETTINGS_REGULAR_SCOPES = []
|
||||
SERVER_SETTINGS_ADMIN_SCOPES = ["server_settings:read", "server_settings:write"]
|
||||
SCOPES_DICT = {
|
||||
"profile": "Privileges over user's own profile",
|
||||
"users:read": "Read privileges over users",
|
||||
@@ -33,11 +40,13 @@ SCOPES_DICT = {
|
||||
}
|
||||
|
||||
# Constants related to user access types
|
||||
REGULAR_ACCESS = 1
|
||||
REGULAR_ACCESS_SCOPES = USERS_REGULAR_SCOPES + GEARS_SCOPES + ACTIVITIES_SCOPES + HEALTH_SCOPES + SERVER_SETTINGS_REGULAR_SCOPES
|
||||
ADMIN_ACCESS = 2
|
||||
ADMIN_ACCESS_SCOPES = REGULAR_ACCESS_SCOPES + USERS_ADMIN_SCOPES + SERVER_SETTINGS_ADMIN_SCOPES
|
||||
|
||||
# Constants related to user active status
|
||||
USER_ACTIVE = 1
|
||||
USER_NOT_ACTIVE = 2
|
||||
REGULAR_ACCESS_SCOPES = (
|
||||
USERS_REGULAR_SCOPES
|
||||
+ GEARS_SCOPES
|
||||
+ ACTIVITIES_SCOPES
|
||||
+ HEALTH_SCOPES
|
||||
+ SERVER_SETTINGS_REGULAR_SCOPES
|
||||
)
|
||||
ADMIN_ACCESS_SCOPES = (
|
||||
REGULAR_ACCESS_SCOPES + USERS_ADMIN_SCOPES + SERVER_SETTINGS_ADMIN_SCOPES
|
||||
)
|
||||
|
||||
@@ -50,27 +50,29 @@ async def login_for_access_token(
|
||||
if profile_utils.is_mfa_enabled_for_user(user.id, db):
|
||||
# Store the user for pending MFA verification
|
||||
pending_mfa_store.add_pending_login(form_data.username, user.id)
|
||||
|
||||
|
||||
# Return MFA required response
|
||||
if client_type == "web":
|
||||
response.status_code = status.HTTP_202_ACCEPTED
|
||||
return session_schema.MFARequiredResponse(
|
||||
mfa_required=True,
|
||||
username=form_data.username,
|
||||
message="MFA verification required"
|
||||
message="MFA verification required",
|
||||
)
|
||||
if client_type == "mobile":
|
||||
return {
|
||||
"mfa_required": True,
|
||||
"username": form_data.username,
|
||||
"message": "MFA verification required"
|
||||
"message": "MFA verification required",
|
||||
}
|
||||
|
||||
|
||||
# If no MFA required, proceed with normal login
|
||||
return await complete_login(response, request, user, client_type, db)
|
||||
|
||||
|
||||
async def complete_login(response: Response, request: Request, user, client_type: str, db: Session):
|
||||
async def complete_login(
|
||||
response: Response, request: Request, user, client_type: str, db: Session
|
||||
):
|
||||
# Create the tokens
|
||||
access_token, refresh_token, csrf_token = session_utils.create_tokens(user)
|
||||
|
||||
@@ -122,31 +124,29 @@ async def verify_mfa_and_login(
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No pending MFA login found for this username"
|
||||
detail="No pending MFA login found for this username",
|
||||
)
|
||||
|
||||
|
||||
# Verify the MFA code
|
||||
if not profile_utils.verify_user_mfa(user_id, mfa_request.mfa_code, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid MFA code"
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MFA code"
|
||||
)
|
||||
|
||||
|
||||
# Get the user and complete login
|
||||
user = users_crud.get_user_by_id(user_id, db)
|
||||
if not user:
|
||||
pending_mfa_store.delete_pending_login(mfa_request.username)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
|
||||
# Check if the user is still active
|
||||
users_utils.check_user_is_active(user)
|
||||
|
||||
|
||||
# Clean up pending login
|
||||
pending_mfa_store.delete_pending_login(mfa_request.username)
|
||||
|
||||
|
||||
# Complete the login
|
||||
return await complete_login(response, request, user, client_type, db)
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"/api/v1/mfa/verify",
|
||||
"/api/v1/password-reset/request",
|
||||
"/api/v1/password-reset/confirm",
|
||||
"/api/v1/sign-up/request",
|
||||
"/api/v1/sign-up/confirm"
|
||||
]
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
|
||||
@@ -112,7 +112,7 @@ def validate_token_expiration(token: Annotated[str, Depends(oauth2_scheme)]) ->
|
||||
|
||||
# Validate token expiration
|
||||
claims_requests.validate(payload.claims)
|
||||
except jwt.JWTClaimsError as claims_err:
|
||||
except jwt.InvalidClaimError as claims_err:
|
||||
core_logger.print_to_log(
|
||||
f"JWT claims validation error: {claims_err}",
|
||||
"error",
|
||||
|
||||
@@ -22,7 +22,7 @@ import users.user.schema as users_schema
|
||||
|
||||
|
||||
def create_session_object(
|
||||
user: users_schema.User,
|
||||
user: users_schema.UserRead,
|
||||
request: Request,
|
||||
refresh_token: str,
|
||||
refresh_token_exp: datetime,
|
||||
@@ -92,9 +92,9 @@ def authenticate_user(username: str, password: str, db: Session):
|
||||
return user
|
||||
|
||||
|
||||
def create_tokens(user: users_schema.User):
|
||||
def create_tokens(user: users_schema.UserRead):
|
||||
# Check user access level and set scopes accordingly
|
||||
if user.access_type == session_constants.REGULAR_ACCESS:
|
||||
if user.access_type == users_schema.UserAccessType.REGULAR:
|
||||
scopes = session_constants.REGULAR_ACCESS_SCOPES
|
||||
else:
|
||||
scopes = session_constants.ADMIN_ACCESS_SCOPES
|
||||
@@ -165,7 +165,7 @@ def create_response_with_tokens(
|
||||
|
||||
|
||||
def create_session(
|
||||
user: users_schema.User,
|
||||
user: users_schema.UserRead,
|
||||
request: Request,
|
||||
refresh_token: str,
|
||||
db: Session,
|
||||
|
||||
0
backend/app/sign_up_tokens/__init__.py
Normal file
0
backend/app/sign_up_tokens/__init__.py
Normal file
264
backend/app/sign_up_tokens/crud.py
Normal file
264
backend/app/sign_up_tokens/crud.py
Normal file
@@ -0,0 +1,264 @@
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sign_up_tokens.schema as sign_up_tokens_schema
|
||||
import sign_up_tokens.models as sign_up_tokens_models
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
|
||||
def get_sign_up_token_by_hash(
|
||||
token_hash: str, db: Session
|
||||
) -> sign_up_tokens_models.SignUpToken | None:
|
||||
"""
|
||||
Retrieve an unused, unexpired SignUpToken matching the provided token hash.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token_hash : str
|
||||
The hashed token value to look up in the database.
|
||||
db : Session
|
||||
The SQLAlchemy Session used to perform the query.
|
||||
|
||||
Returns
|
||||
-------
|
||||
sign_up_tokens_models.SignUpToken | None
|
||||
The SignUpToken model instance if a matching token exists, is not marked as used,
|
||||
and has an expires_at timestamp later than the current UTC time. Returns None when
|
||||
no valid token is found.
|
||||
|
||||
Raises
|
||||
------
|
||||
HTTPException
|
||||
If an unexpected error occurs during the database query, the exception is logged
|
||||
and an HTTPException with status code 500 (Internal Server Error) is raised.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- The function filters tokens by: token_hash equality, used == False, and expires_at > now (UTC).
|
||||
- Any caught exception is logged via core_logger.print_to_log before raising the HTTPException.
|
||||
"""
|
||||
try:
|
||||
# Get the token from the database
|
||||
db_token = (
|
||||
db.query(sign_up_tokens_models.SignUpToken)
|
||||
.filter(
|
||||
and_(
|
||||
sign_up_tokens_models.SignUpToken.token_hash == token_hash,
|
||||
sign_up_tokens_models.SignUpToken.used == False,
|
||||
sign_up_tokens_models.SignUpToken.expires_at
|
||||
> datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Return the token (can be None if not found)
|
||||
return db_token
|
||||
except Exception as err:
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in get_sign_up_token_by_hash: {err}", "error", exc=err
|
||||
)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def create_sign_up_token(
|
||||
token: sign_up_tokens_schema.SignUpToken, db: Session
|
||||
) -> sign_up_tokens_models.SignUpToken:
|
||||
"""
|
||||
Create and persist a sign-up token in the database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token : sign_up_tokens_schema.SignUpToken
|
||||
Schema object containing the token data to store. Expected fields include
|
||||
id, user_id, token_hash, created_at, expires_at, and used.
|
||||
db : Session
|
||||
SQLAlchemy session used for the database transaction.
|
||||
|
||||
Returns
|
||||
-------
|
||||
sign_up_tokens_models.SignUpToken
|
||||
The persisted SignUpToken model instance refreshed from the database so
|
||||
any DB-generated values (defaults, timestamps, etc.) are populated.
|
||||
|
||||
Side effects
|
||||
------------
|
||||
- Adds a new SignUpToken row to the database and commits the transaction on success.
|
||||
- Calls db.refresh() to populate the returned model with persisted state.
|
||||
- On error, rolls back the transaction and logs the exception via core_logger.print_to_log().
|
||||
|
||||
Raises
|
||||
------
|
||||
HTTPException
|
||||
An HTTPException with status_code 500 (Internal Server Error) is raised if
|
||||
any exception occurs during creation. The original exception is chained
|
||||
to the raised HTTPException.
|
||||
"""
|
||||
try:
|
||||
# Create a new sign up token
|
||||
db_token = sign_up_tokens_models.SignUpToken(
|
||||
id=token.id,
|
||||
user_id=token.user_id,
|
||||
token_hash=token.token_hash,
|
||||
created_at=token.created_at,
|
||||
expires_at=token.expires_at,
|
||||
used=token.used,
|
||||
)
|
||||
|
||||
# Add the token to the database
|
||||
db.add(db_token)
|
||||
db.commit()
|
||||
db.refresh(db_token)
|
||||
|
||||
# Return the token
|
||||
return db_token
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in create_sign_up_token: {err}", "error", exc=err
|
||||
)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def mark_sign_up_token_used(
|
||||
token_id: str, db: Session
|
||||
) -> sign_up_tokens_models.SignUpToken | None:
|
||||
"""
|
||||
Mark a sign-up token as used.
|
||||
|
||||
This function looks up a SignUpToken by its ID and, if found, sets its 'used'
|
||||
attribute to True and commits the change to the provided SQLAlchemy session.
|
||||
If no token with the given ID exists, the function returns None and does not
|
||||
modify the database.
|
||||
|
||||
Args:
|
||||
token_id (str): The unique identifier of the sign-up token to mark as used.
|
||||
db (Session): An active SQLAlchemy Session used to query and persist changes.
|
||||
|
||||
Returns:
|
||||
sign_up_tokens_models.SignUpToken | None: The updated SignUpToken instance
|
||||
if it was found (with 'used' set to True), otherwise None.
|
||||
|
||||
Raises:
|
||||
HTTPException: If an unexpected error occurs while accessing or committing
|
||||
to the database, the function rolls back the transaction, logs the error,
|
||||
and raises an HTTPException with status code 500 (Internal Server Error).
|
||||
|
||||
Side effects:
|
||||
- Commits the transaction when a token is found and updated.
|
||||
- Rolls back the transaction on exception.
|
||||
- Logs exceptions via core_logger.print_to_log.
|
||||
|
||||
Notes:
|
||||
- The caller is responsible for providing a managed Session. This function
|
||||
performs commit/rollback and therefore affects session state.
|
||||
- For concurrent scenarios, consider appropriate locking or transactional
|
||||
isolation to avoid race conditions when marking tokens as used.
|
||||
"""
|
||||
try:
|
||||
# Get the token from the database
|
||||
db_token = (
|
||||
db.query(sign_up_tokens_models.SignUpToken)
|
||||
.filter(sign_up_tokens_models.SignUpToken.id == token_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if db_token:
|
||||
# Mark the token as used
|
||||
db_token.used = True
|
||||
db.commit()
|
||||
|
||||
return db_token
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in mark_sign_up_token_used: {err}", "error", exc=err
|
||||
)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def delete_expired_sign_up_tokens(db: Session):
|
||||
"""
|
||||
Delete expired SignUpToken records from the database.
|
||||
|
||||
This function deletes all SignUpToken rows whose `expires_at` is earlier than
|
||||
the current UTC time. It performs the deletion using the provided SQLAlchemy
|
||||
Session, commits the transaction on success, and returns the number of rows
|
||||
deleted. If an error occurs, the transaction is rolled back, the error is
|
||||
logged, and an HTTPException with a 500 status code is raised (the original
|
||||
exception is preserved for chaining).
|
||||
|
||||
Args:
|
||||
db (Session): An active SQLAlchemy Session used to execute the delete and
|
||||
commit operations.
|
||||
|
||||
Returns:
|
||||
int: The number of SignUpToken records removed by the operation.
|
||||
|
||||
Raises:
|
||||
HTTPException: Raised with status_code=500 if any unexpected error occurs
|
||||
during deletion or commit.
|
||||
|
||||
Side effects and notes:
|
||||
- Permanently removes matching rows from the database.
|
||||
- Commits the transaction on success; rolls back the transaction on error.
|
||||
- Uses UTC-aware comparison (datetime.now(timezone.utc)) to evaluate expiration.
|
||||
- Uses a bulk query-level delete; such bulk operations may bypass ORM-level
|
||||
cascades, event hooks, and may not synchronize in-memory objects in the
|
||||
session. If the session holds SignUpToken instances, consider session
|
||||
synchronization or expiring/refreshing those objects after the operation.
|
||||
"""
|
||||
try:
|
||||
# Delete expired tokens
|
||||
num_deleted = (
|
||||
db.query(sign_up_tokens_models.SignUpToken)
|
||||
.filter(
|
||||
sign_up_tokens_models.SignUpToken.expires_at
|
||||
< datetime.now(timezone.utc)
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return num_deleted
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in delete_expired_sign_up_tokens: {err}", "error", exc=err
|
||||
)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
254
backend/app/sign_up_tokens/email_messages.py
Normal file
254
backend/app/sign_up_tokens/email_messages.py
Normal file
@@ -0,0 +1,254 @@
|
||||
import core.apprise as core_apprise
|
||||
|
||||
|
||||
def get_signup_confirmation_email_en(
|
||||
user_name: str, signup_link: str, email_service: core_apprise.AppriseService
|
||||
) -> tuple:
|
||||
"""
|
||||
Return the subject, HTML body, and plain-text body for an English sign-up confirmation email.
|
||||
|
||||
Args:
|
||||
user_name (str): The recipient's display name inserted into the greeting.
|
||||
signup_link (str): The URL the user will follow to confirm their sign-up; inserted into the CTA button
|
||||
and included as a plain link for clients that do not render the button.
|
||||
email_service (core_apprise.AppriseService): Notification service instance used to obtain service
|
||||
metadata (e.g., `frontend_host`) for the email footer.
|
||||
|
||||
Returns:
|
||||
tuple[str, str, str]: A 3-tuple containing:
|
||||
- subject: The email subject line.
|
||||
- html_content: The full HTML email content (string) including inline styles, logo, a prominent
|
||||
"Confirm Account" button linking to `signup_link`, a security notice about a 24-hour expiry, and
|
||||
a footer referencing `email_service.frontend_host`.
|
||||
- text_content: A plain-text alternative suitable for clients that do not render HTML, containing
|
||||
the greeting, confirmation instructions, the raw `signup_link`, expiry notice, and sign-off.
|
||||
|
||||
Notes:
|
||||
- The function only constructs and returns strings; it does not send emails or perform network I/O.
|
||||
- Calling code should ensure `signup_link` and `user_name` are properly validated/sanitized as needed.
|
||||
- The HTML is crafted with inline styles for broad email-client compatibility.
|
||||
"""
|
||||
subject = "Endurain - Confirm your account"
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{subject}</title>
|
||||
</head>
|
||||
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f4f4f4;">
|
||||
<div style="background-color: #ffffff; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<div style="font-size: 34px; font-weight: bold; margin-bottom: 10px; display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||
<img src="https://github.com/joaovitoriasilva/endurain/blob/0e17fafe450b66eda7982311e6f94cee44316684/frontend/app/public/logo/logo.svg?raw=true"
|
||||
alt="Endurain logo" style="height: 32px; width: auto;">
|
||||
<span>Endurain</span>
|
||||
</div>
|
||||
<h3 style="margin: 0;">Confirm your account</h3>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<p>Hi {user_name},</p>
|
||||
|
||||
<p>Thank you for signing up for Endurain! Please confirm your account by clicking the button below:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{signup_link}" style="background-color: #198754; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Confirm Account</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Security notice:</strong> This confirmation link will expire in 24 hours.
|
||||
</div>
|
||||
|
||||
<p>If you didn’t create an Endurain account, please ignore this email.</p>
|
||||
|
||||
<p>If the button above doesn’t work, you can copy and paste the following link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #198754;">{signup_link}</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; font-size: 12px; color: #666; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
|
||||
<p>Best regards,<br>The Endurain team</p>
|
||||
<p>Visit Endurain at: <a style="color: #198754;" href="{email_service.frontend_host}">{email_service.frontend_host}</a> -
|
||||
Source code at: <a style="color: #198754;"
|
||||
href="https://github.com/joaovitoriasilva/endurain">GitHub</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
""".strip()
|
||||
|
||||
# Create text version
|
||||
text_content = f"""
|
||||
Hi {user_name},
|
||||
|
||||
Thank you for signing up for Endurain!
|
||||
|
||||
Please confirm your account by clicking the following link:
|
||||
{signup_link}
|
||||
|
||||
This confirmation link will expire in 24 hours.
|
||||
|
||||
If you didn’t create an Endurain account, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
The Endurain team
|
||||
""".strip()
|
||||
|
||||
return subject, html_content, text_content
|
||||
|
||||
|
||||
def get_admin_signup_notification_email_en(
|
||||
user_name: str,
|
||||
sign_up_user_name: str,
|
||||
sign_up_user_username: str,
|
||||
email_service: core_apprise.AppriseService,
|
||||
) -> tuple:
|
||||
subject = "Endurain - New user sign-up pending approval"
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{subject}</title>
|
||||
</head>
|
||||
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f4f4f4;">
|
||||
<div style="background-color: #ffffff; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<div style="font-size: 34px; font-weight: bold; margin-bottom: 10px; display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||
<img src="https://github.com/joaovitoriasilva/endurain/blob/0e17fafe450b66eda7982311e6f94cee44316684/frontend/app/public/logo/logo.svg?raw=true"
|
||||
alt="Endurain logo" style="height: 32px; width: auto;">
|
||||
<span>Endurain</span>
|
||||
</div>
|
||||
<h3 style="margin: 0;">New sign-up requires approval</h3>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<p>Hello {user_name},</p>
|
||||
|
||||
<p>A new user has signed up and is awaiting approval:</p>
|
||||
|
||||
<div style="background-color: #e9ecef; border: 1px solid #ccc; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>User:</strong> {sign_up_user_name}
|
||||
</div>
|
||||
|
||||
<p>Please log in to the Endurain admin panel to review and approve this request.</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{email_service.frontend_host}/settings?tab=users&username={sign_up_user_username}" style="background-color: #198754; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Go to Admin Panel</a>
|
||||
</div>
|
||||
|
||||
<p>If the button above doesn’t work, you can copy and paste the following link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #198754;">{email_service.frontend_host}/settings?tab=users&username={sign_up_user_username}</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; font-size: 12px; color: #666; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
|
||||
<p>Best regards,<br>The Endurain system</p>
|
||||
<p>Visit Endurain at: <a style="color: #198754;" href="{email_service.frontend_host}">{email_service.frontend_host}</a> -
|
||||
Source code at: <a style="color: #198754;"
|
||||
href="https://github.com/joaovitoriasilva/endurain">GitHub</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
""".strip()
|
||||
|
||||
text_content = f"""
|
||||
Hello {user_name},
|
||||
|
||||
A new user has signed up and is awaiting approval.
|
||||
|
||||
User: {sign_up_user_name}
|
||||
|
||||
Please log in to the Endurain admin panel to review and approve this request:
|
||||
{email_service.frontend_host}/settings?tab=users&username={sign_up_user_username}
|
||||
|
||||
Best regards,
|
||||
The Endurain system
|
||||
""".strip()
|
||||
|
||||
return subject, html_content, text_content
|
||||
|
||||
|
||||
def get_user_signup_approved_email_en(
|
||||
sign_up_user_name: str,
|
||||
sign_up_user_username: str,
|
||||
email_service: core_apprise.AppriseService,
|
||||
) -> tuple:
|
||||
subject = "Endurain - Your account has been approved"
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{subject}</title>
|
||||
</head>
|
||||
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f4f4f4;">
|
||||
<div style="background-color: #ffffff; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<div style="font-size: 34px; font-weight: bold; margin-bottom: 10px; display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||
<img src="https://github.com/joaovitoriasilva/endurain/blob/0e17fafe450b66eda7982311e6f94cee44316684/frontend/app/public/logo/logo.svg?raw=true"
|
||||
alt="Endurain logo" style="height: 32px; width: auto;">
|
||||
<span>Endurain</span>
|
||||
</div>
|
||||
<h3 style="margin: 0;">Your account is now active</h3>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<p>Hello {sign_up_user_name},</p>
|
||||
|
||||
<p>Good news! Your account has been approved and is now active.</p>
|
||||
|
||||
<div style="background-color: #e9ecef; border: 1px solid #ccc; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<strong>Username:</strong> {sign_up_user_username}
|
||||
</div>
|
||||
|
||||
<p>You can now log in and start using Endurain:</p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{email_service.frontend_host}/login" style="background-color: #198754; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: bold;">Log in to Endurain</a>
|
||||
</div>
|
||||
|
||||
<p>If the button above doesn’t work, you can copy and paste the following link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #198754;">{email_service.frontend_host}/login</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; font-size: 12px; color: #666; margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
|
||||
<p>Best regards,<br>The Endurain team</p>
|
||||
<p>Visit Endurain at: <a style="color: #198754;" href="{email_service.frontend_host}">{email_service.frontend_host}</a> -
|
||||
Source code at: <a style="color: #198754;"
|
||||
href="https://github.com/joaovitoriasilva/endurain">GitHub</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
""".strip()
|
||||
|
||||
text_content = f"""
|
||||
Hello {sign_up_user_name},
|
||||
|
||||
Good news! Your account has been approved and is now active.
|
||||
|
||||
Username: {sign_up_user_username}
|
||||
|
||||
You can now log in and start using Endurain:
|
||||
{email_service.frontend_host}/login
|
||||
|
||||
Best regards,
|
||||
The Endurain team
|
||||
""".strip()
|
||||
|
||||
return subject, html_content, text_content
|
||||
41
backend/app/sign_up_tokens/models.py
Normal file
41
backend/app/sign_up_tokens/models.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Boolean,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class SignUpToken(Base):
|
||||
__tablename__ = "sign_up_tokens"
|
||||
|
||||
id = Column(String(length=64), nullable=False, primary_key=True)
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="User ID that the sign-up token belongs to",
|
||||
)
|
||||
token_hash = Column(
|
||||
String(length=128), nullable=False, comment="Hashed sign-up token"
|
||||
)
|
||||
created_at = Column(
|
||||
DateTime, nullable=False, comment="Token creation date (datetime)"
|
||||
)
|
||||
expires_at = Column(
|
||||
DateTime, nullable=False, comment="Token expiration date (datetime)"
|
||||
)
|
||||
used = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Token usage status (False - unused, True - used)",
|
||||
)
|
||||
|
||||
# Define a relationship to the User model
|
||||
user = relationship("User", back_populates="sign_up_tokens")
|
||||
201
backend/app/sign_up_tokens/router.py
Normal file
201
backend/app/sign_up_tokens/router.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
status,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import users.user.crud as users_crud
|
||||
import users.user.utils as users_utils
|
||||
import users.user.schema as users_schema
|
||||
import users.user_integrations.crud as user_integrations_crud
|
||||
import users.user_default_gear.crud as user_default_gear_crud
|
||||
import users.user_privacy_settings.crud as users_privacy_settings_crud
|
||||
|
||||
import notifications.utils as notifications_utils
|
||||
|
||||
import health_targets.crud as health_targets_crud
|
||||
|
||||
import sign_up_tokens.utils as sign_up_tokens_utils
|
||||
import sign_up_tokens.schema as sign_up_tokens_schema
|
||||
|
||||
import server_settings.utils as server_settings_utils
|
||||
|
||||
import core.database as core_database
|
||||
import core.apprise as core_apprise
|
||||
|
||||
import websocket.schema as websocket_schema
|
||||
|
||||
# Define the API router
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/sign-up/request", status_code=201)
|
||||
async def signup(
|
||||
user: users_schema.UserSignup,
|
||||
email_service: Annotated[
|
||||
core_apprise.AppriseService,
|
||||
Depends(core_apprise.get_email_service),
|
||||
],
|
||||
db: Annotated[
|
||||
Session,
|
||||
Depends(core_database.get_db),
|
||||
],
|
||||
):
|
||||
"""
|
||||
Handle user sign-up: create the user and related default data, enforce server sign-up policies,
|
||||
and trigger ancillary actions such as sending verification/approval emails and notifying admins.
|
||||
|
||||
Parameters
|
||||
- user (users_schema.UserSignup): The payload containing the user's sign-up information.
|
||||
- email_service (core_apprise.AppriseService): Injected email service used to send
|
||||
verification and admin approval emails.
|
||||
- websocket_manager (websocket_schema.WebSocketManager): Injected manager used to send
|
||||
real-time notifications (e.g., admin approval requests).
|
||||
- db (Session): Database session/connection used to create the user and related records.
|
||||
|
||||
Behavior and side effects
|
||||
- Reads server settings to determine whether sign-up is enabled and whether email verification
|
||||
and/or admin approval are required.
|
||||
- If sign-up is disabled, raises an HTTPException(403).
|
||||
- Creates the user record and several related default records in the database, including:
|
||||
- user integrations
|
||||
- user privacy settings
|
||||
- user health targets
|
||||
- user default gear
|
||||
- Depending on server settings:
|
||||
- If email verification is required (and admin approval is not required):
|
||||
- Attempts to send an email with verification instructions to the created user.
|
||||
- Adds the "email_verification_required" flag to the returned response and updates
|
||||
the human-readable message to reflect email sending success or failure.
|
||||
- Note: account creation still occurs even if sending the verification email fails.
|
||||
- If admin approval is required:
|
||||
- Adds the "admin_approval_required" flag to the returned response and updates
|
||||
the human-readable message to indicate the account is pending approval.
|
||||
- Sends an admin-approval email and creates a real-time admin notification via
|
||||
the websocket manager.
|
||||
- If neither email verification nor admin approval is required:
|
||||
- Updates the human-readable message to inform the user they can now log in.
|
||||
|
||||
Return
|
||||
- dict: A dictionary containing at least a "message" key describing the result.
|
||||
Additional keys may be present:
|
||||
- "email_verification_required" (bool): Present when email verification must be completed.
|
||||
- "admin_approval_required" (bool): Present when admin approval is required.
|
||||
|
||||
Raises
|
||||
- HTTPException: Raised with status code 403 when server sign-up is disabled.
|
||||
- Any exceptions raised by the underlying CRUD utilities, email service, notification utilities,
|
||||
or database session may propagate (e.g., for transaction rollback or upstream error handling).
|
||||
|
||||
Notes
|
||||
- This is an async FastAPI route handler intended to be used with dependency injection.
|
||||
- The function performs persistent writes and external I/O (sending emails, pushing notifications);
|
||||
callers and tests should account for these side effects (e.g., by using transactions, fakes, or mocks).
|
||||
"""
|
||||
# Get server settings to check if signup is enabled
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
|
||||
# Check if signup is enabled
|
||||
if not server_settings.signup_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User sign-up is not enabled on this server",
|
||||
)
|
||||
|
||||
# Create the user in the database
|
||||
created_user = users_crud.create_signup_user(user, server_settings, db)
|
||||
|
||||
# Create the user integrations in the database
|
||||
user_integrations_crud.create_user_integrations(created_user.id, db)
|
||||
|
||||
# Create the user privacy settings
|
||||
users_privacy_settings_crud.create_user_privacy_settings(created_user.id, db)
|
||||
|
||||
# Create the user health targets
|
||||
health_targets_crud.create_health_targets(created_user.id, db)
|
||||
|
||||
# Create the user default gear
|
||||
user_default_gear_crud.create_user_default_gear(created_user.id, db)
|
||||
|
||||
# Return appropriate response based on server configuration
|
||||
response_data = {"message": "User created successfully."}
|
||||
|
||||
if server_settings.signup_require_email_verification:
|
||||
# Send the sign-up email
|
||||
success = await sign_up_tokens_utils.send_sign_up_email(
|
||||
created_user, email_service, db
|
||||
)
|
||||
|
||||
if success:
|
||||
response_data["message"] = (
|
||||
response_data["message"] + " Email sent with verification instructions."
|
||||
)
|
||||
else:
|
||||
response_data["message"] = (
|
||||
response_data["message"]
|
||||
+ " Failed to send verification email. Please contact support."
|
||||
)
|
||||
response_data["email_verification_required"] = True
|
||||
if server_settings.signup_require_admin_approval:
|
||||
response_data["message"] = (
|
||||
response_data["message"] + " Account is pending admin approval."
|
||||
)
|
||||
response_data["admin_approval_required"] = True
|
||||
if (
|
||||
not server_settings.signup_require_email_verification
|
||||
and not server_settings.signup_require_admin_approval
|
||||
):
|
||||
response_data["message"] = response_data["message"] + " You can now log in."
|
||||
return response_data
|
||||
|
||||
|
||||
@router.post("/sign-up/confirm")
|
||||
async def verify_email(
|
||||
confirm_data: sign_up_tokens_schema.SignUpConfirm,
|
||||
email_service: Annotated[
|
||||
core_apprise.AppriseService,
|
||||
Depends(core_apprise.get_email_service),
|
||||
],
|
||||
websocket_manager: Annotated[
|
||||
websocket_schema.WebSocketManager,
|
||||
Depends(websocket_schema.get_websocket_manager),
|
||||
],
|
||||
db: Annotated[
|
||||
Session,
|
||||
Depends(core_database.get_db),
|
||||
],
|
||||
):
|
||||
# Get server settings
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
if not server_settings.signup_require_email_verification:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
||||
detail="Email verification is not enabled",
|
||||
)
|
||||
|
||||
# Verify the email
|
||||
user_id = sign_up_tokens_utils.use_sign_up_token(confirm_data.token, db)
|
||||
users_crud.verify_user_email(user_id, server_settings, db)
|
||||
|
||||
if email_service.is_configured():
|
||||
user = users_crud.get_user_by_id(user_id, db)
|
||||
await sign_up_tokens_utils.send_sign_up_admin_approval_email(
|
||||
user, email_service, db
|
||||
)
|
||||
await notifications_utils.create_admin_new_sign_up_approval_request_notification(
|
||||
user, websocket_manager, db
|
||||
)
|
||||
|
||||
# Return appropriate response based on server configuration
|
||||
response_data = {"message": "Email verified successfully."}
|
||||
if server_settings.signup_require_admin_approval:
|
||||
response_data["message"] += " Your account is now pending admin approval."
|
||||
response_data["admin_approval_required"] = True
|
||||
else:
|
||||
response_data["message"] += " You can now log in."
|
||||
|
||||
return response_data
|
||||
19
backend/app/sign_up_tokens/schema.py
Normal file
19
backend/app/sign_up_tokens/schema.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SignUpToken(BaseModel):
|
||||
id: str
|
||||
user_id: int
|
||||
token_hash: str
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
used: bool
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True, extra="forbid", validate_assignment=True
|
||||
)
|
||||
|
||||
|
||||
class SignUpConfirm(BaseModel):
|
||||
token: str
|
||||
188
backend/app/sign_up_tokens/utils.py
Normal file
188
backend/app/sign_up_tokens/utils.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import (
|
||||
HTTPException,
|
||||
status,
|
||||
)
|
||||
from uuid import uuid4
|
||||
import hashlib
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import sign_up_tokens.email_messages as sign_up_tokens_email_messages
|
||||
import sign_up_tokens.schema as sign_up_tokens_schema
|
||||
import sign_up_tokens.crud as sign_up_tokens_crud
|
||||
|
||||
import users.user.crud as users_crud
|
||||
import users.user.models as users_models
|
||||
import users.user.utils as users_utils
|
||||
|
||||
import core.apprise as core_apprise
|
||||
import core.logger as core_logger
|
||||
|
||||
from core.database import SessionLocal
|
||||
|
||||
|
||||
def create_sign_up_token(user_id: int, db: Session) -> str:
|
||||
# Generate token and hash
|
||||
token, token_hash = core_apprise.generate_token_and_hash()
|
||||
|
||||
# Create token object
|
||||
reset_token = sign_up_tokens_schema.SignUpToken(
|
||||
id=str(uuid4()),
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
expires_at=datetime.now(timezone.utc)
|
||||
+ timedelta(hours=24), # 24 hour expiration
|
||||
used=0,
|
||||
)
|
||||
|
||||
# Save to database
|
||||
sign_up_tokens_crud.create_sign_up_token(reset_token, db)
|
||||
|
||||
# Return the plain token (not the hash)
|
||||
return token
|
||||
|
||||
|
||||
async def send_sign_up_email(
|
||||
user: users_models.User, email_service: core_apprise.AppriseService, db: Session
|
||||
) -> bool:
|
||||
# Check if email service is configured
|
||||
if not email_service.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Email service is not configured",
|
||||
)
|
||||
|
||||
# Generate sign up token
|
||||
token = create_sign_up_token(user.id, db)
|
||||
|
||||
# Generate reset link
|
||||
reset_link = f"{email_service.frontend_host}/verify-email?token={token}"
|
||||
|
||||
# use default email message in English
|
||||
subject, html_content, text_content = (
|
||||
sign_up_tokens_email_messages.get_signup_confirmation_email_en(
|
||||
user.name, reset_link, email_service
|
||||
)
|
||||
)
|
||||
|
||||
# Send email
|
||||
return await email_service.send_email(
|
||||
to_emails=[user.email],
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
text_content=text_content,
|
||||
)
|
||||
|
||||
|
||||
async def send_sign_up_admin_approval_email(
|
||||
user: users_models.User, email_service: core_apprise.AppriseService, db: Session
|
||||
) -> None:
|
||||
# Check if email service is configured
|
||||
if not email_service.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Email service is not configured",
|
||||
)
|
||||
|
||||
admins = users_utils.get_admin_users(db)
|
||||
|
||||
# Send email to all admin users
|
||||
for admin in admins:
|
||||
# use default email message in English
|
||||
subject, html_content, text_content = (
|
||||
sign_up_tokens_email_messages.get_admin_signup_notification_email_en(
|
||||
admin.name, user.name, user.username, email_service
|
||||
)
|
||||
)
|
||||
|
||||
# Send email
|
||||
await email_service.send_email(
|
||||
to_emails=[admin.email],
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
text_content=text_content,
|
||||
)
|
||||
|
||||
|
||||
async def send_sign_up_approval_email(
|
||||
user_id: int, email_service: core_apprise.AppriseService, db: Session
|
||||
) -> bool:
|
||||
# Check if email service is configured
|
||||
if not email_service.is_configured():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Email service is not configured",
|
||||
)
|
||||
|
||||
# Get user info
|
||||
user = users_crud.get_user_by_id(user_id, db)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# use default email message in English
|
||||
subject, html_content, text_content = (
|
||||
sign_up_tokens_email_messages.get_user_signup_approved_email_en(
|
||||
user.name, user.username, email_service
|
||||
)
|
||||
)
|
||||
|
||||
# Send email
|
||||
return await email_service.send_email(
|
||||
to_emails=[user.email],
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
text_content=text_content,
|
||||
)
|
||||
|
||||
|
||||
def use_sign_up_token(token: str, db: Session) -> int:
|
||||
# Hash the provided token to find the database record
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
# Look up the token in the database
|
||||
db_token = sign_up_tokens_crud.get_sign_up_token_by_hash(token_hash, db)
|
||||
|
||||
if not db_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired sign up token",
|
||||
)
|
||||
|
||||
try:
|
||||
# Mark token as used
|
||||
sign_up_tokens_crud.mark_sign_up_token_used(db_token.id, db)
|
||||
|
||||
# Return the associated user ID
|
||||
return db_token.user_id
|
||||
except HTTPException as http_err:
|
||||
raise http_err
|
||||
except Exception as err:
|
||||
core_logger.print_to_log(f"Error in use_sign_up_token: {err}", "error", exc=err)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def delete_invalid_tokens_from_db():
|
||||
# Create a new database session
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Get num tokens deleted
|
||||
num_deleted = sign_up_tokens_crud.delete_expired_sign_up_tokens(db)
|
||||
|
||||
# Log the number of deleted tokens
|
||||
if num_deleted > 0:
|
||||
core_logger.print_to_log_and_console(
|
||||
f"Deleted {num_deleted} expired sign up tokens", "info"
|
||||
)
|
||||
finally:
|
||||
# Ensure the session is closed after use
|
||||
db.close()
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -12,16 +12,20 @@ import users.user.models as users_models
|
||||
|
||||
import health_data.utils as health_data_utils
|
||||
|
||||
import server_settings.crud as server_settings_crud
|
||||
import sign_up_tokens.utils as sign_up_tokens_utils
|
||||
|
||||
import server_settings.utils as server_settings_utils
|
||||
import server_settings.schema as server_settings_schema
|
||||
|
||||
import core.logger as core_logger
|
||||
import core.apprise as core_apprise
|
||||
|
||||
|
||||
def authenticate_user(username: str, db: Session):
|
||||
try:
|
||||
user = (
|
||||
db.query(users_models.User)
|
||||
.filter(users_models.User.username == username)
|
||||
.filter(users_models.User.username == username.lower())
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -85,7 +89,6 @@ def get_users_with_pagination(db: Session, page_number: int = 1, num_records: in
|
||||
|
||||
# Return the users
|
||||
return users
|
||||
|
||||
except Exception as err:
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
@@ -167,7 +170,9 @@ def get_user_by_email(email: str, db: Session):
|
||||
try:
|
||||
# Get the user from the database
|
||||
user = (
|
||||
db.query(users_models.User).filter(users_models.User.email == email).first()
|
||||
db.query(users_models.User)
|
||||
.filter(users_models.User.email == email.lower())
|
||||
.first()
|
||||
)
|
||||
|
||||
# If the user was not found, return None
|
||||
@@ -218,12 +223,11 @@ def get_user_by_id(user_id: int, db: Session):
|
||||
def get_user_by_id_if_is_public(user_id: int, db: Session):
|
||||
try:
|
||||
# Check if public sharable links are enabled in server settings
|
||||
server_settings = server_settings_crud.get_server_settings(db)
|
||||
server_settings = server_settings_utils.get_server_settings(db)
|
||||
|
||||
# Return None if public sharable links are disabled
|
||||
if (
|
||||
not server_settings
|
||||
or not server_settings.public_shareable_links
|
||||
not server_settings.public_shareable_links
|
||||
or not server_settings.public_shareable_links_user_info
|
||||
):
|
||||
return None
|
||||
@@ -254,8 +258,36 @@ def get_user_by_id_if_is_public(user_id: int, db: Session):
|
||||
) from err
|
||||
|
||||
|
||||
def get_users_admin(db: Session):
|
||||
try:
|
||||
# Get the users from the database and format the birthdate
|
||||
users = [
|
||||
users_utils.format_user_birthdate(user)
|
||||
for user in db.query(users_models.User)
|
||||
.filter(users_models.User.access_type == 2)
|
||||
.all()
|
||||
]
|
||||
|
||||
# If the users were not found, return None
|
||||
if not users:
|
||||
return None
|
||||
|
||||
# Return the users
|
||||
return users
|
||||
except Exception as err:
|
||||
# Log the exception
|
||||
core_logger.print_to_log(f"Error in get_users_admin: {err}", "error", exc=err)
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def create_user(user: users_schema.UserCreate, db: Session):
|
||||
try:
|
||||
user.username = user.username.lower()
|
||||
user.email = user.email.lower()
|
||||
# Create a new user
|
||||
db_user = users_models.User(
|
||||
**user.model_dump(exclude={"password"}),
|
||||
@@ -292,7 +324,7 @@ def create_user(user: users_schema.UserCreate, db: Session):
|
||||
) from err
|
||||
|
||||
|
||||
def edit_user(user_id: int, user: users_schema.User, db: Session):
|
||||
def edit_user(user_id: int, user: users_schema.UserRead, db: Session):
|
||||
try:
|
||||
# Get the user from the database
|
||||
db_user = (
|
||||
@@ -313,6 +345,8 @@ def edit_user(user_id: int, user: users_schema.User, db: Session):
|
||||
# Delete the user photo in the filesystem
|
||||
users_utils.delete_user_photo_filesystem(db_user.id)
|
||||
|
||||
user.username = user.username.lower()
|
||||
|
||||
# Dictionary of the fields to update if they are not None
|
||||
user_data = user.model_dump(exclude_unset=True)
|
||||
# Iterate over the fields and update the db_user dynamically
|
||||
@@ -354,6 +388,165 @@ def edit_user(user_id: int, user: users_schema.User, db: Session):
|
||||
) from err
|
||||
|
||||
|
||||
def approve_user(
|
||||
user_id: int, db: Session
|
||||
):
|
||||
"""
|
||||
Approve a user by ID.
|
||||
|
||||
Fetches the user with the given user_id from the provided SQLAlchemy Session.
|
||||
If the user exists and their email is verified, marks the user as approved by
|
||||
setting `pending_admin_approval` to False and `active` to True, then commits
|
||||
the transaction.
|
||||
|
||||
Parameters:
|
||||
user_id (int): The primary key of the user to approve.
|
||||
db (Session): SQLAlchemy Session used for querying and committing changes.
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 Not Found if no user with the given ID exists. The
|
||||
raised exception includes a "WWW-Authenticate: Bearer" header.
|
||||
HTTPException: 400 Bad Request if the user exists but their email has not
|
||||
been verified.
|
||||
HTTPException: 500 Internal Server Error for any unexpected error during
|
||||
processing; the function will rollback the transaction and log the
|
||||
original exception before raising this error.
|
||||
|
||||
Side effects:
|
||||
- Updates the user object by setting `pending_admin_approval = False` and
|
||||
`active = True`.
|
||||
- Commits the DB transaction on success.
|
||||
- Rolls back the DB transaction and logs the error via
|
||||
`core_logger.print_to_log` on unexpected failures.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Notes:
|
||||
- The function expects the `users_models.User` model to be importable and the
|
||||
provided `db` to be a working SQLAlchemy session.
|
||||
- The original exception is chained to the re-raised 500 HTTPException to
|
||||
preserve context for debugging.
|
||||
"""
|
||||
try:
|
||||
# Get the user from the database
|
||||
db_user = (
|
||||
db.query(users_models.User).filter(users_models.User.id == user_id).first()
|
||||
)
|
||||
|
||||
if db_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not db_user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User email is not verified",
|
||||
)
|
||||
|
||||
db_user.pending_admin_approval = False
|
||||
db_user.active = True
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
except HTTPException as http_err:
|
||||
raise http_err
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(f"Error in approve_user: {err}", "error", exc=err)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def verify_user_email(
|
||||
user_id: int,
|
||||
server_settings: server_settings_schema.ServerSettingsRead,
|
||||
db: Session,
|
||||
):
|
||||
"""
|
||||
Verify a user's email and update their account status in the database.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
user_id : int
|
||||
The primary key of the user to verify.
|
||||
server_settings : server_settings_schema.ServerSettingsRead
|
||||
Server configuration used to determine whether admin approval is required
|
||||
(controls whether the account should be activated immediately).
|
||||
db : Session
|
||||
SQLAlchemy session used to query and persist changes to the database.
|
||||
|
||||
Returns
|
||||
-------
|
||||
None
|
||||
|
||||
Side effects
|
||||
------------
|
||||
- Marks the user's email as verified (sets db_user.email_verified = True).
|
||||
- If server_settings.signup_require_admin_approval is False:
|
||||
- Clears pending admin approval (db_user.pending_admin_approval = False).
|
||||
- Activates the user account (db_user.active = True).
|
||||
- Commits the transaction on success.
|
||||
- On unexpected errors, rolls back the transaction and logs the exception via core_logger.print_to_log.
|
||||
|
||||
Raises
|
||||
------
|
||||
HTTPException
|
||||
- 404 Not Found: if no user exists with the provided user_id.
|
||||
- Re-raises any HTTPException raised during processing.
|
||||
- 500 Internal Server Error: for unexpected exceptions encountered while updating the database.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- The function queries users_models.User for the given user_id.
|
||||
- The caller is responsible for managing the lifecycle of the provided DB session.
|
||||
"""
|
||||
try:
|
||||
# Get the user from the database
|
||||
db_user = (
|
||||
db.query(users_models.User).filter(users_models.User.id == user_id).first()
|
||||
)
|
||||
|
||||
if db_user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
db_user.email_verified = True
|
||||
if not server_settings.signup_require_admin_approval:
|
||||
db_user.pending_admin_approval = False
|
||||
db_user.active = True
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
except HTTPException as http_err:
|
||||
raise http_err
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(f"Error in verify_user_email: {err}", "error", exc=err)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def edit_user_password(user_id: int, password: str, db: Session):
|
||||
try:
|
||||
# Get the user from the database
|
||||
@@ -548,7 +741,7 @@ def disable_user_mfa(user_id: int, db: Session):
|
||||
Returns:
|
||||
None
|
||||
Raises:
|
||||
HTTPException:
|
||||
HTTPException:
|
||||
- 404 Not Found if the user does not exist.
|
||||
- 500 Internal Server Error for any other failure; in this case the
|
||||
transaction is rolled back and the error is logged.
|
||||
@@ -568,7 +761,7 @@ def disable_user_mfa(user_id: int, db: Session):
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
db_user.mfa_enabled = False
|
||||
db_user.mfa_secret = None
|
||||
db.commit()
|
||||
@@ -580,3 +773,98 @@ def disable_user_mfa(user_id: int, db: Session):
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def create_signup_user(
|
||||
user: users_schema.UserSignup,
|
||||
server_settings,
|
||||
db: Session,
|
||||
):
|
||||
"""
|
||||
Creates a new user during the signup process, handling email verification and admin approval requirements.
|
||||
|
||||
Args:
|
||||
user (users_schema.UserSignup): The user signup data containing user details.
|
||||
server_settings: Server configuration settings that determine signup requirements.
|
||||
db (Session): SQLAlchemy database session.
|
||||
|
||||
Returns:
|
||||
users_models.User: The newly created user object.
|
||||
|
||||
Raises:
|
||||
HTTPException:
|
||||
- 409 Conflict if the email or username is not unique.
|
||||
- 500 Internal Server Error for any other exceptions.
|
||||
"""
|
||||
try:
|
||||
# Determine user status based on server settings
|
||||
active = True
|
||||
email_verified = False
|
||||
pending_admin_approval = False
|
||||
|
||||
if server_settings.signup_require_email_verification:
|
||||
email_verified = False
|
||||
active = False # Inactive until email verified
|
||||
|
||||
if server_settings.signup_require_admin_approval:
|
||||
pending_admin_approval = True
|
||||
active = False # Inactive until approved
|
||||
|
||||
# If both email verification and admin approval are disabled, user is immediately active
|
||||
if (
|
||||
not server_settings.signup_require_email_verification
|
||||
and not server_settings.signup_require_admin_approval
|
||||
):
|
||||
active = True
|
||||
email_verified = True
|
||||
|
||||
# Create a new user
|
||||
db_user = users_models.User(
|
||||
name=user.name,
|
||||
username=user.username.lower(),
|
||||
email=user.email.lower(),
|
||||
city=user.city,
|
||||
birthdate=user.birthdate,
|
||||
preferred_language=user.preferred_language,
|
||||
gender=user.gender,
|
||||
units=user.units,
|
||||
height=user.height,
|
||||
access_type=users_schema.UserAccessType.REGULAR,
|
||||
active=active,
|
||||
first_day_of_week=user.first_day_of_week,
|
||||
currency=user.currency,
|
||||
email_verified=email_verified,
|
||||
pending_admin_approval=pending_admin_approval,
|
||||
password=session_security.hash_password(user.password),
|
||||
)
|
||||
|
||||
# Add the user to the database
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
|
||||
# Return user
|
||||
return db_user
|
||||
except IntegrityError as integrity_error:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Raise an HTTPException with a 409 Conflict status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Duplicate entry error. Check if email and username are unique",
|
||||
) from integrity_error
|
||||
except Exception as err:
|
||||
# Rollback the transaction
|
||||
db.rollback()
|
||||
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in create_signup_user: {err}", "error", exc=err
|
||||
)
|
||||
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Date,
|
||||
Boolean
|
||||
)
|
||||
from sqlalchemy import Column, Integer, String, Date, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from core.database import Base
|
||||
|
||||
@@ -62,8 +56,11 @@ class User(Base):
|
||||
Integer, nullable=False, comment="User type (one digit)(1 - user, 2 - admin)"
|
||||
)
|
||||
photo_path = Column(String(length=250), nullable=True, comment="User photo path")
|
||||
is_active = Column(
|
||||
Integer, nullable=False, comment="Is user active (1 - active, 2 - not active)"
|
||||
active = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=True,
|
||||
comment="Whether the user is active (true - yes, false - no)",
|
||||
)
|
||||
first_day_of_week = Column(
|
||||
Integer,
|
||||
@@ -88,6 +85,18 @@ class User(Base):
|
||||
nullable=True,
|
||||
comment="User MFA secret for TOTP generation (encrypted at rest)",
|
||||
)
|
||||
email_verified = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Whether the user's email address has been verified (true - yes, false - no)",
|
||||
)
|
||||
pending_admin_approval = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Whether the user is pending admin approval for activation (true - yes, false - no)",
|
||||
)
|
||||
|
||||
# Define a relationship to UsersSessions model
|
||||
users_sessions = relationship(
|
||||
@@ -101,6 +110,12 @@ class User(Base):
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
# Define a relationship to SignUpToken model
|
||||
sign_up_tokens = relationship(
|
||||
"SignUpToken",
|
||||
back_populates="user",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
# Define a relationship to UsersIntegrations model
|
||||
users_integrations = relationship(
|
||||
"UsersIntegrations",
|
||||
|
||||
@@ -13,7 +13,7 @@ import core.database as core_database
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/id/{user_id}", response_model=users_schema.User)
|
||||
@router.get("/id/{user_id}", response_model=users_schema.UserRead)
|
||||
async def read_users_id(
|
||||
user_id: int,
|
||||
validate_id: Annotated[Callable, Depends(users_dependencies.validate_user_id)],
|
||||
|
||||
@@ -16,8 +16,10 @@ import users.user_privacy_settings.crud as users_privacy_settings_crud
|
||||
|
||||
import health_targets.crud as health_targets_crud
|
||||
|
||||
import sign_up_tokens.utils as sign_up_tokens_utils
|
||||
import session.security as session_security
|
||||
|
||||
import core.apprise as core_apprise
|
||||
import core.database as core_database
|
||||
import core.dependencies as core_dependencies
|
||||
|
||||
@@ -40,7 +42,7 @@ async def read_users_number(
|
||||
|
||||
@router.get(
|
||||
"/page_number/{page_number}/num_records/{num_records}",
|
||||
response_model=list[users_schema.User] | None,
|
||||
response_model=list[users_schema.UserRead] | None,
|
||||
)
|
||||
async def read_users_all_pagination(
|
||||
page_number: int,
|
||||
@@ -62,7 +64,7 @@ async def read_users_all_pagination(
|
||||
|
||||
@router.get(
|
||||
"/username/contains/{username}",
|
||||
response_model=list[users_schema.User] | None,
|
||||
response_model=list[users_schema.UserRead] | None,
|
||||
)
|
||||
async def read_users_contain_username(
|
||||
username: str,
|
||||
@@ -80,7 +82,7 @@ async def read_users_contain_username(
|
||||
|
||||
@router.get(
|
||||
"/username/{username}",
|
||||
response_model=users_schema.User | None,
|
||||
response_model=users_schema.UserRead | None,
|
||||
)
|
||||
async def read_users_username(
|
||||
username: str,
|
||||
@@ -98,7 +100,7 @@ async def read_users_username(
|
||||
|
||||
@router.get(
|
||||
"/email/{email}",
|
||||
response_model=users_schema.User | None,
|
||||
response_model=users_schema.UserRead | None,
|
||||
)
|
||||
async def read_users_email(
|
||||
email: str,
|
||||
@@ -114,7 +116,7 @@ async def read_users_email(
|
||||
return users_crud.get_user_by_email(email, db)
|
||||
|
||||
|
||||
@router.get("/id/{user_id}", response_model=users_schema.User)
|
||||
@router.get("/id/{user_id}", response_model=users_schema.UserRead)
|
||||
async def read_users_id(
|
||||
user_id: int,
|
||||
validate_id: Annotated[Callable, Depends(users_dependencies.validate_user_id)],
|
||||
@@ -130,7 +132,7 @@ async def read_users_id(
|
||||
return users_crud.get_user_by_id(user_id, db)
|
||||
|
||||
|
||||
@router.post("", response_model=users_schema.User, status_code=201)
|
||||
@router.post("", response_model=users_schema.UserRead, status_code=201)
|
||||
async def create_user(
|
||||
user: users_schema.UserCreate,
|
||||
check_scopes: Annotated[
|
||||
@@ -184,7 +186,7 @@ async def upload_user_image(
|
||||
async def edit_user(
|
||||
user_id: int,
|
||||
validate_id: Annotated[Callable, Depends(users_dependencies.validate_user_id)],
|
||||
user_attributtes: users_schema.User,
|
||||
user_attributtes: users_schema.UserRead,
|
||||
check_scopes: Annotated[
|
||||
Callable, Security(session_security.check_scopes, scopes=["users:write"])
|
||||
],
|
||||
@@ -200,6 +202,32 @@ async def edit_user(
|
||||
return {"detail": f"User ID {user_attributtes.id} updated successfully"}
|
||||
|
||||
|
||||
@router.put("/{user_id}/approve")
|
||||
async def approve_user(
|
||||
user_id: int,
|
||||
validate_id: Annotated[Callable, Depends(users_dependencies.validate_user_id)],
|
||||
check_scopes: Annotated[
|
||||
Callable, Security(session_security.check_scopes, scopes=["users:write"])
|
||||
],
|
||||
email_service: Annotated[
|
||||
core_apprise.AppriseService,
|
||||
Depends(core_apprise.get_email_service),
|
||||
],
|
||||
db: Annotated[
|
||||
Session,
|
||||
Depends(core_database.get_db),
|
||||
],
|
||||
):
|
||||
# Approve the user in the database
|
||||
users_crud.approve_user(user_id, db)
|
||||
|
||||
# Send approval email
|
||||
await sign_up_tokens_utils.send_sign_up_approval_email(user_id, email_service, db)
|
||||
|
||||
# Return success message
|
||||
return {"message": f"User ID {user_id} approved successfully."}
|
||||
|
||||
|
||||
@router.put("/{user_id}/password")
|
||||
async def edit_user_password(
|
||||
user_id: int,
|
||||
|
||||
@@ -1,10 +1,107 @@
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
from enum import Enum, IntEnum
|
||||
from pydantic import BaseModel, EmailStr, field_validator, StrictInt, ConfigDict
|
||||
import re
|
||||
import server_settings.schema as server_settings_schema
|
||||
|
||||
|
||||
class Gender(IntEnum):
|
||||
"""
|
||||
An enumeration representing the gender of a user.
|
||||
|
||||
Attributes:
|
||||
MALE (int): Represents male gender.
|
||||
FEMALE (int): Represents female gender.
|
||||
UNSPECIFIED (int): Represents unspecified or undisclosed gender.
|
||||
"""
|
||||
|
||||
MALE = 1
|
||||
FEMALE = 2
|
||||
UNSPECIFIED = 3
|
||||
|
||||
|
||||
class Language(Enum):
|
||||
"""
|
||||
An enumeration representing supported languages for the application.
|
||||
|
||||
Members:
|
||||
CATALAN: Catalan language code ("ca-ES").
|
||||
CHINESE_SIMPLIFIED: Simplified Chinese language code ("cn").
|
||||
CHINESE_TRADITIONAL: Traditional Chinese language code ("tw").
|
||||
DUTCH: Dutch language code ("nl").
|
||||
GERMAN: German language code ("de").
|
||||
FRENCH: French language code ("fr").
|
||||
SPANISH: Spanish language code ("es").
|
||||
PORTUGUESE: Portuguese language code ("pt").
|
||||
ENGLISH_USA: US English language code ("us").
|
||||
"""
|
||||
|
||||
CATALAN = "ca"
|
||||
CHINESE_SIMPLIFIED = "cn"
|
||||
CHINESE_TRADITIONAL = "tw"
|
||||
DUTCH = "nl"
|
||||
GERMAN = "de"
|
||||
FRENCH = "fr"
|
||||
SPANISH = "es"
|
||||
PORTUGUESE = "pt"
|
||||
ENGLISH_USA = "us"
|
||||
|
||||
|
||||
class WeekDay(IntEnum):
|
||||
"""
|
||||
An enumeration representing the days of the week.
|
||||
|
||||
Attributes:
|
||||
SUNDAY (int): Represents Sunday (0).
|
||||
MONDAY (int): Represents Monday (1).
|
||||
TUESDAY (int): Represents Tuesday (2).
|
||||
WEDNESDAY (int): Represents Wednesday (3).
|
||||
THURSDAY (int): Represents Thursday (4).
|
||||
FRIDAY (int): Represents Friday (5).
|
||||
SATURDAY (int): Represents Saturday (6).
|
||||
"""
|
||||
|
||||
SUNDAY = 0
|
||||
MONDAY = 1
|
||||
TUESDAY = 2
|
||||
WEDNESDAY = 3
|
||||
THURSDAY = 4
|
||||
FRIDAY = 5
|
||||
SATURDAY = 6
|
||||
|
||||
|
||||
class UserAccessType(IntEnum):
|
||||
"""
|
||||
Enumeration representing different types of user access levels.
|
||||
|
||||
Attributes:
|
||||
REGULAR (int): Standard user with regular access permissions.
|
||||
ADMIN (int): User with administrative access permissions.
|
||||
"""
|
||||
|
||||
REGULAR = 1
|
||||
ADMIN = 2
|
||||
|
||||
|
||||
PASSWORD_REGEX = r"^(?=.*[A-Z])(?=.*\d)(?=.*[ !\"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])[A-Za-z\d !\"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]{8,}$"
|
||||
|
||||
|
||||
def validate_password(value: str) -> str:
|
||||
"""
|
||||
Validates that the provided password meets the required complexity.
|
||||
|
||||
Args:
|
||||
value (str): The password string to validate.
|
||||
|
||||
Raises:
|
||||
ValueError: If the password does not meet the following criteria:
|
||||
- At least 8 characters long
|
||||
- Includes an uppercase letter
|
||||
- Includes a number
|
||||
- Includes a special character
|
||||
|
||||
Returns:
|
||||
str: The validated password string.
|
||||
"""
|
||||
if not re.match(PASSWORD_REGEX, value):
|
||||
raise ValueError(
|
||||
"Password must be at least 8 characters long, include an uppercase letter, a number, and a special character."
|
||||
@@ -12,37 +109,40 @@ def validate_password(value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: int | None = None
|
||||
class UserBase(BaseModel):
|
||||
name: str
|
||||
username: str
|
||||
email: EmailStr
|
||||
city: str | None = None
|
||||
birthdate: str | None = None
|
||||
preferred_language: str
|
||||
gender: int
|
||||
units: int
|
||||
preferred_language: Language = Language.ENGLISH_USA
|
||||
gender: Gender = Gender.MALE
|
||||
units: server_settings_schema.Units = server_settings_schema.Units.METRIC
|
||||
height: int | None = None
|
||||
access_type: int
|
||||
first_day_of_week: WeekDay = WeekDay.MONDAY
|
||||
currency: server_settings_schema.Currency = server_settings_schema.Currency.EURO
|
||||
|
||||
model_config = ConfigDict(use_enum_values=True)
|
||||
|
||||
|
||||
class User(UserBase):
|
||||
access_type: UserAccessType
|
||||
photo_path: str | None = None
|
||||
is_active: int
|
||||
first_day_of_week: int = 1
|
||||
currency: int
|
||||
active: bool
|
||||
mfa_enabled: bool = False
|
||||
mfa_secret: str | None = None
|
||||
email_verified: bool = False
|
||||
pending_admin_approval: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True, extra="forbid", validate_assignment=True
|
||||
)
|
||||
|
||||
class UserRead(User):
|
||||
id: StrictInt
|
||||
|
||||
|
||||
class UserCreate(User):
|
||||
password: str
|
||||
|
||||
@field_validator("password")
|
||||
def validate_password_field(cls, value):
|
||||
return validate_password(value)
|
||||
|
||||
|
||||
class UserMe(User):
|
||||
class UserMe(UserRead):
|
||||
is_strava_linked: int | None = None
|
||||
is_garminconnect_linked: int | None = None
|
||||
default_activity_visibility: int | None = None
|
||||
@@ -60,6 +160,22 @@ class UserMe(User):
|
||||
hide_activity_gear: bool | None = None
|
||||
|
||||
|
||||
class UserSignup(UserBase):
|
||||
password: str
|
||||
|
||||
@field_validator("password")
|
||||
def validate_password_field(cls, value):
|
||||
return validate_password(value)
|
||||
|
||||
|
||||
class UserCreate(User):
|
||||
password: str
|
||||
|
||||
@field_validator("password")
|
||||
def validate_password_field(cls, value):
|
||||
return validate_password(value)
|
||||
|
||||
|
||||
class UserEditPassword(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
import shutil
|
||||
|
||||
import session.constants as session_constants
|
||||
|
||||
import users.user.crud as users_crud
|
||||
import users.user.schema as users_schema
|
||||
|
||||
@@ -15,16 +13,50 @@ import core.logger as core_logger
|
||||
import core.config as core_config
|
||||
|
||||
|
||||
def check_user_is_active(user: users_schema.User) -> None:
|
||||
if user.is_active == session_constants.USER_NOT_ACTIVE:
|
||||
def check_user_is_active(user: users_schema.UserRead) -> None:
|
||||
"""
|
||||
Checks if the given user is active.
|
||||
|
||||
Raises:
|
||||
HTTPException: If the user is not active, raises an HTTP 403 Forbidden exception
|
||||
with a detail message "Inactive user" and a "WWW-Authenticate" header.
|
||||
|
||||
Args:
|
||||
user (users_schema.UserRead): The user object to check.
|
||||
"""
|
||||
if not user.active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def get_admin_users(db: Session):
|
||||
admins = users_crud.get_users_admin(db)
|
||||
|
||||
if not admins:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No admin users found",
|
||||
)
|
||||
|
||||
return admins
|
||||
|
||||
|
||||
def delete_user_photo_filesystem(user_id: int):
|
||||
"""
|
||||
Deletes all photo files associated with a user from the filesystem.
|
||||
|
||||
This function searches for files in the directory specified by `core_config.USER_IMAGES_DIR`
|
||||
that match the given `user_id` with any file extension, and removes them.
|
||||
|
||||
Args:
|
||||
user_id (int): The ID of the user whose photo files should be deleted.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
# Define the pattern to match files with the specified name regardless of the extension
|
||||
folder = core_config.USER_IMAGES_DIR
|
||||
file = f"{user_id}.*"
|
||||
@@ -39,11 +71,39 @@ def delete_user_photo_filesystem(user_id: int):
|
||||
|
||||
|
||||
def format_user_birthdate(user):
|
||||
user.birthdate = user.birthdate if isinstance(user.birthdate, str) else user.birthdate.isoformat() if user.birthdate else None
|
||||
"""
|
||||
Formats the birthdate attribute of a user object to an ISO 8601 string if it is a date/datetime object.
|
||||
If the birthdate is already a string or None, it remains unchanged.
|
||||
|
||||
Args:
|
||||
user: An object with a 'birthdate' attribute, which can be a string, date/datetime object, or None.
|
||||
|
||||
Returns:
|
||||
The user object with the 'birthdate' attribute formatted as an ISO 8601 string, string, or None.
|
||||
"""
|
||||
user.birthdate = (
|
||||
user.birthdate
|
||||
if isinstance(user.birthdate, str)
|
||||
else user.birthdate.isoformat() if user.birthdate else None
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def save_user_image(user_id: int, file: UploadFile, db: Session):
|
||||
"""
|
||||
Saves a user's image to the server and updates the user's photo path in the database.
|
||||
|
||||
Args:
|
||||
user_id (int): The ID of the user whose image is being saved.
|
||||
file (UploadFile): The uploaded image file.
|
||||
db (Session): The database session.
|
||||
|
||||
Returns:
|
||||
Any: The result of updating the user's photo path in the database.
|
||||
|
||||
Raises:
|
||||
HTTPException: If an error occurs during the image saving process, raises a 500 Internal Server Error.
|
||||
"""
|
||||
try:
|
||||
upload_dir = core_config.USER_IMAGES_DIR
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
@@ -28,7 +28,7 @@ def get_user_default_gear_by_activity_type(
|
||||
return user_default_gear.virtual_ride_gear_id
|
||||
elif activity_type == 9:
|
||||
return user_default_gear.ows_gear_id
|
||||
elif activity_type == 11 or activity_type == 31:
|
||||
elif activity_type in (11, 31):
|
||||
return user_default_gear.walk_gear_id
|
||||
elif activity_type == 12:
|
||||
return user_default_gear.hike_gear_id
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
import users.user_goals.schema as user_goals_schema
|
||||
import users.user_goals.models as user_goals_models
|
||||
import users.user_goals.utils as user_goals_utils
|
||||
|
||||
import core.logger as core_logger
|
||||
|
||||
@@ -46,50 +44,6 @@ def get_user_goals_by_user_id(
|
||||
) from err
|
||||
|
||||
|
||||
def calculate_user_goals(
|
||||
user_id: int, date: str | None, db: Session
|
||||
) -> List[user_goals_schema.UserGoalProgress] | None:
|
||||
"""
|
||||
Calculates the progress of all goals for a given user on a specified date.
|
||||
|
||||
Args:
|
||||
user_id (int): The ID of the user whose goals are to be calculated.
|
||||
date (str | None): The date for which to calculate goal progress, in "YYYY-MM-DD" format. If None, uses the current date.
|
||||
db (Session): The SQLAlchemy database session.
|
||||
|
||||
Returns:
|
||||
List[user_goals_schema.UserGoalProgress] | None:
|
||||
A list of UserGoalProgress objects representing the progress of each goal, or None if no goals are found.
|
||||
|
||||
Raises:
|
||||
HTTPException: If an error occurs during calculation or database access.
|
||||
"""
|
||||
if not date:
|
||||
date = datetime.now().strftime("%Y-%m-%d")
|
||||
try:
|
||||
goals = get_user_goals_by_user_id(user_id, db)
|
||||
|
||||
if not goals:
|
||||
return None
|
||||
|
||||
return [
|
||||
user_goals_utils.calculate_goal_progress_by_activity_type(goal, date, db)
|
||||
for goal in goals
|
||||
]
|
||||
except HTTPException as http_err:
|
||||
raise http_err
|
||||
except Exception as err:
|
||||
# Log the exception
|
||||
core_logger.print_to_log(
|
||||
f"Error in calculate_user_goals: {err}", "error", exc=err
|
||||
)
|
||||
# Raise an HTTPException with a 500 Internal Server Error status code
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal Server Error",
|
||||
) from err
|
||||
|
||||
|
||||
def create_user_goal(user_id: int, user_goal: user_goals_schema.UserGoalCreate, db: Session):
|
||||
"""
|
||||
Creates a new user goal for a specific user, activity type, and interval.
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
||||
import users.user_goals.dependencies as user_goals_dependencies
|
||||
import users.user_goals.schema as user_goals_schema
|
||||
import users.user_goals.crud as user_goals_crud
|
||||
import users.user_goals.utils as user_goals_utils
|
||||
|
||||
import session.security as session_security
|
||||
|
||||
@@ -53,7 +54,7 @@ async def get_user_goals_results(
|
||||
Returns:
|
||||
Any: The calculated user goals results for the specified user.
|
||||
"""
|
||||
return user_goals_crud.calculate_user_goals(token_user_id, None, db)
|
||||
return user_goals_utils.calculate_user_goals(token_user_id, None, db)
|
||||
|
||||
|
||||
@router.post("", response_model=user_goals_schema.UserGoalRead, status_code=201)
|
||||
|
||||
@@ -1,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
1393
backend/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "endurain"
|
||||
version = "0.14.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"
|
||||
|
||||
115
crowdin.yml
115
crowdin.yml
@@ -1,37 +1,78 @@
|
||||
files:
|
||||
- source: /frontend/app/src/i18n/us/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/%file_name%.%file_extension%
|
||||
- source: /frontend/app/src/i18n/us/components/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/%file_name%.%file_extension%
|
||||
- source: /frontend/app/src/i18n/us/gears/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/gears/%file_name%.%file_extension%
|
||||
- source: /frontend/app/src/i18n/us/strava/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/strava/%file_name%.%file_extension%
|
||||
- source: /frontend/app/src/i18n/us/components/activities/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/activities/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/activities/modals/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/activities/modals/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/followers/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/followers/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/gears/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/gears/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/health/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/health/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/health/healthWeightZone/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/health/healthWeightZone/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/navbar/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/navbar/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/notifications/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/notifications/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/settings/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/settings/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/users/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/users/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/settings/settingsGeneralZone/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/settings/settingsGeneralZone/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/settings/settingsIntegrationsZone/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/settings/settingsIntegrationsZone/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/settings/settingsUsersZone/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/settings/settingsUsersZone/%original_file_name%
|
||||
- source: /frontend/app/src/i18n/us/components/settings/settingsUserSessionsZone/*.json
|
||||
translation: /frontend/app/src/i18n/%two_letters_code%/components/settings/settingsUserSessionsZone/%original_file_name%
|
||||
"project_id": "654570"
|
||||
|
||||
"preserve_hierarchy": true
|
||||
|
||||
"files": [
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/gears/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/gears/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/strava/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/strava/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/activities/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/activities/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/activities/modals/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/activities/modals/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/followers/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/followers/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/gears/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/gears/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/health/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/health/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/health/healthWeightZone/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/health/healthWeightZone/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/navbar/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/navbar/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/notifications/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/notifications/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/settings/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/settings/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/users/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/users/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/settings/settingsGeneralZone/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/settings/settingsGeneralZone/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/settings/settingsIntegrationsZone/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/settings/settingsIntegrationsZone/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/settings/settingsUsersZone/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/settings/settingsUsersZone/%original_file_name%"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/app/src/i18n/us/components/settings/settingsUserSessionsZone/*.json",
|
||||
"translation": "/frontend/app/src/i18n/%two_letters_code%/components/settings/settingsUserSessionsZone/%original_file_name%"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -27,5 +27,5 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
volumes:
|
||||
- /opt/endurain/postgres:/var/lib/postgresql/data
|
||||
- <local_path>/endurain/postgres:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -150,6 +150,91 @@ The API is reachable under `/api/v1`. Below are some example endpoints. All endp
|
||||
| **Activity Upload** | `/activities/create/upload` | .gpx, .tcx, .gz or .fit file |
|
||||
| **Set Weight** | `/health/weight` | JSON {'weight': <number>, 'created_at': `yyyy-MM-dd`} |
|
||||
|
||||
### MFA Authentication Flow
|
||||
|
||||
When Multi-Factor Authentication (MFA) is enabled for a user, the authentication process requires two steps:
|
||||
|
||||
#### Step 1: Initial Login Request
|
||||
Make a standard login request to `/token`:
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
POST /api/v1/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
X-Client-Type: web|mobile
|
||||
|
||||
username=user@example.com&password=userpassword
|
||||
```
|
||||
|
||||
**Response (when MFA is enabled):**
|
||||
- **Web clients**: HTTP 202 Accepted
|
||||
```json
|
||||
{
|
||||
"mfa_required": true,
|
||||
"username": "example",
|
||||
"message": "MFA verification required"
|
||||
}
|
||||
```
|
||||
- **Mobile clients**: HTTP 200 OK
|
||||
```json
|
||||
{
|
||||
"mfa_required": true,
|
||||
"username": "example",
|
||||
"message": "MFA verification required"
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: MFA Verification
|
||||
Complete the login by providing the MFA code to `/mfa/verify`:
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
POST /api/v1/mfa/verify
|
||||
Content-Type: application/json
|
||||
X-Client-Type: web|mobile
|
||||
|
||||
{
|
||||
"username": "user@example.com",
|
||||
"mfa_code": "123456"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (successful verification):**
|
||||
- **Web clients**: Tokens are set as HTTP-only cookies
|
||||
```json
|
||||
{
|
||||
"session_id": "unique_session_id"
|
||||
}
|
||||
```
|
||||
- **Mobile clients**: Tokens are returned in response body
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ...",
|
||||
"session_id": "unique_session_id"
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Handling
|
||||
- **No pending MFA login**: HTTP 400 Bad Request
|
||||
```json
|
||||
{
|
||||
"detail": "No pending MFA login found for this username"
|
||||
}
|
||||
```
|
||||
- **Invalid MFA code**: HTTP 401 Unauthorized
|
||||
```json
|
||||
{
|
||||
"detail": "Invalid MFA code"
|
||||
}
|
||||
```
|
||||
|
||||
#### Important Notes
|
||||
- The pending MFA login session is temporary and will expire if not completed within a reasonable time
|
||||
- After successful MFA verification, the pending login is automatically cleaned up
|
||||
- The user must still be active at the time of MFA verification
|
||||
- If no MFA is enabled for the user, the standard single-step authentication flow applies
|
||||
|
||||
|
||||
## Supported activity types
|
||||
The table bellow details the activity types supported by Endurain.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ module.exports = {
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
'@vue/eslint-config-prettier/skip-formatting'
|
||||
],
|
||||
parserOptions: {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
2317
frontend/app/package-lock.json
generated
2317
frontend/app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "endurain",
|
||||
"version": "0.14.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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
class="form-select"
|
||||
name="gearComponentIsActiveAddEdit"
|
||||
:disabled="newEditGearComponentRetiredDate"
|
||||
v-model="newEditGearComponentIsActive"
|
||||
v-model="newEditGearComponentActive"
|
||||
required
|
||||
>
|
||||
<option :value="true">{{ $t('generalItems.yes') }}</option>
|
||||
@@ -359,7 +359,7 @@ const newEditGearComponentExpectedDistanceMiles = ref(null)
|
||||
const newEditGearComponentExpectedTime = ref(null)
|
||||
const newEditGearComponentPurchaseValue = ref(null)
|
||||
const newEditGearComponentRetiredDate = ref(null)
|
||||
const newEditGearComponentIsActive = ref(true)
|
||||
const newEditGearComponentActive = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
newEditGearComponentUserId.value = props.gear.user_id
|
||||
@@ -384,7 +384,7 @@ onMounted(() => {
|
||||
}
|
||||
newEditGearComponentPurchaseValue.value = props.gearComponent.purchase_value
|
||||
newEditGearComponentRetiredDate.value = props.gearComponent.retired_date
|
||||
newEditGearComponentIsActive.value = props.gearComponent.is_active
|
||||
newEditGearComponentActive.value = props.gearComponent.active
|
||||
} else {
|
||||
if (props.gear.gear_type === 1) {
|
||||
newEditGearComponentType.value = 'back_break_oil'
|
||||
@@ -400,10 +400,10 @@ onMounted(() => {
|
||||
|
||||
function updateIsActiveBasedOnRetiredDate() {
|
||||
if (newEditGearComponentRetiredDate.value && newEditGearComponentRetiredDate.value !== '') {
|
||||
newEditGearComponentIsActive.value = false
|
||||
newEditGearComponentActive.value = false
|
||||
} else {
|
||||
newEditGearComponentRetiredDate.value = null
|
||||
newEditGearComponentIsActive.value = true
|
||||
newEditGearComponentActive.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +479,7 @@ async function submitEditGearComponentForm() {
|
||||
retired_date: newEditGearComponentRetiredDate.value
|
||||
? newEditGearComponentRetiredDate.value
|
||||
: null,
|
||||
is_active: newEditGearComponentIsActive.value,
|
||||
active: newEditGearComponentActive.value,
|
||||
expected_kms: expected_kms,
|
||||
purchase_value: newEditGearComponentPurchaseValue.value
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
/></a>
|
||||
</span>
|
||||
|
||||
<div v-if="gearComponent.is_active == 0" class="d-flex justify-content-end">
|
||||
<div v-if="gearComponent.active == false" class="d-flex justify-content-end">
|
||||
<span class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis">
|
||||
{{ $t('gearComponentListComponent.gearComponentListGearComponentIsInactiveBadge') }}
|
||||
</span>
|
||||
|
||||
@@ -119,22 +119,22 @@
|
||||
v-model="newEditGearCreatedDate"
|
||||
required
|
||||
/>
|
||||
<!-- gear is_active fields -->
|
||||
<label for="gearIsActiveAddEdit"
|
||||
<!-- gear active fields -->
|
||||
<label for="gearActiveAddEdit"
|
||||
><b
|
||||
>* {{ $t('gearsAddEditGearModalComponent.addEditGearModalAddIsActiveLabel') }}</b
|
||||
></label
|
||||
>
|
||||
<select
|
||||
class="form-select"
|
||||
name="gearIsActiveAddEdit"
|
||||
v-model="newEditGearIsActive"
|
||||
name="gearActiveAddEdit"
|
||||
v-model="newEditGearActive"
|
||||
required
|
||||
>
|
||||
<option value="1">
|
||||
<option value="true">
|
||||
{{ $t('gearsAddEditGearModalComponent.addEditGearModalAddIsActiveOption1') }}
|
||||
</option>
|
||||
<option value="0">
|
||||
<option value="false">
|
||||
{{ $t('gearsAddEditGearModalComponent.addEditGearModalAddIsActiveOption0') }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -268,7 +268,7 @@ const newEditGearModel = ref('')
|
||||
const newEditGearNickname = ref('')
|
||||
const newEditGearType = ref(1)
|
||||
const newEditGearCreatedDate = ref(new Date().toISOString().split('T')[0])
|
||||
const newEditGearIsActive = ref(1)
|
||||
const newEditGearActive = ref(true)
|
||||
const newEditGearInitialKms = ref(0)
|
||||
const newEditGearInitialMiles = ref(0)
|
||||
const newEditGearPurchaseValue = ref(0)
|
||||
@@ -314,7 +314,7 @@ onMounted(() => {
|
||||
newEditGearNickname.value = props.gear.nickname
|
||||
newEditGearType.value = props.gear.gear_type
|
||||
newEditGearCreatedDate.value = props.gear.created_at
|
||||
newEditGearIsActive.value = props.gear.is_active
|
||||
newEditGearActive.value = props.gear.active
|
||||
newEditGearInitialKms.value = props.gear.initial_kms
|
||||
if (props.gear.initial_kms && props.gear.initial_kms !== 0) {
|
||||
newEditGearInitialMiles.value = kmToMiles(props.gear.initial_kms)
|
||||
@@ -332,7 +332,7 @@ async function submitAddGearForm() {
|
||||
nickname: newEditGearNickname.value,
|
||||
gear_type: newEditGearType.value,
|
||||
created_at: newEditGearCreatedDate.value,
|
||||
is_active: newEditGearIsActive.value,
|
||||
active: newEditGearActive.value,
|
||||
initial_kms: newEditGearInitialKms.value,
|
||||
purchase_value: newEditGearPurchaseValue.value
|
||||
}
|
||||
@@ -344,7 +344,7 @@ async function submitAddGearForm() {
|
||||
newEditGearNickname.value = ''
|
||||
newEditGearType.value = 1
|
||||
newEditGearCreatedDate.value = new Date().toISOString().split('T')[0]
|
||||
newEditGearIsActive.value = 1
|
||||
newEditGearActive.value = true
|
||||
newEditGearInitialKms.value = 0
|
||||
newEditGearInitialMiles.value = 0
|
||||
newEditGearPurchaseValue.value = 0
|
||||
@@ -372,7 +372,7 @@ async function submitEditGearForm() {
|
||||
nickname: newEditGearNickname.value,
|
||||
gear_type: newEditGearType.value,
|
||||
created_at: newEditGearCreatedDate.value,
|
||||
is_active: newEditGearIsActive.value,
|
||||
active: newEditGearActive.value,
|
||||
initial_kms: newEditGearInitialKms.value,
|
||||
purchase_value: newEditGearPurchaseValue.value
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
</span>
|
||||
<span
|
||||
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis align-middle d-none d-sm-inline"
|
||||
v-if="gear.is_active == 0"
|
||||
v-if="gear.active == false"
|
||||
>{{ $t('gearsListComponent.gearListGearIsInactiveBadge') }}</span
|
||||
>
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
// import the stores
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
|
||||
// Import Notivue push
|
||||
import { push } from 'notivue'
|
||||
|
||||
@@ -153,12 +154,14 @@ import NavbarNotificationsComponent from '@/components/Navbar/NavbarNotification
|
||||
// Composables
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const serverSettingsStore = useServerSettingsStore()
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
// Methods
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await authStore.logoutUser(router, locale)
|
||||
serverSettingsStore.setServerSettingsOnLogout()
|
||||
} catch (error) {
|
||||
push.error(`${t('navbarComponent.errorLogout')} - ${error}`)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ import { useI18n } from 'vue-i18n'
|
||||
const { locale, t } = useI18n()
|
||||
const languages = computed(() => [
|
||||
{ value: 'ca', label: t('generalItems.languageOption2') },
|
||||
{ value: 'cn', label: t('generalItems.languageOption8') },
|
||||
{ value: 'tw', label: t('generalItems.languageOption9') },
|
||||
{ value: 'de', label: t('generalItems.languageOption4') },
|
||||
{ value: 'fr', label: t('generalItems.languageOption5') },
|
||||
{ value: 'nl', label: t('generalItems.languageOption6') },
|
||||
|
||||
@@ -56,6 +56,12 @@
|
||||
v-else-if="notification.type === 12"
|
||||
@notificationRead="markNotificationAsRead"
|
||||
/>
|
||||
<AdminNewSignUpApprovalRequestNotificationComponent
|
||||
:notification="notification"
|
||||
:showDropdown="showDropdown"
|
||||
v-else-if="notification.type === 101"
|
||||
@notificationRead="markNotificationAsRead"
|
||||
/>
|
||||
</li>
|
||||
<li v-if="totalPages > 1 && totalPages > pageNumber">
|
||||
<a class="dropdown-item" @click="setPageNumber">Load more...</a>
|
||||
@@ -75,6 +81,7 @@ import { notifications } from '@/services/notificationsService'
|
||||
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
import AdminNewSignUpApprovalRequestNotificationComponent from '@/components/Notifications/AdminNewSignUpApprovalRequestNotificationComponent.vue'
|
||||
import NewAcceptedRequestNotificationComponent from '@/components/Notifications/NewAcceptedRequestNotificationComponent.vue'
|
||||
import NewActivityNotificationComponent from '@/components/Notifications/NewActivityNotificationComponent.vue'
|
||||
import NewActivityDuplicateStartTimeNotificationComponent from '@/components/Notifications/NewActivityDuplicateStartTimeNotificationComponent.vue'
|
||||
@@ -177,7 +184,8 @@ onMounted(async () => {
|
||||
(data.message === 'NEW_ACTIVITY_NOTIFICATION' ||
|
||||
data.message === 'NEW_DUPLICATE_ACTIVITY_START_TIME_NOTIFICATION' ||
|
||||
data.message === 'NEW_FOLLOWER_REQUEST_NOTIFICATION' ||
|
||||
data.message === 'NEW_FOLLOWER_REQUEST_ACCEPTED_NOTIFICATION')
|
||||
data.message === 'NEW_FOLLOWER_REQUEST_ACCEPTED_NOTIFICATION' ||
|
||||
data.message === 'ADMIN_NEW_SIGN_UP_APPROVAL_REQUEST_NOTIFICATION')
|
||||
) {
|
||||
await fetchNotificationById(data.notification_id)
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
@click="changeTheme(theme.value)"
|
||||
:aria-pressed="themeStore.theme === theme.value ? 'true' : 'false'"
|
||||
>
|
||||
<span v-if="theme.label == 'Dark'" class="me-1"
|
||||
<span v-if="theme.value == 'dark'" class="me-1"
|
||||
><font-awesome-icon :icon="['fas', 'moon']"
|
||||
/></span>
|
||||
<span v-else-if="theme.label == 'Light'"
|
||||
<span v-else-if="theme.value == 'light'"
|
||||
><font-awesome-icon :icon="['fas', 'sun']"
|
||||
/></span>
|
||||
<span v-else><font-awesome-icon :icon="['fas', 'circle-half-stroke']" /></span>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<router-link
|
||||
class="dropdown-item link-body-emphasis text-wrap"
|
||||
:to="{
|
||||
name: 'settings',
|
||||
query: { tab: 'users', username: notification.options['user_username'] }
|
||||
}"
|
||||
>
|
||||
<span
|
||||
><b>{{ $t('adminNewSignUpApprovalRequestNotificationComponent.title') }}</b></span
|
||||
>
|
||||
<br />
|
||||
<span class="fw-lighter">
|
||||
{{ notification.options['user_name'] }} - @{{ notification.options['user_username']
|
||||
}}{{ $t('adminNewSignUpApprovalRequestNotificationComponent.subTitle') }}
|
||||
</span>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
// Importing the i18n
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { notifications } from '@/services/notificationsService'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits(['notificationRead'])
|
||||
const props = defineProps({
|
||||
notification: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showDropdown: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const dropdownState = computed(() => {
|
||||
return props.showDropdown
|
||||
})
|
||||
|
||||
function markNotificationAsRead() {
|
||||
if (props.notification.read === false && props.showDropdown === true) {
|
||||
notifications.markNotificationAsRead(props.notification.id)
|
||||
emit('notificationRead', props.notification.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch the page number variable.
|
||||
watch(dropdownState, markNotificationAsRead, { immediate: false })
|
||||
</script>
|
||||
@@ -26,6 +26,8 @@ export default {
|
||||
const { locale, t } = useI18n()
|
||||
const languages = computed(() => [
|
||||
{ value: 'ca', label: t('generalItems.languageOption2') },
|
||||
{ value: 'cn', label: t('generalItems.languageOption8') },
|
||||
{ value: 'tw', label: t('generalItems.languageOption9') },
|
||||
{ value: 'de', label: t('generalItems.languageOption4') },
|
||||
{ value: 'fr', label: t('generalItems.languageOption5') },
|
||||
{ value: 'nl', label: t('generalItems.languageOption6') },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -149,6 +149,12 @@
|
||||
<span v-if="authStore.user.preferred_language == 'ca'">{{
|
||||
$t('generalItems.languageOption2')
|
||||
}}</span>
|
||||
<span v-if="authStore.user.preferred_language == 'cn'">{{
|
||||
$t('generalItems.languageOption8')
|
||||
}}</span>
|
||||
<span v-if="authStore.user.preferred_language == 'tw'">{{
|
||||
$t('generalItems.languageOption9')
|
||||
}}</span>
|
||||
<span v-if="authStore.user.preferred_language == 'de'">{{
|
||||
$t('generalItems.languageOption4')
|
||||
}}</span>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<LoadingComponent v-if="isLoading" />
|
||||
<LoadingComponent class="mt-3" v-if="isLoading" />
|
||||
<div v-else>
|
||||
<!-- Checking if usersArray is loaded and has length -->
|
||||
<div class="mt-3" v-if="usersArray && usersArray.length">
|
||||
@@ -68,6 +68,7 @@
|
||||
:user="user"
|
||||
@userDeleted="updateUserList"
|
||||
@editedUser="editUserList"
|
||||
@approvedUser="approvedUserList"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
@@ -92,6 +93,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { push } from 'notivue'
|
||||
import { debounce } from 'lodash'
|
||||
@@ -104,6 +106,7 @@ import { users } from '@/services/usersService'
|
||||
import { useServerSettingsStore } from '@/stores/serverSettingsStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const serverSettingsStore = useServerSettingsStore()
|
||||
const isLoading = ref(false)
|
||||
const isUsersUpdatingLoading = ref(false)
|
||||
@@ -122,9 +125,13 @@ const performSearch = debounce(async () => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
isLoading.value = true
|
||||
usersArray.value = await users.getUserContainsUsername(searchUsername.value)
|
||||
usersNumber.value = usersArray.value.length
|
||||
} catch (error) {
|
||||
push.error(`${t('settingsUsersZone.errorFetchingUsers')} - ${error}`)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}, 500)
|
||||
|
||||
@@ -168,14 +175,25 @@ function editUserList(editedUser) {
|
||||
usersArray.value[index] = editedUser
|
||||
}
|
||||
|
||||
function approvedUserList(userID) {
|
||||
const index = usersArray.value.findIndex((user) => user.id === userID)
|
||||
usersArray.value[index].pending_admin_approval = false
|
||||
usersArray.value[index].email_verified = true
|
||||
usersArray.value[index].active = true
|
||||
}
|
||||
|
||||
function setIsLoadingNewUser(state) {
|
||||
isLoadingNewUser.value = state
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
await fetchUsers()
|
||||
isLoading.value = false
|
||||
if (route.query.username) {
|
||||
searchUsername.value = route.query.username
|
||||
} else {
|
||||
await fetchUsers()
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(searchUsername, performSearch, { immediate: false })
|
||||
|
||||
@@ -204,13 +204,13 @@
|
||||
required
|
||||
>
|
||||
<option :value="1">
|
||||
{{ $t('usersAddEditUserModalComponent.addEditUserModalGenderOption1') }}
|
||||
{{ $t('generalItems.genderMale') }}
|
||||
</option>
|
||||
<option :value="2">
|
||||
{{ $t('usersAddEditUserModalComponent.addEditUserModalGenderOption2') }}
|
||||
{{ $t('generalItems.genderFemale') }}
|
||||
</option>
|
||||
<option :value="3">
|
||||
{{ $t('usersAddEditUserModalComponent.addEditUserModalGenderOption3') }}
|
||||
{{ $t('generalItems.genderUnspecified') }}
|
||||
</option>
|
||||
</select>
|
||||
<!-- units fields -->
|
||||
@@ -250,18 +250,18 @@
|
||||
}})</b
|
||||
></label
|
||||
>
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
name="userHeightAddEditCms"
|
||||
:placeholder="
|
||||
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder') +
|
||||
' (' +
|
||||
$t('generalItems.unitsCm') +
|
||||
')'
|
||||
"
|
||||
v-model="newEditUserHeightCms"
|
||||
/>
|
||||
<div class="input-group">
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
name="userHeightAddEditCms"
|
||||
:placeholder="
|
||||
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder')
|
||||
"
|
||||
v-model="newEditUserHeightCms"
|
||||
/>
|
||||
<span class="input-group-text">{{ $t('generalItems.unitsCm') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<label for="userHeightAddEditFeetInches"
|
||||
@@ -279,17 +279,14 @@
|
||||
aria-describedby="validationFeetFeedback"
|
||||
name="userHeightAddEditFeet"
|
||||
:placeholder="
|
||||
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder') +
|
||||
' (' +
|
||||
$t('generalItems.unitsFeet') +
|
||||
')'
|
||||
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder')
|
||||
"
|
||||
v-model="newEditUserHeightFeet"
|
||||
min="0"
|
||||
max="10"
|
||||
step="1"
|
||||
/>
|
||||
<span class="input-group-text">’</span>
|
||||
<span class="input-group-text">{{ $t('generalItems.unitsFeet') }}</span>
|
||||
<input
|
||||
class="form-control"
|
||||
:class="{ 'is-invalid': !isInchesValid }"
|
||||
@@ -297,17 +294,14 @@
|
||||
aria-describedby="validationInchesFeedback"
|
||||
name="userHeightAddEditInches"
|
||||
:placeholder="
|
||||
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder') +
|
||||
' (' +
|
||||
$t('generalItems.unitsInches') +
|
||||
')'
|
||||
$t('usersAddEditUserModalComponent.addEditUserModalHeightPlaceholder')
|
||||
"
|
||||
v-model="newEditUserHeightInches"
|
||||
min="0"
|
||||
max="11"
|
||||
step="1"
|
||||
/>
|
||||
<span class="input-group-text">’’</span>
|
||||
<span class="input-group-text">{{ $t('generalItems.unitsInches') }}</span>
|
||||
<div id="validationFeetFeedback" class="invalid-feedback" v-if="!isFeetValid">
|
||||
{{ $t('usersAddEditUserModalComponent.addEditUserModalFeetValidationLabel') }}
|
||||
</div>
|
||||
@@ -332,6 +326,8 @@
|
||||
required
|
||||
>
|
||||
<option value="ca">{{ $t('generalItems.languageOption2') }}</option>
|
||||
<option value="cn">{{ $t('generalItems.languageOption8') }}</option>
|
||||
<option value="tw">{{ $t('generalItems.languageOption9') }}</option>
|
||||
<option value="de">{{ $t('generalItems.languageOption4') }}</option>
|
||||
<option value="fr">{{ $t('generalItems.languageOption5') }}</option>
|
||||
<option value="nl">{{ $t('generalItems.languageOption6') }}</option>
|
||||
@@ -383,24 +379,24 @@
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- user is_active fields -->
|
||||
<!-- user active fields -->
|
||||
<div v-if="action != 'profile'">
|
||||
<label for="userIsActiveAddEdit"
|
||||
<label for="userActiveAddEdit"
|
||||
><b
|
||||
>* {{ $t('usersAddEditUserModalComponent.addEditUserModalIsActiveLabel') }}</b
|
||||
></label
|
||||
>
|
||||
<select
|
||||
class="form-select"
|
||||
name="userIsActiveAddEdit"
|
||||
v-model="newEditUserIsActive"
|
||||
name="userActiveAddEdit"
|
||||
v-model="newEditUserActive"
|
||||
required
|
||||
>
|
||||
<option :value="1">
|
||||
{{ $t('usersAddEditUserModalComponent.addEditUserModalIsActiveOption1') }}
|
||||
<option :value="true">
|
||||
{{ $t('generalItems.yes') }}
|
||||
</option>
|
||||
<option :value="2">
|
||||
{{ $t('usersAddEditUserModalComponent.addEditUserModalIsActiveOption2') }}
|
||||
<option :value="false">
|
||||
{{ $t('generalItems.no') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -508,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 {
|
||||
|
||||
@@ -25,10 +25,15 @@
|
||||
>{{ $t('usersListComponent.userListUserIsAdminBadge') }}</span
|
||||
>
|
||||
<span
|
||||
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis d-none d-sm-inline"
|
||||
v-if="user.is_active == 2"
|
||||
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
|
||||
v-if="user.active == false"
|
||||
>{{ $t('usersListComponent.userListUserIsInactiveBadge') }}</span
|
||||
>
|
||||
<span
|
||||
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis d-none d-sm-inline"
|
||||
v-if="user.email_verified == false"
|
||||
>{{ $t('usersListComponent.userListUserHasUnverifiedEmailBadge') }}</span
|
||||
>
|
||||
|
||||
<!-- button toggle user details -->
|
||||
<a
|
||||
@@ -44,6 +49,50 @@
|
||||
<font-awesome-icon :icon="['fas', 'caret-up']" v-else />
|
||||
</a>
|
||||
|
||||
<!-- approve sign-up button -->
|
||||
<a
|
||||
class="btn btn-link btn-lg link-body-emphasis"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="modal"
|
||||
:data-bs-target="`#approveSignUpModal${user.id}`"
|
||||
v-if="user.pending_admin_approval && user.email_verified"
|
||||
><font-awesome-icon :icon="['fas', 'fa-check']"
|
||||
/></a>
|
||||
|
||||
<!-- approve sign up modal -->
|
||||
<ModalComponent
|
||||
:modalId="`approveSignUpModal${user.id}`"
|
||||
:title="t('usersListComponent.modalApproveSignUpTitle')"
|
||||
:body="`${t('usersListComponent.modalApproveSignUpBody')}<b>${user.username}</b>?`"
|
||||
:actionButtonType="`success`"
|
||||
:actionButtonText="t('usersListComponent.modalApproveSignUpTitle')"
|
||||
@submitAction="submitApproveSignUp"
|
||||
v-if="user.pending_admin_approval && user.email_verified"
|
||||
/>
|
||||
|
||||
<!-- reject sign-up button -->
|
||||
<a
|
||||
class="btn btn-link btn-lg link-body-emphasis"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="modal"
|
||||
:data-bs-target="`#rejectSignUpModal${user.id}`"
|
||||
v-if="user.pending_admin_approval && user.email_verified"
|
||||
><font-awesome-icon :icon="['fas', 'fa-xmark']"
|
||||
/></a>
|
||||
|
||||
<!-- reject sign up modal -->
|
||||
<ModalComponent
|
||||
:modalId="`rejectSignUpModal${user.id}`"
|
||||
:title="t('usersListComponent.modalRejectSignUpTitle')"
|
||||
:body="`${t('usersListComponent.modalRejectSignUpBody1')}<b>${user.username}</b>? ${t('usersListComponent.modalRejectSignUpBody2')}`"
|
||||
:actionButtonType="`danger`"
|
||||
:actionButtonText="t('usersListComponent.modalRejectSignUpTitle')"
|
||||
@submitAction="submitDeleteUser"
|
||||
v-if="user.pending_admin_approval && user.email_verified"
|
||||
/>
|
||||
|
||||
<!-- change user password button -->
|
||||
<a
|
||||
class="btn btn-link btn-lg link-body-emphasis"
|
||||
@@ -106,9 +155,7 @@
|
||||
@sessionDeleted="updateSessionListDeleted"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<NoItemsFoundComponents />
|
||||
</div>
|
||||
<NoItemsFoundComponents :show-shadow="false" v-else />
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -134,7 +181,7 @@ const props = defineProps({
|
||||
required: true
|
||||
}
|
||||
})
|
||||
const emit = defineEmits(['userDeleted', 'editedUser'])
|
||||
const emit = defineEmits(['userDeleted', 'editedUser', 'approvedUser'])
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const userDetails = ref(false)
|
||||
@@ -168,6 +215,17 @@ async function updateSessionListDeleted(sessionDeletedId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitApproveSignUp() {
|
||||
const notification = push.promise(t('usersListComponent.processingApproval'))
|
||||
try {
|
||||
await users.approveUser(props.user.id)
|
||||
notification.resolve(t('usersListComponent.userApproveSuccessMessage'))
|
||||
emit('approvedUser', props.user.id)
|
||||
} catch (error) {
|
||||
notification.reject(`${t('usersListComponent.userApproveErrorMessage')} - ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
userSessions.value = await session.getUserSessions(props.user.id)
|
||||
isLoading.value = false
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
}}{{ goal.goal_activities_number }} {{ $t('userGoalsStatsComponent.activities') }}</span
|
||||
>
|
||||
<span v-if="goal.goal_type === 3"
|
||||
>{{ formatDistanceRaw(t, goal.total_distance, authStore.user.units)
|
||||
>{{ formatDistanceRaw(t, goal.total_distance, authStore.user.units, false)
|
||||
}}{{ $t('generalItems.ofWithSpaces')
|
||||
}}{{ formatDistanceRaw(t, goal.goal_distance, authStore.user.units) }}</span
|
||||
>
|
||||
@@ -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
Reference in New Issue
Block a user