erste Version Online-Nennen
This commit is contained in:
Binary file not shown.
@@ -34,10 +34,17 @@ dependencies {
|
||||
// JDBC Treiber für PostgreSQL (nur zur Laufzeit benötigt)
|
||||
runtimeOnly(libs.postgresql.driver)
|
||||
|
||||
// H2 Datenbank für Tests und lokale Entwicklung
|
||||
// H2 Datenbank für Tests und lokale Entwicklung (legacy)
|
||||
runtimeOnly(libs.h2.driver)
|
||||
|
||||
// SQLite Datenbank für Tests und lokale Entwicklung
|
||||
runtimeOnly("org.xerial:sqlite-jdbc:3.43.0.0")
|
||||
|
||||
// HikariCP für Connection Pooling
|
||||
implementation(libs.hikari.cp)
|
||||
|
||||
// Jakarta Mail für E-Mail-Funktionalität
|
||||
implementation("com.sun.mail:jakarta.mail:2.0.1")
|
||||
implementation("jakarta.activation:jakarta.activation-api:2.1.2")
|
||||
implementation("org.eclipse.angus:angus-activation:2.0.1")
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
package at.mocode
|
||||
|
||||
import at.mocode.model.Turnier
|
||||
import at.mocode.plugins.configureDatabase
|
||||
import at.mocode.tables.TurniereTable
|
||||
import io.ktor.http.*
|
||||
import at.mocode.routes.configureAdminRoutes
|
||||
import at.mocode.routes.configureHomeRoutes
|
||||
import at.mocode.routes.configureNennungRoutes
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.html.*
|
||||
import io.ktor.server.netty.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.html.*
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
|
||||
|
||||
@@ -20,87 +13,15 @@ fun main(args: Array<String>) {
|
||||
EngineMain.main(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Application module configuration.
|
||||
*/
|
||||
fun Application.module() {
|
||||
|
||||
// Als Erstes die Datenbank konfigurieren:
|
||||
// Configure database first
|
||||
configureDatabase()
|
||||
|
||||
// Danach deine anderen Konfigurationen (Routing etc.):
|
||||
routing {
|
||||
get("/") {
|
||||
// Logger holen (optional, aber nützlich)
|
||||
val log = LoggerFactory.getLogger("RootRoute")
|
||||
// --- Datenbankoperationen ---
|
||||
// alle DB-Zugriffe mit Exposed sollten in einer Transaktion stattfinden
|
||||
val turniereFromDb = transaction {
|
||||
// Optional: Füge ein Test-Turnier hinzu, WENN die Tabelle leer ist.
|
||||
// Das ist nur für den ersten Test praktisch.
|
||||
if (TurniereTable.selectAll().count() == 0L) {
|
||||
log.info("Turnier table is empty, inserting dummy tournament...")
|
||||
TurniereTable.insert {
|
||||
it[id] = "dummy-01" // Eindeutige ID
|
||||
it[name] = "Erstes DB Turnier"
|
||||
it[datum] = "19.04.2025" // Heutiges Datum?
|
||||
it[logoUrl] = null // Optional, kann null sein
|
||||
it[ausschreibungUrl] = "/pdfs/ausschreibung_dummy.pdf" // Beispielpfad
|
||||
}
|
||||
}
|
||||
|
||||
// Lese ALLE Einträge aus der TurniereTable
|
||||
log.info("Fetching all tournaments from database...")
|
||||
TurniereTable.selectAll().map { row ->
|
||||
// Wandle jede Datenbank-Zeile (row) wieder in ein Turnier-Objekt um
|
||||
Turnier(
|
||||
id = row[TurniereTable.id],
|
||||
name = row[TurniereTable.name],
|
||||
datum = row[TurniereTable.datum],
|
||||
logoUrl = row[TurniereTable.logoUrl],
|
||||
ausschreibungUrl = row[TurniereTable.ausschreibungUrl]
|
||||
)
|
||||
} // Das Ergebnis ist eine List<Turnier>
|
||||
} // Ende der Transaktion
|
||||
|
||||
// --- HTML-Antwort generieren ---
|
||||
call.respondHtml(HttpStatusCode.OK) {
|
||||
head {
|
||||
title { +"Meldestelle Portal" }
|
||||
}
|
||||
body {
|
||||
h1 { +"Willkommen beim Meldestelle Portal!" }
|
||||
p { +"Datenbankverbindung erfolgreich!" } // Kleine Bestätigung
|
||||
hr()
|
||||
h2 { +"Aktuelle Turniere (aus Datenbank):" } // Geänderte Überschrift
|
||||
|
||||
// Gib die Turnierliste aus der Datenbank aus
|
||||
ul {
|
||||
if (turniereFromDb.isEmpty()) {
|
||||
li { +"Keine Turniere in der Datenbank gefunden." }
|
||||
} else {
|
||||
// Schleife über die Liste aus der DB
|
||||
turniereFromDb.forEach { turnier ->
|
||||
li {
|
||||
strong { +turnier.name }
|
||||
+" (${turnier.datum})"
|
||||
// Füge die Buttons wieder hinzu
|
||||
+" "
|
||||
if (turnier.ausschreibungUrl != null) {
|
||||
a(href = turnier.ausschreibungUrl, target = "_blank") {
|
||||
button { +"Ausschreibung" }
|
||||
}
|
||||
+" "
|
||||
}
|
||||
a(href = "/nennung/${turnier.id}") {
|
||||
button { +"Online Nennen" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Link zum (noch nicht funktionierenden) Admin-Bereich
|
||||
hr()
|
||||
p { a(href = "/admin/tournaments") { +"Zur Turnierverwaltung (TODO)" } }
|
||||
}
|
||||
} // <--- HIER endet der respondHtml-Block
|
||||
} // Ende get("/")
|
||||
}
|
||||
// Configure routes
|
||||
configureHomeRoutes()
|
||||
configureNennungRoutes()
|
||||
configureAdminRoutes()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package at.mocode.config
|
||||
|
||||
import at.mocode.email.EmailService
|
||||
import at.mocode.repository.NennungRepository
|
||||
import at.mocode.repository.TurnierRepository
|
||||
import at.mocode.views.HomeView
|
||||
import at.mocode.views.NennungView
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Simple dependency injection container for the application.
|
||||
* This singleton provides access to all services and repositories.
|
||||
*/
|
||||
object DependencyInjection {
|
||||
private val log = LoggerFactory.getLogger(DependencyInjection::class.java)
|
||||
|
||||
// Repositories
|
||||
val turnierRepository by lazy {
|
||||
log.debug("Creating TurnierRepository")
|
||||
TurnierRepository()
|
||||
}
|
||||
|
||||
val nennungRepository by lazy {
|
||||
log.debug("Creating NennungRepository")
|
||||
NennungRepository()
|
||||
}
|
||||
|
||||
// Services
|
||||
val emailService by lazy {
|
||||
log.debug("Creating EmailService")
|
||||
EmailService.getInstance()
|
||||
}
|
||||
|
||||
// Views
|
||||
val homeView by lazy {
|
||||
log.debug("Creating HomeView")
|
||||
HomeView()
|
||||
}
|
||||
|
||||
val nennungView by lazy {
|
||||
log.debug("Creating NennungView")
|
||||
NennungView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package at.mocode.config
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Configuration for email service.
|
||||
* Loads configuration from environment variables with fallbacks to the .env file and then default values.
|
||||
*/
|
||||
object EmailConfig {
|
||||
private val log = LoggerFactory.getLogger(EmailConfig::class.java)
|
||||
|
||||
// Load values from the .env file if they exist
|
||||
private val envProperties = loadEnvFile()
|
||||
|
||||
/**
|
||||
* Loads environment variables from .env file
|
||||
* @return Property object containing the variables from .env file or empty Properties if a file doesn't exist
|
||||
*/
|
||||
private fun loadEnvFile(): Properties {
|
||||
val properties = Properties()
|
||||
|
||||
// Try multiple possible locations for the .env file
|
||||
val possibleLocations = listOf(
|
||||
".env", // Current working directory
|
||||
"../.env", // Parent directory
|
||||
"../../.env" // Grandparent directory
|
||||
)
|
||||
|
||||
for (location in possibleLocations) {
|
||||
val envFile = File(location)
|
||||
if (envFile.exists()) {
|
||||
try {
|
||||
FileInputStream(envFile).use { fis ->
|
||||
properties.load(fis)
|
||||
}
|
||||
log.info("Loaded .env file from: ${envFile.absolutePath}")
|
||||
break // Stop after successfully loading the file
|
||||
} catch (e: Exception) {
|
||||
log.error("Error loading .env file from ${envFile.absolutePath}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get value from environment, .env file, or default
|
||||
* @param key The environment variable key
|
||||
* @param defaultValue The default value to use if the key is not found
|
||||
* @return The value from environment, .env file, or default
|
||||
*/
|
||||
private fun getConfigValue(key: String, defaultValue: String): String {
|
||||
val value = System.getenv(key) ?: envProperties.getProperty(key) ?: defaultValue
|
||||
if (value == defaultValue) {
|
||||
log.debug("Using default value for $key: $defaultValue")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// SMTP server configuration
|
||||
val smtpHost: String = getConfigValue("SMTP_HOST", "smtp.gmail.com")
|
||||
val smtpPort: Int = System.getenv("SMTP_PORT")?.toIntOrNull()
|
||||
?: envProperties.getProperty("SMTP_PORT")?.toIntOrNull()
|
||||
?: 587
|
||||
|
||||
// Authentication credentials
|
||||
val smtpUsername: String = getConfigValue("SMTP_USER", "")
|
||||
val smtpPassword: String = getConfigValue("SMTP_PASSWORD", "")
|
||||
|
||||
// Email addresses
|
||||
val recipientEmail: String = getConfigValue("RECIPIENT_EMAIL", "")
|
||||
val senderEmail: String = getConfigValue("SMTP_SENDER_EMAIL", smtpUsername)
|
||||
|
||||
/**
|
||||
* Validates that all required configuration is present.
|
||||
* @return true if the configuration is valid, false otherwise
|
||||
*/
|
||||
fun isValid(): Boolean {
|
||||
return smtpHost.isNotBlank() &&
|
||||
smtpPort > 0 &&
|
||||
smtpUsername.isNotBlank() &&
|
||||
smtpPassword.isNotBlank() &&
|
||||
recipientEmail.isNotBlank() &&
|
||||
senderEmail.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string describing any missing configuration.
|
||||
* @return A string with error messages or empty string if the configuration is valid
|
||||
*/
|
||||
fun getValidationErrors(): String {
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
if (smtpHost.isBlank()) errors.add("SMTP_HOST is not configured")
|
||||
if (smtpPort <= 0) errors.add("SMTP_PORT is invalid")
|
||||
if (smtpUsername.isBlank()) errors.add("SMTP_USER is not configured")
|
||||
if (smtpPassword.isBlank()) errors.add("SMTP_PASSWORD is not configured")
|
||||
if (recipientEmail.isBlank()) errors.add("RECIPIENT_EMAIL is not configured")
|
||||
if (senderEmail.isBlank()) errors.add("SMTP_SENDER_EMAIL is not configured")
|
||||
|
||||
return errors.joinToString(", ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package at.mocode.email
|
||||
|
||||
import at.mocode.config.EmailConfig
|
||||
import at.mocode.model.Nennung
|
||||
import jakarta.mail.*
|
||||
import jakarta.mail.internet.InternetAddress
|
||||
import jakarta.mail.internet.MimeBodyPart
|
||||
import jakarta.mail.internet.MimeMessage
|
||||
import jakarta.mail.internet.MimeMultipart
|
||||
import java.util.*
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Service for sending email notifications for form submissions.
|
||||
* Implemented as a singleton to avoid multiple initializations.
|
||||
* Thread-safe implementation with improved error handling and HTML support.
|
||||
*/
|
||||
class EmailService private constructor(
|
||||
private val smtpHost: String,
|
||||
private val smtpPort: Int,
|
||||
private val smtpUsername: String,
|
||||
private val smtpPassword: String,
|
||||
private val recipientEmail: String,
|
||||
private val senderEmail: String = smtpUsername
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(EmailService::class.java)
|
||||
private val maxRetries = 3
|
||||
private val retryDelayMs = 1000L
|
||||
|
||||
companion object {
|
||||
private var instance: EmailService? = null
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
/**
|
||||
* Gets the singleton instance of EmailService.
|
||||
* Initializes it if it doesn't exist yet.
|
||||
* Thread-safe implementation using a lock.
|
||||
*/
|
||||
fun getInstance(): EmailService {
|
||||
return lock.withLock {
|
||||
if (instance == null) {
|
||||
instance = EmailService(
|
||||
smtpHost = EmailConfig.smtpHost,
|
||||
smtpPort = EmailConfig.smtpPort,
|
||||
smtpUsername = EmailConfig.smtpUsername,
|
||||
smtpPassword = EmailConfig.smtpPassword,
|
||||
recipientEmail = EmailConfig.recipientEmail,
|
||||
senderEmail = EmailConfig.senderEmail
|
||||
)
|
||||
}
|
||||
instance!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates email session with the configured properties.
|
||||
*
|
||||
* @param debug Whether to enable debug mode for the mail session
|
||||
* @return The configured mail session
|
||||
*/
|
||||
private fun createSession(debug: Boolean = false): Session {
|
||||
val properties = Properties().apply {
|
||||
put("mail.smtp.auth", "true")
|
||||
put("mail.smtp.starttls.enable", "true")
|
||||
put("mail.smtp.host", smtpHost)
|
||||
put("mail.smtp.port", smtpPort.toString())
|
||||
put("mail.debug", debug.toString())
|
||||
put("mail.smtp.ssl.protocols", "TLSv1.2")
|
||||
put("mail.smtp.connectiontimeout", "10000")
|
||||
put("mail.smtp.timeout", "10000")
|
||||
}
|
||||
|
||||
return Session.getInstance(properties, object : Authenticator() {
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication {
|
||||
return PasswordAuthentication(smtpUsername, smtpPassword)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address format.
|
||||
*
|
||||
* @param email The email address to validate
|
||||
* @return true if the email format is valid, false otherwise
|
||||
*/
|
||||
private fun isValidEmail(email: String): Boolean {
|
||||
val emailRegex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$")
|
||||
return email.matches(emailRegex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an email notification with the form submission data.
|
||||
* Includes retry mechanism for transient failures.
|
||||
*
|
||||
* @param nennung The form submission data
|
||||
* @return true if the email was sent successfully, false otherwise
|
||||
*/
|
||||
fun sendNennungEmail(nennung: Nennung): Boolean {
|
||||
log.info("Attempting to send email for ${nennung.riderName} with ${nennung.horseName}")
|
||||
log.debug("SMTP Configuration: Host=$smtpHost, Port=$smtpPort, Username=$smtpUsername")
|
||||
log.debug("Email addresses: From=$senderEmail, To=$recipientEmail")
|
||||
|
||||
// Validate email addresses
|
||||
if (!isValidEmail(senderEmail) || !isValidEmail(recipientEmail)) {
|
||||
log.error("Invalid email address format: sender=$senderEmail, recipient=$recipientEmail")
|
||||
return false
|
||||
}
|
||||
|
||||
var attempts = 0
|
||||
var lastException: Exception? = null
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
attempts++
|
||||
try {
|
||||
val session = createSession()
|
||||
|
||||
val message = MimeMessage(session).apply {
|
||||
setFrom(InternetAddress(senderEmail))
|
||||
setRecipients(Message.RecipientType.TO, InternetAddress.parse(recipientEmail))
|
||||
subject = nennung.turnier?.let { "Neue Nennung für ${it.name}: ${nennung.riderName} mit ${nennung.horseName}" }
|
||||
?: "Neue Nennung: ${nennung.riderName} mit ${nennung.horseName}"
|
||||
|
||||
// Create multipart message with both plain text and HTML versions
|
||||
val multipart = MimeMultipart("alternative")
|
||||
|
||||
// Plain text part
|
||||
val textPart = MimeBodyPart().apply {
|
||||
setText(createEmailContent(nennung, false))
|
||||
}
|
||||
multipart.addBodyPart(textPart)
|
||||
|
||||
// HTML part
|
||||
val htmlPart = MimeBodyPart().apply {
|
||||
setContent(createEmailContent(nennung, true), "text/html; charset=utf-8")
|
||||
}
|
||||
multipart.addBodyPart(htmlPart)
|
||||
|
||||
// Set content to the multipart
|
||||
setContent(multipart)
|
||||
}
|
||||
|
||||
log.info("Email message prepared, attempting to send... (Attempt $attempts of $maxRetries)")
|
||||
Transport.send(message)
|
||||
log.info("Email sent successfully!")
|
||||
return true
|
||||
} catch (e: MessagingException) {
|
||||
lastException = e
|
||||
log.warn("Failed to send email (Attempt $attempts of $maxRetries): ${e.message}")
|
||||
if (attempts < maxRetries) {
|
||||
log.info("Retrying in ${retryDelayMs}ms...")
|
||||
Thread.sleep(retryDelayMs)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
log.error("Unexpected error sending email: ${e.message}", e)
|
||||
break // Don't retry on non-messaging exceptions
|
||||
}
|
||||
}
|
||||
|
||||
log.error("Failed to send email after $attempts attempts", lastException)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a test email to verify the email configuration.
|
||||
*
|
||||
* @param recipient Optional recipient email address (defaults to configured recipient)
|
||||
* @param debug Whether to enable debug mode for the mail session
|
||||
* @return true if the test email was sent successfully, false otherwise
|
||||
*/
|
||||
fun sendTestEmail(recipient: String = recipientEmail, debug: Boolean = false): Boolean {
|
||||
log.info("Sending test email to $recipient")
|
||||
|
||||
if (!isValidEmail(recipient)) {
|
||||
log.error("Invalid recipient email address format: $recipient")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
val session = createSession(debug)
|
||||
|
||||
val message = MimeMessage(session).apply {
|
||||
setFrom(InternetAddress(senderEmail))
|
||||
setRecipients(Message.RecipientType.TO, InternetAddress.parse(recipient))
|
||||
subject = "Test Email from Meldestelle Application"
|
||||
|
||||
val multipart = MimeMultipart("alternative")
|
||||
|
||||
// Plain text part
|
||||
val textPart = MimeBodyPart().apply {
|
||||
setText("Dies ist eine Test-Email vom Meldestelle System.\n\nWenn Sie diese Email erhalten haben, funktioniert die Email-Konfiguration korrekt.")
|
||||
}
|
||||
multipart.addBodyPart(textPart)
|
||||
|
||||
// HTML part
|
||||
val htmlPart = MimeBodyPart().apply {
|
||||
setContent("""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Dies ist eine Test-Email vom Meldestelle System</h2>
|
||||
<p>Wenn Sie diese Email erhalten haben, funktioniert die Email-Konfiguration korrekt.</p>
|
||||
<p>SMTP-Konfiguration:</p>
|
||||
<ul>
|
||||
<li>Host: $smtpHost</li>
|
||||
<li>Port: $smtpPort</li>
|
||||
<li>Benutzername: $smtpUsername</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent(), "text/html; charset=utf-8")
|
||||
}
|
||||
multipart.addBodyPart(htmlPart)
|
||||
|
||||
setContent(multipart)
|
||||
}
|
||||
|
||||
log.info("Test email prepared, attempting to send...")
|
||||
Transport.send(message)
|
||||
log.info("Test email sent successfully!")
|
||||
return true
|
||||
} catch (e: MessagingException) {
|
||||
log.error("Failed to send test email: ${e.message}", e)
|
||||
return false
|
||||
} catch (e: Exception) {
|
||||
log.error("Unexpected error sending test email: ${e.message}", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the email content from the form submission data.
|
||||
*
|
||||
* @param nennung The form submission data
|
||||
* @param html Whether to generate HTML content (true) or plain text (false)
|
||||
* @return The formatted email content
|
||||
*/
|
||||
private fun createEmailContent(nennung: Nennung, html: Boolean = false): String {
|
||||
if (!html) {
|
||||
// Plain text version
|
||||
val sb = StringBuilder()
|
||||
|
||||
// Add tournament information if available
|
||||
nennung.turnier?.let { turnier ->
|
||||
sb.appendLine(turnier.name)
|
||||
sb.appendLine()
|
||||
}
|
||||
|
||||
sb.appendLine("Reiter: ${nennung.riderName}")
|
||||
sb.appendLine("Pferd: ${nennung.horseName}")
|
||||
sb.appendLine("E-Mail: ${nennung.email}")
|
||||
sb.appendLine("Telefon: ${nennung.phone}")
|
||||
sb.appendLine()
|
||||
sb.appendLine("Bewerbe:")
|
||||
nennung.selectedEvents.forEach { event ->
|
||||
sb.appendLine("- $event")
|
||||
}
|
||||
sb.appendLine()
|
||||
if (nennung.comments.isNotBlank()) {
|
||||
sb.appendLine("Bemerkungen:")
|
||||
sb.appendLine(nennung.comments)
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
} else {
|
||||
// HTML version
|
||||
val sb = StringBuilder()
|
||||
sb.append("<html><body>")
|
||||
|
||||
// Add tournament information if available
|
||||
nennung.turnier?.let { turnier ->
|
||||
sb.append("<h2>${turnier.name}</h2>")
|
||||
}
|
||||
|
||||
sb.append("<p><strong>Reiter:</strong> ${nennung.riderName}</p>")
|
||||
sb.append("<p><strong>Pferd:</strong> ${nennung.horseName}</p>")
|
||||
sb.append("<p><strong>E-Mail:</strong> <a href=\"mailto:${nennung.email}\">${nennung.email}</a></p>")
|
||||
sb.append("<p><strong>Telefon:</strong> ${nennung.phone}</p>")
|
||||
|
||||
sb.append("<h3>Bewerbe:</h3>")
|
||||
sb.append("<ul>")
|
||||
nennung.selectedEvents.forEach { event ->
|
||||
sb.append("<li>$event</li>")
|
||||
}
|
||||
sb.append("</ul>")
|
||||
|
||||
if (nennung.comments.isNotBlank()) {
|
||||
sb.append("<h3>Bemerkungen:</h3>")
|
||||
sb.append("<p>${nennung.comments.replace("\n", "<br>")}</p>")
|
||||
}
|
||||
|
||||
sb.append("</body></html>")
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package at.mocode.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents a competition (Bewerb) within a tournament.
|
||||
* A competition has specific details like number, title, class, and optional task.
|
||||
*/
|
||||
@Serializable
|
||||
data class Bewerb(
|
||||
/** Competition number, e.g. 1, 2, etc. */
|
||||
val nummer: Int,
|
||||
|
||||
/** Title of the competition, e.g. "Stilspringprüfung" or "Dressurprüfung" */
|
||||
val titel: String,
|
||||
|
||||
/** Class/level of the competition, e.g. "60 cm" or "Kl. A" */
|
||||
val klasse: String = "",
|
||||
|
||||
/** Optional task identifier, e.g. "DRA 1" */
|
||||
val task: String? = null
|
||||
)
|
||||
@@ -4,13 +4,24 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Nennung(
|
||||
// Wir brauchen die Turnier-ID, um die Nennung zuzuordnen
|
||||
val turnierId: String,
|
||||
// Einfache Felder für den Start
|
||||
val riderName: String = "", // Standardwerte für leeres Formular
|
||||
val horseName: String = "",
|
||||
val email: String = "",
|
||||
val comments: String? = null
|
||||
// Hier kommen später Felder hinzu: Verein, Lizenznr., Tel,
|
||||
// und vor allem: die Auswahl der Prüfungen!
|
||||
/** Name of the rider */
|
||||
val riderName: String,
|
||||
|
||||
/** Name of the horse */
|
||||
val horseName: String,
|
||||
|
||||
/** Email address for contact */
|
||||
val email: String,
|
||||
|
||||
/** Phone number for contact */
|
||||
val phone: String,
|
||||
|
||||
/** List of selected event numbers */
|
||||
val selectedEvents: List<String>,
|
||||
|
||||
/** Additional comments or wishes */
|
||||
val comments: String,
|
||||
|
||||
/** The tournament this registration is for */
|
||||
val turnier: Turnier
|
||||
)
|
||||
|
||||
@@ -4,11 +4,15 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Turnier(
|
||||
val id: String, // Eine eindeutige ID für das Turnier (z.B. eine UUID als String)
|
||||
val name: String, // Der Name, z.B. "CDN-C Edelhof April 2025"
|
||||
val datum: String, // Das Datum oder der Zeitraum, erstmal als Text, z.B. "14.04.2025 - 15.04.2025"
|
||||
val logoUrl: String? = null, // Optional: Link zum Logo des Veranstalters
|
||||
val ausschreibungUrl: String? = null // Optional: Link zur Ausschreibungs-PDF
|
||||
// Hier können später viele weitere Felder hinzukommen:
|
||||
// Ort, Veranstalter, Status (geplant, läuft, beendet), Disziplinen etc.
|
||||
/** The name of the tournament, e.g. "CSN-C NEU CSNP-C NEU NEUMARKT/M., OÖ" */
|
||||
val name: String,
|
||||
|
||||
/** The date of the tournament as a formatted string, e.g. "7.JUNI 2025" */
|
||||
val datum: String,
|
||||
|
||||
/** Unique identifier for the tournament */
|
||||
val number: Int,
|
||||
|
||||
/** List of competitions (Bewerbe) associated with this tournament */
|
||||
var bewerbe: List<Bewerb> = emptyList()
|
||||
)
|
||||
|
||||
@@ -4,52 +4,67 @@ import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
import org.jetbrains.exposed.sql.SchemaUtils
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import at.mocode.tables.TurniereTable
|
||||
import at.mocode.tables.BewerbeTable
|
||||
import at.mocode.tables.NennungenTable
|
||||
import at.mocode.tables.NennungEventsTable
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Configures the database connection based on the environment.
|
||||
* Supports three environments:
|
||||
* - Test: Uses in-memory SQLite
|
||||
* - Development (IDEA): Uses file-based SQLite
|
||||
* - Production/Docker: Uses PostgreSQL with connection pooling
|
||||
*/
|
||||
fun configureDatabase() {
|
||||
val log = LoggerFactory.getLogger("DatabaseInitialization")
|
||||
var connectionSuccessful = false // Flag: Wurde irgendeine Verbindung hergestellt?
|
||||
var connectionSuccessful = false // Flag: Was any connection established?
|
||||
|
||||
// Prüfen, ob wir in einer Testumgebung sind (z.B. über System Property)
|
||||
// Check if we're in a test environment (e.g. via System Property)
|
||||
val isTestEnvironment = System.getProperty("isTestEnvironment")?.toBoolean() ?: false
|
||||
|
||||
if (isTestEnvironment) {
|
||||
log.info("Test environment detected, using in-memory H2 database (test)...")
|
||||
log.info("Test environment detected, using SQLite in-memory database (test)...")
|
||||
try {
|
||||
// H2 im PostgreSQL-Kompatibilitätsmodus starten, kann helfen
|
||||
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver")
|
||||
log.info("Connected to H2 (test) successfully.")
|
||||
Database.connect("jdbc:sqlite::memory:", driver = "org.sqlite.JDBC")
|
||||
log.info("Connected to SQLite in-memory (test) successfully.")
|
||||
connectionSuccessful = true
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to connect to H2 (test)!", e)
|
||||
throw e // Fehler weiterwerfen, Test soll fehlschlagen
|
||||
log.error("Failed to connect to SQLite (test)!", e)
|
||||
throw e // Rethrow error, test should fail
|
||||
}
|
||||
} else {
|
||||
// Prüfen, ob wir in IDEA laufen (keine Docker Umgebungsvariablen gesetzt)
|
||||
// wir prüfen nur eine Variable, das reicht meistens
|
||||
// Check if we're running in IDEA (no Docker environment variables set)
|
||||
// We only check one variable, that's usually enough
|
||||
val dbHostFromEnv = System.getenv("DB_HOST")
|
||||
val isIdeaEnvironment = (dbHostFromEnv == null)
|
||||
|
||||
if (isIdeaEnvironment) {
|
||||
log.info("IDEA environment detected (missing DB_HOST), using in-memory H2 database (dev)...")
|
||||
// Ensure the data directory exists
|
||||
val dataDir = File("data")
|
||||
if (!dataDir.exists()) {
|
||||
dataDir.mkdir()
|
||||
}
|
||||
|
||||
log.info("IDEA environment detected (missing DB_HOST), using SQLite file database (dev)...")
|
||||
try {
|
||||
Database.connect("jdbc:h2:mem:dev;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver")
|
||||
log.info("Connected to H2 (dev) successfully.")
|
||||
Database.connect("jdbc:sqlite:data/meldestelle.db", driver = "org.sqlite.JDBC")
|
||||
log.info("Connected to SQLite file database (dev) successfully.")
|
||||
connectionSuccessful = true
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to connect to H2 (dev)!", e)
|
||||
// Hier vielleicht nicht werfen, damit App in IDE trotzdem startet? Oder doch? → Aktuell wirft es.
|
||||
log.error("Failed to connect to SQLite (dev)!", e)
|
||||
// Maybe don't throw here so the app starts in IDE anyway? Currently it throws.
|
||||
throw e
|
||||
}
|
||||
} else {
|
||||
// Normale Docker/Produktionsumgebung -> PostgreSQL verwenden
|
||||
// Normal Docker/Production environment -> use PostgreSQL
|
||||
log.info("Production/Docker environment detected, connecting to PostgreSQL...")
|
||||
try {
|
||||
// Lese Konfiguration direkt aus Umgebungsvariablen
|
||||
val dbHost = dbHostFromEnv // Sicherer Fallback
|
||||
// Read configuration directly from environment variables
|
||||
val dbHost = dbHostFromEnv // Safe fallback
|
||||
val dbPort = System.getenv("DB_PORT") ?: "5432"
|
||||
val dbName = System.getenv("DB_NAME") ?: error("DB_NAME not set in environment")
|
||||
val dbUser = System.getenv("DB_USER") ?: error("DB_USER not set in environment")
|
||||
@@ -74,34 +89,24 @@ fun configureDatabase() {
|
||||
connectionSuccessful = true
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to initialize PostgreSQL connection pool!", e)
|
||||
throw e // Fehler weiterwerfen, App soll nicht starten ohne DB in Prod
|
||||
throw e // Rethrow error, app should not start without DB in prod
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Schema Initialisierung (JETZT ZENTRALISIERT) ---
|
||||
// Führe dies nur aus, wenn *irgendeine* DB-Verbindung erfolgreich war
|
||||
transaction { // Führe Schema-Operationen in einer Transaktion aus
|
||||
// --- Schema Initialization (NOW CENTRALIZED) ---
|
||||
// Only execute this if *any* DB connection was successful
|
||||
transaction { // Execute schema operations in a transaction
|
||||
log.info("Initializing/Verifying database schema...")
|
||||
try {
|
||||
// Erstellt die Tabelle(n), falls sie noch nicht existieren
|
||||
SchemaUtils.create(TurniereTable)
|
||||
// Füge hier später weitere Tabellen hinzu:
|
||||
// SchemaUtils.create(TurniereTable, NennungenTable, ...)
|
||||
// Create the table(s) if they don't exist yet
|
||||
SchemaUtils.create(TurniereTable, BewerbeTable, NennungenTable, NennungEventsTable)
|
||||
|
||||
log.info("Database schema initialized successfully (tables created/verified).")
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to initialize database schema!", e)
|
||||
// Hier könntest du entscheiden, ob ein Fehler beim Schema kritisch ist
|
||||
// throw e // Auskommentiert: App startet evtl. trotzdem, auch wenn Schema fehlt/falsch ist
|
||||
// Here you could decide if a schema error is critical
|
||||
// throw e // Commented out: App might start anyway, even if schema is missing/wrong
|
||||
}
|
||||
}
|
||||
|
||||
// --- TODO für den NÄCHSTEN Schritt ---
|
||||
// Hier kommt später die Logik zum Erstellen der Tabellen hin,
|
||||
// z.B. innerhalb einer Transaktion:
|
||||
// transaction {
|
||||
// SchemaUtils.create(TurniereTable) // Erstellt die Tabelle, wenn sie nicht existiert
|
||||
// }
|
||||
// ------------------------------------
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package at.mocode.repository
|
||||
|
||||
import at.mocode.config.DependencyInjection
|
||||
import at.mocode.model.Nennung
|
||||
import at.mocode.tables.NennungEventsTable
|
||||
import at.mocode.tables.NennungenTable
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Repository class for handling registration-related database operations.
|
||||
*/
|
||||
class NennungRepository {
|
||||
private val log = LoggerFactory.getLogger(this::class.java)
|
||||
private val turnierRepository by lazy { DependencyInjection.turnierRepository }
|
||||
|
||||
/**
|
||||
* Saves a registration to the database.
|
||||
* @param nennung The registration to save
|
||||
* @return The ID of the saved registration
|
||||
*/
|
||||
fun saveNennung(nennung: Nennung): Int = transaction {
|
||||
log.info("Saving registration for ${nennung.riderName} with ${nennung.horseName}")
|
||||
|
||||
// Insert the registration
|
||||
val nennungId = NennungenTable.insert {
|
||||
it[riderName] = nennung.riderName
|
||||
it[horseName] = nennung.horseName
|
||||
it[email] = nennung.email
|
||||
it[phone] = nennung.phone
|
||||
it[comments] = nennung.comments
|
||||
it[turnierNumber] = nennung.turnier.number
|
||||
} get NennungenTable.id
|
||||
|
||||
// Insert the selected events
|
||||
nennung.selectedEvents.forEach { eventNumber ->
|
||||
NennungEventsTable.insert {
|
||||
it[NennungEventsTable.nennungId] = nennungId
|
||||
it[NennungEventsTable.eventNumber] = eventNumber
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Registration saved with ID: $nennungId")
|
||||
nennungId
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Nennung object from form parameters.
|
||||
* @param riderName The name of the rider
|
||||
* @param horseName The name of the horse
|
||||
* @param email The email address for contact
|
||||
* @param phone The phone number for contact
|
||||
* @param selectedEvents The list of selected event numbers
|
||||
* @param comments Additional comments or wishes
|
||||
* @param turnierNumber The number of the tournament
|
||||
* @return The created Nennung object, or null if the tournament was not found
|
||||
*/
|
||||
fun createNennung(
|
||||
riderName: String,
|
||||
horseName: String,
|
||||
email: String,
|
||||
phone: String,
|
||||
selectedEvents: List<String>,
|
||||
comments: String,
|
||||
turnierNumber: Int
|
||||
): Nennung? {
|
||||
val turnier = turnierRepository.getTurnierByNumber(turnierNumber) ?: return null
|
||||
|
||||
return Nennung(
|
||||
riderName = riderName,
|
||||
horseName = horseName,
|
||||
email = email,
|
||||
phone = phone,
|
||||
selectedEvents = selectedEvents,
|
||||
comments = comments,
|
||||
turnier = turnier
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package at.mocode.repository
|
||||
|
||||
import at.mocode.model.Bewerb
|
||||
import at.mocode.model.Turnier
|
||||
import at.mocode.tables.BewerbeTable
|
||||
import at.mocode.tables.TurniereTable
|
||||
import org.jetbrains.exposed.sql.*
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Repository class for handling tournament-related database operations.
|
||||
*/
|
||||
class TurnierRepository {
|
||||
private val log = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
/**
|
||||
* Creates a new tournament with competitions.
|
||||
* @param number The tournament number
|
||||
* @param name The tournament name
|
||||
* @param datum The tournament date
|
||||
* @param bewerbe List of competitions for the tournament
|
||||
* @return The created tournament or null if creation failed
|
||||
*/
|
||||
fun createTurnier(number: Int, name: String, datum: String, bewerbe: List<Bewerb>): Turnier? = transaction {
|
||||
try {
|
||||
// Check if a tournament with this number already exists
|
||||
val existingTurnier = TurniereTable.select { TurniereTable.number eq number }.singleOrNull()
|
||||
if (existingTurnier != null) {
|
||||
log.error("Tournament with number $number already exists")
|
||||
return@transaction null
|
||||
}
|
||||
|
||||
// Insert tournament
|
||||
TurniereTable.insert {
|
||||
it[TurniereTable.number] = number
|
||||
it[TurniereTable.name] = name
|
||||
it[TurniereTable.datum] = datum
|
||||
}
|
||||
|
||||
// Insert competitions
|
||||
bewerbe.forEach { bewerb ->
|
||||
BewerbeTable.insert {
|
||||
it[BewerbeTable.nummer] = bewerb.nummer
|
||||
it[BewerbeTable.titel] = bewerb.titel
|
||||
it[BewerbeTable.klasse] = bewerb.klasse
|
||||
it[BewerbeTable.task] = bewerb.task
|
||||
it[BewerbeTable.turnierNumber] = number
|
||||
}
|
||||
}
|
||||
|
||||
// Return the created tournament
|
||||
Turnier(
|
||||
number = number,
|
||||
name = name,
|
||||
datum = datum,
|
||||
bewerbe = bewerbe
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log.error("Error creating tournament", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing tournament with competitions.
|
||||
* @param number The tournament number
|
||||
* @param name The new tournament name
|
||||
* @param datum The new tournament date
|
||||
* @param bewerbe The new list of competitions
|
||||
* @return The updated tournament or null if update failed
|
||||
*/
|
||||
fun updateTurnier(number: Int, name: String, datum: String, bewerbe: List<Bewerb>): Turnier? = transaction {
|
||||
try {
|
||||
// Check if the tournament exists
|
||||
val existingTurnier = TurniereTable.select { TurniereTable.number eq number }.singleOrNull()
|
||||
if (existingTurnier == null) {
|
||||
log.error("Tournament with number $number not found")
|
||||
return@transaction null
|
||||
}
|
||||
|
||||
// Update tournament
|
||||
TurniereTable.update({ TurniereTable.number eq number }) {
|
||||
it[TurniereTable.name] = name
|
||||
it[TurniereTable.datum] = datum
|
||||
}
|
||||
|
||||
// Delete existing competitions
|
||||
BewerbeTable.deleteWhere { BewerbeTable.turnierNumber eq number }
|
||||
|
||||
// Insert new competitions
|
||||
bewerbe.forEach { bewerb ->
|
||||
BewerbeTable.insert {
|
||||
it[BewerbeTable.nummer] = bewerb.nummer
|
||||
it[BewerbeTable.titel] = bewerb.titel
|
||||
it[BewerbeTable.klasse] = bewerb.klasse
|
||||
it[BewerbeTable.task] = bewerb.task
|
||||
it[BewerbeTable.turnierNumber] = number
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated tournament
|
||||
Turnier(
|
||||
number = number,
|
||||
name = name,
|
||||
datum = datum,
|
||||
bewerbe = bewerbe
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log.error("Error updating tournament", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all tournaments from the database with their associated competitions.
|
||||
* @return List of Turnier objects
|
||||
*/
|
||||
fun getAllTurniere(): List<Turnier> = transaction {
|
||||
log.info("Fetching all tournaments from database...")
|
||||
|
||||
// Get all tournaments
|
||||
val turniere = TurniereTable.selectAll().map { row ->
|
||||
Turnier(
|
||||
name = row[TurniereTable.name],
|
||||
datum = row[TurniereTable.datum],
|
||||
number = row[TurniereTable.number]
|
||||
)
|
||||
}
|
||||
|
||||
// For each tournament, get its competitions
|
||||
turniere.forEach { turnier ->
|
||||
val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq turnier.number }.map { row ->
|
||||
Bewerb(
|
||||
nummer = row[BewerbeTable.nummer],
|
||||
titel = row[BewerbeTable.titel],
|
||||
klasse = row[BewerbeTable.klasse],
|
||||
task = row[BewerbeTable.task]
|
||||
)
|
||||
}
|
||||
turnier.bewerbe = bewerbeList
|
||||
}
|
||||
|
||||
turniere
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts dummy tournaments with competitions if the table is empty.
|
||||
*/
|
||||
fun insertDummyTurnierIfEmpty() = transaction {
|
||||
if (TurniereTable.selectAll().count() == 0L) {
|
||||
log.info("Turnier table is empty, inserting dummy tournaments...")
|
||||
|
||||
// Insert first tournament
|
||||
val turnierNumber1 = 25319
|
||||
TurniereTable.insert {
|
||||
it[TurniereTable.number] = turnierNumber1
|
||||
it[TurniereTable.name] = "CSN-C Edelhof April 2025"
|
||||
it[TurniereTable.datum] = "14.04.2025 - 15.04.2025"
|
||||
}
|
||||
|
||||
// Insert competitions for first tournament
|
||||
BewerbeTable.insert {
|
||||
it[BewerbeTable.nummer] = 1
|
||||
it[BewerbeTable.titel] = "Stilspringprüfung"
|
||||
it[BewerbeTable.klasse] = "60 cm"
|
||||
it[BewerbeTable.task] = null
|
||||
it[BewerbeTable.turnierNumber] = turnierNumber1
|
||||
}
|
||||
|
||||
BewerbeTable.insert {
|
||||
it[BewerbeTable.nummer] = 2
|
||||
it[BewerbeTable.titel] = "Dressurprüfung"
|
||||
it[BewerbeTable.klasse] = "Kl. A"
|
||||
it[BewerbeTable.task] = "DRA 1"
|
||||
it[BewerbeTable.turnierNumber] = turnierNumber1
|
||||
}
|
||||
|
||||
// Insert second tournament (as specified in the issue description)
|
||||
val turnierNumber2 = 25320
|
||||
TurniereTable.insert {
|
||||
it[TurniereTable.number] = turnierNumber2
|
||||
it[TurniereTable.name] = "CDN-C CDNP-C NEU Neumarkt/M., OÖ"
|
||||
it[TurniereTable.datum] = "8. JUNI 2025"
|
||||
}
|
||||
|
||||
// Insert competitions for second tournament
|
||||
BewerbeTable.insert {
|
||||
it[BewerbeTable.nummer] = 1
|
||||
it[BewerbeTable.titel] = "Dressurprüfung"
|
||||
it[BewerbeTable.klasse] = "Kl. A"
|
||||
it[BewerbeTable.task] = "DRA 2"
|
||||
it[BewerbeTable.turnierNumber] = turnierNumber2
|
||||
}
|
||||
|
||||
BewerbeTable.insert {
|
||||
it[BewerbeTable.nummer] = 2
|
||||
it[BewerbeTable.titel] = "Dressurprüfung"
|
||||
it[BewerbeTable.klasse] = "Kl. L"
|
||||
it[BewerbeTable.task] = "DRL 1"
|
||||
it[BewerbeTable.turnierNumber] = turnierNumber2
|
||||
}
|
||||
|
||||
BewerbeTable.insert {
|
||||
it[BewerbeTable.nummer] = 3
|
||||
it[BewerbeTable.titel] = "Dressurprüfung"
|
||||
it[BewerbeTable.klasse] = "Kl. L"
|
||||
it[BewerbeTable.task] = "DRL 2"
|
||||
it[BewerbeTable.turnierNumber] = turnierNumber2
|
||||
}
|
||||
|
||||
log.info("Dummy tournaments and competitions inserted successfully.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a tournament by its number.
|
||||
* @param number The tournament number
|
||||
* @return The tournament or null if not found
|
||||
*/
|
||||
fun getTurnierByNumber(number: Int): Turnier? = transaction {
|
||||
// Get the tournament
|
||||
val turnierRow = TurniereTable.selectAll().where { TurniereTable.number eq number }.singleOrNull() ?: return@transaction null
|
||||
|
||||
val turnier = Turnier(
|
||||
name = turnierRow[TurniereTable.name],
|
||||
datum = turnierRow[TurniereTable.datum],
|
||||
number = turnierRow[TurniereTable.number]
|
||||
)
|
||||
|
||||
// Get competitions for this tournament
|
||||
val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq number }.map { row ->
|
||||
Bewerb(
|
||||
nummer = row[BewerbeTable.nummer],
|
||||
titel = row[BewerbeTable.titel],
|
||||
klasse = row[BewerbeTable.klasse],
|
||||
task = row[BewerbeTable.task]
|
||||
)
|
||||
}
|
||||
|
||||
turnier.bewerbe = bewerbeList
|
||||
turnier
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package at.mocode.routes
|
||||
|
||||
import at.mocode.model.Bewerb
|
||||
import at.mocode.repository.TurnierRepository
|
||||
import at.mocode.views.AdminView
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Configures routes for tournament administration.
|
||||
*/
|
||||
fun Route.adminRoutes() {
|
||||
val log = LoggerFactory.getLogger("AdminRoutes")
|
||||
val turnierRepository = TurnierRepository()
|
||||
val adminView = AdminView()
|
||||
|
||||
// Route to display the tournament management page
|
||||
get("/admin/tournaments") {
|
||||
log.info("Handling request to tournament management page")
|
||||
|
||||
// Get all tournaments
|
||||
val turniere = turnierRepository.getAllTurniere()
|
||||
|
||||
// Render the tournament management page
|
||||
adminView.renderTournamentManagementPage(call, turniere)
|
||||
}
|
||||
|
||||
// Route to display the tournament edit page
|
||||
get("/admin/tournaments/edit/{number}") {
|
||||
val turnierNumber = call.parameters["number"]?.toIntOrNull()
|
||||
if (turnierNumber == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid tournament number")
|
||||
return@get
|
||||
}
|
||||
|
||||
log.info("Displaying edit form for tournament $turnierNumber")
|
||||
|
||||
// Get the tournament
|
||||
val turnier = turnierRepository.getTurnierByNumber(turnierNumber)
|
||||
if (turnier == null) {
|
||||
call.respond(HttpStatusCode.NotFound, "Tournament not found")
|
||||
return@get
|
||||
}
|
||||
|
||||
// Render the edit form
|
||||
adminView.renderTournamentEditPage(call, turnier)
|
||||
}
|
||||
|
||||
// Route to handle tournament creation
|
||||
post("/admin/tournaments/add") {
|
||||
log.info("Processing tournament creation")
|
||||
|
||||
// Parse form parameters
|
||||
val formParameters = call.receiveParameters()
|
||||
val number = formParameters["number"]?.toIntOrNull()
|
||||
val name = formParameters["name"]
|
||||
val datum = formParameters["datum"]
|
||||
|
||||
// Validate required fields
|
||||
if (number == null || name.isNullOrBlank() || datum.isNullOrBlank()) {
|
||||
call.respond(HttpStatusCode.BadRequest, "All fields are required")
|
||||
return@post
|
||||
}
|
||||
|
||||
// Parse competitions
|
||||
val bewerbNummern = formParameters.getAll("bewerb-nummer[]")?.mapNotNull { it.toIntOrNull() } ?: emptyList()
|
||||
val bewerbTitel = formParameters.getAll("bewerb-titel[]") ?: emptyList()
|
||||
val bewerbKlasse = formParameters.getAll("bewerb-klasse[]") ?: emptyList()
|
||||
val bewerbTask = formParameters.getAll("bewerb-task[]") ?: emptyList()
|
||||
|
||||
// Create list of competitions
|
||||
val bewerbe = mutableListOf<Bewerb>()
|
||||
for (i in bewerbNummern.indices) {
|
||||
if (i < bewerbTitel.size && i < bewerbKlasse.size && i < bewerbTask.size) {
|
||||
bewerbe.add(
|
||||
Bewerb(
|
||||
nummer = bewerbNummern[i],
|
||||
titel = bewerbTitel[i],
|
||||
klasse = bewerbKlasse[i],
|
||||
task = if (bewerbTask[i].isNotBlank()) bewerbTask[i] else null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the tournament
|
||||
val turnier = turnierRepository.createTurnier(number, name, datum, bewerbe)
|
||||
if (turnier == null) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Failed to create tournament")
|
||||
return@post
|
||||
}
|
||||
|
||||
// Redirect to tournament management page
|
||||
call.respondRedirect("/admin/tournaments")
|
||||
}
|
||||
|
||||
// Route to handle tournament update
|
||||
post("/admin/tournaments/update/{number}") {
|
||||
val turnierNumber = call.parameters["number"]?.toIntOrNull()
|
||||
if (turnierNumber == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid tournament number")
|
||||
return@post
|
||||
}
|
||||
|
||||
log.info("Processing tournament update for tournament $turnierNumber")
|
||||
|
||||
// Parse form parameters
|
||||
val formParameters = call.receiveParameters()
|
||||
val name = formParameters["name"]
|
||||
val datum = formParameters["datum"]
|
||||
|
||||
// Validate required fields
|
||||
if (name.isNullOrBlank() || datum.isNullOrBlank()) {
|
||||
call.respond(HttpStatusCode.BadRequest, "All fields are required")
|
||||
return@post
|
||||
}
|
||||
|
||||
// Parse competitions
|
||||
val bewerbNummern = formParameters.getAll("bewerb-nummer[]")?.mapNotNull { it.toIntOrNull() } ?: emptyList()
|
||||
val bewerbTitel = formParameters.getAll("bewerb-titel[]") ?: emptyList()
|
||||
val bewerbKlasse = formParameters.getAll("bewerb-klasse[]") ?: emptyList()
|
||||
val bewerbTask = formParameters.getAll("bewerb-task[]") ?: emptyList()
|
||||
|
||||
// Create list of competitions
|
||||
val bewerbe = mutableListOf<Bewerb>()
|
||||
for (i in bewerbNummern.indices) {
|
||||
if (i < bewerbTitel.size && i < bewerbKlasse.size && i < bewerbTask.size) {
|
||||
bewerbe.add(
|
||||
Bewerb(
|
||||
nummer = bewerbNummern[i],
|
||||
titel = bewerbTitel[i],
|
||||
klasse = bewerbKlasse[i],
|
||||
task = if (bewerbTask[i].isNotBlank()) bewerbTask[i] else null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the tournament
|
||||
val turnier = turnierRepository.updateTurnier(turnierNumber, name, datum, bewerbe)
|
||||
if (turnier == null) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Failed to update tournament")
|
||||
return@post
|
||||
}
|
||||
|
||||
// Redirect to tournament management page
|
||||
call.respondRedirect("/admin/tournaments")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to register admin routes.
|
||||
*/
|
||||
fun Application.configureAdminRoutes() {
|
||||
routing {
|
||||
adminRoutes()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package at.mocode.routes
|
||||
|
||||
import at.mocode.config.DependencyInjection
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Configures routes for the home page.
|
||||
*/
|
||||
fun Route.homeRoutes() {
|
||||
val log = LoggerFactory.getLogger("HomeRoutes")
|
||||
val turnierRepository = DependencyInjection.turnierRepository
|
||||
val homeView = DependencyInjection.homeView
|
||||
|
||||
get("/") {
|
||||
log.info("Handling request to home page")
|
||||
|
||||
// Insert dummy tournament if needed
|
||||
turnierRepository.insertDummyTurnierIfEmpty()
|
||||
|
||||
// Get all tournaments
|
||||
val turniere = turnierRepository.getAllTurniere()
|
||||
|
||||
// Render the home page
|
||||
homeView.renderHomePage(call, turniere)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to register home routes.
|
||||
*/
|
||||
fun Application.configureHomeRoutes() {
|
||||
routing {
|
||||
homeRoutes()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package at.mocode.routes
|
||||
|
||||
import at.mocode.config.DependencyInjection
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Configures routes for tournament registration forms.
|
||||
*/
|
||||
fun Route.nennungRoutes() {
|
||||
val log = LoggerFactory.getLogger("NennungRoutes")
|
||||
val turnierRepository = DependencyInjection.turnierRepository
|
||||
val nennungRepository = DependencyInjection.nennungRepository
|
||||
val nennungView = DependencyInjection.nennungView
|
||||
val emailService = DependencyInjection.emailService
|
||||
|
||||
// Route to display the registration form for a specific tournament
|
||||
get("/nennung/{number}") {
|
||||
val turnierNumber = call.parameters["number"]?.toIntOrNull()
|
||||
if (turnierNumber == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid tournament number")
|
||||
return@get
|
||||
}
|
||||
|
||||
log.info("Displaying registration form for tournament $turnierNumber")
|
||||
|
||||
// Get the tournament
|
||||
val turnier = turnierRepository.getTurnierByNumber(turnierNumber)
|
||||
if (turnier == null) {
|
||||
call.respond(HttpStatusCode.NotFound, "Tournament not found")
|
||||
return@get
|
||||
}
|
||||
|
||||
// Render the registration form
|
||||
nennungView.renderNennungForm(call, turnier)
|
||||
}
|
||||
|
||||
// Route to handle form submissions
|
||||
post("/nennung/{number}/submit") {
|
||||
val turnierNumber = call.parameters["number"]?.toIntOrNull()
|
||||
if (turnierNumber == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Invalid tournament number")
|
||||
return@post
|
||||
}
|
||||
|
||||
log.info("Processing registration form submission for tournament $turnierNumber")
|
||||
|
||||
// Get the tournament
|
||||
val turnier = turnierRepository.getTurnierByNumber(turnierNumber)
|
||||
if (turnier == null) {
|
||||
call.respond(HttpStatusCode.NotFound, "Tournament not found")
|
||||
return@post
|
||||
}
|
||||
|
||||
// Parse form parameters
|
||||
val formParameters = call.receiveParameters()
|
||||
val riderName = formParameters["riderName"] ?: ""
|
||||
val horseName = formParameters["horseName"] ?: ""
|
||||
val email = formParameters["email"] ?: ""
|
||||
val phone = formParameters["phone"] ?: ""
|
||||
val comments = formParameters["comments"] ?: ""
|
||||
val selectedEvents = formParameters.getAll("selectedEvents") ?: emptyList()
|
||||
|
||||
// Validate required fields
|
||||
if (riderName.isBlank() || horseName.isBlank()) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Rider name and horse name are required")
|
||||
return@post
|
||||
}
|
||||
|
||||
// Validate that at least one contact method is provided
|
||||
if (email.isBlank() && phone.isBlank()) {
|
||||
call.respond(HttpStatusCode.BadRequest, "Either email or phone number is required")
|
||||
return@post
|
||||
}
|
||||
|
||||
// Create Nennung object using repository
|
||||
val nennung = nennungRepository.createNennung(
|
||||
riderName = riderName,
|
||||
horseName = horseName,
|
||||
email = email,
|
||||
phone = phone,
|
||||
selectedEvents = selectedEvents,
|
||||
comments = comments,
|
||||
turnierNumber = turnierNumber
|
||||
)
|
||||
|
||||
if (nennung == null) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Failed to create registration")
|
||||
return@post
|
||||
}
|
||||
|
||||
// Save the registration to the database
|
||||
val nennungId = nennungRepository.saveNennung(nennung)
|
||||
log.info("Registration saved with ID: $nennungId")
|
||||
|
||||
// Send email notification
|
||||
val emailSent = emailService.sendNennungEmail(nennung)
|
||||
if (!emailSent) {
|
||||
log.error("Failed to send email notification for registration: $riderName with $horseName")
|
||||
// Continue anyway, we don't want to block the user if email fails
|
||||
}
|
||||
|
||||
// Render confirmation page
|
||||
nennungView.renderConfirmationPage(call, turnier, riderName, horseName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to register nennung routes.
|
||||
*/
|
||||
fun Application.configureNennungRoutes() {
|
||||
routing {
|
||||
nennungRoutes()
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,142 @@ package at.mocode.tables
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import org.jetbrains.exposed.sql.Column
|
||||
|
||||
// Definiert die Struktur der Tabelle "turniere" in der Datenbank
|
||||
object TurniereTable : Table("turniere") { // "turniere" ist der Name der Tabelle in PostgreSQL
|
||||
/**
|
||||
* Defines the structure of the "turniere" (tournaments) table in the database.
|
||||
*/
|
||||
object TurniereTable : Table("turniere") {
|
||||
/**
|
||||
* Unique number for the tournament, used as primary key.
|
||||
*/
|
||||
val number: Column<Int> = integer("number").uniqueIndex()
|
||||
|
||||
// Spaltendefinitionen - wir mappen die Felder unserer data class Turnier
|
||||
// wir wählen hier passende SQL-Datentypen aus.
|
||||
|
||||
// id: Wir nehmen VARCHAR(36) an, falls wir UUIDs als Strings speichern.
|
||||
// uniqueIndex() sorgt für Eindeutigkeit und ist gut für Primärschlüssel.
|
||||
val id: Column<String> = varchar("id", 36).uniqueIndex()
|
||||
|
||||
// name: Ein Textfeld, max. 255 Zeichen
|
||||
/**
|
||||
* Name of the tournament, max 255 characters.
|
||||
*/
|
||||
val name: Column<String> = varchar("name", 255)
|
||||
|
||||
// datum: Vorerst einfacher Text, max. 100 Zeichen
|
||||
/**
|
||||
* Date of the tournament as text, max 100 characters.
|
||||
*/
|
||||
val datum: Column<String> = varchar("datum", 100)
|
||||
|
||||
// logoUrl: Textfeld, max. 500 Zeichen, kann NULL sein (.nullable())
|
||||
val logoUrl: Column<String?> = varchar("logo_url", 500).nullable()
|
||||
// Define the 'number' column as the primary key for this table
|
||||
override val primaryKey = PrimaryKey(number)
|
||||
}
|
||||
|
||||
// ausschreibungUrl: Textfeld, max. 500 Zeichen, kann NULL sein
|
||||
val ausschreibungUrl: Column<String?> = varchar("ausschreibung_url", 500).nullable()
|
||||
/**
|
||||
* Defines the structure of the "bewerbe" (competitions) table in the database.
|
||||
*/
|
||||
object BewerbeTable : Table("bewerbe") {
|
||||
/**
|
||||
* Auto-generated ID for the competition.
|
||||
*/
|
||||
val id: Column<Int> = integer("id").autoIncrement()
|
||||
|
||||
// Definiert die Spalte 'id' als Primärschlüssel für diese Tabelle
|
||||
/**
|
||||
* Number of the competition.
|
||||
*/
|
||||
val nummer: Column<Int> = integer("nummer")
|
||||
|
||||
/**
|
||||
* Title of the competition.
|
||||
*/
|
||||
val titel: Column<String> = varchar("titel", 255)
|
||||
|
||||
/**
|
||||
* Class/level of the competition.
|
||||
*/
|
||||
val klasse: Column<String> = varchar("klasse", 100)
|
||||
|
||||
/**
|
||||
* Optional task identifier.
|
||||
*/
|
||||
val task: Column<String?> = varchar("task", 100).nullable()
|
||||
|
||||
/**
|
||||
* Foreign key to the tournament table.
|
||||
*/
|
||||
val turnierNumber: Column<Int> = integer("turnier_number")
|
||||
|
||||
init {
|
||||
foreignKey(turnierNumber to TurniereTable.number)
|
||||
}
|
||||
|
||||
// Define the 'id' column as the primary key for this table
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
// Hier können später weitere Table-Objekte für Nennung, Prüfung etc. hinzukommen.
|
||||
/**
|
||||
* Defines the structure of the "nennungen" (registrations) table in the database.
|
||||
*/
|
||||
object NennungenTable : Table("nennungen") {
|
||||
/**
|
||||
* Auto-generated ID for the registration.
|
||||
*/
|
||||
val id: Column<Int> = integer("id").autoIncrement()
|
||||
|
||||
/**
|
||||
* Name of the rider.
|
||||
*/
|
||||
val riderName: Column<String> = varchar("rider_name", 255)
|
||||
|
||||
/**
|
||||
* Name of the horse.
|
||||
*/
|
||||
val horseName: Column<String> = varchar("horse_name", 255)
|
||||
|
||||
/**
|
||||
* Email address for contact.
|
||||
*/
|
||||
val email: Column<String> = varchar("email", 255)
|
||||
|
||||
/**
|
||||
* Phone number for contact.
|
||||
*/
|
||||
val phone: Column<String> = varchar("phone", 100)
|
||||
|
||||
/**
|
||||
* Additional comments or wishes.
|
||||
*/
|
||||
val comments: Column<String> = text("comments")
|
||||
|
||||
/**
|
||||
* Foreign key to the tournament table.
|
||||
*/
|
||||
val turnierNumber: Column<Int> = integer("turnier_number")
|
||||
|
||||
init {
|
||||
foreignKey(turnierNumber to TurniereTable.number)
|
||||
}
|
||||
|
||||
// Define the 'id' column as the primary key for this table
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the structure of the "nennung_events" table in the database.
|
||||
* This table stores the selected competitions for each registration.
|
||||
*/
|
||||
object NennungEventsTable : Table("nennung_events") {
|
||||
/**
|
||||
* Auto-generated ID for the entry.
|
||||
*/
|
||||
val id: Column<Int> = integer("id").autoIncrement()
|
||||
|
||||
/**
|
||||
* Foreign key to the registration table.
|
||||
*/
|
||||
val nennungId: Column<Int> = integer("nennung_id")
|
||||
|
||||
/**
|
||||
* Number of the selected competition.
|
||||
*/
|
||||
val eventNumber: Column<String> = varchar("event_number", 100)
|
||||
|
||||
init {
|
||||
foreignKey(nennungId to NennungenTable.id)
|
||||
}
|
||||
|
||||
// Define the 'id' column as the primary key for this table
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
package at.mocode.views
|
||||
|
||||
import at.mocode.model.Turnier
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.html.*
|
||||
import kotlinx.html.*
|
||||
|
||||
/**
|
||||
* View class for generating HTML for the admin pages.
|
||||
*/
|
||||
class AdminView {
|
||||
private val layoutTemplate = LayoutTemplate()
|
||||
|
||||
/**
|
||||
* Generates the HTML response for the tournament management page.
|
||||
* @param call The ApplicationCall to respond to
|
||||
* @param turniere List of tournaments to display
|
||||
*/
|
||||
suspend fun renderTournamentManagementPage(call: ApplicationCall, turniere: List<Turnier>) {
|
||||
call.respondHtml(HttpStatusCode.OK) {
|
||||
layoutTemplate.apply {
|
||||
applyLayout("Turnierverwaltung") {
|
||||
h1 { +"Turnierverwaltung" }
|
||||
|
||||
// Form to add a new tournament
|
||||
div(classes = "admin-section mb-4") {
|
||||
h2 {
|
||||
i("fas fa-plus-circle") {}
|
||||
+" Neues Turnier hinzufügen"
|
||||
}
|
||||
form(action = "/admin/tournaments/add", method = FormMethod.post, classes = "admin-form") {
|
||||
div(classes = "form-row") {
|
||||
div(classes = "form-group form-group-third") {
|
||||
label {
|
||||
htmlFor = "turnier-number"
|
||||
+"Turniernummer:"
|
||||
}
|
||||
input(type = InputType.number) {
|
||||
id = "turnier-number"
|
||||
name = "number"
|
||||
required = true
|
||||
placeholder = "z.B. 12345"
|
||||
}
|
||||
}
|
||||
div(classes = "form-group form-group-third") {
|
||||
label {
|
||||
htmlFor = "turnier-name"
|
||||
+"Turniername:"
|
||||
}
|
||||
input(type = InputType.text) {
|
||||
id = "turnier-name"
|
||||
name = "name"
|
||||
required = true
|
||||
placeholder = "Name des Turniers"
|
||||
}
|
||||
}
|
||||
div(classes = "form-group form-group-third") {
|
||||
label {
|
||||
htmlFor = "turnier-datum"
|
||||
+"Datum:"
|
||||
}
|
||||
input(type = InputType.text) {
|
||||
id = "turnier-datum"
|
||||
name = "datum"
|
||||
required = true
|
||||
placeholder = "z.B. 01.01.2023"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div(classes = "bewerbe-section mt-3") {
|
||||
h3 {
|
||||
i("fas fa-trophy") {}
|
||||
+" Bewerbe"
|
||||
}
|
||||
div {
|
||||
id = "bewerbe-container"
|
||||
}
|
||||
button(type = ButtonType.button, classes = "button button-secondary mt-2") {
|
||||
onClick = "addBewerbField()"
|
||||
i("fas fa-plus") {}
|
||||
+" Bewerb hinzufügen"
|
||||
}
|
||||
}
|
||||
|
||||
div(classes = "form-actions mt-4") {
|
||||
button(type = ButtonType.submit, classes = "button") {
|
||||
i("fas fa-save") {}
|
||||
+" Turnier speichern"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Table of existing tournaments
|
||||
div(classes = "admin-section") {
|
||||
h2 {
|
||||
i("fas fa-list") {}
|
||||
+" Vorhandene Turniere"
|
||||
}
|
||||
|
||||
if (turniere.isEmpty()) {
|
||||
div(classes = "empty-state") {
|
||||
i("fas fa-info-circle") {}
|
||||
p { +"Keine Turniere vorhanden" }
|
||||
}
|
||||
} else {
|
||||
div(classes = "table-responsive") {
|
||||
table {
|
||||
thead {
|
||||
tr {
|
||||
th { +"Nummer" }
|
||||
th { +"Name" }
|
||||
th { +"Datum" }
|
||||
th { +"Bewerbe" }
|
||||
th { +"Aktionen" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
turniere.forEach { turnier ->
|
||||
tr {
|
||||
td { +turnier.number.toString() }
|
||||
td { +turnier.name }
|
||||
td { +turnier.datum }
|
||||
td {
|
||||
if (turnier.bewerbe.isEmpty()) {
|
||||
+"Keine Bewerbe"
|
||||
} else {
|
||||
ul(classes = "competition-list") {
|
||||
turnier.bewerbe.forEach { bewerb ->
|
||||
li {
|
||||
+"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}"
|
||||
if (bewerb.task != null) {
|
||||
+" (${bewerb.task})"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
td {
|
||||
button(type = ButtonType.button, classes = "button button-secondary") {
|
||||
onClick = "loadTurnierForEdit(${turnier.number})"
|
||||
i("fas fa-edit") {}
|
||||
+" Bearbeiten"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional styles specific to the admin page
|
||||
style {
|
||||
+"""
|
||||
.admin-section {
|
||||
background-color: var(--container-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.admin-section h2 {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -10px;
|
||||
}
|
||||
|
||||
.form-group-third {
|
||||
flex: 0 0 calc(33.333% - 20px);
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.bewerbe-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.bewerb-container {
|
||||
background-color: var(--light-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.bewerb-container h4 {
|
||||
margin-top: 0;
|
||||
color: var(--secondary-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.competition-list {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.competition-list li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-group-third {
|
||||
flex: 0 0 calc(100% - 20px);
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
// JavaScript for dynamic form handling
|
||||
script(type = "text/javascript") {
|
||||
unsafe {
|
||||
+"""
|
||||
let bewerbCounter = 0;
|
||||
|
||||
function addBewerbField() {
|
||||
bewerbCounter++;
|
||||
const container = document.getElementById('bewerbe-container');
|
||||
const bewerbDiv = document.createElement('div');
|
||||
bewerbDiv.className = 'bewerb-container';
|
||||
bewerbDiv.id = 'bewerb-' + bewerbCounter;
|
||||
|
||||
bewerbDiv.innerHTML = '<h4><i class="fas fa-trophy"></i> Bewerb ' + bewerbCounter + '</h4>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group form-group-third">' +
|
||||
'<label for="bewerb-nummer-' + bewerbCounter + '">Nummer:</label>' +
|
||||
'<input type="number" id="bewerb-nummer-' + bewerbCounter + '" name="bewerb-nummer[]" placeholder="Bewerbnummer" required>' +
|
||||
'</div>' +
|
||||
'<div class="form-group form-group-third">' +
|
||||
'<label for="bewerb-titel-' + bewerbCounter + '">Titel:</label>' +
|
||||
'<input type="text" id="bewerb-titel-' + bewerbCounter + '" name="bewerb-titel[]" placeholder="Titel des Bewerbs" required>' +
|
||||
'</div>' +
|
||||
'<div class="form-group form-group-third">' +
|
||||
'<label for="bewerb-klasse-' + bewerbCounter + '">Klasse:</label>' +
|
||||
'<input type="text" id="bewerb-klasse-' + bewerbCounter + '" name="bewerb-klasse[]" placeholder="Klasse" required>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="bewerb-task-' + bewerbCounter + '">Task (optional):</label>' +
|
||||
'<input type="text" id="bewerb-task-' + bewerbCounter + '" name="bewerb-task[]" placeholder="Optionale Task-Beschreibung">' +
|
||||
'</div>' +
|
||||
'<button type="button" onclick="removeBewerbField(' + bewerbCounter + ')" class="button button-secondary"><i class="fas fa-trash"></i> Bewerb entfernen</button>';
|
||||
|
||||
container.appendChild(bewerbDiv);
|
||||
}
|
||||
|
||||
function removeBewerbField(id) {
|
||||
const bewerbDiv = document.getElementById('bewerb-' + id);
|
||||
bewerbDiv.remove();
|
||||
}
|
||||
|
||||
function loadTurnierForEdit(number) {
|
||||
window.location.href = '/admin/tournaments/edit/' + number;
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the HTML response for the tournament edit page.
|
||||
* @param call The ApplicationCall to respond to
|
||||
* @param turnier The tournament to edit
|
||||
*/
|
||||
suspend fun renderTournamentEditPage(call: ApplicationCall, turnier: Turnier) {
|
||||
call.respondHtml(HttpStatusCode.OK) {
|
||||
layoutTemplate.apply {
|
||||
applyLayout("Turnier bearbeiten") {
|
||||
h1 { +"Turnier bearbeiten" }
|
||||
|
||||
div(classes = "admin-section") {
|
||||
form(action = "/admin/tournaments/update/${turnier.number}", method = FormMethod.post, classes = "admin-form") {
|
||||
div(classes = "form-row") {
|
||||
div(classes = "form-group form-group-third") {
|
||||
label {
|
||||
htmlFor = "turnier-number"
|
||||
+"Turniernummer:"
|
||||
}
|
||||
input(type = InputType.number) {
|
||||
id = "turnier-number"
|
||||
name = "number"
|
||||
value = turnier.number.toString()
|
||||
readonly = true
|
||||
classes = setOf("readonly-field")
|
||||
}
|
||||
}
|
||||
div(classes = "form-group form-group-third") {
|
||||
label {
|
||||
htmlFor = "turnier-name"
|
||||
+"Turniername:"
|
||||
}
|
||||
input(type = InputType.text) {
|
||||
id = "turnier-name"
|
||||
name = "name"
|
||||
value = turnier.name
|
||||
required = true
|
||||
}
|
||||
}
|
||||
div(classes = "form-group form-group-third") {
|
||||
label {
|
||||
htmlFor = "turnier-datum"
|
||||
+"Datum:"
|
||||
}
|
||||
input(type = InputType.text) {
|
||||
id = "turnier-datum"
|
||||
name = "datum"
|
||||
value = turnier.datum
|
||||
required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div(classes = "bewerbe-section mt-3") {
|
||||
h3 {
|
||||
i("fas fa-trophy") {}
|
||||
+" Bewerbe"
|
||||
}
|
||||
div {
|
||||
id = "bewerbe-container"
|
||||
|
||||
// Render existing competitions
|
||||
turnier.bewerbe.forEachIndexed { index, bewerb ->
|
||||
val bewerbIndex = index + 1
|
||||
div(classes = "bewerb-container") {
|
||||
id = "bewerb-$bewerbIndex"
|
||||
h4 {
|
||||
i("fas fa-trophy") {}
|
||||
+" Bewerb $bewerbIndex"
|
||||
}
|
||||
|
||||
div(classes = "form-row") {
|
||||
div(classes = "form-group form-group-third") {
|
||||
label {
|
||||
htmlFor = "bewerb-nummer-$bewerbIndex"
|
||||
+"Nummer:"
|
||||
}
|
||||
input(type = InputType.number) {
|
||||
id = "bewerb-nummer-$bewerbIndex"
|
||||
name = "bewerb-nummer[]"
|
||||
value = bewerb.nummer.toString()
|
||||
required = true
|
||||
}
|
||||
}
|
||||
div(classes = "form-group form-group-third") {
|
||||
label {
|
||||
htmlFor = "bewerb-titel-$bewerbIndex"
|
||||
+"Titel:"
|
||||
}
|
||||
input(type = InputType.text) {
|
||||
id = "bewerb-titel-$bewerbIndex"
|
||||
name = "bewerb-titel[]"
|
||||
value = bewerb.titel
|
||||
required = true
|
||||
}
|
||||
}
|
||||
div(classes = "form-group form-group-third") {
|
||||
label {
|
||||
htmlFor = "bewerb-klasse-$bewerbIndex"
|
||||
+"Klasse:"
|
||||
}
|
||||
input(type = InputType.text) {
|
||||
id = "bewerb-klasse-$bewerbIndex"
|
||||
name = "bewerb-klasse[]"
|
||||
value = bewerb.klasse
|
||||
required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
div(classes = "form-group") {
|
||||
label {
|
||||
htmlFor = "bewerb-task-$bewerbIndex"
|
||||
+"Task (optional):"
|
||||
}
|
||||
input(type = InputType.text) {
|
||||
id = "bewerb-task-$bewerbIndex"
|
||||
name = "bewerb-task[]"
|
||||
value = bewerb.task ?: ""
|
||||
}
|
||||
}
|
||||
button(type = ButtonType.button, classes = "button button-secondary") {
|
||||
onClick = "removeBewerbField($bewerbIndex)"
|
||||
i("fas fa-trash") {}
|
||||
+" Bewerb entfernen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
button(type = ButtonType.button, classes = "button button-secondary mt-2") {
|
||||
onClick = "addBewerbField()"
|
||||
i("fas fa-plus") {}
|
||||
+" Bewerb hinzufügen"
|
||||
}
|
||||
}
|
||||
|
||||
div(classes = "form-actions mt-4") {
|
||||
button(type = ButtonType.submit, classes = "button") {
|
||||
i("fas fa-save") {}
|
||||
+" Turnier aktualisieren"
|
||||
}
|
||||
a(href = "/admin/tournaments", classes = "button button-secondary ml-2") {
|
||||
i("fas fa-arrow-left") {}
|
||||
+" Zurück zur Turnierverwaltung"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional styles specific to the edit page
|
||||
style {
|
||||
+"""
|
||||
.admin-section {
|
||||
background-color: var(--container-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -10px;
|
||||
}
|
||||
|
||||
.form-group-third {
|
||||
flex: 0 0 calc(33.333% - 20px);
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.bewerbe-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.bewerb-container {
|
||||
background-color: var(--light-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.bewerb-container h4 {
|
||||
margin-top: 0;
|
||||
color: var(--secondary-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.readonly-field {
|
||||
background-color: var(--light-bg);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-group-third {
|
||||
flex: 0 0 calc(100% - 20px);
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
// JavaScript for dynamic form handling
|
||||
script(type = "text/javascript") {
|
||||
unsafe {
|
||||
raw("let bewerbCounter = ${turnier.bewerbe.size};")
|
||||
+"""
|
||||
function addBewerbField() {
|
||||
bewerbCounter++;
|
||||
const container = document.getElementById('bewerbe-container');
|
||||
const bewerbDiv = document.createElement('div');
|
||||
bewerbDiv.className = 'bewerb-container';
|
||||
bewerbDiv.id = 'bewerb-' + bewerbCounter;
|
||||
|
||||
bewerbDiv.innerHTML = '<h4><i class="fas fa-trophy"></i> Neuer Bewerb</h4>' +
|
||||
'<div class="form-row">' +
|
||||
'<div class="form-group form-group-third">' +
|
||||
'<label for="bewerb-nummer-' + bewerbCounter + '">Nummer:</label>' +
|
||||
'<input type="number" id="bewerb-nummer-' + bewerbCounter + '" name="bewerb-nummer[]" placeholder="Bewerbnummer" required>' +
|
||||
'</div>' +
|
||||
'<div class="form-group form-group-third">' +
|
||||
'<label for="bewerb-titel-' + bewerbCounter + '">Titel:</label>' +
|
||||
'<input type="text" id="bewerb-titel-' + bewerbCounter + '" name="bewerb-titel[]" placeholder="Titel des Bewerbs" required>' +
|
||||
'</div>' +
|
||||
'<div class="form-group form-group-third">' +
|
||||
'<label for="bewerb-klasse-' + bewerbCounter + '">Klasse:</label>' +
|
||||
'<input type="text" id="bewerb-klasse-' + bewerbCounter + '" name="bewerb-klasse[]" placeholder="Klasse" required>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="form-group">' +
|
||||
'<label for="bewerb-task-' + bewerbCounter + '">Task (optional):</label>' +
|
||||
'<input type="text" id="bewerb-task-' + bewerbCounter + '" name="bewerb-task[]" placeholder="Optionale Task-Beschreibung">' +
|
||||
'</div>' +
|
||||
'<button type="button" onclick="removeBewerbField(' + bewerbCounter + ')" class="button button-secondary"><i class="fas fa-trash"></i> Bewerb entfernen</button>';
|
||||
|
||||
container.appendChild(bewerbDiv);
|
||||
}
|
||||
|
||||
function removeBewerbField(id) {
|
||||
const bewerbDiv = document.getElementById('bewerb-' + id);
|
||||
bewerbDiv.remove();
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package at.mocode.views
|
||||
|
||||
import at.mocode.model.Turnier
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.html.*
|
||||
import kotlinx.html.*
|
||||
|
||||
/**
|
||||
* View class for generating HTML for the home page.
|
||||
*/
|
||||
class HomeView {
|
||||
private val layoutTemplate = LayoutTemplate()
|
||||
|
||||
/**
|
||||
* Generates the HTML response for the home page.
|
||||
* @param call The ApplicationCall to respond to
|
||||
* @param turniere List of tournaments to display
|
||||
*/
|
||||
suspend fun renderHomePage(call: ApplicationCall, turniere: List<Turnier>) {
|
||||
call.respondHtml(HttpStatusCode.OK) {
|
||||
layoutTemplate.apply {
|
||||
applyLayout("Meldestelle Portal - Startseite") {
|
||||
h1 { +"Willkommen beim Meldestelle Portal!" }
|
||||
p(classes = "text-center mb-3") { +"Hier finden Sie alle aktuellen Turniere und können sich online anmelden." }
|
||||
|
||||
div(classes = "mt-4") {
|
||||
h2 { +"Aktuelle Turniere" }
|
||||
|
||||
if (turniere.isEmpty()) {
|
||||
div(classes = "text-center mt-3 mb-3") {
|
||||
p { +"Keine Turniere in der Datenbank gefunden." }
|
||||
}
|
||||
} else {
|
||||
div(classes = "tournament-list") {
|
||||
turniere.forEach { turnier ->
|
||||
div(classes = "tournament-item mb-3") {
|
||||
div(classes = "tournament-header") {
|
||||
h3 { +turnier.name }
|
||||
p {
|
||||
i("far fa-calendar-alt") {}
|
||||
+" ${turnier.datum}"
|
||||
}
|
||||
p {
|
||||
i("fas fa-hashtag") {}
|
||||
+" Turnier-Nr.: ${turnier.number}"
|
||||
}
|
||||
}
|
||||
|
||||
div(classes = "tournament-competitions mt-2") {
|
||||
h4 { +"Bewerbe:" }
|
||||
if (turnier.bewerbe.isEmpty()) {
|
||||
p { +"Keine Bewerbe verfügbar" }
|
||||
} else {
|
||||
ul {
|
||||
turnier.bewerbe.forEach { bewerb ->
|
||||
li {
|
||||
+"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}"
|
||||
if (bewerb.task != null) {
|
||||
+" (${bewerb.task})"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div(classes = "tournament-actions mt-2") {
|
||||
a(href = "/nennung/${turnier.number}", classes = "button") {
|
||||
i("fas fa-edit") {}
|
||||
+" Online Nennen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
style {
|
||||
+"""
|
||||
.tournament-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tournament-item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
transition: box-shadow 0.3s;
|
||||
background-color: var(--container-bg);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.tournament-item:hover {
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.tournament-header h3 {
|
||||
margin-top: 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.tournament-header p {
|
||||
color: var(--light-text);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tournament-competitions h4 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.tournament-competitions ul {
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.tournament-actions {
|
||||
margin-top: 1rem;
|
||||
text-align: right;
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package at.mocode.views
|
||||
|
||||
import kotlinx.html.*
|
||||
|
||||
/**
|
||||
* Common layout template for all pages in the application.
|
||||
* Provides consistent styling, header, footer, and responsive design.
|
||||
*/
|
||||
class LayoutTemplate {
|
||||
/**
|
||||
* Applies the common layout template to the provided content.
|
||||
* @param title The page title
|
||||
* @param showNavbar Whether to show the navigation bar
|
||||
* @param showAdminLink Whether to show the admin link in the navbar
|
||||
* @param content The content builder function
|
||||
*/
|
||||
fun HTML.applyLayout(
|
||||
title: String,
|
||||
showNavbar: Boolean = true,
|
||||
showAdminLink: Boolean = true,
|
||||
content: FlowContent.() -> Unit
|
||||
) {
|
||||
head {
|
||||
meta(charset = "UTF-8")
|
||||
meta(name = "viewport", content = "width=device-width, initial-scale=1.0")
|
||||
title { +title }
|
||||
link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css")
|
||||
link(rel = "stylesheet", href = "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap")
|
||||
link(rel = "stylesheet", href = "https://fonts.googleapis.com/icon?family=Material+Icons")
|
||||
link(rel = "stylesheet", href = "https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css")
|
||||
style {
|
||||
+"""
|
||||
/* Base styles */
|
||||
:root {
|
||||
--primary-color: #5d8aa8;
|
||||
--primary-hover: #4a7a98;
|
||||
--secondary-color: #7d9eb1;
|
||||
--secondary-hover: #6a8ca1;
|
||||
--text-color: #333;
|
||||
--light-text: #666;
|
||||
--lighter-text: #999;
|
||||
--border-color: #e0e0e0;
|
||||
--light-bg: #f5f7fa;
|
||||
--container-bg: #fff;
|
||||
--success-color: #66bb6a;
|
||||
--warning-color: #ffa726;
|
||||
--error-color: #ef5350;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background-color: var(--light-bg);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
nav.nav-extended {
|
||||
background-color: var(--primary-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
nav .brand-logo {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
nav .brand-logo i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
nav ul li a {
|
||||
font-weight: 500;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
nav ul li a:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.sidenav {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.sidenav .user-view {
|
||||
padding: 20px 16px 12px;
|
||||
}
|
||||
|
||||
.sidenav .user-view .name {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
margin-top: 8px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.sidenav li > a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidenav li > a > i {
|
||||
margin-right: 16px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
main {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background-color: var(--container-bg);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.8rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="tel"],
|
||||
input[type="number"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 1.2rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 2rem;
|
||||
transition: all 0.3s;
|
||||
margin-bottom: 1.2rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="email"]:focus,
|
||||
input[type="tel"]:focus,
|
||||
input[type="number"]:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0 2px 8px rgba(93,138,168,0.2);
|
||||
}
|
||||
|
||||
.required:after {
|
||||
content: " *";
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.button, button {
|
||||
display: inline-block;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 0.9rem 1.8rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button:hover, button:hover {
|
||||
background-color: var(--primary-hover);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.button-secondary:hover {
|
||||
background-color: var(--secondary-hover);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--light-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
ul, ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.mt-3 { margin-top: 1.5rem; }
|
||||
.mt-4 { margin-top: 2rem; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
.mb-3 { margin-bottom: 1.5rem; }
|
||||
.mb-4 { margin-bottom: 2rem; }
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
background-color: var(--text-color);
|
||||
color: white;
|
||||
padding: 2rem 0;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.menu-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
flex-direction: column;
|
||||
background-color: var(--primary-color);
|
||||
padding: 1rem;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
nav ul.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.button, button {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
script(src = "https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js") {}
|
||||
script {
|
||||
unsafe {
|
||||
+"""
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Mobile menu toggle
|
||||
const menuToggle = document.querySelector('.menu-toggle');
|
||||
const navMenu = document.querySelector('nav ul');
|
||||
|
||||
if (menuToggle && navMenu) {
|
||||
menuToggle.addEventListener('click', function() {
|
||||
navMenu.classList.toggle('show');
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Materialize components
|
||||
M.AutoInit();
|
||||
|
||||
// Enhance form elements
|
||||
const inputs = document.querySelectorAll('input, textarea, select');
|
||||
inputs.forEach(input => {
|
||||
input.classList.add('browser-default');
|
||||
});
|
||||
});
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
body {
|
||||
if (showNavbar) {
|
||||
nav(classes = "nav-extended z-depth-1") {
|
||||
div("nav-wrapper") {
|
||||
div("container") {
|
||||
a(href = "/", classes = "brand-logo") {
|
||||
i("material-icons left") { +"sports_handball" }
|
||||
+"Meldestelle Portal"
|
||||
}
|
||||
a(href = "#", classes = "sidenav-trigger") {
|
||||
attributes["data-target"] = "mobile-nav"
|
||||
i("material-icons") { +"menu" }
|
||||
}
|
||||
ul(classes = "right hide-on-med-and-down") {
|
||||
li {
|
||||
a(href = "/") {
|
||||
i("material-icons left") { +"home" }
|
||||
+"Home"
|
||||
}
|
||||
}
|
||||
if (showAdminLink) {
|
||||
li {
|
||||
a(href = "/admin/tournaments") {
|
||||
i("material-icons left") { +"event" }
|
||||
+"Turnierverwaltung"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile sidenav
|
||||
ul(classes = "sidenav") {
|
||||
attributes["id"] = "mobile-nav"
|
||||
li {
|
||||
div("user-view") {
|
||||
div("background blue-grey lighten-4") {
|
||||
style = "height: 80px;"
|
||||
}
|
||||
span("name") { +"Meldestelle Portal" }
|
||||
}
|
||||
}
|
||||
li {
|
||||
a(href = "/") {
|
||||
i("material-icons") { +"home" }
|
||||
+"Home"
|
||||
}
|
||||
}
|
||||
if (showAdminLink) {
|
||||
li {
|
||||
a(href = "/admin/tournaments") {
|
||||
i("material-icons") { +"event" }
|
||||
+"Turnierverwaltung"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
main {
|
||||
div("container") {
|
||||
div("content-card") {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
footer {
|
||||
div("container") {
|
||||
div("text-center") {
|
||||
p { +"© ${java.time.Year.now().value} Meldestelle Portal. Alle Rechte vorbehalten." }
|
||||
p {
|
||||
+"Entwickelt von "
|
||||
a(href = "#") { +"mocode" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package at.mocode.views
|
||||
|
||||
import at.mocode.model.Turnier
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.html.*
|
||||
import kotlinx.html.*
|
||||
|
||||
/**
|
||||
* View class for generating HTML for the tournament registration forms.
|
||||
*/
|
||||
class NennungView {
|
||||
private val layoutTemplate = LayoutTemplate()
|
||||
|
||||
/**
|
||||
* Generates the HTML response for the tournament registration form.
|
||||
* @param call The ApplicationCall to respond to
|
||||
* @param turnier The tournament to display the registration form for
|
||||
*/
|
||||
suspend fun renderNennungForm(call: ApplicationCall, turnier: Turnier) {
|
||||
call.respondHtml(HttpStatusCode.OK) {
|
||||
layoutTemplate.apply {
|
||||
applyLayout(
|
||||
title = "Online-Nennen - ${turnier.name}",
|
||||
showNavbar = false,
|
||||
showAdminLink = false
|
||||
) {
|
||||
h1 { +"Online-Nennen" }
|
||||
|
||||
// Tournament description
|
||||
div(classes = "tournament-info text-center mb-4") {
|
||||
h2 { +turnier.name }
|
||||
p { +turnier.datum }
|
||||
p { +"Turnier-Nr.: ${turnier.number}" }
|
||||
}
|
||||
|
||||
form(action = "/nennung/${turnier.number}/submit", method = FormMethod.post, classes = "registration-form") {
|
||||
div(classes = "form-section mb-4") {
|
||||
h3 { +"Teilnehmer-Informationen" }
|
||||
|
||||
// Participant information list
|
||||
div(classes = "competitions-list") {
|
||||
// Rider information
|
||||
div(classes = "competition-item") {
|
||||
div(classes = "participant-details") {
|
||||
label(classes = "required") { +"Reiter-Name" }
|
||||
input(type = InputType.text, name = "riderName") {
|
||||
attributes["required"] = "required"
|
||||
attributes["placeholder"] = "Vor- und Nachname"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Horse information
|
||||
div(classes = "competition-item") {
|
||||
div(classes = "participant-details") {
|
||||
label(classes = "required") { +"Kopf-Nr./Pferd" }
|
||||
input(type = InputType.text, name = "horseName") {
|
||||
attributes["required"] = "required"
|
||||
attributes["placeholder"] = "Name des Pferdes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contact information
|
||||
div(classes = "competition-item") {
|
||||
div(classes = "participant-details") {
|
||||
div(classes = "form-row") {
|
||||
div(classes = "form-group form-group-half") {
|
||||
label { +"E-Mail" }
|
||||
input(type = InputType.email, name = "email") {
|
||||
attributes["placeholder"] = "ihre@email.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contact information
|
||||
div(classes = "competition-item") {
|
||||
div(classes = "participant-details") {
|
||||
div(classes = "form-row") {
|
||||
div(classes = "form-group form-group-half") {
|
||||
label { +"Telefon-Nr." }
|
||||
input(type = InputType.tel, name = "phone") {
|
||||
attributes["placeholder"] = "Ihre Telefonnummer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
p(classes = "form-hint") { +"Bitte geben Sie mindestens eine Kontaktmöglichkeit an (E-Mail oder Telefon)." }
|
||||
}
|
||||
|
||||
// Competitions list
|
||||
div(classes = "form-section mb-4") {
|
||||
h3 { +"Bewerbe" }
|
||||
|
||||
if (turnier.bewerbe.isEmpty()) {
|
||||
p { +"Keine Bewerbe verfügbar." }
|
||||
} else {
|
||||
div(classes = "competitions-list") {
|
||||
turnier.bewerbe.forEach { bewerb ->
|
||||
div(classes = "competition-item") {
|
||||
label {
|
||||
input(type = InputType.checkBox, name = "selectedEvents") {
|
||||
attributes["value"] = bewerb.nummer.toString()
|
||||
}
|
||||
span(classes = "competition-details") {
|
||||
strong { +"${bewerb.nummer}. ${bewerb.titel}" }
|
||||
+" - ${bewerb.klasse}"
|
||||
if (bewerb.task != null) {
|
||||
+" - ${bewerb.task}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comments
|
||||
div(classes = "form-section mb-4") {
|
||||
h3 { +"Zusätzliche Informationen" }
|
||||
|
||||
div(classes = "form-group") {
|
||||
label { +"Wünsche/Bemerkungen" }
|
||||
textArea {
|
||||
attributes["rows"] = "4"
|
||||
attributes["name"] = "comments"
|
||||
attributes["placeholder"] = "Ihre Wünsche oder Bemerkungen zur Nennung..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit button
|
||||
div(classes = "form-actions text-center mt-4") {
|
||||
button(type = ButtonType.submit, classes = "button") {
|
||||
+"Jetzt Nennen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional styles specific to the registration form
|
||||
style {
|
||||
+"""
|
||||
.tournament-info {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tournament-info h2 {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
|
||||
border-left: 4px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
color: var(--primary-color);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.form-row {
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group-half {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
width: 100%;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.form-group-half {
|
||||
flex: 0 0 100%;
|
||||
margin: 0 0 1.5rem 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.9rem;
|
||||
color: var(--light-text);
|
||||
margin-top: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.competitions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.competition-item {
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: white;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.competition-item:hover {
|
||||
background-color: rgba(0,0,0,0.02);
|
||||
box-shadow: 0 3px 8px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.competition-item label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.competition-item input[type="checkbox"] {
|
||||
margin-right: 15px;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.competition-details {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Participant information styling */
|
||||
.participant-details {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.participant-details label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.participant-details input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Mobile styles already handled by the responsive layout */
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the HTML response for the confirmation page after successful registration.
|
||||
* @param call The ApplicationCall to respond to
|
||||
* @param turnier The tournament the registration was for
|
||||
* @param riderName The name of the rider
|
||||
* @param horseName The name of the horse
|
||||
*/
|
||||
suspend fun renderConfirmationPage(call: ApplicationCall, turnier: Turnier, riderName: String, horseName: String) {
|
||||
call.respondHtml(HttpStatusCode.OK) {
|
||||
layoutTemplate.apply {
|
||||
applyLayout(
|
||||
title = "Nennung bestätigt - ${turnier.name}",
|
||||
showNavbar = false,
|
||||
showAdminLink = false
|
||||
) {
|
||||
h1 { +"Nennung bestätigt" }
|
||||
|
||||
div(classes = "confirmation-box") {
|
||||
div(classes = "confirmation-icon") {
|
||||
/* Icon removed as per request */
|
||||
}
|
||||
|
||||
h2 { +"Vielen Dank für Ihre Nennung!" }
|
||||
|
||||
div(classes = "confirmation-details") {
|
||||
p {
|
||||
+"Ihre Nennung für "
|
||||
strong { +turnier.name }
|
||||
+" wurde erfolgreich übermittelt."
|
||||
}
|
||||
|
||||
div(classes = "detail-item") {
|
||||
span(classes = "detail-label") { +"Reiter:" }
|
||||
span(classes = "detail-value") { +riderName }
|
||||
}
|
||||
|
||||
div(classes = "detail-item") {
|
||||
span(classes = "detail-label") { +"Pferd:" }
|
||||
span(classes = "detail-value") { +horseName }
|
||||
}
|
||||
}
|
||||
|
||||
p(classes = "confirmation-message") {
|
||||
+"Sie erhalten in Kürze eine Bestätigung per E-Mail."
|
||||
}
|
||||
|
||||
div(classes = "confirmation-actions") {
|
||||
a(href = "/nennung/${turnier.number}", classes = "button") {
|
||||
+"Weitere Nennung abgeben"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional styles specific to the confirmation page
|
||||
style {
|
||||
+"""
|
||||
.confirmation-box {
|
||||
background-color: var(--light-bg);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.confirmation-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--success-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.confirmation-details {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
background-color: var(--container-bg);
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: bold;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.confirmation-message {
|
||||
margin: 1.5rem 0;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.confirmation-actions {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,9 @@ class ApplicationTest {
|
||||
fun testRootRouteShowsTournamentList() {
|
||||
// Erstelle ein Beispiel-Turnier, das in der Datenbank sein würde
|
||||
val mockTurnier = Turnier(
|
||||
id = "dummy-01",
|
||||
name = "Erstes DB Turnier",
|
||||
datum = "19.04.2025",
|
||||
logoUrl = null,
|
||||
ausschreibungUrl = "/pdfs/ausschreibung_dummy.pdf"
|
||||
number = 1
|
||||
)
|
||||
|
||||
// Erstelle eine Liste von Turnieren, wie sie aus der Datenbank kommen würde
|
||||
@@ -45,13 +43,7 @@ class ApplicationTest {
|
||||
strong { +turnier.name }
|
||||
+" (${turnier.datum})"
|
||||
+" "
|
||||
if (turnier.ausschreibungUrl != null) {
|
||||
a(href = turnier.ausschreibungUrl, target = "_blank") {
|
||||
button { +"Ausschreibung" }
|
||||
}
|
||||
+" "
|
||||
}
|
||||
a(href = "/nennung/${turnier.id}") {
|
||||
a(href = "/nennung/${turnier.number}") {
|
||||
button { +"Online Nennen" }
|
||||
}
|
||||
}
|
||||
@@ -82,7 +74,7 @@ class ApplicationTest {
|
||||
htmlContent.contains("(19.04.2025)"),
|
||||
"Dummy tournament date missing or incorrect"
|
||||
)
|
||||
assertTrue(htmlContent.contains("/nennung/dummy-01"), "Link to dummy tournament '/nennung/dummy-01' missing")
|
||||
assertTrue(htmlContent.contains("/nennung/1"), "Link to dummy tournament '/nennung/1' missing")
|
||||
assertFalse(
|
||||
htmlContent.contains("Keine Turniere in der Datenbank gefunden."),
|
||||
"'No tournaments' message should not be present if dummy was inserted"
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package at.mocode
|
||||
|
||||
import at.mocode.model.Bewerb
|
||||
import at.mocode.model.Turnier
|
||||
import at.mocode.tables.BewerbeTable
|
||||
import at.mocode.tables.TurniereTable
|
||||
import `import org`.jetbrains.exposed.sql.selectAll
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.html.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.html.*
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.jetbrains.exposed.sql.Op
|
||||
import org.jetbrains.exposed.sql.SchemaUtils
|
||||
import org.jetbrains.exposed.sql.SqlExpressionBuilder
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
@@ -19,24 +26,40 @@ import org.slf4j.LoggerFactory
|
||||
*/
|
||||
fun configureTestDatabase() {
|
||||
val log = LoggerFactory.getLogger("TestDatabaseInitialization")
|
||||
log.info("Initializing in-memory H2 database for testing...")
|
||||
log.info("Initializing in-memory SQLite database for testing...")
|
||||
|
||||
// Verbinde mit einer In-Memory-H2-Datenbank
|
||||
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
|
||||
// Verbinde mit einer In-Memory-SQLite-Datenbank
|
||||
Database.connect("jdbc:sqlite::memory:", driver = "org.sqlite.JDBC")
|
||||
|
||||
// Initialisiere das Datenbankschema
|
||||
transaction {
|
||||
log.info("Creating test database schema...")
|
||||
SchemaUtils.create(TurniereTable)
|
||||
SchemaUtils.create(TurniereTable, BewerbeTable)
|
||||
|
||||
// Füge ein Test-Turnier hinzu
|
||||
log.info("Inserting test tournament data...")
|
||||
val turnierNumber = 1
|
||||
TurniereTable.insert {
|
||||
it[id] = "dummy-01"
|
||||
it[name] = "Erstes DB Turnier"
|
||||
it[datum] = "19.04.2025"
|
||||
it[logoUrl] = null
|
||||
it[ausschreibungUrl] = "/pdfs/ausschreibung_dummy.pdf"
|
||||
it[TurniereTable.number] = turnierNumber
|
||||
it[TurniereTable.name] = "CSN-C Edelhof April 2025"
|
||||
it[TurniereTable.datum] = "14.04.2025 - 15.04.2025"
|
||||
}
|
||||
|
||||
// Füge Test-Bewerbe hinzu
|
||||
BewerbeTable.insert {
|
||||
it[BewerbeTable.nummer] = 1
|
||||
it[BewerbeTable.titel] = "Stilspringprüfung"
|
||||
it[BewerbeTable.klasse] = "60 cm"
|
||||
it[BewerbeTable.task] = null
|
||||
it[BewerbeTable.turnierNumber] = turnierNumber
|
||||
}
|
||||
|
||||
BewerbeTable.insert {
|
||||
it[BewerbeTable.nummer] = 2
|
||||
it[BewerbeTable.titel] = "Dressurprüfung"
|
||||
it[BewerbeTable.klasse] = "Kl. A"
|
||||
it[BewerbeTable.task] = "DRA 1"
|
||||
it[BewerbeTable.turnierNumber] = turnierNumber
|
||||
}
|
||||
|
||||
log.info("Test database initialized successfully!")
|
||||
@@ -57,15 +80,30 @@ fun Application.testModule() {
|
||||
|
||||
// Lese Daten aus der Test-Datenbank
|
||||
val turniereFromDb = transaction {
|
||||
TurniereTable.selectAll().map { row ->
|
||||
// Get all tournaments
|
||||
val turniere = TurniereTable.selectAll().map { row ->
|
||||
Turnier(
|
||||
id = row[TurniereTable.id],
|
||||
name = row[TurniereTable.name],
|
||||
datum = row[TurniereTable.datum],
|
||||
logoUrl = row[TurniereTable.logoUrl],
|
||||
ausschreibungUrl = row[TurniereTable.ausschreibungUrl]
|
||||
number = row[TurniereTable.number]
|
||||
)
|
||||
}
|
||||
|
||||
// For each tournament, get its competitions
|
||||
turniere.forEach { turnier ->
|
||||
val bewerbeList = BewerbeTable.selectAll().where { BewerbeTable.turnierNumber eq turnier.number }
|
||||
.map { row ->
|
||||
Bewerb(
|
||||
nummer = row[BewerbeTable.nummer],
|
||||
titel = row[BewerbeTable.titel],
|
||||
klasse = row[BewerbeTable.klasse],
|
||||
task = row[BewerbeTable.task]
|
||||
)
|
||||
}
|
||||
turnier.bewerbe = bewerbeList
|
||||
}
|
||||
|
||||
turniere
|
||||
}
|
||||
|
||||
// HTML-Antwort generieren (wie in Application.kt)
|
||||
@@ -88,13 +126,25 @@ fun Application.testModule() {
|
||||
strong { +turnier.name }
|
||||
+" (${turnier.datum})"
|
||||
+" "
|
||||
if (turnier.ausschreibungUrl != null) {
|
||||
a(href = turnier.ausschreibungUrl, target = "_blank") {
|
||||
button { +"Ausschreibung" }
|
||||
div {
|
||||
+"Bewerbe: "
|
||||
if (turnier.bewerbe.isEmpty()) {
|
||||
+"Keine"
|
||||
} else {
|
||||
ul {
|
||||
turnier.bewerbe.forEach { bewerb ->
|
||||
li {
|
||||
+"${bewerb.nummer}. ${bewerb.titel} - ${bewerb.klasse}"
|
||||
if (bewerb.task != null) {
|
||||
+" (${bewerb.task})"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+" "
|
||||
}
|
||||
a(href = "/nennung/${turnier.id}") {
|
||||
+" "
|
||||
a(href = "/nennung/${turnier.number}") {
|
||||
button { +"Online Nennen" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package at.mocode.email
|
||||
|
||||
import at.mocode.config.EmailConfig
|
||||
import org.slf4j.LoggerFactory
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for the EmailService class.
|
||||
* This test verifies that the email configuration is valid and that test emails can be sent.
|
||||
*/
|
||||
class EmailServiceTest {
|
||||
private val log = LoggerFactory.getLogger(EmailServiceTest::class.java)
|
||||
|
||||
@Test
|
||||
fun testEmailConfigurationIsValid() {
|
||||
// Check if the email configuration is valid
|
||||
val isValid = EmailConfig.isValid()
|
||||
|
||||
if (!isValid) {
|
||||
log.warn("Email configuration is not valid: ${EmailConfig.getValidationErrors()}")
|
||||
log.warn("Make sure the .env file contains the correct email configuration")
|
||||
log.warn("SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, RECIPIENT_EMAIL, and SMTP_SENDER_EMAIL must be set")
|
||||
}
|
||||
|
||||
// We don't assert here because the test environment might not have valid email configuration
|
||||
log.info("Email configuration valid: $isValid")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSendTestEmail() {
|
||||
// Only run this test if the email configuration is valid
|
||||
if (!EmailConfig.isValid()) {
|
||||
log.warn("Skipping test because email configuration is not valid")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the EmailService instance
|
||||
val emailService = EmailService.getInstance()
|
||||
|
||||
// Send a test email with debug enabled
|
||||
val result = emailService.sendTestEmail(debug = true)
|
||||
|
||||
// Log the result
|
||||
if (result) {
|
||||
log.info("Test email sent successfully")
|
||||
} else {
|
||||
log.error("Failed to send test email")
|
||||
}
|
||||
|
||||
// Assert that the email was sent successfully
|
||||
assertTrue(result, "Test email should be sent successfully")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package at.mocode.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents a competition (Bewerb) within a tournament.
|
||||
* A competition has specific details like number, title, class, and optional task.
|
||||
*/
|
||||
@Serializable
|
||||
data class Bewerb(
|
||||
/** Competition number, e.g. 1, 2, etc. */
|
||||
val nummer: Int,
|
||||
|
||||
/** Title of the competition, e.g. "Stilspringprüfung" or "Dressurprüfung" */
|
||||
val titel: String,
|
||||
|
||||
/** Class/level of the competition, e.g. "60 cm" or "Kl. A" */
|
||||
val klasse: String = "",
|
||||
|
||||
/** Optional task identifier, e.g. "DRA 1" */
|
||||
val task: String? = null
|
||||
)
|
||||
@@ -4,13 +4,24 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Nennung(
|
||||
// Wir brauchen die Turnier-ID, um die Nennung zuzuordnen
|
||||
val turnierId: String,
|
||||
// Einfache Felder für den Start
|
||||
val riderName: String = "", // Standardwerte für leeres Formular
|
||||
val horseName: String = "",
|
||||
val email: String = "",
|
||||
val comments: String? = null
|
||||
// Hier kommen später Felder hinzu: Verein, Lizenznr., Tel,
|
||||
// und vor allem: die Auswahl der Prüfungen!
|
||||
)
|
||||
/** Name of the rider */
|
||||
val riderName: String,
|
||||
|
||||
/** Name of the horse */
|
||||
val horseName: String,
|
||||
|
||||
/** Email address for contact */
|
||||
val email: String,
|
||||
|
||||
/** Phone number for contact */
|
||||
val phone: String,
|
||||
|
||||
/** List of selected event numbers */
|
||||
val selectedEvents: List<String>,
|
||||
|
||||
/** Additional comments or wishes */
|
||||
val comments: String,
|
||||
|
||||
/** The tournament this registration is for */
|
||||
val turnier: Turnier
|
||||
)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package at.mocode.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Turnier(
|
||||
val id: String, // Eine eindeutige ID für das Turnier (z.B. eine UUID als String)
|
||||
val name: String, // Der Name, z.B. "CDN-C Edelhof April 2025"
|
||||
val datum: String, // Das Datum oder der Zeitraum, erstmal als Text, z.B. "14.04.2025 - 15.04.2025"
|
||||
val logoUrl: String? = null, // Optional: Link zum Logo des Veranstalters
|
||||
val ausschreibungUrl: String? = null // Optional: Link zum Ausschreibung-PDF
|
||||
// Hier können später viele weitere Felder hinzukommen:
|
||||
// Ort, Veranstalter, Status (geplant, läuft, beendet), Disziplinen etc.
|
||||
/** The name of the tournament, e.g. "CSN-C NEU CSNP-C NEU NEUMARKT/M., OÖ" */
|
||||
val name: String,
|
||||
|
||||
/** The date of the tournament as a formatted string, e.g. "7.JUNI 2025" */
|
||||
val datum: String,
|
||||
|
||||
/** Unique identifier for the tournament */
|
||||
val number: Int,
|
||||
|
||||
/** List of competitions (Bewerbe) associated with this tournament */
|
||||
var bewerbe: List<Bewerb> = emptyList()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user