Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/app/applicationModel/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from app.events.repository import EventRepository as event_repository
from app.applicationModel.repository import ApplicationFormRepository as application_form_repository
from app.users.repository import UserRepository as user_repository
from app.utils.auth import auth_required, event_admin_required
from app.utils.auth import auth_required, event_admin_required, event_admin_or_action_editor_required
from app.utils.errors import APPLICATION_FORM_EXISTS, EVENT_NOT_FOUND, QUESTION_NOT_FOUND, SECTION_NOT_FOUND, DB_NOT_AVAILABLE, FORM_NOT_FOUND, APPLICATIONS_CLOSED

from app import db, bcrypt
Expand Down Expand Up @@ -406,7 +406,7 @@ def _serialize_question(question, language):

class QuestionListApi(restful.Resource):

@event_admin_required
@event_admin_or_action_editor_required
def get(self, event_id):
req_parser = reqparse.RequestParser()
req_parser.add_argument('language', type=str, required=True)
Expand Down
6 changes: 3 additions & 3 deletions api/app/outcome/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from app.users.repository import UserRepository as user_repository
from app.utils.emailer import email_user

from app.utils.auth import auth_required, event_admin_required
from app.utils.auth import auth_required, event_admin_required, event_admin_or_action_editor_required
from app import LOGGER
from app import db
from app.utils import errors
Expand Down Expand Up @@ -46,7 +46,7 @@ def _extract_status(outcome):


class OutcomeAPI(restful.Resource):
@event_admin_required
@event_admin_or_action_editor_required
@marshal_with(outcome_fields)
def get(self, event_id):
req_parser = reqparse.RequestParser()
Expand All @@ -69,7 +69,7 @@ def get(self, event_id):
LOGGER.error("Encountered unknown error: {}".format(traceback.format_exc()))
return errors.DB_NOT_AVAILABLE

@event_admin_required
@event_admin_or_action_editor_required
@marshal_with(outcome_fields)
def post(self, event_id):
req_parser = reqparse.RequestParser()
Expand Down
37 changes: 28 additions & 9 deletions api/app/responses/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from app.users.repository import UserRepository as user_repository
from app.utils import emailer, errors, strings, pdfconvertor, zipping, storage
from app.utils.zipping import zip_in_memory
from app.utils.auth import auth_required, event_admin_required
from app.utils.auth import auth_required, event_admin_or_action_editor_required
from flask import g, request, send_file
from flask_restful import fields, inputs, marshal_with, reqparse
from sqlalchemy.exc import SQLAlchemyError
Expand Down Expand Up @@ -276,7 +276,7 @@ def _serialize_tag(tag, language):

class ResponseListAPI(restful.Resource):

@event_admin_required
@event_admin_or_action_editor_required
def get(self, event_id):
req_parser = reqparse.RequestParser()
req_parser.add_argument('include_unsubmitted', type=inputs.boolean, required=True)
Expand All @@ -288,10 +288,15 @@ def get(self, event_id):
include_unsubmitted = args['include_unsubmitted']
question_ids = args['question_ids[]']
language = args['language']

current_user = user_repository.get_by_id(g.current_user['id'])

print(('Include unsubmitted:', include_unsubmitted))

responses = response_repository.get_all_for_event(event_id, not include_unsubmitted)
if current_user.is_event_admin(event_id):
responses = response_repository.get_all_for_event(event_id, not include_unsubmitted)

elif current_user.is_action_editor(event_id):
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if there's a better name than "action-editor" that's not a journal-specific term, since the functionality is not limited to a journal event type. Will try think of something too!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I agree! I was thinking something along the lines of evaluator, critic, etc. What do you think?

responses = response_repository.get_all_for_action_editor(event_id, g.current_user['id'], not include_unsubmitted)

review_config = review_configuration_repository.get_configuration_for_event(event_id)
required_reviewers = 1 if review_config is None else review_config.num_reviews_required + review_config.num_optional_reviews
Expand All @@ -306,10 +311,18 @@ def get(self, event_id):
r.reviewer_user_id: r for r in review_responses
}

event = event_repository.get_by_id(event_id)

serialized_responses = []
for response in responses:
reviewers = [_serialize_reviewer(r, reviewer_to_review_response.get(r.reviewer_user_id, None))
for r in response_to_reviewers.get(response.id, [])]
reviewers = []
action_editor = None
for r in response_to_reviewers.get(response.id, []):
if not r.is_action_editor:
reviewers.append(_serialize_reviewer(r, reviewer_to_review_response.get(r.reviewer_user_id, None)))
else:
action_editor = _serialize_reviewer(r, reviewer_to_review_response.get(r.reviewer_user_id, None))

reviewers = _pad_list(reviewers, required_reviewers)
if question_ids:
answers = [_serialize_answer(answer, language) for answer in response.answers if answer.question_id in question_ids]
Expand All @@ -332,8 +345,10 @@ def get(self, event_id):
'tags': [_serialize_tag(rt.tag, language) for rt in response.response_tags]
}

if (event.event_type == EventType.CONTINUOUS_JOURNAL or event.event_type == EventType.JOURNAL):
serialized['action_editor'] = action_editor

serialized_responses.append(serialized)

return serialized_responses


Expand Down Expand Up @@ -425,7 +440,8 @@ def _serialize_reviewer(response_reviewer, review_form_id):
'user_title': response_reviewer.user.user_title,
'firstname': response_reviewer.user.firstname,
'lastname': response_reviewer.user.lastname,
'status': _review_response_status(review_response)
'status': _review_response_status(review_response),
'is_action_editor': response_reviewer.is_action_editor
}


Expand All @@ -449,7 +465,7 @@ def _serialize_response(response, language, review_form_id, num_reviewers):
'reviewers': [ResponseDetailAPI._serialize_reviewer(r, review_form_id) for r in response.reviewers]
}

@event_admin_required
@event_admin_or_action_editor_required
def get(self, event_id):
req_parser = reqparse.RequestParser()
req_parser.add_argument('response_id', type=int, required=True)
Expand All @@ -458,6 +474,9 @@ def get(self, event_id):

response_id = args['response_id']
language = args['language']

if not _validate_user_admin_or_reviewer(g.current_user['id'], event_id, response_id):
return errors.FORBIDDEN

response = response_repository.get_by_id(response_id)
review_form = review_repository.get_review_form(event_id)
Expand Down
2 changes: 1 addition & 1 deletion api/app/responses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def __init__(self, response_id, reviewer_user_id, is_action_editor=False):
self.response_id = response_id
self.reviewer_user_id = reviewer_user_id
self.active = True
is_action_editor = is_action_editor
self.is_action_editor = is_action_editor

def deactivate(self):
self.active = False
Expand Down
14 changes: 13 additions & 1 deletion api/app/responses/repository.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import List

from app import db
from app.responses.models import Response, Answer, ResponseTag
from app.responses.models import Response, Answer, ResponseTag, ResponseReviewer
from app.applicationModel.models import ApplicationForm, Question, Section
from app.users.models import AppUser
from sqlalchemy import func, cast, Date
Expand Down Expand Up @@ -137,6 +137,18 @@ def get_all_for_event(event_id, submitted_only=True) -> List[Response]:
.join(ApplicationForm, Response.application_form_id == ApplicationForm.id)
.filter_by(event_id=event_id)
.all())

def get_all_for_action_editor(event_id, action_editor_id, submitted_only=True) -> List[Response]:
query = db.session.query(Response)
if submitted_only:
query = query.filter_by(is_submitted=True)

return (query
.join(ApplicationForm, Response.application_form_id == ApplicationForm.id)
.filter_by(event_id=event_id)
.join(ResponseReviewer, Response.id == ResponseReviewer.response_id)
.filter_by(reviewer_user_id=action_editor_id, is_action_editor=True)
.all())

@staticmethod
def tag_response(response_id, tag_id):
Expand Down
26 changes: 18 additions & 8 deletions api/app/reviews/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@
from app.users.repository import UserRepository as user_repository

from app.events.repository import EventRepository as event_repository
from app.utils.auth import auth_required

from app.utils.auth import auth_required, event_admin_required
from app.utils.auth import auth_required, event_admin_required, event_admin_or_action_editor_required
from app.utils.errors import EVENT_NOT_FOUND, REVIEW_RESPONSE_NOT_FOUND, FORBIDDEN, USER_NOT_FOUND, RESPONSE_NOT_FOUND, \
REVIEW_FORM_NOT_FOUND, REVIEW_ALREADY_COMPLETED, NO_ACTIVE_REVIEW_FORM, REVIEW_FORM_FOR_STAGE_NOT_FOUND

Expand Down Expand Up @@ -187,6 +186,18 @@ def _add_reviewer_role(user_id, event_id):
event_role = EventRole('reviewer', user_id, event_id)
db.session.add(event_role)
db.session.commit()

def _validate_user_admin_or_reviewer(user_id, event_id, response_id):
user = user_repository.get_by_id(user_id)
# Check if the user is an event admin
permitted = user.is_event_admin(event_id)
# If they're not an event admin, check if they're a reviewer for the relevant response
if not permitted and user.is_reviewer(event_id):
response_reviewer = review_repository.get_response_reviewer(response_id, user.id)
if response_reviewer is not None:
permitted = True

return permitted


class ReviewResponseUser():
Expand Down Expand Up @@ -431,11 +442,10 @@ class ReviewAssignmentAPI(GetReviewAssignmentMixin, PostReviewAssignmentMixin, r
'reviews_completed': fields.Integer
}

@auth_required
@event_admin_or_action_editor_required
@marshal_with(reviews_count_fields)
def get(self):
def get(self, event_id):
args = self.get_req_parser.parse_args()
event_id = args['event_id']
user_id = g.current_user['id']

current_user = user_repository.get_by_id(user_id)
Expand Down Expand Up @@ -621,7 +631,7 @@ def get(self):
for response, review_response in responses_to_review]

class ResponseReviewAssignmentAPI(restful.Resource):
@event_admin_required
@event_admin_or_action_editor_required
def post(self, event_id):
parser = reqparse.RequestParser()
parser.add_argument('response_ids', type=int, required=True, action='append')
Expand Down Expand Up @@ -662,7 +672,7 @@ def post(self, event_id):
user=reviewer_user)
return {}, 201

@event_admin_required
@event_admin_or_action_editor_required
def delete(self, event_id):
parser = reqparse.RequestParser()
parser.add_argument('response_id', type=int, required=True)
Expand Down Expand Up @@ -749,7 +759,7 @@ def _serialise_review_response(review_response, language):
}

@auth_required
@event_admin_required
@event_admin_or_action_editor_required
def get(self, event_id):
parser = reqparse.RequestParser()
parser.add_argument('language', type=str, required=True)
Expand Down
3 changes: 2 additions & 1 deletion api/app/reviews/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ def count_reviews_allocated_and_completed_per_reviewer(event_id):
0 as reviews_completed
from app_user
join event_role on event_role.user_id = app_user.id
where event_role.role = 'reviewer'
where event_role.role = 'reviewer'
or event_role.role = 'action-editor'
and event_role.event_id = {event_id}
and not exists (
select 1
Expand Down
4 changes: 2 additions & 2 deletions api/app/tags/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from flask import g
import flask_restful as restful
from flask_restful import reqparse, fields, marshal_with
from app.utils.auth import auth_required, event_admin_required
from app.utils.auth import event_admin_required, event_admin_or_action_editor_required
from app.tags.repository import TagRepository as tag_repository
from app.utils import errors
from app.tags.models import Tag, TagTranslation
Expand Down Expand Up @@ -90,7 +90,7 @@ def put(self, event_id):


class TagListAPI(restful.Resource):
@event_admin_required
@event_admin_or_action_editor_required
def get(self, event_id):
req_parser = reqparse.RequestParser()
req_parser.add_argument('language', type=str, required=True)
Expand Down
12 changes: 11 additions & 1 deletion api/app/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,22 @@ def is_registration_admin(self, event_id):
# An event admin is also a registration admin
return self._has_admin_role(event_id, 'registration-admin') or self._has_admin_role(event_id, 'admin')

def is_action_editor(self, event_id):
if self.event_roles is None:
return False

for event_role in self.event_roles:
if event_role.event_id == event_id and event_role.role == 'action-editor':
return True

return False

def is_reviewer(self, event_id):
if self.event_roles is None:
return False

for event_role in self.event_roles:
if event_role.event_id == event_id and event_role.role == 'reviewer':
if event_role.event_id == event_id and (event_role.role == 'reviewer' or event_role.role == 'action-editor'):
return True

return False
Expand Down
17 changes: 17 additions & 0 deletions api/app/utils/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,20 @@ def wrapper(*args, **kwargs):
return FORBIDDEN

return wrapper

def event_admin_or_action_editor_required(func):
@wraps(func)
def wrapper(*args, **kwargs):
req_parser = reqparse.RequestParser()
req_parser.add_argument('event_id', type=int, required=True)
req_args = req_parser.parse_args()

user = get_user_from_request()
if user:
user_info = user_repository.get_by_id(user['id'])
if user_info.is_action_editor(req_args['event_id']) or user_info.is_event_admin(req_args['event_id']):
g.current_user = user
return func(*args, event_id=req_args['event_id'], **kwargs)

return FORBIDDEN
return wrapper
41 changes: 40 additions & 1 deletion webapp/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import ReactGA from "react-ga";
import "./App.css";
import history from "./History";

import { isEventAdmin, isRegistrationAdmin, isRegistrationVolunteer, isEventReviewer } from "./utils/user";
import { isEventAdmin, isRegistrationAdmin, isRegistrationVolunteer, isEventReviewer, isActionEditor } from "./utils/user";
import { withTranslation } from 'react-i18next';
import { userService } from "./services/user";

Expand Down Expand Up @@ -217,6 +217,45 @@ class EventNav extends Component {
</div>
</li>
)}
{isActionEditor(this.props.user, this.props.event) &&
this.props.event &&
this.props.event.is_review_open && (
<li className="nav-item dropdown">
<div
className="nav-link dropdown-toggle"
id="navbarDropdown"
role="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
{t('Reviews')}
</div>
<div className="dropdown-menu" aria-labelledby="navbarDropdown">
<NavLink
to={`/${this.props.eventKey}/responseList`}
className="dropdown-item"
onClick={this.props.toggleMenu}
>
{t('Response List')}
</NavLink>
<NavLink
to={`/${this.props.eventKey}/reviewlist`}
className="dropdown-item"
onClick={this.props.toggleMenu}
>
{t('Review')}
</NavLink>
<NavLink
to={`/${this.props.eventKey}/reviewHistory`}
className="dropdown-item"
onClick={this.props.toggleMenu}
>
{t('Review History')}
</NavLink>
</div>
</li>
)}
{(isRegistrationAdmin(this.props.user, this.props.event) || isRegistrationVolunteer(this.props.user, this.props.event)) &&
this.props.event &&
this.props.event.is_registration_open && (
Expand Down
Loading