526 lines
18 KiB
Go
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: ¬es, 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)
|
|
}
|