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: