Skip to content
Draft
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
1 change: 1 addition & 0 deletions locales/en/csb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import:
not_found: No data was found for the entered hash code.
index:
closes_on: Closes on
download_election_definition: Download election definition
feature:
add_omissions: Add omissions and corrections
create_i1_letters: Create I 1 and omission letters
Expand Down
1 change: 1 addition & 0 deletions locales/nl/csb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import:
not_found: Er is geen data gevonden voor de ingevoerde hash code.
index:
closes_on: Sluit op
download_election_definition: Verkiezings\u00ADdefinitie downloaden
feature:
add_omissions: Verzuimen en correcties toevoegen
create_i1_letters: I 1 en verzuimbrieven aanmaken
Expand Down
45 changes: 42 additions & 3 deletions src/core/election/configs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ super::define_elections! {
en: "Election of the Senate of the States General 2027",
},
electoral_districts: ElectoralDistrict::ek27(),
nineteen_or_more_seats: true,
number_of_seats: 75,
frisian_export_allowed: false,
eligible_date_of_birth: NaiveDate::from_ymd_opt(2014, 4, 20).unwrap(), // TODO: determine definitive date
nomination_day_date: NaiveDate::from_ymd_opt(2027, 4, 20).unwrap(),
Expand Down Expand Up @@ -57,7 +57,20 @@ super::define_elections! {
Province::NB => &[ElectoralDistrict::PsTilburg, ElectoralDistrict::PsDenBosch],
Province::LI => &[ElectoralDistrict::PsMaastricht, ElectoralDistrict::PsVenlo],
},
nineteen_or_more_seats: true, // for this election, all provinces have >= 19 seats
number_of_seats: match province {
Province::GR => 43,
Province::FR => 43,
Province::DR => 43,
Province::OV => 47,
Province::FL => 41,
Province::GE => 55,
Province::UT => 49,
Province::NH => 55,
Province::ZH => 55,
Province::ZE => 39,
Province::NB => 55,
Province::LI => 47,
},
frisian_export_allowed: matches!(province, Province::FR),
eligible_date_of_birth: NaiveDate::from_ymd_opt(2014, 2, 1).unwrap(), // TODO: determine definitive date
nomination_day_date: NaiveDate::from_ymd_opt(2027, 2, 1).unwrap(),
Expand Down Expand Up @@ -105,7 +118,29 @@ super::define_elections! {
WaterCouncil::Limburg => &[ElectoralDistrict::WsLimburg],
WaterCouncil::Zuiderzeeland => &[ElectoralDistrict::WsZuiderzeeland],
},
nineteen_or_more_seats: true, // for this election, all councils have >= 19 seats
number_of_seats: match water_council {
WaterCouncil::AaEnMaas => 26,
WaterCouncil::AmstelGooiEnVecht => 26,
WaterCouncil::BrabantseDelta => 26,
WaterCouncil::DeDommel => 26,
WaterCouncil::DeStichtseRijnlanden => 26,
WaterCouncil::Delfland => 26,
WaterCouncil::DrentsOverijsselseDelta => 25,
WaterCouncil::HollandseDelta => 26,
WaterCouncil::HollandsNoorderkwartier => 26,
WaterCouncil::HunzeEnAas => 19,
WaterCouncil::Fryslan => 21,
WaterCouncil::Limburg => 26,
WaterCouncil::Noorderzijlvest => 19,
WaterCouncil::RijnEnIJssel => 30,
WaterCouncil::Rijnland => 26,
WaterCouncil::Rivierenland => 26,
WaterCouncil::SchielandEnDeKrimpenerwaard => 26,
WaterCouncil::Scheldestromen => 26,
WaterCouncil::ValleiEnVeluwe => 26,
WaterCouncil::Vechtstromen => 23,
WaterCouncil::Zuiderzeeland => 21,
},
frisian_export_allowed: matches!(water_council, WaterCouncil::Fryslan),
eligible_date_of_birth: NaiveDate::from_ymd_opt(2014, 2, 1).unwrap(), // TODO: determine definitive date
nomination_day_date: NaiveDate::from_ymd_opt(2027, 2, 1).unwrap(),
Expand Down Expand Up @@ -248,6 +283,10 @@ impl ElectionConfig {
pub fn has_only_one_district(&self) -> bool {
self.electoral_districts().len() == 1
}

pub fn nineteen_or_more_seats(&self) -> bool {
self.number_of_seats() >= 19
}
}

#[cfg(test)]
Expand Down
6 changes: 3 additions & 3 deletions src/core/election/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ macro_rules! define_elections {
en: $title_en:expr $(,)?
},
electoral_districts: $electoral_districts:expr,
nineteen_or_more_seats: $nineteen_or_more_seats:expr,
number_of_seats: $number_of_seats:expr,
frisian_export_allowed: $frisian_export_allowed:expr,
eligible_date_of_birth: $eligible_date_of_birth:expr,
nomination_day_date: $nomination_day_date:expr,
Expand Down Expand Up @@ -126,11 +126,11 @@ macro_rules! define_elections {
}
}

pub fn nineteen_or_more_seats(&self) -> bool {
pub fn number_of_seats(&self) -> u64 {
#[allow(unused)]
match self {
$(
Self::$name $(($binding))? => $nineteen_or_more_seats,
Self::$name $(($binding))? => $number_of_seats,
)*
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/csb/index/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ pub mod pages;
mod paths;

pub use pages::router;
pub use paths::CsbIndexPath;
pub use paths::{CsbElectionDefinitionDownloadPath, CsbIndexPath};
83 changes: 83 additions & 0 deletions src/csb/index/pages/election_definition.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use axum::{extract::State, http::HeaderValue, response::IntoResponse};

use crate::{
AppError, CsbMainStore, CsbStoreData,
csb::{WithCorrections, index::CsbElectionDefinitionDownloadPath},
models::eml::eml110a::eml110a,
store::StoreRegistry,
utils::no_cache_headers,
};

const XML_CONTENT_TYPE: &str = "application/xml";

pub async fn download_election_definition(
_: CsbElectionDefinitionDownloadPath,
main_store: CsbMainStore,
State(csb_registry): State<StoreRegistry<CsbStoreData>>,
) -> Result<impl IntoResponse, AppError> {
let election = main_store.election;

// TODO: get registered party from a proper source
// Blank lists probably shouldn't be included
let registered_party_names = csb_registry
.stores_by_scope()
.await?
.into_iter()
.map(|store| store.get_display_name(WithCorrections::All))
.collect();

let bytes = eml110a(&election, registered_party_names)?;

let headers = no_cache_headers::generate_attachment_headers(
"eml110a.eml.xml",
HeaderValue::from_static(XML_CONTENT_TYPE),
)?;

Ok((headers, bytes).into_response())
}

#[cfg(test)]
mod tests {
use super::*;
use axum::{
extract::FromRef,
http::{StatusCode, header},
response::IntoResponse,
};

use crate::{AppState, store::StoreRegistry};

#[tokio::test]
async fn download_election_definition_returns_xml_response() -> Result<(), AppError> {
let main_store = CsbMainStore::new_for_test();
let state = AppState::new_for_tests().await;
let csb_registry = StoreRegistry::<CsbStoreData>::from_ref(&state);

let response = download_election_definition(
CsbElectionDefinitionDownloadPath,
main_store,
State(csb_registry),
)
.await?
.into_response();

assert_eq!(response.status(), StatusCode::OK);
let headers = response.headers();
assert_eq!(
headers.get(header::CONTENT_TYPE).expect("content type"),
"application/xml"
);
assert_eq!(
headers
.get(header::CONTENT_DISPOSITION)
.expect("content disposition"),
"attachment; filename=\"eml110a.eml.xml\""
);
assert_eq!(
headers.get(header::CACHE_CONTROL).expect("cache control"),
"no-store, no-cache, must-revalidate, max-age=0"
);

Ok(())
}
}
4 changes: 4 additions & 0 deletions src/csb/index/pages/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
{% let election = "election"|election_value %}
{{ (&election)|election_title }}
{% endblock %}
{% block header_action %}
<a href="{{ crate::csb::index::CsbElectionDefinitionDownloadPath }}"
class="button secondary icon-download">{{ "csb.index.download_election_definition"|trans }}</a>
{% endblock %}
{% block content %}
{% let election = "election"|election_value %}
<div class="cards cards-csb-phases">
Expand Down
5 changes: 4 additions & 1 deletion src/csb/index/pages/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ use crate::AppState;
use axum::Router;
use axum_extra::routing::RouterExt;

mod election_definition;
mod index;

pub fn router() -> Router<AppState> {
Router::new().typed_get(index::index)
Router::new()
.typed_get(index::index)
.typed_get(election_definition::download_election_definition)
}
4 changes: 4 additions & 0 deletions src/csb/index/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ use crate::AppError;
#[derive(TypedPath)]
#[typed_path("/csb", rejection(AppError))]
pub struct CsbIndexPath;

#[derive(TypedPath)]
#[typed_path("/csb/eml110a.eml.xml", rejection(AppError))]
pub struct CsbElectionDefinitionDownloadPath;
2 changes: 1 addition & 1 deletion src/models/documents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use super::{
Pdf,
eml210::eml210,
eml::eml210::eml210,
h1::H1,
h3::H3,
h4::H4,
Expand Down
132 changes: 132 additions & 0 deletions src/models/eml/eml110a.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! The EML Election Definition (`110a`) export, built with [`eml_nl`].

use std::num::NonZeroU64;

use eml_nl::{
common::ContestIdentifier,
documents::{
EML, ElectionIdentifierBuilder,
election_definition::{ElectionDefinition, ElectionDefinitionRegisteredParty},
},
io::EMLWrite,
utils::{ElectionSubcategory, VotingMethod},
};

use crate::{AppError, ElectionConfig};

/// Build the EML 110a election definition XML for the given election and
/// the list of registered party names.
pub fn eml110a(
election: &ElectionConfig,
registered_party_names: Vec<String>,
) -> Result<Vec<u8>, AppError> {
let subcategory = eml_nl::utils::ElectionSubcategory::from(election);

let contest_identifier = if election.has_only_one_district() {
ContestIdentifier::geen()
} else {
ContestIdentifier::alle()
};

// The "voorkeursdrempel", see Kieswet P 15
let preference_threshold: u64 = if subcategory == ElectionSubcategory::GR1 {
50
} else {
25
};

let now = chrono::Utc::now();
let definition = ElectionDefinition::builder()
.transaction_id(1)
.issue_date(now.date_naive())
.creation_date_time(now)
.election_identifier(
ElectionIdentifierBuilder::try_from(*election)?.build_for_definition()?,
)
.contest_identifier(contest_identifier)
.voting_method(VotingMethod::SPV)
.max_votes(NonZeroU64::new(1).unwrap()) // 1 is the default max votes => always empty
.number_of_seats(election.number_of_seats())
.preference_threshold(preference_threshold)
.registered_parties(
registered_party_names
.into_iter()
.map(ElectionDefinitionRegisteredParty::new)
.collect::<Vec<_>>(),
)
.build()?;

Ok(EML::from_election_definition_doc(definition).write_eml_root(true, true)?)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{ElectionConfig, Province, WaterCouncil};

fn check_eml(response: &str, expected: &str) {
let stringify_election_event = |eml: EML| {
format!(
"{:?}",
eml.as_election_definition_doc().unwrap().election_event
)
};

let received = stringify_election_event(response.parse().unwrap());
let expected = stringify_election_event(expected.parse().unwrap());

assert_eq!(received, expected, "received XML:\n{}", response);
}

#[test]
fn ek_export() {
let eml = eml110a(
&ElectionConfig::EK27,
vec!["Kiesraad Demo".to_string(), "Andere Partij".to_string()],
)
.unwrap();
check_eml(
&String::from_utf8(eml).unwrap(),
include_str!("testdata/110a-ek27.eml.xml"),
);
}

#[test]
fn ps1_export() {
let eml = eml110a(
&ElectionConfig::PS27(Province::GR),
vec!["Kiesraad Demo".to_string(), "Andere Partij".to_string()],
)
.unwrap();
check_eml(
&String::from_utf8(eml).unwrap(),
include_str!("testdata/110a-ps27-1.eml.xml"),
);
}

#[test]
fn ps2_export() {
let eml = eml110a(
&ElectionConfig::PS27(Province::LI),
vec!["Kiesraad Demo".to_string(), "Andere Partij".to_string()],
)
.unwrap();
check_eml(
&String::from_utf8(eml).unwrap(),
include_str!("testdata/110a-ps27-2.eml.xml"),
);
}

#[test]
fn ws_export() {
let eml = eml110a(
&ElectionConfig::WS27(WaterCouncil::Fryslan),
vec!["Water Water".to_string()],
)
.unwrap();
check_eml(
&String::from_utf8(eml).unwrap(),
include_str!("testdata/110a-ws27.eml.xml"),
);
}
}
Loading