diff --git a/django/core/jinja2/core/member_profiles/retrieve.jinja b/django/core/jinja2/core/member_profiles/retrieve.jinja
index addb9cc4b..cd32a4568 100644
--- a/django/core/jinja2/core/member_profiles/retrieve.jinja
+++ b/django/core/jinja2/core/member_profiles/retrieve.jinja
@@ -158,8 +158,7 @@
You haven't been invited to review other CoMSES member's models yet. If you would like to be added
to the pool of CoMSES Computational Model Library
- Peer Reviewers please
- let us know.
+ Peer Reviewers please visit your profile edit page and create a peer reviewer profile.
{% endif %}
diff --git a/django/core/models.py b/django/core/models.py
index 80222b33b..e6209736a 100644
--- a/django/core/models.py
+++ b/django/core/models.py
@@ -42,7 +42,6 @@ class ComsesGroups(Enum):
MODERATOR = "Moderators"
EDITOR = "Editors"
FULL_MEMBER = "Full Members"
- REVIEWER = "Reviewers"
@staticmethod
@transaction.atomic
@@ -437,6 +436,10 @@ def discourse_username(self):
def is_active(self):
return self.user.is_active
+ @property
+ def is_reviewer(self):
+ return hasattr(self, "peer_reviewer")
+
# Urls
@property
def orcid_url(self):
@@ -510,10 +513,6 @@ def primary_affiliation_name(self):
def submitter(self):
return self.user
- @cached_property
- def is_reviewer(self):
- return ComsesGroups.REVIEWER.is_member(self.user)
-
@cached_property
def name(self):
return self.user.get_full_name() or self.user.username
diff --git a/django/core/serializers.py b/django/core/serializers.py
index 499c81f00..3c663131a 100644
--- a/django/core/serializers.py
+++ b/django/core/serializers.py
@@ -144,6 +144,9 @@ class MemberProfileSerializer(serializers.ModelSerializer):
profile_url = serializers.URLField(source="get_absolute_url", read_only=True)
bio = MarkdownField()
research_interests = MarkdownField()
+ peer_reviewer_id = serializers.IntegerField(
+ source="peer_reviewer.id", read_only=True, allow_null=True, default=None
+ )
def validate_affiliations(self, value):
return validate_affiliations(value)
@@ -240,6 +243,7 @@ class Meta:
model = MemberProfile
fields = (
# User
+ "id",
"date_joined",
"family_name",
"given_name",
@@ -261,12 +265,12 @@ class Meta:
"orcid_url",
"github_url",
"personal_url",
- "is_reviewer",
"professional_url",
"profile_url",
"research_interests",
"affiliations",
"name",
+ "peer_reviewer_id",
)
diff --git a/django/library/forms.py b/django/library/forms.py
index 628f1d46e..914f09267 100644
--- a/django/library/forms.py
+++ b/django/library/forms.py
@@ -27,6 +27,7 @@ class Meta:
"review",
"editor",
"candidate_reviewer",
+ "reviewer",
]
diff --git a/django/library/jinja2/library/review/dashboard.jinja b/django/library/jinja2/library/review/dashboard.jinja
index 6ec03bbf0..83731ba3d 100644
--- a/django/library/jinja2/library/review/dashboard.jinja
+++ b/django/library/jinja2/library/review/dashboard.jinja
@@ -1,10 +1,26 @@
{% extends 'sidebar_layout.jinja' %}
+{% from "common.jinja" import breadcrumb %}
{% from "library/review/includes/macros.jinja" import display_closed_status %}
{% block introduction %}
diff --git a/django/library/jinja2/library/review/email/review_invitation_declined.jinja b/django/library/jinja2/library/review/email/review_invitation_declined.jinja
index afb530c80..b7a716b4e 100644
--- a/django/library/jinja2/library/review/email/review_invitation_declined.jinja
+++ b/django/library/jinja2/library/review/email/review_invitation_declined.jinja
@@ -1,6 +1,6 @@
Dear {{ invitation.invitee }},
-Thank you for letting us know you were unable to accept the invitation to provide peer review for this model. If you no longer wish to serve as a CoMSES Net peer reviewer please [let us know]({{build_absolute_uri(slugurl("contact"))}}) and we will remove you from this opt-in group. *Note:* submitting your own computational model for peer review automatically adds you to the pool of candidate peer reviewers.
+Thank you for letting us know you were unable to accept the invitation to provide peer review for this model. If you no longer wish to serve as a CoMSES Net peer reviewer please visit your
profile edit page and deactivate your peer reviewer profile.
Best regards,
diff --git a/django/library/jinja2/library/review/email/reviewer_submitted.jinja b/django/library/jinja2/library/review/email/reviewer_submitted.jinja
index ff33ae6df..0608e4f08 100644
--- a/django/library/jinja2/library/review/email/reviewer_submitted.jinja
+++ b/django/library/jinja2/library/review/email/reviewer_submitted.jinja
@@ -1,3 +1,3 @@
Dear {{invitation.editor.name}},
-The reviewer {{ invitation.candidate_reviewer }} has submitted their feedback. Action is needed on your part to continue the peer review process and send the recommendation back to the original submitter. You can do this at the [review management page]({{ build_absolute_uri(review.get_absolute_url()) }}).
+The reviewer {{ invitation.reviewer.member_profile }} has submitted their feedback. Action is needed on your part to continue the peer review process and send the recommendation back to the original submitter. You can do this at the [review management page]({{ build_absolute_uri(review.get_absolute_url()) }}).
diff --git a/django/library/jinja2/library/review/feedback/editor_update.jinja b/django/library/jinja2/library/review/feedback/editor_update.jinja
index 7e5388d0e..9f010a443 100644
--- a/django/library/jinja2/library/review/feedback/editor_update.jinja
+++ b/django/library/jinja2/library/review/feedback/editor_update.jinja
@@ -8,7 +8,7 @@
{% block content %}
Editor Peer Review Management
- Feedback submitted by {{review_feedback.invitation.candidate_reviewer}} on {{format_datetime(review_feedback.last_modified)}}.
+ Feedback submitted by {{review_feedback.invitation.reviewer.member_profile}} on {{format_datetime(review_feedback.last_modified)}}.
diff --git a/django/library/jinja2/library/review/reviewers.jinja b/django/library/jinja2/library/review/reviewers.jinja
new file mode 100644
index 000000000..b8d762cf9
--- /dev/null
+++ b/django/library/jinja2/library/review/reviewers.jinja
@@ -0,0 +1,26 @@
+{% extends 'base.jinja' %}
+{% from "common.jinja" import breadcrumb %}
+
+{% block introduction %}
+
Manage Peer Reviewers
+{% endblock %}
+
+{% block content %}
+ {{ breadcrumb([
+ {'url': '/reviews/', 'text': 'Reviews'},
+ {'text': 'Manage Reviewers' }])
+ }}
+
+
+{% endblock %}
+
+{% block js %}
+ {{ vite_asset("apps/reviewer_list.ts") }}
+{% endblock %}
diff --git a/django/library/migrations/0029_peerreviewer.py b/django/library/migrations/0029_peerreviewer.py
new file mode 100644
index 000000000..4f2c65b53
--- /dev/null
+++ b/django/library/migrations/0029_peerreviewer.py
@@ -0,0 +1,66 @@
+# Generated by Django 4.2.14 on 2024-07-11 17:58
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("core", "0021_add_spam_moderation"),
+ ("library", "0028_contributor_json_affiliations"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="PeerReviewer",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("date_created", models.DateTimeField(auto_now_add=True)),
+ ("is_active", models.BooleanField(default=True)),
+ (
+ "programming_languages",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(max_length=100),
+ blank=True,
+ default=list,
+ help_text="Programming Languages this reviewer knows, e.g. Python, Java",
+ size=None,
+ ),
+ ),
+ (
+ "subject_areas",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(max_length=100),
+ blank=True,
+ default=list,
+ help_text="Areas of expertise, e.g. social science, biology",
+ size=None,
+ ),
+ ),
+ (
+ "notes",
+ models.TextField(
+ blank=True, help_text="Any additional notes about this reviewer"
+ ),
+ ),
+ (
+ "member_profile",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="peer_reviewer",
+ to="core.memberprofile",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/django/library/migrations/0030_peerreviewinvitation.py b/django/library/migrations/0030_peerreviewinvitation.py
new file mode 100644
index 000000000..e0ad75927
--- /dev/null
+++ b/django/library/migrations/0030_peerreviewinvitation.py
@@ -0,0 +1,52 @@
+# Generated by Django 4.2.14 on 2024-09-20 23:12
+
+from django.db import migrations, models
+import django.db.models.deletion
+from django.utils import timezone
+from datetime import timedelta
+
+
+def create_reviewers(apps, schema):
+ PeerReviewInvitation = apps.get_model("library", "PeerReviewInvitation")
+ PeerReviewer = apps.get_model("library", "PeerReviewer")
+ PeerReviewerFeedback = apps.get_model("library", "PeerReviewerFeedback")
+
+ one_year_ago = timezone.now() - timedelta(days=365)
+ for invitation in PeerReviewInvitation.objects.all():
+ member_profile = invitation.candidate_reviewer
+ reviewer, created = PeerReviewer.objects.get_or_create(
+ member_profile=member_profile,
+ defaults={
+ "is_active": PeerReviewerFeedback.objects.filter(
+ invitation__candidate_reviewer=member_profile,
+ last_modified__gte=one_year_ago,
+ ).exists()
+ },
+ )
+ invitation.reviewer = reviewer
+ invitation.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("library", "0029_peerreviewer"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="peerreviewinvitation",
+ name="reviewer",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="invitation_set",
+ to="library.peerreviewer",
+ ),
+ ),
+ migrations.RunPython(create_reviewers),
+ migrations.AlterUniqueTogether(
+ name="peerreviewinvitation",
+ unique_together={("review", "reviewer")},
+ ),
+ ]
diff --git a/django/library/models.py b/django/library/models.py
index 695858530..06241fe43 100644
--- a/django/library/models.py
+++ b/django/library/models.py
@@ -1892,6 +1892,7 @@ class PeerReviewEvent(models.TextChoices):
class PeerReviewQuerySet(models.QuerySet):
+ # Does not seem to be in use currently, move this to PeerReviewerViewSet when implementing fully
def find_candidate_reviewers(self, query=None):
# TODO: return a MemberProfile queryset annotated with number of invitations, accepted invitations, and completed
# reviews
@@ -2001,7 +2002,10 @@ def get_change_closed_url(self):
return reverse("library:peer-review-change-closed", kwargs={"slug": self.slug})
def get_invite(self, member_profile):
- return self.invitation_set.filter(candidate_reviewer=member_profile).first()
+ # return self.invitation_set.filter(candidate_reviewer=member_profile).first()
+ return self.invitation_set.filter(
+ reviewer__member_profile=member_profile
+ ).first()
@transaction.atomic
def get_absolute_url(self):
@@ -2135,6 +2139,51 @@ def __str__(self):
return f"[peer review] {self.title} (status: {self.status}, created: {self.date_created}, last_modified {self.last_modified})"
+@register_snippet
+class PeerReviewer(index.Indexed, models.Model):
+ date_created = models.DateTimeField(auto_now_add=True)
+ member_profile = models.OneToOneField(
+ MemberProfile, related_name="peer_reviewer", on_delete=models.CASCADE
+ )
+ is_active = models.BooleanField(default=True)
+ programming_languages = ArrayField(
+ models.CharField(max_length=100),
+ default=list,
+ blank=True,
+ help_text=_("Programming Languages this reviewer knows, e.g. Python, Java"),
+ )
+ subject_areas = ArrayField(
+ models.CharField(max_length=100),
+ default=list,
+ blank=True,
+ help_text=_("Areas of expertise, e.g. social science, biology"),
+ )
+ notes = models.TextField(
+ blank=True, help_text=_("Any additional notes about this reviewer")
+ )
+
+ search_fields = [
+ index.FilterField("is_active"),
+ index.SearchField("programming_languages"),
+ index.SearchField("subject_areas"),
+ index.RelatedFields(
+ "member_profile",
+ [
+ index.SearchField("username"),
+ index.SearchField("email"),
+ index.SearchField("name"),
+ index.SearchField("research_interests"),
+ ],
+ ),
+ ]
+
+ def get_active_label(self):
+ return "Active" if self.is_active else "Inactive"
+
+ def __str__(self):
+ return f"{self.member_profile} ({self.get_active_label()}) Languages: {self.programming_languages} Subject areas: {self.subject_areas}"
+
+
class PeerReviewInvitationQuerySet(models.QuerySet):
def accepted(self, **kwargs):
return self.filter(accepted=True, **kwargs)
@@ -2145,12 +2194,6 @@ def declined(self, **kwargs):
def pending(self, **kwargs):
return self.filter(accepted__isnull=True, **kwargs)
- def candidate_reviewers(self, **kwargs):
- # FIXME: fairly horribly inefficient
- return MemberProfile.objects.filter(
- id__in=self.values_list("candidate_reviewer", flat=True)
- )
-
def with_reviewer_statistics(self):
return self.prefetch_related(
models.Prefetch(
@@ -2174,6 +2217,9 @@ class PeerReviewInvitation(models.Model):
related_name="peer_review_invitation_set",
on_delete=models.CASCADE,
)
+ reviewer = models.ForeignKey(
+ PeerReviewer, related_name="invitation_set", on_delete=models.PROTECT, null=True
+ )
optional_message = MarkdownField(
help_text=_("Optional markdown text to be added to the email")
)
@@ -2209,7 +2255,7 @@ def is_expired(self):
@property
def reviewer_email(self):
- return self.candidate_reviewer.email
+ return self.reviewer.member_profile.email
@property
def editor_email(self):
@@ -2217,7 +2263,7 @@ def editor_email(self):
@property
def invitee(self):
- return self.candidate_reviewer.name
+ return self.reviewer.member_profile.name
@property
def from_email(self):
@@ -2260,7 +2306,7 @@ def send_candidate_reviewer_email(self, resend=False):
else PeerReviewEvent.INVITATION_SENT
),
author=self.editor,
- message=f"{self.editor} sent an invitation to candidate reviewer {self.candidate_reviewer}",
+ message=f"{self.editor} sent an invitation to reviewer {self.reviewer.member_profile}",
)
@transaction.atomic
@@ -2270,8 +2316,8 @@ def accept(self):
feedback = self.latest_feedback
self.review.log(
action=PeerReviewEvent.INVITATION_ACCEPTED,
- author=self.candidate_reviewer,
- message=f"Invitation accepted by {self.candidate_reviewer}",
+ author=self.reviewer.member_profile,
+ message=f"Invitation accepted by {self.reviewer.member_profile}",
)
send_markdown_email(
subject="Peer review: accepted invitation to review model",
@@ -2288,13 +2334,13 @@ def decline(self):
self.save()
self.review.log(
action=PeerReviewEvent.INVITATION_DECLINED,
- author=self.candidate_reviewer,
- message=f"Invitation declined by {self.candidate_reviewer}",
+ author=self.reviewer.member_profile,
+ message=f"Invitation declined by {self.reviewer.member_profile}",
)
send_markdown_email(
subject="Peer review: declined invitation to review model",
template_name="library/review/email/review_invitation_declined.jinja",
- context={"invitation": self},
+ context={"invitation": self, "profile": self.reviewer.member_profile},
to=[settings.REVIEW_EDITOR_EMAIL],
)
@@ -2302,7 +2348,8 @@ def get_absolute_url(self):
return reverse("library:peer-review-invitation", kwargs=dict(slug=self.slug))
class Meta:
- unique_together = (("review", "candidate_reviewer"),)
+ unique_together = (("review", "reviewer"),)
+ ordering = ["-date_sent"]
@register_snippet
@@ -2390,7 +2437,7 @@ def reviewer_completed(self):
review = self.invitation.review
review.status = ReviewStatus.AWAITING_EDITOR_FEEDBACK
review.save()
- reviewer = self.invitation.candidate_reviewer
+ reviewer = self.invitation.reviewer.member_profile
review.log(
action=PeerReviewEvent.REVIEWER_FEEDBACK_SUBMITTED,
author=reviewer,
@@ -2437,7 +2484,7 @@ def editor_called_for_revisions(self, editor: MemberProfile):
def __str__(self):
invitation = self.invitation
- return f"[peer review] {invitation.candidate_reviewer} submitted? {self.reviewer_submitted}, recommendation: {self.get_recommendation_display()}"
+ return f"[peer review] {invitation.reviewer.member_profile} submitted? {self.reviewer_submitted}, recommendation: {self.get_recommendation_display()}"
class CodeMeta:
diff --git a/django/library/serializers.py b/django/library/serializers.py
index 8347e5cbf..510cce1aa 100644
--- a/django/library/serializers.py
+++ b/django/library/serializers.py
@@ -30,6 +30,7 @@
RelatedUserSerializer,
)
from .models import (
+ PeerReviewer,
ReleaseContributor,
Codebase,
CodebaseRelease,
@@ -686,7 +687,7 @@ def get_review_status(self, instance):
return instance.invitation.review.get_status_display()
def get_reviewer_name(self, instance):
- return instance.invitation.candidate_reviewer.name
+ return instance.invitation.reviewer.member_profile.name
class Meta:
model = PeerReviewerFeedback
@@ -709,13 +710,49 @@ class Meta:
)
+class PeerReviewerSerializer(serializers.ModelSerializer):
+ member_profile_id = serializers.PrimaryKeyRelatedField(
+ queryset=MemberProfile.objects.all(),
+ source="member_profile",
+ )
+ member_profile = RelatedMemberProfileSerializer(read_only=True)
+ date_created = serializers.DateTimeField(
+ format=DATE_PUBLISHED_FORMAT, read_only=True
+ )
+
+ def create(self, validated_data):
+ member_profile = validated_data.pop("member_profile")
+ instance, created = PeerReviewer.objects.get_or_create(
+ member_profile=member_profile
+ )
+ # assuming we want to override the existing instance with the new data
+ for attr, value in validated_data.items():
+ setattr(instance, attr, value)
+ instance.is_active = True
+ instance.save()
+ return instance
+
+ class Meta:
+ model = PeerReviewer
+ fields = (
+ "id",
+ "member_profile",
+ "member_profile_id",
+ "date_created",
+ "is_active",
+ "programming_languages",
+ "subject_areas",
+ "notes",
+ )
+
+
class PeerReviewInvitationSerializer(serializers.ModelSerializer):
"""Serialize review invitations. Build for list, detail and create routes
(updating a peer review invitation may not make sense since an email has
already been sent)"""
url = serializers.ReadOnlyField(source="get_absolute_url")
- candidate_reviewer = RelatedMemberProfileSerializer(read_only=True)
+ reviewer = PeerReviewerSerializer(read_only=True)
expiration_date = serializers.SerializerMethodField()
def get_expiration_date(self, obj):
diff --git a/django/library/tests/base.py b/django/library/tests/base.py
index 39c313100..055bbbe07 100644
--- a/django/library/tests/base.py
+++ b/django/library/tests/base.py
@@ -12,6 +12,7 @@
Contributor,
PeerReviewInvitation,
PeerReview,
+ PeerReviewer,
)
from library.serializers import CodebaseSerializer
@@ -136,7 +137,8 @@ def __init__(self, editor, reviewer, review):
def get_default_data(self):
return {
- "candidate_reviewer": self.reviewer,
+ "candidate_reviewer": self.reviewer.member_profile,
+ "reviewer": self.reviewer,
"editor": self.editor,
"review": self.review,
}
@@ -155,7 +157,8 @@ class ReviewSetup:
def setUpReviewData(cls):
cls.user_factory = UserFactory()
cls.editor = cls.user_factory.create().member_profile
- cls.reviewer = cls.user_factory.create().member_profile
+ reviewer = cls.user_factory.create().member_profile
+ cls.reviewer = PeerReviewer.objects.create(member_profile=reviewer)
cls.submitter = cls.user_factory.create()
cls.codebase_factory = CodebaseFactory(cls.submitter)
diff --git a/django/library/tests/test_forms.py b/django/library/tests/test_forms.py
index 007c130ba..df3d5c5ce 100644
--- a/django/library/tests/test_forms.py
+++ b/django/library/tests/test_forms.py
@@ -12,7 +12,9 @@ def setUpTestData(cls):
def test_cannot_recommend_if_code_is_not_clean(self):
invitation = self.review.invitation_set.create(
- editor=self.editor, candidate_reviewer=self.reviewer
+ editor=self.editor,
+ candidate_reviewer=self.reviewer.member_profile,
+ reviewer=self.reviewer,
)
feedback = invitation.latest_feedback
diff --git a/django/library/urls.py b/django/library/urls.py
index d03be3b80..06379e7f5 100644
--- a/django/library/urls.py
+++ b/django/library/urls.py
@@ -14,7 +14,7 @@
router.register(
r"codebases/(?P
[\w\-.]+)/releases", views.CodebaseReleaseViewSet
)
-router.register(r"reviewers", views.PeerReviewReviewerListView),
+router.register(r"reviewers", views.PeerReviewerViewSet),
router.register(
r"reviews/(?P[\da-f\-]+)/editor/invitations",
views.PeerReviewInvitationViewSet,
@@ -56,6 +56,11 @@
views.PeerReviewDashboardView.as_view(),
name="peer-review-dashboard",
),
+ path(
+ "reviews/reviewers/",
+ views.PeerReviewerDashboardView.as_view(),
+ name="peer-reviewer-dashboard",
+ ),
path(
"reviews//editor/",
views.PeerReviewEditorView.as_view(),
diff --git a/django/library/views.py b/django/library/views.py
index 6b84c4477..d1c865103 100644
--- a/django/library/views.py
+++ b/django/library/views.py
@@ -61,6 +61,7 @@
CodebaseImage,
License,
PeerReview,
+ PeerReviewer,
PeerReviewerFeedback,
PeerReviewInvitation,
ReviewStatus,
@@ -71,6 +72,7 @@
CodebaseReleaseSerializer,
ContributorSerializer,
DownloadRequestSerializer,
+ PeerReviewerSerializer,
ReleaseContributorSerializer,
CodebaseReleaseEditSerializer,
CodebaseImageSerializer,
@@ -172,14 +174,54 @@ def post(self, request, *args, **kwargs):
return redirect(request.META.get("HTTP_REFERER", ""))
-class PeerReviewReviewerListView(mixins.ListModelMixin, viewsets.GenericViewSet):
- queryset = MemberProfile.objects.all()
- serializer_class = RelatedMemberProfileSerializer
+class ChangePeerReviewPermission(permissions.BasePermission):
+ def has_permission(self, request, view):
+ if request.user.has_perm("library.change_peerreview"):
+ return True
+ raise DrfPermissionDenied
+
+
+class PeerReviewerDashboardView(PermissionRequiredMixin, ListView):
+ template_name = "library/review/reviewers.jinja"
+ model = PeerReviewer
+ permission_required = "library.change_peerreview"
+
+
+class PeerReviewerFilter(filters.BaseFilterBackend):
+ def filter_queryset(self, request, queryset, view):
+ query = request.query_params.get("query", None)
+ if query is None:
+ return queryset
+ return get_search_queryset(query, queryset)
+
+
+class PeerReviewerPermission(permissions.BasePermission):
+ def has_permission(self, request, view):
+ if request.user.has_perm("library.change_peerreview"):
+ return True
+ if view.action == "create" and not self._is_creating_self_reviewer(request):
+ raise DrfPermissionDenied
+ return True # drop through to object permission check
+
+ def has_object_permission(self, request, view, obj: PeerReviewer):
+ if request.user.has_perm("library.change_peerreview"):
+ return True
+ if obj.member_profile_id == request.user.member_profile.id:
+ return True
+ raise DrfPermissionDenied
+
+ def _is_creating_self_reviewer(self, request):
+ user_member_profile_id = request.user.member_profile.id
+ request_member_profile_id = request.data.get("member_profile_id")
+ return user_member_profile_id == request_member_profile_id
- def get_queryset(self):
- query = self.request.query_params.get("query", "")
- results = PeerReview.objects.find_candidate_reviewers(query)
- return results
+
+class PeerReviewerViewSet(CommonViewSetMixin, NoDeleteViewSet):
+ queryset = PeerReviewer.objects.all().order_by("member_profile__user__last_name")
+ pagination_class = None
+ serializer_class = PeerReviewerSerializer
+ permission_classes = (PeerReviewerPermission,)
+ filter_backends = (PeerReviewerFilter,)
@api_view(["PUT"])
@@ -200,16 +242,9 @@ def _change_peer_review_status(request):
return Response(data={"status": new_status.name}, status=status.HTTP_200_OK)
-class NestedPeerReviewInvitation(permissions.BasePermission):
- def has_permission(self, request, view):
- if request.user.has_perm("library.change_peerreview"):
- return True
- raise DrfPermissionDenied
-
-
class PeerReviewInvitationViewSet(NoDeleteNoUpdateViewSet):
queryset = PeerReviewInvitation.objects.with_reviewer_statistics()
- permission_classes = (NestedPeerReviewInvitation,)
+ permission_classes = (ChangePeerReviewPermission,)
serializer_class = PeerReviewInvitationSerializer
lookup_url_kwarg = "invitation_slug"
@@ -222,15 +257,14 @@ def get_queryset(self):
def send_invitation(self, request, slug):
data = request.data
candidate_reviewer_id = data.get("id")
- candidate_email = data.get("email")
+ member_profile_id = data.get("member_profile")["id"]
review = get_object_or_404(PeerReview, slug=slug)
form_data = dict(review=review.id, editor=request.user.member_profile.id)
if candidate_reviewer_id is not None:
- form_data["candidate_reviewer"] = candidate_reviewer_id
- elif candidate_email is not None:
- form_data["candidate_email"] = candidate_email
+ form_data["candidate_reviewer"] = member_profile_id
+ form_data["reviewer"] = candidate_reviewer_id
else:
- raise ValidationError("Must have either id or email fields")
+ raise ValidationError("Must specify id of candidate reviewer")
form = PeerReviewInvitationForm(data=form_data)
if form.is_valid():
invitation = form.save()
@@ -311,7 +345,7 @@ def get_success_url(self):
"CoMSES Net reviewer!"
),
)
- return self.object.invitation.candidate_reviewer.get_absolute_url()
+ return self.object.invitation.reviewer.member_profile.get_absolute_url()
else:
messages.info(
self.request,
diff --git a/e2e/cypress/fixtures/data.json b/e2e/cypress/fixtures/data.json
index 572f46206..816735350 100644
--- a/e2e/cypress/fixtures/data.json
+++ b/e2e/cypress/fixtures/data.json
@@ -42,7 +42,7 @@
"description": "Job Description",
"summary": "Job Summary",
"external-url": "https://www.comses.net/",
- "application-deadline": "29"
+ "application-deadline": "21"
}
]
}
diff --git a/e2e/cypress/support/util.ts b/e2e/cypress/support/util.ts
index f642e5f32..a80da6c4e 100644
--- a/e2e/cypress/support/util.ts
+++ b/e2e/cypress/support/util.ts
@@ -5,3 +5,16 @@
export function getDataCy(value: string): Cypress.Chainable {
return cy.get(`[data-cy="${value}"]`);
}
+
+/**
+ * Select the given day in the next month using the date picker
+ * @param {Cypress.Chainable} element - container element of the datepicker input
+ * @param {number} day - day to select
+ */
+export function selectNextMonthDate(element: Cypress.Chainable, day: number): Cypress.Chainable {
+ element.first().click();
+ return element.within(() => {
+ cy.get('[aria-label="Next month"]').click();
+ cy.contains(day).click();
+ });
+}
diff --git a/e2e/cypress/tests/event.spec.ts b/e2e/cypress/tests/event.spec.ts
index 42001e751..5c0c333b3 100644
--- a/e2e/cypress/tests/event.spec.ts
+++ b/e2e/cypress/tests/event.spec.ts
@@ -1,5 +1,5 @@
import { loginBeforeEach } from "../support/setup";
-import { getDataCy } from "../support/util";
+import { getDataCy, selectNextMonthDate } from "../support/util";
import "cypress-file-upload";
describe("Visit events page", () => {
@@ -31,18 +31,14 @@ describe("Visit events page", () => {
cy.contains("Submit an event").click();
getDataCy("event-title").type(event.title);
getDataCy("event-location").type(event.location);
- getDataCy("event-start-date").first().click();
- getDataCy("event-start-date").contains(event["start-date"]).click();
- getDataCy("event-end-date").first().click();
- getDataCy("event-end-date").contains(event["end-date"]).click();
- getDataCy("early-registration-deadline").first().click();
- getDataCy("early-registration-deadline")
- .contains(event["early-registration-deadline"])
- .click();
- getDataCy("registration-deadline").first().click();
- getDataCy("registration-deadline").contains(event["registration-deadline"]).click();
- getDataCy("submission-deadline").first().click();
- getDataCy("submission-deadline").contains(event["submission-deadline"]).click();
+ selectNextMonthDate(getDataCy("event-start-date"), event["start-date"]);
+ selectNextMonthDate(getDataCy("event-end-date"), event["end-date"]);
+ selectNextMonthDate(
+ getDataCy("early-registration-deadline"),
+ event["early-registration-deadline"]
+ );
+ selectNextMonthDate(getDataCy("registration-deadline"), event["registration-deadline"]);
+ selectNextMonthDate(getDataCy("submission-deadline"), event["submission-deadline"]);
getDataCy("description").type(event.description);
getDataCy("summary").type(event.summary);
getDataCy("external-url").type(event["external-url"]);
diff --git a/e2e/cypress/tests/job.spec.ts b/e2e/cypress/tests/job.spec.ts
index b10766ade..85a365523 100644
--- a/e2e/cypress/tests/job.spec.ts
+++ b/e2e/cypress/tests/job.spec.ts
@@ -1,5 +1,5 @@
import { loginBeforeEach } from "../support/setup";
-import { getDataCy } from "../support/util";
+import { getDataCy, selectNextMonthDate } from "../support/util";
import "cypress-file-upload";
describe("Visit jobs page", () => {
@@ -21,8 +21,7 @@ describe("Visit jobs page", () => {
getDataCy("job-description").type(job.description);
getDataCy("job-summary").type(job.summary);
getDataCy("external-url").type(job["external-url"]);
- getDataCy("application-deadline").first().click();
- getDataCy("application-deadline").contains(job["application-deadline"]).click();
+ selectNextMonthDate(getDataCy("application-deadline"), job["application-deadline"]);
getDataCy("create-button").click();
cy.wait(2000);
});
diff --git a/frontend/src/apps/reviewer_list.ts b/frontend/src/apps/reviewer_list.ts
new file mode 100644
index 000000000..f3fd9f4f2
--- /dev/null
+++ b/frontend/src/apps/reviewer_list.ts
@@ -0,0 +1,7 @@
+import "vite/modulepreload-polyfill";
+
+import { createApp } from "vue";
+import ReviewersPage from "@/components/ReviewersPage.vue";
+// import { extractDataParams } from "@/util";
+
+createApp(ReviewersPage).mount("#reviewer-list");
diff --git a/frontend/src/components/ProfileEditForm.vue b/frontend/src/components/ProfileEditForm.vue
index ae3a4596a..c9c626877 100644
--- a/frontend/src/components/ProfileEditForm.vue
+++ b/frontend/src/components/ProfileEditForm.vue
@@ -77,6 +77,14 @@
+
+ Peer Reviewer Profile
+ (reviewer = r)"
+ />
+
@@ -151,7 +159,7 @@
diff --git a/frontend/src/components/ReviewInvitations.vue b/frontend/src/components/ReviewInvitations.vue
index e3b136f25..b643f150f 100644
--- a/frontend/src/components/ReviewInvitations.vue
+++ b/frontend/src/components/ReviewInvitations.vue
@@ -4,17 +4,10 @@