diff --git a/app/crud.py b/app/crud.py index be9e5ba..9acd7f0 100644 --- a/app/crud.py +++ b/app/crud.py @@ -1,4 +1,5 @@ from datetime import datetime +import json import random import string from collections import defaultdict @@ -531,6 +532,15 @@ def update_ballot( _check_election_is_started(db_election) _check_election_is_not_ended(db_election) + # Check if modification is allowed + modification_allowed = True + try: + desc_json = json.loads(str(db_election.description)) + if isinstance(desc_json, dict) and "modificationAllowed" in desc_json: + modification_allowed = desc_json["modificationAllowed"] + except (json.JSONDecodeError, TypeError, ValueError): + pass + if len(ballot.votes) != len(vote_ids): raise errors.ForbiddenError("Edit all votes at once.") @@ -553,6 +563,13 @@ def update_ballot( if len(db_votes) != len(vote_ids): raise errors.NotFoundError("votes") + # If modification is NOT allowed, check if this is the first time voting + if not modification_allowed: + # A ballot is considered "voted" if at least one of its votes already has a grade_id + is_already_voted = any(v.grade_id is not None for v in db_votes) + if is_already_voted: + raise errors.VoteModificationForbiddenError() + # Verify all votes belong to the same ballot ballot_ids = {int(v.ballot_id) for v in db_votes if v.ballot_id is not None} diff --git a/app/errors.py b/app/errors.py index a097802..cb3464f 100644 --- a/app/errors.py +++ b/app/errors.py @@ -99,3 +99,8 @@ class ElectionIsActiveError(CustomError): error_code = "ELECTION_IS_ACTIVE" message = "This election is already active and cannot be modified." +class VoteModificationForbiddenError(CustomError): + status_code = 403 + error_code = "VOTE_MODIFICATION_FORBIDDEN" + message = "Modification of votes is not allowed in this election." + diff --git a/app/tests/test_api.py b/app/tests/test_api.py index 3c68172..2b579bb 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -77,6 +77,7 @@ class RandomElection(t.TypedDict): date_end: t.Optional[str] date_start: t.Optional[str] auth_for_result: bool + description: t.Optional[str] def _random_election(num_candidates: int, num_grades: int) -> RandomElection: @@ -98,6 +99,7 @@ def _random_election(num_candidates: int, num_grades: int) -> RandomElection: "num_voters": 0, "date_end": None, "auth_for_result": False, + "description": "", } @@ -943,3 +945,49 @@ def test_progress(): assert progress_rep.status_code == 200, progress_data assert progress_data["num_voters"] == 10 assert progress_data["num_voters_voted"] == 1 + +def test_vote_modification_restriction(): + """ + Tests that a vote cannot be modified if the election's modificationAllowed is False. + """ + import json + # Create a restricted election with modificationAllowed: False in description + body = _random_election(5, 3) + body["restricted"] = True + body["num_voters"] = 1 + # Store modificationAllowed in the JSON description + body["description"] = json.dumps({ + "description": "This is a test election", + "modificationAllowed": False + }) + + response = client.post("/elections", json=body) + assert response.status_code == 200, response.text + election_data = response.json() + ballot_token = election_data["invites"][0] + + # Cast initial vote + grade_id = election_data["grades"][0]["id"] + votes = [ + {"candidate_id": c["id"], "grade_id": grade_id} + for c in election_data["candidates"] + ] + response = client.put( + "/ballots", + json={"votes": votes}, + headers={"Authorization": f"Bearer {ballot_token}"} + ) + assert response.status_code == 200, "Initial vote failed" + + # Try to modify the vote + new_grade_id = election_data["grades"][1]["id"] + new_votes = [ + {"candidate_id": c["id"], "grade_id": new_grade_id} + for c in election_data["candidates"] + ] + response = client.put( + "/ballots", + json={"votes": new_votes}, + headers={"Authorization": f"Bearer {ballot_token}"} + ) + check_error_response(response, 403, "VOTE_MODIFICATION_FORBIDDEN")