Add backup

This commit is contained in:
Lars Westermann 2019-05-30 21:30:46 +02:00
parent 67f24adfdf
commit c6620d3395
Signed by: lars.westermann
GPG key ID: 9D417FA5BB9D5E1D
14 changed files with 471 additions and 39 deletions

View file

@ -12,6 +12,7 @@ data class Post(
val url: String,
val image: String?,
val pinned: Boolean,
val hideOnProjector: Boolean,
override val createdAt: Long = 0,
override val updateAt: Long = 0
) : Model {

View file

@ -1,5 +1,6 @@
package de.kif.frontend.views
import de.kif.frontend.iterator
import de.kif.frontend.launch
import de.kif.frontend.repository.WorkGroupRepository
import de.westermann.kobserve.event.EventListener
@ -14,16 +15,12 @@ fun initWorkGroupConstraints() {
var index = 10000
val constraints =
ListView.wrap<View>(document.getElementsByClassName("work-group-constraints")[0] as HTMLElement)
document.getElementsByClassName("work-group-constraints")[0] as HTMLElement
val addButton =
View.wrap(document.getElementsByClassName("work-group-constraints-add")[0] as HTMLElement)
val addList =
ListView.wrap<View>(document.getElementsByClassName("work-group-constraints-add-list")[0] as HTMLElement)
console.log(constraints.html)
console.log(addButton.html)
console.log(addList.html)
addButton.onClick {
addList.classList += "active"
@ -39,9 +36,12 @@ fun initWorkGroupConstraints() {
addList.textView("Add only on day") {
onClick {
constraints.html.appendChild(View.wrap(createHtmlView<HTMLDivElement>()) {
constraints.appendChild(View.wrap(createHtmlView<HTMLDivElement>()) {
classList += "input-group"
html.appendChild(TextView("On day").apply { classList += "form-btn" }.html)
html.appendChild(TextView("On day").apply {
classList += "form-btn"
onClick { this@wrap.html.remove() }
}.html)
html.appendChild(InputView(InputType.NUMBER).apply {
classList += "form-control"
html.name = "constraint-only-on-day-${index++}"
@ -53,9 +53,12 @@ fun initWorkGroupConstraints() {
}
addList.textView("Add only after time") {
onClick {
constraints.html.appendChild(View.wrap(createHtmlView<HTMLDivElement>()) {
constraints.appendChild(View.wrap(createHtmlView<HTMLDivElement>()) {
classList += "input-group"
html.appendChild(TextView("After time").apply { classList += "form-btn" }.html)
html.appendChild(TextView("After time").apply {
classList += "form-btn"
onClick { this@wrap.html.remove() }
}.html)
html.appendChild(InputView(InputType.NUMBER).apply {
classList += "form-control"
html.name = "constraint-only-after-time-${index++}"
@ -67,9 +70,12 @@ fun initWorkGroupConstraints() {
}
addList.textView("Add not at same time") {
onClick {
constraints.html.appendChild(View.wrap(createHtmlView<HTMLDivElement>()) {
constraints.appendChild(View.wrap(createHtmlView<HTMLDivElement>()) {
classList += "input-group"
html.appendChild(TextView("Not with").apply { classList += "form-btn" }.html)
html.appendChild(TextView("Not with").apply {
classList += "form-btn"
onClick { this@wrap.html.remove() }
}.html)
val select = createHtmlView<HTMLSelectElement>()
select.classList.add("form-control")
@ -92,9 +98,12 @@ fun initWorkGroupConstraints() {
}
addList.textView("Add only after work group") {
onClick {
constraints.html.appendChild(View.wrap(createHtmlView<HTMLDivElement>()) {
constraints.appendChild(View.wrap(createHtmlView<HTMLDivElement>()) {
classList += "input-group"
html.appendChild(TextView("After AK").apply { classList += "form-btn" }.html)
html.appendChild(TextView("After AK").apply {
classList += "form-btn"
onClick { this@wrap.html.remove() }
}.html)
val select = createHtmlView<HTMLSelectElement>()
select.classList.add("form-control")
@ -115,4 +124,18 @@ fun initWorkGroupConstraints() {
}.html)
}
}
console.log(constraints)
for (child in constraints.children.iterator()) {
console.log(child)
if (child.classList.contains("input-group")) {
val span = child.firstElementChild as HTMLElement
console.log(span)
span.addEventListener("click", org.w3c.dom.events.EventListener {
println("click")
child.remove()
})
}
}
}

View file

@ -43,7 +43,7 @@ open class TableLine(line: HTMLElement) : View(line) {
protected fun setupBoolean(view: TextView, onSave: () -> Unit) {
view.classList += "no-select"
view.tabIndex = 0
view.onDblClick {
view.onClick {
onSave()
}
view.onKeyDown {

View file

@ -23,6 +23,7 @@ $input-border-color: #888;
$table-border-color: rgba($text-primary-color, 0.1);
$table-header-color: rgba($text-primary-color, 0.06);
$border-radius: 0.2rem;
$transitionTime: 150ms;
$bg-disabled-color: rgba($text-primary-color, .26);
@ -45,7 +46,7 @@ body, html {
padding: 0;
& > *:last-child {
margin-bottom: 1rem;
//margin-bottom: 1rem;
}
}
@ -323,7 +324,7 @@ a {
height: 2.5rem;
width: 100%;
background-color: $background-primary-color;
border-radius: 0.2rem;
border-radius: $border-radius;
margin: 1px;
transition: border-color $transitionTime;
@ -433,7 +434,7 @@ select:-moz-focusring {
display: inline-block;
margin-right: 0.6rem;
border-radius: 0.2rem;
border-radius: $border-radius;
font-weight: 600;
text-transform: uppercase;
font-size: 0.9rem;
@ -484,8 +485,14 @@ form {
margin-top: 1px;
}
span {
white-space: nowrap;
}
& > * {
margin-right: 0;
flex-grow: 1;
flex-shrink: 1;
&:not(:first-child) {
border-top-left-radius: 0;
@ -547,7 +554,7 @@ form {
.calendar-work-group {
position: relative;
display: block;
border-radius: 0.2rem;
border-radius: $border-radius;
line-height: 2rem;
font-size: 0.8rem;
white-space: nowrap;
@ -615,7 +622,7 @@ form {
right: 0;
background-color: #fff;
padding: 0.2rem 0.5rem;
border-radius: 0.2rem;
border-radius: $border-radius;
display: none;
z-index: 10;
@ -643,7 +650,7 @@ form {
.calendar-entry {
position: absolute;
display: block;
border-radius: 0.2rem;
border-radius: $border-radius;
z-index: 1;
line-height: 2rem;
font-size: 0.8rem;
@ -934,26 +941,62 @@ form {
.work-group-constraints {
position: relative;
& > label {
margin-bottom: 0.8rem;
}
.input-group {
margin-bottom: 0.5rem;
span {
width: 4rem;
position: relative;
text-align: center;
overflow: hidden;
&:hover::after {
content: 'DELETE';
position: absolute;
top: 0;
left: 0;
width: 100%;
text-align: center;
font-weight: bold;
color: $primary-text-color;
background: $primary-color;
}
}
.form-control {
width: 12rem;
}
}
}
.work-group-constraints-add {
position: absolute;
top: 0;
top: -0.5rem;
right: 0;
}
.work-group-constraints-add-list {
position: absolute;
top: 0;
top: -2rem;
right: 0;
z-index: 1;
display: none;
padding: 0.5rem 0;
background: $background-primary-color;
border: solid 1px $table-border-color;
border: solid 1px $input-border-color;
border-radius: $border-radius;
span {
padding: 0 0.5rem;
line-height: 2rem;
display: block;
&:hover {
background-color: $table-header-color;
@ -1022,6 +1065,10 @@ form {
flex-direction: column;
}
.post-column-left, .post-column-right {
flex-grow: 1;
}
.post-image {
width: 100%;
margin: 0.4rem 0 0;

View file

@ -0,0 +1,82 @@
package de.kif.backend.backup
import de.kif.backend.database.Connection
import de.kif.backend.repository.*
import de.kif.common.Message
import de.kif.common.RepositoryType
import de.kif.common.model.*
import kotlinx.serialization.Serializable
@Serializable
data class Backup(
val posts: List<Post> = emptyList(),
val rooms: List<Room> = emptyList(),
val schedules: List<Schedule> = emptyList(),
val tracks: List<Track> = emptyList(),
val users: List<User> = emptyList(),
val workGroups: List<WorkGroup> = emptyList()
) {
companion object {
suspend fun backup(vararg repositories: RepositoryType): String {
var backup = Backup()
val repositorySet = repositories.toMutableSet()
if (RepositoryType.SCHEDULE in repositorySet) {
repositorySet += RepositoryType.ROOM
repositorySet += RepositoryType.WORK_GROUP
}
if (RepositoryType.WORK_GROUP in repositorySet) {
repositorySet += RepositoryType.TRACK
}
for (repository in repositorySet) {
backup = when (repository) {
RepositoryType.ROOM -> backup.copy(rooms = RoomRepository.all())
RepositoryType.SCHEDULE -> backup.copy(schedules = ScheduleRepository.all())
RepositoryType.TRACK -> backup.copy(tracks = TrackRepository.all())
RepositoryType.USER -> backup.copy(users = UserRepository.all())
RepositoryType.WORK_GROUP -> backup.copy(workGroups = WorkGroupRepository.all())
RepositoryType.POST -> backup.copy(posts = PostRepository.all())
}
}
return Message.json.stringify(serializer(), backup)
}
@Suppress("UNUSED_VARIABLE")
suspend fun import(data: String) {
val backup = Message.json.parse(serializer(), data)
val userMap = backup.users.associateWith { UserRepository.create(it) }
val postMap = backup.posts.associateWith { PostRepository.create(it) }
val roomMap = backup.rooms.associateWith { RoomRepository.create(it) }
val trackMap = backup.tracks.associateWith { TrackRepository.create(it) }
val workGroupMap = backup.workGroups.associateWith {
var workGroup = it
val track = workGroup.track
if (track != null) {
workGroup = workGroup.copy(track = track.copy(id = trackMap[track] ?: return@associateWith -1L))
}
WorkGroupRepository.create(workGroup)
}
val scheduleMap = backup.schedules.associateWith {
ScheduleRepository.create(
it.copy(
room = it.room.copy(id = roomMap[it.room] ?: return@associateWith -1L),
workGroup = it.workGroup.copy(id = workGroupMap[it.workGroup] ?: return@associateWith -1L)
)
)
}
}
suspend fun restore(data: String) {
Connection.reset()
import(data)
}
}
}

View file

@ -3,30 +3,74 @@ package de.kif.backend.database
import de.kif.backend.Configuration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mu.KotlinLogging
import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.TransactionManager
import org.jetbrains.exposed.sql.transactions.transaction
import java.nio.file.Files
import java.sql.Connection.TRANSACTION_SERIALIZABLE
import kotlin.system.exitProcess
object Connection {
fun init() {
val dbPath = Configuration.Path.databasePath.toString()
Database.connect("jdbc:sqlite:$dbPath", "org.sqlite.JDBC")
TransactionManager.manager.defaultIsolationLevel = TRANSACTION_SERIALIZABLE
transaction {
SchemaUtils.create(
private val logger = KotlinLogging.logger {}
private val schemaList = arrayOf(
DbTrack, DbWorkGroup,
DbRoom, DbSchedule,
DbUser, DbUserPermission,
DbPost
)
fun init() {
val dbPath = Configuration.Path.databasePath.toString()
Database.connect("jdbc:sqlite:$dbPath", "org.sqlite.JDBC")
TransactionManager.manager.defaultIsolationLevel = TRANSACTION_SERIALIZABLE
try {
create()
} catch (e: ExposedSQLException) {
logger.error { "Cannot initialize the database!" }
print("Do you want to recreate the database (you will lose all data)? [y/N]: ")
val result = readLine()
if (result == null || result.toLowerCase() !in "yes") {
exitProcess(1)
} else {
reset()
}
}
}
private fun create() {
transaction {
SchemaUtils.createMissingTablesAndColumns(*schemaList)
}
}
private fun delete() {
transaction {
SchemaUtils.drop(*schemaList)
}
}
fun reset() {
delete()
Configuration.Path.uploadsPath.toFile().deleteRecursively()
Configuration.Path.sessionsPath.toFile().deleteRecursively()
Files.createDirectory(Configuration.Path.uploadsPath)
Files.createDirectory(Configuration.Path.sessionsPath)
create()
}
}
suspend fun <T> dbQuery(block: () -> T): T = withContext(Dispatchers.IO) {
transaction { block() }
}

View file

@ -85,6 +85,7 @@ object DbPost : Table() {
val url = varchar("url", 64).uniqueIndex()
val image = varchar("image", 64).nullable()
val pinned = bool("pinned")
val hideOnProjector = bool("hideOnProjector")
val createdAt = long("createdAt")
val updatedAt = long("updatedAt")

View file

@ -25,11 +25,12 @@ object PostRepository : Repository<Post> {
val url = row[DbPost.url]
val image = row[DbPost.image]
val pinned = row[DbPost.pinned]
val hideOnProjector = row[DbPost.hideOnProjector]
val createdAt = row[DbPost.createdAt]
val updatedAt = row[DbPost.updatedAt]
return Post(id, name, content, url, image, pinned, createdAt, updatedAt)
return Post(id, name, content, url, image, pinned, hideOnProjector, createdAt, updatedAt)
}
override suspend fun get(id: Long): Post? {
@ -60,6 +61,7 @@ object PostRepository : Repository<Post> {
it[url] = model.url
it[image] = model.image
it[pinned] = model.pinned
it[hideOnProjector] = model.hideOnProjector
it[createdAt] = now
it[updatedAt] = now
@ -91,6 +93,7 @@ object PostRepository : Repository<Post> {
it[url] = model.url
it[image] = model.image
it[pinned] = model.pinned
it[hideOnProjector] = model.hideOnProjector
it[updatedAt] = now
}

View file

@ -1,12 +1,26 @@
package de.kif.backend.route
import de.kif.backend.authenticate
import de.kif.backend.authenticateOrRedirect
import de.kif.backend.backup.Backup
import de.kif.backend.route.api.error
import de.kif.backend.view.MainTemplate
import de.kif.backend.view.MenuTemplate
import de.kif.common.RepositoryType
import de.kif.common.model.Permission
import io.ktor.application.call
import io.ktor.html.respondHtmlTemplate
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.PartData
import io.ktor.http.content.forEachPart
import io.ktor.http.content.streamProvider
import io.ktor.request.receiveMultipart
import io.ktor.response.respondRedirect
import io.ktor.response.respondText
import io.ktor.routing.Route
import io.ktor.routing.get
import io.ktor.routing.post
import kotlinx.html.*
fun Route.account() {
@ -30,8 +44,175 @@ fun Route.account() {
}
}
}
div {
if (user.checkPermission(Permission.ROOM)) {
a("/account/backup/rooms.json", classes = "form-btn") {
attributes["download"] = "rooms-backup"
+"Create room backup"
}
}
if (user.checkPermission(Permission.USER)) {
a("/account/backup/users.json", classes = "form-btn") {
attributes["download"] = "users-backup"
+"Create user backup"
}
}
if (user.checkPermission(Permission.POST)) {
a("/account/backup/posts.json", classes = "form-btn") {
attributes["download"] = "posts-backup"
+"Create post backup"
}
}
if (user.checkPermission(Permission.WORK_GROUP)) {
a("/account/backup/work-groups.json", classes = "form-btn") {
attributes["download"] = "work-groups-backup"
+"Create work group backup"
}
}
if (
user.checkPermission(Permission.WORK_GROUP) &&
user.checkPermission(Permission.ROOM) &&
user.checkPermission(Permission.SCHEDULE)
) {
a("/account/backup/schedules.json", classes = "form-btn") {
attributes["download"] = "schedules-backup"
+"Create schedule backup"
}
}
if (user.checkPermission(Permission.ADMIN)) {
a("/account/backup.json", classes = "form-btn") {
attributes["download"] = "backup.json"
+"Create backup"
}
}
}
div {
form(
action = "/account/import",
method = FormMethod.post,
encType = FormEncType.multipartFormData
) {
div("form-group") {
label {
htmlFor = "backup"
+"Backup image"
}
input(
name = "backup",
classes = "form-btn",
type = InputType.file
) {
id = "backup"
value = "Select backup image"
accept = ".json"
}
}
div("form-switch-group") {
div("form-group form-switch") {
input(
name = "reset",
classes = "form-control",
type = InputType.checkBox
) {
id = "reset"
checked = false
}
label {
htmlFor = "reset"
+"Reset"
}
}
}
div("form-group") {
button(type = ButtonType.submit, classes = "form-btn btn-primary") {
+"Import"
}
}
}
}
}
}
}
}
get("/account/backup/rooms.json") {
authenticate(Permission.ROOM) {
call.respondText(Backup.backup(RepositoryType.ROOM), ContentType.Application.Json, HttpStatusCode.OK)
} onFailure {
call.error(HttpStatusCode.Unauthorized)
}
}
get("/account/backup/users.json") {
authenticate(Permission.USER) {
call.respondText(Backup.backup(RepositoryType.USER), ContentType.Application.Json, HttpStatusCode.OK)
} onFailure {
call.error(HttpStatusCode.Unauthorized)
}
}
get("/account/backup/posts.json") {
authenticate(Permission.POST) {
call.respondText(Backup.backup(RepositoryType.POST), ContentType.Application.Json, HttpStatusCode.OK)
} onFailure {
call.error(HttpStatusCode.Unauthorized)
}
}
get("/account/backup/work-groups.json") {
authenticate(Permission.WORK_GROUP) {
call.respondText(Backup.backup(RepositoryType.WORK_GROUP), ContentType.Application.Json, HttpStatusCode.OK)
} onFailure {
call.error(HttpStatusCode.Unauthorized)
}
}
get("/account/backup/schedules.json") {
authenticate(Permission.ROOM, Permission.WORK_GROUP, Permission.SCHEDULE) {
call.respondText(Backup.backup(RepositoryType.SCHEDULE), ContentType.Application.Json, HttpStatusCode.OK)
} onFailure {
call.error(HttpStatusCode.Unauthorized)
}
}
get("/account/backup.json") {
authenticate(Permission.ADMIN) {
call.respondText(Backup.backup(*RepositoryType.values()), ContentType.Application.Json, HttpStatusCode.OK)
} onFailure {
call.error(HttpStatusCode.Unauthorized)
}
}
post("/account/import") {
authenticateOrRedirect(Permission.ADMIN) {
var reset = false
var import = ""
call.receiveMultipart().forEachPart { part ->
val name = part.name ?: return@forEachPart
when (part) {
is PartData.FormItem -> {
if (name == "reset" && part.value == "on") {
reset = true
}
}
is PartData.FileItem -> {
import = part.streamProvider().bufferedReader().readText()
}
}
}
if (reset) {
Backup.restore(import)
} else {
Backup.import(import)
}
call.respondRedirect("/account")
}
}
}

View file

@ -236,6 +236,20 @@ fun Route.overview() {
+"Pinned"
}
}
div("form-group form-switch") {
input(
name = "hide-on-projector",
classes = "form-control",
type = InputType.checkBox
) {
id = "hide-on-projector"
checked = editPost.hideOnProjector
}
label {
htmlFor = "hide-on-projector"
+"Hide on projector"
}
}
}
div("form-group") {
@ -317,6 +331,7 @@ fun Route.overview() {
params["url"]?.let { post = post.copy(url = it) }
params["content"]?.let { post = post.copy(content = it) }
params["pinned"]?.let { post = post.copy(pinned = it == "on") }
params["hide-on-projector"]?.let { post = post.copy(hideOnProjector = it == "on") }
if (params["image-delete"] == "on") {
val currentImage = post.image
@ -430,6 +445,20 @@ fun Route.overview() {
+"Pinned"
}
}
div("form-group form-switch") {
input(
name = "hide-on-projector",
classes = "form-control",
type = InputType.checkBox
) {
id = "hide-on-projector"
checked = false
}
label {
htmlFor = "hide-on-projector"
+"Hide on projector"
}
}
}
div("form-group") {
@ -502,8 +531,9 @@ fun Route.overview() {
val content = params["content"] ?: return@post
val url = params["url"] ?: return@post
val pinned = params["pinned"] == "on"
val hideOnProjector = params["hide-on-projector"] == "on"
val post = Post(null, name, content, url, imageUploadName, pinned)
val post = Post(null, name, content, url, imageUploadName, pinned, hideOnProjector)
PostRepository.create(post)

View file

@ -354,8 +354,6 @@ fun Route.workGroup() {
+"Accessible"
}
}
div("work-group-constraints")
}
div("form-group work-group-constraints") {

View file

@ -0,0 +1,18 @@
package de.kif.backend.util
import ch.qos.logback.classic.Level
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.filter.Filter
import ch.qos.logback.core.spi.FilterReply
class LogbackFilter : Filter<ILoggingEvent>() {
override fun decide(event: ILoggingEvent?): FilterReply = if (event == null) {
FilterReply.NEUTRAL
} else {
if (event.loggerName.contains("Exposed".toRegex())) {
if (event.level.toInt() > Level.ERROR_INT) FilterReply.ACCEPT else FilterReply.DENY
} else {
FilterReply.ACCEPT
}
}
}

View file

@ -20,7 +20,11 @@ class MainTemplate : Template<HTML> {
link(href = "/static/external/material-icons.css", type = LinkType.textCss, rel = LinkRel.stylesheet)
link(href = "/static/external/font/Montserrat.css", type = LinkType.textCss, rel = LinkRel.stylesheet)
link(href = "https://fonts.googleapis.com/css?family=Bungee|Oswald", type = LinkType.textCss, rel = LinkRel.stylesheet)
link(
href = "https://fonts.googleapis.com/css?family=Bungee|Oswald",
type = LinkType.textCss,
rel = LinkRel.stylesheet
)
link(href = "/static/style/style.css", type = LinkType.textCss, rel = LinkRel.stylesheet)
script(src = "/static/require.min.js") {}

View file

@ -1,7 +1,7 @@
<configuration debug="false">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<filter class="de.westermann.robots.server.util.LogFilter" />
<filter class="de.kif.backend.util.LogbackFilter" />
<withJansi>true</withJansi>
<encoder>