Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion backend/stewards/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class StewardPermissionAdmin(admin.ModelAdmin):

@admin.register(FeatureCandidateScore)
class FeatureCandidateScoreAdmin(admin.ModelAdmin):
list_display = ('submission', 'steward', 'score', 'created_at', 'updated_at')
list_display = ('submission', 'steward', 'score', 'reason_preview', 'created_at', 'updated_at')
list_filter = ('score', 'created_at', 'updated_at')
search_fields = (
'submission__title',
Expand All @@ -76,6 +76,10 @@ class FeatureCandidateScoreAdmin(admin.ModelAdmin):
readonly_fields = ('created_at', 'updated_at')
ordering = ('-updated_at',)

def reason_preview(self, obj):
return obj.reason[:80] + '...' if len(obj.reason) > 80 else obj.reason
reason_preview.short_description = 'Reason'


@admin.register(ReviewTemplate)
class ReviewTemplateAdmin(admin.ModelAdmin):
Expand Down
16 changes: 16 additions & 0 deletions backend/stewards/migrations/0012_feature_candidate_score_reason.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('stewards', '0011_feature_candidate_scoring'),
]

operations = [
migrations.AddField(
model_name='featurecandidatescore',
name='reason',
field=models.TextField(blank=True, default='', help_text='Reviewer note explaining what stood out for this score.', max_length=2000),
),
]
6 changes: 6 additions & 0 deletions backend/stewards/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ class FeatureCandidateScore(BaseModel):
validators=[MinValueValidator(0), MaxValueValidator(3)],
help_text="0 = not interesting, 1 = weak, 2 = good, 3 = strong.",
)
reason = models.TextField(
max_length=2000,
blank=True,
default='',
help_text="Reviewer note explaining what stood out for this score.",
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

class Meta:
unique_together = ['submission', 'steward']
Expand Down
9 changes: 7 additions & 2 deletions backend/stewards/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,18 @@ class FeatureCandidateSubmissionSerializer(serializers.Serializer):
created_at = serializers.DateTimeField(read_only=True)
updated_at = serializers.DateTimeField(read_only=True)
own_score = serializers.SerializerMethodField()
own_score_reason = serializers.SerializerMethodField()
user_details = serializers.SerializerMethodField()
contribution_type_details = serializers.SerializerMethodField()
evidence_items = serializers.SerializerMethodField()

def get_own_score(self, obj):
score_map = self.context.get('own_score_map', {})
return score_map.get(obj.id)
review_map = self.context.get('own_review_map', {})
return (review_map.get(obj.id) or {}).get('score')

def get_own_score_reason(self, obj):
review_map = self.context.get('own_review_map', {})
return (review_map.get(obj.id) or {}).get('reason') or ''

def get_user_details(self, obj):
return feature_candidate_user_data(obj.user)
Expand Down
24 changes: 21 additions & 3 deletions backend/stewards/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ def test_reviewer_list_is_blind_and_only_includes_interesting_accepted_projects(
self.assertEqual(row['state'], 'accepted')
self.assertEqual(row['contribution_type_details']['slug'], 'projects')
self.assertIsNone(row['own_score'])
self.assertEqual(row['own_score_reason'], '')
self.assertNotIn('id', row['user_details'])
self.assertEqual(row['user_details']['address'], self.submitter.address)
self.assertEqual(row['user_details']['github_connection'], {'platform_username': 'submitter-gh'})
Expand Down Expand Up @@ -220,21 +221,29 @@ def test_reviewer_can_create_and_edit_own_score(self):

create_response = self.client.post(
f'/api/v1/stewards/feature-reviews/{self.submission.id}/score/',
{'score': 2},
{'score': 2, 'reason': 'Strong ecosystem fit, but the demo needs polish.'},
)
edit_response = self.client.post(
f'/api/v1/stewards/feature-reviews/{self.submission.id}/score/',
{'score': 3},
{'score': 3, 'reason': 'The contract design and working demo stood out.'},
)

self.assertEqual(create_response.status_code, status.HTTP_200_OK)
self.assertEqual(create_response.data['reason'], 'Strong ecosystem fit, but the demo needs polish.')
self.assertEqual(edit_response.status_code, status.HTTP_200_OK)
scores = FeatureCandidateScore.objects.filter(
submission=self.submission,
steward=self.reviewer_steward,
)
self.assertEqual(scores.count(), 1)
self.assertEqual(scores.get().score, 3)
score = scores.get()
self.assertEqual(score.score, 3)
self.assertEqual(score.reason, 'The contract design and working demo stood out.')

list_response = self.client.get('/api/v1/stewards/feature-reviews/')
row = list_response.data['results'][0]
self.assertEqual(row['own_score'], 3)
self.assertEqual(row['own_score_reason'], 'The contract design and working demo stood out.')

def test_unpermitted_steward_cannot_score(self):
self.client.force_authenticate(user=self.denied_user)
Expand Down Expand Up @@ -264,6 +273,15 @@ def test_reviewer_score_validation_rejects_invalid_values(self):
self.assertIn('score', response.data)
self.assertFalse(FeatureCandidateScore.objects.exists())

response = self.client.post(
url,
{'score': 2, 'reason': 'x' * 2001},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('reason', response.data)
self.assertFalse(FeatureCandidateScore.objects.exists())

def test_staff_admin_sees_aggregates_and_manual_review_flag(self):
second_user = User.objects.create_user(
email='second-reviewer@example.com',
Expand Down
44 changes: 31 additions & 13 deletions backend/stewards/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,21 @@ def _interesting_queryset(self, include_scores=False):
queryset = queryset.prefetch_related('feature_candidate_scores')
return queryset

def _own_score_map(self, request):
def _own_review_map(self, request):
if not hasattr(request.user, 'steward'):
return {}
return dict(
FeatureCandidateScore.objects.filter(
return {
review['submission_id']: {
'score': review['score'],
'reason': review['reason'] or '',
}
for review in FeatureCandidateScore.objects.filter(
steward=request.user.steward,
submission__is_interesting=True,
submission__state='accepted',
submission__contribution_type__slug='projects',
).values_list('submission_id', 'score')
)
).values('submission_id', 'score', 'reason')
}

def _summary_map(self, submissions):
result = {}
Expand All @@ -106,13 +110,13 @@ def list(self, request):
)

submissions = list(self._interesting_queryset())
own_score_map = self._own_score_map(request)
own_review_map = self._own_review_map(request)
serializer = FeatureCandidateSubmissionSerializer(
submissions,
many=True,
context={'own_score_map': own_score_map},
context={'own_review_map': own_review_map},
)
scored_count = sum(1 for submission in submissions if submission.id in own_score_map)
scored_count = sum(1 for submission in submissions if submission.id in own_review_map)
return Response({
'results': serializer.data,
'progress': {
Expand All @@ -129,21 +133,35 @@ def score(self, request, pk=None):
status=status.HTTP_403_FORBIDDEN,
)

errors = {}
try:
score = serializers.IntegerField(min_value=0, max_value=3).run_validation(
request.data.get('score')
)
except serializers.ValidationError as exc:
return Response({'score': exc.detail}, status=status.HTTP_400_BAD_REQUEST)
errors['score'] = exc.detail

try:
reason = serializers.CharField(
required=False,
allow_blank=True,
trim_whitespace=True,
max_length=2000,
).run_validation(request.data.get('reason', ''))
except serializers.ValidationError as exc:
errors['reason'] = exc.detail

if errors:
return Response(errors, status=status.HTTP_400_BAD_REQUEST)

submission = get_object_or_404(self._interesting_queryset(), pk=pk)
FeatureCandidateScore.objects.update_or_create(
submission=submission,
steward=request.user.steward,
defaults={'score': score},
defaults={'score': score, 'reason': reason},
)

return Response({'submission': str(submission.id), 'score': score})
return Response({'submission': str(submission.id), 'score': score, 'reason': reason})

@action(detail=False, methods=['get'], url_path='admin')
def admin(self, request):
Expand All @@ -154,7 +172,7 @@ def admin(self, request):
)

submissions = list(self._interesting_queryset(include_scores=True))
own_score_map = self._own_score_map(request)
own_review_map = self._own_review_map(request)
summary_map = self._summary_map(submissions)
submissions.sort(
key=lambda submission: (
Expand All @@ -168,7 +186,7 @@ def admin(self, request):
serializer = FeatureCandidateAdminSerializer(
submissions,
many=True,
context={'own_score_map': own_score_map, 'summary_map': summary_map},
context={'own_review_map': own_review_map, 'summary_map': summary_map},
)
return Response({'results': serializer.data})

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ export const stewardAPI = {
// Feature candidate scoring
getFeatureReviewAccess: () => api.get('/stewards/feature-reviews/access/'),
getFeatureReviewCandidates: () => api.get('/stewards/feature-reviews/'),
scoreFeatureReviewCandidate: (id, score) => api.post(`/stewards/feature-reviews/${id}/score/`, { score }),
scoreFeatureReviewCandidate: (id, score, reason = '') => api.post(`/stewards/feature-reviews/${id}/score/`, { score, reason }),
getFeatureReviewAdmin: () => api.get('/stewards/feature-reviews/admin/')
};

Expand Down
88 changes: 80 additions & 8 deletions frontend/src/routes/StewardFeatureReviews.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
let activeTab = $state('review');
let reviewFilterTab = $state('not_scored');
let saving = $state(new Set());
let scoreDrafts = $state({});
let expandedTitles = $state(new Set());
let expandedDescriptions = $state(new Set());
let expandedAdminRows = $state(new Set());
Expand Down Expand Up @@ -116,6 +117,15 @@
const response = await stewardAPI.getFeatureReviewCandidates();
candidates = response.data?.results || [];
progress = response.data?.progress || { scored: 0, total: candidates.length };
scoreDrafts = Object.fromEntries(
candidates.map(candidate => [
candidate.id,
{
score: candidate.own_score,
reason: candidate.own_score_reason || '',
},
])
);
}

async function loadAdminRows() {
Expand All @@ -138,21 +148,64 @@
}
}

async function scoreCandidate(candidate, score) {
function draftFor(candidate) {
return scoreDrafts[candidate.id] || {
score: candidate.own_score,
reason: candidate.own_score_reason || '',
};
}

function draftScore(candidate) {
return draftFor(candidate).score;
}

function draftReason(candidate) {
return draftFor(candidate).reason || '';
}

function updateScoreDraft(candidate, patch) {
scoreDrafts = {
...scoreDrafts,
[candidate.id]: {
...draftFor(candidate),
...patch,
},
};
}

async function scoreCandidate(candidate) {
const draft = draftFor(candidate);
if (!Number.isInteger(draft.score)) {
showError('Select a score before saving.');
return;
}

saving.add(candidate.id);
saving = new Set(saving);
try {
await stewardAPI.scoreFeatureReviewCandidate(candidate.id, score);
const response = await stewardAPI.scoreFeatureReviewCandidate(
candidate.id,
draft.score,
draft.reason || ''
);
const savedReason = response.data?.reason ?? draft.reason ?? '';
const wasUnscored = candidate.own_score === null || candidate.own_score === undefined;
candidates = candidates.map(item =>
item.id === candidate.id ? { ...item, own_score: score } : item
item.id === candidate.id
? { ...item, own_score: draft.score, own_score_reason: savedReason }
: item
);
updateScoreDraft(candidate, { score: draft.score, reason: savedReason });
if (wasUnscored) {
progress = { ...progress, scored: progress.scored + 1 };
}
showSuccess('Score saved');
} catch (err) {
showError(err.response?.data?.detail || err.response?.data?.score || 'Failed to save score.');
const apiError = err.response?.data;
const scoreError = Array.isArray(apiError?.score) ? apiError.score[0] : apiError?.score;
const reasonError = Array.isArray(apiError?.reason) ? apiError.reason[0] : apiError?.reason;
const message = apiError?.detail || scoreError || reasonError || 'Failed to save score.';
showError(typeof message === 'string' ? message : 'Failed to save score.');
} finally {
saving.delete(candidate.id);
saving = new Set(saving);
Expand Down Expand Up @@ -308,12 +361,12 @@
}

function scoreButtonClass(candidate, option) {
if (candidate.own_score === option.value) return option.selectedClass;
if (draftScore(candidate) === option.value) return option.selectedClass;
return `${option.guideClass} hover:border-[#19A663] hover:bg-white hover:text-gray-950`;
}

function scoreNumberClass(candidate, option) {
if (candidate.own_score === option.value) return 'bg-white/20 text-current';
if (draftScore(candidate) === option.value) return 'bg-white/20 text-current';
return option.numberClass;
}

Expand Down Expand Up @@ -564,12 +617,12 @@
{/if}
</div>

<div class="grid grid-cols-1 gap-2">
<div class="grid grid-cols-1 gap-3">
{#each scoreOptions as option}
<button
type="button"
disabled={saving.has(candidate.id)}
onclick={() => scoreCandidate(candidate, option.value)}
onclick={() => updateScoreDraft(candidate, { score: option.value })}
title={`${option.value} - ${option.label}`}
aria-label={`Score ${option.value} - ${option.label}`}
class="flex min-h-16 items-center justify-start gap-3 rounded-md border px-3 py-2 text-left text-xs font-semibold transition-colors active:scale-[0.96] disabled:cursor-not-allowed disabled:opacity-60 {scoreButtonClass(candidate, option)}"
Expand All @@ -580,6 +633,25 @@
<span class="leading-tight">{option.label}</span>
</button>
{/each}
<label class="block">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Reason</span>
<textarea
value={draftReason(candidate)}
oninput={(event) => updateScoreDraft(candidate, { reason: event.currentTarget.value })}
maxlength="2000"
rows="4"
placeholder="What stood out in this project for the score?"
class="mt-2 min-h-28 w-full resize-y rounded-md border border-gray-200 bg-white px-3 py-2 text-sm leading-5 text-gray-800 shadow-sm outline-none transition-colors placeholder:text-gray-400 focus:border-[#19A663] focus:ring-2 focus:ring-emerald-100"
></textarea>
</label>
<button
type="button"
disabled={saving.has(candidate.id) || !Number.isInteger(draftScore(candidate))}
onclick={() => scoreCandidate(candidate)}
class="inline-flex min-h-11 items-center justify-center rounded-md bg-[#19A663] px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[#137f4c] disabled:cursor-not-allowed disabled:bg-gray-300"
>
{saving.has(candidate.id) ? 'Saving...' : 'Save opinion'}
</button>
</div>
</article>
{/each}
Expand Down