263 lines
8.6 KiB
Rust
263 lines
8.6 KiB
Rust
//! 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::<Vec<_>>().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<Vec<Course>, ControllerError> {
|
|
self.db.get_courses().await.map_err(Into::into)
|
|
}
|
|
|
|
pub async fn get_course(&self, id: CourseId) -> Result<Course, ControllerError> {
|
|
self.db.get_course(id).await.map_err(Into::into)
|
|
}
|
|
|
|
pub async fn create_course(&self, course: CourseData) -> Result<Course, ControllerError> {
|
|
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<Course, ControllerError> {
|
|
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<CourseId>,
|
|
) -> 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<Course, ControllerError> {
|
|
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<Course, ControllerError> {
|
|
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<CourseData, ControllerError> {
|
|
let name = course.name.trim().to_owned();
|
|
if name.is_empty() {
|
|
return Err(CoursesControllerError::EmptyName.into());
|
|
}
|
|
|
|
let teachers: Vec<String> = 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");
|
|
}
|
|
}
|