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
18 changes: 18 additions & 0 deletions osf/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,3 +223,21 @@ def __init__(self, response, invalid_responses=None, unsupported_keys=None):
)

super().__init__(error_message)


class IdentifierHasReferencesError(OSFError):
pass


class NoPIDError(OSFError):
pass


class CannotFinalizeArtifactError(OSFError):

def __init__(self, artifact, incomplete_fields):
self.incomplete_fields = incomplete_fields
self.message = (
f'Could not set `finalized=True` for OutcomeArtifact with id [{artifact._id}]. '
f'The following required fields are not set: {incomplete_fields}'
)
41 changes: 41 additions & 0 deletions osf/migrations/0247_artifact_finalized_and_deleted.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.29 on 2022-07-25 15:39
from __future__ import unicode_literals

from django.db import migrations, models
import osf.utils.fields


class Migration(migrations.Migration):

dependencies = [
('osf', '0246_add_outcomes_and_artifacts'),
]

operations = [
migrations.AddField(
model_name='outcomeartifact',
name='deleted',
field=osf.utils.fields.NonNaiveDateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='outcomeartifact',
name='finalized',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='outcomeartifact',
name='description',
field=models.TextField(blank=True),
),
migrations.AlterField(
model_name='outcomeartifact',
name='title',
field=models.TextField(blank=True),
),
migrations.AlterField(
model_name='outcomeartifact',
name='artifact_type',
field=models.IntegerField(choices=[(0, 'UNDEFINED'), (1, 'DATA'), (11, 'ANALYTIC_CODE'), (21, 'MATERIALS'), (31, 'PAPERS'), (41, 'SUPPLEMENTS'), (1001, 'PRIMARY')], default=osf.utils.outcomes.ArtifactTypes(0)),
),
]
8 changes: 8 additions & 0 deletions osf/models/identifiers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone

from osf.exceptions import IdentifierHasReferencesError
from osf.models.base import BaseModel, ObjectIDMixin
from osf.utils.fields import NonNaiveDateTimeField

Expand All @@ -28,6 +30,12 @@ def remove(self, save=True):
if save:
self.save()

def delete(self):
'''Used to delete an orphaned Identifier (distinct from setting `deleted`)'''
if self.object_id or self.artifact_metadata.filter(deleted__isnull=True).exists():
raise IdentifierHasReferencesError
super().delete()


class IdentifierMixin(models.Model):
"""Model mixin that adds methods for getting and setting Identifier objects
Expand Down
7 changes: 6 additions & 1 deletion osf/models/nodelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ class NodeLog(ObjectIDMixin, BaseModel):

MIGRATED_QUICK_FILES = 'migrated_quickfiles'

RESOURCE_ADDED = 'resource_identifier_added'
RESOURCE_UPDATED = 'resource_identifier_udpated'
RESOURCE_REMOVED = 'resource_identifier_removed'
Comment thread
jwalz marked this conversation as resolved.

actions = ([CHECKED_IN, CHECKED_OUT, FILE_TAG_REMOVED, FILE_TAG_ADDED, CREATED_FROM, PROJECT_CREATED,
PROJECT_REGISTERED, PROJECT_DELETED, NODE_CREATED, NODE_FORKED, NODE_REMOVED,
NODE_ACCESS_REQUESTS_ENABLED, NODE_ACCESS_REQUESTS_DISABLED,
Expand All @@ -161,7 +165,8 @@ class NodeLog(ObjectIDMixin, BaseModel):
PREREG_REGISTRATION_INITIATED, PROJECT_CREATED_FROM_DRAFT_REG,
GROUP_ADDED, GROUP_UPDATED, GROUP_REMOVED,
AFFILIATED_INSTITUTION_ADDED, AFFILIATED_INSTITUTION_REMOVED, PREPRINT_INITIATED,
PREPRINT_FILE_UPDATED, PREPRINT_LICENSE_UPDATED, VIEW_ONLY_LINK_ADDED, VIEW_ONLY_LINK_REMOVED] + list(sum([
PREPRINT_FILE_UPDATED, PREPRINT_LICENSE_UPDATED, VIEW_ONLY_LINK_ADDED, VIEW_ONLY_LINK_REMOVED,
RESOURCE_ADDED, RESOURCE_UPDATED, RESOURCE_REMOVED] + list(sum([
config.actions for config in apps.get_app_configs() if config.name.startswith('addons.')
], tuple())))
action_choices = [(action, action.upper()) for action in actions]
Expand Down
139 changes: 118 additions & 21 deletions osf/models/outcome_artifacts.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
from django.db import models
from django.utils import timezone

from osf.exceptions import (
CannotFinalizeArtifactError,
IdentifierHasReferencesError,
NoPIDError
)
from osf.models.base import BaseModel, ObjectIDMixin
from osf.models.identifiers import Identifier
from osf.utils.outcomes import ArtifactTypes, NoPIDError
from osf.utils import outcomes as outcome_utils
from osf.utils.fields import NonNaiveDateTimeField


'''
Expand All @@ -13,31 +20,29 @@
for the research effort described by the Outcome.
'''


ArtifactTypes = outcome_utils.ArtifactTypes
OutcomeActions = outcome_utils.OutcomeActions


class ArtifactManager(models.Manager):

def get_queryset(self):
return super().get_queryset().annotate(
pid=models.F('identifier__value')
)
'''Overrides default `get_queryset` behavior to add custom logic.

def create_for_identifier_value(
self, outcome, pid_value, pid_type='doi', create_identifier=False, **kwargs
):
if create_identifier:
identifier, _ = Identifier.objects.get_or_create(
value=pid_value, category=pid_type
)
else:
try:
identifier = Identifier.objects.get(
value=pid_value, category=pid_type
)
except Identifier.DoesNotExist:
raise NoPIDError('No PID with value {pid_value} found for PID type {pid_type}')
Automatically annotates the `pid` from any linked identifier and the
GUID of the primary resource for the parent artifact.

return self.create(outcome=outcome, identifier=identifier, **kwargs)
Automatically filters out deleted entries
'''
base_queryset = super().get_queryset().select_related('identifier')
return base_queryset.annotate(
pid=models.F('identifier__value'),
primary_resource_guid=outcome_utils.make_primary_resource_guid_annotation(base_queryset)
)

def for_registration(self, registration, identifier_type='doi'):
'''Retrieves all OutcomeArtifacts sharing an Outcome, given the Primary Registration.'''
registration_identifier = registration.get_identifier(identifier_type)
artifact_qs = self.get_queryset()
return artifact_qs.annotate(
Expand Down Expand Up @@ -84,8 +89,10 @@ class OutcomeArtifact(ObjectIDMixin, BaseModel):
default=ArtifactTypes.UNDEFINED,
)

title = models.TextField(null=False)
description = models.TextField(null=False)
title = models.TextField(null=False, blank=True)
description = models.TextField(null=False, blank=True)
finalized = models.BooleanField(default=False)
deleted = NonNaiveDateTimeField(null=True, blank=True)

objects = ArtifactManager()

Expand All @@ -95,3 +102,93 @@ class Meta:
models.Index(fields=['outcome', 'artifact_type'])
]
ordering = ['artifact_type', 'title']

def update_identifier(self, new_pid_value, pid_type='doi', api_request=None):
'''Changes the linked Identifer to one matching the new pid_value and handles callbacks.

If `finalized` is True, will also log the change on the parent Outcome if invoked via API.
Will attempt to delete the previous identifier to avoid orphaned entries.

Parameters:
new_pid_value: The string value of the new PID
pid_type (str): The string "type" of the new PID (for now, only "doi" is supported)
api_request: The api_request data from the API call that initiated the change.
'''
if not new_pid_value:
raise NoPIDError(f'Cannot assign an empty PID to OutcomeArtifact with ID {self._id}')

previous_identifier = self.identifier
self.identifier, _ = Identifier.objects.get_or_create(
value=new_pid_value, category=pid_type
)
self.save()
if previous_identifier:
try:
previous_identifier.delete()
except IdentifierHasReferencesError:
pass

if self.finalized and api_request:
self.outcome.log_artifact_change(
action=OutcomeActions.UPDATE,
artifact=self,
api_request=api_request,
obsolete_identifier=previous_identifier.value if previous_identifier else None,
new_identifier=new_pid_value
)

def finalize(self, api_request=None):
'''Sets `finalized` to True and handles callbacks.

Logs the change on the parent Outcome if invoked via the API.

Parameters:
api_request: The api_request data from the API call that initiated the change.
'''
incomplete_fields = []
if not (self.identifier and self.identifier.value):
incomplete_fields.append('identifier__value')
if not self.artifact_type:
incomplete_fields.append('artifact_type')
if incomplete_fields:
raise CannotFinalizeArtifactError(self, incomplete_fields)

self.finalized = True
self.save()

if api_request:
self.outcome.log_artifact_change(
action=OutcomeActions.ADD,
artifact=self,
api_request=api_request,
new_identifier=self.identifier.value
)

def delete(self, api_request=None, **kwargs):
'''Intercept `delete` behavior on the model instance and handles callbacks.

Deletes from database if not `finalized` otherwise sets the `deleted` timestamp.
Logs the change on the parent Outcome if invoked via the API.
Attempts to delete the linked Identifier to avoid orphaned entries.

Parameters:
api_request: The api_request data from the API call that initiated the change.
'''
identifier = self.identifier
if self.finalized:
if api_request:
self.outcome.log_artifact_change(
action=OutcomeActions.REMOVE,
artifact=self,
api_request=api_request,
obsolete_identifier=identifier.value
)
self.deleted = timezone.now()
self.save()
else:
super().delete(**kwargs)

try:
identifier.delete()
except IdentifierHasReferencesError:
pass
28 changes: 19 additions & 9 deletions osf/models/outcomes.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from django.db import models
from django.utils.functional import cached_property

from osf.exceptions import NoPIDError
from osf.models.base import BaseModel, ObjectIDMixin
from osf.models.mixins import EditableFieldsMixin
from osf.utils.outcomes import ArtifactTypes, NoPIDError
from osf.models.nodelog import NodeLog
from osf.utils.outcomes import ArtifactTypes, OutcomeActions

'''
This module defines the Outcome model and its custom manager.
Expand All @@ -13,6 +15,12 @@
stored in the OutcomeArtifact through table.
'''

NODE_LOGS_FOR_OUTCOME_ACTION = {
OutcomeActions.ADD: NodeLog.RESOURCE_ADDED,
OutcomeActions.UPDATE: NodeLog.RESOURCE_UPDATED,
OutcomeActions.REMOVE: NodeLog.RESOURCE_REMOVED,
}


class OutcomeManager(models.Manager):

Expand All @@ -33,7 +41,8 @@ def for_registration(self, registration, identifier_type='doi', create=False, **
new_outcome.copy_editable_fields(registration, include_contributors=False)
new_outcome.artifact_metadata.create(
identifier=registration_identifier,
artifact_type=ArtifactTypes.PRIMARY
artifact_type=ArtifactTypes.PRIMARY,
finalized=True,
)
return new_outcome

Expand Down Expand Up @@ -72,11 +81,12 @@ class Outcome(ObjectIDMixin, EditableFieldsMixin, BaseModel):
def primary_osf_resource(self):
return self.artifact_metadata.get(artifact_type=ArtifactTypes.PRIMARY).identifier.referent

def add_artifact_by_pid(self, pid, artifact_type, pid_type='doi', create_identifier=False):
return self.artifacts.through.objects.create_for_identifier_value(
outcome=self,
pid_value=pid,
pid_type=pid_type,
artifact_type=artifact_type,
create_identifier=create_identifier
def log_artifact_change(self, action, artifact, api_request, **log_params):
nodelog_action = NODE_LOGS_FOR_OUTCOME_ACTION[action]
nodelog_params = {'artifact_id': artifact._id, **log_params}

self.primary_osf_resource.add_log(
Comment thread
jwalz marked this conversation as resolved.
action=nodelog_action,
params=nodelog_params,
request=api_request,
)
36 changes: 31 additions & 5 deletions osf/utils/outcomes.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from enum import IntEnum
from enum import Enum, IntEnum


class NoPIDError(Exception):
pass
from django.db.models import CharField, OuterRef, Subquery


class ArtifactTypes(IntEnum):
Expand All @@ -15,7 +13,7 @@ class ArtifactTypes(IntEnum):
'''
UNDEFINED = 0
DATA = 1
CODE = 11
ANALYTIC_CODE = 11
MATERIALS = 21
PAPERS = 31
SUPPLEMENTS = 41
Expand All @@ -24,3 +22,31 @@ class ArtifactTypes(IntEnum):
@classmethod
def choices(cls):
return tuple((entry.value, entry.name) for entry in cls)


class OutcomeActions(Enum):
ADD = 0
UPDATE = 1
REMOVE = 2


def make_primary_resource_guid_annotation(base_queryset):
from osf.models import Guid
primary_artifacts_and_guids = base_queryset.filter(
artifact_type=ArtifactTypes.PRIMARY
).annotate(
resource_guid=Subquery(
Guid.objects.filter(
content_type=OuterRef('identifier__content_type'),
object_id=OuterRef('identifier__object_id')
).order_by('-created').values('_id')[:1],
output_field=CharField(),
)
)

return Subquery(
primary_artifacts_and_guids.filter(
outcome_id=OuterRef('outcome_id')
).values('resource_guid')[:1],
output_field=CharField()
)
Loading