Skip to content

Commit

Permalink
Added basic frontend for apportionment (#940)
Browse files Browse the repository at this point in the history
Co-authored-by: Oliver <oliver@sensibly.nl>
  • Loading branch information
Lionqueen94 and oliver3 authored Feb 20, 2025
1 parent 9e19c11 commit 75b295e
Show file tree
Hide file tree
Showing 76 changed files with 4,349 additions and 159 deletions.
15 changes: 14 additions & 1 deletion backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
}
},
"/api/elections/{election_id}/apportionment": {
"get": {
"post": {
"tags": [
"apportionment"
],
Expand Down Expand Up @@ -1351,6 +1351,8 @@
"description": "The result of the apportionment procedure. This contains the number of seats and the quota\nthat was used. It then contains the initial standing after whole seats were assigned,\nand each of the changes and intermediate standings. The final standing contains the\nnumber of seats per political group that was assigned after all seats were assigned.",
"required": [
"seats",
"whole_seats",
"residual_seats",
"quota",
"steps",
"final_standing"
Expand All @@ -1365,6 +1367,11 @@
"quota": {
"$ref": "#/components/schemas/Fraction"
},
"residual_seats": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"seats": {
"type": "integer",
"format": "int64",
Expand All @@ -1375,6 +1382,11 @@
"items": {
"$ref": "#/components/schemas/ApportionmentStep"
}
},
"whole_seats": {
"type": "integer",
"format": "int64",
"minimum": 0
}
}
},
Expand Down Expand Up @@ -1932,6 +1944,7 @@
"type": "string",
"description": "Error reference used to show the corresponding error message to the end-user",
"enum": [
"ApportionmentNotAvailableUntilDataEntryFinalised",
"DatabaseError",
"DrawingOfLotsRequired",
"EntryNotFound",
Expand Down
39 changes: 27 additions & 12 deletions backend/src/apportionment/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::{
apportionment::{seat_allocation, ApportionmentResult},
data_entry::repository::PollingStationResultsEntries,
apportionment::{seat_allocation, ApportionmentError, ApportionmentResult},
data_entry::{
repository::{PollingStationDataEntries, PollingStationResultsEntries},
status::DataEntryStatusName,
},
election::repository::Elections,
polling_station::repository::PollingStations,
summary::ElectionSummary,
Expand All @@ -23,7 +26,7 @@ pub struct ElectionApportionmentResponse {

/// Get the seat allocation for an election
#[utoipa::path(
get,
post,
path = "/api/elections/{election_id}/apportionment",
responses(
(status = 200, description = "Election Apportionment", body = ElectionApportionmentResponse),
Expand All @@ -37,18 +40,30 @@ pub struct ElectionApportionmentResponse {
)]
pub async fn election_apportionment(
State(elections_repo): State<Elections>,
State(data_entry_repo): State<PollingStationDataEntries>,
State(polling_stations_repo): State<PollingStations>,
State(polling_station_results_entries_repo): State<PollingStationResultsEntries>,
Path(id): Path<u32>,
) -> Result<Json<ElectionApportionmentResponse>, APIError> {
let election = elections_repo.get(id).await?;
let results = polling_station_results_entries_repo
.list_with_polling_stations(polling_stations_repo, election.id)
.await?;
let election_summary = ElectionSummary::from_results(&election, &results)?;
let apportionment = seat_allocation(election.number_of_seats.into(), &election_summary)?;
Ok(Json(ElectionApportionmentResponse {
apportionment,
election_summary,
}))
let statuses = data_entry_repo.statuses(id).await?;
if !statuses.is_empty()
&& statuses
.iter()
.all(|s| s.status == DataEntryStatusName::Definitive)
{
let results = polling_station_results_entries_repo
.list_with_polling_stations(polling_stations_repo, election.id)
.await?;
let election_summary = ElectionSummary::from_results(&election, &results)?;
let apportionment = seat_allocation(election.number_of_seats.into(), &election_summary)?;
Ok(Json(ElectionApportionmentResponse {
apportionment,
election_summary,
}))
} else {
Err(APIError::Apportionment(
ApportionmentError::ApportionmentNotAvailableUntilDataEntryFinalised,
))
}
}
5 changes: 5 additions & 0 deletions backend/src/apportionment/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ mod fraction;
#[derive(Debug, PartialEq, Serialize, Deserialize, ToSchema)]
pub struct ApportionmentResult {
pub seats: u64,
pub whole_seats: u64,
pub residual_seats: u64,
pub quota: Fraction,
pub steps: Vec<ApportionmentStep>,
pub final_standing: Vec<PoliticalGroupSeatAssignment>,
Expand Down Expand Up @@ -271,6 +273,8 @@ pub fn seat_allocation(

Ok(ApportionmentResult {
seats,
whole_seats: whole_seats_count,
residual_seats: remaining_seats,
quota,
steps,
final_standing: final_standing.into_iter().map(Into::into).collect(),
Expand Down Expand Up @@ -494,6 +498,7 @@ pub struct HighestSurplusAssignedSeat {
/// Errors that can occur during apportionment
#[derive(Debug, PartialEq)]
pub enum ApportionmentError {
ApportionmentNotAvailableUntilDataEntryFinalised,
DrawingOfLotsNotImplemented,
}

Expand Down
30 changes: 10 additions & 20 deletions backend/src/data_entry/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,7 @@ impl DataEntryStatus {
}
}

/// Get the progress of the first entry (if there is a first entry), from
/// 0 - 100
/// Get the progress of the first entry (if there is a first entry), from 0 to 100
pub fn get_first_entry_progress(&self) -> Option<u8> {
match self {
DataEntryStatus::FirstEntryNotStarted => None,
Expand All @@ -393,8 +392,7 @@ impl DataEntryStatus {
}
}

/// Get the progress of the second entry (if there is a second entry),
/// from 0 - 100
/// Get the progress of the second entry (if there is a second entry), from 0 to 100
pub fn get_second_entry_progress(&self) -> Option<u8> {
match self {
DataEntryStatus::FirstEntryNotStarted
Expand All @@ -405,7 +403,7 @@ impl DataEntryStatus {
}
}

/// Get the total progress of the data entry process (from 0 - 100)
/// Get the total progress of the data entry process, from 0 to 100
pub fn get_progress(&self) -> u8 {
match self {
DataEntryStatus::FirstEntryNotStarted => 0,
Expand Down Expand Up @@ -447,14 +445,6 @@ impl DataEntryStatus {
}
}

/// Returns true if the first entry is finished
pub fn is_first_entry_finished(&self) -> bool {
matches!(
self,
DataEntryStatus::FirstEntryNotStarted | DataEntryStatus::FirstEntryInProgress(_)
)
}

/// Returns the timestamp at which point this data entry process was made definitive
pub fn finished_at(&self) -> Option<&DateTime<Utc>> {
match self {
Expand Down Expand Up @@ -541,8 +531,8 @@ mod tests {
number_of_voters: 100,
category: ElectionCategory::Municipal,
number_of_seats: 18,
election_date: chrono::Utc::now().date_naive(),
nomination_date: chrono::Utc::now().date_naive(),
election_date: Utc::now().date_naive(),
nomination_date: Utc::now().date_naive(),
status: ElectionStatus::DataEntryInProgress,
political_groups: Some(vec![]),
}
Expand All @@ -559,14 +549,14 @@ mod tests {
fn second_entry_not_started() -> DataEntryStatus {
DataEntryStatus::SecondEntryNotStarted(SecondEntryNotStarted {
finalised_first_entry: polling_station_result(),
first_entry_finished_at: chrono::Utc::now(),
first_entry_finished_at: Utc::now(),
})
}

fn second_entry_in_progress() -> DataEntryStatus {
DataEntryStatus::SecondEntryInProgress(SecondEntryInProgress {
finalised_first_entry: polling_station_result(),
first_entry_finished_at: chrono::Utc::now(),
first_entry_finished_at: Utc::now(),
progress: 0,
second_entry: polling_station_result(),
client_state: ClientState::default(),
Expand All @@ -575,7 +565,7 @@ mod tests {

fn definitive() -> DataEntryStatus {
DataEntryStatus::Definitive(Definitive {
finished_at: chrono::Utc::now(),
finished_at: Utc::now(),
})
}

Expand Down Expand Up @@ -827,7 +817,7 @@ mod tests {
},
client_state: ClientState::new_from_str(Some("{}")).unwrap(),
finalised_first_entry: polling_station_result(),
first_entry_finished_at: chrono::Utc::now(),
first_entry_finished_at: Utc::now(),
});
let next = initial.finalise_second_entry(&polling_station(), &election());
assert!(matches!(
Expand All @@ -842,7 +832,7 @@ mod tests {
fn second_entry_in_progress_finalise_not_equal() {
let initial = DataEntryStatus::SecondEntryInProgress(SecondEntryInProgress {
finalised_first_entry: polling_station_result(),
first_entry_finished_at: chrono::Utc::now(),
first_entry_finished_at: Utc::now(),
progress: 0,
second_entry: PollingStationResults {
voters_counts: VotersCounts {
Expand Down
9 changes: 9 additions & 0 deletions backend/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use zip::result::ZipError;
/// Error reference used to show the corresponding error message to the end-user
#[derive(Serialize, Deserialize, ToSchema, Debug)]
pub enum ErrorReference {
ApportionmentNotAvailableUntilDataEntryFinalised,
DatabaseError,
DrawingOfLotsRequired,
EntryNotFound,
Expand Down Expand Up @@ -241,6 +242,14 @@ impl IntoResponse for APIError {
error!("Apportionment error: {:?}", err);

match err {
ApportionmentError::ApportionmentNotAvailableUntilDataEntryFinalised => (
StatusCode::PRECONDITION_FAILED,
to_error(
"Election data entry first needs to be finalised",
ErrorReference::ApportionmentNotAvailableUntilDataEntryFinalised,
false,
),
),
ApportionmentError::DrawingOfLotsNotImplemented => (
StatusCode::UNPROCESSABLE_ENTITY,
to_error(
Expand Down
92 changes: 91 additions & 1 deletion backend/tests/apportionment_integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ use sqlx::SqlitePool;
use test_log::test;

use crate::{
shared::{create_result, create_result_with_non_example_data_entry},
shared::{
create_and_finalise_data_entry, create_result, create_result_with_non_example_data_entry,
},
utils::serve_api,
};
use abacus::{
Expand All @@ -16,6 +18,7 @@ use abacus::{
status::ClientState, CandidateVotes, DataEntry, DifferencesCounts, PoliticalGroupVotes,
PollingStationResults, VotersCounts, VotesCounts,
},
election::Election,
ErrorResponse,
};

Expand Down Expand Up @@ -136,6 +139,91 @@ async fn test_election_apportionment_error_drawing_of_lots_not_implemented(pool:
assert_eq!(body.error, "Drawing of lots is required");
}

#[test(sqlx::test)]
async fn test_election_apportionment_error_apportionment_not_available_no_polling_stations(
pool: SqlitePool,
) {
let addr = serve_api(pool).await;

// Create election without polling stations
let response = reqwest::Client::new()
.post(format!("http://{addr}/api/elections"))
.json(&serde_json::json!({
"name": "Test Election",
"location": "Test Location",
"number_of_voters": 100,
"category": "Municipal",
"number_of_seats": 29,
"election_date": "2026-01-01",
"nomination_date": "2026-01-01",
"status": "DataEntryInProgress",
"political_groups": [
{
"number": 1,
"name": "Political Group A",
"candidates": [
{
"number": 1,
"initials": "A.",
"first_name": "Alice",
"last_name": "Foo",
"locality": "Amsterdam",
"gender": "Female"
},
{
"number": 2,
"initials": "C.",
"first_name": "Charlie",
"last_name": "Doe",
"locality": "Rotterdam",
"gender": null
}
]
}
]
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let election: Election = response.json().await.unwrap();

let url = format!(
"http://{}/api/elections/{}/apportionment",
addr, election.id
);
let response = reqwest::Client::new().post(&url).send().await.unwrap();

// Ensure the response is what we expect
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
let body: ErrorResponse = response.json().await.unwrap();
assert_eq!(
body.error,
"Election data entry first needs to be finalised"
);
}

#[test(sqlx::test(fixtures(path = "../fixtures", scripts("election_3"))))]
async fn test_election_apportionment_error_apportionment_not_available_until_data_entries_finalised(
pool: SqlitePool,
) {
let addr = serve_api(pool).await;

// Add and finalise first data entry
create_and_finalise_data_entry(&addr, 3, 1).await;

let url = format!("http://{addr}/api/elections/3/apportionment");
let response = reqwest::Client::new().post(&url).send().await.unwrap();

// Ensure the response is what we expect
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
let body: ErrorResponse = response.json().await.unwrap();
assert_eq!(
body.error,
"Election data entry first needs to be finalised"
);
}

#[test(sqlx::test)]
async fn test_election_apportionment_election_not_found(pool: SqlitePool) {
let addr = serve_api(pool).await;
Expand All @@ -145,4 +233,6 @@ async fn test_election_apportionment_election_not_found(pool: SqlitePool) {

// Ensure the response is what we expect
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body: ErrorResponse = response.json().await.unwrap();
assert_eq!(body.error, "Item not found");
}
Loading

0 comments on commit 75b295e

Please sign in to comment.