# File: backend/main.py import os import re import json os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1' from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from sqlalchemy import func from typing import Annotated, List, Optional from datetime import datetime, timedelta from pydantic import BaseModel from . import models, schemas, database, auth, email_utils, google_utils, dropbox_utils, cloud_utils from .ml_utils import predict_category, extract_course_code, normalize_course_code from .gemini_utils import process_natural_language_sort app = FastAPI(title="DocuSort API") origins = ["http://localhost:5173", "http://localhost:3000"] app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) models.Base.metadata.create_all(bind=database.engine) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") # --- HELPER: AUDIT LOGGER --- def create_log(db: Session, email: str, action: str, details: str, undo_payload: str = None): log = models.UserLog(user_email=email, action=action, details=details, timestamp=datetime.utcnow(), undo_payload=undo_payload) db.add(log) db.commit() # --- AUTH DEPENDENCIES --- def get_current_user(token: Annotated[str, Depends(oauth2_scheme)], db: Session = Depends(database.get_db)): credentials_exception = HTTPException(status_code=401, detail="Invalid credentials", headers={"WWW-Authenticate": "Bearer"}) payload = auth.verify_token(token, credentials_exception) email: str = payload.get("sub") user = db.query(models.User).filter(models.User.email == email).first() if user is None: raise credentials_exception is_blacklisted = db.query(models.BlacklistedEmail).filter(models.BlacklistedEmail.email == email).first() if is_blacklisted or (not user.is_active and user.otp_code is None): raise HTTPException(status_code=403, detail="Account banned.") return user def get_unrestricted_user(current_user: Annotated[models.User, Depends(get_current_user)]): if current_user.is_frozen: raise HTTPException(status_code=423, detail="Account frozen. Please submit an appeal.") return current_user def get_admin_user(current_user: Annotated[models.User, Depends(get_current_user)]): if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin privileges required") return current_user # ======================= # RESOLVE REPORT & SHARING # ======================= @app.put("/admin/reports/{report_id}/resolve") async def resolve_report(report_id: int, data: schemas.ResolveReportRequest, current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): report = db.query(models.UserReport).filter(models.UserReport.id == report_id).first() if not report: raise HTTPException(status_code=404, detail="Not found") report.status = "resolved" report.admin_response = data.response target_email = report.reporter.email if report.reporter else report.reporter_email_fallback if target_email: await email_utils.send_alert_email(target_email, "Support Response: Resolved", f"An administrator has reviewed your report/appeal and provided the following response:

{data.response}") if report.reporter_id: db.add(models.Notification(user_id=report.reporter_id, message=f"Support Update ({report.type}): {data.response}")) db.commit() create_log(db, current_user.email, "REPORT_RESOLVED", f"Resolved {report.type}") return {"message": "Resolved"} @app.post("/share/respond") def respond_share(data: schemas.RespondToShare, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): req = db.query(models.ShareRequest).filter(models.ShareRequest.id == data.request_id, models.ShareRequest.receiver_email == current_user.email, models.ShareRequest.status == "pending").first() if not req: raise HTTPException(status_code=404) if data.accept: req.status = "accepted" db.add(models.SharedAccess(user_id=current_user.id, folder_id=req.folder_id, access_level="view", expires_at=req.expires_at)) db.add(models.Notification(user_id=req.sender_id, message=f"{current_user.email} accepted your invite to '{req.folder.folder_name}'.")) create_log(db, current_user.email, "SHARE_ACCEPT", f"Accepted {req.folder.folder_name}") else: req.status = "declined" db.add(models.Notification(user_id=req.sender_id, message=f"{current_user.email} declined your invite to '{req.folder.folder_name}'.")) db.commit() return {"message": "Done"} @app.delete("/share/revoke/{access_id}") def revoke(access_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): acc = db.query(models.SharedAccess).filter(models.SharedAccess.id == access_id).first() if not acc: raise HTTPException(status_code=404) is_owner = acc.folder.drive.user_id == current_user.id is_recipient = acc.user_id == current_user.id if not (is_owner or is_recipient): raise HTTPException(status_code=403) if is_owner: db.add(models.Notification(user_id=acc.user_id, message=f"Your access to '{acc.folder.folder_name}' was revoked by the owner.")) if is_recipient: db.add(models.Notification(user_id=acc.folder.drive.user_id, message=f"{current_user.email} left the shared folder '{acc.folder.folder_name}'.")) db.delete(acc); db.commit() create_log(db, current_user.email, "SHARE_REVOKE", f"Revoked {access_id}") return {"message": "Revoked"} @app.get("/share/list") def list_shares(current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): shares = db.query(models.SharedAccess).filter(models.SharedAccess.user_id == current_user.id).all() res = [] now = datetime.utcnow() for s in shares: if s.expires_at and s.expires_at < now: db.add(models.Notification(user_id=s.user_id, message=f"Access to '{s.folder.folder_name}' has expired.")) db.add(models.Notification(user_id=s.folder.drive.user_id, message=f"{s.user.email}'s access to '{s.folder.folder_name}' has expired.")) db.delete(s) continue total_files = len(s.folder.files) sorted_files = sum(1 for f in s.folder.files if getattr(f, 'category', 'Unsorted') != 'Unsorted') res.append({ "id": s.folder.id, "access_id": s.id, "folder_name": s.folder.folder_name, "owner_email": s.folder.drive.owner.email, "access_level": s.access_level, "expires_at": s.expires_at, "total_files": total_files, "sorted_files": sorted_files }) db.commit() return res # ======================= # STANDARD ROUTES # ======================= @app.get("/users/me/logs", response_model=List[schemas.LogResponse]) def get_my_logs(current_user: Annotated[models.User, Depends(get_current_user)], db: Session = Depends(database.get_db), search: str = "", date: str = None): query = db.query(models.UserLog).filter(models.UserLog.user_email == current_user.email) if search: query = query.filter(models.UserLog.action.ilike(f"%{search}%") | models.UserLog.details.ilike(f"%{search}%")) if date: try: d = datetime.strptime(date, "%Y-%m-%d"); query = query.filter(models.UserLog.timestamp >= d, models.UserLog.timestamp < d + timedelta(days=1)) except: pass return query.order_by(models.UserLog.timestamp.desc()).limit(100).all() @app.get("/admin/logs", response_model=List[schemas.LogResponse]) def get_admin_logs(current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db), search: str = "", date: str = None): query = db.query(models.UserLog) if search: query = query.filter(models.UserLog.user_email.ilike(f"%{search}%") | models.UserLog.action.ilike(f"%{search}%") | models.UserLog.details.ilike(f"%{search}%")) if date: try: d = datetime.strptime(date, "%Y-%m-%d"); query = query.filter(models.UserLog.timestamp >= d, models.UserLog.timestamp < d + timedelta(days=1)) except: pass return query.order_by(models.UserLog.timestamp.desc()).limit(200).all() @app.get("/share/pending", response_model=List[schemas.ShareRequestResponse]) def get_pending_invites(current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): reqs = db.query(models.ShareRequest).filter(models.ShareRequest.receiver_email == current_user.email, models.ShareRequest.status == "pending").all() return [{"id": r.id, "sender_email": r.sender.email, "folder_name": r.folder.folder_name, "status": r.status, "created_at": r.created_at, "expires_at": r.expires_at} for r in reqs] @app.get("/notifications", response_model=List[schemas.NotificationResponse]) def get_notifications(current_user: Annotated[models.User, Depends(get_current_user)], db: Session = Depends(database.get_db)): return db.query(models.Notification).filter(models.Notification.user_id == current_user.id).order_by(models.Notification.created_at.desc()).all() @app.put("/notifications/{notif_id}/read") def mark_read(notif_id: int, current_user: Annotated[models.User, Depends(get_current_user)], db: Session = Depends(database.get_db)): n = db.query(models.Notification).filter(models.Notification.id == notif_id, models.Notification.user_id == current_user.id).first() if n: n.is_read = True; db.commit() return {"message": "Read"} # ========================================== # CUSTOM RULES # ========================================== @app.post("/users/me/rules", response_model=schemas.CustomRuleResponse) def create_rule(rule: schemas.CustomRuleBase, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): new_rule = models.CustomRule(user_id=current_user.id, category_name=rule.category_name, keywords=rule.keywords) db.add(new_rule); db.commit(); db.refresh(new_rule) return new_rule @app.get("/users/me/rules", response_model=List[schemas.CustomRuleResponse]) def get_rules(current_user: Annotated[models.User, Depends(get_current_user)], db: Session = Depends(database.get_db)): return db.query(models.CustomRule).filter(models.CustomRule.user_id == current_user.id).all() @app.delete("/users/me/rules/{rule_id}") def delete_rule(rule_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): rule = db.query(models.CustomRule).filter(models.CustomRule.id == rule_id, models.CustomRule.user_id == current_user.id).first() if rule: db.delete(rule); db.commit() return {"message": "Deleted"} # ========================================== # NEW: NATURAL LANGUAGE SORTING & TEMPLATES # ========================================== @app.post("/users/me/prompt-templates", response_model=schemas.PromptTemplateResponse) def create_prompt_template(template: schemas.PromptTemplateCreate, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): new_temp = models.PromptTemplate(user_id=current_user.id, name=template.name, prompt_text=template.prompt_text) db.add(new_temp); db.commit(); db.refresh(new_temp) return new_temp @app.get("/users/me/prompt-templates", response_model=List[schemas.PromptTemplateResponse]) def get_prompt_templates(current_user: Annotated[models.User, Depends(get_current_user)], db: Session = Depends(database.get_db)): return db.query(models.PromptTemplate).filter(models.PromptTemplate.user_id == current_user.id).all() @app.delete("/users/me/prompt-templates/{template_id}") def delete_prompt_template(template_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): temp = db.query(models.PromptTemplate).filter(models.PromptTemplate.id == template_id, models.PromptTemplate.user_id == current_user.id).first() if temp: db.delete(temp); db.commit() return {"message": "Deleted template"} @app.post("/users/me/prompt-templates", response_model=schemas.PromptTemplateResponse) def create_prompt_template(template: schemas.PromptTemplateCreate, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): new_temp = models.PromptTemplate(user_id=current_user.id, name=template.name, prompt_text=template.prompt_text) db.add(new_temp); db.commit(); db.refresh(new_temp) return new_temp @app.get("/users/me/prompt-templates", response_model=List[schemas.PromptTemplateResponse]) def get_prompt_templates(current_user: Annotated[models.User, Depends(get_current_user)], db: Session = Depends(database.get_db)): return db.query(models.PromptTemplate).filter(models.PromptTemplate.user_id == current_user.id).all() @app.delete("/users/me/prompt-templates/{template_id}") def delete_prompt_template(template_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): temp = db.query(models.PromptTemplate).filter(models.PromptTemplate.id == template_id, models.PromptTemplate.user_id == current_user.id).first() if temp: db.delete(temp); db.commit() return {"message": "Deleted template"} @app.post("/folders/{folder_db_id}/prompt-sort") def smart_prompt_sort_folder(folder_db_id: int, payload: schemas.NaturalLanguageSortRequest, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """Passes files to Gemini and returns a DRY RUN preview array to the UI.""" f = db.query(models.IndexedFolder).filter(models.IndexedFolder.id == folder_db_id).first() if not f or f.drive.user_id != current_user.id: raise HTTPException(status_code=403) if getattr(f, 'is_sorting', False): raise HTTPException(status_code=400, detail="Folder is currently sorting.") unresolved_files = [file for file in f.files if not getattr(file, 'is_locked', False) and file.category == "Unsorted"] if not unresolved_files: return {"message": "No unsorted files available for custom prompt execution.", "total": 0} # --- FETCH EXISTING TAGS FOR GEMINI CONTEXT --- all_files = db.query(models.FileMetadata).join(models.IndexedFolder).join(models.ConnectedDrive).filter(models.ConnectedDrive.user_id == current_user.id).all() existing_courses = list(set([file.course_code for file in all_files if file.course_code and file.course_code != "General"])) existing_cats = list(set([file.category for file in all_files if file.category and file.category != "Unsorted"])) f.is_sorting = True db.commit() try: from .gemini_utils import process_natural_language_sort # Pass the context into Gemini! ai_response = process_natural_language_sort(unresolved_files, payload.prompt_text, existing_courses, existing_cats) sorted_mappings = ai_response.get("sorted_files", []) preview = [] for item in sorted_mappings: file_id = item.get("file_id") custom_path = item.get("custom_path", "Unsorted").strip() if custom_path == "Unsorted": continue db_file = db.query(models.FileMetadata).filter(models.FileMetadata.id == file_id, models.FileMetadata.folder_id == folder_db_id).first() if db_file: path_parts = [part.strip() for part in custom_path.split('/') if part.strip()] if path_parts: # FIX: Correctly maps single words if Gemini forgets the slash if len(path_parts) == 1: course_code = "General" cat_name = path_parts[0] else: course_code = path_parts[0] cat_name = "/".join(path_parts[1:]) # --- NEW: REGEX FALLBACK --- # If Gemini defaulted to General, double check with Regex! if course_code == "General": extracted = extract_course_code(db_file.name) if extracted and extracted != "General": course_code = extracted new_name = f"{course_code}_{cat_name} - {db_file.name}" new_path = f"{course_code} / {cat_name}" preview.append({ "file_id": db_file.id, "old_name": db_file.name, "new_name": new_name, "old_path": getattr(db_file, 'original_folder_name', f.folder_name), "new_path": new_path }) return {"files": preview, "total": len(preview)} finally: f.is_sorting = False db.commit() class BulkMoveItem(BaseModel): file_id: int new_path: str @app.post("/files/apply-bulk") def apply_bulk_moves(payload: List[BulkMoveItem], current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """Physically moves edited files, handles cross-drive sorting, saves Undo state, and triggers Notifications.""" moved_count = 0 undo_data = [] folder_cache = {} google_services = {} dropbox_clients = {} for item in payload: file = db.query(models.FileMetadata).filter(models.FileMetadata.id == item.file_id).first() if not file or file.folder.drive.user_id != current_user.id: continue d = file.folder.drive parts = [p.strip() for p in item.new_path.split('/') if p.strip()] # FIX: Ensure it always has a valid Course and Category if len(parts) == 1: course_code = "General" cat_name = parts[0] elif len(parts) > 1: course_code = parts[0] cat_name = "/".join(parts[1:]) else: continue if cat_name == "Unsorted": continue new_file_name = f"{course_code}_{cat_name} - {file.name}" db_folder_name = f"{course_code} / {cat_name}" undo_data.append({ "file_db_id": file.id, "original_cloud_id": file.cloud_id, "original_name": file.name, "original_db_folder_id": file.folder_id, "original_cloud_folder_id": file.folder.folder_id }) if d.provider == "google": if d.id not in google_services: google_services[d.id] = google_utils.get_drive_service(d.access_token, d.refresh_token, "https://oauth2.googleapis.com/token", google_utils.get_client_id_from_file(), google_utils.get_client_secret_from_file()) service = google_services[d.id] course_cache_key = f"{d.id}_{course_code}" if course_cache_key not in folder_cache: folder_cache[course_cache_key] = cloud_utils.create_google_folder(service, course_code) course_folder_id = folder_cache[course_cache_key] folder_cache_key = f"{d.id}_{db_folder_name}" if folder_cache_key not in folder_cache: cloud_subfolder_id = cloud_utils.create_google_folder(service, cat_name, parent_id=course_folder_id) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == d.id, models.IndexedFolder.folder_id == cloud_subfolder_id).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=d.id, folder_id=cloud_subfolder_id, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder); db.commit(); db.refresh(db_folder) folder_cache[folder_cache_key] = { "cloud_id": cloud_subfolder_id, "db_id": db_folder.id } if cloud_utils.move_google_file(service, file.cloud_id, folder_cache[folder_cache_key]["cloud_id"], new_name=new_file_name): file.name = new_file_name; file.is_sorted = True; file.is_locked = True file.course_code = course_code; file.category = cat_name file.folder_id = folder_cache[folder_cache_key]["db_id"] moved_count += 1 elif d.provider == "dropbox": if d.id not in dropbox_clients: dropbox_clients[d.id] = dropbox_utils.get_dropbox_client(d.access_token) dbx = dropbox_clients[d.id] full_cloud_path = f"/{course_code}/{cat_name}" folder_cache_key = f"{d.id}_{db_folder_name}" if folder_cache_key not in folder_cache: cloud_utils.create_dropbox_folder(dbx, course_code) cloud_utils.create_dropbox_folder(dbx, full_cloud_path) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == d.id, models.IndexedFolder.folder_name == db_folder_name).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=d.id, folder_id=full_cloud_path, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder); db.commit(); db.refresh(db_folder) folder_cache[folder_cache_key] = { "cloud_id": full_cloud_path, "db_id": db_folder.id } from_path = file.cloud_id if cloud_utils.move_dropbox_file(dbx, from_path, f"{course_code}/{cat_name}", file.name, new_name=new_file_name): file.name = new_file_name; file.is_sorted = True; file.is_locked = True file.course_code = course_code; file.category = cat_name file.folder_id = folder_cache[folder_cache_key]["db_id"]; file.cloud_id = f"{full_cloud_path}/{new_file_name}" moved_count += 1 if moved_count > 0: create_log(db, current_user.email, "APPLY_SORT", f"Bulk moved and renamed {moved_count} files.", undo_payload=json.dumps(undo_data)) db.add(models.Notification(user_id=current_user.id, message=f"Organization Complete: {moved_count} files successfully moved and renamed based on AI mapping.")) db.commit() return {"message": f"Successfully organized {moved_count} files."} # ========================================== # ANALYTICS # ========================================== @app.get("/users/me/analytics") def get_storage_analytics(current_user: Annotated[models.User, Depends(get_current_user)], db: Session = Depends(database.get_db)): files = db.query(models.FileMetadata).join(models.IndexedFolder).join(models.ConnectedDrive).filter(models.ConnectedDrive.user_id == current_user.id).all() course_data, category_data = {}, {} for f in files: course = getattr(f, 'course_code', 'General') or 'General' cat = f.category or 'Unsorted' size = f.size or 0 if course not in course_data: course_data[course] = {"name": course, "value": 0, "fileCount": 0} course_data[course]["value"] += size course_data[course]["fileCount"] += 1 if cat not in category_data: category_data[cat] = {"name": cat, "value": 0, "fileCount": 0} category_data[cat]["value"] += size category_data[cat]["fileCount"] += 1 largest_files = sorted([f for f in files if f.size], key=lambda x: x.size, reverse=True)[:5] top_files = [{"name": f.name, "course": getattr(f, 'course_code', 'General'), "size": f.size} for f in largest_files] # FIX: Sort alphabetically by name so the React charts stop jumping! sorted_courses = sorted(list(course_data.values()), key=lambda x: x["name"]) sorted_categories = sorted(list(category_data.values()), key=lambda x: x["name"]) return {"byCourse": sorted_courses, "byCategory": sorted_categories, "topFiles": top_files} # --- AUTH --- @app.post("/auth/login") async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: Session = Depends(database.get_db)): is_blacklisted = db.query(models.BlacklistedEmail).filter(models.BlacklistedEmail.email == form_data.username).first() user = db.query(models.User).filter(models.User.email == form_data.username).first() if is_blacklisted or (user and not user.is_active and user.otp_code is None): create_log(db, form_data.username, "LOGIN_REJECTED", "Account is banned") raise HTTPException(status_code=403, detail="Account banned.") if not user or not auth.Hash.verify(form_data.password, user.hashed_password): create_log(db, form_data.username, "LOGIN_FAILED", "Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials") if user.mfa_enabled: otp = email_utils.generate_otp() user.otp_code = otp; user.otp_expiry = datetime.utcnow() + timedelta(minutes=5); db.commit() await email_utils.send_otp_email(user.email, otp, "Login Code") return {"message": "OTP sent", "mfa_required": True} create_log(db, user.email, "LOGIN_SUCCESS", "Logged in") return {"access_token": auth.create_access_token(data={"sub": user.email, "role": user.role}), "token_type": "bearer", "mfa_required": False} @app.post("/auth/login-verify", response_model=schemas.Token) def login_verify(data: schemas.VerifyOTPRequest, db: Session = Depends(database.get_db)): user = db.query(models.User).filter(models.User.email == data.email).first() if not user or user.otp_code != data.otp: raise HTTPException(status_code=400, detail="Invalid OTP") user.otp_code = None; db.commit() create_log(db, user.email, "LOGIN_MFA", "Success") return {"access_token": auth.create_access_token(data={"sub": user.email, "role": user.role}), "token_type": "bearer"} @app.post("/auth/logout") def logout(current_user: Annotated[models.User, Depends(get_current_user)], db: Session = Depends(database.get_db)): create_log(db, current_user.email, "LOGOUT", "User logged out") current_user.token_version += 1 db.commit() return {"message": "Logged out."} @app.post("/auth/register", response_model=dict) async def register(user: schemas.UserCreate, db: Session = Depends(database.get_db)): if db.query(models.BlacklistedEmail).filter(models.BlacklistedEmail.email == user.email).first(): raise HTTPException(status_code=400, detail="Blacklisted") existing_user = db.query(models.User).filter(models.User.email == user.email).first() otp = email_utils.generate_otp() if existing_user: if existing_user.is_active: raise HTTPException(status_code=400, detail="Registered") else: existing_user.hashed_password = auth.Hash.bcrypt(user.password) existing_user.otp_code = otp existing_user.otp_expiry = datetime.utcnow() + timedelta(minutes=10) db.commit() await email_utils.send_alert_email(user.email, "DocuSort Verification", f"Your verification code is:

{otp}

This code expires in 10 minutes.") return {"message": "OTP resent"} new_user = models.User(email=user.email, hashed_password=auth.Hash.bcrypt(user.password), is_active=False, otp_code=otp, otp_expiry=datetime.utcnow()+timedelta(minutes=10)) db.add(new_user) db.commit() await email_utils.send_alert_email(user.email, "DocuSort Verification", f"Your verification code is:

{otp}

This code expires in 10 minutes.") return {"message": "Registered"} @app.post("/auth/verify-registration", response_model=schemas.Token) def verify_reg(data: schemas.VerifyOTPRequest, db: Session = Depends(database.get_db)): user = db.query(models.User).filter(models.User.email == data.email).first() if not user or user.otp_code != data.otp: raise HTTPException(status_code=400, detail="Invalid") user.is_active = True; user.otp_code = None; db.commit() return {"access_token": auth.create_access_token(data={"sub": user.email, "role": user.role}), "token_type": "bearer"} @app.post("/auth/forgot-password") async def forgot_password(data: schemas.ForgotPasswordRequest, db: Session = Depends(database.get_db)): user = db.query(models.User).filter(models.User.email == data.email).first() if not user: raise HTTPException(status_code=404, detail="Email not found in our system.") otp = email_utils.generate_otp() user.otp_code = otp user.otp_expiry = datetime.utcnow() + timedelta(minutes=10) db.commit() await email_utils.send_otp_email(user.email, otp, "Reset Password") return {"message": "OTP sent"} @app.post("/auth/reset-password") def reset_password(data: schemas.ResetPasswordRequest, db: Session = Depends(database.get_db)): user = db.query(models.User).filter(models.User.email == data.email).first() if not user or user.otp_code != data.otp: raise HTTPException(status_code=400, detail="Invalid") user.hashed_password = auth.Hash.bcrypt(data.new_password); user.otp_code = None; db.commit() return {"message": "Updated"} @app.post("/auth/appeal") def submit_appeal(data: schemas.AppealCreate, db: Session = Depends(database.get_db)): user = db.query(models.User).filter(models.User.email == data.email).first() if not user and not db.query(models.BlacklistedEmail).filter(models.BlacklistedEmail.email == data.email).first(): raise HTTPException(status_code=404) if db.query(models.UserReport).filter(models.UserReport.reporter_email_fallback == data.email, models.UserReport.status == "open", models.UserReport.type == "appeal").first(): raise HTTPException(status_code=400, detail="Pending") db.add(models.UserReport(type="appeal", reporter_email_fallback=data.email, reason=data.reason, description=data.description)); db.commit() return {"message": "Submitted"} @app.get("/users/me", response_model=schemas.UserResponse) def read_users_me(current_user: Annotated[models.User, Depends(get_current_user)]): return current_user @app.delete("/users/me") def delete_me(current_user: Annotated[models.User, Depends(get_current_user)], db: Session = Depends(database.get_db)): db.delete(current_user); db.commit(); return {"message": "Deleted"} @app.put("/users/me/settings", response_model=schemas.UserResponse) def update_settings(settings: schemas.UserSettingsUpdate, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): current_user.mfa_enabled = settings.mfa_enabled current_user.ui_theme = settings.ui_theme current_user.font_size = settings.font_size current_user.auto_sync = settings.auto_sync # <--- NEW current_user.disabled_defaults = settings.disabled_defaults # <--- ADD THIS LINE db.commit() return current_user @app.get("/users/search", response_model=List[schemas.UserBase]) def search_users(email: str, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): u = db.query(models.User).filter(models.User.email == email, models.User.id != current_user.id).first() return [u] if u else [] @app.post("/share/request") def share_req(data: schemas.ShareRequestCreate, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): f = db.query(models.IndexedFolder).filter(models.IndexedFolder.id == data.folder_id).first() if not f or f.drive.user_id != current_user.id: raise HTTPException(status_code=403) if db.query(models.ShareRequest).filter(models.ShareRequest.sender_id == current_user.id, models.ShareRequest.receiver_email == data.receiver_email, models.ShareRequest.folder_id == data.folder_id, models.ShareRequest.status == "pending").first(): raise HTTPException(status_code=400) exp = datetime.utcnow() + timedelta(days=data.expires_in_days) if data.expires_in_days else None db.add(models.ShareRequest(sender_id=current_user.id, receiver_email=data.receiver_email, folder_id=data.folder_id, expires_at=exp)) db.commit() return {"message": "Sent"} @app.get("/share/folder/{folder_id}/users", response_model=List[schemas.SharedUserResponse]) def folder_users(folder_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): f = db.query(models.IndexedFolder).filter(models.IndexedFolder.id == folder_id).first() if not f or f.drive.user_id != current_user.id: raise HTTPException(status_code=403) return [{"access_id": s.id, "user_email": s.user.email, "access_level": s.access_level, "expires_at": s.expires_at} for s in db.query(models.SharedAccess).filter(models.SharedAccess.folder_id == folder_id).all()] @app.post("/users/report") def report(data: schemas.ReportCreate, current_user: Annotated[models.User, Depends(get_current_user)], db: Session = Depends(database.get_db)): t = db.query(models.User).filter(models.User.email == data.reported_email).first() if t: db.add(models.UserReport(reporter_id=current_user.id, reported_user_id=t.id, reason=data.reason, description=data.description)); db.commit() return {"message": "Reported"} # --- DRIVES --- @app.get("/auth/google/url") def get_google_url(current_user: Annotated[models.User, Depends(get_unrestricted_user)]): return {"url": google_utils.get_google_auth_url()} @app.post("/auth/google/callback") def cb(code_data: dict, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): t = google_utils.exchange_code_for_token(code_data.get("code")) e = db.query(models.ConnectedDrive).filter(models.ConnectedDrive.user_id == current_user.id, models.ConnectedDrive.drive_email == t['email'], models.ConnectedDrive.provider == "google").first() if e: e.access_token = t['access_token']; e.refresh_token = t['refresh_token']; e.status = "active" else: db.add(models.ConnectedDrive(user_id=current_user.id, provider="google", drive_email=t['email'], access_token=t['access_token'], refresh_token=t['refresh_token'], token_expiry=t['expiry'], status="active")) db.commit() return {"message": "Connected"} @app.get("/drives/list") def list_drives(current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): return [{"id": d.id, "provider": d.provider, "drive_email": d.drive_email, "status": d.status} for d in db.query(models.ConnectedDrive).filter(models.ConnectedDrive.user_id == current_user.id).all()] @app.get("/drives/{drive_id}/files") def get_files(drive_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db), folder_id: str = "root"): d = db.query(models.ConnectedDrive).filter(models.ConnectedDrive.id == drive_id, models.ConnectedDrive.user_id == current_user.id).first() if not d: raise HTTPException(status_code=404) if d.status == 'expired': raise HTTPException(status_code=401) try: if d.provider == "dropbox": dbx = dropbox_utils.get_dropbox_client(d.access_token) return dropbox_utils.list_files_in_folder(dbx, folder_id) else: # Google s = google_utils.get_drive_service(d.access_token, d.refresh_token, "https://oauth2.googleapis.com/token", google_utils.get_client_id_from_file(), google_utils.get_client_secret_from_file()) return google_utils.list_files_in_folder(s, folder_id) except Exception as e: if "invalid_grant" in str(e) or "expired_access_token" in str(e): d.status = "expired"; db.commit(); raise HTTPException(status_code=401) raise HTTPException(status_code=400, detail=str(e)) @app.delete("/drives/{drive_id}") def del_drive(drive_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """Removes a connected drive from the user's account.""" d = db.query(models.ConnectedDrive).filter( models.ConnectedDrive.id == drive_id, models.ConnectedDrive.user_id == current_user.id ).first() if not d: raise HTTPException(status_code=404, detail="Drive not found") db.delete(d) db.commit() return {"message": "Drive successfully disconnected"} @app.post("/drives/{drive_id}/folders") def add_folder(drive_id: int, fd: schemas.FolderSelectRequest, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): if not db.query(models.ConnectedDrive).filter(models.ConnectedDrive.id == drive_id, models.ConnectedDrive.user_id == current_user.id).first(): raise HTTPException(status_code=404) if db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == drive_id, models.IndexedFolder.folder_id == fd.folder_id).first(): raise HTTPException(status_code=400) db.add(models.IndexedFolder(drive_id=drive_id, folder_id=fd.folder_id, folder_name=fd.folder_name, last_synced=datetime.utcnow())); db.commit(); return {"message": "Added"} @app.get("/drives/{drive_id}/folders", response_model=List[schemas.IndexedFolderResponse]) def get_folders(drive_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): return db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == drive_id).all() @app.delete("/folders/{folder_id}") def del_folder(folder_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): f = db.query(models.IndexedFolder).filter(models.IndexedFolder.id == folder_id).first() if f and f.drive.user_id == current_user.id: if getattr(f, 'is_sorting', False): raise HTTPException(status_code=400, detail="Cannot delete a folder while AI is sorting.") db.delete(f); db.commit() return {"message": "Deleted"} @app.get("/auth/dropbox/url") def get_dropbox_url(current_user: Annotated[models.User, Depends(get_unrestricted_user)]): return {"url": dropbox_utils.get_dropbox_auth_url()} @app.post("/auth/dropbox/callback") def dropbox_cb(code_data: dict, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): try: token_data = dropbox_utils.exchange_code_for_token(code_data.get("code")) access_token = token_data.get("access_token") refresh_token = token_data.get("refresh_token", "") dbx = dropbox_utils.get_dropbox_client(access_token) drive_email = dropbox_utils.get_user_email(dbx) e = db.query(models.ConnectedDrive).filter(models.ConnectedDrive.user_id == current_user.id, models.ConnectedDrive.drive_email == drive_email, models.ConnectedDrive.provider == "dropbox").first() if e: e.access_token = access_token if refresh_token: e.refresh_token = refresh_token e.status = "active" else: db.add(models.ConnectedDrive(user_id=current_user.id, provider="dropbox", drive_email=drive_email, access_token=access_token, refresh_token=refresh_token, status="active")) db.commit() return {"message": "Dropbox Connected Successfully!"} except Exception as e: raise HTTPException(status_code=400, detail=f"Dropbox Auth Failed: {str(e)}") # ======================= # METADATA & SORTING ENDPOINTS # ======================= @app.get("/api/duplicates") def get_duplicates(current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """Advanced Duplicate Engine with Exact, Fuzzy, and Version Chain Tracking.""" all_files = db.query(models.FileMetadata).join(models.IndexedFolder).join(models.ConnectedDrive).filter( models.ConnectedDrive.user_id == current_user.id ).all() exact_groups, size_groups, name_groups, version_groups = {}, {}, {}, {} ignored_files = [] for f in all_files: if f.is_ignored_duplicate: ignored_files.append({ "id": f.id, "name": f.name, "cloud": f.cloud_provider, "category": f.category, "web_view_link": f.web_view_link }) continue if f.file_hash: exact_groups.setdefault(f.file_hash, []).append(f) if f.size and f.size > 0: size_groups.setdefault(f.size, []).append(f) if f.name: base_name = os.path.splitext(f.name)[0].lower().strip() # 1. Standard Fuzzy Name Cleaning ("- copy") clean_name = re.sub(r'(\s-\scopy\d*|\(\d+\)|\scopy\d*)$', '', base_name).strip() name_groups.setdefault(clean_name, []).append(f) # 2. NEW: Deep Version Chain Cleaning # Strips _v1, -v2.5, final, draft, revised, etc. from the end of the name version_clean = re.sub(r'[-_\s]*(v\d+(\.\d+)?|final|draft|revised|rev|edit\d*|copy\d*|\(\d+\))[-_\s]*$', '', base_name, flags=re.IGNORECASE).strip() version_groups.setdefault(version_clean, []).append(f) # Tab 1: Exact Hash Matches exact_matches = [] exact_handled_ids = set() for h, files in exact_groups.items(): if len(files) > 1: exact_matches.append({ "hash": h, "files": [{"id": f.id, "name": f.name, "cloud": f.cloud_provider, "category": f.category, "web_view_link": f.web_view_link} for f in files] }) exact_handled_ids.update([f.id for f in files]) # Tab 2: Fuzzy Matches (Name OR Size) fuzzy_matches = [] fuzzy_handled_ids = set() for clean_name, files in name_groups.items(): group_files = [f for f in files if f.id not in exact_handled_ids] if len(group_files) > 1: fuzzy_matches.append({ "reason": f"Similar File Name: '{clean_name}'", "size_bytes": None, "files": [{"id": f.id, "name": f.name, "cloud": f.cloud_provider, "category": f.category, "web_view_link": f.web_view_link} for f in group_files] }) fuzzy_handled_ids.update([f.id for f in group_files]) for size, files in size_groups.items(): group_files = [f for f in files if f.id not in exact_handled_ids and f.id not in fuzzy_handled_ids] if len(group_files) > 1: fuzzy_matches.append({ "reason": f"Same Byte Size", "size_bytes": size, "files": [{"id": f.id, "name": f.name, "cloud": f.cloud_provider, "category": f.category, "web_view_link": f.web_view_link} for f in group_files] }) fuzzy_handled_ids.update([f.id for f in group_files]) # Tab 4: NEW Version Chains version_matches = [] for v_clean, files in version_groups.items(): # Only group files that weren't already caught by exact or fuzzy rules group_files = [f for f in files if f.id not in exact_handled_ids and f.id not in fuzzy_handled_ids] if len(group_files) > 1: # Sort alphabetically (so v1 comes before v2, draft before final, etc.) group_files.sort(key=lambda x: x.name.lower()) version_matches.append({ "reason": f"Project Versions: '{v_clean}'", "files": [{"id": f.id, "name": f.name, "cloud": f.cloud_provider, "category": f.category, "web_view_link": f.web_view_link} for f in group_files] }) return { "tab_1_exact": exact_matches, "tab_2_fuzzy": fuzzy_matches, "tab_3_ignored": ignored_files, "tab_4_versions": version_matches } @app.put("/api/duplicates/unignore/{file_id}") def unignore_duplicate(file_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """Restores an ignored file back to the duplicate scanner.""" file = db.query(models.FileMetadata).filter(models.FileMetadata.id == file_id).first() if not file or file.folder.drive.user_id != current_user.id: raise HTTPException(status_code=403) file.is_ignored_duplicate = False db.commit() create_log(db, current_user.email, "DUPLICATE_RESTORED", f"Unignored file: {file.name}") return {"message": "File restored to cleanup scanner."} @app.get("/drives/{drive_id}/preview-sort") def preview_sort(drive_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """DRY RUN: Returns exactly what will happen without actually moving anything.""" d = db.query(models.ConnectedDrive).filter(models.ConnectedDrive.id == drive_id, models.ConnectedDrive.user_id == current_user.id).first() if not d: raise HTTPException(status_code=404) # FIX: We removed the `category != "Unsorted"` filter so it grabs raw files too! files_to_move = db.query(models.FileMetadata).join(models.IndexedFolder).filter( models.IndexedFolder.drive_id == drive_id, models.FileMetadata.is_sorted == False, models.FileMetadata.is_locked == False ).all() preview = [] custom_rules = db.query(models.CustomRule).filter(models.CustomRule.user_id == current_user.id).all() disabled_defs = current_user.disabled_defaults for f in files_to_move: # FIX: Dynamically predict the category if it hasn't been sorted yet cat_name = f.category if cat_name == "Unsorted": cat_name, _ = predict_category(f.name, custom_rules, disabled_defs) course_code = f.course_code or extract_course_code(f.name) or "General" new_name = f"{course_code}_{cat_name} - {f.name}" new_path = f"{course_code} / {cat_name}" preview.append({ "file_id": f.id, "old_name": f.name, "new_name": new_name, "old_path": f.original_folder_name or f.folder.folder_name, "new_path": new_path }) return {"files": preview, "total": len(preview)} @app.post("/drives/{drive_id}/apply-sort") def apply_sort_and_move(drive_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """Creates nested folders, MOVES files, RENAMES them, and saves the UNDO payload.""" d = db.query(models.ConnectedDrive).filter(models.ConnectedDrive.id == drive_id, models.ConnectedDrive.user_id == current_user.id).first() if not d: raise HTTPException(status_code=404) # FIX: Grabs all raw, unlocked files files_to_move = db.query(models.FileMetadata).join(models.IndexedFolder).filter( models.IndexedFolder.drive_id == drive_id, models.FileMetadata.is_sorted == False, models.FileMetadata.is_locked == False ).all() if not files_to_move: return {"message": "No new files to move. Make sure you run Sort first."} moved_count = 0 folder_cache = {} undo_data = [] try: if d.provider == "google": service = google_utils.get_drive_service(d.access_token, d.refresh_token, "https://oauth2.googleapis.com/token", google_utils.get_client_id_from_file(), google_utils.get_client_secret_from_file()) elif d.provider == "dropbox": dbx = dropbox_utils.get_dropbox_client(d.access_token) custom_rules = db.query(models.CustomRule).filter(models.CustomRule.user_id == current_user.id).all() disabled_defs = current_user.disabled_defaults for file in files_to_move: # FIX: Dynamically predict the category and course if missing if file.category == "Unsorted": cat, _ = predict_category(file.name, custom_rules, disabled_defs) file.category = cat file.course_code = file.course_code or extract_course_code(file.name) or "General" course_code = file.course_code cat_name = file.category new_file_name = f"{course_code}_{cat_name} - {file.name}" db_folder_name = f"{course_code} / {cat_name}" undo_data.append({ "file_db_id": file.id, "original_cloud_id": file.cloud_id, "original_name": file.name, "original_db_folder_id": file.folder_id, "original_cloud_folder_id": file.folder.folder_id }) # --- GOOGLE DRIVE --- if d.provider == "google": if course_code not in folder_cache: folder_cache[course_code] = cloud_utils.create_google_folder(service, course_code) course_folder_id = folder_cache[course_code] if db_folder_name not in folder_cache: cloud_subfolder_id = cloud_utils.create_google_folder(service, cat_name, parent_id=course_folder_id) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == drive_id, models.IndexedFolder.folder_id == cloud_subfolder_id).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=drive_id, folder_id=cloud_subfolder_id, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder); db.commit(); db.refresh(db_folder) folder_cache[db_folder_name] = { "cloud_id": cloud_subfolder_id, "db_id": db_folder.id } if cloud_utils.move_google_file(service, file.cloud_id, folder_cache[db_folder_name]["cloud_id"], new_name=new_file_name): file.name = new_file_name file.is_sorted = True file.folder_id = folder_cache[db_folder_name]["db_id"] moved_count += 1 # --- DROPBOX --- elif d.provider == "dropbox": full_cloud_path = f"/{course_code}/{cat_name}" if db_folder_name not in folder_cache: cloud_utils.create_dropbox_folder(dbx, course_code) cloud_utils.create_dropbox_folder(dbx, full_cloud_path) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == drive_id, models.IndexedFolder.folder_name == db_folder_name).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=drive_id, folder_id=full_cloud_path, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder); db.commit(); db.refresh(db_folder) folder_cache[db_folder_name] = { "cloud_id": full_cloud_path, "db_id": db_folder.id } from_path = file.cloud_id if cloud_utils.move_dropbox_file(dbx, from_path, f"{course_code}/{cat_name}", file.name, new_name=new_file_name): file.name = new_file_name file.is_sorted = True file.folder_id = folder_cache[db_folder_name]["db_id"] file.cloud_id = f"{full_cloud_path}/{new_file_name}" moved_count += 1 db.commit() create_log(db, current_user.email, "APPLY_SORT", f"Moved and renamed {moved_count} files.", undo_payload=json.dumps(undo_data)) return {"message": f"Successfully structured folders, renamed, and moved {moved_count} files!"} except Exception as e: db.commit() raise HTTPException(status_code=500, detail=f"Error moving files: {str(e)}") @app.post("/logs/{log_id}/undo") def undo_sort_action(log_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """1-CLICK TIME TRAVEL: Reverses a batch move.""" log = db.query(models.UserLog).filter(models.UserLog.id == log_id, models.UserLog.user_email == current_user.email).first() if not log or not log.undo_payload: raise HTTPException(status_code=400, detail="Cannot undo this action.") try: undo_data = json.loads(log.undo_payload) restored_count = 0 for item in undo_data: file = db.query(models.FileMetadata).filter(models.FileMetadata.id == item["file_db_id"]).first() if not file: continue d = file.folder.drive success = False if d.provider == "google": service = google_utils.get_drive_service(d.access_token, d.refresh_token, "https://oauth2.googleapis.com/token", google_utils.get_client_id_from_file(), google_utils.get_client_secret_from_file()) success = cloud_utils.move_google_file(service, file.cloud_id, item["original_cloud_folder_id"], new_name=item["original_name"]) elif d.provider == "dropbox": dbx = dropbox_utils.get_dropbox_client(d.access_token) success = cloud_utils.move_dropbox_file(dbx, file.cloud_id, item["original_cloud_folder_id"], file.name, new_name=item["original_name"]) if success: file.cloud_id = item["original_cloud_id"] if success: file.name = item["original_name"] file.folder_id = item["original_db_folder_id"] file.is_sorted = False file.course_code = "General" file.category = "Unsorted" restored_count += 1 # Wipe the payload so it can't be undone twice log.undo_payload = None create_log(db, current_user.email, "UNDO_SORT", f"Reverted {restored_count} files back to their original state.") db.commit() return {"message": f"Successfully time-traveled {restored_count} files back to their original folders!"} except Exception as e: raise HTTPException(status_code=500, detail=f"Undo failed: {str(e)}") @app.post("/folders/{folder_db_id}/sync") def sync(folder_db_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): f = db.query(models.IndexedFolder).filter(models.IndexedFolder.id == folder_db_id).first() if not f or f.drive.user_id != current_user.id: raise HTTPException(status_code=403) if getattr(f, 'is_sorting', False): raise HTTPException(status_code=400, detail="Sync disabled while AI is sorting.") try: if f.drive.provider == "dropbox": dbx = dropbox_utils.get_dropbox_client(f.drive.access_token) files = dropbox_utils.list_files_in_folder(dbx, f.folder_id) else: # Google s = google_utils.get_drive_service(f.drive.access_token, f.drive.refresh_token, "https://oauth2.googleapis.com/token", google_utils.get_client_id_from_file(), google_utils.get_client_secret_from_file()) files = google_utils.list_files_in_folder(s, f.folder_id) for g in files: existing = db.query(models.FileMetadata).filter(models.FileMetadata.folder_id == f.id, models.FileMetadata.cloud_id == g['id']).first() file_size = int(g.get('size', 0)) if g.get('size') else None file_hash = g.get('md5Checksum') if f.drive.provider == 'google' else g.get('content_hash') if existing: # NEW: Updates the name and size if you changed them in Google Drive/Dropbox! existing.name = g['name'] existing.size = file_size existing.file_hash = file_hash else: db.add(models.FileMetadata( cloud_id=g['id'], name=g['name'], mime_type=g['mimeType'], web_view_link=g.get('webViewLink', ''), icon_link=g.get('iconLink', ''), folder_id=f.id, size=file_size, file_hash=file_hash, cloud_provider=f.drive.provider, original_folder_name=f.folder_name )) f.last_synced = datetime.utcnow() db.commit() return {"message": "Synced"} except Exception as e: if "invalid_grant" in str(e) or "expired_access_token" in str(e): f.drive.status = "expired"; db.commit(); raise HTTPException(status_code=401) raise HTTPException(status_code=400, detail=str(e)) @app.get("/files", response_model=List[dict]) def all_files(current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db), search: str = "", search_by: str = "all"): o = db.query(models.FileMetadata).join(models.IndexedFolder).join(models.ConnectedDrive).filter(models.ConnectedDrive.user_id == current_user.id) s = db.query(models.FileMetadata).join(models.IndexedFolder).join(models.SharedAccess).filter(models.SharedAccess.user_id == current_user.id) if search: st = f"%{search}%" if search_by == "name": o = o.filter(models.FileMetadata.name.ilike(st)); s = s.filter(models.FileMetadata.name.ilike(st)) elif search_by == "category": o = o.filter(models.FileMetadata.category.ilike(st)); s = s.filter(models.FileMetadata.category.ilike(st)) elif search_by == "folder": o = o.filter(models.IndexedFolder.folder_name.ilike(st)); s = s.filter(models.IndexedFolder.folder_name.ilike(st)) elif search_by == "provider": o = o.filter(models.ConnectedDrive.provider.ilike(st)); s = s.filter(models.ConnectedDrive.provider.ilike(st)) else: o = o.filter(models.FileMetadata.name.ilike(st) | models.FileMetadata.category.ilike(st) | models.IndexedFolder.folder_name.ilike(st) | models.ConnectedDrive.provider.ilike(st)) s = s.filter(models.FileMetadata.name.ilike(st) | models.FileMetadata.category.ilike(st) | models.IndexedFolder.folder_name.ilike(st) | models.ConnectedDrive.provider.ilike(st)) res = [] for f in o.all() + s.all(): owner = f.folder.drive.owner.email if f.folder.drive and f.folder.drive.owner else "Unknown" res.append({ "id": f.id, "cloud_id": f.cloud_id, "name": f.name, "mime_type": f.mime_type, "web_view_link": f.web_view_link, "icon_link": f.icon_link, "folder_name": f.folder.folder_name, "original_folder_name": getattr(f, 'original_folder_name', None) or f.folder.folder_name, "owner": owner, "drive_email": f.folder.drive.drive_email if f.folder.drive else "Unknown", "provider": f.cloud_provider, "category": f.category, "course_code": getattr(f, 'course_code', 'General'), # <--- NEW: Sends the parent folder down to React "is_locked": f.is_locked, "is_sorted": f.is_sorted }) return res # --- ADMIN --- @app.get("/admin/users", response_model=List[schemas.UserResponse]) def admin_users(current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): return db.query(models.User).all() @app.get("/admin/stats") def admin_stats(current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): users = db.query(models.User).filter(models.User.role != "admin").all() user_stats = [] for u in users: fc = sum(len(f.files) for d in u.drives for f in d.folders) user_stats.append({"id": u.id, "email": u.email, "file_count": fc, "drive_count": len(u.drives), "is_frozen": u.is_frozen, "is_active": u.is_active}) sorted_files_count = db.query(models.FileMetadata).filter(models.FileMetadata.category != "Unsorted").count() return { "counts": { "users": len(users), "files": db.query(models.FileMetadata).count(), "sorted_files": sorted_files_count, "drives": db.query(models.ConnectedDrive).count(), "shares": db.query(models.SharedAccess).join(models.User).count(), "reports": db.query(models.UserReport).filter(models.UserReport.status == "open").count() }, "user_activity": sorted(user_stats, key=lambda x: x['file_count'], reverse=True) } @app.get("/admin/reports", response_model=List[schemas.ReportResponse]) def admin_reports(current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): reports = db.query(models.UserReport).filter(models.UserReport.status == "open").all() res = [] for r in reports: reporter = r.reporter.email if r.reporter else r.reporter_email_fallback target = r.reported_user.email if r.reported_user else "N/A" res.append({"id": r.id, "type": r.type, "reporter_email": reporter, "reported_user_email": target, "reason": r.reason, "description": r.description, "status": r.status, "admin_response": r.admin_response, "created_at": r.created_at}) return res @app.get("/admin/blacklist", response_model=List[schemas.BlacklistResponse]) def get_blacklist(current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): return db.query(models.BlacklistedEmail).all() @app.post("/admin/ban") async def ban_user(data: schemas.BanRequest, current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): if not db.query(models.BlacklistedEmail).filter(models.BlacklistedEmail.email == data.email).first(): db.add(models.BlacklistedEmail(email=data.email, reason=data.reason, banned_by=current_user.email)) u = db.query(models.User).filter(models.User.email == data.email).first() if u and u.role != 'admin': u.is_active = False u.is_frozen = False u.token_version += 1 await email_utils.send_alert_email(u.email, "Account Permanently Banned", f"Your account has been terminated for a violation of terms.

Reason: {data.reason}") db.commit() return {"message": "Banned"} @app.delete("/admin/blacklist/{id}") def unban_user(id: int, current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): entry = db.query(models.BlacklistedEmail).filter(models.BlacklistedEmail.id == id).first() if entry: u = db.query(models.User).filter(models.User.email == entry.email).first() if u: u.is_active = True db.delete(entry) db.commit() return {"message": "Unbanned"} @app.put("/admin/users/{user_id}/freeze") async def freeze_user(user_id: int, freeze: bool, current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): u = db.query(models.User).filter(models.User.id == user_id).first() if u and u.role != 'admin': u.is_frozen = freeze if freeze: u.token_version += 1 await email_utils.send_alert_email(u.email, "Account Frozen", "Your account features have been suspended. Please log in to your dashboard to submit an appeal.") db.add(models.Notification(user_id=u.id, message="Your account has been frozen. Features are currently restricted.")) else: await email_utils.send_alert_email(u.email, "Account Restored", "Your account has been unfrozen. You have full access again.") db.add(models.Notification(user_id=u.id, message="Your account has been unfrozen. You have full access again.")) db.commit() return {"message": "Done"} @app.get("/admin/users/{user_id}/details") def admin_details(user_id: int, current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): u = db.query(models.User).filter(models.User.id == user_id).first() if not u: raise HTTPException(status_code=404) drives_info = [] for d in u.drives: total_files = 0 sorted_files = 0 folders_data = [] for f in d.folders: files_list = [] for file in f.files: files_list.append({ "id": file.id, "name": file.name, "category": getattr(file, 'category', 'Unsorted'), "web_view_link": file.web_view_link }) total_files += len(f.files) sorted_files += sum(1 for file in f.files if getattr(file, 'category', 'Unsorted') != 'Unsorted') folders_data.append({ "id": f.id, "folder_name": f.folder_name, "files": files_list }) drives_info.append({ "id": d.id, "drive_email": d.drive_email, "provider": d.provider, "folder_count": len(d.folders), "total_files": total_files, "sorted_files": sorted_files, "folders": folders_data }) return {"user": u, "drives": drives_info} @app.delete("/admin/drives/{drive_id}") def admin_del_drive(drive_id: int, current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): d = db.query(models.ConnectedDrive).filter(models.ConnectedDrive.id == drive_id).first() if d: db.delete(d); db.commit() return {"message": "Deleted"} @app.delete("/admin/shares/{access_id}") def admin_del_share(access_id: int, current_user: Annotated[models.User, Depends(get_admin_user)], db: Session = Depends(database.get_db)): a = db.query(models.SharedAccess).filter(models.SharedAccess.id == access_id).first() if a: db.delete(a); db.commit() return {"message": "Revoked"} # --- SORTING ROUTES --- class CategoryOverride(BaseModel): category: str class CourseOverride(BaseModel): course_code: str class LockToggle(BaseModel): is_locked: bool @app.put("/files/{file_id}/lock") def toggle_file_lock(file_id: int, payload: LockToggle, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): file = db.query(models.FileMetadata).filter(models.FileMetadata.id == file_id).first() if not file or file.folder.drive.user_id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized") file.is_locked = payload.is_locked db.commit() return {"message": f"File {'locked' if payload.is_locked else 'unlocked'}"} @app.put("/files/{file_id}/category") def update_file_category(file_id: int, payload: CategoryOverride, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """Updates category and auto-moves to a nested folder.""" file = db.query(models.FileMetadata).filter(models.FileMetadata.id == file_id).first() if not file or file.folder.drive.user_id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized") file.category = payload.category file.is_locked = True d = file.folder.drive # FIX: Use the existing database course code, NOT regex extraction! course_code = getattr(file, 'course_code', 'General') if not course_code: course_code = "General" cat_name = payload.category db_folder_name = f"{course_code} / {cat_name}" try: if d.provider == "google": service = google_utils.get_drive_service(d.access_token, d.refresh_token, "https://oauth2.googleapis.com/token", google_utils.get_client_id_from_file(), google_utils.get_client_secret_from_file()) course_folder_id = cloud_utils.create_google_folder(service, course_code) cloud_subfolder_id = cloud_utils.create_google_folder(service, cat_name, parent_id=course_folder_id) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == d.id, models.IndexedFolder.folder_id == cloud_subfolder_id).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=d.id, folder_id=cloud_subfolder_id, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder); db.commit(); db.refresh(db_folder) if cloud_utils.move_google_file(service, file.cloud_id, cloud_subfolder_id): file.folder_id = db_folder.id file.is_sorted = True create_log(db, current_user.email, "MANUAL_MOVE", f"Moved {file.name} to {db_folder_name}") elif d.provider == "dropbox": dbx = dropbox_utils.get_dropbox_client(d.access_token) full_cloud_path = f"/{course_code}/{cat_name}" cloud_utils.create_dropbox_folder(dbx, course_code) cloud_utils.create_dropbox_folder(dbx, full_cloud_path) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == d.id, models.IndexedFolder.folder_name == db_folder_name).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=d.id, folder_id=full_cloud_path, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder); db.commit(); db.refresh(db_folder) from_path = file.cloud_id if cloud_utils.move_dropbox_file(dbx, from_path, f"{course_code}/{cat_name}", file.name): file.folder_id = db_folder.id file.cloud_id = f"{full_cloud_path}/{file.name}" file.is_sorted = True create_log(db, current_user.email, "MANUAL_MOVE", f"Moved {file.name} to {db_folder_name}") except Exception as e: print(f"Auto-move failed during manual categorize: {e}") db.commit() return {"message": "Category updated and file moved successfully!"} @app.put("/files/{file_id}/course") def update_file_course(file_id: int, payload: CourseOverride, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """NEW: Updates the course code manually and instantly auto-moves it to the new parent folder.""" file = db.query(models.FileMetadata).filter(models.FileMetadata.id == file_id).first() if not file or file.folder.drive.user_id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized") new_course = payload.course_code.strip().upper() if not new_course: new_course = "General" file.course_code = new_course file.is_locked = True d = file.folder.drive cat_name = file.category db_folder_name = f"{new_course} / {cat_name}" try: # ONLY AUTO-MOVE IF IT HAS A CATEGORY if cat_name != "Unsorted": if d.provider == "google": service = google_utils.get_drive_service(d.access_token, d.refresh_token, "https://oauth2.googleapis.com/token", google_utils.get_client_id_from_file(), google_utils.get_client_secret_from_file()) course_folder_id = cloud_utils.create_google_folder(service, new_course) cloud_subfolder_id = cloud_utils.create_google_folder(service, cat_name, parent_id=course_folder_id) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == d.id, models.IndexedFolder.folder_id == cloud_subfolder_id).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=d.id, folder_id=cloud_subfolder_id, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder); db.commit(); db.refresh(db_folder) if cloud_utils.move_google_file(service, file.cloud_id, cloud_subfolder_id): file.folder_id = db_folder.id file.is_sorted = True create_log(db, current_user.email, "MANUAL_MOVE", f"Moved {file.name} to {db_folder_name}") elif d.provider == "dropbox": dbx = dropbox_utils.get_dropbox_client(d.access_token) full_cloud_path = f"/{new_course}/{cat_name}" cloud_utils.create_dropbox_folder(dbx, new_course) cloud_utils.create_dropbox_folder(dbx, full_cloud_path) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == d.id, models.IndexedFolder.folder_name == db_folder_name).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=d.id, folder_id=full_cloud_path, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder); db.commit(); db.refresh(db_folder) from_path = file.cloud_id if cloud_utils.move_dropbox_file(dbx, from_path, f"{new_course}/{cat_name}", file.name): file.folder_id = db_folder.id file.cloud_id = f"{full_cloud_path}/{file.name}" file.is_sorted = True create_log(db, current_user.email, "MANUAL_MOVE", f"Moved {file.name} to {db_folder_name}") except Exception as e: print(f"Auto-move failed during course update: {e}") db.commit() return {"message": "Course updated successfully!"} @app.post("/folders/{folder_db_id}/sort") def smart_sort_folder(folder_db_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """PURE AI SORT: Predicts categories and extracts course codes via Regex.""" f = db.query(models.IndexedFolder).filter(models.IndexedFolder.id == folder_db_id).first() if not f or f.drive.user_id != current_user.id: raise HTTPException(status_code=403, detail="Not authorized") if getattr(f, 'is_sorting', False): raise HTTPException(status_code=400, detail="Folder is already being sorted.") f.is_sorting = True db.commit() sorted_count = 0 skipped_count = 0 try: custom_rules = db.query(models.CustomRule).filter(models.CustomRule.user_id == current_user.id).all() disabled_defs = current_user.disabled_defaults for file in f.files: if getattr(file, 'is_locked', False) or file.category != "Unsorted": skipped_count += 1 continue category, confidence = predict_category(file.name, custom_rules, disabled_defs) file.category = category # Only use regex if the user hasn't manually overridden the course code if not file.course_code or file.course_code == 'General': file.course_code = extract_course_code(file.name) sorted_count += 1 create_log(db, current_user.email, "SORT_FOLDER", f"AI Sorted {sorted_count} files in '{f.folder_name}'") except Exception as e: raise HTTPException(status_code=500, detail=f"Sorting failed: {str(e)}") finally: f.is_sorting = False db.commit() return {"message": f"Successfully AI sorted {sorted_count} files.", "sorted_count": sorted_count} @app.put("/folders/{folder_db_id}/assign-course") def assign_folder_course(folder_db_id: int, payload: CourseOverride, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): """NEW MANUAL OVERRIDE: Sets course code, predicts categories, physically MOVES files, and LOCKS them.""" f = db.query(models.IndexedFolder).filter(models.IndexedFolder.id == folder_db_id).first() if not f or f.drive.user_id != current_user.id: raise HTTPException(status_code=403) new_course = normalize_course_code(payload.course_code) or "General" updated_count = 0 folder_cache = {} d = f.drive try: if d.provider == "google": service = google_utils.get_drive_service(d.access_token, d.refresh_token, "https://oauth2.googleapis.com/token", google_utils.get_client_id_from_file(), google_utils.get_client_secret_from_file()) elif d.provider == "dropbox": dbx = dropbox_utils.get_dropbox_client(d.access_token) custom_rules = db.query(models.CustomRule).filter(models.CustomRule.user_id == current_user.id).all() disabled_defs = current_user.disabled_defaults for file in f.files: # Predict category if it hasn't been sorted yet if file.category == "Unsorted": category, conf = predict_category(file.name, custom_rules, disabled_defs) file.category = category file.course_code = new_course cat_name = file.category db_folder_name = f"{new_course} / {cat_name}" # --- GOOGLE DRIVE MOVE LOGIC --- if d.provider == "google": if new_course not in folder_cache: folder_cache[new_course] = cloud_utils.create_google_folder(service, new_course) course_folder_id = folder_cache[new_course] if db_folder_name not in folder_cache: cloud_subfolder_id = cloud_utils.create_google_folder(service, cat_name, parent_id=course_folder_id) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == d.id, models.IndexedFolder.folder_id == cloud_subfolder_id).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=d.id, folder_id=cloud_subfolder_id, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder) db.commit(); db.refresh(db_folder) folder_cache[db_folder_name] = { "cloud_id": cloud_subfolder_id, "db_id": db_folder.id } if cloud_utils.move_google_file(service, file.cloud_id, folder_cache[db_folder_name]["cloud_id"]): file.folder_id = folder_cache[db_folder_name]["db_id"] file.is_sorted = True file.is_locked = True # <--- Locks it to protect from 'Apply Sort' updated_count += 1 # --- DROPBOX MOVE LOGIC --- elif d.provider == "dropbox": full_cloud_path = f"/{new_course}/{cat_name}" if db_folder_name not in folder_cache: cloud_utils.create_dropbox_folder(dbx, new_course) cloud_utils.create_dropbox_folder(dbx, full_cloud_path) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == d.id, models.IndexedFolder.folder_name == db_folder_name).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=d.id, folder_id=full_cloud_path, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder) db.commit(); db.refresh(db_folder) folder_cache[db_folder_name] = { "cloud_id": full_cloud_path, "db_id": db_folder.id } from_path = file.cloud_id if cloud_utils.move_dropbox_file(dbx, from_path, f"{new_course}/{cat_name}", file.name): file.folder_id = folder_cache[db_folder_name]["db_id"] file.cloud_id = f"{full_cloud_path}/{file.name}" file.is_sorted = True file.is_locked = True # <--- Locks it to protect from 'Apply Sort' updated_count += 1 create_log(db, current_user.email, "ASSIGN_COURSE", f"Assigned {new_course}, moved, and locked {updated_count} files in '{f.folder_name}'") db.commit() return {"message": f"Successfully created folders, moved, and locked {updated_count} files!"} except Exception as e: db.commit() raise HTTPException(status_code=500, detail=f"Error moving files: {str(e)}") @app.post("/drives/{drive_id}/sort-all") def smart_sort_drive(drive_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): d = db.query(models.ConnectedDrive).filter(models.ConnectedDrive.id == drive_id, models.ConnectedDrive.user_id == current_user.id).first() if not d: raise HTTPException(status_code=404, detail="Drive not found") folders = d.folders if any(getattr(f, 'is_sorting', False) for f in folders): raise HTTPException(status_code=400, detail="One or more folders are currently sorting. Please wait.") for f in folders: f.is_sorting = True db.commit() sorted_count = 0 try: custom_rules = db.query(models.CustomRule).filter(models.CustomRule.user_id == current_user.id).all() disabled_defs = current_user.disabled_defaults for f in folders: for file in f.files: if getattr(file, 'is_locked', False) or file.category != "Unsorted": continue category, confidence = predict_category(file.name, custom_rules, disabled_defs) file.category = category # ---> THE MISSING LINE: This physically saves it to the DB! <--- file.course_code = extract_course_code(file.name) sorted_count += 1 create_log(db, current_user.email, "SORT_DRIVE", f"Sorted {sorted_count} files across drive {d.drive_email}") finally: for f in folders: f.is_sorting = False db.commit() return {"message": f"Successfully sorted {sorted_count} files across all folders."} @app.put("/api/duplicates/ignore/{file_id}") def ignore_duplicate(file_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): file = db.query(models.FileMetadata).filter(models.FileMetadata.id == file_id).first() if not file: raise HTTPException(status_code=404) file.is_ignored_duplicate = True db.commit() create_log(db, current_user.email, "DUPLICATE_IGNORED", f"Ignored duplicate file: {file.name}") # <--- NEW LOG return {"message": "File ignored in cleanup."} @app.delete("/api/duplicates/delete/{file_id}") def delete_duplicate(file_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], db: Session = Depends(database.get_db)): file = db.query(models.FileMetadata).filter(models.FileMetadata.id == file_id).first() if not file: raise HTTPException(status_code=404) success = False if file.cloud_provider == "google": service = google_utils.get_drive_service(file.folder.drive.access_token, file.folder.drive.refresh_token, "https://oauth2.googleapis.com/token", google_utils.get_client_id_from_file(), google_utils.get_client_secret_from_file()) success = cloud_utils.delete_google_file(service, file.cloud_id) elif file.cloud_provider == "dropbox": dbx = dropbox_utils.get_dropbox_client(file.folder.drive.access_token) success = cloud_utils.delete_dropbox_file(dbx, f"/{file.name}") if success: file_name = file.name db.delete(file) db.commit() create_log(db, current_user.email, "DUPLICATE_DELETED", f"Permanently deleted cloud file: {file_name}") # <--- NEW LOG return {"message": "File permanently deleted."} raise HTTPException(status_code=500, detail="Failed to delete file from cloud provider.") @app.get("/folders/{folder_db_id}/preview-sort") def preview_folder_sort( folder_db_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], custom_course: Optional[str] = None, db: Session = Depends(database.get_db) ): """Generates a visual Dry Run for a SINGLE folder, with an optional course override.""" f = db.query(models.IndexedFolder).filter(models.IndexedFolder.id == folder_db_id).first() if not f or f.drive.user_id != current_user.id: raise HTTPException(status_code=403) norm_course = normalize_course_code(custom_course) if custom_course else None preview = [] custom_rules = db.query(models.CustomRule).filter(models.CustomRule.user_id == current_user.id).all() disabled_defs = current_user.disabled_defaults for file in f.files: if getattr(file, 'is_locked', False) or file.is_sorted: continue # Predict category if it hasn't been done yet cat_name = file.category if cat_name == "Unsorted": cat_name, _ = predict_category(file.name, custom_rules, disabled_defs) # Use the override if provided, otherwise fallback to AI regex extraction course_code = norm_course or extract_course_code(file.name) if not course_code: course_code = "General" new_name = f"{course_code}_{cat_name} - {file.name}" new_path = f"{course_code} / {cat_name}" preview.append({ "file_id": file.id, "old_name": file.name, "new_name": new_name, "old_path": file.original_folder_name or f.folder_name, "new_path": new_path }) return {"files": preview, "total": len(preview)} @app.post("/folders/{folder_db_id}/apply-sort") def apply_folder_sort( folder_db_id: int, current_user: Annotated[models.User, Depends(get_unrestricted_user)], custom_course: Optional[str] = None, db: Session = Depends(database.get_db) ): """Physically moves and renames files for a SINGLE folder, saving the Undo state.""" f = db.query(models.IndexedFolder).filter(models.IndexedFolder.id == folder_db_id).first() if not f or f.drive.user_id != current_user.id: raise HTTPException(status_code=403) norm_course = normalize_course_code(custom_course) if custom_course else None d = f.drive moved_count = 0 folder_cache = {} undo_data = [] try: if d.provider == "google": service = google_utils.get_drive_service(d.access_token, d.refresh_token, "https://oauth2.googleapis.com/token", google_utils.get_client_id_from_file(), google_utils.get_client_secret_from_file()) elif d.provider == "dropbox": dbx = dropbox_utils.get_dropbox_client(d.access_token) custom_rules = db.query(models.CustomRule).filter(models.CustomRule.user_id == current_user.id).all() disabled_defs = current_user.disabled_defaults for file in f.files: if getattr(file, 'is_locked', False) or file.is_sorted: continue if file.category == "Unsorted": cat, _ = predict_category(file.name, custom_rules, disabled_defs) file.category = cat file.course_code = norm_course or extract_course_code(file.name) or "General" course_code = file.course_code cat_name = file.category new_file_name = f"{course_code}_{cat_name} - {file.name}" db_folder_name = f"{course_code} / {cat_name}" undo_data.append({ "file_db_id": file.id, "original_cloud_id": file.cloud_id, "original_name": file.name, "original_db_folder_id": file.folder_id, "original_cloud_folder_id": file.folder.folder_id }) # --- GOOGLE LOGIC --- if d.provider == "google": if course_code not in folder_cache: folder_cache[course_code] = cloud_utils.create_google_folder(service, course_code) course_folder_id = folder_cache[course_code] if db_folder_name not in folder_cache: cloud_subfolder_id = cloud_utils.create_google_folder(service, cat_name, parent_id=course_folder_id) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == d.id, models.IndexedFolder.folder_id == cloud_subfolder_id).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=d.id, folder_id=cloud_subfolder_id, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder); db.commit(); db.refresh(db_folder) folder_cache[db_folder_name] = { "cloud_id": cloud_subfolder_id, "db_id": db_folder.id } if cloud_utils.move_google_file(service, file.cloud_id, folder_cache[db_folder_name]["cloud_id"], new_name=new_file_name): file.name = new_file_name; file.is_sorted = True; file.is_locked = True file.folder_id = folder_cache[db_folder_name]["db_id"] moved_count += 1 # --- DROPBOX LOGIC --- elif d.provider == "dropbox": full_cloud_path = f"/{course_code}/{cat_name}" if db_folder_name not in folder_cache: cloud_utils.create_dropbox_folder(dbx, course_code) cloud_utils.create_dropbox_folder(dbx, full_cloud_path) db_folder = db.query(models.IndexedFolder).filter(models.IndexedFolder.drive_id == d.id, models.IndexedFolder.folder_name == db_folder_name).first() if not db_folder: db_folder = models.IndexedFolder(drive_id=d.id, folder_id=full_cloud_path, folder_name=db_folder_name, last_synced=datetime.utcnow()) db.add(db_folder); db.commit(); db.refresh(db_folder) folder_cache[db_folder_name] = { "cloud_id": full_cloud_path, "db_id": db_folder.id } from_path = file.cloud_id if cloud_utils.move_dropbox_file(dbx, from_path, f"{course_code}/{cat_name}", file.name, new_name=new_file_name): file.name = new_file_name; file.is_sorted = True; file.is_locked = True file.folder_id = folder_cache[db_folder_name]["db_id"]; file.cloud_id = f"{full_cloud_path}/{new_file_name}" moved_count += 1 db.commit() create_log(db, current_user.email, "APPLY_SORT", f"Moved and renamed {moved_count} files in '{f.folder_name}'.", undo_payload=json.dumps(undo_data)) return {"message": f"Successfully previewed, renamed, and moved {moved_count} files!"} except Exception as e: db.commit() raise HTTPException(status_code=500, detail=f"Error moving files: {str(e)}")