393 lines
15 KiB
Go
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)
|
|
}
|