Files
Gardomatic/internal/api/species.go
T
2026-09-16 20:21:41 +02:00

423 lines
14 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"gardomatic.kleiax.de/internal/platform/validate"
"gardomatic.kleiax.de/internal/storage"
)
type speciesInput struct {
Global bool `json:"global"`
Tags []string `json:"tags"`
CommonName *string `json:"common_name"`
Cultivar *string `json:"cultivar"`
BotanicalName *string `json:"botanical_name"`
CategoryID *int `json:"category_id"`
ClearCategoryID bool `json:"clear_category_id"`
SunExposure *string `json:"sun_exposure"`
SoilCondition *string `json:"soil_condition"`
SoilReaction *string `json:"soil_reaction"`
WinterProtection *string `json:"winter_protection"`
SpacingCM *int `json:"spacing_cm"`
HeightCM *int `json:"height_cm"`
HeightCMFrom *int `json:"height_cm_from"`
HeightCMTo *int `json:"height_cm_to"`
WidthCMFrom *int `json:"width_cm_from"`
WidthCMTo *int `json:"width_cm_to"`
ClearHeightCMFrom bool `json:"clear_height_cm_from"`
ClearHeightCMTo bool `json:"clear_height_cm_to"`
ClearWidthCMFrom bool `json:"clear_width_cm_from"`
ClearWidthCMTo bool `json:"clear_width_cm_to"`
SowMonthFrom *int `json:"sow_month_from"`
SowDayFrom *int `json:"sow_day_from"`
ClearSowDayFrom bool `json:"clear_sow_day_from"`
SowMonthTo *int `json:"sow_month_to"`
SowDayTo *int `json:"sow_day_to"`
ClearSowDayTo bool `json:"clear_sow_day_to"`
PlantingMonthFrom *int `json:"planting_month_from"`
PlantingDayFrom *int `json:"planting_day_from"`
ClearPlantingDayFrom bool `json:"clear_planting_day_from"`
PlantingMonthTo *int `json:"planting_month_to"`
PlantingDayTo *int `json:"planting_day_to"`
ClearPlantingDayTo bool `json:"clear_planting_day_to"`
HarvestMonthFrom *int `json:"harvest_month_from"`
HarvestDayFrom *int `json:"harvest_day_from"`
ClearHarvestDayFrom bool `json:"clear_harvest_day_from"`
HarvestMonthTo *int `json:"harvest_month_to"`
HarvestDayTo *int `json:"harvest_day_to"`
ClearHarvestDayTo bool `json:"clear_harvest_day_to"`
ClearSowRange bool `json:"clear_sow_range"`
ClearPlantingRange bool `json:"clear_planting_range"`
ClearHarvestRange bool `json:"clear_harvest_range"`
Notes *string `json:"notes"`
ImageData *string `json:"image_data"`
ImageID *int `json:"image_id"`
Attributes json.RawMessage `json:"attributes"`
}
func (input speciesInput) apply(species *storage.Species) {
assignTrimmed(input.CommonName, &species.CommonName)
assignTrimmed(input.Cultivar, &species.Cultivar)
assignTrimmed(input.BotanicalName, &species.BotanicalName)
assignIntPointer(input.CategoryID, &species.CategoryID)
if input.ClearCategoryID {
species.CategoryID = nil
species.Category = ""
}
assignTrimmed(input.Notes, &species.Notes)
if input.ImageData != nil {
species.ImageData = *input.ImageData
}
assignStringPointer(input.SunExposure, &species.SunExposure)
assignStringPointer(input.SoilCondition, &species.SoilCondition)
assignStringPointer(input.SoilReaction, &species.SoilReaction)
assignStringPointer(input.WinterProtection, &species.WinterProtection)
assignIntPointer(input.SpacingCM, &species.SpacingCM)
assignIntPointer(input.HeightCMFrom, &species.HeightCMFrom)
if input.HeightCMTo != nil {
assignIntPointer(input.HeightCMTo, &species.HeightCMTo)
} else {
assignIntPointer(input.HeightCM, &species.HeightCMTo)
}
assignIntPointer(input.WidthCMFrom, &species.WidthCMFrom)
assignIntPointer(input.WidthCMTo, &species.WidthCMTo)
clearIntPointer(input.ClearHeightCMFrom, &species.HeightCMFrom)
clearIntPointer(input.ClearHeightCMTo, &species.HeightCMTo)
clearIntPointer(input.ClearWidthCMFrom, &species.WidthCMFrom)
clearIntPointer(input.ClearWidthCMTo, &species.WidthCMTo)
species.HeightCM = species.HeightCMTo
assignIntPointer(input.SowMonthFrom, &species.SowMonthFrom)
assignIntPointer(input.SowDayFrom, &species.SowDayFrom)
assignIntPointer(input.SowMonthTo, &species.SowMonthTo)
assignIntPointer(input.SowDayTo, &species.SowDayTo)
assignIntPointer(input.PlantingMonthFrom, &species.PlantingMonthFrom)
assignIntPointer(input.PlantingDayFrom, &species.PlantingDayFrom)
assignIntPointer(input.PlantingMonthTo, &species.PlantingMonthTo)
assignIntPointer(input.PlantingDayTo, &species.PlantingDayTo)
assignIntPointer(input.HarvestMonthFrom, &species.HarvestMonthFrom)
assignIntPointer(input.HarvestDayFrom, &species.HarvestDayFrom)
assignIntPointer(input.HarvestMonthTo, &species.HarvestMonthTo)
assignIntPointer(input.HarvestDayTo, &species.HarvestDayTo)
if input.ClearSowRange {
species.SowMonthFrom, species.SowDayFrom, species.SowMonthTo, species.SowDayTo = nil, nil, nil, nil
}
if input.ClearHarvestRange {
species.HarvestMonthFrom, species.HarvestDayFrom, species.HarvestMonthTo, species.HarvestDayTo = nil, nil, nil, nil
}
if input.ClearPlantingRange {
species.PlantingMonthFrom, species.PlantingDayFrom, species.PlantingMonthTo, species.PlantingDayTo = nil, nil, nil, nil
}
if input.ClearSowDayFrom {
species.SowDayFrom = nil
}
if input.ClearSowDayTo {
species.SowDayTo = nil
}
if input.ClearPlantingDayFrom {
species.PlantingDayFrom = nil
}
if input.ClearPlantingDayTo {
species.PlantingDayTo = nil
}
if input.ClearHarvestDayFrom {
species.HarvestDayFrom = nil
}
if input.ClearHarvestDayTo {
species.HarvestDayTo = nil
}
if len(input.Attributes) != 0 {
species.Attributes = input.Attributes
}
}
func assignTrimmed(input *string, destination *string) {
if input != nil {
*destination = strings.TrimSpace(*input)
}
}
func assignStringPointer(input *string, destination **string) {
if input != nil {
value := strings.TrimSpace(*input)
if value == "" {
*destination = nil
} else {
*destination = &value
}
}
}
func assignIntPointer(input *int, destination **int) {
if input != nil {
*destination = input
}
}
func clearIntPointer(clear bool, destination **int) {
if clear {
*destination = nil
}
}
func (app *application) createSpeciesHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
var input speciesInput
if err := app.readJSON(w, r, &input); err != nil {
app.badRequestResponse(w, r, err)
return
}
member, _ := app.contextGetGardenMember(r)
user, _ := app.contextGetAuthenticatedUser(r)
if input.Global {
if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) {
app.permissionDeniedResponse(w, r)
return
}
} else if !member.Can(storage.GardenPermissionSpeciesWrite) {
app.permissionDeniedResponse(w, r)
return
}
var ownerGardenID *int
if !input.Global {
ownerGardenID = &gardenID
}
species := storage.Species{GardenID: ownerGardenID, Attributes: json.RawMessage(`{}`), CreatedBy: user.ID, UpdatedBy: user.ID}
input.apply(&species)
changedCategoryID := input.CategoryID
if input.ClearCategoryID {
changedCategoryID = nil
}
if !app.resolveSpeciesCategory(w, r, &species, changedCategoryID, nil) {
return
}
species.Tags = storage.NormalizeTags(input.Tags)
v := validate.New()
validateImageData(v, species.ImageData)
if storage.ValidateSpecies(v, species); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
imageID, err := app.resolveImage(gardenID, species.ImageData, input.ImageID, user.ID, "species")
if err != nil {
app.badRequestResponse(w, r, err)
return
}
species.ImageID = imageID
species.ImageData = ""
species, err = app.models.Species.Insert(species)
if err != nil {
app.respondToEntityModelError(w, r, err)
return
}
if err = app.syncSeasonTaskTemplates(gardenID, species); err != nil {
app.serverErrorResponse(w, r, err)
return
}
tagScope := gardenID
if species.GardenID == nil {
tagScope = 0
}
species.Tags, err = app.saveTags(tagScope, storage.TagEntitySpecies, species.ID, species.Tags)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/species/%d", gardenID, species.ID))
if err := app.writeJSON(w, http.StatusCreated, envelope{"species": species}, headers); err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) listSpeciesHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
allSpecies, err := app.models.Species.GetAllForGarden(gardenID)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
for i := range allSpecies {
tagScope := gardenID
if allSpecies[i].GardenID == nil {
tagScope = 0
}
allSpecies[i].Tags, err = app.loadTags(tagScope, storage.TagEntitySpecies, allSpecies[i].ID)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
}
if err := app.writeJSON(w, http.StatusOK, envelope{"species": allSpecies}, nil); err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) showSpeciesHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
species, err := app.models.Species.Get(gardenID, id)
if err != nil {
app.respondToEntityModelError(w, r, err)
return
}
tagScope := gardenID
if species.GardenID == nil {
tagScope = 0
}
species.Tags, err = app.loadTags(tagScope, storage.TagEntitySpecies, species.ID)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
if err := app.writeJSON(w, http.StatusOK, envelope{"species": species}, nil); err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) updateSpeciesHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
species, err := app.models.Species.Get(gardenID, id)
if err != nil {
app.respondToEntityModelError(w, r, err)
return
}
member, _ := app.contextGetGardenMember(r)
user, _ := app.contextGetAuthenticatedUser(r)
if species.GardenID == nil {
if !user.Can(storage.ApplicationPermissionGlobalSpeciesWrite) {
app.permissionDeniedResponse(w, r)
return
}
} else if *species.GardenID != gardenID {
app.notFoundResponse(w, r)
return
} else if !member.Can(storage.GardenPermissionSpeciesWrite) {
app.permissionDeniedResponse(w, r)
return
}
var input speciesInput
if err := app.readJSON(w, r, &input); err != nil {
app.badRequestResponse(w, r, err)
return
}
previousCategoryID := species.CategoryID
previousImageID := species.ImageID
previousImageData := species.ImageData
input.apply(&species)
species.UpdatedBy = user.ID
changedCategoryID := input.CategoryID
if input.ClearCategoryID {
changedCategoryID = nil
}
if !app.resolveSpeciesCategory(w, r, &species, changedCategoryID, previousCategoryID) {
return
}
if input.Tags != nil {
species.Tags = storage.NormalizeTags(input.Tags)
}
v := validate.New()
validateImageData(v, species.ImageData)
if storage.ValidateSpecies(v, species); !v.Valid() {
app.failedValidationResponse(w, r, v.Errors)
return
}
if input.ImageData != nil && *input.ImageData != previousImageData {
species.ImageID, err = app.resolveImage(gardenID, *input.ImageData, input.ImageID, user.ID, "species")
if err != nil {
app.badRequestResponse(w, r, err)
return
}
}
species.ImageData = ""
updateScope := gardenID
if species.GardenID == nil {
updateScope = 0
}
species, err = app.models.Species.Update(updateScope, species)
if err != nil {
app.respondToEntityModelError(w, r, err)
return
}
if err = app.syncSeasonTaskTemplates(gardenID, species); err != nil {
app.serverErrorResponse(w, r, err)
return
}
if err = app.recordImageAssignment(storage.ImageAssignment{GardenID: gardenID, EntityType: "species", EntityID: species.ID, PreviousImageID: previousImageID, ImageID: species.ImageID, ChangedBy: user.ID}); err != nil {
app.serverErrorResponse(w, r, err)
return
}
if input.Tags != nil {
species.Tags, err = app.saveTags(updateScope, storage.TagEntitySpecies, species.ID, species.Tags)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
}
if err := app.writeJSON(w, http.StatusOK, envelope{"species": species}, nil); err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) resolveSpeciesCategory(w http.ResponseWriter, r *http.Request, species *storage.Species, changedID, existingID *int) bool {
if changedID == nil {
return true
}
category, err := app.models.SpeciesCategories.Get(*changedID)
keepsInactiveCategory := existingID != nil && *existingID == *changedID
if err != nil || !category.Active && !keepsInactiveCategory {
app.failedValidationResponse(w, r, map[string]string{"category_id": "must reference an active category"})
return false
}
species.Category = category.Name
return true
}
func (app *application) deleteSpeciesHandler(w http.ResponseWriter, r *http.Request) {
gardenID, _ := app.readGardenIDParam(r)
id, err := app.readIDParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
species, writable := app.requireWritableSpecies(w, r, gardenID, id)
if !writable {
return
}
deleteScope := gardenID
if species.GardenID == nil {
deleteScope = 0
}
if err := app.models.Species.Delete(deleteScope, id); err != nil {
app.respondToEntityModelError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (app *application) respondToEntityModelError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, storage.ErrRecordNotFound):
app.notFoundResponse(w, r)
case errors.Is(err, storage.ErrEditConflict):
app.editConflictResponse(w, r)
case errors.Is(err, storage.ErrConflict):
app.conflictResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
}