feat(datasets): periods (start_date/end_date), dg_id column, date filters; rename observation dates
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -80,9 +80,9 @@ func (h *DatasetHandler) status(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type yearColumnInput struct {
|
||||
Column string `json:"column" validate:"required"`
|
||||
Date string `json:"date" validate:"required,datetime=2006-01-02"`
|
||||
DateTo *string `json:"date_to" validate:"omitempty,datetime=2006-01-02"`
|
||||
Column string `json:"column" validate:"required"`
|
||||
StartDate string `json:"start_date" validate:"required,datetime=2006-01-02"`
|
||||
EndDate *string `json:"end_date" validate:"omitempty,datetime=2006-01-02"`
|
||||
}
|
||||
|
||||
type mappingRequest struct {
|
||||
@@ -109,7 +109,7 @@ func (h *DatasetHandler) mapping(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
in := service.MappingInput{KatoColumn: req.KatoColumn}
|
||||
for _, yc := range req.YearColumns {
|
||||
in.YearColumns = append(in.YearColumns, domain.YearColumn{Column: yc.Column, Date: yc.Date, DateTo: yc.DateTo})
|
||||
in.YearColumns = append(in.YearColumns, domain.YearColumn{Column: yc.Column, StartDate: yc.StartDate, EndDate: yc.EndDate})
|
||||
}
|
||||
|
||||
dataset, err := h.svc.SaveMapping(r.Context(), id, in)
|
||||
@@ -150,6 +150,17 @@ func (h *DatasetHandler) upload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The dataset group is optional; the period is validated by the service.
|
||||
var groupID *uuid.UUID
|
||||
if v := optionalFormValue(r, "dg_id"); v != nil {
|
||||
gid, err := uuid.Parse(*v)
|
||||
if err != nil {
|
||||
httputil.WriteError(w, http.StatusUnprocessableEntity, "dg_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
groupID = &gid
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
httputil.WriteError(w, http.StatusBadRequest, "file is required")
|
||||
@@ -163,17 +174,20 @@ func (h *DatasetHandler) upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
dataset, err := h.svc.Upload(r.Context(), service.UploadInput{
|
||||
CategoryID: categoryID,
|
||||
Name: r.FormValue("name"),
|
||||
Description: optionalFormValue(r, "description"),
|
||||
Unit: optionalFormValue(r, "unit"),
|
||||
Meta: meta,
|
||||
Automated: automated,
|
||||
Filename: header.Filename,
|
||||
FileType: fileType,
|
||||
ContentType: contentType,
|
||||
Size: header.Size,
|
||||
Reader: file,
|
||||
CategoryID: categoryID,
|
||||
Name: r.FormValue("name"),
|
||||
Description: optionalFormValue(r, "description"),
|
||||
Unit: optionalFormValue(r, "unit"),
|
||||
Meta: meta,
|
||||
Automated: automated,
|
||||
Filename: header.Filename,
|
||||
FileType: fileType,
|
||||
ContentType: contentType,
|
||||
Size: header.Size,
|
||||
Reader: file,
|
||||
DatasetGroupID: groupID,
|
||||
StartDate: optionalFormValue(r, "start_date"),
|
||||
EndDate: optionalFormValue(r, "end_date"),
|
||||
})
|
||||
if err != nil {
|
||||
respondDomainError(w, err)
|
||||
@@ -226,7 +240,32 @@ func (h *DatasetHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
categoryCode = &v
|
||||
}
|
||||
|
||||
filter := domain.DatasetFilter{CategoryID: categoryID}
|
||||
groupID, ok := parseOptionalUUIDQuery(w, r, "dg_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
filter := domain.DatasetFilter{CategoryID: categoryID, DatasetGroupID: groupID}
|
||||
|
||||
for _, b := range []struct {
|
||||
name string
|
||||
dst **string
|
||||
}{
|
||||
{"start_date_from", &filter.StartDateFrom},
|
||||
{"start_date_to", &filter.StartDateTo},
|
||||
{"end_date_from", &filter.EndDateFrom},
|
||||
{"end_date_to", &filter.EndDateTo},
|
||||
} {
|
||||
v := strings.TrimSpace(r.URL.Query().Get(b.name))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", v); err != nil {
|
||||
httputil.WriteError(w, http.StatusBadRequest, b.name+" must be YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
*b.dst = &v
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(r.URL.Query().Get("file_type")); v != "" {
|
||||
ft := domain.FileType(v)
|
||||
@@ -320,10 +359,15 @@ type datasetPutRequest struct {
|
||||
Name string `json:"name" validate:"required,max=255"`
|
||||
Description *string `json:"description" validate:"required"`
|
||||
Unit *string `json:"unit" validate:"required"`
|
||||
// DgID, StartDate and EndDate are optional; omitting them (or sending null
|
||||
// or a blank string) clears the corresponding nullable column.
|
||||
DgID *string `json:"dg_id" validate:"omitempty,uuid"`
|
||||
StartDate *string `json:"start_date" validate:"omitempty,datetime=2006-01-02"`
|
||||
EndDate *string `json:"end_date" validate:"omitempty,datetime=2006-01-02"`
|
||||
}
|
||||
|
||||
// update replaces a dataset's editable metadata (category, name, description,
|
||||
// unit).
|
||||
// unit, dataset group, period).
|
||||
func (h *DatasetHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseUUIDParam(w, r, "id")
|
||||
if !ok {
|
||||
@@ -344,11 +388,24 @@ func (h *DatasetHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var groupID *uuid.UUID
|
||||
if raw := optionalPtr(req.DgID); raw != nil {
|
||||
gid, err := uuid.Parse(*raw)
|
||||
if err != nil {
|
||||
httputil.WriteError(w, http.StatusUnprocessableEntity, "dg_id must be a valid UUID")
|
||||
return
|
||||
}
|
||||
groupID = &gid
|
||||
}
|
||||
|
||||
dataset, err := h.svc.Update(r.Context(), id, service.DatasetUpdateInput{
|
||||
CategoryID: categoryID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Description: normalizeOptional(*req.Description),
|
||||
Unit: normalizeOptional(*req.Unit),
|
||||
CategoryID: categoryID,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Description: normalizeOptional(*req.Description),
|
||||
Unit: normalizeOptional(*req.Unit),
|
||||
DatasetGroupID: groupID,
|
||||
StartDate: optionalPtr(req.StartDate),
|
||||
EndDate: optionalPtr(req.EndDate),
|
||||
})
|
||||
if err != nil {
|
||||
respondDomainError(w, err)
|
||||
@@ -360,11 +417,13 @@ func (h *DatasetHandler) update(w http.ResponseWriter, r *http.Request) {
|
||||
// datasetPatchFields lists the JSON keys accepted by PATCH /datasets/{id}.
|
||||
var datasetPatchFields = map[string]struct{}{
|
||||
"category_id": {}, "name": {}, "description": {}, "unit": {},
|
||||
"dg_id": {}, "start_date": {}, "end_date": {},
|
||||
}
|
||||
|
||||
// patch applies a sparse update to a dataset's editable metadata (category,
|
||||
// name, description, unit): only fields present in the body are changed.
|
||||
// Sending an explicit null for description/unit clears that column.
|
||||
// name, description, unit, dg_id, start_date, end_date): only fields present in
|
||||
// the body are changed. Sending an explicit null for a nullable field clears
|
||||
// that column.
|
||||
func (h *DatasetHandler) patch(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseUUIDParam(w, r, "id")
|
||||
if !ok {
|
||||
@@ -421,6 +480,35 @@ func (h *DatasetHandler) patch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
in.Unit, in.UnitSet = unit, true
|
||||
case "dg_id":
|
||||
raw, err := decodeOptionalPatchString(val)
|
||||
if err != nil {
|
||||
httputil.WriteError(w, http.StatusUnprocessableEntity, "dg_id must be a valid UUID or null")
|
||||
return
|
||||
}
|
||||
if raw != nil {
|
||||
gid, err := uuid.Parse(*raw)
|
||||
if err != nil {
|
||||
httputil.WriteError(w, http.StatusUnprocessableEntity, "dg_id must be a valid UUID or null")
|
||||
return
|
||||
}
|
||||
in.DatasetGroupID = &gid
|
||||
}
|
||||
in.DatasetGroupIDSet = true
|
||||
case "start_date":
|
||||
date, err := decodeOptionalPatchDate(val)
|
||||
if err != nil {
|
||||
httputil.WriteError(w, http.StatusUnprocessableEntity, "start_date must be YYYY-MM-DD or null")
|
||||
return
|
||||
}
|
||||
in.StartDate, in.StartDateSet = date, true
|
||||
case "end_date":
|
||||
date, err := decodeOptionalPatchDate(val)
|
||||
if err != nil {
|
||||
httputil.WriteError(w, http.StatusUnprocessableEntity, "end_date must be YYYY-MM-DD or null")
|
||||
return
|
||||
}
|
||||
in.EndDate, in.EndDateSet = date, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +520,15 @@ func (h *DatasetHandler) patch(w http.ResponseWriter, r *http.Request) {
|
||||
httputil.WriteJSON(w, http.StatusOK, dataset)
|
||||
}
|
||||
|
||||
// optionalPtr normalizes an optional request field: a nil or blank value
|
||||
// yields nil, so an omitted or empty field clears a nullable column.
|
||||
func optionalPtr(v *string) *string {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return normalizeOptional(*v)
|
||||
}
|
||||
|
||||
// normalizeOptional trims s and returns nil when blank, so an empty string
|
||||
// clears a nullable column rather than being stored verbatim.
|
||||
func normalizeOptional(s string) *string {
|
||||
@@ -455,6 +552,20 @@ func decodeOptionalPatchString(raw json.RawMessage) (*string, error) {
|
||||
return normalizeOptional(s), nil
|
||||
}
|
||||
|
||||
// decodeOptionalPatchDate unmarshals a PATCH date field that may be a
|
||||
// YYYY-MM-DD JSON string or null, returning nil (clear the column) for null or
|
||||
// a blank string.
|
||||
func decodeOptionalPatchDate(raw json.RawMessage) (*string, error) {
|
||||
v, err := decodeOptionalPatchString(raw)
|
||||
if err != nil || v == nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", *v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// geojson returns the dataset's pre-assembled GeoJSON FeatureCollection (RFC
|
||||
// 7946), generated and stored at processing time. A vector_with_kato dataset's
|
||||
// collection joins the districts table on KATO code (one feature per KATO with
|
||||
|
||||
Reference in New Issue
Block a user