Files
signal-platform/app/services/admin_service.py
Dennis Thiessen 61ab24490d
Some checks failed
Deploy / lint (push) Failing after 7s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped
first commit
2026-02-20 17:31:01 +01:00

239 lines
7.9 KiB
Python

"""Admin service: user management, system settings, data cleanup, job control."""
from datetime import datetime, timedelta, timezone
from passlib.hash import bcrypt
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import DuplicateError, NotFoundError, ValidationError
from app.models.fundamental import FundamentalData
from app.models.ohlcv import OHLCVRecord
from app.models.sentiment import SentimentScore
from app.models.settings import SystemSetting
from app.models.user import User
# ---------------------------------------------------------------------------
# User management
# ---------------------------------------------------------------------------
async def list_users(db: AsyncSession) -> list[User]:
"""Return all users ordered by id."""
result = await db.execute(select(User).order_by(User.id))
return list(result.scalars().all())
async def create_user(
db: AsyncSession,
username: str,
password: str,
role: str = "user",
has_access: bool = False,
) -> User:
"""Create a new user account (admin action)."""
result = await db.execute(select(User).where(User.username == username))
if result.scalar_one_or_none() is not None:
raise DuplicateError(f"Username already exists: {username}")
user = User(
username=username,
password_hash=bcrypt.hash(password),
role=role,
has_access=has_access,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def set_user_access(db: AsyncSession, user_id: int, has_access: bool) -> User:
"""Grant or revoke API access for a user."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise NotFoundError(f"User not found: {user_id}")
user.has_access = has_access
await db.commit()
await db.refresh(user)
return user
async def reset_password(db: AsyncSession, user_id: int, new_password: str) -> User:
"""Reset a user's password."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise NotFoundError(f"User not found: {user_id}")
user.password_hash = bcrypt.hash(new_password)
await db.commit()
await db.refresh(user)
return user
# ---------------------------------------------------------------------------
# Registration toggle
# ---------------------------------------------------------------------------
async def toggle_registration(db: AsyncSession, enabled: bool) -> SystemSetting:
"""Enable or disable user registration via SystemSetting."""
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == "registration_enabled")
)
setting = result.scalar_one_or_none()
value = str(enabled).lower()
if setting is None:
setting = SystemSetting(key="registration_enabled", value=value)
db.add(setting)
else:
setting.value = value
await db.commit()
await db.refresh(setting)
return setting
# ---------------------------------------------------------------------------
# System settings CRUD
# ---------------------------------------------------------------------------
async def list_settings(db: AsyncSession) -> list[SystemSetting]:
"""Return all system settings."""
result = await db.execute(select(SystemSetting).order_by(SystemSetting.key))
return list(result.scalars().all())
async def update_setting(db: AsyncSession, key: str, value: str) -> SystemSetting:
"""Create or update a system setting."""
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
if setting is None:
setting = SystemSetting(key=key, value=value)
db.add(setting)
else:
setting.value = value
await db.commit()
await db.refresh(setting)
return setting
# ---------------------------------------------------------------------------
# Data cleanup
# ---------------------------------------------------------------------------
async def cleanup_data(db: AsyncSession, older_than_days: int) -> dict[str, int]:
"""Delete OHLCV, sentiment, and fundamental records older than N days.
Preserves tickers, users, and latest scores.
Returns a dict with counts of deleted records per table.
"""
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
counts: dict[str, int] = {}
# OHLCV — date column is a date, compare with cutoff date
result = await db.execute(
delete(OHLCVRecord).where(OHLCVRecord.date < cutoff.date())
)
counts["ohlcv"] = result.rowcount # type: ignore[assignment]
# Sentiment — timestamp is datetime
result = await db.execute(
delete(SentimentScore).where(SentimentScore.timestamp < cutoff)
)
counts["sentiment"] = result.rowcount # type: ignore[assignment]
# Fundamentals — fetched_at is datetime
result = await db.execute(
delete(FundamentalData).where(FundamentalData.fetched_at < cutoff)
)
counts["fundamentals"] = result.rowcount # type: ignore[assignment]
await db.commit()
return counts
# ---------------------------------------------------------------------------
# Job control (placeholder — scheduler is Task 12.1)
# ---------------------------------------------------------------------------
VALID_JOB_NAMES = {"data_collector", "sentiment_collector", "fundamental_collector", "rr_scanner"}
JOB_LABELS = {
"data_collector": "Data Collector (OHLCV)",
"sentiment_collector": "Sentiment Collector",
"fundamental_collector": "Fundamental Collector",
"rr_scanner": "R:R Scanner",
}
async def list_jobs(db: AsyncSession) -> list[dict]:
"""Return status of all scheduled jobs."""
from app.scheduler import scheduler
jobs_out = []
for name in sorted(VALID_JOB_NAMES):
# Check enabled setting
key = f"job_{name}_enabled"
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
enabled = setting.value == "true" if setting else True # default enabled
# Get scheduler job info
job = scheduler.get_job(name)
next_run = None
if job and job.next_run_time:
next_run = job.next_run_time.isoformat()
jobs_out.append({
"name": name,
"label": JOB_LABELS.get(name, name),
"enabled": enabled,
"next_run_at": next_run,
"registered": job is not None,
})
return jobs_out
async def trigger_job(db: AsyncSession, job_name: str) -> dict[str, str]:
"""Trigger a manual job run via the scheduler.
Runs the job immediately (in addition to its regular schedule).
"""
if job_name not in VALID_JOB_NAMES:
raise ValidationError(f"Unknown job: {job_name}. Valid jobs: {', '.join(sorted(VALID_JOB_NAMES))}")
from app.scheduler import scheduler
job = scheduler.get_job(job_name)
if job is None:
return {"job": job_name, "status": "not_found", "message": f"Job '{job_name}' is not registered in the scheduler"}
job.modify(next_run_time=None) # Reset, then trigger immediately
from datetime import datetime, timezone
job.modify(next_run_time=datetime.now(timezone.utc))
return {"job": job_name, "status": "triggered", "message": f"Job '{job_name}' triggered for immediate execution"}
async def toggle_job(db: AsyncSession, job_name: str, enabled: bool) -> SystemSetting:
"""Enable or disable a scheduled job by storing state in SystemSetting.
Actual scheduler integration happens in Task 12.1.
"""
if job_name not in VALID_JOB_NAMES:
raise ValidationError(f"Unknown job: {job_name}. Valid jobs: {', '.join(sorted(VALID_JOB_NAMES))}")
key = f"job_{job_name}_enabled"
return await update_setting(db, key, str(enabled).lower())