Initial commit

This commit is contained in:
2025-12-04 22:24:47 +01:00
commit 453ce10494
106 changed files with 17145 additions and 0 deletions

32
backend/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
FROM python:3.11-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
ffmpeg \
wget \
curl \
git \
postgresql-client \
docker.io \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create directories
RUN mkdir -p /downloads /plex
# Expose port
EXPOSE 8000
# Run migrations and start server
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

88
backend/alembic.ini Normal file
View File

@@ -0,0 +1,88 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

88
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,88 @@
"""Alembic environment configuration."""
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
# Add parent directory to path
sys.path.append(os.path.dirname(os.path.dirname(__file__)))
from app.db.base import Base
from app.config import settings
# Import all models so Alembic can detect them for auto-generating migrations
from app.models.user import User # noqa
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# Set database URL from settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,45 @@
"""Initial schema with users table
Revision ID: 001
Revises:
Create Date: 2025-01-15 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create users table (SQLite compatible)
op.create_table(
'users',
sa.Column('id', sa.String(length=36), primary_key=True),
sa.Column('username', sa.String(length=100), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('is_superuser', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
)
# Create indexes
op.create_index('ix_users_username', 'users', ['username'], unique=True)
op.create_index('ix_users_email', 'users', ['email'], unique=True)
def downgrade() -> None:
# Drop indexes
op.drop_index('ix_users_email', table_name='users')
op.drop_index('ix_users_username', table_name='users')
# Drop table
op.drop_table('users')

View File

@@ -0,0 +1,41 @@
"""Add settings table
Revision ID: 002
Revises: 001
Create Date: 2025-01-27 10:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002'
down_revision: Union[str, None] = '001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create settings table (SQLite compatible)
op.create_table(
'settings',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('value_str', sa.String(length=500), nullable=True),
sa.Column('value_bool', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text("(datetime('now'))"), nullable=False),
)
# Create unique index on key
op.create_index('ix_settings_key', 'settings', ['key'], unique=True)
def downgrade() -> None:
# Drop index
op.drop_index('ix_settings_key', table_name='settings')
# Drop table
op.drop_table('settings')

View File

@@ -0,0 +1,28 @@
"""add permissions to users
Revision ID: 004
Revises: 3382f3992222
Create Date: 2025-11-28 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '004'
down_revision: Union[str, None] = '3382f3992222'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add permissions column to users table (JSON stored as TEXT for SQLite)
op.add_column('users', sa.Column('permissions', sa.Text(), nullable=True))
def downgrade() -> None:
# Remove permissions column from users table
op.drop_column('users', 'permissions')

View File

@@ -0,0 +1,28 @@
"""add last_login to users
Revision ID: 3382f3992222
Revises: 002
Create Date: 2025-11-27 15:15:10.667875
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3382f3992222'
down_revision: Union[str, None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add last_login column to users table
op.add_column('users', sa.Column('last_login', sa.DateTime(), nullable=True))
def downgrade() -> None:
# Remove last_login column from users table
op.drop_column('users', 'last_login')

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,5 @@
"""API v1 package - exports the main v1 router."""
from app.api.v1.router import router
__all__ = ["router"]

155
backend/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,155 @@
"""Authentication endpoints."""
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, schemas
from app.dependencies import get_db, get_current_user
from app.core.security import create_access_token
from app.config import settings
from app.models.user import User
router = APIRouter()
@router.post("/register", response_model=schemas.User, status_code=status.HTTP_201_CREATED)
def register(
*,
db: Session = Depends(get_db),
user_in: schemas.RegisterRequest
) -> Any:
"""
Register a new user.
Creates a new user account with the provided credentials.
Registration can be disabled by administrators via settings.
"""
# Check if this is the first user (always allow for initial setup)
user_count = db.query(User).count()
is_first_user = user_count == 0
# If not the first user, check if registration is enabled
if not is_first_user:
registration_enabled = crud.settings.get_setting_value(
db,
key="registration_enabled",
default=True # Default to enabled if setting doesn't exist
)
if not registration_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User registration is currently disabled"
)
# Check if username already exists
user = crud.user.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Check if email already exists
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user (first user becomes superuser)
user_create = schemas.UserCreate(
username=user_in.username,
email=user_in.email,
password=user_in.password,
is_active=True,
is_superuser=is_first_user # First user is superuser
)
user = crud.user.create(db, obj_in=user_create)
return user
@router.post("/login", response_model=schemas.Token)
def login(
*,
db: Session = Depends(get_db),
credentials: schemas.LoginRequest
) -> Any:
"""
Login and get access token.
Authenticates user and returns a JWT access token.
"""
# Authenticate user
user = crud.user.authenticate(
db,
username=credentials.username,
password=credentials.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not crud.user.is_active(user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
# Update last_login timestamp
user.last_login = datetime.utcnow()
db.add(user)
db.commit()
# Create access token
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.id},
expires_delta=access_token_expires
)
return {
"access_token": access_token,
"token_type": "bearer"
}
@router.get("/me", response_model=schemas.User)
def read_users_me(
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get current user.
Returns the currently authenticated user's information.
Requires a valid JWT token in the Authorization header.
"""
return current_user
@router.get("/registration-status")
def get_registration_status(
db: Session = Depends(get_db)
) -> Any:
"""
Check if user registration is enabled.
This is a public endpoint that doesn't require authentication.
Returns the registration_enabled setting value.
"""
registration_enabled = crud.settings.get_setting_value(
db,
key="registration_enabled",
default=True
)
return {"registration_enabled": registration_enabled}

View File

@@ -0,0 +1,34 @@
"""Health check endpoints."""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.dependencies import get_db
from app.config import settings
router = APIRouter()
@router.get("")
async def health_check(db: Session = Depends(get_db)):
"""
Health check endpoint that verifies database connectivity.
Returns:
Dictionary with health status and database connectivity
"""
try:
# Test database connection
db.execute(text("SELECT 1"))
db_status = "connected"
except Exception as e:
db_status = f"error: {str(e)}"
return {
"status": "healthy" if db_status == "connected" else "unhealthy",
"app": settings.APP_NAME,
"version": settings.APP_VERSION,
"database": db_status
}

View File

@@ -0,0 +1,15 @@
"""Main API v1 router that aggregates all sub-routers."""
from fastapi import APIRouter
from app.api.v1 import health, auth, users, settings
# Create main API v1 router
router = APIRouter()
# Include all sub-routers
router.include_router(health.router, prefix="/health", tags=["Health"])
router.include_router(auth.router, prefix="/auth", tags=["Authentication"])
router.include_router(users.router, prefix="/users", tags=["Users"])
router.include_router(settings.router, prefix="/settings", tags=["Settings"])

View File

@@ -0,0 +1,184 @@
"""Settings endpoints."""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, schemas
from app.dependencies import get_db, get_current_superuser, get_current_user
from app.models.user import User
from app.core.settings_registry import (
THEME_KEYS,
MODULE_KEYS,
SETTINGS_REGISTRY,
get_default_value,
)
router = APIRouter()
@router.get("/theme", response_model=dict[str, Any])
def get_theme_settings(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get theme settings (accessible to all authenticated users).
Returns theme-related settings that apply to all users.
"""
result = {}
for key in THEME_KEYS:
setting = crud.settings.get_setting(db, key=key)
if setting:
result[key] = setting.get_value()
else:
# Use default from registry
result[key] = get_default_value(key)
return result
@router.put("/theme", response_model=dict[str, Any])
def update_theme_settings(
*,
db: Session = Depends(get_db),
theme_data: dict[str, Any],
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Update theme settings (admin only).
Updates multiple theme settings at once.
"""
result = {}
for key, value in theme_data.items():
if key in THEME_KEYS or key.startswith("theme_"):
setting = crud.settings.update_setting(db, key=key, value=value)
result[key] = setting.get_value()
return result
@router.get("/modules", response_model=dict[str, Any])
def get_module_settings(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get module settings (accessible to all authenticated users).
Returns module enabled/disabled states.
"""
result = {}
for key in MODULE_KEYS:
setting = crud.settings.get_setting(db, key=key)
if setting:
result[key] = setting.get_value()
else:
# Use default from registry
result[key] = get_default_value(key)
return result
@router.put("/modules", response_model=dict[str, Any])
def update_module_settings(
*,
db: Session = Depends(get_db),
module_data: dict[str, Any],
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Update module settings (admin only).
Enable or disable modules for all users.
"""
result = {}
for key, value in module_data.items():
if key in MODULE_KEYS or key.startswith("module_"):
setting = crud.settings.update_setting(db, key=key, value=value)
result[key] = setting.get_value()
return result
@router.get("/user_mode_enabled", response_model=dict[str, Any])
def get_user_mode_enabled(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get user mode enabled setting (accessible to all authenticated users).
"""
setting = crud.settings.get_setting(db, key="user_mode_enabled")
if setting:
return {"key": "user_mode_enabled", "value": setting.get_value()}
# Use default from registry
return {"key": "user_mode_enabled", "value": get_default_value("user_mode_enabled")}
@router.put("/user_mode_enabled", response_model=dict[str, Any])
def update_user_mode_enabled(
*,
db: Session = Depends(get_db),
data: dict[str, Any],
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Update user mode enabled setting (admin only).
"""
value = data.get("value", True)
setting = crud.settings.update_setting(db, key="user_mode_enabled", value=value)
return {"key": "user_mode_enabled", "value": setting.get_value()}
@router.get("", response_model=dict[str, Any])
def get_all_settings(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Get all settings (admin only).
Returns all application settings as a dictionary.
"""
settings_list = crud.settings.get_all_settings(db)
return {s.key: s.get_value() for s in settings_list}
@router.get("/{key}", response_model=schemas.Setting)
def get_setting(
*,
db: Session = Depends(get_db),
key: str,
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Get a specific setting by key (admin only).
"""
setting = crud.settings.get_setting(db, key=key)
if not setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Setting '{key}' not found"
)
return schemas.Setting.from_orm(setting)
@router.put("/{key}", response_model=schemas.Setting)
def update_setting(
*,
db: Session = Depends(get_db),
key: str,
setting_in: schemas.SettingUpdate,
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Update a setting (admin only).
Creates the setting if it doesn't exist.
"""
setting = crud.settings.update_setting(db, key=key, value=setting_in.value)
return schemas.Setting.from_orm(setting)

160
backend/app/api/v1/users.py Normal file
View File

@@ -0,0 +1,160 @@
"""User management endpoints (admin only)."""
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, schemas
from app.dependencies import get_db, get_current_user, get_current_active_superuser
from app.models.user import User
router = APIRouter()
@router.get("", response_model=List[schemas.User])
def list_users(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_superuser),
skip: int = 0,
limit: int = 100
) -> Any:
"""
List all users (admin only).
Requires superuser privileges.
"""
users = crud.user.get_multi(db, skip=skip, limit=limit)
return users
@router.get("/{user_id}", response_model=schemas.User)
def get_user(
*,
db: Session = Depends(get_db),
user_id: str,
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get user by ID.
Users can view their own profile, admins can view any profile.
"""
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check if user is viewing their own profile or is superuser
if user.id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return user
@router.put("/{user_id}", response_model=schemas.User)
def update_user(
*,
db: Session = Depends(get_db),
user_id: str,
user_in: schemas.UserUpdate,
current_user: User = Depends(get_current_user)
) -> Any:
"""
Update user.
Users can update their own profile, admins can update any profile.
Only admins can change is_superuser and is_active flags.
"""
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check permissions
if user.id != current_user.id and not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
# Only superusers can change is_superuser and is_active
if not current_user.is_superuser:
if user_in.is_superuser is not None or user_in.is_active is not None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can change user status"
)
user = crud.user.update(db, db_obj=user, obj_in=user_in)
return user
@router.delete("/{user_id}", response_model=schemas.User)
def delete_user(
*,
db: Session = Depends(get_db),
user_id: str,
current_user: User = Depends(get_current_active_superuser)
) -> Any:
"""
Delete user (admin only).
Requires superuser privileges.
Users cannot delete themselves.
"""
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Users cannot delete themselves"
)
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
user = crud.user.remove(db, id=user_id)
return user
@router.post("", response_model=schemas.User, status_code=status.HTTP_201_CREATED)
def create_user(
*,
db: Session = Depends(get_db),
user_in: schemas.UserCreate,
current_user: User = Depends(get_current_active_superuser)
) -> Any:
"""
Create new user (admin only).
Requires superuser privileges.
"""
# Check if username already exists
user = crud.user.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Check if email already exists
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
user = crud.user.create(db, obj_in=user_in)
return user

48
backend/app/config.py Normal file
View File

@@ -0,0 +1,48 @@
"""Application configuration using Pydantic Settings."""
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# App Info
APP_NAME: str = "Service App"
APP_VERSION: str = "1.0.0"
API_V1_PREFIX: str = "/api/v1"
# Database
DATABASE_URL: str = "sqlite:////config/config.db"
# Security
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24 hours
# CORS
ALLOWED_HOSTS: list[str] = []
CORS_ORIGINS: list[str] = [
"http://localhost:3000",
"http://localhost:5173", # Vite dev server
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
]
@property
def all_cors_origins(self) -> list[str]:
"""Combine default CORS origins with allowed hosts."""
return self.CORS_ORIGINS + self.ALLOWED_HOSTS
# Logging
LOG_LEVEL: str = "info"
DEBUG: bool = False
class Config:
env_file = ".env"
case_sensitive = True
extra = "ignore" # Ignore extra fields from .env (like VITE_* for frontend)
# Global settings instance
settings = Settings()

View File

View File

@@ -0,0 +1,21 @@
"""Custom exceptions for the application."""
class AppException(Exception):
"""Base exception for the application."""
pass
class DatabaseException(AppException):
"""Exception for database errors."""
pass
class AuthenticationException(AppException):
"""Exception for authentication errors."""
pass
class AuthorizationException(AppException):
"""Exception for authorization errors."""
pass

View File

@@ -0,0 +1,94 @@
"""Security utilities for authentication and authorization."""
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a plain password against a hashed password.
Args:
plain_password: The plain text password
hashed_password: The hashed password to compare against
Returns:
True if password matches, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password: The plain text password to hash
Returns:
The hashed password
"""
return pwd_context.hash(password)
def create_access_token(
data: Dict[str, Any],
expires_delta: Optional[timedelta] = None
) -> str:
"""
Create a JWT access token.
Args:
data: Dictionary containing claims to encode in the token
expires_delta: Optional expiration time delta
Returns:
Encoded JWT token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode,
settings.SECRET_KEY,
algorithm=settings.ALGORITHM
)
return encoded_jwt
def decode_access_token(token: str) -> Dict[str, Any]:
"""
Decode and validate a JWT access token.
Args:
token: The JWT token string to decode
Returns:
Dictionary containing the decoded token payload
Raises:
JWTError: If token is invalid or expired
"""
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM]
)
return payload

View File

@@ -0,0 +1,601 @@
"""
Settings Registry - Central definition of all application settings.
This file defines ALL configurable settings in the application with:
- Storage location (database in /config, localStorage, env)
- Scope (global or user-specific)
- Default values
- Data types
- Descriptions
IMPORTANT: Any new setting MUST be added here first.
"""
from enum import Enum
from dataclasses import dataclass, field
from typing import Any, Optional
class SettingScope(Enum):
"""Defines who the setting applies to."""
GLOBAL = "global" # Same for all users, admin-controlled
USER_SPECIFIC = "user" # Each user can have different value
SYSTEM = "system" # Infrastructure/env settings, not in DB
class SettingStorage(Enum):
"""Defines where the setting is stored."""
DATABASE = "database" # Stored in /config/config.db (settings table)
LOCAL_STORAGE = "localStorage" # Browser localStorage (frontend only)
ENV = "env" # Environment variable (.env file)
MEMORY = "memory" # Runtime only, not persisted
class SettingType(Enum):
"""Data type of the setting value."""
BOOLEAN = "boolean"
STRING = "string"
INTEGER = "integer"
JSON = "json"
LIST = "list"
@dataclass
class SettingDefinition:
"""Definition of a single setting."""
key: str
type: SettingType
scope: SettingScope
storage: SettingStorage
default: Any
description: str
category: str
admin_only: bool = True # Only admins can modify (for DB settings)
sync_to_frontend: bool = True # Should be synced to frontend
choices: Optional[list] = None # Valid values if restricted
# =============================================================================
# SETTINGS REGISTRY
# =============================================================================
SETTINGS_REGISTRY: dict[str, SettingDefinition] = {}
def register_setting(setting: SettingDefinition) -> SettingDefinition:
"""Register a setting in the registry."""
SETTINGS_REGISTRY[setting.key] = setting
return setting
# =============================================================================
# THEME SETTINGS (Global, Database)
# =============================================================================
register_setting(SettingDefinition(
key="theme_accent_color",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="auto",
description="Accent color for UI elements",
category="theme",
choices=["auto", "blue", "purple", "green", "orange", "pink", "red", "teal", "amber", "indigo", "cyan", "rose"]
))
register_setting(SettingDefinition(
key="theme_border_radius",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="large",
description="Border radius style for UI components",
category="theme",
choices=["small", "medium", "large"]
))
register_setting(SettingDefinition(
key="theme_sidebar_style",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="default",
description="Visual style of the sidebar",
category="theme",
choices=["default", "dark", "light"]
))
register_setting(SettingDefinition(
key="theme_density",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="compact",
description="UI density/spacing",
category="theme",
choices=["compact", "comfortable", "spacious"]
))
register_setting(SettingDefinition(
key="theme_font_family",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="sans",
description="Font family for the application",
category="theme",
choices=["sans", "inter", "roboto"]
))
register_setting(SettingDefinition(
key="theme_color_palette",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="monochrome",
description="Color palette preset",
category="theme",
choices=["default", "monochrome", "monochromeBlue", "sepia", "nord", "dracula", "solarized", "github", "ocean", "forest", "midnight", "sunset"]
))
register_setting(SettingDefinition(
key="theme_custom_colors",
type=SettingType.JSON,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="{}",
description="Custom color overrides as JSON",
category="theme"
))
register_setting(SettingDefinition(
key="theme_dark_mode_location",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="sidebar",
description="Where to show dark mode toggle",
category="theme",
choices=["sidebar", "user_menu"]
))
register_setting(SettingDefinition(
key="theme_language_location",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="sidebar",
description="Where to show language toggle",
category="theme",
choices=["sidebar", "user_menu"]
))
register_setting(SettingDefinition(
key="theme_show_dark_mode_toggle",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Show/hide dark mode toggle globally",
category="theme"
))
register_setting(SettingDefinition(
key="theme_show_language_toggle",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=False,
description="Show/hide language toggle globally",
category="theme"
))
register_setting(SettingDefinition(
key="theme_show_dark_mode_login",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Show dark mode toggle on login page",
category="theme"
))
register_setting(SettingDefinition(
key="theme_show_language_login",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=False,
description="Show language toggle on login page",
category="theme"
))
# =============================================================================
# MODULE/FEATURE SETTINGS (Global, Database)
# =============================================================================
register_setting(SettingDefinition(
key="module_feature1_admin_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 1 module for admin users",
category="modules"
))
register_setting(SettingDefinition(
key="module_feature1_user_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 1 module for regular users",
category="modules"
))
register_setting(SettingDefinition(
key="module_feature2_admin_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 2 module for admin users",
category="modules"
))
register_setting(SettingDefinition(
key="module_feature2_user_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 2 module for regular users",
category="modules"
))
register_setting(SettingDefinition(
key="module_feature3_admin_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 3 module for admin users",
category="modules"
))
register_setting(SettingDefinition(
key="module_feature3_user_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Enable Feature 3 module for regular users",
category="modules"
))
# =============================================================================
# AUTHENTICATION & SECURITY SETTINGS (Global, Database)
# =============================================================================
register_setting(SettingDefinition(
key="registration_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Allow new user registration",
category="auth"
))
register_setting(SettingDefinition(
key="user_mode_enabled",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=False,
description="Enable admin/user view mode switching",
category="auth"
))
# =============================================================================
# UI/LAYOUT SETTINGS (Global, Database)
# =============================================================================
register_setting(SettingDefinition(
key="sidebar_mode",
type=SettingType.STRING,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default="toggle",
description="Sidebar behavior mode",
category="ui",
choices=["collapsed", "expanded", "toggle", "dynamic"]
))
register_setting(SettingDefinition(
key="show_logo",
type=SettingType.BOOLEAN,
scope=SettingScope.GLOBAL,
storage=SettingStorage.DATABASE,
default=True,
description="Show logo in sidebar instead of text",
category="ui"
))
# =============================================================================
# USER-SPECIFIC SETTINGS (Per-user, Database)
# These settings can be different for each user
# =============================================================================
register_setting(SettingDefinition(
key="user_theme_mode",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
default="system",
description="User's preferred theme mode (light/dark/system)",
category="user_preferences",
admin_only=False,
choices=["light", "dark", "system"]
))
register_setting(SettingDefinition(
key="user_language",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
default="en",
description="User's preferred language",
category="user_preferences",
admin_only=False,
choices=["en", "it"]
))
register_setting(SettingDefinition(
key="user_sidebar_collapsed",
type=SettingType.BOOLEAN,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
default=False,
description="User's sidebar collapsed state preference",
category="user_preferences",
admin_only=False
))
register_setting(SettingDefinition(
key="user_view_mode",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
default="admin",
description="User's current view mode (admin/user)",
category="user_preferences",
admin_only=False,
choices=["admin", "user"]
))
# =============================================================================
# SYSTEM/ENVIRONMENT SETTINGS (Infrastructure, .env file)
# These are NOT stored in the database
# =============================================================================
register_setting(SettingDefinition(
key="SECRET_KEY",
type=SettingType.STRING,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default=None, # Required, no default
description="Secret key for JWT token signing",
category="security",
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="DATABASE_URL",
type=SettingType.STRING,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default="sqlite:////config/config.db",
description="Database connection URL",
category="infrastructure",
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="ALGORITHM",
type=SettingType.STRING,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default="HS256",
description="JWT signing algorithm",
category="security",
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="ACCESS_TOKEN_EXPIRE_MINUTES",
type=SettingType.INTEGER,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default=1440,
description="JWT token expiration time in minutes",
category="security",
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="ALLOWED_HOSTS",
type=SettingType.LIST,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default=[],
description="Additional allowed CORS origins",
category="security",
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="LOG_LEVEL",
type=SettingType.STRING,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default="info",
description="Application logging level",
category="infrastructure",
choices=["debug", "info", "warning", "error", "critical"],
sync_to_frontend=False
))
register_setting(SettingDefinition(
key="DEBUG",
type=SettingType.BOOLEAN,
scope=SettingScope.SYSTEM,
storage=SettingStorage.ENV,
default=False,
description="Enable debug mode",
category="infrastructure",
sync_to_frontend=False
))
# =============================================================================
# FRONTEND-ONLY SETTINGS (localStorage, not in database)
# These are managed entirely by the frontend
# =============================================================================
register_setting(SettingDefinition(
key="token",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.LOCAL_STORAGE,
default=None,
description="JWT authentication token",
category="auth",
sync_to_frontend=False # Managed by frontend only
))
# =============================================================================
# HELPER FUNCTIONS
# =============================================================================
def get_settings_by_category(category: str) -> list[SettingDefinition]:
"""Get all settings in a specific category."""
return [s for s in SETTINGS_REGISTRY.values() if s.category == category]
def get_settings_by_scope(scope: SettingScope) -> list[SettingDefinition]:
"""Get all settings with a specific scope."""
return [s for s in SETTINGS_REGISTRY.values() if s.scope == scope]
def get_settings_by_storage(storage: SettingStorage) -> list[SettingDefinition]:
"""Get all settings stored in a specific location."""
return [s for s in SETTINGS_REGISTRY.values() if s.storage == storage]
def get_database_settings() -> list[SettingDefinition]:
"""Get all settings that should be stored in the database."""
return get_settings_by_storage(SettingStorage.DATABASE)
def get_global_settings() -> list[SettingDefinition]:
"""Get all global (non user-specific) settings."""
return get_settings_by_scope(SettingScope.GLOBAL)
def get_user_specific_settings() -> list[SettingDefinition]:
"""Get all user-specific settings."""
return get_settings_by_scope(SettingScope.USER_SPECIFIC)
def get_default_value(key: str) -> Any:
"""Get the default value for a setting."""
if key in SETTINGS_REGISTRY:
return SETTINGS_REGISTRY[key].default
return None
def get_all_defaults() -> dict[str, Any]:
"""Get all default values as a dictionary."""
return {key: setting.default for key, setting in SETTINGS_REGISTRY.items()}
def get_database_defaults() -> dict[str, Any]:
"""Get default values for all database-stored settings."""
return {
setting.key: setting.default
for setting in get_database_settings()
}
def validate_setting_value(key: str, value: Any) -> bool:
"""Validate a setting value against its definition."""
if key not in SETTINGS_REGISTRY:
return False
setting = SETTINGS_REGISTRY[key]
# Check type
if setting.type == SettingType.BOOLEAN:
if not isinstance(value, bool) and value not in ['true', 'false', 'True', 'False']:
return False
elif setting.type == SettingType.INTEGER:
if not isinstance(value, int):
try:
int(value)
except (ValueError, TypeError):
return False
elif setting.type == SettingType.STRING:
if not isinstance(value, str):
return False
# Check choices if defined
if setting.choices and value not in setting.choices:
# For booleans converted to strings
if setting.type == SettingType.BOOLEAN:
return True
return False
return True
# =============================================================================
# CATEGORY KEYS (for API endpoints)
# =============================================================================
THEME_KEYS = [s.key for s in get_settings_by_category("theme")]
MODULE_KEYS = [s.key for s in get_settings_by_category("modules")]
AUTH_KEYS = [s.key for s in get_settings_by_category("auth")]
UI_KEYS = [s.key for s in get_settings_by_category("ui")]
USER_PREFERENCE_KEYS = [s.key for s in get_settings_by_category("user_preferences")]
# =============================================================================
# PRINT SUMMARY (for debugging)
# =============================================================================
if __name__ == "__main__":
print("\n=== SETTINGS REGISTRY SUMMARY ===\n")
print(f"Total settings: {len(SETTINGS_REGISTRY)}")
print(f" - Database settings: {len(get_database_settings())}")
print(f" - Global settings: {len(get_global_settings())}")
print(f" - User-specific settings: {len(get_user_specific_settings())}")
print("\n--- By Category ---")
categories = set(s.category for s in SETTINGS_REGISTRY.values())
for cat in sorted(categories):
settings = get_settings_by_category(cat)
print(f" {cat}: {len(settings)} settings")
print("\n--- Database Settings (stored in /config/config.db) ---")
for setting in get_database_settings():
scope_label = "GLOBAL" if setting.scope == SettingScope.GLOBAL else "USER"
print(f" [{scope_label}] {setting.key}: {setting.type.value} = {setting.default}")

View File

@@ -0,0 +1,6 @@
"""CRUD operations package."""
from app.crud.user import user
from app.crud import settings
__all__ = ["user", "settings"]

81
backend/app/crud/base.py Normal file
View File

@@ -0,0 +1,81 @@
"""Base CRUD operations for database models."""
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.base import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
"""Base class for CRUD operations."""
def __init__(self, model: Type[ModelType]):
"""
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
**Parameters**
* `model`: A SQLAlchemy model class
* `schema`: A Pydantic model (schema) class
"""
self.model = model
def get(self, db: Session, id: Any) -> Optional[ModelType]:
"""Get a single record by ID."""
return db.query(self.model).filter(self.model.id == id).first()
def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
"""Get multiple records with pagination."""
return db.query(self.model).offset(skip).limit(limit).all()
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
"""Create a new record."""
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
*,
db_obj: ModelType,
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> ModelType:
"""Update a record."""
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(self, db: Session, *, id: Any) -> ModelType:
"""Delete a record."""
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj
def remove(self, db: Session, *, id: Any) -> ModelType:
"""Remove a record (alias for delete)."""
return self.delete(db, id=id)

View File

@@ -0,0 +1,48 @@
"""CRUD operations for Settings."""
from typing import Optional
from sqlalchemy.orm import Session
from app.models.settings import Settings
def get_setting(db: Session, key: str) -> Optional[Settings]:
"""Get a setting by key."""
return db.query(Settings).filter(Settings.key == key).first()
def get_setting_value(db: Session, key: str, default: any = None) -> any:
"""Get a setting value by key, with optional default."""
setting = get_setting(db, key)
if setting:
return setting.get_value()
return default
def update_setting(db: Session, key: str, value: any) -> Settings:
"""Update or create a setting."""
setting = get_setting(db, key)
if setting is None:
setting = Settings(key=key)
db.add(setting)
# Determine if value is bool or string
if isinstance(value, bool):
setting.value_bool = value
setting.value_str = None
elif isinstance(value, str) and value.lower() in ('true', 'false'):
setting.value_bool = value.lower() == 'true'
setting.value_str = None
else:
setting.value_str = str(value)
setting.value_bool = None
db.commit()
db.refresh(setting)
return setting
def get_all_settings(db: Session) -> list[Settings]:
"""Get all settings."""
return db.query(Settings).all()

78
backend/app/crud/user.py Normal file
View File

@@ -0,0 +1,78 @@
"""CRUD operations for User model."""
from typing import Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
from app.core.security import get_password_hash, verify_password
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
"""CRUD operations for User model."""
def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
"""Get user by email."""
return db.query(User).filter(User.email == email).first()
def get_by_username(self, db: Session, *, username: str) -> Optional[User]:
"""Get user by username."""
return db.query(User).filter(User.username == username).first()
def create(self, db: Session, *, obj_in: UserCreate) -> User:
"""Create a new user with hashed password."""
db_obj = User(
username=obj_in.username,
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
is_active=obj_in.is_active,
is_superuser=obj_in.is_superuser,
)
# Set permissions if provided
if obj_in.permissions is not None:
db_obj.permissions = obj_in.permissions
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self, db: Session, *, db_obj: User, obj_in: UserUpdate
) -> User:
"""Update user, hashing password if provided."""
update_data = obj_in.model_dump(exclude_unset=True)
if "password" in update_data and update_data["password"]:
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
# Handle permissions separately since it uses a property setter
if "permissions" in update_data:
db_obj.permissions = update_data.pop("permissions")
return super().update(db, db_obj=db_obj, obj_in=update_data)
def authenticate(
self, db: Session, *, username: str, password: str
) -> Optional[User]:
"""Authenticate a user by username and password."""
user = self.get_by_username(db, username=username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(self, user: User) -> bool:
"""Check if user is active."""
return user.is_active
def is_superuser(self, user: User) -> bool:
"""Check if user is superuser."""
return user.is_superuser
# Create instance
user = CRUDUser(User)

View File

8
backend/app/db/base.py Normal file
View File

@@ -0,0 +1,8 @@
"""Base model class for all database models."""
from sqlalchemy.ext.declarative import declarative_base
# Base class for all SQLAlchemy models
Base = declarative_base()
# Note: Model imports have been moved to alembic/env.py to avoid circular imports

26
backend/app/db/session.py Normal file
View File

@@ -0,0 +1,26 @@
"""Database session management."""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.config import settings
# SQLite specific connection arguments
connect_args = {}
if settings.DATABASE_URL.startswith("sqlite"):
connect_args = {"check_same_thread": False}
# Create database engine
engine = create_engine(
settings.DATABASE_URL,
connect_args=connect_args,
pool_pre_ping=True, # Verify connections before using
echo=settings.DEBUG, # Log SQL queries in debug mode
)
# Create session factory
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine
)

View File

@@ -0,0 +1,82 @@
"""Shared dependencies for FastAPI dependency injection."""
from typing import Generator, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from jose import JWTError, jwt
from app.db.session import SessionLocal
from app.config import settings
from app.core.security import decode_access_token
from app import models, crud
# Database dependency
def get_db() -> Generator[Session, None, None]:
"""
Dependency that provides a database session.
Automatically closes the session after the request.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
# Security
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> models.User:
"""
Dependency that validates JWT token and returns current user.
Raises HTTPException if token is invalid or user not found.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(credentials.credentials)
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = crud.user.get(db, id=user_id)
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
return user
def get_current_active_superuser(
current_user: models.User = Depends(get_current_user)
) -> models.User:
"""
Dependency that requires the current user to be a superuser.
"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
# Alias for backward compatibility
get_current_superuser = get_current_active_superuser

195
backend/app/main.py Normal file
View File

@@ -0,0 +1,195 @@
"""
App Service - FastAPI Backend
Main application entry point.
"""
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
import logging
import time
from app.config import settings
from app.api.v1 import router as api_v1_router
# from app.api.websocket import router as websocket_router # TODO: Create later
from app.db.session import engine
from app.db.base import Base
# Static files path
STATIC_DIR = Path(__file__).parent.parent / "static"
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL.upper()),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Create FastAPI application
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
description="Modern web application for service management",
docs_url="/docs",
redoc_url="/redoc",
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json"
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.all_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Request logging middleware
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Log all incoming requests with timing information."""
start_time = time.time()
# Process request
response = await call_next(request)
# Calculate duration
process_time = time.time() - start_time
# Log request
logger.info(
f"{request.method} {request.url.path} "
f"completed in {process_time:.3f}s "
f"with status {response.status_code}"
)
# Add timing header
response.headers["X-Process-Time"] = str(process_time)
return response
# Exception handlers
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Handle uncaught exceptions."""
logger.error(f"Unhandled exception: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"detail": "Internal server error" if not settings.DEBUG else str(exc)
}
)
# API routers
app.include_router(api_v1_router, prefix=settings.API_V1_PREFIX)
# app.include_router(websocket_router) # TODO: Add WebSocket router
# Health check endpoint
@app.get("/health", tags=["System"])
async def health_check():
"""Health check endpoint for monitoring."""
return {
"status": "healthy",
"app_name": settings.APP_NAME,
"version": settings.APP_VERSION
}
# Root endpoint
@app.get("/", tags=["System"])
async def root():
"""Root endpoint - serve frontend or API info."""
# If frontend exists, serve it
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
# Otherwise return API info
return {
"message": "App Service API",
"version": settings.APP_VERSION,
"docs": "/docs",
"api": settings.API_V1_PREFIX
}
# Startup event
@app.on_event("startup")
async def startup_event():
"""Run on application startup."""
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
logger.info(f"Debug mode: {settings.DEBUG}")
logger.info(f"Database: {settings.DATABASE_URL.split('@')[1] if '@' in settings.DATABASE_URL else 'configured'}")
# Create tables if they don't exist (for development)
# In production, use Alembic migrations
if settings.DEBUG:
logger.info("Creating database tables (debug mode)")
Base.metadata.create_all(bind=engine)
# Seed default settings from registry
from sqlalchemy.orm import Session
from app import crud
from app.core.settings_registry import get_database_defaults
with Session(engine) as db:
# Get all database settings with defaults from registry
default_settings = get_database_defaults()
for key, value in default_settings.items():
if crud.settings.get_setting(db, key) is None:
logger.info(f"Seeding default setting: {key}={value}")
crud.settings.update_setting(db, key, value)
# Shutdown event
@app.on_event("shutdown")
async def shutdown_event():
"""Run on application shutdown."""
logger.info(f"Shutting down {settings.APP_NAME}")
# Serve static files (frontend) - must be after API routes
if STATIC_DIR.exists():
# Mount static assets (JS, CSS, images)
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
# SPA catch-all: serve index.html for all non-API routes
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(request: Request, full_path: str):
"""Serve the SPA for all non-API routes."""
# Skip API routes
if full_path.startswith("api/") or full_path in ["docs", "redoc", "health", "openapi.json"]:
return JSONResponse(status_code=404, content={"detail": "Not found"})
# Check if file exists in static dir
file_path = STATIC_DIR / full_path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
# Serve index.html for SPA routing
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
return JSONResponse(status_code=404, content={"detail": "Not found"})
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG,
log_level=settings.LOG_LEVEL.lower()
)

View File

@@ -0,0 +1,6 @@
"""Models package."""
from app.models.user import User
from app.models.settings import Settings
__all__ = ["User", "Settings"]

View File

@@ -0,0 +1,25 @@
"""Settings model for application configuration."""
from sqlalchemy import Column, String, Boolean, Integer, DateTime
from sqlalchemy.sql import func
from app.db.base import Base
class Settings(Base):
"""Settings model for storing application-wide configuration."""
__tablename__ = "settings"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
key = Column(String, unique=True, index=True, nullable=False)
value_bool = Column(Boolean, nullable=True)
value_str = Column(String, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
def get_value(self):
"""Get the actual value (bool or string)."""
if self.value_bool is not None:
return bool(self.value_bool)
return self.value_str

View File

@@ -0,0 +1,53 @@
"""User database model."""
import json
import uuid
from sqlalchemy import Column, String, Boolean, Text
from sqlalchemy.sql import func
from sqlalchemy.types import DateTime
from app.db.base import Base
class User(Base):
"""User model for authentication and authorization."""
__tablename__ = "users"
# Using String(36) for UUID to support SQLite
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username = Column(String(100), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
is_superuser = Column(Boolean, default=False, nullable=False)
# User permissions for modules (JSON stored as text for SQLite compatibility)
# Format: {"playlists": true, "downloads": false, "chromecast": true}
# null means inherit from global settings (all enabled by default)
_permissions = Column("permissions", Text, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
last_login = Column(DateTime, nullable=True)
@property
def permissions(self) -> dict:
"""Get permissions as a dictionary."""
if self._permissions:
try:
return json.loads(self._permissions)
except json.JSONDecodeError:
return {}
return {}
@permissions.setter
def permissions(self, value: dict):
"""Set permissions from a dictionary."""
if value is None:
self._permissions = None
else:
self._permissions = json.dumps(value)
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}', email='{self.email}')>"

View File

@@ -0,0 +1,18 @@
"""Schemas package - exports all Pydantic schemas."""
from app.schemas.user import User, UserCreate, UserUpdate, UserInDB
from app.schemas.auth import Token, TokenData, LoginRequest, RegisterRequest
from app.schemas.settings import Setting, SettingUpdate
__all__ = [
"User",
"UserCreate",
"UserUpdate",
"UserInDB",
"Token",
"TokenData",
"LoginRequest",
"RegisterRequest",
"Setting",
"SettingUpdate",
]

View File

@@ -0,0 +1,41 @@
"""Pydantic schemas for authentication requests/responses."""
from typing import Optional
from pydantic import BaseModel, Field, EmailStr
class Token(BaseModel):
"""JWT token response schema."""
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
"""Token payload data schema."""
user_id: Optional[str] = None
class LoginRequest(BaseModel):
"""Login request schema."""
username: str = Field(..., min_length=3, max_length=100)
password: str = Field(..., min_length=1)
class RegisterRequest(BaseModel):
"""Registration request schema."""
username: str = Field(..., min_length=3, max_length=100)
email: EmailStr = Field(..., description="Valid email address")
password: str = Field(..., min_length=8, description="Password must be at least 8 characters")
class Config:
json_schema_extra = {
"example": {
"username": "johndoe",
"email": "john@example.com",
"password": "securepassword123"
}
}

View File

@@ -0,0 +1,40 @@
"""Common Pydantic schemas used across the application."""
from typing import Optional, Generic, TypeVar, List
from pydantic import BaseModel
DataT = TypeVar('DataT')
class PaginatedResponse(BaseModel, Generic[DataT]):
"""Generic paginated response schema."""
items: List[DataT]
total: int
page: int
limit: int
pages: int
class MessageResponse(BaseModel):
"""Simple message response schema."""
message: str
detail: Optional[str] = None
class ErrorResponse(BaseModel):
"""Error response schema."""
error: str
detail: Optional[str] = None
status_code: int
class TokenResponse(BaseModel):
"""JWT token response schema."""
access_token: str
token_type: str = "bearer"
expires_in: int # seconds

View File

@@ -0,0 +1,35 @@
"""Settings schemas."""
from pydantic import BaseModel
from typing import Optional, Any
from datetime import datetime
class SettingBase(BaseModel):
"""Base setting schema."""
key: str
value: Any
class SettingUpdate(BaseModel):
"""Schema for updating a setting."""
value: Any
class Setting(BaseModel):
"""Setting schema for responses."""
key: str
value: Any
updated_at: datetime
class Config:
from_attributes = True
@classmethod
def from_orm(cls, obj):
"""Custom from_orm to handle value extraction."""
return cls(
key=obj.key,
value=obj.get_value(),
updated_at=obj.updated_at
)

View File

@@ -0,0 +1,61 @@
"""Pydantic schemas for User API requests/responses."""
from datetime import datetime
from typing import Optional, Dict
from pydantic import BaseModel, EmailStr, Field
# Shared properties
class UserBase(BaseModel):
"""Base user schema with common fields."""
username: Optional[str] = Field(None, min_length=3, max_length=100)
email: Optional[EmailStr] = None
is_active: Optional[bool] = True
is_superuser: Optional[bool] = False
# Properties to receive via API on creation
class UserCreate(UserBase):
"""Schema for user creation."""
username: str = Field(..., min_length=3, max_length=100)
email: EmailStr
password: str = Field(..., min_length=8)
permissions: Optional[Dict[str, bool]] = None
# Properties to receive via API on update
class UserUpdate(UserBase):
"""Schema for user update."""
password: Optional[str] = Field(None, min_length=8)
permissions: Optional[Dict[str, bool]] = None
# Properties shared by models stored in DB
class UserInDBBase(UserBase):
"""Schema for user in database (base)."""
id: str # UUID stored as string for SQLite compatibility
permissions: Dict[str, bool] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
last_login: Optional[datetime] = None
class Config:
from_attributes = True # Pydantic v2 (was orm_mode in v1)
# Additional properties to return via API
class User(UserInDBBase):
"""Schema for user response."""
pass
# Additional properties stored in DB
class UserInDB(UserInDBBase):
"""Schema for user with hashed password."""
hashed_password: str

41
backend/requirements.txt Normal file
View File

@@ -0,0 +1,41 @@
# FastAPI and ASGI server
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# Database (SQLite is built-in with Python)
sqlalchemy==2.0.25
alembic==1.13.1
aiosqlite==0.19.0 # Async SQLite support
# Pydantic for validation
pydantic==2.5.3
pydantic-settings==2.1.0
email-validator==2.1.0 # For EmailStr validation
# Authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1 # Pin bcrypt version for passlib compatibility
python-multipart==0.0.6
# HTTP requests
requests==2.31.0
httpx==0.26.0
# WebSocket
python-socketio==5.11.0
python-engineio==4.9.0
# Utilities
python-dotenv==1.0.0
click==8.1.7
# CORS
fastapi-cors==0.0.6
# Testing
pytest==7.4.4
pytest-asyncio==0.23.3
pytest-cov==4.1.0
httpx==0.26.0 # For testing async clients