diff --git a/app/crud.py b/app/crud.py index 0410986..dc90404 100644 --- a/app/crud.py +++ b/app/crud.py @@ -173,6 +173,12 @@ def _create_election_without_candidates_or_grade( params = election.model_dump() del params["candidates"] del params["grades"] + + if params.get("date_start") is not None and params.get("date_end") is not None: + if params["date_start"] >= params["date_end"]: + raise errors.ForbiddenError( + "The start date must be before the end date of the election" + ) db_election = models.Election(**params) db.add(db_election) @@ -283,6 +289,17 @@ def update_election( if db_election is None: raise errors.NotFoundError("elections") + if election.date_start is not None and election.date_end is None and db_election.date_end is not None: + if election.date_start > db_election.date_end: + raise errors.ForbiddenError( + "The start date must be before the end date of the election" + ) + elif election.date_end is not None and election.date_start is None: + if election.date_end < db_election.date_start: + raise errors.ForbiddenError( + "The end date must be after the start date of the election" + ) + if ( election.restricted is not None and bool(db_election.restricted) != election.restricted diff --git a/app/main.py b/app/main.py index 790dddb..08795ed 100644 --- a/app/main.py +++ b/app/main.py @@ -28,6 +28,16 @@ async def main(): return {"message": "Hello World"} +@app.exception_handler(schemas.ArgumentsSchemaError) +async def invalid_schema_exception_handler( + request: Request, exc: schemas.ArgumentsSchemaError +): + return JSONResponse( + status_code=422, + content={ + "message": f"Validation Error. {exc}", + }, + ) @app.exception_handler(errors.NotFoundError) async def not_found_exception_handler(request: Request, exc: errors.NotFoundError): diff --git a/app/schemas.py b/app/schemas.py index 6db2498..2d1e270 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,7 +1,8 @@ import typing as t +from typing_extensions import Self from datetime import datetime, timedelta, timezone import dateutil.parser -from pydantic import BaseModel, Field, field_validator, ValidationInfo +from pydantic import BaseModel, Field, field_validator, ValidationInfo, model_validator from pydantic_settings import SettingsConfigDict from .settings import settings @@ -121,6 +122,13 @@ class ElectionBase(BaseModel): restricted: bool = False auth_for_result: bool = False + @model_validator(mode="after") + def check_dates_order(self) -> Self: + if self.date_start and self.date_end and _parse_date(self.date_start) > _parse_date(self.date_end): + raise ArgumentsSchemaError("date_start must be before or equal to date_end") + + return self + @field_validator("date_end", "date_start", mode="before") @classmethod def parse_date(cls, value): @@ -229,6 +237,13 @@ class ElectionUpdate(BaseModel): candidates: list[CandidateUpdate] | None = None auth_for_result: bool | None = None + @model_validator(mode="after") + def check_dates_order(self) -> Self: + if self.date_start and self.date_end and _parse_date(self.date_start) > _parse_date(self.date_end): + raise ArgumentsSchemaError(f"date_start must be before or equal to date_end") + + return self + @field_validator("date_end", "date_start", mode="before") @classmethod def parse_date(cls, value): diff --git a/app/tests/test_api.py b/app/tests/test_api.py index 9330201..90c2420 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -111,6 +111,46 @@ def test_create_election(): data = response.json() assert data["name"] == body["name"] +def test_start_end_date_are_valid(): + # cannot create an election where the start date is after the end date + body = _random_election(2, 2) + body["date_start"] = (datetime.now() + timedelta(days=1)).isoformat() + body["date_end"] = (datetime.now()).isoformat() + response = client.post("/elections", json=body) + assert response.status_code == 422, response.text + + body["date_start"] = (datetime.now()).isoformat() + body["date_end"] = (datetime.now() + timedelta(days=1)).isoformat() + response = client.post("/elections", json=body) + assert response.status_code == 200, response.text + election_data = response.json() + del election_data["candidates"] + del election_data["grades"] + del election_data["name"] + del election_data["restricted"] + del election_data["hide_results"] + del election_data["auth_for_result"] + admin_token = election_data["admin"] + election_ref = election_data["ref"] + + # update election should not be allowed if the start date is after the end date + election_data["date_start"] = (datetime.now() + timedelta(days=1)).isoformat() + election_data["date_end"] = (datetime.now()).isoformat() + response = client.put("/elections", json=election_data, headers={"Authorization": f"Bearer {admin_token}"}) + assert response.status_code == 422, response.text + + # update election should be allowed if the start date is before the end date + del election_data["date_start"] + election_data["date_end"] = (datetime.now() - timedelta(days=1)).isoformat() + response = client.put("/elections", json=election_data, headers={"Authorization": f"Bearer {admin_token}"}) + assert response.status_code == 403, response.text + + # update election should be allowed if the start date is before the end date + del election_data["date_end"] + election_data["date_start"] = (datetime.now() + timedelta(days=2)).isoformat() + response = client.put("/elections", json=election_data, headers={"Authorization": f"Bearer {admin_token}"}) + assert response.status_code == 403, response.text + def test_get_election(): body = _random_election(3, 4) @@ -309,13 +349,14 @@ def test_cannot_create_vote_on_ended_election(): """ # Create a random election body = _random_election(10, 5) + body["date_start"] = (datetime.now() - timedelta(days=2)).isoformat() body["date_end"] = (datetime.now() - timedelta(days=1)).isoformat() response = client.post("/elections", json=body) election_data = response.json() assert response.status_code == 200, election_data assert len(election_data["invites"]) == 0 election_ref = election_data["ref"] - ballot_token = election_data["admin"] + admin_token = election_data["admin"] # We create votes using the ID votes = _generate_votes_from_response("id", election_data) @@ -330,7 +371,7 @@ def test_cannot_create_vote_on_ended_election(): response = client.put( f"/elections", json={"force_close": True, "date_end":(datetime.now() + timedelta(days=1)).isoformat(), "ref": election_ref}, - headers={"Authorization": f"Bearer {ballot_token}"}, + headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 200, response.json() @@ -386,10 +427,14 @@ def test_cannot_update_vote_on_ended_election(): # Test for date_end in the past response = client.put( f"/elections", - json={"force_close": False, "date_end":(datetime.now() - timedelta(days=1)).isoformat(), "ref": election_ref}, + json={"force_close": False, "date_start":(datetime.now() - timedelta(days=2)).isoformat(), "date_end":(datetime.now() - timedelta(days=1)).isoformat(), "ref": election_ref}, headers={"Authorization": f"Bearer {election_token}"}, ) + assert response.status_code == 200, response.json()["date_end"] + + print(response.json()["date_end"]) + response = client.put( f"/ballots", json={"votes": votes},