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)
|
// JDBC Treiber für PostgreSQL (nur zur Laufzeit benötigt)
|
||||||
runtimeOnly(libs.postgresql.driver)
|
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)
|
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
|
// HikariCP für Connection Pooling
|
||||||
implementation(libs.hikari.cp)
|
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
|
package at.mocode
|
||||||
|
|
||||||
import at.mocode.model.Turnier
|
|
||||||
import at.mocode.plugins.configureDatabase
|
import at.mocode.plugins.configureDatabase
|
||||||
import at.mocode.tables.TurniereTable
|
import at.mocode.routes.configureAdminRoutes
|
||||||
import io.ktor.http.*
|
import at.mocode.routes.configureHomeRoutes
|
||||||
|
import at.mocode.routes.configureNennungRoutes
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.html.*
|
|
||||||
import io.ktor.server.netty.*
|
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)
|
EngineMain.main(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application module configuration.
|
||||||
|
*/
|
||||||
fun Application.module() {
|
fun Application.module() {
|
||||||
|
// Configure database first
|
||||||
// Als Erstes die Datenbank konfigurieren:
|
|
||||||
configureDatabase()
|
configureDatabase()
|
||||||
|
|
||||||
// Danach deine anderen Konfigurationen (Routing etc.):
|
// Configure routes
|
||||||
routing {
|
configureHomeRoutes()
|
||||||
get("/") {
|
configureNennungRoutes()
|
||||||
// Logger holen (optional, aber nützlich)
|
configureAdminRoutes()
|
||||||
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("/")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
@Serializable
|
||||||
data class Nennung(
|
data class Nennung(
|
||||||
// Wir brauchen die Turnier-ID, um die Nennung zuzuordnen
|
/** Name of the rider */
|
||||||
val turnierId: String,
|
val riderName: String,
|
||||||
// Einfache Felder für den Start
|
|
||||||
val riderName: String = "", // Standardwerte für leeres Formular
|
/** Name of the horse */
|
||||||
val horseName: String = "",
|
val horseName: String,
|
||||||
val email: String = "",
|
|
||||||
val comments: String? = null
|
/** Email address for contact */
|
||||||
// Hier kommen später Felder hinzu: Verein, Lizenznr., Tel,
|
val email: String,
|
||||||
// und vor allem: die Auswahl der Prüfungen!
|
|
||||||
|
/** 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
|
@Serializable
|
||||||
data class Turnier(
|
data class Turnier(
|
||||||
val id: String, // Eine eindeutige ID für das Turnier (z.B. eine UUID als String)
|
/** The name of the tournament, e.g. "CSN-C NEU CSNP-C NEU NEUMARKT/M., OÖ" */
|
||||||
val name: String, // Der Name, z.B. "CDN-C Edelhof April 2025"
|
val name: String,
|
||||||
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
|
/** The date of the tournament as a formatted string, e.g. "7.JUNI 2025" */
|
||||||
val ausschreibungUrl: String? = null // Optional: Link zur Ausschreibungs-PDF
|
val datum: String,
|
||||||
// Hier können später viele weitere Felder hinzukommen:
|
|
||||||
// Ort, Veranstalter, Status (geplant, läuft, beendet), Disziplinen etc.
|
/** 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 com.zaxxer.hikari.HikariDataSource
|
||||||
import org.jetbrains.exposed.sql.Database
|
import org.jetbrains.exposed.sql.Database
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
import org.jetbrains.exposed.sql.SchemaUtils
|
import org.jetbrains.exposed.sql.SchemaUtils
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import at.mocode.tables.TurniereTable
|
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() {
|
fun configureDatabase() {
|
||||||
val log = LoggerFactory.getLogger("DatabaseInitialization")
|
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
|
val isTestEnvironment = System.getProperty("isTestEnvironment")?.toBoolean() ?: false
|
||||||
|
|
||||||
if (isTestEnvironment) {
|
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 {
|
try {
|
||||||
// H2 im PostgreSQL-Kompatibilitätsmodus starten, kann helfen
|
Database.connect("jdbc:sqlite::memory:", driver = "org.sqlite.JDBC")
|
||||||
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver")
|
log.info("Connected to SQLite in-memory (test) successfully.")
|
||||||
log.info("Connected to H2 (test) successfully.")
|
|
||||||
connectionSuccessful = true
|
connectionSuccessful = true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.error("Failed to connect to H2 (test)!", e)
|
log.error("Failed to connect to SQLite (test)!", e)
|
||||||
throw e // Fehler weiterwerfen, Test soll fehlschlagen
|
throw e // Rethrow error, test should fail
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Prüfen, ob wir in IDEA laufen (keine Docker Umgebungsvariablen gesetzt)
|
// Check if we're running in IDEA (no Docker environment variables set)
|
||||||
// wir prüfen nur eine Variable, das reicht meistens
|
// We only check one variable, that's usually enough
|
||||||
val dbHostFromEnv = System.getenv("DB_HOST")
|
val dbHostFromEnv = System.getenv("DB_HOST")
|
||||||
val isIdeaEnvironment = (dbHostFromEnv == null)
|
val isIdeaEnvironment = (dbHostFromEnv == null)
|
||||||
|
|
||||||
if (isIdeaEnvironment) {
|
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 {
|
try {
|
||||||
Database.connect("jdbc:h2:mem:dev;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver")
|
Database.connect("jdbc:sqlite:data/meldestelle.db", driver = "org.sqlite.JDBC")
|
||||||
log.info("Connected to H2 (dev) successfully.")
|
log.info("Connected to SQLite file database (dev) successfully.")
|
||||||
connectionSuccessful = true
|
connectionSuccessful = true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.error("Failed to connect to H2 (dev)!", e)
|
log.error("Failed to connect to SQLite (dev)!", e)
|
||||||
// Hier vielleicht nicht werfen, damit App in IDE trotzdem startet? Oder doch? → Aktuell wirft es.
|
// Maybe don't throw here so the app starts in IDE anyway? Currently it throws.
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normale Docker/Produktionsumgebung -> PostgreSQL verwenden
|
// Normal Docker/Production environment -> use PostgreSQL
|
||||||
log.info("Production/Docker environment detected, connecting to PostgreSQL...")
|
log.info("Production/Docker environment detected, connecting to PostgreSQL...")
|
||||||
try {
|
try {
|
||||||
// Lese Konfiguration direkt aus Umgebungsvariablen
|
// Read configuration directly from environment variables
|
||||||
val dbHost = dbHostFromEnv // Sicherer Fallback
|
val dbHost = dbHostFromEnv // Safe fallback
|
||||||
val dbPort = System.getenv("DB_PORT") ?: "5432"
|
val dbPort = System.getenv("DB_PORT") ?: "5432"
|
||||||
val dbName = System.getenv("DB_NAME") ?: error("DB_NAME not set in environment")
|
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")
|
val dbUser = System.getenv("DB_USER") ?: error("DB_USER not set in environment")
|
||||||
@@ -74,34 +89,24 @@ fun configureDatabase() {
|
|||||||
connectionSuccessful = true
|
connectionSuccessful = true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.error("Failed to initialize PostgreSQL connection pool!", e)
|
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) ---
|
// --- Schema Initialization (NOW CENTRALIZED) ---
|
||||||
// Führe dies nur aus, wenn *irgendeine* DB-Verbindung erfolgreich war
|
// Only execute this if *any* DB connection was successful
|
||||||
transaction { // Führe Schema-Operationen in einer Transaktion aus
|
transaction { // Execute schema operations in a transaction
|
||||||
log.info("Initializing/Verifying database schema...")
|
log.info("Initializing/Verifying database schema...")
|
||||||
try {
|
try {
|
||||||
// Erstellt die Tabelle(n), falls sie noch nicht existieren
|
// Create the table(s) if they don't exist yet
|
||||||
SchemaUtils.create(TurniereTable)
|
SchemaUtils.create(TurniereTable, BewerbeTable, NennungenTable, NennungEventsTable)
|
||||||
// Füge hier später weitere Tabellen hinzu:
|
|
||||||
// SchemaUtils.create(TurniereTable, NennungenTable, ...)
|
|
||||||
|
|
||||||
log.info("Database schema initialized successfully (tables created/verified).")
|
log.info("Database schema initialized successfully (tables created/verified).")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.error("Failed to initialize database schema!", e)
|
log.error("Failed to initialize database schema!", e)
|
||||||
// Hier könntest du entscheiden, ob ein Fehler beim Schema kritisch ist
|
// Here you could decide if a schema error is critical
|
||||||
// throw e // Auskommentiert: App startet evtl. trotzdem, auch wenn Schema fehlt/falsch ist
|
// 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.Table
|
||||||
import org.jetbrains.exposed.sql.Column
|
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.
|
* Name of the tournament, max 255 characters.
|
||||||
|
*/
|
||||||
// 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
|
|
||||||
val name: Column<String> = varchar("name", 255)
|
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)
|
val datum: Column<String> = varchar("datum", 100)
|
||||||
|
|
||||||
// logoUrl: Textfeld, max. 500 Zeichen, kann NULL sein (.nullable())
|
// Define the 'number' column as the primary key for this table
|
||||||
val logoUrl: Column<String?> = varchar("logo_url", 500).nullable()
|
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)
|
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() {
|
fun testRootRouteShowsTournamentList() {
|
||||||
// Erstelle ein Beispiel-Turnier, das in der Datenbank sein würde
|
// Erstelle ein Beispiel-Turnier, das in der Datenbank sein würde
|
||||||
val mockTurnier = Turnier(
|
val mockTurnier = Turnier(
|
||||||
id = "dummy-01",
|
|
||||||
name = "Erstes DB Turnier",
|
name = "Erstes DB Turnier",
|
||||||
datum = "19.04.2025",
|
datum = "19.04.2025",
|
||||||
logoUrl = null,
|
number = 1
|
||||||
ausschreibungUrl = "/pdfs/ausschreibung_dummy.pdf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Erstelle eine Liste von Turnieren, wie sie aus der Datenbank kommen würde
|
// Erstelle eine Liste von Turnieren, wie sie aus der Datenbank kommen würde
|
||||||
@@ -45,13 +43,7 @@ class ApplicationTest {
|
|||||||
strong { +turnier.name }
|
strong { +turnier.name }
|
||||||
+" (${turnier.datum})"
|
+" (${turnier.datum})"
|
||||||
+" "
|
+" "
|
||||||
if (turnier.ausschreibungUrl != null) {
|
a(href = "/nennung/${turnier.number}") {
|
||||||
a(href = turnier.ausschreibungUrl, target = "_blank") {
|
|
||||||
button { +"Ausschreibung" }
|
|
||||||
}
|
|
||||||
+" "
|
|
||||||
}
|
|
||||||
a(href = "/nennung/${turnier.id}") {
|
|
||||||
button { +"Online Nennen" }
|
button { +"Online Nennen" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +74,7 @@ class ApplicationTest {
|
|||||||
htmlContent.contains("(19.04.2025)"),
|
htmlContent.contains("(19.04.2025)"),
|
||||||
"Dummy tournament date missing or incorrect"
|
"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(
|
assertFalse(
|
||||||
htmlContent.contains("Keine Turniere in der Datenbank gefunden."),
|
htmlContent.contains("Keine Turniere in der Datenbank gefunden."),
|
||||||
"'No tournaments' message should not be present if dummy was inserted"
|
"'No tournaments' message should not be present if dummy was inserted"
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
package at.mocode
|
package at.mocode
|
||||||
|
|
||||||
|
import at.mocode.model.Bewerb
|
||||||
import at.mocode.model.Turnier
|
import at.mocode.model.Turnier
|
||||||
|
import at.mocode.tables.BewerbeTable
|
||||||
import at.mocode.tables.TurniereTable
|
import at.mocode.tables.TurniereTable
|
||||||
|
import `import org`.jetbrains.exposed.sql.selectAll
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.html.*
|
import io.ktor.server.html.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import kotlinx.html.*
|
import kotlinx.html.*
|
||||||
import org.jetbrains.exposed.sql.Database
|
import org.jetbrains.exposed.sql.Database
|
||||||
|
import org.jetbrains.exposed.sql.Op
|
||||||
import org.jetbrains.exposed.sql.SchemaUtils
|
import org.jetbrains.exposed.sql.SchemaUtils
|
||||||
|
import org.jetbrains.exposed.sql.SqlExpressionBuilder
|
||||||
import org.jetbrains.exposed.sql.insert
|
import org.jetbrains.exposed.sql.insert
|
||||||
import org.jetbrains.exposed.sql.selectAll
|
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.jetbrains.exposed.sql.transactions.transaction
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
@@ -19,24 +26,40 @@ import org.slf4j.LoggerFactory
|
|||||||
*/
|
*/
|
||||||
fun configureTestDatabase() {
|
fun configureTestDatabase() {
|
||||||
val log = LoggerFactory.getLogger("TestDatabaseInitialization")
|
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
|
// Verbinde mit einer In-Memory-SQLite-Datenbank
|
||||||
Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
|
Database.connect("jdbc:sqlite::memory:", driver = "org.sqlite.JDBC")
|
||||||
|
|
||||||
// Initialisiere das Datenbankschema
|
// Initialisiere das Datenbankschema
|
||||||
transaction {
|
transaction {
|
||||||
log.info("Creating test database schema...")
|
log.info("Creating test database schema...")
|
||||||
SchemaUtils.create(TurniereTable)
|
SchemaUtils.create(TurniereTable, BewerbeTable)
|
||||||
|
|
||||||
// Füge ein Test-Turnier hinzu
|
// Füge ein Test-Turnier hinzu
|
||||||
log.info("Inserting test tournament data...")
|
log.info("Inserting test tournament data...")
|
||||||
|
val turnierNumber = 1
|
||||||
TurniereTable.insert {
|
TurniereTable.insert {
|
||||||
it[id] = "dummy-01"
|
it[TurniereTable.number] = turnierNumber
|
||||||
it[name] = "Erstes DB Turnier"
|
it[TurniereTable.name] = "CSN-C Edelhof April 2025"
|
||||||
it[datum] = "19.04.2025"
|
it[TurniereTable.datum] = "14.04.2025 - 15.04.2025"
|
||||||
it[logoUrl] = null
|
}
|
||||||
it[ausschreibungUrl] = "/pdfs/ausschreibung_dummy.pdf"
|
|
||||||
|
// 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!")
|
log.info("Test database initialized successfully!")
|
||||||
@@ -57,15 +80,30 @@ fun Application.testModule() {
|
|||||||
|
|
||||||
// Lese Daten aus der Test-Datenbank
|
// Lese Daten aus der Test-Datenbank
|
||||||
val turniereFromDb = transaction {
|
val turniereFromDb = transaction {
|
||||||
TurniereTable.selectAll().map { row ->
|
// Get all tournaments
|
||||||
|
val turniere = TurniereTable.selectAll().map { row ->
|
||||||
Turnier(
|
Turnier(
|
||||||
id = row[TurniereTable.id],
|
|
||||||
name = row[TurniereTable.name],
|
name = row[TurniereTable.name],
|
||||||
datum = row[TurniereTable.datum],
|
datum = row[TurniereTable.datum],
|
||||||
logoUrl = row[TurniereTable.logoUrl],
|
number = row[TurniereTable.number]
|
||||||
ausschreibungUrl = row[TurniereTable.ausschreibungUrl]
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// HTML-Antwort generieren (wie in Application.kt)
|
||||||
@@ -88,13 +126,25 @@ fun Application.testModule() {
|
|||||||
strong { +turnier.name }
|
strong { +turnier.name }
|
||||||
+" (${turnier.datum})"
|
+" (${turnier.datum})"
|
||||||
+" "
|
+" "
|
||||||
if (turnier.ausschreibungUrl != null) {
|
div {
|
||||||
a(href = turnier.ausschreibungUrl, target = "_blank") {
|
+"Bewerbe: "
|
||||||
button { +"Ausschreibung" }
|
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" }
|
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
|
@Serializable
|
||||||
data class Nennung(
|
data class Nennung(
|
||||||
// Wir brauchen die Turnier-ID, um die Nennung zuzuordnen
|
/** Name of the rider */
|
||||||
val turnierId: String,
|
val riderName: String,
|
||||||
// Einfache Felder für den Start
|
|
||||||
val riderName: String = "", // Standardwerte für leeres Formular
|
/** Name of the horse */
|
||||||
val horseName: String = "",
|
val horseName: String,
|
||||||
val email: String = "",
|
|
||||||
val comments: String? = null
|
/** Email address for contact */
|
||||||
// Hier kommen später Felder hinzu: Verein, Lizenznr., Tel,
|
val email: String,
|
||||||
// und vor allem: die Auswahl der Prüfungen!
|
|
||||||
|
/** 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
|
package at.mocode.model
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Turnier(
|
data class Turnier(
|
||||||
val id: String, // Eine eindeutige ID für das Turnier (z.B. eine UUID als String)
|
/** The name of the tournament, e.g. "CSN-C NEU CSNP-C NEU NEUMARKT/M., OÖ" */
|
||||||
val name: String, // Der Name, z.B. "CDN-C Edelhof April 2025"
|
val name: String,
|
||||||
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
|
/** The date of the tournament as a formatted string, e.g. "7.JUNI 2025" */
|
||||||
val ausschreibungUrl: String? = null // Optional: Link zum Ausschreibung-PDF
|
val datum: String,
|
||||||
// Hier können später viele weitere Felder hinzukommen:
|
|
||||||
// Ort, Veranstalter, Status (geplant, läuft, beendet), Disziplinen etc.
|
/** 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