Skip to content
Open
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
11 changes: 9 additions & 2 deletions invenio.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ from cds_rdm.permissions import (
CDSCommunitiesPermissionPolicy,
CDSRDMRecordPermissionPolicy,
CDSRequestsPermissionPolicy,
CDSRDMPreservationSyncPermissionPolicy, lock_edit_record_published_files,
CDSRDMPreservationSyncPermissionPolicy,
lock_edit_record_published_files,
can_access_administration_menu,
CDSAuditLogPermissionPolicy
)
from cds_rdm.files import storage_factory
from cds_rdm.inspire_harvester.reader import InspireHTTPReader
Expand Down Expand Up @@ -623,6 +626,8 @@ _RECORD_EXPORTERS = {
APP_RDM_RECORD_EXPORTERS = dict(sorted(_RECORD_EXPORTERS.items()))

ADMINISTRATION_BASE_TEMPLATE = "cds_rdm/administration/admin_base_template.html"
ADMINISTRATION_MENU_VISIBLE_WHEN = lambda: can_access_administration_menu()
ADMINISTRATION_DASHBOARD_VIEW = "cds_rdm.administration.dashboard:CDSAdminDashboardView"
LOGGING_CONSOLE_LEVEL = "INFO"
JOBS_LOGGING_LEVEL = "WARNING"

Expand All @@ -640,4 +645,6 @@ APP_RDM_DETAIL_SIDE_BAR_TEMPLATES = [
"invenio_app_rdm/records/details/side_bar/export.html",
"invenio_app_rdm/records/details/side_bar/technical_metadata.html",
]
AUDIT_LOGS_ENABLED = True
HARVESTER_REPORTS_ENABLED=True
AUDIT_LOGS_ENABLED = True
AUDIT_LOGS_PERMISSION_POLICY=CDSAuditLogPermissionPolicy
48 changes: 48 additions & 0 deletions site/cds_rdm/administration/dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2026 CERN.
#
# CDS-RDM is free software; you can redistribute it and/or modify it
# under the terms of the GPL-2.0 License; see LICENSE file for more details.

"""Custom administration dashboard view."""

from functools import wraps

from flask import abort
from flask_principal import Permission, RoleNeed
from invenio_administration.permissions import administration_permission
from invenio_administration.views.dashboard import (
AdminDashboardView as BaseAdminDashboardView,
)

from cds_rdm.permissions import can_access_administration_menu


def require_admin_or_harvester_curator(f):
Copy link
Contributor

Choose a reason for hiding this comment

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

see my comment in the invenio-administration PR

"""Decorator to check if user has admin or harvester-curator permission."""
@wraps(f)
def decorated_view(*args, **kwargs):
# Check standard administration permission
if administration_permission.can():
return f(*args, **kwargs)

# Also allow harvester-curator role
if Permission(RoleNeed("harvester-curator")).can():
return f(*args, **kwargs)

# No permission, return 403
abort(403)

return decorated_view


class CDSAdminDashboardView(BaseAdminDashboardView):
"""Custom dashboard view accessible by admin and harvester-curator."""

decorators = [require_admin_or_harvester_curator]

@staticmethod
def visible_when():
"""Return a callable to check if dashboard menu should be visible."""
return lambda: can_access_administration_menu()
136 changes: 136 additions & 0 deletions site/cds_rdm/administration/harvester_reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2026 CERN.
#
# CDS-RDM is free software; you can redistribute it and/or modify it
# under the terms of the GPL-2.0 License; see LICENSE file for more details.

"""Harvester Reports administration views."""

import json
from functools import partial

from flask import current_app, request
from flask_principal import Permission, RoleNeed
from invenio_administration.views.base import AdminResourceListView
from invenio_i18n import lazy_gettext as _
from invenio_jobs.models import Job, Run
from invenio_search_ui.searchconfig import search_app_config


class HarvesterReportsView(AdminResourceListView):
"""Harvester reports admin view for curators."""

api_endpoint = "/audit-logs/"
extension_name = "invenio-audit-logs"
name = "harvester-reports"
resource_config = "audit_log_resource"

title = "Harvester Reports"
menu_label = "Harvester Reports"
category = "Logs"
pid_path = "id"
icon = "file alternate"
template = "cds_rdm/administration/harvester_reports.html"
order = 2
search_request_headers = {"Accept": "application/vnd.inveniordm.v1+json"}

display_search = True
display_delete = False
display_create = False
display_edit = False

item_field_list = {
"resource.type": {"text": _("Resource"), "order": 1, "width": 2},
"resource.id": {"text": _("Resource ID"), "order": 2},
"action": {"text": _("Action"), "order": 3},
"user.id": {"text": _("User"), "order": 4},
"created": {"text": _("Created"), "order": 5},
}

actions = {
"view_log": {"text": _("View Log"), "payload_schema": None, "order": 1},
"view_changes": {
"text": _("View Changes"),
"payload_schema": None,
"order": 2,
"show_for": ["record.publish"],
},
}

search_config_name = "AUDIT_LOGS_SEARCH"
search_facets_config_name = "AUDIT_LOGS_FACETS"
search_sort_config_name = "AUDIT_LOGS_SORT_OPTIONS"

decorators = [
Permission(RoleNeed("harvester-curator")).require(http_exception=403)
]

@staticmethod
def disabled():
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO No need to hide this behind a feature flag

"""Disable the view on demand."""
return not current_app.config.get("HARVESTER_REPORTS_ENABLED", True)

@staticmethod
def visible_when():
"""Return a callable to check if menu should be visible."""
return lambda: Permission(RoleNeed("harvester-curator")).can()

def _get_inspire_job_id(self):
"""Get the INSPIRE harvester job ID."""
job = Job.query.filter_by(task="process_inspire").first()
return job.id if job else None

def _fetch_recent_runs(self, job_id, limit=20):
"""Fetch recent parent runs for the INSPIRE job."""
# Fetch only parent runs (parent_run_id is None) that have started
runs = (
Run.query.filter_by(job_id=job_id, parent_run_id=None)
.filter(Run.started_at.isnot(None))
.order_by(Run.started_at.desc())
.limit(limit)
.all()
)

# Serialize runs with all available info
return [
{
"id": str(run.id),
"started_at": run.started_at.isoformat() if run.started_at else None,
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
"status": run.status.value if run.status else None,
"title": run.title or f"Run {run.id}",
"message": run.message,
}
for run in runs
]

def get_context(self, **kwargs):
Copy link
Contributor

@kpsherva kpsherva Mar 2, 2026

Choose a reason for hiding this comment

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

this was actually more than what I had in mind, nice UX addition, thanks!

"""Add runs data to template context."""
context = super().get_context(**kwargs)

# Get INSPIRE job and its runs
job_id = self._get_inspire_job_id()
if job_id:
runs = self._fetch_recent_runs(job_id, limit=20)
context["harvester_runs"] = json.dumps(runs)
context["default_run"] = json.dumps(runs[0]) if runs else None
else:
context["harvester_runs"] = json.dumps([])
context["default_run"] = None

return context

def init_search_config(self, **kwargs):
"""Build search view config."""
return partial(
search_app_config,
config_name=self.get_search_app_name(**kwargs),
available_facets=current_app.config.get(self.search_facets_config_name),
sort_options=current_app.config[self.search_sort_config_name],
endpoint=self.get_api_endpoint(**kwargs),
headers=self.get_search_request_headers(**kwargs),
pagination_options=(20, 50),
default_size=20,
hidden_params=[["action", "record.publish"]],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// This file is part of CDS-RDM
// Copyright (C) 2026 CERN.
//
// CDS-RDM is free software; you can redistribute it and/or modify it
// under the terms of the GPL-2.0 License; see LICENSE file for more details.

import React from "react";
import { withState } from "react-searchkit";
import { Button, Icon } from "semantic-ui-react";
import { i18next } from "@translations/invenio_administration/i18next";

const DownloadButtonComponent = ({ currentQueryState }) => {
const handleDownload = () => {
const query = currentQueryState.queryString || "";
const hiddenParams = currentQueryState.hiddenParams || [];

const params = new URLSearchParams();
if (query) params.set("q", query);
hiddenParams.forEach(([key, value]) => params.append(key, value));

const downloadUrl = `/harvester-reports/download?${params.toString()}`;
window.location.href = downloadUrl;
};

return (
<Button
icon
labelPosition="left"
onClick={handleDownload}
className="harvester-download-button"
size="small"
>
<Icon name="download" />
{i18next.t("Download")}
</Button>
);
};

export const DownloadButton = withState(DownloadButtonComponent);
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// This file is part of CDS-RDM
// Copyright (C) 2026 CERN.
//
// CDS-RDM is free software; you can redistribute it and/or modify it
// under the terms of the GPL-2.0 License; see LICENSE file for more details.

import React from "react";
import { Segment, Header, Icon } from "semantic-ui-react";
import { i18next } from "@translations/invenio_administration/i18next";

/**
* Custom Empty Results component (without showing the query)
*/
export const CustomEmptyResults = () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added this to hide the query from the user, the default one shows it and we are "manipulating" it ot add the timestamp

Copy link
Contributor

@kpsherva kpsherva Mar 2, 2026

Choose a reason for hiding this comment

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

I have a feeling this could have been solved by hidden_params in search view

return (
<Segment placeholder textAlign="center" className="harvester-empty-results">
<Header icon>
<Icon name="search" />
{i18next.t("No logs found")}
</Header>
<Segment.Inline>
<p>{i18next.t("No logs match your current filters. Try selecting a different run or adjusting your search.")}</p>
</Segment.Inline>
</Segment>
);
};
Loading
Loading