diff --git a/api/internal/handler/collection_groups.go b/api/internal/handler/collection_groups.go index adf100ea..e90fca0f 100644 --- a/api/internal/handler/collection_groups.go +++ b/api/internal/handler/collection_groups.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "strconv" "time" "github.com/USACE/instrumentation-api/api/internal/httperr" @@ -141,7 +142,7 @@ func (h *ApiHandler) UpdateCollectionGroup(c echo.Context) error { if err != nil { return httperr.InternalServerError(err) } - return c.JSON(http.StatusCreated, cgUpdated) + return c.JSON(http.StatusOK, cgUpdated) } // DeleteCollectionGroup godoc @@ -197,7 +198,57 @@ func (h *ApiHandler) AddTimeseriesToCollectionGroup(c echo.Context) error { if err != nil { return httperr.MalformedID(err) } - if err := h.CollectionGroupService.AddTimeseriesToCollectionGroup(c.Request().Context(), cgID, tsID); err != nil { + var sortOrder int + soParam := c.QueryParam("sort_order") + if soParam != "" { + so64, err := strconv.ParseInt(soParam, 10, 0) + if err != nil { + return httperr.BadRequest(err) + } + sortOrder = int(so64) + } + + if err := h.CollectionGroupService.AddTimeseriesToCollectionGroup(c.Request().Context(), cgID, tsID, sortOrder); err != nil { + return httperr.InternalServerError(err) + } + return c.JSON(http.StatusCreated, make(map[string]interface{})) +} + +// UpdateTimeseriesCollectionGroupSortOrder godoc +// +// @Summary updates sort order for collection group timesries +// @Tags collection-groups +// @Produce json +// @Param project_id path string true "project uuid" Format(uuid) +// @Param collection_group_id path string true "collection group uuid" Format(uuid) +// @Param timeseries_id path string true "timeseries uuid" Format(uuid) +// @Param key query string false "api key" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} echo.HTTPError +// @Failure 404 {object} echo.HTTPError +// @Failure 500 {object} echo.HTTPError +// @Router /projects/{project_id}/collection_groups/{collection_group_id}/timeseries/{timeseries_id} [put] +// @Security Bearer +func (h *ApiHandler) UpdateTimeseriesCollectionGroupSortOrder(c echo.Context) error { + cgID, err := uuid.Parse(c.Param("collection_group_id")) + if err != nil { + return httperr.MalformedID(err) + } + tsID, err := uuid.Parse(c.Param("timeseries_id")) + if err != nil { + return httperr.MalformedID(err) + } + var sortOrder int + soParam := c.QueryParam("sort_order") + if soParam != "" { + so64, err := strconv.ParseInt(soParam, 10, 0) + if err != nil { + return httperr.BadRequest(err) + } + sortOrder = int(so64) + } + + if err := h.CollectionGroupService.UpdateTimeseriesCollectionGroupSortOrder(c.Request().Context(), cgID, tsID, sortOrder); err != nil { return httperr.InternalServerError(err) } return c.JSON(http.StatusOK, make(map[string]interface{})) diff --git a/api/internal/handler/collection_groups_test.go b/api/internal/handler/collection_groups_test.go index 4b15fe50..bd29efb3 100644 --- a/api/internal/handler/collection_groups_test.go +++ b/api/internal/handler/collection_groups_test.go @@ -19,12 +19,15 @@ const collectionGroupSchema = `{ "creator_id": { "type": "string" }, "create_date": { "type": "string", "format": "date-time" }, "updater_id": { "type": ["string", "null"] }, - "update_date": { "type": ["string", "null"], "format": "date-time" } + "update_date": { "type": ["string", "null"], "format": "date-time" }, + "sort_order": { "type": "integer" } }, - "required": ["id", "project_id", "name", "slug", "creator_id", "create_date", "updater_id", "update_date"], + "required": ["id", "project_id", "name", "slug", "creator_id", "create_date", "updater_id", "update_date", "sort_order"], "additionalProperties": false }` +var collectionGroupObjectLoader = gojsonschema.NewStringLoader(collectionGroupSchema) + var collectionGroupArrayLoader = gojsonschema.NewStringLoader(fmt.Sprintf(`{ "type": "array", "items": %s @@ -41,6 +44,7 @@ const collectionGroupDetailsSchema = `{ "create_date": { "type": "string", "format": "date-time" }, "updater_id": { "type": ["string", "null"] }, "update_date": { "type": ["string", "null"], "format": "date-time" }, + "sort_order": { "type": "integer" }, "timeseries": { "type": "array", "items": { @@ -60,14 +64,15 @@ const collectionGroupDetailsSchema = `{ "latest_time": {"type": "string", "format": "date-time" }, "latest_value": {"type": "number" }, "is_computed": { "type": "boolean" }, + "sort_order": { "type": "integer" }, "type": { "type": "string" } }, - "required": ["id", "slug", "name", "variable", "instrument_id", "instrument", "instrument_slug", "parameter_id", "parameter", "unit_id", "unit", "latest_time", "latest_value", "is_computed", "type"], + "required": ["id", "slug", "name", "variable", "instrument_id", "instrument", "instrument_slug", "parameter_id", "parameter", "unit_id", "unit", "latest_time", "latest_value", "is_computed", "type", "sort_order"], "additionalProperties": false } } }, - "required": ["id", "project_id", "name", "slug", "creator_id", "create_date", "updater_id", "update_date", "timeseries"], + "required": ["id", "project_id", "name", "slug", "creator_id", "create_date", "updater_id", "update_date", "timeseries", "sort_order"], "additionalProperties": false }` @@ -75,12 +80,27 @@ var collectionGroupDetailsObjectLoader = gojsonschema.NewStringLoader(collection const testCollectionGroupID = "30b32cb1-0936-42c4-95d1-63a7832a57db" +var createCollectionGroupBody = `{ + "name": "test new collection group", + "sort_order": 2 +}` + +const updateCollectionGroupBody = `{ + "name": "test update collection group", + "sort_order": 3 +}` + func TestCollectionGroups(t *testing.T) { - objSchema, err := gojsonschema.NewSchema(collectionGroupDetailsObjectLoader) + objSchema, err := gojsonschema.NewSchema(collectionGroupObjectLoader) assert.Nil(t, err) if err != nil { t.Log("invalid object schema") } + detailsObjSchema, err := gojsonschema.NewSchema(collectionGroupDetailsObjectLoader) + assert.Nil(t, err) + if err != nil { + t.Log("invalid details object schema") + } arrSchema, err := gojsonschema.NewSchema(collectionGroupArrayLoader) assert.Nil(t, err) if err != nil { @@ -93,7 +113,7 @@ func TestCollectionGroups(t *testing.T) { URL: fmt.Sprintf("/projects/%s/collection_groups/%s", testProjectID, testCollectionGroupID), Method: http.MethodGet, ExpectedStatus: http.StatusOK, - ExpectedSchema: objSchema, + ExpectedSchema: detailsObjSchema, }, { Name: "ListCollectionGroups", @@ -102,6 +122,22 @@ func TestCollectionGroups(t *testing.T) { ExpectedStatus: http.StatusOK, ExpectedSchema: arrSchema, }, + { + Name: "CreateCollectionGroup", + URL: fmt.Sprintf("/projects/%s/collection_groups", testProjectID), + Method: http.MethodPost, + ExpectedStatus: http.StatusCreated, + ExpectedSchema: arrSchema, + Body: createCollectionGroupBody, + }, + { + Name: "UpdateCollectionGroup", + URL: fmt.Sprintf("/projects/%s/collection_groups/%s", testProjectID, testCollectionGroupID), + Method: http.MethodPut, + ExpectedStatus: http.StatusOK, + ExpectedSchema: objSchema, + Body: updateCollectionGroupBody, + }, { Name: "DeleteCollectionGroup", URL: fmt.Sprintf("/projects/%s/collection_groups/%s", testProjectID, testCollectionGroupID), diff --git a/api/internal/handler/domain.go b/api/internal/handler/domain.go index d7fe4831..ad8cf96d 100644 --- a/api/internal/handler/domain.go +++ b/api/internal/handler/domain.go @@ -43,3 +43,21 @@ func (h *ApiHandler) GetDomainMap(c echo.Context) error { } return c.JSON(http.StatusOK, dm) } + +// ListTimezoneOptions godoc +// +// @Summary lists time zone options +// @Tags domain +// @Produce json +// @Success 200 {array} model.TimezoneOption +// @Failure 400 {object} echo.HTTPError +// @Failure 404 {object} echo.HTTPError +// @Failure 500 {object} echo.HTTPError +// @Router /domains [get] +func (h *ApiHandler) ListTimezoneOptions(c echo.Context) error { + dd, err := h.DomainService.ListTimezoneOptions(c.Request().Context()) + if err != nil { + return httperr.InternalServerError(err) + } + return c.JSON(http.StatusOK, dd) +} diff --git a/api/internal/handler/handler.go b/api/internal/handler/handler.go index 456ded3a..2681b0a6 100644 --- a/api/internal/handler/handler.go +++ b/api/internal/handler/handler.go @@ -30,6 +30,7 @@ type ApiHandler struct { AwareParameterService service.AwareParameterService CollectionGroupService service.CollectionGroupService DataloggerService service.DataloggerService + DataloggerTelemetryService service.DataloggerTelemetryService DistrictRollupService service.DistrictRollupService DomainService service.DomainService EquivalencyTableService service.EquivalencyTableService @@ -58,6 +59,7 @@ type ApiHandler struct { CalculatedTimeseriesService service.CalculatedTimeseriesService ProcessTimeseriesService service.ProcessTimeseriesService UnitService service.UnitService + UploaderService service.UploaderService } func NewApi(cfg *config.ApiConfig) *ApiHandler { @@ -80,6 +82,7 @@ func NewApi(cfg *config.ApiConfig) *ApiHandler { AwareParameterService: service.NewAwareParameterService(db, q), CollectionGroupService: service.NewCollectionGroupService(db, q), DataloggerService: service.NewDataloggerService(db, q), + DataloggerTelemetryService: dataloggerTelemetryService, DistrictRollupService: service.NewDistrictRollupService(db, q), DomainService: service.NewDomainService(db, q), EquivalencyTableService: service.NewEquivalencyTableService(db, q), @@ -108,6 +111,7 @@ func NewApi(cfg *config.ApiConfig) *ApiHandler { CalculatedTimeseriesService: service.NewCalculatedTimeseriesService(db, q), ProcessTimeseriesService: service.NewProcessTimeseriesService(db, q), UnitService: service.NewUnitService(db, q), + UploaderService: service.NewUploaderService(db, q), } } diff --git a/api/internal/handler/measurement.go b/api/internal/handler/measurement.go index 0ee8ca4c..74bf87da 100644 --- a/api/internal/handler/measurement.go +++ b/api/internal/handler/measurement.go @@ -1,7 +1,9 @@ package handler import ( + "log" "net/http" + "strings" "time" "github.com/USACE/instrumentation-api/api/internal/httperr" @@ -139,3 +141,54 @@ func (h *ApiHandler) DeleteTimeserieMeasurements(c echo.Context) error { } return c.JSON(http.StatusOK, make(map[string]interface{})) } + +// CreateOrUpdateTimeseriesMeasurements godoc +// +// @Summary creates one or more timeseries measurements +// @Tags measurement +// @Accept json,mpfd +// @Produce json +// @Param timeseries_measurement_collections body model.TimeseriesMeasurementCollectionCollection false "json array of timeseries measurement collections" +// @Param timeseries_measurement_collections formData file false "TOA5 file of timeseries measurement collections" +// @Success 200 {array} model.MeasurementCollection +// @Failure 400 {object} echo.HTTPError +// @Failure 404 {object} echo.HTTPError +// @Failure 500 {object} echo.HTTPError +// @Router /timeseries_measurements [post] +// @Security Bearer +func (h *ApiHandler) _CreateOrUpdateTimeseriesMeasurements(c echo.Context) error { + contentType := "application/json" + contentTypeHeader, ok := c.Request().Header["Content-Type"] + if ok && len(contentTypeHeader) > 0 { + contentType = strings.ToLower(contentTypeHeader[0]) + } + + if strings.Contains(contentType, "multipart/form-data") { + return h.createOrUpdateTimeseriesMeasurementsMultipartFormData(c) + } + + return h.CreateOrUpdateTimeseriesMeasurements(c) +} + +func (h *ApiHandler) createOrUpdateTimeseriesMeasurementsMultipartFormData(c echo.Context) error { + file, err := c.FormFile("file") + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + src, err := file.Open() + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + defer func() { + if err := src.Close(); err != nil { + log.Printf("error closing file: %s", err.Error()) + } + }() + + if err := h.DataloggerTelemetryService.CreateOrUpdateDataloggerTOA5MeasurementCollection(c.Request().Context(), src); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.JSON(http.StatusCreated, map[string]interface{}{}) +} diff --git a/api/internal/handler/uploader.go b/api/internal/handler/uploader.go new file mode 100644 index 00000000..dbe6f8ec --- /dev/null +++ b/api/internal/handler/uploader.go @@ -0,0 +1,240 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/USACE/instrumentation-api/api/internal/httperr" + "github.com/USACE/instrumentation-api/api/internal/model" + "github.com/google/uuid" + "github.com/labstack/echo/v4" +) + +// ListUploaderConfigsForProject godoc +// +// @Summary lists uploader configs for a project +// @Tags uploader +// @Produce json +// @Param project_id path string true "project uuid" Format(uuid) +// @Success 200 {array} model.UploaderConfig +// @Failure 400 {object} echo.HTTPError +// @Router /projects/{project_id}/uploader_configs [get] +func (h *ApiHandler) ListUploaderConfigsForProject(c echo.Context) error { + projectID, err := uuid.Parse(c.Param("project_id")) + if err != nil { + return httperr.MalformedID(err) + } + uu, err := h.UploaderService.ListUploaderConfigsForProject(c.Request().Context(), projectID) + if err != nil { + return httperr.InternalServerError(err) + } + return c.JSON(http.StatusOK, uu) +} + +// ListUploaderConfigMappings godoc +// +// @Summary lists timeseries mappings for an uploader config +// @Tags uploader +// @Produce json +// @Param project_id path string true "project uuid" Format(uuid) +// @Param uploader_config_id path string true "uploader config uuid" Format(uuid) +// @Success 200 {array} model.UploaderConfigMapping +// @Failure 400 {object} echo.HTTPError +// @Router /projects/{project_id}/uploader_configs/{uploader_config_id}/mappings [get] +func (h *ApiHandler) ListUploaderConfigMappings(c echo.Context) error { + _, err := uuid.Parse(c.Param("project_id")) + if err != nil { + return httperr.MalformedID(err) + } + ucID, err := uuid.Parse(c.Param("uploader_config_id")) + if err != nil { + return httperr.MalformedID(err) + } + mm, err := h.UploaderService.ListUploaderConfigMappings(c.Request().Context(), ucID) + if err != nil { + return httperr.InternalServerError(err) + } + return c.JSON(http.StatusOK, mm) +} + +// CreateUploaderConfig godoc +// +// @Summary creates an uploader config +// @Tags uploader +// @Produce json +// @Param project_id path string true "project uuid" Format(uuid) +// @Param uploader_config body model.UploaderConfig true "uploader config payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} echo.HTTPError +// @Router /projects/{project_id}/uploader_configs [post] +func (h *ApiHandler) CreateUploaderConfig(c echo.Context) error { + projectID, err := uuid.Parse(c.Param("project_id")) + if err != nil { + return httperr.MalformedID(err) + } + var uc model.UploaderConfig + if err := c.Bind(&uc); err != nil { + return httperr.MalformedBody(err) + } + + profile := c.Get("profile").(model.Profile) + + uc.CreatorID = profile.ID + uc.CreateDate = time.Now() + uc.ProjectID = projectID + + newID, err := h.UploaderService.CreateUploaderConfig(c.Request().Context(), uc) + if err != nil { + return httperr.InternalServerError(err) + } + return c.JSON(http.StatusCreated, map[string]interface{}{"id": newID}) +} + +// UpdateUploaderConfig godoc +// +// @Summary updates an uploader config +// @Tags uploader +// @Produce json +// @Param project_id path string true "project uuid" Format(uuid) +// @Param uploader_config_id path string true "uploader config uuid" Format(uuid) +// @Param uploader_config body model.UploaderConfig true "uploader config payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} echo.HTTPError +// @Router /projects/{project_id}/uploader_configs/{uploader_config_id} [put] +func (h *ApiHandler) UpdateUploaderConfig(c echo.Context) error { + projectID, err := uuid.Parse(c.Param("project_id")) + if err != nil { + return httperr.MalformedID(err) + } + ucID, err := uuid.Parse(c.Param("uploader_config_id")) + if err != nil { + return httperr.MalformedID(err) + } + var uc model.UploaderConfig + if err := c.Bind(&uc); err != nil { + return httperr.MalformedBody(err) + } + + profile := c.Get("profile").(model.Profile) + + t := time.Now() + uc.UpdaterID = &profile.ID + uc.UpdateDate = &t + uc.ProjectID = projectID + uc.ID = ucID + + if err := h.UploaderService.UpdateUploaderConfig(c.Request().Context(), uc); err != nil { + return httperr.InternalServerError(err) + } + return c.JSON(http.StatusOK, map[string]interface{}{"id": ucID}) +} + +// DeleteUploaderConfig godoc +// +// @Summary deletes an uploader config +// @Tags uploader +// @Produce json +// @Param project_id path string true "project uuid" Format(uuid) +// @Param uploader_config_id path string true "uploader config uuid" Format(uuid) +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} echo.HTTPError +// @Router /projects/{project_id}/uploader_configs/{uploader_config_id} [delete] +func (h *ApiHandler) DeleteUploaderConfig(c echo.Context) error { + _, err := uuid.Parse(c.Param("project_id")) + if err != nil { + return httperr.MalformedID(err) + } + ucID, err := uuid.Parse(c.Param("uploader_config_id")) + if err != nil { + return httperr.MalformedID(err) + } + if err := h.UploaderService.DeleteUploaderConfig(c.Request().Context(), ucID); err != nil { + return httperr.InternalServerError(err) + } + return c.JSON(http.StatusOK, map[string]interface{}{"id": ucID}) +} + +// CreateUploaderConfigMappings godoc +// +// @Summary creates mappings for an uploader config +// @Tags uploader +// @Produce json +// @Param project_id path string true "project uuid" Format(uuid) +// @Param uploader_config_id path string true "uploader config uuid" Format(uuid) +// @Param uploader_config_mappings body []model.UploaderConfigMapping true "uploader config mappings payload" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} echo.HTTPError +// @Router /projects/{project_id}/uploader_configs/{uploader_config_id}/mappings [post] +func (h *ApiHandler) CreateUploaderConfigMappings(c echo.Context) error { + _, err := uuid.Parse(c.Param("project_id")) + if err != nil { + return httperr.MalformedID(err) + } + ucID, err := uuid.Parse(c.Param("uploader_config_id")) + if err != nil { + return httperr.MalformedID(err) + } + mm := make([]model.UploaderConfigMapping, 0) + if err := c.Bind(&mm); err != nil { + return httperr.MalformedBody(err) + } + if err := h.UploaderService.CreateUploaderConfigMappings(c.Request().Context(), ucID, mm); err != nil { + return httperr.InternalServerError(err) + } + return c.JSON(http.StatusCreated, map[string]interface{}{"id": ucID}) +} + +// UpdateUploaderConfigMappings godoc +// +// @Summary updates mappings for an uploader config +// @Tags uploader +// @Produce json +// @Param project_id path string true "project uuid" Format(uuid) +// @Param uploader_config_id path string true "uploader config uuid" Format(uuid) +// @Param uploader_config_mappings body []model.UploaderConfigMapping true "uploader config mappings payload" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} echo.HTTPError +// @Router /projects/{project_id}/uploader_configs/{uploader_config_id}/mappings [put] +func (h *ApiHandler) UpdateUploaderConfigMappings(c echo.Context) error { + _, err := uuid.Parse(c.Param("project_id")) + if err != nil { + return httperr.MalformedID(err) + } + ucID, err := uuid.Parse(c.Param("uploader_config_id")) + if err != nil { + return httperr.MalformedID(err) + } + mm := make([]model.UploaderConfigMapping, 0) + if err := c.Bind(&mm); err != nil { + return httperr.MalformedBody(err) + } + if err := h.UploaderService.UpdateUploaderConfigMappings(c.Request().Context(), ucID, mm); err != nil { + return httperr.InternalServerError(err) + } + return c.JSON(http.StatusOK, map[string]interface{}{"id": ucID}) +} + +// DeleteAllUploaderConfigMappingsForUploaderConfig godoc +// +// @Summary updates mappings for an uploader config +// @Tags uploader +// @Produce json +// @Param project_id path string true "project uuid" Format(uuid) +// @Param uploader_config_id path string true "uploader config uuid" Format(uuid) +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} echo.HTTPError +// @Router /projects/{project_id}/uploader_configs/{uploader_config_id}/mappings [delete] +func (h *ApiHandler) DeleteAllUploaderConfigMappingsForUploaderConfig(c echo.Context) error { + _, err := uuid.Parse(c.Param("project_id")) + if err != nil { + return httperr.MalformedID(err) + } + ucID, err := uuid.Parse(c.Param("uploader_config_id")) + if err != nil { + return httperr.MalformedID(err) + } + if err := h.UploaderService.DeleteAllUploaderConfigMappingsForUploaderConfig(c.Request().Context(), ucID); err != nil { + return httperr.InternalServerError(err) + } + return c.JSON(http.StatusOK, map[string]interface{}{"id": ucID}) +} diff --git a/api/internal/model/collection_group.go b/api/internal/model/collection_group.go index 89ede791..1d25da33 100644 --- a/api/internal/model/collection_group.go +++ b/api/internal/model/collection_group.go @@ -13,6 +13,7 @@ type CollectionGroup struct { ProjectID uuid.UUID `json:"project_id" db:"project_id"` Slug string `json:"slug" db:"slug"` Name string `json:"name" db:"name"` + SortOrder int `json:"sort_order" db:"sort_order"` AuditInfo } @@ -29,12 +30,11 @@ type collectionGroupDetailsTimeseries struct { Timeseries LatestTime *time.Time `json:"latest_time" db:"latest_time"` LatestValue *float32 `json:"latest_value" db:"latest_value"` + SortOrder int `json:"sort_order" db:"sort_order"` } const listCollectionGroups = ` - SELECT id, project_id, slug, name, creator, create_date, updater, update_date - FROM collection_group - WHERE project_id = $1 + SELECT * FROM collection_group WHERE project_id = $1 ORDER BY sort_order, name ` // ListCollectionGroups lists all collection groups for a project @@ -46,8 +46,8 @@ func (q *Queries) ListCollectionGroups(ctx context.Context, projectID uuid.UUID) return aa, nil } -const getCollectionGroupDetails = listCollectionGroups + ` - AND id = $2 +const getCollectionGroupDetails = ` + SELECT * FROM collection_group WHERE project_id = $1 AND id = $2 ` // GetCollectionGroupDetails returns details for a single CollectionGroup @@ -60,7 +60,7 @@ func (q *Queries) GetCollectionGroupDetails(ctx context.Context, projectID, coll } const getCollectionGroupDetailsTimeseries = ` - SELECT t.*, tm.time as latest_time, tm.value as latest_value + SELECT t.*, tm.time as latest_time, tm.value as latest_value, cgt.sort_order FROM collection_group_timeseries cgt INNER JOIN collection_group cg on cg.id = cgt.collection_group_id INNER JOIN v_timeseries t on t.id = cgt.timeseries_id @@ -75,6 +75,7 @@ const getCollectionGroupDetailsTimeseries = ` WHERE project_id = $1 ) AND cgt.collection_group_id = $2 + ORDER BY sort_order ASC, t.name ASC ` // GetCollectionGroupDetails returns details for a single CollectionGroup @@ -87,30 +88,30 @@ func (q *Queries) GetCollectionGroupDetailsTimeseries(ctx context.Context, proje } const createCollectionGroup = ` - INSERT INTO collection_group (project_id, name, slug, creator, create_date, updater, update_date) - VALUES ($1, $2::varchar, slugify($2::varchar, 'collection_group'), $3, $4, $5, $6) - RETURNING id, project_id, name, slug, creator, create_date, updater, update_date + INSERT INTO collection_group (project_id, name, slug, creator, create_date, updater, update_date, sort_order) + VALUES ($1, $2::varchar, slugify($2::varchar, 'collection_group'), $3, $4, $5, $6, $7) + RETURNING id, project_id, name, slug, creator, create_date, updater, update_date, sort_order ` // CreateCollectionGroup creates a new collection group func (q *Queries) CreateCollectionGroup(ctx context.Context, cg CollectionGroup) (CollectionGroup, error) { var cgNew CollectionGroup - if err := q.db.GetContext(ctx, &cgNew, createCollectionGroup, cg.ProjectID, cg.Name, cg.CreatorID, cg.CreateDate, cg.UpdaterID, cg.UpdateDate); err != nil { + if err := q.db.GetContext(ctx, &cgNew, createCollectionGroup, cg.ProjectID, cg.Name, cg.CreatorID, cg.CreateDate, cg.UpdaterID, cg.UpdateDate, cg.SortOrder); err != nil { return cgNew, err } return cgNew, nil } const updateCollectionGroup = ` - UPDATE collection_group SET name=$3, updater=$4, update_date=$5 + UPDATE collection_group SET name=$3, updater=$4, update_date=$5, sort_order=$6 WHERE project_id=$1 AND id=$2 - RETURNING id, project_id, name, slug, creator, create_date, updater, update_date + RETURNING id, project_id, name, slug, creator, create_date, updater, update_date, sort_order ` // UpdateCollectionGroup updates an existing collection group's metadata func (q *Queries) UpdateCollectionGroup(ctx context.Context, cg CollectionGroup) (CollectionGroup, error) { var cgUpdated CollectionGroup - if err := q.db.GetContext(ctx, &cgUpdated, updateCollectionGroup, cg.ProjectID, cg.ID, cg.Name, cg.UpdaterID, cg.UpdateDate); err != nil { + if err := q.db.GetContext(ctx, &cgUpdated, updateCollectionGroup, cg.ProjectID, cg.ID, cg.Name, cg.UpdaterID, cg.UpdateDate, cg.SortOrder); err != nil { return cgUpdated, err } return cgUpdated, nil @@ -128,13 +129,13 @@ func (q *Queries) DeleteCollectionGroup(ctx context.Context, projectID, collecti } const addTimeseriesToCollectionGroup = ` - INSERT INTO collection_group_timeseries (collection_group_id, timeseries_id) VALUES ($1, $2) + INSERT INTO collection_group_timeseries (collection_group_id, timeseries_id, sort_order) VALUES ($1, $2, $3) ON CONFLICT ON CONSTRAINT collection_group_unique_timeseries DO NOTHING ` // AddTimeseriesToCollectionGroup adds a timeseries to a collection group -func (q *Queries) AddTimeseriesToCollectionGroup(ctx context.Context, collectionGroupID, timeseriesID uuid.UUID) error { - _, err := q.db.ExecContext(ctx, addTimeseriesToCollectionGroup, collectionGroupID, timeseriesID) +func (q *Queries) AddTimeseriesToCollectionGroup(ctx context.Context, collectionGroupID, timeseriesID uuid.UUID, sortOrder int) error { + _, err := q.db.ExecContext(ctx, addTimeseriesToCollectionGroup, collectionGroupID, timeseriesID, sortOrder) return err } @@ -147,3 +148,13 @@ func (q *Queries) RemoveTimeseriesFromCollectionGroup(ctx context.Context, colle _, err := q.db.ExecContext(ctx, removeTimeseriesFromCollectionGroup, collectionGroupID, timeseriesID) return err } + +const updateTimeseriesCollectionGroupSortOrder = ` + UPDATE collection_group_timeseries set sort_order=$3 + WHERE collection_group_id=$1 AND timeseries_id=$2 +` + +func (q *Queries) UpdateTimeseriesCollectionGroupSortOrder(ctx context.Context, collectionGroupID, timeseriesID uuid.UUID, sortOrder int) error { + _, err := q.db.ExecContext(ctx, updateTimeseriesCollectionGroupSortOrder, collectionGroupID, timeseriesID, sortOrder) + return err +} diff --git a/api/internal/model/domains.go b/api/internal/model/domains.go index 2caeb54e..6ee923f2 100644 --- a/api/internal/model/domains.go +++ b/api/internal/model/domains.go @@ -58,3 +58,20 @@ func (q *Queries) GetDomainMap(ctx context.Context) (DomainMap, error) { } return m, nil } + +type TimezoneOption struct { + Name string `json:"name" db:"name"` + Abbrev string `json:"abbrev" db:"abbrev"` + UtcOffset string `json:"utc_offset" db:"utc_offset"` + IsDst bool `json:"is_dst" db:"is_dst"` +} + +const listTimezoneOptions = ` + SELECT * FROM pg_timezone_names +` + +func (q *Queries) ListTimezoneOptions(ctx context.Context) ([]TimezoneOption, error) { + dd := make([]TimezoneOption, 0) + err := q.db.SelectContext(ctx, &dd, listTimezoneOptions) + return dd, err +} diff --git a/api/internal/model/uploader.go b/api/internal/model/uploader.go new file mode 100644 index 00000000..1a441105 --- /dev/null +++ b/api/internal/model/uploader.go @@ -0,0 +1,118 @@ +package model + +import ( + "context" + + "github.com/google/uuid" +) + +type UploaderConfigType string + +const ( + CSV, DUX, TOA5 UploaderConfigType = "csv", "dux", "toa5" +) + +type UploaderConfig struct { + ID uuid.UUID `json:"id" db:"id"` + ProjectID uuid.UUID `json:"project_id" db:"project_id"` + Name string `json:"name" db:"name"` + Slug string `json:"slug" db:"slug"` + Description string `json:"description" db:"description"` + Type UploaderConfigType `json:"type" db:"type"` + TzName string `json:"tz_name" db:"tz_name"` + AuditInfo +} + +type UploaderConfigMapping struct { + UploaderConfigID uuid.UUID `json:"-" db:"uploader_config_id"` + FieldName string `json:"field_name" db:"field_name"` + TimeseriesID *uuid.UUID `json:"timeseries_id" db:"timeseries_id"` +} + +const listUploaderConfigsForProject = ` + SELECT + u.*, + p1.username creator_username, + p2.username updater_username + FROM uploader_config u + INNER JOIN profile p1 ON p1.id = u.creator + LEFT JOIN profile p2 ON p2.id = u.updater + WHERE project_id=$1 +` + +func (q *Queries) ListUploaderConfigsForProject(ctx context.Context, projectID uuid.UUID) ([]UploaderConfig, error) { + uu := make([]UploaderConfig, 0) + err := q.db.SelectContext(ctx, &uu, listUploaderConfigsForProject, projectID) + return uu, err +} + +const createUploaderConfig = ` + INSERT INTO uploader_config (project_id, name, slug, description, create_date, creator, type, tz_name) + VALUES ($1, $2, slugify($2, 'uploader_config'), $3, $4, $5, $6, $7) + RETURNING id +` + +func (q *Queries) CreateUploaderConfig(ctx context.Context, uc UploaderConfig) (uuid.UUID, error) { + var newID uuid.UUID + err := q.db.GetContext( + ctx, &newID, createUploaderConfig, + uc.ProjectID, uc.Name, uc.Description, uc.CreateDate, uc.CreatorID, uc.Type, uc.TzName, + ) + return newID, err +} + +const updateUploaderConfig = ` + UPDATE uploader_config SET + name=$2, + description=$3, + update_date=$4, + updater=$5, + type=$6, + tz_name=$7 + WHERE id=$1 +` + +func (q *Queries) UpdateUploaderConfig(ctx context.Context, uc UploaderConfig) error { + _, err := q.db.ExecContext( + ctx, updateUploaderConfig, + uc.ID, uc.Name, uc.Description, uc.UpdateDate, uc.UpdaterID, uc.Type, uc.TzName, + ) + return err +} + +const deleteUploaderConfig = ` + DELETE FROM uploader_config WHERE id=$1 +` + +func (q *Queries) DeleteUploaderConfig(ctx context.Context, ucID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteUploaderConfig, ucID) + return err +} + +const listUploaderConfigMappings = ` + SELECT * FROM uploader_config_mapping WHERE uploader_config_id=$1 +` + +func (q *Queries) ListUploaderConfigMappings(ctx context.Context, ucID uuid.UUID) ([]UploaderConfigMapping, error) { + mm := make([]UploaderConfigMapping, 0) + err := q.db.SelectContext(ctx, &mm, listUploaderConfigMappings, ucID) + return mm, err +} + +const createUploaderConfigMapping = ` + INSERT INTO uploader_config_mapping (uploader_config_id, field_name, timeseries_id) VALUES ($1, $2, $3) +` + +func (q *Queries) CreateUploaderConfigMapping(ctx context.Context, m UploaderConfigMapping) error { + _, err := q.db.ExecContext(ctx, createUploaderConfigMapping, m.UploaderConfigID, m.FieldName, m.TimeseriesID) + return err +} + +const deleteAllUploaderConfigMappingsForUploaderConfig = ` + DELETE FROM uploader_config_mapping WHERE uploader_config_id=$1 +` + +func (q *Queries) DeleteAllUploaderConfigMappingsForUploaderConfig(ctx context.Context, ucID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteAllUploaderConfigMappingsForUploaderConfig, ucID) + return err +} diff --git a/api/internal/server/api.go b/api/internal/server/api.go index cea68cdb..4c3ce31c 100644 --- a/api/internal/server/api.go +++ b/api/internal/server/api.go @@ -123,6 +123,7 @@ func (r *ApiServer) RegisterRoutes(h *handler.ApiHandler) { r.private.PUT("/projects/:project_id/collection_groups/:collection_group_id", h.UpdateCollectionGroup) r.private.DELETE("/projects/:project_id/collection_groups/:collection_group_id", h.DeleteCollectionGroup) r.private.POST("/projects/:project_id/collection_groups/:collection_group_id/timeseries/:timeseries_id", h.AddTimeseriesToCollectionGroup) + r.private.PUT("/projects/:project_id/collection_groups/:collection_group_id/timeseries/:timeseries_id", h.UpdateTimeseriesCollectionGroupSortOrder) r.private.DELETE("/projects/:project_id/collection_groups/:collection_group_id/timeseries/:timeseries_id", h.RemoveTimeseriesFromCollectionGroup) // Datalogger @@ -142,6 +143,7 @@ func (r *ApiServer) RegisterRoutes(h *handler.ApiHandler) { // Domain r.public.GET("/domains", h.GetDomains) r.public.GET("/domains/map", h.GetDomainMap) + r.public.GET("/domains/timezones", h.ListTimezoneOptions) // EquivalencyTable r.private.GET("/datalogger/:datalogger_id/tables/:datalogger_table_id/equivalency_table", h.GetEquivalencyTable) @@ -352,4 +354,14 @@ func (r *ApiServer) RegisterRoutes(h *handler.ApiHandler) { // Unit r.public.GET("/units", h.ListUnits) + + // Uploader + r.private.GET("/projects/:project_id/uploader_configs", h.ListUploaderConfigsForProject) + r.private.GET("/projects/:project_id/uploader_configs/:uploader_config_id/mappings", h.ListUploaderConfigMappings) + r.private.POST("/projects/:project_id/uploader_configs", h.CreateUploaderConfig) + r.private.PUT("/projects/:project_id/uploader_configs/:uploader_config_id", h.UpdateUploaderConfig) + r.private.DELETE("/projects/:project_id/uploader_configs/:uploader_config_id", h.DeleteUploaderConfig) + r.private.POST("/projects/:project_id/uploader_configs/:uploader_config_id/mappings", h.CreateUploaderConfigMappings) + r.private.PUT("/projects/:project_id/uploader_configs/:uploader_config_id/mappings", h.UpdateUploaderConfigMappings) + r.private.DELETE("/projects/:project_id/uploader_configs/:uploader_config_id/mappings", h.DeleteAllUploaderConfigMappingsForUploaderConfig) } diff --git a/api/internal/server/docs/openapi.json b/api/internal/server/docs/openapi.json index e62c791f..e699ac20 100644 --- a/api/internal/server/docs/openapi.json +++ b/api/internal/server/docs/openapi.json @@ -1456,7 +1456,7 @@ "application/json" : { "schema" : { "items" : { - "$ref" : "#/components/schemas/Domain" + "$ref" : "#/components/schemas/TimezoneOption" }, "type" : "array" } @@ -1495,7 +1495,7 @@ "description" : "Internal Server Error" } }, - "summary" : "lists all domains", + "summary" : "lists time zone options", "tags" : [ "domain" ] } }, @@ -6246,6 +6246,91 @@ } ], "summary" : "adds a timeseries to a collection group", "tags" : [ "collection-groups" ] + }, + "put" : { + "parameters" : [ { + "description" : "project uuid", + "in" : "path", + "name" : "project_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + }, { + "description" : "collection group uuid", + "in" : "path", + "name" : "collection_group_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + }, { + "description" : "timeseries uuid", + "in" : "path", + "name" : "timeseries_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + }, { + "description" : "api key", + "in" : "query", + "name" : "key", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "additionalProperties" : true, + "type" : "object" + } + } + }, + "description" : "OK" + }, + "400" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Bad Request" + }, + "404" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Not Found" + }, + "500" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Internal Server Error" + } + }, + "security" : [ { + "Bearer" : [ ] + } ], + "summary" : "updates sort order for collection group timesries", + "tags" : [ "collection-groups" ] } }, "/projects/{project_id}/district_rollup/evaluation_submittals" : { @@ -11268,82 +11353,114 @@ "x-codegen-request-body-name" : "timeseries_measurement_collections" } }, - "/projects/{project_slug}/images/{uri_path}" : { + "/projects/{project_id}/uploader_configs" : { "get" : { "parameters" : [ { - "description" : "project abbr", - "in" : "path", - "name" : "project_slug", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "description" : "uri path of requested resource", + "description" : "project uuid", "in" : "path", - "name" : "uri_path", + "name" : "project_id", "required" : true, "schema" : { + "format" : "uuid", "type" : "string" } } ], "responses" : { "200" : { - "content" : { }, + "content" : { + "application/json" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/UploaderConfig" + }, + "type" : "array" + } + } + }, "description" : "OK" }, "400" : { "content" : { - "image/jpeg" : { + "application/json" : { "schema" : { "$ref" : "#/components/schemas/echo.HTTPError" } } }, "description" : "Bad Request" + } + }, + "summary" : "lists uploader configs for a project", + "tags" : [ "uploader" ] + }, + "post" : { + "parameters" : [ { + "description" : "project uuid", + "in" : "path", + "name" : "project_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UploaderConfig" + } + } }, - "404" : { + "description" : "uploader config payload", + "required" : true + }, + "responses" : { + "201" : { "content" : { - "image/jpeg" : { + "application/json" : { "schema" : { - "$ref" : "#/components/schemas/echo.HTTPError" + "additionalProperties" : true, + "type" : "object" } } }, - "description" : "Not Found" + "description" : "Created" }, - "500" : { + "400" : { "content" : { - "image/jpeg" : { + "application/json" : { "schema" : { "$ref" : "#/components/schemas/echo.HTTPError" } } }, - "description" : "Internal Server Error" + "description" : "Bad Request" } }, - "summary" : "serves media, files, etc for a given project", - "tags" : [ "media" ] + "summary" : "creates an uploader config", + "tags" : [ "uploader" ], + "x-codegen-request-body-name" : "uploader_config" } }, - "/report_configs/{report_config_id}/plot_configs" : { - "get" : { + "/projects/{project_id}/uploader_configs/{uploader_config_id}" : { + "delete" : { "parameters" : [ { - "description" : "report config uuid", + "description" : "project uuid", "in" : "path", - "name" : "report_config_id", + "name" : "project_id", "required" : true, "schema" : { "format" : "uuid", "type" : "string" } }, { - "description" : "api key", - "in" : "query", - "name" : "key", + "description" : "uploader config uuid", + "in" : "path", + "name" : "uploader_config_id", "required" : true, "schema" : { + "format" : "uuid", "type" : "string" } } ], @@ -11352,7 +11469,8 @@ "content" : { "application/json" : { "schema" : { - "$ref" : "#/components/schemas/ReportConfigWithPlotConfigs" + "additionalProperties" : true, + "type" : "object" } } }, @@ -11367,61 +11485,40 @@ } }, "description" : "Bad Request" - }, - "404" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/echo.HTTPError" - } - } - }, - "description" : "Not Found" - }, - "500" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/echo.HTTPError" - } - } - }, - "description" : "Internal Server Error" } }, - "summary" : "Lists all plot configs for a report config", - "tags" : [ "report-config" ] - } - }, - "/report_jobs/{job_id}" : { + "summary" : "deletes an uploader config", + "tags" : [ "uploader" ] + }, "put" : { "parameters" : [ { - "description" : "download job uuid", + "description" : "project uuid", "in" : "path", - "name" : "job_id", + "name" : "project_id", "required" : true, "schema" : { "format" : "uuid", "type" : "string" } }, { - "description" : "api key", - "in" : "query", - "name" : "key", + "description" : "uploader config uuid", + "in" : "path", + "name" : "uploader_config_id", "required" : true, "schema" : { + "format" : "uuid", "type" : "string" } } ], "requestBody" : { "content" : { - "application/json" : { + "*/*" : { "schema" : { - "$ref" : "#/components/schemas/ReportDownloadJob" + "$ref" : "#/components/schemas/UploaderConfig" } } }, - "description" : "report download job payload", + "description" : "uploader config payload", "required" : true }, "responses" : { @@ -11445,48 +11542,31 @@ } }, "description" : "Bad Request" - }, - "404" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/echo.HTTPError" - } - } - }, - "description" : "Not Found" - }, - "500" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/echo.HTTPError" - } - } - }, - "description" : "Internal Server Error" } }, - "summary" : "updates a job that creates a pdf report", - "tags" : [ "report-config" ], - "x-codegen-request-body-name" : "report_download_job" + "summary" : "updates an uploader config", + "tags" : [ "uploader" ], + "x-codegen-request-body-name" : "uploader_config" } }, - "/search/{entity}" : { - "get" : { + "/projects/{project_id}/uploader_configs/{uploader_config_id}/mappings" : { + "delete" : { "parameters" : [ { - "description" : "entity to search (i.e. projects, etc.)", + "description" : "project uuid", "in" : "path", - "name" : "entity", + "name" : "project_id", "required" : true, "schema" : { + "format" : "uuid", "type" : "string" } }, { - "description" : "search string", - "in" : "query", - "name" : "q", + "description" : "uploader config uuid", + "in" : "path", + "name" : "uploader_config_id", + "required" : true, "schema" : { + "format" : "uuid", "type" : "string" } } ], @@ -11495,10 +11575,8 @@ "content" : { "application/json" : { "schema" : { - "items" : { - "$ref" : "#/components/schemas/SearchResult" - }, - "type" : "array" + "additionalProperties" : true, + "type" : "object" } } }, @@ -11513,15 +11591,436 @@ } }, "description" : "Bad Request" - }, - "404" : { - "content" : { - "application/json" : { - "schema" : { - "$ref" : "#/components/schemas/echo.HTTPError" - } - } - }, + } + }, + "summary" : "updates mappings for an uploader config", + "tags" : [ "uploader" ] + }, + "get" : { + "parameters" : [ { + "description" : "project uuid", + "in" : "path", + "name" : "project_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + }, { + "description" : "uploader config uuid", + "in" : "path", + "name" : "uploader_config_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/UploaderConfigMapping" + }, + "type" : "array" + } + } + }, + "description" : "OK" + }, + "400" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Bad Request" + } + }, + "summary" : "lists timeseries mappings for an uploader config", + "tags" : [ "uploader" ] + }, + "post" : { + "parameters" : [ { + "description" : "project uuid", + "in" : "path", + "name" : "project_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + }, { + "description" : "uploader config uuid", + "in" : "path", + "name" : "uploader_config_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/UploaderConfigMapping" + }, + "type" : "array" + } + } + }, + "description" : "uploader config mappings payload", + "required" : true + }, + "responses" : { + "201" : { + "content" : { + "application/json" : { + "schema" : { + "additionalProperties" : true, + "type" : "object" + } + } + }, + "description" : "Created" + }, + "400" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Bad Request" + } + }, + "summary" : "creates mappings for an uploader config", + "tags" : [ "uploader" ], + "x-codegen-request-body-name" : "uploader_config_mappings" + }, + "put" : { + "parameters" : [ { + "description" : "project uuid", + "in" : "path", + "name" : "project_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + }, { + "description" : "uploader config uuid", + "in" : "path", + "name" : "uploader_config_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "*/*" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/UploaderConfigMapping" + }, + "type" : "array" + } + } + }, + "description" : "uploader config mappings payload", + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "additionalProperties" : true, + "type" : "object" + } + } + }, + "description" : "OK" + }, + "400" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Bad Request" + } + }, + "summary" : "updates mappings for an uploader config", + "tags" : [ "uploader" ], + "x-codegen-request-body-name" : "uploader_config_mappings" + } + }, + "/projects/{project_slug}/images/{uri_path}" : { + "get" : { + "parameters" : [ { + "description" : "project abbr", + "in" : "path", + "name" : "project_slug", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "description" : "uri path of requested resource", + "in" : "path", + "name" : "uri_path", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { }, + "description" : "OK" + }, + "400" : { + "content" : { + "image/jpeg" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Bad Request" + }, + "404" : { + "content" : { + "image/jpeg" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Not Found" + }, + "500" : { + "content" : { + "image/jpeg" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Internal Server Error" + } + }, + "summary" : "serves media, files, etc for a given project", + "tags" : [ "media" ] + } + }, + "/report_configs/{report_config_id}/plot_configs" : { + "get" : { + "parameters" : [ { + "description" : "report config uuid", + "in" : "path", + "name" : "report_config_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + }, { + "description" : "api key", + "in" : "query", + "name" : "key", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ReportConfigWithPlotConfigs" + } + } + }, + "description" : "OK" + }, + "400" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Bad Request" + }, + "404" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Not Found" + }, + "500" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Internal Server Error" + } + }, + "summary" : "Lists all plot configs for a report config", + "tags" : [ "report-config" ] + } + }, + "/report_jobs/{job_id}" : { + "put" : { + "parameters" : [ { + "description" : "download job uuid", + "in" : "path", + "name" : "job_id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + }, { + "description" : "api key", + "in" : "query", + "name" : "key", + "required" : true, + "schema" : { + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ReportDownloadJob" + } + } + }, + "description" : "report download job payload", + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "additionalProperties" : true, + "type" : "object" + } + } + }, + "description" : "OK" + }, + "400" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Bad Request" + }, + "404" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Not Found" + }, + "500" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Internal Server Error" + } + }, + "summary" : "updates a job that creates a pdf report", + "tags" : [ "report-config" ], + "x-codegen-request-body-name" : "report_download_job" + } + }, + "/search/{entity}" : { + "get" : { + "parameters" : [ { + "description" : "entity to search (i.e. projects, etc.)", + "in" : "path", + "name" : "entity", + "required" : true, + "schema" : { + "type" : "string" + } + }, { + "description" : "search string", + "in" : "query", + "name" : "q", + "schema" : { + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/SearchResult" + }, + "type" : "array" + } + } + }, + "description" : "OK" + }, + "400" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, + "description" : "Bad Request" + }, + "404" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/echo.HTTPError" + } + } + }, "description" : "Not Found" }, "500" : { @@ -12200,25 +12699,19 @@ }, "/timeseries_measurements" : { "post" : { - "parameters" : [ { - "description" : "api key", - "in" : "query", - "name" : "key", - "required" : true, - "schema" : { - "type" : "string" - } - } ], "requestBody" : { "content" : { - "*/*" : { + "application/json" : { "schema" : { - "$ref" : "#/components/schemas/TimeseriesMeasurementCollectionCollection" + "$ref" : "#/components/schemas/_timeseries_measurements_post_request" + } + }, + "multipart/form-data" : { + "schema" : { + "$ref" : "#/components/schemas/_timeseries_measurements_post_request" } } - }, - "description" : "array of timeseries measurement collections", - "required" : true + } }, "responses" : { "200" : { @@ -12265,7 +12758,10 @@ "description" : "Internal Server Error" } }, - "summary" : "creates or updates one or more timeseries measurements", + "security" : [ { + "Bearer" : [ ] + } ], + "summary" : "creates one or more timeseries measurements", "tags" : [ "measurement" ], "x-codegen-request-body-name" : "timeseries_measurement_collections" } @@ -12578,6 +13074,9 @@ "slug" : { "type" : "string" }, + "sort_order" : { + "type" : "integer" + }, "update_date" : { "type" : "string" }, @@ -12613,6 +13112,9 @@ "slug" : { "type" : "string" }, + "sort_order" : { + "type" : "integer" + }, "timeseries" : { "items" : { "$ref" : "#/components/schemas/collectionGroupDetailsTimeseries" @@ -14584,6 +15086,23 @@ }, "type" : "object" }, + "TimezoneOption" : { + "properties" : { + "abbrev" : { + "type" : "string" + }, + "is_dst" : { + "type" : "boolean" + }, + "name" : { + "type" : "string" + }, + "utc_offset" : { + "type" : "string" + } + }, + "type" : "object" + }, "ToggleOption" : { "properties" : { "enabled" : { @@ -14649,6 +15168,63 @@ }, "type" : "object" }, + "UploaderConfig" : { + "properties" : { + "create_date" : { + "type" : "string" + }, + "creator_id" : { + "type" : "string" + }, + "creator_username" : { + "type" : "string" + }, + "description" : { + "type" : "string" + }, + "id" : { + "type" : "string" + }, + "name" : { + "type" : "string" + }, + "project_id" : { + "type" : "string" + }, + "type" : { + "$ref" : "#/components/schemas/UploaderConfigType" + }, + "tz_name" : { + "type" : "string" + }, + "update_date" : { + "type" : "string" + }, + "updater_id" : { + "type" : "string" + }, + "updater_username" : { + "type" : "string" + } + }, + "type" : "object" + }, + "UploaderConfigMapping" : { + "properties" : { + "field_name" : { + "type" : "string" + }, + "timeseries_id" : { + "type" : "string" + } + }, + "type" : "object" + }, + "UploaderConfigType" : { + "enum" : [ "csv", "dux", "toa5" ], + "type" : "string", + "x-enum-varnames" : [ "CSV", "DUX", "TOA5" ] + }, "collectionGroupDetailsTimeseries" : { "properties" : { "id" : { @@ -14684,6 +15260,9 @@ "slug" : { "type" : "string" }, + "sort_order" : { + "type" : "integer" + }, "type" : { "type" : "string" }, @@ -14723,6 +15302,16 @@ "enum" : [ 0, 1, 2 ], "type" : "integer", "x-enum-varnames" : [ "Undefined", "Null", "Present" ] + }, + "_timeseries_measurements_post_request" : { + "properties" : { + "timeseries_measurement_collections" : { + "description" : "TOA5 file of timeseries measurement collections", + "format" : "binary", + "type" : "string" + } + }, + "type" : "object" } }, "securitySchemes" : { diff --git a/api/internal/server/docs/openapi.yaml b/api/internal/server/docs/openapi.yaml index e6bde2c5..c84bd1c8 100644 --- a/api/internal/server/docs/openapi.yaml +++ b/api/internal/server/docs/openapi.yaml @@ -974,7 +974,7 @@ paths: application/json: schema: items: - $ref: '#/components/schemas/Domain' + $ref: '#/components/schemas/TimezoneOption' type: array description: OK "400": @@ -995,7 +995,7 @@ paths: schema: $ref: '#/components/schemas/echo.HTTPError' description: Internal Server Error - summary: lists all domains + summary: lists time zone options tags: - domain /domains/map: @@ -4145,6 +4145,65 @@ paths: summary: adds a timeseries to a collection group tags: - collection-groups + put: + parameters: + - description: project uuid + in: path + name: project_id + required: true + schema: + format: uuid + type: string + - description: collection group uuid + in: path + name: collection_group_id + required: true + schema: + format: uuid + type: string + - description: timeseries uuid + in: path + name: timeseries_id + required: true + schema: + format: uuid + type: string + - description: api key + in: query + name: key + schema: + type: string + responses: + "200": + content: + application/json: + schema: + additionalProperties: true + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Bad Request + "404": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Not Found + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Internal Server Error + security: + - Bearer: [] + summary: updates sort order for collection group timesries + tags: + - collection-groups /projects/{project_id}/district_rollup/evaluation_submittals: get: parameters: @@ -7532,6 +7591,297 @@ paths: tags: - measurement x-codegen-request-body-name: timeseries_measurement_collections + /projects/{project_id}/uploader_configs: + get: + parameters: + - description: project uuid + in: path + name: project_id + required: true + schema: + format: uuid + type: string + responses: + "200": + content: + application/json: + schema: + items: + $ref: '#/components/schemas/UploaderConfig' + type: array + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Bad Request + summary: lists uploader configs for a project + tags: + - uploader + post: + parameters: + - description: project uuid + in: path + name: project_id + required: true + schema: + format: uuid + type: string + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/UploaderConfig' + description: uploader config payload + required: true + responses: + "201": + content: + application/json: + schema: + additionalProperties: true + type: object + description: Created + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Bad Request + summary: creates an uploader config + tags: + - uploader + x-codegen-request-body-name: uploader_config + /projects/{project_id}/uploader_configs/{uploader_config_id}: + delete: + parameters: + - description: project uuid + in: path + name: project_id + required: true + schema: + format: uuid + type: string + - description: uploader config uuid + in: path + name: uploader_config_id + required: true + schema: + format: uuid + type: string + responses: + "200": + content: + application/json: + schema: + additionalProperties: true + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Bad Request + summary: deletes an uploader config + tags: + - uploader + put: + parameters: + - description: project uuid + in: path + name: project_id + required: true + schema: + format: uuid + type: string + - description: uploader config uuid + in: path + name: uploader_config_id + required: true + schema: + format: uuid + type: string + requestBody: + content: + '*/*': + schema: + $ref: '#/components/schemas/UploaderConfig' + description: uploader config payload + required: true + responses: + "200": + content: + application/json: + schema: + additionalProperties: true + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Bad Request + summary: updates an uploader config + tags: + - uploader + x-codegen-request-body-name: uploader_config + /projects/{project_id}/uploader_configs/{uploader_config_id}/mappings: + delete: + parameters: + - description: project uuid + in: path + name: project_id + required: true + schema: + format: uuid + type: string + - description: uploader config uuid + in: path + name: uploader_config_id + required: true + schema: + format: uuid + type: string + responses: + "200": + content: + application/json: + schema: + additionalProperties: true + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Bad Request + summary: updates mappings for an uploader config + tags: + - uploader + get: + parameters: + - description: project uuid + in: path + name: project_id + required: true + schema: + format: uuid + type: string + - description: uploader config uuid + in: path + name: uploader_config_id + required: true + schema: + format: uuid + type: string + responses: + "200": + content: + application/json: + schema: + items: + $ref: '#/components/schemas/UploaderConfigMapping' + type: array + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Bad Request + summary: lists timeseries mappings for an uploader config + tags: + - uploader + post: + parameters: + - description: project uuid + in: path + name: project_id + required: true + schema: + format: uuid + type: string + - description: uploader config uuid + in: path + name: uploader_config_id + required: true + schema: + format: uuid + type: string + requestBody: + content: + '*/*': + schema: + items: + $ref: '#/components/schemas/UploaderConfigMapping' + type: array + description: uploader config mappings payload + required: true + responses: + "201": + content: + application/json: + schema: + additionalProperties: true + type: object + description: Created + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Bad Request + summary: creates mappings for an uploader config + tags: + - uploader + x-codegen-request-body-name: uploader_config_mappings + put: + parameters: + - description: project uuid + in: path + name: project_id + required: true + schema: + format: uuid + type: string + - description: uploader config uuid + in: path + name: uploader_config_id + required: true + schema: + format: uuid + type: string + requestBody: + content: + '*/*': + schema: + items: + $ref: '#/components/schemas/UploaderConfigMapping' + type: array + description: uploader config mappings payload + required: true + responses: + "200": + content: + application/json: + schema: + additionalProperties: true + type: object + description: OK + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/echo.HTTPError' + description: Bad Request + summary: updates mappings for an uploader config + tags: + - uploader + x-codegen-request-body-name: uploader_config_mappings /projects/{project_slug}/images/{uri_path}: get: parameters: @@ -8157,20 +8507,14 @@ paths: - timeseries /timeseries_measurements: post: - parameters: - - description: api key - in: query - name: key - required: true - schema: - type: string requestBody: content: - '*/*': + application/json: schema: - $ref: '#/components/schemas/TimeseriesMeasurementCollectionCollection' - description: array of timeseries measurement collections - required: true + $ref: '#/components/schemas/_timeseries_measurements_post_request' + multipart/form-data: + schema: + $ref: '#/components/schemas/_timeseries_measurements_post_request' responses: "200": content: @@ -8198,7 +8542,9 @@ paths: schema: $ref: '#/components/schemas/echo.HTTPError' description: Internal Server Error - summary: creates or updates one or more timeseries measurements + security: + - Bearer: [] + summary: creates one or more timeseries measurements tags: - measurement x-codegen-request-body-name: timeseries_measurement_collections @@ -8492,6 +8838,7 @@ components: updater_id: updater_id id: id create_date: create_date + sort_order: 0 slug: slug update_date: update_date properties: @@ -8509,6 +8856,8 @@ components: type: string slug: type: string + sort_order: + type: integer update_date: type: string updater_id: @@ -8540,11 +8889,12 @@ components: parameter: parameter name: name variable: variable - latest_value: 0.8008281904610115 + latest_value: 6.027456183070403 id: id instrument_slug: instrument_slug is_computed: true latest_time: latest_time + sort_order: 1 unit_id: unit_id slug: slug parameter_id: parameter_id @@ -8568,11 +8918,12 @@ components: parameter: parameter name: name variable: variable - latest_value: 0.8008281904610115 + latest_value: 6.027456183070403 id: id instrument_slug: instrument_slug is_computed: true latest_time: latest_time + sort_order: 1 unit_id: unit_id slug: slug parameter_id: parameter_id @@ -8583,6 +8934,7 @@ components: updater_id: updater_id id: id create_date: create_date + sort_order: 0 slug: slug update_date: update_date properties: @@ -8600,6 +8952,8 @@ components: type: string slug: type: string + sort_order: + type: integer timeseries: items: $ref: '#/components/schemas/collectionGroupDetailsTimeseries' @@ -8822,11 +9176,6 @@ components: type: integer type: object Domain: - example: - description: description - id: id - value: value - group: group properties: description: type: string @@ -11026,6 +11375,22 @@ components: $ref: '#/components/schemas/MeasurementCollection' type: array type: object + TimezoneOption: + example: + utc_offset: utc_offset + name: name + abbrev: abbrev + is_dst: true + properties: + abbrev: + type: string + is_dst: + type: boolean + name: + type: string + utc_offset: + type: string + type: object ToggleOption: example: value: true @@ -11087,6 +11452,66 @@ components: unit_family_id: type: string type: object + UploaderConfig: + example: + updater_username: updater_username + tz_name: tz_name + project_id: project_id + creator_username: creator_username + creator_id: creator_id + name: name + updater_id: updater_id + description: description + id: id + create_date: create_date + type: csv + update_date: update_date + properties: + create_date: + type: string + creator_id: + type: string + creator_username: + type: string + description: + type: string + id: + type: string + name: + type: string + project_id: + type: string + type: + $ref: '#/components/schemas/UploaderConfigType' + tz_name: + type: string + update_date: + type: string + updater_id: + type: string + updater_username: + type: string + type: object + UploaderConfigMapping: + example: + timeseries_id: timeseries_id + field_name: field_name + properties: + field_name: + type: string + timeseries_id: + type: string + type: object + UploaderConfigType: + enum: + - csv + - dux + - toa5 + type: string + x-enum-varnames: + - CSV + - DUX + - TOA5 collectionGroupDetailsTimeseries: example: values: @@ -11109,11 +11534,12 @@ components: parameter: parameter name: name variable: variable - latest_value: 0.8008281904610115 + latest_value: 6.027456183070403 id: id instrument_slug: instrument_slug is_computed: true latest_time: latest_time + sort_order: 1 unit_id: unit_id slug: slug parameter_id: parameter_id @@ -11140,6 +11566,8 @@ components: type: string slug: type: string + sort_order: + type: integer type: type: string unit: @@ -11177,6 +11605,13 @@ components: - Undefined - "Null" - Present + _timeseries_measurements_post_request: + properties: + timeseries_measurement_collections: + description: TOA5 file of timeseries measurement collections + format: binary + type: string + type: object securitySchemes: Bearer: description: Type "Bearer" followed by a space and access token. diff --git a/api/internal/service/collection_group.go b/api/internal/service/collection_group.go index 784adab9..405d412a 100644 --- a/api/internal/service/collection_group.go +++ b/api/internal/service/collection_group.go @@ -13,7 +13,8 @@ type CollectionGroupService interface { CreateCollectionGroup(ctx context.Context, cg model.CollectionGroup) (model.CollectionGroup, error) UpdateCollectionGroup(ctx context.Context, cg model.CollectionGroup) (model.CollectionGroup, error) DeleteCollectionGroup(ctx context.Context, projectID, collectionGroupID uuid.UUID) error - AddTimeseriesToCollectionGroup(ctx context.Context, collectionGroupID, timeseriesID uuid.UUID) error + AddTimeseriesToCollectionGroup(ctx context.Context, collectionGroupID, timeseriesID uuid.UUID, sortOrder int) error + UpdateTimeseriesCollectionGroupSortOrder(ctx context.Context, collectionGroupID, timeseriesID uuid.UUID, sortOrder int) error RemoveTimeseriesFromCollectionGroup(ctx context.Context, collectionGroupID, timeseriesID uuid.UUID) error } diff --git a/api/internal/service/datalogger_telemetry.go b/api/internal/service/datalogger_telemetry.go index aee3e441..b55f2063 100644 --- a/api/internal/service/datalogger_telemetry.go +++ b/api/internal/service/datalogger_telemetry.go @@ -3,7 +3,13 @@ package service import ( "context" "database/sql" + "encoding/csv" "errors" + "fmt" + "io" + "math" + "strconv" + "time" "github.com/USACE/instrumentation-api/api/internal/model" "github.com/google/uuid" @@ -15,6 +21,7 @@ type DataloggerTelemetryService interface { CreateDataloggerTablePreview(ctx context.Context, prv model.DataloggerTablePreview) error UpdateDataloggerTablePreview(ctx context.Context, dataloggerID uuid.UUID, tableName string, prv model.DataloggerTablePreview) (uuid.UUID, error) UpdateDataloggerTableError(ctx context.Context, dataloggerID uuid.UUID, tableName *string, e *model.DataloggerError) error + CreateOrUpdateDataloggerTOA5MeasurementCollection(ctx context.Context, r io.Reader) error } type dataloggerTelemetryService struct { @@ -79,3 +86,125 @@ func (s dataloggerTelemetryService) UpdateDataloggerTableError(ctx context.Conte return tx.Commit() } + +// ParseTOA5 parses a Campbell Scientific TOA5 data file that is simlar to a csv. +// The unique properties of TOA5 are that the meatdata are stored in header of file (first 4 lines of csv) +func (s dataloggerTelemetryService) CreateOrUpdateDataloggerTOA5MeasurementCollection(ctx context.Context, r io.Reader) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer model.TxDo(tx.Rollback) + + qtx := s.WithTx(tx) + + reader := csv.NewReader(r) + + envHeader, err := reader.Read() + if err != nil { + return err + } + fieldHeader, err := reader.Read() + if err != nil { + return err + } + unitsHeader, err := reader.Read() + if err != nil { + return err + } + processHeader, err := reader.Read() + if err != nil { + return err + } + + meta := model.Environment{ + StationName: envHeader[1], + Model: envHeader[2], + SerialNo: envHeader[3], + OSVersion: envHeader[4], + ProgName: envHeader[5], + TableName: envHeader[6], + } + + dl, err := qtx.GetDataloggerByModelSN(ctx, meta.Model, meta.SerialNo) + if err != nil { + return err + } + + tableID, err := qtx.GetOrCreateDataloggerTable(ctx, dl.ID, meta.TableName) + if err != nil { + return err + } + + em := make([]string, 0) + defer func() { + s.UpdateDataloggerTableError(ctx, dl.ID, &meta.TableName, &model.DataloggerError{Errors: em}) + }() + + // first two columns are timestamp and record number + // we only want to collect the measurement fields here + fields := make([]model.Field, len(fieldHeader)-2) + for i := 2; i < len(fieldHeader); i++ { + fields[i] = model.Field{ + Name: fieldHeader[i], + Units: unitsHeader[i], + Process: processHeader[i], + } + } + + eqt, err := qtx.GetEquivalencyTable(ctx, tableID) + if err != nil { + return err + } + + fieldNameTimeseriesIDMap := make(map[string]uuid.UUID) + for _, eqtRow := range eqt.Rows { + fieldNameTimeseriesIDMap[eqtRow.FieldName] = *eqtRow.TimeseriesID + } + + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return err + } + + t, err := time.Parse(record[0], time.RFC3339) + if err != nil { + return err + } + + for idx, cell := range record[2:] { + fieldName := fields[idx].Name + tsID, ok := fieldNameTimeseriesIDMap[fieldName] + if !ok { + // key error, field_name does not exist for equivalency table + // add error to Measurement payload to report back to user + em = append(em, fmt.Sprintf( + "key error: field_name %s does not exist for equivalency table %s", + fieldName, meta.TableName, + )) + continue + } + + v, err := strconv.ParseFloat(cell, 64) + if err != nil || math.IsNaN(v) || math.IsInf(v, 0) { + // could not parse float + // add error to Measurement payload to report back to user + em = append(em, fmt.Sprintf( + "value error: field_name %s contains invalid value entry at %s", + fieldName, t, + )) + continue + } + + if err := qtx.CreateOrUpdateTimeseriesMeasurement(ctx, tsID, t, v); err != nil { + return err + } + } + } + + return tx.Commit() +} diff --git a/api/internal/service/domain.go b/api/internal/service/domain.go index 9f7fc6e5..94d4530c 100644 --- a/api/internal/service/domain.go +++ b/api/internal/service/domain.go @@ -9,6 +9,7 @@ import ( type DomainService interface { GetDomains(ctx context.Context) ([]model.Domain, error) GetDomainMap(ctx context.Context) (model.DomainMap, error) + ListTimezoneOptions(ctx context.Context) ([]model.TimezoneOption, error) } type domainService struct { diff --git a/api/internal/service/uploader.go b/api/internal/service/uploader.go new file mode 100644 index 00000000..fc122ae0 --- /dev/null +++ b/api/internal/service/uploader.go @@ -0,0 +1,177 @@ +package service + +import ( + "context" + "encoding/csv" + "io" + "math" + "strconv" + "time" + + "github.com/USACE/instrumentation-api/api/internal/model" + "github.com/google/uuid" +) + +type UploaderService interface { + ListUploaderConfigsForProject(ctx context.Context, projectID uuid.UUID) ([]model.UploaderConfig, error) + ListUploaderConfigMappings(ctx context.Context, ucID uuid.UUID) ([]model.UploaderConfigMapping, error) + CreateUploaderConfig(ctx context.Context, uc model.UploaderConfig) (uuid.UUID, error) + UpdateUploaderConfig(ctx context.Context, uc model.UploaderConfig) error + DeleteUploaderConfig(ctx context.Context, ucID uuid.UUID) error + CreateUploaderConfigMapping(ctx context.Context, m model.UploaderConfigMapping) error + CreateUploaderConfigMappings(ctx context.Context, ucID uuid.UUID, mm []model.UploaderConfigMapping) error + UpdateUploaderConfigMappings(ctx context.Context, ucID uuid.UUID, mm []model.UploaderConfigMapping) error + DeleteAllUploaderConfigMappingsForUploaderConfig(ctx context.Context, ucID uuid.UUID) error + // CreateTimeseriesMeasurementsFromDuxFile(ctx context.Context, r io.Reader) error + // CreateTimeseriesMeasurementsFromTOA5File(ctx context.Context, r io.Reader) error +} + +type uploaderService struct { + db *model.Database + *model.Queries +} + +func NewUploaderService(db *model.Database, q *model.Queries) *uploaderService { + return &uploaderService{db, q} +} + +func (s uploaderService) CreateUploaderConfigMappings(ctx context.Context, ucID uuid.UUID, mm []model.UploaderConfigMapping) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer model.TxDo(tx.Rollback) + qtx := s.WithTx(tx) + + for _, m := range mm { + m.UploaderConfigID = ucID + if err := qtx.CreateUploaderConfigMapping(ctx, m); err != nil { + return err + } + } + return tx.Commit() +} + +func (s uploaderService) UpdateUploaderConfigMappings(ctx context.Context, ucID uuid.UUID, mm []model.UploaderConfigMapping) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer model.TxDo(tx.Rollback) + qtx := s.WithTx(tx) + + if err := qtx.DeleteAllUploaderConfigMappingsForUploaderConfig(ctx, ucID); err != nil { + return err + } + + for _, m := range mm { + m.UploaderConfigID = ucID + if err := qtx.CreateUploaderConfigMapping(ctx, m); err != nil { + return err + } + } + return tx.Commit() +} + +// TODO: transition away from datalogger equivalency table to different parser that's uploader specific +func (s uploaderService) CreateTimeseriesMeasurementsFromTOA5File(ctx context.Context, r io.Reader) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer model.TxDo(tx.Rollback) + + qtx := s.WithTx(tx) + + reader := csv.NewReader(r) + + envHeader, err := reader.Read() + if err != nil { + return err + } + fieldHeader, err := reader.Read() + if err != nil { + return err + } + unitsHeader, err := reader.Read() + if err != nil { + return err + } + processHeader, err := reader.Read() + if err != nil { + return err + } + + meta := model.Environment{ + StationName: envHeader[1], + Model: envHeader[2], + SerialNo: envHeader[3], + OSVersion: envHeader[4], + ProgName: envHeader[5], + TableName: envHeader[6], + } + + dl, err := qtx.GetDataloggerByModelSN(ctx, meta.Model, meta.SerialNo) + if err != nil { + return err + } + + tableID, err := qtx.GetOrCreateDataloggerTable(ctx, dl.ID, meta.TableName) + if err != nil { + return err + } + + // first two columns are timestamp and record number + // we only want to collect the measurement fields here + fields := make([]model.Field, len(fieldHeader)-2) + for i := 2; i < len(fieldHeader); i++ { + fields[i] = model.Field{ + Name: fieldHeader[i], + Units: unitsHeader[i], + Process: processHeader[i], + } + } + + eqt, err := qtx.GetEquivalencyTable(ctx, tableID) + if err != nil { + return err + } + + fieldNameTimeseriesIDMap := make(map[string]uuid.UUID) + for _, eqtRow := range eqt.Rows { + fieldNameTimeseriesIDMap[eqtRow.FieldName] = *eqtRow.TimeseriesID + } + + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + return err + } + + t, err := time.Parse(record[0], time.RFC3339) + if err != nil { + return err + } + + for idx, cell := range record[2:] { + fieldName := fields[idx].Name + tsID, ok := fieldNameTimeseriesIDMap[fieldName] + if !ok { + continue + } + + v, err := strconv.ParseFloat(cell, 64) + if err != nil || math.IsNaN(v) || math.IsInf(v, 0) { + continue + } + + if err := qtx.CreateOrUpdateTimeseriesMeasurement(ctx, tsID, t, v); err != nil { + return err + } + } + } + return nil +} diff --git a/api/migrations/schema/V1.14.00__uploader.sql b/api/migrations/schema/V1.14.00__uploader.sql new file mode 100644 index 00000000..25aeafee --- /dev/null +++ b/api/migrations/schema/V1.14.00__uploader.sql @@ -0,0 +1,32 @@ +-- checked db and there are no null instrument geometries +ALTER TABLE instrument ALTER COLUMN "geometry" TYPE geometry(Point, 4326); +ALTER TABLE instrument ALTER COLUMN "geometry" SET NOT NULL; + + +CREATE TYPE uploader_config_type AS ENUM ('csv', 'dux', 'toa5'); + + +CREATE TABLE uploader_config ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + project_id uuid NOT NULL REFERENCES project(id), + slug text UNIQUE NOT NULL, + name text NOT NULL, + description text NOT NULL, + create_date timestamptz NOT NULL DEFAULT now(), + creator uuid NOT NULL REFERENCES profile(id), + update_date timestamptz, + updater uuid REFERENCES profile(id), + type uploader_config_type NOT NULL, + tz_name text NOT NULL DEFAULT 'UTC' +); + + +CREATE TABLE uploader_config_mapping ( + uploader_config_id uuid NOT NULL REFERENCES uploader_config(id) ON DELETE CASCADE, + field_name text NOT NULL, + timeseries_id uuid REFERENCES timeseries(id) ON DELETE SET NULL, + CONSTRAINT uploader_config_mapping_uploader_config_id_field_name UNIQUE(uploader_config_id, field_name) +); + +ALTER TABLE collection_group ADD COLUMN sort_order integer NOT NULL DEFAULT 0; +ALTER TABLE collection_group_timeseries ADD COLUMN sort_order integer NOT NULL DEFAULT 0; diff --git a/compose.sh b/compose.sh index 4ce826ef..a7c955a3 100755 --- a/compose.sh +++ b/compose.sh @@ -2,7 +2,10 @@ set -Eeo pipefail -parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) +parent_path=$( + cd "$(dirname "${BASH_SOURCE[0]}")" + pwd -P +) cd "$parent_path" COMPOSECMD="docker compose -f docker-compose.yml" @@ -10,28 +13,25 @@ COMPOSECMD="docker compose -f docker-compose.yml" mkdocs() { ( DOCKER_BUILDKIT=1 docker build --file api/Dockerfile.openapi --output api/internal/server/docs api - cd report && npm run generate >/dev/null; + cd report && npm run generate >/dev/null ) } - if [ "$1" = "watch" ]; then mkdocs -q if [ "$2" = "mock" ]; then - DOCKER_BUILDKIT=1 $COMPOSECMD -f docker-compose.dev.yml --profile=mock watch + DOCKER_BUILDKIT=1 $COMPOSECMD -f docker-compose.dev.yml --profile=mock up --watch else - DOCKER_BUILDKIT=1 $COMPOSECMD -f docker-compose.dev.yml watch + DOCKER_BUILDKIT=1 $COMPOSECMD -f docker-compose.dev.yml up --watch fi - elif [ "$1" = "up" ]; then mkdocs -q if [ "$2" = "mock" ]; then - DOCKER_BUILDKIT=1 $COMPOSECMD --profile=mock up -d --build + DOCKER_BUILDKIT=1 $COMPOSECMD --profile=mock up --remove-orphans -d --build else - DOCKER_BUILDKIT=1 $COMPOSECMD up -d --build + DOCKER_BUILDKIT=1 $COMPOSECMD up --remove-orphans -d --build fi - elif [ "$1" = "build" ]; then if [ "$2" = "local" ] || [ "$2" = "develop" ] || [ "$2" = "test" ] || [ "$2" = "prod" ]; then @@ -46,20 +46,19 @@ elif [ "$1" = "build" ]; then AMD64_TARGET_PLATFORM=true fi - for BUILD_TARGET in midas-api midas-sql midas-telemetry midas-alert midas-dcs-loader - do - docker build \ - ${AMD64_TARGET_PLATFORM:+--platform=linux/amd64} \ - --build-arg="BASE_IMAGE=${SCRATCH_BASE_IMAGE}" \ - --build-arg="GO_VERSION=1.23" \ - --build-arg="BUILD_TAG=$2" \ - --build-arg="BUILD_TARGET=${BUILD_TARGET}" \ - -t $BUILD_TARGET:"$2" api + for BUILD_TARGET in midas-api midas-sql midas-telemetry midas-alert midas-dcs-loader; do + docker build \ + ${AMD64_TARGET_PLATFORM:+--platform=linux/amd64} \ + --build-arg="BASE_IMAGE=${SCRATCH_BASE_IMAGE}" \ + --build-arg="GO_VERSION=1.23" \ + --build-arg="BUILD_TAG=$2" \ + --build-arg="BUILD_TARGET=${BUILD_TARGET}" \ + -t $BUILD_TARGET:"$2" api done docker build \ - --build-arg="BASE_IMAGE=${ALPINE_BASE_IMAGE}" \ - -t midas-report:$2 report + --build-arg="BASE_IMAGE=${ALPINE_BASE_IMAGE}" \ + -t midas-report:$2 report else echo -e "usage:\n\t./compose.sh build [local,develop,test,prod]" exit 1 @@ -74,8 +73,7 @@ elif [ "$1" = "build" ]; then declare -a REGISTRIES=("midas-api" "midas-telemetry" "midas-alert" "midas-dcs-loader" "midas-sql") # tag - for IMAGE in "${REGISTRIES[@]}" - do + for IMAGE in "${REGISTRIES[@]}"; do docker tag $IMAGE:"$2" $4/$IMAGE:"$2" done if [ "$2" = "develop" ]; then @@ -83,8 +81,7 @@ elif [ "$1" = "build" ]; then fi # push - for IMAGE in "${REGISTRIES[@]}" - do + for IMAGE in "${REGISTRIES[@]}"; do docker push $4/$IMAGE:"$2" done if [ "$2" = "develop" ]; then @@ -92,22 +89,18 @@ elif [ "$1" = "build" ]; then fi fi - elif [ "$1" = "authdbdump" ]; then - $COMPOSECMD exec authdb pg_dump postgres > auth/initdb/init2.sql - + $COMPOSECMD exec authdb pg_dump postgres >auth/initdb/init2.sql elif [ "$1" = "down" ]; then mkdocs -q $COMPOSECMD -f docker-compose.dev.yml --profile=mock down - elif [ "$1" = "clean" ]; then $COMPOSECMD -f docker-compose.dev.yml --profile=mock down -v - elif [ "$1" = "test" ]; then - docker compose build + $COMPOSECMD up --remove-orphans -d --build db migrate elasticmq api shift TEARDOWN=false @@ -115,34 +108,34 @@ elif [ "$1" = "test" ]; then while [[ $# -gt 0 ]]; do case $1 in - -rm) - TEARDOWN=true - shift - ;; - *) - REST_ARGS+=("$1") - shift - ;; + -rm) + TEARDOWN=true + shift + ;; + *) + REST_ARGS+=("$1") + shift + ;; esac done GOCMD="go test ${REST_ARGS[@]} github.com/USACE/instrumentation-api/api/internal/handler" + set +e if [ "$REPORT" = true ]; then - docker compose run -e INSTRUMENTATION_AUTH_JWT_MOCKED=true --entrypoint="$GOCMD" api > $(pwd)/test.log + docker compose run --remove-orphans -e INSTRUMENTATION_AUTH_JWT_MOCKED=true --entrypoint="$GOCMD" api >$(pwd)/test.log else - docker compose run -e INSTRUMENTATION_AUTH_JWT_MOCKED=true --entrypoint="$GOCMD" api + docker compose run --remove-orphans -e INSTRUMENTATION_AUTH_JWT_MOCKED=true --entrypoint="$GOCMD" api fi + set -e if [ $TEARDOWN = true ]; then docker compose --profile=mock down -v fi - elif [ "$1" = "mkdocs" ]; then mkdocs - else echo -e "usage:\n\t./compose.sh watch\n\t./compose.sh up\n\t./compose.sh down\n\t./compose.sh clean\n\t./compose.sh test\n\t./compose.sh mkdocs" fi diff --git a/docker-compose.yml b/docker-compose.yml index e3465ea9..f2d9c487 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,13 +87,6 @@ services: required: true ports: - "${API_PORT:-8080}:80" - depends_on: - db: - condition: service_healthy - migrate: - condition: service_completed_successfully - elasticmq: - condition: service_started telemetry: build: diff --git a/report/generated.d.ts b/report/generated.d.ts index 0f48113c..6fd296d3 100644 --- a/report/generated.d.ts +++ b/report/generated.d.ts @@ -834,13 +834,13 @@ export interface paths { }; }; "/domains": { - /** lists all domains */ + /** lists time zone options */ get: { responses: { /** @description OK */ 200: { content: { - "application/json": components["schemas"]["Domain"][]; + "application/json": components["schemas"]["TimezoneOption"][]; }; }; /** @description Bad Request */ @@ -3575,6 +3575,51 @@ export interface paths { }; }; "/projects/{project_id}/collection_groups/{collection_group_id}/timeseries/{timeseries_id}": { + /** updates sort order for collection group timesries */ + put: { + parameters: { + query?: { + /** @description api key */ + key?: string; + }; + path: { + /** @description project uuid */ + project_id: string; + /** @description collection group uuid */ + collection_group_id: string; + /** @description timeseries uuid */ + timeseries_id: string; + }; + }; + responses: { + /** @description OK */ + 200: { + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Bad Request */ + 400: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + /** @description Not Found */ + 404: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + }; + }; /** adds a timeseries to a collection group */ post: { parameters: { @@ -6473,6 +6518,244 @@ export interface paths { }; }; }; + "/projects/{project_id}/uploader_configs": { + /** lists uploader configs for a project */ + get: { + parameters: { + path: { + /** @description project uuid */ + project_id: string; + }; + }; + responses: { + /** @description OK */ + 200: { + content: { + "application/json": components["schemas"]["UploaderConfig"][]; + }; + }; + /** @description Bad Request */ + 400: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + }; + }; + /** creates an uploader config */ + post: { + parameters: { + path: { + /** @description project uuid */ + project_id: string; + }; + }; + /** @description uploader config payload */ + requestBody: { + content: { + "*/*": components["schemas"]["UploaderConfig"]; + }; + }; + responses: { + /** @description Created */ + 201: { + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Bad Request */ + 400: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + }; + }; + }; + "/projects/{project_id}/uploader_configs/{uploader_config_id}": { + /** updates an uploader config */ + put: { + parameters: { + path: { + /** @description project uuid */ + project_id: string; + /** @description uploader config uuid */ + uploader_config_id: string; + }; + }; + /** @description uploader config payload */ + requestBody: { + content: { + "*/*": components["schemas"]["UploaderConfig"]; + }; + }; + responses: { + /** @description OK */ + 200: { + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Bad Request */ + 400: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + }; + }; + /** deletes an uploader config */ + delete: { + parameters: { + path: { + /** @description project uuid */ + project_id: string; + /** @description uploader config uuid */ + uploader_config_id: string; + }; + }; + responses: { + /** @description OK */ + 200: { + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Bad Request */ + 400: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + }; + }; + }; + "/projects/{project_id}/uploader_configs/{uploader_config_id}/mappings": { + /** lists timeseries mappings for an uploader config */ + get: { + parameters: { + path: { + /** @description project uuid */ + project_id: string; + /** @description uploader config uuid */ + uploader_config_id: string; + }; + }; + responses: { + /** @description OK */ + 200: { + content: { + "application/json": components["schemas"]["UploaderConfigMapping"][]; + }; + }; + /** @description Bad Request */ + 400: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + }; + }; + /** updates mappings for an uploader config */ + put: { + parameters: { + path: { + /** @description project uuid */ + project_id: string; + /** @description uploader config uuid */ + uploader_config_id: string; + }; + }; + /** @description uploader config mappings payload */ + requestBody: { + content: { + "*/*": components["schemas"]["UploaderConfigMapping"][]; + }; + }; + responses: { + /** @description OK */ + 200: { + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Bad Request */ + 400: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + }; + }; + /** creates mappings for an uploader config */ + post: { + parameters: { + path: { + /** @description project uuid */ + project_id: string; + /** @description uploader config uuid */ + uploader_config_id: string; + }; + }; + /** @description uploader config mappings payload */ + requestBody: { + content: { + "*/*": components["schemas"]["UploaderConfigMapping"][]; + }; + }; + responses: { + /** @description Created */ + 201: { + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Bad Request */ + 400: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + }; + }; + /** updates mappings for an uploader config */ + delete: { + parameters: { + path: { + /** @description project uuid */ + project_id: string; + /** @description uploader config uuid */ + uploader_config_id: string; + }; + }; + responses: { + /** @description OK */ + 200: { + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Bad Request */ + 400: { + content: { + "application/json": components["schemas"]["echo.HTTPError"]; + }; + }; + }; + }; + }; "/projects/{project_slug}/images/{uri_path}": { /** serves media, files, etc for a given project */ get: { @@ -7030,18 +7313,12 @@ export interface paths { }; }; "/timeseries_measurements": { - /** creates or updates one or more timeseries measurements */ + /** creates one or more timeseries measurements */ post: { - parameters: { - query: { - /** @description api key */ - key: string; - }; - }; - /** @description array of timeseries measurement collections */ - requestBody: { + requestBody?: { content: { - "*/*": components["schemas"]["TimeseriesMeasurementCollectionCollection"]; + "application/json": components["schemas"]["_timeseries_measurements_post_request"]; + "multipart/form-data": components["schemas"]["_timeseries_measurements_post_request"]; }; }; responses: { @@ -7332,6 +7609,7 @@ export interface components { * "updater_id": "updater_id", * "id": "id", * "create_date": "create_date", + * "sort_order": 0, * "slug": "slug", * "update_date": "update_date" * } @@ -7344,6 +7622,7 @@ export interface components { name?: string; project_id?: string; slug?: string; + sort_order?: number; update_date?: string; updater_id?: string; updater_username?: string; @@ -7378,11 +7657,12 @@ export interface components { * "parameter": "parameter", * "name": "name", * "variable": "variable", - * "latest_value": 0.8008281904610115, + * "latest_value": 6.027456183070403, * "id": "id", * "instrument_slug": "instrument_slug", * "is_computed": true, * "latest_time": "latest_time", + * "sort_order": 1, * "unit_id": "unit_id", * "slug": "slug", * "parameter_id": "parameter_id" @@ -7413,11 +7693,12 @@ export interface components { * "parameter": "parameter", * "name": "name", * "variable": "variable", - * "latest_value": 0.8008281904610115, + * "latest_value": 6.027456183070403, * "id": "id", * "instrument_slug": "instrument_slug", * "is_computed": true, * "latest_time": "latest_time", + * "sort_order": 1, * "unit_id": "unit_id", * "slug": "slug", * "parameter_id": "parameter_id" @@ -7430,6 +7711,7 @@ export interface components { * "updater_id": "updater_id", * "id": "id", * "create_date": "create_date", + * "sort_order": 0, * "slug": "slug", * "update_date": "update_date" * } @@ -7442,6 +7724,7 @@ export interface components { name?: string; project_id?: string; slug?: string; + sort_order?: number; timeseries?: components["schemas"]["collectionGroupDetailsTimeseries"][]; update_date?: string; updater_id?: string; @@ -7621,14 +7904,6 @@ export interface components { red_submittals?: number; yellow_submittals?: number; }; - /** - * @example { - * "description": "description", - * "id": "id", - * "value": "value", - * "group": "group" - * } - */ Domain: { description?: string; group?: string; @@ -9689,6 +9964,20 @@ export interface components { TimeseriesMeasurementCollectionCollection: { items?: components["schemas"]["MeasurementCollection"][]; }; + /** + * @example { + * "utc_offset": "utc_offset", + * "name": "name", + * "abbrev": "abbrev", + * "is_dst": true + * } + */ + TimezoneOption: { + abbrev?: string; + is_dst?: boolean; + name?: string; + utc_offset?: string; + }; /** * @example { * "value": true, @@ -9743,6 +10032,48 @@ export interface components { unit_family?: string; unit_family_id?: string; }; + /** + * @example { + * "updater_username": "updater_username", + * "tz_name": "tz_name", + * "project_id": "project_id", + * "creator_username": "creator_username", + * "creator_id": "creator_id", + * "name": "name", + * "updater_id": "updater_id", + * "description": "description", + * "id": "id", + * "create_date": "create_date", + * "type": "csv", + * "update_date": "update_date" + * } + */ + UploaderConfig: { + create_date?: string; + creator_id?: string; + creator_username?: string; + description?: string; + id?: string; + name?: string; + project_id?: string; + type?: components["schemas"]["UploaderConfigType"]; + tz_name?: string; + update_date?: string; + updater_id?: string; + updater_username?: string; + }; + /** + * @example { + * "timeseries_id": "timeseries_id", + * "field_name": "field_name" + * } + */ + UploaderConfigMapping: { + field_name?: string; + timeseries_id?: string; + }; + /** @enum {string} */ + UploaderConfigType: "csv" | "dux" | "toa5"; /** * @example { * "values": [ @@ -9770,11 +10101,12 @@ export interface components { * "parameter": "parameter", * "name": "name", * "variable": "variable", - * "latest_value": 0.8008281904610115, + * "latest_value": 6.027456183070403, * "id": "id", * "instrument_slug": "instrument_slug", * "is_computed": true, * "latest_time": "latest_time", + * "sort_order": 1, * "unit_id": "unit_id", * "slug": "slug", * "parameter_id": "parameter_id" @@ -9792,6 +10124,7 @@ export interface components { parameter?: string; parameter_id?: string; slug?: string; + sort_order?: number; type?: string; unit?: string; unit_id?: string; @@ -9813,6 +10146,13 @@ export interface components { }; /** @enum {integer} */ "pgtype.Status": 0 | 1 | 2; + _timeseries_measurements_post_request: { + /** + * Format: binary + * @description TOA5 file of timeseries measurement collections + */ + timeseries_measurement_collections?: string; + }; }; responses: never; parameters: never;