//! Restaurants: added from the map, ranked by each person.
use std::collections::HashMap;
use axum::extract::{Form, Path, State};
use axum::http::StatusCode;
use axum::response::{Html, Redirect};
use chrono::Utc;
use minijinja::context;
use sea_orm::sea_query::OnConflict;
use sea_orm::*;
use serde::Deserialize;
use uuid::Uuid;
use super::areas::load_area;
use super::{internal, non_empty, render};
use chrono::NaiveDate;
use crate::entities::rankings::{Rank, RankKind};
use crate::osm::ElementRef;
use crate::entities::{lunch_attendees, lunches, rankings, restaurants, users};
use crate::middleware::UserContext;
use crate::AppState;
async fn load_restaurant(db: &DatabaseConnection, id: Uuid) -> Result<restaurants::Model, StatusCode> {
restaurants::Entity::find_by_id(id)
.one(db)
.await
.map_err(internal("load restaurant failed"))?
.ok_or(StatusCode::NOT_FOUND)
}
pub async fn new_restaurant(
State(state): State<AppState>,
user: UserContext,
Path(area_id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let area = load_area(db, area_id).await?;
// Show what's already here so the map isn't blank context.
let markers: Vec<minijinja::Value> = restaurants::Entity::find()
.filter(restaurants::Column::AreaId.eq(area_id))
.filter(restaurants::Column::Archived.eq(false))
.all(db)
.await
.map_err(internal("list restaurants failed"))?
.into_iter()
.map(|r| context! { name => r.name, lat => r.lat, lon => r.lon, url => format!("/restaurants/{}", r.id) })
.collect();
render(
&state,
"restaurants/new.html",
context! { title => "Add a restaurant", user => user.user, area => area, markers => markers },
)
}
#[derive(Debug, Deserialize)]
pub struct RestaurantForm {
pub area_id: Uuid,
pub name: String,
pub osm_ref: Option<String>,
pub lat: f64,
pub lon: f64,
pub cuisine: Option<String>,
pub address: Option<String>,
pub website: Option<String>,
pub opening_hours: Option<String>,
pub notes: Option<String>,
pub rank: Option<String>,
}
pub async fn create_restaurant(
State(state): State<AppState>,
user: UserContext,
Form(form): Form<RestaurantForm>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
let name = non_empty(Some(form.name)).ok_or(StatusCode::BAD_REQUEST)?;
load_area(db, form.area_id).await?;
let osm_ref = non_empty(form.osm_ref);
let rank = form.rank.as_deref().and_then(RankKind::parse);
// The same OSM element added twice is the same restaurant: keep the first
// and just record this person's rank on it.
let existing = match &osm_ref {
Some(r) => restaurants::Entity::find()
.filter(restaurants::Column::OsmRef.eq(r.as_str()))
.one(db)
.await
.map_err(internal("load restaurant failed"))?,
None => None,
};
let id = match existing {
Some(r) => r.id,
None => {
let id = Uuid::now_v7();
restaurants::ActiveModel {
id: Set(id),
area_id: Set(form.area_id),
name: Set(name),
osm_ref: Set(osm_ref),
lat: Set(form.lat),
lon: Set(form.lon),
cuisine: Set(non_empty(form.cuisine)),
address: Set(non_empty(form.address)),
website: Set(non_empty(form.website)),
opening_hours: Set(non_empty(form.opening_hours)),
notes: Set(non_empty(form.notes).unwrap_or_default()),
archived: Set(false),
added_by: Set(Some(user.user.id)),
created_at: Set(Utc::now().into()),
}
.insert(db)
.await
.map_err(internal("insert restaurant failed"))?;
id
}
};
if let Some(kind) = rank {
apply_rank(db, user.user.id, id, kind, None).await?;
}
Ok(Redirect::to(&format!("/restaurants/{id}")))
}
/// Set someone's rank on a restaurant. An obsession starts today unless
/// `since` says otherwise — and re-picking Obsessed without a date keeps the
/// one already recorded, so clicking it again doesn't reset the clock.
async fn apply_rank(
db: &DatabaseConnection,
user_id: Uuid,
restaurant_id: Uuid,
kind: RankKind,
since: Option<NaiveDate>,
) -> Result<Rank, StatusCode> {
let today = Utc::now().date_naive();
let rank = match kind {
RankKind::Obsessed => {
let since = match since {
Some(d) => d.min(today),
None => rankings::Entity::find_by_id((user_id, restaurant_id))
.one(db)
.await
.map_err(internal("load rank failed"))?
.and_then(|r| r.rank().since())
.unwrap_or(today),
};
Rank::Obsessed { since }
}
other => Rank::from_parts(other, None, today),
};
rankings::Entity::insert(rankings::ActiveModel {
user_id: Set(user_id),
restaurant_id: Set(restaurant_id),
rank: Set(rank.kind()),
obsessed_since: Set(rank.since()),
updated_at: Set(Utc::now().into()),
})
.on_conflict(
OnConflict::columns([rankings::Column::UserId, rankings::Column::RestaurantId])
.update_columns([
rankings::Column::Rank,
rankings::Column::ObsessedSince,
rankings::Column::UpdatedAt,
])
.to_owned(),
)
.exec_without_returning(db)
.await
.map_err(internal("upsert rank failed"))?;
Ok(rank)
}
pub async fn show_restaurant(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let restaurant = load_restaurant(db, id).await?;
let area = load_area(db, restaurant.area_id).await?;
let ranks: HashMap<Uuid, Rank> = rankings::Entity::find()
.filter(rankings::Column::RestaurantId.eq(id))
.all(db)
.await
.map_err(internal("load rankings failed"))?
.into_iter()
.map(|r| (r.user_id, r.rank()))
.collect();
let people = users::Entity::find()
.filter(users::Column::IsActive.eq(true))
.order_by_asc(users::Column::Username)
.all(db)
.await
.map_err(internal("list users failed"))?;
let names: HashMap<Uuid, String> = people.iter().map(|u| (u.id, u.username.clone())).collect();
let everyone: Vec<minijinja::Value> = people
.iter()
.map(|u| {
let rank = ranks.get(&u.id);
context! { username => u.username, rank => rank, since => rank.and_then(|r| r.since()) }
})
.collect();
let visits = lunches::Entity::find()
.filter(lunches::Column::RestaurantId.eq(id))
.order_by_desc(lunches::Column::WentOn)
.find_with_related(lunch_attendees::Entity)
.all(db)
.await
.map_err(internal("load lunches failed"))?;
let visits: Vec<minijinja::Value> = visits
.into_iter()
.map(|(l, att)| {
let who: Vec<&str> = att.iter().filter_map(|a| names.get(&a.user_id).map(String::as_str)).collect();
context! { id => l.id, went_on => l.went_on, attendees => who }
})
.collect();
let today = super::lunch::today();
let lunch_today = today.and_time(super::lunch::default_meal_time());
let hours = restaurant
.opening_hours
.as_deref()
.map(|h| context! { raw => h, at_lunch => crate::hours::state_at(h, lunch_today) });
render(
&state,
"restaurants/show.html",
context! {
title => restaurant.name.clone(),
today => today,
hours => hours,
cuisines => restaurant.cuisine_tags(),
user => user.user,
my_rank => ranks.get(&user.user.id),
my_since => ranks.get(&user.user.id).and_then(|r| r.since()),
restaurant => restaurant,
area => area,
everyone => everyone,
visits => visits,
},
)
}
#[derive(Debug, Deserialize)]
pub struct RankForm {
pub rank: String,
/// Only for `obsessed`: when it started. Blank keeps the current date.
pub since: Option<String>,
}
/// htmx: set the viewer's rank, return the re-rendered rank widget.
pub async fn set_rank(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
Form(form): Form<RankForm>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let kind = RankKind::parse(&form.rank).ok_or(StatusCode::BAD_REQUEST)?;
let since = match non_empty(form.since) {
Some(d) => Some(d.parse::<NaiveDate>().map_err(|_| StatusCode::BAD_REQUEST)?),
None => None,
};
load_restaurant(db, id).await?;
let rank = apply_rank(db, user.user.id, id, kind, since).await?;
render(
&state,
"restaurants/_rank.html",
context! { restaurant_id => id, my_rank => rank, my_since => rank.since() },
)
}
#[derive(Debug, Deserialize)]
pub struct EditForm {
pub notes: Option<String>,
pub cuisine: Option<String>,
pub website: Option<String>,
pub opening_hours: Option<String>,
}
pub async fn update_restaurant(
State(state): State<AppState>,
_user: UserContext,
Path(id): Path<Uuid>,
Form(form): Form<EditForm>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
let mut active: restaurants::ActiveModel = load_restaurant(db, id).await?.into();
active.notes = Set(non_empty(form.notes).unwrap_or_default());
active.cuisine = Set(non_empty(form.cuisine));
active.website = Set(non_empty(form.website));
active.opening_hours = Set(non_empty(form.opening_hours));
active.update(db).await.map_err(internal("update restaurant failed"))?;
Ok(Redirect::to(&format!("/restaurants/{id}")))
}
/// Bring restaurants up to date with OSM. Hours are replaced whenever OSM
/// has them, since they change; cuisine, website and address are only filled
/// where blank, since someone may have corrected them here. Returns how many
/// restaurants changed.
pub(crate) async fn refresh_from_osm(
db: &DatabaseConnection,
osm: &crate::osm::OsmClient,
list: Vec<restaurants::Model>,
) -> Result<usize, StatusCode> {
let refs: Vec<ElementRef> = list
.iter()
.filter_map(|r| Some(ElementRef { osm_ref: r.osm_ref.clone()?, lat: r.lat, lon: r.lon }))
.collect();
if refs.is_empty() {
return Ok(0);
}
let mut found = osm.tags_for(&refs).await;
let mut changed = 0;
for r in list {
let Some(tags) = r.osm_ref.as_ref().and_then(|o| found.remove(o)) else { continue };
let d = crate::osm::details(&tags);
let next = restaurants::Model {
opening_hours: d.opening_hours.or(r.opening_hours.clone()),
cuisine: r.cuisine.clone().or(d.cuisine),
website: r.website.clone().or(d.website),
address: r.address.clone().or(d.address),
..r.clone()
};
if next != r {
let mut active: restaurants::ActiveModel = r.into();
active.opening_hours = Set(next.opening_hours);
active.cuisine = Set(next.cuisine);
active.website = Set(next.website);
active.address = Set(next.address);
active.update(db).await.map_err(internal("refresh restaurant failed"))?;
changed += 1;
}
}
Ok(changed)
}
pub async fn refresh_restaurant(
State(state): State<AppState>,
_user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
let restaurant = load_restaurant(db, id).await?;
refresh_from_osm(db, &state.osm, vec![restaurant]).await?;
Ok(Redirect::to(&format!("/restaurants/{id}")))
}
/// Closed for good, or just off the rotation: archived places stay in the
/// history but are never suggested.
pub async fn toggle_archive(
State(state): State<AppState>,
_user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
let restaurant = load_restaurant(db, id).await?;
let archived = !restaurant.archived;
let mut active: restaurants::ActiveModel = restaurant.into();
active.archived = Set(archived);
active.update(db).await.map_err(internal("archive restaurant failed"))?;
Ok(Redirect::to(&format!("/restaurants/{id}")))
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
12.5 KiB · text
History
6dd7389