web api automatically refreshes outdated
This commit is contained in:
parent
98775d88b3
commit
4729c1afab
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT canteen, max(scraped_at) AS \"scraped_at!\" FROM canteens_scraped WHERE canteen = ANY($1) AND scraped_for = $2 GROUP BY canteen",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "canteen",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "scraped_at!",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Date"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2306ceee73b304c3ca88da52837ee4173631a63d3a89e6440b3334c546213863"
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO canteens_scraped (scraped_for, canteen) VALUES ($1, $2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Date",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "474de9870fb2cbfb2cdc37004c82f42b80a311d4a00ee22b97dd1e7b5c91ad39"
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO meals (date,canteen,name,dish_type,image_src,price_students,price_employees,price_guests,vegan,vegetarian)\n VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)\n ON CONFLICT (date,canteen,name) DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Date",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Numeric",
|
||||
"Numeric",
|
||||
"Numeric",
|
||||
"Bool",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4fdb615a3e155d8394c70f25d2d8946bed129746b70f92f66704f02093b2e27c"
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT date, canteen FROM MEALS WHERE date >= $1 AND date <= $2",
|
||||
"query": "SELECT DISTINCT scraped_for, canteen FROM canteens_scraped WHERE scraped_for >= $1 AND scraped_for <= $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "date",
|
||||
"name": "scraped_for",
|
||||
"type_info": "Date"
|
||||
},
|
||||
{
|
||||
|
|
@ -25,5 +25,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "b94a6b49fb5e53e361da7a890dd5f62d467293454b01175939e32339ee90fd23"
|
||||
"hash": "65858112433addbff921108a5b110ffead845478d359af83b70d98ff8d1945f2"
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE meals SET is_latest = FALSE WHERE date = $1 AND canteen = $2 AND is_latest = TRUE",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Date",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f804f9c634a34945d7aa0cd3162b20ff9f1ff928912d871a708a088f2d011ba7"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name, array_agg(DISTINCT canteen ORDER BY canteen) AS canteens, dish_type, image_src, price_students, price_employees, price_guests, vegan, vegetarian \n FROM meals WHERE date = $1 AND canteen = ANY($2) \n GROUP BY name, dish_type, image_src, price_students, price_employees, price_guests, vegan, vegetarian\n ORDER BY name",
|
||||
"query": "SELECT name, array_agg(DISTINCT canteen ORDER BY canteen) AS \"canteens!\", dish_type AS \"dish_type: DishType\", image_src, price_students, price_employees, price_guests, vegan, vegetarian \n FROM meals WHERE date = $1 AND canteen = ANY($2) AND is_latest = TRUE\n GROUP BY name, dish_type, image_src, price_students, price_employees, price_guests, vegan, vegetarian\n ORDER BY name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
|
@ -10,13 +10,24 @@
|
|||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "canteens",
|
||||
"name": "canteens!",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "dish_type",
|
||||
"type_info": "Text"
|
||||
"name": "dish_type: DishType",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "dish_type_enum",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"main",
|
||||
"side",
|
||||
"dessert"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
|
|
@ -67,5 +78,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "b5a990f34095b255672e81562dc905e1957d1d33d823dc82ec92b552f5092028"
|
||||
"hash": "ffbe520bbd10d79f189bc4cb202fc4367d1a1ea563d1b7845ab099ef6ec1e47a"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
|
|
@ -3,6 +3,7 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"scraper",
|
||||
"shared",
|
||||
"web-api",
|
||||
]
|
||||
resolver = "2"
|
||||
|
|
@ -14,12 +15,14 @@ repository = "https://github.com/moritz-hoelting/mensa-upb-api"
|
|||
readme = "README.md"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1.0.93"
|
||||
chrono = "0.4.38"
|
||||
anyhow = "1.0.100"
|
||||
chrono = "0.4.42"
|
||||
dotenvy = "0.15.7"
|
||||
futures = "0.3.31"
|
||||
itertools = "0.14.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
sqlx = "0.8.2"
|
||||
strum = "0.27.1"
|
||||
tokio = "1.46.0"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = "0.3.18"
|
||||
strum = "0.27.2"
|
||||
tokio = "1.48.0"
|
||||
tracing = "0.1.43"
|
||||
tracing-subscriber = "0.3.22"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
-- Add down migration script here
|
||||
|
||||
DROP VIEW IF EXISTS meals_view;
|
||||
|
||||
DROP INDEX IF EXISTS idx_meals_date_canteen_latest;
|
||||
|
||||
DROP INDEX IF EXISTS idx_meals_refreshed_at;
|
||||
|
||||
DELETE FROM meals WHERE is_latest = FALSE;
|
||||
|
||||
ALTER TABLE meals
|
||||
DROP CONSTRAINT meals_pkey;
|
||||
|
||||
ALTER TABLE meals
|
||||
DROP COLUMN id;
|
||||
|
||||
ALTER TABLE meals
|
||||
ADD CONSTRAINT meals_pkey PRIMARY KEY (date, canteen, name);
|
||||
|
||||
ALTER TABLE meals
|
||||
DROP COLUMN is_latest;
|
||||
|
||||
ALTER TABLE meals
|
||||
DROP COLUMN refreshed_at;
|
||||
|
||||
ALTER TABLE meals
|
||||
ALTER COLUMN dish_type
|
||||
TYPE TEXT
|
||||
USING dish_type::TEXT;
|
||||
|
||||
DROP TABLE IF EXISTS canteens_scraped;
|
||||
|
||||
DROP TYPE IF EXISTS dish_type_enum;
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
-- Add up migration script here
|
||||
|
||||
CREATE TABLE canteens_scraped (
|
||||
canteen TEXT NOT NULL,
|
||||
scraped_for DATE NOT NULL,
|
||||
scraped_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (canteen, scraped_for, scraped_at)
|
||||
);
|
||||
|
||||
ALTER TABLE meals
|
||||
ADD COLUMN id UUID NOT NULL DEFAULT gen_random_uuid();
|
||||
|
||||
-- Remove existing primary key constraints
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT conname
|
||||
FROM pg_constraint
|
||||
WHERE contype = 'p'
|
||||
AND conrelid = 'meals'::regclass
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE meals DROP CONSTRAINT %I', r.conname);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE meals
|
||||
ADD CONSTRAINT meals_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE meals
|
||||
ADD COLUMN is_latest BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
ALTER TABLE meals
|
||||
ADD COLUMN refreshed_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
|
||||
CREATE TYPE dish_type_enum AS ENUM ('main', 'side', 'dessert');
|
||||
|
||||
ALTER TABLE meals
|
||||
ALTER COLUMN dish_type
|
||||
TYPE dish_type_enum
|
||||
USING dish_type::dish_type_enum;
|
||||
|
||||
CREATE INDEX idx_meals_date_canteen_latest ON meals(date, canteen, is_latest);
|
||||
|
||||
CREATE INDEX idx_meals_refreshed_at ON meals(refreshed_at);
|
||||
|
||||
CREATE VIEW meals_view AS
|
||||
SELECT id, date, canteen, name, dish_type, image_src, price_students, price_employees, price_guests, vegan, vegetarian
|
||||
FROM meals
|
||||
WHERE is_latest = TRUE;
|
||||
|
|
@ -14,11 +14,12 @@ anyhow = { workspace = true }
|
|||
chrono = { workspace = true }
|
||||
const_format = "0.2.33"
|
||||
dotenvy = { workspace = true }
|
||||
futures = "0.3.31"
|
||||
futures = { workspace = true }
|
||||
itertools = { workspace = true }
|
||||
num-bigint = "0.4.6"
|
||||
reqwest = { version = "0.12.9", default-features = false, features = ["charset", "rustls-tls", "http2"] }
|
||||
scraper = "0.23.1"
|
||||
scraper = "0.25.0"
|
||||
shared = { path = "../shared" }
|
||||
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "postgres", "migrate", "chrono", "uuid", "bigdecimal"] }
|
||||
strum = { workspace = true, features = ["derive"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
|
|
|||
|
|
@ -1,24 +1,14 @@
|
|||
use std::str::FromStr;
|
||||
|
||||
use const_format::concatcp;
|
||||
use strum::EnumIter;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumIter, Hash)]
|
||||
pub enum Canteen {
|
||||
Forum,
|
||||
Academica,
|
||||
Picknick,
|
||||
BonaVista,
|
||||
GrillCafe,
|
||||
ZM2,
|
||||
Basilica,
|
||||
Atrium,
|
||||
}
|
||||
use shared::Canteen;
|
||||
|
||||
const POST_URL_BASE: &str = "https://www.studierendenwerk-pb.de/gastronomie/speiseplaene/";
|
||||
|
||||
impl Canteen {
|
||||
pub fn get_url(&self) -> &str {
|
||||
pub trait CanteenExt {
|
||||
fn get_url(&self) -> &str;
|
||||
}
|
||||
|
||||
impl CanteenExt for Canteen {
|
||||
fn get_url(&self) -> &str {
|
||||
match self {
|
||||
Self::Forum => concatcp!(POST_URL_BASE, "forum/"),
|
||||
Self::Academica => concatcp!(POST_URL_BASE, "mensa-academica/"),
|
||||
|
|
@ -30,35 +20,4 @@ impl Canteen {
|
|||
Self::Atrium => concatcp!(POST_URL_BASE, "mensa-atrium-lippstadt/"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_identifier(&self) -> &str {
|
||||
match self {
|
||||
Self::Forum => "forum",
|
||||
Self::Academica => "academica",
|
||||
Self::Picknick => "picknick",
|
||||
Self::BonaVista => "bona-vista",
|
||||
Self::GrillCafe => "grillcafe",
|
||||
Self::ZM2 => "zm2",
|
||||
Self::Basilica => "basilica",
|
||||
Self::Atrium => "atrium",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Canteen {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"forum" => Ok(Self::Forum),
|
||||
"academica" => Ok(Self::Academica),
|
||||
"picknick" => Ok(Self::Picknick),
|
||||
"bona-vista" => Ok(Self::BonaVista),
|
||||
"grillcafe" => Ok(Self::GrillCafe),
|
||||
"zm2" => Ok(Self::ZM2),
|
||||
"basilica" => Ok(Self::Basilica),
|
||||
"atrium" => Ok(Self::Atrium),
|
||||
invalid => Err(format!("Invalid canteen identifier: {}", invalid)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use std::fmt::Display;
|
||||
|
||||
use itertools::Itertools;
|
||||
use scraper::ElementRef;
|
||||
use shared::DishType;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Dish {
|
||||
|
|
@ -125,21 +124,3 @@ impl PartialOrd for Dish {
|
|||
self.name.partial_cmp(&other.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DishType {
|
||||
Main,
|
||||
Side,
|
||||
Dessert,
|
||||
}
|
||||
|
||||
impl Display for DishType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::Main => "main",
|
||||
Self::Side => "side",
|
||||
Self::Dessert => "dessert",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
mod canteen;
|
||||
mod dish;
|
||||
mod menu;
|
||||
mod refresh;
|
||||
pub mod util;
|
||||
|
||||
use std::{error::Error, fmt::Display};
|
||||
|
||||
pub use canteen::Canteen;
|
||||
pub use dish::Dish;
|
||||
pub use menu::scrape_menu;
|
||||
pub use refresh::check_refresh;
|
||||
pub use util::scrape_canteens_at_days;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CustomError(String);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ use std::{collections::HashSet, env};
|
|||
use anyhow::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use itertools::Itertools as _;
|
||||
use mensa_upb_scraper::{util, Canteen};
|
||||
use strum::IntoEnumIterator;
|
||||
use mensa_upb_scraper::util;
|
||||
use shared::Canteen;
|
||||
use strum::IntoEnumIterator as _;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
|
|
@ -22,7 +23,7 @@ async fn main() -> Result<()> {
|
|||
let end_date = (Utc::now() + Duration::days(6)).date_naive();
|
||||
|
||||
let already_scraped = sqlx::query!(
|
||||
"SELECT DISTINCT date, canteen FROM MEALS WHERE date >= $1 AND date <= $2",
|
||||
"SELECT DISTINCT scraped_for, canteen FROM canteens_scraped WHERE scraped_for >= $1 AND scraped_for <= $2",
|
||||
start_date,
|
||||
end_date
|
||||
)
|
||||
|
|
@ -31,7 +32,7 @@ async fn main() -> Result<()> {
|
|||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.date,
|
||||
r.scraped_for,
|
||||
r.canteen.parse::<Canteen>().expect("Invalid db entry"),
|
||||
)
|
||||
})
|
||||
|
|
@ -49,15 +50,12 @@ async fn main() -> Result<()> {
|
|||
let date_canteen_combinations = (0..7)
|
||||
.map(|d| (Utc::now() + Duration::days(d)).date_naive())
|
||||
.cartesian_product(Canteen::iter())
|
||||
.filter(|entry| !filter_canteens.contains(&entry.1) && !already_scraped.contains(entry))
|
||||
.collect::<Vec<_>>();
|
||||
util::async_for_each(&date_canteen_combinations, |(date, canteen, menu)| {
|
||||
let db = db.clone();
|
||||
async move {
|
||||
util::add_menu_to_db(&db, &date, canteen, menu).await;
|
||||
}
|
||||
.filter(|entry @ (_, canteen)| {
|
||||
!filter_canteens.contains(canteen) && !already_scraped.contains(entry)
|
||||
})
|
||||
.await;
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
util::scrape_canteens_at_days(&db, &date_canteen_combinations).await?;
|
||||
|
||||
tracing::info!("Finished scraping menu");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
use anyhow::Result;
|
||||
use chrono::NaiveDate;
|
||||
use shared::{Canteen, DishType};
|
||||
|
||||
use crate::{dish::DishType, Canteen, CustomError, Dish};
|
||||
use crate::{canteen::CanteenExt as _, CustomError, Dish};
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn scrape_menu(date: &NaiveDate, canteen: Canteen) -> Result<Vec<Dish>> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
use std::{collections::BTreeSet, str::FromStr};
|
||||
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use shared::Canteen;
|
||||
|
||||
use crate::util;
|
||||
|
||||
pub async fn check_refresh(db: &sqlx::PgPool, date: NaiveDate, canteens: &[Canteen]) -> bool {
|
||||
let canteens_needing_refresh = match sqlx::query!(
|
||||
r#"SELECT canteen, max(scraped_at) AS "scraped_at!" FROM canteens_scraped WHERE canteen = ANY($1) AND scraped_for = $2 GROUP BY canteen"#,
|
||||
&canteens
|
||||
.iter()
|
||||
.map(|c| c.get_identifier().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
date
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
{
|
||||
Ok(v) => v.iter().filter_map(|r| if needs_refresh(r.scraped_at, date) { Some(Canteen::from_str(&r.canteen).expect("malformed db canteen entry")) } else { None }).collect::<BTreeSet<_>>(),
|
||||
Err(err) => {
|
||||
tracing::error!("Error checking for existing scrapes: {}", err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if canteens_needing_refresh.is_empty() {
|
||||
false
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"Refreshing menu for date {} for canteens: {:?}",
|
||||
date,
|
||||
canteens_needing_refresh
|
||||
);
|
||||
|
||||
if let Err(err) = util::scrape_canteens_at_days(
|
||||
db,
|
||||
&canteens_needing_refresh
|
||||
.iter()
|
||||
.map(|c| (date, *c))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error during refresh scrape: {}", err);
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_refresh(last_refreshed: chrono::DateTime<Utc>, date_entry: chrono::NaiveDate) -> bool {
|
||||
let now = Utc::now();
|
||||
|
||||
if date_entry == now.naive_local().date() {
|
||||
now.signed_duration_since(last_refreshed) >= chrono::Duration::hours(8)
|
||||
} else if date_entry < now.naive_local().date() {
|
||||
false
|
||||
} else {
|
||||
now.signed_duration_since(last_refreshed) >= chrono::Duration::days(2)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,64 +1,111 @@
|
|||
use std::{env, future::Future};
|
||||
use std::env;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::NaiveDate;
|
||||
use futures::StreamExt as _;
|
||||
use num_bigint::BigInt;
|
||||
use sqlx::{postgres::PgPoolOptions, types::BigDecimal, PgPool};
|
||||
use shared::{Canteen, DishType};
|
||||
use sqlx::{postgres::PgPoolOptions, types::BigDecimal, PgPool, PgTransaction};
|
||||
|
||||
use crate::{menu::scrape_menu, Canteen, Dish};
|
||||
|
||||
pub async fn async_for_each<F, Fut>(date_canteen_combinations: &[(NaiveDate, Canteen)], f: F)
|
||||
where
|
||||
F: FnMut((NaiveDate, Canteen, Vec<Dish>)) -> Fut,
|
||||
Fut: Future<Output = ()>,
|
||||
{
|
||||
futures::stream::iter(date_canteen_combinations)
|
||||
.then(|(date, canteen)| async move { (*date, *canteen, scrape_menu(date, *canteen).await) })
|
||||
.filter_map(|(date, canteen, menu)| async move { menu.ok().map(|menu| (date, canteen, menu)) })
|
||||
.for_each(f)
|
||||
.await;
|
||||
}
|
||||
use crate::{scrape_menu, Dish};
|
||||
|
||||
pub fn get_db() -> Result<PgPool> {
|
||||
Ok(PgPoolOptions::new()
|
||||
.connect_lazy(&env::var("DATABASE_URL").expect("missing DATABASE_URL env variable"))?)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(db))]
|
||||
pub async fn add_meal_to_db(db: &PgPool, date: &NaiveDate, canteen: Canteen, dish: &Dish) -> Result<()> {
|
||||
let vegan = dish.is_vegan();
|
||||
pub async fn scrape_canteens_at_days(
|
||||
db: &PgPool,
|
||||
date_canteen_combinations: &[(NaiveDate, Canteen)],
|
||||
) -> Result<()> {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<(NaiveDate, Canteen, Vec<Dish>)>(128);
|
||||
|
||||
let mut transaction = db.begin().await?;
|
||||
|
||||
for (date, canteen) in date_canteen_combinations {
|
||||
sqlx::query!(
|
||||
"UPDATE meals SET is_latest = FALSE WHERE date = $1 AND canteen = $2 AND is_latest = TRUE",
|
||||
date,
|
||||
canteen.get_identifier()
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
let insert_handle = tokio::spawn(async move {
|
||||
while let Some((date, canteen, menu)) = rx.recv().await {
|
||||
add_menu_to_db(&mut transaction, &date, canteen, menu).await?;
|
||||
}
|
||||
|
||||
transaction.commit().await
|
||||
});
|
||||
|
||||
futures::stream::iter(date_canteen_combinations)
|
||||
.then(|(date, canteen)| async move { (*date, *canteen, scrape_menu(date, *canteen).await) })
|
||||
.filter_map(
|
||||
|(date, canteen, menu)| async move { menu.ok().map(|menu| (date, canteen, menu)) },
|
||||
)
|
||||
.for_each(|(date, canteen, menu)| {
|
||||
let tx = tx.clone();
|
||||
async move {
|
||||
tx.send((date, canteen, menu)).await.ok();
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
drop(tx);
|
||||
|
||||
insert_handle.await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_menu_to_db(
|
||||
db: &mut PgTransaction<'_>,
|
||||
date: &NaiveDate,
|
||||
canteen: Canteen,
|
||||
menu: Vec<Dish>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
if menu.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut query = sqlx::QueryBuilder::new("INSERT INTO meals (date,canteen,name,dish_type,image_src,price_students,price_employees,price_guests,vegan,vegetarian) ");
|
||||
|
||||
query
|
||||
.push_values(menu, |mut sep, item| {
|
||||
let vegan = item.is_vegan();
|
||||
|
||||
sep.push_bind(date)
|
||||
.push_bind(canteen.get_identifier())
|
||||
.push_bind(item.get_name().to_string())
|
||||
.push_bind(item.get_type() as DishType)
|
||||
.push_bind(item.get_image_src().map(str::to_string))
|
||||
.push_bind(price_to_bigdecimal(item.get_price_students()))
|
||||
.push_bind(price_to_bigdecimal(item.get_price_employees()))
|
||||
.push_bind(price_to_bigdecimal(item.get_price_guests()))
|
||||
.push_bind(vegan)
|
||||
.push_bind(vegan || item.is_vegetarian());
|
||||
})
|
||||
.build()
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO meals (date,canteen,name,dish_type,image_src,price_students,price_employees,price_guests,vegan,vegetarian)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
ON CONFLICT (date,canteen,name) DO NOTHING",
|
||||
date, canteen.get_identifier(), dish.get_name(),
|
||||
dish.get_type().to_string(), dish.get_image_src(),
|
||||
price_to_bigdecimal(dish.get_price_students()),
|
||||
price_to_bigdecimal(dish.get_price_employees()),
|
||||
price_to_bigdecimal(dish.get_price_guests()),
|
||||
vegan, vegan || dish.is_vegetarian()
|
||||
).execute(db).await.inspect_err(|e| {
|
||||
tracing::error!("error during database insert: {}", e);
|
||||
})?;
|
||||
"INSERT INTO canteens_scraped (scraped_for, canteen) VALUES ($1, $2)",
|
||||
date,
|
||||
canteen.get_identifier()
|
||||
)
|
||||
.execute(&mut **db)
|
||||
.await?;
|
||||
|
||||
tracing::trace!("Insert to DB successfull");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_menu_to_db(db: &PgPool, date: &NaiveDate, canteen: Canteen, menu: Vec<Dish>) {
|
||||
futures::stream::iter(menu)
|
||||
.for_each(|dish| async move {
|
||||
if !dish.get_name().is_empty() {
|
||||
add_meal_to_db(db, date, canteen, &dish).await.ok();
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub fn price_to_bigdecimal(s: Option<&str>) -> BigDecimal {
|
||||
s.and_then(|p| p.trim_end_matches(" €").replace(',', ".").parse().ok())
|
||||
.unwrap_or_else(|| BigDecimal::new(BigInt::from(99999), 2))
|
||||
.unwrap_or_else(|| BigDecimal::from_bigint(BigInt::from(99999), 2))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "shared"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
strum = { workspace = true, features = ["derive"] }
|
||||
sqlx = { workspace = true }
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
use std::fmt::Display;
|
||||
|
||||
mod canteen;
|
||||
pub use canteen::Canteen;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
|
||||
#[sqlx(type_name = "dish_type_enum")]
|
||||
#[sqlx(rename_all = "lowercase")]
|
||||
pub enum DishType {
|
||||
Main,
|
||||
Side,
|
||||
Dessert,
|
||||
}
|
||||
|
||||
impl Display for DishType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::Main => "main",
|
||||
Self::Side => "side",
|
||||
Self::Dessert => "dessert",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,18 +10,20 @@ edition = "2021"
|
|||
publish = false
|
||||
|
||||
[dependencies]
|
||||
actix-cors = "0.7.0"
|
||||
actix-governor = { version = "0.8.0", features = ["log"] }
|
||||
actix-web = "4.9.0"
|
||||
actix-cors = "0.7.1"
|
||||
actix-governor = { version = "0.10.0", features = ["log"] }
|
||||
actix-web = "4.12.1"
|
||||
anyhow = { workspace = true }
|
||||
bigdecimal = { version = "0.4.6", features = ["serde"] }
|
||||
bigdecimal = { version = "0.4.9", features = ["serde"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
dotenvy = { workspace = true }
|
||||
itertools = { workspace = true }
|
||||
serde = { version = "1.0.215", features = ["derive"] }
|
||||
serde_json = "1.0.133"
|
||||
mensa-upb-scraper = { path = "../scraper" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = "1.0.145"
|
||||
shared = { path = "../shared" }
|
||||
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "postgres", "migrate", "chrono", "uuid", "bigdecimal"] }
|
||||
strum = { workspace = true, features = ["derive"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
tracing = "0.1.40"
|
||||
tracing = "0.1.43"
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use bigdecimal::BigDecimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::Canteen;
|
||||
use shared::Canteen;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Dish {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ use chrono::NaiveDate;
|
|||
use itertools::Itertools as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use shared::Canteen;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::{Canteen, Menu};
|
||||
use crate::Menu;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MenuQuery {
|
||||
date: Option<NaiveDate>,
|
||||
no_update: Option<bool>,
|
||||
}
|
||||
#[get("/menu/{canteen}")]
|
||||
async fn menu(
|
||||
|
|
@ -31,7 +34,7 @@ async fn menu(
|
|||
.date
|
||||
.unwrap_or_else(|| chrono::Local::now().date_naive());
|
||||
|
||||
let menu = Menu::query(&db, date, &canteens).await;
|
||||
let menu = Menu::query(&db, date, &canteens, !query.no_update.unwrap_or_default()).await;
|
||||
|
||||
if let Ok(menu) = menu {
|
||||
HttpResponse::Ok().json(menu)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
use actix_web::{get, web::ServiceConfig, HttpResponse, Responder};
|
||||
use itertools::Itertools as _;
|
||||
use serde_json::json;
|
||||
use shared::Canteen;
|
||||
use strum::IntoEnumIterator as _;
|
||||
|
||||
use crate::Canteen;
|
||||
|
||||
mod menu;
|
||||
|
||||
pub fn configure(cfg: &mut ServiceConfig) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
mod canteen;
|
||||
mod dish;
|
||||
pub mod endpoints;
|
||||
mod governor;
|
||||
mod menu;
|
||||
|
||||
use std::{error::Error, fmt::Display, sync::LazyLock};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub use canteen::Canteen;
|
||||
pub use dish::{Dish, DishPrices};
|
||||
pub use governor::get_governor;
|
||||
pub use menu::Menu;
|
||||
|
|
@ -16,26 +14,3 @@ pub(crate) static USE_X_FORWARDED_HOST: LazyLock<bool> = LazyLock::new(|| {
|
|||
.map(|val| val == "true")
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CustomError(String);
|
||||
|
||||
impl Display for CustomError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CustomError {}
|
||||
|
||||
impl From<&str> for CustomError {
|
||||
fn from(s: &str) -> Self {
|
||||
CustomError(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for CustomError {
|
||||
fn from(s: String) -> Self {
|
||||
CustomError(s)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
use chrono::NaiveDate;
|
||||
use mensa_upb_scraper::check_refresh;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{Canteen, DishType};
|
||||
use sqlx::PgPool;
|
||||
use std::str::FromStr as _;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::{Canteen, Dish, DishPrices};
|
||||
use crate::{Dish, DishPrices};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Menu {
|
||||
|
|
@ -15,18 +16,32 @@ pub struct Menu {
|
|||
}
|
||||
|
||||
impl Menu {
|
||||
pub async fn query(db: &PgPool, date: NaiveDate, canteens: &[Canteen]) -> sqlx::Result<Self> {
|
||||
let canteens = canteens
|
||||
pub async fn query(
|
||||
db: &PgPool,
|
||||
date: NaiveDate,
|
||||
canteens: &[Canteen],
|
||||
allow_refresh: bool,
|
||||
) -> sqlx::Result<Self> {
|
||||
let canteens_str = canteens
|
||||
.iter()
|
||||
.map(|c| c.get_identifier().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let result = sqlx::query!("SELECT name, array_agg(DISTINCT canteen ORDER BY canteen) AS canteens, dish_type, image_src, price_students, price_employees, price_guests, vegan, vegetarian
|
||||
FROM meals WHERE date = $1 AND canteen = ANY($2)
|
||||
|
||||
let query_db = async || {
|
||||
sqlx::query!(r#"SELECT name, array_agg(DISTINCT canteen ORDER BY canteen) AS "canteens!", dish_type AS "dish_type: DishType", image_src, price_students, price_employees, price_guests, vegan, vegetarian
|
||||
FROM meals WHERE date = $1 AND canteen = ANY($2) AND is_latest = TRUE
|
||||
GROUP BY name, dish_type, image_src, price_students, price_employees, price_guests, vegan, vegetarian
|
||||
ORDER BY name",
|
||||
date, &canteens)
|
||||
ORDER BY name"#,
|
||||
date, &canteens_str)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
.await
|
||||
};
|
||||
|
||||
let mut result = query_db().await?;
|
||||
|
||||
if allow_refresh && check_refresh(db, date, canteens).await {
|
||||
result = query_db().await?;
|
||||
}
|
||||
|
||||
let mut main_dishes = Vec::new();
|
||||
let mut side_dishes = Vec::new();
|
||||
|
|
@ -36,12 +51,11 @@ impl Menu {
|
|||
let dish = Dish {
|
||||
name: row.name,
|
||||
image_src: row.image_src,
|
||||
canteens: row.canteens.map_or_else(Vec::new, |canteens| {
|
||||
canteens
|
||||
canteens: row
|
||||
.canteens
|
||||
.iter()
|
||||
.map(|canteen| Canteen::from_str(canteen).expect("Invalid database entry"))
|
||||
.collect()
|
||||
}),
|
||||
.collect(),
|
||||
vegan: row.vegan,
|
||||
vegetarian: row.vegetarian,
|
||||
price: DishPrices {
|
||||
|
|
@ -50,11 +64,11 @@ impl Menu {
|
|||
guests: row.price_guests.with_prec(5).with_scale(2),
|
||||
},
|
||||
};
|
||||
if row.dish_type == "main" {
|
||||
if row.dish_type == DishType::Main {
|
||||
main_dishes.push(dish);
|
||||
} else if row.dish_type == "side" {
|
||||
} else if row.dish_type == DishType::Side {
|
||||
side_dishes.push(dish);
|
||||
} else if row.dish_type == "dessert" {
|
||||
} else if row.dish_type == DishType::Dessert {
|
||||
desserts.push(dish);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue