From a2a9e31784c953bf4cd7e9262a82c4af78cde324 Mon Sep 17 00:00:00 2001 From: Celeo Date: Wed, 23 Oct 2024 21:49:14 -0700 Subject: [PATCH] Activity report feature --- vzdv-site/src/endpoints/admin.rs | 187 +++++++++++++++++- vzdv-site/src/endpoints/facility.rs | 3 + vzdv-site/templates/_layout.jinja | 1 + .../templates/admin/activity_report.jinja | 51 +++++ .../admin/activity_report_container.jinja | 55 ++++++ vzdv-site/templates/changelog.jinja | 11 ++ vzdv/src/sql.rs | 2 +- 7 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 vzdv-site/templates/admin/activity_report.jinja create mode 100644 vzdv-site/templates/admin/activity_report_container.jinja diff --git a/vzdv-site/src/endpoints/admin.rs b/vzdv-site/src/endpoints/admin.rs index 9937571..ccf146a 100644 --- a/vzdv-site/src/endpoints/admin.rs +++ b/vzdv-site/src/endpoints/admin.rs @@ -4,7 +4,7 @@ use crate::{ email::{self, send_mail}, flashed_messages::{self, MessageLevel}, shared::{ - is_user_member_of, post_audit, reject_if_not_in, AppError, AppState, UserInfo, + is_user_member_of, post_audit, reject_if_not_in, AppError, AppState, CacheEntry, UserInfo, SESSION_USER_INFO_KEY, }, }; @@ -14,18 +14,19 @@ use axum::{ routing::{delete, get, post}, Form, Router, }; -use chrono::Utc; +use chrono::{Months, Utc}; use log::{debug, error, info, warn}; use minijinja::{context, Environment}; use reqwest::StatusCode; use rev_buf_reader::RevBufReader; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::json; -use std::{collections::HashMap, io::BufRead, path::Path as FilePath, sync::Arc}; +use std::{collections::HashMap, io::BufRead, path::Path as FilePath, sync::Arc, time::Instant}; use tower_sessions::Session; use uuid::Uuid; use vzdv::{ - sql::{self, Controller, Feedback, FeedbackForReview, Resource, VisitorRequest}, + get_controller_cids_and_names, + sql::{self, Activity, Controller, Feedback, FeedbackForReview, Resource, VisitorRequest}, vatusa::{self, add_visiting_controller, get_multiple_controller_info}, ControllerRating, PermissionsGroup, GENERAL_HTTP_CLIENT, }; @@ -759,6 +760,164 @@ async fn page_off_roster_list( Ok(Html(rendered).into_response()) } +/// Simple page with controls to render the activity report. +/// +/// Admin staff members only. +async fn page_activity_report( + State(state): State>, + session: Session, +) -> Result { + let user_info: Option = session.get(SESSION_USER_INFO_KEY).await?; + if let Some(redirect) = reject_if_not_in(&state, &user_info, PermissionsGroup::Admin).await { + return Ok(redirect.into_response()); + } + let template = state + .templates + .get_template("admin/activity_report_container")?; + let rendered = template.render(context! { user_info })?; + Ok(Html(rendered).into_response()) +} + +/// Page to render the activity report. +/// +/// May take up to ~30 seconds to load, so will be loaded into the container page +/// as part of an HTMX action. Cached. +/// +/// Admin staff members only. +async fn page_activity_report_generate( + State(state): State>, + session: Session, +) -> Result { + #[derive(Serialize)] + struct CidAndName { + cid: u32, + name: String, + home: bool, + minutes_online: u32, + } + + let user_info: Option = session.get(SESSION_USER_INFO_KEY).await?; + if let Some(redirect) = reject_if_not_in(&state, &user_info, PermissionsGroup::Admin).await { + return Ok(redirect.into_response()); + } + let user_info = user_info.unwrap(); + + // cache this endpoint's returned data for 6 hours + let cache_key = "ACTIVITY_REPORT"; + if let Some(cached) = state.cache.get(&cache_key) { + let elapsed = Instant::now() - cached.inserted; + if elapsed.as_secs() < 60 * 60 * 6 { + return Ok(Html(cached.data).into_response()); + } + state.cache.invalidate(&cache_key); + } + + info!("{} generating activity report", user_info.cid); + let now = Utc::now(); + let months: [String; 3] = [ + now.format("%Y-%m").to_string(), + now.checked_sub_months(Months::new(1)) + .unwrap() + .format("%Y-%m") + .to_string(), + now.checked_sub_months(Months::new(2)) + .unwrap() + .format("%Y-%m") + .to_string(), + ]; + let controllers: Vec = sqlx::query_as(sql::GET_ALL_CONTROLLERS_ON_ROSTER) + .fetch_all(&state.db) + .await?; + let activity: Vec = sqlx::query_as(sql::GET_ALL_ACTIVITY) + .fetch_all(&state.db) + .await?; + let activity_map: HashMap = + activity.iter().fold(HashMap::new(), |mut acc, activity| { + if !months.contains(&activity.month) { + return acc; + } + acc.entry(activity.cid) + .and_modify(|entry| *entry += activity.minutes) + .or_insert(activity.minutes); + acc + }); + + let rated_violations: Vec = controllers + .iter() + .filter(|controller| { + controller.rating > ControllerRating::OBS.as_id() + && activity_map.get(&controller.cid).unwrap_or(&0) < &180 + }) + .map(|controller| CidAndName { + cid: controller.cid, + name: format!( + "{} {} ({})", + controller.first_name, + controller.last_name, + match &controller.operating_initials { + Some(oi) => oi, + None => "??", + } + ), + home: controller.home_facility == "ZDV", + minutes_online: *activity_map.get(&controller.cid).unwrap_or(&0), + }) + .collect(); + + let mut unrated_violations: Vec = Vec::new(); + for controller in &controllers { + if controller.rating != ControllerRating::OBS.as_id() { + continue; + } + let records = + match vatusa::get_training_records(&state.config.vatsim.vatusa_api_key, controller.cid) + .await + { + Ok(t) => t, + Err(e) => { + error!( + "Error getting training record data for {}: {e}", + controller.cid + ); + continue; + } + }; + if records.is_empty() { + unrated_violations.push(CidAndName { + cid: controller.cid, + name: format!( + "{} {} ({})", + controller.first_name, + controller.last_name, + match &controller.operating_initials { + Some(oi) => oi, + None => "??", + } + ), + home: controller.home_facility == "ZDV", + minutes_online: 0, + }); + } + } + + let cid_name_map = get_controller_cids_and_names(&state.db) + .await + .map_err(|err| AppError::GenericFallback("getting cids and names from DB", err))?; + let template = state.templates.get_template("admin/activity_report")?; + let rendered = template.render(context! { + user_info, + controllers, + rated_violations, + unrated_violations, + cid_name_map, + now_utc => Utc::now().to_rfc2822(), + })?; + state + .cache + .insert(cache_key, CacheEntry::new(rendered.clone())); + Ok(Html(rendered).into_response()) +} + /// This file's routes and templates. pub fn router(templates: &mut Environment) -> Router> { templates @@ -797,6 +956,19 @@ pub fn router(templates: &mut Environment) -> Router> { include_str!("../../templates/admin/off_roster_list.jinja"), ) .unwrap(); + templates + .add_template( + "admin/activity_report_container", + include_str!("../../templates/admin/activity_report_container.jinja"), + ) + .unwrap(); + templates + .add_template( + "admin/activity_report", + include_str!("../../templates/admin/activity_report.jinja"), + ) + .unwrap(); + templates.add_filter("nice_date", |date: String| { chrono::DateTime::parse_from_rfc3339(&date) .unwrap() @@ -839,4 +1011,9 @@ pub fn router(templates: &mut Environment) -> Router> { .layer(DefaultBodyLimit::disable()) // no upload limit on this endpoint .route("/admin/resources/:id", delete(api_delete_resource)) .route("/admin/off_roster_list", get(page_off_roster_list)) + .route("/admin/activity_report", get(page_activity_report)) + .route( + "/admin/activity_report/generate", + get(page_activity_report_generate), + ) } diff --git a/vzdv-site/src/endpoints/facility.rs b/vzdv-site/src/endpoints/facility.rs index 7f47044..3b13d8b 100644 --- a/vzdv-site/src/endpoints/facility.rs +++ b/vzdv-site/src/endpoints/facility.rs @@ -548,6 +548,9 @@ pub fn router(templates: &mut Environment) -> Router> { ) .unwrap(); templates.add_filter("minutes_to_hm", |total_minutes: u32| { + if total_minutes == 0 { + return String::new(); + } let hours = total_minutes / 60; let minutes = total_minutes % 60; if hours > 0 || minutes > 0 { diff --git a/vzdv-site/templates/_layout.jinja b/vzdv-site/templates/_layout.jinja index 202ace3..464a660 100644 --- a/vzdv-site/templates/_layout.jinja +++ b/vzdv-site/templates/_layout.jinja @@ -98,6 +98,7 @@ {% if user_info.is_admin %}
  • Manage feedback
  • Manage visitor apps
  • +
  • Activity report
  • Emails
  • Read logs
  • {% endif %} diff --git a/vzdv-site/templates/admin/activity_report.jinja b/vzdv-site/templates/admin/activity_report.jinja new file mode 100644 index 0000000..41b6564 --- /dev/null +++ b/vzdv-site/templates/admin/activity_report.jinja @@ -0,0 +1,51 @@ +

    Rated controller violations

    + + + + + + + + + + + {% for controller in rated_violations %} + + + + + + + {% endfor %} + +
    NameTypeHours on scope
    {{ controller.name }}{% if controller.home %}Home{% else %}Visiting{% endif %}{{ controller.minutes_online|minutes_to_hm }} + + + +
    + +

    Observer controller violations

    + + + + + + + + + + {% for controller in unrated_violations %} + + + + + + {% endfor %} + +
    NameType
    {{ controller.name }}{% if controller.home %}Home{% else %}Visiting{% endif %} + + + +
    + +

    Generated at: {{ now_utc }}; cached for 6 hours afterwards

    diff --git a/vzdv-site/templates/admin/activity_report_container.jinja b/vzdv-site/templates/admin/activity_report_container.jinja new file mode 100644 index 0000000..276160e --- /dev/null +++ b/vzdv-site/templates/admin/activity_report_container.jinja @@ -0,0 +1,55 @@ +{% extends "_layout" %} + +{% block title %}Activity report | {{ super() }}{% endblock %} + +{% block body %} + +

    Activity report

    +

    + Click this button to load the activity report for the facility.
    + It can take a short while to generate. +

    +

    + This report shows rated controllers who have not met the activity requirement in the last 3 months + and Observer controllers who have had no training notes added from this facility in the same time frame. +

    + +
    + +
    + + + + + + + + + + +
    +
    + + + +{% endblock %} diff --git a/vzdv-site/templates/changelog.jinja b/vzdv-site/templates/changelog.jinja index d852bdf..5dbcf0b 100644 --- a/vzdv-site/templates/changelog.jinja +++ b/vzdv-site/templates/changelog.jinja @@ -14,6 +14,17 @@
    +
    +
    +
    2024-10-23
    +
    +
      +
    • Sr Staff activity report feature.
    • +
    +
    +
    +
    +
    2024-10-22
    diff --git a/vzdv/src/sql.rs b/vzdv/src/sql.rs index 9844cb3..0a5a4c6 100644 --- a/vzdv/src/sql.rs +++ b/vzdv/src/sql.rs @@ -35,7 +35,7 @@ pub struct Certification { } /// Requires joining the `controller` column for the name. -#[derive(Debug, FromRow, Serialize)] +#[derive(Debug, FromRow, Serialize, Clone)] pub struct Activity { pub id: u32, pub cid: u32,