feat(download-tracking): implementar seguimiento de descargas por usuario en el dashboard
Se añaden funciones para marcar publicaciones y resultados de grupo como descargados en los componentes DashboardPage y GroupResultsPage. Se optimiza la lógica de carga de publicaciones para incluir un control de estado que evita la descarga innecesaria. Además, se actualizan los mocks de publicaciones para reflejar el estado de descarga. Se mejora la presentación del texto en el componente PublicationsTable.
This commit is contained in:
@@ -64,10 +64,18 @@ def init_db():
|
|||||||
|
|
||||||
def _ensure_columns():
|
def _ensure_columns():
|
||||||
insp = inspect(engine)
|
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")}
|
cols = {c["name"] for c in insp.get_columns("publications")}
|
||||||
if "downloaded" not in cols:
|
if "downloaded" not in cols:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text("ALTER TABLE publications ADD COLUMN downloaded BOOLEAN NOT NULL DEFAULT FALSE")
|
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)
|
||||||
|
|||||||
@@ -18,17 +18,23 @@ def require_export_access(
|
|||||||
api_key: str | None = Depends(api_key_header),
|
api_key: str | None = Depends(api_key_header),
|
||||||
current: Researcher | None = Depends(get_optional_current_researcher),
|
current: Researcher | None = Depends(get_optional_current_researcher),
|
||||||
) -> Researcher | None:
|
) -> Researcher | None:
|
||||||
if api_key is not None:
|
"""
|
||||||
if not is_valid_api_key(api_key):
|
Allow export when the proxy supplies a valid API key and/or the user
|
||||||
raise HTTPException(
|
sends a valid Bearer token. Prefer returning `current` when both are
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
present so per-user download tracking is recorded on export.
|
||||||
detail="Invalid API key",
|
"""
|
||||||
)
|
if api_key is not None and not is_valid_api_key(api_key):
|
||||||
return current
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid API key",
|
||||||
|
)
|
||||||
|
|
||||||
if current is not None:
|
if current is not None:
|
||||||
return current
|
return current
|
||||||
|
|
||||||
|
if api_key is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid or missing API key",
|
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"
|
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} />
|
<SparkleIcon size={9} />
|
||||||
Nuevo
|
NUEVO
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-[14px] font-medium leading-relaxed text-ink-primary">
|
<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"
|
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} />
|
<SparkleIcon size={9} />
|
||||||
Nuevo
|
NUEVO
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pub.title}
|
{pub.title}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
swordXmlFilename,
|
swordXmlFilename,
|
||||||
} from "../utils/exportProfiles";
|
} from "../utils/exportProfiles";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import {
|
||||||
|
markPublicationsAsDownloaded,
|
||||||
|
publicationsNeedDownloadFlags,
|
||||||
|
} from "../utils/downloadTracking";
|
||||||
|
|
||||||
const SUCCESS_FLASH_MS = 3000;
|
const SUCCESS_FLASH_MS = 3000;
|
||||||
/** Minimum gap between sync requests (protects backend + avoids toast spam). */
|
/** Minimum gap between sync requests (protects backend + avoids toast spam). */
|
||||||
@@ -48,6 +52,7 @@ export function DashboardPage() {
|
|||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
const initialBundleRef = useRef(location.state?.bundle ?? null);
|
const initialBundleRef = useRef(location.state?.bundle ?? null);
|
||||||
|
const consumedInitialBundleRef = useRef(false);
|
||||||
|
|
||||||
const initialBundle = initialBundleRef.current;
|
const initialBundle = initialBundleRef.current;
|
||||||
const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null);
|
const [researcher, setResearcher] = useState(initialBundle?.researcher ?? null);
|
||||||
@@ -115,14 +120,25 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isValidOrcid(orcid)) return;
|
if (!isValidOrcid(orcid)) return;
|
||||||
if (initialBundleRef.current) {
|
|
||||||
|
const cachedBundle = initialBundleRef.current;
|
||||||
|
if (cachedBundle && !consumedInitialBundleRef.current) {
|
||||||
|
consumedInitialBundleRef.current = true;
|
||||||
initialBundleRef.current = null;
|
initialBundleRef.current = null;
|
||||||
return;
|
if (
|
||||||
|
!publicationsNeedDownloadFlags(
|
||||||
|
cachedBundle.publications,
|
||||||
|
isAuthenticated,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
loadBundle(ctrl.signal);
|
loadBundle(ctrl.signal);
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
}, [orcid, loadBundle]);
|
}, [orcid, loadBundle, isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -270,6 +286,10 @@ export function DashboardPage() {
|
|||||||
} else {
|
} else {
|
||||||
scope = "todo el investigador";
|
scope = "todo el investigador";
|
||||||
}
|
}
|
||||||
|
if (isAuthenticated && ids?.length) {
|
||||||
|
setPublications((prev) => markPublicationsAsDownloaded(prev, ids));
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
||||||
id: EXPORT_TOAST_ID,
|
id: EXPORT_TOAST_ID,
|
||||||
description: scope,
|
description: scope,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "../utils/exportProfiles";
|
} from "../utils/exportProfiles";
|
||||||
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
|
import { ExportDropdown } from "../components/dashboard/ExportDropdown";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { markGroupResultsAsDownloaded } from "../utils/downloadTracking";
|
||||||
|
|
||||||
const EXPORT_COOLDOWN_MS = 5000;
|
const EXPORT_COOLDOWN_MS = 5000;
|
||||||
const GLOBAL_EXPORT_TOAST_ID = "group-export-global";
|
const GLOBAL_EXPORT_TOAST_ID = "group-export-global";
|
||||||
@@ -108,7 +109,7 @@ export function GroupResultsPage() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
}, [orcidIds, navigate]);
|
}, [orcidIds, navigate, isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cardTimersObj = cardExportCooldownTimerRef.current;
|
const cardTimersObj = cardExportCooldownTimerRef.current;
|
||||||
@@ -218,6 +219,10 @@ export function GroupResultsPage() {
|
|||||||
anchor.remove();
|
anchor.remove();
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
|
if (isAuthenticated) {
|
||||||
|
setResults((prev) => markGroupResultsAsDownloaded(prev, ids));
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
||||||
id: GLOBAL_EXPORT_TOAST_ID,
|
id: GLOBAL_EXPORT_TOAST_ID,
|
||||||
description: `${ids.length} publicaciones exportadas.`,
|
description: `${ids.length} publicaciones exportadas.`,
|
||||||
@@ -276,6 +281,10 @@ export function GroupResultsPage() {
|
|||||||
anchor.remove();
|
anchor.remove();
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
|
if (isAuthenticated) {
|
||||||
|
setResults((prev) => markGroupResultsAsDownloaded(prev, ids));
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
toast.success(`Exportación ${format.toUpperCase()} completada`, {
|
||||||
id: `group-export-card-${orcidId}`,
|
id: `group-export-card-${orcidId}`,
|
||||||
description: `${ids.length} publicaciones de ${orcidId}.`,
|
description: `${ids.length} publicaciones de ${orcidId}.`,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const MOCK_PUBLICATIONS = [
|
|||||||
doi: "10.1038/s41567-025-xxxx",
|
doi: "10.1038/s41567-025-xxxx",
|
||||||
type: "journal-article",
|
type: "journal-article",
|
||||||
last_modified: "2025-09-01T10:00:00Z",
|
last_modified: "2025-09-01T10:00:00Z",
|
||||||
|
downloaded_by_me: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "uuid-2",
|
id: "uuid-2",
|
||||||
@@ -40,6 +41,7 @@ export const MOCK_PUBLICATIONS = [
|
|||||||
doi: "10.1000/jdr.2024.12",
|
doi: "10.1000/jdr.2024.12",
|
||||||
type: "review",
|
type: "review",
|
||||||
last_modified: "2024-11-12T09:00:00Z",
|
last_modified: "2024-11-12T09:00:00Z",
|
||||||
|
downloaded_by_me: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "uuid-3",
|
id: "uuid-3",
|
||||||
@@ -50,6 +52,7 @@ export const MOCK_PUBLICATIONS = [
|
|||||||
doi: "10.1007/s11192-024-04801-z",
|
doi: "10.1007/s11192-024-04801-z",
|
||||||
type: "journal-article",
|
type: "journal-article",
|
||||||
last_modified: "2024-06-20T15:30:00Z",
|
last_modified: "2024-06-20T15:30:00Z",
|
||||||
|
downloaded_by_me: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "uuid-4",
|
id: "uuid-4",
|
||||||
@@ -60,6 +63,7 @@ export const MOCK_PUBLICATIONS = [
|
|||||||
doi: "10.1145/3587-dl.2023.09",
|
doi: "10.1145/3587-dl.2023.09",
|
||||||
type: "conference-paper",
|
type: "conference-paper",
|
||||||
last_modified: "2023-10-05T11:45:00Z",
|
last_modified: "2023-10-05T11:45:00Z",
|
||||||
|
downloaded_by_me: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "uuid-5",
|
id: "uuid-5",
|
||||||
@@ -70,6 +74,7 @@ export const MOCK_PUBLICATIONS = [
|
|||||||
doi: "10.1016/j.ijls.2023.03.011",
|
doi: "10.1016/j.ijls.2023.03.011",
|
||||||
type: "journal-article",
|
type: "journal-article",
|
||||||
last_modified: "2023-04-18T08:15:00Z",
|
last_modified: "2023-04-18T08:15:00Z",
|
||||||
|
downloaded_by_me: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user