src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt

Tue, 03 Jan 2023 18:25:51 +0100

author
Mike Becker <universe@uap-core.de>
date
Tue, 03 Jan 2023 18:25:51 +0100
changeset 267
d8ec2d8ffa82
parent 266
65c72e65ff67
child 268
ca5501d851fa
permissions
-rw-r--r--

fix default sort criteria

/*
 * Copyright 2021 Mike Becker. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright
 * notice, this list of conditions and the following disclaimer in the
 * documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package de.uapcore.lightpit.servlet

import de.uapcore.lightpit.AbstractServlet
import de.uapcore.lightpit.HttpRequest
import de.uapcore.lightpit.boolValidator
import de.uapcore.lightpit.dao.DataAccessObject
import de.uapcore.lightpit.dateOptValidator
import de.uapcore.lightpit.entities.*
import de.uapcore.lightpit.types.*
import de.uapcore.lightpit.viewmodel.*
import jakarta.servlet.annotation.WebServlet
import java.sql.Date

@WebServlet(urlPatterns = ["/projects/*"])
class ProjectServlet : AbstractServlet() {

    init {
        get("/", this::projects)
        get("/%project", this::project)
        get("/%project/issues/%version/%component/", this::project)
        get("/%project/edit", this::projectForm)
        get("/-/create", this::projectForm)
        post("/-/commit", this::projectCommit)

        get("/%project/versions/", this::versions)
        get("/%project/versions/%version/edit", this::versionForm)
        get("/%project/versions/-/create", this::versionForm)
        post("/%project/versions/-/commit", this::versionCommit)

        get("/%project/components/", this::components)
        get("/%project/components/%component/edit", this::componentForm)
        get("/%project/components/-/create", this::componentForm)
        post("/%project/components/-/commit", this::componentCommit)

        get("/%project/issues/%version/%component/%issue", this::issue)
        get("/%project/issues/%version/%component/%issue/edit", this::issueForm)
        post("/%project/issues/%version/%component/%issue/comment", this::issueComment)
        post("/%project/issues/%version/%component/%issue/relation", this::issueRelation)
        get("/%project/issues/%version/%component/%issue/removeRelation", this::issueRemoveRelation)
        get("/%project/issues/%version/%component/-/create", this::issueForm)
        post("/%project/issues/%version/%component/-/commit", this::issueCommit)
    }

    private fun projects(http: HttpRequest, dao: DataAccessObject) {
        val projects = dao.listProjects()
        val projectInfos = projects.map {
            ProjectInfo(
                project = it,
                versions = dao.listVersions(it),
                components = emptyList(), // not required in this view
                issueSummary = dao.collectIssueSummary(it)
            )
        }

        with(http) {
            view = ProjectsView(projectInfos)
            navigationMenu = projectNavMenu(projects)
            styleSheets = listOf("projects")
            render("projects")
        }
    }

    private fun activeProjectNavMenu(
        projects: List<Project>,
        projectInfo: ProjectInfo,
        selectedVersion: Version? = null,
        selectedComponent: Component? = null
    ) =
        projectNavMenu(
            projects,
            projectInfo.versions,
            projectInfo.components,
            projectInfo.project,
            selectedVersion,
            selectedComponent
        )

    private sealed interface LookupResult<T>
    private class NotFound<T> : LookupResult<T>
    private data class Found<T>(val elem: T?) : LookupResult<T>

    private fun <T : HasNode> HttpRequest.lookupPathParam(paramName: String, list: List<T>): LookupResult<T> {
        val node = pathParams[paramName]
        return if (node == null || node == "-") {
            Found(null)
        } else {
            val result = list.find { it.node == node }
            if (result == null) {
                NotFound()
            } else {
                Found(result)
            }
        }
    }

    private fun obtainProjectInfo(http: HttpRequest, dao: DataAccessObject): ProjectInfo? {
        val project = dao.findProjectByNode(http.pathParams["project"] ?: "") ?: return null

        val versions: List<Version> = dao.listVersions(project)
        val components: List<Component> = dao.listComponents(project)

        return ProjectInfo(
            project,
            versions,
            components,
            dao.collectIssueSummary(project)
        )
    }

    private fun sanitizeNode(name: String): String {
        val san = name.replace(Regex("[/\\\\]"), "-")
        return if (san.startsWith(".")) {
            "v$san"
        } else {
            san
        }
    }

    private fun feedPath(project: Project) = "feed/${project.node}/issues.rss"

    private data class PathInfos(
        val projectInfo: ProjectInfo,
        val version: Version?,
        val component: Component?
    ) {
        val project = projectInfo.project
        val issuesHref by lazyOf("projects/${project.node}/issues/${version?.node ?: "-"}/${component?.node ?: "-"}/")
    }

    private fun withPathInfo(http: HttpRequest, dao: DataAccessObject): PathInfos? {
        val projectInfo = obtainProjectInfo(http, dao)
        if (projectInfo == null) {
            http.response.sendError(404)
            return null
        }

        val version = when (val result = http.lookupPathParam("version", projectInfo.versions)) {
            is NotFound -> {
                http.response.sendError(404)
                return null
            }
            is Found -> {
                result.elem
            }
        }
        val component = when (val result = http.lookupPathParam("component", projectInfo.components)) {
            is NotFound -> {
                http.response.sendError(404)
                return null
            }
            is Found -> {
                result.elem
            }
        }

        return PathInfos(projectInfo, version, component)
    }

    private fun project(http: HttpRequest, dao: DataAccessObject) {
        withPathInfo(http, dao)?.run {

            val issues = dao.listIssues(project, version, component)
                .sortedWith(
                    IssueSorter(
                        IssueSorter.Criteria(IssueSorter.Field.DONE),
                        IssueSorter.Criteria(IssueSorter.Field.ETA),
                        IssueSorter.Criteria(IssueSorter.Field.UPDATED, false)
                    )
                )

            with(http) {
                pageTitle = project.name
                view = ProjectDetails(projectInfo, issues, version, component)
                feedPath = feedPath(project)
                navigationMenu = activeProjectNavMenu(
                    dao.listProjects(),
                    projectInfo,
                    version,
                    component
                )
                styleSheets = listOf("projects")
                javascript = "project-details"
                render("project-details")
            }
        }
    }

    private fun projectForm(http: HttpRequest, dao: DataAccessObject) {
        if (!http.pathParams.containsKey("project")) {
            http.view = ProjectEditView(Project(-1), dao.listUsers())
            http.navigationMenu = projectNavMenu(dao.listProjects())
        } else {
            val projectInfo = obtainProjectInfo(http, dao)
            if (projectInfo == null) {
                http.response.sendError(404)
                return
            }
            http.view = ProjectEditView(projectInfo.project, dao.listUsers())
            http.navigationMenu = activeProjectNavMenu(
                dao.listProjects(),
                projectInfo
            )
        }
        http.styleSheets = listOf("projects")
        http.render("project-form")
    }

    private fun projectCommit(http: HttpRequest, dao: DataAccessObject) {
        val project = Project(http.param("id")?.toIntOrNull() ?: -1).apply {
            name = http.param("name") ?: ""
            node = http.param("node") ?: ""
            description = http.param("description") ?: ""
            ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
            repoUrl = http.param("repoUrl") ?: ""
            owner = (http.param("owner")?.toIntOrNull() ?: -1).let {
                if (it < 0) null else dao.findUser(it)
            }
            // intentional defaults
            if (node.isBlank()) node = name
            // sanitizing
            node = sanitizeNode(node)
        }

        if (project.id < 0) {
            dao.insertProject(project)
        } else {
            dao.updateProject(project)
        }

        http.renderCommit("projects/${project.node}")
    }

    private fun versions(http: HttpRequest, dao: DataAccessObject) {
        val projectInfo = obtainProjectInfo(http, dao)
        if (projectInfo == null) {
            http.response.sendError(404)
            return
        }

        with(http) {
            pageTitle = "${projectInfo.project.name} - ${i18n("navmenu.versions")}"
            view = VersionsView(
                projectInfo,
                dao.listVersionSummaries(projectInfo.project)
            )
            feedPath = feedPath(projectInfo.project)
            navigationMenu = activeProjectNavMenu(
                dao.listProjects(),
                projectInfo
            )
            styleSheets = listOf("projects")
            javascript = "project-details"
            render("versions")
        }
    }

    private fun versionForm(http: HttpRequest, dao: DataAccessObject) {
        val projectInfo = obtainProjectInfo(http, dao)
        if (projectInfo == null) {
            http.response.sendError(404)
            return
        }

        val version: Version
        when (val result = http.lookupPathParam("version", projectInfo.versions)) {
            is NotFound -> {
                http.response.sendError(404)
                return
            }
            is Found -> {
                version = result.elem ?: Version(-1, projectInfo.project.id)
            }
        }

        with(http) {
            view = VersionEditView(projectInfo, version)
            feedPath = feedPath(projectInfo.project)
            navigationMenu = activeProjectNavMenu(
                dao.listProjects(),
                projectInfo,
                selectedVersion = version
            )
            styleSheets = listOf("projects")
            render("version-form")
        }
    }

    private fun obtainIdAndProject(http: HttpRequest, dao: DataAccessObject): Pair<Int, Project>? {
        val id = http.param("id")?.toIntOrNull()
        val projectid = http.param("projectid")?.toIntOrNull() ?: -1
        val project = dao.findProject(projectid)
        return if (id == null || project == null) {
            http.response.sendError(400)
            null
        } else {
            Pair(id, project)
        }
    }

    private fun versionCommit(http: HttpRequest, dao: DataAccessObject) {
        val idParams = obtainIdAndProject(http, dao) ?: return
        val (id, project) = idParams

        val version = Version(id, project.id).apply {
            name = http.param("name") ?: ""
            node = http.param("node") ?: ""
            ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
            status = http.param("status")?.let(VersionStatus::valueOf) ?: VersionStatus.Future
            // TODO: process error messages
            eol = http.param("eol", ::dateOptValidator, null, mutableListOf())
            release = http.param("release", ::dateOptValidator, null, mutableListOf())
            // intentional defaults
            if (node.isBlank()) node = name
            // sanitizing
            node = sanitizeNode(node)
        }

        // sanitize eol and release date
        if (version.status.isEndOfLife) {
            if (version.eol == null) version.eol = Date(System.currentTimeMillis())
        } else if (version.status.isReleased) {
            if (version.release == null) version.release = Date(System.currentTimeMillis())
        }

        if (id < 0) {
            dao.insertVersion(version)
        } else {
            dao.updateVersion(version)
        }

        http.renderCommit("projects/${project.node}/versions/")
    }

    private fun components(http: HttpRequest, dao: DataAccessObject) {
        val projectInfo = obtainProjectInfo(http, dao)
        if (projectInfo == null) {
            http.response.sendError(404)
            return
        }

        with(http) {
            pageTitle = "${projectInfo.project.name} - ${i18n("navmenu.components")}"
            view = ComponentsView(
                projectInfo,
                dao.listComponentSummaries(projectInfo.project)
            )
            feedPath = feedPath(projectInfo.project)
            navigationMenu = activeProjectNavMenu(
                dao.listProjects(),
                projectInfo
            )
            styleSheets = listOf("projects")
            javascript = "project-details"
            render("components")
        }
    }

    private fun componentForm(http: HttpRequest, dao: DataAccessObject) {
        val projectInfo = obtainProjectInfo(http, dao)
        if (projectInfo == null) {
            http.response.sendError(404)
            return
        }

        val component: Component
        when (val result = http.lookupPathParam("component", projectInfo.components)) {
            is NotFound -> {
                http.response.sendError(404)
                return
            }
            is Found -> {
                component = result.elem ?: Component(-1, projectInfo.project.id)
            }
        }

        with(http) {
            view = ComponentEditView(projectInfo, component, dao.listUsers())
            feedPath = feedPath(projectInfo.project)
            navigationMenu = activeProjectNavMenu(
                dao.listProjects(),
                projectInfo,
                selectedComponent = component
            )
            styleSheets = listOf("projects")
            render("component-form")
        }
    }

    private fun componentCommit(http: HttpRequest, dao: DataAccessObject) {
        val idParams = obtainIdAndProject(http, dao) ?: return
        val (id, project) = idParams

        val component = Component(id, project.id).apply {
            name = http.param("name") ?: ""
            node = http.param("node") ?: ""
            ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
            color = WebColor(http.param("color") ?: "#000000")
            description = http.param("description")
            // TODO: process error message
            active = http.param("active", ::boolValidator, true, mutableListOf())
            lead = (http.param("lead")?.toIntOrNull() ?: -1).let {
                if (it < 0) null else dao.findUser(it)
            }
            // intentional defaults
            if (node.isBlank()) node = name
            // sanitizing
            node = sanitizeNode(node)
        }

        if (id < 0) {
            dao.insertComponent(component)
        } else {
            dao.updateComponent(component)
        }

        http.renderCommit("projects/${project.node}/components/")
    }

    private fun issue(http: HttpRequest, dao: DataAccessObject) {
        val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
        if (issue == null) {
            http.response.sendError(404)
            return
        }
        renderIssueView(http, dao, issue)
    }

    private fun renderIssueView(
        http: HttpRequest,
        dao: DataAccessObject,
        issue: Issue,
        relationError: String? = null
    ) {
        withPathInfo(http, dao)?.run {
            val comments = dao.listComments(issue)

            with(http) {
                pageTitle = "${projectInfo.project.name}: #${issue.id} ${issue.subject}"
                view = IssueDetailView(
                    issue,
                    comments,
                    project,
                    version,
                    component,
                    dao.listIssues(project),
                    dao.listIssueRelations(issue),
                    relationError
                )
                feedPath = feedPath(projectInfo.project)
                navigationMenu = activeProjectNavMenu(
                    dao.listProjects(),
                    projectInfo,
                    version,
                    component
                )
                styleSheets = listOf("projects")
                javascript = "issue-editor"
                render("issue-view")
            }
        }
    }

    private fun issueForm(http: HttpRequest, dao: DataAccessObject) {
        withPathInfo(http, dao)?.run {
            val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue) ?: Issue(
                -1,
                project,
            )

            // for new issues set some defaults
            if (issue.id < 0) {
                // pre-select component, if available in the path info
                issue.component = component

                // pre-select version, if available in the path info
                if (version != null) {
                    if (version.status.isReleased) {
                        issue.affected = version
                    } else {
                        issue.resolved = version
                    }
                }
            }

            with(http) {
                view = IssueEditView(
                    issue,
                    projectInfo.versions,
                    projectInfo.components,
                    dao.listUsers(),
                    project,
                    version,
                    component
                )
                feedPath = feedPath(projectInfo.project)
                navigationMenu = activeProjectNavMenu(
                    dao.listProjects(),
                    projectInfo,
                    version,
                    component
                )
                styleSheets = listOf("projects")
                javascript = "issue-editor"
                render("issue-form")
            }
        }
    }

    private fun issueComment(http: HttpRequest, dao: DataAccessObject) {
        withPathInfo(http, dao)?.run {
            val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
            if (issue == null) {
                http.response.sendError(404)
                return
            }

            val commentId = http.param("commentid")?.toIntOrNull() ?: -1
            if (commentId > 0) {
                val comment = dao.findComment(commentId)
                if (comment == null) {
                    http.response.sendError(404)
                    return
                }
                val originalAuthor = comment.author?.username
                if (originalAuthor != null && originalAuthor == http.remoteUser) {
                    val newComment = http.param("comment")
                    if (!newComment.isNullOrBlank()) {
                        comment.comment = newComment
                        dao.updateComment(comment)
                        dao.insertHistoryEvent(issue, comment)
                    } else {
                        logger.debug("Not updating comment ${comment.id} because nothing changed.")
                    }
                } else {
                    http.response.sendError(403)
                    return
                }
            } else {
                val comment = IssueComment(-1, issue.id).apply {
                    author = http.remoteUser?.let { dao.findUserByName(it) }
                    comment = http.param("comment") ?: ""
                }
                val newId = dao.insertComment(comment)
                dao.insertHistoryEvent(issue, comment, newId)
            }

            http.renderCommit("${issuesHref}${issue.id}")
        }
    }

    private fun issueCommit(http: HttpRequest, dao: DataAccessObject) {
        withPathInfo(http, dao)?.run {
            val issue = Issue(
                http.param("id")?.toIntOrNull() ?: -1,
                project
            ).apply {
                component = dao.findComponent(http.param("component")?.toIntOrNull() ?: -1)
                category = IssueCategory.valueOf(http.param("category") ?: "")
                status = IssueStatus.valueOf(http.param("status") ?: "")
                subject = http.param("subject") ?: ""
                description = http.param("description") ?: ""
                assignee = http.param("assignee")?.toIntOrNull()?.let {
                    when (it) {
                        -1 -> null
                        -2 -> component?.lead
                        else -> dao.findUser(it)
                    }
                }
                // TODO: process error messages
                eta = http.param("eta", ::dateOptValidator, null, mutableListOf())

                affected = http.param("affected")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) }
                resolved = http.param("resolved")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) }
            }

            val openId = if (issue.id < 0) {
                val id = dao.insertIssue(issue)
                dao.insertHistoryEvent(issue, id)
                id
            } else {
                val reference = dao.findIssue(issue.id)
                if (reference == null) {
                    http.response.sendError(404)
                    return
                }

                if (issue.hasChanged(reference)) {
                    dao.updateIssue(issue)
                    dao.insertHistoryEvent(issue)
                } else {
                    logger.debug("Not updating issue ${issue.id} because nothing changed.")
                }

                val newComment = http.param("comment")
                if (!newComment.isNullOrBlank()) {
                    val comment = IssueComment(-1, issue.id).apply {
                        author = http.remoteUser?.let { dao.findUserByName(it) }
                        comment = newComment
                    }
                    val commentid = dao.insertComment(comment)
                    dao.insertHistoryEvent(issue, comment, commentid)
                }
                issue.id
            }

            if (http.param("more") != null) {
                http.renderCommit("${issuesHref}-/create")
            } else {
                http.renderCommit("${issuesHref}${openId}")
            }
        }
    }

    private fun issueRelation(http: HttpRequest, dao: DataAccessObject) {
        withPathInfo(http, dao)?.run {
            val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
            if (issue == null) {
                http.response.sendError(404)
                return
            }
            
            // determine the relation type
            val type: Pair<RelationType, Boolean>? = http.param("type")?.let {
                try {
                    if (it.startsWith("!")) {
                        Pair(RelationType.valueOf(it.substring(1)), true)
                    } else {
                        Pair(RelationType.valueOf(it), false)
                    }
                } catch (_: IllegalArgumentException) {
                    null
                }
            }
            
            // if the relation type was invalid, send HTTP 500
            if (type == null) {
                http.response.sendError(500)
                return
            }
            
            // determine the target issue
            val targetIssue: Issue? = http.param("issue")?.let {
                if (it.startsWith("#") && it.length > 1) {
                    it.substring(1).split(" ", limit = 2)[0].toIntOrNull()
                        ?.let(dao::findIssue)
                        ?.takeIf { target -> target.project.id == issue.project.id }
                } else {
                    null
                }
            }

            // check if the target issue is valid
            if (targetIssue == null) {
                renderIssueView(http, dao, issue, "issue.relations.target.invalid")
                return
            }
            
            // commit the result
            dao.insertIssueRelation(IssueRelation(issue, targetIssue, type.first, type.second))
            http.renderCommit("${issuesHref}${issue.id}")
        }
    }

    private fun issueRemoveRelation(http: HttpRequest, dao: DataAccessObject) {
        withPathInfo(http, dao)?.run {
            val issue = http.pathParams["issue"]?.toIntOrNull()?.let(dao::findIssue)
            if (issue == null) {
                http.response.sendError(404)
                return
            }

            // determine relation
            val type = http.param("type")?.let {
                try {RelationType.valueOf(it)}
                catch (_:IllegalArgumentException) {null}
            }
            if (type == null) {
                http.response.sendError(500)
                return
            }
            val rel = http.param("to")?.toIntOrNull()?.let(dao::findIssue)?.let {
                IssueRelation(
                    issue,
                    it,
                    type,
                    http.param("reverse")?.toBoolean() ?: false
                )
            }

            // execute removal, if there is something to remove
            rel?.run(dao::deleteIssueRelation)

            // always pretend that the operation was successful - if there was nothing to remove, it's okay
            http.renderCommit("${issuesHref}${issue.id}")
        }
    }
}

mercurial