hgame 0.26.4

CG production management structs, e.g. of assets, personnels, progress, etc.
Documentation
use super::*;

use mkentity::{Entity, ProjectSource};
use serde::{Deserialize, Serialize};

const USERNAME_UNIX_ENV_VAR: &str = "USER";
const USERNAME_WINDOWS_ENV_VAR: &str = "USERNAME";
const UNDEFINED_USERNAME: &str = "undefined_user";

#[derive(Default, Serialize, Deserialize)]
/// A staff in a project.
/// LEGACY DESIGN: DO NOT change field names or `serde(rename)` values.
pub struct Staff {
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    id: Option<bson::oid::ObjectId>,

    #[serde(rename = "staff_name")]
    name: String,

    #[serde(rename = "role")]
    pub role_str: String,

    // level: i32, // obsolete
    //
    #[serde(skip)]
    pub(crate) role: ProductionRole,

    #[serde(skip)]
    #[cfg(all(feature = "ldap", feature = "image_processing"))]
    pub(super) profile_img: Option<AnyResult<RetainedImage>>,
}

impl Staff {
    /// An undefined staff whose name queried from `USERNAME` of `USER` env var.
    pub fn from_env() -> Self {
        let username = match std::env::var({
            if cfg!(target_os = "windows") {
                USERNAME_WINDOWS_ENV_VAR
            } else {
                USERNAME_UNIX_ENV_VAR
            }
        }) {
            Ok(val) => val,
            Err(_) => UNDEFINED_USERNAME.to_owned(),
        };
        Self::unknown_role(&username)
    }

    pub fn role(mut self, role: &ProductionRole) -> Self {
        self.role_str = role.as_str();
        self.role = role.clone();
        self
    }

    /// IMPORTANT: this must be invoked after deserializing into "vanilla" [`Staff`]s.
    /// Maps values from existing database to our structure.
    pub fn role_from_str(mut self) -> Self {
        self.role = self.role_str.as_str().into();
        self
    }

    pub fn undefined() -> Self {
        Self {
            name: UNDEFINED_USERNAME.to_owned(),
            ..Default::default()
        }
    }

    /// A staff with name knowned, but whose role is undefined.
    pub fn unknown_role(name: &str) -> Self {
        Self {
            name: name.to_owned(),
            ..Self::empty()
        }
    }

    /// A staff with name knowned, but whose role is undefined.
    pub fn into_unknown_role(name: String) -> Self {
        Self {
            name,
            ..Self::empty()
        }
    }

    /// An artist, -- assignee --, of a [`ProductionAsset`].
    /// NOTE: `Self::id` is left empty.
    pub fn artist_with_name(name: String) -> Self {
        Self {
            name,
            role: ProductionRole::Artist,
            role_str: ProductionRole::Artist.as_str(),
            ..Self::empty()
        }
    }

    pub fn name_owned(self) -> String {
        self.name
    }

    pub fn name_unwrap(&self) -> &String {
        &self.name
    }

    pub fn name_mut(&mut self, name: &str) {
        self.name = name.to_owned();
    }
}

#[cfg(feature = "gui")]
/// All UI-related methods.
impl Staff {
    pub fn preview_name(&self, ui: &mut egui::Ui) {
        ui.label(&self.name).on_hover_text(self.role.as_ref());
    }

    pub fn show_name_and_role(&self, ui: &mut egui::Ui) -> egui::Response {
        ui.label(self.role.as_ref());
        ui.colored_label(Color32::GREEN, format!("{}:", self.name_unwrap()))
    }

    #[cfg(all(feature = "ldap", feature = "image_processing"))]
    pub fn show_profile_img(&self, ui: &mut egui::Ui, width_clamp: f32) {
        let img = match &self.profile_img {
            Some(Ok(img)) => img,
            _ => IconCel::icon_book().get_included(embedded_icons::DUMMY_PROFILE),
        };

        let size = img.size_vec2();

        let _response = ui.add(egui::Image::new(
            img.texture_id(ui.ctx()),
            [width_clamp, (size.y / size.x) * width_clamp],
        ));

        #[cfg(debug_assertions)]
        // hint image error
        if let Some(Err(e)) = &self.profile_img {
            _response.on_hover_text(e.to_string());
        };
    }
}

impl BsonId for Staff {
    fn bson_id_as_ref(&self) -> Option<&ObjectId> {
        self.id.as_ref()
    }

    fn bson_id(&self) -> AnyResult<&ObjectId> {
        self.bson_id_as_ref().context("Staff without BSON ObjectId")
    }
}

impl Entity for Staff {
    fn empty() -> Self {
        Self {
            role_str: ProductionRole::default().as_str(),
            ..Default::default()
        }
    }

    fn as_group(_group_name: &str, _typ: &ProjectSource) -> Self {
        Self::empty()
    }

    fn name(&self) -> Option<&String> {
        Some(&self.name)
    }
}

impl fmt::Debug for Staff {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut debug = f.debug_struct("Staff");

        debug
            .field("id", &self.id)
            .field("name", &self.name)
            .field("role_str", &self.role_str)
            .field("role", &self.role);

        #[cfg(all(feature = "ldap", feature = "image_processing"))]
        {
            debug.field("has profile image", &self.profile_img.is_some());
        }

        debug.finish()
    }
}

impl std::clone::Clone for Staff {
    fn clone(&self) -> Self {
        Self {
            id: self.id.clone(),
            name: self.name.clone(),
            role_str: self.role_str.clone(),
            role: self.role.clone(),
            #[cfg(all(feature = "ldap", feature = "image_processing"))]
            /// NOTE: lossy clone.
            profile_img: None,
        }
    }
}

impl PartialEq for Staff {
    fn eq(&self, other: &Self) -> bool {
        // NOTE: should not check for `Self::id`,
        // since `Self::default` which does not hold any `ObjectId`
        // is used at many places.
        self.name == other.name
    }
}

impl std::cmp::Eq for Staff {}

impl std::hash::Hash for Staff {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        // ATTENTION: not checking for `Self::id`
        // since also hashing with `ObjectId` is stricter
        self.name.hash(state);
    }
}

impl PartialOrd for Staff {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        self.name.partial_cmp(&other.name)
    }
}

impl Ord for Staff {
    fn cmp(&self, other: &Self) -> Ordering {
        self.name.cmp(&other.name)
    }
}

// ----------------------------------------------------------------------------
impl From<Vec<Staff>> for RoleMap {
    fn from(staves: Vec<Staff>) -> Self {
        let mut layout = HashMap::<ProductionRole, HashSet<Staff>>::new();
        for s in staves.into_iter() {
            match layout.get_mut(&s.role) {
                Some(group) => {
                    group.insert(s);
                }
                None => {
                    layout.insert(s.role.clone(), HashSet::from([s]));
                }
            }
        }
        RoleMap(layout)
    }
}

// ----------------------------------------------------------------------------
#[derive(
    Debug,
    Clone,
    Default,
    Serialize,
    Deserialize,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    Hash,
    strum::AsRefStr,
    strum::EnumIter,
)]
pub enum ProductionRole {
    // Administrator,
    #[strum(serialize = "Art Director")]
    ArtDirector,

    #[strum(serialize = "Art Producer")]
    ArtProducer,

    // #[strum(serialize = "Project Owner")]
    // ProjectOwner,
    #[strum(serialize = "Team Lead")]
    TeamLead,

    Artist,

    // #[strum(serialize = "Art Trainee")]
    // ArtTrainee,
    //
    #[strum(serialize = "Tech Support")]
    TechSupport,

    Watcher,

    // Intern,
    //
    Unassigned,

    #[default]
    Undefined,
}

impl ProductionRole {
    pub fn is_supervisor(&self) -> bool {
        matches!(
            &self,
            Self::ArtDirector | Self::ArtProducer | Self::TeamLead | Self::TechSupport
        )
    }

    pub fn as_str(&self) -> String {
        let role = match self {
            Self::ArtDirector => "_AD",
            Self::ArtProducer => "_PRODUCER",
            Self::TeamLead => "_LEADER",
            Self::Artist => "_ARTIST",
            Self::TechSupport => "_TECH_SUPPORT",
            Self::Watcher => "_WATCHER",
            Self::Unassigned => "_UNASSIGNED",
            Self::Undefined => "_UNDEFINED",
        };
        role.to_string()
    }

    #[cfg(feature = "query_message")]
    /// Interval in seconds of a cron job to fetch `QueryMsg`.
    pub fn qms_cron_fetch_seconds(&self) -> String {
        let seconds = match self {
            ProductionRole::ArtDirector => "1/10",
            ProductionRole::ArtProducer | ProductionRole::TeamLead => "1/20",
            ProductionRole::TechSupport => "1/7",
            ProductionRole::Artist => "1/60",
            _ => "1/120",
        };
        seconds.to_string()
    }

    #[cfg(feature = "alert")]
    /// Interval in seconds of a cron job to fetch `Notification`.
    pub fn notif_cron_fetch_seconds(&self) -> u64 {
        #[cfg(debug_assertions)]
        return 30;

        #[cfg(not(debug_assertions))]
        150
    }
}

impl From<&str> for ProductionRole {
    fn from(role: &str) -> Self {
        match role {
            "_AD" => ProductionRole::ArtDirector,
            "_PRODUCER" => ProductionRole::ArtProducer,
            "_LEADER" => ProductionRole::TeamLead,
            "_ARTIST" => ProductionRole::Artist,
            "_TECH_SUPPORT" => ProductionRole::TechSupport,
            "_WATCHER" => ProductionRole::Watcher,
            // some value we don't recognize
            _ => ProductionRole::Undefined,
        }
    }
}

// -------------------------------------------------------------------------------
#[cfg(feature = "gui")]
pub fn production_role_options_ui(ui: &mut egui::Ui, role: &mut ProductionRole) {
    egui::ComboBox::from_label("Group")
        .selected_text(role.as_ref())
        .show_ui(ui, |ui| {
            for r in ProductionRole::iter() {
                if ui.selectable_label(role == &r, r.as_ref()).clicked() {
                    *role = r;
                };
            }
        });
}