From 7a4fe15797db2bcad582bcb4fa0c7e424f47348f Mon Sep 17 00:00:00 2001 From: Habfast <paul@ahead-solutions.ch> Date: Thu, 19 May 2016 15:19:43 +0200 Subject: [PATCH] added experiment api --- pom.xml | 8 +- .../hbp/mip/controllers/ExperimentApi.java | 392 ++++++++++++++++++ .../java/org/hbp/mip/model/Experiment.java | 197 +++++++++ src/main/java/org/hbp/mip/model/Model.java | 3 + src/main/java/org/hbp/mip/model/User.java | 19 + src/main/java/org/hbp/mip/model/Variable.java | 2 + src/main/resources/hibernate.cfg.xml | 1 + src/test/db | 2 +- 8 files changed, 621 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/hbp/mip/controllers/ExperimentApi.java create mode 100644 src/main/java/org/hbp/mip/model/Experiment.java diff --git a/pom.xml b/pom.xml index f1a051c63..21835f3d3 100644 --- a/pom.xml +++ b/pom.xml @@ -57,7 +57,9 @@ <connection.password>test</connection.password> <hibernate.dialect>org.hibernate.dialect.PostgreSQL82Dialect</hibernate.dialect> <schema.deploy>false</schema.deploy> - <frontend.redirect>http://frontend/#/home</frontend.redirect> + <frontend.redirect>http://frontend/home</frontend.redirect> + <workflow.experimentUrl>http://as-dev.cloudapp.net:8087/experiment</workflow.experimentUrl> + <workflow.listMethodsUrl>http://as-dev.cloudapp.net:8087/list-methods</workflow.listMethodsUrl> </properties> </profile> <profile> @@ -71,7 +73,9 @@ <connection.password>test</connection.password> <hibernate.dialect>org.hibernate.dialect.PostgreSQL82Dialect</hibernate.dialect> <schema.deploy>false</schema.deploy> - <frontend.redirect>https://hbp-dev.ahead-solutions.ch/#/home</frontend.redirect> + <frontend.redirect>https://hbp-dev.ahead-solutions.ch/home</frontend.redirect> + <workflow.experimentUrl>http://as-dev.cloudapp.net:8087/experiment</workflow.experimentUrl> + <workflow.listMethodsUrl>http://as-dev.cloudapp.net:8087/list-methods</workflow.listMethodsUrl> </properties> </profile> </profiles> diff --git a/src/main/java/org/hbp/mip/controllers/ExperimentApi.java b/src/main/java/org/hbp/mip/controllers/ExperimentApi.java new file mode 100644 index 000000000..e8a52f99e --- /dev/null +++ b/src/main/java/org/hbp/mip/controllers/ExperimentApi.java @@ -0,0 +1,392 @@ +package org.hbp.mip.controllers; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import io.swagger.annotations.*; +import org.hbp.mip.MIPApplication; +import org.hbp.mip.model.Experiment; +import org.hbp.mip.model.Model; +import org.hbp.mip.model.User; +import org.hbp.mip.utils.HibernateUtil; +import org.hibernate.Query; +import org.hibernate.Session; +import org.hibernate.Transaction; +import org.hibernate.exception.DataException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.ProtocolException; +import java.net.URL; +import java.util.*; + +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +/** + * Created by habfast on 21/04/16. + */ +@RestController +@RequestMapping(value = "/experiments", produces = {APPLICATION_JSON_VALUE}) +@Api(value = "/experiments", description = "the experiments API") +@javax.annotation.Generated(value = "class io.swagger.codegen.languages.SpringMVCServerCodegen", date = "2016-01-07T07:38:20.227Z") +public class ExperimentApi { + + private static final Gson gson = new GsonBuilder() + .serializeNulls() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") + .excludeFieldsWithoutExposeAnnotation() + .create(); + + @Value("#{'${workflow.experimentUrl:http://as-dev.cloudapp.net:8087/experiment}'}") + private String experimentUrl; + + + @Value("#{'${workflow.listMethodsUrl:http://as-dev.cloudapp.net:8087/list-methods}'}") + private String listMethodsUrl; + + @Autowired + MIPApplication mipApplication; + + private void sendPost(Experiment experiment) throws MalformedURLException { + URL obj = new URL(experimentUrl); + + // this runs in the background. For future optimization: use a thread pool + new Thread() { + public void run() { + try { + HttpURLConnection con = (HttpURLConnection) obj.openConnection(); + + String query = experiment.computeQuery(); + System.out.println("Running experiment: " + query); + + // create query + try { + con.setRequestMethod("POST"); + } catch (ProtocolException pe) {} // ignore; won't happen + con.addRequestProperty("Content-Type", "application/json"); + con.setRequestProperty("Content-Length", Integer.toString(query.length())); + con.setFollowRedirects(true); + con.setReadTimeout(3600000); // 1 hour: 60*60*1000 ms + + // write body of query + con.setDoOutput(true); + DataOutputStream wr = new DataOutputStream(con.getOutputStream()); + wr.write(query.getBytes("UTF8")); + wr.flush(); + wr.close(); + + // get response + InputStream stream = con.getResponseCode() < 400 ? con.getInputStream() : con.getErrorStream(); + BufferedReader in = new BufferedReader(new InputStreamReader(stream)); + String inputLine; + StringBuilder response = new StringBuilder(); + while ((inputLine = in.readLine()) != null) { + response.append(inputLine + '\n'); + } + in.close(); + + // write to experiment + experiment.setResult(response.toString().replace("\0", "")); + experiment.setHasError(con.getResponseCode() >= 400); + experiment.setHasServerError(con.getResponseCode() >= 500); + + } catch (IOException ioe) { + // write error to + experiment.setHasError(true); + experiment.setHasServerError(true); + experiment.setResult(ioe.getMessage()); + } + + experiment.setFinished(new Date()); + + // finally + try { + Session session = HibernateUtil.getSessionFactory().openSession(); + Transaction transaction = session.beginTransaction(); + session.update(experiment); + transaction.commit(); + session.close(); + } catch (DataException e) { + throw e; + } + + } + }.start(); + } + + @ApiOperation(value = "Send a request to the workflow to run an experiment", response = Experiment.class) + @ApiResponses(value = { @ApiResponse(code = 200, message = "Success") }) + @RequestMapping(method = RequestMethod.POST) + public ResponseEntity<String> runExperiment(@RequestBody String incomingQueryString) { + JsonObject incomingQuery = gson.fromJson(incomingQueryString, JsonObject.class); + + Experiment experiment = new Experiment(); + experiment.setUuid(UUID.randomUUID()); + User user = mipApplication.getUser(); + + Session session = HibernateUtil.getSessionFactory().getCurrentSession(); + Transaction transaction = session.beginTransaction(); + + try { + + experiment.setAlgorithms(incomingQuery.get("algorithms").toString()); + experiment.setValidations(incomingQuery.get("validations").toString()); + experiment.setName(incomingQuery.get("name").getAsString()); + experiment.setCreatedBy(user); + + Query hibernateQuery = session.createQuery("from Model as model where model.slug = :slug"); + hibernateQuery.setParameter("slug", incomingQuery.get("model").getAsString()); + experiment.setModel((Model)hibernateQuery.uniqueResult()); + + session.save(experiment); + transaction.commit(); + + } catch (Exception e) { + transaction.rollback(); + e.printStackTrace(); + // 400 here probably + return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + } + + try { + sendPost(experiment); + } catch (MalformedURLException mue) {} // ignore + + return new ResponseEntity<>(gson.toJson(experiment), HttpStatus.OK); + } + + @ApiOperation(value = "get an experiment", response = Experiment.class) + @ApiResponses(value = { @ApiResponse(code = 200, message = "Success") }) + @RequestMapping(value = "/{uuid}", method = RequestMethod.GET) + public ResponseEntity<String> getExperiment(@ApiParam(value = "uuid", required = true) @PathVariable("uuid") String uuid) { + + Experiment experiment; + UUID experimentUuid; + try { + experimentUuid = UUID.fromString(uuid); + } catch (IllegalArgumentException iae) { + return ResponseEntity.badRequest().body("Invalid Experiment UUID"); + } + + Session session = HibernateUtil.getSessionFactory().getCurrentSession(); + + try { + session.beginTransaction(); + + Query hibernateQuery = session.createQuery("from Experiment as experiment where experiment.uuid = :uuid"); + hibernateQuery.setParameter("uuid", experimentUuid); + experiment = (Experiment) hibernateQuery.uniqueResult(); + session.getTransaction().commit(); + } catch (Exception e) { + // 404 here probably + session.getTransaction().rollback(); + throw e; + } + + if (experiment == null) { + return new ResponseEntity<>("Not found", HttpStatus.NOT_FOUND); + } + return new ResponseEntity<>(gson.toJson(experiment), HttpStatus.OK); + } + + @ApiOperation(value = "get an experiment", response = Experiment.class) + @ApiResponses(value = { @ApiResponse(code = 200, message = "Success") }) + @RequestMapping(value = "/{uuid}/markAsViewed", method = RequestMethod.GET) + public ResponseEntity<String> markExperimentAsViewed(@ApiParam(value = "uuid", required = true) @PathVariable("uuid") String uuid) { + + Experiment experiment; + UUID experimentUuid; + User user = mipApplication.getUser(); + try { + experimentUuid = UUID.fromString(uuid); + } catch (IllegalArgumentException iae) { + return ResponseEntity.badRequest().body("Invalid Experiment UUID"); + } + + Session session = HibernateUtil.getSessionFactory().getCurrentSession(); + Transaction transaction = null; + try { + transaction = session.beginTransaction(); + + Query hibernateQuery = session.createQuery("from Experiment as experiment where experiment.uuid = :uuid"); + hibernateQuery.setParameter("uuid", experimentUuid); + experiment = (Experiment) hibernateQuery.uniqueResult(); + + if (!experiment.getCreatedBy().getUsername().equals(user.getUsername())) + return new ResponseEntity<>("You're not the owner of this experiment", HttpStatus.BAD_REQUEST); + + experiment.setResultsViewed(true); + session.update(experiment); + + transaction.commit(); + } catch (Exception e) { + // 404 here probably + transaction.rollback(); + throw e; + } + + if (experiment == null) { + return new ResponseEntity<>("Not found", HttpStatus.NOT_FOUND); + } + return new ResponseEntity<>(gson.toJson(experiment), HttpStatus.OK); + } + + public ResponseEntity<String> doMarkExperimentAsShared(String uuid, boolean shared) { + + Experiment experiment; + UUID experimentUuid; + User user = mipApplication.getUser(); + try { + experimentUuid = UUID.fromString(uuid); + } catch (IllegalArgumentException iae) { + return ResponseEntity.badRequest().body("Invalid Experiment UUID"); + } + + Session session = HibernateUtil.getSessionFactory().getCurrentSession(); + Transaction transaction = null; + try { + transaction = session.beginTransaction(); + + Query hibernateQuery = session.createQuery("from Experiment as experiment where experiment.uuid = :uuid"); + hibernateQuery.setParameter("uuid", experimentUuid); + experiment = (Experiment) hibernateQuery.uniqueResult(); + + if (!experiment.getCreatedBy().getUsername().equals(user.getUsername())) + return new ResponseEntity<>("You're not the owner of this experiment", HttpStatus.BAD_REQUEST); + + experiment.setShared(shared); + session.update(experiment); + + transaction.commit(); + } catch (Exception e) { + // 404 here probably + transaction.rollback(); + throw e; + } + + if (experiment == null) { + return new ResponseEntity<>("Not found", HttpStatus.NOT_FOUND); + } + return new ResponseEntity<>(gson.toJson(experiment), HttpStatus.OK); + } + + + @ApiOperation(value = "get an experiment", response = Experiment.class) + @ApiResponses(value = { @ApiResponse(code = 200, message = "Success") }) + @RequestMapping(value = "/{uuid}/markAsShared", method = RequestMethod.GET) + public ResponseEntity<String> markExperimentAsShared(@ApiParam(value = "uuid", required = true) @PathVariable("uuid") String uuid) { + return doMarkExperimentAsShared(uuid, true); + } + + @ApiOperation(value = "get an experiment", response = Experiment.class) + @ApiResponses(value = { @ApiResponse(code = 200, message = "Success") }) + @RequestMapping(value = "/{uuid}/markAsUnshared", method = RequestMethod.GET) + public ResponseEntity<String> markExperimentAsUnshared(@ApiParam(value = "uuid", required = true) @PathVariable("uuid") String uuid) { + return doMarkExperimentAsShared(uuid, false); + } + + public ResponseEntity<String> doListExperiments( + boolean mine, + int maxResultCount, + String modelSlug + ) { + List<Experiment> experiments = new LinkedList<>(); + User user = mipApplication.getUser(); + + Session session = HibernateUtil.getSessionFactory().getCurrentSession(); + + try { + session.beginTransaction(); + + Query hibernateQuery; + String baseQuery = "from Experiment as e WHERE "; + + baseQuery += mine ? "e.createdBy = :user" : "(e.createdBy = :user OR e.shared is true)"; + + if (modelSlug == null || modelSlug.equals("")) { + hibernateQuery = session.createQuery(baseQuery); + } else { + hibernateQuery = session.createQuery(baseQuery + " AND e.model.slug = :slug"); + hibernateQuery.setParameter("slug", modelSlug); + } + hibernateQuery.setParameter("user", user); + + if (maxResultCount > 0) + hibernateQuery.setMaxResults(maxResultCount); + + for (Object experiment: hibernateQuery.list()) { + if (experiment instanceof Experiment) { // should definitely be true + Experiment experiment1 = (Experiment) experiment; + // remove some fields because it is costly and not useful to send them over the network + experiment1.setResult(null); + experiment1.setAlgorithms(null); + experiment1.setValidations(null); + experiments.add(experiment1); + + } + + } + } catch (Exception e) { + // 404 here probably + throw e; + } finally { + session.getTransaction().rollback(); + } + + return new ResponseEntity<>(gson.toJson(experiments), HttpStatus.OK); + } + + @ApiOperation(value = "list experiments", response = Experiment.class, responseContainer = "List") + @ApiResponses(value = { @ApiResponse(code = 200, message = "Success") }) + @RequestMapping(value = "/mine", method = RequestMethod.GET, params = {"maxResultCount"}) + public ResponseEntity<String> listExperiments( + @ApiParam(value = "maxResultCount", required = false) @RequestParam int maxResultCount + ) { + return doListExperiments(true, maxResultCount, null); + } + + @ApiOperation(value = "list experiments", response = Experiment.class, responseContainer = "List") + @ApiResponses(value = { @ApiResponse(code = 200, message = "Success") }) + @RequestMapping(method = RequestMethod.GET, params = {"slug", "maxResultCount"}) + public ResponseEntity<String> listExperiments( + @ApiParam(value = "slug", required = false) @RequestParam("slug") String modelSlug, + @ApiParam(value = "maxResultCount", required = false) @RequestParam("maxResultCount") int maxResultCount + ) { + + if (maxResultCount <= 0 && (modelSlug == null || modelSlug.equals(""))) { + return new ResponseEntity<>("You must provide at least a slug or a limit of result", HttpStatus.BAD_REQUEST); + } + + return doListExperiments(false, maxResultCount, modelSlug); + } + + @ApiOperation(value = "List available methods and validations", response = String.class) + @ApiResponses(value = { @ApiResponse(code = 200, message = "Success") }) + @RequestMapping(path = "/methods", method = RequestMethod.GET) + public ResponseEntity<String> listAvailableMethodsAndValidations() throws Exception { + + URL obj = new URL(listMethodsUrl); + HttpURLConnection con = (HttpURLConnection) obj.openConnection(); + con.setRequestMethod("GET"); + + int respCode = con.getResponseCode(); + + BufferedReader in = new BufferedReader(new InputStreamReader(respCode == 200 ? con.getInputStream() : con.getErrorStream())); + + String inputLine; + StringBuilder response = new StringBuilder(); + + while ((inputLine = in.readLine()) != null) { + response.append(inputLine); + } + in.close(); + + return new ResponseEntity<>(response.toString(), HttpStatus.valueOf(respCode)); + } +} diff --git a/src/main/java/org/hbp/mip/model/Experiment.java b/src/main/java/org/hbp/mip/model/Experiment.java new file mode 100644 index 000000000..a569c9719 --- /dev/null +++ b/src/main/java/org/hbp/mip/model/Experiment.java @@ -0,0 +1,197 @@ +package org.hbp.mip.model; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.annotations.Expose; +import org.hibernate.annotations.*; + +import javax.persistence.*; +import javax.persistence.Entity; +import javax.persistence.Table; +import java.util.Date; +import java.util.UUID; + +/** + * Created by habfast on 21/04/16. + */ +@Entity +@Table(name = "`experiment`") +public class Experiment { + + private static final Gson gson = new GsonBuilder() + .serializeNulls() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") + .excludeFieldsWithoutExposeAnnotation() + .create(); + + @Id + @Column(columnDefinition = "uuid") + @org.hibernate.annotations.Type(type="pg-uuid") + @Expose + private UUID uuid; + + @Column(columnDefinition="TEXT") + @Expose + private String name; + + @Expose + @ManyToOne + @JoinColumn(name = "createdby_username") + private User createdBy; + + @ManyToOne + @Cascade(org.hibernate.annotations.CascadeType.SAVE_UPDATE) + @Expose + private Model model; + + @Column(columnDefinition="TEXT") + @Expose + private String algorithms; + + @Column(columnDefinition="TEXT") + @Expose + private String validations; + + @Column(columnDefinition="TEXT") + @Expose + private String result; + + @Expose + private Date created = new Date(); + + @Expose + private Date finished; + + @Expose + private boolean hasError = false; + + @Expose + private boolean hasServerError = false; + + @Expose + private boolean shared = false; + + // whether or not the experiment's result have been resultsViewed by its owner + @Expose + private boolean resultsViewed = false; + + public Experiment() { + } + + public String computeQuery() { + JsonObject outgoingQuery = new JsonObject(); + outgoingQuery.add("algorithms", gson.fromJson(algorithms, JsonArray.class)); + outgoingQuery.add("validations", gson.fromJson(validations, JsonArray.class)); + outgoingQuery.add("covariables", gson.toJsonTree(model.getQuery().getCovariables())); + outgoingQuery.add("variables", gson.toJsonTree(model.getQuery().getVariables())); + outgoingQuery.add("filters", gson.toJsonTree(model.getQuery().getFilters())); + outgoingQuery.add("grouping", gson.toJsonTree(model.getQuery().getGrouping())); + return outgoingQuery.toString(); + } + + public String getValidations() { + return validations; + } + + public void setValidations(String validations) { + this.validations = validations; + } + + public String getAlgorithms() { + return algorithms; + } + + public void setAlgorithms(String algorithms) { + this.algorithms = algorithms; + } + + public Model getModel() { + return model; + } + + public void setModel(Model model) { + this.model = model; + } + + public boolean isHasError() { + return hasError; + } + + public void setHasError(boolean hasError) { + this.hasError = hasError; + } + + public String getResult() { + return result; + } + + public void setResult(String result) { + this.result = result; + } + + public Date getFinished() { + return finished; + } + + public void setFinished(Date finished) { + this.finished = finished; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public UUID getUuid() { + return uuid; + } + + public void setUuid(UUID uuid) { + this.uuid = uuid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public User getCreatedBy() { + return createdBy; + } + + public void setCreatedBy(User createdBy) { + this.createdBy = createdBy; + } + + public boolean isResultsViewed() { + return resultsViewed; + } + + public void setResultsViewed(boolean resultsViewed) { + this.resultsViewed = resultsViewed; + } + + public boolean isHasServerError() { + return hasServerError; + } + + public void setHasServerError(boolean hasServerError) { + this.hasServerError = hasServerError; + } + + public boolean isShared() { + return shared; + } + + public void setShared(boolean shared) { + this.shared = shared; + } +} diff --git a/src/main/java/org/hbp/mip/model/Model.java b/src/main/java/org/hbp/mip/model/Model.java index 38daf00d6..774b8e0cf 100644 --- a/src/main/java/org/hbp/mip/model/Model.java +++ b/src/main/java/org/hbp/mip/model/Model.java @@ -5,6 +5,7 @@ package org.hbp.mip.model; import com.fasterxml.jackson.annotation.JsonInclude; +import com.google.gson.annotations.Expose; import io.swagger.annotations.ApiModel; import org.hibernate.annotations.Cascade; import org.hibernate.annotations.CascadeType; @@ -19,8 +20,10 @@ import java.util.Date; public class Model { @Id + @Expose private String slug = null; + @Expose private String title = null; private String description = null; diff --git a/src/main/java/org/hbp/mip/model/User.java b/src/main/java/org/hbp/mip/model/User.java index c36195101..3928f7bc8 100644 --- a/src/main/java/org/hbp/mip/model/User.java +++ b/src/main/java/org/hbp/mip/model/User.java @@ -5,6 +5,7 @@ package org.hbp.mip.model; import com.fasterxml.jackson.annotation.JsonInclude; +import com.google.gson.annotations.Expose; import io.swagger.annotations.ApiModel; import javax.persistence.*; @@ -20,44 +21,62 @@ import java.util.regex.Pattern; public class User { @Id + @Expose private String username = null; + @Expose private String fullname = null; + @Expose private String firstname = null; + @Expose private String lastname = null; + @Expose private String picture = null; + @Expose private String web = null; + @Expose private String phone = null; + @Expose private String birthday = null; + @Expose private String gender = null; + @Expose private String city = null; + @Expose private String country = null; + @Expose private String password = null; + @Expose private String email = null; + @Expose private String apikey = null; + @Expose private String team = null; + @Expose private Boolean isActive = null; @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "user_languages", joinColumns = @JoinColumn(name = "user_username")) + @Expose private List<String> languages = new LinkedList<>(); @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_username")) + @Expose private List<String> roles = new LinkedList<>(); private Boolean agreeNDA = null; diff --git a/src/main/java/org/hbp/mip/model/Variable.java b/src/main/java/org/hbp/mip/model/Variable.java index df0bef2c8..e651105c6 100644 --- a/src/main/java/org/hbp/mip/model/Variable.java +++ b/src/main/java/org/hbp/mip/model/Variable.java @@ -7,6 +7,7 @@ package org.hbp.mip.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.gson.annotations.Expose; import io.swagger.annotations.ApiModel; import javax.persistence.*; @@ -21,6 +22,7 @@ import java.util.List; public class Variable { @Id + @Expose private String code = null; private String label = null; diff --git a/src/main/resources/hibernate.cfg.xml b/src/main/resources/hibernate.cfg.xml index d6a47ee25..b37c0788b 100644 --- a/src/main/resources/hibernate.cfg.xml +++ b/src/main/resources/hibernate.cfg.xml @@ -20,6 +20,7 @@ <mapping class="org.hbp.mip.model.Article"/> <mapping class="org.hbp.mip.model.Dataset"/> <mapping class="org.hbp.mip.model.Model"/> + <mapping class="org.hbp.mip.model.Experiment"/> <mapping class="org.hbp.mip.model.Query"/> <mapping class="org.hbp.mip.model.Tag"/> <mapping class="org.hbp.mip.model.User"/> diff --git a/src/test/db b/src/test/db index cce1c65bc..2870ec6bc 160000 --- a/src/test/db +++ b/src/test/db @@ -1 +1 @@ -Subproject commit cce1c65bcde8f9a20ceb233442135fdd9cdb73ae +Subproject commit 2870ec6bc3a5e33d5e0c59c23abd154ad836028c -- GitLab