Files
Gardomatic/internal/web/species.go
T
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

393 lines
15 KiB
Go

package web
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
type speciesForm struct {
Global bool `form:"global"`
CommonName string `form:"common_name"`
Cultivar string `form:"cultivar"`
BotanicalName string `form:"botanical_name"`
CategoryID int `form:"category_id"`
SunExposure string `form:"sun_exposure"`
SoilCondition string `form:"soil_condition"`
SoilReaction string `form:"soil_reaction"`
WinterProtection string `form:"winter_protection"`
SpacingCM int `form:"spacing_cm"`
HeightCM int `form:"height_cm"`
Notes string `form:"notes"`
ImageData string `form:"image_data"`
ImageID int `form:"image_id"`
Attributes string `form:"attributes"`
TemplateSpeciesID int `form:"template_species_id"`
Keywords string `form:"keywords"`
SowMonthFrom int `form:"sow_month_from"`
SowDayFrom int `form:"sow_day_from"`
SowMonthTo int `form:"sow_month_to"`
SowDayTo int `form:"sow_day_to"`
PlantingMonthFrom int `form:"planting_month_from"`
PlantingDayFrom int `form:"planting_day_from"`
PlantingMonthTo int `form:"planting_month_to"`
PlantingDayTo int `form:"planting_day_to"`
HarvestMonthFrom int `form:"harvest_month_from"`
HarvestDayFrom int `form:"harvest_day_from"`
HarvestMonthTo int `form:"harvest_month_to"`
HarvestDayTo int `form:"harvest_day_to"`
SowDuration int `form:"sow_duration"`
SowDurationUnit string `form:"sow_duration_unit"`
PlantingDuration int `form:"planting_duration"`
PlantingDurationUnit string `form:"planting_duration_unit"`
HarvestDuration int `form:"harvest_duration"`
HarvestDurationUnit string `form:"harvest_duration_unit"`
Errors map[string]string
}
func (app *application) speciesList(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
species, _, err := apiClient.SpeciesForGarden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Filters = collectionFilters(r, "q", "origin", "sort")
data.Garden, data.Species = &garden, filterAndSortSpecies(species, data.Filters)
data.Species, data.Pagination = paginateCollection(r, data.Species)
app.render(w, http.StatusOK, "species.tmpl", data)
}
func (app *application) speciesCreate(w http.ResponseWriter, r *http.Request) {
form := speciesForm{SowDayFrom: 1, PlantingDayFrom: 1, HarvestDayFrom: 1, SowDuration: 1, PlantingDuration: 1, HarvestDuration: 1, SowDurationUnit: "week", PlantingDurationUnit: "week", HarvestDurationUnit: "week", Errors: map[string]string{}}
if templateID, _ := strconv.Atoi(r.URL.Query().Get("template_id")); templateID > 0 {
gardenID, err := app.readPathID(r, "gardenID")
if err == nil {
if source, _, loadErr := client.FromContext(r.Context()).Species(r.Context(), gardenID, templateID); loadErr == nil {
form = speciesFormFromSpecies(source)
form.CommonName = ""
form.Global = false
form.TemplateSpeciesID = source.ID
} else {
app.handleAPIError(w, r, loadErr)
return
}
}
}
app.renderSpeciesForm(w, r, form, http.StatusOK, 0)
}
func (app *application) speciesEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
speciesID, err := app.readPathID(r, "speciesID")
if err != nil {
app.notFound(w)
return
}
value, _, err := client.FromContext(r.Context()).Species(r.Context(), gardenID, speciesID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if value.GardenID != nil && *value.GardenID != gardenID {
app.notFound(w)
return
}
form := speciesFormFromSpecies(value)
app.renderSpeciesForm(w, r, form, http.StatusOK, speciesID)
}
func speciesFormFromSpecies(value client.Species) speciesForm {
form := speciesForm{Global: value.GardenID == nil, CommonName: value.CommonName, Cultivar: value.Cultivar, BotanicalName: value.BotanicalName, Notes: value.Notes, ImageData: value.ImageData, Attributes: string(value.Attributes), Keywords: strings.Join(value.Tags, ", "), Errors: map[string]string{}}
if value.ImageID != nil {
form.ImageID = *value.ImageID
}
if value.SunExposure != nil {
form.SunExposure = *value.SunExposure
}
if value.SoilCondition != nil {
form.SoilCondition = *value.SoilCondition
}
if value.SoilReaction != nil {
form.SoilReaction = *value.SoilReaction
}
if value.WinterProtection != nil {
form.WinterProtection = *value.WinterProtection
}
copyOptionalInt(value.SpacingCM, &form.SpacingCM)
copyOptionalInt(value.HeightCM, &form.HeightCM)
copyOptionalInt(value.CategoryID, &form.CategoryID)
copyOptionalInt(value.SowMonthFrom, &form.SowMonthFrom)
copyOptionalInt(value.SowDayFrom, &form.SowDayFrom)
copyOptionalInt(value.SowMonthTo, &form.SowMonthTo)
copyOptionalInt(value.SowDayTo, &form.SowDayTo)
copyOptionalInt(value.PlantingMonthFrom, &form.PlantingMonthFrom)
copyOptionalInt(value.PlantingDayFrom, &form.PlantingDayFrom)
copyOptionalInt(value.PlantingMonthTo, &form.PlantingMonthTo)
copyOptionalInt(value.PlantingDayTo, &form.PlantingDayTo)
copyOptionalInt(value.HarvestMonthFrom, &form.HarvestMonthFrom)
copyOptionalInt(value.HarvestDayFrom, &form.HarvestDayFrom)
copyOptionalInt(value.HarvestMonthTo, &form.HarvestMonthTo)
copyOptionalInt(value.HarvestDayTo, &form.HarvestDayTo)
form.SowDuration, form.SowDurationUnit = rangeDuration(value.SowMonthFrom, value.SowDayFrom, value.SowMonthTo, value.SowDayTo)
form.PlantingDuration, form.PlantingDurationUnit = rangeDuration(value.PlantingMonthFrom, value.PlantingDayFrom, value.PlantingMonthTo, value.PlantingDayTo)
form.HarvestDuration, form.HarvestDurationUnit = rangeDuration(value.HarvestMonthFrom, value.HarvestDayFrom, value.HarvestMonthTo, value.HarvestDayTo)
return form
}
func (app *application) speciesSave(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
speciesID := 0
if id, readErr := app.readPathID(r, "speciesID"); readErr == nil {
speciesID = id
}
var form speciesForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.CommonName, form.Cultivar, form.BotanicalName, form.Notes = strings.TrimSpace(form.CommonName), strings.TrimSpace(form.Cultivar), strings.TrimSpace(form.BotanicalName), strings.TrimSpace(form.Notes)
form.Errors = map[string]string{}
if form.CommonName == "" {
form.Errors["common_name"] = "Ein Name ist erforderlich."
}
input := speciesInput(form)
if len(form.Errors) == 0 {
apiClient := client.FromContext(r.Context())
var saveErr error
savedSpeciesID := speciesID
if speciesID > 0 {
_, _, saveErr = apiClient.UpdateSpecies(r.Context(), gardenID, speciesID, input)
} else {
var created client.Species
created, _, saveErr = apiClient.CreateSpecies(r.Context(), gardenID, input)
savedSpeciesID = created.ID
if saveErr == nil && form.TemplateSpeciesID > 0 {
saveErr = app.copySpeciesTaskTemplates(r, apiClient, gardenID, form.TemplateSpeciesID, created.ID)
if saveErr != nil {
_, _ = apiClient.DeleteSpecies(r.Context(), gardenID, created.ID)
}
}
}
if saveErr == nil {
if r.FormValue("continue") == "care" {
http.Redirect(w, r, pathWithQuery(webPath("species.edit", gardenID, savedSpeciesID), "step", "care"), http.StatusSeeOther)
return
}
http.Redirect(w, r, webPath("species", gardenID), http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(saveErr, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, saveErr)
return
}
}
app.renderSpeciesForm(w, r, form, http.StatusUnprocessableEntity, speciesID)
}
func speciesInput(form speciesForm) client.SpeciesInput {
input := client.SpeciesInput{Global: form.Global, CommonName: &form.CommonName, Cultivar: &form.Cultivar, BotanicalName: &form.BotanicalName, Notes: &form.Notes, ImageData: &form.ImageData, Tags: parseKeywords(form.Keywords)}
if form.ImageID > 0 {
imageID := form.ImageID
input.ImageID = &imageID
}
input.SunExposure, input.SoilCondition, input.SoilReaction, input.WinterProtection = &form.SunExposure, &form.SoilCondition, &form.SoilReaction, &form.WinterProtection
if form.SpacingCM > 0 {
input.SpacingCM = &form.SpacingCM
}
if form.HeightCM > 0 {
input.HeightCM = &form.HeightCM
}
if json.Valid([]byte(form.Attributes)) {
input.Attributes = json.RawMessage(form.Attributes)
}
if form.CategoryID > 0 {
input.CategoryID = &form.CategoryID
} else {
input.ClearCategoryID = true
}
assignPositive := func(value int) *int {
if value > 0 {
return &value
}
return nil
}
form.SowMonthTo, form.SowDayTo = rangeEnd(form.SowMonthFrom, form.SowDayFrom, form.SowDuration, form.SowDurationUnit)
form.PlantingMonthTo, form.PlantingDayTo = rangeEnd(form.PlantingMonthFrom, form.PlantingDayFrom, form.PlantingDuration, form.PlantingDurationUnit)
form.HarvestMonthTo, form.HarvestDayTo = rangeEnd(form.HarvestMonthFrom, form.HarvestDayFrom, form.HarvestDuration, form.HarvestDurationUnit)
input.SowMonthFrom, input.SowDayFrom, input.SowMonthTo, input.SowDayTo = assignPositive(form.SowMonthFrom), assignPositive(form.SowDayFrom), assignPositive(form.SowMonthTo), assignPositive(form.SowDayTo)
input.PlantingMonthFrom, input.PlantingDayFrom, input.PlantingMonthTo, input.PlantingDayTo = assignPositive(form.PlantingMonthFrom), assignPositive(form.PlantingDayFrom), assignPositive(form.PlantingMonthTo), assignPositive(form.PlantingDayTo)
input.HarvestMonthFrom, input.HarvestDayFrom, input.HarvestMonthTo, input.HarvestDayTo = assignPositive(form.HarvestMonthFrom), assignPositive(form.HarvestDayFrom), assignPositive(form.HarvestMonthTo), assignPositive(form.HarvestDayTo)
input.ClearSowRange = form.SowMonthFrom == 0 && form.SowMonthTo == 0
input.ClearPlantingRange = form.PlantingMonthFrom == 0 && form.PlantingMonthTo == 0
input.ClearHarvestRange = form.HarvestMonthFrom == 0 && form.HarvestMonthTo == 0
input.ClearSowDayFrom, input.ClearSowDayTo = form.SowDayFrom == 0, form.SowDayTo == 0
input.ClearPlantingDayFrom, input.ClearPlantingDayTo = form.PlantingDayFrom == 0, form.PlantingDayTo == 0
input.ClearHarvestDayFrom, input.ClearHarvestDayTo = form.HarvestDayFrom == 0, form.HarvestDayTo == 0
return input
}
func rangeEnd(month, day, duration int, unit string) (int, int) {
if month < 1 {
return 0, 0
}
if day < 1 {
day = 1
}
start := time.Date(2024, time.Month(month), day, 0, 0, 0, 0, time.UTC)
switch unit {
case "month":
start = start.AddDate(0, duration, 0)
case "week":
start = start.AddDate(0, 0, 7*duration)
default:
start = start.AddDate(0, 0, duration)
}
return int(start.Month()), start.Day()
}
func rangeDuration(fromMonth, fromDay, toMonth, toDay *int) (int, string) {
if fromMonth == nil || toMonth == nil {
return 1, "week"
}
fd, td := 1, 1
if fromDay != nil {
fd = *fromDay
}
if toDay != nil {
td = *toDay
}
from := time.Date(2024, time.Month(*fromMonth), fd, 0, 0, 0, 0, time.UTC)
to := time.Date(2024, time.Month(*toMonth), td, 0, 0, 0, 0, time.UTC)
if to.Before(from) {
to = to.AddDate(1, 0, 0)
}
days := int(to.Sub(from).Hours() / 24)
if days%7 == 0 && days > 0 {
return days / 7, "week"
}
return days, "day"
}
func (app *application) copySpeciesTaskTemplates(r *http.Request, apiClient *client.Client, gardenID, sourceID, targetID int) error {
templates, _, err := apiClient.SpeciesTaskTemplates(r.Context(), gardenID, sourceID)
if err != nil {
return err
}
for _, item := range templates {
if item.Origin != "" && item.Origin != "manual" {
continue
}
title, description, triggerType := item.Title, item.Description, item.TriggerType
triggerOffset, triggerOffsetUnit := item.TriggerOffset, item.TriggerOffsetUnit
duration, durationUnit := item.Duration, item.DurationUnit
recurrence, recurrenceInterval, priority, active := item.Recurrence, item.RecurrenceInterval, item.Priority, item.Active
input := client.SpeciesTaskTemplateInput{
Title: &title, Description: &description, TriggerType: &triggerType,
MonthFrom: item.MonthFrom, DayFrom: item.DayFrom, MonthTo: item.MonthTo, DayTo: item.DayTo,
OffsetDaysFrom: item.OffsetDaysFrom, OffsetDaysTo: item.OffsetDaysTo, IntervalDays: item.IntervalDays,
TriggerOffset: &triggerOffset, TriggerOffsetUnit: &triggerOffsetUnit,
Duration: &duration, DurationUnit: &durationUnit, Recurrence: &recurrence,
RecurrenceInterval: &recurrenceInterval, Priority: &priority, Active: &active,
}
if _, _, err = apiClient.CreateSpeciesTaskTemplate(r.Context(), gardenID, targetID, input); err != nil {
return err
}
}
return nil
}
func (app *application) speciesDelete(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
speciesID, err := app.readPathID(r, "speciesID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteSpecies(r.Context(), gardenID, speciesID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("species", gardenID), http.StatusSeeOther)
}
func (app *application) renderSpeciesForm(w http.ResponseWriter, r *http.Request, form speciesForm, status, speciesID int) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden, data.Form, data.SpeciesID, data.SpeciesGlobal = &garden, form, speciesID, form.Global
data.WizardStep = r.URL.Query().Get("step")
if speciesID > 0 && data.WizardStep == "" {
data.WizardStep = "all"
} else if data.WizardStep != "general" && data.WizardStep != "care" && data.WizardStep != "tasks" {
data.WizardStep = "general"
}
data.Images, err = app.loadImageLibrary(r, gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.TagSuggestions = app.loadTagSuggestions(r, gardenID)
categories, _, err := apiClient.SpeciesCategories(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.SpeciesCategories = categories
if speciesID == 0 {
data.Species, _, err = apiClient.SpeciesForGarden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
}
if speciesID > 0 {
templates, _, err := apiClient.SpeciesTaskTemplates(r.Context(), gardenID, speciesID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.TaskTemplates = map[int][]client.SpeciesTaskTemplate{speciesID: templates}
data.CareInstructions, _, err = apiClient.CareInstructions(r.Context(), gardenID, speciesID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
}
app.render(w, status, "species_form.tmpl", data)
}