423 lines
14 KiB
Go
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)
|
|
}
|
|
}
|