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:
@@ -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