added query for reports

This commit is contained in:
Mitchell Marino 2023-04-13 21:44:20 -05:00
parent a48cace929
commit 7842a6014c
8 changed files with 290 additions and 211 deletions

View File

@ -9,7 +9,7 @@ services:
POSTGRES_USER: school_app_api_user
PGDATA: /db
volumes:
- ./pgdata:/db:rw
- /home/mitchell/prod/school_app_api/pgdata:/db:rw
school-app-api:
image: registry.mdev.local/school_app_api

View File

@ -0,0 +1,5 @@
-- fill existing users with grade 9
ALTER TABLE users ADD COLUMN grade INTEGER NOT NULL DEFAULT 9;
ALTER TABLE users ALTER COLUMN grade DROP DEFAULT;
ALTER TABLE event_attendees ADD COLUMN confirmed BOOLEAN NOT NULL DEFAULT false;

View File

@ -1,107 +1,14 @@
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use axum_auth::AuthBearer;
use jsonwebtoken::{Algorithm, Validation};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{
query, query_as,
types::{chrono::NaiveDateTime, BigDecimal},
use sqlx::{query, query_as};
use crate::{
jwt::handle_token,
models::{Event, EventType, NewEventRequestEntry, Role},
AppState,
};
use crate::{jwt::Claims, user::Role, AppState};
#[derive(
sqlx::Type, Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize,
)]
#[sqlx(type_name = "event_type", rename_all = "snake_case")]
enum EventType {
Sports,
Meetings,
Drama,
Music,
Other,
}
#[derive(Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct EventsPreviewQuery {
count: u32,
}
#[derive(Clone, Deserialize, Debug)]
pub struct NewEventRequestEntry {
title: String,
description: String,
#[serde(with = "serialize_datetime")]
time_start: NaiveDateTime,
#[serde(with = "serialize_datetime")]
time_end: NaiveDateTime,
event_type: EventType,
points: i32,
place: Option<String>,
#[serde(with = "serialize_big_decimal")]
price: BigDecimal,
}
#[derive(Clone, Serialize, Debug)]
pub struct Event {
id: i32,
title: String,
description: String,
#[serde(with = "serialize_datetime")]
time_start: NaiveDateTime,
#[serde(with = "serialize_datetime")]
time_end: NaiveDateTime,
event_type: EventType,
points: i32,
place: Option<String>,
#[serde(with = "serialize_big_decimal")]
price: BigDecimal,
created_by: Option<i32>,
}
mod serialize_datetime {
use std::str::FromStr;
use serde::{self, Deserialize, Deserializer, Serializer};
use sqlx::types::chrono::NaiveDateTime;
pub fn serialize<S>(dt: &NaiveDateTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&dt.to_string())
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<NaiveDateTime, D::Error> {
let str = <String as Deserialize>::deserialize(deserializer)?;
NaiveDateTime::from_str(&str).map_err(|err| serde::de::Error::custom(err))
}
}
mod serialize_big_decimal {
use serde::{self, Deserialize, Deserializer, Serializer};
use sqlx::types::BigDecimal;
pub fn serialize<S>(bd: &BigDecimal, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&bd.to_string())
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<BigDecimal, D::Error> {
let float = f32::deserialize(deserializer)?;
BigDecimal::try_from(float).map_err(|err| serde::de::Error::custom(err))
}
}
pub async fn get_events_preview(State(app_state): State<AppState>) -> impl IntoResponse {
let result = query_as!(
Event,
@ -144,7 +51,7 @@ LIMIT
pub async fn create_event(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
Json(event_post_query): Json<NewEventRequestEntry>,
Json(event_req): Json<NewEventRequestEntry>,
) -> impl IntoResponse {
let token_data = match handle_token(token, &app_state, Role::Teacher) {
Ok(value) => value,
@ -157,14 +64,14 @@ pub async fn create_event(
VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9 )
RETURNING id
"#,
event_post_query.title,
event_post_query.description,
event_post_query.time_start,
event_post_query.time_end,
event_post_query.event_type as EventType,
event_post_query.points,
event_post_query.place,
event_post_query.price,
event_req.title,
event_req.description,
event_req.time_start,
event_req.time_end,
event_req.event_type as EventType,
event_req.points,
event_req.place,
event_req.price,
token_data.id,
).fetch_one(&app_state.db_pool).await;
@ -179,35 +86,3 @@ pub async fn create_event(
.into_response(),
}
}
fn handle_token(
token: String,
app_state: &AppState,
minimum_role: Role,
) -> Result<Claims, Response> {
let token_result = jsonwebtoken::decode::<Claims>(
&token,
&app_state.jwt_decode,
&Validation::new(Algorithm::HS256),
);
let token_data = match token_result {
Ok(token_data) => token_data.claims,
Err(err) => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("Failed to parse JWT: {}", err) })),
)
.into_response())
}
};
if token_data.role < minimum_role {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({
"error": format!("You must be at least a {:?} to do this", minimum_role)
})),
)
.into_response());
}
Ok(token_data)
}

View File

@ -1,22 +1,18 @@
use crate::{
models::{Claims, Role},
AppState,
};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Validation};
use serde_json::json;
use std::time::Duration;
use jsonwebtoken::{DecodingKey, EncodingKey};
use serde::{Deserialize, Serialize};
use sqlx::types::time::OffsetDateTime;
use crate::user::Role;
pub const JWT_LIFETIME: Duration = Duration::from_secs(60 * 60 * 24 * 7);
#[derive(Clone, Serialize, Deserialize, Debug, Hash)]
pub struct Claims {
#[serde(with = "jwt_numeric_date")]
pub exp: OffsetDateTime,
pub id: i32,
pub username: String,
pub role: Role,
}
pub fn get_jwt_keys() -> (EncodingKey, DecodingKey) {
let secret = std::env::var("SAAPI_JWT_SECRET").unwrap_or("Sdb\\y5PP`,hmG+98".into());
(
@ -25,26 +21,34 @@ pub fn get_jwt_keys() -> (EncodingKey, DecodingKey) {
)
}
mod jwt_numeric_date {
//! Custom serialization of OffsetDateTime to conform with the JWT spec (RFC 7519 section 2, "Numeric Date")
use serde::{self, Deserialize, Deserializer, Serializer};
use sqlx::types::time::OffsetDateTime;
/// Serializes an OffsetDateTime to a Unix timestamp (milliseconds since 1970/1/1T00:00:00T)
pub fn serialize<S>(date: &OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let timestamp = date.unix_timestamp();
serializer.serialize_i64(timestamp)
pub fn handle_token(
token: String,
app_state: &AppState,
minimum_role: Role,
) -> Result<Claims, Response> {
let token_result = jsonwebtoken::decode::<Claims>(
&token,
&app_state.jwt_decode,
&Validation::new(Algorithm::HS256),
);
let token_data = match token_result {
Ok(token_data) => token_data.claims,
Err(err) => {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!("Failed to parse JWT: {}", err) })),
)
.into_response())
}
/// Attempts to deserialize an i64 and use as a Unix timestamp
pub fn deserialize<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
where
D: Deserializer<'de>,
{
OffsetDateTime::from_unix_timestamp(i64::deserialize(deserializer)?)
.map_err(|_| serde::de::Error::custom("invalid Unix timestamp value"))
};
if token_data.role < minimum_role {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({
"error": format!("You must be at least a {:?} to do this", minimum_role)
})),
)
.into_response());
}
Ok(token_data)
}

View File

@ -1,5 +1,7 @@
mod events;
mod jwt;
mod models;
mod report;
mod user;
use axum::{

162
src/models.rs Normal file
View File

@ -0,0 +1,162 @@
use serde::{Deserialize, Serialize};
use sqlx::types::{chrono::NaiveDateTime, time::OffsetDateTime, BigDecimal};
/// The possible event types.
#[derive(
sqlx::Type, Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize,
)]
#[sqlx(type_name = "event_type", rename_all = "snake_case")]
pub enum EventType {
Sports,
Meetings,
Drama,
Music,
Other,
}
/// The model for the body of the create event request.
#[derive(Clone, Deserialize, Debug)]
pub struct NewEventRequestEntry {
pub title: String,
pub description: String,
#[serde(with = "serde_datetime")]
pub time_start: NaiveDateTime,
#[serde(with = "serde_datetime")]
pub time_end: NaiveDateTime,
pub event_type: EventType,
pub points: i32,
pub place: Option<String>,
#[serde(with = "serde_big_decimal")]
pub price: BigDecimal,
}
/// The model for an Event in the db.
#[derive(Clone, Serialize, Debug)]
pub struct Event {
pub id: i32,
pub title: String,
pub description: String,
#[serde(with = "serde_datetime")]
pub time_start: NaiveDateTime,
#[serde(with = "serde_datetime")]
pub time_end: NaiveDateTime,
pub event_type: EventType,
pub points: i32,
pub place: Option<String>,
#[serde(with = "serde_big_decimal")]
pub price: BigDecimal,
pub created_by: Option<i32>,
}
/// The model for the body of the signup request.
#[derive(Clone, Debug, Deserialize)]
pub struct Signup {
pub username: String,
pub role: Role,
pub grade: i32,
pub password: String,
}
/// The possible roles for a user.
#[derive(
sqlx::Type, Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize,
)]
// #[sqlx(type_name = "role", rename_all = "snake_case")]
pub enum Role {
Student,
Teacher,
Admin,
}
/// The model for the signin request.
#[derive(Clone, Debug, Deserialize)]
pub struct Signin {
pub username: String,
pub password: String,
}
/// The model for the query parameters of the report request.
#[derive(Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ReportQuery {
pub grade: Option<i32>,
}
/// The claims for the JWT.
#[derive(Clone, Serialize, Deserialize, Debug, Hash)]
pub struct Claims {
#[serde(with = "serde_numeric_date")]
pub exp: OffsetDateTime,
pub id: i32,
pub username: String,
pub role: Role,
}
#[derive(
sqlx::Type, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize,
)]
pub struct UserReportEntry {
pub username: String,
pub points: i32,
}
/// Module for (de)serializing [OffsetDateTime] to conform with the JWT spec (RFC 7519 section 2, "Numeric Date")
mod serde_numeric_date {
use serde::{self, Deserialize, Deserializer, Serializer};
use sqlx::types::time::OffsetDateTime;
pub fn serialize<S>(date: &OffsetDateTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let timestamp = date.unix_timestamp();
serializer.serialize_i64(timestamp)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
where
D: Deserializer<'de>,
{
OffsetDateTime::from_unix_timestamp(i64::deserialize(deserializer)?)
.map_err(|_| serde::de::Error::custom("invalid Unix timestamp value"))
}
}
/// Module for (de)serializing [NaiveDateTime]
mod serde_datetime {
use std::str::FromStr;
use serde::{self, Deserialize, Deserializer, Serializer};
use sqlx::types::chrono::NaiveDateTime;
pub fn serialize<S>(dt: &NaiveDateTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&dt.to_string())
}
pub fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<NaiveDateTime, D::Error> {
let str = <String as Deserialize>::deserialize(deserializer)?;
NaiveDateTime::from_str(&str).map_err(|err| serde::de::Error::custom(err))
}
}
/// Module for (de)serializing [BigDecimal]
mod serde_big_decimal {
use serde::{self, Deserialize, Deserializer, Serializer};
use sqlx::types::BigDecimal;
pub fn serialize<S>(bd: &BigDecimal, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&bd.to_string())
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<BigDecimal, D::Error> {
let float = f32::deserialize(deserializer)?;
BigDecimal::try_from(float).map_err(|err| serde::de::Error::custom(err))
}
}

59
src/report.rs Normal file
View File

@ -0,0 +1,59 @@
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use axum_auth::AuthBearer;
use serde_json::json;
use sqlx::{query, query_as};
use crate::{
jwt::handle_token,
models::{ReportQuery, Role, UserReportEntry},
AppState,
};
pub async fn report(
AuthBearer(token): AuthBearer,
State(app_state): State<AppState>,
Query(report_query): Query<ReportQuery>,
) -> impl IntoResponse {
let token_data = match handle_token(token, &app_state, Role::Teacher) {
Ok(value) => value,
Err(value) => return value,
};
let result = query_as!(
UserReportEntry,
r#"
SELECT
u.username,
COALESCE(SUM(e.points), 0) AS "points!: i32"
FROM
users u
LEFT JOIN event_attendees ea
ON u.id = ea.user_id AND ea.confirmed = true
LEFT JOIN events e
ON ea.event_id = e.id
WHERE
u.grade = $1 OR $2
GROUP BY u.id;
"#,
report_query.grade.unwrap_or(0),
report_query.grade.is_none(),
)
.fetch_all(&app_state.db_pool)
.await;
match result {
Ok(record) => (StatusCode::OK, Json(json!({ "data": record }))).into_response(),
Err(err) => (
StatusCode::BAD_REQUEST,
Json(json!({
"error": format!("Unknown error creating event: {}", err)
})),
)
.into_response(),
}
}

View File

@ -1,43 +1,14 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use jsonwebtoken::Header;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::types::time::OffsetDateTime;
use crate::{
jwt::{Claims, JWT_LIFETIME},
jwt::JWT_LIFETIME,
models::{Claims, Role, Signin, Signup},
AppState,
};
#[derive(Clone, Debug, Deserialize)]
pub struct Signup {
username: String,
role: Role,
password: String,
}
#[derive(
sqlx::Type, Copy, Clone, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize,
)]
#[sqlx(type_name = "role", rename_all = "snake_case")]
pub enum Role {
Student,
Teacher,
Admin,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Signin {
username: String,
password: String,
}
#[derive(Serialize, sqlx::FromRow)]
struct User {
id: u64,
username: String,
}
pub async fn signup(
State(app_state): State<AppState>,
Json(signup): Json<Signup>,
@ -47,12 +18,13 @@ pub async fn signup(
let result = sqlx::query!(
r#"
INSERT INTO users (username, role, password)
VALUES ( $1, $2, $3 )
INSERT INTO users (username, role, grade, password)
VALUES ( $1, $2, $3, $4 )
RETURNING id;
"#,
signup.username,
signup.role as Role,
signup.grade,
pass_hash.as_bytes(),
)
.fetch_one(&app_state.db_pool)