import re from datetime import datetime, timezone from typing import Annotated, Any, Optional from fastapi import APIRouter, HTTPException, Query, status from pymongo import ASCENDING, DESCENDING, ReturnDocument from ..crud import ref_filter, to_object_id from ..db import get_db from ..models import ( AssignRequest, AssignmentCount, Frequency, Page, PreprintSourceCreate, PreprintSourceOptions, PreprintSourceOut, PreprintSourceUpdate, SourceType, ) # Assignment is restricted to this department. `users.department` is an int. MRD_DEPARTMENT = 0 router = APIRouter(prefix="/preprint-sources", tags=["preprint sources"]) COLLECTION = "preprintsource" SORTABLE = {"title", "website", "discipline", "type", "frequency", "status", "created_on", "updated_on"} def sources(): return get_db()[COLLECTION] def clean_disciplines(disciplines: Any) -> list[dict[str, str]]: """Tidy the per-source discipline list. Blanks are dropped and duplicates collapse on short name, case-insensitively -- but the first spelling entered is kept, so typing "MATH" later does not rewrite "math". The order the user arranged them in is preserved. """ if not disciplines: return [] cleaned: list[dict[str, str]] = [] seen: set[str] = set() for item in disciplines: data = item if isinstance(item, dict) else item.model_dump() title = (data.get("title") or "").strip() short = (data.get("short_name") or "").strip() if not title or not short or short.lower() in seen: continue seen.add(short.lower()) cleaned.append({"title": title, "short_name": short}) return cleaned def discipline_label(disciplines: list[dict[str, str]]) -> str: """The readable form stored alongside, e.g. 'Mathematics, Physics'.""" return ", ".join(d["title"] for d in disciplines) def normalise_website(value: str) -> str: """Accept 'arxiv.org' as readily as 'https://arxiv.org'.""" value = value.strip().rstrip("/") if value and not re.match(r"^https?://", value, re.I): value = f"https://{value}" return value def not_found(source_id: str) -> HTTPException: return HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No preprint source with id '{source_id}'", ) def object_id_or_400(source_id: str): oid = to_object_id(source_id) if oid is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="id must be a 24-character ObjectId", ) return oid async def assert_website_free(website: str, exclude_id=None) -> None: """Websites are the natural key here, so keep them unique (case-insensitive).""" query: dict[str, Any] = {"website": {"$regex": f"^{re.escape(website)}$", "$options": "i"}} if exclude_id is not None: query["_id"] = {"$ne": exclude_id} if await sources().find_one(query, {"_id": 1}): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"A source with website '{website}' already exists", ) @router.get("/meta/options", response_model=PreprintSourceOptions, summary="Dropdown values") async def options(): return PreprintSourceOptions( types=[t.value for t in SourceType], frequencies=[f.value for f in Frequency], ) @router.get("", response_model=Page[PreprintSourceOut], summary="List / search preprint sources") async def list_sources( q: Annotated[Optional[str], Query(description="Search title, website, discipline, key features, note")] = None, type: Annotated[Optional[list[SourceType]], Query()] = None, frequency: Annotated[Optional[list[Frequency]], Query()] = None, status_: Annotated[Optional[int], Query(alias="status", ge=0, le=1)] = None, assigned: Annotated[ Optional[bool], Query(description="true = taken by someone, false = still free to assign"), ] = None, assigned_to: Annotated[ Optional[str], Query(description="Only sources belonging to this user (_id)") ] = None, sort_by: Annotated[str, Query()] = "created_on", sort_dir: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", skip: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=200)] = 25, ): if sort_by not in SORTABLE: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"sort_by must be one of: {', '.join(sorted(SORTABLE))}", ) query: dict[str, Any] = {} if q: rx = {"$regex": re.escape(q), "$options": "i"} query["$or"] = [ {"title": rx}, {"website": rx}, {"discipline": rx}, {"disciplines.title": rx}, {"disciplines.short_name": rx}, {"key_features": rx}, {"note": rx}, ] if type: query["type"] = {"$in": [t.value for t in type]} if frequency: query["frequency"] = {"$in": [f.value for f in frequency]} if status_ is not None: query["status"] = status_ # This is the rule that keeps topics exclusive: `assigned=false` returns # only sources nobody owns yet, so one user's picks vanish from everyone # else's available list. if assigned is not None: query["assigned_to"] = {"$ne": None} if assigned else None if assigned_to: oid = to_object_id(assigned_to) query["assigned_to"] = oid if oid is not None else assigned_to col = sources() total = await col.count_documents(query) cursor = ( col.find(query) .sort(sort_by, ASCENDING if sort_dir == "asc" else DESCENDING) .skip(skip) .limit(limit) ) return Page[PreprintSourceOut]( total=total, skip=skip, limit=limit, items=await cursor.to_list(length=limit) ) @router.get( "/assignments/summary", response_model=list[AssignmentCount], summary="How many sources each user holds", ) async def assignment_summary(): rows = await ( await sources().aggregate( [ {"$match": {"assigned_to": {"$ne": None}}}, { "$group": { "_id": "$assigned_to", "assigned_user_id": {"$first": "$assigned_user_id"}, "count": {"$sum": 1}, } }, ] ) ).to_list(length=None) return [ AssignmentCount( assigned_to=str(r["_id"]), assigned_user_id=r.get("assigned_user_id"), count=r["count"], ) for r in rows ] @router.put( "/{source_id}/assignment", response_model=PreprintSourceOut, summary="Assign a source to an MRD user (fails if already taken)", ) async def assign_source(source_id: str, payload: AssignRequest): oid = object_id_or_400(source_id) user = await get_db()["users"].find_one( ref_filter(payload.user_ref), {"user_id": 1, "fname": 1, "lname": 1, "department": 1, "status": 1} ) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No user matching '{payload.user_ref}'", ) if user.get("department") != MRD_DEPARTMENT: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"{user.get('user_id')} is not an MRD user (department must be {MRD_DEPARTMENT})", ) source = await sources().find_one({"_id": oid}, {"title": 1, "assigned_to": 1, "assigned_user_id": 1}) if not source: raise not_found(source_id) # The uniqueness rule. Re-assigning to the same person is a no-op, but # taking it from someone else has to be an explicit unassign first. holder = source.get("assigned_to") if holder is not None and holder != user["_id"]: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=( f"“{source.get('title')}” is already assigned to " f"{source.get('assigned_user_id')}. Unassign it first." ), ) full_name = " ".join(part for part in (user.get("fname"), user.get("lname")) if part) doc = await sources().find_one_and_update( {"_id": oid, "$or": [{"assigned_to": None}, {"assigned_to": user["_id"]}]}, { "$set": { "assigned_to": user["_id"], "assigned_user_id": user.get("user_id"), "assigned_name": full_name or user.get("user_id"), "assigned_on": datetime.now(timezone.utc), "updated_on": datetime.now(timezone.utc), } }, return_document=ReturnDocument.AFTER, ) if not doc: # Lost a race against a concurrent assignment. raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="That source was assigned to someone else a moment ago.", ) return doc @router.delete( "/{source_id}/assignment", response_model=PreprintSourceOut, summary="Release a source back to the unassigned pool", ) async def unassign_source(source_id: str): doc = await sources().find_one_and_update( {"_id": object_id_or_400(source_id)}, { "$set": { "assigned_to": None, "assigned_user_id": None, "assigned_name": None, "assigned_on": None, "updated_on": datetime.now(timezone.utc), } }, return_document=ReturnDocument.AFTER, ) if not doc: raise not_found(source_id) return doc @router.get("/{source_id}", response_model=PreprintSourceOut, summary="Get one source") async def get_source(source_id: str): doc = await sources().find_one({"_id": object_id_or_400(source_id)}) if not doc: raise not_found(source_id) return doc @router.post("", response_model=PreprintSourceOut, status_code=status.HTTP_201_CREATED, summary="Create a source") async def create_source(payload: PreprintSourceCreate): doc = payload.model_dump(exclude_none=True) doc["website"] = normalise_website(doc["website"]) doc["title"] = doc["title"].strip() doc["disciplines"] = clean_disciplines(doc.get("disciplines")) doc["discipline"] = discipline_label(doc["disciplines"]) await assert_website_free(doc["website"]) now = datetime.now(timezone.utc) doc["created_on"] = now doc["updated_on"] = now result = await sources().insert_one(doc) return await sources().find_one({"_id": result.inserted_id}) @router.patch("/{source_id}", response_model=PreprintSourceOut, summary="Partially update a source") async def update_source(source_id: str, payload: PreprintSourceUpdate): oid = object_id_or_400(source_id) changes = payload.model_dump(exclude_unset=True) if not changes: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields supplied") if "website" in changes and changes["website"]: changes["website"] = normalise_website(changes["website"]) await assert_website_free(changes["website"], exclude_id=oid) if "title" in changes and changes["title"]: changes["title"] = changes["title"].strip() if "disciplines" in changes: # `discipline` is derived, never edited directly, so the readable form # can never drift out of step with the codes. changes["disciplines"] = clean_disciplines(changes["disciplines"]) changes["discipline"] = discipline_label(changes["disciplines"]) changes["updated_on"] = datetime.now(timezone.utc) doc = await sources().find_one_and_update( {"_id": oid}, {"$set": changes}, return_document=ReturnDocument.AFTER ) if not doc: raise not_found(source_id) return doc @router.delete("/{source_id}", response_model=PreprintSourceOut, summary="Deactivate (default) or hard-delete") async def delete_source( source_id: str, hard: Annotated[bool, Query(description="true permanently removes the document")] = False, ): oid = object_id_or_400(source_id) if hard: doc = await sources().find_one_and_delete({"_id": oid}) else: doc = await sources().find_one_and_update( {"_id": oid}, {"$set": {"status": 0, "updated_on": datetime.now(timezone.utc)}}, return_document=ReturnDocument.AFTER, ) if not doc: raise not_found(source_id) return doc