From dffaa2235e8ca695c9db5b21243c5dc1754a8c57 Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 10:13:54 +0100 Subject: [PATCH 01/13] feat: Added migration to external table --- .../2024-11-19-090556_scaling_groups/down.sql | 18 ++++++++++++++++++ .../2024-11-19-090556_scaling_groups/up.sql | 14 ++++++++++++++ tasky/src/models/group.rs | 1 - tasky/src/schema.rs | 9 ++++++++- 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 tasky/migrations/2024-11-19-090556_scaling_groups/down.sql create mode 100644 tasky/migrations/2024-11-19-090556_scaling_groups/up.sql diff --git a/tasky/migrations/2024-11-19-090556_scaling_groups/down.sql b/tasky/migrations/2024-11-19-090556_scaling_groups/down.sql new file mode 100644 index 0000000..7c036fd --- /dev/null +++ b/tasky/migrations/2024-11-19-090556_scaling_groups/down.sql @@ -0,0 +1,18 @@ +ALTER TABLE groups +ADD COLUMN members INTEGER[] NOT NULL; -- Assuming members was an INTEGER array + +-- 2. Populate the members column by aggregating data from group_members +UPDATE groups +SET members = subquery.members_array +FROM ( + SELECT + group_id, + ARRAY_AGG(member_id) AS members_array + FROM + group_members + GROUP BY + group_id +) AS subquery +WHERE groups.id = subquery.group_id; + +DROP TABLE group_members; diff --git a/tasky/migrations/2024-11-19-090556_scaling_groups/up.sql b/tasky/migrations/2024-11-19-090556_scaling_groups/up.sql new file mode 100644 index 0000000..fa23216 --- /dev/null +++ b/tasky/migrations/2024-11-19-090556_scaling_groups/up.sql @@ -0,0 +1,14 @@ +CREATE TABLE group_members ( + group_id INTEGER NOT NULL, + member_id INTEGER NOT NULL, + PRIMARY KEY (group_id, member_id) +); + +INSERT INTO group_members (group_id, member_id) +SELECT + g.id AS group_id, + unnest(g.members) AS member_id +FROM + groups g; + +ALTER TABLE groups DROP COLUMN members; diff --git a/tasky/src/models/group.rs b/tasky/src/models/group.rs index edbd084..cd6d577 100644 --- a/tasky/src/models/group.rs +++ b/tasky/src/models/group.rs @@ -28,7 +28,6 @@ pub enum JoinRequestPolicy { pub struct Group { pub id: i32, pub title: String, - pub members: Vec>, pub tutor: i32, pub join_policy: JoinRequestPolicy, pub created_at: NaiveDateTime, diff --git a/tasky/src/schema.rs b/tasky/src/schema.rs index 7193333..f7682e2 100644 --- a/tasky/src/schema.rs +++ b/tasky/src/schema.rs @@ -73,6 +73,13 @@ diesel::table! { } } +diesel::table! { + group_members (group_id, member_id) { + group_id -> Int4, + member_id -> Int4, + } +} + diesel::table! { use diesel::sql_types::*; use super::sql_types::JoinRequestPolicy; @@ -80,7 +87,6 @@ diesel::table! { groups (id) { id -> Int4, title -> Varchar, - members -> Array>, tutor -> Int4, join_policy -> JoinRequestPolicy, created_at -> Timestamp, @@ -131,6 +137,7 @@ diesel::allow_tables_to_appear_in_same_query!( assignments, code_comments, group_join_requests, + group_members, groups, notifications, solutions, From caded6a32d1a0324b363c2c70cd12720b7b49092 Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 10:22:24 +0100 Subject: [PATCH 02/13] wip: Updated logic in repository --- .../2024-11-19-090556_scaling_groups/down.sql | 19 +------------------ .../2024-11-19-091626_groups_fix/down.sql | 1 + .../2024-11-19-091626_groups_fix/up.sql | 5 +++++ tasky/src/models/group.rs | 15 ++++++++++----- tasky/src/schema.rs | 1 + 5 files changed, 18 insertions(+), 23 deletions(-) create mode 100644 tasky/migrations/2024-11-19-091626_groups_fix/down.sql create mode 100644 tasky/migrations/2024-11-19-091626_groups_fix/up.sql diff --git a/tasky/migrations/2024-11-19-090556_scaling_groups/down.sql b/tasky/migrations/2024-11-19-090556_scaling_groups/down.sql index 7c036fd..d664f48 100644 --- a/tasky/migrations/2024-11-19-090556_scaling_groups/down.sql +++ b/tasky/migrations/2024-11-19-090556_scaling_groups/down.sql @@ -1,18 +1 @@ -ALTER TABLE groups -ADD COLUMN members INTEGER[] NOT NULL; -- Assuming members was an INTEGER array - --- 2. Populate the members column by aggregating data from group_members -UPDATE groups -SET members = subquery.members_array -FROM ( - SELECT - group_id, - ARRAY_AGG(member_id) AS members_array - FROM - group_members - GROUP BY - group_id -) AS subquery -WHERE groups.id = subquery.group_id; - -DROP TABLE group_members; +-- There should be no way back here diff --git a/tasky/migrations/2024-11-19-091626_groups_fix/down.sql b/tasky/migrations/2024-11-19-091626_groups_fix/down.sql new file mode 100644 index 0000000..d9a93fe --- /dev/null +++ b/tasky/migrations/2024-11-19-091626_groups_fix/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` diff --git a/tasky/migrations/2024-11-19-091626_groups_fix/up.sql b/tasky/migrations/2024-11-19-091626_groups_fix/up.sql new file mode 100644 index 0000000..174d36a --- /dev/null +++ b/tasky/migrations/2024-11-19-091626_groups_fix/up.sql @@ -0,0 +1,5 @@ +ALTER TABLE group_members +ADD CONSTRAINT fk_group_id +FOREIGN KEY (group_id) +REFERENCES groups(id) +ON DELETE CASCADE; diff --git a/tasky/src/models/group.rs b/tasky/src/models/group.rs index cd6d577..59018bd 100644 --- a/tasky/src/models/group.rs +++ b/tasky/src/models/group.rs @@ -1,8 +1,8 @@ use super::group_join_request::GroupJoinRequestRepository; use super::Paginate; use super::{PaginatedModel, DB}; -use crate::schema; use crate::schema::groups::dsl; +use crate::schema::{self, group_members}; use chrono::NaiveDateTime; use diesel::dsl::count_star; use diesel::pg::Pg; @@ -41,7 +41,6 @@ pub struct Group { pub struct CreateGroup { pub title: String, pub tutor: i32, - pub members: Vec, pub join_policy: JoinRequestPolicy, } @@ -89,11 +88,13 @@ impl GroupRepository { /// Gets all groups a user is not member or tutor of pub fn get_groups_for_member(member_id: i32, conn: &mut DB) -> Vec { dsl::groups + .left_join(group_members::table) .filter( dsl::tutor .eq(member_id) - .or(dsl::members.contains(vec![Some(member_id)])), + .or(group_members::member_id.eq(member_id)), ) + .select(Group::as_select()) .get_results::(conn) .expect("Cannot fetch groups for member") } @@ -105,11 +106,13 @@ impl GroupRepository { conn: &mut DB, ) -> PaginatedModel { let result = dsl::groups + .left_join(group_members::table) .filter( dsl::tutor .eq(member_id) - .or(dsl::members.contains(vec![Some(member_id)])), + .or(group_members::member_id.eq(member_id)), ) + .select(Group::as_select()) .group_by((dsl::id, dsl::verified)) .order(dsl::verified.desc()) .paginate(page) @@ -138,6 +141,7 @@ impl GroupRepository { .collect(); let total = dsl::groups + .left_join(group_members::table) .into_boxed() .filter(apply_search_filter( member_id, @@ -149,6 +153,7 @@ impl GroupRepository { .expect("Result cannot be fetched"); let results = dsl::groups + .left_join(group_members::table) .group_by((dsl::id, dsl::verified)) .into_boxed() .filter(apply_search_filter(member_id, requested, search)) @@ -188,7 +193,7 @@ fn apply_search_filter( let base_predicate = not(dsl::tutor .eq(member_id) .or(dsl::id.eq_any(requested)) - .or(dsl::members.contains(vec![Some(member_id)]))); + .or(group_members::member_id.eq(member_id))); let query: Box< dyn BoxableExpression, > = if let Some(ref search_value) = search { diff --git a/tasky/src/schema.rs b/tasky/src/schema.rs index f7682e2..4864373 100644 --- a/tasky/src/schema.rs +++ b/tasky/src/schema.rs @@ -129,6 +129,7 @@ diesel::joinable!(assignments -> groups (group_id)); diesel::joinable!(code_comments -> groups (group_id)); diesel::joinable!(code_comments -> solutions (solution_id)); diesel::joinable!(group_join_requests -> groups (group_id)); +diesel::joinable!(group_members -> groups (group_id)); diesel::joinable!(solutions -> assignments (assignment_id)); diesel::joinable!(solutions -> groups (group_id)); From 1fc224869d47828cba7ff1556e507994610ea67a Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 17:55:01 +0100 Subject: [PATCH 03/13] feat: Migrated whole user specific functionality to new schema --- tasky/src/models/assignment.rs | 6 +- tasky/src/models/code_comment.rs | 4 +- tasky/src/models/group.rs | 74 ++++++++++------------ tasky/src/models/group_join_request.rs | 2 +- tasky/src/models/group_member.rs | 86 ++++++++++++++++++++++++++ tasky/src/models/mod.rs | 1 + tasky/src/models/notification.rs | 11 +++- tasky/src/models/solution.rs | 6 +- tasky/src/response/group.rs | 28 ++------- tasky/src/response/grpc.rs | 5 +- tasky/src/routes/group.rs | 52 ++++++++-------- tasky/src/routes/group_join_request.rs | 24 +++++-- tasky/src/security/group.rs | 2 +- 13 files changed, 193 insertions(+), 108 deletions(-) create mode 100644 tasky/src/models/group_member.rs diff --git a/tasky/src/models/assignment.rs b/tasky/src/models/assignment.rs index e08cec0..2238269 100644 --- a/tasky/src/models/assignment.rs +++ b/tasky/src/models/assignment.rs @@ -1,6 +1,7 @@ use super::notification::NotificationRepository; use super::{Paginate, PaginatedModel, DB}; use crate::schema::assignments::dsl; +use crate::schema::{self, group_members}; use chrono::NaiveDateTime; use diesel::associations::HasTable; use diesel::dsl::not; @@ -166,7 +167,10 @@ impl AssignmentRepository { dsl::assignments .left_join(crate::schema::groups::table) .left_join(crate::schema::solutions::table) - .filter(crate::schema::groups::dsl::members.contains(vec![Some(student_id)])) + .left_join( + schema::group_members::table.on(schema::groups::id.eq(group_members::group_id)), + ) + .filter(group_members::dsl::member_id.eq(student_id)) .filter(not(crate::schema::solutions::dsl::submitter_id .eq(student_id) .and( diff --git a/tasky/src/models/code_comment.rs b/tasky/src/models/code_comment.rs index 744d634..4d990ab 100644 --- a/tasky/src/models/code_comment.rs +++ b/tasky/src/models/code_comment.rs @@ -44,8 +44,8 @@ impl CodeCommentRepository { let solution = SolutionRepository::get_solution_by_id(create.solution_id, conn).unwrap(); let group = GroupRepository::get_by_id(create.group_id, conn).unwrap(); let targeted_users = match solution.submitter_id == create.commentor { - true => vec![Some(group.tutor)], - false => vec![Some(solution.submitter_id)], + true => vec![group.tutor], + false => vec![solution.submitter_id], }; NotificationRepository::create_notification( &CreateNotification { diff --git a/tasky/src/models/group.rs b/tasky/src/models/group.rs index 59018bd..c6a0a2b 100644 --- a/tasky/src/models/group.rs +++ b/tasky/src/models/group.rs @@ -1,11 +1,10 @@ use super::group_join_request::GroupJoinRequestRepository; use super::Paginate; use super::{PaginatedModel, DB}; +use crate::schema::group_members; use crate::schema::groups::dsl; -use crate::schema::{self, group_members}; use chrono::NaiveDateTime; use diesel::dsl::count_star; -use diesel::pg::Pg; use diesel::prelude::*; use diesel::{associations::HasTable, dsl::not}; use serde::{Deserialize, Serialize}; @@ -140,27 +139,43 @@ impl GroupRepository { .map(|x| x.group_id) .collect(); - let total = dsl::groups - .left_join(group_members::table) - .into_boxed() - .filter(apply_search_filter( - member_id, - requested.clone(), - search.clone(), - )) - .select(count_star()) - .get_result::(conn) - .expect("Result cannot be fetched"); + let base_predicate = not(dsl::tutor + .eq(member_id) + .or(dsl::id.eq_any(requested)) + .or(group_members::dsl::member_id.eq(member_id))); - let results = dsl::groups - .left_join(group_members::table) + let total_base_query = dsl::groups + .left_join(group_members::dsl::group_members) + .select(count_star()) + .filter(base_predicate.clone()) + .into_boxed(); + + let total = match search.clone() { + None => total_base_query + .get_result::(conn) + .expect("Result cannot be fetched"), + Some(search_value) => total_base_query + .filter(dsl::title.like(format!("%{}%", search_value))) + .get_result::(conn) + .expect("Result cannot be fetched"), + }; + + let results_base_query = dsl::groups + .left_join(group_members::dsl::group_members) .group_by((dsl::id, dsl::verified)) - .into_boxed() - .filter(apply_search_filter(member_id, requested, search)) + .select(Group::as_select()) + .filter(base_predicate) .order(dsl::verified.desc()) .limit(50) .offset((page - 1) * 50) - .load::(conn); + .into_boxed(); + + let results = match search { + None => results_base_query.load::(conn), + Some(search_value) => results_base_query + .filter(dsl::title.like(format!("%{}%", search_value))) + .load::(conn), + }; if results.is_err() { return PaginatedModel { @@ -184,26 +199,3 @@ impl GroupRepository { .expect("Cannot delete group"); } } - -fn apply_search_filter( - member_id: i32, - requested: Vec, - search: Option, -) -> Box> { - let base_predicate = not(dsl::tutor - .eq(member_id) - .or(dsl::id.eq_any(requested)) - .or(group_members::member_id.eq(member_id))); - let query: Box< - dyn BoxableExpression, - > = if let Some(ref search_value) = search { - Box::new( - dsl::title - .like(format!("%{}%", search_value)) - .and(base_predicate), - ) - } else { - Box::new(base_predicate) - }; - query -} diff --git a/tasky/src/models/group_join_request.rs b/tasky/src/models/group_join_request.rs index d6196be..5f8d49c 100644 --- a/tasky/src/models/group_join_request.rs +++ b/tasky/src/models/group_join_request.rs @@ -102,7 +102,7 @@ impl GroupJoinRequestRepository { &CreateNotification { title: "Join request rejected".to_string(), content: "One of your join requests has been rejected".to_string(), - targeted_users: vec![Some(req.requestor)], + targeted_users: vec![req.requestor], }, conn, ); diff --git a/tasky/src/models/group_member.rs b/tasky/src/models/group_member.rs new file mode 100644 index 0000000..bc8666c --- /dev/null +++ b/tasky/src/models/group_member.rs @@ -0,0 +1,86 @@ +use crate::schema::group_members; +use diesel::prelude::*; +use diesel::OptionalExtension; +use diesel::{ + deserialize::Queryable, dsl::count_star, BoolExpressionMethods, ExpressionMethods, QueryDsl, + RunQueryDsl, Selectable, +}; + +use super::DB; + +#[derive(Queryable, Selectable, Clone, Insertable)] +#[diesel(primary_key(group_id, member_id))] +#[diesel(belongs_to(Group))] +#[diesel(table_name = group_members)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct GroupMember { + pub group_id: i32, + pub member_id: i32, +} + +pub struct GroupMemberRepository; + +impl GroupMemberRepository { + /// Inserts a new member into a group + pub fn insert_member(new: GroupMember, conn: &mut DB) { + diesel::insert_into(group_members::table) + .values(new) + .execute(conn) + .expect("Cannot insert new member"); + } + + /// Checks if a user is group member + pub fn is_member(group_id: i32, member_id: i32, conn: &mut DB) -> bool { + group_members::dsl::group_members + .filter(group_members::dsl::group_id.eq(group_id)) + .filter(group_members::dsl::member_id.eq(member_id)) + .first::(conn) + .optional() + .expect("Cannot load if user is group member") + .is_some() + } + + /// Unable to remove user membership + pub fn remove_membership(group_id: i32, user_id: i32, conn: &mut DB) { + diesel::delete( + group_members::dsl::group_members.filter( + group_members::dsl::group_id + .eq(group_id) + .and(group_members::dsl::member_id.eq(user_id)), + ), + ) + .execute(conn) + .expect("Cannot remove user membership"); + } + + /// Gets all enlisted users from selection + pub fn get_enlisted_from_selection( + group_id: i32, + selection: Vec, + conn: &mut DB, + ) -> Vec { + group_members::dsl::group_members + .filter(group_members::group_id.eq(group_id)) + .filter(group_members::member_id.eq_any(selection)) + .select(group_members::member_id) + .get_results::(conn) + .expect("Cannot load enlisted from selection") + } + + /// Inserts a hand full of new members + pub fn insert_new_members(new: Vec, conn: &mut DB) { + diesel::insert_into(group_members::table) + .values(new) + .execute(conn) + .expect("Cannot insert new members"); + } + + /// Fetches member count of a group + pub fn member_count(group_id: i32, conn: &mut DB) -> i64 { + group_members::dsl::group_members + .filter(group_members::group_id.eq(group_id)) + .select(count_star()) + .get_result::(conn) + .expect("Cannot fetch member count") + } +} diff --git a/tasky/src/models/mod.rs b/tasky/src/models/mod.rs index 3829fad..1ccb8a7 100644 --- a/tasky/src/models/mod.rs +++ b/tasky/src/models/mod.rs @@ -15,6 +15,7 @@ pub mod code_comment; pub mod database; pub mod group; pub mod group_join_request; +pub mod group_member; pub mod notification; pub mod solution; diff --git a/tasky/src/models/notification.rs b/tasky/src/models/notification.rs index 99a6a75..44f038d 100644 --- a/tasky/src/models/notification.rs +++ b/tasky/src/models/notification.rs @@ -1,3 +1,4 @@ +use crate::schema::group_members; use crate::schema::notifications::dsl; use diesel::associations::HasTable; use diesel::prelude::*; @@ -7,7 +8,6 @@ use serde::{Deserialize, Serialize}; use chrono::NaiveDateTime; -use super::group::GroupRepository; use super::DB; /// notification entry type @@ -29,7 +29,7 @@ pub struct Notification { pub struct CreateNotification { pub title: String, pub content: String, - pub targeted_users: Vec>, + pub targeted_users: Vec, } pub struct NotificationRepository; @@ -51,11 +51,16 @@ impl NotificationRepository { group_id: i32, conn: &mut DB, ) -> Notification { + let members: Vec = group_members::dsl::group_members + .filter(group_members::dsl::group_id.eq(group_id)) + .select(group_members::dsl::member_id) + .get_results::(conn) + .expect("Cannot fetch group members"); Self::create_notification( &CreateNotification { title, content, - targeted_users: GroupRepository::get_by_id(group_id, conn).unwrap().members, + targeted_users: members, }, conn, ) diff --git a/tasky/src/models/solution.rs b/tasky/src/models/solution.rs index c93bd0e..dca080d 100644 --- a/tasky/src/models/solution.rs +++ b/tasky/src/models/solution.rs @@ -126,7 +126,7 @@ impl SolutionRepository { &CreateNotification { title: "Solution updated".to_string(), content: format!("Your solution {} has been updated", solution.id), - targeted_users: vec![Some(solution.submitter_id)], + targeted_users: vec![solution.submitter_id], }, conn, ); @@ -156,11 +156,11 @@ impl SolutionRepository { .unwrap() .title ), - targeted_users: vec![Some( + targeted_users: vec![ GroupRepository::get_by_id(new.group_id, conn) .unwrap() .tutor, - )], + ], }, conn, ); diff --git a/tasky/src/response/group.rs b/tasky/src/response/group.rs index 66966e5..e759a7f 100644 --- a/tasky/src/response/group.rs +++ b/tasky/src/response/group.rs @@ -2,8 +2,9 @@ use crate::api::usernator_api_client::UsernatorApiClient; use crate::error::ApiError; use crate::models::group::JoinRequestPolicy; use crate::models::group_join_request::GroupJoinRequestRepository; +use crate::models::group_member::GroupMemberRepository; use crate::models::PaginatedModel; -use crate::{api::UserRequest, api::UsersRequest, models::group::Group, response::shared::User}; +use crate::{api::UserRequest, models::group::Group, response::shared::User}; use serde::Serialize; use tonic::transport::Channel; @@ -14,7 +15,6 @@ use super::{Enrich, DB}; pub struct GroupResponse { pub id: i32, pub title: String, - pub members: Vec, pub tutor: User, pub request_count: i32, pub join_policy: JoinRequestPolicy, @@ -26,7 +26,7 @@ pub struct GroupResponse { pub struct MinifiedGroupResponse { pub id: i32, pub title: String, - pub member_count: i32, + pub member_count: i64, pub tutor: User, pub join_policy: JoinRequestPolicy, pub verified: bool, @@ -44,7 +44,7 @@ impl Enrich for MinifiedGroupResponse { async fn enrich( from: &Group, client: &mut UsernatorApiClient, - _: &mut DB, + db: &mut DB, ) -> Result { let tut = client .get_user(UserRequest { @@ -55,7 +55,7 @@ impl Enrich for MinifiedGroupResponse { Ok(MinifiedGroupResponse { id: from.id, title: from.title.clone(), - member_count: from.members.len() as i32, + member_count: GroupMemberRepository::member_count(from.id, db), tutor: tut.into_inner().into(), join_policy: from.join_policy.clone(), verified: from.verified, @@ -93,28 +93,10 @@ impl Enrich for GroupResponse { }) .await?; - let members = client - .get_users(UsersRequest { - user_ids: from - .members - .clone() - .into_iter() - .filter(|m| m.is_some()) - .map(|m| u64::try_from(m.unwrap()).unwrap()) - .collect(), - }) - .await?; - let request_count = GroupJoinRequestRepository::get_group_request_count(from.id, conn); Ok(GroupResponse { id: from.id, title: from.title.clone(), - members: members - .into_inner() - .users - .into_iter() - .map(|x| x.into()) - .collect(), tutor: tut.into_inner().into(), join_policy: from.join_policy.clone(), verified: from.verified, diff --git a/tasky/src/response/grpc.rs b/tasky/src/response/grpc.rs index 519b4a4..ccae8c4 100644 --- a/tasky/src/response/grpc.rs +++ b/tasky/src/response/grpc.rs @@ -5,7 +5,10 @@ impl From for crate::tasky_grpc::Group { crate::tasky_grpc::Group { id: u64::try_from(val.id).unwrap(), title: val.title.clone(), - member_count: u64::try_from(val.members.len()).unwrap(), + // - deprecated + // This field is deprecated and not used anymore + // It will we removed in the future + member_count: 0, tutor_id: u64::try_from(val.tutor).unwrap(), } } diff --git a/tasky/src/routes/group.rs b/tasky/src/routes/group.rs index 6a20af8..4d60ab7 100644 --- a/tasky/src/routes/group.rs +++ b/tasky/src/routes/group.rs @@ -7,6 +7,8 @@ use crate::error::ApiError; use crate::models::assignment::AssignmentRepository; use crate::models::group::{CreateGroup, GroupRepository, JoinRequestPolicy}; use crate::models::group_join_request::GroupJoinRequestRepository; +use crate::models::group_member::GroupMember; +use crate::models::group_member::GroupMemberRepository; use crate::models::solution::SolutionRepository; use crate::mongo::task_file::TaskFileCollection; use crate::mongo::test_file::TestFileCollection; @@ -45,7 +47,6 @@ pub async fn create_group( let mut new_group = CreateGroup { title: (req.title).clone(), tutor: user.user_id, - members: vec![], join_policy: req.join_policy.clone(), }; if !new_group.is_granted(SecurityAction::Create, &user) { @@ -157,13 +158,19 @@ pub async fn update_group( group.join_policy = req.join_policy.clone(); if group.join_policy == JoinRequestPolicy::Open { - let requests = GroupJoinRequestRepository::get_group_requests_no_pagination(group.id, conn); - group - .members - .extend(requests.iter().map(|r| Some(r.requestor))); + let new_members: Vec = + GroupJoinRequestRepository::get_group_requests_no_pagination(group.id, conn) + .iter() + .map(|r| GroupMember { + member_id: r.requestor, + group_id: r.group_id, + }) + .collect(); + GroupMemberRepository::insert_new_members(new_members, conn); GroupJoinRequestRepository::delete_all_requests_for_group(group.id, conn); } else if group.join_policy == JoinRequestPolicy::Closed { let requests = GroupJoinRequestRepository::get_group_requests_no_pagination(group.id, conn); + for join_request in requests.iter() { GroupJoinRequestRepository::delete_request(join_request.clone(), conn); } @@ -211,9 +218,12 @@ pub async fn get_enlistable_users( .into_iter() .map(|x| x.into()) .collect(); + let response_uids: Vec = users.iter().map(|u| i32::try_from(u.id).unwrap()).collect(); + let enlisted_uids = + GroupMemberRepository::get_enlisted_from_selection(group.id, response_uids, conn); let filtered_users: Vec<&User> = users .iter() - .filter(|u| !group.members.contains(&Some(i32::try_from(u.id).unwrap()))) + .filter(|u| enlisted_uids.contains(&i32::try_from(u.id).unwrap())) .collect(); Ok(HttpResponse::Ok().json(filtered_users)) } @@ -247,13 +257,18 @@ pub async fn enlist_user( message: "The requested user does not exist".to_string(), }); } - if group.members.contains(&Some(path_data.1)) { + if GroupMemberRepository::is_member(group.id, path_data.1, conn) { return Err(ApiError::BadRequest { message: "The user is already member of the group".to_string(), }); } - group.members.push(Some(path_data.1)); - GroupRepository::update_group(group, conn); + GroupMemberRepository::insert_member( + GroupMember { + group_id: group.id, + member_id: path_data.1, + }, + conn, + ); Ok(HttpResponse::Ok().finish()) } @@ -277,13 +292,7 @@ pub async fn remove_user( }); } - group.members = group - .members - .iter() - .filter(|m| m.is_some() && m.unwrap() != path_data.1) - .copied() - .collect(); - GroupRepository::update_group(group, conn); + GroupMemberRepository::remove_membership(group.id, user.user_id, conn); Ok(HttpResponse::Ok().finish()) } @@ -313,16 +322,7 @@ pub async fn leave_group( }); } - // TODO: When switching to more scalabe approach, consider adding membership verification here - // This is not nessesary for application security but would be a little extra - - group.members = group - .members - .iter() - .filter(|m| m.is_some() && m.unwrap() != user.user_id) - .copied() - .collect(); - GroupRepository::update_group(group, conn); + GroupMemberRepository::remove_membership(group.id, user.user_id, conn); Ok(HttpResponse::Ok().finish()) } diff --git a/tasky/src/routes/group_join_request.rs b/tasky/src/routes/group_join_request.rs index 519a552..b55cad3 100644 --- a/tasky/src/routes/group_join_request.rs +++ b/tasky/src/routes/group_join_request.rs @@ -3,6 +3,7 @@ use crate::auth_middleware::UserData; use crate::error::ApiError; use crate::models::group::{GroupRepository, JoinRequestPolicy}; use crate::models::group_join_request::{CreateGroupJoinRequest, GroupJoinRequestRepository}; +use crate::models::group_member::{GroupMember, GroupMemberRepository}; use crate::models::notification::{CreateNotification, NotificationRepository}; use crate::response::group::GroupResponse; use crate::response::group_join_request::{GroupJoinRequestResponse, GroupJoinRequestsResponse}; @@ -20,7 +21,7 @@ pub async fn create_join_request( ) -> Result { let conn = &mut data.db.db.get().unwrap(); - let mut group = + let group = GroupRepository::get_by_id(path.into_inner().0, conn).ok_or(ApiError::BadRequest { message: "Group does not exist".to_string(), })?; @@ -32,7 +33,7 @@ pub async fn create_join_request( } if !StaticSecurity::is_granted(StaticSecurityAction::IsStudent, &user) - || group.members.contains(&Some(user.user_id)) + || GroupMemberRepository::is_member(group.id, user.user_id, conn) { return Err(ApiError::Forbidden { message: "The user is already member or not a student".to_string(), @@ -40,7 +41,13 @@ pub async fn create_join_request( } if group.join_policy == JoinRequestPolicy::Open { - group.members.push(Some(user.user_id)); + GroupMemberRepository::insert_member( + GroupMember { + group_id: group.id, + member_id: user.user_id, + }, + conn, + ); GroupRepository::update_group(group, conn); return Ok(HttpResponse::Ok().finish()); } @@ -63,7 +70,7 @@ pub async fn create_join_request( &CreateNotification { title: "New join request".to_string(), content: format!("New join request in group {}", group.title.clone()), - targeted_users: vec![Some(group.tutor)], + targeted_users: vec![group.tutor], }, conn, ); @@ -124,8 +131,13 @@ pub async fn approve_join_request( }); } - group.members.push(Some(request.requestor)); - GroupRepository::update_group(group.clone(), conn); + GroupMemberRepository::insert_member( + GroupMember { + group_id: group.id, + member_id: request.requestor, + }, + conn, + ); GroupJoinRequestRepository::delete_request(request, conn); let enriched = GroupResponse::enrich(&group, &mut data.user_api.clone(), conn).await?; diff --git a/tasky/src/security/group.rs b/tasky/src/security/group.rs index 86aadb5..953cfcb 100644 --- a/tasky/src/security/group.rs +++ b/tasky/src/security/group.rs @@ -13,7 +13,7 @@ impl IsGranted for Group { || (StaticSecurity::is_granted(StaticSecurityAction::IsTutor, user) && self.tutor == user.user_id) || (StaticSecurity::is_granted(StaticSecurityAction::IsStudent, user) - && self.members.contains(&Some(user.user_id))) + && user.groups.contains(&self.id)) } SecurityAction::Update => { (StaticSecurity::is_granted(StaticSecurityAction::IsTutor, user) From 9a72480d040afa4d7ba6aa54cf26b91c4c3c233b Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 18:22:20 +0100 Subject: [PATCH 04/13] feat: Added members fetching endpoint --- tasky/src/models/group_member.rs | 16 ++++++++++++ tasky/src/response/group_member.rs | 42 ++++++++++++++++++++++++++++++ tasky/src/response/mod.rs | 1 + tasky/src/routes/group_member.rs | 37 ++++++++++++++++++++++++++ tasky/src/routes/mod.rs | 2 ++ 5 files changed, 98 insertions(+) create mode 100644 tasky/src/response/group_member.rs create mode 100644 tasky/src/routes/group_member.rs diff --git a/tasky/src/models/group_member.rs b/tasky/src/models/group_member.rs index bc8666c..401a986 100644 --- a/tasky/src/models/group_member.rs +++ b/tasky/src/models/group_member.rs @@ -6,6 +6,8 @@ use diesel::{ RunQueryDsl, Selectable, }; +use super::Paginate; +use super::PaginatedModel; use super::DB; #[derive(Queryable, Selectable, Clone, Insertable)] @@ -83,4 +85,18 @@ impl GroupMemberRepository { .get_result::(conn) .expect("Cannot fetch member count") } + + /// Gets all group member IDs + pub fn get_members_ids_paginated( + group_id: i32, + page: i64, + conn: &mut DB, + ) -> PaginatedModel { + group_members::dsl::group_members + .filter(group_members::dsl::group_id.eq(group_id)) + .select(group_members::dsl::member_id) + .paginate(page) + .load_and_count_pages::(conn) + .expect("Cannot load group members") + } } diff --git a/tasky/src/response/group_member.rs b/tasky/src/response/group_member.rs new file mode 100644 index 0000000..199d2ae --- /dev/null +++ b/tasky/src/response/group_member.rs @@ -0,0 +1,42 @@ +use serde::Serialize; + +use crate::models::PaginatedModel; + +use super::{shared::User, Enrich}; +use crate::api::UsersRequest; +use crate::UsernatorApiClient; + +#[derive(Serialize)] +pub struct GroupMembersResponse { + members: Vec, + total: i64, +} + +impl Enrich> for GroupMembersResponse { + async fn enrich( + from: &PaginatedModel, + client: &mut UsernatorApiClient, + _: &mut super::DB, + ) -> Result { + let members = client + .get_users(UsersRequest { + user_ids: from + .results + .clone() + .into_iter() + .map(|m| u64::try_from(m).unwrap()) + .collect(), + }) + .await?; + + Ok(GroupMembersResponse { + members: members + .into_inner() + .users + .into_iter() + .map(|x| x.into()) + .collect(), + total: from.total, + }) + } +} diff --git a/tasky/src/response/mod.rs b/tasky/src/response/mod.rs index a315c5c..420d73c 100644 --- a/tasky/src/response/mod.rs +++ b/tasky/src/response/mod.rs @@ -6,6 +6,7 @@ use tonic::transport::Channel; pub mod assignment; pub mod group; pub mod group_join_request; +pub mod group_member; pub mod grpc; pub mod shared; pub mod solution; diff --git a/tasky/src/routes/group_member.rs b/tasky/src/routes/group_member.rs new file mode 100644 index 0000000..e26f53b --- /dev/null +++ b/tasky/src/routes/group_member.rs @@ -0,0 +1,37 @@ +use actix_web::get; +use actix_web::web; +use actix_web::HttpResponse; + +use super::PaginationParams; +use crate::auth_middleware::UserData; +use crate::error::ApiError; +use crate::models::group::GroupRepository; +use crate::models::group_member::GroupMemberRepository; +use crate::response::group_member::GroupMembersResponse; +use crate::response::Enrich; +use crate::security::IsGranted; +use crate::security::SecurityAction; +use crate::AppState; + +#[get("/groups/{id}/members")] +pub async fn members_paginated( + data: web::Data, + user: web::ReqData, + pagination: web::Query, + path: web::Path<(i32,)>, +) -> Result { + let conn = &mut data.db.db.get().unwrap(); + + let mut group = + GroupRepository::get_by_id(path.into_inner().0, conn).ok_or(ApiError::BadRequest { + message: "No access to group".to_string(), + })?; + if !group.is_granted(SecurityAction::Read, &user) { + return Err(ApiError::Forbidden { + message: "You are not allowed to view group".to_string(), + }); + } + let members = GroupMemberRepository::get_members_ids_paginated(group.id, pagination.page, conn); + let enriched = GroupMembersResponse::enrich(&members, &mut data.user_api.clone(), conn).await?; + return Ok(HttpResponse::Ok().json(enriched)); +} diff --git a/tasky/src/routes/mod.rs b/tasky/src/routes/mod.rs index 2d8abe5..ff17b13 100644 --- a/tasky/src/routes/mod.rs +++ b/tasky/src/routes/mod.rs @@ -6,6 +6,7 @@ pub mod assignment_wish; pub mod code_comment; pub mod group; pub mod group_join_request; +pub mod group_member; pub mod notifications; pub mod solution; @@ -34,6 +35,7 @@ pub fn init_services(cfg: &mut web::ServiceConfig) { .service(group::delete_group) .service(group::verify_group) .service(group::unverify_group) + .service(group_member::members_paginated) .service(group_join_request::create_join_request) .service(group_join_request::get_join_requests) .service(group_join_request::approve_join_request) From efefc0cbd607bf7391822a46228ddd79a3a9766e Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 18:39:50 +0100 Subject: [PATCH 05/13] feat: Added member display in frontend --- usernator/internal/controllers/self.go | 7 +++---- usernator/internal/models/user.go | 7 +++---- web/app/groups/[groupId]/client.tsx | 14 ++++++++++---- web/service/ApiService.ts | 6 +++++- web/service/types/tasky.ts | 6 +++++- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/usernator/internal/controllers/self.go b/usernator/internal/controllers/self.go index 1d5cc57..77412e8 100644 --- a/usernator/internal/controllers/self.go +++ b/usernator/internal/controllers/self.go @@ -30,10 +30,9 @@ func GetSelf(ctx *fiber.Ctx) error { var tutor models.User shared.Database.First(&tutor, "id = ?", group.GetTutorId()) groups = append(groups, models.Group{ - ID: uint(group.GetId()), - Name: group.GetTitle(), - MemberCount: uint(group.GetMemberCount()), - Tutor: tutor, + ID: uint(group.GetId()), + Name: group.GetTitle(), + Tutor: tutor, }) } resp := models.SelfUser{ diff --git a/usernator/internal/models/user.go b/usernator/internal/models/user.go index dd1ee8f..193cdfa 100644 --- a/usernator/internal/models/user.go +++ b/usernator/internal/models/user.go @@ -21,8 +21,7 @@ type SelfUser struct { } type Group struct { - ID uint `json:"id"` - Name string `json:"name"` - MemberCount uint `json:"member_count"` - Tutor User `json:"tutor"` + ID uint `json:"id"` + Name string `json:"name"` + Tutor User `json:"tutor"` } diff --git a/web/app/groups/[groupId]/client.tsx b/web/app/groups/[groupId]/client.tsx index b4ab1cd..99f161e 100644 --- a/web/app/groups/[groupId]/client.tsx +++ b/web/app/groups/[groupId]/client.tsx @@ -4,7 +4,6 @@ import React, { useState } from "react"; import { Group as TaskyGroup, GroupJoinRequestPolicy, GroupJoinRequestResponse, - TaskyUser, } from "@/service/types/tasky"; import EntityList, { EntityListCol, @@ -21,11 +20,13 @@ import {useTranslation} from "react-i18next"; import EnlistUserModal from "@/components/group/EnlistUserModal"; import {showNotification} from "@mantine/notifications"; -const MembersComponent: React.FC<{ members: TaskyUser[], group: TaskyGroup, refetch: () => void }> = ({ members, group, refetch }) => { +const MembersComponent: React.FC<{ group: TaskyGroup}> = ({ group }) => { const { t } = useTranslation(["common", "group"]); const {user} = useCurrentUser(); const api = useApiServiceClient(); const [enlistModalOpen, setEnlistModalOpen] = useState(false); + const [page, setPage] = useState(1); + const [members, refetch] = useClientQuery(() => api.getGroupMembers(group.id, page), [group.id, page]); const removeUser = async (memberId: number) => { try { @@ -68,7 +69,12 @@ const MembersComponent: React.FC<{ members: TaskyUser[], group: TaskyGroup, refe )} - + + {enlistModalOpen && ( setEnlistModalOpen(false)} groupId={group.id} refetch={refetch} /> )} @@ -173,7 +179,7 @@ export const TabsComponent: React.FC<{ {group && ( - + )} diff --git a/web/service/ApiService.ts b/web/service/ApiService.ts index e20bf90..b90ce21 100644 --- a/web/service/ApiService.ts +++ b/web/service/ApiService.ts @@ -18,7 +18,7 @@ import { AssignmentWish, CodeComment, AssignmentWishesResponse, - Notification, GroupJoinRequestPolicy, TaskyUser, + Notification, GroupJoinRequestPolicy, TaskyUser, GroupMembersResponse, } from "@/service/types/tasky"; import { FileStructureTree } from "@/components/FileStructure"; import { Spotlight3Response } from "@/service/types/spotlight"; @@ -349,6 +349,10 @@ class ApiService { await this.post(`/tasky/groups/${groupId}/unverify`, {}); } + public async getGroupMembers(groupId: number, page: number): Promise { + return await this.get(`/tasky/groups/${groupId}/members?page=${page}`); + } + public async createOrUpdateCodeTests( groupId: number, assignmentId: number, diff --git a/web/service/types/tasky.ts b/web/service/types/tasky.ts index 75d60a7..782ed6e 100644 --- a/web/service/types/tasky.ts +++ b/web/service/types/tasky.ts @@ -23,7 +23,6 @@ export interface MinifiedGroup { export interface Group { id: number; title: string; - members: TaskyUser[]; tutor: TaskyUser; request_count: number; join_policy: GroupJoinRequestPolicy; @@ -180,3 +179,8 @@ export interface Notification { title: string; content: string; } + +export interface GroupMembersResponse { + members: TaskyUser[]; + total: number; +} From f81dbddf10b6db03c4dc5a9911218650a9375859 Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 20:00:53 +0100 Subject: [PATCH 06/13] feat: made underlying assignment infrastructure more scalable --- .../down.sql | 1 + .../up.sql | 14 ++++++ tasky/src/models/assignment.rs | 1 - tasky/src/models/assignment_completion.rs | 44 ++++++++++++++++ tasky/src/models/mod.rs | 1 + tasky/src/response/assignment.rs | 50 ++++++++----------- tasky/src/routes/assignment.rs | 12 ++--- tasky/src/routes/solution.rs | 17 ++++--- tasky/src/schema.rs | 10 +++- 9 files changed, 107 insertions(+), 43 deletions(-) create mode 100644 tasky/migrations/2024-11-19-174343_scaling_assignments/down.sql create mode 100644 tasky/migrations/2024-11-19-174343_scaling_assignments/up.sql create mode 100644 tasky/src/models/assignment_completion.rs diff --git a/tasky/migrations/2024-11-19-174343_scaling_assignments/down.sql b/tasky/migrations/2024-11-19-174343_scaling_assignments/down.sql new file mode 100644 index 0000000..d9a93fe --- /dev/null +++ b/tasky/migrations/2024-11-19-174343_scaling_assignments/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` diff --git a/tasky/migrations/2024-11-19-174343_scaling_assignments/up.sql b/tasky/migrations/2024-11-19-174343_scaling_assignments/up.sql new file mode 100644 index 0000000..e0f1d5b --- /dev/null +++ b/tasky/migrations/2024-11-19-174343_scaling_assignments/up.sql @@ -0,0 +1,14 @@ +CREATE TABLE assignment_completions ( + assignment_id INTEGER REFERENCES assignments(id) ON DELETE CASCADE, + member_id INTEGER NOT NULL, + PRIMARY KEY (assignment_Id, member_id) +); + +INSERT INTO assignment_completions (assignment_id, member_id) +SELECT + a.id AS assignment_id, + unnest(a.completed_by) AS member_id +FROM + assignments a; + +ALTER TABLE assignments DROP COLUMN completed_by; diff --git a/tasky/src/models/assignment.rs b/tasky/src/models/assignment.rs index 2238269..d8478ce 100644 --- a/tasky/src/models/assignment.rs +++ b/tasky/src/models/assignment.rs @@ -60,7 +60,6 @@ pub struct Assignment { pub group_id: i32, pub description: String, pub language: AssignmentLanguage, - pub completed_by: Vec>, pub file_structure: Option, pub runner_cpu: String, pub runner_memory: String, diff --git a/tasky/src/models/assignment_completion.rs b/tasky/src/models/assignment_completion.rs new file mode 100644 index 0000000..f771c00 --- /dev/null +++ b/tasky/src/models/assignment_completion.rs @@ -0,0 +1,44 @@ +use diesel::prelude::*; +use diesel::{ + deserialize::Queryable, prelude::Insertable, BoolExpressionMethods, ExpressionMethods, + QueryDsl, Selectable, +}; + +use crate::schema::assignment_completions; + +use super::DB; + +#[derive(Queryable, Selectable, Clone, Insertable)] +#[diesel(primary_key(assignment_id, member_id))] +#[diesel(table_name = assignment_completions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct AssignmentCompletion { + pub assignment_id: i32, + pub member_id: i32, +} + +pub struct AssignmentCompletionRepository; + +impl AssignmentCompletionRepository { + /// Checks whether a user has completed assignment + pub fn is_completed_by(assignment_id: i32, member_id: i32, conn: &mut DB) -> bool { + assignment_completions::dsl::assignment_completions + .filter( + assignment_completions::assignment_id + .eq(assignment_id) + .and(assignment_completions::member_id.eq(member_id)), + ) + .first::(conn) + .optional() + .expect("Cannot fetch is completed state") + .is_some() + } + + /// Creates a new completion in the system + pub fn create_completion(comp: AssignmentCompletion, conn: &mut DB) { + diesel::insert_into(assignment_completions::table) + .values(comp) + .execute(conn) + .expect("Cannot insert assignment completion"); + } +} diff --git a/tasky/src/models/mod.rs b/tasky/src/models/mod.rs index 1ccb8a7..c9aa570 100644 --- a/tasky/src/models/mod.rs +++ b/tasky/src/models/mod.rs @@ -10,6 +10,7 @@ use diesel::sql_types::SingleValue; use serde::Serialize; pub mod assignment; +pub mod assignment_completion; pub mod assignment_wish; pub mod code_comment; pub mod database; diff --git a/tasky/src/response/assignment.rs b/tasky/src/response/assignment.rs index 18bd57c..dc524d7 100644 --- a/tasky/src/response/assignment.rs +++ b/tasky/src/response/assignment.rs @@ -1,5 +1,5 @@ -use crate::api::UsersRequest; use crate::models::assignment::QuestionCatalogue; +use crate::models::assignment_completion::AssignmentCompletionRepository; use crate::models::PaginatedModel; use crate::security::{StaticSecurity, StaticSecurityAction}; use crate::{api::usernator_api_client::UsernatorApiClient, auth_middleware::UserData}; @@ -14,7 +14,8 @@ use crate::{ }, }; -use super::{group::MinifiedGroupResponse, shared::User, Enrich}; +use super::DB; +use super::{group::MinifiedGroupResponse, Enrich}; /// An file on an assignment #[derive(Serialize, Deserialize, Clone, Debug)] @@ -42,7 +43,6 @@ pub struct AssignmentResponse { pub group: MinifiedGroupResponse, pub description: String, pub language: AssignmentLanguage, - pub completed_by: Vec, pub question_catalogue: Option, pub file_structure: Option, pub runner_cpu: String, @@ -91,9 +91,13 @@ impl Enrich for MinifiedAssignmentResponse { impl MinifiedAssignmentResponse { /// Determines whether current user has completed assignment - pub fn determine_completed(&mut self, user: &UserData, source: &Assignment) { + pub fn determine_completed(&mut self, user: &UserData, source: &Assignment, conn: &mut DB) { if StaticSecurity::is_granted(StaticSecurityAction::IsStudent, user) { - self.completed = Some(source.completed_by.contains(&Some(user.user_id))); + self.completed = Some(AssignmentCompletionRepository::is_completed_by( + source.id, + user.user_id, + conn, + )); } } } @@ -120,11 +124,16 @@ impl Enrich> for AssignmentsResponse { impl AssignmentsResponse { /// Determines whether current user has completed the assignments - pub fn determine_completed(&mut self, user: &UserData, source: &PaginatedModel) { + pub fn determine_completed( + &mut self, + user: &UserData, + source: &PaginatedModel, + conn: &mut DB, + ) { let reference: &mut Vec = self.assignments.as_mut(); for i in 0..reference.len() { let partial_response = reference.get_mut(i).unwrap(); - partial_response.determine_completed(user, source.results.get(i).unwrap()); + partial_response.determine_completed(user, source.results.get(i).unwrap(), conn); } } } @@ -140,19 +149,6 @@ impl Enrich for AssignmentResponse { let group = GroupRepository::get_by_id(from.group_id, db_conn).unwrap(); let group_response = MinifiedGroupResponse::enrich(&group, client, db_conn).await?; - let completed_by: Vec = from - .completed_by - .iter() - .filter(|x| x.is_some()) - .map(|m| u64::try_from(m.unwrap()).unwrap()) - .collect(); - - let users = client - .get_users(UsersRequest { - user_ids: completed_by, - }) - .await?; - let file_structure = serde_json::from_value( from.file_structure .clone() @@ -174,12 +170,6 @@ impl Enrich for AssignmentResponse { group: group_response, description: from.description.clone(), language: from.language.clone(), - completed_by: users - .into_inner() - .users - .into_iter() - .map(|x| x.into()) - .collect(), file_structure, question_catalogue, runner_cpu: from.runner_cpu.clone(), @@ -206,9 +196,13 @@ impl AssignmentResponse { } /// Determines whether current user has completed the assignment - pub fn determine_completed(&mut self, user: &UserData, source: &Assignment) { + pub fn determine_completed(&mut self, user: &UserData, source: &Assignment, conn: &mut DB) { if StaticSecurity::is_granted(StaticSecurityAction::IsStudent, user) { - self.completed = Some(source.completed_by.contains(&Some(user.user_id))); + self.completed = Some(AssignmentCompletionRepository::is_completed_by( + source.id, + user.user_id, + conn, + )); } } } diff --git a/tasky/src/routes/assignment.rs b/tasky/src/routes/assignment.rs index ba914db..be89245 100644 --- a/tasky/src/routes/assignment.rs +++ b/tasky/src/routes/assignment.rs @@ -95,7 +95,7 @@ pub async fn get_all_group_assignments( AssignmentRepository::get_all_group_assignments(group.id, pagination.page, conn); let mut enriched = AssignmentsResponse::enrich(&assignments, &mut data.user_api.clone(), conn).await?; - enriched.determine_completed(&user_data, &assignments); + enriched.determine_completed(&user_data, &assignments, conn); Ok(HttpResponse::Ok().json(enriched)) } @@ -154,7 +154,7 @@ pub async fn get_assignment( let mut enrichted = AssignmentResponse::enrich(&assignment, &mut data.user_api.clone(), conn).await?; enrichted.authorize(&user_data); - enrichted.determine_completed(&user_data, &assignment); + enrichted.determine_completed(&user_data, &assignment, conn); Ok(HttpResponse::Ok().json(enrichted)) } @@ -186,7 +186,7 @@ pub async fn update_assignment( let mut enrichted = AssignmentResponse::enrich(&assignment, &mut data.user_api.clone(), conn).await?; enrichted.authorize(&user_data); - enrichted.determine_completed(&user_data, &assignment); + enrichted.determine_completed(&user_data, &assignment, conn); Ok(HttpResponse::Ok().json(enrichted)) } @@ -218,7 +218,7 @@ pub async fn create_assignment_test( let mut enriched = AssignmentResponse::enrich(&updated, &mut data.user_api.clone(), conn).await?; enriched.authorize(&user_data); - enriched.determine_completed(&user_data, &updated); + enriched.determine_completed(&user_data, &updated, conn); Ok(HttpResponse::Ok().json(enriched)) } @@ -249,7 +249,7 @@ pub async fn update_assignment_test( let mut enriched = AssignmentResponse::enrich(&updated, &mut data.user_api.clone(), conn).await?; enriched.authorize(&user_data); - enriched.determine_completed(&user_data, &updated); + enriched.determine_completed(&user_data, &updated, conn); Ok(HttpResponse::Ok().json(enriched)) } @@ -318,7 +318,7 @@ pub async fn create_question_catalogue( let mut response = AssignmentResponse::enrich(&assignment, &mut data.user_api.clone(), conn).await?; response.authorize(&user_data); - response.determine_completed(&user_data, &assignment); + response.determine_completed(&user_data, &assignment, conn); Ok(HttpResponse::Ok().json(response)) } diff --git a/tasky/src/routes/solution.rs b/tasky/src/routes/solution.rs index 12849c6..4c29c49 100644 --- a/tasky/src/routes/solution.rs +++ b/tasky/src/routes/solution.rs @@ -1,6 +1,7 @@ use super::PaginationParams; use crate::http::run_task; use crate::models::assignment::AssignmentLanguage; +use crate::models::assignment_completion::{AssignmentCompletion, AssignmentCompletionRepository}; use crate::models::solution::{ApprovalStatus, Solution, SolutionRepository}; use crate::mongo::task_file::{TaskFile, TaskFileCollection}; use crate::mongo::test_file::{TestFile, TestFileCollection}; @@ -181,8 +182,7 @@ pub async fn approve_solution( let path_data = path.into_inner(); let conn = &mut data.db.db.get().unwrap(); - let (mut assignment, mut solution) = - get_solution_and_assignment(path_data.0, &user_data, conn)?; + let (assignment, mut solution) = get_solution_and_assignment(path_data.0, &user_data, conn)?; if !solution.is_granted(SecurityAction::Update, &user_data) { return Err(ApiError::Forbidden { message: "You are not allowed to approve solution".to_string(), @@ -192,14 +192,17 @@ pub async fn approve_solution( solution.approval_status = Some(ApprovalStatus::Approved.string()); SolutionRepository::update_solution(solution.clone(), conn); - if !assignment - .completed_by - .contains(&Some(solution.submitter_id)) + if !AssignmentCompletionRepository::is_completed_by(assignment.id, solution.submitter_id, conn) { - assignment.completed_by.push(Some(solution.submitter_id)); + AssignmentCompletionRepository::create_completion( + AssignmentCompletion { + assignment_id: assignment.id, + member_id: solution.submitter_id, + }, + conn, + ); } - AssignmentRepository::update_assignment(assignment.clone(), conn); let response = SolutionResponse::enrich(&solution, &mut data.user_api.clone(), conn).await?; Ok(HttpResponse::Ok().json(response)) } diff --git a/tasky/src/schema.rs b/tasky/src/schema.rs index 4864373..c20ce7e 100644 --- a/tasky/src/schema.rs +++ b/tasky/src/schema.rs @@ -10,6 +10,13 @@ pub mod sql_types { pub struct JoinRequestPolicy; } +diesel::table! { + assignment_completions (assignment_id, member_id) { + assignment_id -> Int4, + member_id -> Int4, + } +} + diesel::table! { assignment_wishes (id) { id -> Int4, @@ -34,7 +41,6 @@ diesel::table! { group_id -> Int4, description -> Text, language -> AssignmentLanguage, - completed_by -> Array>, file_structure -> Nullable, #[max_length = 5] runner_cpu -> Varchar, @@ -124,6 +130,7 @@ diesel::table! { } } +diesel::joinable!(assignment_completions -> assignments (assignment_id)); diesel::joinable!(assignment_wishes -> groups (group_id)); diesel::joinable!(assignments -> groups (group_id)); diesel::joinable!(code_comments -> groups (group_id)); @@ -134,6 +141,7 @@ diesel::joinable!(solutions -> assignments (assignment_id)); diesel::joinable!(solutions -> groups (group_id)); diesel::allow_tables_to_appear_in_same_query!( + assignment_completions, assignment_wishes, assignments, code_comments, From 2eb8509644ea64e794f921e230558e1dc1ba9d94 Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 20:37:13 +0100 Subject: [PATCH 07/13] feat: Added REST endpoint for fetching assignment completions --- tasky/src/models/assignment_completion.rs | 16 +++++++- tasky/src/response/assignment_completion.rs | 42 +++++++++++++++++++++ tasky/src/response/mod.rs | 1 + tasky/src/routes/assignment.rs | 2 +- tasky/src/routes/assignment_completion.rs | 37 ++++++++++++++++++ tasky/src/routes/group_member.rs | 2 +- tasky/src/routes/mod.rs | 1 + 7 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 tasky/src/response/assignment_completion.rs create mode 100644 tasky/src/routes/assignment_completion.rs diff --git a/tasky/src/models/assignment_completion.rs b/tasky/src/models/assignment_completion.rs index f771c00..c623c93 100644 --- a/tasky/src/models/assignment_completion.rs +++ b/tasky/src/models/assignment_completion.rs @@ -6,7 +6,7 @@ use diesel::{ use crate::schema::assignment_completions; -use super::DB; +use super::{Paginate, PaginatedModel, DB}; #[derive(Queryable, Selectable, Clone, Insertable)] #[diesel(primary_key(assignment_id, member_id))] @@ -41,4 +41,18 @@ impl AssignmentCompletionRepository { .execute(conn) .expect("Cannot insert assignment completion"); } + + /// Gets all completion IDs for assignment + pub fn get_completion_ids_for_assignment( + assignment_id: i32, + page: i64, + conn: &mut DB, + ) -> PaginatedModel { + assignment_completions::dsl::assignment_completions + .filter(assignment_completions::assignment_id.eq(assignment_id)) + .select(assignment_completions::member_id) + .paginate(page) + .load_and_count_pages::(conn) + .expect("Cannot load completions") + } } diff --git a/tasky/src/response/assignment_completion.rs b/tasky/src/response/assignment_completion.rs new file mode 100644 index 0000000..8dd99ae --- /dev/null +++ b/tasky/src/response/assignment_completion.rs @@ -0,0 +1,42 @@ +use serde::Serialize; + +use crate::models::PaginatedModel; + +use super::{shared::User, Enrich}; +use crate::api::UsersRequest; +use crate::UsernatorApiClient; + +#[derive(Serialize)] +pub struct AssignmentCompletionsResponse { + pub completions: Vec, + pub total: i64, +} + +impl Enrich> for AssignmentCompletionsResponse { + async fn enrich( + from: &PaginatedModel, + client: &mut UsernatorApiClient, + _: &mut super::DB, + ) -> Result { + let completions = client + .get_users(UsersRequest { + user_ids: from + .results + .clone() + .into_iter() + .map(|m| u64::try_from(m).unwrap()) + .collect(), + }) + .await?; + + Ok(AssignmentCompletionsResponse { + completions: completions + .into_inner() + .users + .into_iter() + .map(|x| x.into()) + .collect(), + total: from.total, + }) + } +} diff --git a/tasky/src/response/mod.rs b/tasky/src/response/mod.rs index 420d73c..ad42b2e 100644 --- a/tasky/src/response/mod.rs +++ b/tasky/src/response/mod.rs @@ -4,6 +4,7 @@ use diesel::r2d2::{ConnectionManager, PooledConnection}; use tonic::transport::Channel; pub mod assignment; +pub mod assignment_completion; pub mod group; pub mod group_join_request; pub mod group_member; diff --git a/tasky/src/routes/assignment.rs b/tasky/src/routes/assignment.rs index be89245..8eb5623 100644 --- a/tasky/src/routes/assignment.rs +++ b/tasky/src/routes/assignment.rs @@ -348,7 +348,7 @@ pub async fn get_student_pending_assignments( /// Gets group and assignment from request params and connection. /// Furthermore, it handles all the user security checks -fn get_group_and_assignment( +pub fn get_group_and_assignment( user_data: &UserData, path_data: (i32, i32), conn: &mut DB, diff --git a/tasky/src/routes/assignment_completion.rs b/tasky/src/routes/assignment_completion.rs new file mode 100644 index 0000000..9068520 --- /dev/null +++ b/tasky/src/routes/assignment_completion.rs @@ -0,0 +1,37 @@ +use actix_web::{get, web, HttpResponse}; + +use crate::routes::assignment::get_group_and_assignment; +use crate::{ + auth_middleware::UserData, + error::ApiError, + models::assignment_completion::AssignmentCompletionRepository, + response::{assignment_completion::AssignmentCompletionsResponse, Enrich}, + AppState, +}; + +use super::PaginationParams; + +/// Endpoint for assignment completions +#[get("/groups/{group_id}/assignments/{id}/completions")] +pub async fn assignment_completions( + data: web::Data, + user: web::ReqData, + pagination: web::Query, + path: web::Path<(i32, i32)>, +) -> Result { + let user_data = user.into_inner(); + let path_data = path.into_inner(); + let conn = &mut data.db.db.get().unwrap(); + + let (_, assignment) = get_group_and_assignment(&user_data, path_data, conn)?; + + let completions = AssignmentCompletionRepository::get_completion_ids_for_assignment( + assignment.id, + pagination.page, + conn, + ); + let enriched = + AssignmentCompletionsResponse::enrich(&completions, &mut data.user_api.clone(), conn) + .await?; + Ok(HttpResponse::Ok().json(enriched)) +} diff --git a/tasky/src/routes/group_member.rs b/tasky/src/routes/group_member.rs index e26f53b..4137901 100644 --- a/tasky/src/routes/group_member.rs +++ b/tasky/src/routes/group_member.rs @@ -33,5 +33,5 @@ pub async fn members_paginated( } let members = GroupMemberRepository::get_members_ids_paginated(group.id, pagination.page, conn); let enriched = GroupMembersResponse::enrich(&members, &mut data.user_api.clone(), conn).await?; - return Ok(HttpResponse::Ok().json(enriched)); + Ok(HttpResponse::Ok().json(enriched)) } diff --git a/tasky/src/routes/mod.rs b/tasky/src/routes/mod.rs index ff17b13..04042de 100644 --- a/tasky/src/routes/mod.rs +++ b/tasky/src/routes/mod.rs @@ -2,6 +2,7 @@ use actix_web::web; use serde::Deserialize; pub mod assignment; +pub mod assignment_completion; pub mod assignment_wish; pub mod code_comment; pub mod group; From 83b70331193e22917bec98f427baf61246998ea9 Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 21:48:44 +0100 Subject: [PATCH 08/13] fix: Added rest endpoint to service init --- tasky/src/routes/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tasky/src/routes/mod.rs b/tasky/src/routes/mod.rs index 04042de..0c8388f 100644 --- a/tasky/src/routes/mod.rs +++ b/tasky/src/routes/mod.rs @@ -50,6 +50,7 @@ pub fn init_services(cfg: &mut web::ServiceConfig) { .service(assignment::create_question_catalogue) .service(assignment::update_assignment_test) .service(assignment::get_student_pending_assignments) + .service(assignment_completion::assignment_completions) .service(solution::create_solution) .service(solution::get_solution) .service(solution::get_solutions_for_assignment) From b5a141b7db0bce4d0c719c53a6603f31f2110f35 Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 22:38:18 +0100 Subject: [PATCH 09/13] feat: Reworked notification database schema --- .../down.sql | 1 + .../up.sql | 14 ++++ tasky/src/models/mod.rs | 1 + tasky/src/models/notification.rs | 71 +++++++++++++------ tasky/src/models/notification_target.rs | 26 +++++++ tasky/src/schema.rs | 10 ++- 6 files changed, 101 insertions(+), 22 deletions(-) create mode 100644 tasky/migrations/2024-11-19-211502_scalable_notifications/down.sql create mode 100644 tasky/migrations/2024-11-19-211502_scalable_notifications/up.sql create mode 100644 tasky/src/models/notification_target.rs diff --git a/tasky/migrations/2024-11-19-211502_scalable_notifications/down.sql b/tasky/migrations/2024-11-19-211502_scalable_notifications/down.sql new file mode 100644 index 0000000..d9a93fe --- /dev/null +++ b/tasky/migrations/2024-11-19-211502_scalable_notifications/down.sql @@ -0,0 +1 @@ +-- This file should undo anything in `up.sql` diff --git a/tasky/migrations/2024-11-19-211502_scalable_notifications/up.sql b/tasky/migrations/2024-11-19-211502_scalable_notifications/up.sql new file mode 100644 index 0000000..f4794b9 --- /dev/null +++ b/tasky/migrations/2024-11-19-211502_scalable_notifications/up.sql @@ -0,0 +1,14 @@ +CREATE TABLE notification_targets ( + notification_id INTEGER NOT NULL REFERENCES notifications(id), + user_id INTEGER NOT NULL, + PRIMARY KEY (notification_id, user_id) +); + +INSERT INTO notification_targets (notification_id, user_id) +SELECT + n.id AS notification_id, + unnest(n.targeted_users) AS user_id +FROM + notifications n; + +ALTER TABLE notifications DROP COLUMN targeted_users; diff --git a/tasky/src/models/mod.rs b/tasky/src/models/mod.rs index c9aa570..7fd5183 100644 --- a/tasky/src/models/mod.rs +++ b/tasky/src/models/mod.rs @@ -18,6 +18,7 @@ pub mod group; pub mod group_join_request; pub mod group_member; pub mod notification; +pub mod notification_target; pub mod solution; pub type DB = PooledConnection>; diff --git a/tasky/src/models/notification.rs b/tasky/src/models/notification.rs index 44f038d..72b5915 100644 --- a/tasky/src/models/notification.rs +++ b/tasky/src/models/notification.rs @@ -1,13 +1,14 @@ use crate::schema::group_members; +use crate::schema::notification_targets; use crate::schema::notifications::dsl; use diesel::associations::HasTable; use diesel::prelude::*; -use diesel::sql_query; -use diesel::sql_types::Integer; use serde::{Deserialize, Serialize}; use chrono::NaiveDateTime; +use super::notification_target::NotificationTarget; +use super::notification_target::NotificationTargetRepository; use super::DB; /// notification entry type @@ -18,30 +19,52 @@ pub struct Notification { pub id: i32, pub title: String, pub content: String, - pub targeted_users: Vec>, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, } -/// notification insert type -#[derive(Insertable, Deserialize, Serialize)] -#[diesel(table_name = crate::schema::notifications)] +/// notification insert type for external calls pub struct CreateNotification { pub title: String, pub content: String, pub targeted_users: Vec, } +/// create model used internally for notifications +#[derive(Insertable)] +#[diesel(table_name = crate::schema::notifications)] +struct InternalCreate { + pub title: String, + pub content: String, +} + pub struct NotificationRepository; impl NotificationRepository { /// Creates a new notification pub fn create_notification(notification: &CreateNotification, conn: &mut DB) -> Notification { - diesel::insert_into(dsl::notifications::table()) - .values(notification) + let notification_create = InternalCreate { + title: notification.title.clone(), + content: notification.content.clone(), + }; + + let created = diesel::insert_into(dsl::notifications::table()) + .values(notification_create) .returning(Notification::as_returning()) .get_result::(conn) - .expect("Cannot create new notification") + .expect("Cannot create new notification"); + + let targets: Vec = notification + .targeted_users + .iter() + .map(|u| NotificationTarget { + notification_id: created.id, + user_id: u.clone(), + }) + .collect(); + + NotificationTargetRepository::create(targets, conn); + created } /// Creates a notification for a group @@ -69,27 +92,33 @@ impl NotificationRepository { /// Gets all notifications for a user pub fn get_notifications_for_user(user_id: i32, conn: &mut DB) -> Vec { dsl::notifications - .filter(dsl::targeted_users.contains(vec![Some(user_id)])) + .left_join(notification_targets::table) + .filter(notification_targets::user_id.eq(user_id)) + .select(Notification::as_select()) .get_results::(conn) .expect("Cannot get notifications for user") } /// Removes user from specfic notification pub fn remove_user_from_notification(id: i32, user_id: i32, conn: &mut DB) { - sql_query("UPDATE notifications SET targeted_users = array_remove(targeted_users, $1) WHERE id = $2") - .bind::(user_id) - .bind::(id) - .execute(conn) - .expect("Cannot remove user from notification"); - sql_query("DELETE FROM notifications WHERE array_length(targeted_users, 1) IS NULL OR array_length(targeted_users, 1) = 0;").execute(conn).expect("Cannot remove pending notifications"); + diesel::delete( + notification_targets::dsl::notification_targets.filter( + notification_targets::user_id + .eq(user_id) + .and(notification_targets::notification_id.eq(id)), + ), + ) + .execute(conn) + .expect("Cannot remove user from notification"); } /// Removes user from all notifiations pub fn remove_user_from_all_notification(user_id: i32, conn: &mut DB) { - sql_query("UPDATE notifications SET targeted_users = array_remove(targeted_users, $1) WHERE $1 = ANY(targeted_users)") - .bind::(user_id) - .execute(conn) - .expect("Cannot remove user from notification"); - sql_query("DELETE FROM notifications WHERE array_length(targeted_users, 1) IS NULL OR array_length(targeted_users, 1) = 0;").execute(conn).expect("Cannot remove pending notifications"); + diesel::delete( + notification_targets::dsl::notification_targets + .filter(notification_targets::user_id.eq(user_id)), + ) + .execute(conn) + .expect("Cannot remove user from notification"); } } diff --git a/tasky/src/models/notification_target.rs b/tasky/src/models/notification_target.rs new file mode 100644 index 0000000..a949f94 --- /dev/null +++ b/tasky/src/models/notification_target.rs @@ -0,0 +1,26 @@ +use diesel::{deserialize::Queryable, prelude::Insertable, RunQueryDsl, Selectable}; + +use crate::schema::notification_targets; + +use super::DB; + +#[derive(Queryable, Selectable, Clone, Insertable)] +#[diesel(primary_key(notification_id, user_id))] +#[diesel(table_name = notification_targets)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct NotificationTarget { + pub notification_id: i32, + pub user_id: i32, +} + +pub struct NotificationTargetRepository; + +impl NotificationTargetRepository { + ///Creates a new notification target + pub fn create(targets: Vec, conn: &mut DB) { + diesel::insert_into(notification_targets::table) + .values(targets) + .execute(conn) + .expect("Cannot create new notification relation"); + } +} diff --git a/tasky/src/schema.rs b/tasky/src/schema.rs index c20ce7e..c5c7ee3 100644 --- a/tasky/src/schema.rs +++ b/tasky/src/schema.rs @@ -101,13 +101,19 @@ diesel::table! { } } +diesel::table! { + notification_targets (notification_id, user_id) { + notification_id -> Int4, + user_id -> Int4, + } +} + diesel::table! { notifications (id) { id -> Int4, #[max_length = 255] title -> Varchar, content -> Text, - targeted_users -> Array>, created_at -> Timestamp, updated_at -> Timestamp, } @@ -137,6 +143,7 @@ diesel::joinable!(code_comments -> groups (group_id)); diesel::joinable!(code_comments -> solutions (solution_id)); diesel::joinable!(group_join_requests -> groups (group_id)); diesel::joinable!(group_members -> groups (group_id)); +diesel::joinable!(notification_targets -> notifications (notification_id)); diesel::joinable!(solutions -> assignments (assignment_id)); diesel::joinable!(solutions -> groups (group_id)); @@ -148,6 +155,7 @@ diesel::allow_tables_to_appear_in_same_query!( group_join_requests, group_members, groups, + notification_targets, notifications, solutions, ); From d43259e9fbb9d30c09c897ee58c4fd984d78800b Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 22:49:31 +0100 Subject: [PATCH 10/13] chore: Fixed linting --- tasky/src/models/notification.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasky/src/models/notification.rs b/tasky/src/models/notification.rs index 72b5915..d9390c2 100644 --- a/tasky/src/models/notification.rs +++ b/tasky/src/models/notification.rs @@ -59,7 +59,7 @@ impl NotificationRepository { .iter() .map(|u| NotificationTarget { notification_id: created.id, - user_id: u.clone(), + user_id: *u, }) .collect(); From de7f0a47ae7b4aea2f98185bd337eede2169b8b8 Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 22:51:21 +0100 Subject: [PATCH 11/13] feat: Updated assignment completion tab --- .../assignments/[assignmentId]/page.tsx | 2 +- .../assignments/AssignmentCompletedByTab.tsx | 25 ++++++++++++++++--- web/service/ApiService.ts | 6 ++++- web/service/types/tasky.ts | 5 ++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/web/app/groups/[groupId]/assignments/[assignmentId]/page.tsx b/web/app/groups/[groupId]/assignments/[assignmentId]/page.tsx index 5765ef7..3d6f4ea 100644 --- a/web/app/groups/[groupId]/assignments/[assignmentId]/page.tsx +++ b/web/app/groups/[groupId]/assignments/[assignmentId]/page.tsx @@ -136,7 +136,7 @@ const AssignmentDetailsPage = ({ - + )} diff --git a/web/components/assignments/AssignmentCompletedByTab.tsx b/web/components/assignments/AssignmentCompletedByTab.tsx index 4418d64..945134a 100644 --- a/web/components/assignments/AssignmentCompletedByTab.tsx +++ b/web/components/assignments/AssignmentCompletedByTab.tsx @@ -1,15 +1,23 @@ -import { TaskyUser } from "@/service/types/tasky"; import EntityList, { EntityListCol } from "@/components/EntityList"; import { useTranslation } from "react-i18next"; +import useApiServiceClient from "@/hooks/useApiServiceClient"; +import useClientQuery from "@/hooks/useClientQuery"; +import React, {useState} from "react"; +import {Pagination} from "@mantine/core"; interface AssignmentCompletedByTabProps { - completedBy: TaskyUser[]; + groupId: number; + assignmentId: number; } const AssignmentCompletedByTab = ({ - completedBy, + groupId, + assignmentId, }: AssignmentCompletedByTabProps) => { const { t } = useTranslation("common"); + const api = useApiServiceClient(); + const [page, setPage] = useState(1); + const [completedBy] = useClientQuery(() => api.getAssignmentCompletions(groupId, assignmentId, page), [groupId, assignmentId, page]); const cols: EntityListCol[] = [ { @@ -22,7 +30,16 @@ const AssignmentCompletedByTab = ({ }, ]; - return ; + return ( + <> + + + + ); }; export default AssignmentCompletedByTab; diff --git a/web/service/ApiService.ts b/web/service/ApiService.ts index b90ce21..a0b4d17 100644 --- a/web/service/ApiService.ts +++ b/web/service/ApiService.ts @@ -18,7 +18,7 @@ import { AssignmentWish, CodeComment, AssignmentWishesResponse, - Notification, GroupJoinRequestPolicy, TaskyUser, GroupMembersResponse, + Notification, GroupJoinRequestPolicy, TaskyUser, GroupMembersResponse, AssignmentCompletionsResponse, } from "@/service/types/tasky"; import { FileStructureTree } from "@/components/FileStructure"; import { Spotlight3Response } from "@/service/types/spotlight"; @@ -353,6 +353,10 @@ class ApiService { return await this.get(`/tasky/groups/${groupId}/members?page=${page}`); } + public async getAssignmentCompletions(groupId: number, assignmentId: number, page: number): Promise { + return await this.get(`/tasky/groups/${groupId}/assignments/${assignmentId}/completions?page=${page}`); + } + public async createOrUpdateCodeTests( groupId: number, assignmentId: number, diff --git a/web/service/types/tasky.ts b/web/service/types/tasky.ts index 782ed6e..8699b8d 100644 --- a/web/service/types/tasky.ts +++ b/web/service/types/tasky.ts @@ -184,3 +184,8 @@ export interface GroupMembersResponse { members: TaskyUser[]; total: number; } + +export interface AssignmentCompletionsResponse { + completions: TaskyUser[]; + total: number; +} From 92a96ace7e6f6e67c02165d16827f0b9080a5a86 Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 23:12:43 +0100 Subject: [PATCH 12/13] test: Fixed tests --- tasky/tests/security/assignment_test.rs | 11 ----------- tasky/tests/security/group_test.rs | 18 ------------------ 2 files changed, 29 deletions(-) diff --git a/tasky/tests/security/assignment_test.rs b/tasky/tests/security/assignment_test.rs index 5bca95b..314b7f4 100644 --- a/tasky/tests/security/assignment_test.rs +++ b/tasky/tests/security/assignment_test.rs @@ -18,7 +18,6 @@ fn test_create_assignment_disabled() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), @@ -45,7 +44,6 @@ fn test_read_assignment_as_wrong_student() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), @@ -72,7 +70,6 @@ fn test_read_assignment_as_student() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), @@ -99,7 +96,6 @@ fn test_read_assignment_as_wrong_tutor() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), @@ -126,7 +122,6 @@ fn test_read_assignment_as_tutor() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), @@ -153,7 +148,6 @@ fn test_read_assignment_as_admin() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), @@ -180,7 +174,6 @@ fn test_update_assignment_as_wrong_student() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), @@ -207,7 +200,6 @@ fn test_update_assignment_as_student() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), @@ -234,7 +226,6 @@ fn test_update_assignment_as_wrong_tutor() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), @@ -261,7 +252,6 @@ fn test_update_assignment_as_tutor() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), @@ -288,7 +278,6 @@ fn test_update_assignment_as_admin() { group_id: 1, description: "".to_string(), language: AssignmentLanguage::Golang, - completed_by: vec![], file_structure: None, runner_cmd: "".to_string(), runner_memory: "".to_string(), diff --git a/tasky/tests/security/group_test.rs b/tasky/tests/security/group_test.rs index a9e84d6..44ca929 100644 --- a/tasky/tests/security/group_test.rs +++ b/tasky/tests/security/group_test.rs @@ -17,7 +17,6 @@ fn test_create_group() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 1, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -35,7 +34,6 @@ fn test_read_group_as_admin() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 1, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -53,7 +51,6 @@ fn test_read_group_as_tutor() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 1, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -71,7 +68,6 @@ fn test_read_group_as_wrong_tutor() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 2, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -89,7 +85,6 @@ fn test_read_group_as_student() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![Some(1)], tutor: 2, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -107,7 +102,6 @@ fn test_read_group_as_wrong_student() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 2, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -125,7 +119,6 @@ fn test_update_as_admin() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 2, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -143,7 +136,6 @@ fn test_update_as_tutor() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 1, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -161,7 +153,6 @@ fn test_update_as_wrong_tutor() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 2, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -179,7 +170,6 @@ fn test_update_as_student() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 2, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -197,7 +187,6 @@ fn test_delete_as_admin() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 2, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -215,7 +204,6 @@ fn test_delete_as_tutor() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 1, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -233,7 +221,6 @@ fn test_delete_as_wrong_tutor() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 2, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -251,7 +238,6 @@ fn test_delete_as_student() { let mut group = Group { id: 1, title: "group".to_string(), - members: vec![], tutor: 2, join_policy: JoinRequestPolicy::Request, created_at: NaiveDateTime::parse_from_str("2015-09-05 23:56:04", "%Y-%m-%d %H:%M:%S") @@ -269,7 +255,6 @@ fn test_create_create_as_student() { let mut create = CreateGroup { title: "".to_string(), tutor: 1, - members: vec![], join_policy: JoinRequestPolicy::Request, }; assert_eq!(create.is_granted(SecurityAction::Create, &user), false); @@ -281,7 +266,6 @@ fn test_create_create_as_tutor() { let mut create = CreateGroup { title: "".to_string(), tutor: 1, - members: vec![], join_policy: JoinRequestPolicy::Request, }; assert_eq!(create.is_granted(SecurityAction::Create, &user), true); @@ -293,7 +277,6 @@ fn test_create_create_as_admin() { let mut create = CreateGroup { title: "".to_string(), tutor: 1, - members: vec![], join_policy: JoinRequestPolicy::Request, }; assert_eq!(create.is_granted(SecurityAction::Create, &user), false); @@ -305,7 +288,6 @@ fn test_create_create_pending() { let mut create = CreateGroup { title: "".to_string(), tutor: 1, - members: vec![], join_policy: JoinRequestPolicy::Request, }; assert_eq!(create.is_granted(SecurityAction::Read, &user), false); From 396007c2dfc8b0af03ec9c583d6a04e554698991 Mon Sep 17 00:00:00 2001 From: Mathis Burger Date: Tue, 19 Nov 2024 23:48:13 +0100 Subject: [PATCH 13/13] fix: Fixed tests --- tasky/tests/security/group_test.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tasky/tests/security/group_test.rs b/tasky/tests/security/group_test.rs index 44ca929..a32104e 100644 --- a/tasky/tests/security/group_test.rs +++ b/tasky/tests/security/group_test.rs @@ -1,4 +1,5 @@ use crate::security::get_student; +use crate::security::get_student_with_group; use crate::security::get_tutor; use crate::security::get_tutor_with_group; @@ -81,7 +82,7 @@ fn test_read_group_as_wrong_tutor() { #[test] fn test_read_group_as_student() { - let admin = get_student(); + let admin = get_student_with_group(); let mut group = Group { id: 1, title: "group".to_string(),