-
-
-
-
-
- {{ $t(`weekdays.${day.weekday}`) }}
-
-
-
-
-
- {{ hour }}:00
-
-
-
-
-
-
-
-
-
-
-
- {{ block.course.name }}
-
-
- {{ toHourMinutes(block.slot.start_time) }}–{{
- toHourMinutes(block.slot.end_time)
- }}
- · {{ block.tag?.name }}
-
-
-
-
- {{ block.course.name }}
- {{ block.course.code }}
-
- {{ block.tag?.name }} · {{ $t(`weekdays.${block.slot.weekday}`) }}
- {{ toHourMinutes(block.slot.start_time) }}–{{
- toHourMinutes(block.slot.end_time)
- }}
-
- {{ block.course.teachers.join(', ') }}
-
-
-
-
- {{ fromMinutes(selectedRange.start) }}–{{ fromMinutes(selectedRange.end) }}
-
-
-
-
+
diff --git a/src/api/course_files.rs b/src/api/course_files.rs
new file mode 100644
index 0000000..93ae2d6
--- /dev/null
+++ b/src/api/course_files.rs
@@ -0,0 +1,168 @@
+//! Files of a course. For now only the lecture notes: markdown saved in the storage and
+//! rendered to pdf on every save.
+
+use aide::{
+ axum::{
+ ApiRouter,
+ routing::{get_with, post_with},
+ },
+ transform::TransformOperation,
+};
+use axum::{
+ Json,
+ extract::Path,
+ http::{StatusCode, header},
+ response::{IntoResponse, Response},
+};
+
+use crate::{
+ api::{
+ helpers::{desc, error_response},
+ models::{CoursePath, LectureNoteBody, LectureNotePath},
+ },
+ core::{
+ controller::AppController,
+ models::course_file::{CourseFile, LectureNoteContent, SavedLectureNote},
+ },
+};
+
+pub fn routes() -> ApiRouter {
+ ApiRouter::new()
+ .api_route(
+ "/api/courses/{id}/files",
+ get_with(get_course_files, get_course_files_docs),
+ )
+ .api_route(
+ "/api/courses/{id}/notes",
+ post_with(create_lecture_note, create_lecture_note_docs),
+ )
+ .api_route(
+ "/api/notes/{id}",
+ get_with(get_lecture_note, get_lecture_note_docs)
+ .put_with(update_lecture_note, update_lecture_note_docs)
+ .delete_with(delete_lecture_note, delete_lecture_note_docs),
+ )
+ .api_route(
+ "/api/notes/{id}/pdf",
+ get_with(get_lecture_note_pdf, get_lecture_note_pdf_docs),
+ )
+}
+
+async fn get_course_files(
+ controller: AppController,
+ Path(path): Path
,
+) -> Result>, (StatusCode, String)> {
+ match controller.get_course_files(path.id).await {
+ Ok(files) => Ok(Json(files)),
+ Err(err) => error_response("get_course_files", err),
+ }
+}
+
+fn get_course_files_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Course files")
+ .summary("Get the files of a course (lecture notes...), without their content")
+ .response_with::<404, String, _>(desc("The course does not exist"))
+}
+
+async fn create_lecture_note(
+ controller: AppController,
+ Path(path): Path,
+ Json(body): Json,
+) -> Result<(StatusCode, Json), (StatusCode, String)> {
+ match controller
+ .create_lecture_note(path.id, body.title, body.content)
+ .await
+ {
+ Ok(saved) => Ok((StatusCode::CREATED, Json(saved))),
+ Err(err) => error_response("create_lecture_note", err),
+ }
+}
+
+fn create_lecture_note_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Course files")
+ .summary("Create a lecture note, tagged `notes`, and render it to pdf")
+ .description("The note is created even when the rendering fails: see `pdf_error`.")
+ .response_with::<201, Json, _>(desc("The created note"))
+ .response_with::<400, String, _>(desc("The note is invalid"))
+ .response_with::<404, String, _>(desc("The course does not exist"))
+}
+
+async fn get_lecture_note(
+ controller: AppController,
+ Path(path): Path,
+) -> Result, (StatusCode, String)> {
+ match controller.get_lecture_note(path.id).await {
+ Ok(note) => Ok(Json(note)),
+ Err(err) => error_response("get_lecture_note", err),
+ }
+}
+
+fn get_lecture_note_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Course files")
+ .summary("Get a lecture note with its markdown content")
+ .response_with::<404, String, _>(desc("The note does not exist"))
+}
+
+async fn update_lecture_note(
+ controller: AppController,
+ Path(path): Path,
+ Json(body): Json,
+) -> Result, (StatusCode, String)> {
+ match controller
+ .update_lecture_note(path.id, body.title, body.content)
+ .await
+ {
+ Ok(saved) => Ok(Json(saved)),
+ Err(err) => error_response("update_lecture_note", err),
+ }
+}
+
+fn update_lecture_note_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Course files")
+ .summary("Replace a lecture note and render it to pdf again")
+ .description("The note is saved even when the rendering fails: see `pdf_error`.")
+ .response_with::<400, String, _>(desc("The note is invalid"))
+ .response_with::<404, String, _>(desc("The note does not exist"))
+}
+
+async fn delete_lecture_note(
+ controller: AppController,
+ Path(path): Path,
+) -> Result {
+ match controller.delete_lecture_note(path.id).await {
+ Ok(()) => Ok(StatusCode::NO_CONTENT),
+ Err(err) => error_response("delete_lecture_note", err),
+ }
+}
+
+fn delete_lecture_note_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Course files")
+ .summary("Delete a lecture note and its files")
+ .response_with::<204, (), _>(desc("The note was deleted"))
+ .response_with::<404, String, _>(desc("The note does not exist"))
+}
+
+async fn get_lecture_note_pdf(
+ controller: AppController,
+ Path(path): Path,
+) -> Result {
+ match controller.get_lecture_note_pdf(path.id).await {
+ Ok(pdf) => Ok((
+ [
+ (header::CONTENT_TYPE, "application/pdf"),
+ (header::CACHE_CONTROL, "no-cache"),
+ ],
+ pdf,
+ )
+ .into_response()),
+ Err(err) => error_response("get_lecture_note_pdf", err),
+ }
+}
+
+fn get_lecture_note_pdf_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Course files")
+ .summary("Get the pdf of a lecture note")
+ .response_with::<404, String, _>(desc(
+ "The note does not exist, or its last rendering failed",
+ ))
+}
diff --git a/src/api/courses.rs b/src/api/courses.rs
index 226e19c..62cd21b 100644
--- a/src/api/courses.rs
+++ b/src/api/courses.rs
@@ -1,7 +1,7 @@
use aide::{
axum::{
ApiRouter,
- routing::{get_with, put_with},
+ routing::{delete_with, get_with, post_with},
},
transform::TransformOperation,
};
@@ -9,32 +9,38 @@ use axum::{Json, extract::Path, http::StatusCode};
use crate::{
api::{
- helpers::{desc, unexpected_error},
- models::{CourseBody, CoursePath},
- },
- core::{
- controller::{AppController, ControllerError},
- models::course::Course,
+ helpers::{desc, error_response},
+ models::{CourseBody, CoursePath, SlotPath, TimeSlotBody},
},
+ core::{controller::AppController, models::course::Course},
};
pub fn routes() -> ApiRouter {
ApiRouter::new()
.api_route(
- "/",
+ "/api/courses",
get_with(get_courses, get_courses_docs).post_with(create_course, create_course_docs),
)
.api_route(
- "/{id}",
- put_with(update_course, update_course_docs)
+ "/api/courses/{id}",
+ get_with(get_course, get_course_docs)
+ .put_with(update_course, update_course_docs)
.delete_with(delete_course, delete_course_docs),
)
+ .api_route(
+ "/api/courses/{id}/slots",
+ post_with(add_slot, add_slot_docs),
+ )
+ .api_route(
+ "/api/courses/{id}/slots/{slot_id}",
+ delete_with(delete_slot, delete_slot_docs),
+ )
}
async fn get_courses(controller: AppController) -> Result>, (StatusCode, String)> {
match controller.get_courses().await {
Ok(courses) => Ok(Json(courses)),
- Err(err) => unexpected_error("get_courses", err),
+ Err(err) => error_response("get_courses", err),
}
}
@@ -43,20 +49,35 @@ fn get_courses_docs(op: TransformOperation) -> TransformOperation {
.summary("Get every course with its time slots")
}
+async fn get_course(
+ controller: AppController,
+ Path(path): Path,
+) -> Result, (StatusCode, String)> {
+ match controller.get_course(path.id).await {
+ Ok(course) => Ok(Json(course)),
+ Err(err) => error_response("get_course", err),
+ }
+}
+
+fn get_course_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Courses")
+ .summary("Get a course with its time slots")
+ .response_with::<404, String, _>(desc("The course does not exist"))
+}
+
async fn create_course(
controller: AppController,
Json(body): Json,
) -> Result<(StatusCode, Json), (StatusCode, String)> {
match controller.create_course(body.into()).await {
Ok(course) => Ok((StatusCode::CREATED, Json(course))),
- Err(ControllerError::Course(err)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
- Err(err) => unexpected_error("create_course", err),
+ Err(err) => error_response("create_course", err),
}
}
fn create_course_docs(op: TransformOperation) -> TransformOperation {
op.tag("Courses")
- .summary("Create a course with its time slots")
+ .summary("Create a course, without any time slot")
.response_with::<201, Json, _>(desc("The created course"))
.response_with::<400, String, _>(desc("The course is invalid"))
}
@@ -68,17 +89,13 @@ async fn update_course(
) -> Result, (StatusCode, String)> {
match controller.update_course(path.id, body.into()).await {
Ok(course) => Ok(Json(course)),
- Err(ControllerError::Course(err)) => Err((StatusCode::BAD_REQUEST, err.to_string())),
- Err(err) if err.is_not_found() => {
- Err((StatusCode::NOT_FOUND, "Course not found".to_owned()))
- }
- Err(err) => unexpected_error("update_course", err),
+ Err(err) => error_response("update_course", err),
}
}
fn update_course_docs(op: TransformOperation) -> TransformOperation {
op.tag("Courses")
- .summary("Replace a course and all its time slots")
+ .summary("Replace a course, keeping its time slots")
.response_with::<400, String, _>(desc("The course is invalid"))
.response_with::<404, String, _>(desc("The course does not exist"))
}
@@ -89,16 +106,47 @@ async fn delete_course(
) -> Result {
match controller.delete_course(path.id).await {
Ok(()) => Ok(StatusCode::NO_CONTENT),
- Err(err) if err.is_not_found() => {
- Err((StatusCode::NOT_FOUND, "Course not found".to_owned()))
- }
- Err(err) => unexpected_error("delete_course", err),
+ Err(err) => error_response("delete_course", err),
}
}
fn delete_course_docs(op: TransformOperation) -> TransformOperation {
op.tag("Courses")
- .summary("Delete a course and its time slots")
+ .summary("Delete a course with its slots, grades, tasks, notes and files")
.response_with::<204, (), _>(desc("The course was deleted"))
.response_with::<404, String, _>(desc("The course does not exist"))
}
+
+async fn add_slot(
+ controller: AppController,
+ Path(path): Path,
+ Json(body): Json,
+) -> Result, (StatusCode, String)> {
+ match controller.add_slot(path.id, body.into()).await {
+ Ok(course) => Ok(Json(course)),
+ Err(err) => error_response("add_slot", err),
+ }
+}
+
+fn add_slot_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Courses")
+ .summary("Add a weekly time slot to a course, returns the updated course")
+ .response_with::<400, String, _>(desc("The slot is invalid"))
+ .response_with::<404, String, _>(desc("The course does not exist"))
+}
+
+async fn delete_slot(
+ controller: AppController,
+ Path(path): Path,
+) -> Result, (StatusCode, String)> {
+ match controller.delete_slot(path.id, path.slot_id).await {
+ Ok(course) => Ok(Json(course)),
+ Err(err) => error_response("delete_slot", err),
+ }
+}
+
+fn delete_slot_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Courses")
+ .summary("Remove a time slot from a course, returns the updated course")
+ .response_with::<404, String, _>(desc("The slot is not in this course"))
+}
diff --git a/src/api/docs.rs b/src/api/docs.rs
index b583304..a918670 100644
--- a/src/api/docs.rs
+++ b/src/api/docs.rs
@@ -19,6 +19,18 @@ pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi {
name: "Courses".to_owned(),
..Default::default()
})
+ .tag(Tag {
+ name: "Grades".to_owned(),
+ ..Default::default()
+ })
+ .tag(Tag {
+ name: "Tasks".to_owned(),
+ ..Default::default()
+ })
+ .tag(Tag {
+ name: "Course files".to_owned(),
+ ..Default::default()
+ })
.tag(Tag {
name: "misc".to_owned(),
..Default::default()
diff --git a/src/api/grades.rs b/src/api/grades.rs
new file mode 100644
index 0000000..8019de2
--- /dev/null
+++ b/src/api/grades.rs
@@ -0,0 +1,99 @@
+//! Grades of a course. Every change returns all the grades of the course with the new
+//! average, so the page never needs a second request.
+
+use aide::{
+ axum::{
+ ApiRouter,
+ routing::{get_with, put_with},
+ },
+ transform::TransformOperation,
+};
+use axum::{Json, extract::Path, http::StatusCode};
+
+use crate::{
+ api::{
+ helpers::{desc, error_response},
+ models::{CoursePath, GradeBody, GradePath},
+ },
+ core::{controller::AppController, models::grade::CourseGrades},
+};
+
+pub fn routes() -> ApiRouter {
+ ApiRouter::new()
+ .api_route(
+ "/api/courses/{id}/grades",
+ get_with(get_course_grades, get_course_grades_docs)
+ .post_with(create_grade, create_grade_docs),
+ )
+ .api_route(
+ "/api/grades/{id}",
+ put_with(update_grade, update_grade_docs).delete_with(delete_grade, delete_grade_docs),
+ )
+}
+
+type GradesResult = Result, (StatusCode, String)>;
+
+async fn get_course_grades(
+ controller: AppController,
+ Path(path): Path,
+) -> GradesResult {
+ match controller.get_course_grades(path.id).await {
+ Ok(grades) => Ok(Json(grades)),
+ Err(err) => error_response("get_course_grades", err),
+ }
+}
+
+fn get_course_grades_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Grades")
+ .summary("Get the grades of a course and their weighted average out of 6")
+ .response_with::<404, String, _>(desc("The course does not exist"))
+}
+
+async fn create_grade(
+ controller: AppController,
+ Path(path): Path,
+ Json(body): Json,
+) -> GradesResult {
+ match controller.create_grade(path.id, body.into()).await {
+ Ok(grades) => Ok(Json(grades)),
+ Err(err) => error_response("create_grade", err),
+ }
+}
+
+fn create_grade_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Grades")
+ .summary("Add a grade to a course, returns every grade of the course")
+ .response_with::<400, String, _>(desc("The grade is invalid"))
+ .response_with::<404, String, _>(desc("The course does not exist"))
+}
+
+async fn update_grade(
+ controller: AppController,
+ Path(path): Path,
+ Json(body): Json,
+) -> GradesResult {
+ match controller.update_grade(path.id, body.into()).await {
+ Ok(grades) => Ok(Json(grades)),
+ Err(err) => error_response("update_grade", err),
+ }
+}
+
+fn update_grade_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Grades")
+ .summary("Replace a grade, returns every grade of its course")
+ .response_with::<400, String, _>(desc("The grade is invalid"))
+ .response_with::<404, String, _>(desc("The grade does not exist"))
+}
+
+async fn delete_grade(controller: AppController, Path(path): Path) -> GradesResult {
+ match controller.delete_grade(path.id).await {
+ Ok(grades) => Ok(Json(grades)),
+ Err(err) => error_response("delete_grade", err),
+ }
+}
+
+fn delete_grade_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Grades")
+ .summary("Delete a grade, returns the grades left in its course")
+ .response_with::<404, String, _>(desc("The grade does not exist"))
+}
diff --git a/src/api/helpers.rs b/src/api/helpers.rs
index dc052ae..c19c832 100644
--- a/src/api/helpers.rs
+++ b/src/api/helpers.rs
@@ -2,7 +2,7 @@ use aide::transform::TransformResponse;
use axum::http::StatusCode;
use tracing::error;
-use crate::core::controller::ControllerError;
+use crate::core::{controller::ControllerError, repositories::RepositoryError};
/// Last resort branch of a handler `match`: logs the error and answers 500
pub fn unexpected_error(fn_name: &str, err: ControllerError) -> Result {
@@ -13,6 +13,25 @@ pub fn unexpected_error(fn_name: &str, err: ControllerError) -> Result(fn_name: &str, err: ControllerError) -> Result {
+ let invalid = match &err {
+ ControllerError::Course(err) => Some(err.to_string()),
+ ControllerError::Grade(err) => Some(err.to_string()),
+ ControllerError::Task(err) => Some(err.to_string()),
+ ControllerError::LectureNote(err) => Some(err.to_string()),
+ _ => None,
+ };
+ match (invalid, err) {
+ (Some(message), _) => Err((StatusCode::BAD_REQUEST, message)),
+ (None, ControllerError::RepositoryError(err @ RepositoryError::NotFound(_))) => {
+ Err((StatusCode::NOT_FOUND, err.to_string()))
+ }
+ (None, err) => unexpected_error(fn_name, err),
+ }
+}
+
/// Shorthand to document a response: `.response_with::<404, (), _>(desc("..."))`
pub fn desc(
description: &str,
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 67488eb..8477164 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -19,11 +19,14 @@ use tracing::error;
use crate::core::controller::AppController;
+mod course_files;
mod courses;
mod docs;
+mod grades;
mod helpers;
mod models;
-mod slot_tags;
+mod tags;
+mod tasks;
pub fn get_router(controller: AppController) -> Router {
aide::generate::on_error(|err| error!("aide generated error: {err}"));
@@ -38,8 +41,11 @@ pub fn get_router(controller: AppController) -> Router {
|op| op.tag("misc").summary("Get app version"),
),
)
- .nest_api_service("/api/courses", courses::routes())
- .nest_api_service("/api/slot-tags", slot_tags::routes())
+ .merge(courses::routes())
+ .nest_api_service("/api/tags", tags::routes())
+ .merge(grades::routes())
+ .merge(tasks::routes())
+ .merge(course_files::routes())
.nest_api_service("/api/docs", docs::routes())
.finish_api_with(&mut api, docs::api_docs_metadata)
.layer(Extension(controller))
diff --git a/src/api/models.rs b/src/api/models.rs
index 2bf5573..0482724 100644
--- a/src/api/models.rs
+++ b/src/api/models.rs
@@ -1,14 +1,17 @@
-//! Request bodies, kept apart from the domain models so the public contract does not
-//! change every time a domain model does.
+//! Request bodies and path parameters, kept apart from the domain models so the public
+//! contract does not change every time a domain model does.
-use chrono::NaiveTime;
+use chrono::{DateTime, NaiveTime, Utc};
use schemars::JsonSchema;
use serde::Deserialize;
use crate::core::models::{
course::{CourseData, CourseId},
- slot_tag::SlotTagId,
- time_slot::TimeSlotData,
+ course_file::CourseFileId,
+ grade::{GradeData, GradeId},
+ tag::TagId,
+ task::{TaskData, TaskId},
+ time_slot::{TimeSlotData, TimeSlotId},
};
#[derive(Deserialize, JsonSchema)]
@@ -16,7 +19,36 @@ pub struct CoursePath {
pub id: CourseId,
}
-/// A course and all its slots. On update, the slots replace the existing ones.
+#[derive(Deserialize, JsonSchema)]
+pub struct SlotPath {
+ /// Id of the course
+ pub id: CourseId,
+ pub slot_id: TimeSlotId,
+}
+
+#[derive(Deserialize, JsonSchema)]
+pub struct GradePath {
+ pub id: GradeId,
+}
+
+#[derive(Deserialize, JsonSchema)]
+pub struct TaskPath {
+ pub id: TaskId,
+}
+
+#[derive(Deserialize, JsonSchema)]
+pub struct LectureNotePath {
+ pub id: CourseFileId,
+}
+
+/// Period `[from, to[`, at most 62 days
+#[derive(Deserialize, JsonSchema)]
+pub struct PeriodQuery {
+ pub from: DateTime,
+ pub to: DateTime,
+}
+
+/// A course, without its slots: they are added and removed one by one
#[derive(Deserialize, JsonSchema)]
pub struct CourseBody {
pub name: String,
@@ -25,12 +57,11 @@ pub struct CourseBody {
pub teachers: Vec,
/// http(s) url of the main page of the course
pub main_page_url: Option,
- pub slots: Vec,
}
#[derive(Deserialize, JsonSchema)]
pub struct TimeSlotBody {
- pub tag_id: SlotTagId,
+ pub tag_id: TagId,
/// ISO weekday: 1 = monday ... 5 = friday
pub weekday: u8,
/// Between 08:00 and 19:00, before `end_time`
@@ -38,6 +69,49 @@ pub struct TimeSlotBody {
pub end_time: NaiveTime,
}
+/// A grade on any scale, e.g. 20 out of 100
+#[derive(Deserialize, JsonSchema)]
+pub struct GradeBody {
+ pub name: String,
+ /// Between 0 and `max_score`
+ pub score: f64,
+ /// Positive
+ pub max_score: f64,
+ /// Positive, relative to the other grades of the course
+ pub weight: f64,
+}
+
+#[derive(Deserialize, JsonSchema)]
+pub struct TaskBody {
+ pub name: String,
+ pub description: Option,
+ /// http(s) urls
+ #[serde(default)]
+ pub links: Vec,
+ pub deadline: Option>,
+ #[serde(default)]
+ pub done: bool,
+ /// At least one for a task, optional for a subtask
+ #[serde(default)]
+ pub tag_ids: Vec,
+}
+
+#[derive(Deserialize, JsonSchema)]
+pub struct CreateTaskBody {
+ /// Creates a subtask of this task, which must be a task of the same course
+ pub parent_id: Option,
+ #[serde(flatten)]
+ pub task: TaskBody,
+}
+
+#[derive(Deserialize, JsonSchema)]
+pub struct LectureNoteBody {
+ /// Subtitle of the pdf, e.g. "Lecture 2 Notes: Memory subsystem"
+ pub title: String,
+ /// Markdown, without front matter: it is generated from the course
+ pub content: String,
+}
+
impl From for CourseData {
fn from(value: CourseBody) -> Self {
CourseData {
@@ -45,7 +119,6 @@ impl From for CourseData {
code: value.code,
teachers: value.teachers,
main_page_url: value.main_page_url,
- slots: value.slots.into_iter().map(Into::into).collect(),
}
}
}
@@ -60,3 +133,27 @@ impl From for TimeSlotData {
}
}
}
+
+impl From for GradeData {
+ fn from(value: GradeBody) -> Self {
+ GradeData {
+ name: value.name,
+ score: value.score,
+ max_score: value.max_score,
+ weight: value.weight,
+ }
+ }
+}
+
+impl From for TaskData {
+ fn from(value: TaskBody) -> Self {
+ TaskData {
+ name: value.name,
+ description: value.description,
+ links: value.links,
+ deadline: value.deadline,
+ done: value.done,
+ tag_ids: value.tag_ids,
+ }
+ }
+}
diff --git a/src/api/slot_tags.rs b/src/api/slot_tags.rs
deleted file mode 100644
index a74c058..0000000
--- a/src/api/slot_tags.rs
+++ /dev/null
@@ -1,28 +0,0 @@
-use aide::{
- axum::{ApiRouter, routing::get_with},
- transform::TransformOperation,
-};
-use axum::{Json, http::StatusCode};
-
-use crate::{
- api::helpers::unexpected_error,
- core::{controller::AppController, models::slot_tag::SlotTag},
-};
-
-pub fn routes() -> ApiRouter {
- ApiRouter::new().api_route("/", get_with(get_slot_tags, get_slot_tags_docs))
-}
-
-async fn get_slot_tags(
- controller: AppController,
-) -> Result>, (StatusCode, String)> {
- match controller.get_slot_tags().await {
- Ok(tags) => Ok(Json(tags)),
- Err(err) => unexpected_error("get_slot_tags", err),
- }
-}
-
-fn get_slot_tags_docs(op: TransformOperation) -> TransformOperation {
- op.tag("Courses")
- .summary("Get the kinds of time slots (lecture, exercises, lab...)")
-}
diff --git a/src/api/tags.rs b/src/api/tags.rs
new file mode 100644
index 0000000..bd29bb1
--- /dev/null
+++ b/src/api/tags.rs
@@ -0,0 +1,40 @@
+use aide::{
+ axum::{ApiRouter, routing::get_with},
+ transform::TransformOperation,
+};
+use axum::{Json, extract::Query, http::StatusCode};
+use schemars::JsonSchema;
+use serde::Deserialize;
+
+use crate::{
+ api::helpers::error_response,
+ core::{
+ controller::AppController,
+ models::tag::{Tag, TagScope},
+ },
+};
+
+pub fn routes() -> ApiRouter {
+ ApiRouter::new().api_route("/", get_with(get_tags, get_tags_docs))
+}
+
+#[derive(Deserialize, JsonSchema)]
+struct TagsQuery {
+ /// Only the tags that can label this
+ scope: Option,
+}
+
+async fn get_tags(
+ controller: AppController,
+ Query(query): Query,
+) -> Result>, (StatusCode, String)> {
+ match controller.get_tags(query.scope).await {
+ Ok(tags) => Ok(Json(tags)),
+ Err(err) => error_response("get_tags", err),
+ }
+}
+
+fn get_tags_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Courses")
+ .summary("Get the tags of the time slots, tasks and files (lecture, exam, slides...)")
+}
diff --git a/src/api/tasks.rs b/src/api/tasks.rs
new file mode 100644
index 0000000..3ab0619
--- /dev/null
+++ b/src/api/tasks.rs
@@ -0,0 +1,126 @@
+//! Tasks of a course. Every change returns all the tasks of the course, nested and
+//! sorted, with the deadlines: the page never needs a second request.
+
+use aide::{
+ axum::{
+ ApiRouter,
+ routing::{get_with, put_with},
+ },
+ transform::TransformOperation,
+};
+use axum::{
+ Json,
+ extract::{Path, Query},
+ http::StatusCode,
+};
+
+use crate::{
+ api::{
+ helpers::{desc, error_response},
+ models::{CoursePath, CreateTaskBody, PeriodQuery, TaskBody, TaskPath},
+ },
+ core::{
+ controller::AppController,
+ models::task::{CalendarTask, CourseTasks},
+ },
+};
+
+pub fn routes() -> ApiRouter {
+ ApiRouter::new()
+ .api_route(
+ "/api/tasks",
+ get_with(get_calendar_tasks, get_calendar_tasks_docs),
+ )
+ .api_route(
+ "/api/courses/{id}/tasks",
+ get_with(get_course_tasks, get_course_tasks_docs)
+ .post_with(create_task, create_task_docs),
+ )
+ .api_route(
+ "/api/tasks/{id}",
+ put_with(update_task, update_task_docs).delete_with(delete_task, delete_task_docs),
+ )
+}
+
+async fn get_calendar_tasks(
+ controller: AppController,
+ Query(period): Query,
+) -> Result>, (StatusCode, String)> {
+ match controller.get_calendar_tasks(period.from, period.to).await {
+ Ok(tasks) => Ok(Json(tasks)),
+ Err(err) => error_response("get_calendar_tasks", err),
+ }
+}
+
+fn get_calendar_tasks_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Tasks")
+ .summary("Get the tasks and subtasks of every course with a deadline in a period")
+ .response_with::<400, String, _>(desc("The period is invalid"))
+}
+
+type TasksResult = Result, (StatusCode, String)>;
+
+async fn get_course_tasks(controller: AppController, Path(path): Path) -> TasksResult {
+ match controller.get_course_tasks(path.id).await {
+ Ok(tasks) => Ok(Json(tasks)),
+ Err(err) => error_response("get_course_tasks", err),
+ }
+}
+
+fn get_course_tasks_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Tasks")
+ .summary("Get the tasks of a course with their subtasks")
+ .response_with::<404, String, _>(desc("The course does not exist"))
+}
+
+async fn create_task(
+ controller: AppController,
+ Path(path): Path,
+ Json(body): Json,
+) -> TasksResult {
+ match controller
+ .create_task(path.id, body.parent_id, body.task.into())
+ .await
+ {
+ Ok(tasks) => Ok(Json(tasks)),
+ Err(err) => error_response("create_task", err),
+ }
+}
+
+fn create_task_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Tasks")
+ .summary("Add a task (or a subtask) to a course, returns every task of the course")
+ .response_with::<400, String, _>(desc("The task is invalid"))
+ .response_with::<404, String, _>(desc("The course or the parent task does not exist"))
+}
+
+async fn update_task(
+ controller: AppController,
+ Path(path): Path,
+ Json(body): Json,
+) -> TasksResult {
+ match controller.update_task(path.id, body.into()).await {
+ Ok(tasks) => Ok(Json(tasks)),
+ Err(err) => error_response("update_task", err),
+ }
+}
+
+fn update_task_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Tasks")
+ .summary("Replace a task, returns every task of its course")
+ .response_with::<400, String, _>(desc("The task is invalid"))
+ .response_with::<404, String, _>(desc("The task does not exist"))
+}
+
+async fn delete_task(controller: AppController, Path(path): Path) -> TasksResult {
+ match controller.delete_task(path.id).await {
+ Ok(tasks) => Ok(Json(tasks)),
+ Err(err) => error_response("delete_task", err),
+ }
+}
+
+fn delete_task_docs(op: TransformOperation) -> TransformOperation {
+ op.tag("Tasks")
+ .summary("Delete a task and its subtasks, returns the tasks left in its course")
+ .response_with::<404, String, _>(desc("The task does not exist"))
+}
diff --git a/src/core/controller/course_files.rs b/src/core/controller/course_files.rs
new file mode 100644
index 0000000..722e947
--- /dev/null
+++ b/src/core/controller/course_files.rs
@@ -0,0 +1,229 @@
+//! Files of a course. For now only the lecture notes: markdown written in the app,
+//! rendered to pdf with the course template, and tagged `notes`.
+//!
+//! Both files of a note live in the storage, next to the other files of the course:
+//! `courses/{course}/notes/{note}.md` and `.pdf`. The `.md` holds the front matter
+//! expected by the template, so it can also be rendered by hand with `mdpdf`. The
+//! editor only sees what comes after it.
+
+use thiserror::Error;
+use tracing::error;
+
+use crate::core::{
+ controller::{ControllerError, courses::course_dir},
+ models::{
+ course::{Course, CourseId},
+ course_file::{CourseFile, CourseFileId, FileKind, LectureNoteContent, SavedLectureNote},
+ tag::NOTES_TAG,
+ },
+ repositories::{RepositoryError, pdf_renderer::RenderError},
+};
+
+/// Values of the front matter that do not depend on the note
+#[derive(Debug, Clone, Default)]
+pub struct NotesSettings {
+ pub student_name: Option,
+ pub student_id: Option,
+ /// Absolute path of the logo
+ pub logo: Option,
+}
+
+fn markdown_path(note: &CourseFile) -> String {
+ format!("{}/notes/{}.md", course_dir(note.course_id), note.id)
+}
+
+fn pdf_path(note: &CourseFile) -> String {
+ format!("{}/notes/{}.pdf", course_dir(note.course_id), note.id)
+}
+
+impl super::AppController {
+ pub async fn get_course_files(
+ &self,
+ course_id: CourseId,
+ ) -> Result, ControllerError> {
+ // 404 rather than an empty list for a course that does not exist
+ self.db.get_course(course_id).await?;
+ self.db
+ .get_course_files(course_id)
+ .await
+ .map_err(Into::into)
+ }
+
+ /// `NotFound` if the file does not exist or is not a note
+ async fn get_note(&self, id: CourseFileId) -> Result {
+ let file = self.db.get_course_file(id).await?;
+ if file.kind != FileKind::Note {
+ return Err(RepositoryError::NotFound(format!("lecture note {id}")).into());
+ }
+ Ok(file)
+ }
+
+ pub async fn get_lecture_note(
+ &self,
+ id: CourseFileId,
+ ) -> Result {
+ let note = self.get_note(id).await?;
+ let content = match self.storage.read_file(&markdown_path(¬e)).await {
+ Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
+ // Lost file: the note can still be rewritten
+ Err(RepositoryError::NotFound(_)) => String::new(),
+ Err(err) => return Err(err.into()),
+ };
+ Ok(LectureNoteContent {
+ file: note,
+ content: strip_front_matter(&content).to_owned(),
+ })
+ }
+
+ /// `NotFound` if the note does not exist or its last rendering failed
+ pub async fn get_lecture_note_pdf(&self, id: CourseFileId) -> Result, ControllerError> {
+ let note = self.get_note(id).await?;
+ self.storage
+ .read_file(&pdf_path(¬e))
+ .await
+ .map_err(Into::into)
+ }
+
+ pub async fn create_lecture_note(
+ &self,
+ course_id: CourseId,
+ title: String,
+ content: String,
+ ) -> Result {
+ let title = validate_title(title)?;
+ let course = self.db.get_course(course_id).await?;
+ let notes_tag = self.db.get_tag_id(NOTES_TAG).await?;
+ let note = self
+ .db
+ .create_lecture_note(course_id, title.clone(), &[notes_tag])
+ .await?;
+ self.save_lecture_note(&course, note, title, content).await
+ }
+
+ pub async fn update_lecture_note(
+ &self,
+ id: CourseFileId,
+ title: String,
+ content: String,
+ ) -> Result {
+ let title = validate_title(title)?;
+ let note = self.get_note(id).await?;
+ let course = self.db.get_course(note.course_id).await?;
+ self.save_lecture_note(&course, note, title, content).await
+ }
+
+ pub async fn delete_lecture_note(&self, id: CourseFileId) -> Result<(), ControllerError> {
+ let note = self.get_note(id).await?;
+ self.db.delete_course_file(id).await?;
+ self.storage.delete_file(&markdown_path(¬e)).await?;
+ self.storage.delete_file(&pdf_path(¬e)).await?;
+ Ok(())
+ }
+
+ /// Writes the markdown, then renders it. The markdown is kept even when the
+ /// rendering fails, and the error is returned to be shown in the editor.
+ async fn save_lecture_note(
+ &self,
+ course: &Course,
+ note: CourseFile,
+ title: String,
+ content: String,
+ ) -> Result {
+ let markdown = format!("{}{}", self.front_matter(course, &title), content);
+ self.storage
+ .write_file(&markdown_path(¬e), markdown.as_bytes())
+ .await?;
+
+ let pdf_error = match self.renderer.render(&markdown).await {
+ Ok(pdf) => {
+ self.storage.write_file(&pdf_path(¬e), &pdf).await?;
+ None
+ }
+ Err(err) => {
+ // No stale pdf that would not match the markdown anymore
+ self.storage.delete_file(&pdf_path(¬e)).await?;
+ Some(match err {
+ RenderError::Failed(log) => log,
+ RenderError::Internal(message) => {
+ error!("Unable to render lecture note {}: {message}", note.id);
+ message
+ }
+ })
+ }
+ };
+
+ let note = self
+ .db
+ .update_lecture_note(note.id, title, pdf_error.is_none())
+ .await?;
+ Ok(SavedLectureNote { note, pdf_error })
+ }
+
+ /// Front matter read by `template.tex`, e.g. `course: "CS-477 -- Advanced OS"`
+ fn front_matter(&self, course: &Course, title: &str) -> String {
+ let course_title = match &course.code {
+ Some(code) => format!("{code} -- {}", course.name),
+ None => course.name.clone(),
+ };
+ let fields = [
+ ("student-name", self.notes.student_name.as_deref()),
+ ("student-id", self.notes.student_id.as_deref()),
+ ("logo", self.notes.logo.as_deref()),
+ ("course", Some(course_title.as_str())),
+ ("subtitle", Some(title)),
+ ];
+ let mut front_matter = String::from("---\n");
+ for (key, value) in fields {
+ if let Some(value) = value {
+ front_matter.push_str(&format!("{key}: {}\n", yaml_string(value)));
+ }
+ }
+ front_matter.push_str("---\n\n");
+ front_matter
+ }
+}
+
+/// A json string is a valid double quoted yaml scalar: quotes and backslashes escaped
+fn yaml_string(value: &str) -> String {
+ serde_json::to_string(value).unwrap_or_default()
+}
+
+/// Content of a markdown file without its front matter
+fn strip_front_matter(markdown: &str) -> &str {
+ markdown
+ .strip_prefix("---\n")
+ .and_then(|rest| rest.split_once("\n---\n"))
+ .map(|(_, body)| body.trim_start_matches('\n'))
+ .unwrap_or(markdown)
+}
+
+fn validate_title(title: String) -> Result {
+ let title = title.trim().to_owned();
+ if title.is_empty() {
+ return Err(LectureNotesControllerError::EmptyTitle.into());
+ }
+ Ok(title)
+}
+
+#[derive(Error, Debug)]
+pub enum LectureNotesControllerError {
+ #[error("The title of the note is empty")]
+ EmptyTitle,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn strips_front_matter() {
+ let markdown = "---\ncourse: \"X\"\nsubtitle: \"Y\"\n---\n\n# Intro\n---\nText";
+ assert_eq!(strip_front_matter(markdown), "# Intro\n---\nText");
+ assert_eq!(strip_front_matter("# No front matter"), "# No front matter");
+ }
+
+ #[test]
+ fn escapes_yaml() {
+ assert_eq!(yaml_string(r#"Notes: "memory""#), r#""Notes: \"memory\"""#);
+ }
+}
diff --git a/src/core/controller/courses.rs b/src/core/controller/courses.rs
index 5efbe8d..f293fc4 100644
--- a/src/core/controller/courses.rs
+++ b/src/core/controller/courses.rs
@@ -4,10 +4,11 @@ use chrono::NaiveTime;
use thiserror::Error;
use crate::core::{
- controller::ControllerError,
+ controller::{ControllerError, is_http_url, non_blank},
models::{
course::{Course, CourseData, CourseId},
- slot_tag::SlotTagId,
+ tag::{TagId, TagScope},
+ time_slot::{TimeSlotData, TimeSlotId},
},
};
@@ -18,13 +19,22 @@ const LAST_WEEKDAY: u8 = 5;
const DAY_START: NaiveTime = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
const DAY_END: NaiveTime = NaiveTime::from_hms_opt(19, 0, 0).unwrap();
+/// Directory of the files of a course in the storage
+pub(crate) fn course_dir(id: CourseId) -> String {
+ format!("courses/{id}")
+}
+
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 = self.validate_course(course).await?;
+ let course = validate_course(course)?;
self.db.create_course(course).await.map_err(Into::into)
}
@@ -33,71 +43,79 @@ impl super::AppController {
id: CourseId,
course: CourseData,
) -> Result {
- let course = self.validate_course(course).await?;
+ let course = validate_course(course)?;
self.db.update_course(id, course).await.map_err(Into::into)
}
+ /// Deletes the course with everything attached to it, its files included
pub async fn delete_course(&self, id: CourseId) -> Result<(), ControllerError> {
- self.db.delete_course(id).await.map_err(Into::into)
+ self.db.delete_course(id).await?;
+ self.storage.delete_dir(&course_dir(id)).await?;
+ Ok(())
}
- /// Trims the texts, turns blank optional fields into `None`, and checks the rules
- /// of a course and its slots
- async fn validate_course(&self, course: CourseData) -> Result {
- let name = course.name.trim().to_owned();
- if name.is_empty() {
- return Err(CoursesControllerError::EmptyName.into());
+ 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());
}
-
- let code = non_blank(course.code);
-
- let teachers: Vec = course
- .teachers
- .iter()
- .map(|teacher| teacher.trim().to_owned())
- .filter(|teacher| !teacher.is_empty())
- .collect();
- if teachers.is_empty() {
- return Err(CoursesControllerError::NoTeacher.into());
- }
-
- let main_page_url = non_blank(course.main_page_url);
- if let Some(url) = &main_page_url
- && !(url.starts_with("http://") || url.starts_with("https://"))
+ if slot.start_time >= slot.end_time
+ || slot.start_time < DAY_START
+ || slot.end_time > DAY_END
{
- return Err(CoursesControllerError::InvalidMainPageUrl.into());
+ 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)
+ }
- let tags = self.db.get_slot_tags().await?;
- for slot in &course.slots {
- 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());
- }
- if !tags.iter().any(|tag| tag.id == slot.tag_id) {
- return Err(CoursesControllerError::UnknownSlotTag(slot.tag_id).into());
- }
- }
-
- Ok(CourseData {
- name,
- code,
- teachers,
- main_page_url,
- slots: course.slots,
- })
+ 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)
}
}
-fn non_blank(value: Option) -> Option {
- value
- .map(|value| value.trim().to_owned())
- .filter(|value| !value.is_empty())
+/// 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() {
+ 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 {
+ name,
+ code: non_blank(course.code),
+ teachers,
+ main_page_url,
+ })
}
/// Errors specific to this domain area, turned into status codes by the api
@@ -113,6 +131,6 @@ pub enum CoursesControllerError {
InvalidWeekday(u8),
#[error("A slot must start before it ends, between 8:00 and 19:00")]
InvalidSlotTime,
- #[error("Unknown slot tag {0}")]
- UnknownSlotTag(SlotTagId),
+ #[error("Unknown tag {0}")]
+ UnknownTag(TagId),
}
diff --git a/src/core/controller/grades.rs b/src/core/controller/grades.rs
new file mode 100644
index 0000000..ab609c3
--- /dev/null
+++ b/src/core/controller/grades.rs
@@ -0,0 +1,142 @@
+//! Grades of a course and their weighted average, in the swiss system (1 to 6).
+
+use thiserror::Error;
+
+use crate::core::{
+ controller::ControllerError,
+ models::{
+ course::CourseId,
+ grade::{CourseGrades, Grade, GradeData, GradeId, to_swiss},
+ },
+};
+
+impl super::AppController {
+ pub async fn get_course_grades(
+ &self,
+ course_id: CourseId,
+ ) -> Result {
+ // 404 rather than an empty list for a course that does not exist
+ self.db.get_course(course_id).await?;
+ self.course_grades(course_id).await
+ }
+
+ /// Returns every grade of the course, the new one included
+ pub async fn create_grade(
+ &self,
+ course_id: CourseId,
+ grade: GradeData,
+ ) -> Result {
+ let grade = validate_grade(grade)?;
+ self.db.get_course(course_id).await?;
+ self.db.create_grade(course_id, grade).await?;
+ self.course_grades(course_id).await
+ }
+
+ /// Returns every grade of the course of the grade
+ pub async fn update_grade(
+ &self,
+ id: GradeId,
+ grade: GradeData,
+ ) -> Result {
+ let grade = validate_grade(grade)?;
+ let course_id = self.db.get_grade_course(id).await?;
+ self.db.update_grade(id, grade).await?;
+ self.course_grades(course_id).await
+ }
+
+ /// Returns the grades left in the course of the grade
+ pub async fn delete_grade(&self, id: GradeId) -> Result {
+ let course_id = self.db.get_grade_course(id).await?;
+ self.db.delete_grade(id).await?;
+ self.course_grades(course_id).await
+ }
+
+ async fn course_grades(&self, course_id: CourseId) -> Result {
+ let grades = self.db.get_grades(course_id).await?;
+ Ok(summarize(grades))
+ }
+}
+
+/// Weighted average of the grades: each one counts for `weight / total_weight`, so the
+/// weights do not need to add up to 100
+fn summarize(grades: Vec) -> CourseGrades {
+ let total_weight: f64 = grades.iter().map(|grade| grade.weight).sum();
+ let average = (total_weight > 0.0).then(|| {
+ let weighted: f64 = grades
+ .iter()
+ .map(|grade| grade.weight * grade.score / grade.max_score)
+ .sum();
+ to_swiss(weighted / total_weight)
+ });
+ CourseGrades {
+ grades,
+ average,
+ total_weight,
+ }
+}
+
+fn validate_grade(grade: GradeData) -> Result {
+ let name = grade.name.trim().to_owned();
+ if name.is_empty() {
+ return Err(GradesControllerError::EmptyName.into());
+ }
+ let numbers = [grade.score, grade.max_score, grade.weight];
+ if numbers.iter().any(|number| !number.is_finite()) {
+ return Err(GradesControllerError::NotANumber.into());
+ }
+ if grade.max_score <= 0.0 || grade.score < 0.0 || grade.score > grade.max_score {
+ return Err(GradesControllerError::InvalidScore.into());
+ }
+ if grade.weight <= 0.0 {
+ return Err(GradesControllerError::InvalidWeight.into());
+ }
+ Ok(GradeData { name, ..grade })
+}
+
+#[derive(Error, Debug)]
+pub enum GradesControllerError {
+ #[error("The name of the grade is empty")]
+ EmptyName,
+ #[error("Scores and weight must be numbers")]
+ NotANumber,
+ #[error("The score must be between 0 and the maximal score, which must be positive")]
+ InvalidScore,
+ #[error("The weight must be positive")]
+ InvalidWeight,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn grade(score: f64, max_score: f64, weight: f64) -> Grade {
+ Grade {
+ id: 0,
+ name: String::new(),
+ score,
+ max_score,
+ weight,
+ swiss_grade: to_swiss(score / max_score),
+ }
+ }
+
+ #[test]
+ fn average_is_weighted_and_scale_independent() {
+ // 5/6 counting for 40, 20/100 for 60: 0.4 * 5/6 + 0.6 * 0.2 = 0.4533...
+ let summary = summarize(vec![grade(5.0, 6.0, 40.0), grade(20.0, 100.0, 60.0)]);
+ let expected = 1.0 + 5.0 * (0.4 * 5.0 / 6.0 + 0.6 * 0.2);
+ assert!((summary.average.unwrap() - expected).abs() < 1e-9);
+ assert_eq!(summary.total_weight, 100.0);
+ }
+
+ #[test]
+ fn no_grade_no_average() {
+ assert!(summarize(Vec::new()).average.is_none());
+ }
+
+ #[test]
+ fn full_marks_is_a_six() {
+ assert_eq!(to_swiss(1.0), 6.0);
+ assert_eq!(to_swiss(0.0), 1.0);
+ }
+}
diff --git a/src/core/controller/mod.rs b/src/core/controller/mod.rs
index 0543b7a..423e77f 100644
--- a/src/core/controller/mod.rs
+++ b/src/core/controller/mod.rs
@@ -10,27 +10,51 @@ use std::sync::Arc;
use thiserror::Error;
use crate::core::{
- controller::courses::CoursesControllerError,
- repositories::{DatabaseRepository, RepositoryError},
+ controller::{
+ course_files::{LectureNotesControllerError, NotesSettings},
+ courses::CoursesControllerError,
+ grades::GradesControllerError,
+ tasks::TasksControllerError,
+ },
+ repositories::{
+ DatabaseRepository, RepositoryError, pdf_renderer::PdfRenderer,
+ storage_repository::StorageRepository,
+ },
};
+pub mod course_files;
pub mod courses;
-pub mod slot_tags;
+pub mod grades;
+pub mod tags;
+pub mod tasks;
/// Entry point of the business logic. Cheap to clone: handlers get one per request.
#[derive(Clone)]
pub struct AppController {
db: Arc>,
+ storage: Arc,
+ renderer: Arc,
+ notes: Arc,
}
impl AppController {
- pub fn new(db: Arc>) -> Self {
- Self { db }
+ pub fn new(
+ db: Arc>,
+ storage: Arc,
+ renderer: Arc,
+ notes: NotesSettings,
+ ) -> Self {
+ Self {
+ db,
+ storage,
+ renderer,
+ notes: Arc::new(notes),
+ }
}
}
/// Every error the api may have to translate into a status code.
-/// Domain specific errors are nested (`Course`), so each area keeps its own enum.
+/// Domain specific errors are nested (`Course`...), so each area keeps its own enum.
#[derive(Error, Debug)]
#[allow(dead_code)]
pub enum ControllerError {
@@ -41,14 +65,21 @@ pub enum ControllerError {
#[error("Course specific error: {0}")]
Course(#[from] CoursesControllerError),
+ #[error("Grade specific error: {0}")]
+ Grade(#[from] GradesControllerError),
+ #[error("Task specific error: {0}")]
+ Task(#[from] TasksControllerError),
+ #[error("Lecture note specific error: {0}")]
+ LectureNote(#[from] LectureNotesControllerError),
}
-impl ControllerError {
- /// Handy in the handlers: `Err(err) if err.is_not_found() => 404`
- pub fn is_not_found(&self) -> bool {
- matches!(
- self,
- ControllerError::RepositoryError(RepositoryError::NotFound(_))
- )
- }
+/// Trims a text, `None` when it is blank
+pub(crate) fn non_blank(value: Option) -> Option {
+ value
+ .map(|value| value.trim().to_owned())
+ .filter(|value| !value.is_empty())
+}
+
+pub(crate) fn is_http_url(url: &str) -> bool {
+ url.starts_with("http://") || url.starts_with("https://")
}
diff --git a/src/core/controller/slot_tags.rs b/src/core/controller/slot_tags.rs
deleted file mode 100644
index d67460e..0000000
--- a/src/core/controller/slot_tags.rs
+++ /dev/null
@@ -1,9 +0,0 @@
-//! Kinds of time slots (lecture, exercises, lab...).
-
-use crate::core::{controller::ControllerError, models::slot_tag::SlotTag};
-
-impl super::AppController {
- pub async fn get_slot_tags(&self) -> Result, ControllerError> {
- self.db.get_slot_tags().await.map_err(Into::into)
- }
-}
diff --git a/src/core/controller/tags.rs b/src/core/controller/tags.rs
new file mode 100644
index 0000000..9026168
--- /dev/null
+++ b/src/core/controller/tags.rs
@@ -0,0 +1,28 @@
+//! Tags of the time slots, tasks and course files (lecture, lab, exam, slides...).
+
+use crate::core::{
+ controller::{ControllerError, courses::CoursesControllerError},
+ models::tag::{Tag, TagId, TagScope},
+};
+
+impl super::AppController {
+ pub async fn get_tags(&self, scope: Option) -> Result, ControllerError> {
+ self.db.get_tags(scope).await.map_err(Into::into)
+ }
+
+ /// `UnknownTag` if one of the tags does not exist or cannot label `scope`
+ pub(crate) async fn check_tags(
+ &self,
+ tag_ids: &[TagId],
+ scope: TagScope,
+ ) -> Result<(), ControllerError> {
+ let tags = self.db.get_tags(Some(scope)).await?;
+ match tag_ids
+ .iter()
+ .find(|id| !tags.iter().any(|tag| tag.id == **id))
+ {
+ Some(id) => Err(CoursesControllerError::UnknownTag(*id).into()),
+ None => Ok(()),
+ }
+ }
+}
diff --git a/src/core/controller/tasks.rs b/src/core/controller/tasks.rs
new file mode 100644
index 0000000..f090ce6
--- /dev/null
+++ b/src/core/controller/tasks.rs
@@ -0,0 +1,287 @@
+//! Tasks of a course (homework, exams, anything to do), with one level of subtasks.
+
+use std::{cmp::Ordering, collections::HashMap};
+
+use chrono::{DateTime, Duration, Utc};
+
+use thiserror::Error;
+
+use crate::core::{
+ controller::{ControllerError, is_http_url, non_blank},
+ models::{
+ course::Course,
+ course::CourseId,
+ tag::TagScope,
+ task::{CalendarTask, CourseTasks, Task, TaskData, TaskId},
+ },
+};
+
+/// Longest period the calendar can ask for, a month view needs 6 weeks
+const MAX_CALENDAR_DAYS: i64 = 62;
+
+impl super::AppController {
+ /// Tasks and subtasks of every course with a deadline in `[from, to[`, the closest
+ /// first
+ pub async fn get_calendar_tasks(
+ &self,
+ from: DateTime,
+ to: DateTime,
+ ) -> Result, ControllerError> {
+ if from >= to || to - from > Duration::days(MAX_CALENDAR_DAYS) {
+ return Err(TasksControllerError::InvalidPeriod(MAX_CALENDAR_DAYS).into());
+ }
+ let tasks = self.db.get_dated_tasks(from, to).await?;
+ let courses = self.db.get_courses().await?;
+ Ok(calendar(tasks, &courses, from, to))
+ }
+
+ pub async fn get_course_tasks(
+ &self,
+ course_id: CourseId,
+ ) -> Result {
+ // 404 rather than an empty list for a course that does not exist
+ self.db.get_course(course_id).await?;
+ self.course_tasks(course_id).await
+ }
+
+ /// Adds a task to the course, or a subtask to `parent_id`. Returns every task of the
+ /// course.
+ pub async fn create_task(
+ &self,
+ course_id: CourseId,
+ parent_id: Option,
+ task: TaskData,
+ ) -> Result {
+ self.db.get_course(course_id).await?;
+ if let Some(parent_id) = parent_id {
+ let parent = self.db.get_task(parent_id).await?;
+ if parent.course_id != course_id {
+ return Err(TasksControllerError::ParentInOtherCourse.into());
+ }
+ if parent.parent_id.is_some() {
+ return Err(TasksControllerError::NestedSubtask.into());
+ }
+ }
+ let task = self.validate_task(task, parent_id.is_some()).await?;
+ self.db.create_task(course_id, parent_id, task).await?;
+ self.course_tasks(course_id).await
+ }
+
+ /// Replaces a task, without moving it. Returns every task of its course.
+ pub async fn update_task(
+ &self,
+ id: TaskId,
+ task: TaskData,
+ ) -> Result {
+ let existing = self.db.get_task(id).await?;
+ let task = self
+ .validate_task(task, existing.parent_id.is_some())
+ .await?;
+ self.db.update_task(id, task).await?;
+ self.course_tasks(existing.course_id).await
+ }
+
+ /// Deletes a task and its subtasks. Returns the tasks left in its course.
+ pub async fn delete_task(&self, id: TaskId) -> Result {
+ let existing = self.db.get_task(id).await?;
+ self.db.delete_task(id).await?;
+ self.course_tasks(existing.course_id).await
+ }
+
+ async fn course_tasks(&self, course_id: CourseId) -> Result {
+ let tasks = self.db.get_tasks(course_id).await?;
+ Ok(organize(tasks))
+ }
+
+ /// Trims the texts and checks the rules of a task. A task needs at least one tag,
+ /// a subtask may have none.
+ async fn validate_task(
+ &self,
+ task: TaskData,
+ is_subtask: bool,
+ ) -> Result {
+ let name = task.name.trim().to_owned();
+ if name.is_empty() {
+ return Err(TasksControllerError::EmptyName.into());
+ }
+
+ let links: Vec = task
+ .links
+ .iter()
+ .map(|link| link.trim().to_owned())
+ .filter(|link| !link.is_empty())
+ .collect();
+ if links.iter().any(|link| !is_http_url(link)) {
+ return Err(TasksControllerError::InvalidLink.into());
+ }
+
+ let mut tag_ids = task.tag_ids;
+ tag_ids.sort_unstable();
+ tag_ids.dedup();
+ if tag_ids.is_empty() && !is_subtask {
+ return Err(TasksControllerError::NoTag.into());
+ }
+ self.check_tags(&tag_ids, TagScope::Task).await?;
+
+ Ok(TaskData {
+ name,
+ description: non_blank(task.description),
+ links,
+ tag_ids,
+ ..task
+ })
+ }
+}
+
+/// Not done first, then the closest deadline, the tasks without any last
+fn task_order(a: &Task, b: &Task) -> Ordering {
+ (a.done, a.deadline.is_none(), a.deadline, a.id).cmp(&(
+ b.done,
+ b.deadline.is_none(),
+ b.deadline,
+ b.id,
+ ))
+}
+
+/// Nests the subtasks in their parent and sorts everything
+fn organize(flat: Vec) -> CourseTasks {
+ let (mut tasks, subtasks): (Vec, Vec) =
+ flat.into_iter().partition(|task| task.parent_id.is_none());
+
+ let mut subtasks_by_parent: HashMap> = HashMap::new();
+ for subtask in subtasks {
+ if let Some(parent_id) = subtask.parent_id {
+ subtasks_by_parent
+ .entry(parent_id)
+ .or_default()
+ .push(subtask);
+ }
+ }
+ for task in &mut tasks {
+ let mut subtasks = subtasks_by_parent.remove(&task.id).unwrap_or_default();
+ subtasks.sort_by(task_order);
+ task.subtasks = subtasks;
+ }
+ tasks.sort_by(task_order);
+
+ CourseTasks { tasks }
+}
+
+/// Tasks with a deadline in the period, named and tagged after their parent for the
+/// subtasks. `tasks` holds the parents of the subtasks too, whatever their deadline.
+fn calendar(
+ tasks: Vec,
+ courses: &[Course],
+ from: DateTime,
+ to: DateTime,
+) -> Vec {
+ let by_id: HashMap = tasks.iter().map(|task| (task.id, task)).collect();
+ let courses: HashMap<_, &Course> = courses.iter().map(|course| (course.id, course)).collect();
+
+ let mut result: Vec = tasks
+ .iter()
+ .filter_map(|task| {
+ let deadline = task
+ .deadline
+ .filter(|deadline| *deadline >= from && *deadline < to)?;
+ let course = courses.get(&task.course_id)?;
+ let parent = task.parent_id.and_then(|id| by_id.get(&id));
+ Some(CalendarTask {
+ task_id: task.id,
+ course_id: course.id,
+ course_name: course.name.clone(),
+ course_code: course.code.clone(),
+ name: match parent {
+ Some(parent) => format!("{} › {}", parent.name, task.name),
+ None => task.name.clone(),
+ },
+ deadline,
+ done: task.done || parent.is_some_and(|parent| parent.done),
+ tag_ids: match parent {
+ Some(parent) if task.tag_ids.is_empty() => parent.tag_ids.clone(),
+ _ => task.tag_ids.clone(),
+ },
+ })
+ })
+ .collect();
+ result.sort_by_key(|task| (task.deadline, task.task_id));
+ result
+}
+
+#[derive(Error, Debug)]
+pub enum TasksControllerError {
+ #[error("The period must end after it starts, and last at most {0} days")]
+ InvalidPeriod(i64),
+ #[error("The name of the task is empty")]
+ EmptyName,
+ #[error("A task needs at least one tag")]
+ NoTag,
+ #[error("Links must be http(s) urls")]
+ InvalidLink,
+ #[error("The parent task belongs to another course")]
+ ParentInOtherCourse,
+ #[error("A subtask cannot have subtasks")]
+ NestedSubtask,
+}
+
+#[cfg(test)]
+mod tests {
+ use chrono::{TimeZone, Utc};
+
+ use super::*;
+
+ fn task(id: TaskId, parent_id: Option, day: Option, done: bool) -> Task {
+ Task {
+ id,
+ course_id: 1,
+ parent_id,
+ name: format!("task {id}"),
+ description: None,
+ links: Vec::new(),
+ deadline: day.map(|day| Utc.with_ymd_and_hms(2026, 10, day, 12, 0, 0).unwrap()),
+ done,
+ tag_ids: vec![1],
+ subtasks: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn nests_and_sorts() {
+ let result = organize(vec![
+ task(1, None, None, false),
+ task(2, None, Some(20), false),
+ task(3, None, Some(10), true),
+ task(4, Some(2), Some(15), false),
+ task(5, Some(2), Some(5), true),
+ ]);
+ let ids: Vec = result.tasks.iter().map(|task| task.id).collect();
+ assert_eq!(ids, [2, 1, 3]);
+ let subtask_ids: Vec = result.tasks[0].subtasks.iter().map(|t| t.id).collect();
+ assert_eq!(subtask_ids, [4, 5]);
+ }
+
+ #[test]
+ fn calendar_names_and_tags_subtasks_after_their_parent() {
+ let course = Course {
+ id: 1,
+ name: "OS".to_owned(),
+ code: None,
+ teachers: Vec::new(),
+ main_page_url: None,
+ slots: Vec::new(),
+ };
+ let from = Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, 0).unwrap();
+ let to = Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).unwrap();
+ let mut subtask = task(4, Some(2), Some(15), false);
+ subtask.tag_ids.clear();
+ // The parent is out of the period and done: only its subtask is shown, done
+ let mut parent = task(2, None, Some(20), true);
+ parent.deadline = Some(to);
+ parent.tag_ids = vec![7];
+ let result = calendar(vec![parent, subtask], &[course], from, to);
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].name, "task 2 › task 4");
+ assert_eq!(result[0].tag_ids, [7]);
+ assert!(result[0].done);
+ }
+}
diff --git a/src/core/models/course.rs b/src/core/models/course.rs
index c1e214a..79bfd3d 100644
--- a/src/core/models/course.rs
+++ b/src/core/models/course.rs
@@ -1,7 +1,7 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use crate::core::models::time_slot::{TimeSlot, TimeSlotData};
+use crate::core::models::time_slot::TimeSlot;
pub type CourseId = i32;
@@ -20,12 +20,11 @@ pub struct Course {
pub slots: Vec,
}
-/// Content of a course, used to create or replace one
+/// Content of a course, used to create or replace one. The slots are managed apart.
#[derive(Debug, Clone)]
pub struct CourseData {
pub name: String,
pub code: Option,
pub teachers: Vec,
pub main_page_url: Option,
- pub slots: Vec,
}
diff --git a/src/core/models/course_file.rs b/src/core/models/course_file.rs
new file mode 100644
index 0000000..8429d5b
--- /dev/null
+++ b/src/core/models/course_file.rs
@@ -0,0 +1,49 @@
+use chrono::{DateTime, Utc};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use crate::core::models::{course::CourseId, tag::TagId};
+
+pub type CourseFileId = i32;
+
+/// A file of a course: lecture notes written in the app, or (soon) an uploaded file.
+/// Only the metadata is in the database, the content is in the storage (the NAS).
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct CourseFile {
+ pub id: CourseFileId,
+ pub course_id: CourseId,
+ /// For a note, e.g. "Lecture 2 Notes: Memory subsystem", the subtitle of its pdf
+ pub name: String,
+ pub kind: FileKind,
+ /// e.g. slides, notes
+ pub tag_ids: Vec,
+ /// For a note: false when the last rendering failed
+ pub has_pdf: bool,
+ pub created_at: DateTime,
+ pub updated_at: DateTime,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum FileKind {
+ /// Markdown written in the app, rendered to pdf with the course template
+ Note,
+ /// Any file uploaded to the storage
+ Upload,
+}
+
+/// A note with its markdown content (without the front matter, added on rendering)
+#[derive(Debug, Clone, Serialize, JsonSchema)]
+pub struct LectureNoteContent {
+ #[serde(flatten)]
+ pub file: CourseFile,
+ pub content: String,
+}
+
+/// Result of a save: the markdown is always saved, even when the pdf fails to render
+#[derive(Debug, Clone, Serialize, JsonSchema)]
+pub struct SavedLectureNote {
+ pub note: CourseFile,
+ /// End of the pandoc/LaTeX log when the rendering failed
+ pub pdf_error: Option,
+}
diff --git a/src/core/models/grade.rs b/src/core/models/grade.rs
new file mode 100644
index 0000000..1fbff4f
--- /dev/null
+++ b/src/core/models/grade.rs
@@ -0,0 +1,45 @@
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+pub type GradeId = i32;
+
+/// Best grade of the swiss system, grades go from 1 to 6
+pub const SWISS_MAX: f64 = 6.0;
+
+/// A grade on any scale: `score` out of `max_score`
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct Grade {
+ pub id: GradeId,
+ /// e.g. "Midterm"
+ pub name: String,
+ pub score: f64,
+ pub max_score: f64,
+ /// Relative to the other grades of the course
+ pub weight: f64,
+ /// The grade converted to the swiss system (1 to 6)
+ pub swiss_grade: f64,
+}
+
+/// Content of a grade, used to create or replace one
+#[derive(Debug, Clone)]
+pub struct GradeData {
+ pub name: String,
+ pub score: f64,
+ pub max_score: f64,
+ pub weight: f64,
+}
+
+/// Every grade of a course, with its weighted average
+#[derive(Debug, Clone, Serialize, JsonSchema)]
+pub struct CourseGrades {
+ pub grades: Vec,
+ /// Weighted average in the swiss system (1 to 6), `None` without any grade
+ pub average: Option,
+ /// Sum of the weights, e.g. to check they add up to 100 %
+ pub total_weight: f64,
+}
+
+/// `score / max_score` scaled to the swiss system, where 0 is a 1 and full marks a 6
+pub fn to_swiss(ratio: f64) -> f64 {
+ 1.0 + (SWISS_MAX - 1.0) * ratio
+}
diff --git a/src/core/models/mod.rs b/src/core/models/mod.rs
index 1d94c71..9b173a4 100644
--- a/src/core/models/mod.rs
+++ b/src/core/models/mod.rs
@@ -1,4 +1,7 @@
pub mod course;
+pub mod course_file;
+pub mod grade;
pub mod localized_string;
-pub mod slot_tag;
+pub mod tag;
+pub mod task;
pub mod time_slot;
diff --git a/src/core/models/slot_tag.rs b/src/core/models/slot_tag.rs
deleted file mode 100644
index 4a9a55e..0000000
--- a/src/core/models/slot_tag.rs
+++ /dev/null
@@ -1,15 +0,0 @@
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-
-use crate::core::models::localized_string::LocalizedString;
-
-pub type SlotTagId = i32;
-
-/// Kind of a time slot: lecture, exercises, lab...
-#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
-pub struct SlotTag {
- pub id: SlotTagId,
- pub name: LocalizedString,
- /// Hex color used to display the slots of this kind, e.g. `#2563eb`
- pub color: String,
-}
diff --git a/src/core/models/tag.rs b/src/core/models/tag.rs
new file mode 100644
index 0000000..ea9d654
--- /dev/null
+++ b/src/core/models/tag.rs
@@ -0,0 +1,38 @@
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use crate::core::models::localized_string::LocalizedString;
+
+pub type TagId = i32;
+
+/// Stable name of the tag attached to the lecture notes
+pub const NOTES_TAG: &str = "notes";
+
+/// Label of time slots, tasks and course files: lecture, lab, exam, slides...
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct Tag {
+ pub id: TagId,
+ pub name: LocalizedString,
+ /// Hex color used to display what is tagged, e.g. `#2563eb`
+ pub color: String,
+ /// What the tag can label
+ pub scopes: Vec,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum TagScope {
+ Slot,
+ Task,
+ File,
+}
+
+impl TagScope {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ TagScope::Slot => "slot",
+ TagScope::Task => "task",
+ TagScope::File => "file",
+ }
+ }
+}
diff --git a/src/core/models/task.rs b/src/core/models/task.rs
new file mode 100644
index 0000000..5db2250
--- /dev/null
+++ b/src/core/models/task.rs
@@ -0,0 +1,60 @@
+use chrono::{DateTime, Utc};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+use crate::core::models::{course::CourseId, tag::TagId};
+
+pub type TaskId = i32;
+
+/// Homework, exam, or anything to do for a course
+#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
+pub struct Task {
+ pub id: TaskId,
+ pub course_id: CourseId,
+ /// Task this one is a subtask of
+ pub parent_id: Option,
+ pub name: String,
+ pub description: Option,
+ /// http(s) urls
+ pub links: Vec,
+ pub deadline: Option>,
+ pub done: bool,
+ pub tag_ids: Vec,
+ /// Sorted like the tasks. Always empty for a subtask: there is only one level.
+ pub subtasks: Vec,
+}
+
+/// Content of a task, used to create or replace one
+#[derive(Debug, Clone)]
+pub struct TaskData {
+ pub name: String,
+ pub description: Option,
+ pub links: Vec,
+ pub deadline: Option>,
+ pub done: bool,
+ pub tag_ids: Vec,
+}
+
+/// Every task of a course
+#[derive(Debug, Clone, Serialize, JsonSchema)]
+pub struct CourseTasks {
+ /// Tasks with their subtasks: not done first, then by deadline (the ones without
+ /// a deadline last)
+ pub tasks: Vec,
+}
+
+/// A task or subtask with a deadline, as shown in the calendar
+#[derive(Debug, Clone, Serialize, JsonSchema)]
+pub struct CalendarTask {
+ pub task_id: TaskId,
+ pub course_id: CourseId,
+ pub course_name: String,
+ pub course_code: Option,
+ /// Name of the task, prefixed by the one of its parent for a subtask
+ pub name: String,
+ pub deadline: DateTime,
+ /// Also true for a subtask of a task done
+ pub done: bool,
+ /// Tags of the task, or of its parent for a subtask without any
+ pub tag_ids: Vec,
+}
diff --git a/src/core/models/time_slot.rs b/src/core/models/time_slot.rs
index 77ffdf6..3b1e5ee 100644
--- a/src/core/models/time_slot.rs
+++ b/src/core/models/time_slot.rs
@@ -2,7 +2,7 @@ use chrono::NaiveTime;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
-use crate::core::models::slot_tag::SlotTagId;
+use crate::core::models::tag::TagId;
pub type TimeSlotId = i32;
@@ -10,17 +10,17 @@ pub type TimeSlotId = i32;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TimeSlot {
pub id: TimeSlotId,
- pub tag_id: SlotTagId,
+ pub tag_id: TagId,
/// ISO weekday: 1 = monday ... 5 = friday
pub weekday: u8,
pub start_time: NaiveTime,
pub end_time: NaiveTime,
}
-/// Content of a slot, used when creating or replacing a course
+/// Content of a slot, used when adding one to a course
#[derive(Debug, Clone)]
pub struct TimeSlotData {
- pub tag_id: SlotTagId,
+ pub tag_id: TagId,
pub weekday: u8,
pub start_time: NaiveTime,
pub end_time: NaiveTime,
diff --git a/src/core/repositories/course_files_repository.rs b/src/core/repositories/course_files_repository.rs
new file mode 100644
index 0000000..d8675ea
--- /dev/null
+++ b/src/core/repositories/course_files_repository.rs
@@ -0,0 +1,37 @@
+use async_trait::async_trait;
+
+use crate::core::{
+ models::{
+ course::CourseId,
+ course_file::{CourseFile, CourseFileId},
+ tag::TagId,
+ },
+ repositories::RepositoryError,
+};
+
+#[async_trait]
+pub trait CourseFilesRepository {
+ /// Files of a course, the oldest first
+ async fn get_course_files(
+ &self,
+ course_id: CourseId,
+ ) -> Result, RepositoryError>;
+ /// `NotFound` if the file does not exist
+ async fn get_course_file(&self, id: CourseFileId) -> Result;
+ async fn create_lecture_note(
+ &self,
+ course_id: CourseId,
+ name: String,
+ tag_ids: &[TagId],
+ ) -> Result;
+ /// Sets the name and `has_pdf`, and bumps `updated_at`. `NotFound` if the note does
+ /// not exist.
+ async fn update_lecture_note(
+ &self,
+ id: CourseFileId,
+ name: String,
+ has_pdf: bool,
+ ) -> Result;
+ /// `NotFound` if the file does not exist
+ async fn delete_course_file(&self, id: CourseFileId) -> Result<(), RepositoryError>;
+}
diff --git a/src/core/repositories/courses_repository.rs b/src/core/repositories/courses_repository.rs
index c295ded..9c77e62 100644
--- a/src/core/repositories/courses_repository.rs
+++ b/src/core/repositories/courses_repository.rs
@@ -1,7 +1,10 @@
use async_trait::async_trait;
use crate::core::{
- models::course::{Course, CourseData, CourseId},
+ models::{
+ course::{Course, CourseData, CourseId},
+ time_slot::{TimeSlotData, TimeSlotId},
+ },
repositories::RepositoryError,
};
@@ -9,13 +12,29 @@ use crate::core::{
pub trait CoursesRepository {
/// Every course with its slots, sorted by name
async fn get_courses(&self) -> Result, RepositoryError>;
+ /// `NotFound` if the course does not exist
+ async fn get_course(&self, id: CourseId) -> Result;
+ /// Creates a course without any slot
async fn create_course(&self, course: CourseData) -> Result;
- /// Replaces the course and all its slots. `NotFound` if the course does not exist.
+ /// Replaces the course, keeping its slots. `NotFound` if the course does not exist.
async fn update_course(
&self,
id: CourseId,
course: CourseData,
) -> Result;
- /// Deletes the course and its slots. `NotFound` if the course does not exist.
+ /// Deletes the course and everything attached to it. `NotFound` if it does not exist.
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError>;
+
+ /// Returns the course with the new slot. `NotFound` if the course does not exist.
+ async fn add_slot(
+ &self,
+ course_id: CourseId,
+ slot: TimeSlotData,
+ ) -> Result;
+ /// Returns the course without the slot. `NotFound` if the slot is not in the course.
+ async fn delete_slot(
+ &self,
+ course_id: CourseId,
+ slot_id: TimeSlotId,
+ ) -> Result;
}
diff --git a/src/core/repositories/grades_repository.rs b/src/core/repositories/grades_repository.rs
new file mode 100644
index 0000000..693931c
--- /dev/null
+++ b/src/core/repositories/grades_repository.rs
@@ -0,0 +1,26 @@
+use async_trait::async_trait;
+
+use crate::core::{
+ models::{
+ course::CourseId,
+ grade::{Grade, GradeData, GradeId},
+ },
+ repositories::RepositoryError,
+};
+
+#[async_trait]
+pub trait GradesRepository {
+ /// Grades of a course, in the order they were added
+ async fn get_grades(&self, course_id: CourseId) -> Result, RepositoryError>;
+ /// Course of a grade. `NotFound` if the grade does not exist.
+ async fn get_grade_course(&self, id: GradeId) -> Result;
+ async fn create_grade(
+ &self,
+ course_id: CourseId,
+ grade: GradeData,
+ ) -> Result<(), RepositoryError>;
+ /// `NotFound` if the grade does not exist
+ async fn update_grade(&self, id: GradeId, grade: GradeData) -> Result<(), RepositoryError>;
+ /// `NotFound` if the grade does not exist
+ async fn delete_grade(&self, id: GradeId) -> Result<(), RepositoryError>;
+}
diff --git a/src/core/repositories/mod.rs b/src/core/repositories/mod.rs
index 74ac972..2e89618 100644
--- a/src/core/repositories/mod.rs
+++ b/src/core/repositories/mod.rs
@@ -8,15 +8,31 @@ use async_trait::async_trait;
use thiserror::Error;
use crate::core::repositories::{
- courses_repository::CoursesRepository, slot_tags_repository::SlotTagsRepository,
+ course_files_repository::CourseFilesRepository, courses_repository::CoursesRepository,
+ grades_repository::GradesRepository, tags_repository::TagsRepository,
+ tasks_repository::TasksRepository,
};
+pub mod course_files_repository;
pub mod courses_repository;
-pub mod slot_tags_repository;
+pub mod grades_repository;
+pub mod pdf_renderer;
+pub mod storage_repository;
+pub mod tags_repository;
+pub mod tasks_repository;
/// Add every new repository trait here so the controller can use it through `db`
#[async_trait]
-pub trait DatabaseRepository: CoursesRepository + SlotTagsRepository + Send + Sync {}
+pub trait DatabaseRepository:
+ CoursesRepository
+ + TagsRepository
+ + GradesRepository
+ + TasksRepository
+ + CourseFilesRepository
+ + Send
+ + Sync
+{
+}
#[derive(Error, Debug)]
pub enum RepositoryError {
diff --git a/src/core/repositories/pdf_renderer.rs b/src/core/repositories/pdf_renderer.rs
new file mode 100644
index 0000000..2b7c60a
--- /dev/null
+++ b/src/core/repositories/pdf_renderer.rs
@@ -0,0 +1,18 @@
+use async_trait::async_trait;
+use thiserror::Error;
+
+/// Turns a markdown document (front matter included) into a pdf
+#[async_trait]
+pub trait PdfRenderer: Send + Sync {
+ async fn render(&self, markdown: &str) -> Result, RenderError>;
+}
+
+#[derive(Error, Debug)]
+pub enum RenderError {
+ /// The document does not compile. Holds the end of the log, to show to the user.
+ #[error("rendering failed: {0}")]
+ Failed(String),
+ /// The renderer itself could not run (missing pandoc, i/o error...)
+ #[error("renderer error: {0}")]
+ Internal(String),
+}
diff --git a/src/core/repositories/slot_tags_repository.rs b/src/core/repositories/slot_tags_repository.rs
deleted file mode 100644
index f14e3d1..0000000
--- a/src/core/repositories/slot_tags_repository.rs
+++ /dev/null
@@ -1,8 +0,0 @@
-use async_trait::async_trait;
-
-use crate::core::{models::slot_tag::SlotTag, repositories::RepositoryError};
-
-#[async_trait]
-pub trait SlotTagsRepository {
- async fn get_slot_tags(&self) -> Result, RepositoryError>;
-}
diff --git a/src/core/repositories/storage_repository.rs b/src/core/repositories/storage_repository.rs
new file mode 100644
index 0000000..4acd8f1
--- /dev/null
+++ b/src/core/repositories/storage_repository.rs
@@ -0,0 +1,18 @@
+use async_trait::async_trait;
+
+use crate::core::repositories::RepositoryError;
+
+/// Where the files of the courses are kept (lecture notes, their pdf...). Meant to be the
+/// NAS; for now a local directory. Paths are relative to the root of the storage and use
+/// `/` as separator.
+#[async_trait]
+pub trait StorageRepository: Send + Sync {
+ /// `NotFound` if the file does not exist
+ async fn read_file(&self, path: &str) -> Result, RepositoryError>;
+ /// Creates the missing directories, replaces an existing file
+ async fn write_file(&self, path: &str, content: &[u8]) -> Result<(), RepositoryError>;
+ /// Does nothing if the file does not exist
+ async fn delete_file(&self, path: &str) -> Result<(), RepositoryError>;
+ /// Deletes a directory and everything in it. Does nothing if it does not exist.
+ async fn delete_dir(&self, path: &str) -> Result<(), RepositoryError>;
+}
diff --git a/src/core/repositories/tags_repository.rs b/src/core/repositories/tags_repository.rs
new file mode 100644
index 0000000..89dc6d2
--- /dev/null
+++ b/src/core/repositories/tags_repository.rs
@@ -0,0 +1,14 @@
+use async_trait::async_trait;
+
+use crate::core::{
+ models::tag::{Tag, TagId, TagScope},
+ repositories::RepositoryError,
+};
+
+#[async_trait]
+pub trait TagsRepository {
+ /// Every tag, or only the ones that can label `scope`
+ async fn get_tags(&self, scope: Option) -> Result, RepositoryError>;
+ /// Id of a tag by its stable name. `NotFound` if there is none.
+ async fn get_tag_id(&self, slug: &str) -> Result;
+}
diff --git a/src/core/repositories/tasks_repository.rs b/src/core/repositories/tasks_repository.rs
new file mode 100644
index 0000000..c389292
--- /dev/null
+++ b/src/core/repositories/tasks_repository.rs
@@ -0,0 +1,35 @@
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+
+use crate::core::{
+ models::{
+ course::CourseId,
+ task::{Task, TaskData, TaskId},
+ },
+ repositories::RepositoryError,
+};
+
+#[async_trait]
+pub trait TasksRepository {
+ /// Every task and subtask of a course, flat: `subtasks` is always empty
+ async fn get_tasks(&self, course_id: CourseId) -> Result, RepositoryError>;
+ /// Tasks and subtasks of every course with a deadline in `[from, to[`, and the parents
+ /// of those subtasks. Flat: `subtasks` is always empty.
+ async fn get_dated_tasks(
+ &self,
+ from: DateTime,
+ to: DateTime,
+ ) -> Result, RepositoryError>;
+ /// A single task, `subtasks` empty. `NotFound` if the task does not exist.
+ async fn get_task(&self, id: TaskId) -> Result;
+ async fn create_task(
+ &self,
+ course_id: CourseId,
+ parent_id: Option,
+ task: TaskData,
+ ) -> Result<(), RepositoryError>;
+ /// Replaces the task and its tags. `NotFound` if the task does not exist.
+ async fn update_task(&self, id: TaskId, task: TaskData) -> Result<(), RepositoryError>;
+ /// Deletes the task and its subtasks. `NotFound` if the task does not exist.
+ async fn delete_task(&self, id: TaskId) -> Result<(), RepositoryError>;
+}
diff --git a/src/main.rs b/src/main.rs
index 8fa7d9f..6e78ac8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,10 +1,13 @@
-use std::sync::Arc;
+use std::{path::Path, sync::Arc};
use tower_http::services::{ServeDir, ServeFile};
use tracing::info;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
-use crate::{core::controller::AppController, services::database::SqlxDatabase};
+use crate::{
+ core::controller::{AppController, course_files::NotesSettings},
+ services::{database::SqlxDatabase, pandoc::PandocRenderer, storage::LocalStorage},
+};
mod api;
mod core;
@@ -19,7 +22,7 @@ async fn main() {
.with(tracing_subscriber::fmt::layer())
.with(
EnvFilter::try_from_default_env()
- .unwrap_or_else(|_| EnvFilter::new(format!("info, {}=debug", env!("CRATE_NAME")))),
+ .unwrap_or_else(|_| EnvFilter::new(format!("info,{}=debug", env!("CRATE_NAME")))),
)
.init();
@@ -27,7 +30,26 @@ async fn main() {
.await
.expect("Unable to connect to database");
- let controller = AppController::new(Arc::new(Box::new(db)));
+ // Absolute: pandoc runs in a temporary directory
+ let template_dir = Path::new(&config.notes.template_dir)
+ .canonicalize()
+ .expect("Unable to find the template directory of the notes");
+ let notes = NotesSettings {
+ student_name: config.notes.student_name.clone(),
+ student_id: config.notes.student_id.clone(),
+ logo: config
+ .notes
+ .logo
+ .as_ref()
+ .map(|logo| template_dir.join(logo).display().to_string()),
+ };
+
+ let controller = AppController::new(
+ Arc::new(Box::new(db)),
+ Arc::new(LocalStorage::new(&config.storage_dir)),
+ Arc::new(PandocRenderer::new(template_dir)),
+ notes,
+ );
// Anything that is not an api route is served from the built frontend,
// falling back on index.html so the vue router can handle the path.
diff --git a/src/services/database/course_files.rs b/src/services/database/course_files.rs
new file mode 100644
index 0000000..d37b528
--- /dev/null
+++ b/src/services/database/course_files.rs
@@ -0,0 +1,158 @@
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+use sqlx::{query, query_as};
+
+use crate::{
+ core::{
+ models::{
+ course::CourseId,
+ course_file::{CourseFile, CourseFileId, FileKind},
+ tag::TagId,
+ },
+ repositories::{RepositoryError, course_files_repository::CourseFilesRepository},
+ },
+ services::database::SqlxDatabase,
+};
+
+struct CourseFileDB {
+ pub id: i32,
+ pub course_id: i32,
+ pub name: String,
+ pub kind: String,
+ pub tag_ids: Vec,
+ pub has_pdf: bool,
+ pub created_at: DateTime,
+ pub updated_at: DateTime,
+}
+
+impl TryFrom for CourseFile {
+ type Error = RepositoryError;
+
+ fn try_from(value: CourseFileDB) -> Result {
+ Ok(CourseFile {
+ id: value.id,
+ course_id: value.course_id,
+ name: value.name,
+ kind: match value.kind.as_str() {
+ "note" => FileKind::Note,
+ "upload" => FileKind::Upload,
+ kind => {
+ return Err(RepositoryError::TypeConversion(format!(
+ "invalid file kind {kind}"
+ )));
+ }
+ },
+ tag_ids: value.tag_ids,
+ has_pdf: value.has_pdf,
+ created_at: value.created_at,
+ updated_at: value.updated_at,
+ })
+ }
+}
+
+fn not_found(id: CourseFileId) -> RepositoryError {
+ RepositoryError::NotFound(format!("file {id}"))
+}
+
+#[async_trait]
+impl CourseFilesRepository for SqlxDatabase {
+ async fn get_course_files(
+ &self,
+ course_id: CourseId,
+ ) -> Result, RepositoryError> {
+ query_as!(
+ CourseFileDB,
+ r#"SELECT f.id, f.course_id, f.name, f.kind, f.has_pdf, f.created_at, f.updated_at,
+ COALESCE(ARRAY_AGG(ft.tag_id ORDER BY ft.tag_id)
+ FILTER (WHERE ft.tag_id IS NOT NULL), '{}') AS "tag_ids!"
+ FROM course_files f
+ LEFT JOIN file_tags ft ON ft.file_id = f.id
+ WHERE f.course_id = $1
+ GROUP BY f.id
+ ORDER BY f.created_at, f.id"#,
+ course_id,
+ )
+ .fetch_all(&self.pool)
+ .await?
+ .into_iter()
+ .map(TryInto::try_into)
+ .collect()
+ }
+
+ async fn get_course_file(&self, id: CourseFileId) -> Result {
+ query_as!(
+ CourseFileDB,
+ r#"SELECT f.id, f.course_id, f.name, f.kind, f.has_pdf, f.created_at, f.updated_at,
+ COALESCE(ARRAY_AGG(ft.tag_id ORDER BY ft.tag_id)
+ FILTER (WHERE ft.tag_id IS NOT NULL), '{}') AS "tag_ids!"
+ FROM course_files f
+ LEFT JOIN file_tags ft ON ft.file_id = f.id
+ WHERE f.id = $1
+ GROUP BY f.id"#,
+ id,
+ )
+ .fetch_optional(&self.pool)
+ .await?
+ .ok_or_else(|| not_found(id))?
+ .try_into()
+ }
+
+ async fn create_lecture_note(
+ &self,
+ course_id: CourseId,
+ name: String,
+ tag_ids: &[TagId],
+ ) -> Result {
+ let mut tx = self.pool.begin().await?;
+ let id = query!(
+ r#"INSERT INTO course_files (course_id, name, kind) VALUES ($1, $2, 'note')
+ RETURNING id"#,
+ course_id,
+ name,
+ )
+ .fetch_one(&mut *tx)
+ .await?
+ .id;
+ query!(
+ r#"INSERT INTO file_tags (file_id, tag_id)
+ SELECT $1, tag_id FROM UNNEST($2::INTEGER[]) AS tag_id"#,
+ id,
+ tag_ids,
+ )
+ .execute(&mut *tx)
+ .await?;
+ tx.commit().await?;
+ self.get_course_file(id).await
+ }
+
+ async fn update_lecture_note(
+ &self,
+ id: CourseFileId,
+ name: String,
+ has_pdf: bool,
+ ) -> Result {
+ let result = query!(
+ r#"UPDATE course_files SET name = $2, has_pdf = $3, updated_at = now()
+ WHERE id = $1 AND kind = 'note'"#,
+ id,
+ name,
+ has_pdf,
+ )
+ .execute(&self.pool)
+ .await?;
+ if result.rows_affected() == 0 {
+ return Err(not_found(id));
+ }
+ self.get_course_file(id).await
+ }
+
+ async fn delete_course_file(&self, id: CourseFileId) -> Result<(), RepositoryError> {
+ let result = query!("DELETE FROM course_files WHERE id = $1", id)
+ .execute(&self.pool)
+ .await?;
+ if result.rows_affected() == 0 {
+ return Err(not_found(id));
+ }
+ Ok(())
+ }
+}
diff --git a/src/services/database/courses.rs b/src/services/database/courses.rs
index 92715af..619e52f 100644
--- a/src/services/database/courses.rs
+++ b/src/services/database/courses.rs
@@ -1,17 +1,17 @@
-//! Courses and their time slots. A course is always read and written together with
-//! its slots: updating a course replaces all of them, inside a transaction.
+//! Courses and their time slots. A course is always read together with its slots, but
+//! the slots are added and removed one by one.
use std::collections::HashMap;
use async_trait::async_trait;
use chrono::NaiveTime;
-use sqlx::{Postgres, Transaction, query, query_as};
+use sqlx::{PgExecutor, query, query_as};
use crate::{
core::{
models::{
course::{Course, CourseData, CourseId},
- time_slot::{TimeSlot, TimeSlotData},
+ time_slot::{TimeSlot, TimeSlotData, TimeSlotId},
},
repositories::{RepositoryError, courses_repository::CoursesRepository},
},
@@ -64,31 +64,22 @@ impl TryFrom for TimeSlot {
}
}
-/// Inserts the slots of a course, returning them sorted like `get_courses` does
-async fn insert_slots(
- tx: &mut Transaction<'_, Postgres>,
+/// Slots of a course, sorted like `get_courses` does
+async fn get_slots(
+ executor: impl PgExecutor<'_>,
course_id: CourseId,
- slots: Vec,
) -> Result, RepositoryError> {
- let mut inserted = Vec::with_capacity(slots.len());
- for slot in slots {
- let row = query_as!(
- TimeSlotDB,
- r#"INSERT INTO time_slots (course_id, tag_id, weekday, start_time, end_time)
- VALUES ($1, $2, $3, $4, $5)
- RETURNING id, course_id, tag_id, weekday, start_time, end_time"#,
- course_id,
- slot.tag_id,
- i16::from(slot.weekday),
- slot.start_time,
- slot.end_time,
- )
- .fetch_one(&mut **tx)
- .await?;
- inserted.push(row.try_into()?);
- }
- inserted.sort_by_key(|slot: &TimeSlot| (slot.weekday, slot.start_time));
- Ok(inserted)
+ query_as!(
+ TimeSlotDB,
+ r#"SELECT id, course_id, tag_id, weekday, start_time, end_time
+ FROM time_slots WHERE course_id = $1 ORDER BY weekday, start_time, id"#,
+ course_id,
+ )
+ .fetch_all(executor)
+ .await?
+ .into_iter()
+ .map(TryInto::try_into)
+ .collect()
}
#[async_trait]
@@ -126,9 +117,20 @@ impl CoursesRepository for SqlxDatabase {
.collect())
}
- async fn create_course(&self, course: CourseData) -> Result {
- let mut tx = self.pool.begin().await?;
+ async fn get_course(&self, id: CourseId) -> Result {
+ let row = query_as!(
+ CourseDB,
+ r#"SELECT id, name, code, teachers, main_page_url FROM courses WHERE id = $1"#,
+ id,
+ )
+ .fetch_optional(&self.pool)
+ .await?
+ .ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
+ let slots = get_slots(&self.pool, id).await?;
+ Ok(row.with_slots(slots))
+ }
+ async fn create_course(&self, course: CourseData) -> Result {
let row = query_as!(
CourseDB,
r#"INSERT INTO courses (name, code, teachers, main_page_url)
@@ -139,12 +141,9 @@ impl CoursesRepository for SqlxDatabase {
&course.teachers,
course.main_page_url,
)
- .fetch_one(&mut *tx)
+ .fetch_one(&self.pool)
.await?;
- let slots = insert_slots(&mut tx, row.id, course.slots).await?;
-
- tx.commit().await?;
- Ok(row.with_slots(slots))
+ Ok(row.with_slots(Vec::new()))
}
async fn update_course(
@@ -152,8 +151,6 @@ impl CoursesRepository for SqlxDatabase {
id: CourseId,
course: CourseData,
) -> Result {
- let mut tx = self.pool.begin().await?;
-
let row = query_as!(
CourseDB,
r#"UPDATE courses SET name = $2, code = $3, teachers = $4, main_page_url = $5
@@ -165,21 +162,15 @@ impl CoursesRepository for SqlxDatabase {
&course.teachers,
course.main_page_url,
)
- .fetch_optional(&mut *tx)
+ .fetch_optional(&self.pool)
.await?
.ok_or_else(|| RepositoryError::NotFound(format!("course {id}")))?;
-
- query!("DELETE FROM time_slots WHERE course_id = $1", id)
- .execute(&mut *tx)
- .await?;
- let slots = insert_slots(&mut tx, id, course.slots).await?;
-
- tx.commit().await?;
+ let slots = get_slots(&self.pool, id).await?;
Ok(row.with_slots(slots))
}
async fn delete_course(&self, id: CourseId) -> Result<(), RepositoryError> {
- // The slots are removed by the `ON DELETE CASCADE`
+ // Slots, grades, tasks and notes are removed by the `ON DELETE CASCADE`
let result = query!("DELETE FROM courses WHERE id = $1", id)
.execute(&self.pool)
.await?;
@@ -188,4 +179,46 @@ impl CoursesRepository for SqlxDatabase {
}
Ok(())
}
+
+ async fn add_slot(
+ &self,
+ course_id: CourseId,
+ slot: TimeSlotData,
+ ) -> Result {
+ let result = query!(
+ r#"INSERT INTO time_slots (course_id, tag_id, weekday, start_time, end_time)
+ SELECT id, $2, $3, $4, $5 FROM courses WHERE id = $1"#,
+ course_id,
+ slot.tag_id,
+ i16::from(slot.weekday),
+ slot.start_time,
+ slot.end_time,
+ )
+ .execute(&self.pool)
+ .await?;
+ if result.rows_affected() == 0 {
+ return Err(RepositoryError::NotFound(format!("course {course_id}")));
+ }
+ self.get_course(course_id).await
+ }
+
+ async fn delete_slot(
+ &self,
+ course_id: CourseId,
+ slot_id: TimeSlotId,
+ ) -> Result {
+ let result = query!(
+ "DELETE FROM time_slots WHERE id = $1 AND course_id = $2",
+ slot_id,
+ course_id,
+ )
+ .execute(&self.pool)
+ .await?;
+ if result.rows_affected() == 0 {
+ return Err(RepositoryError::NotFound(format!(
+ "slot {slot_id} of course {course_id}"
+ )));
+ }
+ self.get_course(course_id).await
+ }
}
diff --git a/src/services/database/grades.rs b/src/services/database/grades.rs
new file mode 100644
index 0000000..c0ae331
--- /dev/null
+++ b/src/services/database/grades.rs
@@ -0,0 +1,110 @@
+use async_trait::async_trait;
+use sqlx::{query, query_as};
+
+use crate::{
+ core::{
+ models::{
+ course::CourseId,
+ grade::{Grade, GradeData, GradeId, to_swiss},
+ },
+ repositories::{RepositoryError, grades_repository::GradesRepository},
+ },
+ services::database::SqlxDatabase,
+};
+
+struct GradeDB {
+ pub id: i32,
+ pub name: String,
+ pub score: f64,
+ pub max_score: f64,
+ pub weight: f64,
+}
+
+impl From for Grade {
+ fn from(value: GradeDB) -> Self {
+ Grade {
+ id: value.id,
+ swiss_grade: to_swiss(value.score / value.max_score),
+ name: value.name,
+ score: value.score,
+ max_score: value.max_score,
+ weight: value.weight,
+ }
+ }
+}
+
+fn not_found(id: GradeId) -> RepositoryError {
+ RepositoryError::NotFound(format!("grade {id}"))
+}
+
+#[async_trait]
+impl GradesRepository for SqlxDatabase {
+ async fn get_grades(&self, course_id: CourseId) -> Result, RepositoryError> {
+ Ok(query_as!(
+ GradeDB,
+ r#"SELECT id, name, score, max_score, weight FROM grades
+ WHERE course_id = $1 ORDER BY id"#,
+ course_id,
+ )
+ .fetch_all(&self.pool)
+ .await?
+ .into_iter()
+ .map(Into::into)
+ .collect())
+ }
+
+ async fn get_grade_course(&self, id: GradeId) -> Result {
+ query!("SELECT course_id FROM grades WHERE id = $1", id)
+ .fetch_optional(&self.pool)
+ .await?
+ .map(|row| row.course_id)
+ .ok_or_else(|| not_found(id))
+ }
+
+ async fn create_grade(
+ &self,
+ course_id: CourseId,
+ grade: GradeData,
+ ) -> Result<(), RepositoryError> {
+ query!(
+ r#"INSERT INTO grades (course_id, name, score, max_score, weight)
+ VALUES ($1, $2, $3, $4, $5)"#,
+ course_id,
+ grade.name,
+ grade.score,
+ grade.max_score,
+ grade.weight,
+ )
+ .execute(&self.pool)
+ .await?;
+ Ok(())
+ }
+
+ async fn update_grade(&self, id: GradeId, grade: GradeData) -> Result<(), RepositoryError> {
+ let result = query!(
+ r#"UPDATE grades SET name = $2, score = $3, max_score = $4, weight = $5
+ WHERE id = $1"#,
+ id,
+ grade.name,
+ grade.score,
+ grade.max_score,
+ grade.weight,
+ )
+ .execute(&self.pool)
+ .await?;
+ if result.rows_affected() == 0 {
+ return Err(not_found(id));
+ }
+ Ok(())
+ }
+
+ async fn delete_grade(&self, id: GradeId) -> Result<(), RepositoryError> {
+ let result = query!("DELETE FROM grades WHERE id = $1", id)
+ .execute(&self.pool)
+ .await?;
+ if result.rows_affected() == 0 {
+ return Err(not_found(id));
+ }
+ Ok(())
+ }
+}
diff --git a/src/services/database/mod.rs b/src/services/database/mod.rs
index f4f2bff..59a0232 100644
--- a/src/services/database/mod.rs
+++ b/src/services/database/mod.rs
@@ -4,8 +4,11 @@
//! time: `dev-db` must be up and migrated, or the `.sqlx` offline data must be present
//! (`cargo sqlx prepare`).
+mod course_files;
mod courses;
-mod slot_tags;
+mod grades;
+mod tags;
+mod tasks;
use async_trait::async_trait;
use sqlx::{Pool, Postgres, postgres::PgPoolOptions};
diff --git a/src/services/database/slot_tags.rs b/src/services/database/slot_tags.rs
deleted file mode 100644
index 1fa6bad..0000000
--- a/src/services/database/slot_tags.rs
+++ /dev/null
@@ -1,44 +0,0 @@
-use async_trait::async_trait;
-use serde_json::Value;
-use sqlx::query_as;
-
-use crate::{
- core::{
- models::slot_tag::SlotTag,
- repositories::{RepositoryError, slot_tags_repository::SlotTagsRepository},
- },
- services::database::SqlxDatabase,
-};
-
-struct SlotTagDB {
- pub id: i32,
- pub name: Value,
- pub color: String,
-}
-
-impl TryFrom for SlotTag {
- type Error = RepositoryError;
-
- fn try_from(value: SlotTagDB) -> Result {
- Ok(SlotTag {
- id: value.id,
- name: serde_json::from_value(value.name)?,
- color: value.color,
- })
- }
-}
-
-#[async_trait]
-impl SlotTagsRepository for SqlxDatabase {
- async fn get_slot_tags(&self) -> Result, RepositoryError> {
- query_as!(
- SlotTagDB,
- r#"SELECT id, name, color FROM slot_tags ORDER BY id"#
- )
- .fetch_all(&self.pool)
- .await?
- .into_iter()
- .map(TryInto::try_into)
- .collect()
- }
-}
diff --git a/src/services/database/tags.rs b/src/services/database/tags.rs
new file mode 100644
index 0000000..703cded
--- /dev/null
+++ b/src/services/database/tags.rs
@@ -0,0 +1,72 @@
+use async_trait::async_trait;
+use serde_json::Value;
+use sqlx::{query, query_as};
+
+use crate::{
+ core::{
+ models::tag::{Tag, TagId, TagScope},
+ repositories::{RepositoryError, tags_repository::TagsRepository},
+ },
+ services::database::SqlxDatabase,
+};
+
+struct TagDB {
+ pub id: i32,
+ pub name: Value,
+ pub color: String,
+ pub scopes: Vec,
+}
+
+fn parse_scope(scope: &str) -> Result {
+ match scope {
+ "slot" => Ok(TagScope::Slot),
+ "task" => Ok(TagScope::Task),
+ "file" => Ok(TagScope::File),
+ _ => Err(RepositoryError::TypeConversion(format!(
+ "invalid tag scope {scope}"
+ ))),
+ }
+}
+
+impl TryFrom for Tag {
+ type Error = RepositoryError;
+
+ fn try_from(value: TagDB) -> Result {
+ Ok(Tag {
+ id: value.id,
+ name: serde_json::from_value(value.name)?,
+ color: value.color,
+ scopes: value
+ .scopes
+ .iter()
+ .map(|scope| parse_scope(scope))
+ .collect::>()?,
+ })
+ }
+}
+
+#[async_trait]
+impl TagsRepository for SqlxDatabase {
+ async fn get_tags(&self, scope: Option) -> Result, RepositoryError> {
+ query_as!(
+ TagDB,
+ r#"SELECT id, name, color, scopes FROM tags
+ WHERE $1::TEXT IS NULL OR $1 = ANY(scopes)
+ ORDER BY id"#,
+ scope.map(TagScope::as_str),
+ )
+ .fetch_all(&self.pool)
+ .await?
+ .into_iter()
+ .map(TryInto::try_into)
+ .collect()
+ }
+
+ async fn get_tag_id(&self, slug: &str) -> Result {
+ query!("SELECT id FROM tags WHERE slug = $1", slug)
+ .fetch_optional(&self.pool)
+ .await?
+ .map(|row| row.id)
+ .ok_or_else(|| RepositoryError::NotFound(format!("tag {slug}")))
+ }
+}
diff --git a/src/services/database/tasks.rs b/src/services/database/tasks.rs
new file mode 100644
index 0000000..8e1fe2d
--- /dev/null
+++ b/src/services/database/tasks.rs
@@ -0,0 +1,202 @@
+//! Tasks and their tags. A task is always written together with its tags, inside a
+//! transaction.
+
+use async_trait::async_trait;
+use chrono::{DateTime, Utc};
+use sqlx::{Postgres, Transaction, query, query_as};
+
+use crate::{
+ core::{
+ models::{
+ course::CourseId,
+ tag::TagId,
+ task::{Task, TaskData, TaskId},
+ },
+ repositories::{RepositoryError, tasks_repository::TasksRepository},
+ },
+ services::database::SqlxDatabase,
+};
+
+struct TaskDB {
+ pub id: i32,
+ pub course_id: i32,
+ pub parent_id: Option,
+ pub name: String,
+ pub description: Option,
+ pub links: Vec,
+ pub deadline: Option>,
+ pub done: bool,
+ pub tag_ids: Vec,
+}
+
+impl From for Task {
+ fn from(value: TaskDB) -> Self {
+ Task {
+ id: value.id,
+ course_id: value.course_id,
+ parent_id: value.parent_id,
+ name: value.name,
+ description: value.description,
+ links: value.links,
+ deadline: value.deadline,
+ done: value.done,
+ tag_ids: value.tag_ids,
+ subtasks: Vec::new(),
+ }
+ }
+}
+
+fn not_found(id: TaskId) -> RepositoryError {
+ RepositoryError::NotFound(format!("task {id}"))
+}
+
+async fn insert_tags(
+ tx: &mut Transaction<'_, Postgres>,
+ task_id: TaskId,
+ tag_ids: &[TagId],
+) -> Result<(), RepositoryError> {
+ query!(
+ r#"INSERT INTO task_tags (task_id, tag_id)
+ SELECT $1, tag_id FROM UNNEST($2::INTEGER[]) AS tag_id
+ ON CONFLICT DO NOTHING"#,
+ task_id,
+ tag_ids,
+ )
+ .execute(&mut **tx)
+ .await?;
+ Ok(())
+}
+
+#[async_trait]
+impl TasksRepository for SqlxDatabase {
+ async fn get_tasks(&self, course_id: CourseId) -> Result, RepositoryError> {
+ Ok(query_as!(
+ TaskDB,
+ r#"SELECT t.id, t.course_id, t.parent_id, t.name, t.description, t.links,
+ t.deadline, t.done,
+ COALESCE(ARRAY_AGG(tt.tag_id ORDER BY tt.tag_id)
+ FILTER (WHERE tt.tag_id IS NOT NULL), '{}') AS "tag_ids!"
+ FROM tasks t
+ LEFT JOIN task_tags tt ON tt.task_id = t.id
+ WHERE t.course_id = $1
+ GROUP BY t.id
+ ORDER BY t.id"#,
+ course_id,
+ )
+ .fetch_all(&self.pool)
+ .await?
+ .into_iter()
+ .map(Into::into)
+ .collect())
+ }
+
+ async fn get_dated_tasks(
+ &self,
+ from: DateTime,
+ to: DateTime