diff --git a/api/app/applicationModel/api.py b/api/app/applicationModel/api.py index 6226102be..0755cecda 100644 --- a/api/app/applicationModel/api.py +++ b/api/app/applicationModel/api.py @@ -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 @@ -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) diff --git a/api/app/outcome/api.py b/api/app/outcome/api.py index a782a49fc..2fb40cab6 100644 --- a/api/app/outcome/api.py +++ b/api/app/outcome/api.py @@ -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 @@ -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() @@ -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() diff --git a/api/app/responses/api.py b/api/app/responses/api.py index fad3c160e..15d7a794e 100644 --- a/api/app/responses/api.py +++ b/api/app/responses/api.py @@ -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 @@ -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) @@ -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): + 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 @@ -306,10 +311,19 @@ 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, []): + reviewer = _serialize_reviewer(r, reviewer_to_review_response.get(r.reviewer_user_id, None)) + if r.is_action_editor: + action_editor = reviewer + else: + reviewers.append(reviewer) + 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] @@ -329,11 +343,11 @@ def get(self, event_id): 'language': response.language, 'answers': answers, 'reviewers': reviewers, + 'action_editor': action_editor, 'tags': [_serialize_tag(rt.tag, language) for rt in response.response_tags] } - + serialized_responses.append(serialized) - return serialized_responses @@ -425,7 +439,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 } @@ -449,7 +464,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) @@ -458,6 +473,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) diff --git a/api/app/responses/models.py b/api/app/responses/models.py index dac0fd2c7..43df0740c 100644 --- a/api/app/responses/models.py +++ b/api/app/responses/models.py @@ -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 diff --git a/api/app/responses/repository.py b/api/app/responses/repository.py index 48ea1caf0..b81f79525 100644 --- a/api/app/responses/repository.py +++ b/api/app/responses/repository.py @@ -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 @@ -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): diff --git a/api/app/reviews/api.py b/api/app/reviews/api.py index b0b202638..d9af04322 100644 --- a/api/app/reviews/api.py +++ b/api/app/reviews/api.py @@ -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 @@ -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(): @@ -431,17 +442,9 @@ 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): - 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) - if not current_user.is_event_admin(event_id): - return FORBIDDEN - + def get(self, event_id): counts = review_repository.count_reviews_allocated_and_completed_per_reviewer(event_id) views = [ReviewCountView(count) for count in counts] return views @@ -621,7 +624,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') @@ -662,7 +665,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) @@ -749,7 +752,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) diff --git a/api/app/reviews/repository.py b/api/app/reviews/repository.py index d687a19f4..183da0645 100644 --- a/api/app/reviews/repository.py +++ b/api/app/reviews/repository.py @@ -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 diff --git a/api/app/tags/api.py b/api/app/tags/api.py index be578c96a..981bfdc80 100644 --- a/api/app/tags/api.py +++ b/api/app/tags/api.py @@ -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 @@ -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) diff --git a/api/app/users/models.py b/api/app/users/models.py index b19977735..a94e0908a 100644 --- a/api/app/users/models.py +++ b/api/app/users/models.py @@ -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 diff --git a/api/app/utils/auth.py b/api/app/utils/auth.py index fc449a970..529423b04 100644 --- a/api/app/utils/auth.py +++ b/api/app/utils/auth.py @@ -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 diff --git a/webapp/src/App.js b/webapp/src/App.js index 90b1d72d5..9a87da267 100755 --- a/webapp/src/App.js +++ b/webapp/src/App.js @@ -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"; @@ -217,6 +217,45 @@ class EventNav extends Component { )} + {isActionEditor(this.props.user, this.props.event) && + this.props.event && + this.props.event.is_review_open && ( +
{action_editor.user_title} {action_editor.firstname} {action_editor.lastname}
+ +{val.user_title} {val.firstname} {val.lastname}
- - {val.status === "completed" &&{this.props.t("Completed")}
} - {val.status === "started" &&{this.props.t("In Progress")}
} - {val.status === "not_started" && -- {this.props.t("Not Started")} - -
- } -{val.user_title} {val.firstname} {val.lastname}
+ + {val.status === "completed" &&{this.props.t("Completed")}
} + {val.status === "started" &&{this.props.t("In Progress")}
} + {val.status === "not_started" && ++ {this.props.t("Not Started")} + +
+ }