Refactor server settings module and improve API docs

Refactored the server settings module for better structure and clarity, including splitting base and public schemas, updating SQLAlchemy model to use Mapped/mapped_column, and improving type hints and docstrings. Enhanced API endpoints with detailed documentation, added file validation for login photo upload, and improved error handling. The __init__.py now provides explicit exports for all key components.
This commit is contained in:
João Vitória Silva
2026-01-06 22:29:54 +00:00
parent d4797c35a0
commit 5d32f8c649
7 changed files with 331 additions and 126 deletions

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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=(
'&copy; <a href="https://www.openstreetmap.org/'
'copyright">OpenStreetMap</a> 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"),)

View File

@@ -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

View File

@@ -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

View File

@@ -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.
"""

View File

@@ -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: