5.8 KiB · text History 7a00c02
//! 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,
        },
    )
}