diff --git a/backend/app/server_settings/__init__.py b/backend/app/server_settings/__init__.py
index e69de29bb..dc50e759d 100644
--- a/backend/app/server_settings/__init__.py
+++ b/backend/app/server_settings/__init__.py
@@ -0,0 +1,50 @@
+"""
+Server settings module for configuration and database management.
+
+This module provides server-wide configuration management, including
+measurement units, currency settings, signup policies, SSO configuration,
+and map tile server settings.
+
+Exports:
+ - CRUD operations: get_server_settings, edit_server_settings
+ - Schemas: ServerSettings, ServerSettingsEdit, ServerSettingsRead,
+ ServerSettingsReadPublic
+ - Utilities: get_server_settings (wrapper)
+ - Models: ServerSettings (ORM model)
+ - Enums: Units, Currency, PasswordType
+"""
+
+from .crud import get_server_settings as get_server_settings_db
+from .crud import edit_server_settings
+from .models import ServerSettings as ServerSettingsModel
+from .schema import (
+ ServerSettings,
+ ServerSettingsBase,
+ ServerSettingsEdit,
+ ServerSettingsRead,
+ ServerSettingsReadPublic,
+ Units,
+ Currency,
+ PasswordType,
+)
+from .utils import get_server_settings
+
+__all__ = [
+ # CRUD operations
+ "get_server_settings_db",
+ "edit_server_settings",
+ # Database model
+ "ServerSettingsModel",
+ # Pydantic schemas
+ "ServerSettings",
+ "ServerSettingsBase",
+ "ServerSettingsEdit",
+ "ServerSettingsRead",
+ "ServerSettingsReadPublic",
+ # Enums
+ "Units",
+ "Currency",
+ "PasswordType",
+ # Utility functions
+ "get_server_settings",
+]
diff --git a/backend/app/server_settings/crud.py b/backend/app/server_settings/crud.py
index fa806db83..c77e0cc75 100644
--- a/backend/app/server_settings/crud.py
+++ b/backend/app/server_settings/crud.py
@@ -8,6 +8,18 @@ import core.logger as core_logger
def get_server_settings(db: Session) -> server_settings_models.ServerSettings:
+ """
+ Retrieve singleton server settings from database.
+
+ Args:
+ db: Database session.
+
+ Returns:
+ ServerSettings instance or None if not found.
+
+ Raises:
+ HTTPException: If database error occurs.
+ """
try:
# Get the user from the database
return (
@@ -23,13 +35,26 @@ def get_server_settings(db: Session) -> server_settings_models.ServerSettings:
# 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: {err}",
+ detail=f"Internal Server Error: {err}",
) from err
def edit_server_settings(
server_settings: server_settings_schema.ServerSettingsEdit, db: Session
) -> server_settings_models.ServerSettings:
+ """
+ Update server settings in database.
+
+ Args:
+ server_settings: New settings to apply.
+ db: Database session.
+
+ Returns:
+ Updated ServerSettings instance.
+
+ Raises:
+ HTTPException: If settings not found or database error.
+ """
try:
# Get the server_settings from the database
db_server_settings = get_server_settings(db)
@@ -49,6 +74,8 @@ def edit_server_settings(
# Commit the transaction
db.commit()
+ # Refresh the object to ensure it reflects database state
+ db.refresh(db_server_settings)
return db_server_settings
except HTTPException as http_err:
@@ -65,5 +92,5 @@ def edit_server_settings(
# 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: {err}",
+ detail=f"Internal Server Error: {err}",
) from err
diff --git a/backend/app/server_settings/models.py b/backend/app/server_settings/models.py
index 0386067ef..fa6f00098 100644
--- a/backend/app/server_settings/models.py
+++ b/backend/app/server_settings/models.py
@@ -1,118 +1,138 @@
-from sqlalchemy import Column, Integer, Boolean, CheckConstraint, String
+from sqlalchemy import CheckConstraint, String
+from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
class ServerSettings(Base):
+ """
+ Server-wide configuration settings.
+
+ Attributes:
+ id: Primary key (always 1, singleton pattern).
+ units: Measurement units (1=metric, 2=imperial).
+ public_shareable_links: Allow public shareable links.
+ public_shareable_links_user_info: Show user info on
+ public links.
+ login_photo_set: Login photo has been configured.
+ currency: Currency type (1=euro, 2=dollar, 3=pound).
+ num_records_per_page: Default pagination size.
+ signup_enabled: Allow user registration.
+ signup_require_admin_approval: Require approval for
+ new signups.
+ signup_require_email_verification: Require email
+ verification for new signups.
+ sso_enabled: Enable SSO/IdP login.
+ local_login_enabled: Allow local login.
+ sso_auto_redirect: Auto-redirect to SSO.
+ tileserver_url: Map tile server URL template.
+ tileserver_attribution: Map tile attribution.
+ map_background_color: Map background hex color.
+ password_type: Password policy type.
+ password_length_regular_users: Min password length
+ for regular users.
+ password_length_admin_users: Min password length for
+ admin users.
+ """
+
__tablename__ = "server_settings"
- id = Column(Integer, primary_key=True, default=1, nullable=False)
- units = Column(
- Integer,
- nullable=False,
+ id: Mapped[int] = mapped_column(
+ primary_key=True,
default=1,
- comment="Units (one digit)(1 - metric, 2 - imperial)",
- )
- public_shareable_links = Column(
- Boolean,
nullable=False,
- default=False,
- comment="Allow public shareable links (true - yes, false - no)",
)
- public_shareable_links_user_info = Column(
- Boolean,
- nullable=False,
- default=False,
- comment="Allow show user info on public shareable links (true - yes, false - no)",
- )
- login_photo_set = Column(
- Boolean,
- nullable=False,
- default=False,
- comment="Is login photo set (true - yes, false - no)",
- )
- currency = Column(
- Integer,
- nullable=False,
+ units: Mapped[int] = mapped_column(
default=1,
- comment="Currency (one digit)(1 - euro, 2 - dollar, 3 - pound)",
- )
- num_records_per_page = Column(
- Integer,
nullable=False,
+ comment="Units (1=metric, 2=imperial)",
+ )
+ public_shareable_links: Mapped[bool] = mapped_column(
+ default=False,
+ nullable=False,
+ comment="Allow public shareable links",
+ )
+ public_shareable_links_user_info: Mapped[bool] = mapped_column(
+ default=False,
+ nullable=False,
+ comment="Show user info on public links",
+ )
+ login_photo_set: Mapped[bool] = mapped_column(
+ default=False,
+ nullable=False,
+ comment="Login photo has been set",
+ )
+ currency: Mapped[int] = mapped_column(
+ default=1,
+ nullable=False,
+ comment="Currency (1=euro, 2=dollar, 3=pound)",
+ )
+ num_records_per_page: Mapped[int] = mapped_column(
default=25,
- comment="Number of records per page in lists",
- )
- signup_enabled = Column(
- Boolean,
nullable=False,
+ comment="Records per page in lists",
+ )
+ signup_enabled: Mapped[bool] = mapped_column(
default=False,
- comment="Allow user sign-up registration (true - yes, false - no)",
- )
- signup_require_admin_approval = Column(
- Boolean,
nullable=False,
+ comment="Allow user registration",
+ )
+ signup_require_admin_approval: Mapped[bool] = mapped_column(
default=True,
- comment="Require admin approval for new sign-ups (true - yes, false - no)",
- )
- signup_require_email_verification = Column(
- Boolean,
nullable=False,
+ comment="Require admin approval for signups",
+ )
+ signup_require_email_verification: Mapped[bool] = mapped_column(
default=True,
- comment="Require email verification for new sign-ups (true - yes, false - no)",
- )
- sso_enabled = Column(
- Boolean,
nullable=False,
+ comment="Require email verification for signups",
+ )
+ sso_enabled: Mapped[bool] = mapped_column(
default=False,
- comment="Enable SSO/IdP login (true - yes, false - no)",
- )
- local_login_enabled = Column(
- Boolean,
nullable=False,
+ comment="Enable SSO/IdP login",
+ )
+ local_login_enabled: Mapped[bool] = mapped_column(
default=True,
- comment="Allow local username/password login (true - yes, false - no)",
- )
- sso_auto_redirect = Column(
- Boolean,
nullable=False,
+ comment="Allow local username/password login",
+ )
+ sso_auto_redirect: Mapped[bool] = mapped_column(
default=False,
- comment="Auto-redirect to SSO if only one IdP (true - yes, false - no)",
- )
- tileserver_url = Column(
- String,
nullable=False,
- default="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
- comment="URL template for the map tileserver",
+ comment="Auto-redirect to SSO if only one IdP",
)
- tileserver_attribution = Column(
- String,
+ tileserver_url: Mapped[str] = mapped_column(
+ default=("https://{s}.tile.openstreetmap.org/" "{z}/{x}/{y}.png"),
nullable=False,
- default='© OpenStreetMap contributors',
- comment="Attribution string for the map tileserver",
+ comment="Map tile server URL template",
)
- map_background_color = Column(
- String,
+ tileserver_attribution: Mapped[str] = mapped_column(
+ default=(
+ '© OpenStreetMap contributors'
+ ),
nullable=False,
+ comment="Map tile attribution",
+ )
+ map_background_color: Mapped[str] = mapped_column(
default="#dddddd",
- comment="Background color for the map",
- )
- password_type = Column(
- String,
nullable=False,
+ comment="Map background hex color",
+ )
+ password_type: Mapped[str] = mapped_column(
default="strict",
- comment="Password type policy (strict, length_only)",
- )
- password_length_regular_users = Column(
- Integer,
nullable=False,
+ comment="Password policy (strict, length_only)",
+ )
+ password_length_regular_users: Mapped[int] = mapped_column(
default=8,
- comment="Minimum password length for regular users",
- )
- password_length_admin_users = Column(
- Integer,
nullable=False,
+ comment="Min password length for regular users",
+ )
+ password_length_admin_users: Mapped[int] = mapped_column(
default=12,
- comment="Minimum password length for admin users",
+ nullable=False,
+ comment="Min password length for admin users",
)
__table_args__ = (CheckConstraint("id = 1", name="single_row_check"),)
diff --git a/backend/app/server_settings/public_router.py b/backend/app/server_settings/public_router.py
index 92278c9ea..d354a5e50 100644
--- a/backend/app/server_settings/public_router.py
+++ b/backend/app/server_settings/public_router.py
@@ -19,10 +19,16 @@ async def read_public_server_settings(
Depends(core_database.get_db),
],
):
- # Get the server_settings from the database
+ """
+ Get public server settings (unauthenticated).
+
+ Returns only the public subset of server configuration
+ (sensitive signup approval/verification settings excluded).
+ Pydantic model filtering automatically excludes sensitive fields.
+
+ Returns:
+ Public subset of server configuration.
+ """
server_settings = server_settings_utils.get_server_settings(db)
-
- delattr(server_settings, "signup_require_admin_approval")
- delattr(server_settings, "signup_require_email_verification")
-
+ # Pydantic model_validate handles field filtering automatically
return server_settings
diff --git a/backend/app/server_settings/router.py b/backend/app/server_settings/router.py
index a703e6d95..0d31fad49 100644
--- a/backend/app/server_settings/router.py
+++ b/backend/app/server_settings/router.py
@@ -1,9 +1,12 @@
import os
from typing import Annotated, Callable
-from fastapi import APIRouter, Depends, Security, UploadFile
+from fastapi import APIRouter, Depends, Security, UploadFile, HTTPException, status
from sqlalchemy.orm import Session
+from safeuploads import FileValidator
+from safeuploads.exceptions import FileValidationError
+
import server_settings.schema as server_settings_schema
import server_settings.crud as server_settings_crud
import server_settings.utils as server_settings_utils
@@ -17,6 +20,9 @@ import core.config as core_config
# Define the API router
router = APIRouter()
+# Initialize the file validator
+file_validator = FileValidator()
+
@router.get("", response_model=server_settings_schema.ServerSettingsRead)
async def read_server_settings(
@@ -29,13 +35,20 @@ async def read_server_settings(
Depends(core_database.get_db),
],
):
- # Get the server_settings from the database
+ """
+ Get current server settings.
+
+ Requires admin authentication with server_settings:read scope.
+
+ Returns:
+ Current server settings configuration.
+ """
return server_settings_utils.get_server_settings(db)
@router.put("", response_model=server_settings_schema.ServerSettingsRead)
async def edit_server_settings(
- server_settings_attributtes: server_settings_schema.ServerSettingsEdit,
+ server_settings_attributes: server_settings_schema.ServerSettingsEdit,
_check_scopes: Annotated[
Callable,
Security(auth_security.check_scopes, scopes=["server_settings:write"]),
@@ -45,13 +58,24 @@ async def edit_server_settings(
Depends(core_database.get_db),
],
):
- # Update the server_settings in the database
- return server_settings_crud.edit_server_settings(server_settings_attributtes, db)
+ """
+ Update server settings.
+
+ Requires admin authentication with server_settings:write scope.
+
+ Args:
+ server_settings_attributes: Settings to update.
+
+ Returns:
+ Updated server settings configuration.
+ """
+ return server_settings_crud.edit_server_settings(server_settings_attributes, db)
@router.post(
"/upload/login",
- status_code=201,
+ status_code=status.HTTP_201_CREATED,
+ response_model=dict,
)
async def upload_login_photo(
file: UploadFile,
@@ -59,8 +83,23 @@ async def upload_login_photo(
Callable,
Security(auth_security.check_scopes, scopes=["server_settings:write"]),
],
-):
+) -> dict:
+ """
+ Upload custom login page photo with security validation.
+
+ Requires admin authentication with server_settings:write scope.
+
+ Args:
+ file: Image file to upload.
+
+ Returns:
+ Success confirmation message.
+
+ Raises:
+ HTTPException: If file validation or upload fails.
+ """
try:
+ await file_validator.validate_image_file(file)
# Ensure the 'server_images' directory exists
upload_dir = core_config.SERVER_IMAGES_DIR
os.makedirs(upload_dir, exist_ok=True)
@@ -69,41 +108,69 @@ async def upload_login_photo(
file_path = os.path.join(upload_dir, "login.png")
# Save the uploaded file with the name "login.png"
+ content = await file.read()
with open(file_path, "wb") as save_file:
- save_file.write(file.file.read())
+ save_file.write(content)
+
+ return {"detail": "Login photo uploaded successfully"}
+ except FileValidationError as err:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail=str(err)
+ ) from err
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in upload_login_photo: {err}", "error", exc=err
)
- # Raise an HTTPException with a 500 Internal Server Error status code
- raise err
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to upload login photo",
+ ) from err
@router.delete(
"/upload/login",
- status_code=200,
+ status_code=status.HTTP_200_OK,
+ response_model=dict,
)
async def delete_login_photo(
_check_scopes: Annotated[
Callable,
Security(auth_security.check_scopes, scopes=["server_settings:write"]),
],
-):
+) -> dict:
+ """
+ Delete custom login page photo.
+
+ Requires admin authentication with server_settings:write scope.
+
+ Returns:
+ Success confirmation message.
+
+ Raises:
+ HTTPException: If deletion fails.
+ """
try:
# Build the full path to the file
- file_path = os.path.join(core_config.SERVER_IMAGES_DIR, "login.png")
+ file_path = os.path.join(
+ core_config.SERVER_IMAGES_DIR,
+ "login.png",
+ )
# Check if the file exists
if os.path.exists(file_path):
# Delete the file
os.remove(file_path)
+
+ return {"detail": "Login photo deleted successfully"}
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in delete_login_photo: {err}", "error", exc=err
)
- # Raise an HTTPException with a 500 Internal Server Error status code
- raise err
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to delete login photo",
+ ) from err
diff --git a/backend/app/server_settings/schema.py b/backend/app/server_settings/schema.py
index 1b12ee0ab..ddc9627dc 100644
--- a/backend/app/server_settings/schema.py
+++ b/backend/app/server_settings/schema.py
@@ -51,31 +51,33 @@ class PasswordType(Enum):
LENGTH_ONLY = "length_only"
-class ServerSettings(BaseModel):
+class ServerSettingsBase(BaseModel):
"""
- Represents the configuration settings for a server.
+ Base schema for server configuration settings.
+
+ Shared fields and validation rules used across all server settings
+ schemas. Does not include the id field, allowing flexible inheritance
+ for different use cases.
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.
- sso_enabled (bool): Indicates if SSO/IdP login is enabled.
- local_login_enabled (bool): Indicates if local username/password login is allowed.
- sso_auto_redirect (bool): Auto-redirect to SSO if only one IdP is configured.
- tileserver_url (str): URL template for the map tileserver.
- tileserver_attribution (str): Attribution string for the map tileserver.
- map_background_color (str): Background color for the map.
- password_type (PasswordType): Type of password policy enforced.
- password_length_regular_users (int): Minimum password length for regular users.
- password_length_admin_users (int): Minimum password length for admin users.
+ units: Measurement units (metric/imperial).
+ public_shareable_links: Enable public shareable activity links.
+ public_shareable_links_user_info: Show user info on public links.
+ login_photo_set: Whether login photo has been configured.
+ currency: Currency type (euro/dollar/pound).
+ num_records_per_page: Default pagination size.
+ signup_enabled: Allow new user registration.
+ sso_enabled: Enable SSO/IdP authentication.
+ local_login_enabled: Allow username/password login.
+ sso_auto_redirect: Auto-redirect to SSO login.
+ tileserver_url: Map tile server URL template.
+ tileserver_attribution: Map tile attribution HTML.
+ map_background_color: Map background hex color.
+ password_type: Password policy type.
+ password_length_regular_users: Min length for regular users.
+ password_length_admin_users: Min length for admin users.
"""
- id: StrictInt
units: Units
public_shareable_links: bool
public_shareable_links_user_info: bool
@@ -178,6 +180,22 @@ class ServerSettings(BaseModel):
return sanitize_attribution(value) or ""
+class ServerSettings(ServerSettingsBase):
+ """
+ Complete server settings schema with unique identifier.
+
+ Extends ServerSettingsBase by adding the id field for complete
+ server settings representation. Used for internal operations and
+ full server configuration responses.
+
+ Attributes:
+ id: Unique identifier (always 1, singleton pattern).
+ (plus all fields inherited from ServerSettingsBase)
+ """
+
+ id: StrictInt
+
+
class ServerSettingsEdit(ServerSettings):
"""
Extends ServerSettings with additional fields for user signup configuration.
@@ -198,10 +216,14 @@ class ServerSettingsRead(ServerSettingsEdit):
"""
-class ServerSettingsReadPublic(ServerSettings):
+class ServerSettingsReadPublic(ServerSettingsBase):
"""
- A public-facing schema for reading server settings.
+ Public-facing schema for unauthenticated server settings access.
- This class inherits all fields and behaviors from `ServerSettings` and is intended
- for use cases where only public server settings should be exposed.
+ Provides only public-safe server settings, excluding sensitive
+ configuration like signup requirements. Used for the public API
+ endpoint that doesn't require authentication.
+
+ Inherits all safe fields from ServerSettingsBase but explicitly
+ excludes admin-level configuration fields.
"""
diff --git a/backend/app/server_settings/utils.py b/backend/app/server_settings/utils.py
index 47e23a508..ea6085b00 100644
--- a/backend/app/server_settings/utils.py
+++ b/backend/app/server_settings/utils.py
@@ -2,9 +2,22 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
import server_settings.crud as server_settings_crud
+import server_settings.models as server_settings_models
-def get_server_settings(db: Session):
+def get_server_settings(db: Session) -> server_settings_models.ServerSettings:
+ """
+ Get server settings or raise 404.
+
+ Args:
+ db: Database session.
+
+ Returns:
+ ServerSettings instance.
+
+ Raises:
+ HTTPException: If server settings not found.
+ """
server_settings = server_settings_crud.get_server_settings(db)
if not server_settings: