diff --git a/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs index c61ea4f565..4fd0c498e3 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/facets/mod.rs @@ -59,7 +59,7 @@ pub struct ProjectPlaytimeFacets { pub country: Vec, } -/// Get analytics facets. +/// Get analytics facets. #[utoipa::path( context_path = "/analytics", tag = "analytics", diff --git a/apps/labrinth/src/routes/v3/analytics_get/mod.rs b/apps/labrinth/src/routes/v3/analytics_get/mod.rs index 6dc980a1c7..71679c1e54 100644 --- a/apps/labrinth/src/routes/v3/analytics_get/mod.rs +++ b/apps/labrinth/src/routes/v3/analytics_get/mod.rs @@ -14,7 +14,6 @@ use xredis::RedisPool; pub mod facets; mod metrics; -pub mod old; use std::{ collections::{HashMap, HashSet}, @@ -61,7 +60,6 @@ pub use metrics::*; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(fetch_analytics); cfg.configure(facets::config); - cfg.configure(old::config); } // request @@ -176,7 +174,7 @@ pub enum ProjectAnalyticsEventKind { // logic -/// Fetch analytics data. +/// Fetch analytics data. #[utoipa::path( context_path = "/analytics", tag = "analytics", diff --git a/apps/labrinth/src/routes/v3/analytics_get/old.rs b/apps/labrinth/src/routes/v3/analytics_get/old.rs deleted file mode 100644 index a164921783..0000000000 --- a/apps/labrinth/src/routes/v3/analytics_get/old.rs +++ /dev/null @@ -1,766 +0,0 @@ -//! TODO: this module should be removed; it is superseded by `analytics_get` - -use crate::util::error::ApiContext as _; -use crate::util::error::Context as _; - -use super::ApiError; -use crate::database; -use crate::database::PgPool; -use crate::models::teams::ProjectPermissions; -use crate::{ - auth::get_user_from_headers, - database::models::user_item, - models::{ids::ProjectId, pats::Scopes}, - queue::session::AuthQueue, -}; -use actix_web::{HttpRequest, HttpResponse, get, web}; -use ariadne::ids::base62_impl::to_base62; -use chrono::{DateTime, Duration, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::postgres::types::PgInterval; -use std::collections::HashMap; -use std::convert::TryInto; -use std::num::NonZeroU32; -use xredis::RedisPool; - -pub fn config(cfg: &mut actix_web::web::ServiceConfig) { - cfg.service(playtimes_get) - .service(views_get) - .service(downloads_get) - .service(revenue_get) - .service(countries_downloads_get) - .service(countries_views_get); -} - -/// The json data to be passed to fetch analytic data. -/// -/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. -/// start_date and end_date are optional, and default to two weeks ago, and the maximum date respectively. -/// resolution_minutes is optional. This refers to the window by which we are looking (every day, every minute, etc) and defaults to 1440 (1 day) -#[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] -pub struct GetData { - // only one of project_ids or version_ids should be used - // if neither are provided, all projects the user has access to will be used - pub project_ids: Option, - - pub start_date: Option>, // defaults to 2 weeks ago - pub end_date: Option>, // defaults to now - - #[schema(value_type = Option, minimum = 1)] - pub resolution_minutes: Option, // defaults to 1 day. Ignored in routes that do not aggregate over a resolution (eg: /countries) -} - -/// Get playtime data. -#[utoipa::path( - context_path = "/analytics", - tag = "analytics", - params( - ("project_ids" = Option, Query), - ("start_date" = Option, Query), - ("end_date" = Option, Query), - ("resolution_minutes" = Option, Query) - ), - responses((status = OK, body = HashMap>)), -)] -#[get("/playtime")] -pub async fn playtimes_get( - req: HttpRequest, - clickhouse: web::Data, - data: web::Query, - session_queue: web::Data, - pool: web::Data, - redis: web::Data, -) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::ANALYTICS, - ) - .await - .map(|x| x.1) - .wrap_auth_err("authenticating API request")?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose() - .wrap_request_err("deserializing JSON data")?; - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - let resolution_minutes = data - .resolution_minutes - .map_or(60 * 24, |minutes| minutes.get()); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let project_ids = - filter_allowed_ids(project_ids, user, &pool, &redis, None) - .await - .wrap_api_err("filtering authorized playtime project IDs")?; - - // Get the views - let playtimes = crate::clickhouse::fetch_playtimes( - project_ids.unwrap_or_default(), - start_date, - end_date, - resolution_minutes, - clickhouse.into_inner(), - ) - .await - .wrap_api_err("fetching project playtime from ClickHouse")?; - - let mut hm = HashMap::new(); - for playtime in playtimes { - let id_string = to_base62(playtime.id); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(playtime.time, playtime.total); - } - } - - Ok(HttpResponse::Ok().json(hm)) -} - -/// Get view data. -/// -/// Data is returned as a hashmap of project/version ids to a hashmap of days to views -/// eg: -/// { -/// "4N1tEhnO": { -/// "20230824": 1090 -/// } -///} -/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. -#[utoipa::path( - context_path = "/analytics", - tag = "analytics", - params( - ("project_ids" = Option, Query), - ("start_date" = Option, Query), - ("end_date" = Option, Query), - ("resolution_minutes" = Option, Query) - ), - responses((status = OK, body = HashMap>)), -)] -#[get("/views")] -pub async fn views_get( - req: HttpRequest, - clickhouse: web::Data, - data: web::Query, - session_queue: web::Data, - pool: web::Data, - redis: web::Data, -) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::ANALYTICS, - ) - .await - .map(|x| x.1) - .wrap_auth_err("authenticating API request")?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose() - .wrap_request_err("deserializing JSON data")?; - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - let resolution_minutes = data - .resolution_minutes - .map_or(60 * 24, |minutes| minutes.get()); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let project_ids = - filter_allowed_ids(project_ids, user, &pool, &redis, None) - .await - .wrap_api_err("filtering authorized view project IDs")?; - - // Get the views - let views = crate::clickhouse::fetch_views( - project_ids.unwrap_or_default(), - start_date, - end_date, - resolution_minutes, - clickhouse.into_inner(), - ) - .await - .wrap_api_err("fetching project views from ClickHouse")?; - - let mut hm = HashMap::new(); - for views in views { - let id_string = to_base62(views.id); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(views.time, views.total); - } - } - - Ok(HttpResponse::Ok().json(hm)) -} - -/// Get download data. -/// -/// Data is returned as a hashmap of project/version ids to a hashmap of days to downloads -/// eg: -/// { -/// "4N1tEhnO": { -/// "20230824": 32 -/// } -///} -/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. -#[utoipa::path( - context_path = "/analytics", - tag = "analytics", - params( - ("project_ids" = Option, Query), - ("start_date" = Option, Query), - ("end_date" = Option, Query), - ("resolution_minutes" = Option, Query) - ), - responses((status = OK, body = HashMap>)), -)] -#[get("/downloads")] -pub async fn downloads_get( - req: HttpRequest, - clickhouse: web::Data, - data: web::Query, - session_queue: web::Data, - pool: web::Data, - redis: web::Data, -) -> Result { - let user_option = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::ANALYTICS, - ) - .await - .map(|x| x.1) - .wrap_auth_err("authenticating API request")?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose() - .wrap_request_err("deserializing JSON data")?; - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - let resolution_minutes = data - .resolution_minutes - .map_or(60 * 24, |minutes| minutes.get()); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let project_ids = - filter_allowed_ids(project_ids, user_option, &pool, &redis, None) - .await - .wrap_api_err("filtering authorized download project IDs")?; - - // Get the downloads - let downloads = crate::clickhouse::fetch_downloads( - project_ids.unwrap_or_default(), - start_date, - end_date, - resolution_minutes, - clickhouse.into_inner(), - ) - .await - .wrap_api_err("fetching project downloads from ClickHouse")?; - - let mut hm = HashMap::new(); - for downloads in downloads { - let id_string = to_base62(downloads.id); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(downloads.time, downloads.total); - } - } - - Ok(HttpResponse::Ok().json(hm)) -} - -/// Get payout data. -/// -/// Data is returned as a hashmap of project ids to a hashmap of days to amount earned per day -/// eg: -/// { -/// "4N1tEhnO": { -/// "20230824": 0.001 -/// } -///} -/// ONLY project IDs can be used. Unauthorized projects will be filtered out. -#[utoipa::path( - context_path = "/analytics", - tag = "analytics", - params( - ("project_ids" = Option, Query), - ("start_date" = Option, Query), - ("end_date" = Option, Query), - ("resolution_minutes" = Option, Query) - ), - responses((status = OK, body = HashMap>)), -)] -#[get("/revenue")] -pub async fn revenue_get( - req: HttpRequest, - data: web::Query, - session_queue: web::Data, - pool: web::Data, - redis: web::Data, -) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::PAYOUTS_READ, - ) - .await - .map(|x| x.1) - .wrap_auth_err("authenticating API request")?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose() - .wrap_request_err("deserializing JSON data")?; - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - let resolution_minutes = data - .resolution_minutes - .map_or(60 * 24, |minutes| minutes.get()); - - // Round up/down to nearest duration as we are using pgadmin, does not have rounding in the fetch command - // Round start_date down to nearest resolution - let diff = start_date.timestamp() % (resolution_minutes as i64 * 60); - let start_date = start_date - Duration::seconds(diff); - - // Round end_date up to nearest resolution - let diff = end_date.timestamp() % (resolution_minutes as i64 * 60); - let end_date = - end_date + Duration::seconds((resolution_minutes as i64 * 60) - diff); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let project_ids = filter_allowed_ids( - project_ids, - user.clone(), - &pool, - &redis, - Some(true), - ) - .await - .wrap_api_err("filtering authorized revenue project IDs")?; - - let duration: PgInterval = Duration::minutes(resolution_minutes as i64) - .try_into() - .map_err(|err: Box| { - eyre::eyre!("{err}") - }) - .wrap_request_err("invalid `resolution_minutes`")?; - // Get the revenue data - let project_ids = project_ids.unwrap_or_default(); - - struct PayoutValue { - mod_id: Option, - amount_sum: Option, - interval_start: Option>, - } - - let payouts_values = if project_ids.is_empty() { - sqlx::query!( - " - SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start - FROM payouts_values - WHERE user_id = $1 AND created >= $2 AND created < $3 - GROUP by mod_id, interval_start ORDER BY interval_start - ", - user.id.0 as i64, - start_date, - end_date, - duration, - ) - .fetch_all(&**pool) - .await.wrap_internal_err("fetching payouts values from database")?.into_iter().map(|x| PayoutValue { - mod_id: x.mod_id, - amount_sum: x.amount_sum, - interval_start: x.interval_start, - }).collect::>() - } else { - sqlx::query!( - " - SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start - FROM payouts_values - WHERE mod_id = ANY($1) AND created >= $2 AND created < $3 - GROUP by mod_id, interval_start ORDER BY interval_start - ", - &project_ids.iter().map(|x| x.0 as i64).collect::>(), - start_date, - end_date, - duration, - ) - .fetch_all(&**pool) - .await.wrap_internal_err("querying database for `revenue_get`")?.into_iter().map(|x| PayoutValue { - mod_id: x.mod_id, - amount_sum: x.amount_sum, - interval_start: x.interval_start, - }).collect::>() - }; - - let mut hm: HashMap<_, _> = project_ids - .into_iter() - .map(|x| (x.to_string(), HashMap::new())) - .collect::>(); - for value in payouts_values { - if let Some(mod_id) = value.mod_id - && let Some(amount) = value.amount_sum - && let Some(interval_start) = value.interval_start - { - let id_string = to_base62(mod_id as u64); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(interval_start.timestamp(), amount); - } - } - } - - Ok(HttpResponse::Ok().json(hm)) -} - -/// Get download country data. -/// -/// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to downloads. -/// Unknown countries are labeled "". -/// This is usable to see significant performing countries per project -/// eg: -/// { -/// "4N1tEhnO": { -/// "CAN": 22 -/// } -///} -/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. -/// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch -#[utoipa::path( - context_path = "/analytics", - tag = "analytics", - params( - ("project_ids" = Option, Query), - ("start_date" = Option, Query), - ("end_date" = Option, Query), - ("resolution_minutes" = Option, Query) - ), - responses((status = OK, body = HashMap>)), -)] -#[get("/countries/downloads")] -pub async fn countries_downloads_get( - req: HttpRequest, - clickhouse: web::Data, - data: web::Query, - session_queue: web::Data, - pool: web::Data, - redis: web::Data, -) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::ANALYTICS, - ) - .await - .map(|x| x.1) - .wrap_auth_err("authenticating API request")?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose() - .wrap_request_err("deserializing JSON data")?; - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let project_ids = - filter_allowed_ids(project_ids, user, &pool, &redis, None) - .await - .wrap_api_err( - "filtering authorized download-country project IDs", - )?; - - // Get the countries - let countries = crate::clickhouse::fetch_countries_downloads( - project_ids.unwrap_or_default(), - start_date, - end_date, - clickhouse.into_inner(), - ) - .await - .wrap_api_err("fetching download countries from ClickHouse")?; - - let mut hm = HashMap::new(); - for views in countries { - let id_string = to_base62(views.id); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(views.country, views.total); - } - } - - let hm: HashMap> = hm - .into_iter() - .map(|(key, value)| (key, condense_countries(value))) - .collect(); - - Ok(HttpResponse::Ok().json(hm)) -} - -/// Get view country data. -/// -/// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to views. -/// Unknown countries are labeled "". -/// This is usable to see significant performing countries per project -/// eg: -/// { -/// "4N1tEhnO": { -/// "CAN": 56165 -/// } -///} -/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. -/// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch -#[utoipa::path( - context_path = "/analytics", - tag = "analytics", - params( - ("project_ids" = Option, Query), - ("start_date" = Option, Query), - ("end_date" = Option, Query), - ("resolution_minutes" = Option, Query) - ), - responses((status = OK, body = HashMap>)), -)] -#[get("/countries/views")] -pub async fn countries_views_get( - req: HttpRequest, - clickhouse: web::Data, - data: web::Query, - session_queue: web::Data, - pool: web::Data, - redis: web::Data, -) -> Result { - let user = get_user_from_headers( - &req, - &**pool, - &redis, - &session_queue, - Scopes::ANALYTICS, - ) - .await - .map(|x| x.1) - .wrap_auth_err("authenticating API request")?; - - let project_ids = data - .project_ids - .as_ref() - .map(|ids| serde_json::from_str::>(ids)) - .transpose() - .wrap_request_err("deserializing JSON data")?; - - let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); - let end_date = data.end_date.unwrap_or(Utc::now()); - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - // - If no project_ids or version_ids are provided, we default to all projects the user has access to - let project_ids = - filter_allowed_ids(project_ids, user, &pool, &redis, None) - .await - .wrap_api_err("filtering authorized view-country project IDs")?; - - // Get the countries - let countries = crate::clickhouse::fetch_countries_views( - project_ids.unwrap_or_default(), - start_date, - end_date, - clickhouse.into_inner(), - ) - .await - .wrap_api_err("fetching view countries from ClickHouse")?; - - let mut hm = HashMap::new(); - for views in countries { - let id_string = to_base62(views.id); - if !hm.contains_key(&id_string) { - hm.insert(id_string.clone(), HashMap::new()); - } - if let Some(hm) = hm.get_mut(&id_string) { - hm.insert(views.country, views.total); - } - } - - let hm: HashMap> = hm - .into_iter() - .map(|(key, value)| (key, condense_countries(value))) - .collect(); - - Ok(HttpResponse::Ok().json(hm)) -} - -fn condense_countries(countries: HashMap) -> HashMap { - // Every country under '15' (view or downloads) should be condensed into 'XX' - let mut hm = HashMap::new(); - for (mut country, count) in countries { - if count < 50 { - country = "XX".to_string(); - } - if !hm.contains_key(&country) { - hm.insert(country.to_string(), 0); - } - if let Some(hm) = hm.get_mut(&country) { - *hm += count; - } - } - hm -} - -async fn filter_allowed_ids( - mut project_ids: Option>, - user: crate::models::users::User, - pool: &web::Data, - redis: &RedisPool, - remove_defaults: Option, -) -> Result>, ApiError> { - // If no project_ids or version_ids are provided, we default to all projects the user has *public* access to - if project_ids.is_none() && !remove_defaults.unwrap_or(false) { - project_ids = Some( - user_item::DBUser::get_projects(user.id.into(), &***pool, redis) - .await - .wrap_internal_err("deleting user from database")? - .into_iter() - .map(|x| ProjectId::from(x).to_string()) - .collect(), - ); - } - - // Convert String list to list of ProjectIds or VersionIds - // - Filter out unauthorized projects/versions - let project_ids = if let Some(project_strings) = project_ids { - let projects_data = database::models::DBProject::get_many( - &project_strings, - &***pool, - redis, - ) - .await - .wrap_api_err("fetching analytics projects")?; - - let team_ids = projects_data - .iter() - .map(|x| x.inner.team_id) - .collect::>(); - let team_members = - database::models::DBTeamMember::get_from_team_full_many( - &team_ids, &***pool, redis, - ) - .await - .wrap_internal_err("fetching team members from database")?; - - let organization_ids = projects_data - .iter() - .filter_map(|x| x.inner.organization_id) - .collect::>(); - let organizations = database::models::DBOrganization::get_many_ids( - &organization_ids, - &***pool, - redis, - ) - .await - .wrap_internal_err("fetching organizations from database")?; - - let organization_team_ids = organizations - .iter() - .map(|x| x.team_id) - .collect::>(); - let organization_team_members = - database::models::DBTeamMember::get_from_team_full_many( - &organization_team_ids, - &***pool, - redis, - ) - .await - .wrap_internal_err("fetching team members from database")?; - - let ids = projects_data - .into_iter() - .filter(|project| { - let team_member = team_members.iter().find(|x| { - x.team_id == project.inner.team_id - && x.user_id == user.id.into() - }); - - let organization = project - .inner - .organization_id - .and_then(|oid| organizations.iter().find(|x| x.id == oid)); - - let organization_team_member = - if let Some(organization) = organization { - organization_team_members.iter().find(|x| { - x.team_id == organization.team_id - && x.user_id == user.id.into() - }) - } else { - None - }; - - let permissions = ProjectPermissions::get_permissions_by_role( - &user.role, - &team_member.cloned(), - &organization_team_member.cloned(), - ) - .unwrap_or_default(); - - permissions.contains(ProjectPermissions::VIEW_ANALYTICS) - }) - .map(|x| x.inner.id.into()) - .collect::>(); - - Some(ids) - } else { - None - }; - // Only one of project_ids or version_ids will be Some - Ok(project_ids) -} diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 1b3d952881..46f6dba5aa 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -86,12 +86,6 @@ pub fn config(cfg: &mut web::ServiceConfig) { analytics_event::analytics_events_get, analytics_get::fetch_analytics, analytics_get::facets::fetch_facets, - analytics_get::old::playtimes_get, - analytics_get::old::views_get, - analytics_get::old::downloads_get, - analytics_get::old::revenue_get, - analytics_get::old::countries_downloads_get, - analytics_get::old::countries_views_get, payouts::post_compliance_form, payouts::calculate_fees, payouts::create_payout, diff --git a/apps/labrinth/src/test/api_v3/project.rs b/apps/labrinth/src/test/api_v3/project.rs index 2899bd0954..01eb43b14b 100644 --- a/apps/labrinth/src/test/api_v3/project.rs +++ b/apps/labrinth/src/test/api_v3/project.rs @@ -12,8 +12,7 @@ use actix_web::{ }; use async_trait::async_trait; use bytes::Bytes; -use chrono::{DateTime, Utc}; -use rust_decimal::Decimal; + use serde_json::json; use crate::test::asserts::assert_status; @@ -565,74 +564,4 @@ impl ApiV3 { assert_status!(&resp, StatusCode::OK); test::read_body_json(resp).await } - - pub async fn get_analytics_revenue( - &self, - id_or_slugs: Vec<&str>, - ids_are_version_ids: bool, - start_date: Option>, - end_date: Option>, - resolution_minutes: Option, - pat: Option<&str>, - ) -> ServiceResponse { - let pv_string = if ids_are_version_ids { - let version_string: String = - serde_json::to_string(&id_or_slugs).unwrap(); - let version_string = urlencoding::encode(&version_string); - format!("version_ids={version_string}") - } else { - let projects_string: String = - serde_json::to_string(&id_or_slugs).unwrap(); - let projects_string = urlencoding::encode(&projects_string); - format!("project_ids={projects_string}") - }; - - let mut extra_args = String::new(); - if let Some(start_date) = start_date { - let start_date = start_date.to_rfc3339(); - // let start_date = serde_json::to_string(&start_date).unwrap(); - let start_date = urlencoding::encode(&start_date); - write!(&mut extra_args, "&start_date={start_date}").unwrap(); - } - if let Some(end_date) = end_date { - let end_date = end_date.to_rfc3339(); - // let end_date = serde_json::to_string(&end_date).unwrap(); - let end_date = urlencoding::encode(&end_date); - write!(&mut extra_args, "&end_date={end_date}").unwrap(); - } - if let Some(resolution_minutes) = resolution_minutes { - write!(&mut extra_args, "&resolution_minutes={resolution_minutes}") - .unwrap(); - } - - let req = test::TestRequest::get() - .uri(&format!("/v3/analytics/revenue?{pv_string}{extra_args}",)) - .append_pat(pat) - .to_request(); - - self.call(req).await - } - - pub async fn get_analytics_revenue_deserialized( - &self, - id_or_slugs: Vec<&str>, - ids_are_version_ids: bool, - start_date: Option>, - end_date: Option>, - resolution_minutes: Option, - pat: Option<&str>, - ) -> HashMap> { - let resp = self - .get_analytics_revenue( - id_or_slugs, - ids_are_version_ids, - start_date, - end_date, - resolution_minutes, - pat, - ) - .await; - assert_status!(&resp, StatusCode::OK); - test::read_body_json(resp).await - } } diff --git a/apps/labrinth/tests/analytics.rs b/apps/labrinth/tests/analytics.rs deleted file mode 100644 index 217f0e7b15..0000000000 --- a/apps/labrinth/tests/analytics.rs +++ /dev/null @@ -1,247 +0,0 @@ -use ariadne::ids::base62_impl::parse_base62; -use chrono::{DateTime, Duration, Utc}; -use common::permissions::PermissionsTest; -use common::permissions::PermissionsTestContext; -use common::{ - api_v3::ApiV3, - database::*, - environment::{TestEnvironment, with_test_environment}, -}; -use itertools::Itertools; -use labrinth::models::teams::ProjectPermissions; -use labrinth::queue::payouts; -use rust_decimal::{Decimal, prelude::ToPrimitive}; - -pub mod common; - -#[actix_rt::test] -pub async fn analytics_revenue() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let api = &test_env.api; - - let alpha_project_id = - test_env.dummy.project_alpha.project_id.clone(); - - let pool = test_env.db.pool.clone(); - - // Generate sample revenue data- directly insert into sql - let ( - mut insert_user_ids, - mut insert_project_ids, - mut insert_payouts, - mut insert_starts, - mut insert_availables, - ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); - - // Note: these go from most recent to least recent - let money_time_pairs: [(f64, DateTime); 10] = [ - (50.0, Utc::now() - Duration::minutes(5)), - (50.1, Utc::now() - Duration::minutes(10)), - (101.0, Utc::now() - Duration::days(1)), - (200.0, Utc::now() - Duration::days(2)), - (311.0, Utc::now() - Duration::days(3)), - (400.0, Utc::now() - Duration::days(4)), - (526.0, Utc::now() - Duration::days(5)), - (633.0, Utc::now() - Duration::days(6)), - (800.0, Utc::now() - Duration::days(14)), - (800.0, Utc::now() - Duration::days(800)), - ]; - - let project_id = parse_base62(&alpha_project_id).unwrap() as i64; - for (money, time) in &money_time_pairs { - insert_user_ids.push(USER_USER_ID_PARSED); - insert_project_ids.push(project_id); - insert_payouts.push(Decimal::from_f64_retain(*money).unwrap()); - insert_starts.push(*time); - insert_availables.push(*time); - } - - let mut transaction = pool.begin().await.unwrap(); - payouts::insert_payouts( - insert_user_ids, - insert_project_ids, - insert_payouts, - insert_starts, - insert_availables, - &mut transaction, - ) - .await - .unwrap(); - transaction.commit().await.unwrap(); - - let day = 86400; - - // Test analytics endpoint with default values - // - all time points in the last 2 weeks - // - 1 day resolution - let analytics = api - .get_analytics_revenue_deserialized( - vec![&alpha_project_id], - false, - None, - None, - None, - USER_USER_PAT, - ) - .await; - assert_eq!(analytics.len(), 1); // 1 project - let project_analytics = &analytics[&alpha_project_id]; - assert_eq!(project_analytics.len(), 8); // 1 days cut off, and 2 points take place on the same day. note that the day exactly 14 days ago is included - // sorted_by_key, values in the order of smallest to largest key - let (sorted_keys, sorted_by_key): (Vec, Vec) = - project_analytics - .iter() - .sorted_by_key(|(k, _)| *k) - .rev() - .unzip(); - assert_eq!( - vec![100.1, 101.0, 200.0, 311.0, 400.0, 526.0, 633.0, 800.0], - to_f64_vec_rounded_up(sorted_by_key) - ); - // Ensure that the keys are in multiples of 1 day - for k in sorted_keys { - assert_eq!(k % day, 0); - } - - // Test analytics with last 900 days to include all data - // keep resolution at default - let analytics = api - .get_analytics_revenue_deserialized( - vec![&alpha_project_id], - false, - Some(Utc::now() - Duration::days(801)), - None, - None, - USER_USER_PAT, - ) - .await; - let project_analytics = &analytics[&alpha_project_id]; - assert_eq!(project_analytics.len(), 9); // and 2 points take place on the same day - let (sorted_keys, sorted_by_key): (Vec, Vec) = - project_analytics - .iter() - .sorted_by_key(|(k, _)| *k) - .rev() - .unzip(); - assert_eq!( - vec![ - 100.1, 101.0, 200.0, 311.0, 400.0, 526.0, 633.0, 800.0, - 800.0 - ], - to_f64_vec_rounded_up(sorted_by_key) - ); - for k in sorted_keys { - assert_eq!(k % day, 0); - } - }, - ) - .await; -} - -fn to_f64_rounded_up(d: Decimal) -> f64 { - d.round_dp_with_strategy( - 1, - rust_decimal::RoundingStrategy::MidpointAwayFromZero, - ) - .to_f64() - .unwrap() -} - -fn to_f64_vec_rounded_up(d: Vec) -> Vec { - d.into_iter().map(to_f64_rounded_up).collect_vec() -} - -#[actix_rt::test] -pub async fn permissions_analytics_revenue() { - with_test_environment( - None, - |test_env: TestEnvironment| async move { - let alpha_project_id = - test_env.dummy.project_alpha.project_id.clone(); - let alpha_version_id = - test_env.dummy.project_alpha.version_id.clone(); - let alpha_team_id = test_env.dummy.project_alpha.team_id.clone(); - - let api = &test_env.api; - - let view_analytics = ProjectPermissions::VIEW_ANALYTICS; - - // first, do check with a project - let req_gen = |ctx: PermissionsTestContext| async move { - let project_id = ctx.project_id.unwrap(); - let ids_or_slugs = vec![project_id.as_str()]; - api.get_analytics_revenue( - ids_or_slugs, - false, - None, - None, - Some(5), - ctx.test_pat.as_deref(), - ) - .await - }; - - PermissionsTest::new(&test_env) - .with_failure_codes(vec![200, 401]) - .with_200_json_checks( - // On failure, should have 0 projects returned - |value: &serde_json::Value| { - let value = value.as_object().unwrap(); - assert_eq!(value.len(), 0); - }, - // On success, should have 1 project returned - |value: &serde_json::Value| { - let value = value.as_object().unwrap(); - assert_eq!(value.len(), 1); - }, - ) - .simple_project_permissions_test(view_analytics, req_gen) - .await - .unwrap(); - - // Now with a version - // Need to use alpha - let req_gen = |ctx: PermissionsTestContext| { - let alpha_version_id = alpha_version_id.clone(); - async move { - let ids_or_slugs = vec![alpha_version_id.as_str()]; - api.get_analytics_revenue( - ids_or_slugs, - true, - None, - None, - Some(5), - ctx.test_pat.as_deref(), - ) - .await - } - }; - - PermissionsTest::new(&test_env) - .with_failure_codes(vec![200, 401]) - .with_existing_project(&alpha_project_id, &alpha_team_id) - .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) - .with_200_json_checks( - // On failure, should have 0 versions returned - |value: &serde_json::Value| { - let value = value.as_object().unwrap(); - assert_eq!(value.len(), 0); - }, - // On success, should have 1 versions returned - |value: &serde_json::Value| { - let value = value.as_object().unwrap(); - assert_eq!(value.len(), 0); - }, - ) - .simple_project_permissions_test(view_analytics, req_gen) - .await - .unwrap(); - - // Cleanup test db - test_env.cleanup().await; - }, - ) - .await; -} diff --git a/scripts/seed-db.sh b/scripts/seed-db.sh new file mode 100755 index 0000000000..13b5305dc6 --- /dev/null +++ b/scripts/seed-db.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +source .env +psql $DATABASE_URL < fixtures/labrinth-seed-data-202508052143.sql