Auto-seed dev PocketBase and share its schema with the tests
This commit is contained in:
1 parent
d5372963bb
commit
c3da5eb6e1
6 files changed
+537
-65
No files matched your search
@@ -2,6 +2,8 @@ using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Elternbeirat.PocketBase;
|
||||
|
||||
namespace Elternbeirat.Web.Tests;
|
||||
@@ -16,9 +18,16 @@ namespace Elternbeirat.Web.Tests;
|
||||
/// (only the <c>eb-pocketbase</c> service, not the web app) by shelling out to
|
||||
/// <c>docker compose</c>, so the image version, superuser env and port stay defined
|
||||
/// in one place -- the compose files -- and the tests always exercise the same
|
||||
/// PocketBase the stack runs. The seed data is deliberately fixed here rather than
|
||||
/// exported from the real instance, so tests assert against values this file
|
||||
/// controls. Requires Docker with the Compose plugin.
|
||||
/// PocketBase the stack runs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The collection <b>schema</b> is not typed out here: it is imported from
|
||||
/// <c>pb/pb_migrations/collections_schema.json</c>, the same file the dev seed
|
||||
/// migration uses, so the schema is defined once and cannot drift between the two.
|
||||
/// The seeded <b>records</b>, by contrast, are fixed in this file on purpose (not
|
||||
/// the dev seed's data), so tests assert against values this file controls -- e.g.
|
||||
/// a non-public draft page that must never appear, and an event at a known time.
|
||||
/// Requires Docker with the Compose plugin.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
@@ -145,15 +154,15 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates as superuser, then creates the collections and records. This
|
||||
/// mirrors the shape the client reads, not the full production schema.
|
||||
/// Authenticates as superuser, then imports the shared collection schema and
|
||||
/// seeds the fixed test records the tests assert against.
|
||||
/// </summary>
|
||||
private async Task SeedAsync()
|
||||
{
|
||||
var token = await AuthenticateAsync(_http);
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(token);
|
||||
|
||||
await CreateCollectionsAsync(_http);
|
||||
await ImportCollectionsAsync(_http);
|
||||
await SeedRecordsAsync(_http);
|
||||
|
||||
// Drop the superuser token so the tests read as an anonymous visitor would,
|
||||
@@ -184,59 +193,27 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateCollectionsAsync(HttpClient http)
|
||||
/// <summary>
|
||||
/// Imports the four content collections from the shared schema file, the same
|
||||
/// one the dev seed migration reads (<c>pb/pb_migrations/collections_schema.json</c>).
|
||||
/// Uses PocketBase's <c>/api/collections/import</c> so the whole schema is
|
||||
/// defined in one place instead of being typed out here.
|
||||
/// </summary>
|
||||
private static async Task ImportCollectionsAsync(HttpClient http)
|
||||
{
|
||||
// listRule/viewRule = "" means publicly readable; the client's
|
||||
// filter=public=true does the visibility gating on top.
|
||||
await CreateCollectionAsync(http, "pages", new object[]
|
||||
{
|
||||
new { name = "title", type = "text", required = true },
|
||||
new { name = "body", type = "text" },
|
||||
new { name = "location", type = "select", maxSelect = 1, values = new[] { "header", "footer" } },
|
||||
new { name = "order", type = "number" },
|
||||
new { name = "slug", type = "text", required = true },
|
||||
new { name = "embed", type = "select", maxSelect = 3, values = new[] { "posts", "events", "faqs" } },
|
||||
new { name = "public", type = "bool" },
|
||||
});
|
||||
// The file maps name -> collection object; the import endpoint wants a
|
||||
// flat list, so unwrap the values. deleteMissing=false leaves anything
|
||||
// already present untouched (the import is idempotent).
|
||||
var path = Path.Combine(RepoRoot(), "pb", "pb_migrations", "collections_schema.json");
|
||||
using var schema = JsonDocument.Parse(await File.ReadAllTextAsync(path));
|
||||
var collections = schema.RootElement.EnumerateObject()
|
||||
.Select(property => property.Value)
|
||||
.ToArray();
|
||||
|
||||
await CreateCollectionAsync(http, "posts", new object[]
|
||||
{
|
||||
new { name = "date", type = "date" },
|
||||
new { name = "title", type = "text", required = true },
|
||||
new { name = "body", type = "text" },
|
||||
new { name = "slug", type = "text", required = true },
|
||||
new { name = "public", type = "bool" },
|
||||
});
|
||||
|
||||
await CreateCollectionAsync(http, "events", new object[]
|
||||
{
|
||||
new { name = "start", type = "date", required = true },
|
||||
new { name = "end", type = "date" },
|
||||
new { name = "title", type = "text", required = true },
|
||||
new { name = "location", type = "text" },
|
||||
new { name = "note", type = "text" },
|
||||
new { name = "public", type = "bool" },
|
||||
});
|
||||
|
||||
await CreateCollectionAsync(http, "faqs", new object[]
|
||||
{
|
||||
new { name = "question", type = "text", required = true },
|
||||
new { name = "answer", type = "text" },
|
||||
new { name = "topic", type = "select", maxSelect = 1, values = new[] { "mensa", "schliessfach", "elterneuro", "elternarbeit" } },
|
||||
new { name = "public", type = "bool" },
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task CreateCollectionAsync(HttpClient http, string name, object[] fields)
|
||||
{
|
||||
var response = await http.PostAsJsonAsync("/api/collections", new
|
||||
{
|
||||
name,
|
||||
type = "base",
|
||||
listRule = "",
|
||||
viewRule = "",
|
||||
fields,
|
||||
});
|
||||
// Serialize the JsonElement list back to JSON for the request body.
|
||||
var body = JsonSerializer.Serialize(new { collections, deleteMissing = false });
|
||||
using var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
var response = await http.PutAsync(new Uri("/api/collections/import", UriKind.Relative), content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
@@ -277,7 +254,7 @@ public sealed class PocketBaseFixture : IAsyncLifetime
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
title = "FAQ Mensa", body = "# Mensa", location = "",
|
||||
order = 0, slug = "faq-mensa", embed = Array.Empty<string>(), @public = true,
|
||||
order = 0, slug = "faq-lunch", embed = Array.Empty<string>(), @public = true,
|
||||
});
|
||||
await CreateRecordAsync(http, "pages", new
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class RouteSmokeTests : IDisposable
|
||||
"/board",
|
||||
"/patrons",
|
||||
"/faqs",
|
||||
"/faq-mensa",
|
||||
"/faq-lunch",
|
||||
"/downloads",
|
||||
"/contact",
|
||||
"/imprint",
|
||||
|
||||
@@ -18,6 +18,14 @@ services:
|
||||
# lets Docker pick a random free one; the tests read it back with `compose port`.
|
||||
ports: !override
|
||||
- "8090"
|
||||
# Drop the dev overlay's pb_migrations bind mount. The tests must NOT run the
|
||||
# dev seed -- it would insert the dev data set, which the tests do not assert
|
||||
# against (and which lacks the fixtures' draft page, known event time, etc.).
|
||||
# The fixture creates the collections itself by importing the shared schema
|
||||
# file, then seeds its own fixed records. !override replaces the inherited
|
||||
# volumes list; the throwaway pb_data volume is re-declared so it survives.
|
||||
volumes: !override
|
||||
- pb_data_dev:/pb_data
|
||||
|
||||
eb-web:
|
||||
container_name: !reset null # see eb-pocketbase above (eb-web is not started in tests)
|
||||
+57
-2
@@ -17,8 +17,8 @@ the base for a local run — you layer an overlay on top of it.
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `compose.yaml` | Base, production-shaped: both services, **no** host ports, PocketBase data on the Unraid bind mount. Also holds the fixed project name `eb-stack`. |
|
||||
| `compose.dev.yaml` | Dev overlay: adds host ports (5000, 8090), builds the web image from local source instead of pulling it, swaps the Unraid bind mount for a throwaway volume, and seeds a local PocketBase superuser. |
|
||||
| `compose.test.yaml` | Test overlay: used **only** by the integration tests. Drops the fixed host port and container names so a test run does not collide with a running dev stack. |
|
||||
| `compose.dev.yaml` | Dev overlay: adds host ports (5000, 8090), builds the web image from local source instead of pulling it, swaps the Unraid bind mount for a throwaway volume, seeds a local PocketBase superuser, and mounts `pb/pb_migrations` so a fresh volume is auto-seeded (see below). |
|
||||
| `compose.test.yaml` | Test overlay: used **only** by the integration tests. Drops the fixed host port and container names so a test run does not collide with a running dev stack, and removes the seed mount so the dev seed does not run during tests. |
|
||||
|
||||
> **Project name.** `name: eb-stack` is set once, in the base. The overlays
|
||||
> deliberately don't set their own `name:` — a competing `name:` across `-f`
|
||||
@@ -88,11 +88,60 @@ dev stack — you can have DevEnvironment up in Rider and run `dotnet test` at t
|
||||
same time. The fixture cleans up before and after each run, so a hard-killed
|
||||
earlier run leaves nothing behind.
|
||||
|
||||
The fixture creates the collections by importing the **same** schema file the dev
|
||||
seed uses (see the next section), so the schema is defined once. Its *records*,
|
||||
though, are fixed in `PocketBaseFixture` on purpose — the tests assert against
|
||||
values that file controls (a non-public draft page that must never show, an event
|
||||
at a known time), not against the dev seed's content. The test overlay therefore
|
||||
drops the `pb_migrations` mount so the dev seed does **not** run during tests.
|
||||
|
||||
**Requirements:** Docker with the Compose plugin must be available when the tests
|
||||
run (they call `docker compose`).
|
||||
|
||||
---
|
||||
|
||||
## Example content on a fresh volume (the dev seed)
|
||||
|
||||
A fresh dev volume comes up populated, so a first `docker compose … up` shows a
|
||||
working site instead of an empty one. Two files under `pb/pb_migrations/` do this,
|
||||
and the dev overlay mounts that folder into PocketBase (`./pb/pb_migrations:/pb_migrations`):
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `collections_schema.json` | The four content collections (`pages`, `posts`, `events`, `faqs`) as PocketBase exports them. **The single source of truth for the schema** — the test fixture imports this same file. |
|
||||
| `<timestamp>_dev_seed.js` | The PocketBase migration. Reads the schema file, creates any missing collection, then inserts the example records. |
|
||||
|
||||
Both steps are **idempotent**: a collection is created only if it is missing, and
|
||||
filled only while it is empty. So a populated volume — or the production DB, should
|
||||
this ever run there — is never touched. To start over from an empty, then freshly
|
||||
seeded volume:
|
||||
|
||||
```bash
|
||||
docker volume rm eb-stack_pb_data_dev
|
||||
docker compose -f compose.yaml -f compose.dev.yaml up --build
|
||||
```
|
||||
|
||||
> **Seed only in dev.** The `pb_migrations` mount lives in `compose.dev.yaml`
|
||||
> alone. The base (production) file does not mount it, and the test overlay
|
||||
> removes it again — production content is seeded by hand, never from this repo.
|
||||
|
||||
### Refreshing the seed after a schema or content change
|
||||
|
||||
The seed is generated from a running dev instance, not hand-edited. When you
|
||||
change a collection's schema in the admin UI, or want to capture the current
|
||||
example content as the new seed, re-export from the running dev PocketBase
|
||||
(authenticated as the dev superuser) and regenerate the two files. The schema file
|
||||
is the plain export of the four collections; the migration embeds the records the
|
||||
same way. After regenerating, prove it end to end:
|
||||
|
||||
```bash
|
||||
docker volume rm eb-stack_pb_data_dev # force a fresh volume
|
||||
docker compose -f compose.yaml -f compose.dev.yaml up --build
|
||||
dotnet test # fixture imports the new schema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Content while developing
|
||||
|
||||
The app reads its content from PocketBase over the compose network
|
||||
@@ -126,6 +175,12 @@ It takes three steps, all small because the plumbing is shared:
|
||||
`EventList`, `FaqList`) for the load-in-`OnInitializedAsync`, log-and-degrade
|
||||
pattern, and add a route smoke test plus a fixture seed.
|
||||
|
||||
Also refresh the seed so the new collection comes up on a fresh volume and in the
|
||||
tests: re-export it into `pb/pb_migrations/collections_schema.json` (the shared
|
||||
schema) and add example rows to the dev seed migration — see *Refreshing the seed*
|
||||
above. The test fixture picks the new schema up automatically once it is in the
|
||||
file.
|
||||
|
||||
Text pages need none of this — they are all served generically by
|
||||
`ContentPage` (`@page "/{Slug}"`), which is why a new `pages` record is live
|
||||
without a rebuild.
|
||||
@@ -5,16 +5,20 @@
|
||||
// `docker compose up`. Mounted into the container ONLY by compose.dev.yaml (bind
|
||||
// `./pb/pb_migrations:/pb_migrations`), so it never runs against production.
|
||||
//
|
||||
// The collection SCHEMA is not embedded here -- it is read from
|
||||
// collections_schema.json in this same folder, which is the single source of
|
||||
// truth for the schema. The test fixture (Elternbeirat.Web.Tests) imports that
|
||||
// very same file, so the schema is defined once, not typed twice.
|
||||
//
|
||||
// Two guarded phases, both idempotent:
|
||||
// 1. Schema -- a collection is created only if it does not exist yet.
|
||||
// 2. Records -- a collection is filled only while it is still empty.
|
||||
// So editing content locally, or pointing this at a populated volume, is safe:
|
||||
// existing collections and existing data are left untouched.
|
||||
//
|
||||
// Generated from the local dev database. To refresh the seed, re-export and
|
||||
// regenerate this file rather than hand-editing the schema blocks.
|
||||
migrate((app) => {
|
||||
const schema = {"posts":{"id":"pbc_1125843985","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"posts","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"help":"","hidden":false,"id":"date2862495610","max":"","min":"","name":"date","presentable":false,"required":false,"system":false,"type":"date"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text724990059","max":0,"min":0,"name":"title","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3685223346","max":0,"min":0,"name":"body","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text2560465762","max":0,"min":0,"name":"slug","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 10:38:12.035Z","updated":"2026-09-23 11:25:30.698Z","system":false},"events":{"id":"pbc_1687431684","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"events","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"help":"","hidden":false,"id":"date2675529103","max":"","min":"","name":"start","presentable":false,"required":true,"system":false,"type":"date"},{"help":"","hidden":false,"id":"date16528305","max":"","min":"","name":"end","presentable":false,"required":false,"system":false,"type":"date"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text724990059","max":0,"min":0,"name":"title","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text1587448267","max":0,"min":0,"name":"location","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3485334036","max":0,"min":0,"name":"note","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 10:38:12.110Z","updated":"2026-09-23 10:38:12.110Z","system":false},"pages":{"id":"pbc_3945946014","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"pages","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text724990059","max":0,"min":0,"name":"title","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text3685223346","max":0,"min":0,"name":"body","pattern":"","presentable":false,"primaryKey":false,"required":false,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select1587448267","maxSelect":1,"name":"location","presentable":false,"required":false,"system":false,"type":"select","values":["header","footer"]},{"help":"","hidden":false,"id":"number4113142680","max":null,"min":null,"name":"order","onlyInt":false,"presentable":false,"required":false,"system":false,"type":"number"},{"autogeneratePattern":"","help":"","hidden":false,"id":"text2560465762","max":0,"min":0,"name":"slug","pattern":"","presentable":false,"primaryKey":false,"required":true,"system":false,"type":"text"},{"help":"","hidden":false,"id":"select1203373331","maxSelect":3,"name":"embed","presentable":false,"required":false,"system":false,"type":"select","values":["posts","events","faqs"]},{"help":"","hidden":false,"id":"bool1001664029","name":"public","presentable":false,"required":false,"system":false,"type":"bool"}],"indexes":[],"created":"2026-09-23 11:25:35.648Z","updated":"2026-09-23 11:25:35.648Z","system":false},"faqs":{"id":"pbc_2923026773","listRule":"","viewRule":"","createRule":null,"updateRule":null,"deleteRule":null,"name":"faqs","type":"base","fields":[{"autogeneratePattern":"[a-z0-9]{15}","help":"","hidden":false,"id":"text3208210256","max":15,"min":15,"name":"id","pattern":"^[a-z0-9]+$","presentable":false,"primaryKey":true,"required":true,"system":true,"typeLine truncated
|
||||
// The schema file sits next to this migration. In this image the migrations
|
||||
// dir is mounted at /pb_migrations (see compose.dev.yaml).
|
||||
const schema = JSON.parse(toString($os.readFile("/pb_migrations/collections_schema.json")))
|
||||
|
||||
const records = {
|
||||
pages: [{"body":"# Vorstandsteam\n\nDer Elternbeirat der IGMH vertritt die Interessen aller Eltern gegenüber der Schulleitung und dem Kollegium. Wir treffen uns regelmäßig, bringen Anliegen aus der Elternschaft ein und begleiten das Schulleben aktiv.\n\n## Vorstand\n\n- **Vorsitz** — Anke Berger\n- **Stellvertretung** — Murat Yılmaz\n- **Kasse** — Sabine Krause\n- **Schriftführung** — Thomas Heinrich\n\n## So erreichen Sie uns\n\nAm schnellsten per Mail über unsere [Kontaktseite](/contact). Zu unseren öffentlichen Sitzungen sind alle Eltern herzlich eingeladen — die Termine finden Sie unter [Termine](/events).","embed":[],"location":"header","order":2,"public":true,"slug":"board","title":"Vorstandsteam"},{"body":"# Förderverein\n\nDer Förderverein der IGMH unterstützt Projekte, für die im regulären Schulhaushalt kein Geld vorgesehen ist: von der Ausstattung der Bibliothek über Pausenspielgeräte bis zu Zuschüssen für Klassenfahrten, damit kein Kind aus finanziellen Gründen zurückbleibt.\n\n## Mitglied werden\n\nDer Jahresbeitrag beträgt 24 € — jeder Euro fließt direkt in die Arbeit an der Schule. Den Aufnahmeantrag finden Sie unter [Downloads](/downloads).\n\n## Spenden\n\nSpenden sind jederzeit willkommen und werden auf Wunsch mit einer Zuwendungsbestätigung quittiert. Sprechen Sie uns über die [Kontaktseite](/contact) an.","embed":[],"location":"header","order":3,"public":true,"slug":"patrons","title":"Förderverein"},{"body":"# Häufige Fragen\n\nHier beantworten wir die Fragen, die uns am häufigsten erreichen. Ausführliche Antworten gibt es auf den Unterseiten:\n\n- [Mensa und Mittagessen](/faq-mensa)\n- [Schließfächer](/faq-schliessfach)\n- [Eltern-Euro](/faq-elterneuro)\n- [Elternarbeit und Mitmachen](/faq-elternarbeit)\n\nIhre Frage ist nicht dabei? Schreiben Sie uns über die [Kontaktseite](/contact).","embed":[],"location":"header","order":6,"public":true,"slug":"faqs","title":"FAQ"},{"body":"# Downloads\n\nFormulare und Dokumente zum Herunterladen:\n\n- Aufnahmeantrag Förderverein\n- Einzugsermächtigung\n- Erstattungsformular Klassenkasse\n\nFehlt ein Dokument? Melden Sie sich über die [Kontaktseite](/contact).","embed":[],"location":"header","order":7,"public":true,"slug":"downloads","title":"Downloads"},{"body":"# Kontakt\n\nSie erreichen den Elternbeirat der IGMH per E-Mail:\n\n**elternbeirat@igmh.example**\n\nWir antworten in der Regel innerhalb weniger Tage. Bei dringenden schulischen Angelegenheiten wenden Sie sich bitte direkt an das Sekretariat der Schule.","embed":[],"location":"footer","order":1,"public":true,"slug":"contact","title":"Kontakt"},{"body":"# Impressum\n\nAngaben gemäß § 5 DDG\n\n**Elternbeirat der IGMH**\nVertreten durch den Vorstand\n\nKontakt: siehe [Kontaktseite](/contact)\n\nVerantwortlich für den Inhalt nach § 18 Abs. 2 MStV: der Vorstand des Elternbeirats.","embed":[],"location":"footer","order":2,"public":true,"slug":"imprint","title":"Impressum"},{"body":"# Datenschutz\n\nDer Schutz Ihrer Daten ist uns wichtig. Diese Website kommt ohne externe Dienste aus: keine Analysewerkzeuge, keine eingebundenen Karten oder Videos, keine Schriften von fremden Servern.\n\n## Server-Logs\n\nBeim Aufruf werden technisch notwendige Zugriffsdaten (IP-Adresse, Zeitpunkt, aufgerufene Seite) kurzzeitig verarbeitet, um den Betrieb sicherzustellen. Eine Zusammenführung mit anderen Daten findet nicht statt.\n\n## Ihre Rechte\n\nFragen zum Datenschutz richten Sie bitte an die [Kontaktseite](/contact).","embed":[],"location":"footer","order":3,"public":true,"slug":"privacy","title":"Datenschutz"},{"body":"# Mensa und Mittagessen\n\n## Wie melde ich mein Kind zum Mittagessen an?\n\nDie Anmeldung läuft über das Bestellsystem der Schule. Die Zugangsdaten erhalten Sie zu Schuljahresbeginn über die Klassenleitung.\n\n## Was kostet ein Essen?\n\nEin Mittagessen kostet aktuell 4,20 €. Für Familien mit Anspruch auf Leistungen nach dem Bildungs- und Teilhabepaket ist das Essen kostenfrei — sprechen Sie das Sekretariat an.\n\n## Gibt es vegetarische Gerichte?\n\nJa, täglich steht mindestens ein vegetarisches Gericht zur Wahl.\n\nWeitere Fragen? [Schreiben Sie uns.](/contact)","embed":[],"location":"","order":0,"public":true,"slug":"faq-mensa","title":"Mensa und Mittagessen"},{"body":"# Schließfächer\n\n## Wie bekommt mein Kind ein Schließfach?\n\nSchließfächer werden über einen externen Anbieter gemietet. Das Anmeldeformular liegt bei den [Downloads](/downloads).\n\n## Was kostet die Miete?\n\nDie Miete beträgt rund 15 € pro Schuljahr zuzüglich einer Kaution, die bei Rückgabe erstattet wird.\n\n## Der Schlüssel ist weg — was nun?\n\nWenden Sie sich an den Anbieter; die Kontaktdaten stehen auf dem Mietvertrag. Bei Fragen hilft auch der [Elternbeirat](/contact).","embed":[],"location":"","order":0,"public":true,"slug":"faq-schliessfach","title":"Schließfächer"},{"body":"# Eltern-Euro\n\n## Was ist der Eltern-Euro?\n\nDer ElterLine truncated
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
{
|
||||
"posts": {
|
||||
"id": "pbc_1125843985",
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "posts",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "date2862495610",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "date",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text724990059",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "title",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3685223346",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "body",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text2560465762",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "slug",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool1001664029",
|
||||
"name": "public",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"created": "2026-09-23 10:38:12.035Z",
|
||||
"updated": "2026-09-23 11:25:30.698Z",
|
||||
"system": false
|
||||
},
|
||||
"events": {
|
||||
"id": "pbc_1687431684",
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "events",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "date2675529103",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "start",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "date16528305",
|
||||
"max": "",
|
||||
"min": "",
|
||||
"name": "end",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "date"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text724990059",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "title",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text1587448267",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "location",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3485334036",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "note",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool1001664029",
|
||||
"name": "public",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"created": "2026-09-23 10:38:12.110Z",
|
||||
"updated": "2026-09-23 10:38:12.110Z",
|
||||
"system": false
|
||||
},
|
||||
"pages": {
|
||||
"id": "pbc_3945946014",
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "pages",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text724990059",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "title",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3685223346",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "body",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select1587448267",
|
||||
"maxSelect": 1,
|
||||
"name": "location",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"header",
|
||||
"footer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "number4113142680",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "order",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text2560465762",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "slug",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select1203373331",
|
||||
"maxSelect": 3,
|
||||
"name": "embed",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"posts",
|
||||
"events",
|
||||
"faqs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool1001664029",
|
||||
"name": "public",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"created": "2026-09-23 11:25:35.648Z",
|
||||
"updated": "2026-09-23 11:25:35.648Z",
|
||||
"system": false
|
||||
},
|
||||
"faqs": {
|
||||
"id": "pbc_2923026773",
|
||||
"listRule": "",
|
||||
"viewRule": "",
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "faqs",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3069659470",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "question",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3671935525",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "answer",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "select2638274075",
|
||||
"maxSelect": 1,
|
||||
"name": "topic",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"mensa",
|
||||
"schliessfach",
|
||||
"elterneuro",
|
||||
"elternarbeit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool1001664029",
|
||||
"name": "public",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"created": "2026-09-23 11:59:13.899Z",
|
||||
"updated": "2026-09-23 11:59:13.899Z",
|
||||
"system": false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user