diff --git a/backend/diesel.toml b/backend/diesel.toml new file mode 100644 index 000000000..35a12ff0d --- /dev/null +++ b/backend/diesel.toml @@ -0,0 +1,8 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" + +[migrations_directory] +dir = "migrations" diff --git a/backend/migrations/2023-02-08-081803_questions/down.sql b/backend/migrations/2023-02-08-081803_questions/down.sql new file mode 100644 index 000000000..ebe6feb7e --- /dev/null +++ b/backend/migrations/2023-02-08-081803_questions/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS questions + DROP COLUMN role_id; diff --git a/backend/migrations/2023-02-08-081803_questions/up.sql b/backend/migrations/2023-02-08-081803_questions/up.sql new file mode 100644 index 000000000..3a8bb45b7 --- /dev/null +++ b/backend/migrations/2023-02-08-081803_questions/up.sql @@ -0,0 +1,10 @@ +ALTER TABLE IF EXISTS questions + ADD COLUMN role_id INTEGER DEFAULT NULL; + +-- Make questions currently assigned to one role, uncommon +UPDATE questions SET role_id=role_ids[1] WHERE array_length(role_ids, 1) = 1; + +-- No need to change questions currently assigned to multiple roles, as role_id is NULL by default, thus making them common + +ALTER TABLE IF EXISTS questions + DROP COLUMN role_ids; diff --git a/backend/migrations/2023-03-29-025501_questions/down.sql b/backend/migrations/2023-03-29-025501_questions/down.sql new file mode 100644 index 000000000..2a43a8eba --- /dev/null +++ b/backend/migrations/2023-03-29-025501_questions/down.sql @@ -0,0 +1,6 @@ +-- This file should undo anything in `up.sql` + +ALTER TABLE IF EXISTS questions + DROP COLUMN question_type; + +DROP TYPE question_types; \ No newline at end of file diff --git a/backend/migrations/2023-03-29-025501_questions/up.sql b/backend/migrations/2023-03-29-025501_questions/up.sql new file mode 100644 index 000000000..139604848 --- /dev/null +++ b/backend/migrations/2023-03-29-025501_questions/up.sql @@ -0,0 +1,6 @@ +-- Your SQL goes here + +CREATE TYPE question_types AS ENUM ('ShortAnswer', 'MultiSelect'); + +ALTER TABLE IF EXISTS questions + ADD COLUMN question_type question_types DEFAULT 'ShortAnswer'; diff --git a/backend/migrations/2023-04-02-063755_answers/down.sql b/backend/migrations/2023-04-02-063755_answers/down.sql new file mode 100644 index 000000000..168e63cdd --- /dev/null +++ b/backend/migrations/2023-04-02-063755_answers/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE IF EXISTS answers + DROP COLUMN answer_type; diff --git a/backend/migrations/2023-04-02-063755_answers/up.sql b/backend/migrations/2023-04-02-063755_answers/up.sql new file mode 100644 index 000000000..41b3533b2 --- /dev/null +++ b/backend/migrations/2023-04-02-063755_answers/up.sql @@ -0,0 +1,4 @@ +-- Your SQL goes here + +ALTER TABLE IF EXISTS answers + ADD COLUMN answer_type question_types DEFAULT 'ShortAnswer'; \ No newline at end of file diff --git a/backend/migrations/2023-05-16-105659_questions/down.sql b/backend/migrations/2023-05-16-105659_questions/down.sql new file mode 100644 index 000000000..f6db8d2dd --- /dev/null +++ b/backend/migrations/2023-05-16-105659_questions/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` + +ALTER TYPE question_type RENAME TO question_types \ No newline at end of file diff --git a/backend/migrations/2023-05-16-105659_questions/up.sql b/backend/migrations/2023-05-16-105659_questions/up.sql new file mode 100644 index 000000000..7f6bf8b77 --- /dev/null +++ b/backend/migrations/2023-05-16-105659_questions/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here + +ALTER TYPE question_types RENAME TO question_type diff --git a/backend/migrations/2023-05-16-111625_new_answer_types/down.sql b/backend/migrations/2023-05-16-111625_new_answer_types/down.sql new file mode 100644 index 000000000..a489342aa --- /dev/null +++ b/backend/migrations/2023-05-16-111625_new_answer_types/down.sql @@ -0,0 +1,7 @@ +-- This file should undo anything in `up.sql` + +DROP TABLE short_answer_answers; + +DROP TABLE multi_select_options; + +DROP TABLE multi_select_answers; diff --git a/backend/migrations/2023-05-16-111625_new_answer_types/up.sql b/backend/migrations/2023-05-16-111625_new_answer_types/up.sql new file mode 100644 index 000000000..c411bc2ad --- /dev/null +++ b/backend/migrations/2023-05-16-111625_new_answer_types/up.sql @@ -0,0 +1,42 @@ +-- Your SQL goes here +CREATE TABLE short_answer_answers ( -- TODO: Need to seed this table with data from the current answers table + id SERIAL PRIMARY KEY, + text TEXT NOT NULL, + answer_id INT NOT NULL, + CONSTRAINT fk_short_answer_answer_parent_answer + FOREIGN KEY(answer_id) + REFERENCES answers(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + + +CREATE TABLE multi_select_options( + id SERIAL PRIMARY KEY, + text TEXT NOT NULL, + question_id INT NOT NULL, + CONSTRAINT fk_multi_select_option_parent_question + FOREIGN KEY(question_id) + REFERENCES questions(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +CREATE TABLE multi_select_answers ( + id SERIAL PRIMARY KEY, + option_id INT NOT NULL, + answer_id INT NOT NULL, + + CONSTRAINT fk_multi_select_answer_parent_answer + FOREIGN KEY(answer_id) + REFERENCES answers(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + +-- If the option is deleted, then the selection should be too + CONSTRAINT fk_multi_select_option + FOREIGN KEY(option_id) + REFERENCES multi_select_options(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); \ No newline at end of file diff --git a/backend/migrations/2023-06-14-042126_answer_type_not_null/down.sql b/backend/migrations/2023-06-14-042126_answer_type_not_null/down.sql new file mode 100644 index 000000000..e64fb005a --- /dev/null +++ b/backend/migrations/2023-06-14-042126_answer_type_not_null/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS answers + ALTER COLUMN answer_type DROP NOT NULL; diff --git a/backend/migrations/2023-06-14-042126_answer_type_not_null/up.sql b/backend/migrations/2023-06-14-042126_answer_type_not_null/up.sql new file mode 100644 index 000000000..45e743ce6 --- /dev/null +++ b/backend/migrations/2023-06-14-042126_answer_type_not_null/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS answers + ALTER COLUMN answer_type SET NOT NULL; \ No newline at end of file diff --git a/backend/migrations/2023-06-14-044219_question_type_not_null/down.sql b/backend/migrations/2023-06-14-044219_question_type_not_null/down.sql new file mode 100644 index 000000000..2e31efac7 --- /dev/null +++ b/backend/migrations/2023-06-14-044219_question_type_not_null/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS questions + ALTER COLUMN question_type DROP NOT NULL; diff --git a/backend/migrations/2023-06-14-044219_question_type_not_null/up.sql b/backend/migrations/2023-06-14-044219_question_type_not_null/up.sql new file mode 100644 index 000000000..3a90c14f2 --- /dev/null +++ b/backend/migrations/2023-06-14-044219_question_type_not_null/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE IF EXISTS questions + ALTER COLUMN question_type SET NOT NULL; diff --git a/backend/seed_data/src/seed.rs b/backend/seed_data/src/seed.rs index f542faa2f..259a25127 100644 --- a/backend/seed_data/src/seed.rs +++ b/backend/seed_data/src/seed.rs @@ -1,7 +1,7 @@ #![allow(unused_variables)] use backend::database::models::*; -use backend::database::schema::{AdminLevel, ApplicationStatus, UserGender}; +use backend::database::schema::{AdminLevel, ApplicationStatus, UserGender, QuestionType}; use backend::images::{save_image, try_decode_bytes}; use chrono::naive::NaiveDate; use diesel::pg::PgConnection; @@ -219,9 +219,11 @@ pub fn seed() { let question_one = NewQuestion { title: "What is the meaning of life?".to_string(), max_bytes: 100, - role_ids: vec![senior_mentor_role.id], + role_id: Option::from(senior_mentor_role.id), + // role_ids: vec![senior_mentor_role.id], required: false, description: Some("Please ensure to go into great detail!".to_string()), + question_type: QuestionType::ShortAnswer, } .insert(&connection) .expect("Failed to insert question"); @@ -229,9 +231,10 @@ pub fn seed() { let question_two = NewQuestion { title: "Why do you want to be a Peer Mentor".to_string(), max_bytes: 300, - role_ids: vec![senior_mentor_role.id, mentor_role.id], + role_id: None, required: true, description: Some("Please explain why you would like to be a peer mentor!".to_string()), + question_type: QuestionType::ShortAnswer, } .insert(&connection) .expect("Failed to insert question"); @@ -268,6 +271,7 @@ pub fn seed() { question_id: question_one.id, application_id: application.id, description: "42".to_string(), + answer_type: QuestionType::ShortAnswer, } .insert(&connection) .expect("Failed to insert answer"); diff --git a/backend/server/src/application.rs b/backend/server/src/application.rs index 907984963..70a959e14 100644 --- a/backend/server/src/application.rs +++ b/backend/server/src/application.rs @@ -1,13 +1,13 @@ use diesel::prelude::*; -use crate::database::{ +use crate::{database::{ models::{ Answer, Application, Campaign, Comment, NewAnswer, NewApplication, NewRating, OrganisationUser, Question, Rating, Role, User, }, schema::ApplicationStatus, Database, -}; +}, question_types::AnswerData}; use crate::error::JsonErr; use rocket::{ get, @@ -16,6 +16,7 @@ use rocket::{ serde::{json::Json, Deserialize, Serialize}, FromForm, }; +use crate::question_types::AnswerDataInput; #[derive(Serialize)] pub enum ApplicationError { @@ -28,6 +29,7 @@ pub enum ApplicationError { QuestionNotFound, InvalidInput, CampaignEnded, + AnswerDataNotFound, } #[derive(Deserialize)] @@ -119,13 +121,24 @@ pub async fn create_rating( .await } -#[post("/answer", data = "")] +#[derive(Serialize, Deserialize)] +pub struct AnswerWithData { + pub answer: NewAnswer, + pub data: AnswerDataInput, +} + + +#[post("/answer", data = "")] pub async fn submit_answer( user: User, db: Database, - answer: Json, + answer_with_data: Json, ) -> Result, JsonErr> { db.run(move |conn| { + let answer_with_data = answer_with_data.into_inner(); + let answer = answer_with_data.answer; + let data = answer_with_data.data; + let application = Application::get(answer.application_id, &conn) .ok_or(JsonErr(ApplicationError::AppNotFound, Status::NotFound))?; if application.user_id != user.id { @@ -140,11 +153,15 @@ pub async fn submit_answer( return Err(JsonErr(ApplicationError::InvalidInput, Status::BadRequest)); } - NewAnswer::insert(&answer, &conn).ok_or(JsonErr( + let inserted_answer = NewAnswer::insert(&answer, &conn).ok_or(JsonErr( ApplicationError::UnableToCreate, Status::InternalServerError, ))?; + + // Insert the Answer Data UwU + AnswerDataInput::insert_answer_data(data, conn, &inserted_answer); + Ok(Json(())) }) .await @@ -152,7 +169,13 @@ pub async fn submit_answer( #[derive(Serialize)] pub struct AnswersResponse { - answers: Vec, + answers: Vec, +} + +#[derive(Serialize)] +pub struct AnswerResponse { + answer: Answer, + data: AnswerData, } #[get("//answers")] @@ -170,8 +193,16 @@ pub async fn get_answers( .check() .map_err(|_| JsonErr(ApplicationError::Unauthorized, Status::Forbidden))?; + let mut response: Vec = Vec::new(); + + for answer in Answer::get_all_from_application_id(&conn, application_id) { + let data = AnswerData::get_from_answer(&conn, &answer) + .ok_or(JsonErr(ApplicationError::AnswerDataNotFound, Status::NotFound))?; + response.push(AnswerResponse { answer: answer, data: data }); + } + Ok(Json(AnswersResponse { - answers: Answer::get_all_from_application_id(conn, application_id), + answers: response, })) }) .await diff --git a/backend/server/src/campaigns.rs b/backend/server/src/campaigns.rs index ca64a8eb1..ff5fc3849 100644 --- a/backend/server/src/campaigns.rs +++ b/backend/server/src/campaigns.rs @@ -6,7 +6,7 @@ use crate::{ Campaign, CampaignWithRoles, NewCampaignInput, NewQuestion, OrganisationUser, Role, RoleUpdate, UpdateCampaignInput, User, }, - Database, + Database, schema::QuestionType, }, images::{get_http_image_path, save_image, try_decode_data, ImageLocation}, }; @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use std::fs::remove_file; use uuid::Uuid; +use crate::question_types::QuestionDataInput; #[derive(Serialize)] pub enum CampaignError { @@ -115,11 +116,14 @@ fn default_max_bytes() -> i32 { #[derive(Serialize, Deserialize)] pub struct QuestionInput { pub title: String, + pub common_question: bool, pub description: Option, #[serde(default = "default_max_bytes")] pub max_bytes: i32, #[serde(default)] pub required: bool, + pub question_data: QuestionDataInput, + pub question_type: QuestionType, } #[derive(Deserialize)] @@ -139,17 +143,25 @@ pub async fn new( let NewCampaignWithData { campaign, roles, - questions, + mut questions, } = inner; + + let question_data: Vec = questions + .iter() + .map(|x| { + x.question_data.clone() + }) + .collect(); let mut new_questions: Vec = questions - .into_iter() + .iter_mut() .map(|x| NewQuestion { - role_ids: vec![], - title: x.title, - description: x.description, + role_id: None, + title: x.title.clone(), + description: x.description.clone(), max_bytes: x.max_bytes, required: x.required, + question_type: x.question_type, }) .collect(); @@ -179,20 +191,30 @@ pub async fn new( })?; for question in role.questions_for_role { - if question < new_questions.len() { - new_questions[question].role_ids.push(inserted_role.id); + if questions[question].common_question { + // If question is common + new_questions[question].role_id = None; + } else if let None = new_questions[question].role_id { + // If question is unique and no role_id assigned to it + new_questions[question].role_id = Option::from(inserted_role.id); + } else { + // If question is meant to be unique, but already has a role_id assigned to it + eprintln!("Question is not common, yet has multiple roles asking for it"); + return Err(JsonErr(CampaignError::UnableToCreate, Status::BadRequest)); } } } - for question in new_questions { - if question.role_ids.len() == 0 { - return Err(JsonErr(CampaignError::InvalidInput, Status::BadRequest)); - } - question.insert(conn).ok_or_else(|| { + for (question, question_data) in new_questions.into_iter().zip(question_data.into_iter()) { + + // Insert question (skeleton) into database, and then insert it's data into + // corresponding table in database. + let inserted_id = question.insert(conn).ok_or_else(|| { eprintln!("Failed to create question for some reason"); JsonErr(CampaignError::UnableToCreate, Status::InternalServerError) - })?; + })?.id; + + question_data.insert_question_data(conn, inserted_id); } Ok(Json(campaign)) diff --git a/backend/server/src/database/models.rs b/backend/server/src/database/models.rs index d42360815..c5f7d404b 100644 --- a/backend/server/src/database/models.rs +++ b/backend/server/src/database/models.rs @@ -1,8 +1,9 @@ +use crate::question_types::QuestionData; use crate::images::{get_http_image_path, ImageLocation}; use super::schema::{ answers, applications, campaigns, comments, organisation_users, organisations, questions, - ratings, roles, users, + ratings, roles, users, multi_select_answers, multi_select_options, short_answer_answers }; use super::schema::{AdminLevel, ApplicationStatus, UserGender}; use chrono::NaiveDateTime; @@ -14,6 +15,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::fs::remove_file; use std::path::Path; +use crate::database::schema::QuestionType; #[derive(Queryable)] pub struct User { @@ -784,10 +786,8 @@ impl Role { } pub fn delete_children(conn: &PgConnection, role: Role) -> Option<()> { - use diesel::pg::expression::dsl::any; - let question_items: Vec = questions::table - .filter(any(questions::role_ids).eq(role.id)) + .filter(questions::role_id.eq(role.id)) .load(conn) .map_err(|x| { eprintln!("error in delete_children: {x:?}"); @@ -795,7 +795,7 @@ impl Role { }) .ok()?; - diesel::delete(questions::table.filter(any(questions::role_ids).eq(role.id))) + diesel::delete(questions::table.filter(questions::role_id.eq(role.id))) .execute(conn) .map_err(|x| { eprintln!("error in delete_children: {x:?}"); @@ -978,45 +978,51 @@ impl NewApplication { #[table_name = "questions"] pub struct Question { pub id: i32, - pub role_ids: Vec, + pub role_id: Option, pub title: String, pub description: Option, pub max_bytes: i32, pub required: bool, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, + pub question_type: QuestionType, } #[derive(Insertable, Serialize, Deserialize)] #[table_name = "questions"] pub struct NewQuestion { - pub role_ids: Vec, + pub role_id: Option, pub title: String, pub description: Option, #[serde(default)] pub max_bytes: i32, pub required: bool, + pub question_type: QuestionType, } #[derive(Serialize)] pub struct QuestionResponse { pub id: i32, - pub role_ids: Vec, + pub role_id: Option, pub title: String, pub description: Option, pub max_bytes: i32, pub required: bool, + pub question_data: QuestionData, + pub question_type: QuestionType, } -impl std::convert::From for QuestionResponse { - fn from(question: Question) -> Self { +impl From<(Question, QuestionData)> for QuestionResponse { + fn from(question_with_data: (Question, QuestionData)) -> Self { Self { - id: question.id, - role_ids: question.role_ids, - title: question.title, - description: question.description, - max_bytes: question.max_bytes, - required: question.required, + id: question_with_data.0.id, + role_id: question_with_data.0.role_id, + title: question_with_data.0.title, + description: question_with_data.0.description, + max_bytes: question_with_data.0.max_bytes, + required: question_with_data.0.required, + question_type: question_with_data.0.question_type, + question_data: question_with_data.1 } } } @@ -1030,11 +1036,36 @@ pub struct UpdateQuestionInput { pub required: bool, } +#[derive(Queryable, Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct MultiSelectOption { + pub id: i32, + pub text: String, + pub question_id: i32, +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct MultiSelectOptionInput { + pub text: String, +} + +#[derive(Insertable, Deserialize, Serialize, PartialEq, Debug, Clone)] +#[table_name = "multi_select_options"] +pub struct NewMultiSelectOption { + pub text: String, + pub question_id: i32, +} + +impl NewMultiSelectOption { + pub fn insert(&self, conn: &PgConnection) -> Option { + use crate::database::schema::multi_select_options::dsl::*; + + self.insert_into(multi_select_options).get_result(conn).ok() + } +} + impl Question { pub fn get_first_role(&self) -> i32 { - *self - .role_ids - .get(0) + self.role_id .expect("Question should be for at least one role") } @@ -1055,7 +1086,7 @@ impl Question { pub fn get_all_from_role_id(conn: &PgConnection, role_id_val: i32) -> Vec { diesel::sql_query(&format!( - "select * from questions where {} = any(role_ids)", + "select * from questions where {} = role_id or role_id is null", role_id_val )) .load::(conn) @@ -1064,7 +1095,7 @@ impl Question { pub fn delete_all_from_role_id(conn: &PgConnection, role_id_val: i32) -> bool { diesel::sql_query(&format!( - "delete from questions where {} = any(role_ids)", + "delete from questions where {} = role_id", role_id_val )) .execute(conn) @@ -1114,14 +1145,69 @@ pub struct Answer { pub description: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, + pub answer_type: QuestionType, + // pub answer_data: AnswerData, } -#[derive(Insertable, Deserialize)] +#[derive(Insertable, Deserialize, Serialize)] #[table_name = "answers"] pub struct NewAnswer { pub application_id: i32, pub question_id: i32, pub description: String, + pub answer_type: QuestionType, + // pub answer_data: AnswerData, +} + +#[derive(Queryable, Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct ShortAnswerAnswer { + pub id: i32, + pub text: String, + pub answer_id: i32, +} + +#[derive(Insertable, Deserialize, Serialize, PartialEq, Debug, Clone)] +#[table_name = "short_answer_answers"] +pub struct NewShortAnswerAnswer { + pub text: String, + pub answer_id: i32, +} + +impl NewShortAnswerAnswer { + pub fn insert(&self, conn: &PgConnection) -> Option { + use crate::database::schema::short_answer_answers::dsl::*; + + self.insert_into(short_answer_answers).get_result(conn).ok() + } +} + +/// A struct to store answers for multi-choice, multi-select and drop-down +/// question types, as these work the same way in the backend. The only +/// difference is the restriction on number of answers for each type, +/// with multi-choice only having one unique answer (vector length 1) +/// +/// \ +/// The vector will store the id's of each option selected. +#[derive(Queryable, Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct MultiSelectAnswer { + pub id: i32, + pub option_id: i32, + pub answer_id: i32, +} + +#[derive(Insertable, Deserialize, Serialize, PartialEq, Debug, Clone)] +#[table_name = "multi_select_answers"] +pub struct NewMultiSelectAnswer { + pub option_id: i32, + pub answer_id: i32, +} + +impl NewMultiSelectAnswer { + pub fn insert(&self, conn: &PgConnection) -> Option { + use crate::database::schema::multi_select_answers::dsl::*; + + self.insert_into(multi_select_answers).get_result(conn).ok() + } } impl Answer { @@ -1346,7 +1432,7 @@ pub struct CampaignInfo { pub ends_at: NaiveDateTime, } -impl std::convert::From for CampaignInfo { +impl From for CampaignInfo { fn from(campaign: Campaign) -> Self { Self { id: campaign.id, diff --git a/backend/server/src/database/schema.rs b/backend/server/src/database/schema.rs index a5a526d4f..d91f3bd9e 100644 --- a/backend/server/src/database/schema.rs +++ b/backend/server/src/database/schema.rs @@ -11,6 +11,16 @@ pub enum ApplicationStatus { Success, } +#[derive(Debug, DbEnum, PartialEq, FromFormField, Serialize, Deserialize, Clone, Copy)] +#[DbValueStyle = "PascalCase"] +pub enum QuestionType { + ShortAnswer, + MultiSelect, + MultiChoice, + DropDown, +} + + #[derive(Debug, DbEnum, PartialEq, Serialize, Deserialize, Clone, Copy)] #[DbValueStyle = "PascalCase"] pub enum AdminLevel { @@ -34,6 +44,9 @@ pub enum UserGender { } table! { + use diesel::sql_types::*; + use super::QuestionTypeMapping; + answers (id) { id -> Int4, application_id -> Int4, @@ -41,6 +54,7 @@ table! { description -> Text, created_at -> Timestamp, updated_at -> Timestamp, + answer_type -> QuestionTypeMapping, } } @@ -85,6 +99,26 @@ table! { } } +table! { + use diesel::sql_types::*; + + multi_select_answers (id) { + id -> Int4, + option_id -> Int4, + answer_id -> Int4, + } +} + +table! { + use diesel::sql_types::*; + + multi_select_options (id) { + id -> Int4, + text -> Text, + question_id -> Int4, + } +} + table! { use diesel::sql_types::*; use super::AdminLevelMapping; @@ -111,16 +145,18 @@ table! { table! { use diesel::sql_types::*; + use super::QuestionTypeMapping; questions (id) { id -> Int4, - role_ids -> Array, + role_id -> Nullable, title -> Text, description -> Nullable, max_bytes -> Int4, required -> Bool, created_at -> Timestamp, updated_at -> Timestamp, + question_type -> QuestionTypeMapping, } } @@ -149,6 +185,16 @@ table! { } } +table! { + use diesel::sql_types::*; + + short_answer_answers (id) { + id -> Int4, + text -> Text, + answer_id -> Int4, + } +} + table! { use diesel::sql_types::*; use super::UserGenderMapping; @@ -188,10 +234,13 @@ allow_tables_to_appear_in_same_query!( applications, campaigns, comments, + multi_select_answers, + multi_select_options, organisation_users, organisations, questions, ratings, roles, + short_answer_answers, users, ); diff --git a/backend/server/src/database/schema_new.rs b/backend/server/src/database/schema_new.rs new file mode 100644 index 000000000..b4b972d78 --- /dev/null +++ b/backend/server/src/database/schema_new.rs @@ -0,0 +1,208 @@ +// @generated automatically by Diesel CLI. + +pub mod sql_types { + #[derive(diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "admin_level"))] + pub struct AdminLevel; + + #[derive(diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "application_status"))] + pub struct ApplicationStatus; + + #[derive(diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "question_type"))] + pub struct QuestionType; +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::QuestionType; + + answers (id) { + id -> Int4, + application_id -> Int4, + question_id -> Int4, + description -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + answer_type -> QuestionType, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::ApplicationStatus; + + applications (id) { + id -> Int4, + user_id -> Int4, + role_id -> Int4, + status -> ApplicationStatus, + created_at -> Timestamp, + updated_at -> Timestamp, + private_status -> Nullable, + } +} + +diesel::table! { + campaigns (id) { + id -> Int4, + organisation_id -> Int4, + name -> Text, + cover_image -> Nullable, + description -> Text, + starts_at -> Timestamp, + ends_at -> Timestamp, + published -> Bool, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + +diesel::table! { + comments (id) { + id -> Int4, + application_id -> Int4, + commenter_user_id -> Int4, + description -> Text, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + +diesel::table! { + multi_select_answers (id) { + id -> Int4, + option_id -> Int4, + answer_id -> Int4, + } +} + +diesel::table! { + multi_select_options (id) { + id -> Int4, + text -> Text, + question_id -> Int4, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::AdminLevel; + + organisation_users (id) { + id -> Int4, + user_id -> Int4, + organisation_id -> Int4, + admin_level -> AdminLevel, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + +diesel::table! { + organisations (id) { + id -> Int4, + name -> Text, + logo -> Nullable, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::QuestionType; + + questions (id) { + id -> Int4, + title -> Text, + description -> Nullable, + max_bytes -> Int4, + required -> Bool, + created_at -> Timestamp, + updated_at -> Timestamp, + role_id -> Nullable, + question_type -> Nullable, + } +} + +diesel::table! { + ratings (id) { + id -> Int4, + application_id -> Int4, + rater_user_id -> Int4, + rating -> Int4, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + +diesel::table! { + roles (id) { + id -> Int4, + campaign_id -> Int4, + name -> Text, + description -> Nullable, + min_available -> Int4, + max_available -> Int4, + finalised -> Bool, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + +diesel::table! { + short_answer_answers (id) { + id -> Int4, + text -> Text, + answer_id -> Int4, + } +} + +diesel::table! { + users (id) { + id -> Int4, + email -> Text, + zid -> Text, + display_name -> Text, + degree_name -> Text, + degree_starting_year -> Int4, + superuser -> Bool, + created_at -> Timestamp, + updated_at -> Timestamp, + } +} + +diesel::joinable!(answers -> applications (application_id)); +diesel::joinable!(answers -> questions (question_id)); +diesel::joinable!(applications -> roles (role_id)); +diesel::joinable!(applications -> users (user_id)); +diesel::joinable!(campaigns -> organisations (organisation_id)); +diesel::joinable!(comments -> applications (application_id)); +diesel::joinable!(comments -> users (commenter_user_id)); +diesel::joinable!(multi_select_answers -> answers (answer_id)); +diesel::joinable!(multi_select_answers -> multi_select_options (option_id)); +diesel::joinable!(multi_select_options -> questions (question_id)); +diesel::joinable!(organisation_users -> organisations (organisation_id)); +diesel::joinable!(organisation_users -> users (user_id)); +diesel::joinable!(ratings -> applications (application_id)); +diesel::joinable!(ratings -> users (rater_user_id)); +diesel::joinable!(roles -> campaigns (campaign_id)); +diesel::joinable!(short_answer_answers -> answers (answer_id)); + +diesel::allow_tables_to_appear_in_same_query!( + answers, + applications, + campaigns, + comments, + multi_select_answers, + multi_select_options, + organisation_users, + organisations, + questions, + ratings, + roles, + short_answer_answers, + users, +); diff --git a/backend/server/src/lib.rs b/backend/server/src/lib.rs index 80b721c5e..87008b041 100644 --- a/backend/server/src/lib.rs +++ b/backend/server/src/lib.rs @@ -16,5 +16,6 @@ pub mod permissions; pub mod question; pub mod role; pub mod state; -pub mod static_resources; +pub mod question_types; pub mod user; +pub mod static_resources; diff --git a/backend/server/src/question.rs b/backend/server/src/question.rs index d45bc56f3..a07ef241f 100644 --- a/backend/server/src/question.rs +++ b/backend/server/src/question.rs @@ -1,9 +1,9 @@ -use crate::database::{ +use crate::{database::{ models::{ Campaign, OrganisationUser, Question, QuestionResponse, Role, UpdateQuestionInput, User, }, Database, -}; +}, question_types::QuestionData}; use crate::error::JsonErr; use rocket::{ @@ -18,6 +18,7 @@ use std::convert::From; #[derive(Serialize)] pub enum QuestionError { QuestionNotFound, + QuestionDataNotFound, UpdateFailed, InsufficientPermissions, } @@ -36,15 +37,18 @@ pub async fn get_question( .ok_or(JsonErr(QuestionError::QuestionNotFound, Status::NotFound))?; let c = Campaign::get_from_id(&conn, r.campaign_id) .ok_or(JsonErr(QuestionError::QuestionNotFound, Status::NotFound))?; + let d: Option = QuestionData::get_from_question_id(&conn, q.id); + if let None = d { return Err(JsonErr(QuestionError::QuestionNotFound, Status::NotFound)); } OrganisationUser::role_admin_level(q.get_first_role(), user.id, conn) .is_at_least_director() .or(c.published) .check() .map_err(|_| JsonErr(QuestionError::InsufficientPermissions, Status::Forbidden))?; - Ok(q) + let question_with_data = (q,d.unwrap()); + Ok(question_with_data) }) .await - .map(|q| Json(QuestionResponse::from(q))) + .map(|question_with_data| Json(QuestionResponse::from(question_with_data))) } #[put("/", data = "")] diff --git a/backend/server/src/question_types.rs b/backend/server/src/question_types.rs new file mode 100644 index 000000000..0f2eed4f7 --- /dev/null +++ b/backend/server/src/question_types.rs @@ -0,0 +1,291 @@ +use diesel::{PgConnection, RunQueryDsl}; +use serde::{Deserialize, Serialize}; + +use crate::diesel::QueryDsl; +use diesel::expression_methods::ExpressionMethods; +use crate::database::models::{Question, Answer}; +use crate::database::models::{MultiSelectAnswer, MultiSelectOption, MultiSelectOptionInput, NewMultiSelectAnswer, NewMultiSelectOption, NewShortAnswerAnswer, ShortAnswerAnswer}; +use crate::database::schema::QuestionType; +// QUESTION TYPES +// In this file, add new question types that we need to implement +// e.g. +// MultiSelect +// ShortAnswer +// + +/// An enum that represents all the types of questions that CHAOS can handle. +/// This stores all the data for each question type. +/// +/// \ +/// Some question types are stored in-memory and JSON using the same struct, and only differ +/// in their implementation when inserting to the database and in their restrictions +/// (e.g. max answers allowed in single multi-choice) +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub enum QuestionData { + ShortAnswer, + MultiSelect(Vec), // Vector of option text + MultiChoice(Vec), + DropDown(Vec), +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub enum QuestionDataInput { + ShortAnswer, + MultiSelect(Vec), // Vector of option text + MultiChoice(Vec), + DropDown(Vec), +} + +/// An enum that represents all the types of questions answers that CHAOS can handle. +/// This stores all the data for each answer type. +/// +/// \ +/// Some answers types are stored in-memory and JSON using the same struct, and only differ +/// in their implementation when inserting to the database and in their restrictions +/// (e.g. max answers allowed in single multi-choice) +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub enum AnswerData { + ShortAnswer(String), + MultiSelect(Vec), + MultiChoice(i32), + DropDown(i32), +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub enum AnswerDataInput { + ShortAnswer(String), + MultiSelect(Vec), // Vector of option text + MultiChoice(i32), + DropDown(i32), +} + +impl QuestionDataInput { + /** + * Insert the inner struct into its corresponding table according to the type given by question_type + */ + pub fn insert_question_data( + self, + conn: &PgConnection, + question_id: i32, + ) { + + match self { + QuestionDataInput::ShortAnswer => { + // No need for any question data insertion, as short-answer + // questions only need a title (contained in parent table) + }, + QuestionDataInput::MultiSelect(multi_select_data) => { + // Insert Multi Select Data into table + let new_data: Vec = multi_select_data.into_iter().map(|x| { + NewMultiSelectOption { + text: x, + question_id, + } + }).collect(); + + for option in new_data { + option.insert(conn).ok_or_else(|| { + eprintln!("Failed to create question data for some reason"); + }).ok(); + } + }, + QuestionDataInput::MultiChoice(multi_choice_data) => { + let new_data: Vec = multi_choice_data.into_iter().map(|x| { + NewMultiSelectOption { + text: x, + question_id, + } + }).collect(); + + for option in new_data { + option.insert(conn).ok_or_else(|| { + eprintln!("Failed to create question data for some reason"); + }).ok(); + } + }, + QuestionDataInput::DropDown(drop_down_data) => { + let new_data: Vec = drop_down_data.into_iter().map(|x| { + NewMultiSelectOption { + text: x, + question_id, + } + }).collect(); + + for option in new_data { + option.insert(conn).ok_or_else(|| { + eprintln!("Failed to create question data for some reason"); + }).ok(); + } + }, + } + } +} + +impl QuestionData { + pub fn get_from_question_id(conn: &PgConnection, q_id: i32) -> Option { + + let question: Question; + + match Question::get_from_id(conn, q_id) { + Some(q) => { + question = q; + } + None => { + return None + } + } + + return match question.question_type { + QuestionType::ShortAnswer => { Some(QuestionData::ShortAnswer) }, + QuestionType::MultiSelect => { + use crate::database::schema::multi_select_options::dsl::*; + + let data: Vec = multi_select_options + .filter(question_id.eq(q_id)) + .load(conn) + .unwrap_or_else(|_| vec![]); + + Some(QuestionData::MultiSelect(data.into_iter().map(|x| { + x.text + }).collect())) + }, + QuestionType::MultiChoice => { + use crate::database::schema::multi_select_options::dsl::*; + + let data: Vec = multi_select_options + .filter(question_id.eq(q_id)) + .load(conn) + .unwrap_or_else(|_| vec![]); + + Some(QuestionData::MultiChoice(data.into_iter().map(|x| { + x.text + }).collect())) + }, + QuestionType::DropDown => { + use crate::database::schema::multi_select_options::dsl::*; + + let data: Vec = multi_select_options + .filter(question_id.eq(q_id)) + .load(conn) + .unwrap_or_else(|_| vec![]); + + Some(QuestionData::DropDown(data.into_iter().map(|x| { + x.text + }).collect())) + }, + }; + } +} + +impl AnswerDataInput { + pub fn insert_answer_data( + self, + conn: &mut PgConnection, + answer: &Answer, + ) { + match self { + AnswerDataInput::ShortAnswer(short_answer_data) => { + let answer = NewShortAnswerAnswer { + text: short_answer_data, + answer_id: answer.id, + }; + + answer.insert(conn).ok_or_else(|| { + eprintln!("Failed to create answer data for some reason"); + }).ok(); + }, + AnswerDataInput::MultiSelect(multi_select_data) => { + let new_answers: Vec = multi_select_data.into_iter().map(|x| { + NewMultiSelectAnswer { + option_id: x, + answer_id: answer.id, + } + }).collect(); + + for answer in new_answers { + answer.insert(conn).ok_or_else(|| { + eprintln!("Failed to create answer data for some reason"); + }).ok(); + } + }, + AnswerDataInput::MultiChoice(option_id) => { + NewMultiSelectAnswer { + option_id, + answer_id: answer.id, + }.insert(conn).ok_or_else(|| { + eprintln!("Failed to create answer data for some reason"); + }).ok(); + }, + AnswerDataInput::DropDown(option_id) => { + NewMultiSelectAnswer { + option_id, + answer_id: answer.id, + }.insert(conn).ok_or_else(|| { + eprintln!("Failed to create answer data for some reason"); + }).ok(); + } + } + } +} + +impl AnswerData { + pub fn get_from_answer(conn: &PgConnection, answer: &Answer) -> Option { + return match answer.answer_type { + QuestionType::ShortAnswer => { + use crate::database::schema::short_answer_answers::dsl::*; + + let answer_data: ShortAnswerAnswer = short_answer_answers.filter( + answer_id.eq(answer.id) + ).first(conn).ok()?; + + Some(AnswerData::ShortAnswer(answer_data.text)) + }, + QuestionType::MultiSelect => { + use crate::database::schema::multi_select_answers::dsl::*; + + let answers: Vec = multi_select_answers.filter( + answer_id.eq(answer.id) + ).load(conn).unwrap_or_else(|_| vec![]); + + Some(AnswerData::MultiSelect(answers.into_iter().map(|x| { + x.option_id + }).collect())) + }, + QuestionType::MultiChoice => { + use crate::database::schema::multi_select_answers::dsl::*; + + let answer_data: MultiSelectAnswer = multi_select_answers.filter( + answer_id.eq(answer.id) + ).first(conn).ok().unwrap(); + + Some(AnswerData::MultiChoice(answer_data.option_id)) + }, + QuestionType::DropDown => { + use crate::database::schema::multi_select_answers::dsl::*; + + let answer_data: MultiSelectAnswer = multi_select_answers.filter( + answer_id.eq(answer.id) + ).first(conn).ok().unwrap(); + + Some(AnswerData::DropDown(answer_data.option_id)) + }, + }; + } +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct MultiSelectQuestion { + options: Vec +} + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct MultiSelectQuestionInput { + options: Vec +} + + +#[derive(Deserialize, Serialize, PartialEq, Debug, Clone)] +pub struct MultiSelectAnswerInput { + options_selected: Vec, +} + diff --git a/backend/server/src/role.rs b/backend/server/src/role.rs index 51426c84b..a9946ba9f 100644 --- a/backend/server/src/role.rs +++ b/backend/server/src/role.rs @@ -1,11 +1,11 @@ -use crate::database::{ +use crate::{database::{ models::{ Application, Campaign, GetQuestionsResponse, OrganisationUser, Question, Role, RoleUpdate, User, }, schema::ApplicationStatus, Database, -}; +}, question_types::QuestionData}; use chrono::NaiveDateTime; use diesel::PgConnection; use rocket::{ @@ -116,6 +116,7 @@ pub enum QuestionsError { CampaignNotFound, Unauthorized, UserNotFound, + QuestionDataNotFound, } #[get("//questions")] @@ -140,8 +141,16 @@ pub async fn get_questions( .or(campaign.published) .check() .map_err(|_| Json(QuestionsError::Unauthorized))?; + + let mut questions_with_data = Vec::new(); + + for question in Question::get_all_from_role_id(conn, role_id) { + let data = QuestionData::get_from_question_id(conn, question.id); + if let None = data { return Err(Json(QuestionsError::QuestionDataNotFound)); } + questions_with_data.push((question, data.unwrap())); + } Ok(Json(GetQuestionsResponse { - questions: Question::get_all_from_role_id(conn, role_id) + questions: questions_with_data .into_iter() .map(|x| x.into()) .collect(),