add endpoints for price history and nutrients
This commit is contained in:
parent
8e3dd731c5
commit
340258e461
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT date, price_students, price_employees, price_guests FROM meals WHERE canteen = $1 AND LOWER(\"name\") = $2 AND is_latest = TRUE",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "date",
|
||||||
|
"type_info": "Date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "price_students",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "price_employees",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "price_guests",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "1477fded8be083c2d5e29a92f9e558c9855ad90020dc1199bce0d20cca02799d"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT date, canteen, price_students, price_employees, price_guests FROM meals WHERE LOWER(\"name\") = $1 AND is_latest = TRUE",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "date",
|
||||||
|
"type_info": "Date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "canteen",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "price_students",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "price_employees",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "price_guests",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "56b320a7188c5ee88c869e763f6d23ee37461894a3b20c850908923460c3d3a0"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT kjoules, proteins, carbohydrates, fats FROM meals m WHERE is_latest = TRUE AND LOWER(\"name\") = $1 ORDER BY date DESC LIMIT 1;",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "kjoules",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "proteins",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "carbohydrates",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "fats",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "a08588e594190460891c0e545b9983594e837f8da93db0d7c03e8ef16b9d0e3b"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT date, canteen, price_students, price_employees, price_guests FROM meals WHERE canteen = ANY($1) AND LOWER(\"name\") = $2 AND is_latest = TRUE",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "date",
|
||||||
|
"type_info": "Date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "canteen",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "price_students",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "price_employees",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "price_guests",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"TextArray",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "cdf76d5e7d5d61d3cc61411d526816996885ccbd07237847c57f62cc3b6357db"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT kjoules, proteins, carbohydrates, fats FROM meals m WHERE is_latest = TRUE AND LOWER(\"name\") = $1 AND date = $2 LIMIT 1;",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "kjoules",
|
||||||
|
"type_info": "Int4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "proteins",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "carbohydrates",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "fats",
|
||||||
|
"type_info": "Numeric"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Date"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "d7d20b101fbed8dfe7ff33ac7a6a0e4cddfaa36050c5482818b6ee4783f8173d"
|
||||||
|
}
|
||||||
|
|
@ -1527,7 +1527,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mensa-upb-api"
|
name = "mensa-upb-api"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
"actix-governor",
|
"actix-governor",
|
||||||
|
|
@ -1550,7 +1550,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mensa-upb-scraper"
|
name = "mensa-upb-scraper"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -1895,7 +1895,7 @@ dependencies = [
|
||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.6.1",
|
"socket2 0.5.10",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -1932,9 +1932,9 @@ dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.6.1",
|
"socket2 0.5.10",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
readme.workspace = true
|
readme.workspace = true
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ pub struct Dish {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct NutritionValues {
|
pub struct NutritionValues {
|
||||||
pub kjoule: Option<i64>,
|
pub kjoule: Option<i32>,
|
||||||
pub protein: Option<BigDecimal>,
|
pub protein: Option<BigDecimal>,
|
||||||
pub carbs: Option<BigDecimal>,
|
pub carbs: Option<BigDecimal>,
|
||||||
pub fat: Option<BigDecimal>,
|
pub fat: Option<BigDecimal>,
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,6 @@ pub async fn scrape_menu(date: &NaiveDate, canteen: Canteen) -> Result<Vec<Dish>
|
||||||
res.extend(side_dishes);
|
res.extend(side_dishes);
|
||||||
res.extend(desserts);
|
res.extend(desserts);
|
||||||
|
|
||||||
dbg!(&res);
|
|
||||||
|
|
||||||
tracing::debug!("Finished scraping");
|
tracing::debug!("Finished scraping");
|
||||||
|
|
||||||
Ok(res)
|
Ok(res)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use strum::EnumIter;
|
||||||
#[derive(
|
#[derive(
|
||||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumIter, Hash, Serialize, Deserialize,
|
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, EnumIter, Hash, Serialize, Deserialize,
|
||||||
)]
|
)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum Canteen {
|
pub enum Canteen {
|
||||||
Forum,
|
Forum,
|
||||||
Academica,
|
Academica,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
readme.workspace = true
|
readme.workspace = true
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use bigdecimal::BigDecimal;
|
use bigdecimal::BigDecimal;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use shared::Canteen;
|
use shared::Canteen;
|
||||||
|
use sqlx::prelude::FromRow;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Dish {
|
pub struct Dish {
|
||||||
|
|
@ -19,6 +20,14 @@ pub struct DishPrices {
|
||||||
pub guests: BigDecimal,
|
pub guests: BigDecimal,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct DishNutrients {
|
||||||
|
pub kjoules: Option<i32>,
|
||||||
|
pub carbohydrates: Option<BigDecimal>,
|
||||||
|
pub proteins: Option<BigDecimal>,
|
||||||
|
pub fats: Option<BigDecimal>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Dish {
|
impl Dish {
|
||||||
pub fn same_as(&self, other: &Self) -> bool {
|
pub fn same_as(&self, other: &Self) -> bool {
|
||||||
self.name == other.name
|
self.name == other.name
|
||||||
|
|
@ -39,3 +48,24 @@ impl PartialOrd for Dish {
|
||||||
self.name.partial_cmp(&other.name)
|
self.name.partial_cmp(&other.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl DishPrices {
|
||||||
|
pub fn normalize(self) -> Self {
|
||||||
|
Self {
|
||||||
|
students: self.students.with_prec(5).with_scale(2),
|
||||||
|
employees: self.employees.with_prec(5).with_scale(2),
|
||||||
|
guests: self.guests.with_prec(5).with_scale(2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DishNutrients {
|
||||||
|
pub fn normalize(self) -> Self {
|
||||||
|
Self {
|
||||||
|
kjoules: self.kjoules,
|
||||||
|
carbohydrates: self.carbohydrates.map(|v| v.with_prec(6).with_scale(2)),
|
||||||
|
proteins: self.proteins.map(|v| v.with_prec(6).with_scale(2)),
|
||||||
|
fats: self.fats.map(|v| v.with_prec(6).with_scale(2)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,35 @@
|
||||||
use std::str::FromStr as _;
|
use actix_web::{
|
||||||
|
get,
|
||||||
use actix_web::{get, web, HttpResponse, Responder};
|
web::{self, ServiceConfig},
|
||||||
|
HttpResponse, Responder,
|
||||||
|
};
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use itertools::Itertools as _;
|
use itertools::Itertools as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use shared::Canteen;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::Menu;
|
use crate::{util, Menu};
|
||||||
|
|
||||||
|
pub fn configure(cfg: &mut ServiceConfig) {
|
||||||
|
cfg.service(menu);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct MenuQuery {
|
struct MenuQuery {
|
||||||
date: Option<NaiveDate>,
|
date: Option<NaiveDate>,
|
||||||
no_update: Option<bool>,
|
#[serde(default)]
|
||||||
|
no_update: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/menu/{canteen}")]
|
#[get("/menu/{canteen}")]
|
||||||
async fn menu(
|
async fn menu(
|
||||||
path: web::Path<String>,
|
path: web::Path<String>,
|
||||||
query: web::Query<MenuQuery>,
|
query: web::Query<MenuQuery>,
|
||||||
db: web::Data<PgPool>,
|
db: web::Data<PgPool>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let canteens = path
|
let canteens = util::parse_canteens_comma_separated(&path);
|
||||||
.into_inner()
|
|
||||||
.split(',')
|
|
||||||
.map(Canteen::from_str)
|
|
||||||
.collect_vec();
|
|
||||||
if canteens.iter().all(Result::is_ok) {
|
if canteens.iter().all(Result::is_ok) {
|
||||||
let canteens = canteens.into_iter().filter_map(Result::ok).collect_vec();
|
let canteens = canteens.into_iter().filter_map(Result::ok).collect_vec();
|
||||||
|
|
||||||
|
|
@ -34,14 +37,16 @@ async fn menu(
|
||||||
.date
|
.date
|
||||||
.unwrap_or_else(|| chrono::Local::now().date_naive());
|
.unwrap_or_else(|| chrono::Local::now().date_naive());
|
||||||
|
|
||||||
let menu = Menu::query(&db, date, &canteens, !query.no_update.unwrap_or_default()).await;
|
let menu = Menu::query(&db, date, &canteens, !query.no_update).await;
|
||||||
|
|
||||||
if let Ok(menu) = menu {
|
match menu {
|
||||||
HttpResponse::Ok().json(menu)
|
Ok(menu) => HttpResponse::Ok().json(menu),
|
||||||
} else {
|
Err(err) => {
|
||||||
HttpResponse::InternalServerError().json(json!({
|
tracing::error!("Failed to query database: {err:?}");
|
||||||
"error": "Failed to query database",
|
HttpResponse::InternalServerError().json(json!({
|
||||||
}))
|
"error": "Failed to query database",
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::BadRequest().json(json!({
|
HttpResponse::BadRequest().json(json!({
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,14 @@ use shared::Canteen;
|
||||||
use strum::IntoEnumIterator as _;
|
use strum::IntoEnumIterator as _;
|
||||||
|
|
||||||
mod menu;
|
mod menu;
|
||||||
|
mod nutrition;
|
||||||
|
mod price_history;
|
||||||
|
|
||||||
pub fn configure(cfg: &mut ServiceConfig) {
|
pub fn configure(cfg: &mut ServiceConfig) {
|
||||||
cfg.service(index);
|
cfg.service(index)
|
||||||
cfg.service(menu::menu);
|
.configure(menu::configure)
|
||||||
|
.configure(nutrition::configure)
|
||||||
|
.configure(price_history::configure);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
web::{self, ServiceConfig},
|
||||||
|
HttpResponse, Responder,
|
||||||
|
};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::dish::DishNutrients;
|
||||||
|
|
||||||
|
pub fn configure(cfg: &mut ServiceConfig) {
|
||||||
|
cfg.service(nutrition);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct NutritionQuery {
|
||||||
|
date: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/nutrition/{name}")]
|
||||||
|
async fn nutrition(
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<NutritionQuery>,
|
||||||
|
db: web::Data<PgPool>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let db = db.as_ref();
|
||||||
|
let dish_name = path.into_inner();
|
||||||
|
|
||||||
|
let res = if let Some(date) = query.date {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DishNutrients,
|
||||||
|
r#"SELECT kjoules, proteins, carbohydrates, fats FROM meals m WHERE is_latest = TRUE AND LOWER("name") = $1 AND date = $2 LIMIT 1;"#,
|
||||||
|
dish_name.to_lowercase(),
|
||||||
|
date,
|
||||||
|
).fetch_optional(db).await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DishNutrients,
|
||||||
|
r#"SELECT kjoules, proteins, carbohydrates, fats FROM meals m WHERE is_latest = TRUE AND LOWER("name") = $1 ORDER BY date DESC LIMIT 1;"#,
|
||||||
|
dish_name.to_lowercase(),
|
||||||
|
).fetch_optional(db).await
|
||||||
|
};
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(nutrition)) => HttpResponse::Ok().json(nutrition.normalize()),
|
||||||
|
Ok(None) => HttpResponse::NotFound().json(json!({
|
||||||
|
"error": "Dish cannot be found",
|
||||||
|
})),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Failed to query database: {err:?}");
|
||||||
|
HttpResponse::InternalServerError().json(json!({
|
||||||
|
"error": "Failed to query database",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
web::{self, ServiceConfig},
|
||||||
|
HttpResponse, Responder,
|
||||||
|
};
|
||||||
|
use bigdecimal::BigDecimal;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::{prelude::FromRow, PgPool};
|
||||||
|
|
||||||
|
use crate::{util, DishPrices};
|
||||||
|
|
||||||
|
pub fn configure(cfg: &mut ServiceConfig) {
|
||||||
|
cfg.service(price_history);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PriceHistoryQuery {
|
||||||
|
canteens: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, FromRow)]
|
||||||
|
struct PriceHistoryRow {
|
||||||
|
date: NaiveDate,
|
||||||
|
canteen: String,
|
||||||
|
price_students: BigDecimal,
|
||||||
|
price_employees: BigDecimal,
|
||||||
|
price_guests: BigDecimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/price-history/{name}")]
|
||||||
|
async fn price_history(
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<PriceHistoryQuery>,
|
||||||
|
db: web::Data<PgPool>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let db = db.as_ref();
|
||||||
|
let canteens = query
|
||||||
|
.canteens
|
||||||
|
.as_deref()
|
||||||
|
.map(util::parse_canteens_comma_separated);
|
||||||
|
let dish_name = path.into_inner();
|
||||||
|
|
||||||
|
if let Some(canteens) = canteens {
|
||||||
|
if canteens.iter().all(Result::is_ok) {
|
||||||
|
let canteens = canteens.into_iter().filter_map(Result::ok).collect_vec();
|
||||||
|
|
||||||
|
if canteens.len() == 1 {
|
||||||
|
let canteen = canteens.into_iter().next().expect("length is 1");
|
||||||
|
|
||||||
|
let res = sqlx::query!(
|
||||||
|
r#"SELECT date, price_students, price_employees, price_guests FROM meals WHERE canteen = $1 AND LOWER("name") = $2 AND is_latest = TRUE"#,
|
||||||
|
canteen.get_identifier(),
|
||||||
|
dish_name.to_lowercase(),
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(recs) => {
|
||||||
|
let structured = recs
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
(
|
||||||
|
r.date,
|
||||||
|
DishPrices {
|
||||||
|
students: r.price_students,
|
||||||
|
employees: r.price_employees,
|
||||||
|
guests: r.price_guests,
|
||||||
|
}
|
||||||
|
.normalize(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
|
||||||
|
HttpResponse::Ok().json(structured)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Failed to query database: {err:?}");
|
||||||
|
HttpResponse::InternalServerError().json(json!({
|
||||||
|
"error": "Failed to query database",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let res = sqlx::query_as!(PriceHistoryRow,
|
||||||
|
r#"SELECT date, canteen, price_students, price_employees, price_guests FROM meals WHERE canteen = ANY($1) AND LOWER("name") = $2 AND is_latest = TRUE"#,
|
||||||
|
&canteens.iter().map(|c| c.get_identifier().to_string()).collect_vec(),
|
||||||
|
dish_name.to_lowercase(),
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(recs) => {
|
||||||
|
let structured = structure_multiple_canteens(recs);
|
||||||
|
|
||||||
|
HttpResponse::Ok().json(structured)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Failed to query database: {err:?}");
|
||||||
|
HttpResponse::InternalServerError().json(json!({
|
||||||
|
"error": "Failed to query database",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HttpResponse::BadRequest().json(json!({
|
||||||
|
"error": "Invalid canteen identifier",
|
||||||
|
"invalid": canteens.into_iter().filter_map(|c| c.err()).collect_vec()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let res = sqlx::query_as!(PriceHistoryRow,
|
||||||
|
r#"SELECT date, canteen, price_students, price_employees, price_guests FROM meals WHERE LOWER("name") = $1 AND is_latest = TRUE"#,
|
||||||
|
dish_name.to_lowercase(),
|
||||||
|
)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(recs) => {
|
||||||
|
let structured = structure_multiple_canteens(recs);
|
||||||
|
|
||||||
|
HttpResponse::Ok().json(structured)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Failed to query database: {err:?}");
|
||||||
|
HttpResponse::InternalServerError().json(json!({
|
||||||
|
"error": "Failed to query database",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn structure_multiple_canteens(
|
||||||
|
v: Vec<PriceHistoryRow>,
|
||||||
|
) -> BTreeMap<String, BTreeMap<NaiveDate, DishPrices>> {
|
||||||
|
v.into_iter()
|
||||||
|
.chunk_by(|r| r.canteen.clone())
|
||||||
|
.into_iter()
|
||||||
|
.map(|(d, g)| {
|
||||||
|
(
|
||||||
|
d,
|
||||||
|
g.map(|r| {
|
||||||
|
(
|
||||||
|
r.date,
|
||||||
|
DishPrices {
|
||||||
|
students: r.price_students,
|
||||||
|
employees: r.price_employees,
|
||||||
|
guests: r.price_guests,
|
||||||
|
}
|
||||||
|
.normalize(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ mod dish;
|
||||||
pub mod endpoints;
|
pub mod endpoints;
|
||||||
mod governor;
|
mod governor;
|
||||||
mod menu;
|
mod menu;
|
||||||
|
mod util;
|
||||||
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ use std::env;
|
||||||
|
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_governor::Governor;
|
use actix_governor::Governor;
|
||||||
use actix_web::{web, App, HttpServer};
|
use actix_web::{
|
||||||
|
middleware::{self, NormalizePath},
|
||||||
|
web, App, HttpServer,
|
||||||
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use mensa_upb_api::get_governor;
|
use mensa_upb_api::get_governor;
|
||||||
|
|
@ -72,6 +75,7 @@ async fn main() -> Result<()> {
|
||||||
.allow_any_header()
|
.allow_any_header()
|
||||||
.max_age(3600);
|
.max_age(3600);
|
||||||
App::new()
|
App::new()
|
||||||
|
.wrap(NormalizePath::new(middleware::TrailingSlash::Trim))
|
||||||
.wrap(Governor::new(&governor_conf))
|
.wrap(Governor::new(&governor_conf))
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.app_data(web::Data::new(db.clone()))
|
.app_data(web::Data::new(db.clone()))
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,11 @@ impl Menu {
|
||||||
vegan: row.vegan,
|
vegan: row.vegan,
|
||||||
vegetarian: row.vegetarian,
|
vegetarian: row.vegetarian,
|
||||||
price: DishPrices {
|
price: DishPrices {
|
||||||
students: row.price_students.with_prec(5).with_scale(2),
|
students: row.price_students,
|
||||||
employees: row.price_employees.with_prec(5).with_scale(2),
|
employees: row.price_employees,
|
||||||
guests: row.price_guests.with_prec(5).with_scale(2),
|
guests: row.price_guests,
|
||||||
},
|
}
|
||||||
|
.normalize(),
|
||||||
};
|
};
|
||||||
if row.dish_type == DishType::Main {
|
if row.dish_type == DishType::Main {
|
||||||
main_dishes.push(dish);
|
main_dishes.push(dish);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
use std::str::FromStr as _;
|
||||||
|
|
||||||
|
use shared::Canteen;
|
||||||
|
|
||||||
|
pub fn parse_canteens_comma_separated(s: &str) -> Vec<Result<Canteen, String>> {
|
||||||
|
s.split(',').map(Canteen::from_str).collect()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue