Merge branch 'fix/new-downloads' into 'main'

feat(download-tracking): implementar seguimiento de descargas por usuario en el dashboard

See merge request fjmimbre/orcid_system!5
This commit is contained in:
alexis
2026-06-03 10:31:09 +00:00
7 changed files with 94 additions and 14 deletions
+9 -1
View File
@@ -64,10 +64,18 @@ def init_db():
def _ensure_columns():
insp = inspect(engine)
if "publications" in insp.get_table_names():
table_names = set(insp.get_table_names())
if "publications" in table_names:
cols = {c["name"] for c in insp.get_columns("publications")}
if "downloaded" not in cols:
with engine.begin() as conn:
conn.execute(
text("ALTER TABLE publications ADD COLUMN downloaded BOOLEAN NOT NULL DEFAULT FALSE")
)
# Per-user download tracking (PublicationDownload model).
if "publication_downloads" not in table_names:
from app.db.models import PublicationDownload # noqa: F401
PublicationDownload.__table__.create(bind=engine, checkfirst=True)
+13 -7
View File
@@ -18,17 +18,23 @@ def require_export_access(
api_key: str | None = Depends(api_key_header),
current: Researcher | None = Depends(get_optional_current_researcher),
) -> Researcher | None:
if api_key is not None:
if not is_valid_api_key(api_key):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
return current
"""
Allow export when the proxy supplies a valid API key and/or the user
sends a valid Bearer token. Prefer returning `current` when both are
present so per-user download tracking is recorded on export.
"""
if api_key is not None and not is_valid_api_key(api_key):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
if current is not None:
return current
if api_key is not None:
return None
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API key",
@@ -444,7 +444,7 @@ export function PublicationsTable({
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
>
<SparkleIcon size={9} />
Nuevo
NUEVO
</span>
)}
<p className="text-[14px] font-medium leading-relaxed text-ink-primary">
@@ -570,7 +570,7 @@ export function PublicationsTable({
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
>
<SparkleIcon size={9} />
Nuevo
NUEVO
</span>
)}
{pub.title}
+23 -3
View File
@@ -21,6 +21,10 @@ import {
swordXmlFilename,
} from "../utils/exportProfiles";
import { useAuth } from "../contexts/AuthContext";
import {
markPublicationsAsDownloaded,
publicationsNeedDownloadFlags,
} from "../utils/downloadTracking";
const SUCCESS_FLASH_MS = 3000;
/** Minimum gap between sync requests (protects backend + avoids toast spam). */
@@ -48,6 +52,7 @@ export function DashboardPage() {
const { isAuthenticated } = useAuth();
const initialBundleRef = useRef(location.state?.bundle ?? null);
const consumedInitialBundleRef = useRef(false);
const initialBundle = initialBundleRef.current;
const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null);
@@ -115,14 +120,25 @@ export function DashboardPage() {
useEffect(() => {
if (!isValidOrcid(orcid)) return;
if (initialBundleRef.current) {
const cachedBundle = initialBundleRef.current;
if (cachedBundle && !consumedInitialBundleRef.current) {
consumedInitialBundleRef.current = true;
initialBundleRef.current = null;
return;
if (
!publicationsNeedDownloadFlags(
cachedBundle.publications,
isAuthenticated,
)
) {
return;
}
}
const ctrl = new AbortController();
loadBundle(ctrl.signal);
return () => ctrl.abort();
}, [orcid, loadBundle]);
}, [orcid, loadBundle, isAuthenticated]);
useEffect(() => {
return () => {
@@ -270,6 +286,10 @@ export function DashboardPage() {
} else {
scope = "todo el investigador";
}
if (isAuthenticated && ids?.length) {
setPublications((prev) => markPublicationsAsDownloaded(prev, ids));
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: EXPORT_TOAST_ID,
description: scope,
+10 -1
View File
@@ -22,6 +22,7 @@ import {
} from "../utils/exportProfiles";
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { useAuth } from "../contexts/AuthContext";
import { markGroupResultsAsDownloaded } from "../utils/downloadTracking";
const EXPORT_COOLDOWN_MS = 5000;
const GLOBAL_EXPORT_TOAST_ID = "group-export-global";
@@ -108,7 +109,7 @@ export function GroupResultsPage() {
})();
return () => ctrl.abort();
}, [orcidIds, navigate]);
}, [orcidIds, navigate, isAuthenticated]);
useEffect(() => {
const cardTimersObj = cardExportCooldownTimerRef.current;
@@ -218,6 +219,10 @@ export function GroupResultsPage() {
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
if (isAuthenticated) {
setResults((prev) => markGroupResultsAsDownloaded(prev, ids));
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: GLOBAL_EXPORT_TOAST_ID,
description: `${ids.length} publicaciones exportadas.`,
@@ -276,6 +281,10 @@ export function GroupResultsPage() {
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
if (isAuthenticated) {
setResults((prev) => markGroupResultsAsDownloaded(prev, ids));
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
id: `group-export-card-${orcidId}`,
description: `${ids.length} publicaciones de ${orcidId}.`,
+5
View File
@@ -29,6 +29,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1038/s41567-025-xxxx",
type: "journal-article",
last_modified: "2025-09-01T10:00:00Z",
downloaded_by_me: false,
},
{
id: "uuid-2",
@@ -40,6 +41,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1000/jdr.2024.12",
type: "review",
last_modified: "2024-11-12T09:00:00Z",
downloaded_by_me: false,
},
{
id: "uuid-3",
@@ -50,6 +52,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1007/s11192-024-04801-z",
type: "journal-article",
last_modified: "2024-06-20T15:30:00Z",
downloaded_by_me: true,
},
{
id: "uuid-4",
@@ -60,6 +63,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1145/3587-dl.2023.09",
type: "conference-paper",
last_modified: "2023-10-05T11:45:00Z",
downloaded_by_me: false,
},
{
id: "uuid-5",
@@ -70,6 +74,7 @@ export const MOCK_PUBLICATIONS = [
doi: "10.1016/j.ijls.2023.03.011",
type: "journal-article",
last_modified: "2023-04-18T08:15:00Z",
downloaded_by_me: true,
},
];
+32
View File
@@ -0,0 +1,32 @@
/**
* Helpers for per-user "new publication" (not yet downloaded) tracking.
* Backend sets `downloaded_by_me` when a Bearer token is present; these
* utilities keep the dashboard in sync after login and export.
*/
/** True when an authenticated user needs a refetch to obtain download flags. */
export function publicationsNeedDownloadFlags(publications, isAuthenticated) {
if (!isAuthenticated || !publications?.length) return false;
return publications.some((p) => p.downloaded_by_me == null);
}
/** Mark the given publication IDs as downloaded in local state. */
export function markPublicationsAsDownloaded(publications, downloadedIds) {
if (!downloadedIds?.length || !publications?.length) return publications;
const ids = new Set(downloadedIds);
return publications.map((p) =>
ids.has(p.id) ? { ...p, downloaded_by_me: true } : p,
);
}
/** Apply download flags across group-search result bundles. */
export function markGroupResultsAsDownloaded(results, downloadedIds) {
if (!downloadedIds?.length || !results?.length) return results;
const ids = new Set(downloadedIds);
return results.map((bundle) => ({
...bundle,
publications: (bundle.publications ?? []).map((p) =>
ids.has(p.id) ? { ...p, downloaded_by_me: true } : p,
),
}));
}