//! Courses and associations, and their weekly time slots. //! //! Each one has a folder in the storage named after it, where its files live: //! `Cours/CS-477 Advanced operating systems`, `Associations/AGEPoly`. The folder is //! moved when the course is renamed. use chrono::NaiveTime; use thiserror::Error; use tracing::error; use crate::core::{ controller::{ControllerError, is_http_url, non_blank}, models::{ course::{Course, CourseData, CourseId, CourseKind}, tag::{TagId, TagScope}, time_slot::{TimeSlotData, TimeSlotId}, }, repositories::RepositoryError, }; /// The timetable shows monday to friday... const FIRST_WEEKDAY: u8 = 1; const LAST_WEEKDAY: u8 = 5; /// ...from 8:00 to 19:00: slots must fit in it const DAY_START: NaiveTime = NaiveTime::from_hms_opt(8, 0, 0).unwrap(); const DAY_END: NaiveTime = NaiveTime::from_hms_opt(19, 0, 0).unwrap(); /// Longest name of a file or folder in the storage, in characters const MAX_FILE_NAME: usize = 120; /// Folder of a course in the storage, e.g. `Cours/CS-477 Advanced operating systems` fn course_folder(course: &CourseData) -> String { let root = match course.kind { CourseKind::Course => "Cours", CourseKind::Association => "Associations", }; let name = match &course.code { Some(code) => format!("{code} {}", course.name), None => course.name.clone(), }; format!("{root}/{}", file_name(&name)) } /// A name usable as a file or folder name on any NAS (samba included): without the /// characters Windows forbids, e.g. "Notes: memory/cache" -> "Notes - memory-cache" pub(crate) fn file_name(name: &str) -> String { let replaced: String = name .replace(':', " - ") .chars() .map(|c| match c { '/' | '\\' | '*' | '?' | '"' | '<' | '>' | '|' => '-', c if c.is_control() => ' ', c => c, }) .collect(); let collapsed = replaced.split_whitespace().collect::>().join(" "); let truncated: String = collapsed.chars().take(MAX_FILE_NAME).collect(); // A trailing dot or space is dropped by windows let trimmed = truncated.trim_matches(|c: char| c == '.' || c == ' '); if trimmed.is_empty() { "Sans titre".to_owned() } else { trimmed.to_owned() } } impl super::AppController { pub async fn get_courses(&self) -> Result, ControllerError> { self.db.get_courses().await.map_err(Into::into) } pub async fn get_course(&self, id: CourseId) -> Result { self.db.get_course(id).await.map_err(Into::into) } pub async fn create_course(&self, course: CourseData) -> Result { let course = validate_course(course)?; let folder = course_folder(&course); self.check_folder_free(&folder, None).await?; self.db .create_course(course, &folder) .await .map_err(Into::into) } /// Replaces a course, keeping its kind. Its folder follows its new name. pub async fn update_course( &self, id: CourseId, course: CourseData, ) -> Result { let existing = self.db.get_course(id).await?; let course = validate_course(CourseData { kind: existing.kind, ..course })?; let folder = course_folder(&course); if folder == existing.folder { return self .db .update_course(id, course, &folder) .await .map_err(Into::into); } self.check_folder_free(&folder, Some(id)).await?; let moved = match self.storage.rename(&existing.folder, &folder).await { Ok(()) => true, // No file yet Err(RepositoryError::NotFound(_)) => false, Err(err) => return Err(err.into()), }; match self.db.update_course(id, course, &folder).await { Ok(course) => Ok(course), Err(err) => { if moved && let Err(err) = self.storage.rename(&folder, &existing.folder).await { error!("Unable to move {folder} back to {}: {err}", existing.folder); } Err(err.into()) } } } /// Deletes the course with everything attached to it, its files included pub async fn delete_course(&self, id: CourseId) -> Result<(), ControllerError> { let course = self.db.get_course(id).await?; self.db.delete_course(id).await?; self.storage.delete_dir(&course.folder).await?; Ok(()) } /// `FolderTaken` if another course (than `except`) already has this folder async fn check_folder_free( &self, folder: &str, except: Option, ) -> Result<(), ControllerError> { let courses = self.db.get_courses().await?; if courses .iter() .any(|course| course.folder == folder && Some(course.id) != except) { return Err(CoursesControllerError::FolderTaken(folder.to_owned()).into()); } Ok(()) } pub async fn add_slot( &self, course_id: CourseId, slot: TimeSlotData, ) -> Result { if !(FIRST_WEEKDAY..=LAST_WEEKDAY).contains(&slot.weekday) { return Err(CoursesControllerError::InvalidWeekday(slot.weekday).into()); } if slot.start_time >= slot.end_time || slot.start_time < DAY_START || slot.end_time > DAY_END { return Err(CoursesControllerError::InvalidSlotTime.into()); } self.check_tags(&[slot.tag_id], TagScope::Slot).await?; self.db.add_slot(course_id, slot).await.map_err(Into::into) } pub async fn delete_slot( &self, course_id: CourseId, slot_id: TimeSlotId, ) -> Result { self.db .delete_slot(course_id, slot_id) .await .map_err(Into::into) } } /// Trims the texts, turns blank optional fields into `None`, and checks the rules /// of a course fn validate_course(course: CourseData) -> Result { let name = course.name.trim().to_owned(); if name.is_empty() { return Err(CoursesControllerError::EmptyName.into()); } let teachers: Vec = course .teachers .iter() .map(|teacher| teacher.trim().to_owned()) .filter(|teacher| !teacher.is_empty()) .collect(); if teachers.is_empty() && course.kind == CourseKind::Course { return Err(CoursesControllerError::NoTeacher.into()); } let main_page_url = non_blank(course.main_page_url); if main_page_url .as_deref() .is_some_and(|url| !is_http_url(url)) { return Err(CoursesControllerError::InvalidMainPageUrl.into()); } Ok(CourseData { kind: course.kind, name, code: non_blank(course.code), teachers, main_page_url, }) } /// Errors specific to this domain area, turned into status codes by the api #[derive(Error, Debug)] pub enum CoursesControllerError { #[error("The name of the course is empty")] EmptyName, #[error("A course needs at least one teacher")] NoTeacher, #[error("The main page must be an http(s) url")] InvalidMainPageUrl, #[error("Invalid weekday {0}: slots must be from monday (1) to friday (5)")] InvalidWeekday(u8), #[error("A slot must start before it ends, between 8:00 and 19:00")] InvalidSlotTime, #[error("Unknown tag {0}")] UnknownTag(TagId), #[error("Another course or association already uses the folder {0}")] FolderTaken(String), } #[cfg(test)] mod tests { use super::*; #[test] fn file_names_are_safe() { assert_eq!( file_name("Lecture 2 Notes: Memory/cache"), "Lecture 2 Notes - Memory-cache" ); assert_eq!(file_name(" a *b?. "), "a -b-"); assert_eq!(file_name("..."), "Sans titre"); } #[test] fn folder_of_a_course() { let course = CourseData { kind: CourseKind::Course, name: "Advanced OS".to_owned(), code: Some("CS-477".to_owned()), teachers: Vec::new(), main_page_url: None, }; assert_eq!(course_folder(&course), "Cours/CS-477 Advanced OS"); let association = CourseData { kind: CourseKind::Association, code: None, ..course }; assert_eq!(course_folder(&association), "Associations/Advanced OS"); } }