feat: add meals registrations
This commit is contained in:
parent
36e10cf8d2
commit
1b73fe2cbb
6 changed files with 401 additions and 5 deletions
16
README.md
Normal file
16
README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Khollisé Worker
|
||||
|
||||
## Job Types
|
||||
|
||||
| job_id | description |
|
||||
|--------|------------------------------------------------------------------|
|
||||
| 0 | Fetch ONE colle (with class_name, colle_id and colle_secret) |
|
||||
| 1 | Fetch class colles (with class_name and optionally go to date) |
|
||||
| 2 | Fetch upcoming colles (with class_name) |
|
||||
| 3 | Fetch sumbittable meals |
|
||||
| 4 | Submit meals for user (must include bj_username and bj_password) |
|
||||
| 5 | Test authentication (must include bj_username and bj_password) |
|
||||
|
||||
Actually, all jobs require `class_name` to be specified.
|
||||
|
||||
<!-- TODO: Add documentation for used Redis keys -->
|
||||
74
src/api.rs
74
src/api.rs
|
|
@ -70,6 +70,74 @@ pub async fn post_upcoming_colles(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn post_submittable_meals(
|
||||
meals: &[Meal],
|
||||
config: &Settings,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let api_url = get_config(config, "api");
|
||||
let api_token = get_config(config, "token");
|
||||
|
||||
let url = format!("{api_url}/meals");
|
||||
let meals_json = json!({
|
||||
"meals": &meals
|
||||
});
|
||||
println!("Posting submittable meals: {:?}", meals_json);
|
||||
|
||||
let response = Client::new()
|
||||
.post(&url)
|
||||
.json(&meals_json)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
// Bearer token for authentication
|
||||
.header("Authorization", format!("Bearer {api_token}"))
|
||||
.send()
|
||||
.await?;
|
||||
// Check if the response is successful
|
||||
if !response.status().is_success() {
|
||||
eprintln!(
|
||||
"Failed to post submittable meals: HTTP {}",
|
||||
response.status(),
|
||||
);
|
||||
eprint!("Response: {:?}", response.text().await?);
|
||||
return Err("Failed to post submittable meals".into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn post_selected_meals(
|
||||
meals: &[Meal],
|
||||
user_id: &str,
|
||||
config: &Settings,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let api_url = get_config(config, "api");
|
||||
let api_token = get_config(config, "token");
|
||||
|
||||
let url = format!("{api_url}/internals/meals-registrations");
|
||||
let meals_json = json!({
|
||||
"meals": &meals,
|
||||
"userId": user_id,
|
||||
});
|
||||
|
||||
let response = Client::new()
|
||||
.post(&url)
|
||||
.json(&meals_json)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
// Bearer token for authentication
|
||||
.header("Authorization", format!("Bearer {api_token}"))
|
||||
.send()
|
||||
.await?;
|
||||
// Check if the response is successful
|
||||
if !response.status().is_success() {
|
||||
eprintln!("Failed to post selected meals: HTTP {}", response.status(),);
|
||||
eprint!("Response: {:?}", response.text().await?);
|
||||
return Err("Failed to post selected meals".into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct Colle {
|
||||
pub date: NaiveDateTime,
|
||||
|
|
@ -90,3 +158,9 @@ pub struct ColleAttachment {
|
|||
pub url: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct Meal {
|
||||
pub date: String, // "YYYY-MM-DD"
|
||||
pub meal_type: String, // "lunch" or "dinner"
|
||||
}
|
||||
|
|
|
|||
139
src/main.rs
139
src/main.rs
|
|
@ -1,9 +1,12 @@
|
|||
use std::{str::FromStr, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
api::{post_colle, post_upcoming_colles},
|
||||
api::{Meal, post_colle, post_selected_meals, post_submittable_meals, post_upcoming_colles},
|
||||
configuration::{get_config, get_cron, list_classes, load_config},
|
||||
parser::{authenticate, fetch_class_colles, fetch_colle, fetch_upcoming_colles},
|
||||
parser::{
|
||||
authenticate, fetch_class_colles, fetch_colle, fetch_upcoming_colles, get_available_meals,
|
||||
login, request_session,
|
||||
},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use cron::Schedule;
|
||||
|
|
@ -179,6 +182,111 @@ async fn process_job(
|
|||
eprintln!("Failed to fetch colle {}: {}", colle_id, e);
|
||||
}
|
||||
}
|
||||
} else if job["type"] == 4 || job["type"] == 5 {
|
||||
let user_id = job["user_id"].as_str().unwrap_or_default();
|
||||
println!("Submitting meals for user: {}", user_id);
|
||||
|
||||
// Authenticate (job data contains password and username)
|
||||
let username = job["bj_username"].as_str().unwrap_or_default();
|
||||
let password = job["bj_password"].as_str().unwrap_or_default();
|
||||
if username.is_empty() || password.is_empty() {
|
||||
eprintln!("Job does not contain username or password.");
|
||||
return Err(redis::RedisError::from((
|
||||
redis::ErrorKind::InvalidClientConfig,
|
||||
"Job missing username or password",
|
||||
)));
|
||||
}
|
||||
|
||||
let session = request_session().await;
|
||||
if let Err(err) = session {
|
||||
eprintln!("Failed to request session: {}", err);
|
||||
return Err(redis::RedisError::from((
|
||||
redis::ErrorKind::AuthenticationFailed,
|
||||
"Session request failed",
|
||||
)));
|
||||
}
|
||||
let session_id = session.unwrap();
|
||||
|
||||
let login_result = login(&username, &password, &session_id).await;
|
||||
if let Err(err) = login_result {
|
||||
if job["type"] == 5 {
|
||||
// Set auth failed in Redis
|
||||
let auth_key = format!("auth_success_{}", user_id);
|
||||
con.set_ex(&auth_key, false, 300)?;
|
||||
}
|
||||
eprintln!("Failed to login: {}", err);
|
||||
return Err(redis::RedisError::from((
|
||||
redis::ErrorKind::AuthenticationFailed,
|
||||
"Login failed",
|
||||
)));
|
||||
}
|
||||
|
||||
if job["type"] == 4 {
|
||||
// Fetch already selected meals
|
||||
let selected_meals = get_available_meals(&session_id, true).await;
|
||||
if let Err(err) = selected_meals {
|
||||
eprintln!("Failed to fetch available meals: {}", err);
|
||||
return Err(redis::RedisError::from((
|
||||
redis::ErrorKind::ResponseError,
|
||||
"Failed to fetch available meals",
|
||||
)));
|
||||
}
|
||||
let selected_meals = selected_meals.unwrap();
|
||||
|
||||
// Add job["meal"] (ONLY ONE MEAL) to selected meals
|
||||
let mut meals_parsed = selected_meals;
|
||||
let meal_to_add = Meal {
|
||||
date: job["meal"]["date"].as_str().unwrap_or_default().to_string(),
|
||||
meal_type: job["meal"]["meal_type"]
|
||||
.as_str()
|
||||
.unwrap_or("dinner")
|
||||
.to_string(),
|
||||
};
|
||||
if !meals_parsed
|
||||
.iter()
|
||||
.any(|m| m.date == meal_to_add.date && m.meal_type == meal_to_add.meal_type)
|
||||
{
|
||||
meals_parsed.push(meal_to_add);
|
||||
|
||||
// Submit meals
|
||||
let res = parser::submit_meals(&meals_parsed, &session_id).await;
|
||||
if res.is_err() {
|
||||
eprintln!("Failed to submit meals: {}", res.unwrap_err());
|
||||
return Err(redis::RedisError::from((
|
||||
redis::ErrorKind::ResponseError,
|
||||
"Failed to submit meals",
|
||||
)));
|
||||
}
|
||||
println!("Meals submitted successfully.");
|
||||
} else {
|
||||
println!("Meal already selected, skipping addition.");
|
||||
}
|
||||
|
||||
let meals = get_available_meals(&session_id, true).await;
|
||||
if meals.is_err() {
|
||||
eprintln!("Failed to fetch available meals: {}", meals.unwrap_err());
|
||||
return Err(redis::RedisError::from((
|
||||
redis::ErrorKind::ResponseError,
|
||||
"Failed to fetch available meals",
|
||||
)));
|
||||
}
|
||||
let meals = meals.unwrap();
|
||||
|
||||
// Post selected meals to API
|
||||
let res = post_selected_meals(&meals, user_id, config).await;
|
||||
if res.is_err() {
|
||||
eprintln!("Failed to post selected meals: {}", res.unwrap_err());
|
||||
return Err(redis::RedisError::from((
|
||||
redis::ErrorKind::ResponseError,
|
||||
"Failed to post selected meals",
|
||||
)));
|
||||
}
|
||||
println!("Posted {} selected meals successfully.", meals.len());
|
||||
} else if job["type"] == 5 {
|
||||
// Set auth successful in Redis
|
||||
let auth_key = format!("auth_success_{}", user_id);
|
||||
con.set_ex(&auth_key, true, 300)?;
|
||||
}
|
||||
} else {
|
||||
// Authenticate
|
||||
let session = authenticate(class_name, &mut *con, config).await;
|
||||
|
|
@ -256,6 +364,33 @@ async fn process_job(
|
|||
)));
|
||||
}
|
||||
println!("Posted upcoming colles successfully.");
|
||||
} else if job["type"] == 3 {
|
||||
println!("Fetching available meals...");
|
||||
|
||||
/*
|
||||
* Fetch available meals
|
||||
*/
|
||||
match get_available_meals(&session, false).await {
|
||||
Ok(meals) => {
|
||||
if meals.is_empty() {
|
||||
println!("No available meals found.");
|
||||
return Ok(());
|
||||
}
|
||||
// Post available meals to API
|
||||
let res = post_submittable_meals(&meals, config).await;
|
||||
if res.is_err() {
|
||||
eprintln!("Failed to post submittable meals: {}", res.unwrap_err());
|
||||
return Err(redis::RedisError::from((
|
||||
redis::ErrorKind::ResponseError,
|
||||
"Failed to post submittable meals",
|
||||
)));
|
||||
}
|
||||
println!("Posted {} submittable meals successfully.", meals.len());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to fetch available meals: {}", e);
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Handle unknown job types
|
||||
* This is a catch-all for any job types that are not recognized.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
mod auth;
|
||||
mod utils;
|
||||
mod colles;
|
||||
mod repas;
|
||||
|
||||
pub use colles::fetch_class_colles;
|
||||
pub use colles::fetch_colle;
|
||||
pub use colles::fetch_upcoming_colles;
|
||||
|
||||
pub use auth::authenticate;
|
||||
pub use auth::login;
|
||||
pub use auth::request_session;
|
||||
|
||||
pub use repas::get_available_meals;
|
||||
pub use repas::submit_meals;
|
||||
|
|
|
|||
161
src/parser/repas.rs
Normal file
161
src/parser/repas.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use reqwest::Client;
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
use crate::{api::Meal, parser::utils::parse_french_date};
|
||||
|
||||
pub async fn get_available_meals(
|
||||
session: &str,
|
||||
filter_selected: bool,
|
||||
) -> Result<Vec<Meal>, Box<dyn std::error::Error>> {
|
||||
let url = "https://bjcolle.fr/no_waste.php";
|
||||
let response = Client::new()
|
||||
.get(url)
|
||||
.header("Cookie", session)
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
let document = Html::parse_document(&response);
|
||||
|
||||
// Select each table row
|
||||
let row_selector = Selector::parse("#Choix > table > tbody > tr").unwrap();
|
||||
let label_selector = Selector::parse("label").unwrap();
|
||||
let input_selector = Selector::parse("input[type=\"checkbox\"]").unwrap();
|
||||
|
||||
let mut meals = Vec::new();
|
||||
|
||||
for row in document.select(&row_selector) {
|
||||
// Extract the label text (meal name)
|
||||
if let Some(label) = row.select(&label_selector).next() {
|
||||
let meal_name = label
|
||||
.text()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Extract the input checkbox
|
||||
if let Some(input) = row.select(&input_selector).next() {
|
||||
let is_checked = input.value().attr("checked").is_some();
|
||||
|
||||
// Apply filtering: if filter_selected = true, skip unchecked
|
||||
if filter_selected && !is_checked {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !meal_name.is_empty() {
|
||||
if let Some(meal) = parse_meal(&meal_name) {
|
||||
meals.push(meal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(meals)
|
||||
}
|
||||
|
||||
pub fn parse_meal(name: &str) -> Option<Meal> {
|
||||
// Normalize input
|
||||
let trimmed = name.trim();
|
||||
|
||||
// Detect meal type
|
||||
let meal_type = if trimmed.starts_with("Déjeuner") {
|
||||
"lunch"
|
||||
} else if trimmed.starts_with("Dîner") {
|
||||
"dinner"
|
||||
} else {
|
||||
return None; // Unknown meal type
|
||||
}
|
||||
.to_string();
|
||||
|
||||
// Extract date string (after "du ") and format it YYYY-MM-DD
|
||||
let date_part = trimmed.splitn(2, "du ").nth(1)?;
|
||||
let date = parse_french_date(date_part).unwrap();
|
||||
let date = date.format("%Y-%m-%d").to_string();
|
||||
|
||||
Some(Meal { date, meal_type })
|
||||
}
|
||||
|
||||
// Map chrono weekday to French first letter
|
||||
fn french_day_letter(date: NaiveDate) -> char {
|
||||
match date.weekday() {
|
||||
chrono::Weekday::Mon => 'l', // Lundi
|
||||
chrono::Weekday::Tue => 'm', // Mardi
|
||||
chrono::Weekday::Wed => 'm', // Mercredi
|
||||
chrono::Weekday::Thu => 'j', // Jeudi
|
||||
chrono::Weekday::Fri => 'v', // Vendredi
|
||||
chrono::Weekday::Sat => 's', // Samedi
|
||||
chrono::Weekday::Sun => 'd', // Dimanche
|
||||
}
|
||||
}
|
||||
|
||||
// Map chrono month to French month name
|
||||
fn french_month(month: u32) -> &'static str {
|
||||
match month {
|
||||
1 => "janvier",
|
||||
2 => "février",
|
||||
3 => "mars",
|
||||
4 => "avril",
|
||||
5 => "mai",
|
||||
6 => "juin",
|
||||
7 => "juillet",
|
||||
8 => "août",
|
||||
9 => "septembre",
|
||||
10 => "octobre",
|
||||
11 => "novembre",
|
||||
12 => "décembre",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn submit_meals(meals: &[Meal], session: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = "https://bjcolle.fr/no_waste.php";
|
||||
let client = Client::new();
|
||||
|
||||
let mut form: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Mandatory field
|
||||
form.insert(
|
||||
"VALIDER_CHOIX".to_string(),
|
||||
"Valider+(même+si+rien+n'est+coché)".to_string(),
|
||||
);
|
||||
|
||||
for meal in meals {
|
||||
let meal_date = NaiveDate::parse_from_str(&meal.date, "%Y-%m-%d")?;
|
||||
let day_letter = french_day_letter(meal_date);
|
||||
|
||||
let key = match meal.meal_type.as_str() {
|
||||
"lunch" => format!("{}m", day_letter), // midi
|
||||
"dinner" => format!("{}s", day_letter), // soir
|
||||
other => {
|
||||
eprintln!("Unknown meal_type: {}", other);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Format like "30+août"
|
||||
let value = format!("{}+{}", meal_date.day(), french_month(meal_date.month()));
|
||||
|
||||
form.insert(key, value);
|
||||
}
|
||||
|
||||
let res = client
|
||||
.post(url)
|
||||
.header("Cookie", session)
|
||||
.form(&form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
eprintln!("Failed to submit meals: HTTP {}", res.status());
|
||||
eprint!("Response: {:?}", res.text().await?);
|
||||
return Err("Failed to submit meals".into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
|
|
@ -24,7 +24,7 @@ pub fn parse_french_date(french_date: &str) -> Result<NaiveDate, Box<dyn Error>>
|
|||
|
||||
// Split by spaces
|
||||
let parts: Vec<&str> = french_date.split_whitespace().collect();
|
||||
if parts.len() < 4 {
|
||||
if parts.len() < 3 {
|
||||
return Err("Date string too short".into());
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +42,11 @@ pub fn parse_french_date(french_date: &str) -> Result<NaiveDate, Box<dyn Error>>
|
|||
.ok_or_else(|| format!("Unknown month: {}", parts[2]))?;
|
||||
|
||||
// Year
|
||||
let year: i32 = parts[3].parse()?;
|
||||
let year = if let Some(s) = parts.get(3) {
|
||||
s.parse()?
|
||||
} else {
|
||||
chrono::Local::now().year()
|
||||
};
|
||||
|
||||
let date = NaiveDate::from_ymd_opt(year, *month, day).ok_or("Invalid date components")?;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue