//! Areas: the regions restaurants are grouped under — the office, or a city
//! someone is travelling to. Everything else (the map, suggestions, history)
//! happens inside one.
use std::collections::HashMap;
use axum::extract::{Form, Path, State};
use axum::http::StatusCode;
use axum::response::{Html, Redirect};
use chrono::{NaiveDate, Utc};
use minijinja::context;
use sea_orm::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::{internal, non_empty, render};
use crate::entities::rankings::Rank;
use crate::entities::{areas, lunches, rankings, restaurants};
use crate::middleware::UserContext;
use crate::AppState;
pub(crate) async fn load_area(db: &DatabaseConnection, id: Uuid) -> Result<areas::Model, StatusCode> {
areas::Entity::find_by_id(id)
.one(db)
.await
.map_err(internal("load area failed"))?
.ok_or(StatusCode::NOT_FOUND)
}
#[derive(Serialize)]
struct AreaRow {
area: areas::Model,
restaurants: u64,
}
pub async fn all_areas(db: &DatabaseConnection) -> Result<Vec<areas::Model>, StatusCode> {
areas::Entity::find()
.order_by_asc(areas::Column::Name)
.all(db)
.await
.map_err(internal("list areas failed"))
}
pub async fn list_areas(
State(state): State<AppState>,
user: UserContext,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let mut counts: HashMap<Uuid, u64> = HashMap::new();
for r in restaurants::Entity::find()
.filter(restaurants::Column::Archived.eq(false))
.all(db)
.await
.map_err(internal("count restaurants failed"))?
{
*counts.entry(r.area_id).or_default() += 1;
}
let rows: Vec<AreaRow> = all_areas(db)
.await?
.into_iter()
.map(|a| AreaRow { restaurants: counts.get(&a.id).copied().unwrap_or(0), area: a })
.collect();
render(&state, "areas/list.html", context! { title => "Areas", user => user.user, areas => rows })
}
pub async fn new_area(
State(state): State<AppState>,
user: UserContext,
) -> Result<Html<String>, StatusCode> {
render(&state, "areas/new.html", context! { title => "New area", user => user.user })
}
#[derive(Debug, Deserialize)]
pub struct AreaForm {
pub name: String,
pub lat: f64,
pub lon: f64,
pub zoom: Option<i32>,
}
pub async fn create_area(
State(state): State<AppState>,
user: UserContext,
Form(form): Form<AreaForm>,
) -> Result<Redirect, StatusCode> {
let name = non_empty(Some(form.name)).ok_or(StatusCode::BAD_REQUEST)?;
if !(-90.0..=90.0).contains(&form.lat) || !(-180.0..=180.0).contains(&form.lon) {
return Err(StatusCode::BAD_REQUEST);
}
let db = state.db.as_ref();
// Names are unique; re-submitting one just goes to it.
if let Some(existing) = areas::Entity::find()
.filter(areas::Column::Name.eq(name.as_str()))
.one(db)
.await
.map_err(internal("load area failed"))?
{
return Ok(Redirect::to(&format!("/areas/{}", existing.id)));
}
let id = Uuid::now_v7();
areas::ActiveModel {
id: Set(id),
name: Set(name),
lat: Set(form.lat),
lon: Set(form.lon),
zoom: Set(form.zoom.unwrap_or(15).clamp(3, 19)),
created_by: Set(Some(user.user.id)),
created_at: Set(Utc::now().into()),
}
.insert(db)
.await
.map_err(internal("insert area failed"))?;
Ok(Redirect::to(&format!("/areas/{id}")))
}
/// One restaurant as listed in its area, from the viewer's point of view.
#[derive(Serialize)]
struct RestaurantRow {
#[serde(flatten)]
restaurant: restaurants::Model,
my_rank: Option<Rank>,
my_since: Option<NaiveDate>,
visits: usize,
last_visit: Option<NaiveDate>,
}
pub async fn show_area(
State(state): State<AppState>,
user: UserContext,
Path(id): Path<Uuid>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let area = load_area(db, id).await?;
let list = restaurants::Entity::find()
.filter(restaurants::Column::AreaId.eq(id))
.order_by_asc(restaurants::Column::Archived)
.order_by_asc(restaurants::Column::Name)
.all(db)
.await
.map_err(internal("list restaurants failed"))?;
let ids: Vec<Uuid> = list.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 history: 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"))?
{
history.entry(l.restaurant_id).or_default().push(l.went_on);
}
let rows: Vec<RestaurantRow> = list
.into_iter()
.map(|r| {
let visits = history.get(&r.id);
RestaurantRow {
my_rank: my_ranks.get(&r.id).copied(),
my_since: my_ranks.get(&r.id).and_then(|k| k.since()),
visits: visits.map_or(0, Vec::len),
last_visit: visits.and_then(|v| v.iter().max().copied()),
restaurant: r,
}
})
.collect();
let markers: Vec<minijinja::Value> = rows
.iter()
.filter(|r| !r.restaurant.archived)
.map(|r| {
context! {
name => r.restaurant.name,
lat => r.restaurant.lat,
lon => r.restaurant.lon,
rank => r.my_rank,
url => format!("/restaurants/{}", r.restaurant.id),
}
})
.collect();
let open_outings = super::outings::open_in_area(db, id).await?;
render(
&state,
"areas/show.html",
context! {
title => area.name.clone(),
open_outings => open_outings,
user => user.user,
area => area,
restaurants => rows,
markers => markers,
},
)
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
6.2 KiB · text
History
504ebaf