@@ -0,0 +1,355 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user