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:
2026-09-05 13:16:07 +05:00
parent 37d4f3519b
commit 922812b765
10 changed files with 729 additions and 181 deletions

View File

@@ -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