feat(main): main
This commit is contained in:
105
api/routers/auth.py
Normal file
105
api/routers/auth.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from ninja import Router, Schema
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.http import JsonResponse
|
||||
from django.conf import settings
|
||||
from ninja.errors import HttpError
|
||||
from pydantic import BaseModel
|
||||
from core.models import AppUser
|
||||
|
||||
router = Router(tags=["auth"])
|
||||
|
||||
class LoginSchema(Schema):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class UserSchema(Schema):
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
is_staff: bool
|
||||
is_superuser: bool
|
||||
|
||||
class SetupSchema(Schema):
|
||||
username: str
|
||||
password: str
|
||||
email: str
|
||||
|
||||
@router.get("/has-users")
|
||||
def check_has_users(request):
|
||||
"""Returns True if any users exist in the database."""
|
||||
return {"has_users": AppUser.objects.exists()}
|
||||
|
||||
@router.post("/setup")
|
||||
def setup_admin(request, payload: SetupSchema):
|
||||
"""Allows creating a superuser if and only if the database is entirely empty."""
|
||||
if AppUser.objects.exists():
|
||||
raise HttpError(403, "Database already has users. Cannot run initial setup.")
|
||||
|
||||
user = AppUser.objects.create_superuser(
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
password=payload.password
|
||||
)
|
||||
|
||||
# Bootstrap a default library now that the first user exists.
|
||||
from core.services.bootstrap import ensure_default_library
|
||||
ensure_default_library()
|
||||
|
||||
# Log them in automatically
|
||||
user = authenticate(request, username=payload.username, password=payload.password)
|
||||
login(request, user)
|
||||
request.session.set_expiry(settings.SESSION_COOKIE_AGE)
|
||||
|
||||
from django.middleware.csrf import get_token
|
||||
csrf_token = get_token(request)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_staff": user.is_staff,
|
||||
"is_superuser": user.is_superuser
|
||||
},
|
||||
"csrf_token": csrf_token
|
||||
}
|
||||
|
||||
@router.post("/login")
|
||||
def auth_login(request, payload: LoginSchema):
|
||||
# Passwords in django are already PBKDF2 hashed and verified through `authenticate`
|
||||
user = authenticate(request, username=payload.username, password=payload.password)
|
||||
|
||||
if user is not None:
|
||||
login(request, user)
|
||||
request.session.set_expiry(settings.SESSION_COOKIE_AGE)
|
||||
# We also need to get the CSRF token to be placed in the cookie so the frontend can send it back natively.
|
||||
from django.middleware.csrf import get_token
|
||||
csrf_token = get_token(request)
|
||||
return {
|
||||
"success": True,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"is_staff": user.is_staff,
|
||||
"is_superuser": user.is_superuser
|
||||
},
|
||||
"csrf_token": csrf_token
|
||||
}
|
||||
else:
|
||||
raise HttpError(401, "Invalid username or password")
|
||||
|
||||
@router.post("/logout")
|
||||
def auth_logout(request):
|
||||
logout(request)
|
||||
resp = JsonResponse({"success": True})
|
||||
resp.delete_cookie(settings.SESSION_COOKIE_NAME, path='/')
|
||||
return resp
|
||||
|
||||
@router.get("/me", response=UserSchema)
|
||||
def auth_me(request):
|
||||
if request.user.is_authenticated:
|
||||
return request.user
|
||||
else:
|
||||
raise HttpError(401, "Not authenticated")
|
||||
156
api/routers/backups.py
Normal file
156
api/routers/backups.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.http import FileResponse
|
||||
from ninja import File, Router, Schema
|
||||
from ninja.errors import HttpError
|
||||
from ninja.files import UploadedFile
|
||||
|
||||
from core.models import BackupSchedule
|
||||
from core.services.backups import (
|
||||
compute_next_run,
|
||||
create_backup_now,
|
||||
get_backup_file,
|
||||
import_backup_content,
|
||||
is_backup_due,
|
||||
list_backups,
|
||||
)
|
||||
|
||||
router = Router(tags=["backups"])
|
||||
|
||||
|
||||
class BackupScheduleSchema(Schema):
|
||||
enabled: bool
|
||||
frequency: str
|
||||
minute: int
|
||||
hour: int
|
||||
day_of_week: int
|
||||
day_of_month: int
|
||||
retention_count: int
|
||||
last_run_at: datetime | None = None
|
||||
next_run_at: datetime | None = None
|
||||
|
||||
|
||||
class BackupScheduleUpdateSchema(Schema):
|
||||
enabled: bool
|
||||
frequency: str
|
||||
minute: int = 0
|
||||
hour: int = 2
|
||||
day_of_week: int = 0
|
||||
day_of_month: int = 1
|
||||
retention_count: int = 14
|
||||
|
||||
|
||||
class BackupFileSchema(Schema):
|
||||
filename: str
|
||||
size_bytes: int
|
||||
created_at: str
|
||||
|
||||
|
||||
def _require_admin(request):
|
||||
if not request.user.is_authenticated:
|
||||
raise HttpError(401, "Authentication required.")
|
||||
if not request.user.is_staff:
|
||||
raise HttpError(403, "Admin permissions required.")
|
||||
|
||||
|
||||
@router.get("/settings", response=BackupScheduleSchema)
|
||||
def get_backup_schedule(request):
|
||||
_require_admin(request)
|
||||
schedule = BackupSchedule.get_solo()
|
||||
return {
|
||||
"enabled": schedule.enabled,
|
||||
"frequency": schedule.frequency,
|
||||
"minute": schedule.minute,
|
||||
"hour": schedule.hour,
|
||||
"day_of_week": schedule.day_of_week,
|
||||
"day_of_month": schedule.day_of_month,
|
||||
"retention_count": schedule.retention_count,
|
||||
"last_run_at": schedule.last_run_at,
|
||||
"next_run_at": compute_next_run(schedule),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/settings", response=BackupScheduleSchema)
|
||||
def update_backup_schedule(request, payload: BackupScheduleUpdateSchema):
|
||||
_require_admin(request)
|
||||
if payload.frequency not in BackupSchedule.Frequency.values:
|
||||
raise HttpError(400, "Invalid frequency")
|
||||
if not 0 <= payload.minute <= 59:
|
||||
raise HttpError(400, "minute must be 0..59")
|
||||
if not 0 <= payload.hour <= 23:
|
||||
raise HttpError(400, "hour must be 0..23")
|
||||
if not 0 <= payload.day_of_week <= 6:
|
||||
raise HttpError(400, "day_of_week must be 0..6")
|
||||
if not 1 <= payload.day_of_month <= 28:
|
||||
raise HttpError(400, "day_of_month must be 1..28")
|
||||
if payload.retention_count < 1:
|
||||
raise HttpError(400, "retention_count must be >= 1")
|
||||
|
||||
schedule = BackupSchedule.get_solo()
|
||||
for attr, value in payload.dict().items():
|
||||
setattr(schedule, attr, value)
|
||||
schedule.save()
|
||||
|
||||
return {
|
||||
"enabled": schedule.enabled,
|
||||
"frequency": schedule.frequency,
|
||||
"minute": schedule.minute,
|
||||
"hour": schedule.hour,
|
||||
"day_of_week": schedule.day_of_week,
|
||||
"day_of_month": schedule.day_of_month,
|
||||
"retention_count": schedule.retention_count,
|
||||
"last_run_at": schedule.last_run_at,
|
||||
"next_run_at": compute_next_run(schedule),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
def run_backup_now(request):
|
||||
_require_admin(request)
|
||||
path = create_backup_now()
|
||||
return {"status": "ok", "filename": path.name}
|
||||
|
||||
|
||||
@router.get("/", response=list[BackupFileSchema])
|
||||
def get_backups(request):
|
||||
_require_admin(request)
|
||||
return [x.__dict__ for x in list_backups()]
|
||||
|
||||
|
||||
@router.get("/{filename}/download")
|
||||
def download_backup(request, filename: str):
|
||||
_require_admin(request)
|
||||
try:
|
||||
target = get_backup_file(filename)
|
||||
except FileNotFoundError:
|
||||
raise HttpError(404, "Backup not found")
|
||||
|
||||
response = FileResponse(open(target, "rb"), content_type="application/json")
|
||||
response["Content-Disposition"] = f'attachment; filename="{target.name}"'
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
def import_backup(request, file: UploadedFile = File(...), mode: str = "append"):
|
||||
_require_admin(request)
|
||||
if mode not in {"append", "override"}:
|
||||
raise HttpError(400, "mode must be append or override")
|
||||
|
||||
content = file.read().decode("utf-8")
|
||||
try:
|
||||
result = import_backup_content(content, mode=mode)
|
||||
except ValueError as exc:
|
||||
raise HttpError(400, str(exc))
|
||||
|
||||
return {"status": "ok", **result}
|
||||
|
||||
|
||||
@router.post("/run-if-due")
|
||||
def run_if_due(request):
|
||||
_require_admin(request)
|
||||
schedule = BackupSchedule.get_solo()
|
||||
if not is_backup_due(schedule):
|
||||
return {"status": "skipped"}
|
||||
path = create_backup_now()
|
||||
return {"status": "ok", "filename": path.name}
|
||||
|
||||
@@ -17,6 +17,7 @@ class ChannelSchema(Schema):
|
||||
library_id: int
|
||||
owner_user_id: int
|
||||
fallback_collection_id: Optional[int] = None
|
||||
requires_auth: bool
|
||||
|
||||
class ChannelCreateSchema(Schema):
|
||||
name: str
|
||||
@@ -26,6 +27,7 @@ class ChannelCreateSchema(Schema):
|
||||
library_id: int
|
||||
owner_user_id: int # Mock Auth User
|
||||
fallback_collection_id: Optional[int] = None
|
||||
requires_auth: bool = False
|
||||
|
||||
class ChannelUpdateSchema(Schema):
|
||||
name: Optional[str] = None
|
||||
@@ -35,6 +37,7 @@ class ChannelUpdateSchema(Schema):
|
||||
visibility: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
fallback_collection_id: Optional[int] = None
|
||||
requires_auth: Optional[bool] = None
|
||||
|
||||
class ChannelSourceRuleSchema(Schema):
|
||||
id: int
|
||||
@@ -135,6 +138,10 @@ class ChannelStatusSchema(Schema):
|
||||
@router.get("/{channel_id}/status", response=ChannelStatusSchema)
|
||||
def get_channel_status(request, channel_id: int):
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
if channel.requires_auth and not request.user.is_authenticated:
|
||||
from ninja.errors import HttpError
|
||||
raise HttpError(401, "Authentication required for this channel.")
|
||||
|
||||
now = timezone.now()
|
||||
window_end = now + timedelta(hours=24)
|
||||
|
||||
@@ -196,7 +203,8 @@ def create_channel(request, payload: ChannelCreateSchema):
|
||||
slug=payload.slug,
|
||||
channel_number=payload.channel_number,
|
||||
description=payload.description,
|
||||
fallback_collection_id=payload.fallback_collection_id
|
||||
fallback_collection_id=payload.fallback_collection_id,
|
||||
requires_auth=payload.requires_auth
|
||||
)
|
||||
return 201, channel
|
||||
|
||||
@@ -260,6 +268,10 @@ def remove_source_from_channel(request, channel_id: int, rule_id: int):
|
||||
def channel_now_playing(request, channel_id: int):
|
||||
"""Return the Airing currently on-air for this channel, or null."""
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
if channel.requires_auth and not request.user.is_authenticated:
|
||||
from ninja.errors import HttpError
|
||||
raise HttpError(401, "Authentication required for this channel.")
|
||||
|
||||
# Using a 1-second buffer to handle boundary conditions smoothly
|
||||
now = timezone.now()
|
||||
airing = (
|
||||
@@ -279,6 +291,10 @@ def channel_airings(request, channel_id: int, hours: int = 4):
|
||||
[now - 2 hours, now + {hours} hours]
|
||||
"""
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
if channel.requires_auth and not request.user.is_authenticated:
|
||||
from ninja.errors import HttpError
|
||||
raise HttpError(401, "Authentication required for this channel.")
|
||||
|
||||
now = timezone.now()
|
||||
window_start = now - timedelta(hours=2) # Look back 2h for context
|
||||
window_end = now + timedelta(hours=hours)
|
||||
|
||||
Reference in New Issue
Block a user