<commit_before>import json
import logging
from collections import defaultdict

import itertools
from lazy.lazy import lazy
import webob
from xblock.core import XBlock
from xblock.fields import String, Scope, Boolean
from xblock.validation import ValidationMessage
from group_project_v2.api_error import ApiError
from group_project_v2.stage.base import BaseGroupActivityStage
from group_project_v2.stage_components import (
    GradeRubricStaticContentXBlock, GroupProjectReviewQuestionXBlock, PeerSelectorXBlock, GroupSelectorXBlock
)
from group_project_v2.utils import (
    loader, gettext as _, make_key,
    outsider_disallowed_protected_handler, key_error_protected_handler, conversion_protected_handler,
    MUST_BE_OVERRIDDEN, memoize_with_expiration
)
from group_project_v2.stage.utils import StageState, ReviewState, DISPLAY_NAME_NAME, DISPLAY_NAME_HELP

log = logging.getLogger(__name__)


class ReviewBaseStage(BaseGroupActivityStage):
    NAVIGATION_LABEL = _(u'Task')

    visited = Boolean(default=False, scope=Scope.user_state)

    js_file = "public/js/stages/review_stage.js"
    js_init = "GroupProjectReviewStage"

    REVIEW_ITEM_KEY = None

    STAGE_ACTION = _(u"save feedback")
    FEEDBACK_SAVED_MESSAGE = _(u'Thanks for your feedback.')

    TA_GRADING_NOT_ALLOWED = _(u"TA grading is not allowed for this stage")

    # (has_some, has_all) -> ReviewState. have_all = True and have_some = False is obviously an error
    REVIEW_STATE_CONDITIONS = {
        (True, True): ReviewState.COMPLETED,
        (True, False): ReviewState.INCOMPLETE,
        (False, False): ReviewState.NOT_STARTED
    }

    # pretty much obvious mapping, but still it is useful to separate the two - more stage states could be theoretically
    # added, i.e. Open, Closed, etc. THose won't have a mapping to ReviewState
    STAGE_STATE_REVIEW_STATE_MAPPING = {
        ReviewState.COMPLETED: StageState.COMPLETED,
        ReviewState.INCOMPLETE: StageState.INCOMPLETE,
        ReviewState.NOT_STARTED: StageState.NOT_STARTED,
    }

    @property
    def allowed_nested_blocks(self):
        blocks = super(ReviewBaseStage, self).allowed_nested_blocks
        blocks.extend([GradeRubricStaticContentXBlock, GroupProjectReviewQuestionXBlock])
        return blocks

    @property
    def review_subjects(self):
        raise NotImplementedError(MUST_BE_OVERRIDDEN)

    @property
    def questions(self):
        return self.get_children_by_category(GroupProjectReviewQuestionXBlock.CATEGORY)

    @property
    def required_questions(self):
        return [question for question in self.questions if question.required]

    @property
    def grade_questions(self):
        return [question for question in self.questions if question.grade]

    def validate(self):
        violations = super(ReviewBaseStage, self).validate()

        if not self.questions:
            violations.add(ValidationMessage(
                ValidationMessage.ERROR,
                _(u"Questions are not specified for {class_name} '{stage_title}'").format(
                    class_name=self.__class__.__name__, stage_title=self.display_name
                )
            ))

        return violations

    def _group_review_items_by_reviewer(self, review_items):
        empty_values = (None, '')
        grouped = defaultdict(set)
        for review_item in review_items:
            if review_item["answer"] not in empty_values:  # exclude missing or empty answers
                reviewer = review_item['reviewer']
                review_item_key = make_key(review_item[self.REVIEW_ITEM_KEY], review_item["question"])
                grouped[reviewer].add(review_item_key)

        return grouped

    def _make_required_keys(self, items_to_grade):
        return set(
            make_key(item_id, question.question_id)
            for item_id in items_to_grade
            for question in self.required_questions
        )

    def _calculate_review_status(self, items_to_grade, reviewer_feedback_keys):
        """
        Calculates review status for all reviewers listed in review_items collection
        :param collections.Iterable[int] items_to_grade: Ids of review subjects (teammates or other groups)
        :param collections.Iterable[set[str]] reviewer_feedback_keys: Review feedback keys
        :rtype: ReviewState
        """
        required_keys = self._make_required_keys(items_to_grade)
        has_all = bool(required_keys) and reviewer_feedback_keys >= required_keys
        has_some = bool(reviewer_feedback_keys & required_keys)

        return self.REVIEW_STATE_CONDITIONS.get((has_some, has_all))

    def _check_review_status(self, items_to_grade, review_items):
        """
        Calculates review status for current user
        :param collections.Iterable[int] items_to_grade: Ids of review subjects (teammates or other groups)
        :param collections.Iterable[dict] review_items: review items (answers to review questions)
        :rtype: ReviewState
        """
        my_feedback = [item for item in review_items if item['reviewer'] == self.anonymous_student_id]
        grouped_review_keys = self._group_review_items_by_reviewer(my_feedback)
        my_review_keys = grouped_review_keys.get(self.anonymous_student_id, set())
        return self._calculate_review_status(items_to_grade, my_review_keys)

    def get_stage_state(self):
        review_status = self.review_status()

        if not self.visited:
            return StageState.NOT_STARTED

        return self.STAGE_STATE_REVIEW_STATE_MAPPING[review_status]

    def _pivot_feedback(self, feedback):  # pylint: disable=no-self-use
        """
        Pivots the feedback to show question -> answer
        """
        return {pi['question']: pi['answer'] for pi in feedback}

    def get_users_completion(self, target_workgroups, target_users):
        """
        :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups:
        :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_users:
        :rtype: (set[int], set[int])
        """
        completed_users, partially_completed_users = set(), set()

        for user in target_users:
            review_items, review_subjects = self.get_review_data(user.id)
            grouped_review_items = {
                self.real_user_id(anonymous_id): grouped_items
                for anonymous_id, grouped_items in self._group_review_items_by_reviewer(review_items).iteritems()
            }
            this_user_reviews = grouped_review_items.get(user.id, set())
            review_status = self._calculate_review_status(review_subjects, this_user_reviews)

            if review_status == ReviewState.COMPLETED:
                completed_users.add(user.id)
            elif review_status == ReviewState.INCOMPLETE:
                partially_completed_users.add(user.id)

        return completed_users, partially_completed_users

    def get_review_data(self, user_id):
        """
        :param gint user_id:
        :rtype: (dict, set[int])
        """
        raise NotImplementedError(MUST_BE_OVERRIDDEN)

    @XBlock.json_handler
    @outsider_disallowed_protected_handler
    @key_error_protected_handler
    @conversion_protected_handler
    def submit_review(self, submissions, context=''):  # pylint: disable=unused-argument
        # if admin grader - still allow providing grades even for non-TA-graded activities
        if self.is_admin_grader and not self.allow_admin_grader_access:
            return {'result': 'error', 'msg': self.TA_GRADING_NOT_ALLOWED}

        if not self.available_now:
            reason = self.STAGE_NOT_OPEN_TEMPLATE if not self.is_open else self.STAGE_CLOSED_TEMPLATE
            return {'result': 'error', 'msg': reason.format(action=self.STAGE_ACTION)}

        try:
            self.do_submit_review(submissions)

            if self.can_mark_complete and self.review_status() == ReviewState.COMPLETED:
                self.mark_complete()
        except ApiError as exception:
            log.exception(exception.message)
            return {'result': 'error', 'msg': exception.message}

        return {
            'result': 'success',
            'msg': self.FEEDBACK_SAVED_MESSAGE,
            'new_stage_states': [self.get_new_stage_state_data()]
        }

    def do_submit_review(self, submissions):
        raise NotImplementedError(MUST_BE_OVERRIDDEN)

    def student_view(self, context):
        if self.can_mark_complete:
            self.visited = True

        return super(ReviewBaseStage, self).student_view(context)


class TeamEvaluationStage(ReviewBaseStage):
    display_name = String(
        display_name=DISPLAY_NAME_NAME,
        help=DISPLAY_NAME_HELP,
        scope=Scope.content,
        default=_(u"Team Evaluation Stage")
    )

    CATEGORY = 'gp-v2-stage-team-evaluation'
    STAGE_CONTENT_TEMPLATE = 'templates/html/stages/team_evaluation.html'

    STUDIO_LABEL = _(u"Team Evaluation")

    REVIEW_ITEM_KEY = "user"

    @lazy
    def review_subjects(self):
        return [user for user in self.workgroup.users if user.id != self.user_id]

    @property
    def allowed_nested_blocks(self):
        blocks = super(TeamEvaluationStage, self).allowed_nested_blocks
        blocks.extend([PeerSelectorXBlock])
        return blocks

    def review_status(self):
        peer_review_items = self.project_api.get_peer_review_items_for_group(
            self.workgroup.id, self.activity_content_id
        )

        return self._check_review_status([user.id for user in self.review_subjects], peer_review_items)

    def get_review_data(self, user_id):
        """
        :param int user_id: User ID
        :rtype: (dict, set[int])
        """
        workgroup = self.project_api.get_user_workgroup_for_course(user_id, self.course_id)
        review_subjects = set(user.id for user in workgroup.users) - {user_id}
        review_items = self._get_review_items_for_group(self.project_api, workgroup.id, self.activity_content_id)
        return review_items, review_subjects

    @staticmethod
    @memoize_with_expiration()
    def _get_review_items_for_group(project_api, workgroup_id, activity_content_id):
        return project_api.get_peer_review_items_for_group(workgroup_id, activity_content_id)

    def validate(self):
        violations = super(TeamEvaluationStage, self).validate()

        # Technically, nothing prevents us from allowing graded peer review questions. The only reason why
        # they are considered not supported is that GroupActivityXBlock.calculate_grade does not
        # take them into account.
        if self.grade_questions:
            violations.add(ValidationMessage(
                ValidationMessage.ERROR,
                _(u"Grade questions are not supported for {class_name} stage '{stage_title}'").format(
                    class_name=self.STUDIO_LABEL, stage_title=self.display_name
                )
            ))

        if not self.has_child_of_category(PeerSelectorXBlock.CATEGORY):
            violations.add(ValidationMessage(
                ValidationMessage.ERROR,
                _(
                    u"{class_name} stage '{stage_title}' is missing required component '{peer_selector_class_name}'"
                ).format(
                    class_name=self.STUDIO_LABEL, stage_title=self.display_name,
                    peer_selector_class_name=PeerSelectorXBlock.STUDIO_LABEL
                )
            ))

        return violations

    @XBlock.handler
    @outsider_disallowed_protected_handler
    @key_error_protected_handler
    @conversion_protected_handler
    def load_peer_feedback(self, request, suffix=''):  # pylint: disable=unused-argument
        peer_id = int(request.GET["peer_id"])
        feedback = self.project_api.get_peer_review_items(
            self.anonymous_student_id,
            peer_id,
            self.workgroup.id,
            self.activity_content_id,
        )
        results = self._pivot_feedback(feedback)

        return webob.response.Response(body=json.dumps(results))

    def do_submit_review(self, submissions):
        peer_id = int(submissions["review_subject_id"])
        del submissions["review_subject_id"]

        self.project_api.submit_peer_review_items(
            self.anonymous_student_id,
            peer_id,
            self.workgroup.id,
            self.activity_content_id,
            submissions,
        )


class PeerReviewStage(ReviewBaseStage):
    display_name = String(
        display_name=DISPLAY_NAME_NAME,
        help=DISPLAY_NAME_HELP,
        scope=Scope.content,
        default=_(u"Peer Grading Stage")
    )

    CATEGORY = 'gp-v2-stage-peer-review'
    STAGE_CONTENT_TEMPLATE = 'templates/html/stages/peer_review.html'

    STUDIO_LABEL = _(u"Peer Grading")

    REVIEW_ITEM_KEY = "workgroup"

    @property
    def allowed_nested_blocks(self):
        blocks = super(PeerReviewStage, self).allowed_nested_blocks
        blocks.extend([GroupSelectorXBlock])
        return blocks

    @property
    def allow_admin_grader_access(self):
        return True

    @lazy
    def review_subjects(self):
        """
        Returns groups to review. May throw `class`: OutsiderDisallowedError
        :rtype: list[group_project_v2.project_api.dtos.WorkgroupDetails]
        """
        if self.is_admin_grader:
            return [self.workgroup]

        try:
            return self.project_api.get_workgroups_to_review(self.user_id, self.course_id, self.activity_content_id)
        except ApiError:
            log.exception("Error obtaining list of groups to grade - assuming no groups to grade")
            return []

    @property
    def review_groups(self):
        return self.review_subjects

    @property
    def available_to_current_user(self):
        if not super(PeerReviewStage, self).available_to_current_user:
            return False

        if not self.is_admin_grader and self.activity.is_ta_graded:
            return False

        return True

    @property
    def is_graded_stage(self):
        return True

    def review_status(self):
        group_review_items = list(itertools.chain.from_iterable(
            self._get_review_items_for_group(self.project_api, group.id, self.activity_content_id)
            for group in self.review_groups
        ))

        return self._check_review_status([group.id for group in self.review_groups], group_review_items)

    def get_review_data(self, user_id):
        """
        :param int user_id:
        :rtype: (dict, set[int])
        """
        review_subjects = self.project_api.get_workgroups_to_review(user_id, self.course_id, self.activity_content_id)
        review_items = list(itertools.chain.from_iterable(
            self._get_review_items_for_group(self.project_api, group.id, self.activity_content_id)
            for group in review_subjects
        ))
        return review_items, set(group.id for group in review_subjects)

    @staticmethod
    @memoize_with_expiration()
    def _get_review_items_for_group(project_api, workgroup_id, activity_content_id):
        return project_api.get_workgroup_review_items_for_group(workgroup_id, activity_content_id)

    def validate(self):
        violations = super(PeerReviewStage, self).validate()

        if not self.grade_questions:
            violations.add(ValidationMessage(
                ValidationMessage.ERROR,
                _(u"Grade questions are required for {class_name} stage '{stage_title}'").format(
                    class_name=self.STUDIO_LABEL, stage_title=self.display_name
                )
            ))

        if not self.has_child_of_category(GroupSelectorXBlock.CATEGORY):
            violations.add(ValidationMessage(
                ValidationMessage.ERROR,
                _(
                    u"{class_name} stage '{stage_title}' is missing required component '{group_selector_class_name}'"
                ).format(
                    class_name=self.STUDIO_LABEL, stage_title=self.display_name,
                    group_selector_class_name=GroupSelectorXBlock.STUDIO_LABEL
                )
            ))

        return violations

    def get_stage_state(self):
        if not self.review_subjects:
            return StageState.NOT_STARTED

        return super(PeerReviewStage, self).get_stage_state()

    @XBlock.handler
    @outsider_disallowed_protected_handler
    @key_error_protected_handler
    @conversion_protected_handler
    def other_submission_links(self, request, suffix=''):  # pylint: disable=unused-argument
        group_id = int(request.GET["group_id"])

        target_stages = [stage for stage in self.activity.stages if stage.submissions_stage]

        submission_stage_contents = []
        for stage in target_stages:
            stage_fragment = stage.render('review_submissions_view', {'group_id': group_id})
            submission_stage_contents.append(stage_fragment.content)

        context = {'block': self, 'submission_stage_contents': submission_stage_contents}
        html_output = loader.render_template(
            '/templates/html/stages/stages_group_review_other_team_submissions.html', context
        )

        return webob.response.Response(body=json.dumps({"html": html_output}))

    @XBlock.handler
    @outsider_disallowed_protected_handler
    @key_error_protected_handler
    def load_other_group_feedback(self, request, suffix=''):  # pylint: disable=unused-argument
        group_id = int(request.GET["group_id"])
        feedback = self.project_api.get_workgroup_review_items(
            self.anonymous_student_id, group_id, self.activity_content_id
        )
        results = self._pivot_feedback(feedback)

        return webob.response.Response(body=json.dumps(results))

    def do_submit_review(self, submissions):
        group_id = int(submissions["review_subject_id"])
        del submissions["review_subject_id"]

        self.project_api.submit_workgroup_review_items(
            self.anonymous_student_id,
            group_id,
            self.activity_content_id,
            submissions
        )

        for question_id in self.grade_questions:
            if question_id in submissions:
                # Emit analytics event...
                self.runtime.publish(
                    self,
                    "group_activity.received_grade_question_score",
                    {
                        "question": question_id,
                        "answer": submissions[question_id],
                        "reviewer_id": self.anonymous_student_id,
                        "is_admin_grader": self.is_admin_grader,
                        "group_id": group_id,
                        "content_id": self.activity_content_id,
                    }
                )

        self.activity.calculate_and_send_grade(group_id)
<commit_msg>Fixed broken integration test (and corresponding issue)<commit_after>import json
import logging
from collections import defaultdict

import itertools
from lazy.lazy import lazy
import webob
from xblock.core import XBlock
from xblock.fields import String, Scope, Boolean
from xblock.validation import ValidationMessage
from group_project_v2.api_error import ApiError
from group_project_v2.stage.base import BaseGroupActivityStage
from group_project_v2.stage_components import (
    GradeRubricStaticContentXBlock, GroupProjectReviewQuestionXBlock, PeerSelectorXBlock, GroupSelectorXBlock
)
from group_project_v2.utils import (
    loader, gettext as _, make_key,
    outsider_disallowed_protected_handler, key_error_protected_handler, conversion_protected_handler,
    MUST_BE_OVERRIDDEN, memoize_with_expiration
)
from group_project_v2.stage.utils import StageState, ReviewState, DISPLAY_NAME_NAME, DISPLAY_NAME_HELP

log = logging.getLogger(__name__)


class ReviewBaseStage(BaseGroupActivityStage):
    NAVIGATION_LABEL = _(u'Task')

    visited = Boolean(default=False, scope=Scope.user_state)

    js_file = "public/js/stages/review_stage.js"
    js_init = "GroupProjectReviewStage"

    REVIEW_ITEM_KEY = None

    STAGE_ACTION = _(u"save feedback")
    FEEDBACK_SAVED_MESSAGE = _(u'Thanks for your feedback.')

    TA_GRADING_NOT_ALLOWED = _(u"TA grading is not allowed for this stage")

    # (has_some, has_all) -> ReviewState. have_all = True and have_some = False is obviously an error
    REVIEW_STATE_CONDITIONS = {
        (True, True): ReviewState.COMPLETED,
        (True, False): ReviewState.INCOMPLETE,
        (False, False): ReviewState.NOT_STARTED
    }

    # pretty much obvious mapping, but still it is useful to separate the two - more stage states could be theoretically
    # added, i.e. Open, Closed, etc. THose won't have a mapping to ReviewState
    STAGE_STATE_REVIEW_STATE_MAPPING = {
        ReviewState.COMPLETED: StageState.COMPLETED,
        ReviewState.INCOMPLETE: StageState.INCOMPLETE,
        ReviewState.NOT_STARTED: StageState.NOT_STARTED,
    }

    @property
    def allowed_nested_blocks(self):
        blocks = super(ReviewBaseStage, self).allowed_nested_blocks
        blocks.extend([GradeRubricStaticContentXBlock, GroupProjectReviewQuestionXBlock])
        return blocks

    @property
    def review_subjects(self):
        raise NotImplementedError(MUST_BE_OVERRIDDEN)

    @property
    def questions(self):
        return self.get_children_by_category(GroupProjectReviewQuestionXBlock.CATEGORY)

    @property
    def required_questions(self):
        return [question for question in self.questions if question.required]

    @property
    def grade_questions(self):
        return [question for question in self.questions if question.grade]

    def validate(self):
        violations = super(ReviewBaseStage, self).validate()

        if not self.questions:
            violations.add(ValidationMessage(
                ValidationMessage.ERROR,
                _(u"Questions are not specified for {class_name} '{stage_title}'").format(
                    class_name=self.__class__.__name__, stage_title=self.display_name
                )
            ))

        return violations

    def _group_review_items_by_reviewer(self, review_items):
        empty_values = (None, '')
        grouped = defaultdict(set)
        for review_item in review_items:
            if review_item["answer"] not in empty_values:  # exclude missing or empty answers
                reviewer = review_item['reviewer']
                review_item_key = make_key(review_item[self.REVIEW_ITEM_KEY], review_item["question"])
                grouped[reviewer].add(review_item_key)

        return grouped

    def _make_required_keys(self, items_to_grade):
        return set(
            make_key(item_id, question.question_id)
            for item_id in items_to_grade
            for question in self.required_questions
        )

    def _calculate_review_status(self, items_to_grade, reviewer_feedback_keys):
        """
        Calculates review status for all reviewers listed in review_items collection
        :param collections.Iterable[int] items_to_grade: Ids of review subjects (teammates or other groups)
        :param collections.Iterable[set[str]] reviewer_feedback_keys: Review feedback keys
        :rtype: ReviewState
        """
        required_keys = self._make_required_keys(items_to_grade)
        has_all = bool(required_keys) and reviewer_feedback_keys >= required_keys
        has_some = bool(reviewer_feedback_keys & required_keys)

        return self.REVIEW_STATE_CONDITIONS.get((has_some, has_all))

    def _check_review_status(self, items_to_grade, review_items):
        """
        Calculates review status for current user
        :param collections.Iterable[int] items_to_grade: Ids of review subjects (teammates or other groups)
        :param collections.Iterable[dict] review_items: review items (answers to review questions)
        :rtype: ReviewState
        """
        my_feedback = [item for item in review_items if item['reviewer'] == self.anonymous_student_id]
        grouped_review_keys = self._group_review_items_by_reviewer(my_feedback)
        my_review_keys = grouped_review_keys.get(self.anonymous_student_id, set())
        return self._calculate_review_status(items_to_grade, my_review_keys)

    def get_stage_state(self):
        review_status = self.review_status()

        if not self.visited:
            return StageState.NOT_STARTED

        return self.STAGE_STATE_REVIEW_STATE_MAPPING[review_status]

    def _pivot_feedback(self, feedback):  # pylint: disable=no-self-use
        """
        Pivots the feedback to show question -> answer
        """
        return {pi['question']: pi['answer'] for pi in feedback}

    def get_users_completion(self, target_workgroups, target_users):
        """
        :param collections.Iterable[group_project_v2.project_api.dtos.WorkgroupDetails] target_workgroups:
        :param collections.Iterable[group_project_v2.project_api.dtos.ReducedUserDetails] target_users:
        :rtype: (set[int], set[int])
        """
        completed_users, partially_completed_users = set(), set()

        for user in target_users:
            review_items, review_subjects = self.get_review_data(user.id)
            grouped_review_items = {
                self.real_user_id(anonymous_id): grouped_items
                for anonymous_id, grouped_items in self._group_review_items_by_reviewer(review_items).iteritems()
            }
            this_user_reviews = grouped_review_items.get(user.id, set())
            review_status = self._calculate_review_status(review_subjects, this_user_reviews)

            if review_status == ReviewState.COMPLETED:
                completed_users.add(user.id)
            elif review_status == ReviewState.INCOMPLETE:
                partially_completed_users.add(user.id)

        return completed_users, partially_completed_users

    def get_review_data(self, user_id):
        """
        :param gint user_id:
        :rtype: (dict, set[int])
        """
        raise NotImplementedError(MUST_BE_OVERRIDDEN)

    @XBlock.json_handler
    @outsider_disallowed_protected_handler
    @key_error_protected_handler
    @conversion_protected_handler
    def submit_review(self, submissions, context=''):  # pylint: disable=unused-argument
        # if admin grader - still allow providing grades even for non-TA-graded activities
        if self.is_admin_grader and not self.allow_admin_grader_access:
            return {'result': 'error', 'msg': self.TA_GRADING_NOT_ALLOWED}

        if not self.available_now:
            reason = self.STAGE_NOT_OPEN_TEMPLATE if not self.is_open else self.STAGE_CLOSED_TEMPLATE
            return {'result': 'error', 'msg': reason.format(action=self.STAGE_ACTION)}

        try:
            self.do_submit_review(submissions)

            if self.can_mark_complete and self.review_status() == ReviewState.COMPLETED:
                self.mark_complete()
        except ApiError as exception:
            log.exception(exception.message)
            return {'result': 'error', 'msg': exception.message}

        return {
            'result': 'success',
            'msg': self.FEEDBACK_SAVED_MESSAGE,
            'new_stage_states': [self.get_new_stage_state_data()]
        }

    def do_submit_review(self, submissions):
        raise NotImplementedError(MUST_BE_OVERRIDDEN)

    def student_view(self, context):
        if self.can_mark_complete:
            self.visited = True

        return super(ReviewBaseStage, self).student_view(context)


class TeamEvaluationStage(ReviewBaseStage):
    display_name = String(
        display_name=DISPLAY_NAME_NAME,
        help=DISPLAY_NAME_HELP,
        scope=Scope.content,
        default=_(u"Team Evaluation Stage")
    )

    CATEGORY = 'gp-v2-stage-team-evaluation'
    STAGE_CONTENT_TEMPLATE = 'templates/html/stages/team_evaluation.html'

    STUDIO_LABEL = _(u"Team Evaluation")

    REVIEW_ITEM_KEY = "user"

    @lazy
    def review_subjects(self):
        return [user for user in self.workgroup.users if user.id != self.user_id]

    @property
    def allowed_nested_blocks(self):
        blocks = super(TeamEvaluationStage, self).allowed_nested_blocks
        blocks.extend([PeerSelectorXBlock])
        return blocks

    def review_status(self):
        peer_review_items = self.project_api.get_peer_review_items_for_group(
            self.workgroup.id, self.activity_content_id
        )

        return self._check_review_status([user.id for user in self.review_subjects], peer_review_items)

    def get_review_data(self, user_id):
        """
        :param int user_id: User ID
        :rtype: (dict, set[int])
        """
        workgroup = self.project_api.get_user_workgroup_for_course(user_id, self.course_id)
        review_subjects = set(user.id for user in workgroup.users) - {user_id}
        review_items = self._get_review_items_for_group(self.project_api, workgroup.id, self.activity_content_id)
        return review_items, review_subjects

    @staticmethod
    @memoize_with_expiration()
    def _get_review_items_for_group(project_api, workgroup_id, activity_content_id):
        return project_api.get_peer_review_items_for_group(workgroup_id, activity_content_id)

    def validate(self):
        violations = super(TeamEvaluationStage, self).validate()

        # Technically, nothing prevents us from allowing graded peer review questions. The only reason why
        # they are considered not supported is that GroupActivityXBlock.calculate_grade does not
        # take them into account.
        if self.grade_questions:
            violations.add(ValidationMessage(
                ValidationMessage.ERROR,
                _(u"Grade questions are not supported for {class_name} stage '{stage_title}'").format(
                    class_name=self.STUDIO_LABEL, stage_title=self.display_name
                )
            ))

        if not self.has_child_of_category(PeerSelectorXBlock.CATEGORY):
            violations.add(ValidationMessage(
                ValidationMessage.ERROR,
                _(
                    u"{class_name} stage '{stage_title}' is missing required component '{peer_selector_class_name}'"
                ).format(
                    class_name=self.STUDIO_LABEL, stage_title=self.display_name,
                    peer_selector_class_name=PeerSelectorXBlock.STUDIO_LABEL
                )
            ))

        return violations

    @XBlock.handler
    @outsider_disallowed_protected_handler
    @key_error_protected_handler
    @conversion_protected_handler
    def load_peer_feedback(self, request, suffix=''):  # pylint: disable=unused-argument
        peer_id = int(request.GET["peer_id"])
        feedback = self.project_api.get_peer_review_items(
            self.anonymous_student_id,
            peer_id,
            self.workgroup.id,
            self.activity_content_id,
        )
        results = self._pivot_feedback(feedback)

        return webob.response.Response(body=json.dumps(results))

    def do_submit_review(self, submissions):
        peer_id = int(submissions["review_subject_id"])
        del submissions["review_subject_id"]

        self.project_api.submit_peer_review_items(
            self.anonymous_student_id,
            peer_id,
            self.workgroup.id,
            self.activity_content_id,
            submissions,
        )


class PeerReviewStage(ReviewBaseStage):
    display_name = String(
        display_name=DISPLAY_NAME_NAME,
        help=DISPLAY_NAME_HELP,
        scope=Scope.content,
        default=_(u"Peer Grading Stage")
    )

    CATEGORY = 'gp-v2-stage-peer-review'
    STAGE_CONTENT_TEMPLATE = 'templates/html/stages/peer_review.html'

    STUDIO_LABEL = _(u"Peer Grading")

    REVIEW_ITEM_KEY = "workgroup"

    @property
    def allowed_nested_blocks(self):
        blocks = super(PeerReviewStage, self).allowed_nested_blocks
        blocks.extend([GroupSelectorXBlock])
        return blocks

    @property
    def allow_admin_grader_access(self):
        return True

    @lazy
    def review_subjects(self):
        """
        Returns groups to review. May throw `class`: OutsiderDisallowedError
        :rtype: list[group_project_v2.project_api.dtos.WorkgroupDetails]
        """
        if self.is_admin_grader:
            return [self.workgroup]

        try:
            return self.project_api.get_workgroups_to_review(self.user_id, self.course_id, self.activity_content_id)
        except ApiError:
            log.exception("Error obtaining list of groups to grade - assuming no groups to grade")
            return []

    @property
    def review_groups(self):
        return self.review_subjects

    @property
    def available_to_current_user(self):
        if not super(PeerReviewStage, self).available_to_current_user:
            return False

        if not self.is_admin_grader and self.activity.is_ta_graded:
            return False

        return True

    @property
    def is_graded_stage(self):
        return True

    def review_status(self):
        group_review_items = list(itertools.chain.from_iterable(
            self.project_api.get_workgroup_review_items_for_group(group.id, self.activity_content_id)
            for group in self.review_groups
        ))
        return self._check_review_status([group.id for group in self.review_groups], group_review_items)

    def get_review_data(self, user_id):
        """
        :param int user_id:
        :rtype: (dict, set[int])
        """
        review_subjects = self.project_api.get_workgroups_to_review(user_id, self.course_id, self.activity_content_id)
        review_items = list(itertools.chain.from_iterable(
            self._get_review_items_for_group(self.project_api, group.id, self.activity_content_id)
            for group in review_subjects
        ))
        return review_items, set(group.id for group in review_subjects)

    @staticmethod
    @memoize_with_expiration()
    def _get_review_items_for_group(project_api, workgroup_id, activity_content_id):
        return project_api.get_workgroup_review_items_for_group(workgroup_id, activity_content_id)

    def validate(self):
        violations = super(PeerReviewStage, self).validate()

        if not self.grade_questions:
            violations.add(ValidationMessage(
                ValidationMessage.ERROR,
                _(u"Grade questions are required for {class_name} stage '{stage_title}'").format(
                    class_name=self.STUDIO_LABEL, stage_title=self.display_name
                )
            ))

        if not self.has_child_of_category(GroupSelectorXBlock.CATEGORY):
            violations.add(ValidationMessage(
                ValidationMessage.ERROR,
                _(
                    u"{class_name} stage '{stage_title}' is missing required component '{group_selector_class_name}'"
                ).format(
                    class_name=self.STUDIO_LABEL, stage_title=self.display_name,
                    group_selector_class_name=GroupSelectorXBlock.STUDIO_LABEL
                )
            ))

        return violations

    def get_stage_state(self):
        if not self.review_subjects:
            return StageState.NOT_STARTED

        return super(PeerReviewStage, self).get_stage_state()

    @XBlock.handler
    @outsider_disallowed_protected_handler
    @key_error_protected_handler
    @conversion_protected_handler
    def other_submission_links(self, request, suffix=''):  # pylint: disable=unused-argument
        group_id = int(request.GET["group_id"])

        target_stages = [stage for stage in self.activity.stages if stage.submissions_stage]

        submission_stage_contents = []
        for stage in target_stages:
            stage_fragment = stage.render('review_submissions_view', {'group_id': group_id})
            submission_stage_contents.append(stage_fragment.content)

        context = {'block': self, 'submission_stage_contents': submission_stage_contents}
        html_output = loader.render_template(
            '/templates/html/stages/stages_group_review_other_team_submissions.html', context
        )

        return webob.response.Response(body=json.dumps({"html": html_output}))

    @XBlock.handler
    @outsider_disallowed_protected_handler
    @key_error_protected_handler
    def load_other_group_feedback(self, request, suffix=''):  # pylint: disable=unused-argument
        group_id = int(request.GET["group_id"])
        feedback = self.project_api.get_workgroup_review_items(
            self.anonymous_student_id, group_id, self.activity_content_id
        )
        results = self._pivot_feedback(feedback)

        return webob.response.Response(body=json.dumps(results))

    def do_submit_review(self, submissions):
        group_id = int(submissions["review_subject_id"])
        del submissions["review_subject_id"]

        self.project_api.submit_workgroup_review_items(
            self.anonymous_student_id,
            group_id,
            self.activity_content_id,
            submissions
        )

        for question_id in self.grade_questions:
            if question_id in submissions:
                # Emit analytics event...
                self.runtime.publish(
                    self,
                    "group_activity.received_grade_question_score",
                    {
                        "question": question_id,
                        "answer": submissions[question_id],
                        "reviewer_id": self.anonymous_student_id,
                        "is_admin_grader": self.is_admin_grader,
                        "group_id": group_id,
                        "content_id": self.activity_content_id,
                    }
                )

        self.activity.calculate_and_send_grade(group_id)

{'fraction_non_alphanumeric': 0.07745061695705131, 'fraction_numerical': 0.0005295768680824022, 'mean_word_length': 2.830713054062278, 'pattern_counts': {'":': 14, '<': 3, '<?xml version=': 0, '>': 9, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '5427702', 'n_tokens_mistral': 11061, 'n_tokens_neox': 10664, 'n_words': 2065}