Merge remote-tracking branch 'refs/remotes/origin/main'
This commit is contained in:
+2
-2
@@ -36,8 +36,8 @@ RUN set -eu; \
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
RUN chmod +x /app/provision_instance.sh
|
||||
# Copy application sources into the image (templates, static, main app, config)
|
||||
COPY . /app
|
||||
|
||||
EXPOSE 4999
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
mem_reservation: 192m
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: Invario_Website
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
healthcheck:
|
||||
|
||||
@@ -15,6 +15,12 @@ echo " INSTANCE_TLS_MODE=$INSTANCE_TLS_MODE"
|
||||
echo " INSTANCE_PARENT_DOMAIN=$INSTANCE_PARENT_DOMAIN"
|
||||
echo " DEV_HOSTS_REFRESH_INTERVAL=$DEV_HOSTS_REFRESH_INTERVAL"
|
||||
|
||||
# Ensure required runtime files exist before building
|
||||
if [ ! -f "$SCRIPT_DIR/gunicorn.conf.py" ]; then
|
||||
echo "[FEHLER] gunicorn.conf.py fehlt in $SCRIPT_DIR. Bitte prüfen." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
docker compose build website
|
||||
docker compose up -d
|
||||
|
||||
@@ -24,6 +30,31 @@ if [[ -x "$NGINX_SCRIPT" ]]; then
|
||||
sudo "$NGINX_SCRIPT" apply || echo "Warnung: Nginx konnte nicht automatisch aktualisiert werden."
|
||||
fi
|
||||
|
||||
# Wait for website to become healthy (simple HTTP check)
|
||||
check_url="http://127.0.0.1:4999/"
|
||||
timeout_seconds=60
|
||||
interval=2
|
||||
elapsed=0
|
||||
|
||||
echo "Warte auf Website (${check_url}) bis ${timeout_seconds}s..."
|
||||
while [ $elapsed -lt $timeout_seconds ]; do
|
||||
if curl -sS --max-time 2 "$check_url" >/dev/null 2>&1; then
|
||||
echo "Website erreichbar nach ${elapsed}s"
|
||||
break
|
||||
fi
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $timeout_seconds ]; then
|
||||
echo "[FEHLER] Website nicht erreichbar nach ${timeout_seconds}s. Sammle Diagnosedaten..."
|
||||
echo "--- docker compose ps ---"
|
||||
docker compose ps || true
|
||||
echo "--- docker logs website (tail 200) ---"
|
||||
docker logs --tail 200 website-website-1 || true
|
||||
echo "Bitte prüfe Container-Logs und nginx-Konfiguration."
|
||||
fi
|
||||
|
||||
if [[ -x "$SCRIPT_DIR/sync_dev_hosts.sh" ]]; then
|
||||
"$SCRIPT_DIR/sync_dev_hosts.sh" || true
|
||||
if [[ "${DEV_HOSTS_REFRESH_INTERVAL}" != "0" ]]; then
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
export SESSION_COOKIE_SECURE="1"
|
||||
export INSTANCE_TLS_MODE="production"
|
||||
export INSTANCE_PARENT_DOMAIN="${INSTANCE_PARENT_DOMAIN:-meine-domain}"
|
||||
export INSTANCE_WILDCARD_CERT_FILE="${INSTANCE_WILDCARD_CERT_FILE:-/etc/nginx/certs/wildcard.meine-domain.crt}"
|
||||
export INSTANCE_WILDCARD_KEY_FILE="${INSTANCE_WILDCARD_KEY_FILE:-/etc/nginx/certs/wildcard.meine-domain.key}"
|
||||
|
||||
if [[ ! -f "$INSTANCE_WILDCARD_CERT_FILE" || ! -f "$INSTANCE_WILDCARD_KEY_FILE" ]]; then
|
||||
echo "Error: Wildcard certificate missing for production mode"
|
||||
echo " CERT: $INSTANCE_WILDCARD_CERT_FILE"
|
||||
echo " KEY: $INSTANCE_WILDCARD_KEY_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Launching PRODUCTION stack"
|
||||
echo " SESSION_COOKIE_SECURE=$SESSION_COOKIE_SECURE"
|
||||
echo " INSTANCE_TLS_MODE=$INSTANCE_TLS_MODE"
|
||||
echo " INSTANCE_PARENT_DOMAIN=$INSTANCE_PARENT_DOMAIN"
|
||||
echo " INSTANCE_WILDCARD_CERT_FILE=$INSTANCE_WILDCARD_CERT_FILE"
|
||||
echo " INSTANCE_WILDCARD_KEY_FILE=$INSTANCE_WILDCARD_KEY_FILE"
|
||||
|
||||
docker compose build website
|
||||
docker compose up -d
|
||||
|
||||
NGINX_SCRIPT="$SCRIPT_DIR/../nginx.sh"
|
||||
if [[ -x "$NGINX_SCRIPT" ]]; then
|
||||
echo "Aktualisiere Nginx Reverse Proxy..."
|
||||
sudo "$NGINX_SCRIPT" apply || echo "Warnung: Nginx konnte nicht automatisch aktualisiert werden."
|
||||
fi
|
||||
|
||||
# Automatically sync hosts after container start
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if [[ -x "$SCRIPT_DIR/sync_dev_hosts.sh" ]]; then
|
||||
echo "Warte auf MongoDB-Verfügbarkeit..."
|
||||
sleep 5
|
||||
"$SCRIPT_DIR/sync_dev_hosts.sh" || true
|
||||
echo "Production Hosts werden synchronisiert (stündlich)"
|
||||
|
||||
# Starte Background-Daemon für stündliche Sync
|
||||
if [[ "${PROD_HOSTS_REFRESH_INTERVAL}" != "0" ]]; then
|
||||
nohup bash -lc "while true; do '$SCRIPT_DIR/sync_dev_hosts.sh' >/dev/null 2>&1 || true; sleep ${PROD_HOSTS_REFRESH_INTERVAL:-3600}; done" \
|
||||
>"$SCRIPT_DIR/prod-hosts-sync.log" 2>&1 &
|
||||
echo " Prod-Hosts-Sync läuft im Hintergrund (Log: $SCRIPT_DIR/prod-hosts-sync.log)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Production stack is running on http://localhost:4999"
|
||||
echo "Provisioning expects wildcard certificate for all subdomains."
|
||||
+42
-2
@@ -1615,6 +1615,47 @@ def default():
|
||||
return render_template("main.html")
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health():
|
||||
return jsonify({"status": "ok", "time": _utc_now_iso()}), 200
|
||||
|
||||
|
||||
@app.route('/robots.txt')
|
||||
def robots_txt():
|
||||
path = os.path.join(BASE_DIR, 'static', 'robots.txt')
|
||||
if os.path.exists(path):
|
||||
return send_file(path, mimetype='text/plain')
|
||||
return (
|
||||
"User-agent: *\nDisallow: /admin/\nAllow: /\nSitemap: /sitemap.xml\n",
|
||||
200,
|
||||
{"Content-Type": "text/plain"},
|
||||
)
|
||||
|
||||
|
||||
@app.route('/sitemap.xml')
|
||||
def sitemap_xml():
|
||||
site_root = request.url_root.rstrip('/')
|
||||
sitemap_path = os.path.join(BASE_DIR, 'static', 'sitemap.xml')
|
||||
if os.path.exists(sitemap_path):
|
||||
try:
|
||||
with open(sitemap_path, 'r', encoding='utf-8') as fh:
|
||||
content = fh.read().replace('{{ SITE_ROOT }}', site_root)
|
||||
return app.response_class(content, mimetype='application/xml')
|
||||
except Exception:
|
||||
pass
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
' <url>\n'
|
||||
f' <loc>{site_root}</loc>\n'
|
||||
' <changefreq>daily</changefreq>\n'
|
||||
' <priority>1.0</priority>\n'
|
||||
' </url>\n'
|
||||
'</urlset>'
|
||||
)
|
||||
return app.response_class(xml, mimetype='application/xml')
|
||||
|
||||
|
||||
@app.route('/dienstleistungen')
|
||||
def dienstleistungen():
|
||||
return render_template("dienstleistungen.html")
|
||||
@@ -1785,8 +1826,7 @@ def appointments():
|
||||
def book_option_package():
|
||||
package_raw = _sanitize_text(request.form.get("package") or "", 40).lower()
|
||||
package_map = {
|
||||
"normal": "Normal",
|
||||
"pro": "Pro",
|
||||
"inventarsystem": "Inventarsystem",
|
||||
"buecherei": "Bücherei",
|
||||
}
|
||||
selected_package = package_map.get(package_raw)
|
||||
|
||||
@@ -1,765 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ACTION="create"
|
||||
SCHOOL_NAME=""
|
||||
SUBDOMAIN=""
|
||||
REPO_URL=""
|
||||
BASE_DIR=""
|
||||
PARENT_DOMAIN=""
|
||||
HTTPS_PORT=""
|
||||
HTTP_PORT=""
|
||||
TLS_MODE="development"
|
||||
APP_IMAGE_TAG="latest"
|
||||
LIBRARY_ENABLED="0"
|
||||
WILDCARD_CERT_FILE="/etc/nginx/certs/wildcard.meine-domain.crt"
|
||||
WILDCARD_KEY_FILE="/etc/nginx/certs/wildcard.meine-domain.key"
|
||||
NGINX_SITES_AVAILABLE="/etc/nginx/sites-available"
|
||||
NGINX_SITES_ENABLED="/etc/nginx/sites-enabled"
|
||||
|
||||
RELEASE_BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
|
||||
RELEASE_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
||||
|
||||
print_kv() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
printf '%s=%s\n' "$key" "$value"
|
||||
}
|
||||
|
||||
fail() {
|
||||
local message="$1"
|
||||
print_kv "MESSAGE" "$message"
|
||||
print_kv "ERROR" "$message"
|
||||
exit 1
|
||||
}
|
||||
|
||||
slugify() {
|
||||
local input="$1"
|
||||
input="${input,,}"
|
||||
input="$(echo "$input" | sed -E 's/ä/ae/g; s/ö/oe/g; s/ü/ue/g; s/ß/ss/g')"
|
||||
input="$(echo "$input" | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-+//; s/-+$//')"
|
||||
printf '%s' "${input:0:63}"
|
||||
}
|
||||
|
||||
is_valid_subdomain() {
|
||||
local value="$1"
|
||||
[[ "$value" =~ ^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])$ ]]
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "Fehlender Befehl: $cmd"
|
||||
}
|
||||
|
||||
is_port_in_use() {
|
||||
local port="$1"
|
||||
|
||||
# Running inside container namespaces cannot reliably see host listeners,
|
||||
# but docker CLI can inspect host-published ports via the socket.
|
||||
if docker ps --format '{{.Ports}}' | grep -Eq "(^|[,[:space:]])((0\.0\.0\.0|\[::\]):)?${port}->"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltn "( sport = :$port )" 2>/dev/null | awk 'NR>1 {print $4}' | grep -q . && return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
next_free_port() {
|
||||
local start_port="$1"
|
||||
local current="$start_port"
|
||||
while is_port_in_use "$current"; do
|
||||
current=$((current + 1))
|
||||
done
|
||||
printf '%s' "$current"
|
||||
}
|
||||
|
||||
write_env_file() {
|
||||
local target_dir="$1"
|
||||
local http_port="$2"
|
||||
local https_port="$3"
|
||||
local app_image_tag="$4"
|
||||
|
||||
cat > "$target_dir/.docker-build.env" <<EOF
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=$http_port
|
||||
INVENTAR_HTTPS_PORT=$https_port
|
||||
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag
|
||||
INVENTAR_MULTITENANT_ENABLED=true
|
||||
INVENTAR_SESSION_BACKEND=redis
|
||||
INVENTAR_REDIS_HOST=redis
|
||||
INVENTAR_REDIS_PORT=6379
|
||||
INVENTAR_QUERY_CACHE_ENABLED=true
|
||||
EOF
|
||||
}
|
||||
|
||||
preferred_compose_file() {
|
||||
if [ -f "docker-compose-multitenant.yml" ]; then
|
||||
printf '%s' "docker-compose-multitenant.yml"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s' "docker-compose.yml"
|
||||
}
|
||||
|
||||
repo_slug_from_url() {
|
||||
local repo_url="$1"
|
||||
local normalized=""
|
||||
|
||||
normalized="$repo_url"
|
||||
normalized="${normalized#https://github.com/}"
|
||||
normalized="${normalized#http://github.com/}"
|
||||
normalized="${normalized#git@github.com:}"
|
||||
normalized="${normalized%.git}"
|
||||
|
||||
if [[ "$normalized" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
|
||||
printf '%s' "$normalized"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
fetch_release_metadata() {
|
||||
local repo_slug="$1"
|
||||
local requested_tag="$2"
|
||||
local out_file="$3"
|
||||
local api_url=""
|
||||
|
||||
if [ "$requested_tag" = "latest" ]; then
|
||||
api_url="https://api.github.com/repos/$repo_slug/releases/latest"
|
||||
else
|
||||
api_url="https://api.github.com/repos/$repo_slug/releases/tags/$requested_tag"
|
||||
fi
|
||||
|
||||
if ! curl -fsSL "$api_url" -o "$out_file"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
extract_release_info() {
|
||||
local meta_file="$1"
|
||||
|
||||
python3 - <<'PY' "$meta_file" "$RELEASE_BUNDLE_ASSET" "$RELEASE_IMAGE_ASSET_PREFIX"
|
||||
import json
|
||||
import sys
|
||||
|
||||
meta_file, bundle_asset, image_prefix = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
|
||||
with open(meta_file, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
|
||||
tag = (data.get("tag_name") or "").strip()
|
||||
bundle_url = ""
|
||||
image_url = ""
|
||||
|
||||
for asset in data.get("assets", []):
|
||||
name = (asset.get("name") or "").strip()
|
||||
url = (asset.get("browser_download_url") or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
if name == bundle_asset:
|
||||
bundle_url = url
|
||||
if tag and name == f"{image_prefix}{tag}.tar.gz":
|
||||
image_url = url
|
||||
|
||||
print(tag)
|
||||
print(bundle_url)
|
||||
print(image_url)
|
||||
PY
|
||||
}
|
||||
|
||||
pin_compose_app_image() {
|
||||
local instance_dir="$1"
|
||||
local resolved_tag="$2"
|
||||
local compose_file=""
|
||||
|
||||
for compose_file in "$instance_dir/docker-compose.yml" "$instance_dir/docker-compose-multitenant.yml"; do
|
||||
[ -f "$compose_file" ] || continue
|
||||
|
||||
python3 - <<'PY' "$compose_file" "$resolved_tag"
|
||||
import re
|
||||
import sys
|
||||
|
||||
compose_file, tag = sys.argv[1], sys.argv[2]
|
||||
target_image = f"ghcr.io/aiirondev/legendary-octo-garbanzo:{tag}"
|
||||
|
||||
with open(compose_file, "r", encoding="utf-8") as fh:
|
||||
lines = fh.readlines()
|
||||
|
||||
out = []
|
||||
in_app = False
|
||||
in_build = False
|
||||
image_set = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.lstrip(" ")
|
||||
indent = len(line) - len(stripped)
|
||||
|
||||
if not in_app and re.match(r"^\s{2}app:\s*$", line):
|
||||
in_app = True
|
||||
image_set = True
|
||||
out.append(line)
|
||||
out.append(f" image: {target_image}\n")
|
||||
continue
|
||||
|
||||
if in_app:
|
||||
if indent == 2 and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped):
|
||||
in_app = False
|
||||
in_build = False
|
||||
|
||||
if in_app:
|
||||
if in_build:
|
||||
if indent > 4:
|
||||
continue
|
||||
in_build = False
|
||||
|
||||
if re.match(r"^\s{4}build:\s*$", line):
|
||||
in_build = True
|
||||
continue
|
||||
|
||||
if re.match(r"^\s{4}image:\s*", line):
|
||||
if image_set:
|
||||
continue
|
||||
out.append(f" image: {target_image}\n")
|
||||
image_set = True
|
||||
continue
|
||||
|
||||
out.append(line)
|
||||
|
||||
with open(compose_file, "w", encoding="utf-8") as fh:
|
||||
fh.writelines(out)
|
||||
PY
|
||||
done
|
||||
}
|
||||
|
||||
install_from_release() {
|
||||
local target_dir="$1"
|
||||
local repo_url="$2"
|
||||
local requested_tag="$3"
|
||||
local tmp_dir=""
|
||||
local repo_slug=""
|
||||
local meta_file=""
|
||||
local bundle_path=""
|
||||
local image_path=""
|
||||
local tag=""
|
||||
local bundle_url=""
|
||||
local image_url=""
|
||||
|
||||
repo_slug="$(repo_slug_from_url "$repo_url")" || fail "Repository-URL nicht unterstützbar für Release-Install: $repo_url"
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
|
||||
meta_file="$tmp_dir/release.json"
|
||||
|
||||
if ! fetch_release_metadata "$repo_slug" "$requested_tag" "$meta_file"; then
|
||||
fail "Release-Metadaten konnten nicht geladen werden (Repo: $repo_slug, Tag: $requested_tag)."
|
||||
fi
|
||||
|
||||
mapfile -t release_info < <(extract_release_info "$meta_file")
|
||||
tag="${release_info[0]:-}"
|
||||
bundle_url="${release_info[1]:-}"
|
||||
image_url="${release_info[2]:-}"
|
||||
|
||||
[ -n "$tag" ] || fail "Release-Metadaten enthalten keinen gültigen Tag."
|
||||
[ -n "$bundle_url" ] || fail "Release-Asset fehlt: $RELEASE_BUNDLE_ASSET"
|
||||
[ -n "$image_url" ] || fail "Release-Image-Asset fehlt: ${RELEASE_IMAGE_ASSET_PREFIX}${tag}.tar.gz"
|
||||
|
||||
bundle_path="$tmp_dir/$RELEASE_BUNDLE_ASSET"
|
||||
image_path="$tmp_dir/${RELEASE_IMAGE_ASSET_PREFIX}${tag}.tar.gz"
|
||||
|
||||
curl -fsSL "$bundle_url" -o "$bundle_path" || fail "Release-Bundle konnte nicht geladen werden."
|
||||
curl -fsSL "$image_url" -o "$image_path" || fail "Release-Image konnte nicht geladen werden."
|
||||
|
||||
mkdir -p "$target_dir"
|
||||
tar -xzf "$bundle_path" -C "$target_dir" || fail "Release-Bundle konnte nicht entpackt werden."
|
||||
docker load -i "$image_path" >/dev/null 2>&1 || fail "Docker-Image konnte nicht geladen werden."
|
||||
docker tag "ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" "ghcr.io/aiirondev/legendary-octo-garbanzo:latest" >/dev/null 2>&1 || true
|
||||
|
||||
pin_compose_app_image "$target_dir" "$tag"
|
||||
rm -rf "$tmp_dir" >/dev/null 2>&1 || true
|
||||
print_kv "APP_IMAGE_TAG" "$tag"
|
||||
}
|
||||
|
||||
ensure_requested_app_image() {
|
||||
local app_image_tag="$1"
|
||||
local app_image="ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag"
|
||||
|
||||
if docker image inspect "$app_image" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! docker pull "$app_image" >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
set_library_enabled() {
|
||||
local instance_dir="$1"
|
||||
local enabled="$2"
|
||||
local cfg="$instance_dir/config.json"
|
||||
[ -f "$cfg" ] || return 0
|
||||
|
||||
python3 - <<'PY' "$cfg" "$enabled"
|
||||
import json
|
||||
import sys
|
||||
|
||||
cfg_path = sys.argv[1]
|
||||
enabled = sys.argv[2] == "1"
|
||||
|
||||
with open(cfg_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
|
||||
modules = data.get("modules")
|
||||
if not isinstance(modules, dict):
|
||||
modules = {}
|
||||
data["modules"] = modules
|
||||
|
||||
library = modules.get("library")
|
||||
if not isinstance(library, dict):
|
||||
library = {}
|
||||
modules["library"] = library
|
||||
|
||||
library["enabled"] = enabled
|
||||
|
||||
with open(cfg_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
PY
|
||||
}
|
||||
|
||||
normalize_instance_compose() {
|
||||
local compose_file=""
|
||||
|
||||
for compose_file in "docker-compose.yml" "docker-compose-multitenant.yml"; do
|
||||
[ -f "$compose_file" ] || continue
|
||||
|
||||
python3 - <<'PY' "$compose_file"
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
content = path.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
|
||||
out = []
|
||||
in_nginx = False
|
||||
in_ports = False
|
||||
ports_indent = ""
|
||||
in_app = False
|
||||
seen_app_image = False
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
indent = len(line) - len(line.lstrip(" "))
|
||||
|
||||
if stripped.startswith("nginx:") and indent == 2:
|
||||
in_nginx = True
|
||||
in_ports = False
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if indent == 2 and stripped.endswith(":"):
|
||||
in_app = stripped.startswith("app:")
|
||||
seen_app_image = False
|
||||
|
||||
if in_nginx and indent == 2 and stripped.endswith(":") and not stripped.startswith("nginx:"):
|
||||
in_nginx = False
|
||||
in_ports = False
|
||||
|
||||
if stripped.startswith("container_name:") and indent == 4:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if in_app and indent == 4 and stripped.startswith("image:"):
|
||||
if seen_app_image:
|
||||
i += 1
|
||||
continue
|
||||
seen_app_image = True
|
||||
|
||||
if in_nginx and stripped.startswith("ports:") and indent == 4:
|
||||
out.append(line)
|
||||
out.append(" - \"${INVENTAR_HTTP_PORT:-80}:80\"")
|
||||
out.append(" - \"${INVENTAR_HTTPS_PORT:-443}:443\"")
|
||||
i += 1
|
||||
in_ports = True
|
||||
ports_indent = " "
|
||||
while i < len(lines):
|
||||
nxt = lines[i]
|
||||
nxt_stripped = nxt.strip()
|
||||
nxt_indent = len(nxt) - len(nxt.lstrip(" "))
|
||||
if nxt_stripped.startswith("-") and nxt_indent >= len(ports_indent):
|
||||
i += 1
|
||||
continue
|
||||
break
|
||||
continue
|
||||
|
||||
out.append(line)
|
||||
i += 1
|
||||
|
||||
normalized = "\n".join(out)
|
||||
if content.endswith("\n"):
|
||||
normalized += "\n"
|
||||
|
||||
if normalized != content:
|
||||
path.write_text(normalized, encoding="utf-8")
|
||||
PY
|
||||
done
|
||||
}
|
||||
|
||||
run_instance_start() {
|
||||
local start_output
|
||||
local retry_output
|
||||
local update_output
|
||||
local compose_file
|
||||
local has_redis=0
|
||||
|
||||
compose_file="$(preferred_compose_file)"
|
||||
if [ -f "$compose_file" ] && grep -Eq '^\s{2}redis:\s*$' "$compose_file"; then
|
||||
has_redis=1
|
||||
fi
|
||||
|
||||
stack_is_running() {
|
||||
local running_services
|
||||
running_services="$(docker compose -f "$compose_file" --env-file .docker-build.env ps --status running --services 2>/dev/null || true)"
|
||||
printf '%s\n' "$running_services" | grep -Fxq app || return 1
|
||||
printf '%s\n' "$running_services" | grep -Fxq nginx || return 1
|
||||
printf '%s\n' "$running_services" | grep -Fxq mongodb || return 1
|
||||
if [ "$has_redis" = "1" ]; then
|
||||
printf '%s\n' "$running_services" | grep -Fxq redis || return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ "$compose_file" = "docker-compose-multitenant.yml" ]; then
|
||||
if start_output="$(docker compose -f "$compose_file" --env-file .docker-build.env up -d --remove-orphans 2>&1)"; then
|
||||
print_kv "MESSAGE" "Instanz gestartet (Inventarsystem Multiinstancing aktiv)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if stack_is_running; then
|
||||
print_kv "MESSAGE" "Instanz gestartet (Multiinstancing läuft, Healthcheck im Startskript war nicht erreichbar)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if printf '%s' "$start_output" | grep -qi "local app image not found"; then
|
||||
if [ ! -x ./update.sh ]; then
|
||||
fail "Multiinstancing-Start fehlgeschlagen und update.sh fehlt. Letzte Meldung: $(printf '%s' "$start_output" | tail -n1)"
|
||||
fi
|
||||
|
||||
if ! update_output="$(bash ./update.sh 2>&1)"; then
|
||||
if stack_is_running; then
|
||||
print_kv "MESSAGE" "Instanz gestartet (update.sh meldete Healthcheck-Fehler, Multiinstancing-Dienste laufen)."
|
||||
return 0
|
||||
fi
|
||||
fail "update.sh fehlgeschlagen: $(printf '%s' "$update_output" | tail -n1)"
|
||||
fi
|
||||
|
||||
if retry_output="$(docker compose -f "$compose_file" --env-file .docker-build.env up -d --remove-orphans 2>&1)"; then
|
||||
print_kv "MESSAGE" "Instanz gestartet (Multiinstancing-Image automatisch per update.sh geladen)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
fail "Multiinstancing-Start nach update.sh fehlgeschlagen: $(printf '%s' "$retry_output" | tail -n1)"
|
||||
fi
|
||||
|
||||
fail "Multiinstancing-Start fehlgeschlagen: $(printf '%s' "$start_output" | tail -n1)"
|
||||
fi
|
||||
|
||||
if start_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if stack_is_running; then
|
||||
print_kv "MESSAGE" "Instanz gestartet (Healthcheck im Startskript war nicht erreichbar, Dienste laufen)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if printf '%s' "$start_output" | grep -qi "local app image not found"; then
|
||||
if [ ! -x ./update.sh ]; then
|
||||
fail "start.sh fehlgeschlagen und update.sh fehlt. Letzte Meldung: $(printf '%s' "$start_output" | tail -n1)"
|
||||
fi
|
||||
|
||||
if ! update_output="$(bash ./update.sh 2>&1)"; then
|
||||
if stack_is_running; then
|
||||
print_kv "MESSAGE" "Instanz gestartet (update.sh meldete Healthcheck-Fehler, Dienste laufen)."
|
||||
return 0
|
||||
fi
|
||||
fail "update.sh fehlgeschlagen: $(printf '%s' "$update_output" | tail -n1)"
|
||||
fi
|
||||
|
||||
if retry_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then
|
||||
print_kv "MESSAGE" "Instanz gestartet (Image automatisch per update.sh geladen)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
fail "Start nach update.sh fehlgeschlagen: $(printf '%s' "$retry_output" | tail -n1)"
|
||||
fi
|
||||
|
||||
fail "start.sh fehlgeschlagen: $(printf '%s' "$start_output" | tail -n1)"
|
||||
}
|
||||
|
||||
setup_or_update_repo() {
|
||||
local target_dir="$1"
|
||||
local repo_url="$2"
|
||||
local app_image_tag="$3"
|
||||
|
||||
if [ -f "$target_dir/start.sh" ]; then
|
||||
# Existing installation: keep update flow inside Inventarsystem tooling.
|
||||
if [ "$app_image_tag" = "latest" ] && [ -x "$target_dir/update.sh" ]; then
|
||||
(cd "$target_dir" && bash ./update.sh >/dev/null 2>&1 || true)
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -d "$target_dir" ] && [ "$(find "$target_dir" -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then
|
||||
fail "Zielverzeichnis ist nicht leer: $target_dir"
|
||||
fi
|
||||
|
||||
install_from_release "$target_dir" "$repo_url" "$app_image_tag"
|
||||
}
|
||||
|
||||
write_nginx_site() {
|
||||
local subdomain="$1"
|
||||
local full_domain="$2"
|
||||
local https_port="$3"
|
||||
local cert_file=""
|
||||
local key_file=""
|
||||
local cert_dir="/etc/nginx/certs"
|
||||
local site_name="inventarsystem-${subdomain}.conf"
|
||||
local avail_file="$NGINX_SITES_AVAILABLE/$site_name"
|
||||
local enabled_file="$NGINX_SITES_ENABLED/$site_name"
|
||||
|
||||
if [ ! -d "$NGINX_SITES_AVAILABLE" ] || [ ! -d "$NGINX_SITES_ENABLED" ]; then
|
||||
print_kv "NGINX_STATUS" "skipped"
|
||||
print_kv "MESSAGE" "Instanz gestartet. Nginx-Verzeichnisse nicht gefunden, Reverse-Proxy manuell anlegen."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -w "$NGINX_SITES_AVAILABLE" ] || [ ! -w "$NGINX_SITES_ENABLED" ]; then
|
||||
print_kv "NGINX_STATUS" "manual_required"
|
||||
print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf Nginx-Konfiguration, bitte als root/sudo ausführen."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$TLS_MODE" = "production" ]; then
|
||||
cert_file="$WILDCARD_CERT_FILE"
|
||||
key_file="$WILDCARD_KEY_FILE"
|
||||
|
||||
if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then
|
||||
print_kv "NGINX_STATUS" "error"
|
||||
print_kv "MESSAGE" "Produktivmodus aktiv, aber Wildcard-Zertifikat fehlt: $cert_file / $key_file"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
cert_file="$cert_dir/$full_domain.crt"
|
||||
key_file="$cert_dir/$full_domain.key"
|
||||
|
||||
if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
print_kv "NGINX_STATUS" "manual_required"
|
||||
print_kv "MESSAGE" "Instanz gestartet. openssl fehlt für Development-Zertifikat, Zertifikat bitte manuell anlegen."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$cert_dir" ] || [ ! -w "$cert_dir" ]; then
|
||||
print_kv "NGINX_STATUS" "manual_required"
|
||||
print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf $cert_dir für Development-Zertifikat."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
|
||||
-days 825 \
|
||||
-keyout "$key_file" \
|
||||
-out "$cert_file" \
|
||||
-subj "/CN=$full_domain" \
|
||||
-addext "subjectAltName=DNS:$full_domain" >/dev/null 2>&1; then
|
||||
openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
|
||||
-days 825 \
|
||||
-keyout "$key_file" \
|
||||
-out "$cert_file" \
|
||||
-subj "/CN=$full_domain" >/dev/null 2>&1 || {
|
||||
print_kv "NGINX_STATUS" "manual_required"
|
||||
print_kv "MESSAGE" "Instanz gestartet. Development-Zertifikat konnte nicht erstellt werden."
|
||||
return 0
|
||||
}
|
||||
fi
|
||||
|
||||
chmod 600 "$key_file" 2>/dev/null || true
|
||||
chmod 644 "$cert_file" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > "$avail_file" <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name $full_domain;
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name $full_domain;
|
||||
|
||||
ssl_certificate $cert_file;
|
||||
ssl_certificate_key $key_file;
|
||||
|
||||
location / {
|
||||
proxy_pass https://127.0.0.1:$https_port;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_ssl_verify off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
ln -sfn "$avail_file" "$enabled_file"
|
||||
|
||||
if ! command -v nginx >/dev/null 2>&1; then
|
||||
print_kv "NGINX_STATUS" "manual_required"
|
||||
print_kv "MESSAGE" "Instanz gestartet. Nginx-Site wurde geschrieben, Reload bitte auf dem Host ausführen."
|
||||
return 0
|
||||
fi
|
||||
|
||||
if nginx -t >/dev/null 2>&1; then
|
||||
nginx -s reload >/dev/null 2>&1 || true
|
||||
print_kv "NGINX_STATUS" "ok"
|
||||
else
|
||||
print_kv "NGINX_STATUS" "error"
|
||||
print_kv "MESSAGE" "Instanz gestartet, aber nginx -t ist fehlgeschlagen."
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--action)
|
||||
ACTION="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--school-name)
|
||||
SCHOOL_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--subdomain)
|
||||
SUBDOMAIN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--repo)
|
||||
REPO_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--base-dir)
|
||||
BASE_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--domain)
|
||||
PARENT_DOMAIN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--https-port)
|
||||
HTTPS_PORT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--http-port)
|
||||
HTTP_PORT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--tls-mode)
|
||||
TLS_MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--wildcard-cert-file)
|
||||
WILDCARD_CERT_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--wildcard-key-file)
|
||||
WILDCARD_KEY_FILE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--app-image-tag)
|
||||
APP_IMAGE_TAG="${2:-latest}"
|
||||
shift 2
|
||||
;;
|
||||
--library-enabled)
|
||||
LIBRARY_ENABLED="${2:-0}"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
fail "Unbekannter Parameter: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$REPO_URL" ] || fail "Repository-URL fehlt"
|
||||
[ -n "$BASE_DIR" ] || fail "Basisverzeichnis fehlt"
|
||||
[ -n "$PARENT_DOMAIN" ] || fail "Parent-Domain fehlt"
|
||||
|
||||
if [ -z "$SUBDOMAIN" ]; then
|
||||
SUBDOMAIN="$(slugify "$SCHOOL_NAME")"
|
||||
else
|
||||
SUBDOMAIN="$(slugify "$SUBDOMAIN")"
|
||||
fi
|
||||
|
||||
is_valid_subdomain "$SUBDOMAIN" || fail "Ungueltige Subdomain: $SUBDOMAIN"
|
||||
|
||||
INSTANCE_DIR="$BASE_DIR/$SUBDOMAIN"
|
||||
FULL_DOMAIN="$SUBDOMAIN.$PARENT_DOMAIN"
|
||||
|
||||
require_cmd docker
|
||||
require_cmd bash
|
||||
require_cmd curl
|
||||
require_cmd tar
|
||||
require_cmd python3
|
||||
|
||||
mkdir -p "$BASE_DIR"
|
||||
|
||||
if [ -z "$HTTPS_PORT" ]; then
|
||||
HTTPS_PORT="$(next_free_port 15443)"
|
||||
fi
|
||||
|
||||
if [ -z "$HTTP_PORT" ]; then
|
||||
HTTP_PORT="$(next_free_port 15080)"
|
||||
fi
|
||||
|
||||
if [ "$ACTION" = "create" ] || [ "$ACTION" = "start" ]; then
|
||||
setup_or_update_repo "$INSTANCE_DIR" "$REPO_URL" "$APP_IMAGE_TAG"
|
||||
|
||||
[ -f "$INSTANCE_DIR/start.sh" ] || fail "start.sh im Zielrepository nicht gefunden: $INSTANCE_DIR"
|
||||
|
||||
if ! ensure_requested_app_image "$APP_IMAGE_TAG"; then
|
||||
fail "App-Image konnte nicht geladen werden: ghcr.io/aiirondev/legendary-octo-garbanzo:$APP_IMAGE_TAG"
|
||||
fi
|
||||
|
||||
write_env_file "$INSTANCE_DIR" "$HTTP_PORT" "$HTTPS_PORT" "$APP_IMAGE_TAG"
|
||||
set_library_enabled "$INSTANCE_DIR" "$LIBRARY_ENABLED"
|
||||
|
||||
cd "$INSTANCE_DIR"
|
||||
normalize_instance_compose
|
||||
run_instance_start
|
||||
|
||||
write_nginx_site "$SUBDOMAIN" "$FULL_DOMAIN" "$HTTPS_PORT"
|
||||
|
||||
print_kv "SUBDOMAIN" "$SUBDOMAIN"
|
||||
print_kv "DOMAIN" "$FULL_DOMAIN"
|
||||
print_kv "HTTPS_PORT" "$HTTPS_PORT"
|
||||
print_kv "HTTP_PORT" "$HTTP_PORT"
|
||||
print_kv "APP_IMAGE_TAG" "$APP_IMAGE_TAG"
|
||||
print_kv "LIBRARY_ENABLED" "$LIBRARY_ENABLED"
|
||||
print_kv "INSTANCE_DIR" "$INSTANCE_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fail "Nicht unterstuetzte Action: $ACTION"
|
||||
@@ -5,16 +5,18 @@ import requests
|
||||
_var = "/opt/legendary-octo-garbanzo"
|
||||
_cmd = "run-tenant-cmd.sh"
|
||||
|
||||
def add_dns(name: str) -> bool:
|
||||
def add_dns(name: str, port: int) -> bool:
|
||||
"""
|
||||
Adds the subdomain to the DNS Routes
|
||||
|
||||
Input:
|
||||
- name (Name of the subdomain) -> String
|
||||
- port (Port number for the subdomain) -> int
|
||||
|
||||
Output:
|
||||
- bool (True if adding of the Subdomain is active)
|
||||
"""
|
||||
# This is a placeholder function, as the actual implementation would depend on the DNS provider and how the DNS records are managed.
|
||||
pass
|
||||
|
||||
def clear_special(var_:str) -> str:
|
||||
@@ -45,7 +47,7 @@ def clear_special(var_:str) -> str:
|
||||
except:
|
||||
return False
|
||||
|
||||
def execute_script(wd_: str, file_: str, com_: str, com2_: str="None"):
|
||||
def execute_script(wd_: str, file_: str, com_: str, com2_: str="None", com3_: str="None"):
|
||||
"""
|
||||
executes a script with the option of to extra inputs
|
||||
|
||||
@@ -54,6 +56,7 @@ def execute_script(wd_: str, file_: str, com_: str, com2_: str="None"):
|
||||
- file_ = working file that youre targeting -> String
|
||||
- com_ = first option -> String
|
||||
- com2_ = second option (Optional if needet)-> String
|
||||
- com3_ = third option (Optional if needet)-> String
|
||||
|
||||
Output:
|
||||
- ether False if failed -> bool
|
||||
@@ -64,9 +67,11 @@ def execute_script(wd_: str, file_: str, com_: str, com2_: str="None"):
|
||||
if not update_path:
|
||||
return False
|
||||
if com2_ != "None":
|
||||
cmd = f'bash "{update_path}" {com_}'
|
||||
else:
|
||||
cmd = f'bash "{update_path}" {com_} {com2_}'
|
||||
elif com3_ != "None":
|
||||
cmd = f'bash "{update_path}" {com_} {com2_} {com3_}'
|
||||
else:
|
||||
cmd = f'bash "{update_path}" {com_}'
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", "-lc", cmd],
|
||||
@@ -99,7 +104,7 @@ class instace:
|
||||
def __init__():
|
||||
return list()
|
||||
|
||||
def new(name: str) -> bool:
|
||||
def new(name: str):
|
||||
"""
|
||||
Generates a new instance with the subdomain [name].invario.eu
|
||||
|
||||
@@ -107,10 +112,16 @@ class instace:
|
||||
- name -> String
|
||||
|
||||
Output:
|
||||
- bool if the initiation works (True: generation worked; False: didnt work)
|
||||
- int (Port number for the subdomain) or False if the creation of the instance didnt work
|
||||
"""
|
||||
if execute_script(_var, _cmd, "add", clear_special(name)):
|
||||
return True
|
||||
port_starter = 10002
|
||||
port = port_starter
|
||||
for i in instace.list():
|
||||
port =+ 1
|
||||
|
||||
if execute_script(_var, _cmd, "add", clear_special(name), port):
|
||||
add_dns(name, port)
|
||||
return int(port)
|
||||
else:
|
||||
return False
|
||||
|
||||
@@ -180,6 +191,38 @@ class instace:
|
||||
counter += 1
|
||||
return result
|
||||
|
||||
def backup(name: str) -> bool:
|
||||
"""
|
||||
Creates a backup of an instance with the subdomain [name].invario.eu
|
||||
|
||||
Input:
|
||||
- name -> String
|
||||
|
||||
Output:
|
||||
- bool if the backup works (True: backup worked; False: didnt work)
|
||||
"""
|
||||
import subprocess
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"-f",
|
||||
"docker-compose-multitenant.yml",
|
||||
"exec",
|
||||
"-T",
|
||||
"mongodb",
|
||||
"mongodump",
|
||||
f"--archive=/data/backups/inventar_{name}-$(date +%Y%m%d%H%M%S).gz",
|
||||
"--gzip",
|
||||
"--db",
|
||||
f"inventar_{name}"
|
||||
],
|
||||
check=True
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
class ussage:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
User-agent: *
|
||||
Disallow: /admin/
|
||||
Disallow: /my/
|
||||
Disallow: /login
|
||||
Disallow: /register
|
||||
Disallow: /chat
|
||||
Disallow: /tickets
|
||||
Disallow: /appointments
|
||||
Disallow: /static/uploads/
|
||||
Allow: /
|
||||
Sitemap: /sitemap.xml
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>{{ SITE_ROOT }}</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ SITE_ROOT }}/dienstleistungen</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ SITE_ROOT }}/projekte</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ SITE_ROOT }}/inventarsystem</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ SITE_ROOT }}/team</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ SITE_ROOT }}/kontakt</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ SITE_ROOT }}/blog</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ SITE_ROOT }}/datenschutz</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.2</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ SITE_ROOT }}/impressum</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.2</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ SITE_ROOT }}/nutzungsbedingungen</loc>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.2</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -5,137 +5,298 @@
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.packages-hero {
|
||||
background: linear-gradient(140deg, #f6f2e6 0%, #e8efe4 55%, #dfe9ef 100%);
|
||||
border: 1px solid #ced8d1;
|
||||
border-radius: 18px;
|
||||
padding: 1.4rem;
|
||||
box-shadow: 0 14px 34px rgba(16, 36, 47, 0.08);
|
||||
margin-bottom: 1rem;
|
||||
.booking-hero {
|
||||
background: linear-gradient(135deg, rgba(28, 90, 99, 0.14), rgba(255, 255, 255, 0.65)),
|
||||
linear-gradient(180deg, #fffefb 0%, #f2f5f2 100%);
|
||||
border: 1px solid rgba(28, 90, 99, 0.16);
|
||||
border-radius: 24px;
|
||||
padding: 2.2rem;
|
||||
margin-bottom: 1.8rem;
|
||||
box-shadow: 0 18px 44px rgba(18, 49, 54, 0.08);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.packages-hero h1 {
|
||||
color: #173f57;
|
||||
font-size: clamp(1.65rem, 3.2vw, 2.45rem);
|
||||
margin-bottom: 0.35rem;
|
||||
.booking-hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at top right, rgba(34, 98, 128, 0.16), transparent 36%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.booking-hero > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.booking-hero h1 {
|
||||
margin-bottom: 0.65rem;
|
||||
color: #133d4a;
|
||||
font-size: clamp(2rem, 3.5vw, 3.2rem);
|
||||
}
|
||||
|
||||
.booking-hero p {
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 780px;
|
||||
color: #3b5f69;
|
||||
font-size: clamp(1rem, 1.05vw, 1.1rem);
|
||||
}
|
||||
|
||||
.packages-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.4rem;
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
|
||||
.package-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d5e0e8;
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 10px 24px rgba(23, 44, 57, 0.05);
|
||||
border: 1px solid #dfe7e7;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 18px 40px rgba(23, 44, 57, 0.05);
|
||||
padding: 1.8rem;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
gap: 1.2rem;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
.package-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: #b2d4dc;
|
||||
box-shadow: 0 24px 48px rgba(23, 44, 57, 0.1);
|
||||
}
|
||||
|
||||
.package-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.package-card__header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.package-card__header > div:first-child {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.package-card__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: #e6f5f8;
|
||||
color: #106476;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.package-card__name {
|
||||
margin: 0;
|
||||
color: #184560;
|
||||
font-size: 1.35rem;
|
||||
color: #143f46;
|
||||
font-size: clamp(1.4rem, 2.5vw, 2rem);
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.package-headline {
|
||||
color: #3a6177;
|
||||
font-weight: 600;
|
||||
.package-card__price {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
font-weight: 700;
|
||||
color: #102d37;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-card__price span {
|
||||
font-size: clamp(2rem, 3vw, 2.75rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.package-card__price small {
|
||||
color: #57717b;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
.package-card__description {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
color: #456274;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: #4b6a72;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.package-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
.package-card__features {
|
||||
margin: 1rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.package-card__features li {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
color: #4f6a72;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.package-card__features li::before {
|
||||
content: "✓";
|
||||
color: #187490;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.package-card__actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.package-card__actions form,
|
||||
.package-card__actions a {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid #0d4f77;
|
||||
background: linear-gradient(120deg, #0d6294 0%, #0b476a 100%);
|
||||
width: 100%;
|
||||
border: 1px solid #1c5a63;
|
||||
background: linear-gradient(120deg, #1d6d79 0%, #164b55 100%);
|
||||
color: #ffffff;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.88rem;
|
||||
padding: 0.95rem 1.2rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover,
|
||||
.btn:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 24px rgba(28, 90, 99, 0.22);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
border-color: #9ab5c8;
|
||||
background: #f2f8fc;
|
||||
color: #275a78;
|
||||
border-color: #c7dde3;
|
||||
background: #f7fbfb;
|
||||
color: #1d5f6f;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-top: 1rem;
|
||||
padding: 1.3rem 1.35rem;
|
||||
border-radius: 20px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d4e0e9;
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem;
|
||||
color: #274f65;
|
||||
border: 1px solid #dfe7ea;
|
||||
color: #304f5a;
|
||||
box-shadow: 0 12px 24px rgba(23, 44, 57, 0.04);
|
||||
}
|
||||
|
||||
.notice strong {
|
||||
color: #173f57;
|
||||
color: #15444c;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.packages-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.package-card__tag + .package-card__name {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.booking-hero {
|
||||
padding: 1.6rem;
|
||||
}
|
||||
|
||||
.package-card {
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.package-card__header {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.package-card__price {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="packages-hero">
|
||||
<h1>Buchungsoptionen als Softwarepakete</h1>
|
||||
<p>Alle Pakete sind frei einsehbar, auch ohne Admin.</p>
|
||||
<p>Jedes Paket basiert auf derselben Sicherheitsarchitektur mit Rollenrechten, geschützten Sessions und nachvollziehbaren Aktionen.</p>
|
||||
<section class="booking-hero">
|
||||
<h1>Buchungsoptionen entdecken</h1>
|
||||
<p>Unsere Pakete sind klar strukturiert und transparent. Wählen Sie die passende Lösung für Ihre Schule und senden Sie die Anfrage direkt an unser Admin-Team.</p>
|
||||
</section>
|
||||
|
||||
<section class="packages-grid">
|
||||
{% for package in software_packages %}
|
||||
<article class="package-card">
|
||||
<h2 class="package-name">{{ package.name }}</h2>
|
||||
<p class="package-headline">{{ package.headline }}</p>
|
||||
<ul class="feature-list">
|
||||
{% for feature in package.features %}
|
||||
<li>{{ feature }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="package-actions">
|
||||
{% if 'username' in session %}
|
||||
<form method="POST" action="{{ url_for('book_option_package') }}">
|
||||
<input type="hidden" name="package" value="{{ package.slug }}">
|
||||
<button class="btn" type="submit">Option buchen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto buchen</a>
|
||||
{% endif %}
|
||||
<section class="packages-grid" aria-label="Buchungspakete">
|
||||
<article class="package-card">
|
||||
<div class="package-card__header">
|
||||
<div>
|
||||
<span class="package-card__tag">Beliebt</span>
|
||||
<h2 class="package-card__name">Inventarsystem</h2>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
<div class="package-card__price">
|
||||
<span>250 €</span>
|
||||
<small>/ Monat</small>
|
||||
</div>
|
||||
</div>
|
||||
<p class="package-card__description">Der stabile Einstieg in die Schulverwaltung mit Inventarverwaltung, Benutzerrechten und Berichten für den gesamten Schulbetrieb.</p>
|
||||
<ul class="package-card__features">
|
||||
<li>Inventarverwaltung mit Rollenrechten</li>
|
||||
<li>Basis-Support und Ticketing</li>
|
||||
<li>Schulweite Übersichten und Berichte</li>
|
||||
<li>Erweiterte Instanzverwaltung</li>
|
||||
</ul>
|
||||
<div class="package-card__actions">
|
||||
{% if 'username' in session %}
|
||||
<form method="POST" action="{{ url_for('book_option_package') }}">
|
||||
<input type="hidden" name="package" value="inventarsystem">
|
||||
<button class="btn" type="submit">Jetzt buchen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto buchen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="package-card">
|
||||
<div class="package-card__header">
|
||||
<div>
|
||||
<span class="package-card__tag">Modul</span>
|
||||
<h2 class="package-card__name">Bibliothekssystem</h2>
|
||||
</div>
|
||||
<div class="package-card__price">
|
||||
<span>150 €</span>
|
||||
<small>/ Monat</small>
|
||||
</div>
|
||||
</div>
|
||||
<p class="package-card__description">Ein spezialisiertes Modul für Bibliotheken und Medienzentren mit Ausleihprozessen, Medienbeständen und historischer Transparenz.</p>
|
||||
<ul class="package-card__features">
|
||||
<li>Ausleih- und Rückgabeprozesse</li>
|
||||
<li>Bestandsübersichten für Medien</li>
|
||||
<li>Transparente Historie pro Medium</li>
|
||||
<li>Einfaches Modul-Setup für Schulen</li>
|
||||
</ul>
|
||||
<div class="package-card__actions">
|
||||
{% if 'username' in session %}
|
||||
<form method="POST" action="{{ url_for('book_option_package') }}">
|
||||
<input type="hidden" name="package" value="buecherei">
|
||||
<button class="btn" type="submit">Jetzt buchen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto buchen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="notice">
|
||||
{% if 'username' in session %}
|
||||
<p><strong>Hinweis:</strong> Buchungsanfragen werden direkt vom Admin-Team verarbeitet und Ihrer Instanz zugeordnet.</p>
|
||||
<p><strong>Hinweis:</strong> Deine Buchungsanfrage wird direkt vom Admin-Team bearbeitet und deiner Schulinstanz zugeordnet.</p>
|
||||
{% else %}
|
||||
<p><strong>Hinweis:</strong> Bitte einloggen oder registrieren, um Buchungsoptionen anzufragen.</p>
|
||||
<p><strong>Hinweis:</strong> Bitte logge dich ein oder registriere dich, um eine Buchungsoption anzufragen.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
+227
-30
@@ -8,6 +8,21 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>{% block title %}Digitale Schulverwaltung{% endblock %}</title>
|
||||
{# Basic SEO meta tags, can be overridden per-template by setting `meta_description` #}
|
||||
<meta name="description" content="{{ meta_description|default('Invario — Lehrmittel-, Inventar- und Ausleihverwaltung für Schulen. Sicher, einfach, nachvollziehbar.') }}">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="{{ request.url_root.rstrip('/') }}{{ request.path }}">
|
||||
<!-- OpenGraph / Twitter -->
|
||||
<meta property="og:site_name" content="Invario">
|
||||
<meta property="og:title" content="{{ meta_title|default('Digitale Schulverwaltung') }}">
|
||||
<meta property="og:description" content="{{ meta_description|default('Invario — Lehrmittel-, Inventar- und Ausleihverwaltung für Schulen. Sicher, einfach, nachvollziehbar.') }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="{{ request.url_root.rstrip('/') }}{{ request.path }}">
|
||||
<meta property="og:image" content="{{ url_for('static', filename='images/logo-1.jpeg') }}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ meta_title|default('Digitale Schulverwaltung') }}">
|
||||
<meta name="twitter:description" content="{{ meta_description|default('Invario — Lehrmittel-, Inventar- und Ausleihverwaltung für Schulen. Sicher, einfach, nachvollziehbar.') }}">
|
||||
<meta name="twitter:image" content="{{ url_for('static', filename='images/logo-1.jpeg') }}">
|
||||
{% block head %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -115,21 +130,22 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.62rem;
|
||||
gap: 0.72rem;
|
||||
font-family: "Merriweather", Georgia, serif;
|
||||
font-size: 1.15rem;
|
||||
font-size: 1.22rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #bccdc4;
|
||||
box-shadow: 0 8px 16px rgba(19, 49, 54, 0.14);
|
||||
@@ -140,21 +156,56 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.1rem;
|
||||
flex-wrap: wrap;
|
||||
font-weight: 600;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.nav-links .nav-user-link,
|
||||
.nav-links .nav-mobile-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-main);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-toggle:hover,
|
||||
.nav-toggle:focus-visible {
|
||||
background: rgba(28, 90, 99, 0.08);
|
||||
}
|
||||
|
||||
.nav-toggle svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -325,25 +376,81 @@
|
||||
@media (max-width: 820px) {
|
||||
.site-nav-inner {
|
||||
min-height: 64px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: calc(100% + 0.6rem);
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
width: min(100%, calc(100% - 1.5rem));
|
||||
max-width: 520px;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid #d8dfdb;
|
||||
border-radius: 20px;
|
||||
padding: 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
z-index: 25;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 18px 40px rgba(23, 44, 57, 0.12);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-links.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 1rem 1rem;
|
||||
border-radius: 16px;
|
||||
background: #f7f5f0;
|
||||
color: var(--text-main);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-links a:hover,
|
||||
.nav-links a:focus-visible {
|
||||
background: #e7f0ec;
|
||||
}
|
||||
|
||||
.nav-links .nav-user-link,
|
||||
.nav-links .nav-mobile-action {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-links .nav-user-link {
|
||||
padding-left: 1rem;
|
||||
border-left: 3px solid rgba(28, 90, 99, 0.18);
|
||||
background: #f1f6f4;
|
||||
}
|
||||
|
||||
.nav-links .nav-mobile-action {
|
||||
border: 1px solid #bcd0de;
|
||||
background: #ffffff;
|
||||
color: #1b4b52;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
gap: 0.4rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.42rem 0.72rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
right: auto;
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
.main-wrap {
|
||||
padding: 1.4rem 0 2.2rem;
|
||||
}
|
||||
|
||||
.cookie-banner {
|
||||
@@ -358,28 +465,73 @@
|
||||
.cookie-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
right: auto;
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.site-shell {
|
||||
width: min(100%, calc(100% - 1rem));
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
top: calc(100% + 0.6rem);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.nav-actions .nav-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-links .nav-user-link {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="site-nav">
|
||||
<nav class="site-nav" role="navigation" aria-label="Hauptnavigation">
|
||||
<div class="site-shell site-nav-inner">
|
||||
<a class="brand" href="{{ url_for('default') }}">
|
||||
<img src="{{ url_for('static', filename='images/Logo-clear.png') }}" alt="Invario Logo">
|
||||
<span>Invario</span>
|
||||
</a>
|
||||
<div class="nav-links" aria-label="Main Navigation">
|
||||
<a href="{{ url_for('dienstleistungen') }}">Leistungen für Schulen</a>
|
||||
<a href="{{ url_for('projekte') }}">Praxisbeispiele</a>
|
||||
<a href="{{ url_for('team') }}">Team</a>
|
||||
<button type="button" class="nav-toggle" id="mobileMenuToggle" aria-expanded="false" aria-controls="mainNav" aria-label="Navigation öffnen">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="nav-links" id="mainNav" aria-label="Main Navigation" aria-hidden="true">
|
||||
<a href="{{ url_for('default') }}">Start</a>
|
||||
<a href="{{ url_for('dienstleistungen') }}">Für Schulen</a>
|
||||
<a href="{{ url_for('kontakt') }}">Kontakt</a>
|
||||
<a href="{{ url_for('inventarsystem') }}">Inventarsystem</a>
|
||||
{% if 'username' in session %}
|
||||
<a class="nav-user-link" href="{{ url_for('my_invoices') }}">Meine Rechnungen</a>
|
||||
<a class="nav-user-link" href="{{ url_for('my_instance_management') }}">Meine Instanz</a>
|
||||
<a class="nav-user-link" href="{{ url_for('user_chat') }}">Chat mit Admin</a>
|
||||
<a class="nav-user-link" href="{{ url_for('user_tickets') }}">Support Tickets</a>
|
||||
<a class="nav-user-link" href="{{ url_for('logout') }}">Abmelden</a>
|
||||
{% if session.get('is_admin') %}
|
||||
<a class="nav-user-link" href="{{ url_for('admin_dashboard') }}">Admin Dashboard</a>
|
||||
<a class="nav-user-link" href="{{ url_for('admin_users') }}">Userverwaltung</a>
|
||||
<a class="nav-user-link" href="{{ url_for('admin_invoices') }}">Rechnungsverwaltung</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<a class="nav-mobile-action nav-btn primary" href="{{ url_for('login') }}">Login</a>
|
||||
<a class="nav-mobile-action nav-btn" href="{{ url_for('register') }}">Registrieren</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
{% if 'username' in session %}
|
||||
<a class="nav-btn" href="{{ url_for('appointments') }}">Buchungsoption</a>
|
||||
|
||||
<details class="nav-dropdown">
|
||||
<summary class="nav-btn">Nutzerbereich</summary>
|
||||
<div class="dropdown-menu">
|
||||
@@ -389,7 +541,6 @@
|
||||
<a href="{{ url_for('user_tickets') }}">Support Tickets</a>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if session.get('is_admin') %}
|
||||
<details class="nav-dropdown">
|
||||
<summary class="nav-btn">Admin</summary>
|
||||
@@ -406,17 +557,16 @@
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<a class="nav-btn primary" href="{{ url_for('appointments') }}">Buchungsoption</a>
|
||||
<a class="nav-btn" href="{{ url_for('logout') }}">Logout</a>
|
||||
{% else %}
|
||||
<a class="nav-btn" href="{{ url_for('blog') }}">Neuigkeiten</a>
|
||||
<a class="nav-btn" href="{{ url_for('login') }}">Login</a>
|
||||
<a class="nav-btn primary" href="{{ url_for('register') }}">Registrieren</a>
|
||||
<a class="nav-btn primary" href="{{ url_for('login') }}">Login</a>
|
||||
<a class="nav-btn" href="{{ url_for('register') }}">Registrieren</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="site-shell main-wrap">
|
||||
<main class="site-shell main-wrap" role="main">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flashes">
|
||||
@@ -554,6 +704,53 @@
|
||||
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var menuToggle = document.getElementById('mobileMenuToggle');
|
||||
var navLinks = document.getElementById('mainNav');
|
||||
if (menuToggle && navLinks) {
|
||||
menuToggle.addEventListener('click', function () {
|
||||
var isExpanded = this.getAttribute('aria-expanded') === 'true';
|
||||
this.setAttribute('aria-expanded', String(!isExpanded));
|
||||
navLinks.classList.toggle('open');
|
||||
navLinks.setAttribute('aria-hidden', String(isExpanded));
|
||||
});
|
||||
|
||||
navLinks.querySelectorAll('a').forEach(function (link) {
|
||||
link.addEventListener('click', function () {
|
||||
if (navLinks.classList.contains('open')) {
|
||||
menuToggle.setAttribute('aria-expanded', 'false');
|
||||
navLinks.classList.remove('open');
|
||||
navLinks.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (!navLinks.contains(event.target) && !menuToggle.contains(event.target) && navLinks.classList.contains('open')) {
|
||||
menuToggle.setAttribute('aria-expanded', 'false');
|
||||
navLinks.classList.remove('open');
|
||||
navLinks.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
navLinks.querySelectorAll('a').forEach(function (link) {
|
||||
link.addEventListener('click', function () {
|
||||
if (navLinks.classList.contains('open')) {
|
||||
menuToggle.setAttribute('aria-expanded', 'false');
|
||||
navLinks.classList.remove('open');
|
||||
navLinks.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
if (window.innerWidth > 820 && navLinks.classList.contains('open')) {
|
||||
menuToggle.setAttribute('aria-expanded', 'false');
|
||||
navLinks.classList.remove('open');
|
||||
navLinks.removeAttribute('aria-hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var banner = document.getElementById("cookieBanner");
|
||||
var acceptBtn = document.getElementById("cookieAcceptBtn");
|
||||
var declineBtn = document.getElementById("cookieDeclineBtn");
|
||||
|
||||
+146
-11
@@ -1,5 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{# SEO: per-page description used in base.html meta tags #}
|
||||
{% set meta_description = "Invario bündelt Lehrmittelverwaltung, Inventar und Ausleihe für Schulen – sicher, transparent und einfach zu bedienen." %}
|
||||
|
||||
{% block title %}Digitale Schulverwaltung mit Sicherheitsfokus{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
@@ -550,12 +553,73 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero" id="top">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": "{{ request.url_root.rstrip('/') }}#website",
|
||||
"url": "{{ request.url_root.rstrip('/') }}",
|
||||
"name": "Invario",
|
||||
"description": "{{ meta_description }}",
|
||||
"inLanguage": "de-DE",
|
||||
"publisher": { "@id": "{{ request.url_root.rstrip('/') }}#organization" },
|
||||
"potentialAction": {
|
||||
"@type": "ContactAction",
|
||||
"target": "{{ request.url_root.rstrip('/') }}/kontakt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": "{{ request.url_root.rstrip('/') }}#organization",
|
||||
"name": "Invario",
|
||||
"url": "{{ request.url_root.rstrip('/') }}",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "{{ url_for('static', filename='images/logo-1.jpeg') }}"
|
||||
},
|
||||
"contactPoint": [
|
||||
{
|
||||
"@type": "ContactPoint",
|
||||
"contactType": "customer support",
|
||||
"url": "{{ request.url_root.rstrip('/') }}/kontakt",
|
||||
"availableLanguage": ["de", "en"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": "{{ request.url_root.rstrip('/') }}#software",
|
||||
"name": "Invario",
|
||||
"url": "{{ request.url_root.rstrip('/') }}",
|
||||
"description": "{{ meta_description }}",
|
||||
"operatingSystem": "Web",
|
||||
"applicationCategory": "EducationApplication",
|
||||
"softwareVersion": "1.0",
|
||||
"image": "{{ url_for('static', filename='images/logo-1.jpeg') }}",
|
||||
"author": { "@id": "{{ request.url_root.rstrip('/') }}#organization" },
|
||||
"publisher": { "@id": "{{ request.url_root.rstrip('/') }}#organization" },
|
||||
"inLanguage": "de-DE"
|
||||
},
|
||||
{
|
||||
"@type": "WebPage",
|
||||
"@id": "{{ request.url_root.rstrip('/') }}#webpage",
|
||||
"url": "{{ request.url_root.rstrip('/') }}",
|
||||
"name": "Invario Startseite",
|
||||
"description": "{{ meta_description }}",
|
||||
"isPartOf": { "@id": "{{ request.url_root.rstrip('/') }}#website" },
|
||||
"mainEntity": { "@id": "{{ request.url_root.rstrip('/') }}#software" }
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<header class="hero" id="top" aria-label="Seitenstart">
|
||||
<div>
|
||||
<span class="eyebrow">Lehrmittelverwaltungssoftware für Schulen</span>
|
||||
<h1>Lehrmittel, Schul-IT und Ausleihen in einem sicheren System.</h1>
|
||||
<h1>Invario ist die sichere Schulsoftware für Lehrmittelverwaltung, Inventar und Ausleihe.</h1>
|
||||
<p>
|
||||
Invario ist eine Lehrmittelverwaltungssoftware, die Schulbibliothek, Inventar und digitale Ausleihe zusammenführt. Prozesse bleiben bewusst schlank und schnell, während Sicherheitsfunktionen wie rollenbasierte Rechte, Sitzungsabsicherung und nachvollziehbare Protokolle im Hintergrund dauerhaft aktiv sind.
|
||||
Invario hilft Schulen, Lehrmittel, Medien und Ausleihprozesse übersichtlich, digital und DSGVO-konform zu organisieren. Die Oberfläche bleibt für Lehrkräfte und Bibliothekspersonal verständlich, während Sicherheitsfunktionen im Hintergrund für Verlässlichkeit sorgen.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
{% if 'username' in session %}
|
||||
@@ -563,9 +627,7 @@
|
||||
{% else %}
|
||||
<a class="btn btn-primary" href="{{ url_for('login') }}">Buchungsoption nutzen</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-secondary" href="{{ url_for('dienstleistungen') }}">Leistungen ansehen</a>
|
||||
<a class="btn btn-secondary" href="{{ url_for('team') }}">Team ansehen</a>
|
||||
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Direkt kontaktieren</a>
|
||||
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Kontakt aufnehmen</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-right">
|
||||
@@ -596,10 +658,75 @@
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="primary-focus" id="zielgruppen" aria-label="Zielgruppen">
|
||||
<div class="primary-focus-content">
|
||||
<span class="eyebrow">Für wen ist das?</span>
|
||||
<h2>Passend für jede Schulform</h2>
|
||||
<div class="primary-points">
|
||||
<span>Grundschule: Übersicht für Klassen 1–4</span>
|
||||
<span>Weiterführende Schulen: Effizienz für Klassen 5–13</span>
|
||||
<span>Oberschulen: Verwaltung für FOS/BOS</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="ablauf" style="animation-delay: 100ms;">
|
||||
<h2>So verläuft die Zusammenarbeit</h2>
|
||||
<p>Ein klarer Prozess sorgt für Sicherheit, besonders bei begrenzten Zeitfenstern im Schulbetrieb.</p>
|
||||
<div class="process" aria-label="Ablauf in fünf Schritten">
|
||||
<div class="step"><strong>1. Kennenlernen</strong><small>Ziele klären</small></div>
|
||||
<div class="step"><strong>2. Struktur</strong><small>Bestehende Abläufe erfassen</small></div>
|
||||
<div class="step"><strong>3. Einrichtung</strong><small>System passgenau einstellen</small></div>
|
||||
<div class="step"><strong>4. Schulung</strong><small>Praxisnah einführen</small></div>
|
||||
<div class="step"><strong>5. Betrieb</strong><small>Persönlich begleiten</small></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="nutzen" style="animation-delay: 180ms;">
|
||||
<h2>Nutzen-Inseln aus dem Schulalltag</h2>
|
||||
<div class="section-grid">
|
||||
<article class="card">
|
||||
<h3>Bücher sicher ausgeben und zurücknehmen</h3>
|
||||
<p>Mit Barcode oder QR-Code werden Bücher und Materialien schnell und nachvollziehbar ausgegeben und zurückgenommen – ohne Zettelwirtschaft.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Den Überblick über Laptops und iPads behalten</h3>
|
||||
<p>IT-Geräte, Tablets und Medien werden gemeinsam verwaltet. Verantwortlichkeiten und Rückgaben sind jederzeit einsehbar.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Lehrmittel für die Unterrichtsplanung</h3>
|
||||
<p>Lehrkräfte planen Materialbedarf im Voraus und vermeiden so Engpässe und Doppelbestellungen.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="sicherheit" style="animation-delay: 220ms;">
|
||||
<h2>Datenschutz & Sicherheit</h2>
|
||||
<div class="section-grid">
|
||||
<article class="card">
|
||||
<h3>Rollen- und Bereichstrennung</h3>
|
||||
<p>Bibliothek, Verwaltung und IT erhalten abgestufte Rechte, sodass jede Rolle nur ihre benötigten Funktionen sieht.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Protokollierung und Nachweis</h3>
|
||||
<p>Ausgaben, Rücknahmen und kritische Admin-Aktionen werden dokumentiert und sind jederzeit auditierbar.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Gesicherte Web-Session</h3>
|
||||
<p>Sicherheits-Header, HttpOnly-Cookies und klare Session-Richtlinien schützen den laufenden Betrieb vor typischen Webangriffen.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="hilfe" style="animation-delay: 240ms;">
|
||||
<h2>Wir lassen Sie nicht allein</h2>
|
||||
<p>Sie erhalten persönlichen Support – per E-Mail, über den Chat mit einem Administrator oder für die Einrichtung vor Ort. So bleibt keine Frage offen und die Hemmschwelle vor der Digitalisierung sinkt.</p>
|
||||
</section>
|
||||
|
||||
<section class="testimonials">
|
||||
<h2>Das sagen unsere Schulen</h2>
|
||||
<h2>Das sagen andere Schulleitungen</h2>
|
||||
<div class="testimonials-grid">
|
||||
<div class="testimonial-card">
|
||||
<div class="testimonial-stars">
|
||||
@@ -609,7 +736,7 @@
|
||||
<span class="star">★</span>
|
||||
<span class="star">★</span>
|
||||
</div>
|
||||
<p class="testimonial-text">"Mit Invario haben wir unsere Lehrmittelausleihe um 70% beschleunigt. Die Schüler wissen sofort, welche Materialien verfügbar sind, ohne die Bibliothek zu stören."</p>
|
||||
<p class="testimonial-text">"Mit Invario haben wir unsere Lehrmittelausleihe um 70% beschleunigt. Die Oberfläche ist intuitiv und unsere Bibliothekare sparen täglich Zeit."</p>
|
||||
<div class="testimonial-author">
|
||||
<div class="testimonial-avatar">SR</div>
|
||||
<div class="testimonial-info">
|
||||
@@ -667,7 +794,7 @@
|
||||
<span>Kinderleichte Bedienung</span>
|
||||
</div>
|
||||
<div class="cta-row">
|
||||
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Module ansehen</a>
|
||||
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Grundschul-Modul ansehen</a>
|
||||
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Beratung für Grundschule</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -685,7 +812,7 @@
|
||||
<span>Kinderleichte Bedienung</span>
|
||||
</div>
|
||||
<div class="cta-row">
|
||||
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Module ansehen</a>
|
||||
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Modul ansehen</a>
|
||||
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Beratung für Weiterführende Schulen</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -703,7 +830,7 @@
|
||||
<span>Kinderleichte Bedienung</span>
|
||||
</div>
|
||||
<div class="cta-row">
|
||||
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Module ansehen</a>
|
||||
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Modul ansehen</a>
|
||||
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Beratung für Oberschulen</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -798,4 +925,12 @@
|
||||
<a class="btn btn-secondary" href="{{ url_for('login') }}">Buchungsoption nutzen</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="contact" id="kontakt">
|
||||
<div>
|
||||
<h2>Sie sind Schulträger?</h2>
|
||||
<p>In einer Persönlichen Präsentation klären wir Ihre Anforderungen und prüfen in Zusammenarbeit wie Invario ihren Digitalisierungsvorgang in ihren Schulen unterstützen kann.</p>
|
||||
</div>
|
||||
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Beratungstermin vereinbaren</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user