diff --git a/.gitignore b/.gitignore index 9ffbfaf..674a78b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ build/ *.iws *.ipr out/ +build/ diff --git a/README.md b/README.md index 490dbeb..dfe853f 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ Kinder können durch das Lösen von Lernaufgaben, beispielsweise Mathematikaufga | Feature | Beschreibung | | :--------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **JWT-Authentifizierung** | Sichere API-Endpunkte für authentifizierte Benutzer über JSON Web Tokens (JWT). | -| **Lernaufgaben (Tasks)** | Abrufen von interaktiven Lernaufgaben mit vordefinierten Antwortmöglichkeiten und Belohnungen in Minuten. | +| **Lernaufgaben (Tasks)** | Abrufen von interaktiven Lernaufgaben mit vordefinierten Antwortmöglichkeiten, Filterung nach Fach und Alter sowie Belohnungen in Minuten. | | **Antwort-Verifizierung** | Überprüfung der vom Kind ausgewählten Antworten. Bei korrekter Antwort wird die Belohnung direkt auf die verbleibende Bildschirmzeit aufgerechnet. | +| **OER / H5P Ingestion Pipeline** | Automatisierter Live-Crawler und Batch-Import von freien Bildungsinhalten (ZUM.de / H5P) inklusive HTML-Bereinigung und Lizenzattributierung (CC-BY). | | **Bildschirmzeit-Synchronisation** | Laufender Datenabgleich der verbrauchten Bildschirmzeit. Sinkt die verbleibende Zeit auf oder unter null, wird der Status automatisch auf `isBlocked = true` gesetzt. | | **Zwei-Ebenen-Speicher** | PostgreSQL als persistente Datenbank, kombiniert mit Redis-Caching für schnelle Screentime-Abfragen. | | **Fallback-Mechanismus** | Ist Redis nicht erreichbar, fällt das System automatisch und transparent auf PostgreSQL zurück. | @@ -98,7 +99,8 @@ Prüft, ob der Backend-Server ordnungsgemäß läuft. ```json { "token": "eyJhbGciOiJIUzI1NiIsIn...", - "familyName": "Familie Schmidt" + "userId": "6380776f-88d3-4e9f-a2be-0d9cacae447e", + "message": "Registrierung erfolgreich" } ``` @@ -128,7 +130,9 @@ Prüft, ob der Backend-Server ordnungsgemäß läuft. ```json { "token": "eyJhbGciOiJIUzI1NiIsIn...", - "familyName": "Familie Schmidt" + "userId": "6380776f-88d3-4e9f-a2be-0d9cacae447e", + "familyName": "Familie Schmidt", + "childAge": 8 } ``` @@ -156,9 +160,9 @@ Authorization: Bearer **Beschreibung:** -Liefert die aktuell verbleibende Bildschirmzeit sowie den aktuellen Sperrstatus. +Liefert die aktuell verbleibende Bildschirmzeit sowie den aktuellen Sperrstatus des authentifizierten Benutzers. -**Antwort:** +**Antwort – `200 OK`:** ```json { @@ -181,29 +185,28 @@ Liefert die aktuell verbleibende Bildschirmzeit sowie den aktuellen Sperrstatus. **Beschreibung:** -Zieht die seit dem letzten Abgleich verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab. +Zieht die seit dem letzten Abgleich vom Endgerät verbrauchten Sekunden von der verfügbaren Bildschirmzeit ab. **Request-Body:** ```json { - "userId": "default_user", - "usedSecondsSinceLastSync": 200 + "usedSecondsSinceLastSync": 30 } ``` -**Antwort:** +**Antwort – `200 OK`:** ```json { - "remainingSeconds": 1600, + "remainingSeconds": 1770, "isBlocked": false } ``` --- -#### 6. Alle verfügbaren Aufgaben abrufen +#### 6. Lernaufgaben abrufen (mit Fach- & Altersfilter) **Methode:** `GET` @@ -213,24 +216,42 @@ Zieht die seit dem letzten Abgleich verbrauchten Sekunden von der verfügbaren B /api/v1/tasks ``` +**Optionale Query-Parameter:** + +| Parameter | Typ | Standardwert | Beschreibung | +| :--- | :--- | :--- | :--- | +| `subject` | String | `null` | Schulfach (z. B. `Mathematik`, `Deutsch`, `Sachunterricht`, `Englisch`, `Biologie`, `Geographie`, `Geschichte`). | +| `age` | Integer | `null` | Zielalter des Kindes (filtert nach `minAge <= age AND maxAge >= age`). | +| `limit` | Integer | `10` | Maximale Anzahl an zurückgegebenen Aufgaben. | + +**Beispiel-Anfrage:** + +```text +GET /api/v1/tasks?subject=Mathematik&age=8&limit=5 +``` + **Beschreibung:** -Liefert eine Liste aller verfügbaren Lernaufgaben. +Liefert kuratierte oder aus OER/H5P importierte Lernaufgaben. Die korrekte Lösung (`correctAnswer`) wird zum Schutz vor Manipulationen serverseitig geheim gehalten und niemals an den Client übertragen. -**Antwort:** +**Antwort – `200 OK`:** ```json [ { - "id": "t1", - "question": "Was ist 12 + 15?", + "id": "zum-math-001", + "subject": "Mathematik", + "minAge": 8, + "maxAge": 9, + "question": "Was ist das Ergebnis von 6 × 8?", "options": [ - "25", - "27", - "30", - "22" + "42", + "48", + "54", + "56" ], - "rewardMinutes": 10 + "rewardMinutes": 5, + "license": "CC BY-SA 4.0" } ] ``` @@ -251,22 +272,22 @@ Liefert eine Liste aller verfügbaren Lernaufgaben. ```json { - "taskId": "t1", - "selectedAnswer": "27" + "taskId": "zum-math-001", + "selectedAnswer": "48" } ``` -**Antwort – korrekte Lösung:** +**Antwort – korrekte Lösung (`200 OK`):** ```json { "isCorrect": true, - "earnedMinutes": 10, - "message": "Super gemacht! Du hast 10 Minuten gewonnen." + "earnedMinutes": 5, + "message": "Super gemacht! Du hast 5 Minuten gewonnen." } ``` -**Antwort – falsche Lösung:** +**Antwort – falsche Lösung (`200 OK`):** ```json { @@ -278,6 +299,38 @@ Liefert eine Liste aller verfügbaren Lernaufgaben. --- +## H5P / ZUM.de Aufgaben-Import (Standalone CLI) + +Das Backend verfügt über ein integriertes CLI-Tool zum automatisierten Importieren und Crawlen interaktiver Lernaufgaben von **ZUM.de / OER (H5P)**. + +### Batch-Import ausführen + +```bash +# Alle Fächer importieren (z. B. 100 Aufgaben) +./gradlew runIngestion --args="--limit=100" + +# Gezielt ein Schulfach importieren (z. B. Mathematik) +./gradlew runIngestion --args="--subject=Mathematik --limit=50" + +# Deutsch für Grundschüler importieren +./gradlew runIngestion --args="--subject=Deutsch --limit=30" +``` + +### CLI-Optionen + +| Argument | Standardwert | Beschreibung | +| :--- | :--- | :--- | +| `--subject=` | *(alle Fächer)* | Filtert Aufgaben nach Fach (z. B. `Mathematik`, `Deutsch`, `Englisch`, `Sachunterricht`). | +| `--limit=` | `50` | Maximale Anzahl an Aufgaben, die im Durchlauf importiert/aktualisiert werden. | + +### Funktionsweise der Ingestion-Pipeline +1. **Live-Crawler & Discovery:** Durchsucht `apps.zum.de` nach interaktiven Inhalten (`H5P.MultiChoice`, `H5P.TrueFalse`, `H5P.QuestionSet`). +2. **Textbereinigung:** Entfernt störende HTML-Tags (`

`, ``, ` `) für native mobile Darstellung. +3. **Taxonomie- & Alters-Mapping:** Weist Altersgrenzen (`minAge`, `maxAge`) basierend auf Klassenstufen automatisch zu. +4. **Idempotenter Upsert:** Speichert Fragen, Optionen, geheime Lösungen und CC-Lizenzen direkt relational in PostgreSQL (`TasksTable`). Duplikate werden vermieden. + +--- + ## Projektkonfiguration und Umgebungsvariablen Die Anwendung wird über folgende Konfigurationsdatei konfiguriert: @@ -517,3 +570,16 @@ MIT License ``` Eine entsprechende `LICENSE`-Datei sollte sich im Root-Verzeichnis des Repositorys befinden. + + +--- + +## Kidio Ökosystem +Für Kinder (Der spielerische Teil - "Kid"): + +* Kidio Learn: Interaktive Lernspiele (Mathe, Sprachen, Logik), die sich dem Alter und Lernfortschritt des Kindes anpassen. + +* Kidio Create: Tools und Plattformen, um eigene Ideen, Kunstwerke oder kleine Projekte zu entwickeln und zu teilen. + +* Kidio Media: Ein sicherer Bereich mit kindgerechten Videos, Hörspielen und Büchern (ähnlich wie YouTube Kids). +![img.png](img.png) \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 9868e1e..9c6ad99 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,12 @@ version = "1.0.0-SNAPSHOT" application { mainClass = "io.ktor.server.netty.EngineMain" } +ktor { + openApi { + enabled = true // Aktiviert die OpenAPI-Code-Generierung + codeInferenceEnabled = true + } +} kotlin { jvmToolchain(21) @@ -20,7 +26,7 @@ dependencies { implementation(ktorLibs.server.config.yaml) implementation(ktorLibs.server.core) implementation(ktorLibs.server.netty) - + implementation("io.ktor:ktor-server-openapi") // JWT Authentication implementation("io.ktor:ktor-server-auth") implementation("io.ktor:ktor-server-auth-jwt") @@ -46,3 +52,11 @@ dependencies { // redis implementation("redis.clients:jedis:5.1.2") } + +tasks.register("runIngestion") { + group = "application" + description = "Runs the standalone H5P/ZUM task ingestion CLI tool" + mainClass.set("com.oliver.kidio.backend.ingestion.H5PIngestionCli") + classpath = sourceSets["main"].runtimeClasspath + standardInput = System.`in` +} diff --git a/docs/.swagger-codegen-ignore b/docs/.swagger-codegen-ignore new file mode 100644 index 0000000..c5fa491 --- /dev/null +++ b/docs/.swagger-codegen-ignore @@ -0,0 +1,23 @@ +# Swagger Codegen Ignore +# Generated by swagger-codegen https://github.com/swagger-api/swagger-codegen + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell Swagger Codgen to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/docs/.swagger-codegen/VERSION b/docs/.swagger-codegen/VERSION new file mode 100644 index 0000000..2680502 --- /dev/null +++ b/docs/.swagger-codegen/VERSION @@ -0,0 +1 @@ +3.0.81 \ No newline at end of file diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..0d0c426 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,4987 @@ + + + + + Untitled API + + + + + + + + + + + + + +

+
+ +
+
+
+

Untitled API

+
+
+
+ +
+
+

Default

+
+
+
+

apiV1AuthLoginPost

+

+
+
+
+

+

+

+
+
/api/v1/auth/login
+

+

Usage and SDK Samples

+

+ + +
+
+
curl -X POST\
+-H "Accept: application/json"\
+-H "Content-Type: application/json"\
+"//api/v1/auth/login"
+
+
+
import io.swagger.client.*;
+import io.swagger.client.auth.*;
+import io.swagger.client.model.*;
+import io.swagger.client.api.DefaultApi;
+
+import java.io.File;
+import java.util.*;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        
+        DefaultApi apiInstance = new DefaultApi();
+        LoginRequest body = ; // LoginRequest | 
+        try {
+            AuthResponse result = apiInstance.apiV1AuthLoginPost(body);
+            System.out.println(result);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1AuthLoginPost");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
import io.swagger.client.api.DefaultApi;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        DefaultApi apiInstance = new DefaultApi();
+        LoginRequest body = ; // LoginRequest | 
+        try {
+            AuthResponse result = apiInstance.apiV1AuthLoginPost(body);
+            System.out.println(result);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1AuthLoginPost");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
LoginRequest *body = ; //  (optional)
+
+DefaultApi *apiInstance = [[DefaultApi alloc] init];
+
+[apiInstance apiV1AuthLoginPostWith:body
+              completionHandler: ^(AuthResponse output, NSError* error) {
+                            if (output) {
+                                NSLog(@"%@", output);
+                            }
+                            if (error) {
+                                NSLog(@"Error: %@", error);
+                            }
+                        }];
+
+
+ +
+
var UntitledApi = require('untitled_api');
+
+var api = new UntitledApi.DefaultApi()
+var opts = { 
+  'body':  // {{LoginRequest}} 
+};
+var callback = function(error, data, response) {
+  if (error) {
+    console.error(error);
+  } else {
+    console.log('API called successfully. Returned data: ' + data);
+  }
+};
+api.apiV1AuthLoginPost(opts, callback);
+
+
+ + +
+
using System;
+using System.Diagnostics;
+using IO.Swagger.Api;
+using IO.Swagger.Client;
+using IO.Swagger.Model;
+
+namespace Example
+{
+    public class apiV1AuthLoginPostExample
+    {
+        public void main()
+        {
+
+            var apiInstance = new DefaultApi();
+            var body = new LoginRequest(); // LoginRequest |  (optional) 
+
+            try
+            {
+                AuthResponse result = apiInstance.apiV1AuthLoginPost(body);
+                Debug.WriteLine(result);
+            }
+            catch (Exception e)
+            {
+                Debug.Print("Exception when calling DefaultApi.apiV1AuthLoginPost: " + e.Message );
+            }
+        }
+    }
+}
+
+
+ +
+
<?php
+require_once(__DIR__ . '/vendor/autoload.php');
+
+$api_instance = new Swagger\Client\ApiDefaultApi();
+$body = ; // LoginRequest | 
+
+try {
+    $result = $api_instance->apiV1AuthLoginPost($body);
+    print_r($result);
+} catch (Exception $e) {
+    echo 'Exception when calling DefaultApi->apiV1AuthLoginPost: ', $e->getMessage(), PHP_EOL;
+}
+?>
+
+ +
+
use Data::Dumper;
+use WWW::SwaggerClient::Configuration;
+use WWW::SwaggerClient::DefaultApi;
+
+my $api_instance = WWW::SwaggerClient::DefaultApi->new();
+my $body = WWW::SwaggerClient::Object::LoginRequest->new(); # LoginRequest | 
+
+eval { 
+    my $result = $api_instance->apiV1AuthLoginPost(body => $body);
+    print Dumper($result);
+};
+if ($@) {
+    warn "Exception when calling DefaultApi->apiV1AuthLoginPost: $@\n";
+}
+
+ +
+
from __future__ import print_statement
+import time
+import swagger_client
+from swagger_client.rest import ApiException
+from pprint import pprint
+
+# create an instance of the API class
+api_instance = swagger_client.DefaultApi()
+body =  # LoginRequest |  (optional)
+
+try: 
+    api_response = api_instance.api_v1_auth_login_post(body=body)
+    pprint(api_response)
+except ApiException as e:
+    print("Exception when calling DefaultApi->apiV1AuthLoginPost: %s\n" % e)
+
+
+ +

Parameters

+ + + +
Body parameters
+ + + + + + + + +
NameDescription
body + + + +
+
+ + + +

Responses

+

Status: 400 -

+ + + +
+
+
+ +
+ +
+
+ +

Status: 401 -

+ + + +
+
+
+ +
+ +
+
+ +

Status: 200 -

+ + + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+

apiV1AuthRegisterPost

+

+
+
+
+

+

+

+
+
/api/v1/auth/register
+

+

Usage and SDK Samples

+

+ + +
+
+
curl -X POST\
+-H "Accept: application/json"\
+-H "Content-Type: application/json"\
+"//api/v1/auth/register"
+
+
+
import io.swagger.client.*;
+import io.swagger.client.auth.*;
+import io.swagger.client.model.*;
+import io.swagger.client.api.DefaultApi;
+
+import java.io.File;
+import java.util.*;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        
+        DefaultApi apiInstance = new DefaultApi();
+        RegisterRequest body = ; // RegisterRequest | 
+        try {
+            apiInstance.apiV1AuthRegisterPost(body);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1AuthRegisterPost");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
import io.swagger.client.api.DefaultApi;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        DefaultApi apiInstance = new DefaultApi();
+        RegisterRequest body = ; // RegisterRequest | 
+        try {
+            apiInstance.apiV1AuthRegisterPost(body);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1AuthRegisterPost");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
RegisterRequest *body = ; //  (optional)
+
+DefaultApi *apiInstance = [[DefaultApi alloc] init];
+
+[apiInstance apiV1AuthRegisterPostWith:body
+              completionHandler: ^(NSError* error) {
+                            if (error) {
+                                NSLog(@"Error: %@", error);
+                            }
+                        }];
+
+
+ +
+
var UntitledApi = require('untitled_api');
+
+var api = new UntitledApi.DefaultApi()
+var opts = { 
+  'body':  // {{RegisterRequest}} 
+};
+var callback = function(error, data, response) {
+  if (error) {
+    console.error(error);
+  } else {
+    console.log('API called successfully.');
+  }
+};
+api.apiV1AuthRegisterPost(opts, callback);
+
+
+ + +
+
using System;
+using System.Diagnostics;
+using IO.Swagger.Api;
+using IO.Swagger.Client;
+using IO.Swagger.Model;
+
+namespace Example
+{
+    public class apiV1AuthRegisterPostExample
+    {
+        public void main()
+        {
+
+            var apiInstance = new DefaultApi();
+            var body = new RegisterRequest(); // RegisterRequest |  (optional) 
+
+            try
+            {
+                apiInstance.apiV1AuthRegisterPost(body);
+            }
+            catch (Exception e)
+            {
+                Debug.Print("Exception when calling DefaultApi.apiV1AuthRegisterPost: " + e.Message );
+            }
+        }
+    }
+}
+
+
+ +
+
<?php
+require_once(__DIR__ . '/vendor/autoload.php');
+
+$api_instance = new Swagger\Client\ApiDefaultApi();
+$body = ; // RegisterRequest | 
+
+try {
+    $api_instance->apiV1AuthRegisterPost($body);
+} catch (Exception $e) {
+    echo 'Exception when calling DefaultApi->apiV1AuthRegisterPost: ', $e->getMessage(), PHP_EOL;
+}
+?>
+
+ +
+
use Data::Dumper;
+use WWW::SwaggerClient::Configuration;
+use WWW::SwaggerClient::DefaultApi;
+
+my $api_instance = WWW::SwaggerClient::DefaultApi->new();
+my $body = WWW::SwaggerClient::Object::RegisterRequest->new(); # RegisterRequest | 
+
+eval { 
+    $api_instance->apiV1AuthRegisterPost(body => $body);
+};
+if ($@) {
+    warn "Exception when calling DefaultApi->apiV1AuthRegisterPost: $@\n";
+}
+
+ +
+
from __future__ import print_statement
+import time
+import swagger_client
+from swagger_client.rest import ApiException
+from pprint import pprint
+
+# create an instance of the API class
+api_instance = swagger_client.DefaultApi()
+body =  # RegisterRequest |  (optional)
+
+try: 
+    api_instance.api_v1_auth_register_post(body=body)
+except ApiException as e:
+    print("Exception when calling DefaultApi->apiV1AuthRegisterPost: %s\n" % e)
+
+
+ +

Parameters

+ + + +
Body parameters
+ + + + + + + + +
NameDescription
body + + + +
+
+ + + +

Responses

+

Status: 400 -

+ + + +
+
+
+ +
+ +
+
+ +

Status: 409 -

+ + + +
+
+
+ +
+ +
+
+ +

Status: 201 -

+ + + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+

apiV1HealthGet

+

Health-Check

+
+
+
+

+

+

+
+
/api/v1/health
+

+

Usage and SDK Samples

+

+ + +
+
+
curl -X GET\
+-H "Accept: application/json"\
+"//api/v1/health"
+
+
+
import io.swagger.client.*;
+import io.swagger.client.auth.*;
+import io.swagger.client.model.*;
+import io.swagger.client.api.DefaultApi;
+
+import java.io.File;
+import java.util.*;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        
+        DefaultApi apiInstance = new DefaultApi();
+        try {
+            apiInstance.apiV1HealthGet();
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1HealthGet");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
import io.swagger.client.api.DefaultApi;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        DefaultApi apiInstance = new DefaultApi();
+        try {
+            apiInstance.apiV1HealthGet();
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1HealthGet");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+

+DefaultApi *apiInstance = [[DefaultApi alloc] init];
+
+// Health-Check
+[apiInstance apiV1HealthGetWithCompletionHandler: 
+              ^(NSError* error) {
+                            if (error) {
+                                NSLog(@"Error: %@", error);
+                            }
+                        }];
+
+
+ +
+
var UntitledApi = require('untitled_api');
+
+var api = new UntitledApi.DefaultApi()
+var callback = function(error, data, response) {
+  if (error) {
+    console.error(error);
+  } else {
+    console.log('API called successfully.');
+  }
+};
+api.apiV1HealthGet(callback);
+
+
+ + +
+
using System;
+using System.Diagnostics;
+using IO.Swagger.Api;
+using IO.Swagger.Client;
+using IO.Swagger.Model;
+
+namespace Example
+{
+    public class apiV1HealthGetExample
+    {
+        public void main()
+        {
+
+            var apiInstance = new DefaultApi();
+
+            try
+            {
+                // Health-Check
+                apiInstance.apiV1HealthGet();
+            }
+            catch (Exception e)
+            {
+                Debug.Print("Exception when calling DefaultApi.apiV1HealthGet: " + e.Message );
+            }
+        }
+    }
+}
+
+
+ +
+
<?php
+require_once(__DIR__ . '/vendor/autoload.php');
+
+$api_instance = new Swagger\Client\ApiDefaultApi();
+
+try {
+    $api_instance->apiV1HealthGet();
+} catch (Exception $e) {
+    echo 'Exception when calling DefaultApi->apiV1HealthGet: ', $e->getMessage(), PHP_EOL;
+}
+?>
+
+ +
+
use Data::Dumper;
+use WWW::SwaggerClient::Configuration;
+use WWW::SwaggerClient::DefaultApi;
+
+my $api_instance = WWW::SwaggerClient::DefaultApi->new();
+
+eval { 
+    $api_instance->apiV1HealthGet();
+};
+if ($@) {
+    warn "Exception when calling DefaultApi->apiV1HealthGet: $@\n";
+}
+
+ +
+
from __future__ import print_statement
+import time
+import swagger_client
+from swagger_client.rest import ApiException
+from pprint import pprint
+
+# create an instance of the API class
+api_instance = swagger_client.DefaultApi()
+
+try: 
+    # Health-Check
+    api_instance.api_v1_health_get()
+except ApiException as e:
+    print("Exception when calling DefaultApi->apiV1HealthGet: %s\n" % e)
+
+
+ +

Parameters

+ + + + + + +

Responses

+

Status: 200 -

+ + + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+

apiV1ScreentimeGet

+

+
+
+
+

+

+

+
+
/api/v1/screentime
+

+

Usage and SDK Samples

+

+ + +
+
+
curl -X GET\
+ -H "Authorization: Bearer [[accessToken]]"\
+-H "Accept: application/json"\
+"//api/v1/screentime"
+
+
+
import io.swagger.client.*;
+import io.swagger.client.auth.*;
+import io.swagger.client.model.*;
+import io.swagger.client.api.DefaultApi;
+
+import java.io.File;
+import java.util.*;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        ApiClient defaultClient = Configuration.getDefaultApiClient();
+
+
+        DefaultApi apiInstance = new DefaultApi();
+        try {
+            SyncResponse result = apiInstance.apiV1ScreentimeGet();
+            System.out.println(result);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1ScreentimeGet");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
import io.swagger.client.api.DefaultApi;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        DefaultApi apiInstance = new DefaultApi();
+        try {
+            SyncResponse result = apiInstance.apiV1ScreentimeGet();
+            System.out.println(result);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1ScreentimeGet");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
Configuration *apiConfig = [Configuration sharedConfig];
+
+DefaultApi *apiInstance = [[DefaultApi alloc] init];
+
+[apiInstance apiV1ScreentimeGetWithCompletionHandler: 
+              ^(SyncResponse output, NSError* error) {
+                            if (output) {
+                                NSLog(@"%@", output);
+                            }
+                            if (error) {
+                                NSLog(@"Error: %@", error);
+                            }
+                        }];
+
+
+ +
+
var UntitledApi = require('untitled_api');
+var defaultClient = UntitledApi.ApiClient.instance;
+
+
+var api = new UntitledApi.DefaultApi()
+var callback = function(error, data, response) {
+  if (error) {
+    console.error(error);
+  } else {
+    console.log('API called successfully. Returned data: ' + data);
+  }
+};
+api.apiV1ScreentimeGet(callback);
+
+
+ + +
+
using System;
+using System.Diagnostics;
+using IO.Swagger.Api;
+using IO.Swagger.Client;
+using IO.Swagger.Model;
+
+namespace Example
+{
+    public class apiV1ScreentimeGetExample
+    {
+        public void main()
+        {
+
+
+            var apiInstance = new DefaultApi();
+
+            try
+            {
+                SyncResponse result = apiInstance.apiV1ScreentimeGet();
+                Debug.WriteLine(result);
+            }
+            catch (Exception e)
+            {
+                Debug.Print("Exception when calling DefaultApi.apiV1ScreentimeGet: " + e.Message );
+            }
+        }
+    }
+}
+
+
+ +
+
<?php
+require_once(__DIR__ . '/vendor/autoload.php');
+
+
+$api_instance = new Swagger\Client\ApiDefaultApi();
+
+try {
+    $result = $api_instance->apiV1ScreentimeGet();
+    print_r($result);
+} catch (Exception $e) {
+    echo 'Exception when calling DefaultApi->apiV1ScreentimeGet: ', $e->getMessage(), PHP_EOL;
+}
+?>
+
+ +
+
use Data::Dumper;
+use WWW::SwaggerClient::Configuration;
+use WWW::SwaggerClient::DefaultApi;
+
+
+my $api_instance = WWW::SwaggerClient::DefaultApi->new();
+
+eval { 
+    my $result = $api_instance->apiV1ScreentimeGet();
+    print Dumper($result);
+};
+if ($@) {
+    warn "Exception when calling DefaultApi->apiV1ScreentimeGet: $@\n";
+}
+
+ +
+
from __future__ import print_statement
+import time
+import swagger_client
+from swagger_client.rest import ApiException
+from pprint import pprint
+
+
+# create an instance of the API class
+api_instance = swagger_client.DefaultApi()
+
+try: 
+    api_response = api_instance.api_v1_screentime_get()
+    pprint(api_response)
+except ApiException as e:
+    print("Exception when calling DefaultApi->apiV1ScreentimeGet: %s\n" % e)
+
+
+ +

Parameters

+ + + + + + +

Responses

+

Status: 401 -

+ + + +
+
+
+ +
+ +
+
+ +

Status: 200 -

+ + + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+

apiV1ScreentimeSyncPost

+

3. Bildschirmzeit synchronisieren

+
+
+
+

+

+

+
+
/api/v1/screentime/sync
+

+

Usage and SDK Samples

+

+ + +
+
+
curl -X POST\
+ -H "Authorization: Bearer [[accessToken]]"\
+-H "Accept: application/json"\
+-H "Content-Type: application/json"\
+"//api/v1/screentime/sync"
+
+
+
import io.swagger.client.*;
+import io.swagger.client.auth.*;
+import io.swagger.client.model.*;
+import io.swagger.client.api.DefaultApi;
+
+import java.io.File;
+import java.util.*;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        ApiClient defaultClient = Configuration.getDefaultApiClient();
+
+
+        DefaultApi apiInstance = new DefaultApi();
+        SyncRequest body = ; // SyncRequest | 
+        try {
+            SyncResponse result = apiInstance.apiV1ScreentimeSyncPost(body);
+            System.out.println(result);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1ScreentimeSyncPost");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
import io.swagger.client.api.DefaultApi;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        DefaultApi apiInstance = new DefaultApi();
+        SyncRequest body = ; // SyncRequest | 
+        try {
+            SyncResponse result = apiInstance.apiV1ScreentimeSyncPost(body);
+            System.out.println(result);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1ScreentimeSyncPost");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
Configuration *apiConfig = [Configuration sharedConfig];
+SyncRequest *body = ; //  (optional)
+
+DefaultApi *apiInstance = [[DefaultApi alloc] init];
+
+// 3. Bildschirmzeit synchronisieren
+[apiInstance apiV1ScreentimeSyncPostWith:body
+              completionHandler: ^(SyncResponse output, NSError* error) {
+                            if (output) {
+                                NSLog(@"%@", output);
+                            }
+                            if (error) {
+                                NSLog(@"Error: %@", error);
+                            }
+                        }];
+
+
+ +
+
var UntitledApi = require('untitled_api');
+var defaultClient = UntitledApi.ApiClient.instance;
+
+
+var api = new UntitledApi.DefaultApi()
+var opts = { 
+  'body':  // {{SyncRequest}} 
+};
+var callback = function(error, data, response) {
+  if (error) {
+    console.error(error);
+  } else {
+    console.log('API called successfully. Returned data: ' + data);
+  }
+};
+api.apiV1ScreentimeSyncPost(opts, callback);
+
+
+ + +
+
using System;
+using System.Diagnostics;
+using IO.Swagger.Api;
+using IO.Swagger.Client;
+using IO.Swagger.Model;
+
+namespace Example
+{
+    public class apiV1ScreentimeSyncPostExample
+    {
+        public void main()
+        {
+
+
+            var apiInstance = new DefaultApi();
+            var body = new SyncRequest(); // SyncRequest |  (optional) 
+
+            try
+            {
+                // 3. Bildschirmzeit synchronisieren
+                SyncResponse result = apiInstance.apiV1ScreentimeSyncPost(body);
+                Debug.WriteLine(result);
+            }
+            catch (Exception e)
+            {
+                Debug.Print("Exception when calling DefaultApi.apiV1ScreentimeSyncPost: " + e.Message );
+            }
+        }
+    }
+}
+
+
+ +
+
<?php
+require_once(__DIR__ . '/vendor/autoload.php');
+
+
+$api_instance = new Swagger\Client\ApiDefaultApi();
+$body = ; // SyncRequest | 
+
+try {
+    $result = $api_instance->apiV1ScreentimeSyncPost($body);
+    print_r($result);
+} catch (Exception $e) {
+    echo 'Exception when calling DefaultApi->apiV1ScreentimeSyncPost: ', $e->getMessage(), PHP_EOL;
+}
+?>
+
+ +
+
use Data::Dumper;
+use WWW::SwaggerClient::Configuration;
+use WWW::SwaggerClient::DefaultApi;
+
+
+my $api_instance = WWW::SwaggerClient::DefaultApi->new();
+my $body = WWW::SwaggerClient::Object::SyncRequest->new(); # SyncRequest | 
+
+eval { 
+    my $result = $api_instance->apiV1ScreentimeSyncPost(body => $body);
+    print Dumper($result);
+};
+if ($@) {
+    warn "Exception when calling DefaultApi->apiV1ScreentimeSyncPost: $@\n";
+}
+
+ +
+
from __future__ import print_statement
+import time
+import swagger_client
+from swagger_client.rest import ApiException
+from pprint import pprint
+
+
+# create an instance of the API class
+api_instance = swagger_client.DefaultApi()
+body =  # SyncRequest |  (optional)
+
+try: 
+    # 3. Bildschirmzeit synchronisieren
+    api_response = api_instance.api_v1_screentime_sync_post(body=body)
+    pprint(api_response)
+except ApiException as e:
+    print("Exception when calling DefaultApi->apiV1ScreentimeSyncPost: %s\n" % e)
+
+
+ +

Parameters

+ + + +
Body parameters
+ + + + + + + + +
NameDescription
body + + + +
+
+ + + +

Responses

+

Status: 200 -

+ + + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+

apiV1TasksGet

+

1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)

+
+
+
+

+

+

+
+
/api/v1/tasks
+

+

Usage and SDK Samples

+

+ + +
+
+
curl -X GET\
+ -H "Authorization: Bearer [[accessToken]]"\
+-H "Accept: application/json"\
+"//api/v1/tasks?subject=&age=&limit="
+
+
+
import io.swagger.client.*;
+import io.swagger.client.auth.*;
+import io.swagger.client.model.*;
+import io.swagger.client.api.DefaultApi;
+
+import java.io.File;
+import java.util.*;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        ApiClient defaultClient = Configuration.getDefaultApiClient();
+
+
+        DefaultApi apiInstance = new DefaultApi();
+         subject = ; //  | 
+         age = ; //  | 
+         limit = ; //  | 
+        try {
+            apiInstance.apiV1TasksGet(subject, age, limit);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1TasksGet");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
import io.swagger.client.api.DefaultApi;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        DefaultApi apiInstance = new DefaultApi();
+         subject = ; //  | 
+         age = ; //  | 
+         limit = ; //  | 
+        try {
+            apiInstance.apiV1TasksGet(subject, age, limit);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1TasksGet");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
Configuration *apiConfig = [Configuration sharedConfig];
+ *subject = ; //  (optional)
+ *age = ; //  (optional)
+ *limit = ; //  (optional)
+
+DefaultApi *apiInstance = [[DefaultApi alloc] init];
+
+// 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
+[apiInstance apiV1TasksGetWith:subject
+    age:age
+    limit:limit
+              completionHandler: ^(NSError* error) {
+                            if (error) {
+                                NSLog(@"Error: %@", error);
+                            }
+                        }];
+
+
+ +
+
var UntitledApi = require('untitled_api');
+var defaultClient = UntitledApi.ApiClient.instance;
+
+
+var api = new UntitledApi.DefaultApi()
+var opts = { 
+  'subject': , // {{}} 
+  'age': , // {{}} 
+  'limit':  // {{}} 
+};
+var callback = function(error, data, response) {
+  if (error) {
+    console.error(error);
+  } else {
+    console.log('API called successfully.');
+  }
+};
+api.apiV1TasksGet(opts, callback);
+
+
+ + +
+
using System;
+using System.Diagnostics;
+using IO.Swagger.Api;
+using IO.Swagger.Client;
+using IO.Swagger.Model;
+
+namespace Example
+{
+    public class apiV1TasksGetExample
+    {
+        public void main()
+        {
+
+
+            var apiInstance = new DefaultApi();
+            var subject = new (); //  |  (optional) 
+            var age = new (); //  |  (optional) 
+            var limit = new (); //  |  (optional) 
+
+            try
+            {
+                // 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
+                apiInstance.apiV1TasksGet(subject, age, limit);
+            }
+            catch (Exception e)
+            {
+                Debug.Print("Exception when calling DefaultApi.apiV1TasksGet: " + e.Message );
+            }
+        }
+    }
+}
+
+
+ +
+
<?php
+require_once(__DIR__ . '/vendor/autoload.php');
+
+
+$api_instance = new Swagger\Client\ApiDefaultApi();
+$subject = ; //  | 
+$age = ; //  | 
+$limit = ; //  | 
+
+try {
+    $api_instance->apiV1TasksGet($subject, $age, $limit);
+} catch (Exception $e) {
+    echo 'Exception when calling DefaultApi->apiV1TasksGet: ', $e->getMessage(), PHP_EOL;
+}
+?>
+
+ +
+
use Data::Dumper;
+use WWW::SwaggerClient::Configuration;
+use WWW::SwaggerClient::DefaultApi;
+
+
+my $api_instance = WWW::SwaggerClient::DefaultApi->new();
+my $subject = ; #  | 
+my $age = ; #  | 
+my $limit = ; #  | 
+
+eval { 
+    $api_instance->apiV1TasksGet(subject => $subject, age => $age, limit => $limit);
+};
+if ($@) {
+    warn "Exception when calling DefaultApi->apiV1TasksGet: $@\n";
+}
+
+ +
+
from __future__ import print_statement
+import time
+import swagger_client
+from swagger_client.rest import ApiException
+from pprint import pprint
+
+
+# create an instance of the API class
+api_instance = swagger_client.DefaultApi()
+subject =  #  |  (optional)
+age =  #  |  (optional)
+limit =  #  |  (optional)
+
+try: 
+    # 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter)
+    api_instance.api_v1_tasks_get(subject=subject, age=age, limit=limit)
+except ApiException as e:
+    print("Exception when calling DefaultApi->apiV1TasksGet: %s\n" % e)
+
+
+ +

Parameters

+ + + + + +
Query parameters
+ + + + + + + + + + + + + + +
NameDescription
subject + + +
+
+
+ + + + +
+
+
+
age + + +
+
+
+ + + + +
+
+
+
limit + + +
+
+
+ + + + +
+
+
+
+ +

Responses

+

Status: 200 -

+ + + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+

apiV1TasksVerifyPost

+

2. Antwort auf eine Aufgabe überprüfen

+
+
+
+

+

+

+
+
/api/v1/tasks/verify
+

+

Usage and SDK Samples

+

+ + +
+
+
curl -X POST\
+ -H "Authorization: Bearer [[accessToken]]"\
+-H "Accept: application/json"\
+-H "Content-Type: application/json"\
+"//api/v1/tasks/verify"
+
+
+
import io.swagger.client.*;
+import io.swagger.client.auth.*;
+import io.swagger.client.model.*;
+import io.swagger.client.api.DefaultApi;
+
+import java.io.File;
+import java.util.*;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        ApiClient defaultClient = Configuration.getDefaultApiClient();
+
+
+        DefaultApi apiInstance = new DefaultApi();
+        TaskVerifyRequest body = ; // TaskVerifyRequest | 
+        try {
+            TaskVerifyResponse result = apiInstance.apiV1TasksVerifyPost(body);
+            System.out.println(result);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1TasksVerifyPost");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
import io.swagger.client.api.DefaultApi;
+
+public class DefaultApiExample {
+
+    public static void main(String[] args) {
+        DefaultApi apiInstance = new DefaultApi();
+        TaskVerifyRequest body = ; // TaskVerifyRequest | 
+        try {
+            TaskVerifyResponse result = apiInstance.apiV1TasksVerifyPost(body);
+            System.out.println(result);
+        } catch (ApiException e) {
+            System.err.println("Exception when calling DefaultApi#apiV1TasksVerifyPost");
+            e.printStackTrace();
+        }
+    }
+}
+
+ +
+
Configuration *apiConfig = [Configuration sharedConfig];
+TaskVerifyRequest *body = ; //  (optional)
+
+DefaultApi *apiInstance = [[DefaultApi alloc] init];
+
+// 2. Antwort auf eine Aufgabe überprüfen
+[apiInstance apiV1TasksVerifyPostWith:body
+              completionHandler: ^(TaskVerifyResponse output, NSError* error) {
+                            if (output) {
+                                NSLog(@"%@", output);
+                            }
+                            if (error) {
+                                NSLog(@"Error: %@", error);
+                            }
+                        }];
+
+
+ +
+
var UntitledApi = require('untitled_api');
+var defaultClient = UntitledApi.ApiClient.instance;
+
+
+var api = new UntitledApi.DefaultApi()
+var opts = { 
+  'body':  // {{TaskVerifyRequest}} 
+};
+var callback = function(error, data, response) {
+  if (error) {
+    console.error(error);
+  } else {
+    console.log('API called successfully. Returned data: ' + data);
+  }
+};
+api.apiV1TasksVerifyPost(opts, callback);
+
+
+ + +
+
using System;
+using System.Diagnostics;
+using IO.Swagger.Api;
+using IO.Swagger.Client;
+using IO.Swagger.Model;
+
+namespace Example
+{
+    public class apiV1TasksVerifyPostExample
+    {
+        public void main()
+        {
+
+
+            var apiInstance = new DefaultApi();
+            var body = new TaskVerifyRequest(); // TaskVerifyRequest |  (optional) 
+
+            try
+            {
+                // 2. Antwort auf eine Aufgabe überprüfen
+                TaskVerifyResponse result = apiInstance.apiV1TasksVerifyPost(body);
+                Debug.WriteLine(result);
+            }
+            catch (Exception e)
+            {
+                Debug.Print("Exception when calling DefaultApi.apiV1TasksVerifyPost: " + e.Message );
+            }
+        }
+    }
+}
+
+
+ +
+
<?php
+require_once(__DIR__ . '/vendor/autoload.php');
+
+
+$api_instance = new Swagger\Client\ApiDefaultApi();
+$body = ; // TaskVerifyRequest | 
+
+try {
+    $result = $api_instance->apiV1TasksVerifyPost($body);
+    print_r($result);
+} catch (Exception $e) {
+    echo 'Exception when calling DefaultApi->apiV1TasksVerifyPost: ', $e->getMessage(), PHP_EOL;
+}
+?>
+
+ +
+
use Data::Dumper;
+use WWW::SwaggerClient::Configuration;
+use WWW::SwaggerClient::DefaultApi;
+
+
+my $api_instance = WWW::SwaggerClient::DefaultApi->new();
+my $body = WWW::SwaggerClient::Object::TaskVerifyRequest->new(); # TaskVerifyRequest | 
+
+eval { 
+    my $result = $api_instance->apiV1TasksVerifyPost(body => $body);
+    print Dumper($result);
+};
+if ($@) {
+    warn "Exception when calling DefaultApi->apiV1TasksVerifyPost: $@\n";
+}
+
+ +
+
from __future__ import print_statement
+import time
+import swagger_client
+from swagger_client.rest import ApiException
+from pprint import pprint
+
+
+# create an instance of the API class
+api_instance = swagger_client.DefaultApi()
+body =  # TaskVerifyRequest |  (optional)
+
+try: 
+    # 2. Antwort auf eine Aufgabe überprüfen
+    api_response = api_instance.api_v1_tasks_verify_post(body=body)
+    pprint(api_response)
+except ApiException as e:
+    print("Exception when calling DefaultApi->apiV1TasksVerifyPost: %s\n" % e)
+
+
+ +

Parameters

+ + + +
Body parameters
+ + + + + + + + +
NameDescription
body + + + +
+
+ + + +

Responses

+

Status: 401 -

+ + + +
+
+
+ +
+ +
+
+ +

Status: 200 -

+ + + +
+
+
+ +
+ +
+
+ +
+
+
+
+
+ +
+
+
+ + + + + + + + + diff --git a/img.png b/img.png new file mode 100644 index 0000000..24abf18 Binary files /dev/null and b/img.png differ diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt b/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt index b1aa9c0..80dbf1c 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt @@ -7,13 +7,24 @@ import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransacti import org.jetbrains.exposed.sql.transactions.transaction object TasksTable : Table("tasks") { - val id = varchar("id", 50) + val id = varchar("id", 64) + val externalSource = varchar("external_source", 32).default("local") + val sourceUrl = text("source_url").nullable() + val license = varchar("license", 100).default("CC BY-SA 4.0") + val subject = varchar("subject", 64).default("Mathematik") + val minAge = integer("min_age").default(6) + val maxAge = integer("max_age").default(12) + val taskType = varchar("task_type", 32).default("MULTIPLE_CHOICE") val question = text("question") val options = text("options") val correctAnswer = varchar("correct_answer", 255) - val rewardMinutes = integer("reward_minutes") + val rewardMinutes = integer("reward_minutes").default(5) override val primaryKey = PrimaryKey(id) + + init { + index("idx_tasks_filter", false, subject, minAge, maxAge) + } } object ScreentimeTable : Table("user_screentime") { @@ -27,9 +38,9 @@ object UserTable : Table("users") { val id = varchar("id", 36) val email = varchar("email", 255).uniqueIndex() val passwordHash = varchar("password_hash", 255) - val familyName = varchar("family_name", 100) - val childAge = integer("child_age") - val parentPin = varchar("parent_pin", 4) + val familyName = varchar("family_name", 100).default("Familie") + val childAge = integer("child_age").default(8) + val parentPin = varchar("parent_pin", 4).default("1234") override val primaryKey = PrimaryKey(id) } @@ -55,11 +66,42 @@ object DatabaseFactory { ) transaction(database) { - SchemaUtils.create(TasksTable, ScreentimeTable, UserTable) + // Fill existing NULL values if columns exist before SchemaUtils alters constraints + exec( + """ + DO ${'$'}${'$'} + BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='users') THEN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='family_name') THEN + UPDATE users SET family_name = 'Familie' WHERE family_name IS NULL; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='child_age') THEN + UPDATE users SET child_age = 8 WHERE child_age IS NULL; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='parent_pin') THEN + UPDATE users SET parent_pin = '1234' WHERE parent_pin IS NULL; + END IF; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name='tasks') THEN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='tasks' AND column_name='subject') THEN + UPDATE tasks SET subject = 'Mathematik' WHERE subject IS NULL; + END IF; + END IF; + END ${'$'}${'$'}; + """.trimIndent(), + ) + + SchemaUtils.createMissingTablesAndColumns(TasksTable, ScreentimeTable, UserTable) if (TasksTable.selectAll().empty()) { TasksTable.insert { it[id] = "t1" + it[externalSource] = "local" + it[license] = "CC BY-SA 4.0" + it[subject] = "Mathematik" + it[minAge] = 6 + it[maxAge] = 10 + it[taskType] = "MULTIPLE_CHOICE" it[question] = "Was ist 12 + 15?" it[options] = "25,27,30,22" it[correctAnswer] = "27" @@ -67,6 +109,12 @@ object DatabaseFactory { } TasksTable.insert { it[id] = "t2" + it[externalSource] = "local" + it[license] = "CC BY-SA 4.0" + it[subject] = "Mathematik" + it[minAge] = 7 + it[maxAge] = 12 + it[taskType] = "MULTIPLE_CHOICE" it[question] = "Was ist 8 x 7?" it[options] = "54,56,64,48" it[correctAnswer] = "56" diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt index d4b56df..b143dea 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/models/Models.kt @@ -6,9 +6,29 @@ import kotlinx.serialization.Serializable @Serializable data class Task( val id: String, + val subject: String = "Mathematik", + val minAge: Int = 6, + val maxAge: Int = 12, val question: String, val options: List, val rewardMinutes: Int, + val license: String? = null, +) + +// Interne Datenklasse für importierte Aufgaben (Ingestion) +data class ImportedTask( + val id: String, + val externalSource: String = "zum.de", + val sourceUrl: String? = null, + val license: String = "CC BY-SA 4.0", + val subject: String, + val minAge: Int, + val maxAge: Int, + val taskType: String = "MULTIPLE_CHOICE", + val question: String, + val options: List, + val correctAnswer: String, + val rewardMinutes: Int = 5, ) // Anfrage des Kindes, wenn es eine Aufgabe löst diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt index 177e0e9..988365c 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt @@ -1,5 +1,6 @@ package com.oliver.kidio.backend.domain.repository +import com.oliver.kidio.backend.ImportedTask import com.oliver.kidio.backend.Task import com.oliver.kidio.backend.User import com.oliver.kidio.backend.data.database.DatabaseFactory @@ -8,10 +9,27 @@ import com.oliver.kidio.backend.data.database.ScreentimeTable import com.oliver.kidio.backend.data.database.TasksTable import com.oliver.kidio.backend.data.database.UserTable import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.SqlExpressionBuilder.greaterEq +import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq +import org.jetbrains.exposed.sql.lowerCase import org.mindrot.jbcrypt.BCrypt interface KidioRepository { - suspend fun getAllTasks(): List + suspend fun getAllTasks(): List = getTasks(limit = 100) + + suspend fun getUserById(userId: String): User? + + suspend fun getTasks( + subject: String? = null, + age: Int? = null, + limit: Int = 10, + ): List + + // Mit suspend markiert, damit es zur DatabaseFactory.dbQuery Architektur passt + suspend fun getExistingTaskIds(externalSource: String = "zum.de"): Set + + suspend fun upsertTask(task: ImportedTask): Boolean suspend fun getTaskAnswerAndReward(taskId: String): Pair? @@ -33,18 +51,103 @@ interface KidioRepository { } class ExposedKidioRepository : KidioRepository { - override suspend fun getAllTasks(): List = + override suspend fun getTasks( + subject: String?, + age: Int?, + limit: Int, + ): List = DatabaseFactory.dbQuery { - TasksTable.selectAll().map { + var query = TasksTable.selectAll() + if (!subject.isNullOrBlank()) { + query = query.where { TasksTable.subject.lowerCase() eq subject.trim().lowercase() } + } + if (age != null) { + query = query.andWhere { (TasksTable.minAge lessEq age) and (TasksTable.maxAge greaterEq age) } + } + val safeLimit = if (limit <= 0) 10 else limit + query.limit(safeLimit).map { Task( id = it[TasksTable.id], + subject = it[TasksTable.subject], + minAge = it[TasksTable.minAge], + maxAge = it[TasksTable.maxAge], question = it[TasksTable.question], options = it[TasksTable.options].split(",").map { opt -> opt.trim() }, rewardMinutes = it[TasksTable.rewardMinutes], + license = it[TasksTable.license], ) } } + override suspend fun getUserById(userId: String): User? = + DatabaseFactory.dbQuery { + UserTable.selectAll() + .where { UserTable.id eq userId } + .map { + User( + id = it[UserTable.id], + email = it[UserTable.email], + passwordHash = it[UserTable.passwordHash], + familyName = it[UserTable.familyName], + childAge = it[UserTable.childAge], + parentPin = it[UserTable.parentPin], + ) + } + .singleOrNull() + } + + override suspend fun getAllTasks(): List = getTasks(limit = 100) + + // NEU: Implementierung für Exposed (PostgreSQL) + override suspend fun getExistingTaskIds(externalSource: String): Set = + DatabaseFactory.dbQuery { + TasksTable + .select(TasksTable.id) // Wir brauchen nur die ID-Spalte + .where { TasksTable.externalSource eq externalSource } + .map { it[TasksTable.id] } + .toSet() + } + + override suspend fun upsertTask(task: ImportedTask): Boolean = + DatabaseFactory.dbQuery { + try { + val updated = + TasksTable.update({ TasksTable.id eq task.id }) { + it[externalSource] = task.externalSource + it[sourceUrl] = task.sourceUrl + it[license] = task.license + it[subject] = task.subject + it[minAge] = task.minAge + it[maxAge] = task.maxAge + it[taskType] = task.taskType + it[question] = task.question + it[options] = task.options.joinToString(",") + it[correctAnswer] = task.correctAnswer + it[rewardMinutes] = task.rewardMinutes + } + if (updated == 0) { + TasksTable.insert { + it[id] = task.id + it[externalSource] = task.externalSource + it[sourceUrl] = task.sourceUrl + it[license] = task.license + it[subject] = task.subject + it[minAge] = task.minAge + it[maxAge] = task.maxAge + it[taskType] = task.taskType + it[question] = task.question + it[options] = task.options.joinToString(",") + it[correctAnswer] = task.correctAnswer + it[rewardMinutes] = task.rewardMinutes + } + } + true + } catch (e: Exception) { + println("FEHLER beim Upsert von Task ${task.id}: ${e.message}") + false + } + } + override suspend fun registerUser(user: User): Boolean = DatabaseFactory.dbQuery { try { @@ -164,13 +267,34 @@ class ExposedKidioRepository : KidioRepository { class InMemoryKidioRepository : KidioRepository { private val tasks = mutableListOf( - Task("t1", "Was ist 12 + 15?", listOf("25", "27", "30", "22"), 10), - Task("t2", "Was ist 8 x 7?", listOf("54", "56", "64", "48"), 15), - ) - private val answers = - mutableMapOf( - "t1" to ("27" to 10), - "t2" to ("56" to 15), + ImportedTask( + id = "t1", + externalSource = "manual", + subject = "Mathematik", + minAge = 6, + maxAge = 10, + taskType = "MULTIPLE_CHOICE", + question = "Was ist 12 + 15?", + options = listOf("25", "27", "30", "22"), + correctAnswer = "27", + rewardMinutes = 10, + license = "CC BY-SA 4.0", + sourceUrl = "local", + ), + ImportedTask( + id = "t2", + externalSource = "manual", + subject = "Mathematik", + minAge = 7, + maxAge = 12, + taskType = "MULTIPLE_CHOICE", + question = "Was ist 8 x 7?", + options = listOf("54", "56", "64", "48"), + correctAnswer = "56", + rewardMinutes = 15, + license = "CC BY-SA 4.0", + sourceUrl = "local", + ), ) private val screentimes = mutableMapOf( @@ -178,9 +302,54 @@ class InMemoryKidioRepository : KidioRepository { ) private val users = mutableListOf() - override suspend fun getAllTasks(): List = tasks + override suspend fun getAllTasks(): List = getTasks(limit = 100) - override suspend fun getTaskAnswerAndReward(taskId: String): Pair? = answers[taskId] + override suspend fun getTasks( + subject: String?, + age: Int?, + limit: Int, + ): List { + val safeLimit = if (limit <= 0) 10 else limit + return tasks.filter { task -> + val matchSubject = subject.isNullOrBlank() || task.subject.equals(subject.trim(), ignoreCase = true) + val matchAge = age == null || (task.minAge <= age && task.maxAge >= age) + matchSubject && matchAge + }.take(safeLimit).map { + Task( + id = it.id, + subject = it.subject, + minAge = it.minAge, + maxAge = it.maxAge, + question = it.question, + options = it.options, + rewardMinutes = it.rewardMinutes, + license = it.license, + ) + } + } + + override suspend fun getUserById(userId: String): User? { + return users.find { it.id == userId } + } + + // NEU: Implementierung für InMemory (Mock-Daten) + override suspend fun getExistingTaskIds(externalSource: String): Set { + return tasks + .filter { it.externalSource == externalSource } + .map { it.id } + .toSet() + } + + override suspend fun upsertTask(task: ImportedTask): Boolean { + tasks.removeIf { it.id == task.id } + tasks.add(task) + return true + } + + override suspend fun getTaskAnswerAndReward(taskId: String): Pair? { + val task = tasks.find { it.id == taskId } ?: return null + return task.correctAnswer to task.rewardMinutes + } override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800 diff --git a/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt b/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt new file mode 100644 index 0000000..70e2d99 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PIngestionCli.kt @@ -0,0 +1,133 @@ +package com.oliver.kidio.backend.ingestion + +import com.oliver.kidio.backend.ImportedTask +import com.oliver.kidio.backend.data.database.TasksTable +import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository +import com.oliver.kidio.backend.domain.repository.KidioRepository +import kotlinx.coroutines.runBlocking +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.transactions.transaction +import java.io.ByteArrayInputStream + +object H5PIngestionCli { + /** + * Executes batch ingestion pipeline and upserts tasks into the target repository. + */ + fun ingestBatch( + repository: KidioRepository, + client: ZumApiClient = ZumApiClient(), + subject: String? = null, + limit: Int = 50, + ): Int = + runBlocking { + // Bereits vorhandene Task-IDs aus der Datenbank auslesen + val existingIds = repository.getExistingTaskIds("zum.de") + println("Bereits in DB vorhandene ZUM-Aufgaben: ${existingIds.size}") + + var importedCount = 0 + // existingIds an fetchLiveOerItems übergeben, um Duplikate vor dem Download zu filtern + val items = + client.fetchLiveOerItems( + subject = subject, + limit = limit, + existingIds = existingIds, + ) + + for (item in items) { + var parsedList = emptyList() + + // 1. Try parsing JSON content directly (fast, no ZIP decompression needed) + if (item.rawJsonContent != null) { + parsedList = + H5PParser.parseAllTasks( + contentJson = item.rawJsonContent, + library = item.library ?: "H5P.MultiChoice", + taskId = item.id, + title = item.title, + license = item.license, + sourceUrl = item.downloadUrl, + ) + } + + // 2. Fallback to downloading .h5p ZIP archive if remote archive is specified + if (parsedList.isEmpty() && item.downloadUrl.endsWith(".h5p", ignoreCase = true)) { + val h5pBytes = client.downloadH5P(item.downloadUrl) + if (h5pBytes != null) { + val single = H5PParser.parseH5PZip(ByteArrayInputStream(h5pBytes), item.id, item.downloadUrl) + if (single != null) { + parsedList = listOf(single) + } + } + } + + for (parsed in parsedList) { + val (minAge, maxAge) = ZumApiClient.mapGradeToAgeRange(item.grade) + val task = + ImportedTask( + id = parsed.id, + externalSource = "zum.de", + sourceUrl = parsed.sourceUrl ?: item.downloadUrl, + license = parsed.license, + subject = ZumApiClient.mapSubject(item.subject), + minAge = minAge, + maxAge = maxAge, + taskType = if (parsed.options.size == 2 && parsed.options.contains("Wahr")) "TRUE_FALSE" else "MULTIPLE_CHOICE", + question = parsed.question, + options = parsed.options, + correctAnswer = parsed.correctAnswer, + rewardMinutes = 5, + ) + + val success = repository.upsertTask(task) + if (success) { + importedCount++ + val shortQ = task.question.take(30) + val ageRange = "${task.minAge}-${task.maxAge}" + println(" -> [Importiert] ${task.id} (${task.subject}, $ageRange): \"$shortQ...\"") + } + } + } + + println("Ingestion completed: $importedCount tasks successfully imported/updated.") + importedCount + } + + @JvmStatic + fun main(args: Array) { + println("=== Kidio H5P/ZUM Ingestion Tool ===") + val host = System.getenv("DB_HOST") ?: "localhost" + val port = System.getenv("DB_PORT") ?: "5432" + val dbName = System.getenv("DB_NAME") ?: "kidio_db" + val user = System.getenv("DB_USER") ?: "kidio_admin" + val password = System.getenv("DB_PASSWORD") ?: "learning" + + var subject: String? = null + var limit = 50 + + for (arg in args) { + when { + arg.startsWith("--subject=") -> subject = arg.substringAfter("--subject=") + arg.startsWith("--limit=") -> limit = arg.substringAfter("--limit=").toIntOrNull() ?: 50 + } + } + + val jdbcUrl = "jdbc:postgresql://$host:$port/$dbName" + println("Connecting to database: $jdbcUrl") + + val database = + Database.connect( + url = jdbcUrl, + driver = "org.postgresql.Driver", + user = user, + password = password, + ) + + transaction(database) { + SchemaUtils.createMissingTablesAndColumns(TasksTable) + } + + val repository = ExposedKidioRepository() + ingestBatch(repository = repository, subject = subject, limit = limit) + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PParser.kt b/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PParser.kt new file mode 100644 index 0000000..32f1a4a --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/ingestion/H5PParser.kt @@ -0,0 +1,270 @@ +package com.oliver.kidio.backend.ingestion + +import kotlinx.serialization.json.* +import java.io.InputStream +import java.util.zip.ZipInputStream + +data class ParsedH5PTask( + val id: String, + val title: String, + val library: String, + val question: String, + val options: List, + val correctAnswer: String, + val license: String = "CC BY-SA 4.0", + val sourceUrl: String? = null, +) + +object H5PParser { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + /** + * Strips HTML tags and decodes common HTML entities to provide clean text. + */ + fun stripHtml(html: String): String { + var text = html.replace(Regex("(?i)<(br|p|div|li|tr|h[1-6])[^>]*>"), " ") + text = text.replace(Regex("<[^>]*>"), "") + text = + text + .replace(" ", " ") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("×", "×") + .replace("„", "„") + .replace("“", "“") + .replace("ä", "ä") + .replace("Ä", "Ä") + .replace("ö", "ö") + .replace("Ö", "Ö") + .replace("ü", "ü") + .replace("Ü", "Ü") + .replace("ß", "ß") + text = text.replace(Regex("\\s+([?.!,:;])"), "$1") + return text.replace(Regex("\\s+"), " ").trim() + } + + /** + * Parses an .h5p ZIP archive stream. + */ + fun parseH5PZip( + inputStream: InputStream, + taskId: String, + sourceUrl: String? = null, + ): ParsedH5PTask? { + val zip = ZipInputStream(inputStream) + var h5pJsonString: String? = null + var contentJsonString: String? = null + + var entry = zip.nextEntry + while (entry != null) { + val name = entry.name + if (name == "h5p.json" || name.endsWith("/h5p.json")) { + h5pJsonString = zip.readBytes().toString(Charsets.UTF_8) + } else if (name == "content/content.json" || name == "content.json" || name.endsWith("/content.json")) { + contentJsonString = zip.readBytes().toString(Charsets.UTF_8) + } + zip.closeEntry() + entry = zip.nextEntry + } + + if (contentJsonString == null) { + return null + } + + var library = "H5P.MultiChoice" + var title = "H5P Task" + var license = "CC BY-SA 4.0" + + if (h5pJsonString != null) { + try { + val h5pTree = json.parseToJsonElement(h5pJsonString).jsonObject + library = h5pTree["mainLibrary"]?.jsonPrimitive?.contentOrNull ?: library + title = h5pTree["title"]?.jsonPrimitive?.contentOrNull ?: title + license = h5pTree["license"]?.jsonPrimitive?.contentOrNull ?: license + } catch (_: Exception) { + } + } + + return parseContentJson( + contentJson = contentJsonString, + library = library, + taskId = taskId, + title = title, + license = license, + sourceUrl = sourceUrl, + ) + } + + /** + * Parses content.json representation for supported H5P libraries (MultiChoice, TrueFalse). + */ + fun parseContentJson( + contentJson: String, + library: String = "H5P.MultiChoice", + taskId: String = "task-1", + title: String = "H5P Task", + license: String = "CC BY-SA 4.0", + sourceUrl: String? = null, + ): ParsedH5PTask? { + val element = + try { + json.parseToJsonElement(contentJson).jsonObject + } catch (e: Exception) { + return null + } + + if (library.contains("TrueFalse", ignoreCase = true) || (element.containsKey("correct") && !element.containsKey("answers"))) { + return parseTrueFalse(element, taskId, title, library, license, sourceUrl) + } + + return parseMultiChoice(element, taskId, title, library, license, sourceUrl) + } + + /** + * Parses one or multiple tasks from content.json (supports MultiChoice, TrueFalse, and QuestionSet). + */ + fun parseAllTasks( + contentJson: String, + library: String = "H5P.MultiChoice", + taskId: String = "task-1", + title: String = "H5P Task", + license: String = "CC BY-SA 4.0", + sourceUrl: String? = null, + ): List { + val element = + try { + json.parseToJsonElement(contentJson).jsonObject + } catch (e: Exception) { + return emptyList() + } + + if (library.contains("QuestionSet", ignoreCase = true) && element.containsKey("questions")) { + val questionsArray = element["questions"]?.jsonArray ?: return emptyList() + val results = mutableListOf() + + for ((idx, qElement) in questionsArray.withIndex()) { + val qObj = qElement.jsonObject + val subLib = qObj["library"]?.jsonPrimitive?.contentOrNull ?: "H5P.MultiChoice" + val subParams = qObj["params"]?.jsonObject ?: continue + val subId = "$taskId-q${idx + 1}" + + val isTrueFalse = + subLib.contains("TrueFalse", ignoreCase = true) || + (subParams.containsKey("correct") && !subParams.containsKey("answers")) + val isMultiChoice = + subLib.contains("MultiChoice", ignoreCase = true) || subParams.containsKey("answers") + + val parsedSub = + if (isTrueFalse) { + parseTrueFalse(subParams, subId, "$title #${idx + 1}", subLib, license, sourceUrl) + } else if (isMultiChoice) { + parseMultiChoice(subParams, subId, "$title #${idx + 1}", subLib, license, sourceUrl) + } else { + null + } + + if (parsedSub != null) { + results.add(parsedSub) + } + } + return results + } + + val single = parseContentJson(contentJson, library, taskId, title, license, sourceUrl) + return if (single != null) listOf(single) else emptyList() + } + + private fun parseMultiChoice( + element: JsonObject, + taskId: String, + title: String, + library: String, + license: String, + sourceUrl: String?, + ): ParsedH5PTask? { + val questionRaw = + element["question"]?.jsonPrimitive?.contentOrNull + ?: element["text"]?.jsonPrimitive?.contentOrNull + ?: return null + val questionClean = stripHtml(questionRaw) + if (questionClean.isBlank()) return null + + val answersArray = element["answers"]?.jsonArray ?: return null + val options = mutableListOf() + var correctAnswer: String? = null + + for (item in answersArray) { + val itemObj = item.jsonObject + val textRaw = itemObj["text"]?.jsonPrimitive?.contentOrNull ?: continue + val textClean = stripHtml(textRaw) + if (textClean.isNotBlank()) { + options.add(textClean) + val isCorrect = itemObj["correct"]?.jsonPrimitive?.booleanOrNull ?: false + if (isCorrect && correctAnswer == null) { + correctAnswer = textClean + } + } + } + + if (options.isEmpty() || correctAnswer == null) { + return null + } + + return ParsedH5PTask( + id = taskId, + title = title, + library = library, + question = questionClean, + options = options, + correctAnswer = correctAnswer, + license = license, + sourceUrl = sourceUrl, + ) + } + + private fun parseTrueFalse( + element: JsonObject, + taskId: String, + title: String, + library: String, + license: String, + sourceUrl: String?, + ): ParsedH5PTask? { + val questionRaw = + element["question"]?.jsonPrimitive?.contentOrNull + ?: element["text"]?.jsonPrimitive?.contentOrNull + ?: return null + val questionClean = stripHtml(questionRaw) + if (questionClean.isBlank()) return null + + val correctPrimitive = element["correct"]?.jsonPrimitive + val isTrueCorrect = + when { + correctPrimitive?.booleanOrNull != null -> correctPrimitive.boolean + correctPrimitive?.contentOrNull != null -> correctPrimitive.content.equals("true", ignoreCase = true) + else -> true + } + + val options = listOf("Wahr", "Falsch") + val correctAnswer = if (isTrueCorrect) "Wahr" else "Falsch" + + return ParsedH5PTask( + id = taskId, + title = title, + library = library, + question = questionClean, + options = options, + correctAnswer = correctAnswer, + license = license, + sourceUrl = sourceUrl, + ) + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/ingestion/ZumApiClient.kt b/src/main/kotlin/com/oliver/kidio/backend/ingestion/ZumApiClient.kt new file mode 100644 index 0000000..eec9bf1 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/ingestion/ZumApiClient.kt @@ -0,0 +1,514 @@ +package com.oliver.kidio.backend.ingestion + +import kotlinx.serialization.json.* +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +data class ZumOerItem( + val id: String, + val title: String, + val subject: String, + val grade: Int? = null, + val downloadUrl: String, + val license: String = "CC BY-SA 4.0", + val library: String? = null, + val rawJsonContent: String? = null, +) + +class ZumApiClient( + private val httpClient: HttpClient = + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(), +) { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + companion object { + fun mapSubject(raw: String): String { + val lower = raw.trim().lowercase() + return when { + lower.contains("mathe") -> "Mathematik" + lower.contains("deutsch") || lower.contains("german") -> "Deutsch" + lower.contains("sach") || lower.contains("hsu") || lower.contains("natur") || lower.contains("heimat") -> "Sachunterricht" + lower.contains("englisch") || lower.contains("english") -> "Englisch" + lower.contains("bio") -> "Biologie" + lower.contains("geo") || lower.contains("erdkunde") -> "Geographie" + lower.contains("gesch") -> "Geschichte" + lower.contains("chemie") -> "Chemie" + lower.contains("physik") -> "Physik" + lower.contains("musik") -> "Musik" + lower.contains("kunst") -> "Kunst" + lower.contains("informatik") -> "Informatik" + else -> raw.trim().replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + } + } + + fun mapGradeToAgeRange(grade: Int?): Pair { + return when (grade) { + 1 -> 6 to 7 + 2 -> 7 to 8 + 3 -> 8 to 9 + 4 -> 9 to 10 + 5 -> 10 to 11 + 6 -> 11 to 12 + 7 -> 12 to 13 + 8 -> 13 to 14 + 9 -> 14 to 15 + 10 -> 15 to 16 + 11, 12, 13 -> 16 to 18 + else -> 6 to 12 + } + } + } + + /** + * Downloads an .h5p package file from a remote URL. + */ + fun downloadH5P(downloadUrl: String): ByteArray? { + return try { + val request = + HttpRequest.newBuilder() + .uri(URI.create(downloadUrl)) + .timeout(Duration.ofSeconds(15)) + .header("User-Agent", "Mozilla/5.0 (compatible; KidioBot/1.0; +https://kidio.oliver.com)") + .GET() + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()) + if (response.statusCode() == 200) { + response.body() + } else { + null + } + } catch (e: Exception) { + println("WARNUNG: Download fehlgeschlagen für $downloadUrl: ${e.message}") + null + } + } + + /** + * Fetches live OER items from apps.zum.de via search and Drupal settings extraction. + * Skips items whose IDs are already present in existingIds. + * Falls back to curated items if remote catalog is unreachable or returns 0 matching interactive items. + */ + fun fetchLiveOerItems( + subject: String? = null, + limit: Int = 50, + maxPages: Int = 15, + existingIds: Set = emptySet(), + ): List { + val results = mutableListOf() + val seenAppIds = mutableSetOf() + + println("[ZUM-Apps Live] Starte Live-Abfrage auf apps.zum.de (Fach: ${subject ?: "Alle Fächer"}, Limit: $limit)...") + + for (page in 0 until maxPages) { + if (results.size >= limit) break + + val searchUrl = + if (subject.isNullOrBlank()) { + "https://apps.zum.de/apps?page=$page" + } else { + val encoded = URLEncoder.encode(subject, "UTF-8") + "https://apps.zum.de/apps?keys=$encoded&page=$page" + } + + val html = fetchString(searchUrl) + if (html == null) { + println("[ZUM-Apps Live] Warnung: Seite $page konnte nicht geladen werden ($searchUrl)") + continue + } + + // Filtert bereits verarbeitete oder in DB vorhandene Apps heraus + val appIds = + Regex("""href=["']/apps/(\d+)["']""") + .findAll(html) + .map { it.groupValues[1] } + .distinct() + .filter { appId -> + val fullId = "zum-$appId" + !seenAppIds.contains(appId) && !existingIds.contains(fullId) && !existingIds.contains(appId) + } + .toList() + + if (appIds.isEmpty()) { + println("[ZUM-Apps Live] Seite $page enthält keine neuen Aufgaben. Blättere weiter...") + continue + } + + for (appId in appIds) { + if (results.size >= limit) break + seenAppIds.add(appId) + + val item = fetchAppDetails(appId, subject) + if (item != null) { + results.add(item) + val lib = item.library ?: "H5P" + val gr = item.grade ?: "k.A." + println( + "[ZUM-Apps Live] Neue App #$appId: \"${item.title}\" ($lib) [${item.subject}, Kl. $gr]", + ) + } + } + } + + if (results.isEmpty()) { + println("[ZUM-Apps Live] Keine neuen Live-Inhalte gefunden oder Seite nicht erreichbar. Nutze kuratierte OER-Fallback-Daten.") + return getCuratedOerItems(subject = subject, limit = limit) + } + + println("[ZUM-Apps Live] ${results.size} neue interaktive Aufgaben erfolgreich von apps.zum.de geladen.") + return results + } + + private fun fetchAppDetails( + appId: String, + fallbackSubject: String?, + ): ZumOerItem? { + val appUrl = "https://apps.zum.de/apps/$appId" + val html = fetchString(appUrl) ?: return null + + val settingsMatch = Regex("""data-drupal-selector="drupal-settings-json">([^<]+)<""").find(html) ?: return null + val jsonStr = settingsMatch.groupValues[1] + + val drupalSettings = + try { + json.parseToJsonElement(jsonStr).jsonObject + } catch (_: Exception) { + return null + } + + val h5pContents = + drupalSettings["h5p"]?.jsonObject + ?.get("H5PIntegration")?.jsonObject + ?.get("contents")?.jsonObject + ?: return null + + val contentObj = + h5pContents["cid-$appId"]?.jsonObject + ?: h5pContents.values.firstOrNull()?.jsonObject + ?: return null + + val library = contentObj["library"]?.jsonPrimitive?.contentOrNull ?: "" + if (!library.contains("MultiChoice", ignoreCase = true) && + !library.contains("TrueFalse", ignoreCase = true) && + !library.contains("QuestionSet", ignoreCase = true) + ) { + return null + } + + val jsonContent = contentObj["jsonContent"]?.jsonPrimitive?.contentOrNull ?: return null + val metadata = contentObj["metadata"]?.jsonObject + val title = + metadata?.get("title")?.jsonPrimitive?.contentOrNull + ?: Regex("""(.*?)(?:\|.*)?""").find(html)?.groupValues?.get(1)?.trim() + ?: "ZUM App #$appId" + + val rawLicense = metadata?.get("license")?.jsonPrimitive?.contentOrNull + val license = + when { + rawLicense == "U" || rawLicense == null || rawLicense.isBlank() -> "CC BY-SA 4.0" + rawLicense.contains("CC", ignoreCase = true) -> rawLicense + else -> "CC BY-SA 4.0 ($rawLicense)" + } + + val exportUrl = contentObj["exportUrl"]?.jsonPrimitive?.contentOrNull ?: appUrl + + // Breitgefächerter Abruf aller HTML-Linktexte (Kategorien, Schulstufen, Tags) + val tags = + Regex("""]*>([^<]+)""") + .findAll(html) + .map { it.groupValues[1].trim() } + .filter { it.isNotBlank() && it.length < 60 } + .toList() + + val subject = inferSubject(title, tags, fallbackSubject) + val grade = inferGrade(title, html, tags) + + return ZumOerItem( + id = "zum-$appId", + title = title, + subject = subject, + grade = grade, + downloadUrl = exportUrl, + license = license, + library = library, + rawJsonContent = jsonContent, + ) + } + + private fun inferSubject( + title: String, + tags: List, + fallbackSubject: String?, + ): String { + for (tag in tags) { + val mapped = mapSubject(tag) + if (mapped != tag || + tag.equals("Mathematik", ignoreCase = true) || + tag.equals("Deutsch", ignoreCase = true) || + tag.equals("Biologie", ignoreCase = true) || + tag.equals("Englisch", ignoreCase = true) + ) { + return mapped + } + } + if (!fallbackSubject.isNullOrBlank()) { + return mapSubject(fallbackSubject) + } + return mapSubject(title) + } + + private fun inferGrade( + title: String, + html: String, + tags: List, + ): Int? { + val primaryText = (tags + title).joinToString(" ") + + // 1. Muster: "3. Klasse", "3. Schuljahr", "3. Kl." + Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE) + .find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it } + + // 2. Muster: "Klasse 3", "Kl. 3", "Schuljahr 3" + Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE) + .find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it } + + // 3. Muster: "Klasse 3-4", "Klasse 3/4", "3.-4. Klasse" + Regex("""\b(?:Klasse|Kl\.)?\s*(\d+)\s*[-/]\s*(\d+)\b""", RegexOption.IGNORE_CASE) + .find(primaryText)?.groupValues?.get(1)?.toIntOrNull()?.let { return it } + + // 4. Fallback: Gesamten HTML-Text der Seite nach Klassenangaben absuchen + Regex("""\b(\d+)\.\s*(?:Klasse|Schuljahr|Kl)\b""", RegexOption.IGNORE_CASE) + .find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it } + + Regex("""\b(?:Klasse|Schuljahr|Kl\.)\s*(\d+)\b""", RegexOption.IGNORE_CASE) + .find(html)?.groupValues?.get(1)?.toIntOrNull()?.let { return it } + + // 5. Fallback: Schulstufen-Schlüsselwörter + val combinedLower = (primaryText + " " + html).lowercase() + if (combinedLower.contains("primarstufe") || combinedLower.contains("grundschule")) return 3 + val isSek1 = + combinedLower.contains("sekundarstufe i") || + combinedLower.contains("sek i") || + combinedLower.contains("realschule") || + combinedLower.contains("hauptschule") + if (isSek1) return 6 + + val isSek2 = + combinedLower.contains("sekundarstufe ii") || + combinedLower.contains("sek ii") || + combinedLower.contains("gymnasium") || + combinedLower.contains("abitur") + if (isSek2) return 10 + + return null + } + + private fun fetchString(url: String): String? { + return try { + val request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(12)) + .header("User-Agent", "Mozilla/5.0 (compatible; KidioBot/1.0; +https://kidio.oliver.com)") + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "de-DE,de;q=0.9,en;q=0.8") + .GET() + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(Charsets.UTF_8)) + if (response.statusCode() == 200) { + response.body() + } else { + null + } + } catch (e: Exception) { + null + } + } + + /** + * Returns curated ZUM/OER sample metadata items, filterable by subject. + */ + fun getCuratedOerItems( + subject: String? = null, + limit: Int = 20, + ): List { + val allItems = + listOf( + ZumOerItem( + id = "zum-math-001", + title = "Grundrechenarten: Multiplikation 3. Klasse", + subject = "Mathematik", + grade = 3, + downloadUrl = "https://apps.zum.de/api/h5p/math-001.h5p", + license = "CC BY-SA 4.0", + rawJsonContent = + """ + { + "question": "

Was ist das Ergebnis von 6 × 8?

", + "answers": [ + {"text": "42", "correct": false}, + {"text": "48", "correct": true}, + {"text": "54", "correct": false}, + {"text": "56", "correct": false} + ] + } + """.trimIndent(), + ), + ZumOerItem( + id = "zum-math-002", + title = "Zahlenraum bis 100: Addition", + subject = "Mathematik", + grade = 2, + downloadUrl = "https://apps.zum.de/api/h5p/math-002.h5p", + license = "CC BY-SA 4.0", + rawJsonContent = + """ + { + "question": "

Berechne: 34 + 28

", + "answers": [ + {"text": "52", "correct": false}, + {"text": "62", "correct": true}, + {"text": "64", "correct": false} + ] + } + """.trimIndent(), + ), + ZumOerItem( + id = "zum-math-003", + title = "Geometrie: Formen erkennen", + subject = "Mathematik", + grade = 4, + downloadUrl = "https://apps.zum.de/api/h5p/math-003.h5p", + license = "CC BY-SA 4.0", + rawJsonContent = + """ + { + "question": "

Ein Quadrat hat vier gleich lange Seiten und vier rechte Winkel.

", + "correct": true + } + """.trimIndent(), + ), + ZumOerItem( + id = "zum-de-001", + title = "Wortarten: Nomen erkennen", + subject = "Deutsch", + grade = 2, + downloadUrl = "https://apps.zum.de/api/h5p/de-001.h5p", + license = "CC BY-SA 4.0", + rawJsonContent = + """ + { + "question": "

Welches der folgenden Wörter ist ein Nomen (Namenwort)?

", + "answers": [ + {"text": "schnell", "correct": false}, + {"text": "laufen", "correct": false}, + {"text": "Schule", "correct": true}, + {"text": "schön", "correct": false} + ] + } + """.trimIndent(), + ), + ZumOerItem( + id = "zum-de-002", + title = "Rechtschreibung: Doppel-s oder ss", + subject = "Deutsch", + grade = 4, + downloadUrl = "https://apps.zum.de/api/h5p/de-002.h5p", + license = "CC BY-SA 4.0", + rawJsonContent = + """ + { + "question": "

Wie wird das Wort richtig geschrieben? „Der Flu... ist tief.“

", + "answers": [ + {"text": "Flus", "correct": false}, + {"text": "Fluss", "correct": true}, + {"text": "Fluß", "correct": false} + ] + } + """.trimIndent(), + ), + ZumOerItem( + id = "zum-sach-001", + title = "Natur & Tiere: Winterschlaf", + subject = "Sachunterricht", + grade = 3, + downloadUrl = "https://apps.zum.de/api/h5p/sach-001.h5p", + license = "CC BY-SA 4.0", + rawJsonContent = + """ + { + "question": "

Welches dieser Tiere hält einen echten Winterschlaf?

", + "answers": [ + {"text": "Igel", "correct": true}, + {"text": "Eichhörnchen", "correct": false}, + {"text": "Amsel", "correct": false}, + {"text": "Fuchs", "correct": false} + ] + } + """.trimIndent(), + ), + ZumOerItem( + id = "zum-sach-002", + title = "Planetensystem: Sonnennähe", + subject = "Sachunterricht", + grade = 4, + downloadUrl = "https://apps.zum.de/api/h5p/sach-002.h5p", + license = "CC BY-SA 4.0", + rawJsonContent = + """ + { + "question": "

Welcher Planet ist der Sonne am nächsten?

", + "answers": [ + {"text": "Venus", "correct": false}, + {"text": "Mars", "correct": false}, + {"text": "Merkur", "correct": true}, + {"text": "Jupiter", "correct": false} + ] + } + """.trimIndent(), + ), + ZumOerItem( + id = "zum-eng-001", + title = "English Vocabulary: Colors & Animals", + subject = "Englisch", + grade = 4, + downloadUrl = "https://apps.zum.de/api/h5p/eng-001.h5p", + license = "CC BY-SA 4.0", + rawJsonContent = + """ + { + "question": "

What is the English word for „Hund“?

", + "answers": [ + {"text": "Cat", "correct": false}, + {"text": "Dog", "correct": true}, + {"text": "Bird", "correct": false}, + {"text": "Rabbit", "correct": false} + ] + } + """.trimIndent(), + ), + ) + + val normalizedSubject = subject?.let { mapSubject(it) } + val filtered = + if (normalizedSubject.isNullOrBlank()) { + allItems + } else { + allItems.filter { it.subject.equals(normalizedSubject, ignoreCase = true) } + } + + return filtered.take(limit) + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt index 9f9d595..6ea30bd 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt @@ -7,6 +7,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.server.application.* import io.ktor.server.auth.authenticate import io.ktor.server.auth.principal +import io.ktor.server.plugins.openapi.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* @@ -38,6 +39,8 @@ fun Application.configureRouting( jwtService: JwtService, ) { routing { + openAPI(path = "openapi") + post("/api/v1/auth/register") { val registerRequest = call.receive() if (registerRequest.email.isBlank() || registerRequest.passwordPlain.isBlank()) { @@ -119,9 +122,21 @@ fun Application.configureRouting( } authenticate("auth-jwt") { - // 1. Alle verfügbaren Aufgaben abrufen + // 1. Alle verfügbaren Aufgaben abrufen (mit optionaler Filterung nach Fach und Alter) get("/api/v1/tasks") { - val tasks = repository.getAllTasks() + val principal = call.principal() + val userId = principal?.userId + + val subject = call.request.queryParameters["subject"] + + // Expliziter Query-Parameter 'age' ODER Fallback auf das Alter des Users + val queryAge = call.request.queryParameters["age"]?.toIntOrNull() + val userAge = userId?.let { repository.getUserById(it)?.childAge } + val effectiveAge = queryAge ?: userAge + + val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 10 + + val tasks = repository.getTasks(subject = subject, age = effectiveAge, limit = limit) call.respond(tasks) } @@ -174,6 +189,7 @@ fun Application.configureRouting( ) } } + get("/api/v1/screentime") { val principal = call.principal() val userid = diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index dcf0bd7..cf8b828 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,13 +1,13 @@ ktor: deployment: - port: 8080 + port: 8081 application: modules: - MainKt.module storage: driverClassName: "org.postgresql.Driver" - host: "82.165.11.162" + host: "localhost" port: "5432" database: "kidio_db" username: "kidio_admin" diff --git a/src/test/kotlin/H5PParserTest.kt b/src/test/kotlin/H5PParserTest.kt new file mode 100644 index 0000000..1434094 --- /dev/null +++ b/src/test/kotlin/H5PParserTest.kt @@ -0,0 +1,178 @@ +package com.oliver.kidio.backend + +import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository +import com.oliver.kidio.backend.ingestion.H5PIngestionCli +import com.oliver.kidio.backend.ingestion.H5PParser +import com.oliver.kidio.backend.ingestion.ZumApiClient +import kotlinx.coroutines.runBlocking +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class H5PParserTest { + @Test + fun `test HTML stripping and entity decoding`() { + val html = "

Was ist 12 × 4?   „Test“ äöü

" + val clean = H5PParser.stripHtml(html) + assertTrue(clean.contains("Was ist 12")) + assertTrue(clean.contains("äöü")) + } + + @Test + fun `test parse MultiChoice content json`() { + val json = + """ + { + "question": "

Was ist die Hauptstadt von Frankreich?

", + "answers": [ + {"text": "
Berlin
", "correct": false}, + {"text": "
Paris
", "correct": true}, + {"text": "
Rom
", "correct": false} + ] + } + """.trimIndent() + + val parsed = H5PParser.parseContentJson(json, library = "H5P.MultiChoice", taskId = "test-1") + assertNotNull(parsed) + assertEquals("test-1", parsed.id) + assertEquals("Was ist die Hauptstadt von Frankreich?", parsed.question) + assertEquals(3, parsed.options.size) + assertEquals("Paris", parsed.correctAnswer) + } + + @Test + fun `test parse TrueFalse content json`() { + val json = + """ + { + "question": "

Die Erde ist eine Scheibe.

", + "correct": false + } + """.trimIndent() + + val parsed = H5PParser.parseContentJson(json, library = "H5P.TrueFalse", taskId = "tf-1") + assertNotNull(parsed) + assertEquals("tf-1", parsed.id) + assertEquals("Die Erde ist eine Scheibe.", parsed.question) + assertEquals(listOf("Wahr", "Falsch"), parsed.options) + assertEquals("Falsch", parsed.correctAnswer) + } + + @Test + fun `test parse ZIP stream with h5p metadata and content`() { + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { zip -> + // Add h5p.json + val h5pEntry = ZipEntry("h5p.json") + zip.putNextEntry(h5pEntry) + zip.write( + """ + { + "title": "Mathematik Quiz", + "mainLibrary": "H5P.MultiChoice", + "license": "CC BY-SA 4.0" + } + """.trimIndent().toByteArray(Charsets.UTF_8), + ) + zip.closeEntry() + + // Add content/content.json + val contentEntry = ZipEntry("content/content.json") + zip.putNextEntry(contentEntry) + zip.write( + """ + { + "question": "

Wie viele Seiten hat ein Dreieck?

", + "answers": [ + {"text": "3", "correct": true}, + {"text": "4", "correct": false}, + {"text": "5", "correct": false} + ] + } + """.trimIndent().toByteArray(Charsets.UTF_8), + ) + zip.closeEntry() + } + + val parsed = H5PParser.parseH5PZip(ByteArrayInputStream(baos.toByteArray()), "zip-task-1") + assertNotNull(parsed) + assertEquals("zip-task-1", parsed.id) + assertEquals("Mathematik Quiz", parsed.title) + assertEquals("Wie viele Seiten hat ein Dreieck?", parsed.question) + assertEquals("3", parsed.correctAnswer) + assertEquals(listOf("3", "4", "5"), parsed.options) + assertEquals("CC BY-SA 4.0", parsed.license) + } + + @Test + fun `test parse QuestionSet with multiple questions`() { + val json = + """ + { + "questions": [ + { + "library": "H5P.MultiChoice 1.16", + "params": { + "question": "

Wie viel ist 5 + 5?

", + "answers": [ + {"text": "10", "correct": true}, + {"text": "11", "correct": false} + ] + } + }, + { + "library": "H5P.TrueFalse 1.8", + "params": { + "question": "

Wasser kocht bei 100 Grad Celsius.

", + "correct": true + } + } + ] + } + """.trimIndent() + + val parsedList = H5PParser.parseAllTasks(json, library = "H5P.QuestionSet", taskId = "qs-1", title = "Mini Quiz") + assertEquals(2, parsedList.size) + + val q1 = parsedList[0] + assertEquals("qs-1-q1", q1.id) + assertEquals("Wie viel ist 5 + 5?", q1.question) + assertEquals("10", q1.correctAnswer) + + val q2 = parsedList[1] + assertEquals("qs-1-q2", q2.id) + assertEquals("Wasser kocht bei 100 Grad Celsius.", q2.question) + assertEquals("Wahr", q2.correctAnswer) + } + + @Test + fun `test taxonomy and grade mapping`() { + assertEquals("Mathematik", ZumApiClient.mapSubject("mathe")) + assertEquals("Deutsch", ZumApiClient.mapSubject("german language")) + assertEquals("Sachunterricht", ZumApiClient.mapSubject("Sachkunde")) + assertEquals("Englisch", ZumApiClient.mapSubject("english")) + + assertEquals(6 to 7, ZumApiClient.mapGradeToAgeRange(1)) + assertEquals(8 to 9, ZumApiClient.mapGradeToAgeRange(3)) + assertEquals(6 to 12, ZumApiClient.mapGradeToAgeRange(null)) + } + + @Test + fun `test ingestion CLI batch process with in-memory repository`() = + runBlocking { + val repository = InMemoryKidioRepository() + val importedCount = H5PIngestionCli.ingestBatch(repository = repository, limit = 10) + + assertTrue(importedCount > 0) + val mathTasks = repository.getTasks(subject = "Mathematik", age = 8) + assertTrue(mathTasks.isNotEmpty()) + + val deTasks = repository.getTasks(subject = "Deutsch") + assertTrue(deTasks.isNotEmpty()) + } +} diff --git a/src/test/kotlin/ServerTest.kt b/src/test/kotlin/ServerTest.kt index cce6bf3..ceda5b0 100644 --- a/src/test/kotlin/ServerTest.kt +++ b/src/test/kotlin/ServerTest.kt @@ -210,7 +210,29 @@ class ServerTest { assertEquals("t1", tasksJson[0].id) assertEquals("Was ist 12 + 15?", tasksJson[0].question) - // 2. Verify Task correct answer + // 2. Filter nach Fach & Alter + val filteredResponse = + client.get("/api/v1/tasks?subject=Mathematik&age=8") { + header(HttpHeaders.Authorization, "Bearer $token") + } + assertEquals(HttpStatusCode.OK, filteredResponse.status) + val filteredJson = Json.decodeFromString>(filteredResponse.bodyAsText()) + assertEquals(2, filteredJson.size) + + // 3. Filter mit Alter außerhalb des Bereichs + val age15Response = + client.get("/api/v1/tasks?subject=Mathematik&age=15") { + header(HttpHeaders.Authorization, "Bearer $token") + } + assertEquals(HttpStatusCode.OK, age15Response.status) + val age15Json = Json.decodeFromString>(age15Response.bodyAsText()) + assertEquals(0, age15Json.size) + + // 4. Antwort enthält NIEMALS correctAnswer + assertFalse(tasksResponse.bodyAsText().contains("correctAnswer")) + assertFalse(tasksResponse.bodyAsText().contains("correct_answer")) + + // 5. Verify Task correct answer val verifyResponse = client.post("/api/v1/tasks/verify") { header(HttpHeaders.Authorization, "Bearer $token") @@ -222,7 +244,7 @@ class ServerTest { assertTrue(verifyJson.isCorrect) assertEquals(10, verifyJson.earnedMinutes) - // 3. Verify Task incorrect answer + // 6. Verify Task incorrect answer val verifyWrongResponse = client.post("/api/v1/tasks/verify") { header(HttpHeaders.Authorization, "Bearer $token")