b4e0d680f1
Ruff lint surfaced 23 violations across three rules; all addressed: UP017 (Use datetime.UTC alias): Replaced 13 sites of datetime.now(timezone.utc) with datetime.now(UTC), also adjusted from-imports accordingly. UTC is a Python 3.11+ alias for timezone.utc that ruff's pyupgrade rules prefer. UP042 (StrEnum): Replaced `class TagKind(str, Enum)` and `class SkipReason(str, Enum)` with `class Foo(StrEnum)`. StrEnum was added in Python 3.11 stdlib and is the modern idiom. Behavior is equivalent for our usage (the .value attribute, str(member) semantics). I001 (Import sorting): Added `known-first-party = ["backend"]` to ruff.toml's [lint.isort] so ruff groups `backend.*` imports correctly. Without it, ruff treated them as third-party and demanded a different grouping. The existing import order is stdlib → third-party → first-party → local relative, which ruff now accepts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
5.2 KiB
Python
152 lines
5.2 KiB
Python
"""Import admin API: trigger scan, list tasks, retry, clear."""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
from sqlalchemy import delete, select, update
|
|
|
|
from ..extensions import make_engine, make_session_factory
|
|
from ..models import ImportBatch, ImportTask
|
|
|
|
import_admin_bp = Blueprint("import_admin", __name__, url_prefix="/api/import")
|
|
|
|
_engine = None
|
|
_Session = None
|
|
|
|
|
|
def _session_factory():
|
|
global _engine, _Session
|
|
if _engine is None:
|
|
_engine = make_engine()
|
|
_Session = make_session_factory(_engine)
|
|
return _Session
|
|
|
|
|
|
@import_admin_bp.route("/trigger", methods=["POST"])
|
|
async def trigger_scan():
|
|
body = await request.get_json(silent=True) or {}
|
|
mode = body.get("mode", "quick")
|
|
if mode != "quick":
|
|
# FC-2d will accept 'deep'. For now, reject anything else.
|
|
return jsonify({"error": f"mode {mode!r} not supported in FC-2a; use 'quick'"}), 400
|
|
|
|
# Enqueue the scan task in Celery. The task creates the batch itself.
|
|
from ..tasks.scan import scan_directory
|
|
|
|
async_result = scan_directory.delay(triggered_by="manual")
|
|
return jsonify({"celery_task_id": async_result.id, "mode": mode}), 202
|
|
|
|
|
|
@import_admin_bp.route("/status", methods=["GET"])
|
|
async def status():
|
|
Session = _session_factory()
|
|
async with Session() as session:
|
|
active = (
|
|
await session.execute(
|
|
select(ImportBatch)
|
|
.where(ImportBatch.status == "running")
|
|
.order_by(ImportBatch.started_at.desc())
|
|
.limit(1)
|
|
)
|
|
).scalar_one_or_none()
|
|
payload = {"active_batch": None}
|
|
if active:
|
|
payload["active_batch"] = {
|
|
"id": active.id,
|
|
"total_files": active.total_files,
|
|
"imported": active.imported,
|
|
"skipped": active.skipped,
|
|
"failed": active.failed,
|
|
"started_at": active.started_at.isoformat(),
|
|
}
|
|
return jsonify(payload)
|
|
|
|
|
|
@import_admin_bp.route("/tasks", methods=["GET"])
|
|
async def list_tasks():
|
|
status_filter = request.args.get("status")
|
|
try:
|
|
limit = min(int(request.args.get("limit", "50")), 200)
|
|
except ValueError:
|
|
return jsonify({"error": "limit must be an integer"}), 400
|
|
cursor_raw = request.args.get("cursor")
|
|
cursor_id = int(cursor_raw) if cursor_raw else None
|
|
|
|
Session = _session_factory()
|
|
async with Session() as session:
|
|
stmt = select(ImportTask).order_by(ImportTask.created_at.desc(), ImportTask.id.desc())
|
|
if status_filter:
|
|
stmt = stmt.where(ImportTask.status == status_filter)
|
|
if cursor_id is not None:
|
|
stmt = stmt.where(ImportTask.id < cursor_id)
|
|
stmt = stmt.limit(limit + 1)
|
|
rows = (await session.execute(stmt)).scalars().all()
|
|
|
|
has_more = len(rows) > limit
|
|
rows = rows[:limit]
|
|
return jsonify({
|
|
"tasks": [
|
|
{
|
|
"id": t.id,
|
|
"batch_id": t.batch_id,
|
|
"source_path": t.source_path,
|
|
"task_type": t.task_type,
|
|
"status": t.status,
|
|
"result_image_id": t.result_image_id,
|
|
"error": t.error,
|
|
"size_bytes": t.size_bytes,
|
|
"created_at": t.created_at.isoformat(),
|
|
"started_at": t.started_at.isoformat() if t.started_at else None,
|
|
"finished_at": t.finished_at.isoformat() if t.finished_at else None,
|
|
}
|
|
for t in rows
|
|
],
|
|
"next_cursor": rows[-1].id if has_more and rows else None,
|
|
})
|
|
|
|
|
|
@import_admin_bp.route("/retry-failed", methods=["POST"])
|
|
async def retry_failed():
|
|
Session = _session_factory()
|
|
async with Session() as session:
|
|
failed_ids = (
|
|
await session.execute(select(ImportTask.id).where(ImportTask.status == "failed"))
|
|
).scalars().all()
|
|
if not failed_ids:
|
|
return jsonify({"retried": 0})
|
|
await session.execute(
|
|
update(ImportTask)
|
|
.where(ImportTask.id.in_(failed_ids))
|
|
.values(status="queued", error=None, started_at=None, finished_at=None)
|
|
)
|
|
await session.commit()
|
|
|
|
from ..tasks.import_file import import_media_file
|
|
for tid in failed_ids:
|
|
import_media_file.delay(tid)
|
|
|
|
return jsonify({"retried": len(failed_ids)})
|
|
|
|
|
|
@import_admin_bp.route("/clear-completed", methods=["POST"])
|
|
async def clear_completed():
|
|
body = await request.get_json(silent=True) or {}
|
|
age_days = body.get("age_days", 0)
|
|
status_filter = body.get("status", ["complete", "skipped"])
|
|
if not isinstance(status_filter, list):
|
|
return jsonify({"error": "status must be a list"}), 400
|
|
|
|
cutoff = (
|
|
datetime.now(UTC) - timedelta(days=int(age_days)) if age_days else None
|
|
)
|
|
|
|
Session = _session_factory()
|
|
async with Session() as session:
|
|
stmt = delete(ImportTask).where(ImportTask.status.in_(status_filter))
|
|
if cutoff is not None:
|
|
stmt = stmt.where(ImportTask.finished_at < cutoff)
|
|
result = await session.execute(stmt)
|
|
await session.commit()
|
|
|
|
return jsonify({"deleted": result.rowcount or 0})
|