Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions backend/routers/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ async def list_images_in_project(
limit: int = 100,
include_deleted: bool = Query(False),
deleted_only: bool = Query(False),
search_field: Optional[str] = Query(None),
search_value: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: schemas.User = Depends(get_current_user),
):
Expand All @@ -104,7 +106,7 @@ async def list_images_in_project(
"""
# Check cache first
cache = get_cache()
cache_key = f"project_images:{project_id}:skip:{skip}:limit:{limit}:include_deleted:{include_deleted}:deleted_only:{deleted_only}"
cache_key = f"project_images:{project_id}:skip:{skip}:limit:{limit}:include_deleted:{include_deleted}:deleted_only:{deleted_only}:search_field:{search_field}:search_value:{search_value}"
cached_images = cache.get(cache_key)

if cached_images is not None:
Expand All @@ -124,7 +126,7 @@ async def list_images_in_project(
if deleted_only:
images = await crud.get_deleted_images_for_project(db=db, project_id=project_id, skip=skip, limit=limit)
else:
images = await crud.get_data_instances_for_project(db=db, project_id=project_id, skip=skip, limit=limit)
images = await crud.get_data_instances_for_project(db=db, project_id=project_id, skip=skip, limit=limit, search_field=search_field, search_value=search_value)

# Process images using utility function for consistent serialization
response_images = []
Expand Down Expand Up @@ -153,6 +155,8 @@ async def list_images_in_project_with_slash(
limit: int = 100,
include_deleted: bool = Query(False),
deleted_only: bool = Query(False),
search_field: Optional[str] = Query(None),
search_value: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: schemas.User = Depends(get_current_user),
):
Expand All @@ -162,7 +166,7 @@ async def list_images_in_project_with_slash(
It ensures compatibility with various frontend routing configurations.
"""
# Just call the main function to avoid code duplication
return await list_images_in_project(project_id, skip, limit, include_deleted, deleted_only, db, current_user)
return await list_images_in_project(project_id, skip, limit, include_deleted, deleted_only, search_field, search_value, db, current_user)


@router.get("/images/{image_id}", response_model=schemas.DataInstance)
Expand Down
12 changes: 6 additions & 6 deletions backend/tests/test_image_list_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_image_list_cache_miss_and_hit(self, client):
assert ur.status_code == 201

cache = get_cache()
cache_key = f"project_images:{pid}:skip:0:limit:100:include_deleted:False:deleted_only:False"
cache_key = f"project_images:{pid}:skip:0:limit:100:include_deleted:False:deleted_only:False:search_field:None:search_value:None"

# Ensure cache is empty initially
assert cache.get(cache_key) is None
Expand Down Expand Up @@ -104,9 +104,9 @@ def test_image_list_pagination_cache_separately(self, client):
assert len(r3.json()) == 5

# Verify separate cache entries
cache_key_1 = f"project_images:{pid}:skip:0:limit:2:include_deleted:False:deleted_only:False"
cache_key_2 = f"project_images:{pid}:skip:2:limit:2:include_deleted:False:deleted_only:False"
cache_key_3 = f"project_images:{pid}:skip:0:limit:100:include_deleted:False:deleted_only:False"
cache_key_1 = f"project_images:{pid}:skip:0:limit:2:include_deleted:False:deleted_only:False:search_field:None:search_value:None"
cache_key_2 = f"project_images:{pid}:skip:2:limit:2:include_deleted:False:deleted_only:False:search_field:None:search_value:None"
cache_key_3 = f"project_images:{pid}:skip:0:limit:100:include_deleted:False:deleted_only:False:search_field:None:search_value:None"

# Debug info in case of failure
cached_1 = cache.get(cache_key_1)
Expand Down Expand Up @@ -139,7 +139,7 @@ def test_image_list_cache_invalidation_on_upload(self, client):
assert ur1.status_code == 201

cache = get_cache()
cache_key = f"project_images:{pid}:skip:0:limit:100:include_deleted:False:deleted_only:False"
cache_key = f"project_images:{pid}:skip:0:limit:100:include_deleted:False:deleted_only:False:search_field:None:search_value:None"

# Get image list - should cache it
r1 = client.get(f"/api/projects/{pid}/images")
Expand Down Expand Up @@ -206,7 +206,7 @@ def test_image_list_nonexistent_project_not_cached(self, client):
assert r.json() == []

# Verify no cache entry was created for nonexistent project
cache_key = f"project_images:{nonexistent_pid}:skip:0:limit:100:include_deleted:False:deleted_only:False"
cache_key = f"project_images:{nonexistent_pid}:skip:0:limit:100:include_deleted:False:deleted_only:False:search_field:None:search_value:None"
# Note: The current implementation may still cache empty results
# This test documents the current behavior - empty results are cached
# which is actually beneficial to avoid repeated database queries
38 changes: 30 additions & 8 deletions backend/utils/crud.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import uuid
from sqlalchemy import select, update, delete, and_
import re
from sqlalchemy import select, update, delete, and_, text, or_, cast, String

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'text' is not used.
Import of 'or_' is not used.

Copilot Autofix

AI about 1 month ago

The best way to fix the problem is to remove the unnecessary text import from the import statement on line 3 in backend/utils/crud.py. Keep all other imported symbols intact, as they may be used elsewhere in the code. The change should only modify the import line, specifically deleting text from the list of symbols imported from sqlalchemy. No other changes are required to existing functionality—just a precise edit to the import.


Suggested changeset 1
backend/utils/crud.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/backend/utils/crud.py b/backend/utils/crud.py
--- a/backend/utils/crud.py
+++ b/backend/utils/crud.py
@@ -1,6 +1,6 @@
 import uuid
 import re
-from sqlalchemy import select, update, delete, and_, text, or_, cast, String
+from sqlalchemy import select, update, delete, and_, or_, cast, String
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from core import models, schemas
EOF
@@ -1,6 +1,6 @@
import uuid
import re
from sqlalchemy import select, update, delete, and_, text, or_, cast, String
from sqlalchemy import select, update, delete, and_, or_, cast, String
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from core import models, schemas
Copilot is powered by AI and may make mistakes. Always verify output.
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from core import models, schemas
Expand Down Expand Up @@ -155,18 +156,39 @@
"""
return await get_data_instance(db, image_id)

async def get_data_instances_for_project(db: AsyncSession, project_id: uuid.UUID, skip: int = 0, limit: int = 100) -> List[models.DataInstance]:
async def get_data_instances_for_project(db: AsyncSession, project_id: uuid.UUID, skip: int = 0, limit: int = 100, search_field: Optional[str] = None, search_value: Optional[str] = None) -> List[models.DataInstance]:
# First check if the project exists
project = await get_project(db, project_id)
if not project:
return []

result = await db.execute(
select(models.DataInstance)
.where(models.DataInstance.project_id == project_id)
.offset(skip)
.limit(limit)
)
query = select(models.DataInstance).where(models.DataInstance.project_id == project_id)

if search_field and search_value:
search_value_lower = f"%{search_value.lower()}%"

if search_field == 'filename':
query = query.where(models.DataInstance.filename.ilike(search_value_lower))
elif search_field == 'content_type':
query = query.where(models.DataInstance.content_type.ilike(search_value_lower))
elif search_field == 'uploaded_by':
query = query.where(models.DataInstance.uploaded_by_user_id.ilike(search_value_lower))
elif search_field == 'metadata':
# Search across all metadata values using safe SQLAlchemy cast
query = query.where(cast(models.DataInstance.metadata_, String).ilike(search_value_lower))
else:
# Search specific metadata key using JSON path with input validation
# Only allow alphanumeric characters, underscores, and hyphens for security
if re.match(r'^[a-zA-Z0-9_-]+$', search_field):
Copy link

Copilot AI Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The regex pattern for validating metadata keys is a magic string that could benefit from being defined as a constant at the module level with a descriptive name like VALID_METADATA_KEY_PATTERN.

Copilot uses AI. Check for mistakes.

# Use SQLAlchemy's JSON path operator safely
query = query.where(models.DataInstance.metadata_[search_field].astext.ilike(search_value_lower))
else:
# Invalid key format, skip filtering for security
safe_search_field = search_field.replace('\n', '').replace('\r', '') if search_field else 'None'
logger.warning(f"Invalid metadata key format rejected: {safe_search_field}")

query = query.offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()

async def get_deleted_images_for_project(db: AsyncSession, project_id: uuid.UUID, skip: int = 0, limit: int = 100) -> List[models.DataInstance]:
Expand Down
89 changes: 89 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -2059,6 +2059,95 @@ ul li:hover {
background: var(--gray-50);
}

/* Custom Metadata Table Styling */
.custom-metadata-table {
margin-top: var(--space-4);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
overflow: hidden;
}

.custom-metadata-table th {
background: var(--primary-600);
color: white;
font-weight: 600;
text-align: left;
padding: var(--space-3) var(--space-4);
border-bottom: none;
}

.custom-metadata-table td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-light);
}

.custom-metadata-table .metadata-label {
font-weight: 600;
color: var(--gray-700);
background: var(--gray-50);
width: 25%;
}

.custom-metadata-table .metadata-value {
font-family: var(--font-family-mono);
font-size: 0.875rem;
color: var(--gray-800);
word-break: break-word;
width: 50%;
}

.custom-metadata-table .metadata-actions {
width: 25%;
text-align: right;
}

.custom-metadata-table .metadata-actions .btn {
margin-left: var(--space-2);
}

.custom-metadata-table .metadata-null {
color: var(--gray-500);
font-style: italic;
}

.custom-metadata-table pre {
background: var(--gray-100);
padding: var(--space-2);
border-radius: var(--radius-sm);
margin: 0;
font-size: 0.75rem;
overflow-x: auto;
}

/* Search Field Select Styling */
.search-field-select {
min-width: 200px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: white;
font-size: 0.875rem;
margin-right: var(--space-2);
}

.search-field-select optgroup {
font-weight: 600;
color: var(--gray-700);
background: var(--gray-50);
padding: var(--space-1);
}

.search-field-select option {
padding: var(--space-1) var(--space-2);
color: var(--gray-800);
}

.search-field-select optgroup[label="Custom Metadata Keys"] option {
font-family: var(--font-family-mono);
font-size: 0.8rem;
padding-left: var(--space-4);
}

.key-id {
background: var(--gray-100);
padding: var(--space-1) var(--space-2);
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/Project.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,19 @@ function Project() {
const fetchImages = useCallback(async (projId, opts = {}) => {
const inc = opts.includeDeleted ?? includeDeleted;
const delOnly = opts.deletedOnly ?? deletedOnly;
const searchField = opts.searchField;
const searchValue = opts.searchValue;
let url = `/api/projects/${projId}/images`;
const params = [];
if (delOnly) {
params.push('deleted_only=true');
} else if (inc) {
params.push('include_deleted=true');
}
if (searchField && searchValue) {
params.push(`search_field=${encodeURIComponent(searchField)}`);
params.push(`search_value=${encodeURIComponent(searchValue)}`);
}
if (params.length) url += `?${params.join('&')}`;
const imagesResponse = await fetch(url);
if (imagesResponse.ok) {
Expand Down Expand Up @@ -186,7 +192,7 @@ function Project() {
images={images}
loading={loading}
onImageUpdated={handleImageStateUpdate}
refreshProjectImages={() => fetchImages(id)}
refreshProjectImages={(searchOpts) => fetchImages(id, searchOpts)}
/>
</div>

Expand Down
Loading