From 575ff35e76f4a1f0c6dbe2edf67598fcde62cfc5 Mon Sep 17 00:00:00 2001 From: David Bond Date: Mon, 9 Nov 2026 02:05:49 +0000 Subject: [PATCH] feat!: group work-package schedule fields in WorkPackageSchedule WorkPackage and WorkPackageUpdate expose ScheduleManually, StartDate, DueDate, Date, EstimatedTime and DerivedEstimatedTime through a Schedule property. A converter keeps them flat in the JSON, as OpenProject sends and expects them. Breaking: workPackage.StartDate becomes workPackage.Schedule.StartDate; version 3.1. --- .../Tests/WorkPackageScheduleJsonTests.cs | 62 ++++++++++++++++++ .../Data/FlatScheduleConverterFactory.cs | 63 +++++++++++++++++++ OpenProject.Api/Data/Models/IHasSchedule.cs | 12 ++++ .../Data/Models/Update/WorkPackageUpdate.cs | 41 ++---------- OpenProject.Api/Data/Models/WorkPackage.cs | 32 ++-------- .../Data/Models/WorkPackageSchedule.cs | 46 ++++++++++++++ OpenProject.Api/OpenProject.Api.csproj | 3 + OpenProject.Api/OpenProjectClient.cs | 11 ++++ version.json | 2 +- 9 files changed, 208 insertions(+), 64 deletions(-) create mode 100644 OpenProject.Api.Test/Tests/WorkPackageScheduleJsonTests.cs create mode 100644 OpenProject.Api/Data/FlatScheduleConverterFactory.cs create mode 100644 OpenProject.Api/Data/Models/IHasSchedule.cs create mode 100644 OpenProject.Api/Data/Models/WorkPackageSchedule.cs diff --git a/OpenProject.Api.Test/Tests/WorkPackageScheduleJsonTests.cs b/OpenProject.Api.Test/Tests/WorkPackageScheduleJsonTests.cs new file mode 100644 index 0000000..2f7dba9 --- /dev/null +++ b/OpenProject.Api.Test/Tests/WorkPackageScheduleJsonTests.cs @@ -0,0 +1,62 @@ +using OpenProject.Api.Data.Models; +using OpenProject.Api.Data.Models.Update; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace OpenProject.Api.Test.Tests; + +/// +/// Work-package schedule fields are grouped in but stay flat in OpenProject's JSON. +/// +public class WorkPackageScheduleJsonTests +{ + private static readonly JsonSerializerOptions Options = OpenProjectClient.CreateJsonOptions(); + + [Fact] + public void WorkPackage_ReadsFlatScheduleFieldsIntoSchedule() + { + var workPackage = JsonSerializer.Deserialize( + """ + {"_type":"WorkPackage","_links":{},"id":7,"subject":"Ship it","percentageDone":10,"derivedPercentageDone":20, + "scheduleManually":true,"startDate":"2026-11-02T00:00:00","dueDate":"2026-11-06T00:00:00", + "date":null,"estimatedTime":"PT8H","derivedEstimatedTime":"PT16H"} + """, + Options)!; + + workPackage.Id.Should().Be(7); + workPackage.Subject.Should().Be("Ship it"); + workPackage.Schedule.ScheduleManually.Should().BeTrue(); + workPackage.Schedule.StartDate.Should().Be(new DateTime(2026, 11, 2)); + workPackage.Schedule.DueDate.Should().Be(new DateTime(2026, 11, 6)); + workPackage.Schedule.Date.Should().BeNull(); + workPackage.Schedule.EstimatedTime.Should().Be("PT8H"); + workPackage.Schedule.DerivedEstimatedTime.Should().Be("PT16H"); + } + + [Fact] + public void WorkPackageUpdate_WritesScheduleFieldsFlatAndLeavesNullsOut() + { + var update = new WorkPackageUpdate + { + LockVersion = 3, + Schedule = new WorkPackageSchedule { StartDate = new DateTime(2026, 11, 2), EstimatedTime = "PT4H" } + }; + + var json = JsonNode.Parse(JsonSerializer.Serialize(update, Options))!.AsObject(); + + json.Select(property => property.Key).Should().BeEquivalentTo("lockVersion", "startDate", "estimatedTime"); + json["lockVersion"]!.GetValue().Should().Be(3); + json["startDate"]!.GetValue().Should().Be("2026-11-02T00:00:00"); + json["estimatedTime"]!.GetValue().Should().Be("PT4H"); + } + + [Fact] + public void WorkPackagesInACollection_AreReadThroughTheSameConverter() + { + var workPackages = JsonSerializer.Deserialize>( + """[{"_type":"WorkPackage","_links":{},"id":1,"subject":"a","percentageDone":0,"derivedPercentageDone":0,"dueDate":"2026-12-24T00:00:00"}]""", + Options)!; + + workPackages.Should().ContainSingle().Which.Schedule.DueDate.Should().Be(new DateTime(2026, 12, 24)); + } +} diff --git a/OpenProject.Api/Data/FlatScheduleConverterFactory.cs b/OpenProject.Api/Data/FlatScheduleConverterFactory.cs new file mode 100644 index 0000000..2d3914c --- /dev/null +++ b/OpenProject.Api/Data/FlatScheduleConverterFactory.cs @@ -0,0 +1,63 @@ +using OpenProject.Api.Data.Models; +using System.Text.Json; +using System.Text.Json.Serialization; +using Type = System.Type; + +namespace OpenProject.Api.Data; + +/// +/// Reads and writes models with their fields at the +/// top level of the JSON object, where OpenProject puts them. +/// +internal sealed class FlatScheduleConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + => typeToConvert.IsClass && typeof(IHasSchedule).IsAssignableFrom(typeToConvert); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + => (JsonConverter)Activator.CreateInstance( + typeof(FlatScheduleConverter<>).MakeGenericType(typeToConvert), + WithoutThisFactory(options))!; + + // Without this, serializing the model inside the converter would re-enter the converter. + private static JsonSerializerOptions WithoutThisFactory(JsonSerializerOptions options) + { + var inner = new JsonSerializerOptions(options); + for (var i = inner.Converters.Count - 1; i >= 0; i--) + { + if (inner.Converters[i] is FlatScheduleConverterFactory) + { + inner.Converters.RemoveAt(i); + } + } + + return inner; + } + + private sealed class FlatScheduleConverter(JsonSerializerOptions inner) : JsonConverter + where T : class, IHasSchedule + { + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var document = JsonDocument.ParseValue(ref reader); + var item = document.RootElement.Deserialize(inner); + if (item is not null) + { + item.Schedule = document.RootElement.Deserialize(inner) ?? new(); + } + + return item; + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + var node = JsonSerializer.SerializeToNode(value, inner)!.AsObject(); + foreach (var (name, field) in JsonSerializer.SerializeToNode(value.Schedule, inner)!.AsObject()) + { + node[name] = field?.DeepClone(); + } + + node.WriteTo(writer, inner); + } + } +} diff --git a/OpenProject.Api/Data/Models/IHasSchedule.cs b/OpenProject.Api/Data/Models/IHasSchedule.cs new file mode 100644 index 0000000..f794b71 --- /dev/null +++ b/OpenProject.Api/Data/Models/IHasSchedule.cs @@ -0,0 +1,12 @@ +namespace OpenProject.Api.Data.Models; + +/// +/// A model whose schedule fields are flat in OpenProject's JSON but grouped in . +/// +public interface IHasSchedule +{ + /// + /// The scheduling and estimate fields. + /// + WorkPackageSchedule Schedule { get; set; } +} diff --git a/OpenProject.Api/Data/Models/Update/WorkPackageUpdate.cs b/OpenProject.Api/Data/Models/Update/WorkPackageUpdate.cs index 1f093cf..10befcd 100644 --- a/OpenProject.Api/Data/Models/Update/WorkPackageUpdate.cs +++ b/OpenProject.Api/Data/Models/Update/WorkPackageUpdate.cs @@ -1,11 +1,12 @@ -using System.Text.Json.Serialization; +using OpenProject.Api.Data.Models; +using System.Text.Json.Serialization; namespace OpenProject.Api.Data.Models.Update; /// /// Represents the fields that can be updated on an existing work package. /// -public class WorkPackageUpdate +public class WorkPackageUpdate : IHasSchedule { /// /// The value of lockVersion is used to implement optimistic locking. @@ -26,38 +27,8 @@ public class WorkPackageUpdate public Formattable? Description { get; set; } /// - /// If false (default) schedule automatically. + /// Scheduling and estimate fields to change; fields left null are not sent. /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? ScheduleManually { get; set; } - - /// - /// Scheduled beginning of a work package - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public DateTime? StartDate { get; set; } - - /// - /// Scheduled end of a work package - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public DateTime? DueDate { get; set; } - - /// - /// Date on which a milestone is achieved - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public DateTime? Date { get; set; } - - /// - /// Corresponds to work. Time a work package likely needs to be completed. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? EstimatedTime { get; set; } - - /// - /// Corresponds to total work. Time a work package likely needs to be completed including itself and its descendants. - /// - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? DerivedEstimatedTime { get; set; } + [JsonIgnore] + public WorkPackageSchedule Schedule { get; set; } = new(); } diff --git a/OpenProject.Api/Data/Models/WorkPackage.cs b/OpenProject.Api/Data/Models/WorkPackage.cs index 7c79877..80f400d 100644 --- a/OpenProject.Api/Data/Models/WorkPackage.cs +++ b/OpenProject.Api/Data/Models/WorkPackage.cs @@ -6,7 +6,7 @@ namespace OpenProject.Api.Data.Models; /// A work package in OpenProject can basically be everything you need to keep track off within your projects. It can be e.g. a task, a feature, a bug, a risk, a milestone or a project phase. /// See /// -public class WorkPackage : IdentifiedItem, IHasTimestamps +public class WorkPackage : IdentifiedItem, IHasTimestamps, IHasSchedule { /// /// The version of the item as used for optimistic locking @@ -27,24 +27,10 @@ public class WorkPackage : IdentifiedItem, IHasTimestamps public Formattable? Description { get; set; } /// - /// If false (default) schedule automatically. + /// Scheduling and estimate fields: start, due and milestone dates, manual scheduling and estimated time. /// - public bool? ScheduleManually { get; set; } - - /// - /// Scheduled beginning of a work package - /// - public DateTime? StartDate { get; set; } - - /// - /// Scheduled end of a work package - /// - public DateTime? DueDate { get; set; } - - /// - /// Date on which a milestone is achieved - /// - public DateTime? Date { get; set; } + [JsonIgnore] + public WorkPackageSchedule Schedule { get; set; } = new(); /// /// Similar to start date but is not set by a client but rather deduced by the work packages’ descendants. If manual scheduleManually is active, the two dates can deviate. @@ -61,16 +47,6 @@ public class WorkPackage : IdentifiedItem, IHasTimestamps /// public string? Duration { get; set; } - /// - /// Corresponds to work. Time a work package likely needs to be completed. - /// - public string? EstimatedTime { get; set; } - - /// - /// Corresponds to total work. Time a work package likely needs to be completed including itself and its descendants. - /// - public string? DerivedEstimatedTime { get; set; } - /// /// Corresponds to remaining work. Remaining time a work package likely needs to be completed. /// diff --git a/OpenProject.Api/Data/Models/WorkPackageSchedule.cs b/OpenProject.Api/Data/Models/WorkPackageSchedule.cs new file mode 100644 index 0000000..0331539 --- /dev/null +++ b/OpenProject.Api/Data/Models/WorkPackageSchedule.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace OpenProject.Api.Data.Models; + +/// +/// The scheduling and estimate fields of a work package. They are top-level fields in OpenProject's JSON; +/// models group them here. +/// +public class WorkPackageSchedule +{ + /// + /// If false (default) schedule automatically. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ScheduleManually { get; set; } + + /// + /// Scheduled beginning of a work package + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTime? StartDate { get; set; } + + /// + /// Scheduled end of a work package + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTime? DueDate { get; set; } + + /// + /// Date on which a milestone is achieved + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DateTime? Date { get; set; } + + /// + /// Corresponds to work. Time a work package likely needs to be completed. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EstimatedTime { get; set; } + + /// + /// Corresponds to total work. Time a work package likely needs to be completed including itself and its descendants. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DerivedEstimatedTime { get; set; } +} diff --git a/OpenProject.Api/OpenProject.Api.csproj b/OpenProject.Api/OpenProject.Api.csproj index 308e46f..5e4bc5a 100644 --- a/OpenProject.Api/OpenProject.Api.csproj +++ b/OpenProject.Api/OpenProject.Api.csproj @@ -38,4 +38,7 @@ + + + \ No newline at end of file diff --git a/OpenProject.Api/OpenProjectClient.cs b/OpenProject.Api/OpenProjectClient.cs index 252be26..ce89623 100644 --- a/OpenProject.Api/OpenProjectClient.cs +++ b/OpenProject.Api/OpenProjectClient.cs @@ -29,6 +29,7 @@ public OpenProjectClient(HttpClient client) _httpClient = client; _refitSettings = new RefitSettings { + ContentSerializer = new SystemTextJsonContentSerializer(CreateJsonOptions()), //UrlParameterFormatter = new OpenProjectUrlParameterFormatter(), }; @@ -59,6 +60,16 @@ public OpenProjectClient(HttpClient client) Types = RefitFor(); } + /// + /// Refit's default JSON options, plus the converter that keeps work-package schedule fields flat on the wire. + /// + internal static System.Text.Json.JsonSerializerOptions CreateJsonOptions() + { + var options = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions(); + options.Converters.Add(new FlatScheduleConverterFactory()); + return options; + } + private T RefitFor() => RestService.For(_httpClient, _refitSettings); diff --git a/version.json b/version.json index 6f8af6b..4ecc882 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "3.0", + "version": "3.1", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/release/\\d+\\.\\d+$",