diff --git a/CHANGELOG.md b/CHANGELOG.md index d17e365c..9fc913fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + - [583](https://github.com/thoth-pub/thoth/issues/583) - Add new field, Permanently Withdrawn Date, to Work for Out-of-print or Withdrawn from Sale Works. + ### Changed - [218](https://github.com/thoth-pub/thoth/issues/218) - Make series ISSN optional diff --git a/thoth-api/migrations/v0.12.3/down.sql b/thoth-api/migrations/v0.12.3/down.sql index a0217cc8..36c5925f 100644 --- a/thoth-api/migrations/v0.12.3/down.sql +++ b/thoth-api/migrations/v0.12.3/down.sql @@ -3,3 +3,10 @@ ALTER TABLE series ALTER TABLE series ALTER COLUMN issn_digital SET NOT NULL; + +ALTER TABLE work + DROP CONSTRAINT work_active_withdrawn_date_check, + DROP CONSTRAINT work_inactive_no_withdrawn_date_check, + DROP CONSTRAINT work_withdrawn_date_after_publication_date_check, + DROP COLUMN withdrawn_date; + diff --git a/thoth-api/migrations/v0.12.3/up.sql b/thoth-api/migrations/v0.12.3/up.sql index 46469ff6..23434478 100644 --- a/thoth-api/migrations/v0.12.3/up.sql +++ b/thoth-api/migrations/v0.12.3/up.sql @@ -4,3 +4,22 @@ ALTER TABLE series ALTER TABLE series ALTER COLUMN issn_digital DROP NOT NULL; +ALTER TABLE work + ADD COLUMN withdrawn_date DATE; + +UPDATE work + SET withdrawn_date = updated_at + WHERE (work_status = 'withdrawn-from-sale' + OR work_status = 'out-of-print'); + +ALTER TABLE work + ADD CONSTRAINT work_active_withdrawn_date_check CHECK + ((work_status = 'withdrawn-from-sale' OR work_status = 'out-of-print') + OR (work_status NOT IN ('withdrawn-from-sale', 'out-of-print') AND withdrawn_date IS NULL)), + + ADD CONSTRAINT work_inactive_no_withdrawn_date_check CHECK + (((work_status = 'withdrawn-from-sale' OR work_status = 'out-of-print') AND withdrawn_date IS NOT NULL) + OR (work_status NOT IN ('withdrawn-from-sale', 'out-of-print'))), + + ADD CONSTRAINT work_withdrawn_date_after_publication_date_check CHECK + (withdrawn_date IS NULL OR (publication_date < withdrawn_date)); \ No newline at end of file diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index 88b0890d..e17fcf44 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -23,6 +23,7 @@ use crate::model::publisher::*; use crate::model::reference::*; use crate::model::series::*; use crate::model::subject::*; +use crate::model::work::crud::WorkValidation; use crate::model::work::*; use crate::model::work_relation::*; use crate::model::Convert; @@ -1516,6 +1517,8 @@ impl MutationRoot { .account_access .can_edit(publisher_id_from_imprint_id(&context.db, data.imprint_id)?)?; + data.validate()?; + Work::create(&context.db, &data).map_err(|e| e.into()) } @@ -1705,17 +1708,20 @@ impl MutationRoot { work.can_be_chapter(&context.db)?; } + data.validate()?; let account_id = context.token.jwt.as_ref().unwrap().account_id(&context.db); // update the work and, if it succeeds, synchronise its children statuses and pub. date match work.update(&context.db, &data, &account_id) { Ok(w) => { - // update chapters if their pub. data or work_status doesn't match the parent's + // update chapters if their pub. data, withdrawn_date or work_status doesn't match the parent's for child in work.children(&context.db)? { if child.publication_date != w.publication_date || child.work_status != w.work_status + || child.withdrawn_date != w.withdrawn_date { let mut data: PatchWork = child.clone().into(); data.publication_date = w.publication_date; + data.withdrawn_date = w.withdrawn_date; data.work_status = w.work_status.clone(); child.update(&context.db, &data, &account_id)?; } @@ -2279,6 +2285,13 @@ impl Work { self.publication_date } + #[graphql( + description = "Date a work was withdrawn from publication. Only applies to out of print and withdrawn from sale works." + )] + pub fn withdrawn_date(&self) -> Option { + self.withdrawn_date + } + pub fn place(&self) -> Option<&String> { self.place.as_ref() } diff --git a/thoth-api/src/model/work/crud.rs b/thoth-api/src/model/work/crud.rs index 160272ab..343f424f 100644 --- a/thoth-api/src/model/work/crud.rs +++ b/thoth-api/src/model/work/crud.rs @@ -1,6 +1,6 @@ use super::{ - NewWork, NewWorkHistory, PatchWork, Work, WorkField, WorkHistory, WorkOrderBy, WorkStatus, - WorkType, + NewWork, NewWorkHistory, PatchWork, Work, WorkField, WorkHistory, WorkOrderBy, WorkProperties, + WorkStatus, WorkType, }; use crate::graphql::model::TimeExpression; use crate::graphql::utils::{Direction, Expression}; @@ -176,6 +176,10 @@ impl Crud for Work { Direction::Asc => query.order(dsl::publication_date.asc()), Direction::Desc => query.order(dsl::publication_date.desc()), }, + WorkField::WithdrawnDate => match order.direction { + Direction::Asc => query.order(dsl::withdrawn_date.asc()), + Direction::Desc => query.order(dsl::withdrawn_date.desc()), + }, WorkField::Place => match order.direction { Direction::Asc => query.order(dsl::place.asc()), Direction::Desc => query.order(dsl::place.desc()), @@ -399,6 +403,21 @@ impl DbInsert for NewWorkHistory { db_insert!(work_history::table); } +pub trait WorkValidation +where + Self: WorkProperties, +{ + fn validate(&self) -> ThothResult<()> { + self.withdrawn_date_error()?; + self.no_withdrawn_date_error()?; + self.withdrawn_date_before_publication_date_error() + } +} + +impl WorkValidation for NewWork {} + +impl WorkValidation for PatchWork {} + #[cfg(test)] mod tests { use super::*; diff --git a/thoth-api/src/model/work/mod.rs b/thoth-api/src/model/work/mod.rs index 2fc57d54..6046a431 100644 --- a/thoth-api/src/model/work/mod.rs +++ b/thoth-api/src/model/work/mod.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use std::fmt; use strum::Display; use strum::EnumString; +use thoth_errors::{ThothError, ThothResult}; use uuid::Uuid; use crate::graphql::utils::Direction; @@ -98,6 +99,7 @@ pub enum WorkField { #[strum(serialize = "DOI")] Doi, PublicationDate, + WithdrawnDate, Place, PageCount, PageBreakdown, @@ -144,6 +146,7 @@ pub struct Work { pub imprint_id: Uuid, pub doi: Option, pub publication_date: Option, + pub withdrawn_date: Option, pub place: Option, pub page_count: Option, pub page_breakdown: Option, @@ -184,6 +187,7 @@ pub struct WorkWithRelations { pub edition: Option, pub doi: Option, pub publication_date: Option, + pub withdrawn_date: Option, pub place: Option, pub page_count: Option, pub page_breakdown: Option, @@ -234,6 +238,7 @@ pub struct NewWork { pub imprint_id: Uuid, pub doi: Option, pub publication_date: Option, + pub withdrawn_date: Option, pub place: Option, pub page_count: Option, pub page_breakdown: Option, @@ -275,6 +280,7 @@ pub struct PatchWork { pub imprint_id: Uuid, pub doi: Option, pub publication_date: Option, + pub withdrawn_date: Option, pub place: Option, pub page_count: Option, pub page_breakdown: Option, @@ -326,6 +332,93 @@ pub struct WorkOrderBy { pub direction: Direction, } +impl WorkStatus { + fn is_withdrawn_out_of_print(&self) -> bool { + matches!(self, WorkStatus::OutOfPrint | WorkStatus::WithdrawnFromSale) + } +} + +pub trait WorkProperties { + fn work_status(&self) -> &WorkStatus; + fn publication_date(&self) -> &Option; + fn withdrawn_date(&self) -> &Option; + + fn is_withdrawn_out_of_print(&self) -> bool { + self.work_status().is_withdrawn_out_of_print() + } + + fn has_withdrawn_date(&self) -> bool { + self.withdrawn_date().is_some() + } + + fn withdrawn_date_error(&self) -> ThothResult<()> { + if !self.is_withdrawn_out_of_print() && self.has_withdrawn_date() { + return Err(ThothError::WithdrawnDateError); + } + Ok(()) + } + + fn no_withdrawn_date_error(&self) -> ThothResult<()> { + if self.is_withdrawn_out_of_print() && !self.has_withdrawn_date() { + return Err(ThothError::NoWithdrawnDateError); + } + Ok(()) + } + + fn withdrawn_date_before_publication_date_error(&self) -> ThothResult<()> { + if let (Some(withdrawn_date), Some(publication_date)) = + (self.withdrawn_date(), self.publication_date()) + { + if withdrawn_date < publication_date { + return Err(ThothError::WithdrawnDateBeforePublicationDateError); + } + } + Ok(()) + } +} + +impl WorkProperties for Work { + fn work_status(&self) -> &WorkStatus { + &self.work_status + } + + fn withdrawn_date(&self) -> &Option { + &self.withdrawn_date + } + + fn publication_date(&self) -> &Option { + &self.publication_date + } +} + +impl WorkProperties for NewWork { + fn work_status(&self) -> &WorkStatus { + &self.work_status + } + + fn withdrawn_date(&self) -> &Option { + &self.withdrawn_date + } + + fn publication_date(&self) -> &Option { + &self.publication_date + } +} + +impl WorkProperties for PatchWork { + fn work_status(&self) -> &WorkStatus { + &self.work_status + } + + fn withdrawn_date(&self) -> &Option { + &self.withdrawn_date + } + + fn publication_date(&self) -> &Option { + &self.publication_date + } +} + impl Work { pub fn compile_fulltitle(&self) -> String { if let Some(subtitle) = &self.subtitle.clone() { @@ -376,6 +469,7 @@ impl From for PatchWork { imprint_id: w.imprint_id, doi: w.doi, publication_date: w.publication_date, + withdrawn_date: w.withdrawn_date, place: w.place, page_count: w.page_count, page_breakdown: w.page_breakdown, @@ -480,6 +574,7 @@ fn test_workfield_display() { assert_eq!(format!("{}", WorkField::Edition), "Edition"); assert_eq!(format!("{}", WorkField::Doi), "DOI"); assert_eq!(format!("{}", WorkField::PublicationDate), "PublicationDate"); + assert_eq!(format!("{}", WorkField::WithdrawnDate), "WithdrawnDate"); assert_eq!(format!("{}", WorkField::Place), "Place"); assert_eq!(format!("{}", WorkField::PageCount), "PageCount"); assert_eq!(format!("{}", WorkField::PageBreakdown), "PageBreakdown"); @@ -621,6 +716,10 @@ fn test_workfield_fromstr() { WorkField::from_str("PublicationDate").unwrap(), WorkField::PublicationDate ); + assert_eq!( + WorkField::from_str("WithdrawnDate").unwrap(), + WorkField::WithdrawnDate + ); assert_eq!(WorkField::from_str("Place").unwrap(), WorkField::Place); assert_eq!( WorkField::from_str("PageCount").unwrap(), @@ -727,6 +826,7 @@ fn test_work_into_patchwork() { imprint_id: Uuid::parse_str("00000000-0000-0000-BBBB-000000000002").unwrap(), doi: Some(Doi::from_str("https://doi.org/10.00001/BOOK.0001").unwrap()), publication_date: chrono::NaiveDate::from_ymd_opt(1999, 12, 31), + withdrawn_date: None, place: Some("León, Spain".to_string()), page_count: Some(123), page_breakdown: None, @@ -766,6 +866,7 @@ fn test_work_into_patchwork() { assert_eq!(work.imprint_id, patch_work.imprint_id); assert_eq!(work.doi, patch_work.doi); assert_eq!(work.publication_date, patch_work.publication_date); + assert_eq!(work.withdrawn_date, patch_work.withdrawn_date); assert_eq!(work.place, patch_work.place); assert_eq!(work.page_count, patch_work.page_count); assert_eq!(work.page_breakdown, patch_work.page_breakdown); diff --git a/thoth-api/src/schema.rs b/thoth-api/src/schema.rs index 898bf3df..e78c5350 100644 --- a/thoth-api/src/schema.rs +++ b/thoth-api/src/schema.rs @@ -531,6 +531,7 @@ table! { imprint_id -> Uuid, doi -> Nullable, publication_date -> Nullable, + withdrawn_date -> Nullable, place -> Nullable, page_count -> Nullable, page_breakdown -> Nullable, diff --git a/thoth-app/src/component/new_work.rs b/thoth-app/src/component/new_work.rs index 8978f82b..ebe7aa48 100644 --- a/thoth-app/src/component/new_work.rs +++ b/thoth-app/src/component/new_work.rs @@ -97,6 +97,7 @@ pub enum Msg { ChangeEdition(String), ChangeDoi(String), ChangeDate(String), + ChangeWithdrawnDate(String), ChangePlace(String), ChangePageCount(String), ChangePageBreakdown(String), @@ -276,7 +277,7 @@ impl Component for NewWorkComponent { } else if let Ok(result) = self.doi.parse::() { self.work.doi.neq_assign(Some(result)); } - // Clear any fields which are not applicable to the currently selected work type. + // Clear any fields which are not applicable to the currently selected work type or work status. // (Do not clear them before the save point as the user may change the type again.) if self.work.work_type == WorkType::BookChapter { self.work.edition = None; @@ -288,6 +289,11 @@ impl Component for NewWorkComponent { self.work.last_page = None; self.work.page_interval = None; } + if self.work.work_status != WorkStatus::WithdrawnFromSale + && self.work.work_status != WorkStatus::OutOfPrint + { + self.work.withdrawn_date = None; + } let body = CreateWorkRequestBody { variables: Variables { work_type: self.work.work_type.clone(), @@ -299,6 +305,7 @@ impl Component for NewWorkComponent { edition: self.work.edition, doi: self.work.doi.clone(), publication_date: self.work.publication_date.clone(), + withdrawn_date: self.work.withdrawn_date.clone(), place: self.work.place.clone(), page_count: self.work.page_count, page_breakdown: self.work.page_breakdown.clone(), @@ -376,6 +383,9 @@ impl Component for NewWorkComponent { } } Msg::ChangeDate(value) => self.work.publication_date.neq_assign(value.to_opt_string()), + Msg::ChangeWithdrawnDate(value) => { + self.work.withdrawn_date.neq_assign(value.to_opt_string()) + } Msg::ChangePlace(value) => self.work.place.neq_assign(value.to_opt_string()), Msg::ChangePageCount(value) => self.work.page_count.neq_assign(value.to_opt_int()), Msg::ChangePageBreakdown(value) => { @@ -448,6 +458,9 @@ impl Component for NewWorkComponent { // Grey out chapter-specific or "book"-specific fields // based on currently selected work type. let is_chapter = self.work.work_type == WorkType::BookChapter; + let is_not_withdrawn_or_out_of_print = self.work.work_status + != WorkStatus::WithdrawnFromSale + && self.work.work_status != WorkStatus::OutOfPrint; html! { <>