-
Notifications
You must be signed in to change notification settings - Fork 359
[MOD-109][Feature] Reviews, Volume I: Moderation #7708
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cc6ba73
8165183
0251b49
8cda742
cb8a053
5b97814
1d854e4
7fa6611
919295a
ac81090
262367f
75eba5c
97a9548
30c7872
3582395
6382c2e
b5a85d6
b059ee9
c103cb6
e194199
9d609db
481ed25
38b28fa
bc68494
20421be
c73320e
3d6eb79
7275977
2e040d0
7c15538
4d6538f
f41dde4
c543c22
39cfb6c
b981ecc
2b47259
b25cb89
3e89674
5244eaa
5b3558e
fbd43d1
ab1bc19
72157a7
33fe2ee
9eb7c61
3a45af9
71f9c08
3eb6e5b
0110e8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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': '<target.provider._id>'}, | ||
| filter_key='target__provider___id', | ||
| ) | ||
|
|
||
| target = TargetRelationshipField( | ||
| read_only=False, | ||
| required=True, | ||
| related_view='preprints:preprint-detail', | ||
| related_view_kwargs={'preprint_id': '<target._id>'}, | ||
| filter_key='target__guids___id', | ||
| ) | ||
|
|
||
| creator = HideIfProviderCommentsAnonymous(RelationshipField( | ||
| read_only=True, | ||
| related_view='users:user-detail', | ||
| related_view_kwargs={'user_id': '<creator._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' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<action_id>\w+)/$', views.ActionDetail.as_view(), name=views.ActionDetail.view_name), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<guid>/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=<Int>` -- page number of results to view, default 1 | ||
|
|
||
| + `filter[<fieldname>]=<Str>` -- 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: <none> | ||
| 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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than returning an empty queryset, would it make more sense to return
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I did that at first (used
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We really aren't relying on the browseable API anymore, and once we are able to link to specific sections of the dev docs, all the BAPI docs will just be links. I'm good with this being CreateAPIView. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we've discussed, but more important than this is documentation at http://github.com/centerforopenscience/developer.osf.io
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, I'll update my WIP PR there next week.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we expect this API to be stable? If nah, it shouldn't go to developer.osf.io quite yet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, though it probably shouldn't go here, either. @aaxelb you'll be getting an invite to an osf project which will be a better place to hold this info until it's stable.