Files
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

356 lines
11 KiB
Go

package web
import (
"errors"
"net/http"
"strconv"
"strings"
"gardomatic.kleiax.de/lib/client"
)
type locationForm struct {
CSRFToken string `form:"csrf_token"`
ParentID int `form:"parent_id"`
Name string `form:"name"`
Description string `form:"description"`
ImageData string `form:"image_data"`
ImageID int `form:"image_id"`
Kind string `form:"kind"`
AreaSQM string `form:"area_sqm"`
SunExposure string `form:"sun_exposure"`
SoilCondition string `form:"soil_condition"`
SoilReaction string `form:"soil_reaction"`
ReturnTo string `form:"return_to"`
Errors map[string]string
}
func (app *application) locations(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
}
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Filters = collectionFilters(r, "q", "kind", "sort")
locations = filterAndSortLocations(locations, data.Filters)
locationTree := buildLocationTree(locations)
locationTree, data.Pagination = paginateCollection(r, locationTree)
data.Garden, data.Locations, data.LocationTree = &garden, flattenLocationTree(locationTree), locationTree
app.render(w, http.StatusOK, "locations.tmpl", data)
}
func (app *application) locationCreate(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
form := locationForm{ReturnTo: safeLocationReturn(r.URL.Query().Get("return_to"), gardenID), Errors: make(map[string]string)}
app.renderLocationForm(w, r, gardenID, form, http.StatusOK, r.Header.Get("HX-Request") == "true")
}
func (app *application) locationCreatePost(w http.ResponseWriter, r *http.Request) {
app.locationSave(w, r)
}
func (app *application) locationEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
locationID, err := app.readPathID(r, "locationID")
if err != nil {
app.notFound(w)
return
}
location, _, err := client.FromContext(r.Context()).Location(r.Context(), gardenID, locationID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
form := locationForm{Name: location.Name, Description: location.Description, ImageData: location.ImageData, Kind: location.Kind, ReturnTo: webPath("locations", gardenID), Errors: map[string]string{}}
if location.ImageID != nil {
form.ImageID = *location.ImageID
}
if location.ParentID != nil {
form.ParentID = *location.ParentID
}
if location.AreaSQM != nil {
form.AreaSQM = strconv.FormatFloat(*location.AreaSQM, 'f', -1, 64)
}
if location.SunExposure != nil {
form.SunExposure = *location.SunExposure
}
if location.SoilCondition != nil {
form.SoilCondition = *location.SoilCondition
}
if location.SoilReaction != nil {
form.SoilReaction = *location.SoilReaction
}
app.renderLocationForm(w, r, gardenID, form, http.StatusOK, false, locationID)
}
func (app *application) locationEditPost(w http.ResponseWriter, r *http.Request) {
app.locationSave(w, r)
}
func (app *application) locationSave(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
locationID := 0
if id, readErr := app.readPathID(r, "locationID"); readErr == nil {
locationID = id
}
var form locationForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.Name, form.Description, form.Kind = strings.TrimSpace(form.Name), strings.TrimSpace(form.Description), strings.TrimSpace(form.Kind)
form.Errors = make(map[string]string)
if form.Name == "" {
form.Errors["name"] = "Ein Name ist erforderlich."
}
var area *float64
if strings.TrimSpace(form.AreaSQM) != "" {
value, parseErr := strconv.ParseFloat(strings.ReplaceAll(form.AreaSQM, ",", "."), 64)
if parseErr != nil || value < 0 {
form.Errors["area_sqm"] = "Die Fläche muss eine nichtnegative Zahl sein."
} else {
area = &value
}
}
if len(form.Errors) == 0 {
name, description, kind := form.Name, form.Description, form.Kind
imageData := form.ImageData
input := client.LocationInput{Name: &name, Description: &description, ImageData: &imageData, Kind: &kind, AreaSQM: area}
if form.ImageID > 0 {
imageID := form.ImageID
input.ImageID = &imageID
}
if form.ParentID > 0 {
parentID := form.ParentID
input.ParentID = &parentID
} else {
input.ClearParentID = true
}
if strings.TrimSpace(form.SunExposure) != "" {
exposure := strings.TrimSpace(form.SunExposure)
input.SunExposure = &exposure
}
input.SoilCondition = &form.SoilCondition
input.SoilReaction = &form.SoilReaction
var location client.Location
var createErr error
if locationID > 0 {
location, _, createErr = client.FromContext(r.Context()).UpdateLocation(r.Context(), gardenID, locationID, input)
} else {
location, _, createErr = client.FromContext(r.Context()).CreateLocation(r.Context(), gardenID, input)
}
if createErr == nil {
if r.Header.Get("HX-Request") == "true" && locationID == 0 {
w.Header().Set("HX-Trigger", "locationCreated")
data := app.newTemplateData(r)
data.Garden = &client.Garden{ID: gardenID}
data.Locations = []client.Location{location}
app.renderTemplate(w, http.StatusCreated, "location_form.tmpl", "location_option", data)
return
}
if locationID > 0 {
http.Redirect(w, r, webPath("locations", gardenID), http.StatusSeeOther)
return
}
returnTo := safeLocationReturn(form.ReturnTo, gardenID)
if returnTo == webPath("plant.new", gardenID) {
returnTo += "?location_id=" + strconv.Itoa(location.ID)
}
http.Redirect(w, r, returnTo, http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(createErr, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, createErr)
return
}
}
inline := r.Header.Get("HX-Request") == "true"
status := http.StatusUnprocessableEntity
if inline {
// htmx does not swap 422 responses by default. Retarget the validated
// dialog fragment while retaining 422 for the normal HTML form.
w.Header().Set("HX-Retarget", "#location-dialog-host")
w.Header().Set("HX-Reswap", "innerHTML")
status = http.StatusOK
}
app.renderLocationForm(w, r, gardenID, form, status, inline, locationID)
}
func (app *application) locationDelete(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
locationID, err := app.readPathID(r, "locationID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeleteLocation(r.Context(), gardenID, locationID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("locations", gardenID), http.StatusSeeOther)
}
func (app *application) renderLocationForm(w http.ResponseWriter, r *http.Request, gardenID int, form locationForm, status int, inline bool, locationIDs ...int) {
apiClient := client.FromContext(r.Context())
garden, _, err := apiClient.Garden(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Garden, data.Locations, data.Form = &garden, locations, form
data.Images, err = app.loadImageLibrary(r, gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
if len(locationIDs) > 0 && locationIDs[0] > 0 {
locationID := locationIDs[0]
data.LocationID = locationID
for _, location := range locations {
if location.ID == locationID {
data.LocationCreatedBy = location.CreatedBy
break
}
}
plants, _, plantsErr := apiClient.Plants(r.Context(), gardenID)
if plantsErr != nil {
app.handleAPIError(w, r, plantsErr)
return
}
for _, plant := range plants {
assignments, _, assignmentErr := apiClient.PlantLocations(r.Context(), gardenID, plant.ID)
if assignmentErr != nil {
app.handleAPIError(w, r, assignmentErr)
return
}
for _, assignment := range assignments {
if assignment.LocationID != locationID {
continue
}
item := locationPlant{Plant: plant, Assignment: assignment}
if assignment.RemovedAt == nil && !isHistoricalPlant(plant) {
data.LocationPlants = append(data.LocationPlants, item)
}
if isHistoricalPlant(plant) {
year := locationPlantHistoryYear(plant, assignment, data.CurrentYear)
for i := range data.LocationHistory {
if data.LocationHistory[i].Year == year {
data.LocationHistory[i].Plants = append(data.LocationHistory[i].Plants, item)
year = 0
break
}
}
if year != 0 {
for i := 0; i < len(data.LocationHistory); i++ {
if data.LocationHistory[i].Year < year {
data.LocationHistory = append(data.LocationHistory, locationPlantHistory{})
copy(data.LocationHistory[i+1:], data.LocationHistory[i:])
data.LocationHistory[i] = locationPlantHistory{Year: year, Plants: []locationPlant{item}}
year = 0
break
}
}
if year != 0 {
data.LocationHistory = append(data.LocationHistory, locationPlantHistory{Year: year, Plants: []locationPlant{item}})
}
}
}
}
}
for year := data.CurrentYear; year >= data.CurrentYear-2; year-- {
found := false
for _, history := range data.LocationHistory {
if history.Year == year {
found = true
break
}
}
if !found {
data.LocationHistory = append(data.LocationHistory, locationPlantHistory{Year: year})
}
}
// Keep the fixed three-year window in descending order.
for i := 0; i < len(data.LocationHistory); i++ {
for j := i + 1; j < len(data.LocationHistory); j++ {
if data.LocationHistory[j].Year > data.LocationHistory[i].Year {
data.LocationHistory[i], data.LocationHistory[j] = data.LocationHistory[j], data.LocationHistory[i]
}
}
}
}
if inline {
app.renderTemplate(w, status, "location_form.tmpl", "location_dialog", data)
} else {
app.render(w, status, "location_form.tmpl", data)
}
}
func isHistoricalPlant(plant client.Plant) bool {
return plant.Status == "dead" || plant.Status == "removed"
}
func locationPlantHistoryYear(plant client.Plant, assignment client.PlantLocation, currentYear int) int {
date := plant.RemovedAt
if date == nil {
date = assignment.RemovedAt
}
if date == nil && !plant.UpdatedAt.IsZero() {
date = &plant.UpdatedAt
}
if date == nil && !plant.CreatedAt.IsZero() {
date = &plant.CreatedAt
}
if date == nil {
return currentYear
}
if date.Year() < currentYear-2 || date.Year() > currentYear {
return 0
}
return date.Year()
}
func safeLocationReturn(value string, gardenID int) string {
plantForm := webPath("plant.new", gardenID)
if value == plantForm {
return value
}
return webPath("locations", gardenID)
}