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

526 lines
18 KiB
Go

package web
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"gardomatic.kleiax.de/lib/client"
)
type plantForm struct {
CSRFToken string `form:"csrf_token"`
Name string `form:"name"`
SpeciesID int `form:"species_id"`
LocationIDs []int `form:"location_id"`
Quantities []int `form:"quantity"`
AssignmentIDs []int `form:"assignment_id"`
Assignments []plantAssignmentForm `form:"-"`
TaskIDs []int `form:"plant_task_id"`
TaskRowKeys []string `form:"plant_task_row_key"`
TaskTitles []string `form:"plant_task_title"`
TaskDescriptions []string `form:"plant_task_description"`
TaskDueAtStarts []string `form:"plant_task_due_at_start"`
TaskDueAtEnds []string `form:"plant_task_due_at_end"`
TaskPriorities []int `form:"plant_task_priority"`
TaskPlantIDs []int `form:"plant_task_plant_id"`
TaskLocationIDs []int `form:"plant_task_location_id"`
TaskRecurrences []string `form:"plant_task_recurrence"`
TaskRecurrenceIntervals []int `form:"plant_task_recurrence_interval"`
TaskKeywords []string `form:"plant_task_keywords"`
TaskStatuses []string `form:"plant_task_status_on_completion"`
TaskActives []bool `form:"plant_task_active"`
TaskDeletes []bool `form:"plant_task_delete"`
Tasks []plantTaskForm `form:"-"`
Notes string `form:"notes"`
ImageData string `form:"image_data"`
ImageID int `form:"image_id"`
AcquiredAt string `form:"acquired_at"`
Status string `form:"status"`
Keywords string `form:"keywords"`
Errors map[string]string
}
type plantTaskForm struct {
ID, Priority, PlantID, LocationID, RecurrenceInterval int
RowKey string
Title, Description, Recurrence, Keywords, PlantStatusOnCompletion string
PendingPlantName string
DueAtStart, DueAtEnd string
Active, Delete bool
Errors map[string]string
}
type plantAssignmentForm struct {
AssignmentID int
LocationID int
Quantity int
}
func (form *plantForm) buildAssignments() {
form.Assignments = make([]plantAssignmentForm, len(form.LocationIDs))
for i, locationID := range form.LocationIDs {
form.Assignments[i].LocationID = locationID
if i < len(form.Quantities) {
form.Assignments[i].Quantity = form.Quantities[i]
}
if i < len(form.AssignmentIDs) {
form.Assignments[i].AssignmentID = form.AssignmentIDs[i]
}
}
}
func (form *plantForm) buildTasks() {
form.Tasks = make([]plantTaskForm, len(form.TaskTitles))
for i := range form.TaskTitles {
form.Tasks[i].Title = strings.TrimSpace(form.TaskTitles[i])
if i < len(form.TaskIDs) {
form.Tasks[i].ID = form.TaskIDs[i]
}
if i < len(form.TaskRowKeys) {
form.Tasks[i].RowKey = form.TaskRowKeys[i]
}
if i < len(form.TaskDescriptions) {
form.Tasks[i].Description = strings.TrimSpace(form.TaskDescriptions[i])
}
if i < len(form.TaskDueAtStarts) {
form.Tasks[i].DueAtStart = form.TaskDueAtStarts[i]
}
if i < len(form.TaskDueAtEnds) {
form.Tasks[i].DueAtEnd = form.TaskDueAtEnds[i]
}
if i < len(form.TaskPriorities) {
form.Tasks[i].Priority = form.TaskPriorities[i]
}
if i < len(form.TaskPlantIDs) {
form.Tasks[i].PlantID = form.TaskPlantIDs[i]
}
if i < len(form.TaskLocationIDs) {
form.Tasks[i].LocationID = form.TaskLocationIDs[i]
}
if i < len(form.TaskRecurrences) {
form.Tasks[i].Recurrence = form.TaskRecurrences[i]
}
if i < len(form.TaskRecurrenceIntervals) {
form.Tasks[i].RecurrenceInterval = form.TaskRecurrenceIntervals[i]
}
if i < len(form.TaskKeywords) {
form.Tasks[i].Keywords = form.TaskKeywords[i]
}
if i < len(form.TaskStatuses) {
form.Tasks[i].PlantStatusOnCompletion = form.TaskStatuses[i]
}
if i < len(form.TaskActives) {
form.Tasks[i].Active = form.TaskActives[i]
}
if i < len(form.TaskDeletes) {
form.Tasks[i].Delete = form.TaskDeletes[i]
}
if form.Tasks[i].RowKey == "" {
form.Tasks[i].RowKey = "task-" + strconv.Itoa(form.Tasks[i].ID) + "-" + strconv.Itoa(i)
}
}
}
func (app *application) plants(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
}
plants, _, err := apiClient.Plants(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.Garden = &garden
data.Plants = plants
data.Species = species
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.Locations = locations
data.LocationNames = make(map[int]string, len(locations))
for _, location := range locations {
data.LocationNames[location.ID] = location.Name
}
data.PlantLocations = make(map[int][]client.PlantLocation, len(plants))
for _, plant := range plants {
assignments, _, assignmentErr := apiClient.PlantLocations(r.Context(), gardenID, plant.ID)
if assignmentErr != nil {
app.handleAPIError(w, r, assignmentErr)
return
}
data.PlantLocations[plant.ID] = assignments
}
data.Filters = collectionFilters(r, "q", "status", "species", "location", "sort")
data.Plants = filterAndSortPlants(plants, data.PlantLocations, data.Filters)
data.Plants, data.Pagination = paginateCollection(r, data.Plants)
app.render(w, http.StatusOK, "plant.tmpl", data)
}
func (app *application) plantCreate(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
locationID, _ := strconv.Atoi(r.URL.Query().Get("location_id"))
app.renderPlantForm(w, r, gardenID, plantForm{Status: "alive", Assignments: []plantAssignmentForm{{LocationID: locationID, Quantity: 1}}, Errors: make(map[string]string)}, http.StatusOK)
}
func (app *application) plantCreatePost(w http.ResponseWriter, r *http.Request) {
app.plantSave(w, r)
}
func (app *application) plantEdit(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
apiClient := client.FromContext(r.Context())
plant, _, err := apiClient.Plant(r.Context(), gardenID, plantID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
form := plantForm{Name: plant.Name, Notes: plant.Notes, ImageData: plant.ImageData, Status: plant.Status, Keywords: strings.Join(plant.Tags, ", "), Errors: map[string]string{}}
if plant.ImageID != nil {
form.ImageID = *plant.ImageID
}
if plant.SpeciesID != nil {
form.SpeciesID = *plant.SpeciesID
}
if plant.AcquiredAt != nil {
form.AcquiredAt = plant.AcquiredAt.Format("2006-01-02")
}
assignments, _, err := apiClient.PlantLocations(r.Context(), gardenID, plantID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
for _, assignment := range assignments {
form.Assignments = append(form.Assignments, plantAssignmentForm{AssignmentID: assignment.ID, LocationID: assignment.LocationID, Quantity: assignment.Quantity})
}
if len(form.Assignments) == 0 {
form.Assignments = []plantAssignmentForm{{Quantity: 1}}
}
app.renderPlantForm(w, r, gardenID, form, http.StatusOK, plantID)
}
func (app *application) plantEditPost(w http.ResponseWriter, r *http.Request) { app.plantSave(w, r) }
func (app *application) plantSave(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID := 0
if id, readErr := app.readPathID(r, "plantID"); readErr == nil {
plantID = id
}
var form plantForm
if err := app.decodePostForm(r, &form); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.buildAssignments()
form.buildTasks()
form.Name = strings.TrimSpace(form.Name)
form.Notes = strings.TrimSpace(form.Notes)
form.Errors = make(map[string]string)
if form.Name == "" {
form.Errors["name"] = "Ein Name ist erforderlich."
}
if form.Status == "" {
form.Status = "alive"
}
for i, assignment := range form.Assignments {
if assignment.LocationID > 0 && assignment.Quantity < 1 {
form.Errors["quantity_"+strconv.Itoa(i)] = "Die Anzahl muss mindestens 1 sein."
}
}
for i, task := range form.Tasks {
if task.Delete {
continue
}
if task.Title == "" {
form.Errors["plant_task_title_"+strconv.Itoa(i)] = "Ein Titel ist erforderlich."
}
start, _ := parseTaskTime(task.DueAtStart, "plant_task_due_at_start_"+strconv.Itoa(i), form.Errors)
end, _ := parseTaskTime(task.DueAtEnd, "plant_task_due_at_end_"+strconv.Itoa(i), form.Errors)
if start != nil && end != nil && start.After(*end) {
form.Errors["plant_task_due_at_end_"+strconv.Itoa(i)] = "Das Ende darf nicht vor dem Beginn liegen."
}
}
var acquiredAt *time.Time
if form.AcquiredAt != "" {
parsed, parseErr := time.Parse("2006-01-02", form.AcquiredAt)
if parseErr != nil {
form.Errors["acquired_at"] = "Das Datum ist ungültig."
} else {
acquiredAt = &parsed
}
}
if len(form.Errors) == 0 {
name, notes, status := form.Name, form.Notes, form.Status
imageData := form.ImageData
input := client.PlantInput{Name: &name, Notes: &notes, ImageData: &imageData, Status: &status, AcquiredAt: acquiredAt, ClearAcquiredAt: acquiredAt == nil, Tags: parseKeywords(form.Keywords)}
if form.ImageID > 0 {
imageID := form.ImageID
input.ImageID = &imageID
}
if form.SpeciesID > 0 {
speciesID := form.SpeciesID
input.SpeciesID = &speciesID
} else {
input.ClearSpeciesID = true
}
apiClient := client.FromContext(r.Context())
var plant client.Plant
if plantID > 0 {
plant, _, err = apiClient.UpdatePlant(r.Context(), gardenID, plantID, input)
} else {
plant, _, err = apiClient.CreatePlant(r.Context(), gardenID, input)
}
if err == nil {
for _, assignment := range form.Assignments {
if assignment.LocationID == 0 {
if assignment.AssignmentID > 0 {
_, err = apiClient.DeletePlantLocation(r.Context(), gardenID, plant.ID, assignment.AssignmentID)
}
} else {
locationID, quantity := assignment.LocationID, assignment.Quantity
assignmentInput := client.PlantLocationInput{LocationID: &locationID, Quantity: &quantity, PlantedAt: acquiredAt, ClearPlantedAt: acquiredAt == nil}
if assignment.AssignmentID > 0 {
_, _, err = apiClient.UpdatePlantLocation(r.Context(), gardenID, plant.ID, assignment.AssignmentID, assignmentInput)
} else {
_, _, err = apiClient.CreatePlantLocation(r.Context(), gardenID, plant.ID, assignmentInput)
}
}
if err != nil {
break
}
}
if err == nil {
err = app.savePlantTasks(r, gardenID, plant.ID, form.Tasks)
}
if err == nil && form.SpeciesID > 0 {
err = app.savePlantTemplateStates(r, gardenID, plant.ID, form.SpeciesID)
}
if err != nil && plantID == 0 {
_, _ = apiClient.DeletePlant(r.Context(), gardenID, plant.ID)
}
}
if err == nil {
http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther)
return
}
var apiError *client.APIError
if errors.As(err, &apiError) && apiError.StatusCode == http.StatusUnprocessableEntity {
form.Errors = apiError.Validation
} else {
app.handleAPIError(w, r, err)
return
}
}
app.renderPlantForm(w, r, gardenID, form, http.StatusUnprocessableEntity, plantID)
}
func (app *application) plantStatusPost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
if err = r.ParseForm(); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
status := r.PostForm.Get("status")
if status != "alive" && status != "dead" && status != "removed" && status != "infested" && status != "harvested" && status != "dormant" {
app.clientError(w, http.StatusUnprocessableEntity)
return
}
if _, _, err = client.FromContext(r.Context()).UpdatePlant(r.Context(), gardenID, plantID, client.PlantInput{Status: &status}); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther)
}
func (app *application) plantDelete(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
if _, err := client.FromContext(r.Context()).DeletePlant(r.Context(), gardenID, plantID); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("plants", gardenID), http.StatusSeeOther)
}
func (app *application) plantTemplateOptOutPost(w http.ResponseWriter, r *http.Request) {
gardenID, err := app.readPathID(r, "gardenID")
if err != nil {
app.notFound(w)
return
}
plantID, err := app.readPathID(r, "plantID")
if err != nil {
app.notFound(w)
return
}
templateID, err := app.readPathID(r, "templateID")
if err != nil {
app.notFound(w)
return
}
if err = r.ParseForm(); err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
optedOut := r.PostForm.Get("opted_out") != "false"
if _, err = client.FromContext(r.Context()).SetTaskTemplateOptOut(r.Context(), gardenID, plantID, templateID, optedOut); err != nil {
app.handleAPIError(w, r, err)
return
}
http.Redirect(w, r, webPath("plant.edit", gardenID, plantID), http.StatusSeeOther)
}
func (app *application) renderPlantForm(w http.ResponseWriter, r *http.Request, gardenID int, form plantForm, status int, plantIDs ...int) {
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.Garden = &garden
data.Species = species
priorities, _, err := apiClient.TaskPriorities(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.TaskPriorities = priorities
locations, _, err := apiClient.Locations(r.Context(), gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.Locations = locations
images, err := app.loadImageLibrary(r, gardenID)
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.Images = images
data.Form = form
data.SpeciesID = form.SpeciesID
data.TaskTemplates = map[int][]client.SpeciesTaskTemplate{}
data.TemplateOptOuts = map[int]bool{}
if len(plantIDs) > 0 {
data.PlantID = plantIDs[0]
plant, _, plantErr := apiClient.Plant(r.Context(), gardenID, data.PlantID)
if plantErr != nil {
app.handleAPIError(w, r, plantErr)
return
}
data.PlantCreatedBy = plant.CreatedBy
if form.Tasks == nil {
tasks, _, taskErr := apiClient.Tasks(r.Context(), gardenID)
var apiError *client.APIError
if taskErr != nil && !(errors.As(taskErr, &apiError) && apiError.StatusCode == http.StatusNotFound) {
app.handleAPIError(w, r, taskErr)
return
}
for _, task := range tasks {
if task.PlantID == nil || *task.PlantID != data.PlantID || task.TemplateID != nil {
continue
}
active := task.Active == nil || *task.Active
item := plantTaskForm{ID: task.ID, PlantID: data.PlantID, RowKey: "task-" + strconv.Itoa(task.ID), Title: task.Title, Description: task.Description, Priority: task.Priority, Active: active, Recurrence: task.Recurrence, RecurrenceInterval: task.RecurrenceInterval, Keywords: strings.Join(task.Tags, ", ")}
if task.LocationID != nil {
item.LocationID = *task.LocationID
}
if task.PlantStatusOnCompletion != nil {
item.PlantStatusOnCompletion = *task.PlantStatusOnCompletion
}
if task.DueAtStart != nil {
item.DueAtStart = task.DueAtStart.Local().Format("2006-01-02")
}
if task.DueAtEnd != nil {
item.DueAtEnd = task.DueAtEnd.Local().Format("2006-01-02")
}
form.Tasks = append(form.Tasks, item)
}
data.Form = form
}
if form.SpeciesID > 0 {
ids, _, optErr := apiClient.TaskTemplateOptOuts(r.Context(), gardenID, data.PlantID)
if optErr != nil {
app.handleAPIError(w, r, optErr)
return
}
for _, id := range ids {
data.TemplateOptOuts[id] = true
}
}
}
if form.SpeciesID > 0 {
templates, _, templateErr := apiClient.SpeciesTaskTemplates(r.Context(), gardenID, form.SpeciesID)
if templateErr != nil {
app.handleAPIError(w, r, templateErr)
return
}
data.TaskTemplates[form.SpeciesID] = templates
for _, item := range templates {
if values, present := r.PostForm["plant_template_active_"+strconv.Itoa(item.ID)]; present {
data.TemplateOptOuts[item.ID] = len(values) == 0 || values[len(values)-1] != "true"
}
}
}
app.render(w, status, "plant_form.tmpl", data)
}