Skip to content

Commit 5295f5b

Browse files
committed
Add content folders migrations ans views
1 parent 1acf835 commit 5295f5b

File tree

19 files changed

+711
-9
lines changed

19 files changed

+711
-9
lines changed

src/database/category.rs

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use sea_orm::entity::prelude::*;
44
use sea_orm::*;
55
use snafu::prelude::*;
66

7+
use crate::database::content_folder;
78
use crate::database::operation::*;
89
use crate::extractors::normalized_path::*;
910
use crate::extractors::user::User;
@@ -15,6 +16,7 @@ use crate::state::logger::LoggerError;
1516
///
1617
/// Each category has a name and an associated path on disk, where
1718
/// symlinks to the content will be created.
19+
#[sea_orm::model]
1820
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
1921
#[sea_orm(table_name = "category")]
2022
pub struct Model {
@@ -24,11 +26,10 @@ pub struct Model {
2426
pub name: NormalizedPathComponent,
2527
#[sea_orm(unique)]
2628
pub path: NormalizedPathAbsolute,
29+
#[sea_orm(has_many)]
30+
pub content_folders: HasMany<super::content_folder::Entity>,
2731
}
2832

29-
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
30-
pub enum Relation {}
31-
3233
#[async_trait::async_trait]
3334
impl ActiveModelBehavior for ActiveModel {}
3435

@@ -64,6 +65,31 @@ impl CategoryOperator {
6465
Self { state, user }
6566
}
6667

68+
/// List content folders of category
69+
///
70+
/// Should not fail, unless SQLite was corrupted for some reason.
71+
pub async fn list_folders_by_category(
72+
&self,
73+
category_id: i32,
74+
) -> Result<Vec<crate::database::content_folder::Model>, CategoryError> {
75+
let category: Option<Model> = Entity::find_by_id(category_id)
76+
.one(&self.state.database)
77+
.await
78+
.context(DBSnafu)?;
79+
80+
if let Some(category) = category {
81+
let folders = category
82+
.find_related(content_folder::Entity)
83+
.filter(content_folder::Column::ParentId.is_null())
84+
.all(&self.state.database)
85+
.await
86+
.context(DBSnafu)?;
87+
Ok(folders)
88+
} else {
89+
Err(CategoryError::IDNotFound { id: category_id })
90+
}
91+
}
92+
6793
/// List categories
6894
///
6995
/// Should not fail, unless SQLite was corrupted for some reason.
@@ -74,6 +100,19 @@ impl CategoryOperator {
74100
.context(DBSnafu)
75101
}
76102

103+
/// Find one category by ID
104+
pub async fn find_by_id(&self, id: i32) -> Result<Model, CategoryError> {
105+
let category = Entity::find_by_id(id)
106+
.one(&self.state.database)
107+
.await
108+
.context(DBSnafu)?;
109+
110+
match category {
111+
Some(category) => Ok(category),
112+
None => Err(CategoryError::IDNotFound { id }),
113+
}
114+
}
115+
77116
/// Find one category by Name
78117
pub async fn find_by_name(&self, name: String) -> Result<Model, CategoryError> {
79118
let category = Entity::find()

src/database/content_folder.rs

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

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 {

src/extractors/normalized_path/absolute.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::str::FromStr;
99
use super::*;
1010

1111
/// [NormalizedPath] with extra constraint that it's absolute.
12-
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Hash, DeriveValueType)]
12+
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash, DeriveValueType)]
1313
#[sea_orm(value_type = "String")]
1414
#[serde(into = "String", try_from = "String")]
1515
pub struct NormalizedPathAbsolute {

src/extractors/normalized_path/component.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::str::FromStr;
99
use super::*;
1010

1111
/// [NormalizedPath] with extra constraint that it contains no slashes.
12-
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Hash, DeriveValueType)]
12+
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash, DeriveValueType)]
1313
#[sea_orm(value_type = "String")]
1414
#[serde(into = "String", try_from = "String")]
1515
pub struct NormalizedPathComponent {

src/extractors/normalized_path/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ pub use relative::*;
2222
/// - disallows parent dir traversal (`..`)
2323
/// - has no trailing slash
2424
/// - may contain current dir `./` but these will be discarded
25-
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Hash, DeriveValueType)]
25+
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash, DeriveValueType)]
2626
#[sea_orm(value_type = "String")]
2727
#[serde(into = "String", try_from = "String")]
2828
pub struct NormalizedPath {

src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ pub fn router(state: state::AppState) -> Router {
2727
.route("/categories/new", get(routes::category::new))
2828
.route("/categories/{id}/delete", get(routes::category::delete))
2929
.route("/folders/{category_id}", get(routes::category::show))
30+
.route(
31+
"/folders/{category_name}/{*folder_path}",
32+
get(routes::content_folder::show),
33+
)
34+
.route("/folders", post(routes::content_folder::create))
3035
.route("/logs", get(routes::logs::index))
3136
// Register static assets routes
3237
.nest("/assets", static_router())

src/migration/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: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
use sea_orm_migration::{prelude::*, schema::*};
2+
3+
use super::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(
20+
ColumnDef::new(ContentFolder::CategoryId)
21+
.integer()
22+
.not_null(),
23+
)
24+
.foreign_key(
25+
ForeignKey::create()
26+
.name("fk-content-file-category_id")
27+
.from(ContentFolder::Table, ContentFolder::CategoryId)
28+
.to(Category::Table, Category::Id),
29+
)
30+
.col(ColumnDef::new(ContentFolder::ParentId).integer())
31+
.foreign_key(
32+
ForeignKey::create()
33+
.name("fk-content-folder-parent_id")
34+
.from(ContentFolder::ParentId, ContentFolder::ParentId)
35+
.to(ContentFolder::Table, ContentFolder::Id),
36+
)
37+
.to_owned(),
38+
)
39+
.await
40+
}
41+
42+
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
43+
manager
44+
.drop_table(Table::drop().table(ContentFolder::Table).to_owned())
45+
.await
46+
}
47+
}
48+
49+
#[derive(DeriveIden)]
50+
pub enum ContentFolder {
51+
Table,
52+
Id,
53+
Name,
54+
Path,
55+
CategoryId,
56+
ParentId,
57+
}

0 commit comments

Comments
 (0)