From 55517220cd4c48442a97a2d9344e9ae8dd30f676 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Tue, 6 Jan 2026 15:57:57 -0600 Subject: [PATCH] Enhanced query endpoints with filtering and global artifacts/tags APIs (#18) - Add search and tag filters to /api/v1/uploads endpoint - Add sort/order parameters to /api/v1/uploads endpoint - Add min_size/max_size filters to package artifacts endpoint - Add sort/order parameters to package artifacts endpoint - Add from/to date filters to tags endpoint - Add global /api/v1/artifacts endpoint with project/package/tag/size/date filters - Add global /api/v1/tags endpoint with project/package/search/date filters - Add GlobalArtifactResponse and GlobalTagResponse schemas --- backend/app/routes.py | 313 +++++++++++++++++++++++++++++++++++++++-- backend/app/schemas.py | 38 +++++ 2 files changed, 341 insertions(+), 10 deletions(-) diff --git a/backend/app/routes.py b/backend/app/routes.py index 381e1d7..4eb01b2 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -92,6 +92,8 @@ from .schemas import ( CrossProjectDeduplicationResponse, TimeBasedStatsResponse, StatsReportResponse, + GlobalArtifactResponse, + GlobalTagResponse, ) from .metadata import extract_metadata from .config import get_settings @@ -2034,6 +2036,12 @@ def list_tags( search: Optional[str] = Query(default=None, description="Search by tag name"), sort: str = Query(default="name", description="Sort field (name, created_at)"), order: str = Query(default="asc", description="Sort order (asc, desc)"), + from_date: Optional[datetime] = Query( + default=None, alias="from", description="Filter tags created after this date" + ), + to_date: Optional[datetime] = Query( + default=None, alias="to", description="Filter tags created before this date" + ), db: Session = Depends(get_db), ): project = db.query(Project).filter(Project.name == project_name).first() @@ -2079,6 +2087,12 @@ def list_tags( ) ) + # Apply date range filters + if from_date: + query = query.filter(Tag.created_at >= from_date) + if to_date: + query = query.filter(Tag.created_at <= to_date) + # Get total count before pagination total = query.count() @@ -2455,9 +2469,19 @@ def list_package_artifacts( created_before: Optional[datetime] = Query( default=None, description="Filter artifacts created before this date" ), + min_size: Optional[int] = Query( + default=None, ge=0, description="Minimum artifact size in bytes" + ), + max_size: Optional[int] = Query( + default=None, ge=0, description="Maximum artifact size in bytes" + ), + sort: Optional[str] = Query( + default=None, description="Sort field: created_at, size, original_name" + ), + order: Optional[str] = Query(default="desc", description="Sort order: asc or desc"), db: Session = Depends(get_db), ): - """List all unique artifacts uploaded to a package""" + """List all unique artifacts uploaded to a package with filtering and sorting.""" project = db.query(Project).filter(Project.name == project_name).first() if not project: raise HTTPException(status_code=404, detail="Project not found") @@ -2489,14 +2513,38 @@ def list_package_artifacts( if created_before: query = query.filter(Artifact.created_at <= created_before) + # Apply size range filters + if min_size is not None: + query = query.filter(Artifact.size >= min_size) + if max_size is not None: + query = query.filter(Artifact.size <= max_size) + + # Validate and apply sorting + valid_sort_fields = { + "created_at": Artifact.created_at, + "size": Artifact.size, + "original_name": Artifact.original_name, + } + if sort and sort not in valid_sort_fields: + raise HTTPException( + status_code=400, + detail=f"Invalid sort field. Valid options: {', '.join(valid_sort_fields.keys())}", + ) + sort_column = valid_sort_fields.get(sort, Artifact.created_at) + if order and order.lower() not in ("asc", "desc"): + raise HTTPException( + status_code=400, detail="Invalid order. Valid options: asc, desc" + ) + sort_order = ( + sort_column.asc() if order and order.lower() == "asc" else sort_column.desc() + ) + # Get total count before pagination total = query.count() # Apply pagination offset = (page - 1) * limit - artifacts = ( - query.order_by(Artifact.created_at.desc()).offset(offset).limit(limit).all() - ) + artifacts = query.order_by(sort_order).offset(offset).limit(limit).all() # Calculate total pages total_pages = math.ceil(total / limit) if total > 0 else 1 @@ -2537,6 +2585,224 @@ def list_package_artifacts( ) +# Global artifacts listing +@router.get( + "/api/v1/artifacts", + response_model=PaginatedResponse[GlobalArtifactResponse], +) +def list_all_artifacts( + project: Optional[str] = Query(None, description="Filter by project name"), + package: Optional[str] = Query(None, description="Filter by package name"), + tag: Optional[str] = Query(None, description="Filter by tag name"), + content_type: Optional[str] = Query(None, description="Filter by content type"), + min_size: Optional[int] = Query(None, ge=0, description="Minimum size in bytes"), + max_size: Optional[int] = Query(None, ge=0, description="Maximum size in bytes"), + from_date: Optional[datetime] = Query( + None, alias="from", description="Created after" + ), + to_date: Optional[datetime] = Query(None, alias="to", description="Created before"), + sort: Optional[str] = Query(None, description="Sort field: created_at, size"), + order: Optional[str] = Query("desc", description="Sort order: asc or desc"), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), +): + """ + List all artifacts globally with filtering by project, package, tag, etc. + + Returns artifacts with context about which projects/packages/tags reference them. + """ + # Start with base query + query = db.query(Artifact) + + # If filtering by project/package/tag, need to join through tags + if project or package or tag: + # Subquery to get artifact IDs that match the filters + tag_query = ( + db.query(Tag.artifact_id) + .join(Package, Tag.package_id == Package.id) + .join(Project, Package.project_id == Project.id) + ) + if project: + tag_query = tag_query.filter(Project.name == project) + if package: + tag_query = tag_query.filter(Package.name == package) + if tag: + tag_query = tag_query.filter(Tag.name == tag) + artifact_ids = tag_query.distinct().subquery() + query = query.filter(Artifact.id.in_(artifact_ids)) + + # Apply content type filter + if content_type: + query = query.filter(Artifact.content_type == content_type) + + # Apply size filters + if min_size is not None: + query = query.filter(Artifact.size >= min_size) + if max_size is not None: + query = query.filter(Artifact.size <= max_size) + + # Apply date filters + if from_date: + query = query.filter(Artifact.created_at >= from_date) + if to_date: + query = query.filter(Artifact.created_at <= to_date) + + # Validate and apply sorting + valid_sort_fields = {"created_at": Artifact.created_at, "size": Artifact.size} + if sort and sort not in valid_sort_fields: + raise HTTPException( + status_code=400, + detail=f"Invalid sort field. Valid options: {', '.join(valid_sort_fields.keys())}", + ) + sort_column = valid_sort_fields.get(sort, Artifact.created_at) + if order and order.lower() not in ("asc", "desc"): + raise HTTPException( + status_code=400, detail="Invalid order. Valid options: asc, desc" + ) + sort_order = ( + sort_column.asc() if order and order.lower() == "asc" else sort_column.desc() + ) + + total = query.count() + total_pages = math.ceil(total / limit) if total > 0 else 1 + + artifacts = query.order_by(sort_order).offset((page - 1) * limit).limit(limit).all() + + # Build responses with context + items = [] + for artifact in artifacts: + # Get all tags referencing this artifact with project/package info + tags_info = ( + db.query(Tag, Package, Project) + .join(Package, Tag.package_id == Package.id) + .join(Project, Package.project_id == Project.id) + .filter(Tag.artifact_id == artifact.id) + .all() + ) + + projects = list(set(proj.name for _, _, proj in tags_info)) + packages = list(set(f"{proj.name}/{pkg.name}" for _, pkg, proj in tags_info)) + tags = [f"{proj.name}/{pkg.name}:{t.name}" for t, pkg, proj in tags_info] + + items.append( + GlobalArtifactResponse( + id=artifact.id, + sha256=artifact.id, + size=artifact.size, + content_type=artifact.content_type, + original_name=artifact.original_name, + created_at=artifact.created_at, + created_by=artifact.created_by, + format_metadata=artifact.artifact_metadata, + ref_count=artifact.ref_count, + projects=projects, + packages=packages, + tags=tags, + ) + ) + + return PaginatedResponse( + items=items, + pagination=PaginationMeta( + page=page, + limit=limit, + total=total, + total_pages=total_pages, + has_more=page < total_pages, + ), + ) + + +# Global tags listing +@router.get( + "/api/v1/tags", + response_model=PaginatedResponse[GlobalTagResponse], +) +def list_all_tags( + project: Optional[str] = Query(None, description="Filter by project name"), + package: Optional[str] = Query(None, description="Filter by package name"), + search: Optional[str] = Query(None, description="Search by tag name"), + from_date: Optional[datetime] = Query( + None, alias="from", description="Created after" + ), + to_date: Optional[datetime] = Query(None, alias="to", description="Created before"), + sort: Optional[str] = Query(None, description="Sort field: name, created_at"), + order: Optional[str] = Query("desc", description="Sort order: asc or desc"), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), +): + """ + List all tags globally with filtering by project, package, name, etc. + """ + query = ( + db.query(Tag, Package, Project, Artifact) + .join(Package, Tag.package_id == Package.id) + .join(Project, Package.project_id == Project.id) + .join(Artifact, Tag.artifact_id == Artifact.id) + ) + + # Apply filters + if project: + query = query.filter(Project.name == project) + if package: + query = query.filter(Package.name == package) + if search: + query = query.filter(Tag.name.ilike(f"%{search}%")) + if from_date: + query = query.filter(Tag.created_at >= from_date) + if to_date: + query = query.filter(Tag.created_at <= to_date) + + # Validate and apply sorting + valid_sort_fields = {"name": Tag.name, "created_at": Tag.created_at} + if sort and sort not in valid_sort_fields: + raise HTTPException( + status_code=400, + detail=f"Invalid sort field. Valid options: {', '.join(valid_sort_fields.keys())}", + ) + sort_column = valid_sort_fields.get(sort, Tag.created_at) + if order and order.lower() not in ("asc", "desc"): + raise HTTPException( + status_code=400, detail="Invalid order. Valid options: asc, desc" + ) + sort_order = ( + sort_column.asc() if order and order.lower() == "asc" else sort_column.desc() + ) + + total = query.count() + total_pages = math.ceil(total / limit) if total > 0 else 1 + + results = query.order_by(sort_order).offset((page - 1) * limit).limit(limit).all() + + items = [ + GlobalTagResponse( + id=tag.id, + name=tag.name, + artifact_id=tag.artifact_id, + created_at=tag.created_at, + created_by=tag.created_by, + project_name=proj.name, + package_name=pkg.name, + artifact_size=artifact.size, + artifact_content_type=artifact.content_type, + ) + for tag, pkg, proj, artifact in results + ] + + return PaginatedResponse( + items=items, + pagination=PaginationMeta( + page=page, + limit=limit, + total=total, + total_pages=total_pages, + has_more=page < total_pages, + ), + ) + + # Artifact by ID @router.get("/api/v1/artifact/{artifact_id}", response_model=ArtifactDetailResponse) def get_artifact(artifact_id: str, db: Session = Depends(get_db)): @@ -3744,6 +4010,12 @@ def list_all_uploads( deduplicated: Optional[bool] = Query( None, description="Filter by deduplication status" ), + search: Optional[str] = Query(None, description="Search by original filename"), + tag: Optional[str] = Query(None, description="Filter by tag name"), + sort: Optional[str] = Query( + None, description="Sort field: uploaded_at, original_name, size" + ), + order: Optional[str] = Query("desc", description="Sort order: asc or desc"), page: int = Query(1, ge=1), limit: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), @@ -3757,6 +4029,8 @@ def list_all_uploads( - uploaded_by: Filter by user ID - from/to: Filter by timestamp range - deduplicated: Filter by deduplication status + - search: Search by original filename (case-insensitive) + - tag: Filter by tag name """ query = ( db.query(Upload, Package, Project, Artifact) @@ -3778,16 +4052,35 @@ def list_all_uploads( query = query.filter(Upload.uploaded_at <= to_date) if deduplicated is not None: query = query.filter(Upload.deduplicated == deduplicated) + if search: + query = query.filter(Upload.original_name.ilike(f"%{search}%")) + if tag: + query = query.filter(Upload.tag_name == tag) + + # Validate and apply sorting + valid_sort_fields = { + "uploaded_at": Upload.uploaded_at, + "original_name": Upload.original_name, + "size": Artifact.size, + } + if sort and sort not in valid_sort_fields: + raise HTTPException( + status_code=400, + detail=f"Invalid sort field. Valid options: {', '.join(valid_sort_fields.keys())}", + ) + sort_column = valid_sort_fields.get(sort, Upload.uploaded_at) + if order and order.lower() not in ("asc", "desc"): + raise HTTPException( + status_code=400, detail="Invalid order. Valid options: asc, desc" + ) + sort_order = ( + sort_column.asc() if order and order.lower() == "asc" else sort_column.desc() + ) total = query.count() total_pages = math.ceil(total / limit) if total > 0 else 1 - results = ( - query.order_by(Upload.uploaded_at.desc()) - .offset((page - 1) * limit) - .limit(limit) - .all() - ) + results = query.order_by(sort_order).offset((page - 1) * limit).limit(limit).all() items = [ UploadHistoryResponse( diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2f66c49..9bd3701 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -343,6 +343,44 @@ class PackageArtifactResponse(BaseModel): from_attributes = True +class GlobalArtifactResponse(BaseModel): + """Artifact with project/package context for global listing""" + + id: str + sha256: str + size: int + content_type: Optional[str] + original_name: Optional[str] + created_at: datetime + created_by: str + format_metadata: Optional[Dict[str, Any]] = None + ref_count: int = 0 + # Context from tags/packages + projects: List[str] = [] # List of project names containing this artifact + packages: List[str] = [] # List of "project/package" paths + tags: List[str] = [] # List of "project/package:tag" references + + class Config: + from_attributes = True + + +class GlobalTagResponse(BaseModel): + """Tag with project/package context for global listing""" + + id: UUID + name: str + artifact_id: str + created_at: datetime + created_by: str + project_name: str + package_name: str + artifact_size: Optional[int] = None + artifact_content_type: Optional[str] = None + + class Config: + from_attributes = True + + # Upload response class UploadResponse(BaseModel): artifact_id: str