This commit is contained in:
Yann 2024-05-14 11:51:19 +02:00
parent bac0eaf561
commit 8ab1c51bac
8 changed files with 463 additions and 76 deletions

View File

@ -7,8 +7,9 @@ edition = "2021"
[dependencies]
axum = "0.7.5"
ldap3 = "0.11.3"
pandoc = "0.8.11"
serde = { version = "1.0.197", features = ["derive"] }
serde = { version = "1.0.197", features = ["derive", "rc"] }
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio"] }
strum = { version = "0.26.2", features = ["derive"] }
tera = "1.19.1"
@ -17,3 +18,5 @@ tokio-util = { version = "0.7.10", features = ["io-util"] }
tower-http = { version = "0.5.2", features = ["fs"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
uuid = { version = "1.8.0", features = ["v5", "v4"] }
validator = { version = "0.17.0", features = ["derive"] }

View File

@ -30,10 +30,12 @@ La génération du PDF se fait juste après l'insertion des données dans la bas
## Todo fonctionnel
Poc :
- [ ] formulaire de quelques champs, avec validation des données
- [x] formulaire de quelques champs, avec validation des données
- [x] récupération de ces données et les injecter dans la base
- [x] génération PDF du dossier
- [ ] transfert vers la page de paiement au besoin
- [x] lien vers le dossier pdf
- [x] transfert vers la page de paiement au besoin
- [ ] s'identifier via ldap, voir le logiciel du magasin pour la configuration
- [ ] envoie de mail de confirmation
Plus tard :

View File

@ -1,21 +1,26 @@
use std::path::PathBuf;
use std::{fmt::Display, path::PathBuf, sync::Arc};
use axum::{extract::State, response::Html, routing::{get, post}, Form, Router};
use axum::{extract::{Path, State}, http::{StatusCode, Uri}, response::{Html, Redirect}, routing::{get, post}, Form, Router};
use ldap3::{LdapConn, LdapConnAsync, LdapConnSettings, Scope, SearchEntry};
use pandoc::{InputFormat, InputKind, MarkdownExtension, OutputKind};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use strum::EnumIter;
use strum::IntoEnumIterator;
use strum::{EnumIter, IntoEnumIterator};
use tera::{Context, Tera};
use tokio::sync::Mutex;
use tower_http::services::ServeDir;
use tracing::{debug, error, Level};
use uuid::Uuid;
use validator::Validate;
#[derive(Clone)]
struct AppState {
tera: Tera,
pool: SqlitePool
pool: SqlitePool,
// conserve les données saisies pour les représenter au cas où elles ne sont pas validées
form: Arc<Mutex<FormCandidate>>
}
impl AppState {
@ -40,6 +45,7 @@ impl AppState {
AppState {
pool,
tera,
form: Arc::new(Mutex::new(FormCandidate::new()))
}
}
}
@ -55,10 +61,13 @@ async fn main() {
// build our application with a single route
let app = Router::new()
.route("/", get(inscription))
.route("/login", get(login))
.route("/login", post(logged_in))
.route("/inscription", get(inscription))
.route("/recapitulation", post(recapitulation))
.route("/add_candidate", post(add_candidate))
.route("/recapitulation/:uuid", get(recapitulation))
.fallback(fallback)
// Permet de servir les fichiers PDF des dossiers générés (static file)
// TODO: mettre une fallback
.nest_service("/dossiers", ServeDir::new("./dossiers"))
// .nest("dossiers", get(get_static_file))
.with_state(state);
@ -68,65 +77,214 @@ async fn main() {
axum::serve(listener, app).await.unwrap();
}
async fn inscription(State(state): State<AppState>) -> Html<String> {
let teracontext = tera::Context::new();
Html(state.tera.render(Template::MainForm.display(), &teracontext).unwrap())
async fn fallback(uri: Uri) -> (StatusCode, String) {
(StatusCode::NOT_FOUND, format!("No route for {uri}"))
}
#[derive(Serialize, Deserialize, Debug, Clone, sqlx::FromRow)]
struct Candidate {
async fn inscription(State(state): State<AppState>) -> Html<String> {
debug!("Inscription");
debug!("FormCandidate: {:?}", state.form);
let form = state.form.lock().await;
Html(state.tera.render(Template::Inscription.display(), &Context::from_serialize(form.clone()).unwrap()).unwrap())
}
async fn login(State(state): State<AppState>) -> Html<String> {
debug!("Login");
Html(state.tera.render(Template::Login.display(), &Context::new()).unwrap())
}
#[derive(Serialize, Deserialize, Validate, Debug, Clone)]
struct Login {
login: String,
password: String,
}
#[derive(Serialize, Deserialize, Validate, Debug, Clone, sqlx::FromRow)]
struct FormCandidate {
#[validate(length(min = 5))]
#[sqlx(rename="prenom")]
firstname: String,
#[validate(length(min = 5))]
#[sqlx(rename="nom")]
lastname: String,
birthdate: String,
birthplace: String,
birthplace_dep: String,
birthplace_country: String,
citizenship: String,
scholarship_holder: bool,
bea_number: String,
address: String,
postcode: String,
city: String,
country: String,
mobile_phone: String,
email: String,
year_n: Option<AcademicYear>,
year_n_minus_1: Option<AcademicYear>,
year_n_minus_2: Option<AcademicYear>,
year_n_minus_3: Option<AcademicYear>,
#[serde(skip_deserializing)]
errors: Vec<String>,
}
impl PartialEq for Candidate {
impl FormCandidate {
fn new() -> FormCandidate {
FormCandidate {
firstname: String::new(),
lastname: String::new(),
birthdate: String::new(),
birthplace: String::new(),
birthplace_dep: String::new(),
birthplace_country: String::new(),
citizenship: String::new(),
scholarship_holder: false,
bea_number: String::new(),
errors: Vec::new(),
address: String::new(),
postcode: String::new(),
city: String::new(),
country: String::new(),
mobile_phone: String::new(),
email: String::new(),
year_n: None,
year_n_minus_1: None,
year_n_minus_2: None,
year_n_minus_3: None,
}
}
}
impl PartialEq for FormCandidate {
fn eq(&self, other: &Self) -> bool {
self.firstname == other.firstname && self.lastname == other.lastname
}
}
#[derive(Clone,Serialize,Deserialize,Debug)]
enum YearOfBachelor {
First,
Second,
Third
}
impl ToString for YearOfBachelor {
fn to_string(&self) -> String {
match self {
YearOfBachelor::First => String::from("1ère année"),
YearOfBachelor::Second => String::from("2ème année"),
YearOfBachelor::Third => String::from("3ème année"),
}
}
}
#[derive(Clone,Serialize,Deserialize,Debug)]
struct AcademicYear {
degree: String,
year: YearOfBachelor,
school: String
}
#[derive(Serialize, Deserialize, Validate, Debug, Clone, sqlx::FromRow)]
struct Candidate {
#[sqlx(rename="prenom")]
firstname: String,
#[sqlx(rename="nom")]
lastname: String,
#[sqlx(rename="uuid")]
uuid: String,
}
#[derive(EnumIter)]
enum Template {
MainForm,
Dossier,
Inscription,
Login,
Pdf
}
impl Template {
fn display(&self) -> &str {
match self {
Template::Pdf => "pdf.html",
Template::MainForm => "index.html"
Template::Inscription => "inscription.html",
Template::Login => "login.html",
Template::Dossier => "dossier.html"
}
}
}
async fn recapitulation(State(state): State<AppState>, Form(candidate): Form<Candidate>) -> Html<String> {
let inserted_candidate = db_add_candidate(&state, candidate).await.unwrap();
async fn logged_in(State(state): State<AppState>, Form(mut login): Form<Login>) -> Redirect {
debug!("Logged in");
let (conn, mut ldap) = LdapConnAsync::with_settings(LdapConnSettings::new()
.set_starttls(false)
.set_no_tls_verify(false),
"ldaps://plg-dc2.ad.univ-lorraine.fr:636").await.unwrap();
ldap3::drive!(conn);
debug!("PDF candidate: {:?}", inserted_candidate);
// Attempts a simple bind using the passed in values of username and Password
let result = ldap.simple_bind("fery3@univ-lorraine.fr", "gutsberserk48").await.unwrap().success();
if result.is_ok() {
debug!("Auth OK");
} else {
debug!("Auth Failed");
}
// Ok(ldap.unbind());
Redirect::to("/login")
}
async fn add_candidate(State(state): State<AppState>, Form(mut candidate): Form<FormCandidate>) -> Redirect {
debug!("Add candidate");
match candidate.validate() {
Ok(_) => {
let inserted_candidate = db_add_candidate(&state, candidate).await.unwrap();
let rendered_data = state.tera.render(Template::Pdf.display(), &Context::from_serialize(&inserted_candidate).unwrap()).unwrap();
debug!("PDF candidate: {:?}", inserted_candidate);
let mut pandoc = pandoc::new();
pandoc.set_input(InputKind::Pipe(rendered_data));
pandoc.set_input_format(InputFormat::Html,[MarkdownExtension::RawHtml].to_vec());
pandoc.set_output(OutputKind::File(PathBuf::from("tmp.pdf")));
pandoc.set_show_cmdline(true);
let _ = pandoc.execute().unwrap();
let rendered_data = state.tera.render(Template::Pdf.display(), &Context::from_serialize(&inserted_candidate).unwrap()).unwrap();
Html(state.tera.render("dossier.html", &Context::from_serialize(&inserted_candidate).unwrap()).unwrap())
let mut pandoc = pandoc::new();
pandoc.set_input(InputKind::Pipe(rendered_data));
pandoc.set_input_format(InputFormat::Html,[MarkdownExtension::RawHtml].to_vec());
pandoc.set_output(OutputKind::File(PathBuf::from(format!("/home/fery3/opt/projectc/dossiers/{}.pdf", inserted_candidate.uuid))));
pandoc.set_show_cmdline(true);
let _ = pandoc.execute().unwrap();
debug!("candidate validated");
Redirect::to(&format!("/recapitulation/{}",inserted_candidate.uuid))
},
Err(e) => {
for error in e.field_errors() {
let (field, _) = error;
candidate.errors.push(field.to_string());
}
debug!("add candidate error array {:?}", candidate.errors);
let mut form = state.form.lock().await;
*form = candidate.to_owned();
// state.form = candidate;
debug!("candidate not validated {e}");
Redirect::to("/")
}
}
}
async fn db_add_candidate(state: &AppState, candidate: Candidate) -> Result<Candidate, sqlx::Error>{
async fn recapitulation(State(state): State<AppState>, Path(uuid): Path<String>) -> Html<String> {
let candidate = db_get_candidate(&state, uuid);
Html(state.tera.render(Template::Dossier.display(), &Context::from_serialize(candidate.await.unwrap()).unwrap()).unwrap())
}
async fn db_add_candidate(state: &AppState, candidate: FormCandidate) -> Result<Candidate, sqlx::Error>{
debug!("db_add_candidate: {} {}", candidate.firstname, candidate.lastname);
let insert = sqlx::query("insert into users (prenom, nom) values ($1,$2)")
let uuid = Uuid::new_v4();
let insert = sqlx::query("insert into users (prenom, nom, uuid) values ($1,$2,$3)")
.bind(&candidate.firstname)
.bind(&candidate.lastname)
.bind(uuid.to_string())
.execute(&state.pool).await;
match insert {
Ok(r) => {
let userid = r.last_insert_rowid();
sqlx::query_as::<_, Candidate>("select prenom, nom from users where id = $1")
sqlx::query_as::<_, Candidate>("select prenom, nom, uuid from users where id = $1")
.bind(userid)
.fetch_one(&state.pool).await
}
@ -136,31 +294,43 @@ async fn db_add_candidate(state: &AppState, candidate: Candidate) -> Result<Cand
}
}
}
#[cfg(test)]
#[tokio::test]
async fn test_sql_candidate() {
let state = AppState::new().await;
let candidate = Candidate {
lastname: "Fery".to_string(),
firstname: "Yann".to_string()
};
let inserted_candidate = db_add_candidate(&state, candidate.clone()).await.unwrap();
assert_eq!(candidate, inserted_candidate);
async fn db_get_candidate(state: &AppState, token: String) -> Result<Candidate, sqlx::Error>{
let candidate = sqlx::query_as::<_, Candidate>("select nom, prenom, uuid from users where uuid = $1")
.bind(token)
.fetch_one(&state.pool).await;
match candidate {
Ok(c) => Ok(c),
Err(e) => {
error!("add_candidate error: {}", e);
Err(e)
}
}
}
#[tokio::test]
async fn test_template_list() {
let state = AppState::new().await;
// #[cfg(test)]
// #[tokio::test]
// async fn test_sql_candidate() {
// let state = AppState::new().await;
// let candidate = FormCandidate {
// lastname: "Fery".to_string(),
// firstname: "Yann".to_string(),
// };
// let inserted_candidate = db_add_candidate(&state, candidate.clone()).await.unwrap();
// assert_eq!(candidate, inserted_candidate);
let mut tera: Vec<&str> = [].to_vec();
for tpl in state.tera.get_template_names() {
tera.push(tpl);
}
let mut list: Vec<String> = [].to_vec();
for tpl in Template::iter() {
list.push(tpl.display().to_string());
}
assert_eq!(tera, list);
}
// }
// #[tokio::test]
// async fn test_template_list() {
// let state = AppState::new().await;
// let mut tera: Vec<&str> = [].to_vec();
// for tpl in state.tera.get_template_names() {
// tera.push(tpl);
// }
// let mut list: Vec<String> = [].to_vec();
// for tpl in Template::iter() {
// list.push(tpl.display().to_string());
// }
// assert_eq!(tera, list);
// }

17
templates/base.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ENIM : Concours bac+2</title>
<!-- <link href="css/style.css" rel="stylesheet"> -->
<style>
{% block css %}
{% endblock css %}
</style>
</head>
<body>
{% block content %}
{% endblock content %}
</body>
</html>

View File

@ -13,6 +13,6 @@
<li>Prénom: {{ firstname }}</li>
</ul>
<a href="/get_pdf/{{ lastname }}">Télécharger le dossier</a>
<a href="/dossiers/{{ uuid }}.pdf">Télécharger le dossier</a>
</body>
</html>

View File

@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link href="css/style.css" rel="stylesheet">
</head>
<body>
<form method="post" action="/add_candidate">
<input type="text" name="firstname" value="">
<label for="firstname">Prénom</label>
<input type="text" name="lastname" value="">
<label for="lastname">Nom</label>
<button type="submit">Valider</button>
</form>
</body>
</html>

191
templates/inscription.html Normal file
View File

@ -0,0 +1,191 @@
{% extends "base.html" %}
{% block content %}
<form id="inscription" method="post" action="/add_candidate" style="display: flex; flex-flow: column wrap;">
<fieldset style="display: flex; flex-flow: column wrap;">
<legend>État civil</legend>
<p>
<label for="firstname">Prénom (seulement le premier)</label>
<input type="text" id="firstname" name="firstname" class="{% if errors is containing('firstname') %}invalid{% endif %}" value="{{ firstname }}">
</p>
<p>
<label for="lastname">Nom</label>
<input type="text" id="lastname" name="lastname" value="{{ lastname }}">
</p>
<p>
<label for="birthdate">Date de naissance</label>
<input type="date" id="birthdate" name="birthdate" value="{{ birthdate }}" aria-discribedby="birthdate-format">
</p>
<p>
<label for="birthplace">Lieu de naissance</label>
<input type="text" id="birthplace" name="birthplace" value="{{ birthplace }}">
</p>
<p>
<label for="birthplace_dep">Département de naissance</label>
<select id="birthplace_dep" name="birthplace_dep" value="{{ birthplace_dep }}">
</select>
</p>
<p>
<label for="birthplace_country">Pays de naissance</label>
<select id="birthplace_country" name="birthplace_country" autocomplete="on" value="{{ birthplace_country }}"></select>
</p>
<p>
<label for="citizenship">Nationalité</label>
<select id="citizenship" name="citizenship" value="{{ citizenship }}">
</select>
</p>
<p>
<fieldset>
<legend>Boursier</legend>
<label for="scholarship_holder_yes">Oui</label>
<input type="radio" id="scholarship_holder_yes" name="scholarship_holder" value="Oui">
<label for="scholarship_holder_no">Non</label>
<input type="radio" id="scholarship_holder_no" name="scholarship_holder" value="Non" checked>
</fieldset>
</p>
<p>
<label for="bea-number">Numéro BEA ou INE</label>
<input type="text" id="bea-number" name="bea-number" value="{{ birthplace }}">
</p>
</fieldset>
<fieldset>
<legend>Coordonnées</legend>
<p>Ces coordonnées seront utilisées pour toute correspondance concernant le concours</p>
<p>
<label for="address">Adresse</label>
<input type="text" id="address" name="address" value="{{ address }}">
</p>
<p>
<label for="postcode">Code postal</label>
<input type="text" id="postcode" name="postcode" value="{{ postcode }}">
</p>
<p>
<label for="city">Ville</label>
<input type="text" id="city" name="city" value="{{ city }}">
</p>
<p>
<label for="country">Pays</label>
<select id="country" name="country" autocomplete="on" value="{{ country }}"></select>
</p>
<p>
<label for="mobile_phone">Téléphone portable</label>
<input type="text" id="mobile_phone" name="mobile_phone" value="{{ mobile_phone }}">
</p>
<p>
<label for="email">Email</label>
<input type="text" id="email" name="email" value="{{ email }}">
</p>
</fieldset>
<fieldset>
<legend>Scolarité</legend>
{% for year in ["2023/2024", "2022/2023", "2021/2022", "2020/2021"] %}
<fieldset>
<legend>Année scolaire {{ year }}</legend>
<p>
<label for="diploma">Diplôme préparé</label>
<select id="diploma" name="diploma" autocomplete="on" value=""></select>
</p>
<p>
<label for="school">Établissement</label>
<select id="school" name="school" autocomplete="on" value=""></select>
</p>
<p>
<label for="school">Année d'études</label>
<select id="school" name="school" autocomplete="on" value=""></select>
</p>
</fieldset>
{% endfor %}
</fieldset>
<fieldset>
<legend>Niveau de langue</legend>
{% for language in ["anglais", "allemand", "espagnol", "fle"] %}
<fieldset>
<legend>{{ language | capitalize}}</legend>
<label for="language_level_{{ language }}_0">Aucun</label>
<input type="radio" id="language_level_{{ language }}_0" name="language_level_{{ language }}" value="Aucun" checked>
<label for="language_level_{{ language }}_1">Débutant</label>
<input type="radio" id="language_level_{{ language }}_1" name="language_level_{{ language }}" value="Débutant">
<label for="language_level_{{ language }}_2">Intermédiaire</label>
<input type="radio" id="language_level_{{ language }}_2" name="language_level_{{ language }}" value="Intermédiaire">
<label for="language_level_{{ language }}_3">Confirmé</label>
<input type="radio" id="language_level_{{ language }}_3" name="language_level_{{ language }}" value="Confirmé">
</fieldset>
{% endfor %}
</fieldset>
<fieldset>
<legend>Vœux des écoles</legend>
{% for wish_number in [1, 2, 3] %}
<p>
<label for="wish_{{ wish_number }}">Vœux {{ wish_number }}</label>
<label for="wish_{{ wish_number }}_brest">Brest</label>
<input type="radio" id="wish_{{ wish_number }}_brest" name="wish_{{ wish_number }}" value="brest">
<label for="wish_{{ wish_number }}_metz">Metz</label>
<input type="radio" id="wish_{{ wish_number }}_metz" name="wish_{{ wish_number }}" value="metz">
<label for="wish_{{ wish_number }}_tarbes">Tarbes</label>
<input type="radio" id="wish_{{ wish_number }}_tarbes" name="wish_{{ wish_number }}" value="tarbes">
</p>
{% endfor %}
</fieldset>
<fieldset>
<legend>Présentation autre concours</legend>
</fieldset>
<button form="inscription" aria-label="Valider" type="submit">Valider l'inscription</button>
</form>
{%endblock content %}
{% block css %}
.invalid {
background-color: ivory;
border: none;
outline: 2px solid red;
border-radius: 5px;
}
#inscription {
width: 1000px;
}
#inscription p {
display: flex;
justify-content: flex-end;
}
#inscription p label {
flex: 1;
}
#inscription p input {
flex: 1;
}
{% endblock css %}

22
templates/login.html Normal file
View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<form method="post" action="/login">
<input type="text" id="login" name="login">
<label for="login">Login</label>
<input type="text" name="password"">
<label for="password">Nom</label>
<button type="submit">Password</button>
</form>
{%endblock content %}
{% block css %}
.invalid {
background-color: ivory;
border: none;
outline: 2px solid red;
border-radius: 5px;
}
{% endblock css %}