Initial commit
This commit is contained in:
32
backend/Dockerfile
Normal file
32
backend/Dockerfile
Normal 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
88
backend/alembic.ini
Normal 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
88
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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"}
|
||||
45
backend/alembic/versions/001_initial_schema.py
Normal file
45
backend/alembic/versions/001_initial_schema.py
Normal 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')
|
||||
41
backend/alembic/versions/002_add_settings_table.py
Normal file
41
backend/alembic/versions/002_add_settings_table.py
Normal 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')
|
||||
28
backend/alembic/versions/004_add_permissions_to_users.py
Normal file
28
backend/alembic/versions/004_add_permissions_to_users.py
Normal 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')
|
||||
@@ -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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
5
backend/app/api/v1/__init__.py
Normal file
5
backend/app/api/v1/__init__.py
Normal 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
155
backend/app/api/v1/auth.py
Normal 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}
|
||||
34
backend/app/api/v1/health.py
Normal file
34
backend/app/api/v1/health.py
Normal 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
|
||||
}
|
||||
15
backend/app/api/v1/router.py
Normal file
15
backend/app/api/v1/router.py
Normal 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"])
|
||||
184
backend/app/api/v1/settings.py
Normal file
184
backend/app/api/v1/settings.py
Normal 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
160
backend/app/api/v1/users.py
Normal 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
48
backend/app/config.py
Normal 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()
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
21
backend/app/core/exceptions.py
Normal file
21
backend/app/core/exceptions.py
Normal 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
|
||||
94
backend/app/core/security.py
Normal file
94
backend/app/core/security.py
Normal 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
|
||||
601
backend/app/core/settings_registry.py
Normal file
601
backend/app/core/settings_registry.py
Normal 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}")
|
||||
6
backend/app/crud/__init__.py
Normal file
6
backend/app/crud/__init__.py
Normal 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
81
backend/app/crud/base.py
Normal 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)
|
||||
48
backend/app/crud/settings.py
Normal file
48
backend/app/crud/settings.py
Normal 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
78
backend/app/crud/user.py
Normal 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)
|
||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
8
backend/app/db/base.py
Normal file
8
backend/app/db/base.py
Normal 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
26
backend/app/db/session.py
Normal 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
|
||||
)
|
||||
82
backend/app/dependencies.py
Normal file
82
backend/app/dependencies.py
Normal 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
195
backend/app/main.py
Normal 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()
|
||||
)
|
||||
6
backend/app/models/__init__.py
Normal file
6
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Models package."""
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.settings import Settings
|
||||
|
||||
__all__ = ["User", "Settings"]
|
||||
25
backend/app/models/settings.py
Normal file
25
backend/app/models/settings.py
Normal 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
|
||||
53
backend/app/models/user.py
Normal file
53
backend/app/models/user.py
Normal 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}')>"
|
||||
18
backend/app/schemas/__init__.py
Normal file
18
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
41
backend/app/schemas/auth.py
Normal file
41
backend/app/schemas/auth.py
Normal 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"
|
||||
}
|
||||
}
|
||||
40
backend/app/schemas/common.py
Normal file
40
backend/app/schemas/common.py
Normal 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
|
||||
35
backend/app/schemas/settings.py
Normal file
35
backend/app/schemas/settings.py
Normal 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
|
||||
)
|
||||
61
backend/app/schemas/user.py
Normal file
61
backend/app/schemas/user.py
Normal 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
41
backend/requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user