Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f50fb2263 | |||
| a1c5a78b20 | |||
| 38320f488a | |||
| 2aee36cc92 | |||
| aa2ad37cd7 | |||
| 315918098d | |||
| ea402f3223 | |||
| 70b108d841 | |||
| fc53333436 | |||
| 33ae7ee1ac | |||
| 16962b20b6 | |||
| 6fcabc8638 | |||
| 3068f44563 | |||
| c4dd18a2e4 | |||
| d0ac21e7dd | |||
| b4a505c20b | |||
| 39302d4d1f | |||
| e46f8b0c66 | |||
| 06486f039f | |||
| feac00f0df | |||
| 0b0169ef96 | |||
| a6db3a001f | |||
| 8fa495a23c | |||
| 9df7a73db1 | |||
| dcfd23b412 | |||
| d523dd0a68 | |||
| d7d96d5567 | |||
| a0018eebd5 | |||
| eebaaef9ea | |||
| 86112ff295 | |||
| 048900058f | |||
| b8923b4417 | |||
| a864bab713 | |||
| 14322e11c0 | |||
| 93bb901f45 | |||
| 4d5ff86f50 | |||
| ceb50ab29c | |||
| d6ecf419ea | |||
| dceea0b047 |
@@ -1,8 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
working_dir: /app/Web
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "--timeout", "30", "--graceful-timeout", "20", "--worker-connections", "100", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
|
||||
build: null
|
||||
ports:
|
||||
- "10000:8000"
|
||||
@@ -25,7 +25,6 @@ env:
|
||||
|
||||
jobs:
|
||||
release-docker:
|
||||
# Hinweis: Falls dein lokaler Gitea-Runner ein anderes Label hat (z.B. 'linux' oder 'self-hosted'), passe dies hier an.
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -35,7 +34,6 @@ jobs:
|
||||
- name: Set metadata
|
||||
id: meta
|
||||
env:
|
||||
# Gitea stellt das GITHUB_TOKEN für Kompatibilität mit GitHub Actions automatisch zur Verfügung
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
EVENT_NAME: ${{ gitea.event_name }}
|
||||
@@ -46,7 +44,6 @@ jobs:
|
||||
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
||||
TAG="$REF_NAME"
|
||||
else
|
||||
# Gitea API Endpunkt nutzen, um das aktuellste Release abzufragen
|
||||
latest_tag="v0.8.31"
|
||||
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/json" "https://git.invario-software.eu/api/v1/repos/$REPO/releases/latest" 2>/dev/null); then
|
||||
tag_name=$(printf "%s" "$meta_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||
@@ -63,7 +60,6 @@ jobs:
|
||||
major=0; minor=8; patch=31
|
||||
fi
|
||||
|
||||
# Bump strategy: major / minor / patch
|
||||
if [ "${BUMP_TYPE:-}" = "major" ]; then
|
||||
major=$((major + 1)); minor=0; patch=0
|
||||
elif [ "${BUMP_TYPE:-}" = "minor" ]; then
|
||||
@@ -72,11 +68,9 @@ jobs:
|
||||
patch=$((patch + 1))
|
||||
fi
|
||||
|
||||
# Zusammenbau des Tags für den manuellen Run
|
||||
TAG="v${major}.${minor}.${patch}"
|
||||
fi
|
||||
|
||||
# Validierung des erzeugten oder übergebenen Tags
|
||||
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-dev(\.[0-9]+)?)?$'; then
|
||||
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
|
||||
exit 1
|
||||
@@ -95,13 +89,11 @@ jobs:
|
||||
LATEST_MAJOR="0"
|
||||
fi
|
||||
|
||||
# If not explicitly bumping major, disallow changing major version
|
||||
if [ "${BUMP_TYPE:-}" != "major" ] && [ "$TAG_MAJOR" != "$LATEST_MAJOR" ]; then
|
||||
echo "Error: major version must stay v$LATEST_MAJOR.x.x (got $TAG)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure tag uniqueness: if tag exists append numeric suffix
|
||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
i=1
|
||||
base="$TAG"
|
||||
@@ -111,24 +103,18 @@ jobs:
|
||||
TAG="${base}.${i}"
|
||||
fi
|
||||
|
||||
# Docker Images verlangen Kleinbuchstaben. Repository-Namen daher umwandeln.
|
||||
LOWER_REPO=$(echo "$REPO" | tr '[:upper:]' '[:lower:]')
|
||||
IMAGE="git.invario-software.eu/${LOWER_REPO}:${TAG}"
|
||||
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||
echo "lower_repo=$LOWER_REPO" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Ensure Docker CLI is available and up to date
|
||||
run: |
|
||||
install_docker=true
|
||||
|
||||
# Prüfen, ob Docker existiert und ob die Version ausreicht
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
# Extrahiere die Major-Version
|
||||
DOCKER_MAJOR=$(docker --version | grep -oE '[0-9]+' | head -n1)
|
||||
|
||||
# API 1.44 erfordert mindestens Docker v25
|
||||
if [ -n "$DOCKER_MAJOR" ] && [ "$DOCKER_MAJOR" -ge 25 ]; then
|
||||
install_docker=false
|
||||
fi
|
||||
@@ -136,34 +122,19 @@ jobs:
|
||||
|
||||
if [ "$install_docker" = true ]; then
|
||||
echo "Veraltete oder fehlende Docker-Installation erkannt. Lade statische Docker CLI herunter..."
|
||||
|
||||
DOCKER_VERSION="26.1.4"
|
||||
|
||||
# Download der statischen Binaries via curl oder wget (umgeht apt-get komplett)
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSLO "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz"
|
||||
else
|
||||
wget -q "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz"
|
||||
fi
|
||||
|
||||
tar -xzf docker-${DOCKER_VERSION}.tgz
|
||||
|
||||
# Installation in lokalen Benutzer-Pfad, um sudo/root-Rechte-Probleme zu vermeiden
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
cp docker/docker "$HOME/.local/bin/"
|
||||
|
||||
# Pfad für nachfolgende GitHub Actions Schritte verfügbar machen
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# Pfad für diesen spezifischen Shell-Run exportieren
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
rm -rf docker docker-${DOCKER_VERSION}.tgz
|
||||
echo "Docker CLI wurde erfolgreich aktualisiert."
|
||||
else
|
||||
echo "Docker CLI ist bereits auf einem aktuellen Stand."
|
||||
fi
|
||||
|
||||
docker --version
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
@@ -186,109 +157,101 @@ jobs:
|
||||
${{ steps.meta.outputs.image }}
|
||||
git.invario-software.eu/${{ steps.meta.outputs.lower_repo }}:latest
|
||||
|
||||
- name: Build local image tar for offline deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="${{ steps.meta.outputs.image }}"
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
|
||||
echo "Pulling freshly pushed image from registry: $IMG"
|
||||
docker pull "$IMG"
|
||||
|
||||
echo "Saving image to offline tar archive..."
|
||||
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
||||
|
||||
- name: Create release-only docker bundle
|
||||
run: |
|
||||
mkdir -p release-bundle
|
||||
cat > release-bundle/docker-compose.yml <<EOF
|
||||
services:
|
||||
app:
|
||||
image: \${INVENTAR_APP_IMAGE:-${{ steps.meta.outputs.image }}}
|
||||
container_name: inventarsystem-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "\${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||
depends_on:
|
||||
- mongodb
|
||||
- redis
|
||||
environment:
|
||||
INVENTAR_MONGODB_HOST: mongodb
|
||||
INVENTAR_MONGODB_PORT: "27017"
|
||||
INVENTAR_MONGODB_DB: Inventarsystem
|
||||
INVENTAR_BACKUP_FOLDER: /data/backups
|
||||
INVENTAR_LOGS_FOLDER: /data/logs
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- app_uploads:/app/Web/uploads
|
||||
- app_thumbnails:/app/Web/thumbnails
|
||||
- app_previews:/app/Web/previews
|
||||
- app_qrcodes:/app/Web/QRCodes
|
||||
- app_backups:/data/backups
|
||||
- app_logs:/data/logs
|
||||
|
||||
mongodb:
|
||||
image: mongo:7.0
|
||||
container_name: inventarsystem-mongodb
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: inventarsystem-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
app_uploads:
|
||||
app_thumbnails:
|
||||
app_previews:
|
||||
app_qrcodes:
|
||||
app_backups:
|
||||
app_logs:
|
||||
redis_data:
|
||||
EOF
|
||||
|
||||
# Copy runtime scripts and config if present
|
||||
for f in start.sh stop.sh restart.sh backup.sh restore.sh config.json update.sh; do
|
||||
if [ -f "$f" ]; then
|
||||
cp "$f" "release-bundle/$(basename "$f")"
|
||||
fi
|
||||
done
|
||||
|
||||
# Multitenant scripts & docs (optional)
|
||||
for f in docker-compose-multitenant.yml manage-tenant.sh run-tenant-cmd.sh MULTITENANT_DEPLOYMENT.md MULTITENANT_PYTHON_API.md; do
|
||||
if [ -f "$f" ]; then
|
||||
cp "$f" "release-bundle/$(basename "$f")"
|
||||
fi
|
||||
done
|
||||
|
||||
# Make any shipped scripts executable
|
||||
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
||||
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
||||
mkdir -p release-bundle
|
||||
cat > release-bundle/docker-compose.yml <<EOF
|
||||
services:
|
||||
app:
|
||||
image: \${INVENTAR_APP_IMAGE:-${{ steps.meta.outputs.image }}}
|
||||
container_name: inventarsystem-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "\${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||
depends_on:
|
||||
- mongodb
|
||||
- redis
|
||||
environment:
|
||||
INVENTAR_MONGODB_HOST: mongodb
|
||||
INVENTAR_MONGODB_PORT: "27017"
|
||||
INVENTAR_MONGODB_DB: Inventarsystem
|
||||
INVENTAR_BACKUP_FOLDER: /data/backups
|
||||
INVENTAR_LOGS_FOLDER: /data/logs
|
||||
INVENTAR_SECRET_KEY: ${{secrets.INVENTAR_SECRET_KEY}}
|
||||
INVENTAR_DATA_ENCRYPTION_KEY: ${{secrets.INVENTAR_DATA_ENCRYPTION_KEY}}
|
||||
INVENTAR_MONGODB_PASSWORD: ${{secrets.INVENTAR_MONGODB_PASSWORD}}
|
||||
EMAIL_ENABLED: ${{secrets.EMAIL_ENABLED}}
|
||||
EMAIL_SMTP_HOST: ${{secrets.EMAIL_SMTP_HOST}}
|
||||
EMAIL_SMTP_PORT: ${{secrets.EMAIL_SMTP_PORT}}
|
||||
EMAIL_USERNAME: ${{secrets.EMAIL_USERNAME}}
|
||||
EMAIL_PASSWORD: ${{secrets.EMAIL_PASSWORD}}
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- app_uploads:/app/Web/uploads
|
||||
- app_thumbnails:/app/Web/thumbnails
|
||||
- app_previews:/app/Web/previews
|
||||
- app_qrcodes:/app/Web/QRCodes
|
||||
- app_backups:/data/backups
|
||||
- app_logs:/data/logs
|
||||
|
||||
mongodb:
|
||||
image: mongo:7.0
|
||||
container_name: inventarsystem-mongodb
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongodb_data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: inventarsystem-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
mongodb_data:
|
||||
app_uploads:
|
||||
app_thumbnails:
|
||||
app_previews:
|
||||
app_qrcodes:
|
||||
app_backups:
|
||||
app_logs:
|
||||
redis_data:
|
||||
EOF
|
||||
|
||||
for f in start.sh stop.sh restart.sh backup.sh restore.sh config.json update.sh; do
|
||||
if [ -f "$f" ]; then
|
||||
cp "$f" "release-bundle/$(basename "$f")"
|
||||
fi
|
||||
done
|
||||
|
||||
for f in docker-compose-multitenant.yml manage-tenant.sh run-tenant-cmd.sh MULTITENANT_DEPLOYMENT.md MULTITENANT_PYTHON_API.md; do
|
||||
if [ -f "$f" ]; then
|
||||
cp "$f" "release-bundle/$(basename "$f")"
|
||||
fi
|
||||
done
|
||||
|
||||
find release-bundle -maxdepth 1 -type f -name '*.sh' -exec chmod +x {} \; || true
|
||||
tar -czf inventarsystem-docker-bundle.tar.gz -C release-bundle .
|
||||
|
||||
- name: Create or update Gitea Release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
tag_name: ${{ steps.meta.outputs.tag }}
|
||||
files: |
|
||||
inventarsystem-docker-bundle.tar.gz
|
||||
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
||||
inventarsystem-docker-bundle.tar.gz
|
||||
@@ -3,6 +3,7 @@ logs
|
||||
certs
|
||||
build
|
||||
.venv
|
||||
.idea
|
||||
__pycache__
|
||||
.pycvapid.json
|
||||
Web/vapid.json
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
v0.8.31.1
|
||||
@@ -1,128 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
Iron.ai.dev@gmail.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
# Endbenutzer-Lizenzvertrag (EULA) und Nutzungsbedingungen
|
||||
**Softwareprojekt:** Inventarsystem
|
||||
**Urheberrechtshalter (Lizenzgeber):** Maximilian Gründinger
|
||||
**Gültigkeit:** Stand 2026
|
||||
|
||||
---
|
||||
|
||||
### PRÄAMBEL
|
||||
Dieser Endbenutzer-Lizenzvertrag (im Folgenden „Vertrag“) stellt eine rechtsgültige Vereinbarung zwischen Ihnen (im Folgenden „Lizenznehmer“) und dem Urheber **AIIrondev** (im Folgenden „Lizenzgeber“) dar. Durch den Zugriff auf den Quellcode, die Installation, das Kopieren oder die sonstige Nutzung der Software „Inventarsystem“ (im Folgenden „Produkt“) erklärt sich der Lizenznehmer mit den nachfolgenden Bedingungen vollumfänglich einverstanden.
|
||||
|
||||
Sollte der Lizenznehmer den Bedingungen dieses Vertrags nicht zustimmen, ist jegliche Nutzung, Vervielfältigung oder Distribution des Produkts mit sofortiger Wirkung untersagt.
|
||||
|
||||
---
|
||||
|
||||
### § 1 GEGENSTAND DER LIZENZ UND EIGENTUMSRECHTE
|
||||
1. Das Produkt wird dem Lizenznehmer unter Vorbehalt lizenziert, nicht verkauft. Sämtliche Eigentumsrechte, Urheberrechte und sonstigen geistigen Eigentumsrechte am Produkt sowie an allen Kopien davon verbleiben ausschließlich beim Lizenzgeber.
|
||||
2. Diese Lizenz gewährt lediglich ein eingeschränktes Nutzungsrecht unter den in diesem Vertrag explizit genannten Bedingungen.
|
||||
|
||||
### § 2 ZULÄSSIGER NUTZUNGSKREIS (PRIVATNUTZUNG)
|
||||
1. Die unentgeltliche Nutzung des Produkts ist ausschließlich **natürlichen Personen für den rein privaten, häuslichen Gebrauch** gestattet.
|
||||
2. Die Nutzung umfasst die Verwaltung privater Bestände ohne jegliche Gewinnerzielungsabsicht.
|
||||
3. Jegliche Nutzung durch **Institutionelle Nutzer** (einschließlich, aber nicht beschränkt auf: Unternehmen, Einzelunternehmer, Freiberufler, Vereine, Bildungseinrichtungen, Behörden oder NGOs) ist ausdrücklich **untersagt** und bedarf einer gesonderten, schriftlichen Lizenzvereinbarung mit dem Lizenzgeber.
|
||||
|
||||
### § 3 FUNKTIONALE EINSCHRÄNKUNGEN UND SUPPORT
|
||||
1. Dem Lizenznehmer wird das Produkt in der jeweils vorliegenden Fassung bereitgestellt („As-Is“).
|
||||
2. Für die kostenlose Privatnutzung besteht **kein Anspruch** auf:
|
||||
- Technischen Support oder Beratung.
|
||||
- Bereitstellung von Sicherheits-Updates oder Patches.
|
||||
- Gewährleistung der Kompatibilität mit spezifischen Hardware- oder Softwareumgebungen.
|
||||
3. Der Lizenzgeber behält sich das Recht vor, Funktionen in zukünftigen Versionen zu ändern, einzuschränken oder kostenpflichtig zu gestalten.
|
||||
|
||||
### § 4 WAHRUNG DER URHEBERBEZEICHNUNG (BRANDING-KLAUSEL)
|
||||
1. Das Produkt verfügt über fest integrierte Urheberrechtshinweise, insbesondere die Kennzeichnung **„Powered by AIIrondev“** in der Benutzeroberfläche (Footer/Menü).
|
||||
2. Es ist dem Lizenznehmer untersagt, diese Hinweise zu entfernen, zu modifizieren, zu verdecken oder deren Sichtbarkeit durch technische Maßnahmen (z. B. Manipulation von CSS, JavaScript oder Metadaten) zu beeinträchtigen.
|
||||
3. Das Entfernen dieser Hinweise führt zum sofortigen und automatischen Erlöschen der Nutzungslizenz.
|
||||
|
||||
### § 5 VERBOT DER KOMMERZIELLEN VERWERTUNG & SAAS
|
||||
1. Die Bereitstellung des Produkts als Dienstleistung für Dritte (Software-as-a-Service, SaaS), insbesondere gegen Entgelt oder zur Generierung von Werbeeinnahmen, ist strikt untersagt.
|
||||
2. Das Hosting auf öffentlichen Servern mit dem Ziel, Dritten ohne eigene Installation Zugriff auf die Funktionalität zu gewähren, ist nur mit ausdrücklicher schriftlicher Genehmigung des Lizenzgebers zulässig.
|
||||
|
||||
### § 6 MODIFIKATIONEN UND CONTRIBUTIONS
|
||||
1. Der Lizenznehmer darf den Quellcode für den privaten Eigenbedarf modifizieren.
|
||||
2. Im Falle einer Veröffentlichung von Modifikationen oder der Einreichung von Verbesserungsvorschlägen (z. B. Pull Requests auf GitHub) räumt der Lizenznehmer dem Lizenzgeber ein unwiderrufliches, weltweites, zeitlich unbeschränktes und kostenfreies Nutzungs- und Verwertungsrecht an diesen Änderungen ein. Der Lizenzgeber ist berechtigt, diese Änderungen in das Hauptprodukt zu übernehmen und unter dieser oder einer anderen Lizenz zu vertreiben.
|
||||
|
||||
### § 7 HAFTUNGSBESCHRÄNKUNG
|
||||
1. Die Haftung des Lizenzgebers für Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung des Produkts entstehen (einschließlich Datenverlust, Betriebsunterbrechung oder entgangener Gewinn), ist auf Vorsatz und grobe Fahrlässigkeit beschränkt.
|
||||
2. Der Lizenzgeber übernimmt keine Haftung für die Richtigkeit der mit dem Produkt verwalteten Daten.
|
||||
|
||||
### § 8 RECHTSWAHL UND GERICHTSSTAND
|
||||
1. Es gilt ausschließlich das Recht der **Bundesrepublik Deutschland** unter Ausschluss des UN-Kaufrechts (CISG).
|
||||
2. Soweit gesetzlich zulässig, wird als Gerichtsstand für alle Streitigkeiten aus diesem Vertrag der Sitz des Lizenzgebers vereinbart.
|
||||
3. Sollten einzelne Bestimmungen dieses Vertrags unwirksam sein, bleibt die Wirksamkeit der übrigen Bestimmungen unberührt (Salvatorische Klausel).
|
||||
|
||||
---
|
||||
**ANFRAGEN FÜR AUSNAHMEGENEHMIGUNGEN (KOMMERZIELLE LIZENZEN):** Bitte kontaktieren Sie den Urheber direkt über das GitHub-Profil: [AIIrondev auf GitHub](https://github.com/AIIrondev)
|
||||
@@ -1,38 +0,0 @@
|
||||
Release-Optionen
|
||||
=================
|
||||
|
||||
Diese Datei beschreibt die Eingabeoptionen des CI-Workflows `.github/workflows/release-docker.yml`.
|
||||
|
||||
Inputs (workflow_dispatch)
|
||||
- `bump` (choice)
|
||||
- `patch` (Standard): Erhöht nur die Patch-Version (vX.Y.Z -> vX.Y.Z+1).
|
||||
- `minor`: Erhöht die Minor-Version und setzt Patch auf 0 (vX.Y.Z -> vX.Y+1.0).
|
||||
- `major`: Erhöht die Major-Version und setzt Minor/Patch auf 0 (vX.Y.Z -> vX+1.0.0).
|
||||
- `development`: Erzeugt einen Development-Release mit Suffix `-dev` (z. B. `v3.1.4-dev`).
|
||||
|
||||
- `push_dev` (choice, optional)
|
||||
- `false` (Standard): Bei `bump=development` wird das `:dev`-Image NICHT automatisch an GHCR gepusht.
|
||||
- `true`: Bei `bump=development` wird zusätzlich das Image `ghcr.io/aiirondev/legendary-octo-garbanzo:dev` gepusht.
|
||||
|
||||
Verhalten/Anmerkungen
|
||||
- Development releases werden als GitHub Release erzeugt und als `prerelease` markiert, damit sie nicht automatisch von normalen Update‑Flows genutzt werden.
|
||||
- Es gibt pro Release genau einen Release‑Eintrag (für Dev‑Releases mit `-dev` Suffix). Es wird kein separates `inventarsystem-image-dev.tar.gz` mehr erzeugt; das Update/Deployment erfolgt über den Release‑Tag / Image‑Tag.
|
||||
- `update.sh` unterstützt weiterhin `dev`/`development`-Modus und akzeptiert nun auch explizite Release‑Tags wie `v3.1.4-dev`.
|
||||
|
||||
Beispiele
|
||||
- Patch-Release (manuell):
|
||||
- GitHub UI: Run workflow → `bump=patch`
|
||||
- CLI mit `gh`:
|
||||
gh workflow run release-docker.yml --repo AIIrondev/legendary-octo-garbanzo --field bump=patch
|
||||
|
||||
- Development prerelease (ohne Push des :dev Images):
|
||||
- GitHub UI: Run workflow → `bump=development` (leave `push_dev=false`)
|
||||
- Ergebnis: Release `vX.Y.Z-dev` als prerelease, Image wird nicht automatisch als `:dev` gepusht.
|
||||
|
||||
- Development prerelease + push des :dev Images:
|
||||
- GitHub UI: Run workflow → `bump=development`, `push_dev=true`
|
||||
- CLI Beispiel:
|
||||
gh workflow run release-docker.yml --repo AIIrondev/legendary-octo-garbanzo --field bump=development --field push_dev=true
|
||||
|
||||
Empfehlung
|
||||
- Verwende `bump=development` für experimentelle/early releases; Nutzer müssen explizit `./update.sh vX.Y.Z-dev` ausführen, um auf diese Version zu upgraden.
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
The latest version will allways be supported the rest are old version that are not activly supported.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.2.17 | ✅ |
|
||||
| 3.2.x | :white_check_mark: |
|
||||
| 3.1.x | :x: |
|
||||
| 3.0.x | :x: |
|
||||
| 2.6.x | :x: |
|
||||
| 2.4.x | :x: |
|
||||
| 1.8.x | :x: |
|
||||
| 1.7.x | :x: |
|
||||
| 1.5.x | :x: |
|
||||
| 1.4.x | :x: |
|
||||
| 1.3.x | :x: |
|
||||
| 1.1.x | :x: |
|
||||
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability contact me via. my E-Mail Iron.ai.dev@gmail.com or in insevere cases over an Issue.
|
||||
+136
-130
@@ -556,7 +556,7 @@ def _enforce_module_access():
|
||||
flash(msg, 'info')
|
||||
|
||||
if name != 'inventory' and cfg.MODULES.is_enabled('inventory'):
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
elif name != 'library' and cfg.MODULES.is_enabled('library'):
|
||||
return redirect('/library')
|
||||
|
||||
@@ -630,7 +630,7 @@ def handle_build_error(e):
|
||||
return redirect('/library')
|
||||
|
||||
if 'username' in session:
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
@@ -708,10 +708,10 @@ def _permission_denied_fallback_endpoint(permissions, current_endpoint=None):
|
||||
is_admin_user = bool(username and us.check_admin(username))
|
||||
admin_home_allowed = _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings')
|
||||
|
||||
for candidate in ('my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum', 'home'):
|
||||
for candidate in ('my_borrowed_items', 'tutorial_page', 'notifications_view', 'impressum', 'home_admin'):
|
||||
if current_endpoint and candidate == current_endpoint:
|
||||
continue
|
||||
if candidate == 'home' and is_admin_user and not admin_home_allowed:
|
||||
if candidate == 'home_admin' and is_admin_user and not admin_home_allowed:
|
||||
continue
|
||||
if _page_access_allowed(permissions, candidate):
|
||||
return candidate
|
||||
@@ -1824,9 +1824,9 @@ def _excel_list(value):
|
||||
seen = set()
|
||||
unique = []
|
||||
for entry in cleaned:
|
||||
if entry not in seen:
|
||||
if str(entry) not in seen:
|
||||
unique.append(entry)
|
||||
seen.add(entry)
|
||||
seen.add(str(entry))
|
||||
return unique
|
||||
|
||||
|
||||
@@ -1984,7 +1984,7 @@ def _upload_student_cards_excel():
|
||||
|
||||
if not current_permissions['actions'].get('can_manage_user', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
|
||||
if not cfg.MODULES.is_enabled('student_cards'):
|
||||
flash('Schülerausweis-Modul ist deaktiviert.', 'error')
|
||||
@@ -2178,7 +2178,7 @@ def _upload_excel_items(scope='inventory'):
|
||||
if is_library_scope:
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
excel_file = request.files.get(file_field)
|
||||
if not excel_file or not excel_file.filename:
|
||||
@@ -2194,7 +2194,7 @@ def _upload_excel_items(scope='inventory'):
|
||||
# Allow CSV imports for authenticated non-admin users only for inventory (non-library)
|
||||
if not (is_csv and not is_library_scope and 'username' in session):
|
||||
flash('Einfüge-Rechte erforderlich.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
filename_lower = excel_file.filename.lower()
|
||||
if not filename_lower.endswith(('.xlsx', '.csv')):
|
||||
@@ -2882,6 +2882,7 @@ def user_status():
|
||||
|
||||
|
||||
##################################################### changes to be made to account for the new account permison managment system ##############################
|
||||
|
||||
@app.route('/')
|
||||
def home():
|
||||
"""
|
||||
@@ -2895,33 +2896,7 @@ def home():
|
||||
flash('Bitte mit registriertem Konto anmelden!', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if not cfg.MODULES.is_enabled('inventory'):
|
||||
if cfg.MODULES.is_enabled('library'):
|
||||
return redirect('/library')
|
||||
else:
|
||||
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
|
||||
|
||||
elif not us.check_admin(session['username']):
|
||||
return render_template(
|
||||
'main.html',
|
||||
username=session['username'],
|
||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||
mail_module_enabled=cfg.MODULES.is_enabled('mail'),
|
||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
||||
open_item=request.args.get('open_item')
|
||||
)
|
||||
else:
|
||||
permissions = _get_current_user_permissions() or us.build_default_permission_payload('standard_user')
|
||||
if _page_access_allowed(permissions, 'home_admin') and _action_access_allowed(permissions, 'can_manage_settings'):
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint='home')
|
||||
if fallback_endpoint == 'logout':
|
||||
flash('Für diesen Benutzer sind aktuell keine Seiten freigegeben.', 'error')
|
||||
return redirect(url_for(fallback_endpoint))
|
||||
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
@app.route('/home_admin')
|
||||
def home_admin():
|
||||
@@ -2942,9 +2917,12 @@ def home_admin():
|
||||
else:
|
||||
return "Weder Inventar- noch Bibliotheks-Modul sind aktiviert.", 403
|
||||
|
||||
if not us.check_admin(session['username']):
|
||||
current_permissions = us.get_effective_permissions(session['username'])
|
||||
|
||||
if not current_permissions['pages'].get('home', False):
|
||||
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
|
||||
return redirect(url_for('login'))
|
||||
return redirect(url_for('library_view'))
|
||||
|
||||
return render_template(
|
||||
'main_admin.html',
|
||||
username=session['username'],
|
||||
@@ -3052,7 +3030,7 @@ def library_view():
|
||||
return redirect(url_for('login'))
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
return render_template(
|
||||
'library_table.html',
|
||||
@@ -3080,7 +3058,7 @@ def library_loans_admin():
|
||||
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
_ensure_audit_indexes_once()
|
||||
|
||||
@@ -3179,6 +3157,7 @@ def library_loans_admin():
|
||||
'damage_text': (damage_reports[0].get('description', '') if damage_reports else ''),
|
||||
'available': bool(item_doc.get('Verfuegbar', False)),
|
||||
'last_updated': fmt_dt(item_doc.get('LastUpdated')),
|
||||
'acquisition_costs': item_doc.get('Anschaffungskosten', "")
|
||||
})
|
||||
|
||||
return render_template(
|
||||
@@ -3627,7 +3606,8 @@ def api_item_detail(item_id):
|
||||
borrows_html = ''
|
||||
if borrow_records:
|
||||
rows = []
|
||||
for rec in borrow_records:
|
||||
|
||||
for rec in borrow_records[:3]:
|
||||
user_raw = rec.get('User')
|
||||
try:
|
||||
user = decrypt_text(user_raw) if user_raw is not None else ''
|
||||
@@ -3637,7 +3617,6 @@ def api_item_detail(item_id):
|
||||
start = fmt_dt(rec.get('Start'))
|
||||
end = fmt_dt(rec.get('End'))
|
||||
notes = html.escape(str(rec.get('Notes') or ''))
|
||||
|
||||
rows.append(
|
||||
f"<li><strong>{html.escape(str(user or '-'))}</strong> — "
|
||||
f"{html.escape(str(status))} — "
|
||||
@@ -3675,7 +3654,7 @@ def api_library_item_update(item_id):
|
||||
|
||||
if not current_permissions['actions'].get('can_edit', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
return jsonify({'ok': False, 'message': 'Bibliotheks-Modul ist deaktiviert.'}), 403
|
||||
|
||||
@@ -3908,7 +3887,7 @@ def student_cards_admin():
|
||||
|
||||
if not current_permissions['actions'].get('can_manage_users', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
if not cfg.MODULES.is_enabled('student_cards'):
|
||||
flash('Schülerausweis-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home_admin'))
|
||||
@@ -4053,7 +4032,7 @@ def student_cards_print():
|
||||
|
||||
if not current_permissions['actions'].get('can_manage_users', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
if not cfg.MODULES.is_enabled('student_cards'):
|
||||
flash('Schülerausweis-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home_admin'))
|
||||
@@ -4087,7 +4066,7 @@ def student_card_barcode_print():
|
||||
|
||||
if not current_permissions['actions'].get('can_manage_users', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
if not cfg.MODULES.is_enabled('student_cards'):
|
||||
flash('Schülerausweis-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home_admin'))
|
||||
@@ -4120,7 +4099,7 @@ def student_card_barcode_download():
|
||||
|
||||
if not current_permissions['actions'].get('can_manage_users', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
if not cfg.MODULES.is_enabled('student_cards'):
|
||||
flash('Schülerausweis-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home_admin'))
|
||||
@@ -4304,7 +4283,7 @@ def student_card_single_barcode_download(card_id):
|
||||
|
||||
if not current_permissions['actions'].get('can_manage_users', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
if not cfg.MODULES.is_enabled('student_cards'):
|
||||
flash('Schülerausweis-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home_admin'))
|
||||
@@ -4478,7 +4457,7 @@ def login():
|
||||
flask.Response: Rendered template or redirect
|
||||
"""
|
||||
if 'username' in session:
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
@@ -4514,7 +4493,7 @@ def login():
|
||||
fallback_endpoint = _permission_denied_fallback_endpoint(permissions, current_endpoint='login')
|
||||
return redirect(url_for(fallback_endpoint))
|
||||
else:
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
else:
|
||||
app.logger.warning(f"Login failed: username={encrypt_text(username)!r} tenant={current_tenant_id or 'default'} db={current_tenant_db} host={request.host} ip={encrypt_text(request.remote_addr)}")
|
||||
flash('Ungültige Anmeldedaten', 'error')
|
||||
@@ -4584,7 +4563,7 @@ def change_password():
|
||||
# Update the password
|
||||
if us.update_password(session['username'], new_password):
|
||||
flash('Ihr Passwort wurde erfolgreich geändert.', 'success')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
else:
|
||||
flash('Fehler beim Ändern des Passworts. Bitte versuchen Sie es später erneut.', 'error')
|
||||
|
||||
@@ -5178,7 +5157,7 @@ def upload_item():
|
||||
elif cfg.MODULES.is_enabled('library') and _page_access_allowed(permissions, 'home_library'):
|
||||
success_redirect_endpoint = 'home_library'
|
||||
else:
|
||||
success_redirect_endpoint = 'home'
|
||||
success_redirect_endpoint = 'home_admin'
|
||||
|
||||
# Detect if request is from mobile device
|
||||
is_mobile = 'Mobile' in request.headers.get('User-Agent', '')
|
||||
@@ -6384,11 +6363,11 @@ def delete_item(id):
|
||||
|
||||
if not current_permissions['actions'].get('can_delete', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion (Löschen) auszuführen.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
if not cfg.MODULES.is_enabled('inventory'):
|
||||
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
|
||||
# Resolve all related item ids (grouped variants) and load their data
|
||||
group_item_ids = it.get_group_item_ids(id)
|
||||
@@ -6448,11 +6427,11 @@ def delete_library_item(id):
|
||||
|
||||
if not current_permissions['actions'].get('can_delete', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion (Löschen) auszuführen.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
# Verify item exists and is a library item
|
||||
try:
|
||||
@@ -6522,11 +6501,11 @@ def bulk_delete_items():
|
||||
|
||||
if not current_permissions['actions'].get('can_delete', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion (Löschen) auszuführen.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
if not cfg.MODULES.is_enabled('inventory'):
|
||||
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
item_ids = payload.get('item_ids') or request.form.getlist('item_ids')
|
||||
@@ -6587,11 +6566,11 @@ def edit_item(id):
|
||||
|
||||
if not current_permissions['actions'].get('can_edit', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion (Löschen) auszuführen.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
if not cfg.MODULES.is_enabled('inventory'):
|
||||
flash('Bibliotheks-Modul ist deaktiviert.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
|
||||
# Strip whitespace from all text fields
|
||||
name = sanitize_form_value(request.form.get('name'))
|
||||
@@ -6721,7 +6700,7 @@ def update_group():
|
||||
|
||||
if not current_permissions['actions'].get('can_edit', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion (Löschen) auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
|
||||
data = request.get_json()
|
||||
series_group_id = data.get('series_group_id')
|
||||
@@ -6758,6 +6737,8 @@ def update_group():
|
||||
{'$set': shared_update}
|
||||
)
|
||||
|
||||
app.logger.debug(f"Individual Codes: {individual_items}")
|
||||
|
||||
# B. Apply Unique Codes to specific items
|
||||
# We iterate through the provided list to update the specific code for each ID
|
||||
for item in individual_items:
|
||||
@@ -6771,6 +6752,8 @@ def update_group():
|
||||
)
|
||||
|
||||
client.close()
|
||||
app.logger.debug("Success When Updating the Item")
|
||||
flash("Objekte wurden erfolgreich Bearbeitet", "success")
|
||||
return jsonify({'success': True, 'message': 'Gruppe und individuelle Codes aktualisiert'})
|
||||
|
||||
except Exception as e:
|
||||
@@ -7005,7 +6988,7 @@ def ausleihen(id):
|
||||
if requested_return_to == 'library' and cfg.MODULES.is_enabled('library'):
|
||||
redirect_target = 'library_view'
|
||||
else:
|
||||
redirect_target = 'home_admin' if us.check_admin(username) else 'home' # check for plausability
|
||||
redirect_target = 'home_admin'
|
||||
|
||||
item = it.get_item(id)
|
||||
if not item:
|
||||
@@ -7069,9 +7052,10 @@ def ausleihen(id):
|
||||
if current_permissions['actions'].get('can_borrow') and not is_library_item:
|
||||
if student_card_id:
|
||||
student_user = us.get_user_by_student_card(student_card_id)
|
||||
app.logger.debug(f"Borrowing on behalf of student card {student_card_id}: found user {student_user}")
|
||||
if not student_user:
|
||||
flash('Keine Schülerin/kein Schüler mit dieser Ausweis-ID gefunden.', 'error')
|
||||
return redirect(url_for('home_admin'))
|
||||
return redirect(url_for('library_view'))
|
||||
effective_borrower = student_user.get('Username') or student_user.get('username') or username
|
||||
if borrow_duration_days is None:
|
||||
try:
|
||||
@@ -7321,13 +7305,13 @@ def zurueckgeben(id):
|
||||
item = it.get_item(id)
|
||||
if not item:
|
||||
flash('Element nicht gefunden', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
username = session['username']
|
||||
|
||||
current_permissions = us.get_effective_permissions(session['username'])
|
||||
|
||||
if not item.get('Verfuegbar', True) and (current_permissions['actions'].get('can_manage_users', False)) or item.get('User') == username):
|
||||
if not item.get('Verfuegbar', True) and (current_permissions['actions'].get('can_manage_users', False)) or item.get('User') == username:
|
||||
try:
|
||||
# Get ALL active borrowing records for this item and complete them
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
@@ -7903,20 +7887,20 @@ def terminplan():
|
||||
|
||||
if not cfg.MODULES.is_enabled('terminplan'):
|
||||
flash('Der Terminplaner ist deaktiviert.', 'info')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
# Make sure the template exists
|
||||
template_path = os.path.join(BASE_DIR, 'templates', 'terminplan.html')
|
||||
if not os.path.exists(template_path):
|
||||
print(f"Template file not found: {template_path}")
|
||||
flash('Vorlage nicht gefunden. Bitte kontaktieren Sie den Administrator.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
return render_template('terminplan.html', school_periods=SCHOOL_PERIODS)
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error rendering terminplan: {e}")
|
||||
flash('Ein Fehler ist beim Anzeigen des Kalenders aufgetreten.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
|
||||
'''-------------------------------------------------------------------------------------------------------------ADMIN ROUTES------------------------------------------------------------------------------------------------------------------'''
|
||||
@@ -7924,66 +7908,69 @@ def terminplan():
|
||||
@app.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
"""
|
||||
User registration route.false
|
||||
User registration route.
|
||||
Returns:
|
||||
flask.Response: Rendered template or redirect
|
||||
"""
|
||||
if 'username' not in session:
|
||||
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adrrese zu nutzen, versuchen sie es erneut nach dem sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
|
||||
flash('Ihnen ist es nicht gestattet auf dieser Internetanwendung, die eben besuchte Adresse zu nutzen, versuchen Sie es erneut, nachdem Sie sich mit einem berechtigten Nutzer angemeldet haben!', 'error')
|
||||
return redirect(url_for('login'))
|
||||
if 'username' in session:
|
||||
if request.method == 'POST':
|
||||
password = request.form['password']
|
||||
name = (request.form.get('name') or '').strip()
|
||||
last_name = (request.form.get('last-name') or '').strip()
|
||||
|
||||
if request.method == 'POST':
|
||||
password = request.form['password']
|
||||
name = (request.form.get('name') or '').strip()
|
||||
last_name = (request.form.get('last-name') or '').strip()
|
||||
|
||||
# Generate a username from the first 2 letters of first and last name.
|
||||
username = us.build_unique_username_from_name(name, last_name)
|
||||
username = us.build_unique_username_from_name(name, last_name)
|
||||
|
||||
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
|
||||
use_custom_permissions = request.form.get('use_custom_permissions') == 'on'
|
||||
|
||||
if not username or not password or not name or not last_name:
|
||||
flash('Bitte füllen Sie alle Felder aus', 'error')
|
||||
return redirect(url_for('register'))
|
||||
|
||||
permission_preset = (request.form.get('permission_preset') or 'standard_user').strip()
|
||||
use_custom_permissions = request.form.get('use_custom_permissions') == 'on'
|
||||
if not us.check_password_strength(password):
|
||||
flash('Passwort ist zu schwach oder entspricht nicht den Richtlinien', 'error')
|
||||
return redirect(url_for('register'))
|
||||
|
||||
action_permissions = None
|
||||
page_permissions = None
|
||||
if use_custom_permissions:
|
||||
action_permissions = {}
|
||||
for action_key, _ in PERMISSION_ACTION_OPTIONS:
|
||||
action_permissions[action_key] = request.form.get(f'action_{action_key}') == 'on'
|
||||
|
||||
page_permissions = {}
|
||||
for endpoint_name, _ in PERMISSION_PAGE_OPTIONS:
|
||||
page_permissions[endpoint_name] = request.form.get(f'page_{endpoint_name}') == 'on'
|
||||
|
||||
if not username or not password or not name or not last_name:
|
||||
flash('Bitte füllen Sie alle Felder aus', 'error')
|
||||
return redirect(url_for('register'))
|
||||
if not us.check_password_strength(password):
|
||||
flash('Passwort ist zu schwach', 'error')
|
||||
return redirect(url_for('register'))
|
||||
|
||||
action_permissions = None
|
||||
page_permissions = None
|
||||
if use_custom_permissions:
|
||||
action_permissions = {}
|
||||
for action_key, _ in PERMISSION_ACTION_OPTIONS:
|
||||
action_permissions[action_key] = request.form.get(f'action_{action_key}') == 'on'
|
||||
|
||||
page_permissions = {}
|
||||
for endpoint_name, _ in PERMISSION_PAGE_OPTIONS:
|
||||
page_permissions[endpoint_name] = request.form.get(f'page_{endpoint_name}') == 'on'
|
||||
|
||||
us.add_user(
|
||||
username,
|
||||
password,
|
||||
name,
|
||||
last_name,
|
||||
is_student=False,
|
||||
student_card_id=None,
|
||||
max_borrow_days=None,
|
||||
permission_preset=permission_preset,
|
||||
action_permissions=action_permissions,
|
||||
page_permissions=page_permissions,
|
||||
)
|
||||
return redirect(url_for('home'))
|
||||
return render_template(
|
||||
'register.html',
|
||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS
|
||||
us.add_user(
|
||||
username,
|
||||
password,
|
||||
name,
|
||||
last_name,
|
||||
is_student=False,
|
||||
student_card_id=None,
|
||||
max_borrow_days=None,
|
||||
permission_preset=permission_preset,
|
||||
action_permissions=action_permissions,
|
||||
page_permissions=page_permissions,
|
||||
)
|
||||
flash('Sie sind nicht berechtigt, diese Seite anzuzeigen', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
flash(f'Benutzer "{username}" wurde erfolgreich registriert!', 'success')
|
||||
return redirect(url_for('home_admin'))
|
||||
|
||||
return render_template(
|
||||
'register.html',
|
||||
library_module_enabled=cfg.MODULES.is_enabled('library'),
|
||||
student_cards_module_enabled=cfg.MODULES.is_enabled('student_cards'),
|
||||
student_default_borrow_days=cfg.STUDENT_DEFAULT_BORROW_DAYS,
|
||||
student_max_borrow_days=cfg.STUDENT_MAX_BORROW_DAYS,
|
||||
permission_presets=getattr(us, 'PERMISSION_PRESETS', {}),
|
||||
permission_action_options=PERMISSION_ACTION_OPTIONS,
|
||||
permission_page_options=PERMISSION_PAGE_OPTIONS
|
||||
)
|
||||
|
||||
@app.route('/user_del', methods=['GET'])
|
||||
def user_del():
|
||||
@@ -8620,17 +8607,36 @@ def admin_create_invoice(borrow_id):
|
||||
flash('Für diese Ausleihe existiert bereits eine Rechnung. Bitte Korrekturbuchung verwenden.', 'warning')
|
||||
return redirect(url_for('admin_borrowings'))
|
||||
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
student_cards = db['student_cards']
|
||||
card = student_cards.find_one({'_id': ObjectId(borrow_id)})
|
||||
card = _decrypt_student_card_doc(card)
|
||||
client.close()
|
||||
borrower = card['SchülerName']
|
||||
student_id = borrow_doc.get('AusweisId')
|
||||
borrower = "Unbekannt"
|
||||
|
||||
# 1. Prüfen: Gibt es eine AusweisId?
|
||||
if student_id:
|
||||
try:
|
||||
card = student_cards.find_one({'_id': ObjectId(student_id)})
|
||||
except Exception:
|
||||
card = student_cards.find_one({'_id': student_id})
|
||||
|
||||
if not card:
|
||||
flash('Der zugehörige Schülerausweis wurde in der Datenbank nicht gefunden.', 'error')
|
||||
return redirect(url_for('admin_borrowings'))
|
||||
|
||||
card = _decrypt_student_card_doc(card)
|
||||
borrower = card.get('SchülerName', 'Unbekannt')
|
||||
|
||||
# 2. Fallback: Wenn keine AusweisId da ist, schauen wir, ob ein Name unter 'User' gespeichert wurde
|
||||
else:
|
||||
fallback_user = borrow_doc.get('User')
|
||||
if fallback_user:
|
||||
borrower = str(fallback_user)
|
||||
else:
|
||||
flash('Dieser Ausleihe ist weder eine AusweisId noch ein Benutzername zugeordnet.', 'error')
|
||||
return redirect(url_for('admin_borrowings'))
|
||||
|
||||
invoice_number = existing_invoice.get('invoice_number') or _build_invoice_number(borrow_doc['_id'], now)
|
||||
item_name = item_doc.get('Name', '')
|
||||
item_code = item_doc.get('Code_4', '')
|
||||
item_code = item_doc.get('Code_4', '')
|
||||
|
||||
invoice_data = {
|
||||
'invoice_number': invoice_number,
|
||||
@@ -8639,7 +8645,7 @@ def admin_create_invoice(borrow_id):
|
||||
'damage_reason': damage_reason,
|
||||
'created_at': now,
|
||||
'created_by': session.get('username', ''),
|
||||
'borrower': borrower,
|
||||
'borrower': decrypt_text(borrower),
|
||||
'item_id': str(item_doc['_id']),
|
||||
'item_name': item_name,
|
||||
'item_code': item_code,
|
||||
@@ -9089,7 +9095,7 @@ def library_item_invoices(item_id):
|
||||
|
||||
if not current_permissions['pages'].get('library_loans_admin', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
|
||||
client = None
|
||||
try:
|
||||
@@ -9996,7 +10002,7 @@ def fetch_book_info(isbn):
|
||||
|
||||
if not current_permissions['actions'].get('can_insert', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
|
||||
@@ -10047,7 +10053,7 @@ def download_book_cover():
|
||||
|
||||
if not current_permissions['actions'].get('can_insert', False):
|
||||
flash('Ihnen fehlen die nötigen Berechtigungen, um diese Aktion auszuführen.', 'error')
|
||||
return redirect(url_for('library'))
|
||||
return redirect(url_for('library_view'))
|
||||
if not cfg.MODULES.is_enabled('library'):
|
||||
return jsonify({"error": "Bibliotheks-Modul ist deaktiviert."}), 403
|
||||
|
||||
@@ -10419,7 +10425,7 @@ def notifications_view():
|
||||
except Exception as exc:
|
||||
app.logger.error(f"Error loading notifications: {exc}")
|
||||
flash('Fehler beim Laden der Benachrichtigungen.', 'error')
|
||||
return redirect(url_for('home'))
|
||||
return redirect(url_for('home_admin'))
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
@@ -220,15 +220,15 @@ SSL_CERT = _get(_conf, ['ssl', 'cert'], DEFAULTS['ssl']['cert'])
|
||||
SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
|
||||
|
||||
# Email settings
|
||||
EMAIL_ENABLED = _get(_conf, ['email', 'enabled'], False)
|
||||
EMAIL_SMTP_HOST = _get(_conf, ['email', 'smtp_host'], 'smtp.gmail.com')
|
||||
EMAIL_SMTP_PORT = int(_get(_conf, ['email', 'smtp_port'], 587))
|
||||
EMAIL_USE_TLS = bool(_get(_conf, ['email', 'use_tls'], True))
|
||||
EMAIL_USERNAME = _get(_conf, ['email', 'username'], '')
|
||||
EMAIL_PASSWORD = _get(_conf, ['email', 'password'], '')
|
||||
EMAIL_ENABLED = bool(os.getenv('EMAIL_ENABLED', False))
|
||||
EMAIL_SMTP_HOST = str(os.getenv('EMAIL_SMTP_HOST', False))
|
||||
EMAIL_SMTP_PORT = int(os.getenv('EMAIL_SMTP_PORT', 587))
|
||||
EMAIL_USE_TLS = True
|
||||
EMAIL_USERNAME = str(os.getenv('EMAIL_USERNAME', False))
|
||||
EMAIL_PASSWORD = str(os.getenv('EMAIL_PASSWORD', False))
|
||||
EMAIL_FROM_ADDRESS = _get(_conf, ['email', 'from_address'], EMAIL_USERNAME)
|
||||
EMAIL_DEFAULT_SENDER_NAME = _get(_conf, ['email', 'default_sender_name'], 'Inventarsystem')
|
||||
EMAIL_TIMEOUT_SECONDS = int(_get(_conf, ['email', 'timeout_seconds'], 30))
|
||||
EMAIL_DEFAULT_SENDER_NAME = "Invario Inventarsystem Sender"
|
||||
EMAIL_TIMEOUT_SECONDS = 20
|
||||
|
||||
# School periods
|
||||
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
|
||||
@@ -278,6 +278,7 @@ INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules',
|
||||
TERMINPLAN_MODULE_ENABLED = _TenantAwareBool('terminplan', _get(_conf, ['modules', 'terminplan', 'enabled'], DEFAULTS['modules']['terminplan']['enabled']))
|
||||
LIBRARY_MODULE_ENABLED = _TenantAwareBool('library', _get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
|
||||
STUDENT_CARDS_MODULE_ENABLED = _TenantAwareBool('student_cards', _get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
|
||||
MAIL_ADD_ON_ENABLED = _TenantAwareBool('mail', _get(_conf, ['email', 'enabled'], False))
|
||||
|
||||
def _match_inventory(path):
|
||||
if not path: return False
|
||||
@@ -297,11 +298,16 @@ def _match_student_cards(path):
|
||||
if not path: return False
|
||||
return path.startswith(('/student_cards'))
|
||||
|
||||
def _match_mail(path):
|
||||
if not path: return False
|
||||
return path.startswith(('/'))
|
||||
|
||||
# Register core modules into the pipeline
|
||||
MODULES.register('inventory', INVENTORY_MODULE_ENABLED, _match_inventory)
|
||||
MODULES.register('terminplan', TERMINPLAN_MODULE_ENABLED, _match_terminplan)
|
||||
MODULES.register('library', LIBRARY_MODULE_ENABLED, _match_library)
|
||||
MODULES.register('student_cards', STUDENT_CARDS_MODULE_ENABLED, _match_student_cards)
|
||||
MODULES.register('mail', MAIL_ADD_ON_ENABLED, _match_mail)
|
||||
|
||||
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
|
||||
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ["modules", "student_cards", "max_borrow_days"], DEFAULTS["modules"]["student_cards"]["max_borrow_days"]))
|
||||
|
||||
@@ -97,18 +97,18 @@ def build_name_synonym(first_name, last_name=''):
|
||||
last = _clean_name_fragment(last_name)
|
||||
|
||||
if first and last:
|
||||
return (first[:2] + last[:2]).title()
|
||||
return (first[:3] + last[:3]).title()
|
||||
|
||||
combined = (first + last)
|
||||
if not combined:
|
||||
return 'User'
|
||||
return combined[:4].title()
|
||||
return combined[:6].title()
|
||||
|
||||
|
||||
def build_username_from_name(first_name, last_name=''):
|
||||
"""
|
||||
Build a deterministic username abbreviation from first and last name.
|
||||
Uses 2 letters from each name and stores it lowercase.
|
||||
Uses 3 letters from each name and stores it lowercase.
|
||||
|
||||
Args:
|
||||
first_name (str): First name
|
||||
@@ -123,12 +123,12 @@ def build_username_from_name(first_name, last_name=''):
|
||||
|
||||
def build_unique_username_from_name(first_name, last_name=''):
|
||||
"""
|
||||
Build a unique username from the first 2 letters of the first name and
|
||||
the first 2 letters of the last name.
|
||||
Build a unique username from the first 3 letters of the first name and
|
||||
the first 3 letters of the last name.
|
||||
"""
|
||||
first = _clean_name_fragment(first_name)
|
||||
last = _clean_name_fragment(last_name)
|
||||
base_username = (first[:2] + last[:2]).lower()
|
||||
base_username = (first[:3] + last[:3]).lower()
|
||||
|
||||
if not base_username:
|
||||
base_username = 'user'
|
||||
@@ -594,8 +594,8 @@ def student_card_exists(student_card_id):
|
||||
return False
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
exists = users.find_one({'StudentCardId': normalized}) is not None
|
||||
users = db['student_cards']
|
||||
exists = users.find_one({'SchülerName': normalized}) is not None
|
||||
client.close()
|
||||
return exists
|
||||
|
||||
@@ -607,8 +607,8 @@ def get_user_by_student_card(student_card_id):
|
||||
return None
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
users = db['users']
|
||||
found_user = users.find_one({'StudentCardId': normalized})
|
||||
users = db['student_cards']
|
||||
found_user = users.find_one({'SchülerName': normalized})
|
||||
client.close()
|
||||
return found_user
|
||||
|
||||
|
||||
@@ -1,51 +1,98 @@
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from modules.module_registry import ModuleRegistry as mr
|
||||
from email.mime.text import MIMEText
|
||||
import smtplib
|
||||
import time
|
||||
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
|
||||
def _build_smtp_client():
|
||||
smtp = smtplib.SMTP(cfg.EMAIL_SMTP_HOST, cfg.EMAIL_SMTP_PORT, timeout=cfg.EMAIL_TIMEOUT_SECONDS)
|
||||
smtp = smtplib.SMTP(
|
||||
cfg.EMAIL_SMTP_HOST,
|
||||
cfg.EMAIL_SMTP_PORT,
|
||||
timeout=cfg.EMAIL_TIMEOUT_SECONDS,
|
||||
)
|
||||
smtp.ehlo()
|
||||
if cfg.EMAIL_USE_TLS:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
if cfg.EMAIL_USERNAME:
|
||||
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or '')
|
||||
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or "")
|
||||
return smtp
|
||||
|
||||
def send(email: list, subject: str, note: str, sender: str) -> bool:
|
||||
"""
|
||||
Sends the email with the link to the Clients
|
||||
|
||||
Input:
|
||||
- email: Email list of all the addresses to send the link to ["","",""]
|
||||
- subject: Subject of the email
|
||||
- note: Note that is send with the Emails
|
||||
def send(email: list | str, subject: str, note: str, sender: str) -> bool:
|
||||
"""Sends the email with the link to the Clients."""
|
||||
if not cfg.MODULES.is_enabled("mail"):
|
||||
print("Debug: Module not enabled")
|
||||
return False
|
||||
|
||||
if isinstance(email, str):
|
||||
email = [email]
|
||||
|
||||
body_message = note
|
||||
|
||||
HTML_SIGNATURE = f"""
|
||||
<table cellpadding="0" cellspacing="0" border="0" style="font-family: Arial, Helvetica, sans-serif; font-size: 13px; color: #333333; line-height: 1.5;">
|
||||
<tr>
|
||||
<td>
|
||||
<p style="margin:0 0 12px 0;">Mit freundlichen Grüßen</p>
|
||||
<p style="margin:0;"><strong style="font-size:16px;">Automatisierter Email Verteiler für die Schule: {cfg.get_school_info().get("name")}</strong><br></p>
|
||||
<p style="margin:12px 0 0 0;"><strong>Invario UG</strong><br>Am Sportplatz 10<br>83052 Bruckmühl</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
|
||||
text_content = f"{body_message}\n\nMit freundlichen Grüßen\n{sender}\nInvario UG"
|
||||
|
||||
html_content = f"""
|
||||
<html>
|
||||
<body>
|
||||
<p>{body_message}</p>
|
||||
<br>
|
||||
{HTML_SIGNATURE}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
mails_per_second = 10
|
||||
interval = 1.0 / mails_per_second
|
||||
|
||||
Output:
|
||||
- bool: true if the sending worked and false if it didnt
|
||||
"""
|
||||
if not mr.registry.is_enabled('mail'):
|
||||
return False
|
||||
else:
|
||||
msg = MIMEMultipart()
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
|
||||
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
|
||||
msg.attach(MIMEText(note))
|
||||
smtp = None
|
||||
try:
|
||||
smtp = _build_smtp_client()
|
||||
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
smtp = _build_smtp_client()
|
||||
|
||||
for i, recipient in enumerate(email):
|
||||
start_time = time.time()
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = str(subject)
|
||||
msg["From"] = f"{sender} <{cfg.EMAIL_USERNAME}>"
|
||||
msg["To"] = str(recipient)
|
||||
|
||||
msg.attach(MIMEText(text_content, "plain"))
|
||||
msg.attach(MIMEText(html_content, "html"))
|
||||
|
||||
smtp.sendmail(
|
||||
from_addr=cfg.EMAIL_USERNAME,
|
||||
to_addrs=[recipient],
|
||||
msg=msg.as_string()
|
||||
)
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
sleep_time = interval - elapsed_time
|
||||
|
||||
if sleep_time > 0 and i < len(email) - 1:
|
||||
time.sleep(sleep_time)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Debug: Fehler beim Senden der E-Mail: {e}")
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
if smtp:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -22,7 +22,7 @@ def _resolve_public_base_url() -> str:
|
||||
subdomain = ''
|
||||
if tenant_context:
|
||||
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
|
||||
return f"https://{subdomain}.invario.eu" if subdomain else "https://invario.eu"
|
||||
return (f"https://{subdomain}.invario-software.de") if subdomain else "https://invario-software.de"
|
||||
|
||||
|
||||
def _current_tenant_id() -> str:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
flask
|
||||
flask-wtf
|
||||
werkzeug
|
||||
gunicorn
|
||||
pymongo
|
||||
|
||||
+24
-15
@@ -1375,8 +1375,10 @@
|
||||
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen / Defekte Items</a></li>
|
||||
{% endif %}
|
||||
{% if student_cards_module_enabled %}
|
||||
{% if current_permissions.actions.get('can_manage_users', False) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if current_permissions.pages.get('admin_school_settings', False) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
|
||||
{% endif %}
|
||||
@@ -1619,11 +1621,10 @@
|
||||
<div id="cookie-banner" role="dialog" aria-live="polite" aria-label="Cookie-Hinweis">
|
||||
<div class="cb-inner">
|
||||
<div class="cb-text">
|
||||
Wir verwenden technisch notwendige Cookies, um Ihre Sitzung zu verwalten und die Anwendung bereitzustellen. Bitte akzeptieren Sie Cookies, um fortzufahren.
|
||||
Wir verwenden ausschließlich technisch notwendige Cookies, um Ihre Sitzung zu verwalten und die Anwendung bereitzustellen. Bitte bestätigen Sie dies, um fortzufahren.
|
||||
</div>
|
||||
<div class="cb-actions">
|
||||
<button class="btn-decline" id="cookie-decline">Ablehnen</button>
|
||||
<button class="btn-accept" id="cookie-accept">Akzeptieren</button>
|
||||
<button class="btn-accept" id="cookie-accept">Notwendige Cookies akzeptieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1728,31 +1729,39 @@
|
||||
(function(){
|
||||
function getCookie(name){
|
||||
const v = document.cookie.split(';').map(s=>s.trim());
|
||||
for(const c of v){ if(c.startsWith(name+'=')) return decodeURIComponent(c.split('=')[1]); }
|
||||
for(const c of v){
|
||||
if(c.startsWith(name+'=')) return decodeURIComponent(c.split('=')[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCookie(name, value, days){
|
||||
const d = new Date(); d.setTime(d.getTime() + (days*24*60*60*1000));
|
||||
const d = new Date();
|
||||
d.setTime(d.getTime() + (days*24*60*60*1000));
|
||||
document.cookie = name + '=' + encodeURIComponent(value) + ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax';
|
||||
}
|
||||
function showBanner(){ var el = document.getElementById('cookie-banner'); if(el) el.style.display = 'block'; }
|
||||
function hideBanner(){ var el = document.getElementById('cookie-banner'); if(el) el.style.display = 'none'; }
|
||||
|
||||
// If not decided yet, show banner and block app until decision
|
||||
function showBanner(){
|
||||
var el = document.getElementById('cookie-banner');
|
||||
if(el) el.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideBanner(){
|
||||
var el = document.getElementById('cookie-banner');
|
||||
if(el) el.style.display = 'none';
|
||||
}
|
||||
|
||||
// Prüfen, ob der Nutzer bereits zugestimmt hat
|
||||
const consent = getCookie('cookie_consent');
|
||||
if(!consent){
|
||||
showBanner();
|
||||
// Optionally blur content until consent
|
||||
document.body.style.filter = 'none';
|
||||
}
|
||||
|
||||
// Nur noch der Akzeptieren-Button für vitale Cookies ist vorhanden
|
||||
document.getElementById('cookie-accept')?.addEventListener('click', function(){
|
||||
setCookie('cookie_consent','accepted',365);
|
||||
setCookie('cookie_consent', 'vital_accepted', 365);
|
||||
hideBanner();
|
||||
});
|
||||
document.getElementById('cookie-decline')?.addEventListener('click', function(){
|
||||
setCookie('cookie_consent','declined',365);
|
||||
window.location.href = 'https://www.ecosia.org/';
|
||||
});
|
||||
|
||||
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
|
||||
const isTutorialPage = window.location.pathname === {{ url_for('tutorial_page')|tojson }};
|
||||
|
||||
@@ -520,7 +520,16 @@
|
||||
window.openDamageReportPrompt = openDamageReportPrompt;
|
||||
|
||||
function openDamageInvoiceModal(row, description) {
|
||||
if (!damageInvoiceModal || !damageInvoiceForm) {
|
||||
const modal = document.getElementById('damage-invoice-modal');
|
||||
const form = document.getElementById('damage-invoice-form');
|
||||
const inputItem = document.getElementById('damage-invoice-item');
|
||||
const inputBorrower = document.getElementById('damage-invoice-borrower');
|
||||
const inputCode = document.getElementById('damage-invoice-code');
|
||||
const inputAmount = document.getElementById('damage-invoice-amount');
|
||||
const inputReason = document.getElementById('damage-invoice-reason');
|
||||
|
||||
if (!modal || !form) {
|
||||
console.error("Modal oder Formular nicht gefunden.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -530,23 +539,29 @@
|
||||
const itemCode = row.dataset.itemCode || '';
|
||||
const itemCost = row.dataset.itemCost || '';
|
||||
|
||||
damageInvoiceForm.action = "{{ url_for('admin_create_invoice', borrow_id='__BORROW_ID__') }}".replace('__BORROW_ID__', borrowId);
|
||||
damageInvoiceItem.value = itemName;
|
||||
damageInvoiceBorrower.value = borrower;
|
||||
damageInvoiceCode.value = itemCode;
|
||||
damageInvoiceAmount.value = String(itemCost).replace(' EUR', '').trim();
|
||||
damageInvoiceReason.value = description || `Schaden gemeldet für ${itemName}`;
|
||||
damageInvoiceModal.style.display = 'block';
|
||||
damageInvoiceAmount.focus();
|
||||
form.action = "{{ url_for('admin_create_invoice', borrow_id='__BORROW_ID__') }}".replace('__BORROW_ID__', borrowId);
|
||||
|
||||
inputItem.value = itemName;
|
||||
inputBorrower.value = borrower;
|
||||
inputCode.value = itemCode;
|
||||
|
||||
inputAmount.value = String(itemCost).replace(' EUR', '').trim();
|
||||
|
||||
inputReason.value = description || `Schaden gemeldet für ${itemName}`;
|
||||
|
||||
modal.style.display = 'block';
|
||||
inputAmount.focus();
|
||||
}
|
||||
|
||||
function closeDamageInvoiceModal() {
|
||||
const modal = document.getElementById('damage-invoice-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
window.openDamageInvoiceModal = openDamageInvoiceModal;
|
||||
|
||||
function closeDamageInvoiceModal() {
|
||||
if (damageInvoiceModal) {
|
||||
damageInvoiceModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
window.closeDamageInvoiceModal = closeDamageInvoiceModal;
|
||||
|
||||
if (damageInvoiceModal) {
|
||||
damageInvoiceModal.addEventListener('click', function(event) {
|
||||
|
||||
@@ -1371,7 +1371,8 @@
|
||||
await loadLibraryItems(); // Daten neu laden
|
||||
// renderTable(); // Ggf. Tabelle neu rendern
|
||||
} else {
|
||||
alert('Fehler: ' + result.message);
|
||||
closeEditLibraryModal();
|
||||
await loadLibraryItems();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
@@ -1416,8 +1417,8 @@
|
||||
<div>
|
||||
<label for="editLibraryType">Medientyp</label>
|
||||
<select id="editLibraryType" style="width: 100%;">
|
||||
<option value="book">Buch</option>
|
||||
<option value="schoolbook">Schulbuch</option>
|
||||
<option value="Buch">Buch</option>
|
||||
<option value="Schulbuch">Schulbuch</option>
|
||||
<option value="cd">CD</option>
|
||||
<option value="dvd">DVD</option>
|
||||
<option value="other">Sonstige Medien</option>
|
||||
@@ -1451,14 +1452,14 @@
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px;">
|
||||
<strong style="color: #0ea5e9;">Gruppen-Range (Total: <span id="editLibraryGroupCount"></span>)</strong>
|
||||
</div>
|
||||
|
||||
|
||||
<p style="margin: 5px 0; font-size: 12px; color: #555;">
|
||||
Alle Codes in dieser Gruppe:
|
||||
</p>
|
||||
|
||||
|
||||
<!-- Hier wird die Liste als Komma-Text eingefügt -->
|
||||
<div id="editLibraryAllCodes" style="font-family: monospace; font-size: 14px; font-weight: bold; color: #333; margin-top: 5px;"></div>
|
||||
|
||||
|
||||
<div style="margin-top: 15px; font-size: 11px; background: #e0f2fe; padding: 8px; border-radius: 4px;">
|
||||
<strong>Hinweis:</strong> Änderungen an Titel/Ort/Beschreibung werden auf <strong>alle</strong> Exemplare der Range übertragen.
|
||||
</div>
|
||||
|
||||
@@ -2309,6 +2309,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</script>
|
||||
|
||||
<div class="admin-content-container">
|
||||
{% if current_permissions.actions.get('can_edit', False) %}
|
||||
<!-- Admin conflict warning banner (populated by JS) -->
|
||||
<div id="conflict-banner" style="display:none; background:#fff3cd; border:1px solid #ffc107; border-left:4px solid #fd7e14; color:#856404; padding:12px 16px; border-radius:4px; margin-bottom:16px; position:relative;">
|
||||
<strong>⚠ Buchungskonflikte erkannt:</strong>
|
||||
@@ -2335,6 +2336,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
.catch(() => {});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<div class="content">
|
||||
<h1 style="position:relative;">Inventar Objekte
|
||||
<div class="view-switch-group">
|
||||
@@ -2344,14 +2347,17 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<button id="toggle-favorites-view" class="view-toggle-btn" title="Nur Merkliste anzeigen">
|
||||
<span id="favorites-view-icon">🔖</span>
|
||||
</button>
|
||||
{% if current_permissions.actions.get('can_delete', False) %}
|
||||
<div class="bulk-delete-inline-wrap">
|
||||
<button type="button" id="bulk-delete-drawer-toggle" class="view-toggle-btn bulk-delete-drawer-toggle" aria-expanded="false" title="Massenlöschung" onclick="toggleBulkDeleteDrawer()">
|
||||
<span class="drawer-icon">⚙</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</h1>
|
||||
<div id="items-indicator" class="items-indicator">Objekte im System: 0</div>
|
||||
{% if current_permissions.actions.get('can_delete', False) %}
|
||||
<div id="bulk-delete-drawer" class="bulk-delete-drawer" aria-hidden="true">
|
||||
<div class="bulk-delete-content">
|
||||
<strong>Massenlöschung nach Kriterien</strong>
|
||||
@@ -2364,6 +2370,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<button type="button" id="bulk-delete-button" class="danger" onclick="deleteSelectedItems()" disabled>Auswahl löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="filter-container">
|
||||
<div class="filter-group">
|
||||
<div class="filter-header">
|
||||
@@ -2469,6 +2476,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
|
||||
<!-- Add the edit modal form -->
|
||||
{% if current_permissions.actions.get('can_edit', False) %}
|
||||
<div id="edit-modal" class="item-modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-modal" onclick="closeEditModal()">×</span>
|
||||
@@ -2646,6 +2654,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Schedule Appointment Modal -->
|
||||
@@ -2724,7 +2733,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Initialize template variables before any JavaScript code -->
|
||||
<script>
|
||||
// Create a global object to hold server-side template values
|
||||
window.serverVars = {};
|
||||
@@ -3239,7 +3247,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ url_for('bulk_delete_items') }}', {
|
||||
fetch("{{ url_for('bulk_delete_items') }}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -3703,6 +3711,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
Für Löschung markieren
|
||||
</label>
|
||||
</div>
|
||||
{% if current_permissions.actions.get('can_borrow', False) %}
|
||||
${isAvailableForBorrow && !item.BlockedNow ?
|
||||
`<form method="POST" action="{{ url_for('ausleihen', id='') }}${item._id}">
|
||||
${isGroupedItem ? `
|
||||
@@ -3724,12 +3733,21 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
:
|
||||
`<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
|
||||
}
|
||||
{% endif %}
|
||||
{% if current_permissions.actions.get('can_delete', False) %}
|
||||
<form method="POST" action="{{ url_for('delete_item', id='') }}${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher, dass Sie dieses Objekt löschen möchten?')">
|
||||
<button class="delete-button" type="submit">Löschen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_permissions.actions.get('can_edit', False) %}
|
||||
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-card-${item._id}')">Bearbeiten</button>
|
||||
{% endif %}
|
||||
{% if current_permissions.actions.get('can_insert', False) %}
|
||||
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
|
||||
{% endif %}
|
||||
{% if current_permissions.pages.get('admin_school_settings', False) %}
|
||||
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
|
||||
{% endif %}
|
||||
</div>
|
||||
`;
|
||||
itemsContainer.appendChild(card);
|
||||
@@ -4506,10 +4524,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
: '<div style="font-size:0.92rem;color:#64748b;">Keine Beschädigungs-Historie vorhanden.</div>';
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<h2>${item.Name}</h2>
|
||||
<h2>${escapeHtml(item.Name || '')}</h2>
|
||||
${borrowerInfoHtml}
|
||||
${appointmentInfoHtml}
|
||||
|
||||
|
||||
<div class="modal-image-container">
|
||||
${imagesHtml}
|
||||
${item.Images && item.Images.length > 1 ? `
|
||||
@@ -4518,66 +4536,67 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
<div class="image-counter">1/${item.Images.length}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-details">
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Ort:</div>
|
||||
<div class="detail-value">${item.Ort || '-'}</div>
|
||||
<div class="detail-value">${escapeHtml(item.Ort || '-')}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Status:</div>
|
||||
<div class="detail-value ${isBorrowed ? 'status-borrowed' : ''}">
|
||||
${isBorrowed ? 'Ausgeliehen' : 'Verfügbar'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Unterrichtsfach:</div>
|
||||
<div class="detail-value">${filter1Array.join(', ') || '-'}</div>
|
||||
<div class="detail-value">${escapeHtml(filter1Array.join(', ') || '-')}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Jahrgangsstufe:</div>
|
||||
<div class="detail-value">${filter2Array.join(', ') || '-'}</div>
|
||||
<div class="detail-value">${escapeHtml(filter2Array.join(', ') || '-')}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Schlagwort:</div>
|
||||
<div class="detail-value">${filter3Array.join(', ') || '-'}</div>
|
||||
<div class="detail-value">${escapeHtml(filter3Array.join(', ') || '-')}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Code:</div>
|
||||
<div class="detail-value">${item.Code_4 || '-'}</div>
|
||||
<div class="detail-value">${escapeHtml(item.Code_4 || '-')}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Anzahl:</div>
|
||||
<div class="detail-value">${item.GroupedDisplayCount || 1}</div>
|
||||
<div class="detail-value">${escapeHtml(String(item.GroupedDisplayCount || 1))}</div>
|
||||
</div>
|
||||
|
||||
|
||||
${isGroupedItem ? `
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Verfügbar:</div>
|
||||
<div class="detail-value">${availableGroupedCount}</div>
|
||||
<div class="detail-value">${escapeHtml(String(availableGroupedCount))}</div>
|
||||
</div>` : ''}
|
||||
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Anschaffungsjahr:</div>
|
||||
<div class="detail-value">${item.Anschaffungsjahr || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Anschaffungskosten:</div>
|
||||
<div class="detail-value">${item.Anschaffungskosten ? item.Anschaffungskosten + ' €' : '-'}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-group full-width">
|
||||
<div class="detail-label">Beschreibung:</div>
|
||||
<div class="detail-value">${item.Beschreibung || '-'}</div>
|
||||
<div class="detail-value">${escapeHtml(String(item.Anschaffungsjahr || '-'))}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-group">
|
||||
<div class="detail-label">Anschaffungskosten:</div>
|
||||
<div class="detail-value">${item.Anschaffungskosten ? escapeHtml(String(item.Anschaffungskosten)) + ' €' : '-'}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-group full-width">
|
||||
<div class="detail-label">Beschreibung:</div>
|
||||
<div class="detail-value">${escapeHtml(item.Beschreibung || '-')}</div>
|
||||
</div>
|
||||
|
||||
{% if current_permissions.actions.get('can_view_logs', False) %}
|
||||
<div class="detail-group full-width" style="margin-top:12px;">
|
||||
<div class="detail-label" style="font-weight:600; color:#374151;">Beschädigungs-Historie</div>
|
||||
<div class="detail-value">
|
||||
@@ -4590,7 +4609,8 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div class="detail-group full-width" style="margin-top:12px; padding:10px; border:1px solid #e3e3e3; border-radius:8px;">
|
||||
<div class="detail-label">Verfügbarkeit prüfen:</div>
|
||||
<div class="detail-value">
|
||||
@@ -4611,7 +4631,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="detail-group full-width" style="margin-top:15px;">
|
||||
<div class="detail-label">Geplante Ausleihen:</div>
|
||||
<div class="detail-value">
|
||||
@@ -4635,9 +4655,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="modal-actions">
|
||||
${!isBorrowed ?
|
||||
{% if current_permissions.actions.get('can_borrow', False) %}
|
||||
`<form method="POST" action="/ausleihen/${item._id}">
|
||||
${isGroupedItem ? `
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:8px; align-items:center;">
|
||||
@@ -4651,6 +4672,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>` : ''}
|
||||
<button class="ausleihen" type="submit">Ausleihen</button>
|
||||
</form>`
|
||||
{% else %}
|
||||
`<button class="ausleihen disabled-button" disabled title="Keine Berechtigung">Ausleihen nicht möglich</button>`
|
||||
{% endif %}
|
||||
: (!isGroupedItem && item.User === currentUsername) ?
|
||||
`<form method="POST" action="/zurueckgeben/${item._id}">
|
||||
<button class="ausleihen" type="submit">Zurückgeben</button>
|
||||
@@ -4659,7 +4683,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
}
|
||||
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-modal-${item._id}')">Bearbeiten</button>
|
||||
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
|
||||
${damageReports.length > 0 ? `<button class="damage-button" onclick="markDamageAsRepaired('${item._id}')">Repariert</button>` : `<button class="damage-button" onclick="registerDamage('${item._id}')">Schaden melden</button>`}
|
||||
${damageReports && damageReports.length > 0 ? `<button class="damage-button" onclick="markDamageAsRepaired('${item._id}')">Repariert</button>` : `<button class="damage-button" onclick="registerDamage('${item._id}')">Schaden melden</button>`}
|
||||
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
|
||||
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
|
||||
<button class="delete-button" type="submit">Löschen</button>
|
||||
|
||||
+133
-83
@@ -1,11 +1,3 @@
|
||||
<!--
|
||||
Copyright 2025-2026 AIIrondev
|
||||
|
||||
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
|
||||
See Legal/LICENSE for the full license text.
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
-->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register{% endblock %}
|
||||
@@ -33,6 +25,7 @@
|
||||
<div class="content">
|
||||
<div class="form-card">
|
||||
<form method="POST" action="{{ url_for('register') }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Vorname</label>
|
||||
<div class="input-container">
|
||||
@@ -44,7 +37,7 @@
|
||||
<span class="input-icon">👤</span>
|
||||
<input type="text" id="last-name" name="last-name" placeholder="Geben Sie den Nachnamen ein" required onchange="generateUsername()" oninput="generateUsername()">
|
||||
</div>
|
||||
<label for="username">Benutzername <span style="color: #9ca3af;">(wird automatisch generiert)</span></label>
|
||||
<label for="username">Benutzername <span style="color: #9ca3af;">(Vorschau - wird serverseitig finalisiert)</span></label>
|
||||
<div class="input-container">
|
||||
<span class="input-icon">👤</span>
|
||||
<input type="text" id="username" name="username" placeholder="Automatisch aus Name und Nachname" readonly style="background-color: #f3f4f6; cursor: not-allowed;">
|
||||
@@ -64,9 +57,21 @@
|
||||
<li id="pw-rule-symbol" class="pw-rule">Mindestens ein Sonderzeichen</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="input-container">
|
||||
<span class="input-icon">🔒</span>
|
||||
<input type="password" id="password" name="password" placeholder="Geben Sie ein sicheres Passwort ein" required>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<div class="input-container">
|
||||
<span class="input-icon">🔒</span>
|
||||
<!-- HTML5 Pattern blockiert unsichere Passwörter vor dem Absenden -->
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Geben Sie ein sicheres Passwort ein"
|
||||
required
|
||||
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9]).{12,}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pw-actions">
|
||||
<button type="button" class="btn-secondary" onclick="generateSecurePassword()">Passwort generieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -258,31 +263,6 @@ input::placeholder {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
|
||||
.navigation-buttons {
|
||||
text-align: center;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.flash-container {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -422,49 +402,147 @@ input::placeholder {
|
||||
margin: 4px 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* Neue Styles für die Passwort-Erweiterungen */
|
||||
.pw-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-wrapper .input-container {
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Function to generate username from first and last name (helper function)
|
||||
// Hilfsfunktion: Umlaute auflösen und Sonderzeichen entfernen
|
||||
function cleanNameForUsername(text) {
|
||||
if (!text) return '';
|
||||
// Remove special characters, convert umlauts, lowercase
|
||||
let cleaned = text
|
||||
.replace(/[^a-zA-Zäöüß\s-]/g, '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
// Convert German umlauts to ASCII
|
||||
cleaned = cleaned
|
||||
let cleaned = text.trim().toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss');
|
||||
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z]/g, '');
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// Generate username from name and last_name fields
|
||||
// Hilfsfunktion: Erster Buchstabe groß
|
||||
function formatPart(str, len) {
|
||||
if (!str) return '';
|
||||
const part = str.slice(0, len);
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
}
|
||||
|
||||
// Generiert die Vorschau des Benutzernamens (z.B. SimFri)
|
||||
function generateUsername() {
|
||||
const firstName = cleanNameForUsername(document.getElementById('name').value || '');
|
||||
const lastName = cleanNameForUsername(document.getElementById('last-name').value || '');
|
||||
const firstName = cleanNameForUsername(document.getElementById('name').value);
|
||||
const lastName = cleanNameForUsername(document.getElementById('last-name').value);
|
||||
let username = '';
|
||||
|
||||
if (firstName && lastName) {
|
||||
username = (firstName.slice(0, 3) + lastName.slice(0, 3));
|
||||
username = formatPart(firstName, 3) + formatPart(lastName, 3);
|
||||
} else if (firstName) {
|
||||
username = firstName.slice(0, 6);
|
||||
username = formatPart(firstName, 6);
|
||||
} else if (lastName) {
|
||||
username = lastName.slice(0, 6);
|
||||
username = formatPart(lastName, 6);
|
||||
}
|
||||
|
||||
// Set the username field
|
||||
const usernameField = document.getElementById('username');
|
||||
if (usernameField) {
|
||||
usernameField.value = username || '';
|
||||
}
|
||||
}
|
||||
|
||||
// PASSWORT GENERATOR
|
||||
function generateSecurePassword() {
|
||||
const length = 16;
|
||||
const charsetLower = "abcdefghijklmnopqrstuvwxyz";
|
||||
const charsetUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const charsetNum = "0123456789";
|
||||
const charsetSym = "!@#$%^&*()_+~|}{[]:;?><,.-=";
|
||||
|
||||
let password = "";
|
||||
// Garantiert mindestens 1 Zeichen aus jeder Kategorie
|
||||
password += charsetLower[Math.floor(Math.random() * charsetLower.length)];
|
||||
password += charsetUpper[Math.floor(Math.random() * charsetUpper.length)];
|
||||
password += charsetNum[Math.floor(Math.random() * charsetNum.length)];
|
||||
password += charsetSym[Math.floor(Math.random() * charsetSym.length)];
|
||||
|
||||
const allChars = charsetLower + charsetUpper + charsetNum + charsetSym;
|
||||
// Restliche Zeichen auffüllen
|
||||
for (let i = 4; i < length; i++) {
|
||||
password += allChars[Math.floor(Math.random() * allChars.length)];
|
||||
}
|
||||
|
||||
// Passwort durchmischen
|
||||
password = password.split('').sort(() => 0.5 - Math.random()).join('');
|
||||
|
||||
const pwField = document.getElementById('password');
|
||||
pwField.value = password;
|
||||
|
||||
// Automatisch sichtbar machen, damit der User es kopieren kann
|
||||
pwField.type = 'text';
|
||||
document.getElementById('toggle-pw-btn').textContent = '🙈';
|
||||
|
||||
updatePasswordRules();
|
||||
}
|
||||
|
||||
// PASSWORT SICHTBARKEIT UMSCHALTEN
|
||||
function togglePasswordVisibility() {
|
||||
const pwField = document.getElementById('password');
|
||||
const toggleBtn = document.getElementById('toggle-pw-btn');
|
||||
if (pwField.type === "password") {
|
||||
pwField.type = "text";
|
||||
toggleBtn.textContent = '🙈';
|
||||
} else {
|
||||
pwField.type = "password";
|
||||
toggleBtn.textContent = '👁️';
|
||||
}
|
||||
}
|
||||
|
||||
// Globale Funktion für Passwort-Update
|
||||
function updatePasswordRules() {
|
||||
const passwordInput = document.getElementById('password');
|
||||
if (!passwordInput) return;
|
||||
|
||||
const value = String(passwordInput.value || '');
|
||||
|
||||
const setRuleState = (id, ok) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node) node.classList.toggle('ok', !!ok);
|
||||
};
|
||||
|
||||
setRuleState('pw-rule-length', value.length >= 12);
|
||||
setRuleState('pw-rule-lower', /[a-z]/.test(value));
|
||||
setRuleState('pw-rule-upper', /[A-Z]/.test(value));
|
||||
setRuleState('pw-rule-digit', /[0-9]/.test(value));
|
||||
setRuleState('pw-rule-symbol', /[^A-Za-z0-9]/.test(value));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const permissionPresets = {{ permission_presets | tojson }};
|
||||
const presetSelect = document.getElementById('permission-preset');
|
||||
@@ -507,34 +585,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
|
||||
const passwordInput = document.getElementById('password');
|
||||
const passwordRules = {
|
||||
length: document.getElementById('pw-rule-length'),
|
||||
lower: document.getElementById('pw-rule-lower'),
|
||||
upper: document.getElementById('pw-rule-upper'),
|
||||
digit: document.getElementById('pw-rule-digit'),
|
||||
symbol: document.getElementById('pw-rule-symbol')
|
||||
};
|
||||
|
||||
function setRuleState(node, ok) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.classList.toggle('ok', !!ok);
|
||||
}
|
||||
|
||||
function updatePasswordRules() {
|
||||
if (!passwordInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = String(passwordInput.value || '');
|
||||
setRuleState(passwordRules.length, value.length >= 12);
|
||||
setRuleState(passwordRules.lower, /[a-z]/.test(value));
|
||||
setRuleState(passwordRules.upper, /[A-Z]/.test(value));
|
||||
setRuleState(passwordRules.digit, /[0-9]/.test(value));
|
||||
setRuleState(passwordRules.symbol, /[^A-Za-z0-9]/.test(value));
|
||||
}
|
||||
|
||||
if (passwordInput) {
|
||||
passwordInput.addEventListener('input', updatePasswordRules);
|
||||
passwordInput.addEventListener('blur', updatePasswordRules);
|
||||
|
||||
+3
-3
@@ -20,14 +20,14 @@
|
||||
"key": "Web/certs/key.pem"
|
||||
},
|
||||
"email": {
|
||||
"enabled": false,
|
||||
"smtp_host": "smtp.gmail.com",
|
||||
"enabled": true,
|
||||
"smtp_host": "",
|
||||
"smtp_port": 587,
|
||||
"use_tls": true,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"from_address": "",
|
||||
"default_sender_name": "Invario Inventarprogramm",
|
||||
"default_sender_name": "Invario Email Service",
|
||||
"timeout_seconds": 30
|
||||
},
|
||||
"images": {
|
||||
|
||||
+2
-30
@@ -82,7 +82,6 @@ while i < len(lines):
|
||||
stripped = line.lstrip(" ")
|
||||
indent = leading_spaces(line)
|
||||
|
||||
# Check if we're starting the app service
|
||||
if not in_app_service and re.match(r"^\s*app:\s*$", line):
|
||||
in_app_service = True
|
||||
app_indent = indent
|
||||
@@ -92,9 +91,7 @@ while i < len(lines):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check if we've exited the app service (found another top-level service)
|
||||
if in_app_service and indent <= app_indent and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped):
|
||||
# We've left the app service - insert image if we haven't already
|
||||
if build_found and app_service_indent is not None:
|
||||
out.append(f"{' ' * app_service_indent}image: {target_image}\n")
|
||||
in_app_service = False
|
||||
@@ -102,24 +99,20 @@ while i < len(lines):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Process lines within the app service
|
||||
if in_app_service:
|
||||
if app_service_indent is None and indent > app_indent:
|
||||
app_service_indent = indent
|
||||
|
||||
# Check for image key (already has an image, don't add)
|
||||
if re.match(rf"^\s+image:\s*", line):
|
||||
in_app_service = False
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for build block
|
||||
if re.match(rf"^\s+build:\s*$", line):
|
||||
build_found = True
|
||||
out.append(line)
|
||||
i += 1
|
||||
# Skip all lines that are part of the build block (indented more than app_service_indent)
|
||||
while i < len(lines):
|
||||
next_line = lines[i]
|
||||
next_indent = leading_spaces(next_line)
|
||||
@@ -130,12 +123,10 @@ while i < len(lines):
|
||||
break
|
||||
continue
|
||||
|
||||
# Insert image after build block before first property
|
||||
if build_found and app_service_indent is not None and indent == app_service_indent:
|
||||
# Check if this is a property line (not build)
|
||||
if not re.match(rf"^\s+build:", line):
|
||||
out.append(f"{' ' * app_service_indent}image: {target_image}\n")
|
||||
build_found = False # Mark that we've inserted the image
|
||||
build_found = False
|
||||
|
||||
out.append(line)
|
||||
i += 1
|
||||
@@ -144,7 +135,6 @@ while i < len(lines):
|
||||
out.append(line)
|
||||
i += 1
|
||||
|
||||
# If we ended while still in the app service, append image at the end
|
||||
if in_app_service and build_found and app_service_indent is not None:
|
||||
out.append(f"{' ' * app_service_indent}image: {target_image}\n")
|
||||
|
||||
@@ -377,18 +367,12 @@ with open(meta_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
tag = data.get('tag_name', '').strip()
|
||||
url = ''
|
||||
image_url = ''
|
||||
for asset in data.get('assets', []):
|
||||
if asset.get('name') == asset_name:
|
||||
url = asset.get('browser_download_url', '').strip()
|
||||
break
|
||||
for asset in data.get('assets', []):
|
||||
if asset.get('name') == f'inventarsystem-image-{tag}.tar.gz':
|
||||
image_url = asset.get('browser_download_url', '').strip()
|
||||
break
|
||||
print(tag)
|
||||
print(url)
|
||||
print(image_url)
|
||||
PY
|
||||
}
|
||||
|
||||
@@ -401,7 +385,7 @@ main() {
|
||||
need_cmd python3
|
||||
need_cmd curl
|
||||
|
||||
local meta_file tag bundle_url image_url
|
||||
local meta_file tag bundle_url
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
meta_file="$TMP_DIR/release.json"
|
||||
trap cleanup_tmp_dir EXIT
|
||||
@@ -409,7 +393,6 @@ main() {
|
||||
mapfile -t release_info < <(latest_tag_and_bundle_url "$meta_file")
|
||||
tag="${release_info[0]:-}"
|
||||
bundle_url="${release_info[1]:-}"
|
||||
image_url="${release_info[2]:-}"
|
||||
|
||||
if [ -z "$tag" ] || [ -z "$bundle_url" ]; then
|
||||
echo "Error: latest release metadata is incomplete."
|
||||
@@ -425,17 +408,6 @@ main() {
|
||||
|
||||
pin_compose_app_image "$tag"
|
||||
|
||||
if [ -z "$image_url" ]; then
|
||||
echo "Error: release image asset is missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -fL "$image_url" -o "$TMP_DIR/inventarsystem-image-$tag.tar.gz"
|
||||
sudo docker load -i "$TMP_DIR/inventarsystem-image-$tag.tar.gz" >/dev/null
|
||||
|
||||
# Tagge das geladene Gitea-Image als latest
|
||||
sudo docker tag "git.invario-software.eu/invario/inventarsystem:$tag" "git.invario-software.eu/invario/inventarsystem:latest" >/dev/null 2>&1 || true
|
||||
|
||||
if [ ! -f "$PROJECT_DIR/start.sh" ]; then
|
||||
echo "Error: release bundle is missing start.sh"
|
||||
exit 1
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
flask
|
||||
flask-wtf
|
||||
werkzeug
|
||||
gunicorn
|
||||
pymongo==4.6.3
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
sudo find /tmp -maxdepth 1 -name "tmp.*" -exec rm -rf {} +
|
||||
echo "Cleaning up old temporary files in /tmp..."
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ cd "$SCRIPT_DIR"
|
||||
|
||||
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
|
||||
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
|
||||
DIST_DIR="$SCRIPT_DIR/dist"
|
||||
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
|
||||
|
||||
SUDO=""
|
||||
@@ -279,52 +278,19 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_app_image_loaded() {
|
||||
ensure_app_image_ready() {
|
||||
if docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local image_archive
|
||||
image_archive="$(find_local_dist_image_archive || true)"
|
||||
if [ -n "$image_archive" ]; then
|
||||
echo "Loading app image from local dist artifact: $image_archive"
|
||||
if docker load -i "$image_archive" >/dev/null 2>&1 && docker image inspect "$APP_IMAGE_VALUE" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
echo "Warning: failed to load expected app image from $image_archive"
|
||||
fi
|
||||
|
||||
echo "Error: local app image not found: $APP_IMAGE_VALUE"
|
||||
echo "Run ./update.sh so the nightly updater loads the release image first."
|
||||
exit 1
|
||||
}
|
||||
|
||||
find_local_dist_image_archive() {
|
||||
local tag archive
|
||||
|
||||
if [ ! -d "$DIST_DIR" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
tag="${APP_IMAGE_VALUE##*:}"
|
||||
for archive in \
|
||||
"$DIST_DIR/inventarsystem-image-$tag.tar.gz" \
|
||||
"$DIST_DIR/inventarsystem-image-$tag.tar" \
|
||||
"$DIST_DIR/inventarsystem-image.tar.gz" \
|
||||
"$DIST_DIR/inventarsystem-image.tar"; do
|
||||
if [ -f "$archive" ]; then
|
||||
echo "$archive"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
archive="$(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) | sort | tail -n1)"
|
||||
if [ -n "$archive" ]; then
|
||||
echo "$archive"
|
||||
echo "Attempting to pull registry image: $APP_IMAGE_VALUE"
|
||||
if docker pull "$APP_IMAGE_VALUE" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
echo "Error: app image not found locally and pull failed: $APP_IMAGE_VALUE"
|
||||
echo "Run ./update.sh to pull the latest release image from the registry."
|
||||
exit 1
|
||||
}
|
||||
|
||||
configure_nuitka_mode() {
|
||||
@@ -605,7 +571,6 @@ verify_stack_health() {
|
||||
fi
|
||||
compose_args+=(--env-file "$ENV_FILE")
|
||||
|
||||
# Try health check with optional restart on first failure
|
||||
while [[ $retry_count -lt 2 ]]; do
|
||||
echo "Waiting for containers to become healthy... (attempt $((retry_count + 1))/2)"
|
||||
for _ in $(seq 1 60); do
|
||||
@@ -623,7 +588,6 @@ verify_stack_health() {
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# First failure: attempt recovery by restarting containers
|
||||
if [[ $retry_count -eq 0 ]]; then
|
||||
echo "Health check failed. Attempting to restart containers..."
|
||||
docker compose "${compose_args[@]}" ps || true
|
||||
@@ -636,7 +600,6 @@ verify_stack_health() {
|
||||
fi
|
||||
done
|
||||
|
||||
# Final failure
|
||||
echo "Error: stack health check failed after restart attempt."
|
||||
docker compose "${compose_args[@]}" ps || true
|
||||
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
|
||||
@@ -654,7 +617,7 @@ resolve_app_image
|
||||
configure_host_ports
|
||||
ensure_min_docker_disk_space
|
||||
detect_server_capacity
|
||||
ensure_app_image_loaded
|
||||
ensure_app_image_ready
|
||||
write_env_file
|
||||
write_runtime_compose_override
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
# Release-only updater for Docker deployment.
|
||||
# Updates are pulled exclusively from Gitea Releases assets.
|
||||
# Updates pull the deployment bundle from Gitea and the Docker Image via 'docker pull'.
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG_DIR="$PROJECT_DIR/logs"
|
||||
@@ -11,13 +11,10 @@ STATE_FILE="$PROJECT_DIR/.release-version"
|
||||
REPO_SLUG="Invario/Inventarsystem"
|
||||
API_URL="https://git.invario-software.eu/api/v1/repos/$REPO_SLUG/releases/latest"
|
||||
BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
|
||||
APP_IMAGE_ASSET_PREFIX="inventarsystem-image-"
|
||||
ENV_FILE="$PROJECT_DIR/.docker-build.env"
|
||||
APP_IMAGE_REPO="git.invario-software.eu/invario/inventarsystem"
|
||||
DIST_DIR="$PROJECT_DIR/dist"
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
MIN_ROOT_FREE_MB="${INVENTAR_MIN_ROOT_FREE_MB:-2048}"
|
||||
DIST_KEEP_COUNT="${INVENTAR_DIST_KEEP_COUNT:-2}"
|
||||
MODE="release"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
@@ -109,36 +106,6 @@ ensure_min_root_disk_space() {
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_old_dist_artifacts() {
|
||||
local keep_count
|
||||
keep_count="$DIST_KEEP_COUNT"
|
||||
|
||||
if [ ! -d "$DIST_DIR" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! [[ "$keep_count" =~ ^[0-9]+$ ]]; then
|
||||
keep_count=2
|
||||
fi
|
||||
|
||||
mapfile -t archives < <(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) -printf '%T@ %p\n' | sort -nr | awk '{print $2}')
|
||||
if [ "${#archives[@]}" -le "$keep_count" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local index old_archive deleted=0
|
||||
for (( index=keep_count; index<${#archives[@]}; index++ )); do
|
||||
old_archive="${archives[$index]}"
|
||||
if rm -f "$old_archive"; then
|
||||
deleted=$((deleted + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$deleted" -gt 0 ]; then
|
||||
log_message "Cleaned up $deleted old dist image archive(s)"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_docker_dangling_images() {
|
||||
if docker image prune -f >> "$LOG_FILE" 2>&1; then
|
||||
log_message "Cleaned up dangling Docker images"
|
||||
@@ -153,7 +120,6 @@ Usage: $0 [options]
|
||||
|
||||
Options:
|
||||
--multitenant Use docker-compose-multitenant.yml (default)
|
||||
development Install development build from Gitea Registry or local dist
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
}
|
||||
@@ -165,10 +131,6 @@ parse_args() {
|
||||
COMPOSE_FILE="docker-compose-multitenant.yml"
|
||||
shift
|
||||
;;
|
||||
development|dev)
|
||||
MODE="development"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -216,14 +178,12 @@ create_backup() {
|
||||
}
|
||||
|
||||
fetch_release_metadata() {
|
||||
local meta_file
|
||||
meta_file="$1"
|
||||
local meta_file="$1"
|
||||
curl -fsSL "$API_URL" -o "$meta_file"
|
||||
}
|
||||
|
||||
parse_latest_tag() {
|
||||
local meta_file
|
||||
meta_file="$1"
|
||||
local meta_file="$1"
|
||||
python3 - <<'PY' "$meta_file"
|
||||
import json, sys
|
||||
with open(sys.argv[1], 'r', encoding='utf-8') as f:
|
||||
@@ -233,9 +193,8 @@ PY
|
||||
}
|
||||
|
||||
parse_asset_url() {
|
||||
local meta_file asset_name
|
||||
meta_file="$1"
|
||||
asset_name="$2"
|
||||
local meta_file="$1"
|
||||
local asset_name="$2"
|
||||
python3 - <<'PY' "$meta_file" "$asset_name"
|
||||
import json, sys
|
||||
meta_file, asset_name = sys.argv[1], sys.argv[2]
|
||||
@@ -248,31 +207,6 @@ for asset in data.get('assets', []):
|
||||
PY
|
||||
}
|
||||
|
||||
load_release_image() {
|
||||
local meta_file tag image_asset image_url tmp_dir archive
|
||||
|
||||
meta_file="$1"
|
||||
tag="$2"
|
||||
image_asset="${APP_IMAGE_ASSET_PREFIX}${tag}.tar.gz"
|
||||
image_url="$(parse_asset_url "$meta_file" "$image_asset")"
|
||||
|
||||
if [ -z "$image_url" ]; then
|
||||
log_message "ERROR: Release image asset not found: $image_asset"
|
||||
return 1
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
archive="$tmp_dir/$image_asset"
|
||||
trap 'rm -rf "${tmp_dir:-}"' RETURN
|
||||
|
||||
log_message "Loading app image from release asset $image_asset"
|
||||
curl -fL "$image_url" -o "$archive"
|
||||
docker load -i "$archive" >> "$LOG_FILE" 2>&1
|
||||
docker tag "$APP_IMAGE_REPO:$tag" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
|
||||
|
||||
trap - RETURN
|
||||
}
|
||||
|
||||
refresh_runtime_scripts_from_main() {
|
||||
local start_url stop_url restart_url update_url
|
||||
start_url="https://git.invario-software.eu/$REPO_SLUG/raw/branch/main/start.sh"
|
||||
@@ -285,61 +219,13 @@ refresh_runtime_scripts_from_main() {
|
||||
curl -fsSL "$restart_url" -o "$PROJECT_DIR/restart.sh" || log_message "WARNING: Could not refresh restart.sh from main"
|
||||
curl -fsSL "$update_url" -o "$PROJECT_DIR/update.sh" || log_message "WARNING: Could not refresh update.sh from main"
|
||||
|
||||
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh"
|
||||
}
|
||||
|
||||
find_local_dist_image_archive() {
|
||||
local tag="$1"
|
||||
local archive
|
||||
|
||||
if [ ! -d "$DIST_DIR" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
for archive in \
|
||||
"$DIST_DIR/inventarsystem-image-$tag.tar.gz" \
|
||||
"$DIST_DIR/inventarsystem-image-$tag.tar" \
|
||||
"$DIST_DIR/inventarsystem-image.tar.gz" \
|
||||
"$DIST_DIR/inventarsystem-image.tar"; do
|
||||
if [ -f "$archive" ]; then
|
||||
echo "$archive"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
archive="$(find "$DIST_DIR" -maxdepth 1 -type f \( -name 'inventarsystem-image-*.tar.gz' -o -name 'inventarsystem-image-*.tar' \) | sort | tail -n1)"
|
||||
if [ -n "$archive" ]; then
|
||||
echo "$archive"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
load_local_dist_image() {
|
||||
local tag="$1"
|
||||
local archive
|
||||
|
||||
archive="$(find_local_dist_image_archive "$tag" || true)"
|
||||
if [ -z "$archive" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_message "Loading app image from local dist artifact: $archive"
|
||||
if docker load -i "$archive" >> "$LOG_FILE" 2>&1; then
|
||||
docker tag "$APP_IMAGE_REPO:$tag" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_message "WARNING: Failed to load local dist artifact: $archive"
|
||||
return 1
|
||||
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" 2>/dev/null || true
|
||||
}
|
||||
|
||||
download_and_extract_bundle() {
|
||||
local url tmp_dir archive
|
||||
url="$1"
|
||||
tmp_dir="$2"
|
||||
archive="$tmp_dir/$BUNDLE_ASSET"
|
||||
local url="$1"
|
||||
local tmp_dir="$2"
|
||||
local archive="$tmp_dir/$BUNDLE_ASSET"
|
||||
|
||||
curl -fL "$url" -o "$archive"
|
||||
tar -xzf "$archive" -C "$tmp_dir"
|
||||
@@ -375,19 +261,15 @@ download_and_extract_bundle() {
|
||||
|
||||
# Ensure executable permissions on all copied scripts
|
||||
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
|
||||
chmod +x "$PROJECT_DIR"/manage-tenant.sh "$PROJECT_DIR"/run-tenant-cmd.sh 2>/dev/null || true
|
||||
|
||||
if [ ! -f "$PROJECT_DIR/config.json" ] && [ -f "$tmp_dir/config.json" ]; then
|
||||
cp -f "$tmp_dir/config.json" "$PROJECT_DIR/config.json"
|
||||
log_message "Installed default config.json from release bundle"
|
||||
fi
|
||||
|
||||
chmod +x "$PROJECT_DIR/start.sh" "$PROJECT_DIR/stop.sh" "$PROJECT_DIR/restart.sh" "$PROJECT_DIR/update.sh" "$PROJECT_DIR/backup.sh" "$PROJECT_DIR/manage-tenant.sh" "$PROJECT_DIR/run-tenant-cmd.sh" 2>/dev/null || true
|
||||
}
|
||||
|
||||
deploy() {
|
||||
local tag="$1"
|
||||
local meta_file="$2"
|
||||
local app_image="${APP_IMAGE_REPO}:${tag}"
|
||||
local compose_path
|
||||
|
||||
@@ -410,17 +292,14 @@ EOF
|
||||
printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$ENV_FILE"
|
||||
fi
|
||||
|
||||
if ! load_local_dist_image "$tag"; then
|
||||
if ! load_release_image "$meta_file" "$tag"; then
|
||||
log_message "Falling back to tagged Gitea Registry image $app_image"
|
||||
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
|
||||
log_message "Falling back to local Docker build for $app_image"
|
||||
docker build -t "$app_image" "$PROJECT_DIR" >> "$LOG_FILE" 2>&1
|
||||
fi
|
||||
fi
|
||||
log_message "Pulling Gitea Registry image $app_image"
|
||||
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
|
||||
log_message "Falling back to local Docker build for $app_image"
|
||||
docker build -t "$app_image" "$PROJECT_DIR" >> "$LOG_FILE" 2>&1
|
||||
fi
|
||||
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1
|
||||
# Image wurde oben gepullt, Stack hochfahren
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull mongodb redis >> "$LOG_FILE" 2>&1 || true
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||
docker tag "$app_image" "$APP_IMAGE_REPO:latest" >> "$LOG_FILE" 2>&1 || true
|
||||
}
|
||||
@@ -460,9 +339,12 @@ cleanup_server_space() {
|
||||
else
|
||||
log_message "WARNING: Docker system prune failed"
|
||||
fi
|
||||
# Clean up old dist artifacts
|
||||
cleanup_old_dist_artifacts
|
||||
# Clean up log files older than 7 days
|
||||
# Delete legacy dist folder if it exists
|
||||
if [ -d "$PROJECT_DIR/dist" ]; then
|
||||
rm -rf "$PROJECT_DIR/dist" || true
|
||||
log_message "Legacy dist folder removed"
|
||||
fi
|
||||
# Clean up old log files older than 7 days
|
||||
if find "$LOG_DIR" -type f -name '*.log' -mtime +7 -exec rm -f {} +; then
|
||||
log_message "Old log files (older than 7 days) cleaned up"
|
||||
else
|
||||
@@ -511,17 +393,14 @@ EOF
|
||||
printf '\nINVENTAR_APP_IMAGE=%s\n' "$app_image" >> "$ENV_FILE"
|
||||
fi
|
||||
|
||||
# Try local dist first, then pull from Gitea Registry
|
||||
if ! load_local_dist_image "$tag"; then
|
||||
log_message "Attempting to pull development image $app_image"
|
||||
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
|
||||
log_message "ERROR: Could not obtain development image $app_image"
|
||||
exit 1
|
||||
fi
|
||||
log_message "Attempting to pull development image $app_image"
|
||||
if ! docker pull "$app_image" >> "$LOG_FILE" 2>&1; then
|
||||
log_message "ERROR: Could not obtain development image $app_image"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bring up stack
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull app mongodb >> "$LOG_FILE" 2>&1 || true
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" pull mongodb redis >> "$LOG_FILE" 2>&1 || true
|
||||
docker compose -f "$compose_path" --env-file "$ENV_FILE" up -d --remove-orphans >> "$LOG_FILE" 2>&1
|
||||
|
||||
if ! verify_stack_health; then
|
||||
@@ -581,7 +460,7 @@ EOF
|
||||
|
||||
if [ "$current_tag" = "$latest_tag" ]; then
|
||||
log_message "Already on latest release ($latest_tag). Refreshing containers from prebuilt image."
|
||||
deploy "$latest_tag" "$meta_file"
|
||||
deploy "$latest_tag"
|
||||
if verify_stack_health; then
|
||||
log_message "Container refresh completed"
|
||||
else
|
||||
@@ -611,18 +490,19 @@ EOF
|
||||
log_message "Updating from release $latest_tag"
|
||||
download_and_extract_bundle "$bundle_url" "$tmp_dir"
|
||||
refresh_runtime_scripts_from_main
|
||||
deploy "$latest_tag" "$meta_file"
|
||||
deploy "$latest_tag"
|
||||
if ! verify_stack_health; then
|
||||
log_message "ERROR: Updated stack failed health check"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$latest_tag" > "$STATE_FILE"
|
||||
cleanup_old_dist_artifacts
|
||||
cleanup_docker_dangling_images
|
||||
log_message "Update completed successfully to release $latest_tag"
|
||||
|
||||
sudo ./opt/Inventarsystem/restart.sh
|
||||
if [ -x "./opt/Inventarsystem/restart.sh" ]; then
|
||||
sudo ./opt/Inventarsystem/restart.sh
|
||||
fi
|
||||
echo "Restart of the Server Completed"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user