//! Cuisines: every restaurant of a kind, grouped by area — "where's the
//! ramen?", at home or on a trip. Cuisine is free text ("indian, nepalese"),
//! split into tags by `restaurants::cuisine_tags`. Covers only the viewer's
//! areas.
use std::collections::{BTreeMap, HashMap};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::Html;
use chrono::NaiveDate;
use minijinja::context;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::lunch::{default_meal_time, today};
use super::{internal, render};
use crate::entities::area_members::Role;
use crate::entities::rankings::Rank;
use crate::entities::{areas, lunches, rankings, restaurants};
use crate::middleware::UserContext;
use crate::{hours, AppState};
#[derive(Debug, Deserialize)]
pub struct CuisineQuery {
/// Limit to one area (and link back to it).
pub area: Option<Uuid>,
}
async fn in_rotation(db: &DatabaseConnection, areas: &[areas::Model]) -> Result<Vec<restaurants::Model>, StatusCode> {
restaurants::Entity::find()
.filter(restaurants::Column::Archived.eq(false))
.filter(restaurants::Column::AreaId.is_in(areas.iter().map(|a| a.id)))
.order_by_asc(restaurants::Column::Name)
.all(db)
.await
.map_err(internal("list restaurants failed"))
}
/// The areas a cuisine query covers: the one asked for (which the viewer
/// must belong to), else every area they're in. Returns the asked-for area
/// separately, for the page's scoping and back links.
async fn scope(
db: &DatabaseConnection,
user_id: Uuid,
area: Option<Uuid>,
) -> Result<(Option<areas::Model>, Vec<areas::Model>), StatusCode> {
match area {
Some(id) => {
let (area, _) = super::areas::authorize(db, id, user_id, Role::Voter).await?;
Ok((Some(area.clone()), vec![area]))
}
None => Ok((None, super::areas::my_areas(db, user_id).await?)),
}
}
pub async fn list_cuisines(
State(state): State<AppState>,
user: UserContext,
Query(qs): Query<CuisineQuery>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let (area, covered) = scope(db, user.user.id, qs.area).await?;
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
let mut untagged = 0;
for r in in_rotation(db, &covered).await? {
let tags = r.cuisine_tags();
if tags.is_empty() {
untagged += 1;
}
for t in tags {
*counts.entry(t).or_default() += 1;
}
}
let mut cuisines: Vec<(String, usize)> = counts.into_iter().collect();
cuisines.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let cuisines: Vec<minijinja::Value> =
cuisines.into_iter().map(|(name, count)| context! { name => name, count => count }).collect();
render(
&state,
"cuisines/list.html",
context! { title => "Cuisines", user => user.user, area => area, cuisines => cuisines, untagged => untagged },
)
}
#[derive(Serialize)]
struct Row {
restaurant: restaurants::Model,
cuisines: Vec<String>,
my_rank: Option<Rank>,
visits: usize,
last_visit: Option<NaiveDate>,
lunch_today: Option<hours::State>,
}
pub async fn show_cuisine(
State(state): State<AppState>,
user: UserContext,
Path(name): Path<String>,
Query(qs): Query<CuisineQuery>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let name = name.trim().to_lowercase();
let (area, covered) = scope(db, user.user.id, qs.area).await?;
let matching: Vec<restaurants::Model> =
in_rotation(db, &covered).await?.into_iter().filter(|r| r.cuisine_tags().contains(&name)).collect();
let ids: Vec<Uuid> = matching.iter().map(|r| r.id).collect();
let my_ranks: HashMap<Uuid, Rank> = rankings::Entity::find()
.filter(rankings::Column::UserId.eq(user.user.id))
.filter(rankings::Column::RestaurantId.is_in(ids.clone()))
.all(db)
.await
.map_err(internal("load rankings failed"))?
.into_iter()
.map(|r| (r.restaurant_id, r.rank()))
.collect();
let mut visits: HashMap<Uuid, Vec<NaiveDate>> = HashMap::new();
for l in lunches::Entity::find()
.filter(lunches::Column::RestaurantId.is_in(ids))
.all(db)
.await
.map_err(internal("load lunches failed"))?
{
visits.entry(l.restaurant_id).or_default().push(l.went_on);
}
let area_names: HashMap<Uuid, String> = covered.into_iter().map(|a| (a.id, a.name)).collect();
let lunch_today = today().and_time(default_meal_time());
let mut groups: BTreeMap<String, (Uuid, Vec<Row>)> = BTreeMap::new();
for r in matching {
let v = visits.get(&r.id);
let row = Row {
cuisines: r.cuisine_tags(),
my_rank: my_ranks.get(&r.id).copied(),
visits: v.map_or(0, Vec::len),
last_visit: v.and_then(|v| v.iter().max().copied()),
lunch_today: r.opening_hours.as_deref().map(|h| hours::state_at(h, lunch_today)),
restaurant: r,
};
let area_name = area_names.get(&row.restaurant.area_id).cloned().unwrap_or_default();
groups.entry(area_name).or_insert_with(|| (row.restaurant.area_id, vec![])).1.push(row);
}
let total: usize = groups.values().map(|g| g.1.len()).sum();
let groups: Vec<minijinja::Value> = groups
.into_iter()
.map(|(area_name, (area_id, rows))| context! { area_name => area_name, area_id => area_id, rows => rows })
.collect();
render(
&state,
"cuisines/show.html",
context! {
title => name.clone(),
user => user.user,
cuisine => name,
area => area,
groups => groups,
total => total,
},
)
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
5.8 KiB · text
History
aecd85b