895a1df3fd8fdecf50b211863b6206cce446dfb7 — Thomas Letan 1 year, 3 months ago 3d2039f
feature: Introduce character sheet and validation process

The present commit implements many changes related to character
information. Now, the main idea to keep in mind is that a given
character can have different versions of its information. The current
implementation lacks the actual validation.
A migrations/2018-09-18-204037_alter_characters_table/down.sql => migrations/2018-09-18-204037_alter_characters_table/down.sql +1 -0
@@ 0,0 1,1 @@
+(fail)

A migrations/2018-09-18-204037_alter_characters_table/up.sql => migrations/2018-09-18-204037_alter_characters_table/up.sql +14 -0
@@ 0,0 1,14 @@
+ALTER TABLE characters
+DROP name;
+
+ALTER TABLE characters
+DROP history;
+
+ALTER TABLE characters
+DROP main;
+
+ALTER TABLE characters
+DROP avatar_small;
+
+ALTER TABLE characters
+DROP avatar_large;

A migrations/2018-09-18-204038_character_summary_history/down.sql => migrations/2018-09-18-204038_character_summary_history/down.sql +1 -0
@@ 0,0 1,1 @@
+DROP TABLE sheets;

A migrations/2018-09-18-204038_character_summary_history/up.sql => migrations/2018-09-18-204038_character_summary_history/up.sql +56 -0
@@ 0,0 1,56 @@
+CREATE TABLE sheets (
+  id             SERIAL PRIMARY KEY,
+  character_id   INTEGER NOT NULL REFERENCES characters,
+  created        TIMESTAMP NOT NULL DEFAULT now(),
+  validated      TIMESTAMP,
+  current        BOOLEAN,
+  future         BOOLEAN,
+  invalidated    TIMESTAMP,
+
+  name          VARCHAR(80) NOT NULL,
+  avatar_small  INTEGER REFERENCES images,
+  avatar_large  INTEGER REFERENCES images,
+
+  history       INTEGER NOT NULL REFERENCES documents,
+
+  CONSTRAINT validation_process CHECK(
+    (invalidated >= validated) AND (validated >= created)
+  ),
+
+  CONSTRAINT invalidated_has_been_validated CHECK(
+    /* invalidated is not null -> validated is not null */
+    (invalidated IS NULL) OR (validated IS NOT NULL)
+  ),
+
+  /* there is only one “current” sheet per character (we rely on the fact that
+     NULL is ignored for uniqueness) */
+  CONSTRAINT current_or_null   CHECK(current),
+  CONSTRAINT one_sheet_current UNIQUE (character_id, current),
+
+  CONSTRAINT current_is_validated CHECK(
+    /* current is not null -> validated is not null */
+    (current IS NULL) OR (validated IS NOT NULL)
+  ),
+  CONSTRAINT invalidated_is_not_current CHECK(
+    /* invalidated is not null -> current is null */
+    (invalidated IS NULL) OR (current IS NULL)
+  ),
+
+  CONSTRAINT future_or_null    CHECK(future),
+  CONSTRAINT one_sheet_future  UNIQUE (character_id, future),
+
+  CONSTRAINT future_is_not_validated CHECK(
+    /* future is not null -> validated is null */
+    (future IS NULL) OR (validated IS NULL)
+  ),
+
+  CONSTRAINT current_has_small_avatar CHECK(
+    /* current is not null -> avatar_small is not null */
+    (current IS NULL) OR (avatar_small IS NOT NULL)
+  ),
+
+  CONSTRAINT current_has_large_avatar CHECK(
+    /* current is not null -> avatar_small is not null */
+    (current IS NULL) OR (avatar_large IS NOT NULL)
+  )
+);

M src/main.rs => src/main.rs +5 -3
@@ 43,7 43,7 @@ use db::{PgPool, PgConn, establish_connection};
 
 use ::errors::{Error, ResultExt};
 use ::models::user::User;
-use ::models::character::Character;
+use ::models::sheet::Sheet;
 use ::models::image::{ImageId, Image};
 
 #[get("/", rank=1)]


@@ 64,14 64,14 @@ fn index_auth(
         .get_result::<i64>(conn.get())
         .chain_err(|| "Could not query users table")?;
 
-    let characters = Character::find_by_owner(user.id, &conn)?;
+    let sheets = Sheet::get_current_sheets_of(user.id, &conn)?;
 
     Ok(Template::render("index", json!({
         "msg": msg,
         "nb": nb,
         "user": json!({
             "default": user.default_character,
-            "characters": characters,
+            "characters": sheets,
             "nickname": user.nickname
         }),
         "hash": env!("GIT_HASH")


@@ 131,6 131,8 @@ fn run() -> Result <(), Error> {
             routes::session::select_character_auth,
             routes::document::submit_document,
             routes::document::render_document,
+            routes::account::account_index,
+            routes::characters::get_current_sheet,
             static_files,
         ])
         .manage(conn)

M src/models/character.rs => src/models/character.rs +12 -50
@@ 1,52 1,32 @@
 use diesel;
 use diesel::prelude::*;
 
-use galatian;
-use galatian::typography::French;
-use galatian::html::Html;
-
 use ::db::PgConn;
 use ::errors::{Error, ResultExt};
 use ::models::id::{CharacterId, UserId};
 use ::models::entity::{EntityId, Entity, Role};
-use ::models::image::ImageId;
-use ::models::document::{DocumentId, Document, Lang};
+use ::models::sheet::{Sheet, Content};
 use ::schema::characters;
 
 #[derive(Queryable)]
 struct CharacterDb {
     id: i32,
     entity_id: i32,
-    name: String,
-    history: i32,
     owner: Option<i32>,
-    main: bool,
-    avatar_small: Option<i32>,
-    avatar_large: Option<i32>,
 }
 
 #[derive(Insertable)]
 #[table_name="characters"]
 pub struct NewCharacter {
     entity_id: i32,
-    name: String,
-    history: i32,
     owner: Option<i32>,
-    main: bool,
-    avatar_small: Option<i32>,
-    avatar_large: Option<i32>,
 }
 
 #[derive(Clone, Serialize, Deserialize)]
 pub struct Character {
     pub id: CharacterId,
     pub entity_id: EntityId,
-    pub history_id: DocumentId,
-    pub name: String,
     pub owner: Option<UserId>,
-    pub main: bool,
-    pub avatar_small: ImageId,
-    pub avatar_large: ImageId,
 }
 
 impl From<CharacterDb> for Character {


@@ 54,57 34,39 @@ impl From<CharacterDb> for Character {
         Character {
             id: CharacterId::from(e.id),
             entity_id: EntityId::from(e.entity_id),
-            history_id: DocumentId::from(e.history),
-            name: e.name,
             owner: e.owner.map(UserId::from),
-            main: e.main,
-            avatar_small: ImageId::from(e.avatar_small.unwrap_or(1)),
-            avatar_large: ImageId::from(e.avatar_large.unwrap_or(2)),
         }
     }
 }
 
 impl Character {
     pub fn create(
-        name: String,
         owner: Option<UserId>,
-        history: String,
-        main: bool,
-        avatar_small: Option<ImageId>,
-        avatar_large: Option<ImageId>,
+        sheet: Content,
         conn: &PgConn
     ) -> Result<Character, Error> {
-        let rendered = galatian::compile(history.as_str(), &French, &Html)
-            .map_err::<Error, _>(|_| {
-                "Could not parse galatian document for some reason".into()
-            })?;
-
         conn.transaction(|| {
             let entity = Entity::create(Role::Character, conn)
                 .chain_err(|| {
                     "Could not create a new entity for a new character"
                 })?;
 
-            let doc = Document::create(
-                history,
-                rendered.into_string(),
-                Lang::Galatian,
-                conn
-            )?;
-
-            diesel::insert_into(characters::table)
+            let character = diesel::insert_into(characters::table)
                 .values(&NewCharacter {
                     entity_id: entity,
-                    history: doc.id.into(),
-                    name: name,
                     owner: owner.map(|x| x.into()),
-                    main: main,
-                    avatar_small: avatar_small.map(|x| x.into()),
-                    avatar_large: avatar_large.map(|x| x.into()),
                 })
                 .get_result::<CharacterDb>(conn.get())
                 .chain_err(|| "Could not create a new character")
-                .map(Character::from)
+                .map(Character::from)?;
+
+            Sheet::try_create_first_sheet(
+                character.id,
+                sheet,
+                &conn
+            )?;
+
+            Ok(character)
         })
     }
 

M src/models/document.rs => src/models/document.rs +34 -1
@@ 5,6 5,11 @@ use ::errors::{ResultExt, Error};
 use ::schema::documents;
 use ::db::PgConn;
 
+use galatian;
+use galatian::html::Html;
+use galatian::typography::English;
+
+#[derive(Clone, Serialize, Deserialize)]
 pub enum Lang {
     Galatian
 }


@@ 55,6 60,7 @@ struct NewDocument {
     rendered: String,
 }
 
+#[derive(Clone, Serialize, Deserialize)]
 pub struct Document {
   pub id: DocumentId,
   pub language: Lang,


@@ 72,12 78,28 @@ impl Document {
         })
     }
 
+    fn render(
+        raw: &String,
+        lang: &Lang,
+    ) -> Result<String, Error> {
+        match lang {
+            Lang::Galatian => {
+                galatian::compile(raw.as_str(), &English, &Html)
+                    .map_err(|_| {
+                        "could not compile galatian document for some reason".into()
+                    })
+                    .map(|r| r.into_string())
+            }
+        }
+    }
+
     pub fn create(
         raw: String,
-        rendered: String,
         lang: Lang,
         conn: &PgConn
     ) -> Result<Document, Error> {
+        let rendered = Document::render(&raw, &lang)?;
+
         diesel::insert_into(documents::table)
             .values(&NewDocument {
                 language: lang.to_i16(),


@@ 88,4 110,15 @@ impl Document {
             .chain_err(|| "Could not create a new entity")
             .and_then(Document::try_from)
     }
+
+    pub fn get(
+        id: DocumentId,
+        conn: &PgConn,
+    ) -> Result<Document, Error> {
+        documents::table
+            .filter(documents::id.eq(id.into(): i32))
+            .get_result::<DocumentDb>(conn.get())
+            .chain_err(|| "Hem?")
+            .and_then(Document::try_from)
+    }
 }

M src/models/id.rs => src/models/id.rs +15 -0
@@ 1,4 1,19 @@
 #[derive(Clone, Copy, Serialize, Deserialize)]
+pub struct Id<A>(i32, ::std::marker::PhantomData<A>);
+
+impl<A> From<i32> for Id<A> {
+    fn from(id: i32) -> Id<A> {
+        Id(id, ::std::marker::PhantomData)
+    }
+}
+
+impl<A> Into<i32> for Id<A> {
+    fn into(self) -> i32 {
+        self.0
+    }
+}
+
+#[derive(Clone, Copy, Serialize, Deserialize)]
 pub struct UserId(i32);
 
 impl From<i32> for UserId {

M src/models/mod.rs => src/models/mod.rs +1 -0
@@ 3,4 3,5 @@ pub mod image;
 pub mod user;
 pub mod entity;
 pub mod document;
+pub mod sheet;
 pub mod character;

A src/models/sheet.rs => src/models/sheet.rs +320 -0
@@ 0,0 1,320 @@
+use std::time::SystemTime;
+
+use diesel;
+use diesel::prelude::*;
+
+use ::errors::{ResultExt, Error};
+use ::schema::*;
+use ::db::PgConn;
+use ::models::id::{Id, CharacterId, UserId};
+use ::models::image::ImageId;
+use ::models::document::{DocumentId, Document};
+
+#[derive(Queryable)]
+struct SheetDb {
+    id:            i32,
+    character_id:  i32,
+    created:       SystemTime,
+    validated:     Option<SystemTime>,
+    current:       Option<bool>,
+    future:        Option<bool>,
+    invalidated:   Option<SystemTime>,
+    name:          String,
+    avatar_small:  Option<i32>,
+    avatar_large:  Option<i32>,
+    history:       i32,
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub enum Validation {
+    Old(SystemTime),
+    Current(SystemTime),
+    Next,
+    Invalidated(SystemTime, SystemTime),
+}
+
+impl Validation {
+    pub fn decide(
+        current: bool,
+        future: bool,
+        validated: Option<SystemTime>,
+        invalidated: Option<SystemTime>,
+    ) -> Option<Validation> {
+        match (current, future, validated, invalidated) {
+            (false, true, None, None) => Some(Validation::Next),
+            (true, false, Some(val), None) => Some(Validation::Current(val)),
+            (false, false, Some(val), None) => Some(Validation::Old(val)),
+            (false, false, Some(val), Some(inval)) => Some(Validation::Invalidated(val, inval)),
+            _ => None,
+        }
+    }
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct Content {
+    pub name:          String,
+    pub avatar_small:  Option<ImageId>,
+    pub avatar_large:  Option<ImageId>,
+    pub history:       DocumentId,
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct Sheet {
+    pub id:            Id<Sheet>,
+    pub character_id:  CharacterId,
+    pub created:       SystemTime,
+    pub validation:    Validation,
+    pub content:       Content,
+}
+
+impl Sheet {
+    fn try_from(origin: SheetDb) -> Result<Sheet, Error> {
+        Ok(Sheet {
+            id: Id::from(origin.id),
+            character_id: CharacterId::from(origin.character_id),
+            created: origin.created,
+            validation: Validation::decide(
+                origin.current.unwrap_or(false),
+                origin.future.unwrap_or(false),
+                origin.validated,
+                origin.invalidated,
+            ).ok_or("Invalid db scheme".into(): Error)?,
+            content: Content {
+                name: origin.name,
+                avatar_small: origin.avatar_small.map(|x| x.into()),
+                avatar_large: origin.avatar_large.map(|x| x.into()),
+                history: origin.history.into(),
+            },
+        })
+    }
+}
+
+#[derive(Insertable)]
+#[table_name="sheets"]
+struct NewSheet {
+    pub character_id:  i32,
+    pub created:       SystemTime,
+    pub name:          String,
+    pub avatar_small:  Option<i32>,
+    pub avatar_large:  Option<i32>,
+    pub history:       i32,
+    pub future:        Option<bool>,
+}
+
+impl Sheet {
+    pub fn get_future_sheets_of(
+        user: UserId,
+        conn: &PgConn,
+    ) -> Result<Vec<Sheet>, Error> {
+        let mut sheets = characters::table
+            .filter(characters::owner.eq(Some(user.into(): i32)))
+            .inner_join(sheets::table)
+            .select(sheets::all_columns)
+            .filter(sheets::future.eq(Some(true)))
+            .get_results::<SheetDb>(conn.get())
+            .chain_err(|| "")?;
+
+        let res = sheets
+            .drain(..)
+            .fold(
+                Ok(Vec::new()),
+                |res: Result<Vec<Sheet>, Error>, x| {
+                    match res {
+                        Ok(mut res) => {
+                            res.push(Sheet::try_from(x)?);
+                            Ok(res)
+                        },
+                        err => err,
+                    }
+                }
+            )?;
+
+        Ok(res)
+    }
+
+    pub fn get_current_sheets_of(
+        user: UserId,
+        conn: &PgConn,
+    ) -> Result<Vec<Sheet>, Error> {
+        let mut sheets = characters::table
+            .filter(characters::owner.eq(Some(user.into(): i32)))
+            .inner_join(sheets::table)
+            .select(sheets::all_columns)
+            .filter(sheets::current.eq(Some(true)))
+            .get_results::<SheetDb>(conn.get())
+            .chain_err(|| "")?;
+
+        let res = sheets
+            .drain(..)
+            .fold(
+                Ok(Vec::new()),
+                |res: Result<Vec<Sheet>, Error>, x| {
+                    match res {
+                        Ok(mut res) => {
+                            res.push(Sheet::try_from(x)?);
+                            Ok(res)
+                        },
+                        err => err,
+                    }
+                }
+            )?;
+
+        Ok(res)
+    }
+
+    pub fn try_create_first_sheet(
+        character_id:  CharacterId,
+        content:       Content,
+        conn:          &PgConn
+    ) -> Result<Sheet, Error> {
+        conn.transaction(|| {
+            diesel::insert_into(sheets::table)
+                .values(&NewSheet {
+                    character_id: character_id.into(),
+                    created: SystemTime::now(),
+                    name:    content.name,
+                    avatar_small: content.avatar_small.map(|x| x.into()),
+                    avatar_large: content.avatar_large.map(|x| x.into()),
+                    history: content.history.into(),
+                    future:  Some(true),
+                })
+                .get_result::<SheetDb>(conn.get())
+                .chain_err(|| "Hum")
+                .and_then(Sheet::try_from)
+        })
+    }
+
+    fn get_current_sheet_db(
+        character:  i32,
+        conn:       &PgConn,
+    ) -> Result<Option<SheetDb>, Error> {
+        sheets::table
+            .filter(sheets::character_id.eq(character)
+                    .and(sheets::current.eq(Some(true))))
+            .limit(1)
+            .get_results::<SheetDb>(conn.get())
+            .chain_err(|| "")
+            .map(|mut res|  res.pop())
+    }
+
+    pub fn get_current_sheet(
+        character:  CharacterId,
+        conn:       &PgConn,
+    ) -> Result<Option<Sheet>, Error> {
+        Sheet::get_current_sheet_db(character.into(): i32, &conn)
+            .and_then(|res| {
+                if let Some(res) = res {
+                    Ok(Some(Sheet::try_from(res)?))
+                } else {
+                    Ok(None)
+                }
+            })
+    }
+
+    fn get_future_sheet_db(
+        character:  i32,
+        conn:       &PgConn,
+    ) -> Result<Option<SheetDb>, Error> {
+        sheets::table
+            .filter(sheets::character_id.eq(character)
+                    .and(sheets::future.eq(Some(true))))
+            .limit(1)
+            .get_results::<SheetDb>(conn.get())
+            .chain_err(|| "")
+            .map(|mut res|  res.pop())
+    }
+
+    pub fn get_future_sheet(
+        character:  CharacterId,
+        conn:       &PgConn,
+    ) -> Result<Option<Sheet>, Error> {
+        Sheet::get_future_sheet_db(character.into(): i32, &conn)
+            .and_then(|res| {
+                if let Some(res) = res {
+                    Ok(Some(Sheet::try_from(res)?))
+                } else {
+                    Ok(None)
+                }
+            })
+    }
+
+    pub fn validate_future_sheet(
+        character:  CharacterId,
+        conn:       &PgConn,
+    ) -> Result<bool, Error> {
+        let current = Sheet::get_current_sheet_db(character.into(), conn)?;
+        let future = Sheet::get_future_sheet_db(character.into(), conn)?;
+
+        match (current, future) {
+            (Some(current), Some(future)) => {
+                conn.transaction(|| {
+                    /* step one, unmark the current one */
+                    diesel::update(sheets::table.find(current.id.into(): i32))
+                        .set(sheets::current.eq(None: Option<bool>))
+                        .execute(conn.get())
+                        .chain_err(|| "could not unmark the current sheet")?;
+
+                    /* step two, mark the future one as current */
+                    diesel::update(sheets::table.find(future.id.into(): i32))
+                        .set((
+                            sheets::future.eq(None: Option<bool>),
+                            sheets::current.eq(Some(true)),
+                            sheets::validated.eq(Some(SystemTime::now()))
+                        ))
+                        .execute(conn.get())
+                        .chain_err(|| "could not mark the future sheet as current")?;
+
+                    Ok(true)
+                })
+            },
+            (None, Some(future)) => {
+                /* mark the future one as current */
+                diesel::update(sheets::table.find(future.id.into(): i32))
+                    .set((
+                        sheets::future.eq(None: Option<bool>),
+                        sheets::current.eq(Some(true)),
+                        sheets::validated.eq(Some(SystemTime::now()))
+                    ))
+                    .execute(conn.get())
+                    .chain_err(|| "..")?;
+
+                Ok(true)
+            },
+            _ => {
+                // nothing to do
+                Ok(false)
+            }
+        }
+    }
+}
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct SheetExtended {
+    pub character_id:  CharacterId,
+    pub created:       SystemTime,
+    pub validation:    Validation,
+    pub name:          String,
+    pub avatar_small:  Option<ImageId>,
+    pub avatar_large:  Option<ImageId>,
+    pub history:       Document,
+}
+
+impl SheetExtended {
+    pub fn extend_from(
+        sh: Sheet,
+        conn: &PgConn
+    ) -> Result<SheetExtended, Error> {
+        let history = Document::get(sh.content.history, conn)?;
+
+        Ok(SheetExtended {
+            character_id: sh.character_id,
+            created: sh.created,
+            validation: sh.validation,
+            name: sh.content.name,
+            avatar_small: sh.content.avatar_small,
+            avatar_large: sh.content.avatar_large,
+            history: history
+
+        })
+    }
+}

A src/routes/account/mod.rs => src/routes/account/mod.rs +29 -0
@@ 0,0 1,29 @@
+use rocket_contrib::Template;
+
+use ::db::PgConn;
+use ::errors::Error;
+use ::models::user::User;
+use ::models::sheet::Sheet;
+
+#[get("/account")]
+pub fn account_index(
+    user: User,
+    conn: PgConn,
+) -> Result<Template, Error> {
+    let currents = Sheet::get_current_sheets_of(user.id, &conn)?;
+    let futures = Sheet::get_future_sheets_of(user.id, &conn)?;
+
+    Ok(Template::render(
+        "account_index",
+        json!({
+            "hash": env!("GIT_HASH"),
+            "currents": currents,
+            "futures": futures,
+            "user": json!({
+                "default": user.default_character,
+                "characters": currents,
+                "nickname": user.nickname
+            }),
+        })
+    ))
+}

A src/routes/characters.rs => src/routes/characters.rs +49 -0
@@ 0,0 1,49 @@
+use rocket_contrib::Template;
+
+use ::db::PgConn;
+use ::errors::Error;
+use ::models::user::User;
+use ::models::sheet::{Sheet, SheetExtended};
+use ::models::id::CharacterId;
+
+#[get("/character/<id>/current")]
+pub fn get_current_sheet(
+    user: Option<User>,
+    conn: PgConn,
+    id: i32,
+) -> Result<Template, Error> {
+    let id = CharacterId::from(id);
+
+    let sheet = match Sheet::get_current_sheet(id, &conn)? {
+        Some(sheet) => {
+            Some(SheetExtended::extend_from(sheet, &conn)?)
+        },
+        None => {
+            None
+        },
+    };
+
+    let user = match user {
+        Some(user) => {
+            let sheets = Sheet::get_current_sheets_of(user.id, &conn)?;
+
+            Some(json!({
+                "default": user.default_character,
+                "characters": sheets,
+                "nickname": user.nickname
+            }))
+        }
+        _ => {
+            None
+        }
+    };
+
+    Ok(Template::render(
+        "character_current",
+        json!({
+            "sheet": sheet,
+            "hash": env!("GIT_HASH"),
+            "user": user,
+        })
+    ))
+}

M src/routes/document.rs => src/routes/document.rs +30 -23
@@ 5,8 5,10 @@ use ::db::PgConn;
 use ::models::image::Image;
 use ::models::user::User;
 use ::models::character::Character;
-
-use errors::Error;
+use ::models::sheet::Sheet;
+use ::models::document::{Lang, Document};
+use ::models::sheet::Content;
+use ::errors::Error;
 
 #[get("/document")]
 pub fn submit_document(


@@ 14,12 16,12 @@ pub fn submit_document(
     user: User,
 ) -> Result<Template, Error> {
 
-    let characters = Character::find_by_owner(user.id, &conn)?;
+    let sheets = Sheet::get_current_sheets_of(user.id, &conn)?;
 
     Ok(Template::render("document", json!({
         "user": json!({
             "default": user.default_character,
-            "characters": characters,
+            "characters": sheets,
             "nickname": user.nickname
         }),
         "hash": env!("GIT_HASH")


@@ 42,28 44,33 @@ pub fn render_document<'a>(
     submission: Form<RenderRequest>,
     conn: PgConn,
 ) -> Result<Template, Error> {
-    let submission = submission.get();
+    conn.transaction(|| {
+        let submission = submission.into_inner();
 
-    let small_id = Image::create_data_url(&submission.avatar_small, &conn)?
-        .map(|x| x.id);
-    let large_id = Image::create_data_url(&submission.avatar_large, &conn)?
-        .map(|x| x.id);
+        let small_id = Image::create_data_url(&submission.avatar_small, &conn)?
+            .map(|x| x.id);
+        let large_id = Image::create_data_url(&submission.avatar_large, &conn)?
+            .map(|x| x.id);
 
-    Character::create(
-        submission.name.clone(),
-        Some(user.id),
-        submission.history.clone(),
-        true,
-        small_id,
-        large_id,
-        &conn,
-    )?;
+        let history_id = Document::create(
+            submission.history,
+            Lang::Galatian,
+            &conn
+        )?.id;
 
-    let characters = Character::find_by_owner(user.id, &conn)?;
+        Character::create(
+            Some(user.id),
+            Content {
+                name: submission.name,
+                avatar_small: small_id,
+                avatar_large: large_id,
+                history: history_id,
+            },
+            &conn,
+        )
+    })?;
 
-    if user.default_character.is_none() && characters.len() > 0 {
-        user.set_default_character(&conn, characters[0].id)?;
-    }
+    let sheets = Sheet::get_current_sheets_of(user.id, &conn)?;
 
     Ok(
         Template::render(


@@ 72,7 79,7 @@ pub fn render_document<'a>(
             "hash": env!("GIT_HASH"),
             "user": json!({
                 "default": user.default_character,
-                "characters": characters,
+                "characters": sheets,
                 "nickname": user.nickname
             }),
         }))

M src/routes/mod.rs => src/routes/mod.rs +2 -0
@@ 1,2 1,4 @@
 pub mod session;
 pub mod document;
+pub mod account;
+pub mod characters;

M src/schema.rs => src/schema.rs +19 -6
@@ 2,12 2,7 @@ table! {
     characters (id) {
         id -> Int4,
         entity_id -> Int4,
-        name -> Varchar,
-        history -> Int4,
         owner -> Nullable<Int4>,
-        main -> Bool,
-        avatar_small -> Nullable<Int4>,
-        avatar_large -> Nullable<Int4>,
     }
 }
 


@@ 61,6 56,22 @@ table! {
 }
 
 table! {
+    sheets (id) {
+        id -> Int4,
+        character_id -> Int4,
+        created -> Timestamp,
+        validated -> Nullable<Timestamp>,
+        current -> Nullable<Bool>,
+        future -> Nullable<Bool>,
+        invalidated -> Nullable<Timestamp>,
+        name -> Varchar,
+        avatar_small -> Nullable<Int4>,
+        avatar_large -> Nullable<Int4>,
+        history -> Int4,
+    }
+}
+
+table! {
     users (id) {
         id -> Int4,
         nickname -> Varchar,


@@ 68,11 79,12 @@ table! {
     }
 }
 
-joinable!(characters -> documents (history));
 joinable!(characters -> entities (entity_id));
 joinable!(event_participants -> entities (entity_id));
 joinable!(event_participants -> events (event_id));
 joinable!(events -> documents (description));
+joinable!(sheets -> characters (character_id));
+joinable!(sheets -> documents (history));
 
 allow_tables_to_appear_in_same_query!(
     characters,


@@ 82,5 94,6 @@ allow_tables_to_appear_in_same_query!(
     events,
     images,
     relationships,
+    sheets,
     users,
 );

A templates/account_index.html.tera => templates/account_index.html.tera +48 -0
@@ 0,0 1,48 @@
+{% extends "base" %}
+
+{% block content %}
+<section class="ui text container">
+  <h1 class="ui header">
+    {{ user.nickname }}
+  </h1>
+
+  <p>
+    Here is the list of your characters.
+    <a href="/document">Want to create a new one?</a>
+  </p>
+
+  {% if futures %}
+  <h2 class="ui header">
+    To Be Validated
+  </h2>
+
+  <ul>
+    {% for character in futures %}
+    <li>
+      <img class="ui avatar image"
+           src="/img/{{ character.content.avatar_small }}" />
+      {{ character.content.name }}
+    </li>
+    {% endfor %}
+  </ul>
+  {% endif %}
+
+  {% if currents %}
+  <h2 class="ui header">
+    Active Characters
+  </h2>
+
+  <ul>
+    {% for character in currents %}
+    <li>
+      <img class="ui avatar image"
+           src="/img/{{ character.content.avatar_small }}" />
+      <a href="/character/{{ character.character_id }}/current">
+        {{ character.content.name }}
+      </a>
+    </li>
+    {% endfor %}
+  </ul>
+  {% endif %}
+</section>
+{% endblock content %}

M templates/base.html.tera => templates/base.html.tera +5 -5
@@ 5,9 5,9 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no" />
     <meta name="referrer" content="no-referrer">
     <title>ogmios</title>
-    <link rel="stylesheet" type="text/css" href="static/semantic/semantic.min.css" />
-    <link rel="stylesheet" type="text/css" href="static/croppie.css" />
-    <link rel="stylesheet" type="text/css" href="static/main.css" />
+    <link rel="stylesheet" type="text/css" href="/static/semantic/semantic.min.css" />
+    <link rel="stylesheet" type="text/css" href="/static/croppie.css" />
+    <link rel="stylesheet" type="text/css" href="/static/main.css" />
   </head>
   <body>
   <header class="ui text fluid">


@@ 39,8 39,8 @@
     integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8="
     crossorigin="anonymous">
   </script>
-  <script src="static/croppie.min.js"></script>
-  <script src="static/semantic/semantic.min.js"></script>
+  <script src="/static/croppie.min.js"></script>
+  <script src="/static/semantic/semantic.min.js"></script>
   <script>
     $('#characterSelection')
     .dropdown({

A templates/character_current.html.tera => templates/character_current.html.tera +21 -0
@@ 0,0 1,21 @@
+{% extends "base" %}
+
+{% block content %}
+<section class="ui text container">
+  {% if sheet %}
+  <h1 class="ui header">
+    {{ sheet.name }}
+  </h1>
+
+  <img src="/img/{{ sheet.avatar_large }}">
+
+  <h2 class="ui header">History</h2>
+
+  {{ sheet.history.rendered | safe }}
+  {% else %}
+  <p>
+    This character, assuming it exists, has not been validated yet.
+  </p>
+  {% endif %}
+</section>
+{% endblock content %}

M templates/partials/navbar-auth.html.tera => templates/partials/navbar-auth.html.tera +5 -5
@@ 14,16 14,16 @@
   <div class="default text">Manage your characters</div>
   <div class="menu">
     {% for character in user.characters %}
-    <div class="item {% if user.default == character.id %} selected {% endif %}"
-         data-value={{ character.id }}>
+    <div class="item {% if user.default == character.character_id %} selected {% endif %}"
+         data-value={{ character.character_id }}>
       <img class="ui avatar image"
-           src="/img/{{ character.avatar_small }}" />
-      {{ character.name }}
+           src="/img/{{ character.content.avatar_small }}" />
+      {{ character.content.name }}
     </div>
     {% endfor %}
     <div class="divider"></div>
     <div class="item" data-value="manage">
-      <a href="/manage-characters">
+      <a href="/account">
         Manage your characters
       </a>
     </div>