Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions crates/op-work-packages/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
name = "op-work-packages"
version.workspace = true
edition.workspace = true
description = "Work package model + services for OpenProject RS"

[dependencies]
op-core = { path = "../op-core" }

serde.workspace = true
chrono.workspace = true

[dev-dependencies]
serde_json.workspace = true
14 changes: 13 additions & 1 deletion crates/op-work-packages/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1 +1,13 @@
//! op-work-packages stub
//! # op-work-packages
//!
//! Work package model + services for OpenProject RS.
//!
//! Mirrors: `app/models/work_package.rb`
//!
//! The headline OpenProject entity — a project-scoped unit of work (task,
//! bug, feature, …). Converges with Redmine's `Issue` on the canonical
//! concept `project_work_item` (codebook `0x0102`); see `op-canon`.

pub mod work_package;

pub use work_package::{DoneRatio, WorkPackage};
319 changes: 319 additions & 0 deletions crates/op-work-packages/src/work_package.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
//! Work package model
//!
//! Mirrors: `app/models/work_package.rb`
//!
//! The headline OpenProject entity — a project-scoped unit of work (task,
//! bug, feature, …). Converges with Redmine's `Issue` on the canonical
//! concept `project_work_item` (codebook `0x0102`); the canonical-class-id
//! wiring lives in `op-canon` and is layered on in a follow-up.

use chrono::{DateTime, NaiveDate, Utc};
use op_core::traits::{Entity, Id, Identifiable, Lockable, ProjectScoped, Timestamped};
use op_core::types::Formattable;
use serde::{Deserialize, Serialize};

/// Progress of a work package as a whole-number percentage `0..=100`
/// (OpenProject's `done_ratio`).
///
/// Construction via [`DoneRatio::new`] / [`From<u8>`] **clamps** into the
/// band, so a constructed value is always valid. Deserialized values are
/// trusted to the source (OpenProject's DB column enforces `0..=100`), the
/// same trust boundary the Rails model relies on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
pub struct DoneRatio(u8);

impl DoneRatio {
/// Clamp `percent` into `0..=100`.
pub fn new(percent: u8) -> Self {
Self(percent.min(100))
}

/// The percentage value — always `0..=100`.
pub fn value(self) -> u8 {
self.0
}

/// Whether the work is fully done (`100%`).
pub fn is_complete(self) -> bool {
self.0 == 100
}
}

impl From<u8> for DoneRatio {
fn from(percent: u8) -> Self {
Self::new(percent)
}
}

/// A work package — OpenProject's core unit of work.
///
/// The foreign keys (`type_id`, `status_id`, `priority_id`, …) reference the
/// respective lookup tables, mirroring the Rails schema; this model carries
/// the ids, not embedded records. Hierarchy is a self-reference via
/// [`WorkPackage::parent_id`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkPackage {
/// Primary key. `None` for a new (unpersisted) record.
pub id: Option<Id>,
/// One-line headline. Required.
pub subject: String,
/// Long-form body (markdown → HTML via [`Formattable`]).
pub description: Formattable,
/// Owning project (`project_id` FK). Required — every WP is scoped.
pub project_id: Id,
/// Work-package type FK (Task / Bug / Feature / …).
pub type_id: Id,
/// Status FK (New / In progress / Closed / …).
pub status_id: Id,
/// Priority FK (Low / Normal / High / …).
pub priority_id: Id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow priority-less work packages

When a work package is created or read without a priority, this model cannot represent it because priority_id is a required Id. The existing database row uses Option<i64> for priority_id (crates/op-db/src/work_packages.rs:21), and the API create DTO accepts priority_id: Option<Id> (crates/op-api/src/handlers/work_packages.rs:291), so persisted NULL-priority rows would either fail conversion/deserialization into this new foundation model or force callers to invent a synthetic priority id. Make this field and the constructor parameter optional to match the existing data shape.

Useful? React with 👍 / 👎.

/// Author FK — who created the WP.
pub author_id: Id,
/// Assignee FK — who is working on it (optional).
pub assigned_to_id: Option<Id>,
/// Responsible/accountable FK (optional).
pub responsible_id: Option<Id>,
/// Parent WP FK for the hierarchy (optional; `None` = top-level).
pub parent_id: Option<Id>,
/// Scheduled start (optional).
pub start_date: Option<NaiveDate>,
/// Scheduled finish (optional).
pub due_date: Option<NaiveDate>,
/// Estimated effort in hours (optional).
pub estimated_hours: Option<f64>,
/// Progress `0..=100`.
pub done_ratio: DoneRatio,
/// Optimistic-locking counter (mirrors Rails' `lock_version`).
pub lock_version: i32,
/// Creation timestamp.
pub created_at: DateTime<Utc>,
/// Last-update timestamp.
pub updated_at: DateTime<Utc>,
}

impl WorkPackage {
/// Create a new (unpersisted) work package from its required attributes.
/// Timestamps are stamped now; optional fields default to empty / `None`;
/// `done_ratio` starts at `0` and `lock_version` at `0`.
pub fn new(
subject: impl Into<String>,
project_id: Id,
type_id: Id,
status_id: Id,
priority_id: Id,
author_id: Id,
) -> Self {
let now = Utc::now();
Self {
id: None,
subject: subject.into(),
description: Formattable::default(),
project_id,
type_id,
status_id,
priority_id,
author_id,
assigned_to_id: None,
responsible_id: None,
parent_id: None,
start_date: None,
due_date: None,
estimated_hours: None,
done_ratio: DoneRatio::default(),
lock_version: 0,
created_at: now,
updated_at: now,
}
}

/// Set the description body.
pub fn with_description(mut self, description: Formattable) -> Self {
self.description = description;
self
}

/// Assign the WP to a user.
pub fn with_assignee(mut self, user_id: Id) -> Self {
self.assigned_to_id = Some(user_id);
self
}

/// Nest this WP under a parent (builds the hierarchy).
pub fn with_parent(mut self, parent_id: Id) -> Self {
self.parent_id = Some(parent_id);
self
}

/// Set the start / due schedule.
pub fn with_dates(mut self, start: Option<NaiveDate>, due: Option<NaiveDate>) -> Self {
self.start_date = start;
self.due_date = due;
self
}

/// Set progress (clamped to `0..=100`).
pub fn with_done_ratio(mut self, done_ratio: impl Into<DoneRatio>) -> Self {
self.done_ratio = done_ratio.into();
self
}

/// Fully complete (`done_ratio == 100`).
pub fn is_complete(&self) -> bool {
self.done_ratio.is_complete()
}

/// Whether this WP is nested under a parent.
pub fn is_child(&self) -> bool {
self.parent_id.is_some()
}

/// Whether it has an assignee.
pub fn has_assignee(&self) -> bool {
self.assigned_to_id.is_some()
}

/// Overdue relative to `today`: a due date strictly in the past **and**
/// not yet complete. A WP with no due date, or one already done, is never
/// overdue.
pub fn is_overdue(&self, today: NaiveDate) -> bool {
self.due_date.is_some_and(|due| due < today) && !self.is_complete()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Base overdue checks on open status

For a work package whose status is closed but whose done_ratio is less than 100, this helper reports it as overdue. Existing query code treats overdue work packages as past-due and open (crates/op-queries/src/builder.rs:547-552), while status closedness is tracked separately from the percentage (crates/op-models/src/status.rs:32-49), so closed/done state is not equivalent to done_ratio == 100. Consumers using this helper for overdue badges or notifications will surface closed items as overdue unless the check uses/accepts status closedness instead of is_complete().

Useful? React with 👍 / 👎.

}
}

impl Identifiable for WorkPackage {
fn id(&self) -> Option<Id> {
self.id
}
}

impl Timestamped for WorkPackage {
fn created_at(&self) -> Option<DateTime<Utc>> {
Some(self.created_at)
}
fn updated_at(&self) -> Option<DateTime<Utc>> {
Some(self.updated_at)
}
}

impl ProjectScoped for WorkPackage {
fn project_id(&self) -> Option<Id> {
Some(self.project_id)
}
}

impl Lockable for WorkPackage {
fn lock_version(&self) -> i32 {
self.lock_version
}
}

impl Entity for WorkPackage {
const TABLE_NAME: &'static str = "work_packages";
const TYPE_NAME: &'static str = "WorkPackage";
}

#[cfg(test)]
mod tests {
use super::*;

fn sample() -> WorkPackage {
// subject, project, type, status, priority, author
WorkPackage::new("Fix the thing", 1, 2, 3, 4, 10)
}

#[test]
fn new_sets_required_fields_and_sane_defaults() {
let wp = sample();
assert_eq!(wp.subject, "Fix the thing");
assert_eq!(wp.project_id, 1);
assert_eq!(wp.type_id, 2);
assert_eq!(wp.status_id, 3);
assert_eq!(wp.priority_id, 4);
assert_eq!(wp.author_id, 10);
// Unpersisted, no optional relations, zero progress, lock 0.
assert!(wp.id.is_none());
assert!(!wp.is_child());
assert!(!wp.has_assignee());
assert_eq!(wp.done_ratio.value(), 0);
assert_eq!(wp.lock_version, 0);
assert!(wp.description.is_empty());
}

#[test]
fn done_ratio_clamps_into_0_100() {
assert_eq!(DoneRatio::new(0).value(), 0);
assert_eq!(DoneRatio::new(50).value(), 50);
assert_eq!(DoneRatio::new(100).value(), 100);
assert_eq!(DoneRatio::new(250).value(), 100, "over-100 clamps");
assert_eq!(DoneRatio::from(200u8).value(), 100);
assert!(DoneRatio::new(100).is_complete());
assert!(!DoneRatio::new(99).is_complete());
}

#[test]
fn builder_sets_optional_relations_and_progress() {
let wp = sample()
.with_assignee(20)
.with_parent(7)
.with_done_ratio(150u8) // clamps to 100
.with_description(Formattable::plain("body"));
assert_eq!(wp.assigned_to_id, Some(20));
assert!(wp.has_assignee());
assert_eq!(wp.parent_id, Some(7));
assert!(wp.is_child());
assert!(wp.is_complete(), "done_ratio clamped to 100 → complete");
assert_eq!(wp.description.raw, "body");
}

#[test]
fn is_overdue_only_when_past_due_and_incomplete() {
let today = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
let yesterday = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap();
let tomorrow = NaiveDate::from_ymd_opt(2026, 7, 2).unwrap();

// Past due + incomplete → overdue.
let overdue = sample().with_dates(None, Some(yesterday));
assert!(overdue.is_overdue(today));

// Past due but complete → NOT overdue.
let done = sample()
.with_dates(None, Some(yesterday))
.with_done_ratio(100u8);
assert!(!done.is_overdue(today));

// Future due → NOT overdue.
let future = sample().with_dates(None, Some(tomorrow));
assert!(!future.is_overdue(today));

// No due date → never overdue.
assert!(!sample().is_overdue(today));
}

#[test]
fn implements_core_entity_traits() {
let wp = sample();
// Identifiable
assert!(wp.id().is_none());
assert!(wp.is_new_record());
// Timestamped
assert!(wp.created_at().is_some());
assert!(wp.updated_at().is_some());
// ProjectScoped
assert_eq!(wp.project_id(), Some(1));
// Lockable
assert_eq!(Lockable::lock_version(&wp), 0);
// Entity consts
assert_eq!(WorkPackage::TABLE_NAME, "work_packages");
assert_eq!(WorkPackage::TYPE_NAME, "WorkPackage");
}

#[test]
fn round_trips_through_json() {
let wp = sample().with_assignee(20).with_done_ratio(40u8);
let json = serde_json::to_string(&wp).unwrap();
let back: WorkPackage = serde_json::from_str(&json).unwrap();
assert_eq!(back.subject, wp.subject);
assert_eq!(back.assigned_to_id, Some(20));
assert_eq!(back.done_ratio.value(), 40);
}
}