diff --git a/backend/reviews/admin.py b/backend/reviews/admin.py index 4ec1e5af6d..6e0628ac5f 100644 --- a/backend/reviews/admin.py +++ b/backend/reviews/admin.py @@ -1,26 +1,14 @@ -from django.contrib.postgres.expressions import ArraySubquery -from django.db.models.expressions import ExpressionWrapper -from django.db.models import FloatField -from django.db.models.functions import Cast -from users.admin_mixins import ConferencePermissionMixin -from django.core.exceptions import PermissionDenied -from django.db.models import Q, Exists -import urllib.parse - from django import forms -from django.contrib import admin, messages -from django.db.models import Count, F, OuterRef, Prefetch, Subquery, Sum, Avg +from django.contrib import admin from django.http.request import HttpRequest -from django.shortcuts import redirect -from django.template.response import TemplateResponse from django.urls import path, reverse from django.utils.safestring import mark_safe -from grants.models import Grant -from participants.models import Participant +from users.admin_mixins import ConferencePermissionMixin from reviews.models import AvailableScoreOption, ReviewSession, UserReview -from submissions.models import Submission, SubmissionTag -from users.models import User +from reviews.admin_mixins import ReviewSessionAdminMixin, ReviewSessionViewMixin +from reviews.services import ReviewSessionService +from submissions.models import SubmissionTag class AvailableScoreOptionInline(admin.TabularInline): @@ -34,7 +22,6 @@ def get_readonly_fields(self, request: HttpRequest, obj): def get_all_tags(): - # todo improve :) return SubmissionTag.objects.values_list("id", "name") @@ -91,7 +78,12 @@ def __init__(self, *args, **kwargs) -> None: @admin.register(ReviewSession) -class ReviewSessionAdmin(ConferencePermissionMixin, admin.ModelAdmin): +class ReviewSessionAdmin( + ReviewSessionAdminMixin, + ReviewSessionViewMixin, + ConferencePermissionMixin, + admin.ModelAdmin, +): form = ReviewSessionForm inlines = [ AvailableScoreOptionInline, @@ -150,38 +142,6 @@ def get_readonly_fields(self, request: HttpRequest, obj): return fields - @admin.display(description="Review Item Screen") - def go_to_review_screen(self, obj): - if not obj.id: - return "" - - if not obj.can_review_items: - return "You cannot review." - - return mark_safe( - f""" - - Go to review screen - -""" - ) - - @admin.display(description="Recap Screen") - def go_to_recap_screen(self, obj): - if not obj.id: - return "" - - if not obj.can_see_recap_screen: - return "You cannot see the recap of this session yet." - - return mark_safe( - f""" - - Go to recap screen - -""" - ) - def get_urls(self): return [ path( @@ -201,601 +161,15 @@ def get_urls(self): ), ] + super().get_urls() - def review_start_view(self, request, review_session_id): - review_session = ReviewSession.objects.get(id=review_session_id) - next_to_review = get_next_to_review_item_id(review_session, request.user) - - if not next_to_review: - messages.warning(request, "No new proposal to review.") - return redirect( - reverse( - "admin:reviews-recap", - kwargs={ - "review_session_id": review_session_id, - }, - ) - ) - - return redirect( - reverse( - "admin:reviews-vote-view", - kwargs={ - "review_session_id": review_session_id, - "review_item_id": next_to_review, - }, - ) - ) - - def review_recap_view(self, request, review_session_id): - review_session = ReviewSession.objects.get(id=review_session_id) - - if not review_session.user_can_review(request.user): - raise PermissionDenied() - - if not review_session.can_see_recap_screen: - messages.error(request, "You cannot see the recap of this session yet.") - return redirect( - reverse( - "admin:reviews_reviewsession_change", - kwargs={ - "object_id": review_session_id, - }, - ) - ) - - if review_session.is_proposals_review: - return self._review_proposals_recap_view(request, review_session) - - if review_session.is_grants_review: - return self._review_grants_recap_view(request, review_session) - - def _review_grants_recap_view(self, request, review_session): - review_session_id = review_session.id - - if request.method == "POST": - if not request.user.has_perm( - "reviews.decision_reviewsession", review_session - ): - raise PermissionDenied() - data = request.POST - - decisions = { - int(key.split("-")[1]): value - for [key, value] in data.items() - if key.startswith("decision-") - } - - approved_type_decisions = { - int(key.split("-")[1]): value - for [key, value] in data.items() - if key.startswith("approvedtype-") - } - - grants = list( - review_session.conference.grants.filter(id__in=decisions.keys()).all() - ) - - for grant in grants: - decision = decisions[grant.id] - if decision not in Grant.REVIEW_SESSION_STATUSES_OPTIONS: - continue - - approved_type = approved_type_decisions.get(grant.id, "") - - if decision != grant.status: - grant.pending_status = decision - elif decision == grant.status: - grant.pending_status = None - - grant.approved_type = ( - approved_type if decision == Grant.Status.approved else None - ) - - for grant in grants: - # save each to make sure we re-calculate the grants amounts - # TODO: move the amount calculation in a separate function maybe? - grant.save(update_fields=["pending_status", "approved_type"]) - - messages.success( - request, "Decisions saved. Check the Grants Summary for more info." - ) - - return redirect( - reverse( - "admin:reviews-recap", - kwargs={ - "review_session_id": review_session_id, - }, - ) - ) - - items = ( - review_session.conference.grants.annotate( - total_score=Cast( - Sum( - "userreview__score__numeric_value", - filter=Q(userreview__review_session_id=review_session_id), - ), - output_field=FloatField(), - ), - vote_count=Cast( - Count( - "userreview", - filter=Q(userreview__review_session_id=review_session_id), - ), - output_field=FloatField(), - ), - score=ExpressionWrapper( - F("total_score") / F("vote_count"), - output_field=FloatField(), - ), - has_sent_a_proposal=Exists( - Submission.objects.non_cancelled().filter( - speaker_id=OuterRef("user_id"), - conference_id=review_session.conference_id, - ) - ), - proposals_ids=ArraySubquery( - Submission.objects.non_cancelled() - .filter( - speaker_id=OuterRef("user_id"), - conference_id=review_session.conference_id, - ) - .values("id") - ), - ) - .order_by(F("score").desc(nulls_last=True)) - .prefetch_related( - Prefetch( - "userreview_set", - queryset=UserReview.objects.prefetch_related( - "user", "score" - ).filter(review_session_id=review_session_id), - ), - "user", - ) - .all() - ) - - proposals = { - submission.id: submission - for submission in Submission.objects.non_cancelled() - .filter( - conference_id=review_session.conference_id, - speaker_id__in=items.values_list("user_id"), - ) - .prefetch_related("rankings", "rankings__tag") - } - - context = dict( - self.admin_site.each_context(request), - request=request, - items=items, - proposals=proposals, - review_session_id=review_session_id, - review_session_repr=str(review_session), - all_review_statuses=[ - choice - for choice in Grant.Status.choices - if choice[0] in Grant.REVIEW_SESSION_STATUSES_OPTIONS - ], - all_statuses=Grant.Status.choices, - all_approved_types=[choice for choice in Grant.ApprovedType.choices], - review_session=review_session, - title="Recap", - ) - return TemplateResponse(request, "grants-recap.html", context) - - def _review_proposals_recap_view(self, request, review_session): - review_session_id = review_session.id - conference = review_session.conference - - if request.method == "POST": - if not request.user.has_perm( - "reviews.decision_reviewsession", review_session - ): - raise PermissionDenied() - - data = request.POST - - decisions = { - int(key.split("-")[1]): value - for [key, value] in data.items() - if key.startswith("decision-") - } - - proposals = list( - conference.submissions.filter(id__in=decisions.keys()).all() - ) - - for proposal in proposals: - decision = decisions[proposal.id] - proposal.pending_status = decision - - Submission.objects.bulk_update( - proposals, - fields=["pending_status"], - ) - - return redirect( - reverse( - "admin:reviews-recap", - kwargs={ - "review_session_id": review_session_id, - }, - ) - ) - - items = ( - Submission.objects.for_conference(review_session.conference_id) - .non_cancelled() - .annotate( - score=Subquery( - UserReview.objects.select_related("score") - .filter( - review_session_id=review_session_id, - proposal_id=OuterRef("id"), - ) - .values("proposal_id") - .annotate(score=Avg("score__numeric_value")) - .values("score") - ) - ) - .order_by(F("score").desc(nulls_last=True)) - .prefetch_related( - Prefetch( - "userreview_set", - queryset=UserReview.objects.prefetch_related( - "user", "score" - ).filter(review_session_id=review_session_id), - ), - "duration", - "audience_level", - "languages", - "speaker", - "tags", - "type", - "rankings", - "rankings__tag", - ) - .all() - ) - - speakers_ids = items.values_list("speaker_id", flat=True) - - grants = { - str(grant.user_id): grant - for grant in Grant.objects.filter( - conference=conference, user_id__in=speakers_ids - ).all() - } - - context = dict( - self.admin_site.each_context(request), - items=items, - grants=grants, - review_session_id=review_session_id, - audience_levels=conference.audience_levels.all(), - review_session_repr=str(review_session), - all_statuses=[choice for choice in Submission.STATUS], - title="Recap", - ) - return TemplateResponse(request, "proposals-recap.html", context) - - def review_view(self, request, review_session_id, review_item_id): - review_session = ReviewSession.objects.get(id=review_session_id) - - if not review_session.user_can_review(request.user): - raise PermissionDenied() - - filter_options = {} - - if review_session.is_proposals_review: - filter_options["proposal_id"] = review_item_id - elif review_session.is_grants_review: - filter_options["grant_id"] = review_item_id - - if request.method == "GET": - user_review = UserReview.objects.filter( - user_id=request.user.id, - review_session_id=review_session_id, - **filter_options, - ).first() - - if review_session.is_proposals_review: - response = self._render_proposal_review( - request, - review_session=review_session, - review_item_id=review_item_id, - user_review=user_review, - ) - elif review_session.is_grants_review: - response = self._render_grant_review( - request, - review_session=review_session, - review_item_id=review_item_id, - user_review=user_review, - ) - - return response - elif request.method == "POST": - # if not review_session.can_review_items: - # messages.error(request, "You cannot vote yet/anymore.") - # return redirect( - # reverse( - # "admin:reviews_reviewsession_change", - # kwargs={ - # "object_id": review_session_id, - # }, - # ) - # ) - - form = SubmitVoteForm(request.POST) - form.is_valid() - - seen = [ - str(id_) for id_ in form.cleaned_data.get("seen", "").split(",") if id_ - ] - seen.append(str(review_item_id)) - - exclude = form.cleaned_data.get("exclude", []) - - if form.cleaned_data.get("_skip"): - # Skipping to the next item without voting - next_to_review = get_next_to_review_item_id( - review_session, - request.user, - skip_item=review_item_id, - exclude=exclude, - seen=seen, - ) - elif form.cleaned_data.get("_next"): - if not form.is_valid(): - messages.error(request, "Invalid vote") - comment = urllib.parse.quote(form.cleaned_data["comment"]) - private_comment = urllib.parse.quote( - form.cleaned_data["private_comment"] - ) - - return redirect( - reverse( - "admin:reviews-vote-view", - kwargs={ - "review_session_id": review_session_id, - "review_item_id": review_item_id, - }, - ) - + f"?exclude={','.join(exclude)}" - + f"&seen={','.join(seen)}" - + f"&comment={comment}" - + f"&private_comment={private_comment}" - ) - - values = { - "user_id": request.user.id, - "review_session_id": review_session_id, - } - - if review_session.is_proposals_review: - values["proposal_id"] = review_item_id - elif review_session.is_grants_review: - values["grant_id"] = review_item_id - - UserReview.objects.update_or_create( - **values, - defaults={ - "score_id": form.cleaned_data["score"].id, - "comment": form.cleaned_data["comment"], - "private_comment": form.cleaned_data["private_comment"], - }, - ) - next_to_review = get_next_to_review_item_id( - review_session, request.user, exclude=exclude, seen=seen - ) - - if not next_to_review: - messages.warning( - request, "No new items to review, showing an already seen one." - ) - next_to_review = get_next_to_review_item_id( - review_session, - request.user, - skip_item=review_item_id, - exclude=exclude, - ) - - if not next_to_review: - messages.warning(request, "No new proposal to review.") - return redirect( - reverse( - "admin:reviews-recap", - kwargs={ - "review_session_id": review_session_id, - }, - ) - ) - - return redirect( - reverse( - "admin:reviews-vote-view", - kwargs={ - "review_session_id": review_session_id, - "review_item_id": next_to_review, - }, - ) - + f"?exclude={','.join(exclude)}&seen={','.join(seen)}" - ) - - def _render_grant_review( - self, request, review_session, review_item_id, user_review - ): - private_comment = request.GET.get( - "private_comment", user_review.private_comment if user_review else "" - ) - comment = request.GET.get("comment", user_review.comment if user_review else "") - - grant = Grant.objects.get(id=review_item_id) - previous_grants = Grant.objects.filter( - user_id=grant.user_id, - conference__organizer_id=grant.conference.organizer_id, - ).exclude(conference_id=grant.conference_id) - - context = dict( - self.admin_site.each_context(request), - grant=grant, - has_sent_proposal=Submission.objects.non_cancelled() - .filter( - speaker_id=grant.user_id, - conference_id=grant.conference_id, - ) - .exists(), - previous_grants=previous_grants, - available_scores=AvailableScoreOption.objects.filter( - review_session_id=review_session.id - ).order_by("-numeric_value"), - review_session_id=review_session.id, - user_review=user_review, - private_comment=private_comment, - comment=comment, - review_session_repr=str(review_session), - can_review_items=review_session.can_review_items, - seen=request.GET.get("seen", "").split(","), - title=f"Grant Review: {grant.user.display_name}", - participant=Participant.objects.filter( - user_id=grant.user_id, - conference=grant.conference, - ).first(), - ) - return TemplateResponse(request, "grant-review.html", context) - - def _render_proposal_review( - self, request, review_session, review_item_id, user_review - ): - proposal = ( - Submission.objects.for_conference(review_session.conference_id) - .prefetch_related( - "rankings", - "rankings__tag", - Prefetch( - "userreview_set", - queryset=UserReview.objects.prefetch_related( - "user", "score" - ).filter(review_session_id=review_session.id), - ), - ) - .get(id=review_item_id) - ) - - languages = list(proposal.languages.all()) - speaker = proposal.speaker - grant = ( - Grant.objects.of_user(proposal.speaker_id) - .for_conference(proposal.conference_id) - .first() - ) - grant_link = ( - reverse("admin:grants_grant_change", args=(grant.id,)) if grant else "" - ) - - existing_comment = request.GET.get("comment", "") - tags_already_excluded = request.GET.get("exclude", "").split(",") - - used_tags = ( - Submission.objects.filter( - conference_id=proposal.conference_id, - ) - .values_list("tags__id", flat=True) - .distinct() - ) - - tags_to_filter = ( - SubmissionTag.objects.filter(id__in=used_tags).order_by("name").all() - ) - - context = dict( - self.admin_site.each_context(request), - proposal=proposal, - languages=proposal.languages.all(), - available_scores=AvailableScoreOption.objects.filter( - review_session_id=review_session.id - ).order_by("-numeric_value"), - proposal_id=review_item_id, - review_session_id=review_session.id, - user_review=user_review, - has_italian_language=any( - language for language in languages if language.code == "it" - ), - has_english_language=any( - language for language in languages if language.code == "en" - ), - speaker=speaker, - grant=grant, - grant_link=grant_link, - participant=Participant.objects.filter( - user_id=proposal.speaker_id, - conference=proposal.conference, - ).first(), - tags_to_filter=tags_to_filter, - tags_already_excluded=tags_already_excluded, - seen=request.GET.get("seen", "").split(","), - existing_comment=existing_comment, - review_session_repr=str(review_session), - title=f"Proposal Review: {proposal.title.localize('en')}", - ) - return TemplateResponse(request, "proposal-review.html", context) - +# Legacy function kept for backward compatibility def get_next_to_review_item_id( review_session: ReviewSession, - user: User, + user, skip_item: int | None = None, exclude: list[int] = None, seen: list[int] = None, ) -> int | None: - exclude = exclude or [] - seen = seen or [] - already_reviewed = UserReview.objects.filter( - user_id=user.id, - review_session_id=review_session.id, - ) - - if review_session.is_proposals_review: - already_reviewed_ids = already_reviewed.values_list("proposal_id", flat=True) - skip_item_array = [skip_item] if skip_item else [] - seen_items_to_ignore = list(already_reviewed_ids) + skip_item_array + seen - qs = ( - Submission.objects.non_cancelled() - .for_conference(review_session.conference_id) - .annotate( - votes_received=Count( - "userreview", - filter=Q(userreview__review_session_id=review_session.id), - ) - ) - .order_by("votes_received", "?") - ) - - if seen_items_to_ignore: - qs = qs.exclude(id__in=seen_items_to_ignore) - - if exclude: - qs = qs.exclude(tags__in=exclude) - - unvoted_item = qs.first() - elif review_session.is_grants_review: - already_reviewed_ids = already_reviewed.values_list("grant_id", flat=True) - unvoted_item = ( - review_session.conference.grants.annotate( - votes_received=Count( - "userreview", - filter=Q(userreview__review_session_id=review_session.id), - ) - ) - .exclude( - id__in=list(already_reviewed_ids) + [skip_item] + seen, - ) - .order_by("votes_received", "?") - .first() - ) - - return unvoted_item.id if unvoted_item else None + """Legacy function - use ReviewSessionService.get_next_item_to_review instead.""" + service = ReviewSessionService(review_session) + return service.get_next_item_to_review(user, skip_item, exclude, seen) diff --git a/backend/reviews/admin_backup.py b/backend/reviews/admin_backup.py new file mode 100644 index 0000000000..4ec1e5af6d --- /dev/null +++ b/backend/reviews/admin_backup.py @@ -0,0 +1,801 @@ +from django.contrib.postgres.expressions import ArraySubquery +from django.db.models.expressions import ExpressionWrapper +from django.db.models import FloatField +from django.db.models.functions import Cast +from users.admin_mixins import ConferencePermissionMixin +from django.core.exceptions import PermissionDenied +from django.db.models import Q, Exists +import urllib.parse + +from django import forms +from django.contrib import admin, messages +from django.db.models import Count, F, OuterRef, Prefetch, Subquery, Sum, Avg +from django.http.request import HttpRequest +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import path, reverse +from django.utils.safestring import mark_safe + +from grants.models import Grant +from participants.models import Participant +from reviews.models import AvailableScoreOption, ReviewSession, UserReview +from submissions.models import Submission, SubmissionTag +from users.models import User + + +class AvailableScoreOptionInline(admin.TabularInline): + model = AvailableScoreOption + + def get_readonly_fields(self, request: HttpRequest, obj): + if obj and not obj.is_draft: + return ["numeric_value", "label"] + + return super().get_readonly_fields(request, obj) + + +def get_all_tags(): + # todo improve :) + return SubmissionTag.objects.values_list("id", "name") + + +class SubmitVoteForm(forms.Form): + score = forms.ModelChoiceField(queryset=AvailableScoreOption.objects.all()) + comment = forms.CharField(required=False) + private_comment = forms.CharField(required=False) + exclude = forms.MultipleChoiceField(choices=get_all_tags, required=False) + seen = forms.CharField(required=False) + _next = forms.CharField(required=False) + _skip = forms.CharField(required=False) + + +@admin.register(UserReview) +class UserReviewAdmin(admin.ModelAdmin): + list_display = ("edit_vote", "object", "score", "review_session") + list_filter = ("review_session",) + list_display_links = () + autocomplete_fields = ( + "user", + "proposal", + "grant", + ) + + def object(self, obj): + return obj.get_object() + + def edit_vote(self, obj): + url = reverse( + "admin:reviews-vote-view", + kwargs={ + "review_session_id": obj.review_session_id, + "review_item_id": obj.object_id, + }, + ) + return mark_safe(f'Edit your vote') + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.filter(user_id=request.user.id).prefetch_related("proposal", "grant") + + +class ReviewSessionForm(forms.ModelForm): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + instance = kwargs.get("instance") + + if instance and "status" in self.fields: + choices = ReviewSession.Status.choices + if instance.is_reviewing and instance.has_user_reviews: + choices = ReviewSession.Status.choices[1:] + + self.fields["status"].choices = choices + + +@admin.register(ReviewSession) +class ReviewSessionAdmin(ConferencePermissionMixin, admin.ModelAdmin): + form = ReviewSessionForm + inlines = [ + AvailableScoreOptionInline, + ] + + def get_fieldsets(self, request: HttpRequest, obj): + goto_fieldset = ( + "Go To", + { + "fields": ( + "go_to_review_screen", + "go_to_recap_screen", + ) + }, + ) + config_fieldset = ( + "Config", + { + "fields": ( + "session_type", + "conference", + "status", + ) + }, + ) + + if obj: + fieldsets = (goto_fieldset, config_fieldset) + else: + fieldsets = (config_fieldset,) + + return fieldsets + + def get_readonly_fields(self, request: HttpRequest, obj): + fields = [ + "go_to_review_screen", + "go_to_recap_screen", + ] + + if obj: + if not obj.is_draft: + fields += [ + "session_type", + "conference", + ] + + if obj.is_completed and obj.has_user_reviews: + fields += [ + "status", + ] + + if not obj: + fields += [ + "status", + ] + + return fields + + @admin.display(description="Review Item Screen") + def go_to_review_screen(self, obj): + if not obj.id: + return "" + + if not obj.can_review_items: + return "You cannot review." + + return mark_safe( + f""" + + Go to review screen + +""" + ) + + @admin.display(description="Recap Screen") + def go_to_recap_screen(self, obj): + if not obj.id: + return "" + + if not obj.can_see_recap_screen: + return "You cannot see the recap of this session yet." + + return mark_safe( + f""" + + Go to recap screen + +""" + ) + + def get_urls(self): + return [ + path( + "/review/recap/", + self.admin_site.admin_view(self.review_recap_view), + name="reviews-recap", + ), + path( + "/review/start/", + self.admin_site.admin_view(self.review_start_view), + name="reviews-start", + ), + path( + "/review//", + self.admin_site.admin_view(self.review_view), + name="reviews-vote-view", + ), + ] + super().get_urls() + + def review_start_view(self, request, review_session_id): + review_session = ReviewSession.objects.get(id=review_session_id) + next_to_review = get_next_to_review_item_id(review_session, request.user) + + if not next_to_review: + messages.warning(request, "No new proposal to review.") + return redirect( + reverse( + "admin:reviews-recap", + kwargs={ + "review_session_id": review_session_id, + }, + ) + ) + + return redirect( + reverse( + "admin:reviews-vote-view", + kwargs={ + "review_session_id": review_session_id, + "review_item_id": next_to_review, + }, + ) + ) + + def review_recap_view(self, request, review_session_id): + review_session = ReviewSession.objects.get(id=review_session_id) + + if not review_session.user_can_review(request.user): + raise PermissionDenied() + + if not review_session.can_see_recap_screen: + messages.error(request, "You cannot see the recap of this session yet.") + return redirect( + reverse( + "admin:reviews_reviewsession_change", + kwargs={ + "object_id": review_session_id, + }, + ) + ) + + if review_session.is_proposals_review: + return self._review_proposals_recap_view(request, review_session) + + if review_session.is_grants_review: + return self._review_grants_recap_view(request, review_session) + + def _review_grants_recap_view(self, request, review_session): + review_session_id = review_session.id + + if request.method == "POST": + if not request.user.has_perm( + "reviews.decision_reviewsession", review_session + ): + raise PermissionDenied() + data = request.POST + + decisions = { + int(key.split("-")[1]): value + for [key, value] in data.items() + if key.startswith("decision-") + } + + approved_type_decisions = { + int(key.split("-")[1]): value + for [key, value] in data.items() + if key.startswith("approvedtype-") + } + + grants = list( + review_session.conference.grants.filter(id__in=decisions.keys()).all() + ) + + for grant in grants: + decision = decisions[grant.id] + if decision not in Grant.REVIEW_SESSION_STATUSES_OPTIONS: + continue + + approved_type = approved_type_decisions.get(grant.id, "") + + if decision != grant.status: + grant.pending_status = decision + elif decision == grant.status: + grant.pending_status = None + + grant.approved_type = ( + approved_type if decision == Grant.Status.approved else None + ) + + for grant in grants: + # save each to make sure we re-calculate the grants amounts + # TODO: move the amount calculation in a separate function maybe? + grant.save(update_fields=["pending_status", "approved_type"]) + + messages.success( + request, "Decisions saved. Check the Grants Summary for more info." + ) + + return redirect( + reverse( + "admin:reviews-recap", + kwargs={ + "review_session_id": review_session_id, + }, + ) + ) + + items = ( + review_session.conference.grants.annotate( + total_score=Cast( + Sum( + "userreview__score__numeric_value", + filter=Q(userreview__review_session_id=review_session_id), + ), + output_field=FloatField(), + ), + vote_count=Cast( + Count( + "userreview", + filter=Q(userreview__review_session_id=review_session_id), + ), + output_field=FloatField(), + ), + score=ExpressionWrapper( + F("total_score") / F("vote_count"), + output_field=FloatField(), + ), + has_sent_a_proposal=Exists( + Submission.objects.non_cancelled().filter( + speaker_id=OuterRef("user_id"), + conference_id=review_session.conference_id, + ) + ), + proposals_ids=ArraySubquery( + Submission.objects.non_cancelled() + .filter( + speaker_id=OuterRef("user_id"), + conference_id=review_session.conference_id, + ) + .values("id") + ), + ) + .order_by(F("score").desc(nulls_last=True)) + .prefetch_related( + Prefetch( + "userreview_set", + queryset=UserReview.objects.prefetch_related( + "user", "score" + ).filter(review_session_id=review_session_id), + ), + "user", + ) + .all() + ) + + proposals = { + submission.id: submission + for submission in Submission.objects.non_cancelled() + .filter( + conference_id=review_session.conference_id, + speaker_id__in=items.values_list("user_id"), + ) + .prefetch_related("rankings", "rankings__tag") + } + + context = dict( + self.admin_site.each_context(request), + request=request, + items=items, + proposals=proposals, + review_session_id=review_session_id, + review_session_repr=str(review_session), + all_review_statuses=[ + choice + for choice in Grant.Status.choices + if choice[0] in Grant.REVIEW_SESSION_STATUSES_OPTIONS + ], + all_statuses=Grant.Status.choices, + all_approved_types=[choice for choice in Grant.ApprovedType.choices], + review_session=review_session, + title="Recap", + ) + return TemplateResponse(request, "grants-recap.html", context) + + def _review_proposals_recap_view(self, request, review_session): + review_session_id = review_session.id + conference = review_session.conference + + if request.method == "POST": + if not request.user.has_perm( + "reviews.decision_reviewsession", review_session + ): + raise PermissionDenied() + + data = request.POST + + decisions = { + int(key.split("-")[1]): value + for [key, value] in data.items() + if key.startswith("decision-") + } + + proposals = list( + conference.submissions.filter(id__in=decisions.keys()).all() + ) + + for proposal in proposals: + decision = decisions[proposal.id] + proposal.pending_status = decision + + Submission.objects.bulk_update( + proposals, + fields=["pending_status"], + ) + + return redirect( + reverse( + "admin:reviews-recap", + kwargs={ + "review_session_id": review_session_id, + }, + ) + ) + + items = ( + Submission.objects.for_conference(review_session.conference_id) + .non_cancelled() + .annotate( + score=Subquery( + UserReview.objects.select_related("score") + .filter( + review_session_id=review_session_id, + proposal_id=OuterRef("id"), + ) + .values("proposal_id") + .annotate(score=Avg("score__numeric_value")) + .values("score") + ) + ) + .order_by(F("score").desc(nulls_last=True)) + .prefetch_related( + Prefetch( + "userreview_set", + queryset=UserReview.objects.prefetch_related( + "user", "score" + ).filter(review_session_id=review_session_id), + ), + "duration", + "audience_level", + "languages", + "speaker", + "tags", + "type", + "rankings", + "rankings__tag", + ) + .all() + ) + + speakers_ids = items.values_list("speaker_id", flat=True) + + grants = { + str(grant.user_id): grant + for grant in Grant.objects.filter( + conference=conference, user_id__in=speakers_ids + ).all() + } + + context = dict( + self.admin_site.each_context(request), + items=items, + grants=grants, + review_session_id=review_session_id, + audience_levels=conference.audience_levels.all(), + review_session_repr=str(review_session), + all_statuses=[choice for choice in Submission.STATUS], + title="Recap", + ) + return TemplateResponse(request, "proposals-recap.html", context) + + def review_view(self, request, review_session_id, review_item_id): + review_session = ReviewSession.objects.get(id=review_session_id) + + if not review_session.user_can_review(request.user): + raise PermissionDenied() + + filter_options = {} + + if review_session.is_proposals_review: + filter_options["proposal_id"] = review_item_id + elif review_session.is_grants_review: + filter_options["grant_id"] = review_item_id + + if request.method == "GET": + user_review = UserReview.objects.filter( + user_id=request.user.id, + review_session_id=review_session_id, + **filter_options, + ).first() + + if review_session.is_proposals_review: + response = self._render_proposal_review( + request, + review_session=review_session, + review_item_id=review_item_id, + user_review=user_review, + ) + elif review_session.is_grants_review: + response = self._render_grant_review( + request, + review_session=review_session, + review_item_id=review_item_id, + user_review=user_review, + ) + + return response + elif request.method == "POST": + # if not review_session.can_review_items: + # messages.error(request, "You cannot vote yet/anymore.") + # return redirect( + # reverse( + # "admin:reviews_reviewsession_change", + # kwargs={ + # "object_id": review_session_id, + # }, + # ) + # ) + + form = SubmitVoteForm(request.POST) + form.is_valid() + + seen = [ + str(id_) for id_ in form.cleaned_data.get("seen", "").split(",") if id_ + ] + seen.append(str(review_item_id)) + + exclude = form.cleaned_data.get("exclude", []) + + if form.cleaned_data.get("_skip"): + # Skipping to the next item without voting + next_to_review = get_next_to_review_item_id( + review_session, + request.user, + skip_item=review_item_id, + exclude=exclude, + seen=seen, + ) + elif form.cleaned_data.get("_next"): + if not form.is_valid(): + messages.error(request, "Invalid vote") + comment = urllib.parse.quote(form.cleaned_data["comment"]) + private_comment = urllib.parse.quote( + form.cleaned_data["private_comment"] + ) + + return redirect( + reverse( + "admin:reviews-vote-view", + kwargs={ + "review_session_id": review_session_id, + "review_item_id": review_item_id, + }, + ) + + f"?exclude={','.join(exclude)}" + + f"&seen={','.join(seen)}" + + f"&comment={comment}" + + f"&private_comment={private_comment}" + ) + + values = { + "user_id": request.user.id, + "review_session_id": review_session_id, + } + + if review_session.is_proposals_review: + values["proposal_id"] = review_item_id + elif review_session.is_grants_review: + values["grant_id"] = review_item_id + + UserReview.objects.update_or_create( + **values, + defaults={ + "score_id": form.cleaned_data["score"].id, + "comment": form.cleaned_data["comment"], + "private_comment": form.cleaned_data["private_comment"], + }, + ) + next_to_review = get_next_to_review_item_id( + review_session, request.user, exclude=exclude, seen=seen + ) + + if not next_to_review: + messages.warning( + request, "No new items to review, showing an already seen one." + ) + next_to_review = get_next_to_review_item_id( + review_session, + request.user, + skip_item=review_item_id, + exclude=exclude, + ) + + if not next_to_review: + messages.warning(request, "No new proposal to review.") + return redirect( + reverse( + "admin:reviews-recap", + kwargs={ + "review_session_id": review_session_id, + }, + ) + ) + + return redirect( + reverse( + "admin:reviews-vote-view", + kwargs={ + "review_session_id": review_session_id, + "review_item_id": next_to_review, + }, + ) + + f"?exclude={','.join(exclude)}&seen={','.join(seen)}" + ) + + def _render_grant_review( + self, request, review_session, review_item_id, user_review + ): + private_comment = request.GET.get( + "private_comment", user_review.private_comment if user_review else "" + ) + comment = request.GET.get("comment", user_review.comment if user_review else "") + + grant = Grant.objects.get(id=review_item_id) + previous_grants = Grant.objects.filter( + user_id=grant.user_id, + conference__organizer_id=grant.conference.organizer_id, + ).exclude(conference_id=grant.conference_id) + + context = dict( + self.admin_site.each_context(request), + grant=grant, + has_sent_proposal=Submission.objects.non_cancelled() + .filter( + speaker_id=grant.user_id, + conference_id=grant.conference_id, + ) + .exists(), + previous_grants=previous_grants, + available_scores=AvailableScoreOption.objects.filter( + review_session_id=review_session.id + ).order_by("-numeric_value"), + review_session_id=review_session.id, + user_review=user_review, + private_comment=private_comment, + comment=comment, + review_session_repr=str(review_session), + can_review_items=review_session.can_review_items, + seen=request.GET.get("seen", "").split(","), + title=f"Grant Review: {grant.user.display_name}", + participant=Participant.objects.filter( + user_id=grant.user_id, + conference=grant.conference, + ).first(), + ) + return TemplateResponse(request, "grant-review.html", context) + + def _render_proposal_review( + self, request, review_session, review_item_id, user_review + ): + proposal = ( + Submission.objects.for_conference(review_session.conference_id) + .prefetch_related( + "rankings", + "rankings__tag", + Prefetch( + "userreview_set", + queryset=UserReview.objects.prefetch_related( + "user", "score" + ).filter(review_session_id=review_session.id), + ), + ) + .get(id=review_item_id) + ) + + languages = list(proposal.languages.all()) + speaker = proposal.speaker + grant = ( + Grant.objects.of_user(proposal.speaker_id) + .for_conference(proposal.conference_id) + .first() + ) + grant_link = ( + reverse("admin:grants_grant_change", args=(grant.id,)) if grant else "" + ) + + existing_comment = request.GET.get("comment", "") + tags_already_excluded = request.GET.get("exclude", "").split(",") + + used_tags = ( + Submission.objects.filter( + conference_id=proposal.conference_id, + ) + .values_list("tags__id", flat=True) + .distinct() + ) + + tags_to_filter = ( + SubmissionTag.objects.filter(id__in=used_tags).order_by("name").all() + ) + + context = dict( + self.admin_site.each_context(request), + proposal=proposal, + languages=proposal.languages.all(), + available_scores=AvailableScoreOption.objects.filter( + review_session_id=review_session.id + ).order_by("-numeric_value"), + proposal_id=review_item_id, + review_session_id=review_session.id, + user_review=user_review, + has_italian_language=any( + language for language in languages if language.code == "it" + ), + has_english_language=any( + language for language in languages if language.code == "en" + ), + speaker=speaker, + grant=grant, + grant_link=grant_link, + participant=Participant.objects.filter( + user_id=proposal.speaker_id, + conference=proposal.conference, + ).first(), + tags_to_filter=tags_to_filter, + tags_already_excluded=tags_already_excluded, + seen=request.GET.get("seen", "").split(","), + existing_comment=existing_comment, + review_session_repr=str(review_session), + title=f"Proposal Review: {proposal.title.localize('en')}", + ) + return TemplateResponse(request, "proposal-review.html", context) + + +def get_next_to_review_item_id( + review_session: ReviewSession, + user: User, + skip_item: int | None = None, + exclude: list[int] = None, + seen: list[int] = None, +) -> int | None: + exclude = exclude or [] + seen = seen or [] + already_reviewed = UserReview.objects.filter( + user_id=user.id, + review_session_id=review_session.id, + ) + + if review_session.is_proposals_review: + already_reviewed_ids = already_reviewed.values_list("proposal_id", flat=True) + skip_item_array = [skip_item] if skip_item else [] + seen_items_to_ignore = list(already_reviewed_ids) + skip_item_array + seen + qs = ( + Submission.objects.non_cancelled() + .for_conference(review_session.conference_id) + .annotate( + votes_received=Count( + "userreview", + filter=Q(userreview__review_session_id=review_session.id), + ) + ) + .order_by("votes_received", "?") + ) + + if seen_items_to_ignore: + qs = qs.exclude(id__in=seen_items_to_ignore) + + if exclude: + qs = qs.exclude(tags__in=exclude) + + unvoted_item = qs.first() + elif review_session.is_grants_review: + already_reviewed_ids = already_reviewed.values_list("grant_id", flat=True) + unvoted_item = ( + review_session.conference.grants.annotate( + votes_received=Count( + "userreview", + filter=Q(userreview__review_session_id=review_session.id), + ) + ) + .exclude( + id__in=list(already_reviewed_ids) + [skip_item] + seen, + ) + .order_by("votes_received", "?") + .first() + ) + + return unvoted_item.id if unvoted_item else None diff --git a/backend/reviews/admin_mixins.py b/backend/reviews/admin_mixins.py new file mode 100644 index 0000000000..91e730d870 --- /dev/null +++ b/backend/reviews/admin_mixins.py @@ -0,0 +1,219 @@ +from django.contrib import admin, messages +from django.core.exceptions import PermissionDenied +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.urls import reverse +from django.utils.safestring import mark_safe + +from reviews.models import ReviewSession, UserReview +from reviews.services import ReviewSessionService, ReviewItemService, ReviewVoteService + + +class ReviewSessionAdminMixin: + """Mixin providing common review session admin functionality.""" + + def get_review_session_service( + self, review_session: ReviewSession + ) -> ReviewSessionService: + """Get a service instance for the review session.""" + return ReviewSessionService(review_session) + + def get_review_item_service( + self, review_session: ReviewSession + ) -> ReviewItemService: + """Get an item service instance for the review session.""" + return ReviewItemService(review_session) + + def get_review_vote_service( + self, review_session: ReviewSession + ) -> ReviewVoteService: + """Get a vote service instance for the review session.""" + return ReviewVoteService(review_session) + + @admin.display(description="Review Item Screen") + def go_to_review_screen(self, obj): + if not obj.id: + return "" + + if not obj.can_review_items: + return "You cannot review." + + return mark_safe( + f""" + + Go to review screen + +""" + ) + + @admin.display(description="Recap Screen") + def go_to_recap_screen(self, obj): + if not obj.id: + return "" + + if not obj.can_see_recap_screen: + return "You cannot see the recap of this session yet." + + return mark_safe( + f""" + + Go to recap screen + +""" + ) + + +class ReviewSessionViewMixin: + """Mixin providing review session view functionality.""" + + def review_start_view(self, request, review_session_id): + """Handle the review start view.""" + review_session = ReviewSession.objects.get(id=review_session_id) + service = self.get_review_session_service(review_session) + + next_to_review = service.get_next_item_to_review(request.user) + + if not next_to_review: + messages.warning(request, "No new proposal to review.") + return redirect( + reverse( + "admin:reviews-recap", + kwargs={"review_session_id": review_session_id}, + ) + ) + + return redirect( + reverse( + "admin:reviews-vote-view", + kwargs={ + "review_session_id": review_session_id, + "review_item_id": next_to_review, + }, + ) + ) + + def review_recap_view(self, request, review_session_id): + """Handle the review recap view.""" + review_session = ReviewSession.objects.get(id=review_session_id) + service = self.get_review_session_service(review_session) + + if not service.can_user_review(request.user): + raise PermissionDenied() + + if not service.can_see_recap_screen(): + messages.error(request, "You cannot see the recap of this session yet.") + return redirect( + reverse( + "admin:reviews_reviewsession_change", + kwargs={"object_id": review_session_id}, + ) + ) + + if request.method == "POST": + service.process_review_decisions(request) + return redirect( + reverse( + "admin:reviews-recap", + kwargs={"review_session_id": review_session_id}, + ) + ) + + item_service = self.get_review_item_service(review_session) + context = dict( + self.admin_site.each_context(request), + request=request, + **service.get_recap_context_data(request), + ) + + template_name = item_service.get_recap_template_name() + return TemplateResponse(request, template_name, context) + + def review_view(self, request, review_session_id, review_item_id): + """Handle the individual review view.""" + review_session = ReviewSession.objects.get(id=review_session_id) + service = self.get_review_session_service(review_session) + + if not service.can_user_review(request.user): + raise PermissionDenied() + + if request.method == "GET": + return self._handle_review_get_request( + request, review_session, review_item_id + ) + elif request.method == "POST": + return self._handle_review_post_request( + request, review_session, review_item_id + ) + + def _handle_review_get_request(self, request, review_session, review_item_id): + """Handle GET request for review view.""" + # Get existing user review if any + filter_options = {} + if review_session.is_proposals_review: + filter_options["proposal_id"] = review_item_id + elif review_session.is_grants_review: + filter_options["grant_id"] = review_item_id + + user_review = UserReview.objects.filter( + user_id=request.user.id, + review_session_id=review_session.id, + **filter_options, + ).first() + + item_service = self.get_review_item_service(review_session) + context = dict( + self.admin_site.each_context(request), + **item_service.get_item_context_data(request, review_item_id, user_review), + ) + + template_name = item_service.get_review_template_name() + return TemplateResponse(request, template_name, context) + + def _handle_review_post_request(self, request, review_session, review_item_id): + """Handle POST request for review view.""" + from reviews.admin import SubmitVoteForm + + form = SubmitVoteForm(request.POST) + form.is_valid() + + vote_service = self.get_review_vote_service(review_session) + next_to_review = vote_service.process_vote_submission( + request, review_item_id, form.cleaned_data + ) + + if next_to_review is None: + # Error occurred, should have been handled by service + return + + seen = [str(id_) for id_ in form.cleaned_data.get("seen", "").split(",") if id_] + seen.append(str(review_item_id)) + exclude = form.cleaned_data.get("exclude", []) + + if not next_to_review: + messages.warning( + request, "No new items to review, showing an already seen one." + ) + service = self.get_review_session_service(review_session) + next_to_review = service.get_next_item_to_review( + request.user, skip_item=review_item_id, exclude=exclude + ) + + if not next_to_review: + messages.warning(request, "No new proposal to review.") + return redirect( + reverse( + "admin:reviews-recap", + kwargs={"review_session_id": review_session.id}, + ) + ) + + return redirect( + reverse( + "admin:reviews-vote-view", + kwargs={ + "review_session_id": review_session.id, + "review_item_id": next_to_review, + }, + ) + + f"?exclude={','.join(map(str, exclude))}&seen={','.join(seen)}" + ) diff --git a/backend/reviews/config.py b/backend/reviews/config.py new file mode 100644 index 0000000000..fc838eb92b --- /dev/null +++ b/backend/reviews/config.py @@ -0,0 +1,149 @@ +from typing import Dict, List, Optional, Type +from dataclasses import dataclass + +from reviews.interfaces import ReviewStrategy, ScoringSystem, ReviewWorkflow + +# Configuration for default review types +from reviews.strategies import ProposalReviewStrategy, GrantReviewStrategy +from reviews.scoring import StandardScoringSystem +from reviews.workflows import StandardReviewWorkflow + + +@dataclass +class ReviewSessionConfig: + """Configuration for a review session type.""" + + name: str + strategy_class: Type[ReviewStrategy] + scoring_system_class: Type[ScoringSystem] + workflow_class: Type[ReviewWorkflow] + + # Permission settings + review_permission: str = "reviews.review_reviewsession" + decision_permission: str = "reviews.decision_reviewsession" + + # Template overrides + review_template: Optional[str] = None + recap_template: Optional[str] = None + + # Workflow settings + allow_skip: bool = True + allow_private_comments: bool = True + require_comments: bool = False + + # Scoring settings + default_score_options: Optional[List[tuple]] = None + allow_custom_scores: bool = True + + +class ReviewSystemRegistry: + """Registry for review session configurations.""" + + def __init__(self): + self._configs: Dict[str, ReviewSessionConfig] = {} + self._strategies: Dict[str, Type[ReviewStrategy]] = {} + self._scoring_systems: Dict[str, Type[ScoringSystem]] = {} + self._workflows: Dict[str, Type[ReviewWorkflow]] = {} + + def register_config(self, session_type: str, config: ReviewSessionConfig) -> None: + """Register a configuration for a session type.""" + self._configs[session_type] = config + + def register_strategy( + self, name: str, strategy_class: Type[ReviewStrategy] + ) -> None: + """Register a review strategy.""" + self._strategies[name] = strategy_class + + def register_scoring_system( + self, name: str, scoring_class: Type[ScoringSystem] + ) -> None: + """Register a scoring system.""" + self._scoring_systems[name] = scoring_class + + def register_workflow( + self, name: str, workflow_class: Type[ReviewWorkflow] + ) -> None: + """Register a workflow.""" + self._workflows[name] = workflow_class + + def get_config(self, session_type: str) -> Optional[ReviewSessionConfig]: + """Get configuration for a session type.""" + return self._configs.get(session_type) + + def get_strategy(self, name: str) -> Optional[Type[ReviewStrategy]]: + """Get a strategy class by name.""" + return self._strategies.get(name) + + def get_scoring_system(self, name: str) -> Optional[Type[ScoringSystem]]: + """Get a scoring system class by name.""" + return self._scoring_systems.get(name) + + def get_workflow(self, name: str) -> Optional[Type[ReviewWorkflow]]: + """Get a workflow class by name.""" + return self._workflows.get(name) + + def list_session_types(self) -> List[str]: + """List all registered session types.""" + return list(self._configs.keys()) + + +# Global registry instance +registry = ReviewSystemRegistry() + + +def setup_default_configurations(): + """Set up default configurations for built-in review types.""" + + # Register default components + registry.register_strategy("proposal", ProposalReviewStrategy) + registry.register_strategy("grant", GrantReviewStrategy) + registry.register_scoring_system("standard", StandardScoringSystem) + registry.register_workflow("standard", StandardReviewWorkflow) + + # Proposals configuration + proposals_config = ReviewSessionConfig( + name="Proposals Review", + strategy_class=ProposalReviewStrategy, + scoring_system_class=StandardScoringSystem, + workflow_class=StandardReviewWorkflow, + review_permission="reviews.review_reviewsession", + decision_permission="reviews.decision_reviewsession", + allow_skip=True, + allow_private_comments=True, + require_comments=False, + default_score_options=[ + (-2, "Rejected"), + (-1, "Not Convinced"), + (0, "Maybe"), + (1, "Good"), + (2, "Excellent"), + ], + ) + + # Grants configuration + grants_config = ReviewSessionConfig( + name="Grants Review", + strategy_class=GrantReviewStrategy, + scoring_system_class=StandardScoringSystem, + workflow_class=StandardReviewWorkflow, + review_permission="reviews.review_reviewsession", + decision_permission="reviews.decision_reviewsession", + allow_skip=True, + allow_private_comments=True, + require_comments=False, + default_score_options=[ + (-2, "Rejected"), + (-1, "Not Convinced"), + (0, "Maybe"), + (1, "Yes"), + (2, "Absolutely"), + ], + ) + + registry.register_config("proposals", proposals_config) + registry.register_config("grants", grants_config) + + +# Auto-setup when module is imported +setup_default_configurations() diff --git a/backend/reviews/interfaces.py b/backend/reviews/interfaces.py new file mode 100644 index 0000000000..df30f91708 --- /dev/null +++ b/backend/reviews/interfaces.py @@ -0,0 +1,112 @@ +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Protocol, TYPE_CHECKING + +from django.db import models +from django.http import HttpRequest + +if TYPE_CHECKING: + from reviews.models import ReviewSession, UserReview + from users.models import User + + +class ReviewableItem(Protocol): + """Protocol for items that can be reviewed.""" + + id: int + + def get_review_display_name(self) -> str: + """Return a human-readable name for the item being reviewed.""" + ... + + def get_review_context_data(self, request: HttpRequest) -> Dict[str, Any]: + """Return context data specific to this item type for review templates.""" + ... + + def can_be_reviewed_by(self, user: "User") -> bool: + """Check if the given user can review this item.""" + ... + + +class ReviewStrategy(ABC): + """Abstract strategy for handling different review workflows.""" + + @abstractmethod + def get_reviewable_items_queryset( + self, review_session: "ReviewSession" + ) -> models.QuerySet: + """Return a queryset of items that can be reviewed in this session.""" + + @abstractmethod + def get_next_item_to_review( + self, + review_session: "ReviewSession", + user: "User", + skip_item: Optional[int] = None, + exclude: Optional[List[int]] = None, + seen: Optional[List[int]] = None, + ) -> Optional[int]: + """Find the next item ID to review for the given user.""" + + @abstractmethod + def get_review_template_name(self) -> str: + """Return the template name for reviewing items of this type.""" + + @abstractmethod + def get_recap_template_name(self) -> str: + """Return the template name for the recap view of this review type.""" + + @abstractmethod + def process_review_decisions( + self, request: HttpRequest, review_session: "ReviewSession" + ) -> None: + """Process decisions made during the review recap phase.""" + + @abstractmethod + def get_recap_context_data( + self, request: HttpRequest, review_session: "ReviewSession" + ) -> Dict[str, Any]: + """Return context data for the recap template.""" + + @abstractmethod + def validate_user_review_data(self, data: Dict[str, Any]) -> bool: + """Validate the data submitted for a user review.""" + + @abstractmethod + def create_user_review_fields(self, review_item_id: int) -> Dict[str, Any]: + """Create the fields needed for a UserReview based on the item type.""" + + +class ScoringSystem(ABC): + """Abstract base class for different scoring systems.""" + + @abstractmethod + def get_score_choices(self) -> List[tuple]: + """Return available score choices as (value, label) tuples.""" + + @abstractmethod + def calculate_aggregate_score( + self, user_reviews: List["UserReview"] + ) -> Optional[float]: + """Calculate an aggregate score from multiple user reviews.""" + + @abstractmethod + def format_score_display(self, score: float) -> str: + """Format a score for display in templates.""" + + +class ReviewWorkflow(ABC): + """Abstract base class for review workflow management.""" + + @abstractmethod + def can_start_review(self, review_session: "ReviewSession") -> bool: + """Check if a review session can be started.""" + + @abstractmethod + def can_see_recap(self, review_session: "ReviewSession") -> bool: + """Check if recap can be viewed for this session.""" + + @abstractmethod + def get_allowed_status_transitions( + self, current_status: str, has_reviews: bool + ) -> List[str]: + """Return allowed status transitions based on current state.""" diff --git a/backend/reviews/scoring.py b/backend/reviews/scoring.py new file mode 100644 index 0000000000..00358d5b54 --- /dev/null +++ b/backend/reviews/scoring.py @@ -0,0 +1,142 @@ +from typing import List, Optional, TYPE_CHECKING + +from reviews.interfaces import ScoringSystem + +if TYPE_CHECKING: + from reviews.models import UserReview + + +class StandardScoringSystem(ScoringSystem): + """Standard scoring system using numeric scores.""" + + def __init__(self, score_choices: Optional[List[tuple]] = None): + self.score_choices = score_choices or [ + (-2, "Strongly Disagree"), + (-1, "Disagree"), + (0, "Neutral"), + (1, "Agree"), + (2, "Strongly Agree"), + ] + + def get_score_choices(self) -> List[tuple]: + """Return available score choices as (value, label) tuples.""" + return self.score_choices + + def calculate_aggregate_score( + self, user_reviews: List["UserReview"] + ) -> Optional[float]: + """Calculate the average score from multiple user reviews.""" + if not user_reviews: + return None + + scores = [review.score.numeric_value for review in user_reviews if review.score] + return sum(scores) / len(scores) if scores else None + + def format_score_display(self, score: float) -> str: + """Format a score for display in templates.""" + return f"{score:.1f}" + + +class WeightedScoringSystem(ScoringSystem): + """Scoring system that allows different weights for different reviewers.""" + + def __init__( + self, + score_choices: Optional[List[tuple]] = None, + weights: Optional[dict] = None, + ): + self.score_choices = score_choices or [ + (-2, "Strongly Disagree"), + (-1, "Disagree"), + (0, "Neutral"), + (1, "Agree"), + (2, "Strongly Agree"), + ] + self.weights = weights or {} # user_id -> weight + + def get_score_choices(self) -> List[tuple]: + """Return available score choices as (value, label) tuples.""" + return self.score_choices + + def calculate_aggregate_score( + self, user_reviews: List["UserReview"] + ) -> Optional[float]: + """Calculate weighted average score from multiple user reviews.""" + if not user_reviews: + return None + + total_score = 0 + total_weight = 0 + + for review in user_reviews: + if review.score: + weight = self.weights.get(review.user_id, 1.0) # Default weight is 1.0 + total_score += review.score.numeric_value * weight + total_weight += weight + + return total_score / total_weight if total_weight > 0 else None + + def format_score_display(self, score: float) -> str: + """Format a score for display in templates.""" + return f"{score:.2f}" + + +class RankingScoringSystem(ScoringSystem): + """Scoring system based on rankings rather than numeric scores.""" + + def __init__(self): + self.score_choices = [ + (1, "Top Choice"), + (2, "Second Choice"), + (3, "Third Choice"), + (4, "Fourth Choice"), + (5, "Fifth Choice"), + ] + + def get_score_choices(self) -> List[tuple]: + """Return available score choices as (value, label) tuples.""" + return self.score_choices + + def calculate_aggregate_score( + self, user_reviews: List["UserReview"] + ) -> Optional[float]: + """Calculate average ranking (lower is better).""" + if not user_reviews: + return None + + rankings = [ + review.score.numeric_value for review in user_reviews if review.score + ] + return sum(rankings) / len(rankings) if rankings else None + + def format_score_display(self, score: float) -> str: + """Format a ranking for display in templates.""" + return f"Rank {score:.1f}" + + +class BinaryScoringSystem(ScoringSystem): + """Simple binary scoring system (Accept/Reject).""" + + def __init__(self): + self.score_choices = [ + (0, "Reject"), + (1, "Accept"), + ] + + def get_score_choices(self) -> List[tuple]: + """Return available score choices as (value, label) tuples.""" + return self.score_choices + + def calculate_aggregate_score( + self, user_reviews: List["UserReview"] + ) -> Optional[float]: + """Calculate percentage of acceptances.""" + if not user_reviews: + return None + + scores = [review.score.numeric_value for review in user_reviews if review.score] + return sum(scores) / len(scores) if scores else None + + def format_score_display(self, score: float) -> str: + """Format a binary score as percentage.""" + return f"{score * 100:.0f}% acceptance" diff --git a/backend/reviews/services.py b/backend/reviews/services.py new file mode 100644 index 0000000000..62d6207653 --- /dev/null +++ b/backend/reviews/services.py @@ -0,0 +1,319 @@ +from typing import Any, Dict, List, Optional, TYPE_CHECKING +import urllib.parse + +from django.contrib import messages +from django.db import models +from django.http import HttpRequest +from django.shortcuts import redirect +from django.urls import reverse + +from reviews.models import AvailableScoreOption, ReviewSession, UserReview +from reviews.strategies import ReviewStrategyFactory + +if TYPE_CHECKING: + from users.models import User + + +class ReviewSessionService: + """Service class for managing review sessions and workflows.""" + + def __init__(self, review_session: ReviewSession): + self.review_session = review_session + self.strategy = ReviewStrategyFactory.get_strategy(review_session.session_type) + + def can_user_review(self, user: "User") -> bool: + """Check if a user can review items in this session.""" + return self.review_session.user_can_review(user) + + def can_review_items(self) -> bool: + """Check if items can be reviewed in this session.""" + return self.review_session.can_review_items + + def can_see_recap_screen(self) -> bool: + """Check if the recap screen can be viewed for this session.""" + return self.review_session.can_see_recap_screen + + def get_next_item_to_review( + self, + user: "User", + skip_item: Optional[int] = None, + exclude: Optional[List[int]] = None, + seen: Optional[List[int]] = None, + ) -> Optional[int]: + """Get the next item ID to review for the given user.""" + return self.strategy.get_next_item_to_review( + self.review_session, user, skip_item, exclude, seen + ) + + def process_review_decisions(self, request: HttpRequest) -> None: + """Process decisions made during the review recap phase.""" + self.strategy.process_review_decisions(request, self.review_session) + + def get_recap_context_data(self, request: HttpRequest) -> Dict[str, Any]: + """Get context data for the recap view.""" + base_context = { + "review_session_id": self.review_session.id, + "review_session_repr": str(self.review_session), + "review_session": self.review_session, + "title": "Recap", + } + strategy_context = self.strategy.get_recap_context_data( + request, self.review_session + ) + return {**base_context, **strategy_context} + + +class ReviewItemService: + """Service class for handling individual review items.""" + + def __init__(self, review_session: ReviewSession): + self.review_session = review_session + self.strategy = ReviewStrategyFactory.get_strategy(review_session.session_type) + + def get_review_template_name(self) -> str: + """Get the template name for reviewing items.""" + return self.strategy.get_review_template_name() + + def get_recap_template_name(self) -> str: + """Get the template name for the recap view.""" + return self.strategy.get_recap_template_name() + + def get_item_context_data( + self, + request: HttpRequest, + review_item_id: int, + user_review: Optional[UserReview] = None, + ) -> Dict[str, Any]: + """Get context data for reviewing a specific item.""" + + private_comment = request.GET.get( + "private_comment", user_review.private_comment if user_review else "" + ) + comment = request.GET.get("comment", user_review.comment if user_review else "") + + base_context = { + "available_scores": AvailableScoreOption.objects.filter( + review_session_id=self.review_session.id + ).order_by("-numeric_value"), + "review_session_id": self.review_session.id, + "user_review": user_review, + "private_comment": private_comment, + "comment": comment, + "review_session_repr": str(self.review_session), + "can_review_items": self.review_session.can_review_items, + "seen": request.GET.get("seen", "").split(","), + } + + # Add item-specific context based on review type + if self.review_session.is_proposals_review: + base_context.update( + self._get_proposal_context_data(request, review_item_id) + ) + elif self.review_session.is_grants_review: + base_context.update(self._get_grant_context_data(request, review_item_id)) + + return base_context + + def _get_proposal_context_data( + self, request: HttpRequest, review_item_id: int + ) -> Dict[str, Any]: + """Get context data specific to proposal reviews.""" + from submissions.models import Submission, SubmissionTag + from grants.models import Grant + from participants.models import Participant + + proposal = ( + Submission.objects.for_conference(self.review_session.conference_id) + .prefetch_related( + "rankings", + "rankings__tag", + models.Prefetch( + "userreview_set", + queryset=UserReview.objects.prefetch_related( + "user", "score" + ).filter(review_session_id=self.review_session.id), + ), + ) + .get(id=review_item_id) + ) + + languages = list(proposal.languages.all()) + speaker = proposal.speaker + grant = ( + Grant.objects.of_user(proposal.speaker_id) + .for_conference(proposal.conference_id) + .first() + ) + grant_link = ( + reverse("admin:grants_grant_change", args=(grant.id,)) if grant else "" + ) + + existing_comment = request.GET.get("comment", "") + tags_already_excluded = request.GET.get("exclude", "").split(",") + + used_tags = ( + Submission.objects.filter( + conference_id=proposal.conference_id, + ) + .values_list("tags__id", flat=True) + .distinct() + ) + + tags_to_filter = ( + SubmissionTag.objects.filter(id__in=used_tags).order_by("name").all() + ) + + return { + "proposal": proposal, + "languages": proposal.languages.all(), + "proposal_id": review_item_id, + "has_italian_language": any( + language for language in languages if language.code == "it" + ), + "has_english_language": any( + language for language in languages if language.code == "en" + ), + "speaker": speaker, + "grant": grant, + "grant_link": grant_link, + "participant": Participant.objects.filter( + user_id=proposal.speaker_id, + conference=proposal.conference, + ).first(), + "tags_to_filter": tags_to_filter, + "tags_already_excluded": tags_already_excluded, + "existing_comment": existing_comment, + "title": f"Proposal Review: {proposal.title.localize('en')}", + } + + def _get_grant_context_data( + self, request: HttpRequest, review_item_id: int + ) -> Dict[str, Any]: + """Get context data specific to grant reviews.""" + from grants.models import Grant + from submissions.models import Submission + from participants.models import Participant + + grant = Grant.objects.get(id=review_item_id) + previous_grants = Grant.objects.filter( + user_id=grant.user_id, + conference__organizer_id=grant.conference.organizer_id, + ).exclude(conference_id=grant.conference_id) + + return { + "grant": grant, + "has_sent_proposal": Submission.objects.non_cancelled() + .filter( + speaker_id=grant.user_id, + conference_id=grant.conference_id, + ) + .exists(), + "previous_grants": previous_grants, + "title": f"Grant Review: {grant.user.display_name}", + "participant": Participant.objects.filter( + user_id=grant.user_id, + conference=grant.conference, + ).first(), + } + + +class ReviewVoteService: + """Service class for handling voting logic.""" + + def __init__(self, review_session: ReviewSession): + self.review_session = review_session + self.strategy = ReviewStrategyFactory.get_strategy(review_session.session_type) + + def process_vote_submission( + self, request: HttpRequest, review_item_id: int, form_data: Dict[str, Any] + ) -> Optional[int]: + """Process a vote submission and return the next item ID to review.""" + seen = [str(id_) for id_ in form_data.get("seen", "").split(",") if id_] + seen.append(str(review_item_id)) + + exclude = form_data.get("exclude", []) + + if form_data.get("_skip"): + # Skipping to the next item without voting + return self._get_next_item(request.user, review_item_id, exclude, seen) + elif form_data.get("_next"): + if not self.strategy.validate_user_review_data(form_data): + self._handle_invalid_vote( + request, review_item_id, form_data, exclude, seen + ) + return None + + self._save_user_review(request.user, review_item_id, form_data) + return self._get_next_item(request.user, None, exclude, seen) + + return None + + def _get_next_item( + self, + user: "User", + skip_item: Optional[int] = None, + exclude: Optional[List[int]] = None, + seen: Optional[List[int]] = None, + ) -> Optional[int]: + """Get the next item to review.""" + next_to_review = self.strategy.get_next_item_to_review( + self.review_session, user, skip_item, exclude, seen + ) + + if not next_to_review: + # Try again without excluding seen items + next_to_review = self.strategy.get_next_item_to_review( + self.review_session, user, skip_item, exclude + ) + + return next_to_review + + def _save_user_review( + self, user: "User", review_item_id: int, form_data: Dict[str, Any] + ) -> None: + """Save or update a user review.""" + values = { + "user_id": user.id, + "review_session_id": self.review_session.id, + } + + # Add item-specific fields + item_fields = self.strategy.create_user_review_fields(review_item_id) + values.update(item_fields) + + UserReview.objects.update_or_create( + **values, + defaults={ + "score_id": form_data["score"].id, + "comment": form_data.get("comment", ""), + "private_comment": form_data.get("private_comment", ""), + }, + ) + + def _handle_invalid_vote( + self, + request: HttpRequest, + review_item_id: int, + form_data: Dict[str, Any], + exclude: List[int], + seen: List[int], + ) -> None: + """Handle invalid vote submission by redirecting with error message.""" + messages.error(request, "Invalid vote") + comment = urllib.parse.quote(form_data.get("comment", "")) + private_comment = urllib.parse.quote(form_data.get("private_comment", "")) + + redirect_url = ( + reverse( + "admin:reviews-vote-view", + kwargs={ + "review_session_id": self.review_session.id, + "review_item_id": review_item_id, + }, + ) + + f"?exclude={','.join(map(str, exclude))}" + + f"&seen={','.join(seen)}" + + f"&comment={comment}" + + f"&private_comment={private_comment}" + ) + raise redirect(redirect_url) diff --git a/backend/reviews/strategies.py b/backend/reviews/strategies.py new file mode 100644 index 0000000000..02718c0135 --- /dev/null +++ b/backend/reviews/strategies.py @@ -0,0 +1,366 @@ +from typing import Any, Dict, List, Optional + +from django.db import models +from django.db.models import Q, Count, F, Avg, Sum, Exists, OuterRef, Prefetch +from django.db.models.expressions import ExpressionWrapper +from django.db.models import FloatField +from django.db.models.functions import Cast +from django.contrib.postgres.expressions import ArraySubquery +from django.core.exceptions import PermissionDenied +from django.contrib import messages +from django.http import HttpRequest + +from reviews.interfaces import ReviewStrategy +from reviews.models import ReviewSession, UserReview + + +class ProposalReviewStrategy(ReviewStrategy): + """Strategy for reviewing proposals/submissions.""" + + def get_reviewable_items_queryset( + self, review_session: ReviewSession + ) -> models.QuerySet: + from submissions.models import Submission + + return Submission.objects.for_conference( + review_session.conference_id + ).non_cancelled() + + def get_next_item_to_review( + self, + review_session: ReviewSession, + user, + skip_item: Optional[int] = None, + exclude: Optional[List[int]] = None, + seen: Optional[List[int]] = None, + ) -> Optional[int]: + exclude = exclude or [] + seen = seen or [] + + already_reviewed = UserReview.objects.filter( + user_id=user.id, + review_session_id=review_session.id, + ) + already_reviewed_ids = already_reviewed.values_list("proposal_id", flat=True) + + skip_item_array = [skip_item] if skip_item else [] + seen_items_to_ignore = list(already_reviewed_ids) + skip_item_array + seen + + qs = ( + self.get_reviewable_items_queryset(review_session) + .annotate( + votes_received=Count( + "userreview", + filter=Q(userreview__review_session_id=review_session.id), + ) + ) + .order_by("votes_received", "?") + ) + + if seen_items_to_ignore: + qs = qs.exclude(id__in=seen_items_to_ignore) + + if exclude: + qs = qs.exclude(tags__in=exclude) + + unvoted_item = qs.first() + return unvoted_item.id if unvoted_item else None + + def get_review_template_name(self) -> str: + return "reviews/proposal-review.html" + + def get_recap_template_name(self) -> str: + return "proposals-recap.html" + + def process_review_decisions( + self, request: HttpRequest, review_session: ReviewSession + ) -> None: + if not request.user.has_perm("reviews.decision_reviewsession", review_session): + raise PermissionDenied() + + data = request.POST + decisions = { + int(key.split("-")[1]): value + for [key, value] in data.items() + if key.startswith("decision-") + } + + from submissions.models import Submission + + proposals = list( + review_session.conference.submissions.filter(id__in=decisions.keys()).all() + ) + + for proposal in proposals: + decision = decisions[proposal.id] + proposal.pending_status = decision + + Submission.objects.bulk_update( + proposals, + fields=["pending_status"], + ) + + def get_recap_context_data( + self, request: HttpRequest, review_session: ReviewSession + ) -> Dict[str, Any]: + from submissions.models import Submission + from grants.models import Grant + + items = ( + Submission.objects.for_conference(review_session.conference_id) + .non_cancelled() + .annotate( + score=models.Subquery( + UserReview.objects.select_related("score") + .filter( + review_session_id=review_session.id, + proposal_id=OuterRef("id"), + ) + .values("proposal_id") + .annotate(score=Avg("score__numeric_value")) + .values("score") + ) + ) + .order_by(F("score").desc(nulls_last=True)) + .prefetch_related( + Prefetch( + "userreview_set", + queryset=UserReview.objects.prefetch_related( + "user", "score" + ).filter(review_session_id=review_session.id), + ), + "duration", + "audience_level", + "languages", + "speaker", + "tags", + "type", + "rankings", + "rankings__tag", + ) + .all() + ) + + speakers_ids = items.values_list("speaker_id", flat=True) + grants = { + str(grant.user_id): grant + for grant in Grant.objects.filter( + conference=review_session.conference, user_id__in=speakers_ids + ).all() + } + + return { + "items": items, + "grants": grants, + "audience_levels": review_session.conference.audience_levels.all(), + "all_statuses": [choice for choice in Submission.STATUS], + } + + def validate_user_review_data(self, data: Dict[str, Any]) -> bool: + required_fields = ["score"] + return all(field in data for field in required_fields) + + def create_user_review_fields(self, review_item_id: int) -> Dict[str, Any]: + return {"proposal_id": review_item_id} + + +class GrantReviewStrategy(ReviewStrategy): + """Strategy for reviewing grants.""" + + def get_reviewable_items_queryset( + self, review_session: ReviewSession + ) -> models.QuerySet: + return review_session.conference.grants.all() + + def get_next_item_to_review( + self, + review_session: ReviewSession, + user, + skip_item: Optional[int] = None, + exclude: Optional[List[int]] = None, + seen: Optional[List[int]] = None, + ) -> Optional[int]: + exclude = exclude or [] + seen = seen or [] + + already_reviewed = UserReview.objects.filter( + user_id=user.id, + review_session_id=review_session.id, + ) + already_reviewed_ids = already_reviewed.values_list("grant_id", flat=True) + + unvoted_item = ( + self.get_reviewable_items_queryset(review_session) + .annotate( + votes_received=Count( + "userreview", + filter=Q(userreview__review_session_id=review_session.id), + ) + ) + .exclude( + id__in=list(already_reviewed_ids) + [skip_item] + seen, + ) + .order_by("votes_received", "?") + .first() + ) + + return unvoted_item.id if unvoted_item else None + + def get_review_template_name(self) -> str: + return "reviews/grant-review.html" + + def get_recap_template_name(self) -> str: + return "grants-recap.html" + + def process_review_decisions( + self, request: HttpRequest, review_session: ReviewSession + ) -> None: + if not request.user.has_perm("reviews.decision_reviewsession", review_session): + raise PermissionDenied() + + data = request.POST + decisions = { + int(key.split("-")[1]): value + for [key, value] in data.items() + if key.startswith("decision-") + } + + approved_type_decisions = { + int(key.split("-")[1]): value + for [key, value] in data.items() + if key.startswith("approvedtype-") + } + + from grants.models import Grant + + grants = list( + review_session.conference.grants.filter(id__in=decisions.keys()).all() + ) + + for grant in grants: + decision = decisions[grant.id] + if decision not in Grant.REVIEW_SESSION_STATUSES_OPTIONS: + continue + + approved_type = approved_type_decisions.get(grant.id, "") + + if decision != grant.status: + grant.pending_status = decision + elif decision == grant.status: + grant.pending_status = None + + grant.approved_type = ( + approved_type if decision == Grant.Status.approved else None + ) + + for grant in grants: + grant.save(update_fields=["pending_status", "approved_type"]) + + messages.success( + request, "Decisions saved. Check the Grants Summary for more info." + ) + + def get_recap_context_data( + self, request: HttpRequest, review_session: ReviewSession + ) -> Dict[str, Any]: + from submissions.models import Submission + from grants.models import Grant + + items = ( + review_session.conference.grants.annotate( + total_score=Cast( + Sum( + "userreview__score__numeric_value", + filter=Q(userreview__review_session_id=review_session.id), + ), + output_field=FloatField(), + ), + vote_count=Cast( + Count( + "userreview", + filter=Q(userreview__review_session_id=review_session.id), + ), + output_field=FloatField(), + ), + score=ExpressionWrapper( + F("total_score") / F("vote_count"), + output_field=FloatField(), + ), + has_sent_a_proposal=Exists( + Submission.objects.non_cancelled().filter( + speaker_id=OuterRef("user_id"), + conference_id=review_session.conference_id, + ) + ), + proposals_ids=ArraySubquery( + Submission.objects.non_cancelled() + .filter( + speaker_id=OuterRef("user_id"), + conference_id=review_session.conference_id, + ) + .values("id") + ), + ) + .order_by(F("score").desc(nulls_last=True)) + .prefetch_related( + Prefetch( + "userreview_set", + queryset=UserReview.objects.prefetch_related( + "user", "score" + ).filter(review_session_id=review_session.id), + ), + "user", + ) + .all() + ) + + proposals = { + submission.id: submission + for submission in Submission.objects.non_cancelled() + .filter( + conference_id=review_session.conference_id, + speaker_id__in=items.values_list("user_id"), + ) + .prefetch_related("rankings", "rankings__tag") + } + + return { + "items": items, + "proposals": proposals, + "all_review_statuses": [ + choice + for choice in Grant.Status.choices + if choice[0] in Grant.REVIEW_SESSION_STATUSES_OPTIONS + ], + "all_statuses": Grant.Status.choices, + "all_approved_types": [choice for choice in Grant.ApprovedType.choices], + } + + def validate_user_review_data(self, data: Dict[str, Any]) -> bool: + required_fields = ["score"] + return all(field in data for field in required_fields) + + def create_user_review_fields(self, review_item_id: int) -> Dict[str, Any]: + return {"grant_id": review_item_id} + + +class ReviewStrategyFactory: + """Factory for creating appropriate review strategies.""" + + _strategies = { + ReviewSession.SessionType.PROPOSALS: ProposalReviewStrategy, + ReviewSession.SessionType.GRANTS: GrantReviewStrategy, + } + + @classmethod + def get_strategy(cls, session_type: str) -> ReviewStrategy: + """Get the appropriate strategy for the given session type.""" + strategy_class = cls._strategies.get(session_type) + if not strategy_class: + raise ValueError(f"No strategy found for session type: {session_type}") + return strategy_class() + + @classmethod + def register_strategy(cls, session_type: str, strategy_class: type) -> None: + """Register a new strategy for a session type.""" + cls._strategies[session_type] = strategy_class diff --git a/backend/reviews/templates/reviews/base/review_base.html b/backend/reviews/templates/reviews/base/review_base.html new file mode 100644 index 0000000000..f5b7f98052 --- /dev/null +++ b/backend/reviews/templates/reviews/base/review_base.html @@ -0,0 +1,194 @@ +{% extends "admin/base_site.html" %} +{% load admin_urls static admin_list %} + +{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+ {% block review_navigation %} +
+ + ← Back to Review Session + + {% if can_review_items %} + + View Recap + + {% endif %} +
+ {% endblock %} + + {% block review_summary %} +
+

{{ title }}

+

Review Session: {{ review_session_repr }}

+ {% block additional_summary %}{% endblock %} +
+ {% endblock %} + + {% block review_content %} +
+
+ {% block main_content %}{% endblock %} +
+ +
+ {% block sidebar_content %}{% endblock %} +
+
+ {% endblock %} + + {% block review_actions %} + {% if can_review_items %} +
+ {% csrf_token %} + {% block review_form %}{% endblock %} + +
+ {% block action_buttons %} + + + {% endblock %} +
+
+ {% endif %} + {% endblock %} +
+ + +{% endblock %} \ No newline at end of file diff --git a/backend/reviews/templates/reviews/components/score_selector.html b/backend/reviews/templates/reviews/components/score_selector.html new file mode 100644 index 0000000000..e80879f0e0 --- /dev/null +++ b/backend/reviews/templates/reviews/components/score_selector.html @@ -0,0 +1,20 @@ +{% comment %} +Score selector component for reviews +Usage: {% include 'reviews/components/score_selector.html' with available_scores=available_scores current_score=user_review.score %} +{% endcomment %} + +
+ +
+ {% for score in available_scores %} +
+ {{ score.numeric_value }}
+ {{ score.label }} +
+ {% endfor %} +
+ +
\ No newline at end of file diff --git a/backend/reviews/templates/reviews/grant-review.html b/backend/reviews/templates/reviews/grant-review.html new file mode 100644 index 0000000000..5a25ed567c --- /dev/null +++ b/backend/reviews/templates/reviews/grant-review.html @@ -0,0 +1,181 @@ +{% extends 'reviews/base/review_base.html' %} + +{% block additional_summary %} +

Applicant: {{ grant.user.display_name }}

+

Requested Amount: €{{ grant.amount_requested }}

+{% endblock %} + +{% block main_content %} +
+

Grant Application: {{ grant.user.display_name }}

+ + {% if grant.reason %} +
+

Reason for Grant

+

{{ grant.reason|linebreaks }}

+
+ {% endif %} + + {% if grant.travel_from %} +
+

Travel Information

+
    +
  • Travelling from: {{ grant.travel_from }}
  • + {% if grant.accommodation_required %} +
  • Accommodation required: {{ grant.accommodation_required|yesno:"Yes,No" }}
  • + {% endif %} +
+
+ {% endif %} + + {% if grant.financial_need_details %} +
+

Financial Need Details

+

{{ grant.financial_need_details|linebreaks }}

+
+ {% endif %} + +
+

Grant Details

+
    +
  • Amount Requested: €{{ grant.amount_requested }}
  • +
  • Current Status: {{ grant.get_status_display }}
  • + {% if grant.approved_amount %} +
  • Approved Amount: €{{ grant.approved_amount }}
  • + {% endif %} + {% if grant.approved_type %} +
  • Approved Type: {{ grant.get_approved_type_display }}
  • + {% endif %} +
+
+ + {% if has_sent_proposal %} +
+

Proposal Status

+

✓ This applicant has submitted a proposal for this conference

+
+ {% else %} +
+

Proposal Status

+

✗ This applicant has not submitted a proposal for this conference

+
+ {% endif %} +
+{% endblock %} + +{% block sidebar_content %} +
+

Applicant Information

+

Name: {{ grant.user.display_name }}

+

Email: {{ grant.user.email }}

+ + {% if participant %} +
+
Participant Status
+

Registered: {{ participant.created|date:"M d, Y" }}

+
+ {% endif %} +
+ +{% if previous_grants %} +
+

Previous Grants

+ {% for prev_grant in previous_grants %} +
+

{{ prev_grant.conference.name }}

+

Amount: €{{ prev_grant.amount_requested }}

+

Status: {{ prev_grant.get_status_display }}

+ {% if prev_grant.approved_amount %} +

Approved: €{{ prev_grant.approved_amount }}

+ {% endif %} +
+ {% endfor %} +
+{% endif %} + +
+

Review Summary

+

Application Date: {{ grant.created|date:"M d, Y" }}

+

Last Updated: {{ grant.modified|date:"M d, Y" }}

+
+{% endblock %} + +{% block review_form %} +{% include 'reviews/components/score_selector.html' with available_scores=available_scores current_score=user_review.score %} + +
+ + +
+ +
+ + +
+ + + +{% endblock %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/backend/reviews/templates/reviews/proposal-review.html b/backend/reviews/templates/reviews/proposal-review.html new file mode 100644 index 0000000000..9d929f12e6 --- /dev/null +++ b/backend/reviews/templates/reviews/proposal-review.html @@ -0,0 +1,178 @@ +{% extends 'reviews/base/review_base.html' %} + +{% block additional_summary %} +

Speaker: {{ speaker.display_name }}

+

Languages: + {% for language in languages %} + {{ language.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +

+{% endblock %} + +{% block main_content %} +
+

{{ proposal.title.localize:'en' }}

+ + {% if proposal.elevator_pitch %} +
+

Elevator Pitch

+

{{ proposal.elevator_pitch.localize:'en'|linebreaks }}

+
+ {% endif %} + + {% if proposal.abstract %} +
+

Abstract

+
{{ proposal.abstract.localize:'en'|linebreaks }}
+
+ {% endif %} + + {% if proposal.notes %} +
+

Notes

+
{{ proposal.notes.localize:'en'|linebreaks }}
+
+ {% endif %} + + {% if proposal.tags.all %} +
+

Tags

+
+ {% for tag in proposal.tags.all %} + {{ tag.name }} + {% endfor %} +
+
+ {% endif %} + +
+

Details

+
    +
  • Type: {{ proposal.type.name }}
  • +
  • Duration: {{ proposal.duration.name }}
  • +
  • Audience Level: {{ proposal.audience_level.name }}
  • +
+
+
+{% endblock %} + +{% block sidebar_content %} +
+

Speaker Information

+

Name: {{ speaker.display_name }}

+

Email: {{ speaker.email }}

+ + {% if speaker.bio %} +
+
Bio
+

{{ speaker.bio.localize:'en'|linebreaks }}

+
+ {% endif %} + + {% if participant %} +
+
Participant Status
+

Registered: {{ participant.created|date:"M d, Y" }}

+
+ {% endif %} + + {% if grant %} +
+
Grant Application
+

View Grant Application

+
+ {% endif %} +
+ +
+

Filter Options

+ {% if tags_to_filter %} +
+ + {% for tag in tags_to_filter %} + + {% endfor %} +
+ {% endif %} +
+{% endblock %} + +{% block review_form %} +{% include 'reviews/components/score_selector.html' with available_scores=available_scores current_score=user_review.score %} + +
+ + +
+ +
+ + +
+ + + +{% endblock %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/backend/reviews/tests/test_config.py b/backend/reviews/tests/test_config.py new file mode 100644 index 0000000000..b920a87642 --- /dev/null +++ b/backend/reviews/tests/test_config.py @@ -0,0 +1,173 @@ +from reviews.config import ReviewSessionConfig, ReviewSystemRegistry +from reviews.strategies import ProposalReviewStrategy, GrantReviewStrategy +from reviews.scoring import StandardScoringSystem +from reviews.workflows import StandardReviewWorkflow + + +class TestReviewSessionConfig: + """Test ReviewSessionConfig dataclass.""" + + def test_create_config_with_defaults(self): + config = ReviewSessionConfig( + name="Test Review", + strategy_class=ProposalReviewStrategy, + scoring_system_class=StandardScoringSystem, + workflow_class=StandardReviewWorkflow, + ) + + assert config.name == "Test Review" + assert config.strategy_class == ProposalReviewStrategy + assert config.scoring_system_class == StandardScoringSystem + assert config.workflow_class == StandardReviewWorkflow + assert config.review_permission == "reviews.review_reviewsession" + assert config.decision_permission == "reviews.decision_reviewsession" + assert config.allow_skip is True + assert config.allow_private_comments is True + assert config.require_comments is False + + def test_create_config_with_custom_settings(self): + config = ReviewSessionConfig( + name="Custom Review", + strategy_class=ProposalReviewStrategy, + scoring_system_class=StandardScoringSystem, + workflow_class=StandardReviewWorkflow, + allow_skip=False, + require_comments=True, + default_score_options=[(0, "Bad"), (1, "Good")], + ) + + assert config.allow_skip is False + assert config.require_comments is True + assert config.default_score_options == [(0, "Bad"), (1, "Good")] + + +class TestReviewSystemRegistry: + """Test ReviewSystemRegistry functionality.""" + + def test_register_and_get_config(self): + registry = ReviewSystemRegistry() + + config = ReviewSessionConfig( + name="Test Review", + strategy_class=ProposalReviewStrategy, + scoring_system_class=StandardScoringSystem, + workflow_class=StandardReviewWorkflow, + ) + + registry.register_config("test", config) + retrieved_config = registry.get_config("test") + + assert retrieved_config == config + + def test_get_nonexistent_config(self): + registry = ReviewSystemRegistry() + config = registry.get_config("nonexistent") + + assert config is None + + def test_register_and_get_strategy(self): + registry = ReviewSystemRegistry() + + registry.register_strategy("test_strategy", ProposalReviewStrategy) + strategy_class = registry.get_strategy("test_strategy") + + assert strategy_class == ProposalReviewStrategy + + def test_register_and_get_scoring_system(self): + registry = ReviewSystemRegistry() + + registry.register_scoring_system("test_scoring", StandardScoringSystem) + scoring_class = registry.get_scoring_system("test_scoring") + + assert scoring_class == StandardScoringSystem + + def test_register_and_get_workflow(self): + registry = ReviewSystemRegistry() + + registry.register_workflow("test_workflow", StandardReviewWorkflow) + workflow_class = registry.get_workflow("test_workflow") + + assert workflow_class == StandardReviewWorkflow + + def test_list_session_types(self): + registry = ReviewSystemRegistry() + + config1 = ReviewSessionConfig( + name="Test Review 1", + strategy_class=ProposalReviewStrategy, + scoring_system_class=StandardScoringSystem, + workflow_class=StandardReviewWorkflow, + ) + + config2 = ReviewSessionConfig( + name="Test Review 2", + strategy_class=GrantReviewStrategy, + scoring_system_class=StandardScoringSystem, + workflow_class=StandardReviewWorkflow, + ) + + registry.register_config("test1", config1) + registry.register_config("test2", config2) + + session_types = registry.list_session_types() + + assert "test1" in session_types + assert "test2" in session_types + assert len(session_types) == 2 + + +class TestDefaultConfigurations: + """Test that default configurations are set up correctly.""" + + def test_default_registry_has_configurations(self): + from reviews.config import registry + + # Check that default session types are registered + session_types = registry.list_session_types() + assert "proposals" in session_types + assert "grants" in session_types + + def test_proposals_config(self): + from reviews.config import registry + + config = registry.get_config("proposals") + + assert config is not None + assert config.name == "Proposals Review" + assert config.strategy_class == ProposalReviewStrategy + assert config.scoring_system_class == StandardScoringSystem + assert config.workflow_class == StandardReviewWorkflow + + def test_grants_config(self): + from reviews.config import registry + + config = registry.get_config("grants") + + assert config is not None + assert config.name == "Grants Review" + assert config.strategy_class == GrantReviewStrategy + assert config.scoring_system_class == StandardScoringSystem + assert config.workflow_class == StandardReviewWorkflow + + def test_default_strategies_registered(self): + from reviews.config import registry + + proposal_strategy = registry.get_strategy("proposal") + grant_strategy = registry.get_strategy("grant") + + assert proposal_strategy == ProposalReviewStrategy + assert grant_strategy == GrantReviewStrategy + + def test_default_scoring_systems_registered(self): + from reviews.config import registry + + scoring_system = registry.get_scoring_system("standard") + + assert scoring_system == StandardScoringSystem + + def test_default_workflows_registered(self): + from reviews.config import registry + + workflow = registry.get_workflow("standard") + + assert workflow == StandardReviewWorkflow diff --git a/backend/reviews/tests/test_services.py b/backend/reviews/tests/test_services.py new file mode 100644 index 0000000000..b7c01f875c --- /dev/null +++ b/backend/reviews/tests/test_services.py @@ -0,0 +1,246 @@ +import pytest +from django.test import RequestFactory + +from conferences.tests.factories import ConferenceFactory +from grants.tests.factories import GrantFactory +from reviews.models import ReviewSession +from reviews.services import ReviewSessionService, ReviewItemService, ReviewVoteService +from reviews.tests.factories import ( + AvailableScoreOptionFactory, + ReviewSessionFactory, + UserReviewFactory, +) +from submissions.tests.factories import SubmissionFactory +from users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class TestReviewSessionService: + """Test ReviewSessionService functionality.""" + + def test_can_user_review(self): + user = UserFactory(is_staff=True, is_superuser=True) + review_session = ReviewSessionFactory( + session_type=ReviewSession.SessionType.PROPOSALS + ) + + service = ReviewSessionService(review_session) + result = service.can_user_review(user) + + assert result is True + + def test_get_next_item_to_review_for_proposals(self): + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.PROPOSALS, + ) + + submission_1 = SubmissionFactory(conference=conference) + submission_2 = SubmissionFactory(conference=conference) + + service = ReviewSessionService(review_session) + next_item = service.get_next_item_to_review(user) + + assert next_item in [submission_1.id, submission_2.id] + + def test_get_next_item_to_review_for_grants(self): + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.GRANTS, + ) + + grant_1 = GrantFactory(conference=conference) + grant_2 = GrantFactory(conference=conference) + + service = ReviewSessionService(review_session) + next_item = service.get_next_item_to_review(user) + + assert next_item in [grant_1.id, grant_2.id] + + def test_next_item_prefers_items_with_fewer_votes(self): + user_1 = UserFactory(is_staff=True, is_superuser=True) + user_2 = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.PROPOSALS, + ) + score = AvailableScoreOptionFactory( + review_session=review_session, numeric_value=0 + ) + + submission_1 = SubmissionFactory(conference=conference) + submission_2 = SubmissionFactory(conference=conference) + + # Give submission_1 a review + UserReviewFactory( + review_session=review_session, + proposal=submission_1, + user=user_1, + score=score, + ) + + service = ReviewSessionService(review_session) + next_item = service.get_next_item_to_review(user_2) + + # Should prefer submission_2 which has no reviews + assert next_item == submission_2.id + + +class TestReviewItemService: + """Test ReviewItemService functionality.""" + + def test_get_review_template_name_for_proposals(self): + review_session = ReviewSessionFactory( + session_type=ReviewSession.SessionType.PROPOSALS + ) + + service = ReviewItemService(review_session) + template_name = service.get_review_template_name() + + assert template_name == "reviews/proposal-review.html" + + def test_get_review_template_name_for_grants(self): + review_session = ReviewSessionFactory( + session_type=ReviewSession.SessionType.GRANTS + ) + + service = ReviewItemService(review_session) + template_name = service.get_review_template_name() + + assert template_name == "reviews/grant-review.html" + + def test_get_recap_template_name(self): + review_session = ReviewSessionFactory( + session_type=ReviewSession.SessionType.PROPOSALS + ) + + service = ReviewItemService(review_session) + template_name = service.get_recap_template_name() + + assert template_name == "proposals-recap.html" + + def test_get_item_context_data_for_proposal(self): + rf = RequestFactory() + request = rf.get("/") + + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, session_type=ReviewSession.SessionType.PROPOSALS + ) + submission = SubmissionFactory(conference=conference) + + service = ReviewItemService(review_session) + context = service.get_item_context_data(request, submission.id) + + assert "proposal" in context + assert "available_scores" in context + assert "review_session_id" in context + + def test_get_item_context_data_for_grant(self): + rf = RequestFactory() + request = rf.get("/") + + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, session_type=ReviewSession.SessionType.GRANTS + ) + grant = GrantFactory(conference=conference) + + service = ReviewItemService(review_session) + context = service.get_item_context_data(request, grant.id) + + assert "grant" in context + assert "available_scores" in context + assert "review_session_id" in context + + +class TestReviewVoteService: + """Test ReviewVoteService functionality.""" + + def test_process_vote_submission_with_next(self): + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.PROPOSALS, + ) + score = AvailableScoreOptionFactory( + review_session=review_session, numeric_value=1 + ) + + submission_1 = SubmissionFactory(conference=conference) + submission_2 = SubmissionFactory(conference=conference) + + rf = RequestFactory() + request = rf.post("/") + request.user = user + + service = ReviewVoteService(review_session) + form_data = { + "score": score, + "comment": "Good proposal", + "private_comment": "Private note", + "_next": True, + "seen": "", + "exclude": [], + } + + next_item = service.process_vote_submission(request, submission_1.id, form_data) + + # Should return the next item to review + assert next_item == submission_2.id + + # Should have created a UserReview + from reviews.models import UserReview + + review = UserReview.objects.filter( + user=user, review_session=review_session, proposal=submission_1 + ).first() + + assert review is not None + assert review.score == score + assert review.comment == "Good proposal" + assert review.private_comment == "Private note" + + def test_process_vote_submission_with_skip(self): + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, + session_type=ReviewSession.SessionType.PROPOSALS, + ) + + submission_1 = SubmissionFactory(conference=conference) + submission_2 = SubmissionFactory(conference=conference) + + rf = RequestFactory() + request = rf.post("/") + request.user = user + + service = ReviewVoteService(review_session) + form_data = { + "_skip": True, + "seen": "", + "exclude": [], + } + + next_item = service.process_vote_submission(request, submission_1.id, form_data) + + # Should return the next item to review + assert next_item == submission_2.id + + # Should NOT have created a UserReview + from reviews.models import UserReview + + review_count = UserReview.objects.filter( + user=user, review_session=review_session, proposal=submission_1 + ).count() + + assert review_count == 0 diff --git a/backend/reviews/tests/test_strategies.py b/backend/reviews/tests/test_strategies.py new file mode 100644 index 0000000000..80ecc06e5e --- /dev/null +++ b/backend/reviews/tests/test_strategies.py @@ -0,0 +1,190 @@ +import pytest + +from conferences.tests.factories import ConferenceFactory +from grants.tests.factories import GrantFactory +from reviews.models import ReviewSession +from reviews.strategies import ( + ProposalReviewStrategy, + GrantReviewStrategy, + ReviewStrategyFactory, +) +from reviews.tests.factories import ( + ReviewSessionFactory, +) +from submissions.tests.factories import SubmissionFactory, SubmissionTagFactory +from users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class TestProposalReviewStrategy: + """Test ProposalReviewStrategy functionality.""" + + def test_get_reviewable_items_queryset(self): + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, session_type=ReviewSession.SessionType.PROPOSALS + ) + + submission_1 = SubmissionFactory(conference=conference) + submission_2 = SubmissionFactory(conference=conference) + # Create submission for different conference + other_submission = SubmissionFactory() + + strategy = ProposalReviewStrategy() + qs = strategy.get_reviewable_items_queryset(review_session) + + submission_ids = list(qs.values_list("id", flat=True)) + assert submission_1.id in submission_ids + assert submission_2.id in submission_ids + assert other_submission.id not in submission_ids + + def test_get_next_item_to_review(self): + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, session_type=ReviewSession.SessionType.PROPOSALS + ) + + submission_1 = SubmissionFactory(conference=conference) + submission_2 = SubmissionFactory(conference=conference) + + strategy = ProposalReviewStrategy() + next_item = strategy.get_next_item_to_review(review_session, user) + + assert next_item in [submission_1.id, submission_2.id] + + def test_get_next_item_excludes_tags(self): + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, session_type=ReviewSession.SessionType.PROPOSALS + ) + + tag_1 = SubmissionTagFactory() + tag_2 = SubmissionTagFactory() + + submission_1 = SubmissionFactory(conference=conference) + submission_1.tags.add(tag_1) + + submission_2 = SubmissionFactory(conference=conference) + submission_2.tags.add(tag_2) + + strategy = ProposalReviewStrategy() + next_item = strategy.get_next_item_to_review( + review_session, user, exclude=[tag_1.id] + ) + + assert next_item == submission_2.id + + def test_get_template_names(self): + strategy = ProposalReviewStrategy() + + assert strategy.get_review_template_name() == "reviews/proposal-review.html" + assert strategy.get_recap_template_name() == "proposals-recap.html" + + def test_validate_user_review_data(self): + strategy = ProposalReviewStrategy() + + valid_data = {"score": "some_score"} + invalid_data = {"comment": "no score"} + + assert strategy.validate_user_review_data(valid_data) is True + assert strategy.validate_user_review_data(invalid_data) is False + + def test_create_user_review_fields(self): + strategy = ProposalReviewStrategy() + fields = strategy.create_user_review_fields(123) + + assert fields == {"proposal_id": 123} + + +class TestGrantReviewStrategy: + """Test GrantReviewStrategy functionality.""" + + def test_get_reviewable_items_queryset(self): + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, session_type=ReviewSession.SessionType.GRANTS + ) + + grant_1 = GrantFactory(conference=conference) + grant_2 = GrantFactory(conference=conference) + # Create grant for different conference + other_grant = GrantFactory() + + strategy = GrantReviewStrategy() + qs = strategy.get_reviewable_items_queryset(review_session) + + grant_ids = list(qs.values_list("id", flat=True)) + assert grant_1.id in grant_ids + assert grant_2.id in grant_ids + assert other_grant.id not in grant_ids + + def test_get_next_item_to_review(self): + user = UserFactory(is_staff=True, is_superuser=True) + conference = ConferenceFactory() + review_session = ReviewSessionFactory( + conference=conference, session_type=ReviewSession.SessionType.GRANTS + ) + + grant_1 = GrantFactory(conference=conference) + grant_2 = GrantFactory(conference=conference) + + strategy = GrantReviewStrategy() + next_item = strategy.get_next_item_to_review(review_session, user) + + assert next_item in [grant_1.id, grant_2.id] + + def test_get_template_names(self): + strategy = GrantReviewStrategy() + + assert strategy.get_review_template_name() == "reviews/grant-review.html" + assert strategy.get_recap_template_name() == "grants-recap.html" + + def test_validate_user_review_data(self): + strategy = GrantReviewStrategy() + + valid_data = {"score": "some_score"} + invalid_data = {"comment": "no score"} + + assert strategy.validate_user_review_data(valid_data) is True + assert strategy.validate_user_review_data(invalid_data) is False + + def test_create_user_review_fields(self): + strategy = GrantReviewStrategy() + fields = strategy.create_user_review_fields(456) + + assert fields == {"grant_id": 456} + + +class TestReviewStrategyFactory: + """Test ReviewStrategyFactory functionality.""" + + def test_get_strategy_for_proposals(self): + strategy = ReviewStrategyFactory.get_strategy( + ReviewSession.SessionType.PROPOSALS + ) + + assert isinstance(strategy, ProposalReviewStrategy) + + def test_get_strategy_for_grants(self): + strategy = ReviewStrategyFactory.get_strategy(ReviewSession.SessionType.GRANTS) + + assert isinstance(strategy, GrantReviewStrategy) + + def test_get_strategy_for_unknown_type(self): + with pytest.raises(ValueError, match="No strategy found for session type"): + ReviewStrategyFactory.get_strategy("unknown_type") + + def test_register_custom_strategy(self): + class CustomStrategy(ProposalReviewStrategy): + pass + + ReviewStrategyFactory.register_strategy("custom", CustomStrategy) + strategy = ReviewStrategyFactory.get_strategy("custom") + + assert isinstance(strategy, CustomStrategy) + + # Clean up + ReviewStrategyFactory._strategies.pop("custom", None) diff --git a/backend/reviews/workflows.py b/backend/reviews/workflows.py new file mode 100644 index 0000000000..c5130d9668 --- /dev/null +++ b/backend/reviews/workflows.py @@ -0,0 +1,141 @@ +from typing import List, TYPE_CHECKING + +from reviews.interfaces import ReviewWorkflow + +if TYPE_CHECKING: + from reviews.models import ReviewSession + + +class StandardReviewWorkflow(ReviewWorkflow): + """Standard review workflow for most review sessions.""" + + def can_start_review(self, review_session: "ReviewSession") -> bool: + """Check if a review session can be started.""" + return review_session.status == review_session.Status.REVIEWING + + def can_see_recap(self, review_session: "ReviewSession") -> bool: + """Check if recap can be viewed for this session.""" + if review_session.is_proposals_review: + return True + + if review_session.is_grants_review and review_session.is_completed: + return True + + return False + + def get_allowed_status_transitions( + self, current_status: str, has_reviews: bool + ) -> List[str]: + """Return allowed status transitions based on current state.""" + from reviews.models import ReviewSession + + if current_status == ReviewSession.Status.DRAFT: + return [ReviewSession.Status.REVIEWING] + + elif current_status == ReviewSession.Status.REVIEWING: + if has_reviews: + return [ReviewSession.Status.COMPLETED] + else: + return [ReviewSession.Status.DRAFT, ReviewSession.Status.COMPLETED] + + elif current_status == ReviewSession.Status.COMPLETED: + if has_reviews: + return [] # Cannot change status once completed with reviews + else: + return [ReviewSession.Status.DRAFT, ReviewSession.Status.REVIEWING] + + return [] + + +class SequentialReviewWorkflow(ReviewWorkflow): + """Workflow where reviews must be completed in a specific order.""" + + def __init__(self, required_phases: List[str]): + self.required_phases = required_phases + + def can_start_review(self, review_session: "ReviewSession") -> bool: + """Check if a review session can be started.""" + # Add logic to check if previous phases are completed + return review_session.status == review_session.Status.REVIEWING + + def can_see_recap(self, review_session: "ReviewSession") -> bool: + """Check if recap can be viewed for this session.""" + # Only allow recap if all phases are completed + return review_session.is_completed + + def get_allowed_status_transitions( + self, current_status: str, has_reviews: bool + ) -> List[str]: + """Return allowed status transitions based on current state.""" + + # Simplified for now - can be extended based on phase requirements + return StandardReviewWorkflow().get_allowed_status_transitions( + current_status, has_reviews + ) + + +class BlindReviewWorkflow(ReviewWorkflow): + """Workflow for blind reviews where reviewers can't see other reviews.""" + + def can_start_review(self, review_session: "ReviewSession") -> bool: + """Check if a review session can be started.""" + return review_session.status == review_session.Status.REVIEWING + + def can_see_recap(self, review_session: "ReviewSession") -> bool: + """Check if recap can be viewed for this session.""" + # Only allow recap after review phase is completed + return review_session.is_completed + + def get_allowed_status_transitions( + self, current_status: str, has_reviews: bool + ) -> List[str]: + """Return allowed status transitions based on current state.""" + from reviews.models import ReviewSession + + if current_status == ReviewSession.Status.DRAFT: + return [ReviewSession.Status.REVIEWING] + + elif current_status == ReviewSession.Status.REVIEWING: + return [ReviewSession.Status.COMPLETED] + + elif current_status == ReviewSession.Status.COMPLETED: + return [] # No transitions allowed once completed + + return [] + + +class ConditionalReviewWorkflow(ReviewWorkflow): + """Workflow with conditional logic based on review session type.""" + + def can_start_review(self, review_session: "ReviewSession") -> bool: + """Check if a review session can be started.""" + if review_session.is_proposals_review: + return review_session.status == review_session.Status.REVIEWING + + elif review_session.is_grants_review: + # Grants can only be reviewed when session is in reviewing status + return ( + review_session.status == review_session.Status.REVIEWING + and review_session.can_review_items + ) + + return False + + def can_see_recap(self, review_session: "ReviewSession") -> bool: + """Check if recap can be viewed for this session.""" + if review_session.is_proposals_review: + return True + + if review_session.is_grants_review and review_session.is_completed: + return True + + return False + + def get_allowed_status_transitions( + self, current_status: str, has_reviews: bool + ) -> List[str]: + """Return allowed status transitions based on current state.""" + # Use standard workflow logic + return StandardReviewWorkflow().get_allowed_status_transitions( + current_status, has_reviews + )