erste Version Online-Nennen

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