"""CLI management commands. Run inside the backend container: docker compose exec backend python -m app.cli [args] Commands: list-users List all users reset-password Reset a user's password set-role Change user role (admin/moderator/user) verify-email Force-verify a user's email delete-user Delete a user and their data stats Show platform statistics list-courses List all courses with status list-quizzes List quizzes (--all includes deleted) list-docs List all documents with status fix-stuck-docs Reset stuck 'processing' docs to 'ready' reprocess-doc Requeue a document for processing cleanup-redis Clear expired/orphaned Redis keys export-users Export users to CSV """ import sys import csv import io from app.database import SessionLocal from app.models.user import User from app.utils.auth import get_password_hash def reset_password(email: str, new_password: str): db = SessionLocal() try: user = db.query(User).filter(User.email == email.lower().strip()).first() if not user: print(f"Error: No user found with email '{email}'") return False if len(new_password) < 6: print("Error: Password must be at least 6 characters") return False user.hashed_password = get_password_hash(new_password) db.commit() print(f"Password reset for {user.name} ({user.email}) — role: {user.role}") return True finally: db.close() def list_users(args=None): db = SessionLocal() try: users = db.query(User).order_by(User.id).all() print(f"{'ID':<5} {'Name':<25} {'Email':<35} {'Role':<12}") print("-" * 77) for u in users: print(f"{u.id:<5} {(u.name or '-'):<25} {u.email:<35} {u.role:<12}") print(f"\n{len(users)} user(s)") finally: db.close() def set_role(email: str, role: str): if role not in ("admin", "moderator", "user"): print(f"Error: Invalid role '{role}'. Must be: admin, moderator, user") return False db = SessionLocal() try: user = db.query(User).filter(User.email == email.lower().strip()).first() if not user: print(f"Error: No user found with email '{email}'") return False old_role = user.role user.role = role db.commit() print(f"{user.name} ({user.email}): {old_role} -> {role}") return True finally: db.close() def verify_email(email: str): from datetime import datetime from app.models.email_verification import EmailVerification db = SessionLocal() try: user = db.query(User).filter(User.email == email.lower().strip()).first() if not user: print(f"Error: No user found with email '{email}'") return False v = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first() if not v: print(f"{user.email} has no verification record (already verified or legacy user)") return True if v.verified_at: print(f"{user.email} is already verified (at {v.verified_at})") return True v.verified_at = datetime.utcnow() db.commit() print(f"Email verified for {user.name} ({user.email})") return True finally: db.close() def delete_user(email: str): db = SessionLocal() try: user = db.query(User).filter(User.email == email.lower().strip()).first() if not user: print(f"Error: No user found with email '{email}'") return False confirm = input(f"Delete {user.name} ({user.email}, role={user.role})? Type 'yes' to confirm: ") if confirm.strip().lower() != "yes": print("Cancelled.") return False db.delete(user) db.commit() print(f"Deleted user {user.name} ({user.email})") return True finally: db.close() def stats(args=None): from app.models.quiz import Quiz from app.models.question import Question from app.models.attempt import QuizAttempt from app.models.flashcard import FlashcardDeck, Flashcard from app.models.pdf_document import PDFDocument db = SessionLocal() try: users = db.query(User).count() admins = db.query(User).filter(User.role == "admin").count() mods = db.query(User).filter(User.role == "moderator").count() quizzes = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.is_(None)).count() course_quizzes = db.query(Quiz).filter(Quiz.deleted_at.is_(None), Quiz.course_id.isnot(None)).count() questions = db.query(Question).count() attempts = db.query(QuizAttempt).filter(QuizAttempt.completed_at.isnot(None)).count() in_progress = db.query(QuizAttempt).filter(QuizAttempt.completed_at.is_(None)).count() decks = db.query(FlashcardDeck).count() cards = db.query(Flashcard).count() docs = db.query(PDFDocument).count() try: from app.models.course import Course, CourseEnrollment courses = db.query(Course).count() enrollments = db.query(CourseEnrollment).count() except Exception: courses = enrollments = "N/A" print("Platform Statistics") print("=" * 40) print(f"Users: {users} ({admins} admin, {mods} moderator)") print(f"Documents: {docs}") print(f"Questions: {questions}") print(f"Quizzes: {quizzes} (+{course_quizzes} course)") print(f"Attempts: {attempts} completed, {in_progress} in-progress") print(f"Flashcard decks: {decks} ({cards} cards)") print(f"Courses: {courses}") print(f"Enrollments: {enrollments}") finally: db.close() def list_courses(args=None): from app.models.course import Course, CourseEnrollment from sqlalchemy import func db = SessionLocal() try: counts = dict(db.query(CourseEnrollment.course_id, func.count()).group_by(CourseEnrollment.course_id).all()) courses = db.query(Course).order_by(Course.id).all() print(f"{'ID':<5} {'Title':<35} {'Status':<12} {'Creator':<20} {'Enrolled':<10}") print("-" * 82) for c in courses: creator = db.query(User).filter(User.id == c.user_id).first() name = creator.name if creator else "?" enrolled = counts.get(c.id, 0) print(f"{c.id:<5} {(c.title or '-')[:34]:<35} {c.status:<12} {name[:19]:<20} {enrolled:<10}") print(f"\n{len(courses)} course(s)") finally: db.close() def list_quizzes(args=None): from app.models.quiz import Quiz db = SessionLocal() try: q = db.query(Quiz) if not (args and "--all" in args): q = q.filter(Quiz.deleted_at.is_(None)) quizzes = q.order_by(Quiz.id).all() print(f"{'ID':<5} {'Title':<35} {'Qs':<5} {'Mode':<10} {'Type':<10} {'Status':<10}") print("-" * 75) for qz in quizzes: qtype = "course" if qz.course_id else "standalone" status = "deleted" if qz.deleted_at else ("shared" if qz.is_shared else "private") print(f"{qz.id:<5} {(qz.title or '-')[:34]:<35} {qz.questions_count:<5} {qz.mode:<10} {qtype:<10} {status:<10}") print(f"\n{len(quizzes)} quiz(zes)") finally: db.close() def list_docs(args=None): from app.models.pdf_document import PDFDocument db = SessionLocal() try: docs = db.query(PDFDocument).order_by(PDFDocument.id).all() print(f"{'ID':<5} {'Filename':<45} {'Pages':<7} {'Status':<12} {'Uploaded':<12}") print("-" * 81) for d in docs: pages = str(d.total_pages) if d.total_pages else "-" date = d.uploaded_at.strftime("%Y-%m-%d") if d.uploaded_at else "-" fname = (d.original_filename or "-")[:44] print(f"{d.id:<5} {fname:<45} {pages:<7} {d.status:<12} {date:<12}") if d.status == "error" and d.error_message: print(f" Error: {d.error_message[:70]}") print(f"\n{len(docs)} document(s)") finally: db.close() def fix_stuck_docs(args=None): from datetime import datetime, timedelta from app.models.pdf_document import PDFDocument db = SessionLocal() try: # Documents stuck in 'processing' for more than 30 minutes cutoff = datetime.utcnow() - timedelta(minutes=30) stuck = db.query(PDFDocument).filter( PDFDocument.status == "processing", PDFDocument.uploaded_at < cutoff, ).all() if not stuck: print("No stuck documents found.") return for d in stuck: d.status = "ready" d.error_message = "Reset by admin — was stuck in processing" print(f" Reset doc {d.id}: {d.original_filename}") db.commit() print(f"\nReset {len(stuck)} stuck document(s) to 'ready'") finally: db.close() def reprocess_doc(doc_id: str): from app.models.pdf_document import PDFDocument db = SessionLocal() try: doc = db.query(PDFDocument).filter(PDFDocument.id == int(doc_id)).first() if not doc: print(f"Error: No document with id {doc_id}") return False print(f"Document: {doc.original_filename} (status: {doc.status})") if doc.status == "processing": print("Warning: Document is currently processing. Resetting first.") doc.status = "processing" doc.error_message = None db.commit() # Queue the Celery task from app.tasks.pdf_tasks import process_pdf process_pdf.delay(doc.id) print(f"Queued for reprocessing (Celery task dispatched)") return True finally: db.close() def cleanup_redis(args=None): from app.config import settings import redis as redis_lib r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) keys = r.keys("quiz_progress:*") expired = 0 for key in keys: ttl = r.ttl(key) if ttl == -1: # no expiry set — orphaned r.delete(key) expired += 1 session_keys = r.keys("quiz_session:*") for key in session_keys: ttl = r.ttl(key) if ttl == -1: r.delete(key) expired += 1 print(f"Cleaned {expired} orphaned keys out of {len(keys) + len(session_keys)} total") def export_users(args=None): db = SessionLocal() try: users = db.query(User).order_by(User.id).all() out = io.StringIO() writer = csv.writer(out) writer.writerow(["id", "name", "email", "role", "created_at"]) for u in users: writer.writerow([u.id, u.name, u.email, u.role, u.created_at]) print(out.getvalue()) finally: db.close() COMMANDS = { "list-users": (list_users, 0), "reset-password": (lambda args: reset_password(args[0], args[1]), 2), "set-role": (lambda args: set_role(args[0], args[1]), 2), "verify-email": (lambda args: verify_email(args[0]), 1), "delete-user": (lambda args: delete_user(args[0]), 1), "stats": (stats, 0), "list-courses": (list_courses, 0), "list-quizzes": (list_quizzes, 0), "list-docs": (list_docs, 0), "fix-stuck-docs": (fix_stuck_docs, 0), "reprocess-doc": (lambda args: reprocess_doc(args[0]), 1), "cleanup-redis": (cleanup_redis, 0), "export-users": (export_users, 0), } if __name__ == "__main__": args = sys.argv[1:] if not args or args[0] in ("-h", "--help", "help"): print(__doc__) sys.exit(0) cmd = args[0] rest = args[1:] if cmd not in COMMANDS: print(f"Unknown command: {cmd}") print("Run with --help to see available commands") sys.exit(1) fn, min_args = COMMANDS[cmd] if len(rest) < min_args: print(f"Error: '{cmd}' requires {min_args} argument(s), got {len(rest)}") sys.exit(1) result = fn(rest) if min_args > 0 else fn(rest) if result is False: sys.exit(1)