Skip to content

Commit

Permalink
Activity report feature
Browse files Browse the repository at this point in the history
  • Loading branch information
Celeo committed Oct 24, 2024
1 parent 618f4c5 commit a2a9e31
Show file tree
Hide file tree
Showing 7 changed files with 304 additions and 6 deletions.
187 changes: 182 additions & 5 deletions vzdv-site/src/endpoints/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Expand All @@ -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,
};
Expand Down Expand Up @@ -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<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
let user_info: Option<UserInfo> = 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<Arc<AppState>>,
session: Session,
) -> Result<Response, AppError> {
#[derive(Serialize)]
struct CidAndName {
cid: u32,
name: String,
home: bool,
minutes_online: u32,
}

let user_info: Option<UserInfo> = 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<Controller> = sqlx::query_as(sql::GET_ALL_CONTROLLERS_ON_ROSTER)
.fetch_all(&state.db)
.await?;
let activity: Vec<Activity> = sqlx::query_as(sql::GET_ALL_ACTIVITY)
.fetch_all(&state.db)
.await?;
let activity_map: HashMap<u32, u32> =
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<CidAndName> = 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<CidAndName> = 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<Arc<AppState>> {
templates
Expand Down Expand Up @@ -797,6 +956,19 @@ pub fn router(templates: &mut Environment) -> Router<Arc<AppState>> {
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()
Expand Down Expand Up @@ -839,4 +1011,9 @@ pub fn router(templates: &mut Environment) -> Router<Arc<AppState>> {
.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),
)
}
3 changes: 3 additions & 0 deletions vzdv-site/src/endpoints/facility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,9 @@ pub fn router(templates: &mut Environment) -> Router<Arc<AppState>> {
)
.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 {
Expand Down
1 change: 1 addition & 0 deletions vzdv-site/templates/_layout.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
{% if user_info.is_admin %}
<li><a href="/admin/feedback" class="dropdown-item">Manage feedback</a></li>
<li><a href="/admin/visitor_applications" class="dropdown-item">Manage visitor apps</a></li>
<li><a href="/admin/activity_report" class="dropdown-item">Activity report</a></li>
<li><a href="/admin/emails" class="dropdown-item">Emails</a></li>
<li><a href="/admin/logs" class="dropdown-item">Read logs</a></li>
{% endif %}
Expand Down
51 changes: 51 additions & 0 deletions vzdv-site/templates/admin/activity_report.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<h3>Rated controller violations</h3>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Hours on scope</th>
<th></th>
</tr>
</thead>
<tbody>
{% for controller in rated_violations %}
<tr>
<td>{{ controller.name }}</td>
<td>{% if controller.home %}Home{% else %}Visiting{% endif %}</td>
<td>{{ controller.minutes_online|minutes_to_hm }}</td>
<td>
<a href="/controller/{{ controller.cid }}" class="icon-link icon-link-hover text-decoration-none">
<i class="bi bi-arrow-right-short"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>

<h3 class="pt-5">Observer controller violations</h3>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th></th>
</tr>
</thead>
<tbody>
{% for controller in unrated_violations %}
<tr>
<td>{{ controller.name }}</td>
<td>{% if controller.home %}Home{% else %}Visiting{% endif %}</td>
<td>
<a href="/controller/{{ controller.cid }}" class="icon-link icon-link-hover text-decoration-none">
<i class="bi bi-arrow-right-short"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>

<p class="pt-3">Generated at: {{ now_utc }}; cached for 6 hours afterwards</p>
55 changes: 55 additions & 0 deletions vzdv-site/templates/admin/activity_report_container.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{% extends "_layout" %}

{% block title %}Activity report | {{ super() }}{% endblock %}

{% block body %}

<h2>Activity report</h2>
<p>
Click this button to load the activity report for the facility.<br>
It can take a short while to generate.
</p>
<p class="pb-3">
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.
</p>

<div>
<button id="generate-report" class="btn btn-sm btn-primary" hx-get="/admin/activity_report/generate" hx-swap="outerHTML" hx-indicator="#activity-report-retrieve-indicator">
<i class="bi bi-search"></i>
Retrieve
</button>
<div class="htmx-indicator pt-2 ps-1" id="activity-report-retrieve-indicator">
<svg
width="38"
height="38"
viewBox="0 0 38 38"
xmlns="http://www.w3.org/2000/svg"
stroke="#fff"
>
<g fill="none" fill-rule="evenodd">
<g transform="translate(1 1)" stroke-width="2">
<circle stroke-opacity=".5" cx="18" cy="18" r="18" />
<path d="M36 18c0-9.94-8.06-18-18-18">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="1s"
repeatCount="indefinite"
/>
</path>
</g>
</g>
</svg>
</div>
</div>

<script>
document.getElementById('generate-report').addEventListener('click', (e) => {
document.getElementById('generate-report').setAttribute('disabled', true);
});
</script>

{% endblock %}
11 changes: 11 additions & 0 deletions vzdv-site/templates/changelog.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@

<hr>

<div class="card shadow mb-3">
<div class="card-body">
<h5 class="card-title">2024-10-23</h5>
<div class="card-text">
<ul>
<li>Sr Staff activity report feature.</li>
</ul>
</div>
</div>
</div>

<div class="card shadow mb-3">
<div class="card-body">
<h5 class="card-title">2024-10-22</h5>
Expand Down
2 changes: 1 addition & 1 deletion vzdv/src/sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit a2a9e31

Please sign in to comment.