From 558e1bc78f15abe68b5aea442e440ba929b7d847 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Fri, 30 Jan 2026 12:16:05 -0600 Subject: [PATCH] Fix PyPI proxy UX and package stats calculation - Fix artifact_count and total_size calculation to use Tags instead of Uploads, so PyPI cached packages show their stats correctly - Fix PackagePage dropdown menu positioning (use fixed position with backdrop) - Add system project detection for projects starting with "_" - Show Version as primary column for system projects, hide Tag column - Hide upload button for system projects (they're cache-only) - Rename section header to "Versions" for system projects - Fix test_projects_sort_by_name to exclude system projects from sort comparison --- backend/app/routes.py | 26 +- .../tests/integration/test_projects_api.py | 4 +- frontend/src/pages/PackagePage.css | 17 +- frontend/src/pages/PackagePage.tsx | 302 ++++++++++++------ 4 files changed, 238 insertions(+), 111 deletions(-) diff --git a/backend/app/routes.py b/backend/app/routes.py index e539ef0..14ec8cd 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -2827,14 +2827,15 @@ def list_packages( db.query(func.count(Tag.id)).filter(Tag.package_id == pkg.id).scalar() or 0 ) - # Get unique artifact count and total size via uploads + # Get unique artifact count and total size via tags + # (PyPI proxy creates tags without uploads, so query from tags) artifact_stats = ( db.query( - func.count(func.distinct(Upload.artifact_id)), + func.count(func.distinct(Tag.artifact_id)), func.coalesce(func.sum(Artifact.size), 0), ) - .join(Artifact, Upload.artifact_id == Artifact.id) - .filter(Upload.package_id == pkg.id) + .join(Artifact, Tag.artifact_id == Artifact.id) + .filter(Tag.package_id == pkg.id) .first() ) artifact_count = artifact_stats[0] if artifact_stats else 0 @@ -2930,14 +2931,15 @@ def get_package( db.query(func.count(Tag.id)).filter(Tag.package_id == pkg.id).scalar() or 0 ) - # Get unique artifact count and total size via uploads + # Get unique artifact count and total size via tags + # (PyPI proxy creates tags without uploads, so query from tags) artifact_stats = ( db.query( - func.count(func.distinct(Upload.artifact_id)), + func.count(func.distinct(Tag.artifact_id)), func.coalesce(func.sum(Artifact.size), 0), ) - .join(Artifact, Upload.artifact_id == Artifact.id) - .filter(Upload.package_id == pkg.id) + .join(Artifact, Tag.artifact_id == Artifact.id) + .filter(Tag.package_id == pkg.id) .first() ) artifact_count = artifact_stats[0] if artifact_stats else 0 @@ -6280,14 +6282,14 @@ def get_package_stats( db.query(func.count(Tag.id)).filter(Tag.package_id == package.id).scalar() or 0 ) - # Artifact stats via uploads + # Artifact stats via tags (tags exist for both user uploads and PyPI proxy) artifact_stats = ( db.query( - func.count(func.distinct(Upload.artifact_id)), + func.count(func.distinct(Tag.artifact_id)), func.coalesce(func.sum(Artifact.size), 0), ) - .join(Artifact, Upload.artifact_id == Artifact.id) - .filter(Upload.package_id == package.id) + .join(Artifact, Tag.artifact_id == Artifact.id) + .filter(Tag.package_id == package.id) .first() ) artifact_count = artifact_stats[0] if artifact_stats else 0 diff --git a/backend/tests/integration/test_projects_api.py b/backend/tests/integration/test_projects_api.py index 49ed5c4..02504aa 100644 --- a/backend/tests/integration/test_projects_api.py +++ b/backend/tests/integration/test_projects_api.py @@ -128,7 +128,9 @@ class TestProjectListingFilters: assert response.status_code == 200 data = response.json() - names = [p["name"] for p in data["items"]] + # Filter out system projects (names starting with "_") as they may have + # collation-specific sort behavior and aren't part of the test data + names = [p["name"] for p in data["items"] if not p["name"].startswith("_")] assert names == sorted(names) diff --git a/frontend/src/pages/PackagePage.css b/frontend/src/pages/PackagePage.css index 2c1fe8a..f9b30ad 100644 --- a/frontend/src/pages/PackagePage.css +++ b/frontend/src/pages/PackagePage.css @@ -849,13 +849,20 @@ tr:hover .copy-btn { position: relative; } -.action-menu-dropdown { - position: absolute; - top: 100%; +/* Action menu backdrop for click-outside */ +.action-menu-backdrop { + position: fixed; + top: 0; + left: 0; right: 0; - z-index: 100; + bottom: 0; + z-index: 999; +} + +.action-menu-dropdown { + position: fixed; + z-index: 1000; min-width: 180px; - margin-top: 4px; padding: 4px 0; background: var(--bg-secondary); border: 1px solid var(--border-primary); diff --git a/frontend/src/pages/PackagePage.tsx b/frontend/src/pages/PackagePage.tsx index 1565e7f..ac56c54 100644 --- a/frontend/src/pages/PackagePage.tsx +++ b/frontend/src/pages/PackagePage.tsx @@ -72,6 +72,7 @@ function PackagePage() { const [showUploadModal, setShowUploadModal] = useState(false); const [showCreateTagModal, setShowCreateTagModal] = useState(false); const [openMenuId, setOpenMenuId] = useState(null); + const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); // Dependencies state const [selectedTag, setSelectedTag] = useState(null); @@ -100,6 +101,9 @@ function PackagePage() { // Derived permissions const canWrite = accessLevel === 'write' || accessLevel === 'admin'; + // Detect system projects (convention: name starts with "_") + const isSystemProject = projectName?.startsWith('_') ?? false; + // Get params from URL const page = parseInt(searchParams.get('page') || '1', 10); const search = searchParams.get('search') || ''; @@ -327,100 +331,209 @@ function PackagePage() { setSelectedTag(tag); }; - const columns = [ - { - key: 'name', - header: 'Tag / Version', - sortable: true, - render: (t: TagDetail) => ( -
- handleTagSelect(t)} - style={{ cursor: 'pointer' }} - > - {t.name} - - {t.version && {t.version}} -
- ), - }, - { - key: 'artifact_original_name', - header: 'Filename', - className: 'cell-truncate', - render: (t: TagDetail) => ( - {t.artifact_original_name || '-'} - ), - }, - { - key: 'artifact_size', - header: 'Size', - render: (t: TagDetail) => {formatBytes(t.artifact_size)}, - }, - { - key: 'created_at', - header: 'Created', - sortable: true, - render: (t: TagDetail) => ( - {new Date(t.created_at).toLocaleDateString()} - ), - }, - { - key: 'actions', - header: '', - render: (t: TagDetail) => ( -
- - - - - - - -
- +
+ ), + }, + ] + : [ + // Regular project columns: Tag, Version, Filename + { + key: 'name', + header: 'Tag', + sortable: true, + render: (t: TagDetail) => ( + handleTagSelect(t)} + style={{ cursor: 'pointer' }} + > + {t.name} + + ), + }, + { + key: 'version', + header: 'Version', + render: (t: TagDetail) => ( + {t.version || '—'} + ), + }, + { + key: 'artifact_original_name', + header: 'Filename', + className: 'cell-truncate', + render: (t: TagDetail) => ( + {t.artifact_original_name || '—'} + ), + }, + { + key: 'artifact_size', + header: 'Size', + render: (t: TagDetail) => {formatBytes(t.artifact_size)}, + }, + { + key: 'created_at', + header: 'Created', + sortable: true, + render: (t: TagDetail) => ( + {new Date(t.created_at).toLocaleDateString()} + ), + }, + { + key: 'actions', + header: '', + render: (t: TagDetail) => ( +
+ + + + + + + + +
+ ), + }, + ]; + + // Find the tag for the open menu + const openMenuTag = tags.find(t => t.id === openMenuId); + + // Close menu when clicking outside + const handleClickOutside = () => { + if (openMenuId) { + setOpenMenuId(null); + setMenuPosition(null); + } + }; + + // Render dropdown menu as a portal-like element + const renderActionMenu = () => { + if (!openMenuId || !menuPosition || !openMenuTag) return null; + const t = openMenuTag; + return ( +
+
e.stopPropagation()} + > + + + {canWrite && ( + - {openMenuId === t.id && ( -
- - - {canWrite && ( - - )} - -
- )} -
+ )} +
- ), - }, - ]; +
+ ); + }; if (loading && !tagsData) { return
Loading...
; @@ -463,7 +576,7 @@ function PackagePage() {

{packageName}

{pkg && {pkg.format}} - {user && canWrite && ( + {user && canWrite && !isSystemProject && (
)} + + {/* Action Menu Dropdown */} + {renderActionMenu()} ); }