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
This commit is contained in:
@@ -92,6 +92,8 @@ from .schemas import (
|
|||||||
CrossProjectDeduplicationResponse,
|
CrossProjectDeduplicationResponse,
|
||||||
TimeBasedStatsResponse,
|
TimeBasedStatsResponse,
|
||||||
StatsReportResponse,
|
StatsReportResponse,
|
||||||
|
GlobalArtifactResponse,
|
||||||
|
GlobalTagResponse,
|
||||||
)
|
)
|
||||||
from .metadata import extract_metadata
|
from .metadata import extract_metadata
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
@@ -2034,6 +2036,12 @@ def list_tags(
|
|||||||
search: Optional[str] = Query(default=None, description="Search by tag name"),
|
search: Optional[str] = Query(default=None, description="Search by tag name"),
|
||||||
sort: str = Query(default="name", description="Sort field (name, created_at)"),
|
sort: str = Query(default="name", description="Sort field (name, created_at)"),
|
||||||
order: str = Query(default="asc", description="Sort order (asc, desc)"),
|
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),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
project = db.query(Project).filter(Project.name == project_name).first()
|
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
|
# Get total count before pagination
|
||||||
total = query.count()
|
total = query.count()
|
||||||
|
|
||||||
@@ -2455,9 +2469,19 @@ def list_package_artifacts(
|
|||||||
created_before: Optional[datetime] = Query(
|
created_before: Optional[datetime] = Query(
|
||||||
default=None, description="Filter artifacts created before this date"
|
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),
|
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()
|
project = db.query(Project).filter(Project.name == project_name).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
@@ -2489,14 +2513,38 @@ def list_package_artifacts(
|
|||||||
if created_before:
|
if created_before:
|
||||||
query = query.filter(Artifact.created_at <= 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
|
# Get total count before pagination
|
||||||
total = query.count()
|
total = query.count()
|
||||||
|
|
||||||
# Apply pagination
|
# Apply pagination
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
artifacts = (
|
artifacts = query.order_by(sort_order).offset(offset).limit(limit).all()
|
||||||
query.order_by(Artifact.created_at.desc()).offset(offset).limit(limit).all()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate total pages
|
# Calculate total pages
|
||||||
total_pages = math.ceil(total / limit) if total > 0 else 1
|
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
|
# Artifact by ID
|
||||||
@router.get("/api/v1/artifact/{artifact_id}", response_model=ArtifactDetailResponse)
|
@router.get("/api/v1/artifact/{artifact_id}", response_model=ArtifactDetailResponse)
|
||||||
def get_artifact(artifact_id: str, db: Session = Depends(get_db)):
|
def get_artifact(artifact_id: str, db: Session = Depends(get_db)):
|
||||||
@@ -3744,6 +4010,12 @@ def list_all_uploads(
|
|||||||
deduplicated: Optional[bool] = Query(
|
deduplicated: Optional[bool] = Query(
|
||||||
None, description="Filter by deduplication status"
|
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),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -3757,6 +4029,8 @@ def list_all_uploads(
|
|||||||
- uploaded_by: Filter by user ID
|
- uploaded_by: Filter by user ID
|
||||||
- from/to: Filter by timestamp range
|
- from/to: Filter by timestamp range
|
||||||
- deduplicated: Filter by deduplication status
|
- deduplicated: Filter by deduplication status
|
||||||
|
- search: Search by original filename (case-insensitive)
|
||||||
|
- tag: Filter by tag name
|
||||||
"""
|
"""
|
||||||
query = (
|
query = (
|
||||||
db.query(Upload, Package, Project, Artifact)
|
db.query(Upload, Package, Project, Artifact)
|
||||||
@@ -3778,16 +4052,35 @@ def list_all_uploads(
|
|||||||
query = query.filter(Upload.uploaded_at <= to_date)
|
query = query.filter(Upload.uploaded_at <= to_date)
|
||||||
if deduplicated is not None:
|
if deduplicated is not None:
|
||||||
query = query.filter(Upload.deduplicated == deduplicated)
|
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 = query.count()
|
||||||
total_pages = math.ceil(total / limit) if total > 0 else 1
|
total_pages = math.ceil(total / limit) if total > 0 else 1
|
||||||
|
|
||||||
results = (
|
results = query.order_by(sort_order).offset((page - 1) * limit).limit(limit).all()
|
||||||
query.order_by(Upload.uploaded_at.desc())
|
|
||||||
.offset((page - 1) * limit)
|
|
||||||
.limit(limit)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
items = [
|
items = [
|
||||||
UploadHistoryResponse(
|
UploadHistoryResponse(
|
||||||
|
|||||||
@@ -343,6 +343,44 @@ class PackageArtifactResponse(BaseModel):
|
|||||||
from_attributes = True
|
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
|
# Upload response
|
||||||
class UploadResponse(BaseModel):
|
class UploadResponse(BaseModel):
|
||||||
artifact_id: str
|
artifact_id: str
|
||||||
|
|||||||
Reference in New Issue
Block a user