Skip to content
Merged
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
44 changes: 43 additions & 1 deletion cds_migrator_kit/rdm/migration_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ def resolve_record_pid(pid):
# Access group already added to the record as 506__m when draft created, no need to add:
# https://gitlab.cern.ch/cds-team/cds-legacy/-/blob/master/src/wn-cdsweb/lib/python/invenio/websubmit_functions/EPPHAPP_Test_values.py#L249-266
"EP Restricted Draft": [],
"PH-EP Restricted Draft": [],
# CERN E-guide restricted docs: https://cds.cern.ch/admin/webaccess/webaccessadmin.py/showroledetails?id_role=69 CERN personnel has view rights
}

Expand Down Expand Up @@ -539,10 +540,51 @@ def resolve_record_pid(pid):

CDS_COMMITTEE_APPROVAL_COMMUNITIES = {
"dd13404c-bcd6-4b15-aeef-38d678c61ff1": {
# faser
"label": "EP approval", # shown in UI buttons/headings
"referee_group": "cds-ph-ep-publication", # CERN e-group slug
"report_number": {
"prefix": "CERN-EP", # literal prefix, e.g. "CERN-EP"
"prefix": "CERN-TH-EP", # literal prefix, e.g. "CERN-EP"
"include_year": True, # append the current year after prefix
"counter_digits": 3, # zero-padding width, e.g. 3 → "001"
},
},
"7c568753-550b-4b48-8d76-461181973100": {
# aleph
"label": "EP approval", # shown in UI buttons/headings

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just fyi, we now also add committee_name to these configs so the UI knows what the actual approving committee is called. But this is only needed for the UI.

e.g. "committee_name": "EP Board"

"referee_group": "cds-ph-ep-publication", # CERN e-group slug
"report_number": {
"prefix": "CERN-TH-EP", # literal prefix, e.g. "CERN-EP"
"include_year": True, # append the current year after prefix
"counter_digits": 3, # zero-padding width, e.g. 3 → "001"
},
},
"bb0ac2d2-b90b-498e-bf64-1986791d1032": {
# l3
"label": "EP approval", # shown in UI buttons/headings
"referee_group": "cds-ph-ep-publication", # CERN e-group slug
"report_number": {
"prefix": "CERN-PH-EP", # literal prefix, e.g. "CERN-EP"
"include_year": True, # append the current year after prefix
"counter_digits": 3, # zero-padding width, e.g. 3 → "001"
},
},
"473e34c5-4fe1-44fc-a4c2-3305cf6adcba": {
# opal
"label": "EP approval", # shown in UI buttons/headings
"referee_group": "cds-ph-ep-publication", # CERN e-group slug
"report_number": {
"prefix": "CERN-PH-EP", # literal prefix, e.g. "CERN-EP"
"include_year": True, # append the current year after prefix
"counter_digits": 3, # zero-padding width, e.g. 3 → "001"
},
},
"b6553d89-ea62-4a7c-9f5b-e76b5bfdb733": {
# delphi
"label": "EP approval", # shown in UI buttons/headings
"referee_group": "cds-ph-ep-publication", # CERN e-group slug
"report_number": {
"prefix": "CERN-PH-EP", # literal prefix, e.g. "CERN-EP"
"include_year": True, # append the current year after prefix
"counter_digits": 3, # zero-padding width, e.g. 3 → "001"
},
Expand Down
143 changes: 107 additions & 36 deletions cds_migrator_kit/rdm/records/load/approval_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@
from invenio_pidstore.models import PersistentIdentifier, PIDStatus
from invenio_rdm_records.records.api import RDMParent
from invenio_records_resources.services.uow import RecordCommitOp
from invenio_requests.customizations.event_types import LogEventType
from invenio_requests.customizations.event_types import (
LogEventType,
ReviewersUpdatedType,
)
from invenio_requests.proxies import current_events_service, current_requests_service
from invenio_requests.resolvers.registry import ResolverRegistry

from cds_migrator_kit.errors import ManualImportRequired, UnexpectedValue

EP_APPROVAL_WAITING_STATUS = "waiting"
EP_APPROVAL_APPROVED_STATUS = "approved"
EP_APPROVAL_REVIEWING_STATUS = "reviewing"


class ApprovalRequest:
Expand All @@ -45,13 +49,14 @@ def __init__(
self.resource_type = resource_type
self.dry_run = dry_run
self.waiting_entry = None
self.reviewing_entry = None
self.approved_entry = None
self.report_number = None
self.approved_at = None

def validate(self):
"""Validate EP approval data before creating any records."""
waiting_entry, approved_entry, report_number = self._parse_history()
waiting_entry, approved_entry, reviewing, report_number = self._parse_history()

existing = self._existing_request()
if existing:
Expand All @@ -69,10 +74,16 @@ def validate(self):

self.waiting_entry = waiting_entry
self.approved_entry = approved_entry
self.reviewing_entry = reviewing
self.report_number = report_number

def create(self, restricted_record_state):
"""Create and approve EP approval request after restricted record exists."""
def create(self, restricted_record_state, uow=None):
"""Create and approve EP approval request after restricted record exists.

If ``uow`` is provided, the request is registered on it without
committing, so the caller can group this atomically with other
operations (e.g. the public record creation and linking).
"""
if self.dry_run:
return

Expand All @@ -91,14 +102,15 @@ def create(self, restricted_record_state):
self._create_request(
restricted_recid,
restricted_parent,
uow=uow,
)
self._mint_apprn_pid(restricted_record_state["latest_version_object_uuid"])

def _parse_history(self):
"""Return waiting/approved history entries and the report number."""
if len(self.ep_approval) != 2:
if len(self.ep_approval) > 3:
raise UnexpectedValue(
message="EP approval history has more/less than 2 entries",
message="EP approval history has more/less than 3 entries",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
message="EP approval history has more/less than 3 entries",
message="EP approval history has more than 3 entries",

stage="load",
priority="critical",
)
Expand All @@ -111,6 +123,14 @@ def _parse_history(self):
),
None,
)
reviewing = next(
(
item
for item in history
if item.get("status") == EP_APPROVAL_REVIEWING_STATUS
),
None,
)
Comment on lines +126 to +133

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this for when it is referred for additional review?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

yes is this additional step

approved = next(
(
item
Expand Down Expand Up @@ -163,7 +183,7 @@ def _parse_history(self):
priority="critical",
)

return waiting, approved, report_number
return waiting, approved, reviewing, report_number

@staticmethod
def parse_legacy_datetime(value):
Expand Down Expand Up @@ -278,6 +298,43 @@ def _create_accept_log_event(self, request, uow):

uow.register(RecordCommitOp(event, indexer=current_events_service.indexer))


def _create_reviewing_log_event(self, request, uow):
"""Create the reviewers-updated timeline event with the legacy reviewer as created_by."""
if not self.reviewing_entry:
return

reviewer_ref = self._resolve_user_by_email(
self.reviewing_entry.get("submitted_by"),
"reviewer",
)
request.reviewers = [reviewer_ref]

event = current_events_service.record_cls.create(
{},
request=request.model,
request_id=str(request.id),
type=ReviewersUpdatedType,
)
event.update(
{
"payload": {
"event": "reviewers_updated",
"content": self.reviewing_entry.get("description", ""),
"reviewers": [reviewer_ref],
}
}
)
event.created_by = ResolverRegistry.resolve_entity_proxy(
reviewer_ref, raise_=True
)

reviewing_at = self.parse_legacy_datetime(self.reviewing_entry.get("date"))
if reviewing_at:
event.model.created = reviewing_at

uow.register(RecordCommitOp(event, indexer=current_events_service.indexer))

def _apply_approved_entry(self, request, uow):
"""Update an existing request to accepted using the legacy approved entry."""
payload = dict(request.get("payload") or {})
Expand All @@ -291,38 +348,52 @@ def _apply_approved_entry(self, request, uow):

self._create_accept_log_event(request, uow)

def _create_request(self, restricted_recid, restricted_parent):
"""Create request from waiting entry, then update it with approved entry."""
def _create_request(self, restricted_recid, restricted_parent, uow=None):
"""Create request from waiting entry, then update it with approved entry.

If ``uow`` is provided, it is used as-is and left uncommitted for the
caller to commit; otherwise a unit of work is created and committed
here.
"""
if uow is not None:
self._build_request(restricted_recid, restricted_parent, uow)
return

with UnitOfWork() as inner_uow:
self._build_request(restricted_recid, restricted_parent, inner_uow)
inner_uow.commit()

def _build_request(self, restricted_recid, restricted_parent, uow):
"""Register the request creation and its updates on the given uow."""
expires_at = self.parse_legacy_datetime(self.waiting_entry.get("deadline"))
referee_group = self._get_referee_group(restricted_parent)

with UnitOfWork() as uow:
request_item = current_requests_service.create(
system_identity,
data={
"title": f'EP approval for "{self.title}"',
"payload": {},
},
request_type=CommitteeApprovalRequest,
receiver={"group": referee_group},
creator=self._resolve_user_by_email(
self.waiting_entry.get("submitted_by"), "submitter"
),
topic={"record": restricted_recid},
expires_at=expires_at,
uow=uow,
)
request = request_item._record
request.number = f"lrecid:{self.legacy_recid}:ep-approval"
request.status = "submitted"
request_item = current_requests_service.create(
system_identity,
data={
"title": f'EP approval for "{self.title}"',
"payload": {},
},
request_type=CommitteeApprovalRequest,
receiver={"group": referee_group},
creator=self._resolve_user_by_email(
self.waiting_entry.get("submitted_by"), "submitter"
),
topic={"record": restricted_recid},
expires_at=expires_at,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we decided not to have request expiry, at least for new approval requests. I'm presuming if you set an expiry date in the past it works without erroring? I guess it is good to migrate the expiry dates of old requests.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we did for the live instance, for the past records I want to keep the date for completeness

uow=uow,
)
request = request_item._record
request.number = f"lrecid:{self.legacy_recid}:ep-approval"
request.status = "submitted"

submitted_at = self.parse_legacy_datetime(self.waiting_entry.get("date"))
if submitted_at:
request.model.created = submitted_at
submitted_at = self.parse_legacy_datetime(self.waiting_entry.get("date"))
if submitted_at:
request.model.created = submitted_at

self._apply_approved_entry(request, uow)
self._create_reviewing_log_event(request, uow)
self._apply_approved_entry(request, uow)

uow.register(
RecordCommitOp(request, indexer=current_requests_service.indexer)
)
uow.commit()
uow.register(
RecordCommitOp(request, indexer=current_requests_service.indexer)
)
67 changes: 47 additions & 20 deletions cds_migrator_kit/rdm/records/load/ep_approval_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ def _apply_metadata(self, split):
def _apply_entry_modifications(self, split):
"""Apply record/parent level modifications."""

@staticmethod
def _is_restricted_file(file_data):
"""Return whether a file belongs to the restricted split.

A file is restricted either because it is an EPPHAPP draft file, or
because it carries its own file-level access restriction independent
of the EPPHAPP workflow (e.g. a record that was never restricted as
a whole, but ships a mix of public and individually-restricted
files).
"""
return bool(
file_data.get("type") == EPPHAPP_FILE_TYPE or file_data.get("access")
)

def _log_removed_identifiers(self, removed, split_type):
recid = self.entry.get("record", {}).get("recid")
self.migration_logger.add_information(
Expand Down Expand Up @@ -96,20 +110,9 @@ def _build_versions(self, split):
current_version_files = OrderedDict()

for key, file_data in version_data.get("files", {}).items():
if file_data.get("type") == EPPHAPP_FILE_TYPE:
if self._is_restricted_file(file_data):
continue

if file_data.get("access"):
raise UnexpectedValue(
message=(
"Public split contains restricted files after excluding "
f"EPPHAPP files: {[key]}"
),
stage="load",
recid=split["record"]["recid"],
priority="critical",
)

current_version_files[key] = deepcopy(file_data)

if not current_version_files:
Expand Down Expand Up @@ -192,9 +195,32 @@ def _add_cern_scientific_community(self, entry):
class RestrictedEntry(MetadataEntry):
"""Build the restricted EP approval split entry."""

def _has_epphapp_files(self, split):
def _apply_entry_modifications(self, split):
self._remove_cern_scientific_community(split)

def _remove_cern_scientific_community(self, entry):
"""Drop the CERN Scientific community from the restricted split.

The restricted record holds the internal-only EPPHAPP draft and must
not be discoverable via the broader community; only PublicEntry adds
CDS_CERN_SCIENTIFIC_COMMUNITY_ID (see _add_cern_scientific_community).
"""
communities = entry.get("parent", {}).get("json", {}).get("communities", {})
ids = [
cid
for cid in communities.get("ids", [])
if cid != CDS_CERN_SCIENTIFIC_COMMUNITY_ID
]
communities["ids"] = ids
if communities.get("default") == CDS_CERN_SCIENTIFIC_COMMUNITY_ID:
communities["default"] = ids[0] if ids else None
entry.setdefault("parent", {}).setdefault("json", {})[
"communities"
] = communities

def _has_restricted_files(self, split):
return any(
file_data.get("type") == EPPHAPP_FILE_TYPE
self._is_restricted_file(file_data)
for version_data in split.get("versions", {}).values()
for file_data in version_data.get("files", {}).values()
)
Expand All @@ -203,14 +229,14 @@ def _build_versions(self, split):
new_versions = OrderedDict()
versioned_files = OrderedDict()
previous_signature = None
has_epphapp_files = self._has_epphapp_files(split)
has_restricted_files = self._has_restricted_files(split)

if not has_epphapp_files:
if not has_restricted_files:
self.migration_logger.add_information(
split["record"]["recid"],
{
"message": (
"No EPPHAPP files found; public files used for the "
"No restricted files found; public files used for the "
"restricted record."
),
"value": "public files",
Expand All @@ -221,10 +247,11 @@ def _build_versions(self, split):
current_version_files = OrderedDict()

for key, file_data in version_data.get("files", {}).items():
is_epphapp = file_data.get("type") == EPPHAPP_FILE_TYPE
is_restricted = self._is_restricted_file(file_data)

# If draft file exists, use that otherwise use the public files.
if not is_epphapp and has_epphapp_files:
# If restricted files exist, use only those; otherwise fall
# back to using all (public) files for the restricted record.
if not is_restricted and has_restricted_files:
continue

current_version_files[key] = deepcopy(file_data)
Expand Down
Loading
Loading