Skip to content

Commit

Permalink
Implement ConfigSetting Module to store and manage DB based configura…
Browse files Browse the repository at this point in the history
…tion (#45)
  • Loading branch information
eloravpn authored Nov 22, 2024
1 parent 91a9d78 commit 87d3993
Show file tree
Hide file tree
Showing 12 changed files with 412 additions and 15 deletions.
18 changes: 15 additions & 3 deletions src/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import logging
import os
import signal
import sys

from apscheduler.schedulers.background import BackgroundScheduler
from fastapi import FastAPI, HTTPException, Request
from fastapi import FastAPI, HTTPException, Request, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi_responses import custom_openapi

from src.accounts.router import router as account_router
from src.admins.router import router as admin_router
from src.admins.schemas import Admin
from src.club.user_router import club_user_router
from src.commerce.router import (
order_router,
Expand All @@ -27,6 +30,7 @@
from src.notification.router import notification_router
from src.subscription.router import router as subscription_router
from src.users.router import router as user_router
from src.config_setting.router import router as config_setting_router
from src.users.schemas import UserResponse
from starlette.exceptions import HTTPException as StarletteHTTPException

Expand Down Expand Up @@ -88,6 +92,7 @@
app.include_router(notification_router, prefix="/api", tags=["Notification"])
app.include_router(monitoring_router, prefix="/api", tags=["Monitoring"])
app.include_router(club_user_router, prefix="/api", tags=["ClubUser"])
app.include_router(config_setting_router, prefix="/api", tags=["ConfigSettings"])

# Check if static folder exists
if os.path.exists(static_path) and os.path.isdir(static_path):
Expand Down Expand Up @@ -129,11 +134,18 @@ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPExce
from src.club import jobs # noqa


@app.post(path="/api/restart")
async def restart_server(admin: Admin = Depends(Admin.get_current)):
try:
os.kill(os.getpid(), signal.SIGTERM)
return JSONResponse({"message": "Server restarting..."})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))


@app.on_event("startup")
def on_startup():
scheduler.start()
# Base.metadata.drop_all(bind=engine)
# Base.metadata.create_all(bind=engine)
logger.info("Application started successfully!")


Expand Down
22 changes: 12 additions & 10 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from decouple import config
from dotenv import load_dotenv

from src.config_setting.utils import get_setting

load_dotenv()

# Disable IPv6
Expand All @@ -23,28 +25,28 @@
print("Failed to get SERVER_IP, using 127.0.0.1 instead")
SERVER_IP = "127.0.0.1"

SQLALCHEMY_DATABASE_URL = config(
"SQLALCHEMY_DATABASE_URL", default="sqlite:///db.sqlite3"
)

UVICORN_HOST = config("UVICORN_HOST", default="0.0.0.0")
UVICORN_PORT = config("UVICORN_PORT", cast=int, default=8000)
UVICORN_UDS = config("UVICORN_UDS", default=None)
UVICORN_SSL_CERTFILE = config("UVICORN_SSL_CERTFILE", default=None)
UVICORN_SSL_KEYFILE = config("UVICORN_SSL_KEYFILE", default=None)
UVICORN_SSL_CERTFILE = get_setting("UVICORN_SSL_CERTFILE", default=None)
UVICORN_SSL_KEYFILE = get_setting("UVICORN_SSL_KEYFILE", default=None)

TELEGRAM_API_TOKEN = config("TELEGRAM_API_TOKEN", default=None)
TELEGRAM_PAYMENT_API_TOKEN = config("TELEGRAM_PAYMENT_API_TOKEN", default=None)
TELEGRAM_ADMIN_ID = config("TELEGRAM_ADMIN_ID", cast=int, default=0)
TELEGRAM_ADMIN_USER_NAME = config("TELEGRAM_ADMIN_USER_NAME", default=None)
TELEGRAM_API_TOKEN = get_setting("TELEGRAM_API_TOKEN", cast=str, default=None)
TELEGRAM_PAYMENT_API_TOKEN = get_setting(
"TELEGRAM_PAYMENT_API_TOKEN", cast=str, default=None
)
TELEGRAM_ADMIN_ID = get_setting("TELEGRAM_ADMIN_ID", cast=int, default=0)
TELEGRAM_ADMIN_USER_NAME = get_setting("TELEGRAM_ADMIN_USER_NAME", default=None)
BOT_USER_NAME = config("BOT_USER_NAME", default="")
TELEGRAM_CHANNEL = config("TELEGRAM_CHANNEL", default=None)
TELEGRAM_PROXY_URL = config("TELEGRAM_PROXY_URL", default=None)

MINIMUM_PAYMENT_TO_TRUST_USER = config(
"MINIMUM_PAYMENT_TO_TRUST_USER", cast=int, default=200000
)
TRUST_CARD_NUMBER = config("TRUST_CARD_NUMBER", default="")
TRUST_CARD_OWNER = config("TRUST_CARD_OWNER", default="")

CARD_NUMBER = config("CARD_NUMBER", default="")
CARD_OWNER = config("CARD_OWNER", default="")

Expand Down
Empty file added src/config_setting/__init__.py
Empty file.
19 changes: 19 additions & 0 deletions src/config_setting/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from datetime import datetime

from sqlalchemy import Column, String, DateTime

from src.database import Base


class ConfigSetting(Base):
"""Database model for storing configuration values"""

__tablename__ = "config_settings"

key = Column(String, primary_key=True, index=True)
value = Column(String, nullable=True)
value_type = Column(String) # Store the type of value for proper casting
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

def __repr__(self):
return f"<ConfigSetting {self.key}={self.value} ({self.value_type})>"
73 changes: 73 additions & 0 deletions src/config_setting/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from fastapi import APIRouter, Depends, HTTPException, Body
from sqlalchemy.orm import Session
from typing import List

from .schemas import (
ConfigSettingCreate,
ConfigSettingResponse,
ConfigSettingsBulkUpdate,
)
from .service import get_all_setting, get_setting, set_setting, delete_setting
from .. import Admin
from ..database import get_db

router = APIRouter()


@router.get("/settings", response_model=List[ConfigSettingResponse])
async def list_configs(
db: Session = Depends(get_db), admin: Admin = Depends(Admin.get_current)
):
"""List all configurations"""
return get_all_setting(db)


@router.get("/settings/{key}", response_model=ConfigSettingResponse)
async def get_config(
key: str, db: Session = Depends(get_db), admin: Admin = Depends(Admin.get_current)
):
"""Get specific configuration"""
setting = get_setting(db, key)
if not setting:
raise HTTPException(status_code=404, detail="Configuration not found")
return setting


@router.post("/settings")
async def update_config(
config: ConfigSettingCreate,
db: Session = Depends(get_db),
admin: Admin = Depends(Admin.get_current),
):
"""Update or create configuration"""
set_setting(db, config.key, config.value)
return {"status": "success", "message": "Configuration Added"}


@router.put("/settings/{key}")
async def update_config(
config: ConfigSettingCreate,
db: Session = Depends(get_db),
admin: Admin = Depends(Admin.get_current),
):
"""Update or create configuration"""
set_setting(db, config.key, config.value)
return {"status": "success", "message": "Configuration Added"}


@router.post("/settings/bulk")
async def update_bulk_config(
configs: ConfigSettingsBulkUpdate, db: Session = Depends(get_db),admin: Admin = Depends(Admin.get_current)
):
"""Update or create configuration"""
for key, value in configs.settings.items():
set_setting(db, key, value)
return {"status": "success", "message": "Configurations updated"}


@router.delete("/settings/{key}")
async def delete_config(key: str, db: Session = Depends(get_db),admin: Admin = Depends(Admin.get_current)):
"""Delete configuration"""
if not delete_setting(db, key):
raise HTTPException(status_code=404, detail="Configuration not found")
return {"status": "success", "message": "Configuration deleted"}
25 changes: 25 additions & 0 deletions src/config_setting/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Pydantic models for API
from datetime import datetime
from typing import Optional, Any, Dict

from pydantic import BaseModel


class ConfigSettingCreate(BaseModel):
key: str
value: Any
description: Optional[str] = None


class ConfigSettingsBulkUpdate(BaseModel):
settings: Dict[str, Any]


class ConfigSettingResponse(BaseModel):
key: str
value: Any
value_type: str
updated_at: datetime

class Config:
orm_mode = True
95 changes: 95 additions & 0 deletions src/config_setting/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import json
from datetime import datetime
from typing import Optional, Any

from sqlalchemy.orm import Session

from .models import ConfigSetting


def get_all_setting(db: Session) -> Optional[Any]:
"""List all configurations"""
settings = db.query(ConfigSetting).all()
return settings


def get_setting(db: Session, key: str) -> Optional[Any]:
"""Get a configuration value from database"""

# Query database
setting = db.query(ConfigSetting).filter(ConfigSetting.key == key).first()
if setting:
try:
value = deserialize_value(setting.value, setting.value_type)
return value
except:
return None
return None


def set_setting(db: Session, key: str, value: Any) -> None:
"""Set a configuration value in database"""
value_type = get_value_type(value)
serialized_value = serialize_value(value)

setting = db.query(ConfigSetting).filter(ConfigSetting.key == key).first()
if setting:
setting.value = serialized_value
setting.value_type = value_type
setting.updated_at = datetime.utcnow()
else:
setting = ConfigSetting(key=key, value=serialized_value, value_type=value_type)
db.add(setting)

db.commit()


def delete_setting(db: Session, key: str) -> bool:
"""Delete a configuration value from database"""
setting = db.query(ConfigSetting).filter(ConfigSetting.key == key).first()
if setting:
db.delete(setting)
db.commit()
return True
return False


def get_value_type(value: Any) -> str:
"""Determine the type of a value"""
if value is None:
return "none"
elif isinstance(value, bool):
return "bool"
elif isinstance(value, int):
return "int"
elif isinstance(value, float):
return "float"
elif isinstance(value, (list, tuple)):
return "list"
elif isinstance(value, dict):
return "dict"
return "str"


def serialize_value(value: Any) -> str:
"""Serialize value for storage"""
if value is None:
return "None"
if isinstance(value, (list, dict)):
return json.dumps(value)
return str(value)


def deserialize_value(value: str, value_type: str) -> Any:
"""Deserialize value from storage"""
if value_type == "none" or value == "None":
return None
elif value_type == "bool":
return value.lower() in ("true", "1", "yes", "on", "t")
elif value_type == "int":
return int(value)
elif value_type == "float":
return float(value)
elif value_type in ("list", "dict"):
return json.loads(value)
return value
78 changes: 78 additions & 0 deletions src/config_setting/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import json
from typing import Any, Optional, Callable, TypeVar, Union

from decouple import config as decouple_config
from exceptiongroup import catch

from src.database import GetDB

from src.config_setting import service as config_setting_service

T = TypeVar("T")


def get_config(
key: str,
default: Any = None,
cast: Optional[Callable[[Any], T]] = None,
) -> Union[T, Any]:
"""
Get configuration value with better type casting and None handling.
Args:
key: The configuration key to look up
default: Default value if key is not found or value is invalid
cast: Optional function to cast the value to a specific type
"""
try:
value = decouple_config(key, default=None)

# Handle cases where value is None or 'None' string
if value is None or (isinstance(value, str) and value.lower() == "none"):
return default

# Now value is not None, handle casting
if cast is None:
return value

# Special handling for boolean values
if cast is bool:
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "on", "t")
return bool(value)

# Try to cast the value
try:
return cast(value)
except (ValueError, TypeError):
return default

except Exception:
return default


def get_setting(key: str, default: Any = None, cast: Optional[type] = None) -> Any:
"""
Get configuration value with database override capability.
First checks database, falls back to environment variables if not found.
Args:
key: Configuration key to look up
default: Default value if key is not found
cast: Optional type casting function
"""

value = None

# Try to get from database first
try:
with GetDB() as db:
value = config_setting_service.get_setting(db, key)
except Exception:
pass

# If not in database, fall back to environment
if value is None:
value = get_config(key, default, cast)

return value
Loading

0 comments on commit 87d3993

Please sign in to comment.