diff --git a/backend/api.test/EventHandlers/TestMissionEventHandler.cs b/backend/api.test/EventHandlers/TestMissionEventHandler.cs index 3fb012a76..cc2352563 100644 --- a/backend/api.test/EventHandlers/TestMissionEventHandler.cs +++ b/backend/api.test/EventHandlers/TestMissionEventHandler.cs @@ -52,6 +52,7 @@ public TestMissionEventHandler(DatabaseFixture fixture) var returnToHomeServiceLogger = new Mock>().Object; var missionDefinitionServiceLogger = new Mock>().Object; var lastMissionRunServiceLogger = new Mock>().Object; + var sourceServiceLogger = new Mock>().Object; var configuration = WebApplication.CreateBuilder().Configuration; @@ -66,8 +67,8 @@ public TestMissionEventHandler(DatabaseFixture fixture) var echoServiceMock = new MockEchoService(); var stidServiceMock = new MockStidService(context); - var customMissionServiceMock = new MockCustomMissionService(); - var missionDefinitionService = new MissionDefinitionService(context, echoServiceMock, customMissionServiceMock, signalRService, accessRoleService, missionDefinitionServiceLogger, _missionRunService); + var sourceService = new SourceService(context, echoServiceMock, sourceServiceLogger); + var missionDefinitionService = new MissionDefinitionService(context, echoServiceMock, sourceService, signalRService, accessRoleService, missionDefinitionServiceLogger, _missionRunService); var robotModelService = new RobotModelService(context); var taskDurationServiceMock = new MockTaskDurationService(); var isarServiceMock = new MockIsarService(); diff --git a/backend/api.test/Mocks/CustomMissionServiceMock.cs b/backend/api.test/Mocks/CustomMissionServiceMock.cs deleted file mode 100644 index d76ead6fa..000000000 --- a/backend/api.test/Mocks/CustomMissionServiceMock.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Api.Controllers.Models; -using Api.Database.Models; -namespace Api.Services -{ - public class MockCustomMissionService : ICustomMissionService - { - private static readonly Dictionary> mockBlobStore = []; - - public Task UploadSource(List tasks) - { - string hash = CalculateHashFromTasks(tasks); - mockBlobStore.Add(hash, tasks); - return Task.FromResult(hash); - } - - public async Task?> GetMissionTasksFromSourceId(string id) - { - if (mockBlobStore.TryGetValue(id, out var value)) - { - var content = value; - foreach (var task in content) - { - task.Id = Guid.NewGuid().ToString(); // This is needed as tasks are owned by mission runs - } - return content; - } - await Task.CompletedTask; - return null; - } - - public string CalculateHashFromTasks(IList tasks) - { - var genericTasks = new List(); - foreach (var task in tasks) - { - var taskCopy = new MissionTask(task) - { - Id = "", - IsarTaskId = "" - }; - taskCopy.Inspections = taskCopy.Inspections.Select(i => new Inspection(i, useEmptyIDs: true)).ToList(); - genericTasks.Add(taskCopy); - } - - string json = JsonSerializer.Serialize(genericTasks); - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(json)); - return BitConverter.ToString(hash).Replace("-", "", StringComparison.CurrentCulture).ToUpperInvariant(); - } - -#pragma warning disable IDE0060, CA1822 // Remove unused parameter - public async Task QueueCustomMissionRun(CustomMissionQuery customMissionQuery, MissionDefinition customMissionDefinition, Robot robot, IList missionTasks) - { - await Task.CompletedTask; - return new MissionRun(); - } -#pragma warning restore IDE0060, CA1822 // Remove unused parameter - } -} diff --git a/backend/api.test/TestWebApplicationFactory.cs b/backend/api.test/TestWebApplicationFactory.cs index ed4d805b5..b5eb2730d 100644 --- a/backend/api.test/TestWebApplicationFactory.cs +++ b/backend/api.test/TestWebApplicationFactory.cs @@ -36,7 +36,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddAuthorizationBuilder().AddFallbackPolicy( TestAuthHandler.AuthenticationScheme, policy => policy.RequireAuthenticatedUser() ); diff --git a/backend/api/Controllers/MissionSchedulingController.cs b/backend/api/Controllers/MissionSchedulingController.cs index 80add502b..0f3dd04ab 100644 --- a/backend/api/Controllers/MissionSchedulingController.cs +++ b/backend/api/Controllers/MissionSchedulingController.cs @@ -14,7 +14,6 @@ namespace Api.Controllers [Route("missions")] public class MissionSchedulingController( IMissionDefinitionService missionDefinitionService, - ICustomMissionSchedulingService customMissionSchedulingService, IMissionRunService missionRunService, IInstallationService installationService, IEchoService echoService, @@ -23,7 +22,8 @@ public class MissionSchedulingController( IStidService stidService, ILocalizationService localizationService, IRobotService robotService, - ISourceService sourceService + ISourceService sourceService, + IAreaService areaService ) : ControllerBase { @@ -371,7 +371,41 @@ [FromBody] CustomMissionQuery customMissionQuery var missionTasks = customMissionQuery.Tasks.Select(task => new MissionTask(task)).ToList(); MissionDefinition? customMissionDefinition; - try { customMissionDefinition = await customMissionSchedulingService.FindExistingOrCreateCustomMissionDefinition(customMissionQuery, missionTasks); } + try + { + Area? area = null; + if (customMissionQuery.AreaName != null) { area = await areaService.ReadByInstallationAndName(customMissionQuery.InstallationCode, customMissionQuery.AreaName); } + + if (area == null) + { + throw new AreaNotFoundException($"No area with name {customMissionQuery.AreaName} in installation {customMissionQuery.InstallationCode} was found"); + } + + var source = await sourceService.CheckForExistingCustomSource(missionTasks); + + MissionDefinition? existingMissionDefinition = null; + if (source == null) + { + source = await sourceService.CreateSourceIfDoesNotExist(missionTasks); + } + else + { + var missionDefinitions = await missionDefinitionService.ReadBySourceId(source.SourceId); + if (missionDefinitions.Count > 0) { existingMissionDefinition = missionDefinitions.First(); } + } + + customMissionDefinition = existingMissionDefinition ?? new MissionDefinition + { + Id = Guid.NewGuid().ToString(), + Source = source, + Name = customMissionQuery.Name, + InspectionFrequency = customMissionQuery.InspectionFrequency, + InstallationCode = customMissionQuery.InstallationCode, + Area = area + }; + + if (existingMissionDefinition == null) { await missionDefinitionService.Create(customMissionDefinition); } + } catch (SourceException e) { return StatusCode(StatusCodes.Status502BadGateway, e.Message); } catch (AreaNotFoundException) { return NotFound($"No area with name {customMissionQuery.AreaName} in installation {customMissionQuery.InstallationCode} was found"); } @@ -380,7 +414,30 @@ [FromBody] CustomMissionQuery customMissionQuery catch (RobotNotInSameInstallationAsMissionException e) { return Conflict(e.Message); } MissionRun? newMissionRun; - try { newMissionRun = await customMissionSchedulingService.QueueCustomMissionRun(customMissionQuery, customMissionDefinition.Id, robot.Id, missionTasks); } + try + { + var scheduledMission = new MissionRun + { + Name = customMissionQuery.Name, + Description = customMissionQuery.Description, + MissionId = customMissionDefinition.Id, + Comment = customMissionQuery.Comment, + Robot = robot, + Status = MissionStatus.Pending, + MissionRunType = MissionRunType.Normal, + DesiredStartTime = customMissionQuery.DesiredStartTime ?? DateTime.UtcNow, + Tasks = missionTasks, + InstallationCode = customMissionQuery.InstallationCode, + Area = customMissionDefinition.Area, + Map = new MapMetadata() + }; + + await mapService.AssignMapToMission(scheduledMission); + + if (scheduledMission.Tasks.Any()) { scheduledMission.CalculateEstimatedDuration(); } + + newMissionRun = await missionRunService.Create(scheduledMission); + } catch (Exception e) when (e is UnsupportedRobotCapabilityException) { return BadRequest(e.Message); } catch (Exception e) when (e is MissionNotFoundException) { return NotFound(e.Message); } catch (Exception e) when (e is RobotNotFoundException) { return NotFound(e.Message); } diff --git a/backend/api/Program.cs b/backend/api/Program.cs index ad5e67594..28a59214f 100644 --- a/backend/api/Program.cs +++ b/backend/api/Program.cs @@ -70,7 +70,6 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -106,7 +105,6 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddTransient(); diff --git a/backend/api/Services/ActionServices/CustomMissionSchedulingService.cs b/backend/api/Services/ActionServices/CustomMissionSchedulingService.cs deleted file mode 100644 index c269f1c24..000000000 --- a/backend/api/Services/ActionServices/CustomMissionSchedulingService.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Api.Controllers.Models; -using Api.Database.Models; -using Api.Utilities; -namespace Api.Services.ActionServices -{ - public interface ICustomMissionSchedulingService - { - public Task FindExistingOrCreateCustomMissionDefinition(CustomMissionQuery customMissionQuery, List missionTasks); - - public Task QueueCustomMissionRun(CustomMissionQuery customMissionQuery, string missionDefinitionId, string robotId, IList missionTasks); - } - - public class CustomMissionSchedulingService( - ILogger logger, - ICustomMissionService customMissionService, - IAreaService areaService, - IRobotService robotService, - ISourceService sourceService, - IMissionDefinitionService missionDefinitionService, - IMissionRunService missionRunService, - IMapService mapService - ) : ICustomMissionSchedulingService - { - public async Task FindExistingOrCreateCustomMissionDefinition(CustomMissionQuery customMissionQuery, List missionTasks) - { - Area? area = null; - if (customMissionQuery.AreaName != null) { area = await areaService.ReadByInstallationAndName(customMissionQuery.InstallationCode, customMissionQuery.AreaName); } - - if (area == null) - { - throw new AreaNotFoundException($"No area with name {customMissionQuery.AreaName} in installation {customMissionQuery.InstallationCode} was found"); - } - - var source = await sourceService.CheckForExistingCustomSource(missionTasks); - - MissionDefinition? existingMissionDefinition = null; - if (source == null) - { - try - { - source = await customMissionService.CreateSourceIfOneDoesNotExist(missionTasks); - } - catch (Exception e) - { - { - string errorMessage = $"Unable to upload source for mission {customMissionQuery.Name}"; - logger.LogError(e, "{Message}", errorMessage); - throw new SourceException(errorMessage); - } - } - } - else - { - var missionDefinitions = await missionDefinitionService.ReadBySourceId(source.SourceId); - if (missionDefinitions.Count > 0) { existingMissionDefinition = missionDefinitions.First(); } - } - - var customMissionDefinition = existingMissionDefinition ?? new MissionDefinition - { - Id = Guid.NewGuid().ToString(), - Source = source, - Name = customMissionQuery.Name, - InspectionFrequency = customMissionQuery.InspectionFrequency, - InstallationCode = customMissionQuery.InstallationCode, - Area = area - }; - - if (existingMissionDefinition == null) { await missionDefinitionService.Create(customMissionDefinition); } - - return customMissionDefinition; - } - - public async Task QueueCustomMissionRun(CustomMissionQuery customMissionQuery, string missionDefinitionId, string robotId, IList missionTasks) - { - var robot = await robotService.ReadById(robotId); - if (robot is null) - { - string errorMessage = $"The robot with ID {robotId} could not be found"; - logger.LogError("{Message}", errorMessage); - throw new RobotNotFoundException(errorMessage); - } - - var missionDefinition = await missionDefinitionService.ReadById(missionDefinitionId); - if (missionDefinition is null) - { - string errorMessage = $"The mission definition with ID {missionDefinition} could not be found"; - logger.LogError("{Message}", errorMessage); - throw new MissionNotFoundException(errorMessage); - } - - var scheduledMission = new MissionRun - { - Name = customMissionQuery.Name, - Description = customMissionQuery.Description, - MissionId = missionDefinition.Id, - Comment = customMissionQuery.Comment, - Robot = robot, - Status = MissionStatus.Pending, - MissionRunType = MissionRunType.Normal, - DesiredStartTime = customMissionQuery.DesiredStartTime ?? DateTime.UtcNow, - Tasks = missionTasks, - InstallationCode = customMissionQuery.InstallationCode, - Area = missionDefinition.Area, - Map = new MapMetadata() - }; - - await mapService.AssignMapToMission(scheduledMission); - - if (scheduledMission.Tasks.Any()) { scheduledMission.CalculateEstimatedDuration(); } - - return await missionRunService.Create(scheduledMission); - } - } -} diff --git a/backend/api/Services/CustomMissionService.cs b/backend/api/Services/CustomMissionService.cs deleted file mode 100644 index 4c77e3e7f..000000000 --- a/backend/api/Services/CustomMissionService.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using System.Text.Json; -using Api.Database.Models; -namespace Api.Services -{ - - public interface ICustomMissionService - { - Task CreateSourceIfOneDoesNotExist(List tasks); - - Task?> GetMissionTasksFromSourceId(string id); - - string CalculateHashFromTasks(IList tasks); - } - - public class CustomMissionService(ILogger logger, ISourceService sourceService) : ICustomMissionService - { - public async Task CreateSourceIfOneDoesNotExist(List tasks) - { - string json = JsonSerializer.Serialize(tasks); - string hash = CalculateHashFromTasks(tasks); - - var existingSource = await sourceService.ReadById(hash); - - if (existingSource != null) return existingSource; - - var newSource = await sourceService.Create( - new Source - { - SourceId = hash, - Type = MissionSourceType.Custom, - CustomMissionTasks = json - } - ); - - return newSource; - } - - public async Task?> GetMissionTasksFromSourceId(string id) - { - var existingSource = await sourceService.ReadById(id); - if (existingSource == null || existingSource.CustomMissionTasks == null) return null; - - try - { - var content = JsonSerializer.Deserialize>(existingSource.CustomMissionTasks); - - if (content == null) return null; - - foreach (var task in content) - { - task.Id = Guid.NewGuid().ToString(); // This is needed as tasks are owned by mission runs - task.IsarTaskId = Guid.NewGuid().ToString(); // This is needed to update the tasks for the correct mission run - } - return content; - } - catch (Exception e) - { - logger.LogWarning("Unable to deserialize custom mission tasks with ID {Id}. {ErrorMessage}", id, e); - return null; - } - } - - public string CalculateHashFromTasks(IList tasks) - { - var genericTasks = new List(); - foreach (var task in tasks) - { - var taskCopy = new MissionTask(task) - { - Id = "", - IsarTaskId = "" - }; - taskCopy.Inspections = taskCopy.Inspections.Select(i => new Inspection(i, useEmptyIDs: true)).ToList(); - genericTasks.Add(taskCopy); - } - - string json = JsonSerializer.Serialize(genericTasks); - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(json)); - return BitConverter.ToString(hash).Replace("-", "", StringComparison.CurrentCulture).ToUpperInvariant(); - } - } -} diff --git a/backend/api/Services/MissionDefinitionService.cs b/backend/api/Services/MissionDefinitionService.cs index 822a299eb..a20231cba 100644 --- a/backend/api/Services/MissionDefinitionService.cs +++ b/backend/api/Services/MissionDefinitionService.cs @@ -43,7 +43,7 @@ public interface IMissionDefinitionService )] public class MissionDefinitionService(FlotillaDbContext context, IEchoService echoService, - ICustomMissionService customMissionService, + ISourceService sourceService, ISignalRService signalRService, IAccessRoleService accessRoleService, ILogger logger, @@ -161,15 +161,10 @@ public async Task Update(MissionDefinition missionDefinition) echoService.GetMissionById( int.Parse(source.SourceId, new CultureInfo("en-US")) ).Result.Tags - .Select( - t => - { - return new MissionTask(t); - } - ) + .Select(t => new MissionTask(t)) .ToList(), MissionSourceType.Custom => - await customMissionService.GetMissionTasksFromSourceId(source.SourceId), + await sourceService.GetMissionTasksFromSourceId(source.SourceId), _ => throw new MissionSourceTypeException($"Mission type {source.Type} is not accounted for") }; diff --git a/backend/api/Services/SourceService.cs b/backend/api/Services/SourceService.cs index 4e41c0cbc..ca5194ea5 100644 --- a/backend/api/Services/SourceService.cs +++ b/backend/api/Services/SourceService.cs @@ -1,6 +1,9 @@ using System.Globalization; using System.Linq.Dynamic.Core; using System.Linq.Expressions; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using Api.Controllers.Models; using Api.Database.Context; using Api.Database.Models; @@ -25,6 +28,12 @@ public interface ISourceService public abstract Task CheckForExistingCustomSource(IList tasks); + public abstract Task?> GetMissionTasksFromSourceId(string id); + + public abstract Task CreateSourceIfDoesNotExist(List tasks); + + public abstract string CalculateHashFromTasks(IList tasks); + public abstract Task Update(Source source); public abstract Task Delete(string id); @@ -38,8 +47,8 @@ public interface ISourceService )] public class SourceService( FlotillaDbContext context, - ICustomMissionService customMissionService, - IEchoService echoService) : ISourceService + IEchoService echoService, + ILogger logger) : ISourceService { public async Task Create(Source source) { @@ -111,7 +120,7 @@ private DbSet GetSources() switch (source.Type) { case MissionSourceType.Custom: - var tasks = await customMissionService.GetMissionTasksFromSourceId(source.SourceId); + var tasks = await GetMissionTasksFromSourceId(source.SourceId); if (tasks == null) return null; return new SourceResponse(source, tasks); case MissionSourceType.Echo: @@ -128,10 +137,75 @@ private DbSet GetSources() public async Task CheckForExistingCustomSource(IList tasks) { - string hash = customMissionService.CalculateHashFromTasks(tasks); + string hash = CalculateHashFromTasks(tasks); return await ReadBySourceId(hash); } + public async Task?> GetMissionTasksFromSourceId(string id) + { + var existingSource = await ReadBySourceId(id); + if (existingSource == null || existingSource.CustomMissionTasks == null) return null; + + try + { + var content = JsonSerializer.Deserialize>(existingSource.CustomMissionTasks); + + if (content == null) return null; + + foreach (var task in content) + { + task.Id = Guid.NewGuid().ToString(); // This is needed as tasks are owned by mission runs + task.IsarTaskId = Guid.NewGuid().ToString(); // This is needed to update the tasks for the correct mission run + } + return content; + } + catch (Exception e) + { + logger.LogWarning("Unable to deserialize custom mission tasks with ID {Id}. {ErrorMessage}", id, e); + return null; + } + } + + public async Task CreateSourceIfDoesNotExist(List tasks) + { + string json = JsonSerializer.Serialize(tasks); + string hash = CalculateHashFromTasks(tasks); + + var existingSource = await ReadById(hash); + + if (existingSource != null) return existingSource; + + var newSource = await Create( + new Source + { + SourceId = hash, + Type = MissionSourceType.Custom, + CustomMissionTasks = json + } + ); + + return newSource; + } + + public string CalculateHashFromTasks(IList tasks) + { + var genericTasks = new List(); + foreach (var task in tasks) + { + var taskCopy = new MissionTask(task) + { + Id = "", + IsarTaskId = "" + }; + taskCopy.Inspections = taskCopy.Inspections.Select(i => new Inspection(i, useEmptyIDs: true)).ToList(); + genericTasks.Add(taskCopy); + } + + string json = JsonSerializer.Serialize(genericTasks); + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(json)); + return BitConverter.ToString(hash).Replace("-", "", StringComparison.CurrentCulture).ToUpperInvariant(); + } + public async Task Update(Source source) { var entry = context.Update(source);