diff --git a/Shared/FiveSafesTes.Core/Models/ViewModels/SubmissionWizardV2.cs b/Shared/FiveSafesTes.Core/Models/ViewModels/SubmissionWizardV2.cs new file mode 100644 index 00000000..ab0bee93 --- /dev/null +++ b/Shared/FiveSafesTes.Core/Models/ViewModels/SubmissionWizardV2.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc.Rendering; +using System.ComponentModel.DataAnnotations; + +namespace FiveSafesTes.Core.Models.ViewModels +{ + public class SubmissionWizardV2 + { + public int ProjectId { get; set; } + + [Display(Name = "TES Name")] public string? TESName { get; set; } + + [Display(Name = "TES Description")] public string? TESDescription { get; set; } + + [Display(Name = "Submitting to Project: ")] + public string? ProjectName { get; set; } + + [Display(Name = "Select TREs")] public List? Tres { get; set; } + + public List? TreRadios { get; set; } + + public List? SelectTresOptions { get; set; } + + [Display(Name = "Select TREs or leave blank for all")] + public string? SelectedTres { get; set; } + + public List? Executors { get; set; } + + public string? DataInputType { get; set; } = ""; + + public string? DataInputPath { get; set; } = ""; + + public string? DataOutputType { get; set; } = "DIRECTORY"; + + public string? DataOutputPath { get; set; } = "/outputs"; + + public string? DataOutputUrl { get; set; } = "s3://"; + + public string? DataOutputDescription { get; set; } = "Results from the requested query execution"; + + public string? DataOutputName { get; set; } = "Query Results"; + + public string? RawInput { get; set; } + + public string? Query { get; set; } + + public virtual List? Submissions { get; set; } + + public IEnumerable? TreItemList { get; set; } + + public IEnumerable? UserItemList { get; set; } + + public string? JsonData { get; set; } = string.Empty; + } + + public class ExecutorsV2 + { + public string Image { get; set; } = string.Empty; + public List Command { get; set; } = new(); + public List ENV { get; set; } = new(); + } +} diff --git a/Submission/Submission.Web/Controllers/ProjectController.cs b/Submission/Submission.Web/Controllers/ProjectController.cs index 5f55cd3a..7bb9695f 100644 --- a/Submission/Submission.Web/Controllers/ProjectController.cs +++ b/Submission/Submission.Web/Controllers/ProjectController.cs @@ -121,7 +121,35 @@ public async Task GetProject(int id) return View(projectView); } + public IActionResult SubmissionProjectSqlV2(int id) + { + if (!ModelState.IsValid) // SonarQube security + { + var errors = ModelState + .Where(x => x.Value?.Errors.Count > 0) + .Select(x => new { Field = x.Key, Errors = x.Value?.Errors.Select(e => e.ErrorMessage) }) + .ToList(); + + return BadRequest($"Model validation failed: {string.Join(", ", errors.SelectMany(e => e.Errors))}"); + } + + var paramlist = new Dictionary(); + paramlist.Add("projectId", id.ToString()); + var project = _clientHelper.CallAPIWithoutModel( + "/api/Project/GetProjectUI/", paramlist).Result; + + ViewBag.UserCanDoSubmissions = IsUserOnProject(project); + + var projectView = new ProjectUserTre() + { + Id = project.Id, + Name = project.Name, + Submissions = project.Submissions.Where(x => x.HasParent == false).ToList(), + }; + + return View(projectView); + } public IActionResult SubmissionProjectSQL(int id) { if (!ModelState.IsValid) // SonarQube security diff --git a/Submission/Submission.Web/Controllers/SubmissionController.cs b/Submission/Submission.Web/Controllers/SubmissionController.cs index 9400493c..78dbf293 100644 --- a/Submission/Submission.Web/Controllers/SubmissionController.cs +++ b/Submission/Submission.Web/Controllers/SubmissionController.cs @@ -21,7 +21,6 @@ public class SubmissionController : Controller private readonly URLSettingsFrontEnd _URLSettingsFrontEnd; private readonly IKeyCloakService _IKeyCloakService; - public SubmissionController(IDareClientHelper client, IConfiguration configuration, URLSettingsFrontEnd URLSettingsFrontEnd, IKeyCloakService IKeyCloakService) { @@ -31,22 +30,24 @@ public SubmissionController(IDareClientHelper client, IConfiguration configurati _IKeyCloakService = IKeyCloakService; } - public IActionResult Instructions() { var url = _configuration["DareAPISettings:HelpAddress"]; return View(model: url); } - - [HttpPost] [Authorize] public async Task SubmissionWizard(SubmissionWizard model) { if (!ModelState.IsValid) // SonarQube security { - return View("/"); + var errors = ModelState + .Where(x => x.Value.Errors.Count > 0) + .Select(x => new { Field = x.Key, Errors = x.Value.Errors.Select(e => e.ErrorMessage) }) + .ToList(); + + return BadRequest($"Model validation failed: {string.Join(", ", errors.SelectMany(e => e.Errors))}"); } try @@ -58,7 +59,6 @@ public async Task SubmissionWizard(SubmissionWizard model) var project = await _clientHelper.CallAPIWithoutModel( "/api/Project/GetProject/", paramlist); - if (model.TreRadios == null) { var paramList = new Dictionary(); @@ -81,23 +81,20 @@ public async Task SubmissionWizard(SubmissionWizard model) else { var paramss = new Dictionary(); - paramss.Add("bucketName", project.SubmissionBucket); if (model.File != null) { - var uplodaResultTest = - await _clientHelper.CallAPIToSendFile("/api/Project/UploadToMinio", "file", - model.File, paramss); + await _clientHelper.CallAPIToSendFile("/api/Project/UploadToMinio", "file", + model.File, paramss); } var minioEndpoint = await _clientHelper.CallAPIWithoutModel("/api/Project/GetMinioEndPoint"); - //Don't add http:// minioEndpoint.Url already has it. And if not it should! - imageUrl = /*"http://" +*/ minioEndpoint.Url + "/browser/" + project.SubmissionBucket + "/" + - model.File.FileName; + // Don't add http:// — minioEndpoint.Url already has it. + imageUrl = minioEndpoint.Url + "/browser/" + project.SubmissionBucket + "/" + model.File.FileName; } - var TesTask = new TesTask() + var tesTask = new TesTask() { Name = model.TESName, Executors = new List() @@ -115,11 +112,8 @@ await _clientHelper.CallAPIToSendFile("/api/Project/UploadToMinio", " } }; - - var result = await _clientHelper.CallAPI("/v1/tasks", TesTask); - + var result = await _clientHelper.CallAPI("/v1/tasks", tesTask); return RedirectToAction("GetASubmission", new { id = result.Id }); - //return Ok(); } catch (Exception e) { @@ -150,13 +144,9 @@ public IActionResult GetAllSubmissions() ViewBag.minioendpoint = minio?.Url; ViewBag.URLBucket = _URLSettingsFrontEnd.MinioUrl; - List displaySubmissionsList = new List(); var res = _clientHelper.CallAPIWithoutModel>("/api/Submission/GetAllSubmissions/").Result .Where(x => x.Parent == null).ToList(); - res = res.Where(x => x.Parent == null).ToList(); - - return View(res); } @@ -165,23 +155,23 @@ public IActionResult GetASubmission(int id) { if (!ModelState.IsValid) // SonarQube security { - return View("/"); + return BadRequest("Invalid model state"); } var res = _clientHelper.CallAPIWithoutModel($"/api/Submission/GetASubmission/{id}").Result; - var minio = _clientHelper.CallAPIWithoutModel("/api/Project/GetMinioEndPoint").Result; ViewBag.minioendpoint = minio?.Url; ViewBag.URLBucket = _URLSettingsFrontEnd.MinioUrl; - var test = new SubmissionInfo() + + var model = new SubmissionInfo() { Submission = res, Stages = _clientHelper.CallAPIWithoutModel("/api/Submission/StageTypes/").Result }; - return View(test); - } + return View(model); + } [HttpPost] [Authorize] @@ -199,6 +189,7 @@ public async Task SubmitDemoTes(AddiSubmissionWizard model, string paramlist.Add("projectId", model.ProjectId.ToString()); var project = await _clientHelper.CallAPIWithoutModel( "/api/Project/GetProject/", paramlist) ?? throw new NullReferenceException("Project not found"); + var treSelection = model.TreRadios.Where(x => x.IsSelected).ToList(); if (treSelection.Count == 0) { @@ -220,6 +211,7 @@ public async Task SubmitDemoTes(AddiSubmissionWizard model, string { tesTask = JsonConvert.DeserializeObject(model.JsonData) ?? throw new NullReferenceException("Json data not returned"); + if (tesTask.Tags == null || tesTask.Tags.Count == 0) { tesTask.Tags = new Dictionary() @@ -232,10 +224,8 @@ public async Task SubmitDemoTes(AddiSubmissionWizard model, string } await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); - await _clientHelper.CallAPI("/v1/tasks", tesTask); - return Ok(); } catch (Exception e) @@ -245,7 +235,6 @@ public async Task SubmitDemoTes(AddiSubmissionWizard model, string } } - [HttpPost] [Authorize] public async Task AddiSubmissionWizard(AddiSubmissionWizard model, string Executors, string SQL) @@ -440,5 +429,214 @@ public async Task AddiSubmissionWizard(AddiSubmissionWizard model, return BadRequest(e.Message); } } + + [HttpPost] + [Authorize] + public async Task SubmissionSqlWizardAction( + SubmissionWizardV2 model, string? CustomExecutors, string Mode) + { + if (!ModelState.IsValid) + { + var errors = ModelState + .Where(x => x.Value.Errors.Count > 0) + .SelectMany(x => x.Value.Errors.Select(e => e.ErrorMessage)); + return BadRequest($"Model validation failed: {string.Join(", ", errors)}"); + } + + try + { + TesTask tes; + + // ── RAW mode ───────────────────────────────────────────────────────── + if (Mode == "Raw") + { + if (string.IsNullOrWhiteSpace(model.RawInput)) + return BadRequest("Raw JSON input is required in Raw mode."); + + tes = JsonConvert.DeserializeObject(model.RawInput) + ?? throw new InvalidOperationException("Failed to deserialise TES JSON."); + } + // ── SIMPLE or CUSTOM modes ──────────────────────────────────────────── + else if (Mode == "Simple" || Mode == "Custom") + { + // 1. Resolve project + var project = await _clientHelper.CallAPIWithoutModel( + "/api/Project/GetProject/", + new Dictionary { { "projectId", model.ProjectId.ToString() } }); + + // 2. Resolve TRE list (fall back to all TREs in the project) + var selectedTres = model.TreRadios? + .Where(x => x.IsSelected) + .Select(x => x.Name) + .ToList() ?? new List(); + + if (selectedTres.Count == 0) + { + return BadRequest("No Tres selected."); + } + + var listOfTre = string.Join("|", selectedTres); + + // 3. Common TES metadata + if (string.IsNullOrWhiteSpace(model.TESName)) + { + return BadRequest("No TES Name provided."); + } + var tesName = model.TESName; + var tesDescription = string.IsNullOrWhiteSpace(model.TESDescription) ? "Federated analysis task" : model.TESDescription; + + // 4. Build executors depending on mode + List executors; + List? tesInputs = null; + + if (Mode == "Simple") + { + if (string.IsNullOrWhiteSpace(model.Query)) + return BadRequest("A SQL query is required in Simple mode."); + + var query = NormaliseText(model.Query); + + executors = new List + { + new TesExecutor + { + Image = _URLSettingsFrontEnd.QueryImageSQL, + Command = new List + { + "--Output=/outputs/output.csv", + $"--Query={query}" + }, + Workdir = "/app", + Stdin = null, + Stdout = null, + Stderr = null, + Env = new Dictionary() + } + }; + } + else // Custom + { + if (string.IsNullOrWhiteSpace(CustomExecutors) || CustomExecutors == "null") + return BadRequest("Executors data is required in Custom mode."); + + var executorDtos = JsonConvert.DeserializeObject>(CustomExecutors) + ?? new List(); + + executors = executorDtos + .Where(ex => !string.IsNullOrEmpty(ex.Image)) + .Select(ex => new TesExecutor + { + Image = ex.Image, + Command = ex.Command? + .Select(NormaliseText) + .ToList() ?? new List(), + Workdir = "/app", + Stdin = null, + Stdout = null, + Stderr = null, + Env = ParseEnvList(ex.ENV) + }) + .ToList(); + + if (executors.Count == 0) + return BadRequest("At least one valid executor with an image is required in Custom mode."); + + // Build optional single input from model fields + if (!string.IsNullOrWhiteSpace(model.DataInputPath)) + { + _ = Enum.TryParse(model.DataInputType, out var fileType); + tesInputs = new List + { + new TesInput + { + Name = "Input", + Description = "Analysis input", + Url = "", + Path = model.DataInputPath, + Type = fileType == 0 ? TesFileType.FILEEnum : fileType, + Content = "" + } + }; + } + } + + // 5. Assemble the TES message (same shape for both Simple and Custom) + tes = new TesTask + { + State = 0, + Name = tesName, + Description = tesDescription, + Inputs = tesInputs, + Outputs = new List + { + new TesOutput + { + Name = "Output", + Description = "Analysis output", + Url = "s3://", + Path = "/outputs", + Type = TesFileType.DIRECTORYEnum + } + }, + Resources = null, + Executors = executors, + Volumes = null, + Tags = new Dictionary + { + { "Project", project?.Name ?? string.Empty }, + { "tres", listOfTre } + }, + Logs = null, + CreationTime = null + }; + } + else + { + return BadRequest($"Unknown submission mode: '{Mode}'. Expected Simple, Custom, or Raw."); + } + + // ── Submit to TES API ───────────────────────────────────────────────── + var authContext = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + await _IKeyCloakService.RefreshUserToken(authContext); + await _clientHelper.CallAPI("/v1/tasks", tes); + + return Ok(); + } + catch (Exception e) + { + Log.Error(e, "Exception in {Function}"); + return BadRequest(e.Message); + } + } + + // ── Private helpers ─────────────────────────────────────────────────────────── + + /// + /// Replaces literal escape sequences (\n, \r\n, \r) with real newline characters. + /// This handles text that arrives as JSON-encoded strings with visible backslash-n. + /// + private static string NormaliseText(string text) => + text.Replace("\\r\\n", "\n") + .Replace("\\r", "\n") + .Replace("\\n", "\n"); + + /// + /// Converts a list of "KEY=value" strings into a dictionary. + /// + private static Dictionary ParseEnvList(IEnumerable? envLines) + { + var result = new Dictionary(); + if (envLines == null) return result; + + foreach (var line in envLines) + { + if (string.IsNullOrWhiteSpace(line)) continue; + var parts = line.Split('=', 2); + if (parts.Length == 2) + result[parts[0].Trim()] = parts[1].Trim(); + } + + return result; + } } } diff --git a/Submission/Submission.Web/ViewComponents/SubmissionWizardV2ViewComponent.cs b/Submission/Submission.Web/ViewComponents/SubmissionWizardV2ViewComponent.cs new file mode 100644 index 00000000..5a1fce95 --- /dev/null +++ b/Submission/Submission.Web/ViewComponents/SubmissionWizardV2ViewComponent.cs @@ -0,0 +1,70 @@ +using FiveSafesTes.Core.Models; +using FiveSafesTes.Core.Models.ViewModels; +using FiveSafesTes.Core.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Submission.Web.Models; + +namespace Submission.Web.ViewComponents +{ + public class SubmissionWizardV2ViewComponent : ViewComponent + { + private readonly IDareClientHelper _clientHelper; + private readonly URLSettingsFrontEnd _URLSettingsFrontEnd; + + public SubmissionWizardV2ViewComponent(IDareClientHelper client, URLSettingsFrontEnd URLSettingsFrontEnd) + { + _clientHelper = client; + _URLSettingsFrontEnd = URLSettingsFrontEnd; + } + + public async Task InvokeAsync(int projectId) + { + var paramlist = new Dictionary(); + paramlist.Add("projectId", projectId.ToString()); + var project = _clientHelper.CallAPIWithoutModel( + "/api/Project/GetProject/", paramlist).Result; + var SelectTresOptions = project.Tres.ToList(); + List treInfoList = new List(); + foreach (var param in SelectTresOptions) + { + var isOnline = param.IsOnline(); + + var treInfo = new TreInfo() + { + Name = param.Name, + IsSelected = false, + IsOnline = isOnline + }; + treInfoList.Add(treInfo); + } + var userItems2 = project.Users; + var treItems2 = project.Tres; + + var userItems = userItems2 + .Select(p => new SelectListItem { Value = p.Id.ToString(), Text = p.FullName != "" ? p.FullName : p.Name }) + .ToList(); + var treItems = treItems2 + .Select(p => new SelectListItem { Value = p.Id.ToString(), Text = p.Name }) + .ToList(); + + var model = new SubmissionWizardV2() + { + ProjectId = project.Id, + ProjectName = project.Name, + SelectTresOptions = project.Tres.Select(x => x.Name).ToList(), + TreRadios = treInfoList, + Submissions = project.Submissions.ToList(), + UserItemList = userItems, + TreItemList = treItems + }; + + // Pass URL settings to the view + ViewBag.QueryImageSQL = _URLSettingsFrontEnd.QueryImageSQL; + ViewBag.QueryImageGraphQL = _URLSettingsFrontEnd.QueryImageGraphQL; + ViewBag.MinioUrl = _URLSettingsFrontEnd.MinioUrl; + + return View(model); + } + } +} diff --git a/Submission/Submission.Web/Views/Project/GetProject.cshtml b/Submission/Submission.Web/Views/Project/GetProject.cshtml index b029d2ef..0f2299ba 100644 --- a/Submission/Submission.Web/Views/Project/GetProject.cshtml +++ b/Submission/Submission.Web/Views/Project/GetProject.cshtml @@ -5,7 +5,6 @@ string pageName = Model.Name; ViewData["Title"] = @pageName; - int pId = Model.Id; } @inject IConfiguration Configuration @@ -125,19 +124,25 @@ Create Submission -