Sat, 04 Jun 2022 18:02:25 +0200
simplify listIssues() interface
1 /*
2 * Copyright 2021 Mike Becker. All rights reserved.
3 *
4 * Redistribution and use in source and binary forms, with or without
5 * modification, are permitted provided that the following conditions are met:
6 *
7 * 1. Redistributions of source code must retain the above copyright
8 * notice, this list of conditions and the following disclaimer.
9 *
10 * 2. Redistributions in binary form must reproduce the above copyright
11 * notice, this list of conditions and the following disclaimer in the
12 * documentation and/or other materials provided with the distribution.
13 *
14 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24 */
26 package de.uapcore.lightpit.servlet
28 import de.uapcore.lightpit.AbstractServlet
29 import de.uapcore.lightpit.HttpRequest
30 import de.uapcore.lightpit.boolValidator
31 import de.uapcore.lightpit.dao.DataAccessObject
32 import de.uapcore.lightpit.dateOptValidator
33 import de.uapcore.lightpit.entities.*
34 import de.uapcore.lightpit.types.IssueCategory
35 import de.uapcore.lightpit.types.IssueStatus
36 import de.uapcore.lightpit.types.VersionStatus
37 import de.uapcore.lightpit.types.WebColor
38 import de.uapcore.lightpit.util.IssueSorter.Companion.DEFAULT_ISSUE_SORTER
39 import de.uapcore.lightpit.viewmodel.*
40 import java.sql.Date
41 import javax.servlet.annotation.WebServlet
43 @WebServlet(urlPatterns = ["/projects/*"])
44 class ProjectServlet : AbstractServlet() {
46 init {
47 get("/", this::projects)
48 get("/%project", this::project)
49 get("/%project/issues/%version/%component/", this::project)
50 get("/%project/edit", this::projectForm)
51 get("/-/create", this::projectForm)
52 post("/-/commit", this::projectCommit)
54 get("/%project/versions/", this::versions)
55 get("/%project/versions/%version/edit", this::versionForm)
56 get("/%project/versions/-/create", this::versionForm)
57 post("/%project/versions/-/commit", this::versionCommit)
59 get("/%project/components/", this::components)
60 get("/%project/components/%component/edit", this::componentForm)
61 get("/%project/components/-/create", this::componentForm)
62 post("/%project/components/-/commit", this::componentCommit)
64 get("/%project/issues/%version/%component/%issue", this::issue)
65 get("/%project/issues/%version/%component/%issue/edit", this::issueForm)
66 post("/%project/issues/%version/%component/%issue/comment", this::issueComment)
67 get("/%project/issues/%version/%component/-/create", this::issueForm)
68 post("/%project/issues/%version/%component/-/commit", this::issueCommit)
69 }
71 private fun projects(http: HttpRequest, dao: DataAccessObject) {
72 val projects = dao.listProjects()
73 val projectInfos = projects.map {
74 ProjectInfo(
75 project = it,
76 versions = dao.listVersions(it),
77 components = emptyList(), // not required in this view
78 issueSummary = dao.collectIssueSummary(it)
79 )
80 }
82 with(http) {
83 view = ProjectsView(projectInfos)
84 navigationMenu = projectNavMenu(projects)
85 styleSheets = listOf("projects")
86 render("projects")
87 }
88 }
90 private fun activeProjectNavMenu(
91 projects: List<Project>,
92 projectInfo: ProjectInfo,
93 selectedVersion: Version? = null,
94 selectedComponent: Component? = null
95 ) =
96 projectNavMenu(
97 projects,
98 projectInfo.versions,
99 projectInfo.components,
100 projectInfo.project,
101 selectedVersion,
102 selectedComponent
103 )
105 private sealed interface LookupResult<T>
106 private class NotFound<T> : LookupResult<T>
107 private data class Found<T>(val elem: T?) : LookupResult<T>
109 private fun <T : HasNode> HttpRequest.lookupPathParam(paramName: String, list: List<T>): LookupResult<T> {
110 val node = pathParams[paramName]
111 return if (node == null || node == "-") {
112 Found(null)
113 } else {
114 val result = list.find { it.node == node }
115 if (result == null) {
116 NotFound()
117 } else {
118 Found(result)
119 }
120 }
121 }
123 private fun obtainProjectInfo(http: HttpRequest, dao: DataAccessObject): ProjectInfo? {
124 val project = dao.findProjectByNode(http.pathParams["project"] ?: "") ?: return null
126 val versions: List<Version> = dao.listVersions(project)
127 val components: List<Component> = dao.listComponents(project)
129 return ProjectInfo(
130 project,
131 versions,
132 components,
133 dao.collectIssueSummary(project)
134 )
135 }
137 private fun sanitizeNode(name: String): String {
138 val san = name.replace(Regex("[/\\\\]"), "-")
139 return if (san.startsWith(".")) {
140 "v$san"
141 } else {
142 san
143 }
144 }
146 private fun feedPath(project: Project) = "feed/${project.node}/issues.rss"
148 private data class PathInfos(
149 val projectInfo: ProjectInfo,
150 val version: Version?,
151 val component: Component?
152 ) {
153 val project = projectInfo.project
154 val issuesHref by lazyOf("projects/${project.node}/issues/${version?.node ?: "-"}/${component?.node ?: "-"}/")
155 }
157 private fun withPathInfo(http: HttpRequest, dao: DataAccessObject): PathInfos? {
158 val projectInfo = obtainProjectInfo(http, dao)
159 if (projectInfo == null) {
160 http.response.sendError(404)
161 return null
162 }
164 val version = when (val result = http.lookupPathParam("version", projectInfo.versions)) {
165 is NotFound -> {
166 http.response.sendError(404)
167 return null
168 }
169 is Found -> {
170 result.elem
171 }
172 }
173 val component = when (val result = http.lookupPathParam("component", projectInfo.components)) {
174 is NotFound -> {
175 http.response.sendError(404)
176 return null
177 }
178 is Found -> {
179 result.elem
180 }
181 }
183 return PathInfos(projectInfo, version, component)
184 }
186 private fun project(http: HttpRequest, dao: DataAccessObject) {
187 withPathInfo(http, dao)?.run {
189 val issues = dao.listIssues(project, version, component)
190 .sortedWith(DEFAULT_ISSUE_SORTER)
192 with(http) {
193 pageTitle = project.name
194 view = ProjectDetails(projectInfo, issues, version, component)
195 feedPath = feedPath(project)
196 navigationMenu = activeProjectNavMenu(
197 dao.listProjects(),
198 projectInfo,
199 version,
200 component
201 )
202 styleSheets = listOf("projects")
203 render("project-details")
204 }
205 }
206 }
208 private fun projectForm(http: HttpRequest, dao: DataAccessObject) {
209 if (!http.pathParams.containsKey("project")) {
210 http.view = ProjectEditView(Project(-1), dao.listUsers())
211 http.navigationMenu = projectNavMenu(dao.listProjects())
212 } else {
213 val projectInfo = obtainProjectInfo(http, dao)
214 if (projectInfo == null) {
215 http.response.sendError(404)
216 return
217 }
218 http.view = ProjectEditView(projectInfo.project, dao.listUsers())
219 http.navigationMenu = activeProjectNavMenu(
220 dao.listProjects(),
221 projectInfo
222 )
223 }
224 http.styleSheets = listOf("projects")
225 http.render("project-form")
226 }
228 private fun projectCommit(http: HttpRequest, dao: DataAccessObject) {
229 val project = Project(http.param("id")?.toIntOrNull() ?: -1).apply {
230 name = http.param("name") ?: ""
231 node = http.param("node") ?: ""
232 description = http.param("description") ?: ""
233 ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
234 repoUrl = http.param("repoUrl") ?: ""
235 owner = (http.param("owner")?.toIntOrNull() ?: -1).let {
236 if (it < 0) null else dao.findUser(it)
237 }
238 // intentional defaults
239 if (node.isBlank()) node = name
240 // sanitizing
241 node = sanitizeNode(node)
242 }
244 if (project.id < 0) {
245 dao.insertProject(project)
246 } else {
247 dao.updateProject(project)
248 }
250 http.renderCommit("projects/${project.node}")
251 }
253 private fun versions(http: HttpRequest, dao: DataAccessObject) {
254 val projectInfo = obtainProjectInfo(http, dao)
255 if (projectInfo == null) {
256 http.response.sendError(404)
257 return
258 }
260 with(http) {
261 pageTitle = "${projectInfo.project.name} - ${i18n("navmenu.versions")}"
262 view = VersionsView(
263 projectInfo,
264 dao.listVersionSummaries(projectInfo.project)
265 )
266 feedPath = feedPath(projectInfo.project)
267 navigationMenu = activeProjectNavMenu(
268 dao.listProjects(),
269 projectInfo
270 )
271 styleSheets = listOf("projects")
272 render("versions")
273 }
274 }
276 private fun versionForm(http: HttpRequest, dao: DataAccessObject) {
277 val projectInfo = obtainProjectInfo(http, dao)
278 if (projectInfo == null) {
279 http.response.sendError(404)
280 return
281 }
283 val version: Version
284 when (val result = http.lookupPathParam("version", projectInfo.versions)) {
285 is NotFound -> {
286 http.response.sendError(404)
287 return
288 }
289 is Found -> {
290 version = result.elem ?: Version(-1, projectInfo.project.id)
291 }
292 }
294 with(http) {
295 view = VersionEditView(projectInfo, version)
296 feedPath = feedPath(projectInfo.project)
297 navigationMenu = activeProjectNavMenu(
298 dao.listProjects(),
299 projectInfo,
300 selectedVersion = version
301 )
302 styleSheets = listOf("projects")
303 render("version-form")
304 }
305 }
307 private fun obtainIdAndProject(http: HttpRequest, dao:DataAccessObject): Pair<Int, Project>? {
308 val id = http.param("id")?.toIntOrNull()
309 val projectid = http.param("projectid")?.toIntOrNull() ?: -1
310 val project = dao.findProject(projectid)
311 return if (id == null || project == null) {
312 http.response.sendError(400)
313 null
314 } else {
315 Pair(id, project)
316 }
317 }
319 private fun versionCommit(http: HttpRequest, dao: DataAccessObject) {
320 val idParams = obtainIdAndProject(http, dao) ?: return
321 val (id, project) = idParams
323 val version = Version(id, project.id).apply {
324 name = http.param("name") ?: ""
325 node = http.param("node") ?: ""
326 ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
327 status = http.param("status")?.let(VersionStatus::valueOf) ?: VersionStatus.Future
328 // TODO: process error messages
329 eol = http.param("eol", ::dateOptValidator, null, mutableListOf())
330 release = http.param("release", ::dateOptValidator, null, mutableListOf())
331 // intentional defaults
332 if (node.isBlank()) node = name
333 // sanitizing
334 node = sanitizeNode(node)
335 }
337 // sanitize eol and release date
338 if (version.status.isEndOfLife) {
339 if (version.eol == null) version.eol = Date(System.currentTimeMillis())
340 } else if (version.status.isReleased) {
341 if (version.release == null) version.release = Date(System.currentTimeMillis())
342 }
344 if (id < 0) {
345 dao.insertVersion(version)
346 } else {
347 dao.updateVersion(version)
348 }
350 http.renderCommit("projects/${project.node}/versions/")
351 }
353 private fun components(http: HttpRequest, dao: DataAccessObject) {
354 val projectInfo = obtainProjectInfo(http, dao)
355 if (projectInfo == null) {
356 http.response.sendError(404)
357 return
358 }
360 with(http) {
361 pageTitle = "${projectInfo.project.name} - ${i18n("navmenu.components")}"
362 view = ComponentsView(
363 projectInfo,
364 dao.listComponentSummaries(projectInfo.project)
365 )
366 feedPath = feedPath(projectInfo.project)
367 navigationMenu = activeProjectNavMenu(
368 dao.listProjects(),
369 projectInfo
370 )
371 styleSheets = listOf("projects")
372 render("components")
373 }
374 }
376 private fun componentForm(http: HttpRequest, dao: DataAccessObject) {
377 val projectInfo = obtainProjectInfo(http, dao)
378 if (projectInfo == null) {
379 http.response.sendError(404)
380 return
381 }
383 val component: Component
384 when (val result = http.lookupPathParam("component", projectInfo.components)) {
385 is NotFound -> {
386 http.response.sendError(404)
387 return
388 }
389 is Found -> {
390 component = result.elem ?: Component(-1, projectInfo.project.id)
391 }
392 }
394 with(http) {
395 view = ComponentEditView(projectInfo, component, dao.listUsers())
396 feedPath = feedPath(projectInfo.project)
397 navigationMenu = activeProjectNavMenu(
398 dao.listProjects(),
399 projectInfo,
400 selectedComponent = component
401 )
402 styleSheets = listOf("projects")
403 render("component-form")
404 }
405 }
407 private fun componentCommit(http: HttpRequest, dao: DataAccessObject) {
408 val idParams = obtainIdAndProject(http, dao) ?: return
409 val (id, project) = idParams
411 val component = Component(id, project.id).apply {
412 name = http.param("name") ?: ""
413 node = http.param("node") ?: ""
414 ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
415 color = WebColor(http.param("color") ?: "#000000")
416 description = http.param("description")
417 // TODO: process error message
418 active = http.param("active", ::boolValidator, true, mutableListOf())
419 lead = (http.param("lead")?.toIntOrNull() ?: -1).let {
420 if (it < 0) null else dao.findUser(it)
421 }
422 // intentional defaults
423 if (node.isBlank()) node = name
424 // sanitizing
425 node = sanitizeNode(node)
426 }
428 if (id < 0) {
429 dao.insertComponent(component)
430 } else {
431 dao.updateComponent(component)
432 }
434 http.renderCommit("projects/${project.node}/components/")
435 }
437 private fun issue(http: HttpRequest, dao: DataAccessObject) {
438 withPathInfo(http, dao)?.run {
439 val issue = dao.findIssue(http.pathParams["issue"]?.toIntOrNull() ?: -1)
440 if (issue == null) {
441 http.response.sendError(404)
442 return
443 }
445 val comments = dao.listComments(issue)
447 with(http) {
448 pageTitle = "${projectInfo.project.name}: #${issue.id} ${issue.subject}"
449 view = IssueDetailView(issue, comments, project, version, component)
450 feedPath = feedPath(projectInfo.project)
451 navigationMenu = activeProjectNavMenu(
452 dao.listProjects(),
453 projectInfo,
454 version,
455 component
456 )
457 styleSheets = listOf("projects")
458 javascript = "issue-editor"
459 render("issue-view")
460 }
461 }
462 }
464 private fun issueForm(http: HttpRequest, dao: DataAccessObject) {
465 withPathInfo(http, dao)?.run {
466 val issue = dao.findIssue(http.pathParams["issue"]?.toIntOrNull() ?: -1) ?: Issue(
467 -1,
468 project,
469 )
471 // for new issues set some defaults
472 if (issue.id < 0) {
473 // pre-select component, if available in the path info
474 issue.component = component
476 // pre-select version, if available in the path info
477 if (version != null) {
478 if (version.status.isReleased) {
479 issue.affected = version
480 } else {
481 issue.resolved = version
482 }
483 }
484 }
486 with(http) {
487 view = IssueEditView(
488 issue,
489 projectInfo.versions,
490 projectInfo.components,
491 dao.listUsers(),
492 project,
493 version,
494 component
495 )
496 feedPath = feedPath(projectInfo.project)
497 navigationMenu = activeProjectNavMenu(
498 dao.listProjects(),
499 projectInfo,
500 version,
501 component
502 )
503 styleSheets = listOf("projects")
504 javascript = "issue-editor"
505 render("issue-form")
506 }
507 }
508 }
510 private fun issueComment(http: HttpRequest, dao: DataAccessObject) {
511 withPathInfo(http, dao)?.run {
512 val issue = dao.findIssue(http.pathParams["issue"]?.toIntOrNull() ?: -1)
513 if (issue == null) {
514 http.response.sendError(404)
515 return
516 }
518 val commentId = http.param("commentid")?.toIntOrNull() ?: -1
519 if (commentId > 0) {
520 val comment = dao.findComment(commentId)
521 if (comment == null) {
522 http.response.sendError(404)
523 return
524 }
525 val originalAuthor = comment.author?.username
526 if (originalAuthor != null && originalAuthor == http.remoteUser) {
527 val newComment = http.param("comment")
528 if (!newComment.isNullOrBlank()) {
529 comment.comment = newComment
530 dao.updateComment(comment)
531 dao.insertHistoryEvent(issue, comment)
532 } else {
533 logger.debug("Not updating comment ${comment.id} because nothing changed.")
534 }
535 } else {
536 http.response.sendError(403)
537 return
538 }
539 } else {
540 val comment = IssueComment(-1, issue.id).apply {
541 author = http.remoteUser?.let { dao.findUserByName(it) }
542 comment = http.param("comment") ?: ""
543 }
544 val newId = dao.insertComment(comment)
545 dao.insertHistoryEvent(issue, comment, newId)
546 }
548 http.renderCommit("${issuesHref}${issue.id}")
549 }
550 }
552 private fun issueCommit(http: HttpRequest, dao: DataAccessObject) {
553 withPathInfo(http, dao)?.run {
554 val issue = Issue(
555 http.param("id")?.toIntOrNull() ?: -1,
556 project
557 ).apply {
558 component = dao.findComponent(http.param("component")?.toIntOrNull() ?: -1)
559 category = IssueCategory.valueOf(http.param("category") ?: "")
560 status = IssueStatus.valueOf(http.param("status") ?: "")
561 subject = http.param("subject") ?: ""
562 description = http.param("description") ?: ""
563 assignee = http.param("assignee")?.toIntOrNull()?.let {
564 when (it) {
565 -1 -> null
566 -2 -> component?.lead
567 else -> dao.findUser(it)
568 }
569 }
570 // TODO: process error messages
571 eta = http.param("eta", ::dateOptValidator, null, mutableListOf())
573 affected = http.param("affected")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) }
574 resolved = http.param("resolved")?.toIntOrNull()?.takeIf { it > 0 }?.let { Version(it, project.id) }
575 }
577 val openId = if (issue.id < 0) {
578 val id = dao.insertIssue(issue)
579 dao.insertHistoryEvent(issue, id)
580 id
581 } else {
582 val reference = dao.findIssue(issue.id)
583 if (reference == null) {
584 http.response.sendError(404)
585 return
586 }
588 if (issue.hasChanged(reference)) {
589 dao.updateIssue(issue)
590 dao.insertHistoryEvent(issue)
591 } else {
592 logger.debug("Not updating issue ${issue.id} because nothing changed.")
593 }
595 val newComment = http.param("comment")
596 if (!newComment.isNullOrBlank()) {
597 val comment = IssueComment(-1, issue.id).apply {
598 author = http.remoteUser?.let { dao.findUserByName(it) }
599 comment = newComment
600 }
601 val commentid = dao.insertComment(comment)
602 dao.insertHistoryEvent(issue, comment, commentid)
603 }
604 issue.id
605 }
607 if (http.param("more") != null) {
608 http.renderCommit("${issuesHref}-/create")
609 } else {
610 http.renderCommit("${issuesHref}${openId}")
611 }
612 }
613 }
614 }