From 89f1ddf4d7b653d9f68f49a79371e6fc17ca4b8b Mon Sep 17 00:00:00 2001 From: Geometrically <18202329+Geometrically@users.noreply.github.com> Date: Mon, 30 Oct 2023 16:59:53 -0700 Subject: [PATCH 1/3] Route to view user's orgs (#742) --- ...7148618caa3be7cf33bc0859e51b06eede6e9.json | 22 +++++ ...f285ef8cb2bf8f40f9facafaae3f8c75d587.json} | 4 +- src/database/models/organization_item.rs | 2 +- src/database/models/user_item.rs | 27 +++++- src/routes/v2/organizations.rs | 7 +- src/routes/v2/project_creation.rs | 7 +- src/routes/v2/users.rs | 85 +++++++++++++++++++ 7 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 .sqlx/query-1d356243ac743720af11e6a49d17148618caa3be7cf33bc0859e51b06eede6e9.json rename .sqlx/{query-05047ef3c49f2b90f5d090f69f8e7f626843d9487d5e63a28e8efe28e27cb9ad.json => query-30307fb92fd2d8e1f03f21f8ad76f285ef8cb2bf8f40f9facafaae3f8c75d587.json} (82%) diff --git a/.sqlx/query-1d356243ac743720af11e6a49d17148618caa3be7cf33bc0859e51b06eede6e9.json b/.sqlx/query-1d356243ac743720af11e6a49d17148618caa3be7cf33bc0859e51b06eede6e9.json new file mode 100644 index 00000000..23b9b12a --- /dev/null +++ b/.sqlx/query-1d356243ac743720af11e6a49d17148618caa3be7cf33bc0859e51b06eede6e9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT o.id FROM organizations o\n INNER JOIN team_members tm ON tm.team_id = o.team_id AND tm.accepted = TRUE\n WHERE tm.user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1d356243ac743720af11e6a49d17148618caa3be7cf33bc0859e51b06eede6e9" +} diff --git a/.sqlx/query-05047ef3c49f2b90f5d090f69f8e7f626843d9487d5e63a28e8efe28e27cb9ad.json b/.sqlx/query-30307fb92fd2d8e1f03f21f8ad76f285ef8cb2bf8f40f9facafaae3f8c75d587.json similarity index 82% rename from .sqlx/query-05047ef3c49f2b90f5d090f69f8e7f626843d9487d5e63a28e8efe28e27cb9ad.json rename to .sqlx/query-30307fb92fd2d8e1f03f21f8ad76f285ef8cb2bf8f40f9facafaae3f8c75d587.json index 049047c4..0532f326 100644 --- a/.sqlx/query-05047ef3c49f2b90f5d090f69f8e7f626843d9487d5e63a28e8efe28e27cb9ad.json +++ b/.sqlx/query-30307fb92fd2d8e1f03f21f8ad76f285ef8cb2bf8f40f9facafaae3f8c75d587.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT o.id, o.title, o.team_id, o.description, o.icon_url, o.color\n FROM organizations o\n WHERE o.id = ANY($1) OR o.title = ANY($2)\n GROUP BY o.id;\n ", + "query": "\n SELECT o.id, o.title, o.team_id, o.description, o.icon_url, o.color\n FROM organizations o\n WHERE o.id = ANY($1) OR LOWER(o.title) = ANY($2)\n GROUP BY o.id;\n ", "describe": { "columns": [ { @@ -49,5 +49,5 @@ true ] }, - "hash": "05047ef3c49f2b90f5d090f69f8e7f626843d9487d5e63a28e8efe28e27cb9ad" + "hash": "30307fb92fd2d8e1f03f21f8ad76f285ef8cb2bf8f40f9facafaae3f8c75d587" } diff --git a/src/database/models/organization_item.rs b/src/database/models/organization_item.rs index fe50ccc7..f92622df 100644 --- a/src/database/models/organization_item.rs +++ b/src/database/models/organization_item.rs @@ -162,7 +162,7 @@ impl Organization { " SELECT o.id, o.title, o.team_id, o.description, o.icon_url, o.color FROM organizations o - WHERE o.id = ANY($1) OR o.title = ANY($2) + WHERE o.id = ANY($1) OR LOWER(o.title) = ANY($2) GROUP BY o.id; ", &organization_ids_parsed, diff --git a/src/database/models/user_item.rs b/src/database/models/user_item.rs index 436750ed..5ab27abe 100644 --- a/src/database/models/user_item.rs +++ b/src/database/models/user_item.rs @@ -1,6 +1,6 @@ use super::ids::{ProjectId, UserId}; use super::CollectionId; -use crate::database::models::DatabaseError; +use crate::database::models::{DatabaseError, OrganizationId}; use crate::database::redis::RedisPool; use crate::models::ids::base62_impl::{parse_base62, to_base62}; use crate::models::users::{Badges, RecipientStatus}; @@ -307,6 +307,31 @@ impl User { Ok(db_projects) } + pub async fn get_organizations<'a, E>( + user_id: UserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + use futures::stream::TryStreamExt; + + let orgs = sqlx::query!( + " + SELECT o.id FROM organizations o + INNER JOIN team_members tm ON tm.team_id = o.team_id AND tm.accepted = TRUE + WHERE tm.user_id = $1 + ", + user_id as UserId, + ) + .fetch_many(exec) + .try_filter_map(|e| async { Ok(e.right().map(|m| OrganizationId(m.id))) }) + .try_collect::>() + .await?; + + Ok(orgs) + } + pub async fn get_collections<'a, E>( user_id: UserId, exec: E, diff --git a/src/routes/v2/organizations.rs b/src/routes/v2/organizations.rs index d4c8a056..e4dc5f07 100644 --- a/src/routes/v2/organizations.rs +++ b/src/routes/v2/organizations.rs @@ -75,8 +75,7 @@ pub async fn organization_create( let mut transaction = pool.begin().await?; // Try title - let title_organization_id_option: Option = - serde_json::from_str(&format!("\"{}\"", new_organization.title)).ok(); + let title_organization_id_option: Option = parse_base62(&new_organization.title).ok(); let mut organization_strings = vec![]; if let Some(title_organization_id) = title_organization_id_option { organization_strings.push(title_organization_id.to_string()); @@ -93,7 +92,7 @@ pub async fn organization_create( let team = team_item::TeamBuilder { members: vec![team_item::TeamMemberBuilder { user_id: current_user.id.into(), - role: crate::models::teams::OWNER_ROLE.to_owned(), + role: models::teams::OWNER_ROLE.to_owned(), permissions: ProjectPermissions::all(), organization_permissions: Some(OrganizationPermissions::all()), accepted: true, @@ -218,7 +217,7 @@ pub async fn organizations_get( .collect::>(); let teams_data = TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; - let users = crate::database::models::User::get_many_ids( + let users = database::models::User::get_many_ids( &teams_data.iter().map(|x| x.user_id).collect::>(), &**pool, &redis, diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 91db4d0c..21c49ed5 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -5,6 +5,7 @@ use crate::database::models::{self, image_item, User}; use crate::database::redis::RedisPool; use crate::file_hosting::{FileHost, FileHostingError}; use crate::models::error::ApiError; +use crate::models::ids::base62_impl::parse_base62; use crate::models::ids::ImageId; use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; @@ -417,16 +418,14 @@ async fn project_create_inner( .validate() .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; - let slug_project_id_option: Option = - serde_json::from_str(&format!("\"{}\"", create_data.slug)).ok(); + let slug_project_id_option: Option = parse_base62(&create_data.slug).ok(); if let Some(slug_project_id) = slug_project_id_option { - let slug_project_id: models::ids::ProjectId = slug_project_id.into(); let results = sqlx::query!( " SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) ", - slug_project_id as models::ids::ProjectId + slug_project_id as i64 ) .fetch_one(&mut **transaction) .await diff --git a/src/routes/v2/users.rs b/src/routes/v2/users.rs index 0ad8b512..bda8ccc2 100644 --- a/src/routes/v2/users.rs +++ b/src/routes/v2/users.rs @@ -21,6 +21,7 @@ use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::PgPool; +use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; use validator::Validate; @@ -32,6 +33,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("user") .service(user_get) + .service(orgs_list) .service(projects_list) .service(collections_list) .service(user_delete) @@ -196,6 +198,89 @@ pub async fn collections_list( } } +#[get("{user_id}/organizatons")] +pub async fn orgs_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + let org_data = User::get_organizations(id, &**pool).await?; + + let organizations_data = + crate::database::models::organization_item::Organization::get_many_ids( + &org_data, &**pool, &redis, + ) + .await?; + + let team_ids = organizations_data + .iter() + .map(|x| x.team_id) + .collect::>(); + + let teams_data = crate::database::models::TeamMember::get_from_team_full_many( + &team_ids, &**pool, &redis, + ) + .await?; + let users = User::get_many_ids( + &teams_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let mut organizations = vec![]; + let mut team_groups = HashMap::new(); + for item in teams_data { + team_groups.entry(item.team_id).or_insert(vec![]).push(item); + } + + for data in organizations_data { + let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]); + let logged_in = user + .as_ref() + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| logged_in || x.accepted || id == x.user_id) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from(data, user.clone(), !logged_in) + }) + }) + .collect(); + + let organization = crate::models::organizations::Organization::from(data, team_members); + organizations.push(organization); + } + + Ok(HttpResponse::Ok().json(organizations)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + lazy_static! { static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap(); } From d5594b03e38ac5c1f77313172a827ef0b374c72d Mon Sep 17 00:00:00 2001 From: Jackson Kruger Date: Tue, 31 Oct 2023 10:58:05 -0500 Subject: [PATCH 2/3] Fix organizations route typo (#743) --- src/routes/v2/users.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/v2/users.rs b/src/routes/v2/users.rs index bda8ccc2..250a3a80 100644 --- a/src/routes/v2/users.rs +++ b/src/routes/v2/users.rs @@ -198,7 +198,7 @@ pub async fn collections_list( } } -#[get("{user_id}/organizatons")] +#[get("{user_id}/organizations")] pub async fn orgs_list( req: HttpRequest, info: web::Path<(String,)>, From 911d4423404658b1705698072d33ca69ad7c163c Mon Sep 17 00:00:00 2001 From: Jackson Kruger Date: Wed, 1 Nov 2023 11:36:39 -0500 Subject: [PATCH 3/3] Version ordering [MOD-551] (#740) * Version ordering * cargo sqlx prepare * Use version ordering for maven * Use version ordering when sorting versions in Rust (not just SQL) * Thanks clippy --- ...9e41fbe8fcd836e926daf3e73aa3bb5552a6.json} | 18 ++- ...592d694ab2d80ac0f132c5b9bc42603f336c6.json | 15 +++ ...a010dc297425883113565d934b8a834014ce.json} | 7 +- ...3a023a96a860270a2f2afcdd48087e441dad.json} | 4 +- .../20231027195838_version_ordering.sql | 1 + src/database/models/version_item.rs | 104 +++++++++++++-- src/models/projects.rs | 5 +- src/routes/maven.rs | 2 +- src/routes/v2/project_creation.rs | 1 + src/routes/v2/version_creation.rs | 5 + src/routes/v2/version_file.rs | 6 +- src/routes/v2/versions.rs | 19 ++- tests/common/api_v2/mod.rs | 1 + tests/common/api_v2/version.rs | 91 +++++++++++++ tests/common/asserts.rs | 14 ++ tests/version.rs | 126 ++++++++++++++++++ 16 files changed, 392 insertions(+), 27 deletions(-) rename .sqlx/{query-a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39.json => query-1e735a003ce305624ce8bbf181c99e41fbe8fcd836e926daf3e73aa3bb5552a6.json} (56%) create mode 100644 .sqlx/query-54c6b31858b7bf383f9b7118583592d694ab2d80ac0f132c5b9bc42603f336c6.json rename .sqlx/{query-df871bd959ba97f105ac575f34d8d2a39cbc44a07e0339750a0e477e6fd582ed.json => query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json} (62%) rename .sqlx/{query-f3d7eb1b62f0b978787dba1132308d070d28911d6ddc380cedfa16e7baa3243a.json => query-defc616ab6e602d87695371761563a023a96a860270a2f2afcdd48087e441dad.json} (80%) create mode 100644 migrations/20231027195838_version_ordering.sql create mode 100644 tests/common/api_v2/version.rs create mode 100644 tests/version.rs diff --git a/.sqlx/query-a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39.json b/.sqlx/query-1e735a003ce305624ce8bbf181c99e41fbe8fcd836e926daf3e73aa3bb5552a6.json similarity index 56% rename from .sqlx/query-a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39.json rename to .sqlx/query-1e735a003ce305624ce8bbf181c99e41fbe8fcd836e926daf3e73aa3bb5552a6.json index ad89e886..bdf80942 100644 --- a/.sqlx/query-a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39.json +++ b/.sqlx/query-1e735a003ce305624ce8bbf181c99e41fbe8fcd836e926daf3e73aa3bb5552a6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status,\n JSONB_AGG(DISTINCT jsonb_build_object('version', gv.version, 'created', gv.created)) filter (where gv.version is not null) game_versions,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies\n FROM versions v\n LEFT OUTER JOIN game_versions_versions gvv on v.id = gvv.joining_version_id\n LEFT OUTER JOIN game_versions gv on gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.date_published ASC;\n ", + "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering,\n JSONB_AGG(DISTINCT jsonb_build_object('version', gv.version, 'created', gv.created)) filter (where gv.version is not null) game_versions,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'), 'file_id', h.file_id)) filter (where h.hash is not null) hashes,\n JSONB_AGG(DISTINCT jsonb_build_object('project_id', d.mod_dependency_id, 'version_id', d.dependency_id, 'dependency_type', d.dependency_type,'file_name', dependency_file_name)) filter (where d.dependency_type is not null) dependencies\n FROM versions v\n LEFT OUTER JOIN game_versions_versions gvv on v.id = gvv.joining_version_id\n LEFT OUTER JOIN game_versions gv on gvv.game_version_id = gv.id\n LEFT OUTER JOIN loaders_versions lv on v.id = lv.version_id\n LEFT OUTER JOIN loaders l on lv.loader_id = l.id\n LEFT OUTER JOIN files f on v.id = f.version_id\n LEFT OUTER JOIN hashes h on f.id = h.file_id\n LEFT OUTER JOIN dependencies d on v.id = d.dependent_id\n WHERE v.id = ANY($1)\n GROUP BY v.id\n ORDER BY v.ordering ASC NULLS LAST, v.date_published ASC;\n ", "describe": { "columns": [ { @@ -65,26 +65,31 @@ }, { "ordinal": 12, + "name": "ordering", + "type_info": "Int4" + }, + { + "ordinal": 13, "name": "game_versions", "type_info": "Jsonb" }, { - "ordinal": 13, + "ordinal": 14, "name": "loaders", "type_info": "VarcharArray" }, { - "ordinal": 14, + "ordinal": 15, "name": "files", "type_info": "Jsonb" }, { - "ordinal": 15, + "ordinal": 16, "name": "hashes", "type_info": "Jsonb" }, { - "ordinal": 16, + "ordinal": 17, "name": "dependencies", "type_info": "Jsonb" } @@ -107,6 +112,7 @@ false, false, true, + true, null, null, null, @@ -114,5 +120,5 @@ null ] }, - "hash": "a62767e812783e8836a11b22878a4248123f3fe212a876e192f549acd6edcb39" + "hash": "1e735a003ce305624ce8bbf181c99e41fbe8fcd836e926daf3e73aa3bb5552a6" } diff --git a/.sqlx/query-54c6b31858b7bf383f9b7118583592d694ab2d80ac0f132c5b9bc42603f336c6.json b/.sqlx/query-54c6b31858b7bf383f9b7118583592d694ab2d80ac0f132c5b9bc42603f336c6.json new file mode 100644 index 00000000..d29360d1 --- /dev/null +++ b/.sqlx/query-54c6b31858b7bf383f9b7118583592d694ab2d80ac0f132c5b9bc42603f336c6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET ordering = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "54c6b31858b7bf383f9b7118583592d694ab2d80ac0f132c5b9bc42603f336c6" +} diff --git a/.sqlx/query-df871bd959ba97f105ac575f34d8d2a39cbc44a07e0339750a0e477e6fd582ed.json b/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json similarity index 62% rename from .sqlx/query-df871bd959ba97f105ac575f34d8d2a39cbc44a07e0339750a0e477e6fd582ed.json rename to .sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json index 7768dbbc..10cba080 100644 --- a/.sqlx/query-df871bd959ba97f105ac575f34d8d2a39cbc44a07e0339750a0e477e6fd582ed.json +++ b/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11\n )\n ", + "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12\n )\n ", "describe": { "columns": [], "parameters": { @@ -15,10 +15,11 @@ "Int4", "Varchar", "Bool", - "Varchar" + "Varchar", + "Int4" ] }, "nullable": [] }, - "hash": "df871bd959ba97f105ac575f34d8d2a39cbc44a07e0339750a0e477e6fd582ed" + "hash": "a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce" } diff --git a/.sqlx/query-f3d7eb1b62f0b978787dba1132308d070d28911d6ddc380cedfa16e7baa3243a.json b/.sqlx/query-defc616ab6e602d87695371761563a023a96a860270a2f2afcdd48087e441dad.json similarity index 80% rename from .sqlx/query-f3d7eb1b62f0b978787dba1132308d070d28911d6ddc380cedfa16e7baa3243a.json rename to .sqlx/query-defc616ab6e602d87695371761563a023a96a860270a2f2afcdd48087e441dad.json index 4794dd9a..151a7fa3 100644 --- a/.sqlx/query-f3d7eb1b62f0b978787dba1132308d070d28911d6ddc380cedfa16e7baa3243a.json +++ b/.sqlx/query-defc616ab6e602d87695371761563a023a96a860270a2f2afcdd48087e441dad.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, version_number, version_type\n FROM versions\n WHERE mod_id = $1 AND status = ANY($2)\n ORDER BY date_published ASC\n ", + "query": "\n SELECT id, version_number, version_type\n FROM versions\n WHERE mod_id = $1 AND status = ANY($2)\n ORDER BY ordering ASC NULLS LAST, date_published ASC\n ", "describe": { "columns": [ { @@ -31,5 +31,5 @@ false ] }, - "hash": "f3d7eb1b62f0b978787dba1132308d070d28911d6ddc380cedfa16e7baa3243a" + "hash": "defc616ab6e602d87695371761563a023a96a860270a2f2afcdd48087e441dad" } diff --git a/migrations/20231027195838_version_ordering.sql b/migrations/20231027195838_version_ordering.sql new file mode 100644 index 00000000..873f2c1b --- /dev/null +++ b/migrations/20231027195838_version_ordering.sql @@ -0,0 +1 @@ +ALTER TABLE versions ADD COLUMN ordering int NULL; \ No newline at end of file diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 7814698f..565e3aae 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -28,6 +28,7 @@ pub struct VersionBuilder { pub featured: bool, pub status: VersionStatus, pub requested_status: Option, + pub ordering: Option, } #[derive(Clone)] @@ -214,6 +215,7 @@ impl VersionBuilder { version_type: self.version_type, status: self.status, requested_status: self.requested_status, + ordering: self.ordering, }; version.insert(transaction).await?; @@ -317,7 +319,7 @@ impl VersionVersion { } } -#[derive(Clone, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct Version { pub id: VersionId, pub project_id: ProjectId, @@ -332,6 +334,7 @@ pub struct Version { pub featured: bool, pub status: VersionStatus, pub requested_status: Option, + pub ordering: Option, } impl Version { @@ -344,12 +347,12 @@ impl Version { INSERT INTO versions ( id, mod_id, author_id, name, version_number, changelog, date_published, downloads, - version_type, featured, status + version_type, featured, status, ordering ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, - $9, $10, $11 + $9, $10, $11, $12 ) ", self.id as VersionId, @@ -362,7 +365,8 @@ impl Version { self.downloads, &self.version_type, self.featured, - self.status.as_str() + self.status.as_str(), + self.ordering ) .execute(&mut **transaction) .await?; @@ -554,7 +558,7 @@ impl Version { " SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number, v.changelog changelog, v.date_published date_published, v.downloads downloads, - v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, + v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering, JSONB_AGG(DISTINCT jsonb_build_object('version', gv.version, 'created', gv.created)) filter (where gv.version is not null) game_versions, ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, JSONB_AGG(DISTINCT jsonb_build_object('id', f.id, 'url', f.url, 'filename', f.filename, 'primary', f.is_primary, 'size', f.size, 'file_type', f.file_type)) filter (where f.id is not null) files, @@ -570,7 +574,7 @@ impl Version { LEFT OUTER JOIN dependencies d on v.id = d.dependent_id WHERE v.id = ANY($1) GROUP BY v.id - ORDER BY v.date_published ASC; + ORDER BY v.ordering ASC NULLS LAST, v.date_published ASC; ", &version_ids_parsed ) @@ -593,6 +597,7 @@ impl Version { status: VersionStatus::from_string(&v.status), requested_status: v.requested_status .map(|x| VersionStatus::from_string(&x)), + ordering: v.ordering, }, files: { #[derive(Deserialize)] @@ -851,7 +856,7 @@ impl Version { } } -#[derive(Clone, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct QueryVersion { pub inner: Version, @@ -861,7 +866,7 @@ pub struct QueryVersion { pub dependencies: Vec, } -#[derive(Clone, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct QueryDependency { pub project_id: Option, pub version_id: Option, @@ -869,7 +874,7 @@ pub struct QueryDependency { pub dependency_type: String, } -#[derive(Clone, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct QueryFile { pub id: FileId, pub url: String, @@ -892,3 +897,84 @@ pub struct SingleFile { pub size: u32, pub file_type: Option, } + +impl std::cmp::Ord for QueryVersion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.inner.cmp(&other.inner) + } +} + +impl std::cmp::PartialOrd for QueryVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::cmp::Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + let ordering_order = match (self.ordering, other.ordering) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => a.cmp(&b), + }; + + match ordering_order { + Ordering::Equal => self.date_published.cmp(&other.date_published), + ordering => ordering, + } + } +} + +impl std::cmp::PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(test)] +mod tests { + use chrono::Months; + + use super::*; + + #[test] + fn test_version_sorting() { + let versions = vec![ + get_version(4, None, months_ago(6)), + get_version(3, None, months_ago(7)), + get_version(2, Some(1), months_ago(6)), + get_version(1, Some(0), months_ago(4)), + get_version(0, Some(0), months_ago(5)), + ]; + + let sorted = versions.iter().cloned().sorted().collect_vec(); + + let expected_sorted_ids = vec![0, 1, 2, 3, 4]; + let actual_sorted_ids = sorted.iter().map(|v| v.id.0).collect_vec(); + assert_eq!(expected_sorted_ids, actual_sorted_ids); + } + + fn months_ago(months: u32) -> DateTime { + Utc::now().checked_sub_months(Months::new(months)).unwrap() + } + + fn get_version(id: i64, ordering: Option, date_published: DateTime) -> Version { + Version { + id: VersionId(id), + ordering, + date_published, + project_id: ProjectId(0), + author_id: UserId(0), + name: Default::default(), + version_number: Default::default(), + changelog: Default::default(), + changelog_url: Default::default(), + downloads: Default::default(), + version_type: Default::default(), + featured: Default::default(), + status: VersionStatus::Listed, + requested_status: Default::default(), + } + } +} diff --git a/src/models/projects.rs b/src/models/projects.rs index 63f48f11..51f1f675 100644 --- a/src/models/projects.rs +++ b/src/models/projects.rs @@ -491,6 +491,8 @@ pub struct Version { pub game_versions: Vec, /// The loaders that this version works on pub loaders: Vec, + /// Ordering override, lower is returned first + pub ordering: Option, } impl From for Version { @@ -515,6 +517,7 @@ impl From for Version { "alpha" => VersionType::Alpha, _ => VersionType::Release, }, + ordering: v.ordering, status: v.status, requested_status: v.requested_status, @@ -729,7 +732,7 @@ impl DependencyType { } } -#[derive(Serialize, Deserialize, Copy, Clone, Debug)] +#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum FileType { RequiredResourcePack, diff --git a/src/routes/maven.rs b/src/routes/maven.rs index d3e7a3c5..f719073a 100644 --- a/src/routes/maven.rs +++ b/src/routes/maven.rs @@ -100,7 +100,7 @@ pub async fn maven_metadata( SELECT id, version_number, version_type FROM versions WHERE mod_id = $1 AND status = ANY($2) - ORDER BY date_published ASC + ORDER BY ordering ASC NULLS LAST, date_published ASC ", project.inner.id as database::models::ids::ProjectId, &*crate::models::projects::VersionStatus::iterator() diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 21c49ed5..dadfc096 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -964,6 +964,7 @@ async fn create_initial_version( status: VersionStatus::Listed, version_type: version_data.release_channel.to_string(), requested_status: None, + ordering: version_data.ordering, }; Ok(version) diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 29e88ba9..e94284cf 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -76,6 +76,9 @@ pub struct InitialVersionData { #[validate(length(max = 10))] #[serde(default)] pub uploaded_images: Vec, + + // The ordering relative to other versions + pub ordering: Option, } #[derive(Serialize, Deserialize, Clone)] @@ -316,6 +319,7 @@ async fn version_create_inner( featured: version_create_data.featured, status: version_create_data.status, requested_status: None, + ordering: version_create_data.ordering, }); return Ok(()); @@ -427,6 +431,7 @@ async fn version_create_inner( version_type: version_data.release_channel, status: builder.status, requested_status: builder.requested_status, + ordering: builder.ordering, files: builder .files .iter() diff --git a/src/routes/v2/version_file.rs b/src/routes/v2/version_file.rs index 171788b1..5d98dc4a 100644 --- a/src/routes/v2/version_file.rs +++ b/src/routes/v2/version_file.rs @@ -323,7 +323,7 @@ pub async fn get_update_from_hash( bool }) - .sorted_by(|a, b| a.inner.date_published.cmp(&b.inner.date_published)) + .sorted() .collect::>(); if let Some(first) = versions.pop() { @@ -522,7 +522,7 @@ pub async fn update_files( bool }) - .sorted_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)) + .sorted() .next(); if let Some(version) = version { @@ -629,7 +629,7 @@ pub async fn update_individual_files( bool }) - .sorted_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)) + .sorted() .next(); if let Some(version) = version { diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 218706e9..44517a84 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -115,7 +115,7 @@ pub async fn version_list( .cloned() .collect::>(); - versions.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)); + versions.sort(); // Attempt to populate versions with "auto featured" versions if response.is_empty() && !versions.is_empty() && filters.featured.unwrap_or(false) { @@ -155,7 +155,7 @@ pub async fn version_list( } } - response.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published)); + response.sort(); response.dedup_by(|a, b| a.inner.id == b.inner.id); let response = filter_authorized_versions(response, &user_option, &pool).await?; @@ -306,6 +306,7 @@ pub struct EditVersion { pub downloads: Option, pub status: Option, pub file_types: Option>, + pub ordering: Option>, //TODO: How do you actually pass this in json? } #[derive(Serialize, Deserialize)] @@ -684,6 +685,20 @@ pub async fn version_edit( } } + if let Some(ordering) = &new_version.ordering { + sqlx::query!( + " + UPDATE versions + SET ordering = $1 + WHERE (id = $2) + ", + ordering.to_owned() as Option, + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + // delete any images no longer in the changelog let checkable_strings: Vec<&str> = vec![&new_version.changelog] .into_iter() diff --git a/tests/common/api_v2/mod.rs b/tests/common/api_v2/mod.rs index 2ecc144e..0f01b771 100644 --- a/tests/common/api_v2/mod.rs +++ b/tests/common/api_v2/mod.rs @@ -7,6 +7,7 @@ use std::rc::Rc; pub mod organization; pub mod project; pub mod team; +pub mod version; #[derive(Clone)] pub struct ApiV2 { diff --git a/tests/common/api_v2/version.rs b/tests/common/api_v2/version.rs new file mode 100644 index 00000000..306db36c --- /dev/null +++ b/tests/common/api_v2/version.rs @@ -0,0 +1,91 @@ +use actix_http::{header::AUTHORIZATION, StatusCode}; +use actix_web::{dev::ServiceResponse, test}; +use labrinth::models::projects::Version; +use serde_json::json; + +use crate::common::{self, actix::AppendsMultipart, asserts::assert_status}; + +use super::ApiV2; + +pub fn url_encode_json_serialized_vec(elements: &[String]) -> String { + let serialized = serde_json::to_string(&elements).unwrap(); + urlencoding::encode(&serialized).to_string() +} + +impl ApiV2 { + pub async fn create_default_version( + &self, + project_id: &str, + ordering: Option, + pat: &str, + ) -> Version { + let json_data = json!( + { + "project_id": project_id, + "file_parts": ["basic-mod-different.jar"], + "version_number": "1.2.3.4", + "version_title": "start", + "dependencies": [], + "game_versions": ["1.20.1"] , + "release_channel": "release", + "loaders": ["fabric"], + "featured": true, + "ordering": ordering, + } + ); + let json_segment = common::actix::MultipartSegment { + name: "data".to_string(), + filename: None, + content_type: Some("application/json".to_string()), + data: common::actix::MultipartSegmentData::Text( + serde_json::to_string(&json_data).unwrap(), + ), + }; + let file_segment = common::actix::MultipartSegment { + name: "basic-mod-different.jar".to_string(), + filename: Some("basic-mod.jar".to_string()), + content_type: Some("application/java-archive".to_string()), + data: common::actix::MultipartSegmentData::Binary( + include_bytes!("../../../tests/files/basic-mod-different.jar").to_vec(), + ), + }; + + let request = test::TestRequest::post() + .uri("/v2/version") + .set_multipart(vec![json_segment.clone(), file_segment.clone()]) + .append_header((AUTHORIZATION, pat)) + .to_request(); + let resp = self.call(request).await; + assert_status(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_versions(&self, version_ids: Vec, pat: &str) -> Vec { + let ids = url_encode_json_serialized_vec(&version_ids); + let request = test::TestRequest::get() + .uri(&format!("/v2/versions?ids={}", ids)) + .append_header((AUTHORIZATION, pat)) + .to_request(); + let resp = self.call(request).await; + assert_status(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn edit_version_ordering( + &self, + version_id: &str, + ordering: Option, + pat: &str, + ) -> ServiceResponse { + let request = test::TestRequest::patch() + .uri(&format!("/v2/version/{version_id}")) + .set_json(json!( + { + "ordering": ordering + } + )) + .append_header((AUTHORIZATION, pat)) + .to_request(); + self.call(request).await + } +} diff --git a/tests/common/asserts.rs b/tests/common/asserts.rs index 3c7f585a..97885b8a 100644 --- a/tests/common/asserts.rs +++ b/tests/common/asserts.rs @@ -1,9 +1,23 @@ #![allow(dead_code)] +use crate::common::get_json_val_str; +use itertools::Itertools; + pub fn assert_status(response: &actix_web::dev::ServiceResponse, status: actix_http::StatusCode) { assert_eq!(response.status(), status, "{:#?}", response.response()); } +pub fn assert_version_ids( + versions: &[labrinth::models::projects::Version], + expected_ids: Vec, +) { + let version_ids = versions + .iter() + .map(|v| get_json_val_str(v.id)) + .collect_vec(); + assert_eq!(version_ids, expected_ids); +} + pub fn assert_any_status_except( response: &actix_web::dev::ServiceResponse, status: actix_http::StatusCode, diff --git a/tests/version.rs b/tests/version.rs new file mode 100644 index 00000000..a1a6d84d --- /dev/null +++ b/tests/version.rs @@ -0,0 +1,126 @@ +use crate::common::{asserts::assert_status, get_json_val_str}; +use actix_http::StatusCode; +use common::{ + asserts::assert_version_ids, database::USER_USER_PAT, environment::with_test_environment, +}; + +mod common; + +#[actix_rt::test] +async fn can_create_version_with_ordering() { + with_test_environment(|env| async move { + let alpha_project_id = env.dummy.as_ref().unwrap().project_alpha.project_id.clone(); + + let new_version_id = get_json_val_str( + env.v2 + .create_default_version(&alpha_project_id, Some(1), USER_USER_PAT) + .await + .id, + ); + + let versions = env + .v2 + .get_versions(vec![new_version_id.clone()], USER_USER_PAT) + .await; + assert_eq!(versions[0].ordering, Some(1)); + }) + .await; +} + +#[actix_rt::test] +async fn edit_version_ordering_works() { + with_test_environment(|env| async move { + let alpha_version_id = env.dummy.as_ref().unwrap().project_alpha.version_id.clone(); + + let resp = env + .v2 + .edit_version_ordering(&alpha_version_id, Some(10), USER_USER_PAT) + .await; + assert_status(&resp, StatusCode::NO_CONTENT); + + let versions = env + .v2 + .get_versions(vec![alpha_version_id.clone()], USER_USER_PAT) + .await; + assert_eq!(versions[0].ordering, Some(10)); + }) + .await; +} + +#[actix_rt::test] +async fn version_ordering_for_specified_orderings_orders_lower_order_first() { + with_test_environment(|env| async move { + let alpha_project_id = env.dummy.as_ref().unwrap().project_alpha.project_id.clone(); + let alpha_version_id = env.dummy.as_ref().unwrap().project_alpha.version_id.clone(); + let new_version_id = get_json_val_str( + env.v2 + .create_default_version(&alpha_project_id, Some(1), USER_USER_PAT) + .await + .id, + ); + env.v2 + .edit_version_ordering(&alpha_version_id, Some(10), USER_USER_PAT) + .await; + + let versions = env + .v2 + .get_versions( + vec![alpha_version_id.clone(), new_version_id.clone()], + USER_USER_PAT, + ) + .await; + assert_version_ids(&versions, vec![new_version_id, alpha_version_id]); + }) + .await; +} + +#[actix_rt::test] +async fn version_ordering_when_unspecified_orders_oldest_first() { + with_test_environment(|env| async move { + let alpha_project_id = &env.dummy.as_ref().unwrap().project_alpha.project_id.clone(); + let alpha_version_id = env.dummy.as_ref().unwrap().project_alpha.version_id.clone(); + let new_version_id = get_json_val_str( + env.v2 + .create_default_version(&alpha_project_id, None, USER_USER_PAT) + .await + .id, + ); + + let versions = env + .v2 + .get_versions( + vec![alpha_version_id.clone(), new_version_id.clone()], + USER_USER_PAT, + ) + .await; + assert_version_ids(&versions, vec![alpha_version_id, new_version_id]); + }) + .await +} + +#[actix_rt::test] +async fn version_ordering_when_specified_orders_specified_before_unspecified() { + with_test_environment(|env| async move { + let alpha_project_id = &env.dummy.as_ref().unwrap().project_alpha.project_id.clone(); + let alpha_version_id = env.dummy.as_ref().unwrap().project_alpha.version_id.clone(); + let new_version_id = get_json_val_str( + env.v2 + .create_default_version(&alpha_project_id, Some(10000), USER_USER_PAT) + .await + .id, + ); + env.v2 + .edit_version_ordering(&alpha_version_id, None, USER_USER_PAT) + .await; + + let versions = env + .v2 + .get_versions( + vec![alpha_version_id.clone(), new_version_id.clone()], + USER_USER_PAT, + ) + .await; + assert_version_ids(&versions, vec![new_version_id, alpha_version_id]); + }) + .await; +}