|
@@ -3,20 +3,21 @@ from fastapi.templating import Jinja2Templates
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
-from sqlalchemy import select
|
|
|
|
|
|
|
+from sqlalchemy import select, func
|
|
|
from contextlib import asynccontextmanager
|
|
from contextlib import asynccontextmanager
|
|
|
import json
|
|
import json
|
|
|
from datetime import datetime
|
|
from datetime import datetime
|
|
|
from typing import Optional
|
|
from typing import Optional
|
|
|
|
|
|
|
|
from app.database import init_db, get_db
|
|
from app.database import init_db, get_db
|
|
|
-from app.models import Book, ListeningSession, Recommendation, User
|
|
|
|
|
|
|
+from app.models import Book, ListeningSession, Recommendation, User, AppSettings
|
|
|
from app.abs_client import get_abs_client
|
|
from app.abs_client import get_abs_client
|
|
|
from app.recommender import BookRecommender
|
|
from app.recommender import BookRecommender
|
|
|
from app.config import get_settings
|
|
from app.config import get_settings
|
|
|
from app.auth import (
|
|
from app.auth import (
|
|
|
get_current_user,
|
|
get_current_user,
|
|
|
get_current_user_optional,
|
|
get_current_user_optional,
|
|
|
|
|
+ get_current_admin,
|
|
|
authenticate_user,
|
|
authenticate_user,
|
|
|
create_user,
|
|
create_user,
|
|
|
set_session_cookie,
|
|
set_session_cookie,
|
|
@@ -98,7 +99,20 @@ async def home(
|
|
|
.order_by(Recommendation.created_at.desc())
|
|
.order_by(Recommendation.created_at.desc())
|
|
|
.limit(5)
|
|
.limit(5)
|
|
|
)
|
|
)
|
|
|
- recommendations = recs_result.scalars().all()
|
|
|
|
|
|
|
+ recommendations_raw = recs_result.scalars().all()
|
|
|
|
|
+
|
|
|
|
|
+ # Parse JSON fields for template
|
|
|
|
|
+ recommendations = []
|
|
|
|
|
+ for rec in recommendations_raw:
|
|
|
|
|
+ rec_dict = {
|
|
|
|
|
+ "id": rec.id,
|
|
|
|
|
+ "title": rec.title,
|
|
|
|
|
+ "author": rec.author,
|
|
|
|
|
+ "description": rec.description,
|
|
|
|
|
+ "reason": rec.reason,
|
|
|
|
|
+ "genres": json.loads(rec.genres) if rec.genres else []
|
|
|
|
|
+ }
|
|
|
|
|
+ recommendations.append(rec_dict)
|
|
|
|
|
|
|
|
return templates.TemplateResponse(
|
|
return templates.TemplateResponse(
|
|
|
"index.html",
|
|
"index.html",
|
|
@@ -122,7 +136,6 @@ async def login_page(request: Request):
|
|
|
|
|
|
|
|
@app.post("/api/auth/login")
|
|
@app.post("/api/auth/login")
|
|
|
async def login(
|
|
async def login(
|
|
|
- response: Response,
|
|
|
|
|
username: str = Form(...),
|
|
username: str = Form(...),
|
|
|
password: str = Form(...),
|
|
password: str = Form(...),
|
|
|
db: AsyncSession = Depends(get_db)
|
|
db: AsyncSession = Depends(get_db)
|
|
@@ -136,18 +149,11 @@ async def login(
|
|
|
detail="Incorrect username or password"
|
|
detail="Incorrect username or password"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- # Set session cookie
|
|
|
|
|
- set_session_cookie(response, user.id)
|
|
|
|
|
|
|
+ # Create redirect response and set session cookie
|
|
|
|
|
+ redirect = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
+ set_session_cookie(redirect, user.id)
|
|
|
|
|
|
|
|
- return JSONResponse({
|
|
|
|
|
- "status": "success",
|
|
|
|
|
- "message": "Logged in successfully",
|
|
|
|
|
- "user": {
|
|
|
|
|
- "username": user.username,
|
|
|
|
|
- "email": user.email,
|
|
|
|
|
- "display_name": user.display_name
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ return redirect
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/register", response_class=HTMLResponse)
|
|
@app.get("/register", response_class=HTMLResponse)
|
|
@@ -158,7 +164,6 @@ async def register_page(request: Request):
|
|
|
|
|
|
|
|
@app.post("/api/auth/register")
|
|
@app.post("/api/auth/register")
|
|
|
async def register(
|
|
async def register(
|
|
|
- response: Response,
|
|
|
|
|
username: str = Form(...),
|
|
username: str = Form(...),
|
|
|
email: str = Form(...),
|
|
email: str = Form(...),
|
|
|
password: str = Form(...),
|
|
password: str = Form(...),
|
|
@@ -169,6 +174,22 @@ async def register(
|
|
|
):
|
|
):
|
|
|
"""Register a new user."""
|
|
"""Register a new user."""
|
|
|
try:
|
|
try:
|
|
|
|
|
+ # Check if registration is allowed
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(AppSettings).where(AppSettings.key == "allow_registration")
|
|
|
|
|
+ )
|
|
|
|
|
+ allow_reg_setting = result.scalar_one_or_none()
|
|
|
|
|
+
|
|
|
|
|
+ # Check if there are any existing users (first user is always allowed)
|
|
|
|
|
+ result = await db.execute(select(func.count(User.id)))
|
|
|
|
|
+ user_count = result.scalar()
|
|
|
|
|
+
|
|
|
|
|
+ if user_count > 0 and allow_reg_setting and allow_reg_setting.value.lower() != 'true':
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
+ detail="Registration is currently disabled"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
user = await create_user(
|
|
user = await create_user(
|
|
|
db=db,
|
|
db=db,
|
|
|
username=username,
|
|
username=username,
|
|
@@ -179,18 +200,11 @@ async def register(
|
|
|
display_name=display_name
|
|
display_name=display_name
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- # Set session cookie
|
|
|
|
|
- set_session_cookie(response, user.id)
|
|
|
|
|
|
|
+ # Create redirect response and set session cookie
|
|
|
|
|
+ redirect = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
|
|
|
|
+ set_session_cookie(redirect, user.id)
|
|
|
|
|
|
|
|
- return JSONResponse({
|
|
|
|
|
- "status": "success",
|
|
|
|
|
- "message": "Account created successfully",
|
|
|
|
|
- "user": {
|
|
|
|
|
- "username": user.username,
|
|
|
|
|
- "email": user.email,
|
|
|
|
|
- "display_name": user.display_name
|
|
|
|
|
- }
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ return redirect
|
|
|
|
|
|
|
|
except HTTPException as e:
|
|
except HTTPException as e:
|
|
|
raise e
|
|
raise e
|
|
@@ -498,7 +512,7 @@ async def get_reading_stats(
|
|
|
end_dt = datetime.fromisoformat(end_date) if end_date else None
|
|
end_dt = datetime.fromisoformat(end_date) if end_date else None
|
|
|
|
|
|
|
|
# Calculate stats
|
|
# Calculate stats
|
|
|
- stats_service = ReadingStatsService(db, user.id)
|
|
|
|
|
|
|
+ stats_service = ReadingStatsService(db, user.id, user.abs_url)
|
|
|
stats = await stats_service.calculate_stats(start_dt, end_dt)
|
|
stats = await stats_service.calculate_stats(start_dt, end_dt)
|
|
|
|
|
|
|
|
return JSONResponse(stats)
|
|
return JSONResponse(stats)
|
|
@@ -560,6 +574,159 @@ async def update_session_rating(
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+# ==================== Admin Routes ====================
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/admin", response_class=HTMLResponse)
|
|
|
|
|
+async def admin_page(
|
|
|
|
|
+ request: Request,
|
|
|
|
|
+ user: User = Depends(get_current_admin)
|
|
|
|
|
+):
|
|
|
|
|
+ """Admin panel page."""
|
|
|
|
|
+ return templates.TemplateResponse(
|
|
|
|
|
+ "admin.html",
|
|
|
|
|
+ {
|
|
|
|
|
+ "request": request,
|
|
|
|
|
+ "user": user
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/api/admin/settings")
|
|
|
|
|
+async def get_admin_settings(
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ admin: User = Depends(get_current_admin)
|
|
|
|
|
+):
|
|
|
|
|
+ """Get application settings."""
|
|
|
|
|
+ result = await db.execute(select(AppSettings))
|
|
|
|
|
+ settings = result.scalars().all()
|
|
|
|
|
+
|
|
|
|
|
+ settings_dict = {s.key: s.value for s in settings}
|
|
|
|
|
+
|
|
|
|
|
+ return JSONResponse(settings_dict)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.put("/api/admin/settings/{key}")
|
|
|
|
|
+async def update_setting(
|
|
|
|
|
+ key: str,
|
|
|
|
|
+ value: str = Form(...),
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ admin: User = Depends(get_current_admin)
|
|
|
|
|
+):
|
|
|
|
|
+ """Update an application setting."""
|
|
|
|
|
+ result = await db.execute(
|
|
|
|
|
+ select(AppSettings).where(AppSettings.key == key)
|
|
|
|
|
+ )
|
|
|
|
|
+ setting = result.scalar_one_or_none()
|
|
|
|
|
+
|
|
|
|
|
+ if not setting:
|
|
|
|
|
+ # Create new setting
|
|
|
|
|
+ setting = AppSettings(key=key, value=value)
|
|
|
|
|
+ db.add(setting)
|
|
|
|
|
+ else:
|
|
|
|
|
+ # Update existing
|
|
|
|
|
+ setting.value = value
|
|
|
|
|
+
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+ return JSONResponse({
|
|
|
|
|
+ "status": "success",
|
|
|
|
|
+ "message": f"Setting {key} updated"
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.get("/api/admin/users")
|
|
|
|
|
+async def get_users(
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ admin: User = Depends(get_current_admin)
|
|
|
|
|
+):
|
|
|
|
|
+ """Get all users."""
|
|
|
|
|
+ result = await db.execute(select(User).order_by(User.created_at.desc()))
|
|
|
|
|
+ users = result.scalars().all()
|
|
|
|
|
+
|
|
|
|
|
+ users_list = [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": user.id,
|
|
|
|
|
+ "username": user.username,
|
|
|
|
|
+ "email": user.email,
|
|
|
|
|
+ "display_name": user.display_name,
|
|
|
|
|
+ "is_admin": user.is_admin,
|
|
|
|
|
+ "is_active": user.is_active,
|
|
|
|
|
+ "created_at": user.created_at.isoformat() if user.created_at else None,
|
|
|
|
|
+ "last_login": user.last_login.isoformat() if user.last_login else None,
|
|
|
|
|
+ "is_current": user.id == admin.id
|
|
|
|
|
+ }
|
|
|
|
|
+ for user in users
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ return JSONResponse({"users": users_list})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.put("/api/admin/users/{user_id}/admin")
|
|
|
|
|
+async def toggle_user_admin(
|
|
|
|
|
+ user_id: int,
|
|
|
|
|
+ is_admin: str = Form(...),
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ admin: User = Depends(get_current_admin)
|
|
|
|
|
+):
|
|
|
|
|
+ """Toggle admin status for a user."""
|
|
|
|
|
+ # Prevent admin from removing their own admin status
|
|
|
|
|
+ if user_id == admin.id:
|
|
|
|
|
+ return JSONResponse(
|
|
|
|
|
+ {"status": "error", "message": "Cannot modify your own admin status"},
|
|
|
|
|
+ status_code=400
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(select(User).where(User.id == user_id))
|
|
|
|
|
+ user = result.scalar_one_or_none()
|
|
|
|
|
+
|
|
|
|
|
+ if not user:
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
+ detail="User not found"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ user.is_admin = is_admin.lower() == 'true'
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+ return JSONResponse({
|
|
|
|
|
+ "status": "success",
|
|
|
|
|
+ "message": "User admin status updated"
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@app.delete("/api/admin/users/{user_id}")
|
|
|
|
|
+async def delete_user(
|
|
|
|
|
+ user_id: int,
|
|
|
|
|
+ db: AsyncSession = Depends(get_db),
|
|
|
|
|
+ admin: User = Depends(get_current_admin)
|
|
|
|
|
+):
|
|
|
|
|
+ """Delete a user."""
|
|
|
|
|
+ # Prevent admin from deleting themselves
|
|
|
|
|
+ if user_id == admin.id:
|
|
|
|
|
+ return JSONResponse(
|
|
|
|
|
+ {"status": "error", "message": "Cannot delete your own account"},
|
|
|
|
|
+ status_code=400
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ result = await db.execute(select(User).where(User.id == user_id))
|
|
|
|
|
+ user = result.scalar_one_or_none()
|
|
|
|
|
+
|
|
|
|
|
+ if not user:
|
|
|
|
|
+ raise HTTPException(
|
|
|
|
|
+ status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
+ detail="User not found"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ await db.delete(user)
|
|
|
|
|
+ await db.commit()
|
|
|
|
|
+
|
|
|
|
|
+ return JSONResponse({
|
|
|
|
|
+ "status": "success",
|
|
|
|
|
+ "message": "User deleted successfully"
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
@app.get("/health")
|
|
@app.get("/health")
|
|
|
async def health_check():
|
|
async def health_check():
|
|
|
"""Health check endpoint."""
|
|
"""Health check endpoint."""
|