studium/src/core/controller/courses.rs
Antoine Pelletier 5e160ae11c wip
2026-09-29 09:47:27 +02:00

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");
}
}