Skip to content

Commit 041c8b0

Browse files
committed
Add Content Folder and category show
1 parent 1a53665 commit 041c8b0

22 files changed

+1663
-144
lines changed

migration/Cargo.lock

Lines changed: 946 additions & 58 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

migration/src/lib.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
pub use sea_orm_migration::prelude::*;
22

3+
mod m20251113_203047_add_content_folder;
34
mod m20251110_01_create_table_category;
5+
mod m20251113_203899_add_uniq_to_content_folder;
46

57
pub struct Migrator;
68

79
#[async_trait::async_trait]
810
impl MigratorTrait for Migrator {
911
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
10-
vec![Box::new(m20251110_01_create_table_category::Migration)]
12+
vec![
13+
Box::new(m20251110_01_create_table_category::Migration),
14+
Box::new(m20251113_203047_add_content_folder::Migration),
15+
Box::new(m20251113_203899_add_uniq_to_content_folder::Migration)
16+
]
1117
}
1218
}

migration/src/m20251110_01_create_table_category.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ impl MigrationTrait for Migration {
2727
}
2828

2929
#[derive(DeriveIden)]
30-
enum Category {
30+
pub enum Category {
3131
Table,
3232
Id,
3333
Name,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use sea_orm_migration::{prelude::*, schema::*};
2+
3+
use crate::m20251110_01_create_table_category::Category;
4+
5+
#[derive(DeriveMigrationName)]
6+
pub struct Migration;
7+
8+
#[async_trait::async_trait]
9+
impl MigrationTrait for Migration {
10+
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
11+
manager
12+
.create_table(
13+
Table::create()
14+
.table(ContentFolder::Table)
15+
.if_not_exists()
16+
.col(pk_auto(ContentFolder::Id))
17+
.col(string(ContentFolder::Name))
18+
.col(string(ContentFolder::Path))
19+
.col(ColumnDef::new(ContentFolder::CategoryId).integer().not_null())
20+
.foreign_key(
21+
ForeignKey::create()
22+
.name("fk-content-file-category_id")
23+
.from(ContentFolder::Table, ContentFolder::CategoryId)
24+
.to(Category::Table, Category::Id),
25+
)
26+
.col(ColumnDef::new(ContentFolder::ParentId).integer())
27+
.foreign_key(
28+
ForeignKey::create()
29+
.name("fk-content-folder-parent_id")
30+
.from(ContentFolder::ParentId, ContentFolder::ParentId)
31+
.to(ContentFolder::Table, ContentFolder::Id),
32+
)
33+
.to_owned(),
34+
)
35+
.await
36+
}
37+
38+
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
39+
manager
40+
.drop_table(Table::drop().table(ContentFolder::Table).to_owned())
41+
.await
42+
}
43+
}
44+
45+
#[derive(DeriveIden)]
46+
pub enum ContentFolder {
47+
Table,
48+
Id,
49+
Name,
50+
Path,
51+
CategoryId,
52+
ParentId
53+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
use sea_orm_migration::{prelude::*};
2+
3+
use crate::{
4+
m20251113_203047_add_content_folder::ContentFolder,
5+
};
6+
7+
#[derive(DeriveMigrationName)]
8+
pub struct Migration;
9+
10+
#[async_trait::async_trait]
11+
impl MigrationTrait for Migration {
12+
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
13+
manager
14+
.create_index(
15+
Index::create()
16+
.name("content_folder_uniq_path")
17+
.table(ContentFolder::Table)
18+
.col(ContentFolder::Path)
19+
.unique()
20+
.to_owned()
21+
)
22+
.await?;
23+
24+
Ok(())
25+
}
26+
27+
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
28+
manager
29+
.drop_index(
30+
Index::drop()
31+
.name("content_folder_uniq_path")
32+
.table(ContentFolder::Table)
33+
.to_owned(),
34+
)
35+
.await?;
36+
37+
Ok(())
38+
}
39+
}

src/database/category.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::state::logger::LoggerError;
1414
///
1515
/// Each category has a name and an associated path on disk, where
1616
/// symlinks to the content will be created.
17+
#[sea_orm::model]
1718
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
1819
#[sea_orm(table_name = "category")]
1920
pub struct Model {
@@ -23,11 +24,10 @@ pub struct Model {
2324
pub name: String,
2425
#[sea_orm(unique)]
2526
pub path: String,
27+
#[sea_orm(has_many)]
28+
pub content_folders: HasMany<super::content_folder::Entity>,
2629
}
2730

28-
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
29-
pub enum Relation {}
30-
3131
#[async_trait::async_trait]
3232
impl ActiveModelBehavior for ActiveModel {}
3333

@@ -71,6 +71,19 @@ impl CategoryOperator {
7171
.context(DBSnafu)
7272
}
7373

74+
/// Find one category by ID
75+
pub async fn find_by_id(&self, id: i32) -> Result<Model, CategoryError> {
76+
let category = Entity::find_by_id(id)
77+
.one(&self.state.database)
78+
.await
79+
.context(DBSnafu)?;
80+
81+
match category {
82+
Some(category) => Ok(category),
83+
None => Err(CategoryError::NotFound { id }),
84+
}
85+
}
86+
7487
/// Delete a category
7588
pub async fn delete(&self, id: i32, user: Option<User>) -> Result<String, CategoryError> {
7689
let db = &self.state.database;

src/database/content_folder.rs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
use chrono::Utc;
2+
use sea_orm::entity::prelude::*;
3+
use sea_orm::*;
4+
use snafu::prelude::*;
5+
6+
use crate::database::category;
7+
use crate::database::operation::{Operation, OperationId, OperationLog, OperationType, Table};
8+
use crate::extractors::user::User;
9+
use crate::routes::content_folder::ContentFolderForm;
10+
use crate::state::AppState;
11+
use crate::state::logger::LoggerError;
12+
13+
/// A content folder to store associated files.
14+
///
15+
/// Each content folder has a name and an associated path on disk, a Category
16+
/// and it can have an Parent Content Folder (None if it's the first folder
17+
/// in category)
18+
#[sea_orm::model]
19+
#[derive(DeriveEntityModel, Clone, Debug, PartialEq, Eq)]
20+
#[sea_orm(table_name = "content_folder")]
21+
pub struct Model {
22+
#[sea_orm(primary_key)]
23+
pub id: i32,
24+
pub name: String,
25+
#[sea_orm(unique)]
26+
pub path: String,
27+
pub category_id: i32,
28+
#[sea_orm(belongs_to, from = "category_id", to = "id")]
29+
pub category: HasOne<category::Entity>,
30+
pub parent_id: Option<i32>,
31+
#[sea_orm(self_ref, relation_enum = "Parent", from = "parent_id", to = "id")]
32+
pub parent: HasOne<Entity>,
33+
}
34+
35+
#[async_trait::async_trait]
36+
impl ActiveModelBehavior for ActiveModel {}
37+
38+
#[derive(Debug, Snafu)]
39+
#[snafu(visibility(pub))]
40+
pub enum ContentFolderError {
41+
#[snafu(display("There is already a content folder called `{name}`"))]
42+
NameTaken { name: String },
43+
#[snafu(display("There is already a content folder in dir `{path}`"))]
44+
PathTaken { path: String },
45+
#[snafu(display("The Content Folder (Path: {path}) does not exist"))]
46+
NotFound { path: String },
47+
#[snafu(display("Database error"))]
48+
DB { source: sea_orm::DbErr },
49+
#[snafu(display("Failed to save the operation log"))]
50+
Logger { source: LoggerError },
51+
}
52+
53+
#[derive(Clone, Debug)]
54+
pub struct ContentFolderOperator {
55+
pub state: AppState,
56+
pub user: Option<User>,
57+
}
58+
59+
impl ContentFolderOperator {
60+
pub fn new(state: AppState, user: Option<User>) -> Self {
61+
Self { state, user }
62+
}
63+
64+
/// List Content Folders
65+
///
66+
/// Should not fail, unless SQLite was corrupted for some reason.
67+
pub async fn list_by_parent_and_category(
68+
&self,
69+
parent_id: Option<i32>,
70+
category_id: i32,
71+
) -> Result<Vec<Model>, ContentFolderError> {
72+
let mut query = Entity::find().filter(Column::CategoryId.eq(category_id));
73+
// parent_id can be None when it's the first folder in categories
74+
match parent_id {
75+
Some(parent_id) => query = query.filter(Column::ParentId.eq(parent_id)),
76+
None => query = query.filter(Column::ParentId.is_null()),
77+
}
78+
79+
query.all(&self.state.database).await.context(DBSnafu)
80+
}
81+
82+
/// Find one Content Folder by path
83+
///
84+
/// Should not fail, unless SQLite was corrupted for some reason.
85+
pub async fn find_by_path(&self, path: String) -> Result<Model, ContentFolderError> {
86+
let content_folder = Entity::find_by_path(path.clone())
87+
.one(&self.state.database)
88+
.await
89+
.context(DBSnafu)?;
90+
91+
match content_folder {
92+
Some(category) => Ok(category),
93+
None => Err(ContentFolderError::NotFound { path }),
94+
}
95+
}
96+
97+
/// Find one Content Folder by ID
98+
///
99+
/// Should not fail, unless SQLite was corrupted for some reason.
100+
pub async fn find_by_id(&self, id: i32) -> Result<Model, ContentFolderError> {
101+
let content_folder = Entity::find_by_id(id)
102+
.one(&self.state.database)
103+
.await
104+
.context(DBSnafu)?;
105+
106+
match content_folder {
107+
Some(category) => Ok(category),
108+
None => Err(ContentFolderError::NotFound {
109+
path: id.to_string(),
110+
}),
111+
}
112+
}
113+
114+
/// Create a new content folder
115+
///
116+
/// Fails if:
117+
///
118+
/// - name or path is already taken (they should be unique in one folder)
119+
/// - path parent directory does not exist (to avoid completely wrong paths)
120+
pub async fn create(
121+
&self,
122+
f: &ContentFolderForm,
123+
user: Option<User>,
124+
) -> Result<Model, ContentFolderError> {
125+
// Check duplicates in same folder
126+
let list = self
127+
.list_by_parent_and_category(f.parent_id, f.category_id)
128+
.await?;
129+
130+
if list.iter().any(|x| x.name == f.name) {
131+
return Err(ContentFolderError::NameTaken {
132+
name: f.name.clone(),
133+
});
134+
}
135+
136+
if list.iter().any(|x| x.path == f.path) {
137+
return Err(ContentFolderError::PathTaken {
138+
path: f.path.clone(),
139+
});
140+
}
141+
142+
let model = ActiveModel {
143+
name: Set(f.name.clone()),
144+
path: Set(f.path.clone()),
145+
category_id: Set(f.category_id),
146+
parent_id: Set(f.parent_id),
147+
..Default::default()
148+
}
149+
.save(&self.state.database)
150+
.await
151+
.context(DBSnafu)?;
152+
153+
// Should not fail
154+
let model = model.try_into_model().unwrap();
155+
156+
let operation_log = OperationLog {
157+
user,
158+
date: Utc::now(),
159+
table: Table::ContentFolder,
160+
operation: OperationType::Create,
161+
operation_id: OperationId {
162+
object_id: model.id.to_owned(),
163+
name: f.name.to_string(),
164+
},
165+
operation_form: Some(Operation::ContentFolder(f.clone())),
166+
};
167+
168+
self.state
169+
.logger
170+
.write(operation_log)
171+
.await
172+
.context(LoggerSnafu)?;
173+
174+
Ok(model)
175+
}
176+
}

src/database/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
// sea_orm example: https://github.com/SeaQL/sea-orm/blob/master/examples/axum_example/
22
pub mod category;
3+
pub mod content_folder;
34
pub mod operation;

src/database/operation.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
44

55
use crate::extractors::user::User;
66
use crate::routes::category::CategoryForm;
7+
use crate::routes::content_folder::ContentFolderForm;
78

89
/// Type of operation applied to the database.
910
#[derive(Clone, Debug, Display, Serialize, Deserialize)]
@@ -22,6 +23,7 @@ pub struct OperationId {
2223
#[derive(Clone, Debug, Display, Serialize, Deserialize)]
2324
pub enum Table {
2425
Category,
26+
ContentFolder,
2527
}
2628

2729
/// Operation applied to the database.
@@ -31,6 +33,7 @@ pub enum Table {
3133
#[serde(untagged)]
3234
pub enum Operation {
3335
Category(CategoryForm),
36+
ContentFolder(ContentFolderForm),
3437
}
3538

3639
impl std::fmt::Display for Operation {

0 commit comments

Comments
 (0)