Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
04bfb79
do not scale/zoom card on hover
rudransh-shrivastava Aug 8, 2026
c378e38
introduce fat methods approving claims
rudransh-shrivastava Aug 8, 2026
82e6975
simplify code by making BoardcandidateClaim.board a mandatory field
rudransh-shrivastava Aug 8, 2026
05463a5
rename BoardOfDirectors.reviewers to claim_reviewers
rudransh-shrivastava Aug 8, 2026
dc12dde
update bod.dump
rudransh-shrivastava Aug 8, 2026
8ff5fc8
address exif metadata stripping related review comments
rudransh-shrivastava Aug 8, 2026
be4f8c6
do not shink candidate card on click
rudransh-shrivastava Aug 8, 2026
f7c7208
add pydantic input validation for graphql mutations
rudransh-shrivastava Aug 9, 2026
65f409a
Merge branch 'feature/bod-candidate-transparency' into feature/bod-ca…
rudransh-shrivastava Aug 9, 2026
01b8d21
generate graphql types post merge
rudransh-shrivastava Aug 9, 2026
cefd1e6
fix source_url bug
rudransh-shrivastava Aug 9, 2026
c658f48
address bot comments
rudransh-shrivastava Aug 9, 2026
099fae5
run prettier
rudransh-shrivastava Aug 9, 2026
5bfb5be
fix frontend tests
rudransh-shrivastava Aug 9, 2026
189c0c1
add s3 bucket for django media storage for production and staging
rudransh-shrivastava Aug 9, 2026
9f54b73
set custom_domain in storage options
rudransh-shrivastava Aug 9, 2026
d3c1385
apply bot comments
rudransh-shrivastava Aug 15, 2026
e99b6bf
update code
rudransh-shrivastava Aug 15, 2026
fcc41cd
publicly show Submitted claims
rudransh-shrivastava Aug 16, 2026
c36300c
update code
rudransh-shrivastava Aug 16, 2026
66e0bbf
Merge branch 'feature/bod-candidate-transparency' into feature/bod-ca…
rudransh-shrivastava Sep 5, 2026
d868c4d
update tests
rudransh-shrivastava Sep 5, 2026
d9b5c7b
fix tests
rudransh-shrivastava Sep 5, 2026
1c0bce0
improve pydantic validation
rudransh-shrivastava Sep 6, 2026
9a5b5eb
address bot comments
rudransh-shrivastava Sep 6, 2026
5050c42
update code
rudransh-shrivastava Sep 6, 2026
cd615c0
rename validate_year to validate_election_year
rudransh-shrivastava Sep 6, 2026
6264edb
update docstring
rudransh-shrivastava Sep 6, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,7 @@ def _validate_review_eligibility(
message=INVALID_STATUS_MSG,
)

if (
claim.board
and reviewer.github_user
and claim.board.get_candidate(login=reviewer.github_user.login)
):
if reviewer.github_user and claim.board.get_candidate(login=reviewer.github_user.login):
return ReviewResult(
ok=False,
code="FORBIDDEN",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,7 @@ def reviews(
if is_self or root.status == BoardCandidateClaim.Status.APPROVED:
return root.reviews.all()

is_reviewer = (
user.is_authenticated
and root.board is not None
and root.board.reviewers.filter(id=user.id).exists()
)
is_reviewer = user.is_authenticated and root.board.reviewers.filter(id=user.id).exists()
if is_reviewer:
return root.reviews.filter(reviewer=user)
return []
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Generated by Django 6.0.6 on 2026-08-08 15:32

import django.core.validators
import django.db.models.deletion
from django.db import migrations, models

import apps.owasp.models.board_candidate_claim_evidence
import apps.owasp.validators


class Migration(migrations.Migration):
dependencies = [
("owasp", "0080_boardcandidateclaimreview_boardofdirectors_reviewers_and_more"),
]

operations = [
migrations.AlterField(
model_name="boardcandidateclaim",
name="board",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="claims",
to="owasp.boardofdirectors",
),
),
migrations.AlterField(
model_name="boardcandidateclaimevidence",
name="file",
field=models.FileField(
blank=True,
null=True,
upload_to=apps.owasp.models.board_candidate_claim_evidence.uuid_upload_to,
validators=[
django.core.validators.FileExtensionValidator(
allowed_extensions=["jpeg", "jpg", "pdf", "png", "webp"]
),
apps.owasp.validators.validate_evidence_file_size,
],
verbose_name="File",
),
),
]
24 changes: 20 additions & 4 deletions backend/src/apps/owasp/models/board_candidate_claim.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ class Status(models.TextChoices):
}
WITHDRAWAL_ALLOWED_FIELDS = frozenset({"status", "withdrawn_reason", "withdrawn_at"})

board = models.ForeignKey(
BoardOfDirectors, blank=True, null=True, on_delete=models.SET_NULL, related_name="claims"
)
board = models.ForeignKey(BoardOfDirectors, on_delete=models.CASCADE, related_name="claims")
candidate = models.ForeignKey(EntityMember, on_delete=models.CASCADE, related_name="claims")
description = models.TextField(default="", verbose_name="Description")
is_locked = models.BooleanField(
Expand Down Expand Up @@ -139,7 +137,7 @@ def save(self, *args, **kwargs) -> None:

self.full_clean()

if not self.pk and self.candidate_id and self.board_id:
if not self.pk:
max_order = (
BoardCandidateClaim.objects.filter(
candidate_id=self.candidate_id,
Expand All @@ -155,6 +153,11 @@ def save(self, *args, **kwargs) -> None:

super().save(*args, **kwargs)

def set_status_approved(self) -> None:
"""Set claim status to approved."""
self.status = self.Status.APPROVED
self.save()

@staticmethod
def bulk_save(claims: list, fields: list | None = None) -> None: # type: ignore[override]
"""Bulk save claims.
Expand All @@ -165,3 +168,16 @@ def bulk_save(claims: list, fields: list | None = None) -> None: # type: ignore

"""
BulkSaveModel.bulk_save(BoardCandidateClaim, claims, fields=fields)

@classmethod
def bulk_set_status_approved(cls, claims: list[BoardCandidateClaim]) -> None:
"""Bulk-approve and lock claims.

Args:
claims (list[BoardCandidateClaim]): Claims to approve.

"""
for claim in claims:
claim.status = cls.Status.APPROVED
claim.is_locked = True
cls.objects.bulk_update(claims, ["is_locked", "status"])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
rudransh-shrivastava marked this conversation as resolved.
Comment thread
rudransh-shrivastava marked this conversation as resolved.
Comment thread
rudransh-shrivastava marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,11 @@ def clean(self) -> None:
err = "Review can only be added to submitted claims."
raise ValidationError(err)

if (
not self.claim.board
or not self.claim.board.reviewers.filter(id=self.reviewer.id).exists()
):
if not self.claim.board.reviewers.filter(id=self.reviewer.id).exists():
err = "Only Claim Reviewers can review claims."
raise ValidationError(err)

if self.claim.board and self.claim.board.get_candidate(
login=self.reviewer.github_user.login
):
if self.claim.board.get_candidate(login=self.reviewer.github_user.login):
err = "A candidate cannot review claims in the same election year."
raise ValidationError(err)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ def review_post_save_finalize_claim_status(sender, instance, **kwargs): # noqa:
).count()

if approved_count >= threshold:
claim.status = BoardCandidateClaim.Status.APPROVED
claim.save()
claim.set_status_approved()
logger.info(
"Claim '%s' auto-approved with %d approvals (threshold: %d).",
claim.key,
Expand Down
4 changes: 1 addition & 3 deletions backend/src/apps/owasp/signals/board_of_directors.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,10 @@ def board_post_save_re_evaluate_claims(sender, instance, **kwargs): # noqa: ARG
).count()

if approved_count >= threshold:
claim.status = BoardCandidateClaim.Status.APPROVED
claim.is_locked = True
claims_to_approve.append(claim)

if claims_to_approve:
BoardCandidateClaim.objects.bulk_update(claims_to_approve, ["is_locked", "status"])
BoardCandidateClaim.bulk_set_status_approved(claims_to_approve)
logger.info(
"Approved %d claims after threshold change on board %d.",
len(claims_to_approve),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,20 @@ def test_clean_non_submitted_claim_raises(self, status):
def test_clean_user_not_reviewer_raises(self):
"""Test that clean raises ValidationError when user is not a reviewer."""
reviewer_user = User()
review = self._build_review(
claim_status=BoardCandidateClaim.Status.SUBMITTED,
reviewer_user=reviewer_user,
)
board = BoardOfDirectors()

with patch.object(User, "github_user") as mock_github_user:
with (
patch.object(BoardOfDirectors, "reviewers") as mock_reviewers,
patch.object(User, "github_user") as mock_github_user,
):
mock_reviewers.filter.return_value.exists.return_value = False
mock_github_user.login = "alice"
review = self._build_review(
claim_status=BoardCandidateClaim.Status.SUBMITTED,
reviewer_user=reviewer_user,
claim_board=board,
)

with pytest.raises(ValidationError) as exc_info:
review.clean()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,12 @@ def test_default_order_zero(self):

assert field.default == 0

def test_board_field_nullable(self):
"""Test board field is nullable."""
def test_board_field_required(self):
"""Test board field is required."""
field = BoardCandidateClaim._meta.get_field("board")

assert field.null
assert field.blank
assert not field.null
assert not field.blank

def test_withdrawn_at_field_nullable(self):
"""Test withdrawn_at field is nullable."""
Expand Down Expand Up @@ -325,6 +325,7 @@ def test_clean_non_draft_claim_allows_status_update_only(self, mock_objects):
def test_save_calls_full_clean(self, mock_super_save, mock_full_clean):
"""Test that save calls full_clean before saving."""
claim = BoardCandidateClaim(name="Test Claim", status=BoardCandidateClaim.Status.DRAFT)
claim.pk = 1

claim.save()

Expand All @@ -346,6 +347,7 @@ def test_save_locks_claim_on_finalized_status(self, mock_super_save, mock_full_c
"""Test that save sets is_locked=True for finalized statuses."""
claim = BoardCandidateClaim(name="Test Claim", status=status)
claim.is_locked = False
claim.pk = 1

claim.save()

Expand All @@ -366,11 +368,22 @@ def test_save_does_not_lock_claim_on_non_finalized_status(
"""Test that save does not set is_locked=True for non-finalized statuses."""
claim = BoardCandidateClaim(name="Test Claim", status=status)
claim.is_locked = False
claim.pk = 1

claim.save()

assert claim.is_locked is False

@patch.object(BoardCandidateClaim, "save")
def test_set_status_approved_sets_status_and_saves(self, mock_save):
"""Test set_status_approved flips status to APPROVED and saves."""
claim = BoardCandidateClaim(name="Test Claim", status=BoardCandidateClaim.Status.SUBMITTED)

claim.set_status_approved()

assert claim.status == BoardCandidateClaim.Status.APPROVED
mock_save.assert_called_once()

@patch("apps.owasp.models.board_candidate_claim.BulkSaveModel.bulk_save")
def test_bulk_save_delegates(self, mock_bulk_save):
"""Test bulk_save delegates to BulkSaveModel.bulk_save."""
Expand All @@ -380,6 +393,22 @@ def test_bulk_save_delegates(self, mock_bulk_save):

mock_bulk_save.assert_called_once_with(BoardCandidateClaim, claims, fields=["order"])

@patch("apps.owasp.models.board_candidate_claim.BoardCandidateClaim.objects")
def test_bulk_set_status_approved_updates_status_lock_and_calls_bulk_update(
self, mock_objects
):
"""Test bulk_set_status_approved flips status, locks, and issues one bulk_update."""
claim_a = BoardCandidateClaim(name="Claim A", status=BoardCandidateClaim.Status.SUBMITTED)
claim_b = BoardCandidateClaim(name="Claim B", status=BoardCandidateClaim.Status.SUBMITTED)
claims = [claim_a, claim_b]

BoardCandidateClaim.bulk_set_status_approved(claims)

for claim in claims:
assert claim.status == BoardCandidateClaim.Status.APPROVED
assert claim.is_locked is True
mock_objects.bulk_update.assert_called_once_with(claims, ["is_locked", "status"])

@patch.object(BoardCandidateClaim, "full_clean")
@patch("apps.owasp.models.board_candidate_claim.TimestampedModel.save")
@patch("apps.owasp.models.board_candidate_claim.BoardCandidateClaim.objects")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ def test_auto_approves_when_threshold_met(self, mock_logger):
claim.reviews.filter.assert_called_once_with(
status=BoardCandidateClaimReview.Status.APPROVED,
)
assert claim.status == BoardCandidateClaim.Status.APPROVED
claim.save.assert_called_once()
claim.set_status_approved.assert_called_once()
mock_logger.info.assert_called_once_with(
"Claim '%s' auto-approved with %d approvals (threshold: %d).",
"test-claim",
Expand All @@ -55,7 +54,7 @@ def test_does_not_approve_when_threshold_not_met(self, mock_logger):
status=BoardCandidateClaimReview.Status.APPROVED,
)
assert claim.status == BoardCandidateClaim.Status.SUBMITTED
claim.save.assert_not_called()
claim.set_status_approved.assert_not_called()
mock_logger.info.assert_not_called()

@patch("apps.owasp.signals.board_candidate_claim_review.logger")
Expand All @@ -68,7 +67,7 @@ def test_does_nothing_when_claim_not_submitted(self, mock_logger):

review_post_save_finalize_claim_status(sender=None, instance=instance)

claim.save.assert_not_called()
claim.set_status_approved.assert_not_called()
mock_logger.info.assert_not_called()

@patch("apps.owasp.signals.board_candidate_claim_review.logger")
Expand All @@ -88,6 +87,5 @@ def test_auto_approves_when_approvals_exactly_equal_threshold(self, mock_logger)
claim.reviews.filter.assert_called_once_with(
status=BoardCandidateClaimReview.Status.APPROVED,
)
assert claim.status == BoardCandidateClaim.Status.APPROVED
claim.save.assert_called_once()
claim.set_status_approved.assert_called_once()
mock_logger.info.assert_called_once()
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,7 @@ def test_approves_claims_when_threshold_met(self, mock_claim_model, mock_logger)

board_post_save_re_evaluate_claims(sender=None, instance=instance)

assert claim_a.status == BoardCandidateClaim.Status.APPROVED
assert claim_a.is_locked is True
assert claim_b.status == BoardCandidateClaim.Status.SUBMITTED
mock_claim_model.objects.bulk_update.assert_called_once_with(
[claim_a], ["is_locked", "status"]
)
mock_claim_model.bulk_set_status_approved.assert_called_once_with([claim_a])
mock_logger.info.assert_called_once_with(
"Approved %d claims after threshold change on board %d.",
1,
Expand All @@ -60,7 +55,7 @@ def test_approves_no_claims_when_threshold_not_met(self, mock_claim_model, mock_

board_post_save_re_evaluate_claims(sender=None, instance=instance)

mock_claim_model.objects.bulk_update.assert_not_called()
mock_claim_model.bulk_set_status_approved.assert_not_called()
mock_logger.info.assert_not_called()

@patch("apps.owasp.signals.board_of_directors.logger")
Expand All @@ -83,13 +78,7 @@ def test_approves_all_eligible_claims(self, mock_claim_model, mock_logger):

board_post_save_re_evaluate_claims(sender=None, instance=instance)

assert claim_a.status == BoardCandidateClaim.Status.APPROVED
assert claim_a.is_locked is True
assert claim_b.status == BoardCandidateClaim.Status.APPROVED
assert claim_b.is_locked is True
mock_claim_model.objects.bulk_update.assert_called_once_with(
[claim_a, claim_b], ["is_locked", "status"]
)
mock_claim_model.bulk_set_status_approved.assert_called_once_with([claim_a, claim_b])
mock_logger.info.assert_called_once()

@patch("apps.owasp.signals.board_of_directors.BoardCandidateClaim")
Expand All @@ -103,4 +92,4 @@ def test_runs_when_threshold_updated(self, mock_claim_model):
board_post_save_re_evaluate_claims(sender=None, instance=instance)

instance.claims.filter.assert_called_once_with(status=BoardCandidateClaim.Status.SUBMITTED)
mock_claim_model.objects.bulk_update.assert_not_called()
mock_claim_model.bulk_set_status_approved.assert_not_called()
2 changes: 1 addition & 1 deletion frontend/src/app/board/[year]/candidates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ const CandidateCard = ({ candidate, isOwnProfile, year }: CandidateCardProps) =>
return (
<Button
onPress={handleCardClick}
className="group flex h-full w-full flex-col items-start justify-start rounded-lg bg-white p-6 text-left shadow-lg transition-transform duration-300 hover:scale-105 hover:shadow-xl dark:bg-gray-800 dark:shadow-gray-900/30"
className="group flex h-full w-full flex-col items-start justify-start rounded-lg bg-white p-6 text-left shadow-lg transition-shadow duration-300 hover:shadow-xl dark:bg-gray-800 dark:shadow-gray-900/30"
>
<div className="flex w-full items-start gap-4">
{candidate.member?.avatarUrl && (
Expand Down
Loading