From c702137008c8451254f585ae6f2fe6bcb281d368 Mon Sep 17 00:00:00 2001 From: Elias Bruvik Date: Fri, 1 Sep 2023 14:01:31 +0100 Subject: [PATCH 1/3] FIX-1931 implement missing data agent --- Src/WitsmlExplorer.Api/Jobs/MissingDataJob.cs | 35 ++ Src/WitsmlExplorer.Api/Models/JobType.cs | 3 +- .../Models/MissingDataCheck.cs | 10 + .../Models/Reports/MissingDataReport.cs | 17 + Src/WitsmlExplorer.Api/Query/QueryHelper.cs | 103 ++++ .../Workers/MissingDataWorker.cs | 369 ++++++++++++++ .../ContentViews/WellboresListView.tsx | 18 +- .../ContextMenus/WellContextMenu.tsx | 21 +- .../ContextMenus/WellboreContextMenu.tsx | 27 +- .../Modals/MissingDataAgentModal.tsx | 168 +++++++ .../Modals/MissingDataAgentProperties.tsx | 305 ++++++++++++ .../components/Modals/ReportModal.tsx | 21 +- .../models/jobs/missingDataJob.tsx | 15 + .../services/jobService.tsx | 1 + .../Query/QueryHelperTests.cs | 143 ++++++ .../Workers/MissingDataWorkerTests.cs | 470 ++++++++++++++++++ 16 files changed, 1716 insertions(+), 10 deletions(-) create mode 100644 Src/WitsmlExplorer.Api/Jobs/MissingDataJob.cs create mode 100644 Src/WitsmlExplorer.Api/Models/MissingDataCheck.cs create mode 100644 Src/WitsmlExplorer.Api/Models/Reports/MissingDataReport.cs create mode 100644 Src/WitsmlExplorer.Api/Query/QueryHelper.cs create mode 100644 Src/WitsmlExplorer.Api/Workers/MissingDataWorker.cs create mode 100644 Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentModal.tsx create mode 100644 Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentProperties.tsx create mode 100644 Src/WitsmlExplorer.Frontend/models/jobs/missingDataJob.tsx create mode 100644 Tests/WitsmlExplorer.Api.Tests/Query/QueryHelperTests.cs create mode 100644 Tests/WitsmlExplorer.Api.Tests/Workers/MissingDataWorkerTests.cs diff --git a/Src/WitsmlExplorer.Api/Jobs/MissingDataJob.cs b/Src/WitsmlExplorer.Api/Jobs/MissingDataJob.cs new file mode 100644 index 000000000..3fca77390 --- /dev/null +++ b/Src/WitsmlExplorer.Api/Jobs/MissingDataJob.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Linq; + +using WitsmlExplorer.Api.Jobs.Common; +using WitsmlExplorer.Api.Models; + +namespace WitsmlExplorer.Api.Jobs +{ + public record MissingDataJob : Job + { + public IEnumerable WellReferences { get; init; } + public IEnumerable WellboreReferences { get; init; } + public IEnumerable MissingDataChecks { get; init; } + + public override string Description() + { + return $"Missing Data Agent"; + } + + public override string GetObjectName() + { + return ""; + } + + public override string GetWellboreName() + { + return ""; + } + + public override string GetWellName() + { + return ""; + } + } +} diff --git a/Src/WitsmlExplorer.Api/Models/JobType.cs b/Src/WitsmlExplorer.Api/Models/JobType.cs index 92c87ec6a..71207dba8 100644 --- a/Src/WitsmlExplorer.Api/Models/JobType.cs +++ b/Src/WitsmlExplorer.Api/Models/JobType.cs @@ -44,6 +44,7 @@ public enum JobType ImportLogData, ReplaceComponents, ReplaceObjects, - CheckLogHeader + CheckLogHeader, + MissingData } } diff --git a/Src/WitsmlExplorer.Api/Models/MissingDataCheck.cs b/Src/WitsmlExplorer.Api/Models/MissingDataCheck.cs new file mode 100644 index 000000000..2de2e0ee6 --- /dev/null +++ b/Src/WitsmlExplorer.Api/Models/MissingDataCheck.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace WitsmlExplorer.Api.Models +{ + public class MissingDataCheck + { + public EntityType ObjectType { get; set; } + public IEnumerable Properties { get; set; } + } +} diff --git a/Src/WitsmlExplorer.Api/Models/Reports/MissingDataReport.cs b/Src/WitsmlExplorer.Api/Models/Reports/MissingDataReport.cs new file mode 100644 index 000000000..2d61b7343 --- /dev/null +++ b/Src/WitsmlExplorer.Api/Models/Reports/MissingDataReport.cs @@ -0,0 +1,17 @@ + +namespace WitsmlExplorer.Api.Models.Reports +{ + public class MissingDataReport : BaseReport { } + + public class MissingDataReportItem + { + public string ObjectType { get; set; } + public string Property { get; init; } + public string WellUid { get; init; } + public string WellName { get; init; } + public string WellboreUid { get; init; } + public string WellboreName { get; init; } + public string ObjectUid { get; init; } + public string ObjectName { get; init; } + } +} diff --git a/Src/WitsmlExplorer.Api/Query/QueryHelper.cs b/Src/WitsmlExplorer.Api/Query/QueryHelper.cs new file mode 100644 index 000000000..095c80b73 --- /dev/null +++ b/Src/WitsmlExplorer.Api/Query/QueryHelper.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; + +using WitsmlExplorer.Api.Extensions; + +namespace WitsmlExplorer.Api.Query +{ + public static class QueryHelper + { + /// + /// Adds properties to an object based on a list of property names. + /// + /// The type of the object. + /// The object to which properties will be added. + /// A collection of property names to add, optionally supporting nested properties (e.g., "commonData.sourceName"). + /// The modified object with added properties. + public static T AddPropertiesToObject(T obj, IEnumerable properties) + { + foreach (string property in properties) + { + obj = AddPropertyToObject(obj, property); + } + return obj; + } + + /// + /// Adds a single property to an object based on its name. + /// + /// The type of the object. + /// The object to which the property will be added. + /// The name of the property to add, optionally supporting nested properties (e.g., "commonData.sourceName"). + /// The modified object with the added property. + public static T AddPropertyToObject(T obj, string property) + { + string childProperty = null; + if (property.Contains('.')) + { + var propertyParts = property.Split(".", 2); + property = propertyParts[0]; + childProperty = propertyParts[1]; + } + + PropertyInfo propertyInfo = obj.GetType().GetProperty(property.CapitalizeFirstLetter()); + + if (propertyInfo == null || !propertyInfo.CanWrite) + { + throw new ArgumentException($"{property} must be a supported property of a {obj.GetType()}."); + } + + object instance = GetOrCreateInstanceOfProperty(obj, propertyInfo); + + if (!string.IsNullOrEmpty(childProperty)) + { + instance = AddPropertyToObject(instance, childProperty); + } + + propertyInfo.SetValue(obj, instance); + + return obj; + } + + private static object GetOrCreateInstanceOfProperty(object obj, PropertyInfo propertyInfo) + { + Type propertyType = propertyInfo.PropertyType; + + object instance = (propertyType == typeof(string) + ? "" + : propertyInfo.GetValue(obj, null)) + ?? (propertyType == typeof(string[]) + ? new string[] { "" } + : Activator.CreateInstance(propertyType)); + + if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(List<>)) + { + Type listObjectType = propertyType.GetGenericArguments()[0]; + object listObjectInstance = listObjectType == typeof(string) + ? "" + : Activator.CreateInstance(listObjectType); + ((IList)instance).Add(listObjectInstance); + } + + return instance; + } + + /// + /// Retrieves a property from an object based on its name, supporting nested properties. + /// + /// The object from which to retrieve the property. + /// The name of the property to retrieve, possibly nested (e.g., "commonData.sourceName"). + /// The value of the specified property. + public static object GetPropertyFromObject(object obj, string property) + { + var propertyParts = property.Split("."); + foreach (var propertyPart in propertyParts) + { + obj = obj?.GetType().GetProperty(propertyPart.CapitalizeFirstLetter())?.GetValue(obj, null); + } + return obj; + } + } +} diff --git a/Src/WitsmlExplorer.Api/Workers/MissingDataWorker.cs b/Src/WitsmlExplorer.Api/Workers/MissingDataWorker.cs new file mode 100644 index 000000000..8fbd39e71 --- /dev/null +++ b/Src/WitsmlExplorer.Api/Workers/MissingDataWorker.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +using Witsml.Data; +using Witsml.Extensions; +using Witsml.ServiceReference; + +using WitsmlExplorer.Api.Jobs; +using WitsmlExplorer.Api.Jobs.Common; +using WitsmlExplorer.Api.Models; +using WitsmlExplorer.Api.Models.Reports; +using WitsmlExplorer.Api.Query; +using WitsmlExplorer.Api.Services; + +namespace WitsmlExplorer.Api.Workers +{ + public class MissingDataWorker : BaseWorker, IWorker + { + public JobType JobType => JobType.MissingData; + + public MissingDataWorker(ILogger logger, IWitsmlClientProvider witsmlClientProvider) : base(witsmlClientProvider, logger) { } + + public override async Task<(WorkerResult, RefreshAction)> Execute(MissingDataJob job) + { + IEnumerable wellReferences = job.WellReferences; + IEnumerable wellboreReferences = job.WellboreReferences; + IEnumerable missingDataChecks = job.MissingDataChecks; + string jobId = job.JobInfo.Id; + + ValidateInput(missingDataChecks, wellReferences, wellboreReferences); + + List missingDataItems = new() { }; + + foreach (MissingDataCheck check in missingDataChecks) + { + switch (check.ObjectType) + { + case EntityType.Well: + missingDataItems.AddRange(await CheckWell(check, wellReferences, wellboreReferences)); + break; + case EntityType.Wellbore: + missingDataItems.AddRange(await CheckWellbore(check, wellReferences, wellboreReferences)); + break; + default: // Any object on wellbore + missingDataItems.AddRange(await CheckObject(check, wellReferences, wellboreReferences)); + break; + } + } + + MissingDataReport report = GetReport(missingDataItems, missingDataChecks, wellReferences, wellboreReferences); + job.JobInfo.Report = report; + + Logger.LogInformation("{JobType} - Job successful", GetType().Name); + + WorkerResult workerResult = new(GetTargetWitsmlClientOrThrow().GetServerHostname(), true, $"Missing Data Agent Job Complete", jobId: jobId); + return (workerResult, null); + } + + private static void ValidateInput(IEnumerable missingDataChecks, IEnumerable wellReferences, IEnumerable wellboreReferences) + { + if (!wellboreReferences.Any() && !wellReferences.Any()) + { + throw new ArgumentException("Either wellReferences or wellboreReferences must be specified"); + } + if (!missingDataChecks.Any()) + { + throw new ArgumentException("MissingDataChecks must be specified"); + } + if (wellboreReferences.Any() && wellReferences.Any()) + { + throw new ArgumentException("Either wellReferences or wellboreReferences must be left empty"); + } + if (missingDataChecks.Where(check => check.ObjectType == EntityType.Well && !check.Properties.Any()).Any()) + { + throw new ArgumentException("Selecting properties is required for wells."); + } + if (missingDataChecks.Where(check => check.ObjectType == EntityType.Wellbore && !check.Properties.Any() && wellboreReferences.Any()).Any()) + { + throw new ArgumentException("Selecting properties is required for wellbores when running Missing Data Agent on wellbores."); + } + } + + private async Task> CheckWell(MissingDataCheck check, IEnumerable wellReferences, IEnumerable wellboreReferences) + { + if (!wellReferences.Any()) + { + // Checking well properties when wellbores are selected, so we actually just want to check the parent well + wellReferences = new WellReference + { + WellName = wellboreReferences.First().WellName, + WellUid = wellboreReferences.First().WellUid + }.AsSingletonList(); + } + + var query = new WitsmlWells + { + Wells = wellReferences.Select(wellReference => + { + WitsmlWell well = new() + { + Uid = wellReference.WellUid, + Name = "", + }; + QueryHelper.AddPropertiesToObject(well, check.Properties); + return well; + }).ToList() + }; + WitsmlWells result = await GetTargetWitsmlClientOrThrow().GetFromStoreNullableAsync(query, new OptionsIn(ReturnElements.Requested)); + + return CheckResultProperties(result.Wells, check); + } + + private async Task> CheckWellbore(MissingDataCheck check, IEnumerable wellReferences, IEnumerable wellboreReferences) + { + var query = new WitsmlWellbores + { + Wellbores = wellboreReferences.Any() + ? wellboreReferences.Select(wellboreReference => + { + WitsmlWellbore wellbore = new() + { + Uid = wellboreReference.WellboreUid, + UidWell = wellboreReference.WellUid, + Name = "", + NameWell = "", + }; + QueryHelper.AddPropertiesToObject(wellbore, check.Properties); + return wellbore; + }).ToList() + : wellReferences.Select(wellReference => + { + WitsmlWellbore wellbore = new() + { + Uid = "", + UidWell = wellReference.WellUid, + Name = "", + NameWell = "", + }; + QueryHelper.AddPropertiesToObject(wellbore, check.Properties); + return wellbore; + }).ToList() + }; + WitsmlWellbores result = await GetTargetWitsmlClientOrThrow().GetFromStoreNullableAsync(query, new OptionsIn(ReturnElements.Requested)); + + return check.Properties.Any() + ? CheckResultProperties(result.Wellbores, check) + : CheckResultWellboreEmpty(result, check, wellReferences); + } + + + private async Task> CheckObject(MissingDataCheck check, IEnumerable wellReferences, IEnumerable wellboreReferences) + { + IWitsmlObjectList query = EntityTypeHelper.ToObjectList(check.ObjectType); + query.Objects = wellboreReferences.Any() + ? wellboreReferences.Select(wellboreReference => + { + var o = EntityTypeHelper.ToObjectOnWellbore(check.ObjectType); + o.Uid = ""; + o.Name = ""; + o.UidWellbore = wellboreReference.WellboreUid; + o.NameWellbore = ""; + o.UidWell = wellboreReference.WellUid; + o.NameWell = ""; + QueryHelper.AddPropertiesToObject(o, check.Properties); + return o; + }).ToList() + : wellReferences.Select(wellReference => + { + var o = EntityTypeHelper.ToObjectOnWellbore(check.ObjectType); + o.Uid = ""; + o.Name = ""; + o.UidWellbore = ""; + o.NameWellbore = ""; + o.UidWell = wellReference.WellUid; + o.NameWell = ""; + QueryHelper.AddPropertiesToObject(o, check.Properties); + return o; + }).ToList(); + + IWitsmlObjectList result = await GetTargetWitsmlClientOrThrow().GetFromStoreNullableAsync(query, new OptionsIn(ReturnElements.Requested)); + + return check.Properties.Any() + ? CheckResultProperties(result.Objects?.ToList(), check) + : await CheckResultObjectEmpty(result, check, wellReferences, wellboreReferences); + } + private static List CheckResultWellboreEmpty(WitsmlWellbores result, MissingDataCheck check, IEnumerable wellReferences) + { + List missingDataItems = new() { }; + + foreach (var wellReference in wellReferences) + { + if (!result.Wellbores.Any(wellbore => wellbore.UidWell == wellReference.WellUid)) + { + missingDataItems.Add(GetReportItem(wellReference, "", check.ObjectType)); + } + } + + return missingDataItems; + } + + private async Task> CheckResultObjectEmpty(IWitsmlObjectList result, MissingDataCheck check, IEnumerable wellReferences, IEnumerable wellboreReferences) + { + List missingDataItems = new() { }; + + if (wellReferences.Any()) + { + var wellboreQuery = new WitsmlWellbores + { + Wellbores = wellReferences.Select(wellReference => new WitsmlWellbore + { + Uid = "", + UidWell = wellReference.WellUid, + Name = "", + NameWell = "", + } + ).ToList() + }; + WitsmlWellbores wellbores = await GetTargetWitsmlClientOrThrow().GetFromStoreNullableAsync(wellboreQuery, new OptionsIn(ReturnElements.Requested)); + wellboreReferences = wellbores.Wellbores.Select(wellbore => new WellboreReference + { + WellUid = wellbore.UidWell, + WellName = wellbore.NameWell, + WellboreUid = wellbore.Uid, + WellboreName = wellbore.Name + }); + + foreach (var wellReference in wellReferences) + { + if (!wellboreReferences.Any(wellboreReference => wellboreReference.WellUid == wellReference.WellUid)) + { + // If a well does not have any wellbores, it doesn't have any objects either + missingDataItems.Add(GetReportItem(wellReference, "", check.ObjectType)); + } + } + } + + if (wellboreReferences.Any()) + { + foreach (var wellboreReference in wellboreReferences) + { + if (!result.Objects.Any(o => o.UidWellbore == wellboreReference.WellboreUid)) + { + missingDataItems.Add(GetReportItem(wellboreReference, "", check.ObjectType)); + } + } + } + + return missingDataItems; + } + + private static List CheckResultProperties(List result, MissingDataCheck check) + { + List missingDataItems = new() { }; + + foreach (T resultObject in result) + { + foreach (string property in check.Properties) + { + var propertyValue = QueryHelper.GetPropertyFromObject(resultObject, property); + if (IsPropertyEmpty(propertyValue)) + { + missingDataItems.Add(GetReportItem(resultObject, property, check.ObjectType)); + } + } + }; + + return missingDataItems; + } + + private static MissingDataReportItem GetReportItem(T resultObject, string property, EntityType objectType) + { + return resultObject switch + { + WitsmlWell well => new MissingDataReportItem + { + ObjectType = objectType.ToString(), + Property = property, + WellUid = well.Uid, + WellName = well.Name, + WellboreUid = "", + WellboreName = "", + ObjectUid = "", + ObjectName = "" + }, + WitsmlWellbore wellbore => new MissingDataReportItem + { + ObjectType = objectType.ToString(), + Property = property, + WellUid = wellbore.UidWell, + WellName = wellbore.NameWell, + WellboreUid = wellbore.Uid, + WellboreName = wellbore.Name, + ObjectUid = "", + ObjectName = "" + }, + WitsmlObjectOnWellbore objectOnWellbore => new MissingDataReportItem + { + ObjectType = objectType.ToString(), + Property = property, + WellUid = objectOnWellbore.UidWell, + WellName = objectOnWellbore.NameWell, + WellboreUid = objectOnWellbore.UidWellbore, + WellboreName = objectOnWellbore.NameWellbore, + ObjectUid = objectOnWellbore.Uid, + ObjectName = objectOnWellbore.Name + }, + WellReference wellReference => new MissingDataReportItem + { + ObjectType = objectType.ToString(), + Property = property, + WellUid = wellReference.WellUid, + WellName = wellReference.WellName, + WellboreUid = "", + WellboreName = "", + ObjectUid = "", + ObjectName = "" + }, + WellboreReference wellboreReference => new MissingDataReportItem + { + ObjectType = objectType.ToString(), + Property = property, + WellUid = wellboreReference.WellUid, + WellName = wellboreReference.WellName, + WellboreUid = wellboreReference.WellboreUid, + WellboreName = wellboreReference.WellboreName, + ObjectUid = "", + ObjectName = "" + }, + _ => throw new Exception($"Expected resultObject to be of type WitsmlWell, WitsmlWellbore, WitsmlObjectOnWellbore, WellReference or WellboreReference, but got {typeof(T)}"), + }; + } + + public static bool IsPropertyEmpty(object property) + { + Type propertyType = property?.GetType(); + if (property == null) + return true; + if (propertyType == typeof(string)) + return string.IsNullOrEmpty((string)property); + if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(List<>)) + return ((IList)property).Count == 0; + if (propertyType.IsClass) + // Recursively check if all properties of a class is empty + return !propertyType.GetProperties().Select(p => p.GetValue(property)).Any(p => !IsPropertyEmpty(p)); + return false; + } + + private static MissingDataReport GetReport(List missingDataItems, IEnumerable checks, IEnumerable wellReferences, IEnumerable wellboreReferences) + { + string checkObjectsSummary = wellboreReferences.Any() + ? $"Checked wellbores for well {wellboreReferences.First().WellName}: {string.Join(", ", wellboreReferences.Select(r => r.WellboreName))}" + : $"Checked wells: {string.Join(", ", wellReferences.Select(r => r.WellName))}"; + string checkSummary = string.Join("\n\n", checks.Select(check => check.Properties.Any() ? $"Checked properties for {check.ObjectType}: {string.Join(", ", check.Properties)}" : $"Checked presence of {check.ObjectType}")); + return new MissingDataReport + { + Title = $"Missing Data Report", + Summary = missingDataItems.Count > 0 + ? $"Found {missingDataItems.Count} cases of missing data.\n{checkObjectsSummary}\n\n{checkSummary}" + : $"No missing data was found.\n{checkObjectsSummary}\n\n{checkSummary}", + ReportItems = missingDataItems + }; + } + } +} diff --git a/Src/WitsmlExplorer.Frontend/components/ContentViews/WellboresListView.tsx b/Src/WitsmlExplorer.Frontend/components/ContentViews/WellboresListView.tsx index d5f6b9d31..2c460ce72 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContentViews/WellboresListView.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContentViews/WellboresListView.tsx @@ -10,7 +10,9 @@ import ObjectService from "../../services/objectService"; import { getContextMenuPosition } from "../ContextMenus/ContextMenu"; import WellboreContextMenu, { WellboreContextMenuProps } from "../ContextMenus/WellboreContextMenu"; import formatDateString from "../DateFormatter"; -import { ContentTable, ContentTableColumn, ContentType } from "./table"; +import { ContentTable, ContentTableColumn, ContentTableRow, ContentType } from "./table"; + +export interface WellboreRow extends ContentTableRow, Wellbore {} export const WellboresListView = (): React.ReactElement => { const { navigationState, dispatchNavigation } = useContext(NavigationContext); @@ -33,8 +35,8 @@ export const WellboresListView = (): React.ReactElement => { { property: "dateTimeLastChange", label: "commonData.dTimLastChange", type: ContentType.DateTime } ]; - const onContextMenu = (event: React.MouseEvent, wellbore: Wellbore) => { - const contextMenuProps: WellboreContextMenuProps = { wellbore, well: selectedWell }; + const onContextMenu = (event: React.MouseEvent, wellbore: Wellbore, checkedWellboreRows: WellboreRow[]) => { + const contextMenuProps: WellboreContextMenuProps = { wellbore, well: selectedWell, checkedWellboreRows }; const position = getContextMenuPosition(event); dispatchOperation({ type: OperationType.DisplayContextMenu, payload: { component: , position } }); }; @@ -65,7 +67,15 @@ export const WellboresListView = (): React.ReactElement => { return ( selectedWell && ( - + ) ); }; diff --git a/Src/WitsmlExplorer.Frontend/components/ContextMenus/WellContextMenu.tsx b/Src/WitsmlExplorer.Frontend/components/ContextMenus/WellContextMenu.tsx index 9561f83fa..dfa500b1c 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContextMenus/WellContextMenu.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContextMenus/WellContextMenu.tsx @@ -12,6 +12,8 @@ import JobService, { JobType } from "../../services/jobService"; import { colors } from "../../styles/Colors"; import { WellRow } from "../ContentViews/WellsListView"; import ConfirmModal from "../Modals/ConfirmModal"; +import DeleteEmptyMnemonicsModal, { DeleteEmptyMnemonicsModalProps } from "../Modals/DeleteEmptyMnemonicsModal"; +import MissingDataAgentModal, { MissingDataAgentModalProps } from "../Modals/MissingDataAgentModal"; import { PropertiesModalMode } from "../Modals/ModalParts"; import WellBatchUpdateModal, { WellBatchUpdateModalProps } from "../Modals/WellBatchUpdateModal"; import WellPropertiesModal, { WellPropertiesModalProps } from "../Modals/WellPropertiesModal"; @@ -19,7 +21,6 @@ import WellborePropertiesModal, { WellborePropertiesModalProps } from "../Modals import ContextMenu from "./ContextMenu"; import { StyledIcon } from "./ContextMenuUtils"; import NestedMenuItem from "./NestedMenuItem"; -import DeleteEmptyMnemonicsModal, { DeleteEmptyMnemonicsModalProps } from "../Modals/DeleteEmptyMnemonicsModal"; export interface WellContextMenuProps { dispatchOperation: (action: DisplayModalAction | HideModalAction | HideContextMenuAction) => void; @@ -97,6 +98,20 @@ const WellContextMenu = (props: WellContextMenuProps): React.ReactElement => { dispatchOperation(action); }; + const onClickMissingDataAgent = () => { + const wellReferences = checkedWellRows?.map((row) => ({ + wellUid: row.uid, + wellName: row.name + })) || [ + { + wellUid: well.uid, + wellName: well.name + } + ]; + const missingDataAgentModalProps: MissingDataAgentModalProps = { wellReferences: wellReferences, wellboreReferences: [] }; + dispatchOperation({ type: OperationType.DisplayModal, payload: }); + }; + const onClickProperties = () => { const wellPropertiesModalProps: WellPropertiesModalProps = { mode: PropertiesModalMode.Edit, well, dispatchOperation }; dispatchOperation({ type: OperationType.DisplayModal, payload: }); @@ -140,6 +155,10 @@ const WellContextMenu = (props: WellContextMenuProps): React.ReactElement => { ))} , + + + Missing Data Agent + , , diff --git a/Src/WitsmlExplorer.Frontend/components/ContextMenus/WellboreContextMenu.tsx b/Src/WitsmlExplorer.Frontend/components/ContextMenus/WellboreContextMenu.tsx index dc2250137..8fabb62da 100644 --- a/Src/WitsmlExplorer.Frontend/components/ContextMenus/WellboreContextMenu.tsx +++ b/Src/WitsmlExplorer.Frontend/components/ContextMenus/WellboreContextMenu.tsx @@ -18,8 +18,10 @@ import JobService, { JobType } from "../../services/jobService"; import ObjectService from "../../services/objectService"; import WellboreService from "../../services/wellboreService"; import { colors } from "../../styles/Colors"; +import { WellboreRow } from "../ContentViews/WellboresListView"; import ConfirmModal from "../Modals/ConfirmModal"; import LogPropertiesModal, { IndexCurve, LogPropertiesModalInterface } from "../Modals/LogPropertiesModal"; +import MissingDataAgentModal, { MissingDataAgentModalProps } from "../Modals/MissingDataAgentModal"; import { PropertiesModalMode } from "../Modals/ModalParts"; import WellborePropertiesModal, { WellborePropertiesModalProps } from "../Modals/WellborePropertiesModal"; import ContextMenu from "./ContextMenu"; @@ -32,10 +34,11 @@ import DeleteEmptyMnemonicsModal, { DeleteEmptyMnemonicsModalProps } from "../Mo export interface WellboreContextMenuProps { wellbore: Wellbore; well: Well; + checkedWellboreRows?: WellboreRow[]; } const WellboreContextMenu = (props: WellboreContextMenuProps): React.ReactElement => { - const { wellbore, well } = props; + const { wellbore, well, checkedWellboreRows } = props; const { dispatchNavigation, navigationState: { servers, expandedTreeNodes, selectedWell, selectedWellbore } @@ -140,6 +143,24 @@ const WellboreContextMenu = (props: WellboreContextMenuProps): React.ReactElemen }); }; + const onClickMissingDataAgent = () => { + const wellboreReferences = checkedWellboreRows?.map((row) => ({ + wellUid: row.wellUid, + wellboreUid: row.uid, + wellName: row.wellName, + wellboreName: row.name + })) || [ + { + wellUid: wellbore.wellUid, + wellboreUid: wellbore.uid, + wellName: wellbore.wellName, + wellboreName: wellbore.name + } + ]; + const missingDataAgentModalProps: MissingDataAgentModalProps = { wellReferences: [], wellboreReferences: wellboreReferences }; + dispatchOperation({ type: OperationType.DisplayModal, payload: }); + }; + const onClickProperties = async () => { const controller = new AbortController(); const detailedWellbore = await WellboreService.getWellbore(wellbore.wellUid, wellbore.uid, controller.signal); @@ -188,6 +209,10 @@ const WellboreContextMenu = (props: WellboreContextMenuProps): React.ReactElemen ))} , + + + Missing Data Agent + , , diff --git a/Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentModal.tsx b/Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentModal.tsx new file mode 100644 index 000000000..b92276e67 --- /dev/null +++ b/Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentModal.tsx @@ -0,0 +1,168 @@ +import { Accordion, Autocomplete, Button, Icon, Typography } from "@equinor/eds-core-react"; +import { useContext, useEffect, useState } from "react"; +import styled from "styled-components"; +import { v4 as uuid } from "uuid"; +import OperationContext from "../../contexts/operationContext"; +import OperationType from "../../contexts/operationType"; +import MissingDataJob, { MissingDataCheck } from "../../models/jobs/missingDataJob"; +import WellReference from "../../models/jobs/wellReference"; +import WellboreReference from "../../models/jobs/wellboreReference"; +import { ObjectType } from "../../models/objectType"; +import JobService, { JobType } from "../../services/jobService"; +import { Colors } from "../../styles/Colors"; +import { StyledAccordionHeader } from "./LogComparisonModal"; +import { objectToProperties, selectAllProperties } from "./MissingDataAgentProperties"; +import ModalDialog, { ModalContentLayout, ModalWidth } from "./ModalDialog"; +import { ReportModal } from "./ReportModal"; + +export interface MissingDataAgentModalProps { + wellReferences: WellReference[]; + wellboreReferences: WellboreReference[]; +} + +export const missingDataObjectOptions = ["Well", "Wellbore", ...Object.values(ObjectType).filter((o) => o != ObjectType.ChangeLog)]; + +const MissingDataAgentModal = (props: MissingDataAgentModalProps): React.ReactElement => { + const { wellReferences, wellboreReferences } = props; + const { + dispatchOperation, + operationState: { colors } + } = useContext(OperationContext); + const [missingDataChecks, setMissingDataChecks] = useState([{ id: uuid() } as MissingDataCheck]); + const [errors, setErrors] = useState([]); + + useEffect(() => { + setErrors([]); + }, [missingDataChecks]); + + const validateChecks = (): boolean => { + const updatedErrors = [...errors]; + + if (!missingDataChecks.some((check) => Boolean(check.objectType))) updatedErrors.push("No objects are selected!"); + if (missingDataChecks.some((check) => check.objectType == "Well" && check.properties.length == 0)) updatedErrors.push("Selecting properties is required for Wells."); + if (missingDataChecks.some((check) => check.objectType == "Wellbore" && check.properties.length == 0 && wellReferences.length == 0)) + updatedErrors.push("Selecting properties is required for Wellbores when running Missing Data Agent on wellbores."); + + if (updatedErrors) setErrors(updatedErrors); + + return updatedErrors.length == 0; + }; + + const onSubmit = async () => { + if (!validateChecks()) return; + dispatchOperation({ type: OperationType.HideModal }); + const filteredChecks = missingDataChecks + .map((check) => ({ ...check, properties: check.properties?.filter((p) => p !== selectAllProperties) })) + .filter((check) => check.objectType != null); + const missingDataJob: MissingDataJob = { wellReferences: wellReferences, wellboreReferences: wellboreReferences, missingDataChecks: filteredChecks }; + const jobId = await JobService.orderJob(JobType.MissingData, missingDataJob); + const reportModalProps = { jobId }; + dispatchOperation({ type: OperationType.DisplayModal, payload: }); + }; + + const addCheck = () => { + setMissingDataChecks([...missingDataChecks, { id: uuid() } as MissingDataCheck]); + }; + + const removeCheck = (id: string) => { + setMissingDataChecks([...missingDataChecks.filter((check) => check.id != id)]); + }; + + const onObjectsChange = (selectedItems: string[], missingDataCheck: MissingDataCheck) => { + setMissingDataChecks(missingDataChecks.map((oldCheck) => (oldCheck.id == missingDataCheck.id ? { ...oldCheck, objectType: selectedItems[0], properties: [] } : oldCheck))); + }; + + const onPropertiesChange = (selectedItems: string[], missingDataCheck: MissingDataCheck) => { + let newSelectedItems = selectedItems; + if (selectedItems.includes(selectAllProperties) != missingDataCheck.properties.includes(selectAllProperties)) { + if (missingDataCheck.properties.length < objectToProperties[missingDataCheck.objectType].length) { + newSelectedItems = objectToProperties[missingDataCheck.objectType]; + } else { + newSelectedItems = []; + } + } + setMissingDataChecks(missingDataChecks.map((oldCheck) => (oldCheck.id == missingDataCheck.id ? { ...oldCheck, properties: newSelectedItems } : oldCheck))); + }; + + const getPropertyLabel = (missingDataCheck: MissingDataCheck) => { + const requiredString = + missingDataCheck.objectType === "Well" || (missingDataCheck.objectType === "Wellbore" && wellboreReferences.length > 0) + ? " (required)" + : missingDataCheck.objectType + ? " (optional)" + : ""; + + return `Select properties${requiredString}`; + }; + + return ( + + + + Missing Data Agent + + + The missing data agent can be used to check if there are data in the selected objects or properties. +
+
+ When leaving the properties field empty, the agent will check if the object is present. +
+
+
+
+ {missingDataChecks.map((missingDataCheck) => ( + + e.preventDefault()} + onOptionsChange={({ selectedItems }) => onObjectsChange(selectedItems, missingDataCheck)} + /> + p != selectAllProperties).join(", ") || ""} + options={objectToProperties[missingDataCheck.objectType]} + selectedOptions={missingDataCheck.properties || []} + onFocus={(e) => e.preventDefault()} + onOptionsChange={({ selectedItems }) => onPropertiesChange(selectedItems, missingDataCheck)} + /> + + + ))} + + + + + } + /> + ); +}; + +export default MissingDataAgentModal; + +const CheckLayout = styled.div` + display: grid; + grid-template-columns: 1fr 2fr 0.2fr; + gap: 10px; +`; + +const StyledButton = styled(Button)<{ colors: Colors }>` + align-self: center; +`; diff --git a/Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentProperties.tsx b/Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentProperties.tsx new file mode 100644 index 000000000..84eaefc43 --- /dev/null +++ b/Src/WitsmlExplorer.Frontend/components/Modals/MissingDataAgentProperties.tsx @@ -0,0 +1,305 @@ +import { ObjectType } from "../../models/objectType"; + +// The properties must equal the name of the corresponding Witsml model's properties +// Checking properties in nested objects are supported by separating the objects and properties with a '.' as long as the parent object is not a list. +export const selectAllProperties = "Select All"; + +const objectUnderWellboreProperties = ["name", "nameWellbore", "nameWell"]; + +const commonDataProperties = [ + "commonData.sourceName", + "commonData.dTimCreation", + "commonData.dTimLastChange", + "commonData.itemState", + "commonData.serviceCategory", + "commonData.comments", + "commonData.acquisitionTimeZone", + "commonData.defaultDatum", + "commonData.privateGroupOnly", + "commonData.extensionAny" +]; + +export const objectToProperties: Record = { + Well: [ + selectAllProperties, + "name", + "field", + "country", + "region", + "timeZone", + "operator", + "numLicense", + "statusWell", + "purposeWell", + "wellDatum", + "waterDepth", + "wellLocation", + ...commonDataProperties + ], + Wellbore: [ + selectAllProperties, + "name", + "nameWell", + "number", + "suffixAPI", + "numGovt", + "parentWellbore", + "statusWellbore", + "isActive", + "purposeWellbore", + "shape", + "dTimKickoff", + "md", + "tvd", + "mdKickoff", + "tvdKickoff", + "mdPlanned", + "tvdPlanned", + "mdSubSeaPlanned", + "tvdSubSeaPlanned", + "dayTarget", + ...commonDataProperties + ], + [ObjectType.BhaRun]: [ + selectAllProperties, + ...objectUnderWellboreProperties, + "tubular", + "dTimStart", + "dTimStop", + "dTimStartDrilling", + "dTimStopDrilling", + "planDogleg", + "actDogleg", + "actDoglegMx", + "statusBha", + "numBitRun", + "numStringRun", + "reasonTrip", + "objectiveBha", + "drillingParams", + ...commonDataProperties + ], + [ObjectType.FluidsReport]: [selectAllProperties, ...objectUnderWellboreProperties, "dTim", "md", "tvd", "numReport", "fluids", ...commonDataProperties], + [ObjectType.FormationMarker]: [ + selectAllProperties, + ...objectUnderWellboreProperties, + "mdPrognosed", + "tvdPrognosed", + "mdTopSample", + "tvdTopSample", + "thicknessBed", + "thicknessApparent", + "thicknessPerpen", + "mdLogSample", + "tvdLogSample", + "dip", + "dipDirection", + "lithostratigraphic", + "chronostratigraphic", + "description", + "commonData", + ...commonDataProperties + ], + [ObjectType.Log]: [ + selectAllProperties, + ...objectUnderWellboreProperties, + "objectGrowing", + "serviceCompany", + "runNumber", + "BHARunNumber", + "pass", + "creationDate", + "description", + "indexType", + "startIndex", + "endIndex", + "startDateTimeIndex", + "endDateTimeIndex", + "direction", + "indexCurve", + ...commonDataProperties + ], + [ObjectType.Message]: [ + selectAllProperties, + ...objectUnderWellboreProperties, + "objectReference", + "subObjectReference", + "dTim", + "activityCode", + "detailActivity", + "md", + "mdBit", + "typeMessage", + "messageText", + "param", + "severity", + "warnProbability", + ...commonDataProperties + ], + [ObjectType.MudLog]: [ + selectAllProperties, + ...objectUnderWellboreProperties, + "objectGrowing", + "dTim", + "mudLogCompany", + "mudLogEngineers", + "startMd", + "endMd", + "relatedLog", + "mudLogParameters", + "geologyInterval", + ...commonDataProperties + ], + [ObjectType.Rig]: [ + selectAllProperties, + ...objectUnderWellboreProperties, + "owner", + "typeRig", + "manufacturer", + "yearEntService", + "classRig", + "approvals", + "registration", + "telNumber", + "faxNumber", + "emailAddress", + "nameContact", + "ratingDrillDepth", + "ratingWaterDepth", + "isOffshore", + "airGap", + "dTimStartOp", + "dTimEndOp", + "bop", + "pit", + "pump", + "shaker", + "centrifuge", + "hydrocyclone", + "degasser", + "surfaceEquipment", + "numDerricks", + "typeDerrick", + "ratingDerrick", + "htDerrick", + "ratingHkld", + "capWindDerrick", + "wtBlock", + "ratingBlock", + "numBlockLines", + "typeHook", + "ratingHook", + "sizeDrillLine", + "typeDrawWorks", + "powerDrawWorks", + "ratingDrawWorks", + "motorDrawWorks", + "descBrake", + "typeSwivel", + "ratingSwivel", + "rotSystem", + "descRotSystem", + "ratingTqRotSys", + "rotSizeOpening", + "ratingRotSystem", + "scrSystem", + "pipeHandlingSystem", + "capBulkMud", + "capLiquidMud", + "capDrillWater", + "capPotableWater", + "capFuel", + "capBulkCement", + "mainEngine", + "generator", + "cementUnit", + "numBunks", + "bunksPerRoom", + "numCranes", + "numAnch", + "moorType", + "numGuideTens", + "numRiserTens", + "varDeckLdMx", + "vdlStorm", + "numThrusters", + "azimuthing", + "motionCompensationMn", + "motionCompensationMx", + "strokeMotionCompensation", + "riserAngleLimit", + "heaveMx", + "gantry", + "flares", + ...commonDataProperties + ], + [ObjectType.Risk]: [ + selectAllProperties, + ...objectUnderWellboreProperties, + "objectReference", + "type", + "category", + "subCategory", + "extendCategory", + "affectedPersonnel", + "dTimStart", + "dTimEnd", + "mdHoleStart", + "mdHoleEnd", + "tvdHoleStart", + "tvdHoleEnd", + "mdBitStart", + "mdBitEnd", + "diaHole", + "severityLevel", + "probabilityLevel", + "summary", + "details", + "identification", + "contingency", + "mitigation", + ...commonDataProperties + ], + [ObjectType.Trajectory]: [ + selectAllProperties, + ...objectUnderWellboreProperties, + "parentTrajectory", + "dTimTrajStart", + "dTimTrajEnd", + "mdMin", + "mdMax", + "serviceCompany", + "magDeclUsed", + "gridCorUsed", + "gridConUsed", + "aziVertSect", + "dispNsVertSectOrig", + "dispEwVertSectOrig", + "definitive", + "memory", + "finalTraj", + "aziRef", + "trajectoryStations", + ...commonDataProperties + ], + [ObjectType.Tubular]: [ + selectAllProperties, + ...objectUnderWellboreProperties, + "typeTubularAssy", + "valveFloat", + "sourceNuclear", + "diaHoleAssy", + "tubularComponents", + ...commonDataProperties + ], + [ObjectType.WbGeometry]: [ + selectAllProperties, + ...objectUnderWellboreProperties, + "dTimReport", + "mdBottom", + "gapAir", + "depthWaterMean", + "wbGeometrySections", + ...commonDataProperties + ] +}; diff --git a/Src/WitsmlExplorer.Frontend/components/Modals/ReportModal.tsx b/Src/WitsmlExplorer.Frontend/components/Modals/ReportModal.tsx index 2db1c5781..a90029c43 100644 --- a/Src/WitsmlExplorer.Frontend/components/Modals/ReportModal.tsx +++ b/Src/WitsmlExplorer.Frontend/components/Modals/ReportModal.tsx @@ -1,4 +1,4 @@ -import { DotProgress, Typography } from "@equinor/eds-core-react"; +import { Accordion, DotProgress, Typography } from "@equinor/eds-core-react"; import React, { useContext, useEffect, useState } from "react"; import styled from "styled-components"; import NavigationContext from "../../contexts/navigationContext"; @@ -8,6 +8,7 @@ import BaseReport, { createReport } from "../../models/reports/BaseReport"; import JobService from "../../services/jobService"; import NotificationService from "../../services/notificationService"; import { ContentTable, ContentTableColumn, ContentType } from "../ContentViews/table"; +import { StyledAccordionHeader } from "./LogComparisonModal"; import ModalDialog, { ModalWidth } from "./ModalDialog"; export interface ReportModal { @@ -29,7 +30,10 @@ export interface ReportModal { */ export const ReportModal = (props: ReportModal): React.ReactElement => { const { jobId, report: reportProp } = props; - const { dispatchOperation } = React.useContext(OperationContext); + const { + dispatchOperation, + operationState: { colors } + } = React.useContext(OperationContext); const [report, setReport] = useState(reportProp); const fetchedReport = useGetReportOnJobFinished(jobId); @@ -59,7 +63,18 @@ export const ReportModal = (props: ReportModal): React.ReactElement => { <> {report ? ( - {report.summary && {report.summary}} + {report.summary && report.summary.includes("\n") ? ( + + + {report.summary.split("\n")[0]} + + {report.summary.split("\n").splice(1).join("\n")} + + + + ) : ( + {report.summary} + )} {columns.length > 0 && } ) : ( diff --git a/Src/WitsmlExplorer.Frontend/models/jobs/missingDataJob.tsx b/Src/WitsmlExplorer.Frontend/models/jobs/missingDataJob.tsx new file mode 100644 index 000000000..e471aac99 --- /dev/null +++ b/Src/WitsmlExplorer.Frontend/models/jobs/missingDataJob.tsx @@ -0,0 +1,15 @@ +import WellReference from "./wellReference"; +import WellboreReference from "./wellboreReference"; + +// Either wellReferences or wellboreReferences should be set, the other should be empty. +export default interface MissingDataJob { + wellReferences: WellReference[]; + wellboreReferences: WellboreReference[]; + missingDataChecks: MissingDataCheck[]; +} + +export interface MissingDataCheck { + id: string; + objectType: string; + properties: string[]; +} diff --git a/Src/WitsmlExplorer.Frontend/services/jobService.tsx b/Src/WitsmlExplorer.Frontend/services/jobService.tsx index f10935b14..07958021e 100644 --- a/Src/WitsmlExplorer.Frontend/services/jobService.tsx +++ b/Src/WitsmlExplorer.Frontend/services/jobService.tsx @@ -81,6 +81,7 @@ export enum JobType { DeleteObjects = "DeleteObjects", DeleteWell = "DeleteWell", DeleteWellbore = "DeleteWellbore", + MissingData = "MissingData", ModifyBhaRun = "ModifyBhaRun", ModifyFormationMarker = "ModifyFormationMarker", ModifyGeologyInterval = "ModifyGeologyInterval", diff --git a/Tests/WitsmlExplorer.Api.Tests/Query/QueryHelperTests.cs b/Tests/WitsmlExplorer.Api.Tests/Query/QueryHelperTests.cs new file mode 100644 index 000000000..308cfaf40 --- /dev/null +++ b/Tests/WitsmlExplorer.Api.Tests/Query/QueryHelperTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Witsml.Data; +using Witsml.Data.Measures; + +using WitsmlExplorer.Api.Query; + +using Xunit; + +namespace WitsmlExplorer.Api.Tests.Query +{ + public class QueryHelperTests + { + private readonly WitsmlWell _well = new() + { + Uid = "uid", + Name = "name", + WellLocation = new WitsmlLocation + { + Uid = "locationUid", + Latitude = new Measure + { + Value = "63.4279799" + } + }, + WellDatum = new List + { + new WellDatum + { + Uid = "wellDatumUid", + Name = "wellDatumName" + } + } + }; + + [Fact] + public void GetPropertyFromObject_NotSupportedProperty_ReturnsNull() + { + object obj = new WitsmlWell { }; + object objPropertyValue = QueryHelper.GetPropertyFromObject(obj, "undefinedProperty"); + Assert.Null(objPropertyValue); + } + + [Fact] + public void GetPropertyFromObject_NullObject_ReturnsNull() + { + object obj = null; + object objPropertyValue = QueryHelper.GetPropertyFromObject(obj, "undefinedProperty"); + Assert.Null(objPropertyValue); + } + + [Fact] + public void GetPropertyFromObject_StringProperty_ReturnsValue() + { + object objPropertyValue = QueryHelper.GetPropertyFromObject(_well, "name"); + Assert.Equal(_well.Name, objPropertyValue); + } + + [Fact] + public void GetPropertyFromObject_ListProperty_ReturnsValue() + { + object objPropertyValue = QueryHelper.GetPropertyFromObject(_well, "wellDatum"); + Assert.Equal(_well.WellDatum, objPropertyValue); + } + + [Fact] + public void GetPropertyFromObject_ObjectProperty_ReturnsValue() + { + object objPropertyValue = QueryHelper.GetPropertyFromObject(_well, "commonData"); + Assert.Equal(_well.CommonData, objPropertyValue); + } + + [Fact] + public void GetPropertyFromObject_NestedObjectProperty_ReturnsValue() + { + object objPropertyValue = QueryHelper.GetPropertyFromObject(_well, "wellLocation.uid"); + Assert.Equal(_well.WellLocation.Uid, objPropertyValue); + } + + [Fact] + public void GetPropertyFromObject_NestedObjectProperty_ReturnsObjectValue() + { + object objPropertyValue = QueryHelper.GetPropertyFromObject(_well, "wellLocation.latitude"); + Assert.Equal(_well.WellLocation.Latitude, objPropertyValue); + } + + [Fact] + public void GetPropertyFromObject_DeepNestedObjectProperty_ReturnsValue() + { + object objPropertyValue = QueryHelper.GetPropertyFromObject(_well, "wellLocation.latitude.value"); + Assert.Equal(_well.WellLocation.Latitude.Value, objPropertyValue); + } + + [Fact] + public void AddPropertyToObject_UnnsuportedProperty_Throws() + { + Assert.Throws(() => QueryHelper.AddPropertyToObject(_well, "unsupportedProperty")); + } + + [Fact] + public void AddPropertyToObject_AddsStringProperty() + { + Assert.Null(_well.Field); + WitsmlWell obj = QueryHelper.AddPropertyToObject(_well, "field"); + Assert.Equal("", obj.Field); + } + + [Fact] + public void AddPropertyToObject_AddsObjectProperty() + { + Assert.Null(_well.CommonData); + WitsmlWell obj = QueryHelper.AddPropertyToObject(_well, "commonData"); + Assert.NotNull(obj.CommonData); + Assert.IsType(obj.CommonData); + } + + [Fact] + public void AddPropertyToObject_AddsNestedObject() + { + Assert.Null(_well.CommonData); + WitsmlWell obj = QueryHelper.AddPropertyToObject(_well, "commonData.sourceName"); + Assert.Equal("", obj.CommonData.SourceName); + } + + [Fact] + public void AddPropertiesToObject_AddsMultipleProperties() + { + Assert.Null(_well.Region); + Assert.Null(_well.CommonData); + Assert.Null(_well.WellLocation.Longitude); + Assert.Null(_well.WellLocation.ProjectedX); + WitsmlWell obj = QueryHelper.AddPropertiesToObject(_well, new List { "region", "commonData.sourceName", "wellLocation.longitude.value", "wellLocation.projectedX" }); + Assert.Equal("", obj.Region); + Assert.Equal("", obj.CommonData.SourceName); + Assert.Equal("", obj.WellLocation.Longitude.Value); + Assert.NotNull(obj.WellLocation.ProjectedX); + Assert.Null(obj.WellLocation.ProjectedY); + } + } +} diff --git a/Tests/WitsmlExplorer.Api.Tests/Workers/MissingDataWorkerTests.cs b/Tests/WitsmlExplorer.Api.Tests/Workers/MissingDataWorkerTests.cs new file mode 100644 index 000000000..66e2559c6 --- /dev/null +++ b/Tests/WitsmlExplorer.Api.Tests/Workers/MissingDataWorkerTests.cs @@ -0,0 +1,470 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; + +using Moq; + +using Serilog; + +using Witsml; +using Witsml.Data; +using Witsml.Data.Rig; +using Witsml.Extensions; +using Witsml.ServiceReference; + +using WitsmlExplorer.Api.Jobs; +using WitsmlExplorer.Api.Jobs.Common; +using WitsmlExplorer.Api.Models; +using WitsmlExplorer.Api.Models.Reports; +using WitsmlExplorer.Api.Services; +using WitsmlExplorer.Api.Workers; + +using Xunit; + +namespace WitsmlExplorer.Api.Tests.Workers +{ + public class MissingDataWorkerTests + { + private readonly Mock _witsmlClient; + private readonly MissingDataWorker _worker; + + public MissingDataWorkerTests() + { + Mock witsmlClientProvider = new(); + _witsmlClient = new Mock(); + witsmlClientProvider.Setup(provider => provider.GetClient()).Returns(_witsmlClient.Object); + ILoggerFactory loggerFactory = new LoggerFactory(); + loggerFactory.AddSerilog(Log.Logger); + ILogger logger = loggerFactory.CreateLogger(); + _worker = new MissingDataWorker(logger, witsmlClientProvider.Object); + } + + [Fact] + public void IsPropertyEmpty_Null_ReturnsTrue() + { + object obj = null; + Assert.True(MissingDataWorker.IsPropertyEmpty(obj)); + } + + [Fact] + public void IsPropertyEmpty_EmptyString_ReturnsTrue() + { + string obj = ""; + Assert.True(MissingDataWorker.IsPropertyEmpty(obj)); + } + + [Fact] + public void IsPropertyEmpty_String_ReturnsFalse() + { + string obj = "123"; + Assert.False(MissingDataWorker.IsPropertyEmpty(obj)); + } + + [Fact] + public void IsPropertyEmpty_EmptyList_ReturnsTrue() + { + var obj = new List(); + Assert.True(MissingDataWorker.IsPropertyEmpty(obj)); + } + + [Fact] + public void IsPropertyEmpty_List_ReturnsFalse() + { + var obj = new List() { "123" }; + Assert.False(MissingDataWorker.IsPropertyEmpty(obj)); + } + + [Fact] + public void IsPropertyEmpty_EmptyObject_ReturnsTrue() + { + var obj = new WitsmlWell { }; + Assert.True(MissingDataWorker.IsPropertyEmpty(obj)); + } + + [Fact] + public void IsPropertyEmpty_Object_ReturnsFalse() + { + var obj = new WitsmlWell { Uid = "uid" }; + Assert.False(MissingDataWorker.IsPropertyEmpty(obj)); + } + + [Fact] + public void IsPropertyEmpty_EmptyNestedObject_ReturnsTrue() + { + var obj = new WitsmlWell { CommonData = new WitsmlCommonData() }; + Assert.True(MissingDataWorker.IsPropertyEmpty(obj)); + } + + [Fact] + public void IsPropertyEmpty_NestedObject_ReturnsFalse() + { + var obj = new WitsmlWell { CommonData = new WitsmlCommonData { SourceName = "sourceName" } }; + Assert.False(MissingDataWorker.IsPropertyEmpty(obj)); + } + + [Fact] + public async Task Execute_CheckWellProperties_AddsMissingProperties() + { + _witsmlClient.Setup(client => + client.GetFromStoreNullableAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(GetTestWells())); + var properties = new List + { + "name", + "field", + "numLicense", + "commonData.sourceName" + }; + var checks = new List { + new MissingDataCheck + { + ObjectType = EntityType.Well, + Properties = properties + } + }; + var job = CreateJobTemplate(checks, true, false); + var (workerResult, _) = await _worker.Execute(job); + var report = job.JobInfo.Report; + List reportItems = (List)report.ReportItems; + + Assert.True(workerResult.IsSuccess); + Assert.DoesNotContain(reportItems, item => !properties.Contains(item.Property)); + Assert.DoesNotContain(reportItems, item => item.Property == "name"); + Assert.DoesNotContain(reportItems, item => item.WellUid == "well3Uid"); + Assert.Contains(reportItems, item => item.Property == "field" && item.WellUid == "well1Uid"); + Assert.Contains(reportItems, item => item.Property == "numLicense" && item.WellUid == "well1Uid"); + Assert.Contains(reportItems, item => item.Property == "numLicense" && item.WellUid == "well2Uid"); + Assert.Contains(reportItems, item => item.Property == "commonData.sourceName" && item.WellUid == "well1Uid"); + Assert.Contains(reportItems, item => item.Property == "commonData.sourceName" && item.WellUid == "well2Uid"); + } + + [Fact] + public async Task Execute_CheckWellboreProperties_AddsMissingProperties() + { + _witsmlClient.Setup(client => + client.GetFromStoreNullableAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(GetTestWellboresForWell1())); + var properties = new List + { + "name", + "number", + "isActive", + "commonData.sourceName" + }; + var checks = new List { + new MissingDataCheck + { + ObjectType = EntityType.Wellbore, + Properties = properties + } + }; + var job = CreateJobTemplate(checks, false, true); + var (workerResult, _) = await _worker.Execute(job); + var report = job.JobInfo.Report; + List reportItems = (List)report.ReportItems; + + Assert.True(workerResult.IsSuccess); + Assert.DoesNotContain(reportItems, item => !properties.Contains(item.Property)); + Assert.DoesNotContain(reportItems, item => item.Property == "name"); + Assert.True(reportItems.All(item => item.WellUid == "well1Uid")); + Assert.Contains(reportItems, item => item.Property == "number" && item.WellboreUid == "well1Wellbore1Uid"); + Assert.Contains(reportItems, item => item.Property == "isActive" && item.WellboreUid == "well1Wellbore1Uid"); + Assert.Contains(reportItems, item => item.Property == "isActive" && item.WellboreUid == "well1Wellbore2Uid"); + Assert.Contains(reportItems, item => item.Property == "commonData.sourceName" && item.WellboreUid == "well1Wellbore1Uid"); + Assert.Contains(reportItems, item => item.Property == "commonData.sourceName" && item.WellboreUid == "well1Wellbore2Uid"); + } + + [Fact] + public async Task Execute_CheckRigProperties_AddsMissingProperties() + { + _witsmlClient.Setup(client => + client.GetFromStoreNullableAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(GetAllTestRigs())); + var properties = new List + { + "name", + "typeRig", + "classRig", + "commonData.sourceName" + }; + var checks = new List { + new MissingDataCheck + { + ObjectType = EntityType.Rig, + Properties = properties + } + }; + var job = CreateJobTemplate(checks, true, false); + var (workerResult, _) = await _worker.Execute(job); + var report = job.JobInfo.Report; + List reportItems = (List)report.ReportItems; + + Assert.True(workerResult.IsSuccess); + Assert.DoesNotContain(reportItems, item => !properties.Contains(item.Property)); + Assert.DoesNotContain(reportItems, item => item.Property == "name"); + Assert.Contains(reportItems, item => item.Property == "typeRig" && item.ObjectUid == "Well1Wellbore1Rig1Uid"); + Assert.Contains(reportItems, item => item.Property == "classRig" && item.ObjectUid == "Well1Wellbore1Rig1Uid"); + Assert.Contains(reportItems, item => item.Property == "classRig" && item.ObjectUid == "Well1Wellbore1Rig2Uid"); + Assert.Contains(reportItems, item => item.Property == "commonData.sourceName" && item.ObjectUid == "Well1Wellbore1Rig1Uid"); + Assert.Contains(reportItems, item => item.Property == "commonData.sourceName" && item.ObjectUid == "Well2Wellbore1Rig1Uid"); + } + + [Fact] + public async Task Execute_CheckMissingWellbore_AddsWellboresWithNoRigs() + { + _witsmlClient.Setup(client => + client.GetFromStoreNullableAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(GetAllTestWellbores())); + var checks = new List { + new MissingDataCheck + { + ObjectType = EntityType.Wellbore, + Properties = new List () + } + }; + var job = CreateJobTemplate(checks, true, false); + var (workerResult, _) = await _worker.Execute(job); + var report = job.JobInfo.Report; + List reportItems = (List)report.ReportItems; + Assert.Single(reportItems); + Assert.Contains(reportItems, item => item.WellUid == "well3Uid"); + Assert.True(workerResult.IsSuccess); + } + + [Fact] + public async Task Execute_CheckMissingRig_AddsWellboresWithNoRigs() + { + _witsmlClient.Setup(client => + client.GetFromStoreNullableAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(GetTestRigsForWell1())); + var checks = new List { + new MissingDataCheck + { + ObjectType = EntityType.Rig, + Properties = new List () + } + }; + var job = CreateJobTemplate(checks, false, true); + var (workerResult, _) = await _worker.Execute(job); + var report = job.JobInfo.Report; + List reportItems = (List)report.ReportItems; + Assert.Single(reportItems); + Assert.Contains(reportItems, item => item.WellboreUid == "well1Wellbore2Uid"); + Assert.True(workerResult.IsSuccess); + } + + private static WitsmlWells GetTestWells() + { + return new WitsmlWells + { + Wells = new List() + { + new WitsmlWell + { + Uid = "well1Uid", + Name = "Well1Name", + }, + new WitsmlWell + { + Uid = "well2Uid", + Name = "Well2Name", + Field = "field", + CommonData = new WitsmlCommonData {ServiceCategory = "serviceCategory"} + }, + new WitsmlWell + { + Uid = "well3Uid", + Name = "Well3Name", + Field = "field", + NumLicense = "license", + CommonData = new WitsmlCommonData {SourceName = "sourceName"} + } + } + }; + } + + private static WitsmlWellbores GetAllTestWellbores() + { + return new WitsmlWellbores + { + Wellbores = new List() + { + new WitsmlWellbore + { + Uid = "well1Wellbore1Uid", + Name = "Well1Wellbore1Name", + UidWell = "well1Uid", + NameWell = "well1Name", + }, + new WitsmlWellbore + { + Uid = "well1Wellbore2Uid", + Name = "Well1Wellbore2Name", + UidWell = "well1Uid", + NameWell = "well1Name", + Number = "number", + CommonData = new WitsmlCommonData {ServiceCategory = "serviceCategory"} + }, + new WitsmlWellbore + { + Uid = "well2Wellbore1Uid", + Name = "Well2Wellbore1Name", + UidWell = "well2Uid", + NameWell = "well2Name", + Number = "number", + IsActive = "True", + CommonData = new WitsmlCommonData {SourceName = "sourceName"} + } + } + }; + } + + private static WitsmlWellbores GetTestWellboresForWell1() + { + return new WitsmlWellbores + { + Wellbores = new List() + { + new WitsmlWellbore + { + Uid = "well1Wellbore1Uid", + Name = "Well1Wellbore1Name", + UidWell = "well1Uid", + NameWell = "well1Name", + }, + new WitsmlWellbore + { + Uid = "well1Wellbore2Uid", + Name = "Well1Wellbore2Name", + UidWell = "well1Uid", + NameWell = "well1Name", + Number = "number", + CommonData = new WitsmlCommonData {ServiceCategory = "serviceCategory"} + }, + } + }; + } + + private static IWitsmlObjectList GetAllTestRigs() + { + return new WitsmlRigs + { + Rigs = new List() + { + new WitsmlRig + { + Uid = "Well1Wellbore1Rig1Uid", + Name = "Well1Wellbore1Rig1Name", + UidWellbore = "well1Wellbore1Uid", + NameWellbore = "Well1Wellbore1Name", + UidWell = "well1Uid", + NameWell = "well1Name", + }, + new WitsmlRig + { + Uid = "Well1Wellbore1Rig2Uid", + Name = "Well1Wellbore1Rig2Name", + UidWellbore = "well1Wellbore1Uid", + NameWellbore = "Well1Wellbore1Name", + UidWell = "well1Uid", + NameWell = "well1Name", + TypeRig = "typeRig", + CommonData = new WitsmlCommonData {SourceName = "sourceName"} + }, + new WitsmlRig + { + Uid = "Well2Wellbore1Rig1Uid", + Name = "Well2Wellbore1Rig1Name", + UidWellbore = "well2Wellbore1Uid", + NameWellbore = "Well2Wellbore1Name", + UidWell = "well2Uid", + NameWell = "well2Name", + TypeRig = "typeRig", + ClassRig = "classRig", + CommonData = new WitsmlCommonData {ServiceCategory = "serviceCategory"} + } + } + }; + } + + private static IWitsmlObjectList GetTestRigsForWell1() + { + return new WitsmlRigs + { + Rigs = new List() + { + new WitsmlRig + { + Uid = "Well1Wellbore1Rig1Uid", + Name = "Well1Wellbore1Rig1Name", + UidWellbore = "well1Wellbore1Uid", + NameWellbore = "Well1Wellbore1Name", + UidWell = "well1Uid", + NameWell = "well1Name", + }, + new WitsmlRig + { + Uid = "Well1Wellbore1Rig2Uid", + Name = "Well1Wellbore1Rig2Name", + UidWellbore = "well1Wellbore1Uid", + NameWellbore = "Well1Wellbore1Name", + UidWell = "well1Uid", + NameWell = "well1Name", + TypeRig = "typeRig", + CommonData = new WitsmlCommonData {SourceName = "sourceName"} + }, + } + }; + } + + private static MissingDataJob CreateJobTemplate(List checks, bool testWells, bool testWellbores) + { + var wells = new List + { + new WellReference + { + WellName = "well1Name", + WellUid = "well1Uid" + }, + new WellReference + { + WellName = "well2Name", + WellUid = "well2Uid" + }, + new WellReference + { + WellName = "well3Name", + WellUid = "well3Uid" + }, + }; + + var wellbores = new List + { + new WellboreReference + { + WellName = "well1Name", + WellUid = "well1Uid", + WellboreName = "well1Wellbore1Name", + WellboreUid = "well1Wellbore1Uid", + }, + new WellboreReference + { + WellName = "well1Name", + WellUid = "well1Uid", + WellboreName = "well1Wellbore2Name", + WellboreUid = "well1Wellbore2Uid", + }, + }; + + return new MissingDataJob + { + WellReferences = testWells ? wells : new List(), + WellboreReferences = testWellbores ? wellbores : new List(), + MissingDataChecks = checks, + JobInfo = new JobInfo(), + }; + } + } +} From 6cd9e675b56f4b08bfc8e6b8ec0794e2eac34bce Mon Sep 17 00:00:00 2001 From: Elias Bruvik Date: Tue, 5 Sep 2023 14:06:47 +0100 Subject: [PATCH 2/3] FIX-2025 url-encode propertyValue in getObjectsWithParamByName --- Src/WitsmlExplorer.Frontend/contexts/filter.tsx | 3 ++- Src/WitsmlExplorer.Frontend/services/objectService.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Src/WitsmlExplorer.Frontend/contexts/filter.tsx b/Src/WitsmlExplorer.Frontend/contexts/filter.tsx index f5a957453..8747cb063 100644 --- a/Src/WitsmlExplorer.Frontend/contexts/filter.tsx +++ b/Src/WitsmlExplorer.Frontend/contexts/filter.tsx @@ -174,6 +174,7 @@ const filterOnName = (wells: Well[], filter: Filter, filterOptions: FilterOption const isWellFilter = isWellFilterType(filterType); const property = isObjectPropertyFilter ? "searchProperty" : filterTypeToProperty[filterType]; const findEmpty = name === "*IS_EMPTY*" && !isWellFilter && !isObjectPropertyFilter; + const isSitecomSyntax = /^sel\(.*\)$/.test(name); let searchName = name; if (!searchName || searchName === "") { @@ -219,7 +220,7 @@ const filterOnName = (wells: Well[], filter: Filter, filterOptions: FilterOption } } } else if (isObjectFilter || isObjectPropertyFilter) { - const filteredObjects = searchResults.filter((object) => regex.test(object[property as keyof ObjectSearchResult])); + const filteredObjects = isSitecomSyntax ? searchResults : searchResults.filter((object) => regex.test(object[property as keyof ObjectSearchResult])); let filteredWellUids = filteredObjects.map((object) => object.wellUid); let filteredWellAndWellboreUids = filteredObjects.map((object) => [object.wellUid, object.wellboreUid].join(",")); diff --git a/Src/WitsmlExplorer.Frontend/services/objectService.tsx b/Src/WitsmlExplorer.Frontend/services/objectService.tsx index 47caa16c6..f75370162 100644 --- a/Src/WitsmlExplorer.Frontend/services/objectService.tsx +++ b/Src/WitsmlExplorer.Frontend/services/objectService.tsx @@ -109,7 +109,7 @@ export default class ObjectService { } public static async getObjectsWithParamByType(type: ObjectType, propertyName: string, propertyValue: string, abortSignal?: AbortSignal): Promise { - const response = await ApiClient.get(`/api/objects/${type}/${propertyName}/${propertyValue}`, abortSignal); + const response = await ApiClient.get(`/api/objects/${type}/${propertyName}/${encodeURIComponent(propertyValue)}`, abortSignal); if (response.ok) { return response.json(); } else { From 92850c99a0cf39a325b84e11f7c6b9c03342f927 Mon Sep 17 00:00:00 2001 From: Elias Bruvik Date: Tue, 5 Sep 2023 14:53:09 +0100 Subject: [PATCH 3/3] FIX-1931 review feedback --- Src/WitsmlExplorer.Api/Jobs/MissingDataJob.cs | 41 +++++++++++++++++-- .../Workers/MissingDataWorker.cs | 2 +- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Src/WitsmlExplorer.Api/Jobs/MissingDataJob.cs b/Src/WitsmlExplorer.Api/Jobs/MissingDataJob.cs index 3fca77390..06933aafd 100644 --- a/Src/WitsmlExplorer.Api/Jobs/MissingDataJob.cs +++ b/Src/WitsmlExplorer.Api/Jobs/MissingDataJob.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.IdentityModel.Tokens; + using WitsmlExplorer.Api.Jobs.Common; using WitsmlExplorer.Api.Models; @@ -14,22 +16,53 @@ public record MissingDataJob : Job public override string Description() { - return $"Missing Data Agent"; + return $"Missing Data Agent" + + $" - WellUids: {GetWellUid()};" + + $" WellboreUids: {string.Join(", ", WellboreReferences.Select(w => w.WellboreUid))}"; } public override string GetObjectName() { - return ""; + return null; } public override string GetWellboreName() { - return ""; + return WellboreReferences.IsNullOrEmpty() ? null : string.Join(", ", WellboreReferences.Select(w => w.WellboreName)); } public override string GetWellName() { - return ""; + var wellNames = new List(); + + if (!WellboreReferences.IsNullOrEmpty()) + { + wellNames.AddRange(WellboreReferences.Select(w => w.WellName).Distinct()); + } + + if (!WellReferences.IsNullOrEmpty()) + { + wellNames.AddRange(WellReferences.Select(w => w.WellName).Distinct()); + } + + return string.Join(", ", wellNames.Distinct()); + } + + private string GetWellUid() + { + var wellUids = new List(); + + if (!WellboreReferences.IsNullOrEmpty()) + { + wellUids.AddRange(WellboreReferences.Select(w => w.WellUid).Distinct()); + } + + if (!WellReferences.IsNullOrEmpty()) + { + wellUids.AddRange(WellReferences.Select(w => w.WellUid).Distinct()); + } + + return string.Join(", ", wellUids.Distinct()); } } } diff --git a/Src/WitsmlExplorer.Api/Workers/MissingDataWorker.cs b/Src/WitsmlExplorer.Api/Workers/MissingDataWorker.cs index 8fbd39e71..25a678e2a 100644 --- a/Src/WitsmlExplorer.Api/Workers/MissingDataWorker.cs +++ b/Src/WitsmlExplorer.Api/Workers/MissingDataWorker.cs @@ -345,7 +345,7 @@ public static bool IsPropertyEmpty(object property) if (propertyType.IsGenericType && propertyType.GetGenericTypeDefinition() == typeof(List<>)) return ((IList)property).Count == 0; if (propertyType.IsClass) - // Recursively check if all properties of a class is empty + // Recursively check if all properties of a class are empty return !propertyType.GetProperties().Select(p => p.GetValue(property)).Any(p => !IsPropertyEmpty(p)); return false; }