diff --git a/admin/base/settings/defaults.py b/admin/base/settings/defaults.py index 2ea8ba4215c..02379cdea8a 100644 --- a/admin/base/settings/defaults.py +++ b/admin/base/settings/defaults.py @@ -88,9 +88,11 @@ 'webpack_loader', 'django_nose', 'password_reset', + 'guardian', # OSF 'osf', + 'reviews', # Addons 'addons.osfstorage', @@ -114,6 +116,7 @@ MIGRATION_MODULES = { 'osf': None, + 'reviews': None, 'addons_osfstorage': None, 'addons_wiki': None, 'addons_twofactor': None, diff --git a/api/actions/__init__.py b/api/actions/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api/actions/serializers.py b/api/actions/serializers.py new file mode 100644 index 00000000000..c3a5fc5267b --- /dev/null +++ b/api/actions/serializers.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from rest_framework import generics +from rest_framework import serializers as ser + +from api.base import utils +from api.base.exceptions import Conflict +from api.base.exceptions import JSONAPIAttributeException +from api.base.serializers import JSONAPISerializer +from api.base.serializers import LinksField +from api.base.serializers import RelationshipField +from api.base.serializers import HideIfProviderCommentsAnonymous +from api.base.serializers import HideIfProviderCommentsPrivate + +from osf.models import PreprintService + +from reviews.exceptions import InvalidTriggerError +from reviews.workflow import Triggers +from reviews.workflow import States + + +class ReviewableCountsRelationshipField(RelationshipField): + + def __init__(self, *args, **kwargs): + kwargs['related_meta'] = kwargs.get('related_meta') or {} + if 'include_state_counts' not in kwargs['related_meta']: + kwargs['related_meta']['include_state_counts'] = True + super(ReviewableCountsRelationshipField, self).__init__(*args, **kwargs) + + def get_meta_information(self, metadata, provider): + # Clone metadata because its mutability is questionable + metadata = dict(metadata or {}) + + # Make counts opt-in + show_counts = utils.is_truthy(self.context['request'].query_params.get('related_counts', False)) + # Only include counts on detail routes + is_detail = self.context.get('view') and not isinstance(self.context['view'], generics.ListAPIView) + # Weird hack to avoid being called twice + # get_meta_information is called with both self.related_meta and self.self_meta. + # `is` could probably be used here but this seems more comprehensive. + is_related_meta = metadata.pop('include_state_counts', False) + + if show_counts and is_detail and is_related_meta: + # Finally, require users to have view_actions permissions + auth = utils.get_user_auth(self.context['request']) + if auth and auth.logged_in and auth.user.has_perm('view_actions', provider): + metadata.update(provider.get_reviewable_state_counts()) + + return super(ReviewableCountsRelationshipField, self).get_meta_information(metadata, provider) + + +class TargetRelationshipField(RelationshipField): + def get_object(self, preprint_id): + return PreprintService.objects.get(guids___id=preprint_id) + + def to_internal_value(self, data): + preprint = self.get_object(data) + return {'target': preprint} + + +class ActionSerializer(JSONAPISerializer): + filterable_fields = frozenset([ + 'id', + 'trigger', + 'from_state', + 'to_state', + 'date_created', + 'date_modified', + 'provider', + 'target', + ]) + + id = ser.CharField(source='_id', read_only=True) + + trigger = ser.ChoiceField(choices=Triggers.choices()) + + comment = HideIfProviderCommentsPrivate(ser.CharField(max_length=65535, required=False)) + + from_state = ser.ChoiceField(choices=States.choices(), read_only=True) + to_state = ser.ChoiceField(choices=States.choices(), read_only=True) + + date_created = ser.DateTimeField(read_only=True) + date_modified = ser.DateTimeField(read_only=True) + + provider = RelationshipField( + read_only=True, + related_view='preprint_providers:preprint_provider-detail', + related_view_kwargs={'provider_id': ''}, + filter_key='target__provider___id', + ) + + target = TargetRelationshipField( + read_only=False, + required=True, + related_view='preprints:preprint-detail', + related_view_kwargs={'preprint_id': ''}, + filter_key='target__guids___id', + ) + + creator = HideIfProviderCommentsAnonymous(RelationshipField( + read_only=True, + related_view='users:user-detail', + related_view_kwargs={'user_id': ''}, + filter_key='creator__guids___id', + always_embed=True, + )) + + links = LinksField( + { + 'self': 'get_action_url', + } + ) + + def get_absolute_url(self, obj): + return self.get_action_url(obj) + + def get_action_url(self, obj): + return utils.absolute_reverse('actions:action-detail', kwargs={'action_id': obj._id, 'version': self.context['request'].parser_context['kwargs']['version']}) + + def create(self, validated_data): + trigger = validated_data.pop('trigger') + user = validated_data.pop('user') + target = validated_data.pop('target') + comment = validated_data.pop('comment', '') + try: + if trigger == Triggers.ACCEPT.value: + return target.reviews_accept(user, comment) + if trigger == Triggers.REJECT.value: + return target.reviews_reject(user, comment) + if trigger == Triggers.EDIT_COMMENT.value: + return target.reviews_edit_comment(user, comment) + if trigger == Triggers.SUBMIT.value: + return target.reviews_submit(user) + except InvalidTriggerError as e: + # Invalid transition from the current state + raise Conflict(e.message) + else: + raise JSONAPIAttributeException(attribute='trigger', detail='Invalid trigger.') + + class Meta: + type_ = 'actions' diff --git a/api/actions/urls.py b/api/actions/urls.py new file mode 100644 index 00000000000..3f7357bbce1 --- /dev/null +++ b/api/actions/urls.py @@ -0,0 +1,8 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^$', views.CreateAction.as_view(), name=views.CreateAction.view_name), + url(r'^(?P\w+)/$', views.ActionDetail.as_view(), name=views.ActionDetail.view_name), +] diff --git a/api/actions/views.py b/api/actions/views.py new file mode 100644 index 00000000000..6645e45802f --- /dev/null +++ b/api/actions/views.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.shortcuts import get_object_or_404 +from rest_framework import generics +from rest_framework import permissions + +from framework.auth.oauth_scopes import CoreScopes +from osf.models import Action +from reviews import permissions as reviews_permissions + +from api.actions.serializers import ActionSerializer +from api.base.exceptions import Conflict +from api.base.parsers import ( + JSONAPIMultipleRelationshipsParser, + JSONAPIMultipleRelationshipsParserForRegularJSON, +) +from api.base.utils import absolute_reverse +from api.base.views import JSONAPIBaseView +from api.base import permissions as base_permissions + + +def get_actions_queryset(): + return Action.objects.include( + 'creator', + 'creator__guids', + 'target', + 'target__guids', + 'target__provider', + ).filter(is_deleted=False) + + +class ActionDetail(JSONAPIBaseView, generics.RetrieveAPIView): + """Action Detail + + Actions represent state changes and/or comments on a reviewable object (e.g. a preprint) + + ##Action Attributes + + name type description + ==================================================================================== + date_created iso8601 timestamp timestamp that the action was created + date_modified iso8601 timestamp timestamp that the action was last modified + from_state string state of the reviewable before this action was created + to_state string state of the reviewable after this action was created + comment string comment explaining the state change + trigger string name of the trigger for this action + + ##Relationships + + ###Target + Link to the object (e.g. preprint) this action acts on + + ###Provider + Link to detail for the target object's provider + + ###Creator + Link to the user that created this action + + ##Links + - `self` -- Detail page for the current action + """ + permission_classes = ( + permissions.IsAuthenticatedOrReadOnly, + base_permissions.TokenHasScope, + reviews_permissions.ActionPermission, + ) + + required_read_scopes = [CoreScopes.ACTIONS_READ] + required_write_scopes = [CoreScopes.ACTIONS_WRITE] + + serializer_class = ActionSerializer + view_category = 'actions' + view_name = 'action-detail' + + def get_object(self): + action = get_object_or_404(get_actions_queryset(), _id=self.kwargs['action_id']) + self.check_object_permissions(self.request, action) + return action + + +class CreateAction(JSONAPIBaseView, generics.ListCreateAPIView): + """Create Actions *Write-only* + + Use this endpoint to create a new Action and thereby trigger a state change on a preprint. + + GETting from this endpoint will always return an empty list. + Use `/user/me/actions/` or `/preprints//actions/` to read lists of actions. + + ##Action Attributes + + name type description + ==================================================================================== + date_created iso8601 timestamp timestamp that the action was created + date_modified iso8601 timestamp timestamp that the action was last modified + from_state string state of the reviewable before this action was created + to_state string state of the reviewable after this action was created + comment string comment explaining the state change + trigger string name of the trigger for this action + + ##Relationships + + ###Target + Link to the object (e.g. preprint) this action acts on + + ###Provider + Link to detail for the target object's provider + + ###Creator + Link to the user that created this action + + ##Links + - `self` -- Detail page for the current action + + ##Query Params + + + `page=` -- page number of results to view, default 1 + + + `filter[]=` -- fields and values to filter the search results on. + + Actions may be filtered by their `id`, `from_state`, `to_state`, `date_created`, `date_modified`, `creator`, `provider`, `target` + + ###Creating New Actions + + Create a new Action by POSTing to `/actions/`, including the target preprint and the action trigger. + + Valid triggers are: `submit`, `accept`, `reject`, and `edit_comment` + + Method: POST + URL: /actions/ + Query Params: + Body (JSON): { + "data": { + "attributes": { + "trigger": {trigger}, # required + "comment": {comment}, + }, + "relationships": { + "target": { # required + "data": { + "type": "preprints", + "id": {preprint_id} + } + }, + } + } + } + Success: 201 CREATED + action representation + """ + permission_classes = ( + permissions.IsAuthenticatedOrReadOnly, + base_permissions.TokenHasScope, + reviews_permissions.ActionPermission, + ) + + required_read_scopes = [CoreScopes.NULL] + required_write_scopes = [CoreScopes.ACTIONS_WRITE] + + parser_classes = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON,) + + serializer_class = ActionSerializer + + view_category = 'actions' + view_name = 'create-action' + + # overrides ListCreateAPIView + def perform_create(self, serializer): + target = serializer.validated_data['target'] + self.check_object_permissions(self.request, target) + + if not target.provider.is_reviewed: + raise Conflict('{} is an unmoderated provider. If you are an admin, set up moderation by setting `reviews_workflow` at {}'.format( + target.provider.name, + absolute_reverse('preprint_providers:preprint_provider-detail', kwargs={ + 'provider_id': target.provider._id, + 'version': self.request.parser_context['kwargs']['version'] + }) + )) + + serializer.save(user=self.request.user) + + # overrides ListCreateAPIView + def get_queryset(self): + return Action.objects.none() diff --git a/api/base/filters.py b/api/base/filters.py index dbac3cb80a2..012f4ae6447 100644 --- a/api/base/filters.py +++ b/api/base/filters.py @@ -4,6 +4,7 @@ import re import pytz +from guardian.shortcuts import get_objects_for_user from api.base import utils from api.base.exceptions import (InvalidFilterComparisonType, InvalidFilterError, InvalidFilterFieldError, @@ -17,7 +18,7 @@ from modularodm.query import queryset as modularodm_queryset from rest_framework import serializers as ser from rest_framework.filters import OrderingFilter -from osf.models import Subject +from osf.models import Subject, PreprintProvider from osf.models.base import GuidMixin @@ -494,3 +495,20 @@ def postprocess_query_param(self, key, field_name, operation): except Subject.DoesNotExist: operation['source_field_name'] = 'subjects__text' operation['op'] = 'iexact' + + def preprints_queryset(self, base_queryset, auth_user, allow_contribs=True): + default_query = Q(node__isnull=False, node__is_deleted=False) + no_user_query = Q(is_published=True, node__is_public=True) + + if auth_user: + admin_user_query = Q(node__contributor__user_id=auth_user.id, node__contributor__admin=True) + reviews_user_query = Q(node__is_public=True, provider__in=get_objects_for_user(auth_user, 'view_submissions', PreprintProvider)) + if allow_contribs: + contrib_user_query = Q(is_published=True, node__contributor__user_id=auth_user.id, node__contributor__read=True) + query = default_query & (no_user_query | contrib_user_query | admin_user_query | reviews_user_query) + else: + query = default_query & (no_user_query | admin_user_query | reviews_user_query) + else: + query = default_query & no_user_query + + return base_queryset.filter(query) diff --git a/api/base/renderers.py b/api/base/renderers.py index accdb0ed3f2..2e710bd36b1 100644 --- a/api/base/renderers.py +++ b/api/base/renderers.py @@ -18,6 +18,15 @@ class JSONAPIRenderer(JSONRendererWithESISupport): format = 'jsonapi' media_type = 'application/vnd.api+json' + def render(self, data, accepted_media_type=None, renderer_context=None): + # Allow adding a top-level `meta` object to the response by including it in renderer_context + # See JSON-API documentation on meta information: http://jsonapi.org/format/#document-meta + if renderer_context is not None: + meta_dict = renderer_context.get('meta') + if meta_dict is not None: + data.setdefault('meta', {}).update(meta_dict) + return super(JSONAPIRenderer, self).render(data, accepted_media_type, renderer_context) + class BrowsableAPIRendererNoForms(BrowsableAPIRenderer): """ diff --git a/api/base/serializers.py b/api/base/serializers.py index 0df4bd1b917..e3219d3e196 100644 --- a/api/base/serializers.py +++ b/api/base/serializers.py @@ -1,5 +1,6 @@ import collections import re +from urlparse import urlparse import furl from django.core.urlresolvers import resolve, reverse, NoReverseMatch @@ -10,6 +11,7 @@ from rest_framework import serializers as ser from rest_framework.fields import SkipField from rest_framework.fields import get_attribute as get_nested_attributes +from rest_framework.mixins import RetrieveModelMixin from api.base import utils from api.base.exceptions import InvalidQueryStringError @@ -63,29 +65,37 @@ def is_anonymized(request): return request._is_anonymized -class ShowIfVersion(ser.Field): +class ConditionalField(ser.Field): """ - Skips the field if the specified request version is not after a feature's earliest supported version, - or not before the feature's latest supported version. + Skips the inner field based on `should_show` or `should_hide`; override whichever makes the logic more readable. + If you'd prefer to return `None` rather skipping the field, override `should_be_none` as well. """ - def __init__(self, field, min_version, max_version, **kwargs): - super(ShowIfVersion, self).__init__(**kwargs) + def __init__(self, field, **kwargs): + super(ConditionalField, self).__init__(**kwargs) self.field = field - self.required = field.required - self.read_only = field.read_only - self.min_version = min_version - self.max_version = max_version - self.help_text = 'This field is deprecated as of version {}'.format(self.max_version) or kwargs.get('help_text') + self.source = self.field.source + self.required = self.field.required + self.read_only = self.field.read_only + + def should_show(self, instance): + return not self.should_hide(instance) + + def should_hide(self, instance): + raise NotImplementedError() + + def should_be_none(self, instance): + return False def get_attribute(self, instance): - request = self.context.get('request') - if request and utils.is_deprecated(request.version, self.min_version, self.max_version): + if not self.should_show(instance): + if self.should_be_none(instance): + return None raise SkipField return self.field.get_attribute(instance) def bind(self, field_name, parent): - super(ShowIfVersion, self).bind(field_name, parent) + super(ConditionalField, self).bind(field_name, parent) self.field.bind(field_name, self) def to_representation(self, value): @@ -106,103 +116,97 @@ def to_internal_value(self, data): return self.field.to_internal_value(data) -class HideIfRegistration(ser.Field): +class ShowIfVersion(ConditionalField): """ - If node is a registration, this field will return None. + Skips the field if the specified request version is not after a feature's earliest supported version, + or not before the feature's latest supported version. """ - def __init__(self, field, **kwargs): - super(HideIfRegistration, self).__init__(**kwargs) - self.field = field - self.source = field.source - self.required = field.required - self.read_only = field.read_only + def __init__(self, field, min_version, max_version, **kwargs): + super(ShowIfVersion, self).__init__(field, **kwargs) + self.min_version = min_version + self.max_version = max_version + self.help_text = 'This field is deprecated as of version {}'.format(self.max_version) or kwargs.get('help_text') - def get_attribute(self, instance): - if instance.is_registration: - if isinstance(self.field, RelationshipField): - raise SkipField - else: - return None - return self.field.get_attribute(instance) + def should_hide(self, instance): + request = self.context.get('request') + return request and utils.is_deprecated(request.version, self.min_version, self.max_version) - def bind(self, field_name, parent): - super(HideIfRegistration, self).bind(field_name, parent) - self.field.bind(field_name, self) - def to_internal_value(self, data): - return self.field.to_internal_value(data) +class ShowIfCurrentUser(ConditionalField): - def to_representation(self, value): - if getattr(self.field.root, 'child', None): - self.field.parent = self.field.root.child - else: - self.field.parent = self.field.root - return self.field.to_representation(value) + def should_show(self, instance): + request = self.context.get('request') + return request and request.user == instance - def to_esi_representation(self, value, envelope='data'): - if getattr(self.field.root, 'child', None): - self.field.parent = self.field.root.child - else: - self.field.parent = self.field.root - return self.field.to_esi_representation(value, envelope) +class HideIfRegistration(ConditionalField): + """ + If node is a registration, this field will return None. + """ + + def should_hide(self, instance): + return instance.is_registration + + def should_be_none(self, instance): + return not isinstance(self.field, RelationshipField) -class HideIfDisabled(ser.Field): + +class HideIfDisabled(ConditionalField): """ If the user is disabled, returns None for attribute fields, or skips if a RelationshipField. """ - def __init__(self, field, **kwargs): - super(HideIfDisabled, self).__init__(**kwargs) - self.field = field - self.source = field.source - self.required = field.required - self.read_only = field.read_only + def should_hide(self, instance): + return instance.is_disabled - def get_attribute(self, instance): - if instance.is_disabled: - if isinstance(self.field, RelationshipField): - raise SkipField - else: - return None - return self.field.get_attribute(instance) + def should_be_none(self, instance): + return not isinstance(self.field, RelationshipField) - def bind(self, field_name, parent): - super(HideIfDisabled, self).bind(field_name, parent) - self.field.bind(field_name, self) - def to_internal_value(self, data): - return self.field.to_internal_value(data) +class HideIfWithdrawal(ConditionalField): + """ + If registration is withdrawn, this field will return None. + """ - def to_representation(self, value): - if getattr(self.field.root, 'child', None): - self.field.parent = self.field.root.child - else: - self.field.parent = self.field.root - return self.field.to_representation(value) + def should_hide(self, instance): + return instance.is_retracted - def to_esi_representation(self, value, envelope='data'): - if getattr(self.field.root, 'child', None): - self.field.parent = self.field.root.child - else: - self.field.parent = self.field.root - return self.field.to_esi_representation(value, envelope) + def should_be_none(self, instance): + return not isinstance(self.field, RelationshipField) -class HideIfWithdrawal(HideIfRegistration): +class HideIfProviderCommentsAnonymous(ConditionalField): """ - If registration is withdrawn, this field will return None. + If the action's provider has anonymous comments and the user does not have `view_actions` + permission on the provider, hide the field. """ - def get_attribute(self, instance): - if instance.is_retracted: - if isinstance(self.field, RelationshipField): - raise SkipField - else: - return None - return self.field.get_attribute(instance) + def should_hide(self, instance): + request = self.context.get('request') + auth = utils.get_user_auth(request) + if auth.logged_in: + provider = instance.target.provider + if provider.reviews_comments_anonymous is False or auth.user.has_perm('view_actions', provider): + return False + return True + + +class HideIfProviderCommentsPrivate(ConditionalField): + """ + If the action's provider has private comments and the user does not have `view_actions` + permission on the provider, hide the field. + """ + + def should_hide(self, instance): + request = self.context.get('request') + auth = utils.get_user_auth(request) + if auth.logged_in: + provider = instance.target.provider + if provider.reviews_comments_private is False or auth.user.has_perm('view_actions', provider): + return False + return True class AllowMissing(ser.Field): @@ -440,7 +444,7 @@ class RelationshipField(ser.HyperlinkedIdentityField): json_api_link = True # serializes to a links object def __init__(self, related_view=None, related_view_kwargs=None, self_view=None, self_view_kwargs=None, - self_meta=None, related_meta=None, always_embed=False, filter=None, filter_key=None, **kwargs): + self_meta=None, related_meta=None, always_embed=False, filter=None, filter_key=None, required=False, **kwargs): related_view = related_view self_view = self_view related_kwargs = related_view_kwargs @@ -477,6 +481,11 @@ def __init__(self, related_view=None, related_view_kwargs=None, self_view=None, if kwargs.get('read_only') is not None: self.read_only = kwargs['read_only'] + # Allow a RelationshipField to be required + if required: + assert not self.read_only, 'May not set both `read_only` and `required`' + self.required = required + def resolve(self, resource, field_name, request): """ Resolves the view when embedding. @@ -715,11 +724,28 @@ def to_representation(self, value): raise SkipField related_url = url['related'] + related_path = urlparse(related_url).path related_meta = self.get_meta_information(self.related_meta, value) self_url = url['self'] self_meta = self.get_meta_information(self.self_meta, value) - return format_relationship_links(related_url, self_url, related_meta, self_meta) - + relationship = format_relationship_links(related_url, self_url, related_meta, self_meta) + if related_url and (len(related_path.split('/')) & 1) == 1: + resolved_url = resolve(related_path) + related_class = resolved_url.func.view_class + if issubclass(related_class, RetrieveModelMixin): + related_type = resolved_url.namespace + try: + # TODO: change kwargs to preprint_provider_id and registration_id + if related_type == 'preprint_providers': + related_id = resolved_url.kwargs['provider_id'] + elif related_type == 'registrations': + related_id = resolved_url.kwargs['node_id'] + else: + related_id = resolved_url.kwargs[related_type[:-1] + '_id'] + except KeyError: + return relationship + relationship['data'] = {'id': related_id, 'type': related_type} + return relationship class FileCommentRelationshipField(RelationshipField): def get_url(self, obj, view_name, request, format): diff --git a/api/base/settings/defaults.py b/api/base/settings/defaults.py index 8f89797e9ff..187e566ef98 100644 --- a/api/base/settings/defaults.py +++ b/api/base/settings/defaults.py @@ -50,6 +50,7 @@ AUTHENTICATION_BACKENDS = ( 'api.base.authentication.backends.ODMBackend', + 'guardian.backends.ObjectPermissionBackend', ) # SECURITY WARNING: don't run with debug turned on in production! @@ -87,9 +88,11 @@ 'corsheaders', 'raven.contrib.django.raven_compat', 'django_extensions', + 'guardian', # OSF 'osf', + 'reviews', # Addons 'addons.osfstorage', @@ -264,3 +267,6 @@ OSF_URL = 'https://osf.io' SELECT_FOR_UPDATE_ENABLED = True + +# Disable anonymous user permissions in django-guardian +ANONYMOUS_USER_NAME = None diff --git a/api/base/urls.py b/api/base/urls.py index a4969e35a32..6f250aa01c9 100644 --- a/api/base/urls.py +++ b/api/base/urls.py @@ -16,6 +16,7 @@ [ url(r'^$', views.root, name='root'), url(r'^status/', views.status_check, name='status_check'), + url(r'^actions/', include('api.actions.urls', namespace='actions')), url(r'^addons/', include('api.addons.urls', namespace='addons')), url(r'^applications/', include('api.applications.urls', namespace='applications')), url(r'^citations/', include('api.citations.urls', namespace='citations')), diff --git a/api/base/views.py b/api/base/views.py index 8613b945abb..cef98c3bfca 100644 --- a/api/base/views.py +++ b/api/base/views.py @@ -140,7 +140,7 @@ def get_serializer_context(self): if self.kwargs.get('is_embedded'): embeds = [] else: - embeds = self.request.query_params.getlist('embed') + embeds = self.request.query_params.getlist('embed') or self.request.query_params.getlist('embed[]') fields_check = self.serializer_class._declared_fields.copy() if 'fields[{}]'.format(self.serializer_class.Meta.type_) in self.request.query_params: diff --git a/api/nodes/filters.py b/api/nodes/filters.py index 7abbf41245d..d52f38cd44e 100644 --- a/api/nodes/filters.py +++ b/api/nodes/filters.py @@ -59,11 +59,19 @@ def build_query_from_field(self, field_name, operation): return Q(root__guids___id__in=operation['value']) if field_name == 'preprint': - not_preprint_query = ( - Q(preprint_file=None) | - Q(_is_preprint_orphan=True) | - Q(_has_abandoned_preprint=True) - ) + # required for "All my preprints" section on the "My Projects" page until preprint/node divorce + if utils.is_truthy(self.request.query_params.get('include_unpublished_preprints')): + not_preprint_query = ( + Q(preprint_file=None) | + Q(_is_preprint_orphan=True) + ) + else: + not_preprint_query = ( + Q(preprint_file=None) | + Q(_is_preprint_orphan=True) | + Q(_has_abandoned_preprint=True) + ) + return ~not_preprint_query if utils.is_truthy(operation['value']) else not_preprint_query return super(NodesFilterMixin, self).build_query_from_field(field_name, operation) diff --git a/api/nodes/views.py b/api/nodes/views.py index 023b44f344b..7c061c0238f 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -3450,9 +3450,7 @@ def get_default_queryset(self): node = self.get_node() # Permissions on the node are handled by the permissions_classes # Permissions on the list objects are handled by the query - if auth_user: - return node.preprints.filter(Q(is_published=True) | Q(node__contributor__user_id=auth_user.id, node__contributor__admin=True)) - return node.preprints.filter(is_published=True) + return self.preprints_queryset(node.preprints.all(), auth_user) def get_queryset(self): return self.get_queryset_from_request().distinct('id', 'date_modified') diff --git a/api/preprint_providers/serializers.py b/api/preprint_providers/serializers.py index d7d59afa45d..8bddfd91478 100644 --- a/api/preprint_providers/serializers.py +++ b/api/preprint_providers/serializers.py @@ -1,6 +1,11 @@ +from guardian.shortcuts import get_perms from rest_framework import serializers as ser +from rest_framework.exceptions import ValidationError -from api.base.utils import absolute_reverse +from reviews.workflow import Workflows + +from api.actions.serializers import ReviewableCountsRelationshipField +from api.base.utils import absolute_reverse, get_user_auth from api.base.serializers import JSONAPISerializer, LinksField, RelationshipField, ShowIfVersion @@ -14,24 +19,33 @@ class PreprintProviderSerializer(JSONAPISerializer): 'id', 'name', 'share_publish_type', + 'reviews_workflow', + 'permissions', ]) - name = ser.CharField(required=True) - description = ser.CharField(required=False) - id = ser.CharField(max_length=200, source='_id') - advisory_board = ser.CharField(required=False) - example = ser.CharField(required=False, allow_null=True) - domain = ser.CharField(required=False, allow_null=False) - domain_redirect_enabled = ser.BooleanField(required=True) - footer_links = ser.CharField(required=False) + name = ser.CharField(read_only=True) + description = ser.CharField(read_only=True) + id = ser.CharField(read_only=True, max_length=200, source='_id') + advisory_board = ser.CharField(read_only=True) + example = ser.CharField(read_only=True, allow_null=True) + domain = ser.CharField(read_only=True, allow_null=False) + domain_redirect_enabled = ser.BooleanField(read_only=True) + footer_links = ser.CharField(read_only=True) share_source = ser.CharField(read_only=True) share_publish_type = ser.CharField(read_only=True) - email_support = ser.CharField(required=False, allow_null=True) - preprint_word = ser.CharField(required=False, allow_null=True) + email_support = ser.CharField(read_only=True, allow_null=True) + preprint_word = ser.CharField(read_only=True, allow_null=True) allow_submissions = ser.BooleanField(read_only=True) - additional_providers = ser.ListField(child=ser.CharField(), read_only=True) + additional_providers = ser.ListField(read_only=True, child=ser.CharField()) + + # Reviews settings are the only writable fields + reviews_workflow = ser.ChoiceField(choices=Workflows.choices()) + reviews_comments_private = ser.BooleanField() + reviews_comments_anonymous = ser.BooleanField() + + permissions = ser.SerializerMethodField() - preprints = RelationshipField( + preprints = ReviewableCountsRelationshipField( related_view='preprint_providers:preprints-list', related_view_kwargs={'provider_id': '<_id>'} ) @@ -59,35 +73,35 @@ class PreprintProviderSerializer(JSONAPISerializer): # Deprecated fields header_text = ShowIfVersion( - ser.CharField(required=False, default=''), + ser.CharField(read_only=True, default=''), min_version='2.0', max_version='2.3' ) banner_path = ShowIfVersion( - ser.CharField(required=False, default=''), + ser.CharField(read_only=True, default=''), min_version='2.0', max_version='2.3' ) logo_path = ShowIfVersion( - ser.CharField(required=False, default=''), + ser.CharField(read_only=True, default=''), min_version='2.0', max_version='2.3' ) email_contact = ShowIfVersion( - ser.CharField(required=False, allow_null=True), + ser.CharField(read_only=True, allow_null=True), min_version='2.0', max_version='2.3' ) social_twitter = ShowIfVersion( - ser.CharField(required=False, allow_null=True), + ser.CharField(read_only=True, allow_null=True), min_version='2.0', max_version='2.3' ) social_facebook = ShowIfVersion( - ser.CharField(required=False, allow_null=True), + ser.CharField(read_only=True, allow_null=True), min_version='2.0', max_version='2.3' ) social_instagram = ShowIfVersion( - ser.CharField(required=False, allow_null=True), + ser.CharField(read_only=True, allow_null=True), min_version='2.0', max_version='2.3' ) subjects_acceptable = ShowIfVersion( - ser.ListField(required=False, default=[]), + ser.ListField(read_only=True, default=[]), min_version='2.0', max_version='2.4' ) @@ -105,3 +119,23 @@ def get_preprints_url(self, obj): def get_external_url(self, obj): return obj.external_url + + def get_permissions(self, obj): + auth = get_user_auth(self.context['request']) + if not auth.user: + return [] + return get_perms(auth.user, obj) + + def validate(self, data): + required_fields = ('reviews_workflow', 'reviews_comments_private', 'reviews_comments_anonymous') + for field in required_fields: + if data.get(field) is None: + raise ValidationError('All reviews fields must be set at once: `{}`'.format('`, `'.join(required_fields))) + return data + + def update(self, instance, validated_data): + instance.reviews_workflow = validated_data['reviews_workflow'] + instance.reviews_comments_private = validated_data['reviews_comments_private'] + instance.reviews_comments_anonymous = validated_data['reviews_comments_anonymous'] + instance.save() + return instance diff --git a/api/preprint_providers/views.py b/api/preprint_providers/views.py index 814fffcd66c..9d1b6197180 100644 --- a/api/preprint_providers/views.py +++ b/api/preprint_providers/views.py @@ -1,5 +1,8 @@ +from guardian.shortcuts import get_objects_for_user + from rest_framework import generics from rest_framework import permissions as drf_permissions +from rest_framework.exceptions import NotAuthenticated from django.db.models import Q @@ -7,11 +10,14 @@ from osf.models import AbstractNode, Subject, PreprintProvider +from reviews import permissions as reviews_permissions + from api.base import permissions as base_permissions +from api.base.exceptions import InvalidFilterValue, InvalidFilterOperator, Conflict from api.base.filters import PreprintFilterMixin, ListFilterMixin from api.base.views import JSONAPIBaseView from api.base.pagination import MaxSizePagination -from api.base.utils import get_object_or_error, get_user_auth +from api.base.utils import get_object_or_error, get_user_auth, is_truthy from api.licenses.views import LicenseList from api.taxonomies.serializers import TaxonomySerializer from api.preprint_providers.serializers import PreprintProviderSerializer @@ -29,21 +35,24 @@ class PreprintProviderList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixi OSF Preprint Providers have the "preprint_providers" `type`. - name type description + name type description ============================================================================================================= - name string name of the preprint provider - logo_path string a path to the preprint provider's static logo - banner_path string a path to the preprint provider's banner - description string description of the preprint provider - advisory_board string HTML for the advisory board/steering committee section - email_contact string the contact email for the preprint provider - email_support string the support email for the preprint provider - social_facebook string the preprint provider's Facebook account - social_instagram string the preprint provider's Instagram account - social_twitter string the preprint provider's Twitter account - domain string the domain name of the preprint provider - domain_redirect_enabled boolean whether or not redirects are enabled for the provider's domain - example string an example guid for a preprint created for the preprint provider + name string name of the preprint provider + logo_path string a path to the preprint provider's static logo + banner_path string a path to the preprint provider's banner + description string description of the preprint provider + advisory_board string HTML for the advisory board/steering committee section + email_contact string the contact email for the preprint provider + email_support string the support email for the preprint provider + social_facebook string the preprint provider's Facebook account + social_instagram string the preprint provider's Instagram account + social_twitter string the preprint provider's Twitter account + domain string the domain name of the preprint provider + domain_redirect_enabled boolean whether or not redirects are enabled for the provider's domain + example string an example guid for a preprint created for the preprint provider + reviews_workflow string the workflow used for reviewing/moderating preprints, if any + reviews_comments_private boolean whether comments made by moderators are visible to authors + reviews_comments_anonymous boolean if comments are not private, whether the name of the moderator is visible to authors ##Relationships @@ -81,9 +90,26 @@ def get_default_queryset(self): def get_queryset(self): return self.get_queryset_from_request() + def build_query_from_field(self, field_name, operation): + if field_name == 'permissions': + if operation['op'] != 'eq': + raise InvalidFilterOperator(value=operation['op'], valid_operators=['eq']) + auth = get_user_auth(self.request) + auth_user = getattr(auth, 'user', None) + if not auth_user: + raise NotAuthenticated() + value = operation['value'].lstrip('[').rstrip(']') + permissions = [v.strip() for v in value.split(',')] + if any(p not in reviews_permissions.PERMISSIONS for p in permissions): + valid_permissions = ', '.join(reviews_permissions.PERMISSIONS.keys()) + raise InvalidFilterValue('Invalid permission! Valid values are: {}'.format(valid_permissions)) + return Q(id__in=get_objects_for_user(auth_user, permissions, PreprintProvider, any_perm=True)) + + return super(PreprintProviderList, self).build_query_from_field(field_name, operation) -class PreprintProviderDetail(JSONAPIBaseView, generics.RetrieveAPIView): - """ Details about a given preprint provider. *Read-only* + +class PreprintProviderDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView): + """ Details about a given preprint provider. *Writeable* Assume undocumented fields are unstable. @@ -91,21 +117,24 @@ class PreprintProviderDetail(JSONAPIBaseView, generics.RetrieveAPIView): OSF Preprint Providers have the "preprint_providers" `type`. - name type description + name type description ============================================================================================================= - name string name of the preprint provider - logo_path string a path to the preprint provider's static logo - banner_path string a path to the preprint provider's banner - description string description of the preprint provider - advisory_board string HTML for the advisory board/steering committee section - email_contact string the contact email for the preprint provider - email_support string the support email for the preprint provider - social_facebook string the preprint provider's Facebook account - social_instagram string the preprint provider's Instagram account - social_twitter string the preprint provider's Twitter account - domain string the domain name of the preprint provider - domain_redirect_enabled boolean whether or not redirects are enabled for the provider's domain - example string an example guid for a preprint created for the preprint provider + name string name of the preprint provider + logo_path string a path to the preprint provider's static logo + banner_path string a path to the preprint provider's banner + description string description of the preprint provider + advisory_board string HTML for the advisory board/steering committee section + email_contact string the contact email for the preprint provider + email_support string the support email for the preprint provider + social_facebook string the preprint provider's Facebook account + social_instagram string the preprint provider's Instagram account + social_twitter string the preprint provider's Twitter account + domain string the domain name of the preprint provider + domain_redirect_enabled boolean whether or not redirects are enabled for the provider's domain + example string an example guid for a preprint created for the preprint provider + reviews_workflow string the workflow used for reviewing/moderating preprints, if any + reviews_comments_private boolean whether comments made by moderators are visible to authors + reviews_comments_anonymous boolean if comments are not private, whether the name of the moderator is visible to authors ##Relationships @@ -118,16 +147,44 @@ class PreprintProviderDetail(JSONAPIBaseView, generics.RetrieveAPIView): preprints: link to the provider's preprints external_url: link to the preprint provider's external URL (e.g. https://socarxiv.org) + ##Setting up Moderation + + Set up moderation for a provider by sending a patch request to the ID of the existing provider. + + Currently, the only parameters which may be set are `reviews_workflow`, + `reviews_comments_private`, and `reviews_comments_anonymous`. These parameters may be set + only once, after which they may be updated only by an OSF Admin. + + If `reviews_workflow` is already non-null, attempting to update the provider will return + a `409` Conflict error. If you need to change your provider's moderation settings, contact + [support@osf.io](mailto:support@osf.io) for help. + + Method: PATCH + URL: /preprint_providers/{provider_id}/ + Query Params: + Body (JSON): { + "data": { + "id": provider_id, + "attributes": { + "reviews_workflow": {workflow}, # Valid workflows: "pre-moderation", "post-moderation" + "reviews_comments_private": {boolean}, + "reviews_comments_anonymous": {boolean} + }, + } + } + Success: 200 OK + provider representation + #This Request/Response """ permission_classes = ( drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, + reviews_permissions.CanSetUpProvider, ) required_read_scopes = [CoreScopes.ALWAYS_PUBLIC] - required_write_scopes = [CoreScopes.NULL] + required_write_scopes = [CoreScopes.PROVIDERS_WRITE] model_class = PreprintProvider serializer_class = PreprintProviderSerializer @@ -135,7 +192,14 @@ class PreprintProviderDetail(JSONAPIBaseView, generics.RetrieveAPIView): view_name = 'preprint_provider-detail' def get_object(self): - return get_object_or_error(PreprintProvider, self.kwargs['provider_id'], self.request, display_name='PreprintProvider') + provider = get_object_or_error(PreprintProvider, self.kwargs['provider_id'], self.request, display_name='PreprintProvider') + self.check_object_permissions(self.request, provider) + return provider + + def perform_update(self, serializer): + if serializer.instance.is_reviewed: + raise Conflict('Reviews settings may be set only once. Contact support@osf.io if you need to update them.') + super(PreprintProviderDetail, self).perform_update(serializer) class PreprintProviderPreprintList(JSONAPIBaseView, generics.ListAPIView, PreprintFilterMixin): @@ -201,19 +265,27 @@ def get_default_queryset(self): provider = get_object_or_error(PreprintProvider, self.kwargs['provider_id'], self.request, display_name='PreprintProvider') # Permissions on the list objects are handled by the query - default_qs = provider.preprint_services.filter(node__isnull=False, node__is_deleted=False) - no_user_query = Q(is_published=True, node__is_public=True) - - if auth_user: - contrib_user_query = Q(is_published=True, node__contributor__user_id=auth_user.id, node__contributor__read=True) - admin_user_query = Q(node__contributor__user_id=auth_user.id, node__contributor__admin=True) - return default_qs.filter(no_user_query | contrib_user_query | admin_user_query) - return default_qs.filter(no_user_query) + return self.preprints_queryset(provider.preprint_services.all(), auth_user) # overrides ListAPIView def get_queryset(self): return self.get_queryset_from_request().distinct('id', 'date_created') + # overrides APIView + def get_renderer_context(self): + context = super(PreprintProviderPreprintList, self).get_renderer_context() + show_counts = is_truthy(self.request.query_params.get('meta[reviews_state_counts]', False)) + if show_counts: + # TODO don't duplicate the above + auth = get_user_auth(self.request) + auth_user = getattr(auth, 'user', None) + provider = get_object_or_error(PreprintProvider, self.kwargs['provider_id'], self.request, display_name='PreprintProvider') + if auth_user and auth_user.has_perm('view_submissions', provider): + context['meta'] = { + 'reviews_state_counts': provider.get_reviewable_state_counts(), + } + return context + class PreprintProviderTaxonomies(JSONAPIBaseView, generics.ListAPIView): permission_classes = ( diff --git a/api/preprints/permissions.py b/api/preprints/permissions.py index 13d934c4f44..c0982b80d20 100644 --- a/api/preprints/permissions.py +++ b/api/preprints/permissions.py @@ -14,7 +14,10 @@ def has_object_permission(self, request, view, obj): node = obj.node auth = get_user_auth(request) if request.method in permissions.SAFE_METHODS: - return obj.is_published or node.has_permission(auth.user, osf_permissions.ADMIN) + if auth.user is None: + return obj.verified_publishable + else: + return obj.verified_publishable or (node.is_public and auth.user.has_perm('view_submissions', obj.provider)) or node.has_permission(auth.user, osf_permissions.ADMIN) else: if not node.has_permission(auth.user, osf_permissions.ADMIN): raise exceptions.PermissionDenied(detail='User must be an admin to update a preprint.') diff --git a/api/preprints/serializers.py b/api/preprints/serializers.py index ab15fba6df7..74d3c04a72d 100644 --- a/api/preprints/serializers.py +++ b/api/preprints/serializers.py @@ -64,6 +64,8 @@ class PreprintSerializer(JSONAPISerializer): 'provider', 'is_published', 'subjects', + 'reviews_state', + 'node_is_public', ]) id = IDField(source='_id', read_only=True) @@ -78,12 +80,16 @@ class PreprintSerializer(JSONAPISerializer): title = ser.CharField(source='node.title', required=False) description = ser.CharField(required=False, allow_blank=True, allow_null=True, source='node.description') tags = JSONAPIListField(child=NodeTagField(), required=False, source='node.tags') + node_is_public = ser.BooleanField(read_only=True, source='node__is_public') contributors = RelationshipField( related_view='nodes:node-contributors', related_view_kwargs={'node_id': ''}, ) + reviews_state = ser.CharField(read_only=True, max_length=15) + date_last_transitioned = DateByVersion(read_only=True) + citation = RelationshipField( related_view='preprints:preprint-citation', related_view_kwargs={'preprint_id': '<_id>'} @@ -124,6 +130,11 @@ class PreprintSerializer(JSONAPISerializer): read_only=False ) + actions = RelationshipField( + related_view='preprints:preprint-action-list', + related_view_kwargs={'preprint_id': '<_id>'} + ) + links = LinksField( { 'self': 'get_preprint_url', @@ -172,6 +183,15 @@ def update(self, preprint, validated_data): if not preprint.node.has_permission(auth.user, 'admin'): raise exceptions.PermissionDenied(detail='User must be an admin to update a preprint.') + published = validated_data.pop('is_published', None) + if published and preprint.provider.is_reviewed: + raise Conflict('{} uses a moderation workflow, so preprints must be submitted for review instead of published directly. Submit a preprint by creating a `submit` Action at {}'.format( + preprint.provider.name, + absolute_reverse('actions:create-action', kwargs={ + 'version': self.context['request'].parser_context['kwargs']['version'] + }) + )) + save_node = False save_preprint = False recently_published = False @@ -211,7 +231,6 @@ def update(self, preprint, validated_data): self.set_field(preprint.set_preprint_license, license_details, auth) save_preprint = True - published = validated_data.pop('is_published', None) if published is not None: if not preprint.primary_file: raise exceptions.ValidationError(detail='A valid primary_file must be set before publishing a preprint.') diff --git a/api/preprints/urls.py b/api/preprints/urls.py index 31ce465997f..065dbb62498 100644 --- a/api/preprints/urls.py +++ b/api/preprints/urls.py @@ -9,4 +9,5 @@ url(r'^(?P\w+)/citation/(?P[-\w]+)/$', views.PreprintCitationStyleDetail.as_view(), name=views.PreprintCitationStyleDetail.view_name), url(r'^(?P\w+)/identifiers/$', views.PreprintIdentifierList.as_view(), name=views.PreprintIdentifierList.view_name), url(r'^(?P\w+)/contributors/$', views.PreprintContributorsList.as_view(), name=views.PreprintContributorsList.view_name), + url(r'^(?P\w+)/actions/$', views.PreprintActionList.as_view(), name=views.PreprintActionList.view_name), ] diff --git a/api/preprints/views.py b/api/preprints/views.py index d81ed7156db..b752a3888cd 100644 --- a/api/preprints/views.py +++ b/api/preprints/views.py @@ -1,18 +1,19 @@ import re -from django.db.models import Q - from rest_framework import generics from rest_framework.exceptions import NotFound, PermissionDenied, NotAuthenticated from rest_framework import permissions as drf_permissions from framework.auth.oauth_scopes import CoreScopes -from osf.models import PreprintService +from osf.models import Action, PreprintService from osf.utils.requests import check_select_for_update +from reviews import permissions as reviews_permissions +from api.actions.serializers import ActionSerializer +from api.actions.views import get_actions_queryset from api.base.exceptions import Conflict from api.base.views import JSONAPIBaseView, WaterButlerMixin -from api.base.filters import PreprintFilterMixin +from api.base.filters import ListFilterMixin, PreprintFilterMixin from api.base.parsers import ( JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON, @@ -161,6 +162,7 @@ class PreprintList(JSONAPIBaseView, generics.ListCreateAPIView, PreprintFilterMi serializer_class = PreprintSerializer ordering = ('-date_created') + ordering_fields = ('date_created', 'date_last_transitioned') view_category = 'preprints' view_name = 'preprint-list' @@ -175,13 +177,7 @@ def get_default_queryset(self): auth_user = getattr(auth, 'user', None) # Permissions on the list objects are handled by the query - default_qs = PreprintService.objects.filter(node__is_deleted=False, node__isnull=False) - no_user_query = Q(is_published=True, node__is_public=True) - if auth_user: - contrib_user_query = Q(is_published=True, node__contributor__user_id=auth_user.id, node__contributor__read=True) - admin_user_query = Q(node__contributor__user_id=auth_user.id, node__contributor__admin=True) - return default_qs.filter(no_user_query | contrib_user_query | admin_user_query) - return default_qs.filter(no_user_query) + return self.preprints_queryset(PreprintService.objects.all(), auth_user) # overrides ListAPIView def get_queryset(self): @@ -407,3 +403,66 @@ class PreprintContributorsList(NodeContributorsList, PreprintMixin): def create(self, request, *args, **kwargs): self.kwargs['node_id'] = self.get_preprint(check_object_permissions=False).node._id return super(PreprintContributorsList, self).create(request, *args, **kwargs) + + +class PreprintActionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin, PreprintMixin): + """Action List *Read-only* + + Actions represent state changes and/or comments on a reviewable object (e.g. a preprint) + + ##Action Attributes + + name type description + ==================================================================================== + date_created iso8601 timestamp timestamp that the action was created + date_modified iso8601 timestamp timestamp that the action was last modified + from_state string state of the reviewable before this action was created + to_state string state of the reviewable after this action was created + comment string comment explaining the state change + trigger string name of the trigger for this action + + ##Relationships + + ###Target + Link to the object (e.g. preprint) this action acts on + + ###Provider + Link to detail for the target object's provider + + ###Creator + Link to the user that created this action + + ##Links + - `self` -- Detail page for the current action + + ##Query Params + + + `page=` -- page number of results to view, default 1 + + + `filter[]=` -- fields and values to filter the search results on. + + Actions may be filtered by their `id`, `from_state`, `to_state`, `date_created`, `date_modified`, `creator`, `provider`, `target` + """ + permission_classes = ( + drf_permissions.IsAuthenticatedOrReadOnly, + base_permissions.TokenHasScope, + reviews_permissions.ActionPermission, + ) + + required_read_scopes = [CoreScopes.ACTIONS_READ] + required_write_scopes = [CoreScopes.ACTIONS_WRITE] + + serializer_class = ActionSerializer + model_class = Action + + ordering = ('-date_created',) + view_category = 'preprints' + view_name = 'preprint-action-list' + + # overrides ListFilterMixin + def get_default_queryset(self): + return get_actions_queryset().filter(target_id=self.get_preprint().id) + + # overrides ListAPIView + def get_queryset(self): + return self.get_queryset_from_request() diff --git a/api/users/serializers.py b/api/users/serializers.py index 0a2123a3070..9613573c0a4 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -1,3 +1,5 @@ +from guardian.models import GroupObjectPermission + from rest_framework import serializers as ser from api.base.exceptions import InvalidModelValueError @@ -5,7 +7,7 @@ BaseAPISerializer, JSONAPISerializer, JSONAPIRelationshipSerializer, DateByVersion, DevOnly, HideIfDisabled, IDField, Link, LinksField, ListDictField, TypeField, RelationshipField, - WaterbutlerLink + WaterbutlerLink, ShowIfCurrentUser ) from api.base.utils import absolute_reverse, get_user_auth from api.files.serializers import QuickFilesSerializer @@ -52,6 +54,7 @@ class UserSerializer(JSONAPISerializer): timezone = HideIfDisabled(ser.CharField(required=False, help_text="User's timezone, e.g. 'Etc/UTC")) locale = HideIfDisabled(ser.CharField(required=False, help_text="User's locale, e.g. 'en_US'")) social = ListDictField(required=False) + can_view_reviews = ShowIfCurrentUser(ser.SerializerMethodField(help_text='Whether the current user has the `view_submissions` permission to ANY reviews provider.')) links = HideIfDisabled(LinksField( { @@ -83,6 +86,11 @@ class UserSerializer(JSONAPISerializer): self_view_kwargs={'user_id': '<_id>'}, )) + actions = ShowIfCurrentUser(RelationshipField( + related_view='users:user-action-list', + related_view_kwargs={'user_id': '<_id>'}, + )) + class Meta: type_ = 'users' @@ -103,6 +111,10 @@ def get_absolute_url(self, obj): 'version': self.context['request'].parser_context['kwargs']['version'] }) + def get_can_view_reviews(self, obj): + group_qs = GroupObjectPermission.objects.filter(group__user=obj, permission__codename='view_submissions') + return group_qs.exists() or obj.userobjectpermission_set.filter(permission__codename='view_submissions') + def profile_image_url(self, user): size = self.context['request'].query_params.get('profile_image_size') return user.profile_image_url(size=size) diff --git a/api/users/urls.py b/api/users/urls.py index b704764bd97..172e8b01e7a 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -5,6 +5,7 @@ urlpatterns = [ url(r'^$', views.UserList.as_view(), name=views.UserList.view_name), url(r'^(?P\w+)/$', views.UserDetail.as_view(), name=views.UserDetail.view_name), + url(r'^(?P\w+)/actions/$', views.UserActionList.as_view(), name=views.UserActionList.view_name), url(r'^(?P\w+)/addons/$', views.UserAddonList.as_view(), name=views.UserAddonList.view_name), url(r'^(?P\w+)/addons/(?P\w+)/$', views.UserAddonDetail.as_view(), name=views.UserAddonDetail.view_name), url(r'^(?P\w+)/addons/(?P\w+)/accounts/$', views.UserAddonAccountList.as_view(), name=views.UserAddonAccountList.view_name), diff --git a/api/users/views.py b/api/users/views.py index d4c21125838..a7c49aefad4 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -1,6 +1,10 @@ from django.apps import apps +from guardian.shortcuts import get_objects_for_user + from api.addons.views import AddonSettingsMixin +from api.actions.views import get_actions_queryset +from api.actions.serializers import ActionSerializer from api.base import permissions as base_permissions from api.base.exceptions import Conflict, UserGone from api.base.filters import ListFilterMixin, PreprintFilterMixin @@ -29,11 +33,17 @@ ReadEmailUserDetailSerializer,) from django.contrib.auth.models import AnonymousUser from framework.auth.oauth_scopes import CoreScopes, normalize_scopes -from django.db.models import Q from rest_framework import permissions as drf_permissions from rest_framework import generics from rest_framework.exceptions import NotAuthenticated, NotFound -from osf.models import Contributor, ExternalAccount, QuickFilesNode, AbstractNode, PreprintService, OSFUser +from osf.models import (Contributor, + ExternalAccount, + QuickFilesNode, + AbstractNode, + PreprintService, + OSFUser, + PreprintProvider, + Action,) class UserMixin(object): @@ -600,16 +610,8 @@ def get_default_queryset(self): target_user = self.get_user(check_permissions=False) # Permissions on the list objects are handled by the query - default_qs = PreprintService.objects.filter( - node__isnull=False, - node__is_deleted=False, - node___contributors__guids___id=target_user._id - ) - no_user_query = Q(is_published=True, node__is_public=True) - if auth_user: - admin_user_query = Q(node__contributor__user_id=auth_user.id, node__contributor__admin=True) - return default_qs.filter(no_user_query | admin_user_query) - return default_qs.filter(no_user_query) + default_qs = PreprintService.objects.filter(node___contributors__guids___id=target_user._id) + return self.preprints_queryset(default_qs, auth_user, allow_contribs=False) def get_queryset(self): return self.get_queryset_from_request().distinct('id', 'date_created') @@ -786,3 +788,68 @@ def perform_destroy(self, instance): if val['id'] in current_institutions: user.remove_institution(val['id']) user.save() + + +class UserActionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin, UserMixin): + """List of actions viewable by this user *Read-only* + + Actions represent state changes and/or comments on a reviewable object (e.g. a preprint) + + ##Action Attributes + + name type description + ==================================================================================== + date_created iso8601 timestamp timestamp that the action was created + date_modified iso8601 timestamp timestamp that the action was last modified + from_state string state of the reviewable before this action was created + to_state string state of the reviewable after this action was created + comment string comment explaining the state change + trigger string name of the trigger for this action + + ##Relationships + + ###Target + Link to the object (e.g. preprint) this action acts on + + ###Provider + Link to detail for the target object's provider + + ###Creator + Link to the user that created this action + + ##Links + - `self` -- Detail page for the current action + + ##Query Params + + + `page=` -- page number of results to view, default 1 + + + `filter[]=` -- fields and values to filter the search results on. + + Actions may be filtered by their `id`, `from_state`, `to_state`, `date_created`, `date_modified`, `creator`, `provider`, `target` + """ + # Permissions handled in get_default_django_query + permission_classes = ( + drf_permissions.IsAuthenticated, + base_permissions.TokenHasScope, + CurrentUser, + ) + + required_read_scopes = [CoreScopes.ACTIONS_READ] + required_write_scopes = [CoreScopes.NULL] + + serializer_class = ActionSerializer + model_class = Action + + ordering = ('-date_created',) + view_category = 'users' + view_name = 'user-action-list' + + # overrides ListFilterMixin + def get_default_queryset(self): + provider_queryset = get_objects_for_user(self.get_user(), 'view_actions', PreprintProvider) + return get_actions_queryset().filter(target__node__is_public=True, target__provider__in=provider_queryset) + + # overrides ListAPIView + def get_queryset(self): + return self.get_queryset_from_request() diff --git a/api_tests/nodes/views/test_node_list.py b/api_tests/nodes/views/test_node_list.py index 8d5d87a249e..e416a35b2f5 100644 --- a/api_tests/nodes/views/test_node_list.py +++ b/api_tests/nodes/views/test_node_list.py @@ -783,6 +783,19 @@ def test_unpublished_preprint_in_preprint_false_filter_results(self, app, user_o assert preprint.node._id not in ids assert unpublished.node._id in ids + def test_unpublished_preprint_in_preprint_true_filter_results_with_include_unpublished_preprints_param(self, app, user_one, preprint): + unpublished = PreprintFactory(creator=preprint.node.creator, is_published=False) + assert not unpublished.is_published + + url = '/{}nodes/?filter[preprint]=true&include_unpublished_preprints=true'.format(API_BASE) + res = app.get(url, auth=user_one.auth) + assert res.status_code == 200 + data = res.json['data'] + ids = [each['id'] for each in data] + + assert preprint.node._id in ids + assert unpublished.node._id in ids + @pytest.mark.django_db class TestNodeCreate: diff --git a/api_tests/preprint_providers/views/test_preprint_provider_detail.py b/api_tests/preprint_providers/views/test_preprint_provider_detail.py index f978c5e87f3..18da85ae498 100644 --- a/api_tests/preprint_providers/views/test_preprint_provider_detail.py +++ b/api_tests/preprint_providers/views/test_preprint_provider_detail.py @@ -1,7 +1,11 @@ import pytest from api.base.settings.defaults import API_BASE -from osf_tests.factories import PreprintProviderFactory +from osf_tests.factories import ( + PreprintProviderFactory, + AuthUserFactory, +) +from reviews.permissions import GroupHelper @pytest.mark.django_db class TestPreprintProviderExists: @@ -45,3 +49,99 @@ def test_preprint_provider_exists(self, app, provider_url, fake_url): taxonomies_res = app.get('{}taxonomies/'.format(fake_url), expect_errors=True) assert taxonomies_res.status_code == 404 + +@pytest.mark.django_db +class TestPreprintProviderUpdate: + + def settings_payload(self, provider_id, **kwargs): + payload = { + 'data': { + 'id': provider_id, + 'attributes': kwargs + } + } + return payload + + @pytest.fixture() + def preprint_provider(self): + return PreprintProviderFactory() + + @pytest.fixture() + def admin(self, preprint_provider): + user = AuthUserFactory() + user.groups.add(GroupHelper(preprint_provider).get_group('admin')) + return user + + @pytest.fixture() + def moderator(self, preprint_provider): + user = AuthUserFactory() + user.groups.add(GroupHelper(preprint_provider).get_group('moderator')) + return user + + @pytest.fixture() + def url(self, preprint_provider): + return '/{}preprint_providers/{}/'.format(API_BASE, preprint_provider._id) + + def test_update_reviews_settings(self, app, preprint_provider, url, admin, moderator): + payload = self.settings_payload( + preprint_provider.id, + reviews_workflow='pre-moderation', + reviews_comments_private=False, + reviews_comments_anonymous=False + ) + + # Unauthorized user can't set up moderation + res = app.patch_json_api(url, payload, expect_errors=True) + assert res.status_code == 401 + + # Random user can't set up moderation + some_rando = AuthUserFactory() + res = app.patch_json_api(url, payload, auth=some_rando.auth, expect_errors=True) + assert res.status_code == 403 + + # Moderator can't set up moderation + res = app.patch_json_api(url, payload, auth=moderator.auth, expect_errors=True) + assert res.status_code == 403 + + # Admin must include all settings + partial_payload = self.settings_payload( + preprint_provider.id, + reviews_workflow='pre-moderation', + reviews_comments_private=False, + ) + res = app.patch_json_api(url, partial_payload, auth=admin.auth, expect_errors=True) + assert res.status_code == 400 + + partial_payload = self.settings_payload( + preprint_provider.id, + reviews_comments_private=False, + reviews_comments_anonymous=False, + ) + res = app.patch_json_api(url, partial_payload, auth=admin.auth, expect_errors=True) + assert res.status_code == 400 + + # Admin can set up moderation + res = app.patch_json_api(url, payload, auth=admin.auth) + assert res.status_code == 200 + preprint_provider.refresh_from_db() + assert preprint_provider.reviews_workflow == 'pre-moderation' + assert not preprint_provider.reviews_comments_private + assert not preprint_provider.reviews_comments_anonymous + + # ...but only once + res = app.patch_json_api(url, payload, auth=admin.auth, expect_errors=True) + assert res.status_code == 409 + + another_payload = self.settings_payload( + preprint_provider.id, + reviews_workflow='post-moderation', + reviews_comments_private=True, + reviews_comments_anonymous=True + ) + res = app.patch_json_api(url, another_payload, auth=admin.auth, expect_errors=True) + assert res.status_code == 409 + + preprint_provider.refresh_from_db() + assert preprint_provider.reviews_workflow == 'pre-moderation' + assert not preprint_provider.reviews_comments_private + assert not preprint_provider.reviews_comments_anonymous diff --git a/api_tests/preprint_providers/views/test_preprint_provider_preprints_list.py b/api_tests/preprint_providers/views/test_preprint_provider_preprints_list.py index 9bb74ff9437..3140f9aae1d 100644 --- a/api_tests/preprint_providers/views/test_preprint_provider_preprints_list.py +++ b/api_tests/preprint_providers/views/test_preprint_provider_preprints_list.py @@ -3,6 +3,7 @@ from api.base.settings.defaults import API_BASE from api_tests.preprints.filters.test_filters import PreprintsListFilteringMixin from api_tests.preprints.views.test_preprint_list_mixin import PreprintIsPublishedListMixin, PreprintIsValidListMixin +from api_tests.reviews.mixins.filter_mixins import ReviewableFilterMixin from framework.auth.core import Auth from osf_tests.factories import ( ProjectFactory, @@ -53,6 +54,77 @@ def test_provider_filter_equals_returns_multiple(self, app, user, provider_one, actual = set([preprint['id'] for preprint in res.json['data']]) assert expected == actual + def test_reviews_state_counts(self, app, user, provider_one, preprint_one, preprint_two, preprint_three, url): + url = '{}meta[reviews_state_counts]=true'.format(url) + preprint_one.reviews_state = 'pending' + preprint_one.save() + preprint_two.reviews_state = 'pending' + preprint_two.save() + preprint_three.reviews_state = 'accepted' + preprint_three.save() + + expected = { + 'initial': 0, + 'pending': 2, + 'accepted': 1, + 'rejected': 0, + } + + # non-moderators can't see counts + res = app.get(url, auth=user.auth) + assert 'reviews_state_counts' not in res.json['meta'] + + provider_one.add_moderator(user) + + # moderators can see counts + res = app.get(url, auth=user.auth) + actual = res.json['meta']['reviews_state_counts'] + assert expected == actual + + # exclude private preprints + preprint_one.node.is_public = False + preprint_one.node.save() + expected['pending'] -= 1 + res = app.get(url, auth=user.auth) + actual = res.json['meta']['reviews_state_counts'] + assert expected == actual + + # exclude deleted preprints + preprint_two.node.is_deleted = True + preprint_two.node.save() + expected['pending'] -= 1 + res = app.get(url, auth=user.auth) + actual = res.json['meta']['reviews_state_counts'] + assert expected == actual + + +class TestPreprintProviderPreprintListFilteringByReviewableFields(ReviewableFilterMixin): + @pytest.fixture() + def provider(self): + return PreprintProviderFactory(reviews_workflow='post-moderation') + + @pytest.fixture() + def url(self, provider): + return '/{}preprint_providers/{}/preprints/'.format(API_BASE, provider._id) + + @pytest.fixture() + def expected_reviewables(self, provider, user): + preprints = [ + PreprintFactory(is_published=False, provider=provider, project=ProjectFactory(is_public=True)), + PreprintFactory(is_published=False, provider=provider, project=ProjectFactory(is_public=True)), + PreprintFactory(is_published=False, provider=provider, project=ProjectFactory(is_public=True)), + ] + preprints[0].reviews_submit(user) + preprints[0].reviews_accept(user, 'comment') + preprints[1].reviews_submit(user) + preprints[2].reviews_submit(user) + return preprints + + @pytest.fixture + def user(self): + return AuthUserFactory() + + class TestPreprintProviderPreprintIsPublishedList(PreprintIsPublishedListMixin): @pytest.fixture() diff --git a/api_tests/preprint_providers/views/test_preprint_providers_list.py b/api_tests/preprint_providers/views/test_preprint_providers_list.py new file mode 100644 index 00000000000..3b81d9aa452 --- /dev/null +++ b/api_tests/preprint_providers/views/test_preprint_providers_list.py @@ -0,0 +1,11 @@ +import pytest + +from api.base.settings.defaults import API_BASE +from api_tests.reviews.mixins.filter_mixins import ReviewProviderFilterMixin + + +class TestReviewProviderFilters(ReviewProviderFilterMixin): + + @pytest.fixture() + def url(self): + return '/{}preprint_providers/'.format(API_BASE) diff --git a/api_tests/preprints/filters/test_filters.py b/api_tests/preprints/filters/test_filters.py index 1871be9767d..6cb582ec8c6 100644 --- a/api_tests/preprints/filters/test_filters.py +++ b/api_tests/preprints/filters/test_filters.py @@ -7,6 +7,8 @@ SubjectFactory, PreprintProviderFactory ) +from reviews.permissions import GroupHelper + @pytest.mark.django_db class PreprintsListFilteringMixin(object): @@ -101,6 +103,10 @@ def is_published_url(self, url): def is_published_and_modified_url(self, url): return '{}filter[is_published]=true&filter[date_created]=2013-12-11'.format(url) + @pytest.fixture() + def node_is_public_url(self, url): + return '{}filter[node_is_public]='.format(url) + @pytest.fixture() def has_subject(self, url): return '{}filter[subjects]='.format(url) @@ -214,3 +220,44 @@ def test_unknows_subject_filter(self, app, user, has_subject): auth=user.auth ) assert len(res.json['data']) == 0 + + def test_node_is_public_filter(self, app, user, preprint_one, preprint_two, preprint_three, node_is_public_url): + preprint_one.node.is_public = False + preprint_one.node.save() + preprint_two.node.is_public = True + preprint_two.node.save() + preprint_three.node.is_public = True + preprint_three.node.save() + + preprints = [preprint_one, preprint_two, preprint_three] + + res = app.get('{}{}'.format(node_is_public_url, 'false'), auth=user.auth) + expected = set([p._id for p in preprints if not p.node.is_public]) + actual = set([preprint['id'] for preprint in res.json['data']]) + assert expected == actual + + res = app.get('{}{}'.format(node_is_public_url, 'true'), auth=user.auth) + expected = set([p._id for p in preprints if p.node.is_public]) + actual = set([preprint['id'] for preprint in res.json['data']]) + assert expected == actual + + @pytest.mark.parametrize('group_name', ['admin', 'moderator']) + def test_permissions(self, app, url, preprint_one, preprint_two, preprint_three, group_name): + another_user = AuthUserFactory() + preprints = (preprint_one, preprint_two, preprint_three) + + for preprint in preprints: + preprint.is_published = False + preprint.save() + + def actual(): + res = app.get(url, auth=another_user.auth) + return set([preprint['id'] for preprint in res.json['data']]) + + expected = set() + assert expected == actual() + + for preprint in preprints: + another_user.groups.add(GroupHelper(preprint.provider).get_group(group_name)) + expected.update([p._id for p in preprints if p.provider_id == preprint.provider_id]) + assert expected == actual() diff --git a/api_tests/preprints/views/test_preprint_actions.py b/api_tests/preprints/views/test_preprint_actions.py new file mode 100644 index 00000000000..c2ee5d5f4bf --- /dev/null +++ b/api_tests/preprints/views/test_preprint_actions.py @@ -0,0 +1,50 @@ +import pytest + +from api.base.settings.defaults import API_BASE +from osf_tests.factories import ( + PreprintFactory, + AuthUserFactory, +) +from reviews.permissions import GroupHelper +from website.util import permissions as osf_permissions + +from api_tests.reviews.mixins.filter_mixins import ActionFilterMixin +from api_tests.reviews.mixins.comment_settings import ActionCommentSettingsMixin + + +class TestPreprintActionFilters(ActionFilterMixin): + + @pytest.fixture() + def preprint(self, all_actions): + return all_actions[0].target + + @pytest.fixture(params=[True, False], ids=['moderator', 'node_admin']) + def user(self, request, preprint): + user = AuthUserFactory() + if request.param: + user.groups.add(GroupHelper(preprint.provider).get_group('moderator')) + else: + preprint.node.add_contributor(user, permissions=[osf_permissions.READ, osf_permissions.WRITE, osf_permissions.ADMIN]) + return user + + @pytest.fixture() + def expected_actions(self, preprint, all_actions): + return [r for r in all_actions if r.target_id == preprint.id] + + @pytest.fixture() + def url(self, preprint): + return '/{}preprints/{}/actions/'.format(API_BASE, preprint._id) + + def test_unauthorized_user(self, app, url): + res = app.get(url, expect_errors=True) + assert res.status_code == 401 + + user = AuthUserFactory() + res = app.get(url, auth=user.auth, expect_errors=True) + assert res.status_code == 403 + + +class TestActionSettings(ActionCommentSettingsMixin): + @pytest.fixture() + def url(self, preprint): + return '/{}preprints/{}/actions/'.format(API_BASE, preprint._id) diff --git a/api_tests/preprints/views/test_preprint_detail.py b/api_tests/preprints/views/test_preprint_detail.py index 37ebb90e512..19ae805c6be 100644 --- a/api_tests/preprints/views/test_preprint_detail.py +++ b/api_tests/preprints/views/test_preprint_detail.py @@ -68,6 +68,11 @@ def test_preprint_detail(self, app, user, preprint, url, res, data): # test contributors in preprint data assert data['relationships'].get('contributors', None) + assert data['relationships']['contributors'].get('data', None) == None + + # test node type and id in preprint data + assert data['relationships']['node']['data'].get('id', None) == preprint.node._id + assert data['relationships']['node']['data'].get('type', None) == 'nodes' # test_preprint_node_deleted_detail_failure deleted_node = ProjectFactory(creator=user, is_deleted=True) @@ -541,6 +546,11 @@ def test_admin_can_update_license(self, admin_contrib, preprint, cc0_license, ur assert res.status_code == 200 preprint.reload() + res_data = res.json['data'] + pp_license_id = preprint.license.node_license._id + assert res_data['relationships']['license']['data'].get('id', None) == pp_license_id + assert res_data['relationships']['license']['data'].get('type', None) == 'licenses' + assert preprint.license.node_license == cc0_license assert preprint.license.year == None assert preprint.license.copyright_holders == [] diff --git a/api_tests/preprints/views/test_preprint_list.py b/api_tests/preprints/views/test_preprint_list.py index 554e614897b..dade7fc3964 100644 --- a/api_tests/preprints/views/test_preprint_list.py +++ b/api_tests/preprints/views/test_preprint_list.py @@ -7,6 +7,7 @@ from api_tests import utils as test_utils from api_tests.preprints.filters.test_filters import PreprintsListFilteringMixin from api_tests.preprints.views.test_preprint_list_mixin import PreprintIsPublishedListMixin, PreprintIsValidListMixin +from api_tests.reviews.mixins.filter_mixins import ReviewableFilterMixin from framework.auth.core import Auth from osf.models import PreprintService, Node from osf_tests.factories import ( @@ -178,6 +179,31 @@ def test_provider_filter_equals_returns_one(self, mock_change_identifier, app, u actual = [preprint['id'] for preprint in res.json['data']] assert expected == actual + +class TestPreprintListFilteringByReviewableFields(ReviewableFilterMixin): + @pytest.fixture() + def url(self): + return '/{}preprints/'.format(API_BASE) + + @pytest.fixture() + def expected_reviewables(self, user): + preprints = [ + PreprintFactory(is_published=False, project=ProjectFactory(is_public=True)), + PreprintFactory(is_published=False, project=ProjectFactory(is_public=True)), + PreprintFactory(is_published=False, project=ProjectFactory(is_public=True)), + ] + preprints[0].reviews_submit(user) + preprints[0].reviews_accept(user, 'comment') + preprints[1].reviews_submit(user) + preprints[1].reviews_reject(user, 'comment') + preprints[2].reviews_submit(user) + return preprints + + @pytest.fixture + def user(self): + return AuthUserFactory() + + class TestPreprintCreate(ApiTestCase): def setUp(self): super(TestPreprintCreate, self).setUp() @@ -377,6 +403,25 @@ def test_create_preprint_from_project_unpublished_does_not_hit_update(self, mock res = self.app.post_json_api(self.url, private_project_payload, auth=self.user.auth) assert not mock_on_preprint_updated.called + @mock.patch('website.preprints.tasks.get_and_set_preprint_identifiers.si') + @mock.patch('website.preprints.tasks.on_preprint_updated.si') + def test_setting_is_published_with_moderated_provider_fails(self, mock_get_identifiers, mock_on_preprint_updated): + self.provider.reviews_workflow = 'pre-moderation' + self.provider.save() + public_project_payload = build_preprint_create_payload( + self.public_project._id, + self.provider._id, + self.file_one_public_project._id, + { + 'is_published': True, + 'subjects': [[SubjectFactory()._id]], + } + ) + res = self.app.post_json_api(self.url, public_project_payload, auth=self.user.auth, expect_errors=True) + assert res.status_code == 409 + assert not mock_get_identifiers.called + assert not mock_on_preprint_updated.called + class TestPreprintIsPublishedList(PreprintIsPublishedListMixin): diff --git a/api_tests/reviews/__init__.py b/api_tests/reviews/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api_tests/reviews/mixins/__init__.py b/api_tests/reviews/mixins/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api_tests/reviews/mixins/comment_settings.py b/api_tests/reviews/mixins/comment_settings.py new file mode 100644 index 00000000000..8826da5b8be --- /dev/null +++ b/api_tests/reviews/mixins/comment_settings.py @@ -0,0 +1,88 @@ +from datetime import timedelta + +import pytest +from furl import furl + +from osf_tests.factories import ( + ActionFactory, + AuthUserFactory, + PreprintFactory, + PreprintProviderFactory, +) +from reviews.permissions import GroupHelper +from website.util import permissions as osf_permissions + + +@pytest.mark.django_db +class ActionCommentSettingsMixin(object): + + @pytest.fixture() + def url(self): + raise NotImplementedError + + @pytest.fixture() + def provider(self): + return PreprintProviderFactory() + + @pytest.fixture() + def preprint(self, provider): + return PreprintFactory(provider=provider) + + @pytest.fixture() + def actions(self, preprint): + return [ActionFactory(target=preprint) for _ in range(5)] + + @pytest.fixture() + def provider_admin(self, provider): + user = AuthUserFactory() + user.groups.add(GroupHelper(provider).get_group('admin')) + return user + + @pytest.fixture() + def provider_moderator(self, provider): + user = AuthUserFactory() + user.groups.add(GroupHelper(provider).get_group('moderator')) + return user + + @pytest.fixture() + def node_admin(self, preprint): + user = AuthUserFactory() + preprint.node.add_contributor(user, permissions=[osf_permissions.READ, osf_permissions.WRITE, osf_permissions.ADMIN]) + return user + + def test_comment_settings(self, app, url, provider, actions, provider_admin, provider_moderator, node_admin): + expected_ids = set([l._id for l in actions]) + for anonymous in [True, False]: + for private in [True, False]: + provider.reviews_comments_anonymous = anonymous + provider.reviews_comments_private = private + provider.save() + + # admin always sees comment/creator + res = app.get(url, auth=provider_admin.auth) + self.__assert_fields(res, expected_ids, False, False) + + # moderator always sees comment/creator + res = app.get(url, auth=provider_moderator.auth) + self.__assert_fields(res, expected_ids, False, False) + + # node admin sees what the settings allow + res = app.get(url, auth=node_admin.auth) + self.__assert_fields(res, expected_ids, anonymous, private) + + def __assert_fields(self, res, expected_ids, hidden_creator, hidden_comment): + data = res.json['data'] + actual_ids = set([l['id'] for l in data]) + if expected_ids != actual_ids: + raise Exception((expected_ids, actual_ids)) + assert expected_ids == actual_ids + + for action in data: + if hidden_creator: + assert 'creator' not in action['relationships'] + else: + assert 'creator' in action['relationships'] + if hidden_comment: + assert 'comment' not in action['attributes'] + else: + assert 'comment' in action['attributes'] diff --git a/api_tests/reviews/mixins/filter_mixins.py b/api_tests/reviews/mixins/filter_mixins.py new file mode 100644 index 00000000000..e1cca87a4eb --- /dev/null +++ b/api_tests/reviews/mixins/filter_mixins.py @@ -0,0 +1,263 @@ +from datetime import timedelta + +import pytest +from furl import furl + +from osf_tests.factories import ( + ActionFactory, + AuthUserFactory, + PreprintFactory, + PreprintProviderFactory, + ProjectFactory, +) +from reviews.permissions import GroupHelper + + +def get_actual(app, url, user=None, sort=None, expect_errors=False, **filters): + url = furl(url) + for k, v in filters.items(): + url.args['filter[{}]'.format(k)] = v + if sort is not None: + url.args['sort'] = sort + url = url.url + + if expect_errors: + if user is None: + res = app.get(url, expect_errors=True) + else: + res = app.get(url, auth=user.auth, expect_errors=True) + return res + + actual = [] + while url: + if user is None: + res = app.get(url) + else: + res = app.get(url, auth=user.auth) + actual.extend([l['id'] for l in res.json['data']]) + url = res.json['links']['next'] + if sort is None: + return set(actual) + return actual + + +@pytest.mark.django_db +class ActionFilterMixin(object): + + @pytest.fixture() + def url(self): + raise NotImplementedError + + @pytest.fixture() + def providers(self): + return [PreprintProviderFactory(reviews_workflow='pre-moderation') for _ in range(5)] + + @pytest.fixture() + def all_actions(self, providers): + actions = [] + for provider in providers: + preprint = PreprintFactory(provider=provider, project=ProjectFactory(is_public=True)) + for _ in range(5): + actions.append(ActionFactory(target=preprint)) + return actions + + @pytest.fixture() + def allowed_providers(self, providers): + return providers + + @pytest.fixture() + def expected_actions(self, all_actions, allowed_providers): + provider_ids = set([p.id for p in allowed_providers]) + return [a for a in all_actions if a.target.provider_id in provider_ids] + + @pytest.fixture() + def user(self, allowed_providers): + user = AuthUserFactory() + for provider in allowed_providers: + user.groups.add(GroupHelper(provider).get_group('moderator')) + return user + + def test_filter_actions(self, app, url, user, expected_actions): + # unfiltered + expected = set([l._id for l in expected_actions]) + actual = get_actual(app, url, user) + assert expected == actual + + if not expected_actions: + return + + action = expected_actions[0] + + # filter by id + expected = set([action._id]) + actual = get_actual(app, url, user, id=action._id) + assert expected == actual + + # filter by trigger + expected = set([l._id for l in expected_actions if l.trigger == action.trigger]) + actual = get_actual(app, url, user, trigger=action.trigger) + assert expected == actual + + # filter by from_state + expected = set([l._id for l in expected_actions if l.from_state == action.from_state]) + actual = get_actual(app, url, user, from_state=action.from_state) + assert expected == actual + + # filter by to_state + expected = set([l._id for l in expected_actions if l.to_state == action.to_state]) + actual = get_actual(app, url, user, to_state=action.to_state) + assert expected == actual + + # filter by date_created + expected = set([l._id for l in expected_actions]) + actual = get_actual(app, url, user, date_created=action.date_created) + assert expected == actual + + expected = set() + actual = get_actual(app, url, user, date_created=action.date_created - timedelta(days=1)) + assert expected == actual + + # filter by date_modified + expected = set([l._id for l in expected_actions]) + actual = get_actual(app, url, user, date_modified=action.date_modified) + assert expected == actual + + expected = set() + actual = get_actual(app, url, user, date_modified=action.date_modified - timedelta(days=1)) + assert expected == actual + + # filter by target + expected = set([l._id for l in expected_actions if l.target_id == action.target_id]) + actual = get_actual(app, url, user, target=action.target._id) + assert expected == actual + + # filter by provider + expected = set([l._id for l in expected_actions if l.target.provider_id == action.target.provider_id]) + actual = get_actual(app, url, user, provider=action.target.provider._id) + assert expected == actual + + +@pytest.mark.django_db +class ReviewableFilterMixin(object): + + @pytest.fixture() + def url(self): + raise NotImplementedError + + @pytest.fixture() + def expected_reviewables(self): + raise NotImplementedError + + @pytest.fixture() + def user(self): + raise NotImplementedError + + def test_reviewable_filters(self, app, url, user, expected_reviewables): + # unfiltered + expected = set([r._id for r in expected_reviewables]) + actual = get_actual(app, url, user) + assert expected == actual + + if not expected_reviewables: + return + + reviewable = expected_reviewables[0] + + # filter by reviews_state + expected = set([r._id for r in expected_reviewables if r.reviews_state == reviewable.reviews_state]) + actual = get_actual(app, url, user, reviews_state=reviewable.reviews_state) + assert expected == actual + + # order by date_last_transitioned + expected = [r._id for r in sorted(expected_reviewables, key=lambda r: r.date_last_transitioned)] + actual = get_actual(app, url, user, sort='date_last_transitioned') + assert expected == actual + + expected.reverse() + actual = get_actual(app, url, user, sort='-date_last_transitioned') + assert expected == actual + + +@pytest.mark.django_db +class ReviewProviderFilterMixin(object): + + @pytest.fixture() + def url(self): + raise NotImplementedError + + @pytest.fixture() + def expected_providers(self): + return [ + PreprintProviderFactory(reviews_workflow='pre-moderation'), + PreprintProviderFactory(reviews_workflow='post-moderation'), + PreprintProviderFactory(reviews_workflow='pre-moderation'), + PreprintProviderFactory(reviews_workflow=None), + ] + + @pytest.fixture() + def moderator_pair(self, expected_providers): + user = AuthUserFactory() + provider = expected_providers[0] + user.groups.add(GroupHelper(provider).get_group('moderator')) + return (user, provider) + + @pytest.fixture() + def admin_pair(self, expected_providers): + user = AuthUserFactory() + provider = expected_providers[1] + user.groups.add(GroupHelper(provider).get_group('admin')) + return (user, provider) + + def test_review_provider_filters(self, app, url, moderator_pair, admin_pair, expected_providers): + # unfiltered + expected = set([p._id for p in expected_providers]) + actual = get_actual(app, url) + assert expected == actual + + provider = expected_providers[0] + + # filter by reviews_workflow + expected = set([p._id for p in expected_providers if p.reviews_workflow == provider.reviews_workflow]) + actual = get_actual(app, url, reviews_workflow=provider.reviews_workflow) + assert expected == actual + + # filter by permissions (admin) + user, provider = admin_pair + expected = set([provider._id]) + actual = get_actual(app, url, user, permissions='view_actions') + assert expected == actual + + actual = get_actual(app, url, user, permissions='set_up_moderation') + assert expected == actual + + actual = get_actual(app, url, user, permissions='set_up_moderation,view_actions') + assert expected == actual + + # filter by permissions (moderator) + user, provider = moderator_pair + expected = set([provider._id]) + actual = get_actual(app, url, user, permissions='view_actions') + assert expected == actual + + actual = get_actual(app, url, user, permissions='set_up_moderation,view_actions') + assert expected == actual + + expected = set() + actual = get_actual(app, url, user, permissions='set_up_moderation') + assert expected == actual + + # filter by permissions (rando) + user = AuthUserFactory() + expected = set() + actual = get_actual(app, url, user, permissions='view_actions') + assert expected == actual + + actual = get_actual(app, url, user, permissions='set_up_moderation') + assert expected == actual + + actual = get_actual(app, url, user, permissions='set_up_moderation,view_actions') + assert expected == actual + + # filter by permissions requires auth + res = get_actual(app, url, expect_errors=True, permissions='set_up_moderation') + assert res.status_code == 401 diff --git a/api_tests/reviews/views/__init__.py b/api_tests/reviews/views/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/api_tests/users/views/test_user_actions.py b/api_tests/users/views/test_user_actions.py new file mode 100644 index 00000000000..1286e666279 --- /dev/null +++ b/api_tests/users/views/test_user_actions.py @@ -0,0 +1,246 @@ +import pytest +import mock + +from api.base.settings.defaults import API_BASE + +from osf_tests.factories import ( + PreprintFactory, + AuthUserFactory, + PreprintProviderFactory, +) + +from website.util import permissions as osf_permissions + +from reviews.permissions import GroupHelper + +from api_tests.reviews.mixins.filter_mixins import ActionFilterMixin + + +class TestActionFilters(ActionFilterMixin): + @pytest.fixture() + def url(self): + return '/{}users/me/actions/'.format(API_BASE) + + @pytest.fixture() + def expected_actions(self, all_actions, allowed_providers): + actions = super(TestActionFilters, self).expected_actions(all_actions, allowed_providers) + node = actions[0].target.node + node.is_public = False + node.save() + return [a for a in actions if a.target.node.is_public] + + def test_no_permission(self, app, url, expected_actions): + res = app.get(url, expect_errors=True) + assert res.status_code == 401 + + some_rando = AuthUserFactory() + res = app.get(url, auth=some_rando.auth) + assert not res.json['data'] + + +@pytest.mark.django_db +class TestActionCreate(object): + def create_payload(self, reviewable_id=None, **attrs): + payload = { + 'data': { + 'attributes': attrs, + 'relationships': {}, + 'type': 'actions' + } + } + if reviewable_id: + payload['data']['relationships']['target'] = { + 'data': { + 'type': 'preprints', + 'id': reviewable_id + } + } + return payload + + @pytest.fixture() + def url(self): + return '/{}actions/'.format(API_BASE) + + @pytest.fixture() + def provider(self): + return PreprintProviderFactory(reviews_workflow='pre-moderation') + + @pytest.fixture() + def node_admin(self): + return AuthUserFactory() + + @pytest.fixture() + def preprint(self, node_admin, provider): + preprint = PreprintFactory(provider=provider, node__creator=node_admin, is_published=False) + preprint.node.add_contributor(node_admin, permissions=[osf_permissions.ADMIN]) + return preprint + + @pytest.fixture() + def moderator(self, provider): + moderator = AuthUserFactory() + moderator.groups.add(GroupHelper(provider).get_group('moderator')) + return moderator + + @mock.patch('website.preprints.tasks.get_and_set_preprint_identifiers.si') + def test_create_permissions(self, mock_ezid, app, url, preprint, node_admin, moderator): + assert preprint.reviews_state == 'initial' + + submit_payload = self.create_payload(preprint._id, trigger='submit') + + # Unauthorized user can't submit + res = app.post_json_api(url, submit_payload, expect_errors=True) + assert res.status_code == 401 + + # A random user can't submit + some_rando = AuthUserFactory() + res = app.post_json_api(url, submit_payload, auth=some_rando.auth, expect_errors=True) + assert res.status_code == 403 + + # Node admin can submit + res = app.post_json_api(url, submit_payload, auth=node_admin.auth) + assert res.status_code == 201 + preprint.refresh_from_db() + assert preprint.reviews_state == 'pending' + assert not preprint.is_published + + accept_payload = self.create_payload(preprint._id, trigger='accept', comment='This is good.') + + # Unauthorized user can't accept + res = app.post_json_api(url, accept_payload, expect_errors=True) + assert res.status_code == 401 + + # A random user can't accept + res = app.post_json_api(url, accept_payload, auth=some_rando.auth, expect_errors=True) + assert res.status_code == 403 + + # Moderator from another provider can't accept + another_moderator = AuthUserFactory() + another_moderator.groups.add(GroupHelper(PreprintProviderFactory()).get_group('moderator')) + res = app.post_json_api(url, accept_payload, auth=another_moderator.auth, expect_errors=True) + assert res.status_code == 403 + + # Node admin can't accept + res = app.post_json_api(url, accept_payload, auth=node_admin.auth, expect_errors=True) + assert res.status_code == 403 + + # Still unchanged after all those tries + preprint.refresh_from_db() + assert preprint.reviews_state == 'pending' + assert not preprint.is_published + + # Moderator can accept + res = app.post_json_api(url, accept_payload, auth=moderator.auth) + assert res.status_code == 201 + preprint.refresh_from_db() + assert preprint.reviews_state == 'accepted' + assert preprint.is_published + + # Check if "get_and_set_preprint_identifiers" is called once. + assert mock_ezid.call_count == 1 + + def test_cannot_create_actions_for_unmoderated_provider(self, app, url, preprint, provider, node_admin): + provider.reviews_workflow = None + provider.save() + submit_payload = self.create_payload(preprint._id, trigger='submit') + res = app.post_json_api(url, submit_payload, auth=node_admin.auth, expect_errors=True) + assert res.status_code == 409 + + def test_bad_requests(self, app, url, preprint, provider, moderator): + invalid_transitions = { + 'post-moderation': [ + ('accepted', 'accept'), + ('accepted', 'submit'), + ('initial', 'accept'), + ('initial', 'edit_comment'), + ('initial', 'reject'), + ('pending', 'submit'), + ('rejected', 'reject'), + ('rejected', 'submit'), + ], + 'pre-moderation': [ + ('accepted', 'accept'), + ('accepted', 'submit'), + ('initial', 'accept'), + ('initial', 'edit_comment'), + ('initial', 'reject'), + ('rejected', 'reject'), + ] + } + for workflow, transitions in invalid_transitions.items(): + provider.reviews_workflow = workflow + provider.save() + for state, trigger in transitions: + preprint.reviews_state = state + preprint.save() + bad_payload = self.create_payload(preprint._id, trigger=trigger) + res = app.post_json_api(url, bad_payload, auth=moderator.auth, expect_errors=True) + assert res.status_code == 409 + + # test invalid trigger + bad_payload = self.create_payload(preprint._id, trigger='badtriggerbad') + res = app.post_json_api(url, bad_payload, auth=moderator.auth, expect_errors=True) + assert res.status_code == 400 + + # test target is required + bad_payload = self.create_payload(trigger='accept') + res = app.post_json_api(url, bad_payload, auth=moderator.auth, expect_errors=True) + assert res.status_code == 400 + + @mock.patch('website.preprints.tasks.get_and_set_preprint_identifiers.si') + def test_valid_transitions(self, mock_ezid, app, url, preprint, provider, moderator): + valid_transitions = { + 'post-moderation': [ + ('accepted', 'edit_comment', 'accepted'), + ('accepted', 'reject', 'rejected'), + ('initial', 'submit', 'pending'), + ('pending', 'accept', 'accepted'), + ('pending', 'edit_comment', 'pending'), + ('pending', 'reject', 'rejected'), + ('rejected', 'accept', 'accepted'), + ('rejected', 'edit_comment', 'rejected'), + ], + 'pre-moderation': [ + ('accepted', 'edit_comment', 'accepted'), + ('accepted', 'reject', 'rejected'), + ('initial', 'submit', 'pending'), + ('pending', 'accept', 'accepted'), + ('pending', 'edit_comment', 'pending'), + ('pending', 'reject', 'rejected'), + ('pending', 'submit', 'pending'), + ('rejected', 'accept', 'accepted'), + ('rejected', 'edit_comment', 'rejected'), + ('rejected', 'submit', 'pending'), + ], + } + for workflow, transitions in valid_transitions.items(): + provider.reviews_workflow = workflow + provider.save() + for from_state, trigger, to_state in transitions: + preprint.reviews_state = from_state + preprint.is_published = False + preprint.date_published = None + preprint.date_last_transitioned = None + preprint.save() + payload = self.create_payload(preprint._id, trigger=trigger) + res = app.post_json_api(url, payload, auth=moderator.auth) + assert res.status_code == 201 + + action = preprint.actions.order_by('-date_created').first() + assert action.trigger == trigger + + preprint.refresh_from_db() + assert preprint.reviews_state == to_state + if preprint.in_public_reviews_state: + assert preprint.is_published + assert preprint.date_published == action.date_created + assert mock_ezid.called + mock_ezid.reset_mock() + else: + assert not preprint.is_published + assert preprint.date_published is None + assert not mock_ezid.called + + if trigger == 'edit_comment': + assert preprint.date_last_transitioned is None + else: + assert preprint.date_last_transitioned == action.date_created diff --git a/docker-compose.override.yml b/docker-compose.override.yml index f18f026836f..41683987194 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -75,6 +75,17 @@ services: ## - emberosf ## command: /bin/bash -c "cd /ember-osf && yarn link && cd /code && yarn link @centerforopenscience/ember-osf && yarn --pure-lockfile --ignore-engines && ./node_modules/bower/bin/bower install --allow-root --config.interactive=false && ./node_modules/ember-cli/bin/ember serve --host 0.0.0.0 --port 4300" +# reviews: +# volumes: +# - reviews-sync:/code:nocopy +# +## # Use this for ember-osf linked development (with docker-sync): +## - reviews_dist_vol:/code/dist +## - emberosf-sync:/ember-osf +## depends_on: +## - emberosf +## command: /bin/bash -c "cd /ember-osf && yarn link && cd /code && yarn link ember-osf && yarn --pure-lockfile && ./node_modules/bower/bin/bower install --allow-root --config.interactive=false && ./node_modules/ember-cli/bin/ember serve --host 0.0.0.0 --port 4400" + # # Use this for ember-osf linked development (with docker-sync): # emberosf: # build: ../ember-osf @@ -100,3 +111,6 @@ volumes: # registries-sync: # external: true + +# reviews-sync: +# external: true diff --git a/docker-compose.yml b/docker-compose.yml index 9ca2b9d0b45..72441082f0d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ volumes: external: false registries_dist_vol: external: false + reviews_dist_vol: + external: false mfr_requirements_vol: external: false mfr_requirements_local_bin_vol: @@ -243,6 +245,29 @@ services: - registries_dist_vol:/code/dist stdin_open: true + ############## + # Reviews # + ############## + + reviews: + image: quay.io/centerforopenscience/ember-osf-reviews:develop-development-local + command: /bin/bash -c "yarn --pure-lockfile --no-progress --no-emoji && ./node_modules/ember-cli/bin/ember serve --host 0.0.0.0 --port 4400" + restart: unless-stopped + depends_on: + - api + - web + environment: + - BACKEND=local + expose: + - 4400 + - 41954 + ports: + - 4400:4400 + - 41954:41954 + volumes: + - reviews_dist_vol:/code/dist + stdin_open: true + ####### # OSF # ####### @@ -399,4 +424,5 @@ services: - osf_node_modules_vol:/code/node_modules - preprints_dist_vol:/preprints - registries_dist_vol:/registries + - reviews_dist_vol:/reviews stdin_open: true diff --git a/docker-sync.yml b/docker-sync.yml index 14771014f10..e32a732463b 100644 --- a/docker-sync.yml +++ b/docker-sync.yml @@ -46,6 +46,14 @@ syncs: # sync_args: [ '-prefer newer' ] # sync_excludes_type: 'Name' # sync_excludes: ['.DS_Store', '*.map', '*.pyc', '*.tmp', '.git', '.idea', 'bower_components', 'node_modules', 'tmp', 'dist'] +# watch_excludes: ['.*\.DS_Store', '.*\.map', '.*\.pyc', '.*\.tmp', '.*/\.git', '.*/\.idea', '.*/bower_components', '.*/node_modules', '.*/tmp', '.*/dist'] + +# reviews-sync: +# src: '../ember-osf-reviews' +# sync_strategy: 'unison' +# sync_args: [ '-prefer newer' ] +# sync_excludes_type: 'Name' +# sync_excludes: ['.DS_Store', '*.map', '*.pyc', '*.tmp', '.git', '.idea', 'bower_components', 'node_modules', 'tmp', 'dist'] # watch_excludes: ['.*\.DS_Store', '.*\.map', '.*\.pyc', '.*\.tmp', '.*/\.git', '.*/\.idea', '.*/bower_components', '.*/node_modules', '.*/tmp', '.*/dist'] osf-sync: diff --git a/framework/auth/oauth_scopes.py b/framework/auth/oauth_scopes.py index 5b7f01786bd..393d67164bf 100644 --- a/framework/auth/oauth_scopes.py +++ b/framework/auth/oauth_scopes.py @@ -95,6 +95,11 @@ class CoreScopes(object): SEARCH = 'search_read' + ACTIONS_READ = 'review_logs_read' + ACTIONS_WRITE = 'review_logs_write' + + PROVIDERS_WRITE = 'providers_write' + NULL = 'null' # NOTE: Use with extreme caution. @@ -186,12 +191,16 @@ class ComposedScopes(object): NODE_ALL_READ = NODE_METADATA_READ + NODE_DATA_READ + NODE_ACCESS_READ NODE_ALL_WRITE = NODE_ALL_READ + NODE_METADATA_WRITE + NODE_DATA_WRITE + NODE_ACCESS_WRITE + # Reviews + REVIEWS_READ = (CoreScopes.ACTIONS_READ,) + REVIEWS_WRITE = (CoreScopes.ACTIONS_WRITE, CoreScopes.PROVIDERS_WRITE) + # Full permissions: all routes intended to be exposed to third party API users - FULL_READ = NODE_ALL_READ + USERS_READ + ORGANIZER_READ + GUIDS_READ + METASCHEMAS_READ + DRAFT_READ + (CoreScopes.INSTITUTION_READ, CoreScopes.SEARCH, ) - FULL_WRITE = FULL_READ + NODE_ALL_WRITE + USERS_WRITE + ORGANIZER_WRITE + DRAFT_WRITE + FULL_READ = NODE_ALL_READ + USERS_READ + ORGANIZER_READ + GUIDS_READ + METASCHEMAS_READ + DRAFT_READ + REVIEWS_READ + (CoreScopes.INSTITUTION_READ, CoreScopes.SEARCH, ) + FULL_WRITE = FULL_READ + NODE_ALL_WRITE + USERS_WRITE + ORGANIZER_WRITE + DRAFT_WRITE + REVIEWS_WRITE # Admin permissions- includes functionality not intended for third-party use - ADMIN_LEVEL = FULL_WRITE + APPLICATIONS_WRITE + TOKENS_WRITE + COMMENT_REPORTS_WRITE + USERS_CREATE +\ + ADMIN_LEVEL = FULL_WRITE + APPLICATIONS_WRITE + TOKENS_WRITE + COMMENT_REPORTS_WRITE + USERS_CREATE + REVIEWS_WRITE +\ (CoreScopes.USER_EMAIL_READ, CoreScopes.USER_ADDON_READ, CoreScopes.NODE_ADDON_READ, CoreScopes.NODE_ADDON_WRITE, ) # List of all publicly documented scopes, mapped to composed scopes defined above. diff --git a/osf/management/commands/create_fake_preprint_actions.py b/osf/management/commands/create_fake_preprint_actions.py new file mode 100644 index 00000000000..a517097e382 --- /dev/null +++ b/osf/management/commands/create_fake_preprint_actions.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import random +import logging +from faker import Faker + +from django.core.management.base import BaseCommand + +from reviews import workflow +from osf.models import Action, PreprintService, OSFUser + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Add fake Actions to every preprint that doesn't already have one""" + + def add_arguments(self, parser): + super(Command, self).add_arguments(parser) + parser.add_argument( + 'user', + type=str, + nargs='?', + default=None, + help='Guid for user to list as creator for all fake actions (default to arbitrary user)' + ) + parser.add_argument( + '--num-actions', + action='store', + type=int, + default=10, + help='Number of actions to create for each preprint which does not have one' + ) + + def handle(self, *args, **options): + user_guid = options.get('user') + num_actions = options.get('--num-actions') + + if user_guid is None: + user = OSFUser.objects.first() + else: + user = OSFUser.objects.get(guids___id=user_guid) + + fake = Faker() + triggers = [a.value for a in workflow.Triggers] + states = [s.value for s in workflow.States] + for preprint in PreprintService.objects.filter(actions__isnull=True): + for i in range(num_actions): + action = Action( + target=preprint, + creator=user, + trigger=random.choice(triggers), + from_state=random.choice(states), + to_state=random.choice(states), + comment=fake.text(), + ) + action.save() diff --git a/osf/management/commands/update_auth_groups.py b/osf/management/commands/update_auth_groups.py new file mode 100644 index 00000000000..30ea165915b --- /dev/null +++ b/osf/management/commands/update_auth_groups.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +import logging + +from django.core.management.base import BaseCommand +from django.db import transaction + +from reviews.models import ReviewProviderMixin +from reviews.permissions import GroupHelper + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Add/update reviews auth groups for all reviews providers""" + def add_arguments(self, parser): + super(Command, self).add_arguments(parser) + parser.add_argument( + '--dry', + action='store_true', + dest='dry_run', + help='Run, then roll back changes to db', + ) + + def handle(self, *args, **options): + dry = options.get('dry_run') + + # Start a transaction that will be rolled back if any exceptions are raised + with transaction.atomic(): + for cls in ReviewProviderMixin.__subclasses__(): + for provider in cls.objects.all(): + logger.info('Updating auth groups for review provider %s', provider) + GroupHelper(provider).update_provider_auth_groups() + if dry: + # When running in dry mode force the transaction to rollback + raise Exception('Abort Transaction - Dry Run') diff --git a/osf/migrations/0060_reviews.py b/osf/migrations/0060_reviews.py new file mode 100644 index 00000000000..b95512c3218 --- /dev/null +++ b/osf/migrations/0060_reviews.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-14 11:09 +from __future__ import unicode_literals + +from django.conf import settings +from django.core.management.sql import emit_post_migrate_signal +from django.db import migrations, models +import django.db.models.deletion +import osf.models.base +import osf.utils.fields +from reviews.permissions import GroupHelper + + +def create_provider_auth_groups(apps, schema_editor): + # this is to make sure that the permissions created in an earlier migration exist! + emit_post_migrate_signal(2, False, 'default') + PreprintProvider = apps.get_model('osf', 'PreprintProvider') + for provider in PreprintProvider.objects.all(): + GroupHelper(provider).update_provider_auth_groups() + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0059_merge_20170914_1100'), + ('guardian', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Action', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('_id', models.CharField(db_index=True, default=osf.models.base.generate_object_id, max_length=24, unique=True)), + ('trigger', models.CharField(choices=[('accept', 'Accept'), ('edit_comment', 'Edit_Comment'), ('reject', 'Reject'), ('submit', 'Submit')], max_length=31)), + ('from_state', models.CharField(choices=[('accepted', 'Accepted'), ('initial', 'Initial'), ('pending', 'Pending'), ('rejected', 'Rejected')], max_length=31)), + ('to_state', models.CharField(choices=[('accepted', 'Accepted'), ('initial', 'Initial'), ('pending', 'Pending'), ('rejected', 'Rejected')], max_length=31)), + ('comment', models.TextField(blank=True)), + ('is_deleted', models.BooleanField(default=False)), + ('date_created', osf.utils.fields.NonNaiveDateTimeField(auto_now_add=True)), + ('date_modified', osf.utils.fields.NonNaiveDateTimeField(auto_now=True)), + ('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterModelOptions( + name='preprintprovider', + options={'permissions': (('view_submissions', 'Can view all submissions to this provider'), ('add_moderator', 'Can add other users as moderators for this provider'), ('view_actions', 'Can view actions on submissions to this provider'), ('add_reviewer', 'Can add other users as reviewers for this provider'), ('review_assigned_submissions', 'Can submit reviews for submissions to this provider which have been assigned to this user'), ('assign_reviewer', 'Can assign reviewers to review specific submissions to this provider'), ('set_up_moderation', 'Can set up moderation for this provider'), ('view_assigned_submissions', 'Can view submissions to this provider which have been assigned to this user'), ('edit_reviews_settings', 'Can edit reviews settings for this provider'), ('accept_submissions', 'Can accept submissions to this provider'), ('reject_submissions', 'Can reject submissions to this provider'), ('edit_review_comments', 'Can edit comments on actions for this provider'), ('view_preprintprovider', 'Can view preprint provider details'))}, + ), + migrations.AddField( + model_name='preprintprovider', + name='reviews_comments_anonymous', + field=models.NullBooleanField(), + ), + migrations.AddField( + model_name='preprintprovider', + name='reviews_comments_private', + field=models.NullBooleanField(), + ), + migrations.AddField( + model_name='preprintprovider', + name='reviews_workflow', + field=models.CharField(blank=True, choices=[(None, 'None'), ('post-moderation', 'Post-Moderation'), ('pre-moderation', 'Pre-Moderation')], max_length=15, null=True), + ), + migrations.AddField( + model_name='preprintservice', + name='date_last_transitioned', + field=models.DateTimeField(blank=True, db_index=True, null=True), + ), + migrations.AddField( + model_name='preprintservice', + name='reviews_state', + field=models.CharField(choices=[('accepted', 'Accepted'), ('initial', 'Initial'), ('pending', 'Pending'), ('rejected', 'Rejected')], db_index=True, default='initial', max_length=15), + ), + migrations.AddField( + model_name='action', + name='target', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to='osf.PreprintService'), + ), + migrations.RunPython( + create_provider_auth_groups + ) + ] diff --git a/osf/migrations/0061_auto_20171002_1438.py b/osf/migrations/0061_auto_20171002_1438.py new file mode 100644 index 00000000000..d6ca5150aa5 --- /dev/null +++ b/osf/migrations/0061_auto_20171002_1438.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-02 19:38 +from __future__ import unicode_literals + +import logging + +from django.db import migrations + +from osf.models import OSFUser +from osf.models import NotificationSubscription +from website.notifications.utils import to_subscription_key + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +def add_reviews_notification_setting(*args, **kwargs): + active_users = OSFUser.objects.filter(date_confirmed__isnull=False).exclude(date_disabled__isnull=False).exclude(is_active=False).order_by('id') + total_active_users = active_users.count() + reviews_notification = 'global_reviews' + + logger.info('About to add a global_reviews setting for {} users.'.format(total_active_users)) + + total_created = 0 + for user in active_users.iterator(): + user_subscription_id = to_subscription_key(user._id, reviews_notification) + + subscription = NotificationSubscription.load(user_subscription_id) + if not subscription: + logger.info('No {} subscription found for user {}. Subscribing...'.format(reviews_notification, user._id)) + subscription = NotificationSubscription(_id=user_subscription_id, owner=user, event_name=reviews_notification) + subscription.save() # Need to save in order to access m2m fields + subscription.add_user_to_subscription(user, 'email_transactional') + else: + logger.info('User {} already has a {} subscription'.format(user._id, reviews_notification)) + total_created += 1 + + logger.info('Added subscriptions for {}/{} users'.format(total_created, total_active_users)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0060_reviews'), + ] + + operations = [ + migrations.RunPython(add_reviews_notification_setting), + ] diff --git a/osf/migrations/0062_auto_20171004_1506.py b/osf/migrations/0062_auto_20171004_1506.py new file mode 100644 index 00000000000..b204ea3dcb0 --- /dev/null +++ b/osf/migrations/0062_auto_20171004_1506.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-04 20:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0061_auto_20171002_1438'), + ] + + operations = [ + migrations.AlterField( + model_name='notificationdigest', + name='message', + field=models.CharField(max_length=10000), + ), + ] diff --git a/osf/migrations/0063_accept_preprints.py b/osf/migrations/0063_accept_preprints.py new file mode 100644 index 00000000000..6cc7a6ccea4 --- /dev/null +++ b/osf/migrations/0063_accept_preprints.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-27 19:13 +from __future__ import unicode_literals + +from django.db import migrations +from django.db.models import F + +from reviews.workflow import States + + +# When a preprint provider is set up with a reviews/moderation workflow, +# make sure all existing preprints will be in a public state. +def accept_all_published_preprints(apps, schema_editor): + Preprint = apps.get_model('osf', 'PreprintService') + published_preprints = Preprint.objects.filter(is_published=True, reviews_state=States.INITIAL.value) + published_preprints.update(reviews_state=States.ACCEPTED.value, date_last_transitioned=F('date_published')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0062_auto_20171004_1506'), + ] + + operations = [ + migrations.RunPython( + accept_all_published_preprints + ), + ] diff --git a/osf/models/__init__.py b/osf/models/__init__.py index d5854d6f8ef..828aee2ff9f 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -35,3 +35,4 @@ from osf.models.admin_log_entry import AdminLogEntry # noqa from osf.models.maintenance_state import MaintenanceState # noqa from osf.models.quickfiles import QuickFilesNode # noqa +from osf.models.action import Action # noqa diff --git a/osf/models/action.py b/osf/models/action.py new file mode 100644 index 00000000000..3012b3f7542 --- /dev/null +++ b/osf/models/action.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models + +from include import IncludeManager + +from reviews.workflow import Triggers +from reviews.workflow import States + +from osf.models.base import BaseModel, ObjectIDMixin +from osf.utils.fields import NonNaiveDateTimeField + + +class Action(ObjectIDMixin, BaseModel): + + objects = IncludeManager() + + target = models.ForeignKey('PreprintService', related_name='actions') + creator = models.ForeignKey('OSFUser', related_name='+') + + trigger = models.CharField(max_length=31, choices=Triggers.choices()) + from_state = models.CharField(max_length=31, choices=States.choices()) + to_state = models.CharField(max_length=31, choices=States.choices()) + + comment = models.TextField(blank=True) + + is_deleted = models.BooleanField(default=False) + date_created = NonNaiveDateTimeField(auto_now_add=True) + date_modified = NonNaiveDateTimeField(auto_now=True) diff --git a/osf/models/notifications.py b/osf/models/notifications.py index c59cedefdb6..abf78cc4399 100644 --- a/osf/models/notifications.py +++ b/osf/models/notifications.py @@ -86,6 +86,6 @@ class NotificationDigest(ObjectIDMixin, BaseModel): timestamp = NonNaiveDateTimeField() send_type = models.CharField(max_length=50, db_index=True, validators=[validate_subscription_type, ]) event = models.CharField(max_length=50) - message = models.CharField(max_length=2048) + message = models.CharField(max_length=10000) # TODO: Could this be a m2m with or without an order field? node_lineage = ArrayField(models.CharField(max_length=5)) diff --git a/osf/models/preprint_provider.py b/osf/models/preprint_provider.py index c892ed67cb5..be2e125144a 100644 --- a/osf/models/preprint_provider.py +++ b/osf/models/preprint_provider.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- -from django.db import models from django.contrib.postgres import fields +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver from osf.models.base import BaseModel, ObjectIDMixin from osf.models.licenses import NodeLicense @@ -8,15 +10,20 @@ from osf.utils.datetime_aware_jsonfield import DateTimeAwareJSONField from osf.utils.fields import EncryptedTextField +from reviews import permissions as reviews_permissions +from reviews.models import ReviewProviderMixin + from website.util import api_v2_url -class PreprintProvider(ObjectIDMixin, BaseModel): +class PreprintProvider(ObjectIDMixin, ReviewProviderMixin, BaseModel): PUSH_SHARE_TYPE_CHOICES = (('Preprint', 'Preprint'), ('Thesis', 'Thesis'),) PUSH_SHARE_TYPE_HELP = 'This SHARE type will be used when pushing publications to SHARE' + REVIEWABLE_RELATION_NAME = 'preprint_services' + name = models.CharField(null=False, max_length=128) # max length on prod: 22 description = models.TextField(default='', blank=True) domain = models.URLField(blank=True, default='', max_length=200) @@ -53,8 +60,8 @@ class PreprintProvider(ObjectIDMixin, BaseModel): default_license = models.ForeignKey(NodeLicense, blank=True, related_name='default_license', null=True) class Meta: - # custom permissions for use in the OSF Admin App - permissions = ( + permissions = tuple(reviews_permissions.PERMISSIONS.items()) + ( + # custom permissions for use in the OSF Admin App ('view_preprintprovider', 'Can view preprint provider details'), ) @@ -111,3 +118,9 @@ def rules_to_subjects(rules): for sub in rule[0]: q.append(models.Q(_id=sub)) return Subject.objects.filter(reduce(lambda x, y: x | y, q)) if len(q) > 1 else (Subject.objects.filter(q[0]) if len(q) else Subject.objects.all()) + + +@receiver(post_save, sender=PreprintProvider) +def create_provider_auth_groups(sender, instance, created, **kwargs): + if created: + reviews_permissions.GroupHelper(instance).update_provider_auth_groups() diff --git a/osf/models/preprint_service.py b/osf/models/preprint_service.py index 7d969fd533e..bc54e2e43f4 100644 --- a/osf/models/preprint_service.py +++ b/osf/models/preprint_service.py @@ -18,10 +18,13 @@ from website.util.permissions import ADMIN from website import settings +from reviews.models.mixins import ReviewableMixin +from reviews.workflow import States + from osf.models.base import BaseModel, GuidMixin from osf.models.identifiers import IdentifierMixin, Identifier -class PreprintService(DirtyFieldsMixin, GuidMixin, IdentifierMixin, BaseModel): +class PreprintService(DirtyFieldsMixin, GuidMixin, IdentifierMixin, ReviewableMixin, BaseModel): date_created = NonNaiveDateTimeField(auto_now_add=True) date_modified = NonNaiveDateTimeField(auto_now=True) provider = models.ForeignKey('osf.PreprintProvider', @@ -49,6 +52,10 @@ class Meta: def __unicode__(self): return '{} preprint (guid={}) of {}'.format('published' if self.is_published else 'unpublished', self._id, self.node.__unicode__()) + @property + def verified_publishable(self): + return self.is_published and self.node.is_preprint and not self.node.is_deleted + @property def primary_file(self): if not self.node: @@ -176,6 +183,10 @@ def set_published(self, published, auth, save=False): self.date_published = timezone.now() self.node._has_abandoned_preprint = False + # In case this provider is ever set up to use a reviews workflow, put this preprint in a sensible state + self.reviews_state = States.ACCEPTED.value + self.date_last_transitioned = self.date_published + self.node.add_log( action=NodeLog.PREPRINT_INITIATED, params={ diff --git a/osf_tests/factories.py b/osf_tests/factories.py index 10c08641b70..64bc3768f53 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -15,6 +15,7 @@ from django.db.utils import IntegrityError from faker import Factory +from reviews import workflow from website import settings from website.notifications.constants import NOTIFICATION_TYPES from website.util import permissions @@ -801,3 +802,18 @@ def _create(cls, target_class, *args, **kwargs): class ArchiveJobFactory(DjangoModelFactory): class Meta: model = models.ArchiveJob + + +class ActionFactory(DjangoModelFactory): + class Meta: + model = models.Action + + trigger = FuzzyChoice(choices=workflow.Triggers.values()) + comment = factory.Faker('text') + from_state = FuzzyChoice(choices=workflow.States.values()) + to_state = FuzzyChoice(choices=workflow.States.values()) + + target = factory.SubFactory(PreprintFactory) + creator = factory.SubFactory(AuthUserFactory) + + is_deleted = False diff --git a/osf_tests/test_reviewable.py b/osf_tests/test_reviewable.py new file mode 100644 index 00000000000..f493dbcf20c --- /dev/null +++ b/osf_tests/test_reviewable.py @@ -0,0 +1,31 @@ +import pytest + +from osf.models import PreprintService +from osf_tests.factories import PreprintFactory, AuthUserFactory +from reviews.workflow import States + +@pytest.mark.django_db +class TestReviewable: + + def test_state_changes(self): + user = AuthUserFactory() + preprint = PreprintFactory(provider__reviews_workflow='pre-moderation', is_published=False) + assert preprint.reviews_state == States.INITIAL.value + + preprint.reviews_submit(user) + assert preprint.reviews_state == States.PENDING.value + + preprint.reviews_accept(user, 'comment') + assert preprint.reviews_state == States.ACCEPTED.value + from_db = PreprintService.objects.get(id=preprint.id) + assert from_db.reviews_state == States.ACCEPTED.value + + preprint.reviews_reject(user, 'comment') + assert preprint.reviews_state == States.REJECTED.value + from_db.refresh_from_db() + assert from_db.reviews_state == States.REJECTED.value + + preprint.reviews_accept(user, 'comment') + assert preprint.reviews_state == States.ACCEPTED.value + from_db.refresh_from_db() + assert from_db.reviews_state == States.ACCEPTED.value diff --git a/requirements.txt b/requirements.txt index 29c59c9fc50..6486ff8bae4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -70,6 +70,7 @@ pyjwt==1.4.0 pycparser==2.13 pyjwe==1.0.0 jsonschema==2.5.1 +django-guardian==1.4.9 # Admin requirements django-webpack-loader==0.2.3 @@ -92,3 +93,7 @@ sqlparse==0.2.2 psycogreen==1.0 django-bulk-update==1.1.10 ciso8601==1.0.3 + +# Reviews requirements +transitions==0.5.3 +enum34==1.1.6 diff --git a/reviews/__init__.py b/reviews/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/reviews/apps.py b/reviews/apps.py new file mode 100644 index 00000000000..09ab21d5a6e --- /dev/null +++ b/reviews/apps.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class ReviewsConfig(AppConfig): + name = 'reviews' diff --git a/reviews/exceptions.py b/reviews/exceptions.py new file mode 100644 index 00000000000..d155f961743 --- /dev/null +++ b/reviews/exceptions.py @@ -0,0 +1,6 @@ +class InvalidTriggerError(Exception): + def __init__(self, trigger, state, valid_triggers): + self.trigger = trigger + self.state = state + self.valid_triggers = valid_triggers + self.message = 'Cannot trigger "{}" from state "{}". Valid triggers: {}'.format(trigger, state, valid_triggers) diff --git a/reviews/models/__init__.py b/reviews/models/__init__.py new file mode 100644 index 00000000000..8fd9a1ce1c0 --- /dev/null +++ b/reviews/models/__init__.py @@ -0,0 +1,2 @@ +from .mixins import ReviewProviderMixin # noqa +from .mixins import ReviewableMixin # noqa diff --git a/reviews/models/mixins.py b/reviews/models/mixins.py new file mode 100644 index 00000000000..4b283be03c1 --- /dev/null +++ b/reviews/models/mixins.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from include import IncludeQuerySet +from transitions import Machine +from framework.auth import Auth +from framework.postcommit_tasks.handlers import enqueue_postcommit_task + +from django.db import models +from django.db import transaction +from django.utils import timezone + +from osf.models.action import Action +from osf.models import NodeLog +from reviews import workflow +from reviews.exceptions import InvalidTriggerError +from website.preprints.tasks import get_and_set_preprint_identifiers + +from website import settings + +from osf.models import NotificationDigest +from osf.models import OSFUser + +from website.mails import mails +from website.notifications.emails import get_user_subscriptions, get_node_lineage +from website.notifications import utils +from website.reviews import signals as reviews_signals + + +class ReviewProviderMixin(models.Model): + """A reviewed/moderated collection of objects. + """ + + REVIEWABLE_RELATION_NAME = None + + class Meta: + abstract = True + + reviews_workflow = models.CharField(null=True, blank=True, max_length=15, choices=workflow.Workflows.choices()) + reviews_comments_private = models.NullBooleanField() + reviews_comments_anonymous = models.NullBooleanField() + + @property + def is_reviewed(self): + return self.reviews_workflow is not None + + def get_reviewable_state_counts(self): + assert self.REVIEWABLE_RELATION_NAME, 'REVIEWABLE_RELATION_NAME must be set to compute state counts' + qs = getattr(self, self.REVIEWABLE_RELATION_NAME) + if isinstance(qs, IncludeQuerySet): + qs = qs.include(None) + qs = qs.filter(node__isnull=False, node__is_deleted=False, node__is_public=True).values('reviews_state').annotate(count=models.Count('*')) + counts = {state.value: 0 for state in workflow.States} + counts.update({row['reviews_state']: row['count'] for row in qs if row['reviews_state'] in counts}) + return counts + + def add_admin(self, user): + from reviews.permissions import GroupHelper + return GroupHelper(self).get_group('admin').user_set.add(user) + + def add_moderator(self, user): + from reviews.permissions import GroupHelper + return GroupHelper(self).get_group('moderator').user_set.add(user) + + +class ReviewableMixin(models.Model): + """Something that may be included in a reviewed collection and is subject to a reviews workflow. + """ + + class Meta: + abstract = True + + # NOTE: reviews_state should rarely/never be modified directly -- use the state transition methods below + reviews_state = models.CharField(max_length=15, db_index=True, choices=workflow.States.choices(), default=workflow.States.INITIAL.value) + + date_last_transitioned = models.DateTimeField(null=True, blank=True, db_index=True) + + @property + def in_public_reviews_state(self): + public_states = workflow.PUBLIC_STATES.get(self.provider.reviews_workflow) + if not public_states: + return False + return self.reviews_state in public_states + + def reviews_submit(self, user): + """Run the 'submit' state transition and create a corresponding Action. + + Params: + user: The user triggering this transition. + """ + return self.__run_transition(workflow.Triggers.SUBMIT.value, user=user) + + def reviews_accept(self, user, comment): + """Run the 'accept' state transition and create a corresponding Action. + + Params: + user: The user triggering this transition. + comment: Text describing why. + """ + return self.__run_transition(workflow.Triggers.ACCEPT.value, user=user, comment=comment) + + def reviews_reject(self, user, comment): + """Run the 'reject' state transition and create a corresponding Action. + + Params: + user: The user triggering this transition. + comment: Text describing why. + """ + return self.__run_transition(workflow.Triggers.REJECT.value, user=user, comment=comment) + + def reviews_edit_comment(self, user, comment): + """Run the 'edit_comment' state transition and create a corresponding Action. + + Params: + user: The user triggering this transition. + comment: New comment text. + """ + return self.__run_transition(workflow.Triggers.EDIT_COMMENT.value, user=user, comment=comment) + + def __run_transition(self, trigger, **kwargs): + reviews_machine = ReviewsMachine(self, 'reviews_state') + trigger_fn = getattr(reviews_machine, trigger) + with transaction.atomic(): + result = trigger_fn(**kwargs) + action = reviews_machine.action + if not result or action is None: + valid_triggers = reviews_machine.get_triggers(self.reviews_state) + raise InvalidTriggerError(trigger, self.reviews_state, valid_triggers) + return action + + +class ReviewsMachine(Machine): + + action = None + from_state = None + + def __init__(self, reviewable, state_attr): + self.reviewable = reviewable + self.__state_attr = state_attr + + super(ReviewsMachine, self).__init__( + states=[s.value for s in workflow.States], + transitions=workflow.TRANSITIONS, + initial=self.state, + send_event=True, + prepare_event=['initialize_machine'], + ignore_invalid_triggers=True, + ) + + @property + def state(self): + return getattr(self.reviewable, self.__state_attr) + + @state.setter + def state(self, value): + setattr(self.reviewable, self.__state_attr, value) + + def initialize_machine(self, ev): + self.action = None + self.from_state = ev.state + + def save_action(self, ev): + user = ev.kwargs.get('user') + self.action = Action.objects.create( + target=self.reviewable, + creator=user, + trigger=ev.event.name, + from_state=self.from_state.name, + to_state=ev.state.name, + comment=ev.kwargs.get('comment', ''), + ) + + def update_last_transitioned(self, ev): + now = self.action.date_created if self.action is not None else timezone.now() + self.reviewable.date_last_transitioned = now + + def save_changes(self, ev): + now = self.action.date_created if self.action is not None else timezone.now() + should_publish = self.reviewable.in_public_reviews_state + if should_publish and not self.reviewable.is_published: + if not (self.reviewable.node.preprint_file and self.reviewable.node.preprint_file.node == self.reviewable.node): + raise ValueError('Preprint node is not a valid preprint; cannot publish.') + if not self.reviewable.provider: + raise ValueError('Preprint provider not specified; cannot publish.') + if not self.reviewable.subjects.exists(): + raise ValueError('Preprint must have at least one subject to be published.') + self.reviewable.date_published = now + self.reviewable.node._has_abandoned_preprint = False + self.reviewable.is_published = True + enqueue_postcommit_task(get_and_set_preprint_identifiers, (), {'preprint': self.reviewable}, celery=True) + elif not should_publish and self.reviewable.is_published: + self.reviewable.is_published = False + self.reviewable.save() + + def resubmission_allowed(self, ev): + return self.reviewable.provider.reviews_workflow == workflow.Workflows.PRE_MODERATION.value + + def notify_submit(self, ev): + context = self.get_context() + context['referrer'] = ev.kwargs.get('user') + context['template'] = 'reviews_submission_confirmation' + user = ev.kwargs.get('user') + auth = Auth(user) + self.reviewable.node.add_log( + action=NodeLog.PREPRINT_INITIATED, + params={ + 'preprint': self.reviewable._id + }, + auth=auth, + save=False, + ) + reviews_signals.reviews_email_submit.send(context=context) + + def notify_accept_reject(self, ev): + context = self.get_context() + context['template'] = 'reviews_submission_status' + context['notify_comment'] = not self.reviewable.provider.reviews_comments_private and self.action.comment + context['is_rejected'] = self.action.to_state == workflow.States.REJECTED.value + reviews_signals.reviews_email.send(context=context) + + def notify_edit_comment(self, ev): + context = self.get_context() + context['template'] = 'reviews_update_comment' + if not self.reviewable.provider.reviews_comments_private and self.action.comment: + reviews_signals.reviews_email.send(context=context) + + def get_context(self): + return { + 'domain': settings.DOMAIN, + 'email_recipients': [contributor._id for contributor in self.reviewable.node.contributors], + 'reviewable': self.reviewable, + 'workflow': self.reviewable.provider.reviews_workflow, + 'provider_url': self.reviewable.provider.domain if self.reviewable.provider.domain is not None else settings.DOMAIN + 'preprints/' + self.reviewable.provider._id, + 'provider_contact_email': self.reviewable.provider.email_contact if self.reviewable.provider.email_contact is not None else 'contact@osf.io', + 'provider_support_email': self.reviewable.provider.email_support if self.reviewable.provider.email_support is not None else 'support@osf.io', + } + +# Handle email notifications including: update comment, accept, and reject of submission. +@reviews_signals.reviews_email.connect +def reviews_notification(self, context): + timestamp = timezone.now() + event_type = utils.find_subscription_type('global_reviews') + template = context['template'] + '.html.mako' + for user_id in context['email_recipients']: + user = OSFUser.load(user_id) + subscriptions = get_user_subscriptions(user, event_type) + for notification_type in subscriptions: + check_user_subscribe = subscriptions[notification_type] and user_id in subscriptions[notification_type] and notification_type != 'none' # check if user is subscribed to this type of notifications + if check_user_subscribe: + node_lineage_ids = get_node_lineage(context.get('reviewable').node) if context.get('reviewable').node else [] + context['user'] = user + context['no_future_emails'] = notification_type == 'none' + send_type = notification_type if notification_type != 'none' else 'email_transactional' + message = mails.render_message(template, **context) + digest = NotificationDigest( + user=user, + timestamp=timestamp, + send_type=send_type, + event='global_reviews', + message=message, + node_lineage=node_lineage_ids + ) + digest.save() + +# Handle email notifications for a new submission. +@reviews_signals.reviews_email_submit.connect +def reviews_submit_notification(self, context): + template = context['template'] + for user_id in context['email_recipients']: + user = OSFUser.load(user_id) + context['is_creator'] = user == context.get('reviewable').node.creator + email = mails.Mail(template, subject='Confirmation of your submission to {provider}'.format(provider=context.get('reviewable').provider.name)) + mails.send_mail(user.username, email, user=user, **context) diff --git a/reviews/permissions.py b/reviews/permissions.py new file mode 100644 index 00000000000..f9c5fd61a2d --- /dev/null +++ b/reviews/permissions.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import logging + +from guardian.shortcuts import assign_perm +from guardian.shortcuts import get_perms +from guardian.shortcuts import remove_perm +from rest_framework import permissions as drf_permissions + +from django.contrib.auth.models import Group + +from api.base.utils import get_user_auth +from osf.models.action import Action +from website.util import permissions as osf_permissions + +from reviews.models import ReviewableMixin, ReviewProviderMixin +from reviews.workflow import Triggers + + +logger = logging.getLogger(__name__) + + +# Object-level permissions for providers. +# Prefer assigning object permissions to groups and adding users to groups, over assigning permissions to users. +PERMISSIONS = { + 'set_up_moderation': 'Can set up moderation for this provider', + 'view_submissions': 'Can view all submissions to this provider', + 'accept_submissions': 'Can accept submissions to this provider', + 'reject_submissions': 'Can reject submissions to this provider', + 'edit_review_comments': 'Can edit comments on actions for this provider', + 'view_actions': 'Can view actions on submissions to this provider', + + # TODO Implement adding/removing moderators via API. Currently must be done in OSF Admin + 'add_moderator': 'Can add other users as moderators for this provider', + + # TODO Implement editing settings, assign this to admin groups + 'edit_reviews_settings': 'Can edit reviews settings for this provider', + + # TODO Implement reviewers, review workflows, use these permissions + 'add_reviewer': 'Can add other users as reviewers for this provider', + 'assign_reviewer': 'Can assign reviewers to review specific submissions to this provider', + 'view_assigned_submissions': 'Can view submissions to this provider which have been assigned to this user', + 'review_assigned_submissions': 'Can submit reviews for submissions to this provider which have been assigned to this user', +} + +# Groups created for each provider. +GROUP_FORMAT = 'reviews_{provider_id}_{group}' +GROUPS = { + 'admin': ('set_up_moderation', 'add_moderator', 'view_submissions', 'accept_submissions', 'reject_submissions', 'edit_review_comments', 'view_actions'), + 'moderator': ('view_submissions', 'accept_submissions', 'reject_submissions', 'edit_review_comments', 'view_actions'), + # 'manager': (), # TODO "Senior editor"-like role, can add/remove/assign moderators and reviewers + # 'reviewer': (), # TODO Implement reviewers +} + + +# Required permission to perform each action. `None` means no permissions required. +TRIGGER_PERMISSIONS = { + Triggers.SUBMIT.value: None, + Triggers.ACCEPT.value: 'accept_submissions', + Triggers.REJECT.value: 'reject_submissions', + Triggers.EDIT_COMMENT.value: 'edit_review_comments', +} + + +class GroupHelper(object): + """Helper for managing permission groups for a given provider. + """ + + def __init__(self, provider): + self.provider = provider + + def format_group(self, name): + if name not in GROUPS: + raise ValueError('Invalid reviews group: "{}"'.format(name)) + return GROUP_FORMAT.format(provider_id=self.provider._id, group=name) + + def get_group(self, name): + return Group.objects.get(name=self.format_group(name)) + + def update_provider_auth_groups(self): + for group_name, group_permissions in GROUPS.items(): + group, created = Group.objects.get_or_create(name=self.format_group(group_name)) + to_remove = set(get_perms(group, self.provider)).difference(group_permissions) + for p in to_remove: + remove_perm(p, group, self.provider) + for p in group_permissions: + assign_perm(p, group, self.provider) + + def get_permissions(self, user): + return [p for p in get_perms(user, self.provider) if p in PERMISSIONS] + + +class ActionPermission(drf_permissions.BasePermission): + def has_object_permission(self, request, view, obj): + auth = get_user_auth(request) + if auth.user is None: + return False + + target = None + provider = None + if isinstance(obj, Action): + target = obj.target + provider = target.provider + elif isinstance(obj, ReviewableMixin): + target = obj + provider = target.provider + elif isinstance(obj, ReviewProviderMixin): + provider = obj + else: + raise ValueError('Not a reviews-related model: {}'.format(obj)) + + serializer = view.get_serializer() + + if request.method in drf_permissions.SAFE_METHODS: + # Moderators and node contributors can view actions + is_node_contributor = target is not None and target.node.has_permission(auth.user, osf_permissions.READ) + return is_node_contributor or auth.user.has_perm('view_actions', provider) + else: + # Moderators and node admins can trigger state changes. + is_node_admin = target is not None and target.node.has_permission(auth.user, osf_permissions.ADMIN) + if not (is_node_admin or auth.user.has_perm('view_submissions', provider)): + return False + + # User can trigger state changes on this reviewable, but can they use this trigger in particular? + serializer = view.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + trigger = serializer.validated_data.get('trigger') + permission = TRIGGER_PERMISSIONS[trigger] + return permission is None or request.user.has_perm(permission, target.provider) + + +class CanSetUpProvider(drf_permissions.BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in drf_permissions.SAFE_METHODS: + return True + auth = get_user_auth(request) + return auth.user.has_perm('set_up_moderation', obj) diff --git a/reviews/test/.gitkeep b/reviews/test/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/reviews/workflow.py b/reviews/workflow.py new file mode 100644 index 00000000000..5e2549d7162 --- /dev/null +++ b/reviews/workflow.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from enum import Enum +from enum import unique + + +class ChoiceEnum(Enum): + @classmethod + def choices(cls): + return tuple((v, unicode(v).title()) for v in cls.values()) + + @classmethod + def values(cls): + return tuple(c.value for c in cls) + + +@unique +class Workflows(ChoiceEnum): + NONE = None + PRE_MODERATION = 'pre-moderation' + POST_MODERATION = 'post-moderation' + + +@unique +class States(ChoiceEnum): + INITIAL = 'initial' + PENDING = 'pending' + ACCEPTED = 'accepted' + REJECTED = 'rejected' + + +@unique +class Triggers(ChoiceEnum): + SUBMIT = 'submit' + ACCEPT = 'accept' + REJECT = 'reject' + EDIT_COMMENT = 'edit_comment' + + +PUBLIC_STATES = { + Workflows.NONE.value: ( + States.INITIAL.value, + States.PENDING.value, + States.ACCEPTED.value, + States.REJECTED.value, + ), + Workflows.PRE_MODERATION.value: ( + States.ACCEPTED.value, + ), + Workflows.POST_MODERATION.value: ( + States.PENDING.value, + States.ACCEPTED.value, + ) +} + + +TRANSITIONS = [ + { + 'trigger': Triggers.SUBMIT.value, + 'source': [States.INITIAL.value], + 'dest': States.PENDING.value, + 'after': ['save_action', 'update_last_transitioned', 'save_changes', 'notify_submit'], + }, + { + 'trigger': Triggers.SUBMIT.value, + 'source': [States.PENDING.value, States.REJECTED.value], + 'conditions': 'resubmission_allowed', + 'dest': States.PENDING.value, + 'after': ['save_action', 'update_last_transitioned', 'save_changes', 'notify_submit'], + }, + { + 'trigger': Triggers.ACCEPT.value, + 'source': [States.PENDING.value, States.REJECTED.value], + 'dest': States.ACCEPTED.value, + 'after': ['save_action', 'update_last_transitioned', 'save_changes', 'notify_accept_reject'], + }, + { + 'trigger': Triggers.REJECT.value, + 'source': [States.PENDING.value, States.ACCEPTED.value], + 'dest': States.REJECTED.value, + 'after': ['save_action', 'update_last_transitioned', 'save_changes', 'notify_accept_reject'], + }, + { + 'trigger': Triggers.EDIT_COMMENT.value, + 'source': [States.PENDING.value, States.REJECTED.value, States.ACCEPTED.value], + 'dest': '=', + 'after': ['save_action', 'save_changes', 'notify_edit_comment'], + }, +] diff --git a/tasks/__init__.py b/tasks/__init__.py index bf221bc6538..5c2b004b59d 100755 --- a/tasks/__init__.py +++ b/tasks/__init__.py @@ -441,6 +441,7 @@ def test_module(ctx, module=None, numprocesses=None, params=None): 'api_tests/comments', 'api_tests/files', 'api_tests/guids', + 'api_tests/reviews', 'api_tests/search', 'api_tests/taxonomies', 'api_tests/test', diff --git a/tests/test_notifications.py b/tests/test_notifications.py index b51b16dae89..e9a4b0b3739 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -23,6 +23,8 @@ from tests.base import capture_signals from tests.base import OsfTestCase, NotificationTestCase +from reviews.models import mixins + class TestNotificationsModels(OsfTestCase): @@ -972,7 +974,16 @@ def test_format_user_subscriptions(self): }, 'kind': 'event', 'children': [] - }, + }, { + 'event': { + 'title': 'global_reviews', + 'description': constants.USER_SUBSCRIPTIONS_AVAILABLE['global_reviews'], + 'notificationType': 'email_transactional', + 'parent_notification_type': None + }, + 'kind': 'event', + 'children': [] + } ] assert_items_equal(data, expected) @@ -1790,3 +1801,54 @@ def test_remove_sent_digest_notifications(self): remove_notifications(email_notification_ids=[digest_id]) with assert_raises(NotificationDigest.DoesNotExist): NotificationDigest.objects.get(_id=digest_id) + +class TestNotificationsReviews(OsfTestCase): + def setUp(self): + super(TestNotificationsReviews, self).setUp() + self.provider = factories.PreprintProviderFactory(_id='engrxiv') + self.preprint = factories.PreprintFactory(provider=self.provider) + self.user = factories.UserFactory() + self.context_info = { + 'email_recipients': [self.user._id], + 'template': 'test', + 'domain': 'osf.io', + 'referrer': self.user, + 'reviewable': self.preprint, + 'workflow': 'pre-moderation', + 'provider_contact_email': 'contact@osf.io', + 'provider_support_email': 'support@osf.io', + } + factories.NotificationSubscriptionFactory( + _id=self.user._id + '_' + 'global_comments', + user=self.user, + event_name='global_comments' + ).add_user_to_subscription(self.user, 'email_transactional') + + factories.NotificationSubscriptionFactory( + _id=self.user._id + '_' + 'global_file_updated', + user=self.user, + event_name='global_file_updated' + ).add_user_to_subscription(self.user, 'email_transactional') + + factories.NotificationSubscriptionFactory( + _id=self.user._id + '_' + 'global_reviews', + user=self.user, + event_name='global_reviews' + ).add_user_to_subscription(self.user, 'email_transactional') + + def test_reviews_base_notification(self): + contributor_subscriptions = list(utils.get_all_user_subscriptions(self.user)) + event_types = [sub.event_name for sub in contributor_subscriptions] + assert_in('global_reviews', event_types) + + @mock.patch('website.mails.mails.send_mail') + def test_reviews_submit_notification(self, mock_send_email): + mixins.reviews_submit_notification(self, context=self.context_info) + assert_true(mock_send_email.called) + + @mock.patch('website.mails.mails.render_message') + def test_reviews_notification(self, mock_render): + mixins.reviews_notification(self, context=self.context_info) + assert_true(mock_render.called) + template = self.context_info['template'] + '.html.mako' + mock_render.assert_called_with(template, **self.context_info) diff --git a/website/notifications/constants.py b/website/notifications/constants.py index 1ebf3b9514d..c34d9e50c3b 100644 --- a/website/notifications/constants.py +++ b/website/notifications/constants.py @@ -10,7 +10,8 @@ 'global_comment_replies': 'Replies to your comments', 'global_comments': 'Comments added', 'global_file_updated': 'Files updated', - 'global_mentions': 'Mentions added' + 'global_mentions': 'Mentions added', + 'global_reviews': 'Preprints submission updated' } # Note: the python value None mean inherit from parent diff --git a/website/preprints/tasks.py b/website/preprints/tasks.py index 3546d05c5ff..1e24a71655e 100644 --- a/website/preprints/tasks.py +++ b/website/preprints/tasks.py @@ -26,7 +26,7 @@ def on_preprint_updated(preprint_id, update_share=True, share_type=None, old_sub if old_subjects is None: old_subjects = [] if preprint.node: - status = 'public' if preprint.node.is_public else 'unavailable' + status = 'public' if preprint.verified_publishable else 'unavailable' try: update_ezid_metadata_on_change(preprint, status=status) except HTTPError as err: @@ -113,11 +113,8 @@ def format_preprint(preprint, share_type, old_subjects=None): 'title': preprint.node.title, 'description': preprint.node.description or '', 'is_deleted': ( - not preprint.is_published or - not preprint.node.is_public or - preprint.node.is_preprint_orphan or - preprint.node.tags.filter(name='qatest').exists() or - preprint.node.is_deleted + not preprint.verified_publishable or + preprint.node.tags.filter(name='qatest').exists() ), # Note: Changing any preprint attribute that is pulled from the node, like title, will NOT bump # the preprint's date modified but will bump the node's date_modified. @@ -183,6 +180,8 @@ def format_preprint(preprint, share_type, old_subjects=None): @celery_app.task(ignore_results=True) def get_and_set_preprint_identifiers(preprint): ezid_response = request_identifiers_from_ezid(preprint) + if ezid_response is None: + return id_dict = parse_identifiers(ezid_response) preprint.set_identifier_values(doi=id_dict['doi'], ark=id_dict['ark']) diff --git a/website/reviews/__init__.py b/website/reviews/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/website/reviews/signals.py b/website/reviews/signals.py new file mode 100644 index 00000000000..aadc096ec9f --- /dev/null +++ b/website/reviews/signals.py @@ -0,0 +1,6 @@ +import blinker + + +signals = blinker.Namespace() +reviews_email = signals.signal('reviews_email') +reviews_email_submit = signals.signal('reviews_email_submit') diff --git a/website/reviews/views.py b/website/reviews/views.py new file mode 100644 index 00000000000..3e547c668af --- /dev/null +++ b/website/reviews/views.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +def reviews_landing_page(**kwargs): + return {} diff --git a/website/routes.py b/website/routes.py index aa867cec304..c433defafa1 100644 --- a/website/routes.py +++ b/website/routes.py @@ -44,6 +44,7 @@ from website.conferences import views as conference_views from website.preprints import views as preprint_views from website.registries import views as registries_views +from website.reviews import views as reviews_views from website.institutions import views as institution_views from website.notifications import views as notification_views from website.closed_challenges import views as closed_challenges_views @@ -420,6 +421,13 @@ def make_url_map(app): OsfWebRenderer('public/pages/registries_landing.mako', trust=False), ), + Rule( + '/reviews/', + 'get', + reviews_views.reviews_landing_page, + OsfWebRenderer('public/pages/reviews_landing.mako', trust=False), + ), + Rule( '/preprint/', 'get', diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 849ecc639ac..948aa2f20e0 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -1824,6 +1824,7 @@ def parent_dir(path): OrderedDict([('loc', 'prereg'), ('changefreq', 'yearly'), ('priority', '0.5')]), OrderedDict([('loc', 'meetings'), ('changefreq', 'yearly'), ('priority', '0.5')]), OrderedDict([('loc', 'registries'), ('changefreq', 'yearly'), ('priority', '0.5')]), + OrderedDict([('loc', 'reviews'), ('changefreq', 'yearly'), ('priority', '0.5')]), OrderedDict([('loc', 'explore/activity'), ('changefreq', 'weekly'), ('priority', '0.5')]), OrderedDict([('loc', 'support'), ('changefreq', 'yearly'), ('priority', '0.5')]), OrderedDict([('loc', 'faq'), ('changefreq', 'yearly'), ('priority', '0.5')]), @@ -1833,6 +1834,7 @@ def parent_dir(path): SITEMAP_USER_CONFIG = OrderedDict([('loc', ''), ('changefreq', 'yearly'), ('priority', '0.5')]) SITEMAP_NODE_CONFIG = OrderedDict([('loc', ''), ('lastmod', ''), ('changefreq', 'monthly'), ('priority', '0.5')]) SITEMAP_REGISTRATION_CONFIG = OrderedDict([('loc', ''), ('lastmod', ''), ('changefreq', 'never'), ('priority', '0.5')]) +SITEMAP_REVIEWS_CONFIG = OrderedDict([('loc', ''), ('lastmod', ''), ('changefreq', 'never'), ('priority', '0.5')]) SITEMAP_PREPRINT_CONFIG = OrderedDict([('loc', ''), ('lastmod', ''), ('changefreq', 'yearly'), ('priority', '0.5')]) SITEMAP_PREPRINT_FILE_CONFIG = OrderedDict([('loc', ''), ('lastmod', ''), ('changefreq', 'yearly'), ('priority', '0.5')]) diff --git a/website/settings/local-dist.py b/website/settings/local-dist.py index a24c1d5ebca..28aeb81b8ab 100644 --- a/website/settings/local-dist.py +++ b/website/settings/local-dist.py @@ -40,6 +40,11 @@ 'url': '/registries/', 'server': 'http://192.168.168.167:4300', 'path': '/registries/' + }, + 'reviews': { + 'url': '/reviews/', + 'server': 'http://localhost:4400', + 'path': '/reviews/' } # 'meetings': { # 'url': '/meetings/', diff --git a/website/signals.py b/website/signals.py index ce7384e58a6..ff4988c3885 100644 --- a/website/signals.py +++ b/website/signals.py @@ -4,6 +4,7 @@ from website.project import signals as project from addons.base import signals as event from website.conferences import signals as conference +from website.reviews import signals as reviews ALL_SIGNALS = [ project.comment_added, @@ -20,4 +21,5 @@ auth.unconfirmed_user_created, event.file_updated, conference.osf4m_user_created, + reviews.reviews_email ] diff --git a/website/static/js/myProjects.js b/website/static/js/myProjects.js index d9992fbd4fb..e629daf6d64 100644 --- a/website/static/js/myProjects.js +++ b/website/static/js/myProjects.js @@ -118,10 +118,10 @@ function NodeFetcher(type, link, handleOrphans, regType, regLink) { // TODO Use sparse fields on preprints, users/contributors already added if (this.type === 'preprints') { - link = link ? link : $osf.apiV2Url('users/me/nodes/', { query : { 'filter[preprint]': true, 'related_counts' : 'children', 'embed' : ['contributors', 'preprints'], 'fields[users]' : sparseUserFields, 'fields[contributors]' : sparseContributorFields}}); + link = link ? link : $osf.apiV2Url('users/me/nodes/', { query : { 'filter[preprint]': true, 'include_unpublished_preprints': true, 'related_counts' : 'children', 'embed' : ['contributors', 'preprints'], 'fields[users]' : sparseUserFields, 'fields[contributors]' : sparseContributorFields}}); } - this.nextLink = link ? + this.nextLink = link ? link + '&version=2.2' : $osf.apiV2Url('users/me/' + this.type + '/', { query: params}); } @@ -1919,7 +1919,8 @@ var Information = { m('.tab-content', [ m('[role="tabpanel"].tab-pane.active#tab-information',[ m('p.db-info-meta.text-muted', [ - item.attributes.preprint ? m('.fangorn-preprint.p-xs.m-b-xs', 'This project is a Preprint') : '', + item.embeds.preprints ? m('.fangorn-preprint.p-xs.m-b-xs', 'This project is a Preprint') : '', // TODO: update once preprint node divorce is finished + item.embeds.preprints && item.embeds.preprints.data[0].attributes.reviews_state && item.embeds.preprints.data[0].attributes.reviews_state !== 'initial' ? m('.text-capitalize', 'Status: ' + item.embeds.preprints.data[0].attributes.reviews_state) : '', // is a preprint, has a state, provider uses moderation m('', 'Visibility : ' + (item.attributes.public ? 'Public' : 'Private')), m('.text-capitalize', 'Category: ' + category), m('.text-capitalize', 'Permission: ' + permission), diff --git a/website/static/js/project-organizer.js b/website/static/js/project-organizer.js index fa2e47996ce..28051937c51 100644 --- a/website/static/js/project-organizer.js +++ b/website/static/js/project-organizer.js @@ -40,7 +40,7 @@ function _poTitleColumn(item) { var isMypreprintsCollection = tb.options.currentView().collection.data.nodeType === 'preprints'; if (item.data.archiving) { // TODO check if this variable will be available return m('span', {'class': 'registration-archiving'}, title + ' [Archiving]'); - } else if (node.attributes.preprint && isMypreprintsCollection){ + } else if (node.embeds.preprints && isMypreprintsCollection){ return [ m('a.fg-file-links', { 'class' : css, href : node.embeds.preprints.data[0].links.html, 'data-nodeID' : node.id, 'data-nodeTitle': title,'data-nodeType': node.type, onclick : function(event) { preventSelect.call(this, event); $osf.trackClick('myProjects', 'projectOrganizer', 'navigate-to-preprint'); @@ -340,7 +340,7 @@ var tbOptions = { onmultiselect : _poMultiselect, resolveIcon : function _poIconView(item) { // Project Organizer doesn't use icons var isMypreprintsCollection = this.options.currentView().collection.data.nodeType === 'preprints'; - var iconType = item.data.attributes.preprint && isMypreprintsCollection ? 'preprint' : item.data.attributes.category; + var iconType = item.data.embeds.preprints && isMypreprintsCollection ? 'preprint' : item.data.attributes.category; return m('i.' + iconmap.projectComponentIcons[iconType]); }, resolveToggle : _poResolveToggle, diff --git a/website/templates/emails/reviews_submission_confirmation.html.mako b/website/templates/emails/reviews_submission_confirmation.html.mako new file mode 100644 index 00000000000..4d1a36306e3 --- /dev/null +++ b/website/templates/emails/reviews_submission_confirmation.html.mako @@ -0,0 +1,30 @@ +## -*- coding: utf-8 -*- +<%inherit file="notify_base.mako"/> +<%def name="content()"> +
+

Hello ${user.fullname},

+ % if is_creator: +

Your ${reviewable.provider.preprint_word} ${reviewable.node.title} has been successfully submitted to ${reviewable.provider.name}.

+ % else: +

${referrer.fullname} has added you as a contributor to the ${reviewable.provider.preprint_word} ${reviewable.node.title} on ${reviewable.provider.name}, which is hosted on the Open Science Framework.

+ % endif + % if workflow == 'pre-moderation': +

${reviewable.provider.name} has chosen to moderate their submissions using a pre-moderation workflow, which means your submission is pending until accepted by a moderator. You will receive a separate notification informing you of any status changes.

+ % elif workflow == 'post-moderation': +

${reviewable.provider.name} has chosen to moderate their submissions using a post-moderation workflow, which means your submission is public and discoverable, while still pending acceptance by a moderator. You will receive a separate notification informing you of any status changes.

+ % endif +

You will ${'not receive ' if no_future_emails else 'be automatically subscribed to '}future notification emails for this ${reviewable.provider.preprint_word}. Each ${reviewable.provider.preprint_word} is associated with a project on the Open Science Framework for managing the ${reviewable.provider.preprint_word}. To change your email notification preferences, visit your project user settings.

+

If you have been erroneously associated with "${reviewable.node.title}", then you may visit the project's "Contributors" page and remove yourself as a contributor.

+

For more information about ${reviewable.provider.name}, visit ${provider_url} to learn more. To learn about the Open Science Framework, visit https://osf.io/.

+

For questions regarding submission criteria, please email ${provider_contact_email}

+
+ Sincerely, +
+ Your ${reviewable.provider.name} and OSF teams +

+ Center for Open Science
+ 210 Ridge McIntire Road, Suite 500, Charlottesville, VA 22903-5083 +

+ Privacy Policy +
+ diff --git a/website/templates/emails/reviews_submission_confirmation.txt.mako b/website/templates/emails/reviews_submission_confirmation.txt.mako new file mode 100644 index 00000000000..fe3be5cbfe4 --- /dev/null +++ b/website/templates/emails/reviews_submission_confirmation.txt.mako @@ -0,0 +1,31 @@ +Hello ${user.fullname}, + +% if is_creator: +Your ${reviewable.provider.preprint_word} ${reviewable.node.title} has been successfully submitted to ${reviewable.provider.name}. +% else: +${referrer.fullname} has added you as a contributor to the ${reviewable.provider.preprint_word} ${reviewable.node.title} on ${reviewable.provider.name}, which is hosted on the Open Science Framework: ${reviewable.absolute_url}. +% endif + +% if workflow == 'pre-moderation': +${reviewable.provider.name} has chosen to moderate their submissions using a pre-moderation workflow, which means your submission is pending until accepted by a moderator. You will receive a separate notification informing you of any status changes. +% elif workflow == 'post-moderation': +${reviewable.provider.name} has chosen to moderate their submissions using a post-moderation workflow, which means your submission is public and discoverable, while still pending acceptance by a moderator. You will receive a separate notification informing you of any status changes. +% endif + +You will ${'not receive ' if no_future_emails else 'be automatically subscribed to '}future notification emails for this ${reviewable.provider.preprint_word}. Each ${reviewable.provider.preprint_word} is associated with a project on the Open Science Framework for managing the ${reviewable.provider.preprint_word}. To change your email notification preferences, visit your project user settings: ${domain + "settings/notifications/"} + +If you have been erroneously associated with "${reviewable.node.title}", then you may visit the project's "Contributors" page and remove yourself as a contributor. + +For more information about ${reviewable.provider.name}, visit ${provider_url} to learn more. To learn about the Open Science Framework, visit https://osf.io/ + +For questions regarding submission criteria, please email ${provider_contact_email} + + +Sincerely, + +Your ${reviewable.provider.name} and OSF teams + +Center for Open Science +210 Ridge McIntire Road, Suite 500, Charlottesville, VA 22903-5083 + +Privacy Policy: https://github.com/CenterForOpenScience/cos.io/blob/master/PRIVACY_POLICY.md diff --git a/website/templates/emails/reviews_submission_status.html.mako b/website/templates/emails/reviews_submission_status.html.mako new file mode 100644 index 00000000000..c1fb9c18745 --- /dev/null +++ b/website/templates/emails/reviews_submission_status.html.mako @@ -0,0 +1,22 @@ +## -*- coding: utf-8 -*- +
+

Hello ${user.fullname},

+ % if workflow == 'pre-moderation': +

Your submission "${reviewable.node.title}", submitted to ${reviewable.provider.name} has ${'not been accepted. You may edit the '+ reviewable.provider.preprint_word+ ' and resubmit, at which time it will becoming pending moderation.' if is_rejected else 'been accepted by the moderator and is now discoverable to others.'}

+ % elif workflow == 'post-moderation': +

Your submission "${reviewable.node.title}", submitted to ${reviewable.provider.name} has ${'not been accepted and will be made private and not discoverable by others. You may edit the '+ reviewable.provider.preprint_word+ ' and contact the moderator at '+ provider_support_email +' to resubmit.' if is_rejected else 'been accepted by the moderator and remains discoverable to others. '} ${'The moderator has also provided a comment that is only visible to contributors of the '+ reviewable.provider.preprint_word+ ', and not to others. ' if notify_comment else ''}

+ % endif +

You will ${'not receive ' if no_future_emails else 'be automatically subscribed to '}future notification emails for this ${reviewable.provider.preprint_word}. Each ${reviewable.provider.preprint_word} is associated with a project on the Open Science Framework for managing the ${reviewable.provider.preprint_word}. To change your email notification preferences, visit your project user settings.

+

If you have been erroneously associated with "${reviewable.node.title}", then you may visit the project's "Contributors" page and remove yourself as a contributor.

+

For more information about ${reviewable.provider.name}, visit ${provider_url} to learn more. To learn about the Open Science Framework, visit https://osf.io/.

+

For questions regarding submission criteria, please email ${provider_contact_email}

+
+ Sincerely, +
+ Your ${reviewable.provider.name} and OSF teams +

+ Center for Open Science
+ 210 Ridge McIntire Road, Suite 500, Charlottesville, VA 22903-5083 +

+ Privacy Policy +
diff --git a/website/templates/emails/reviews_submission_status.txt.mako b/website/templates/emails/reviews_submission_status.txt.mako new file mode 100644 index 00000000000..4f7fe31ec91 --- /dev/null +++ b/website/templates/emails/reviews_submission_status.txt.mako @@ -0,0 +1,25 @@ +Hello ${user.fullname}, + +% if workflow == 'pre-moderation': +Your submission "${reviewable.node.title}", submitted to ${reviewable.provider.name} has ${'not been accepted. You may edit the '+ reviewable.provider.preprint_word+ ' and resubmit, at which time it will becoming pending moderation.' if is_rejected else 'been accepted by the moderator and is now discoverable to others.'} +% elif workflow == 'post-moderation': +Your submission "${reviewable.node.title}", submitted to ${reviewable.provider.name} has ${'not been accepted and will be made private and not discoverable by others. You may edit the '+ reviewable.provider.preprint_word+ ' and contact the moderator at '+ provider_support_email +' to resubmit.' if is_rejected else 'been accepted by the moderator and remains discoverable to others. '} ${'The moderator has also provided a comment that is only visible to contributors of the '+ reviewable.provider.preprint_word+ ', and not to others. ' if notify_comment else ''} +% endif + +You will ${'not receive ' if no_future_emails else 'be automatically subscribed to '}future notification emails for this ${reviewable.provider.preprint_word}. Each ${reviewable.provider.preprint_word} is associated with a project on the Open Science Framework for managing the ${reviewable.provider.preprint_word}. To change your email notification preferences, visit your project user settings: ${domain + "settings/notifications/"} + +If you have been erroneously associated with "${reviewable.node.title}", then you may visit the project's "Contributors" page and remove yourself as a contributor. + +For more information about ${reviewable.provider.name}, visit ${provider_url} to learn more. To learn about the Open Science Framework, visit https://osf.io/ + +For questions regarding submission criteria, please email ${provider_contact_email} + + +Sincerely, + +Your ${reviewable.provider.name} and OSF teams + +Center for Open Science +210 Ridge McIntire Road, Suite 500, Charlottesville, VA 22903-5083 + +Privacy Policy: https://github.com/CenterForOpenScience/cos.io/blob/master/PRIVACY_POLICY.md diff --git a/website/templates/emails/reviews_update_comment.html.mako b/website/templates/emails/reviews_update_comment.html.mako new file mode 100644 index 00000000000..d0562069ae4 --- /dev/null +++ b/website/templates/emails/reviews_update_comment.html.mako @@ -0,0 +1,18 @@ +## -*- coding: utf-8 -*- +
+

Hello ${user.fullname},

+

Your ${reviewable.provider.preprint_word} "${reviewable.node.title}" has an updated comment by the moderator. To view the comment, go to your ${reviewable.provider.preprint_word}.

+

You will ${'not receive ' if no_future_emails else 'be automatically subscribed to '}future notification emails for this ${reviewable.provider.preprint_word}. Each ${reviewable.provider.preprint_word} is associated with a project on the Open Science Framework for managing the ${reviewable.provider.preprint_word}. To change your email notification preferences, visit your project user settings.

+

If you have been erroneously associated with "${reviewable.node.title}", then you may visit the project's "Contributors" page and remove yourself as a contributor.

+

For more information about ${reviewable.provider.name}, visit ${provider_url} to learn more. To learn about the Open Science Framework, visit https://osf.io/.

+

For questions regarding submission criteria, please email ${provider_contact_email}

+
+ Sincerely, +
+ Your ${reviewable.provider.name} and OSF teams +

+ Center for Open Science
+ 210 Ridge McIntire Road, Suite 500, Charlottesville, VA 22903-5083 +

+ Privacy Policy +
diff --git a/website/templates/emails/reviews_update_comment.txt.mako b/website/templates/emails/reviews_update_comment.txt.mako new file mode 100644 index 00000000000..614aa83884a --- /dev/null +++ b/website/templates/emails/reviews_update_comment.txt.mako @@ -0,0 +1,21 @@ +Hello ${user.fullname}, + +Your ${reviewable.provider.preprint_word} "${reviewable.node.title}" has an updated comment by the moderator. To view the comment, go to your ${reviewable.provider.preprint_word} at ${reviewable.absolute_url}. + +You will ${'not receive ' if no_future_emails else 'be automatically subscribed to '}future notification emails for this ${reviewable.provider.preprint_word}. Each ${reviewable.provider.preprint_word} is associated with a project on the Open Science Framework for managing the ${reviewable.provider.preprint_word}. To change your email notification preferences, visit your project user settings: ${domain + "settings/notifications/"} + +If you have been erroneously associated with "${reviewable.node.title}", then you may visit the project's "Contributors" page and remove yourself as a contributor. + +For more information about ${reviewable.provider.name}, visit ${provider_url} to learn more. To learn about the Open Science Framework, visit https://osf.io/ + +For questions regarding submission criteria, please email ${provider_contact_email} + + +Sincerely, + +Your ${reviewable.provider.name} and OSF teams + +Center for Open Science +210 Ridge McIntire Road, Suite 500, Charlottesville, VA 22903-5083 + +Privacy Policy: https://github.com/CenterForOpenScience/cos.io/blob/master/PRIVACY_POLICY.md diff --git a/website/templates/public/pages/reviews_landing.mako b/website/templates/public/pages/reviews_landing.mako new file mode 100644 index 00000000000..0a7fa40575e --- /dev/null +++ b/website/templates/public/pages/reviews_landing.mako @@ -0,0 +1,19 @@ +<%inherit file="base.mako"/> + +<%def name="title()">OSF Reviews + +<%def name="content()"> +

Reviews service is not activated.

+
    +
  • Set the following in local.py:
  • +
    USE_EXTERNAL_EMBER = True
    +EXTERNAL_EMBER_APPS = {
    +  'reviews': {
    +    'url': '/reviews/',
    +    'server': 'http://localhost:4400',
    +    'path': '/reviews/'
    +  }
    +}
    +
  • Start the reviews container with docker-compose up -d reviews.
  • +
+