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 && ( +
  • + +
    + + {t('Response List')} + + + {t('Review')} + + + {t('Review History')} + +
    +
  • + )} {(isRegistrationAdmin(this.props.user, this.props.event) || isRegistrationVolunteer(this.props.user, this.props.event)) && this.props.event && this.props.event.is_registration_open && ( diff --git a/webapp/src/pages/ResponseList/components/ResponseListForm.js b/webapp/src/pages/ResponseList/components/ResponseListForm.js index 84315727c..408fb4d4f 100644 --- a/webapp/src/pages/ResponseList/components/ResponseListForm.js +++ b/webapp/src/pages/ResponseList/components/ResponseListForm.js @@ -52,7 +52,7 @@ class ResponseListForm extends Component { this.toggleList(false); const { selectedTags, selectedQuestions, includeUnsubmitted } = this.state; - responsesService.getResponseList(this.props.event.id, includeUnsubmitted, selectedQuestions).then(resp => { + responsesService.getResponseList(this.props.event.id, includeUnsubmitted, selectedQuestions).then(resp => { this.setState({ responses: resp.responses.filter(r => selectedTags.length == 0 || r.tags.map(t => t.name).some(t => selectedTags.includes(t))), error: resp.error @@ -150,6 +150,8 @@ class ResponseListForm extends Component { val.is_submitted = val.is_submitted ? "True" : "False"; val.is_withdrawn = val.is_withdrawn ? "True" : "False"; + val.action_editor = val.action_editor ? val.action_editor.reviewer_name : ""; + // delete original review and answer rows as they don't need to be displayed with all their data delete val.answers; delete val.reviewers; @@ -232,7 +234,7 @@ class ResponseListForm extends Component { return 200 }; - if (colItem.includes('user') || colItem.includes('Review') || colItem.includes('date') || colItem.includes('email')) { + if (colItem.includes('user') || colItem.includes('Review') || colItem.includes('date') || colItem.includes('email') || colItem.includes('action_editor')) { return 180 } else { diff --git a/webapp/src/pages/ResponsePage/ResponsePage.js b/webapp/src/pages/ResponsePage/ResponsePage.js index a5f4e94f4..355a3e9c3 100644 --- a/webapp/src/pages/ResponsePage/ResponsePage.js +++ b/webapp/src/pages/ResponsePage/ResponsePage.js @@ -53,7 +53,6 @@ class ResponsePage extends Component { reviewers: responses[4].reviewers, error: responses[0].error || responses[1].error || responses[2].error || responses[3].error || responses[4].error, }, () => { - this.handleData(); this.getOutcome(); }); }); @@ -482,42 +481,54 @@ class ResponsePage extends Component { }; }; + renderActionEditor() { + const action_editor = this.state.applicationData.reviewers.find(reviewer => reviewer.is_action_editor === true) + if (action_editor) { + return
    + +
    +

    {action_editor.user_title} {action_editor.firstname} {action_editor.lastname}

    + +
    +
    + } + } // Reviews // Render Reviews renderReviews() { - if (this.state.applicationData) { - if (this.state.applicationData.reviewers) { - const reviews = this.state.applicationData.reviewers.map((val, index) => { - let num = index + 1; - // {"reviewer_user_id": 4, "user_title": "Mr", "firstname": "Joe", "lastname": "Soap", "status": "completed"}, - return
    - -
    -

    {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")} - -

    - } -
    + if (! this.state.applicationData || ! this.state.applicationData.reviewers){ + return
    + } + let num = 0 + const reviews = this.state.applicationData.reviewers.map((val) => { + // {"reviewer_user_id": 4, "user_title": "Mr", "firstname": "Joe", "lastname": "Soap", "status": "completed", "is_action_editor": False}, + if (!val.is_action_editor) { + num = num + 1; + return
    + +
    +

    {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")} + +

    + }
    - }); - - return reviews - }; +
    } - + }); + return reviews; }; // Remove Reviewer @@ -696,6 +707,14 @@ class ResponsePage extends Component { {applicationData &&
    {/* Reviews */} +
    +

    {t('Action Editor')}

    +
    + {this.renderActionEditor()} + +
    +
    +

    {t('Reviewers')}

    diff --git a/webapp/src/utils/user.js b/webapp/src/utils/user.js index 1939905b2..fc1b3bd47 100644 --- a/webapp/src/utils/user.js +++ b/webapp/src/utils/user.js @@ -11,6 +11,19 @@ export const isEventAdmin = (user, event) => { ); }; + export const isActionEditor = (user, event) => { + if (!user) { + return false; + } + return ( + user.is_admin || + (user.roles && + user.roles.some( + r => r.role === "action-editor" && event && r.event_id === event.id + )) + ); + }; + export const isRegistrationAdmin = (user, event) => { if (!user) { return false;