Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b38d1f0c8 | |||
| e7e24b3fae | |||
| fc80857fbe | |||
| f763ad064d | |||
| bc94ffdc18 | |||
| 76e9670cee | |||
| 577c6c4bed | |||
| 3aa19b10d1 | |||
| ff742d018e | |||
| 6fa40f26b0 | |||
| 777fc81064 | |||
| cf5e38e319 | |||
| b76941d5fe | |||
| f46f2674b0 | |||
| 8ebbdcfd54 | |||
| 92b5235035 | |||
| 4bda2e044b | |||
| b83b6c0ba2 | |||
| ab718d6ac2 | |||
| 112d9b6aa0 | |||
| 5139a50a43 | |||
| 997c7b42d5 | |||
| 20e5d3bb9a | |||
| f62b22c2f7 | |||
| e76d525e4b | |||
| 7117dac67c | |||
| 5313c507ed | |||
| 419fed4492 | |||
| c7112c7a42 | |||
| fb73c9d4e7 | |||
| 8414e27f25 | |||
| 5903f2afdd | |||
| a31fb4c048 | |||
| 46da45d373 | |||
| 0b1bcef985 | |||
| 99ad9d4f79 | |||
| 89c1a525d8 | |||
| 9701805552 | |||
| 4227934252 | |||
| d6883d1879 | |||
| f47e133c45 | |||
| dec3c16d7f | |||
| 897b2e43ad | |||
| 6dd44508ce | |||
| 204ded6c7c | |||
| fbd2168aff | |||
| 249a2bc2da | |||
| 7ab8f841d0 | |||
| 43c544244c | |||
| 73e4407d7b | |||
| 94fc01c08a | |||
| 788ec1db62 | |||
| 725584a104 | |||
| fbcc3b4522 | |||
| adc2bad3fb | |||
| f7be7d9bf0 | |||
| 1bd2db41b4 | |||
| ac68beb80a | |||
| ea6826b304 | |||
| d668e54042 | |||
| 3fd00aec30 | |||
| ef2d1dfab0 | |||
| 1c5aa9beed | |||
| a7d3863fde | |||
| ac0e60fadb | |||
| cbeed68147 | |||
| 715d932dba | |||
| 606e1822d1 | |||
| c0ce80d223 | |||
| 96debef4f4 | |||
| c6c57e4b53 | |||
| 3dc9ecfe5b | |||
| 8e20580552 | |||
| aa47325e80 | |||
| 539cf2b21c |
@@ -15,15 +15,6 @@ on:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
- development
|
||||
push_dev:
|
||||
description: "If true, push the :dev image to GHCR for development releases"
|
||||
required: false
|
||||
default: "false"
|
||||
type: choice
|
||||
options:
|
||||
- "true"
|
||||
- "false"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -48,13 +39,12 @@ jobs:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
BUMP_TYPE: ${{ github.event.inputs.bump || 'patch' }}
|
||||
PUSH_DEV: ${{ github.event.inputs.push_dev || 'false' }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "push" ] && [ -n "$REF_NAME" ]; then
|
||||
TAG="$REF_NAME"
|
||||
else
|
||||
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
|
||||
latest_tag="v3.0.0"
|
||||
# Fetch latest release tag via GitHub API (fall back to v0.8.31)
|
||||
latest_tag="v0.8.31"
|
||||
if meta_json=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "https://api.github.com/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)
|
||||
if [ -n "$tag_name" ]; then
|
||||
@@ -67,7 +57,7 @@ jobs:
|
||||
minor=${BASH_REMATCH[2]}
|
||||
patch=${BASH_REMATCH[3]}
|
||||
else
|
||||
major=3; minor=0; patch=0
|
||||
major=0; minor=8; patch=31
|
||||
fi
|
||||
|
||||
# Bump strategy: major / minor / patch
|
||||
@@ -78,30 +68,28 @@ jobs:
|
||||
else
|
||||
patch=$((patch + 1))
|
||||
fi
|
||||
|
||||
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
||||
TAG="v${major}.${minor}.${patch}-dev"
|
||||
else
|
||||
TAG="v${major}.${minor}.${patch}"
|
||||
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 or vX.Y.Z-dev)"
|
||||
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --tags --force
|
||||
LATEST_TAG="$(git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)"
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
LATEST_TAG="v3.0.0"
|
||||
LATEST_TAG="v0.8.31"
|
||||
fi
|
||||
|
||||
TAG_MAJOR="${TAG#v}"
|
||||
TAG_MAJOR="${TAG_MAJOR%%.*}"
|
||||
LATEST_MAJOR="$(echo "$LATEST_TAG" | grep -Eo '^v[0-9]+' | tr -d 'v')"
|
||||
if [ -z "$LATEST_MAJOR" ]; then
|
||||
LATEST_MAJOR="3"
|
||||
LATEST_MAJOR="0"
|
||||
fi
|
||||
|
||||
# If not explicitly bumping major, disallow changing major version
|
||||
@@ -123,33 +111,6 @@ jobs:
|
||||
IMAGE="ghcr.io/aiirondev/legendary-octo-garbanzo:${TAG}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||
if [ "${BUMP_TYPE:-}" = "development" ]; then
|
||||
echo "is_development=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_development=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if [ "${BUMP_TYPE:-}" = "development" ] && [ "${PUSH_DEV:-}" = "true" ]; then
|
||||
echo "push_dev=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push_dev=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update .release-version file
|
||||
run: |
|
||||
echo "${{ steps.meta.outputs.tag }}" > .release-version
|
||||
cat .release-version
|
||||
|
||||
- name: Create and push tag for manual releases
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add .release-version
|
||||
git commit -m "chore: bump version to $TAG" || true
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
git push origin HEAD:${{ github.ref_name }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -162,26 +123,16 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push release image
|
||||
if: steps.meta.outputs.is_development != 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
load: true # Lädt das Image in den lokalen Docker-Daemon für den 'docker save' Schritt
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}
|
||||
ghcr.io/aiirondev/legendary-octo-garbanzo:latest
|
||||
|
||||
- name: Build and push development image (:dev)
|
||||
if: steps.meta.outputs.is_development == 'true' && steps.meta.outputs.push_dev == 'true'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/aiirondev/legendary-octo-garbanzo:dev
|
||||
|
||||
- name: Build local image tar for offline deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -204,30 +155,17 @@ jobs:
|
||||
docker build -t "$IMG" .
|
||||
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
|
||||
|
||||
# development tar omitted: dev releases will be versioned (vX.Y.Z-dev) and handled by update.sh using the tag
|
||||
|
||||
- name: Commit .release-version for tag pushes
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
if ! git diff --quiet .release-version; then
|
||||
git add .release-version
|
||||
git commit -m "chore: update version to ${{ steps.meta.outputs.tag }}"
|
||||
git push origin HEAD:${{ github.ref_name }}
|
||||
fi
|
||||
|
||||
- name: Create release-only docker bundle
|
||||
run: |
|
||||
mkdir -p release-bundle
|
||||
cat > release-bundle/docker-compose.yml <<EOF
|
||||
services:
|
||||
app:
|
||||
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
||||
image: \${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:${{ steps.meta.outputs.tag }}}
|
||||
container_name: inventarsystem-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||
- "\${INVENTAR_HTTP_PORT:-10000}:8000"
|
||||
depends_on:
|
||||
- mongodb
|
||||
- redis
|
||||
@@ -300,17 +238,6 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
cat > release-bundle/DEVELOPMENT.md <<EOF
|
||||
This is a development prerelease bundle for tag: ${{ steps.meta.outputs.tag }}
|
||||
|
||||
This prerelease is intentionally separate from normal releases and will not be used by default.
|
||||
|
||||
To install this prerelease on a target host run:
|
||||
|
||||
./update.sh ${{ steps.meta.outputs.tag }}
|
||||
|
||||
EOF
|
||||
|
||||
# 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 .
|
||||
@@ -319,10 +246,8 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.meta.outputs.tag }}
|
||||
prerelease: ${{ steps.meta.outputs.is_development }}
|
||||
files: |
|
||||
inventarsystem-docker-bundle.tar.gz
|
||||
inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz
|
||||
inventarsystem-image-dev.tar.gz
|
||||
fail_on_unmatched_files: false
|
||||
generate_release_notes: true
|
||||
generate_release_notes: true
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v0.8.4
|
||||
v0.8.31.1
|
||||
|
||||
+1
-1
@@ -32,4 +32,4 @@ RUN if [ "$NUITKA_BUILD" = "1" ]; then \
|
||||
WORKDIR /app/Web
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
|
||||
+107
-5
@@ -37,6 +37,7 @@ if _CURRENT_DIR not in sys.path:
|
||||
import Web.modules.database.user as us
|
||||
import Web.modules.database.items as it
|
||||
import Web.modules.database.ausleihung as au
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.log.audit_log as al
|
||||
import push_notifications as pn
|
||||
import Web.modules.inventarsystem.pdf_export as pdf_export
|
||||
@@ -78,12 +79,13 @@ from Web.modules.inventarsystem.data_protection import (
|
||||
encrypt_document_fields,
|
||||
encrypt_soft_deleted_media_pack,
|
||||
)
|
||||
from Web.modules.terminplaner.blueprint import appoint_bp as terminplaner_bp
|
||||
|
||||
# Set base directory and centralized settings
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.modules.database.settings import MongoClient
|
||||
from tenant import get_tenant_context, get_tenant_trial_status, purge_expired_trial_tenants
|
||||
from tenant import get_tenant_context, get_tenant_db, get_tenant_trial_status, purge_expired_trial_tenants
|
||||
|
||||
|
||||
app = Flask(__name__, static_folder='static') # Correctly set static folder
|
||||
@@ -109,6 +111,7 @@ app.config['SESSION_COOKIE_SECURE'] = cfg.SSL_ENABLED if os.getenv('INVENTAR_SES
|
||||
app.config['PREFERRED_URL_SCHEME'] = 'https' if app.config['SESSION_COOKIE_SECURE'] else 'http'
|
||||
# app.config['QR_CODE_FOLDER'] = cfg.QR_CODE_FOLDER # QR Code storage deactivated
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
||||
app.register_blueprint(terminplaner_bp, url_prefix='/terminplaner')
|
||||
|
||||
"""--------------------------------------------------------------Path Init-------------------------------------------------------"""
|
||||
|
||||
@@ -624,14 +627,18 @@ def handle_unexpected_exception(e):
|
||||
if request.is_json or request.path.startswith('/api/'):
|
||||
return jsonify({'error': 'Internal server error', 'status': 500}), 500
|
||||
|
||||
if 'username' in session:
|
||||
try:
|
||||
return render_template(
|
||||
'maintenance.html',
|
||||
error_code=500,
|
||||
error_message='Das System führt gerade Wartungsarbeiten durch.',
|
||||
), 500
|
||||
except Exception:
|
||||
try:
|
||||
return render_template('error.html', error_code=500, error_message='Interner Serverfehler.'), 500
|
||||
return render_template('error.html', error_code=500, error_message='Das System führt gerade Wartungsarbeiten durch.'), 500
|
||||
except Exception:
|
||||
return 'Internal Server Error', 500
|
||||
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
def _csrf_error_response(message='CSRF token fehlt oder ist ungültig.'):
|
||||
if request.is_json or request.path.startswith('/api/') or request.path in {'/download_book_cover', '/proxy_image', '/log_mobile_issue'}:
|
||||
@@ -1259,6 +1266,7 @@ def inject_version():
|
||||
'CURRENT_MODULE': current_module,
|
||||
'school_periods': SCHOOL_PERIODS,
|
||||
'inventory_module_enabled': cfg.MODULES.is_enabled('inventory'),
|
||||
'terminplan_module_enabled': cfg.MODULES.is_enabled('terminplan'),
|
||||
'library_module_enabled': cfg.MODULES.is_enabled('library'),
|
||||
'student_cards_module_enabled': cfg.MODULES.is_enabled('student_cards'),
|
||||
'is_admin': is_admin,
|
||||
@@ -4796,6 +4804,96 @@ def get_bookings():
|
||||
client.close()
|
||||
|
||||
|
||||
@app.route('/get_user_appointments')
|
||||
def get_user_appointments():
|
||||
"""Return the current user's planned and active appointments for the calendar."""
|
||||
if 'username' not in session:
|
||||
return jsonify({'ok': False, 'error': 'unauthorized'}), 401
|
||||
|
||||
client = None
|
||||
try:
|
||||
username = session.get('username')
|
||||
|
||||
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
|
||||
db = client[MONGODB_DB]
|
||||
items_col = db['items']
|
||||
|
||||
# Use the appointment collection for the user's appointments
|
||||
appointments = termin.get_upcoming_for_user(username, limit=250)
|
||||
|
||||
result = []
|
||||
import re as _re
|
||||
|
||||
def _date_iter(start_value: str, end_value: str):
|
||||
try:
|
||||
start_date = datetime.datetime.strptime(start_value, '%Y-%m-%d').date()
|
||||
end_date = datetime.datetime.strptime(end_value, '%Y-%m-%d').date()
|
||||
except Exception:
|
||||
return []
|
||||
if end_date < start_date:
|
||||
end_date = start_date
|
||||
cursor = start_date
|
||||
days = []
|
||||
while cursor <= end_date:
|
||||
days.append(cursor.strftime('%Y-%m-%d'))
|
||||
cursor += datetime.timedelta(days=1)
|
||||
return days
|
||||
|
||||
for appt in appointments:
|
||||
appt_id = str(appt.get('_id') or '')
|
||||
if not appt_id:
|
||||
continue
|
||||
|
||||
date_start = str(appt.get('date_start') or '')
|
||||
date_end = str(appt.get('date_end') or date_start)
|
||||
time_span = appt.get('time_span', []) or []
|
||||
title = appt.get('note') or f"Termin von {appt.get('user') or ''}"
|
||||
days_in_range = _date_iter(date_start, date_end) or ([date_start] if date_start else [])
|
||||
|
||||
span_entries = []
|
||||
for entry in time_span:
|
||||
s = str(entry or '').strip()
|
||||
if not s:
|
||||
continue
|
||||
m_date = _re.match(r"^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
|
||||
if m_date:
|
||||
span_entries.append((m_date.group(1), m_date.group(2), m_date.group(3)))
|
||||
continue
|
||||
m = _re.match(r"^(\d{2}:\d{2})-(\d{2}:\d{2})$", s)
|
||||
if m:
|
||||
for day in days_in_range:
|
||||
span_entries.append((day, m.group(1), m.group(2)))
|
||||
|
||||
# If the stored span is already day-specific, use it as-is.
|
||||
# If it was generic, we duplicated it to each day in the range above.
|
||||
if not span_entries and days_in_range:
|
||||
# Fallback: create a simple all-day marker for each date so the appointment is visible.
|
||||
for day in days_in_range:
|
||||
span_entries.append((day, '08:00', '16:45'))
|
||||
|
||||
for day, start_time, end_time in span_entries:
|
||||
result.append({
|
||||
'id': f"{appt_id}-{day}-{start_time}",
|
||||
'title': title,
|
||||
'start': f"{day}T{start_time}",
|
||||
'end': f"{day}T{end_time}",
|
||||
'status': 'planned',
|
||||
'itemId': appt_id,
|
||||
'userName': str(appt.get('user') or ''),
|
||||
'notes': str(appt.get('note') or ''),
|
||||
'period': None,
|
||||
'isCurrentUser': True,
|
||||
'itemBorrower': '',
|
||||
})
|
||||
|
||||
return jsonify({'ok': True, 'bookings': result})
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e), 'bookings': []}), 500
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
@app.route('/api/booking_conflicts')
|
||||
def api_booking_conflicts():
|
||||
"""
|
||||
@@ -7563,6 +7661,10 @@ def terminplan():
|
||||
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')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if not cfg.MODULES.is_enabled('terminplan'):
|
||||
flash('Der Terminplaner ist deaktiviert.', 'info')
|
||||
return redirect(url_for('home'))
|
||||
|
||||
# Make sure the template exists
|
||||
template_path = os.path.join(BASE_DIR, 'templates', 'terminplan.html')
|
||||
|
||||
@@ -97,6 +97,9 @@ DEFAULTS = {
|
||||
'inventory': {
|
||||
'enabled': True
|
||||
},
|
||||
'terminplan': {
|
||||
'enabled': True
|
||||
},
|
||||
'library': {
|
||||
'enabled': False
|
||||
},
|
||||
@@ -272,13 +275,20 @@ class _TenantAwareBool:
|
||||
from Web.modules.module_registry import registry as MODULES
|
||||
|
||||
INVENTORY_MODULE_ENABLED = _TenantAwareBool('inventory', _get(_conf, ['modules', 'inventory', 'enabled'], DEFAULTS['modules']['inventory']['enabled']))
|
||||
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']))
|
||||
|
||||
def _match_inventory(path):
|
||||
if not path: return False
|
||||
if path == '/' or path.startswith('/home'): return True
|
||||
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items', '/terminplan'))
|
||||
return path.startswith(('/scanner', '/inventory', '/upload_admin', '/manage_filters', '/manage_locations', '/admin_borrowings', '/admin_damaged_items', '/admin/borrowings', '/admin/damaged_items'))
|
||||
|
||||
|
||||
def _match_terminplan(path):
|
||||
if not path:
|
||||
return False
|
||||
return path.startswith(('/terminplan', '/terminplaner'))
|
||||
|
||||
def _match_library(path):
|
||||
if not path: return False
|
||||
@@ -290,6 +300,7 @@ def _match_student_cards(path):
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ from Web.modules.database.settings import MongoClient
|
||||
from bson.objectid import ObjectId
|
||||
import datetime
|
||||
|
||||
|
||||
def _get_tenant_db(client):
|
||||
try:
|
||||
from tenant import get_tenant_db
|
||||
return get_tenant_db(client)
|
||||
except Exception:
|
||||
return client[cfg.MONGODB_DB]
|
||||
|
||||
def _active_record_query(extra_query=None):
|
||||
"""Build a query that excludes logically deleted records."""
|
||||
base_query = {'Deleted': {'$ne': True}}
|
||||
@@ -30,10 +38,10 @@ def _active_record_query(extra_query=None):
|
||||
return base_query
|
||||
|
||||
|
||||
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str=""):
|
||||
def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False):
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
item = {
|
||||
@@ -45,6 +53,7 @@ def add(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght
|
||||
'user': user,
|
||||
'mail': mail,
|
||||
'note': note,
|
||||
'calendar_enabled': bool(calendar_enabled),
|
||||
'slots_booked': [], # -> [(start_time, name), ...]the list gets there indexes as the slot 1-defined so is can be counted without an extra variable
|
||||
'Created': datetime.datetime.now(),
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
@@ -67,7 +76,7 @@ def get_item(id):
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
|
||||
client.close()
|
||||
@@ -89,11 +98,11 @@ def update(id,slots_used: list):
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = client[cfg.MONGODB_DB]
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
update_data = {
|
||||
'slots_booked': [slots_used],
|
||||
'slots_booked': slots_used,
|
||||
'LastUpdated': datetime.datetime.now()
|
||||
}
|
||||
|
||||
@@ -108,3 +117,118 @@ def update(id,slots_used: list):
|
||||
print(f"Error updating item: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_slot(id, date_start_time, name):
|
||||
"""
|
||||
Remove a booked slot from an appointment's `slots_booked`.
|
||||
|
||||
Args:
|
||||
id (str): Appointment ID
|
||||
date_start_time: The start time value used when booking
|
||||
name (str): Name associated with the booking
|
||||
|
||||
Returns:
|
||||
bool: True if a slot was removed, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
# Attempt to pull the exact element (stored as an array/tuple)
|
||||
result = items.update_one(
|
||||
{'_id': ObjectId(id)},
|
||||
{'$pull': {'slots_booked': [date_start_time, name]}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error removing slot: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove(id):
|
||||
"""
|
||||
Soft-delete an appointment by setting its `Deleted` flag.
|
||||
|
||||
Args:
|
||||
id (str): Appointment ID
|
||||
|
||||
Returns:
|
||||
bool: True if the appointment was marked deleted, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
result = items.delete_one({'_id': ObjectId(id)})
|
||||
|
||||
client.close()
|
||||
return result.deleted_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error removing appointment: {e}")
|
||||
return False
|
||||
|
||||
def remove_done():
|
||||
"""removose already finisched appointments"""
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||
removed_count = 0
|
||||
|
||||
cursor = items.find(
|
||||
_active_record_query(
|
||||
{
|
||||
'date_end': {'$lt': today},
|
||||
}
|
||||
)
|
||||
).sort('date_start', 1)
|
||||
|
||||
for item in cursor:
|
||||
item['_id'] = str(item.get('_id'))
|
||||
result = items.delete_one({'_id': ObjectId(item['_id'])})
|
||||
removed_count += result.deleted_count
|
||||
|
||||
client.close()
|
||||
return removed_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error removing appointment: {e}")
|
||||
return False
|
||||
|
||||
def get_upcoming_for_user(user: str, limit: int = 25):
|
||||
"""Return upcoming appointment plans for a user ordered by start date."""
|
||||
remove_done()
|
||||
try:
|
||||
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
|
||||
db = _get_tenant_db(client)
|
||||
items = db['appointments']
|
||||
|
||||
today = datetime.date.today().strftime('%Y-%m-%d')
|
||||
cursor = items.find(
|
||||
_active_record_query(
|
||||
{
|
||||
'user': str(user or '').strip(),
|
||||
'date_end': {'$gte': today},
|
||||
}
|
||||
)
|
||||
).sort('date_start', 1)
|
||||
|
||||
results = []
|
||||
for item in cursor:
|
||||
item['_id'] = str(item.get('_id'))
|
||||
results.append(item)
|
||||
if len(results) >= max(1, int(limit)):
|
||||
break
|
||||
|
||||
client.close()
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"Error retrieving upcoming appointments: {e}")
|
||||
return []
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
"""
|
||||
Class for all funktions of the executive -> Lehrer
|
||||
"""
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
from flask import url_for, has_request_context, request
|
||||
import Web.modules.emailservice.email as mail_service
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.database.settings as cfg
|
||||
from Web.tenant import get_tenant_context
|
||||
|
||||
|
||||
def _resolve_public_base_url() -> str:
|
||||
if has_request_context():
|
||||
try:
|
||||
return request.url_root.rstrip('/')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tenant_context = get_tenant_context()
|
||||
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"
|
||||
|
||||
|
||||
def _current_tenant_id() -> str:
|
||||
tenant_context = get_tenant_context()
|
||||
if tenant_context and getattr(tenant_context, 'tenant_id', None):
|
||||
return str(tenant_context.tenant_id)
|
||||
if has_request_context():
|
||||
try:
|
||||
return str(request.args.get('tenant', '') or request.args.get('tenant_id', '') or '').strip()
|
||||
except Exception:
|
||||
return ''
|
||||
return ''
|
||||
|
||||
|
||||
def _normalize_time_span(time_span):
|
||||
if isinstance(time_span, list):
|
||||
return [str(entry).strip() for entry in time_span if str(entry).strip()]
|
||||
if isinstance(time_span, tuple):
|
||||
return [str(entry).strip() for entry in time_span if str(entry).strip()]
|
||||
if isinstance(time_span, str):
|
||||
normalized = []
|
||||
for line in time_span.replace(';', '\n').replace(',', '\n').splitlines():
|
||||
value = line.strip()
|
||||
if value:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_mail_list(mail):
|
||||
if isinstance(mail, list):
|
||||
return [str(entry).strip() for entry in mail if str(entry).strip()]
|
||||
if isinstance(mail, tuple):
|
||||
return [str(entry).strip() for entry in mail if str(entry).strip()]
|
||||
if isinstance(mail, str):
|
||||
return [entry.strip() for entry in mail.replace(';', ',').split(',') if entry.strip()]
|
||||
return []
|
||||
|
||||
def _escape_ics_text(value):
|
||||
return str(value or '').replace('\\', '\\\\').replace(';', '\\;').replace(',', '\\,').replace('\r\n', '\n').replace('\n', '\\n').replace('\r', '')
|
||||
|
||||
|
||||
def _format_ics_date(date_value):
|
||||
if isinstance(date_value, datetime.datetime):
|
||||
return date_value.strftime('%Y%m%dT%H%M%SZ')
|
||||
if isinstance(date_value, datetime.date):
|
||||
return date_value.strftime('%Y%m%d')
|
||||
return ''
|
||||
|
||||
|
||||
def build_calendar_ics(appointment_id: str) -> str | None:
|
||||
item = termin.get_item(appointment_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
date_start = item.get('date_start')
|
||||
date_end = item.get('date_end')
|
||||
time_span = item.get('time_span', []) or []
|
||||
creator = item.get('user', 'Terminplaner')
|
||||
note = item.get('note', '') or ''
|
||||
tenant_id = _current_tenant_id()
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
host = _resolve_public_base_url()
|
||||
link = host + "/terminplaner/client/" + str(appointment_id)
|
||||
if tenant_id:
|
||||
link += f"?tenant={tenant_id}"
|
||||
|
||||
try:
|
||||
start_date = datetime.datetime.strptime(str(date_start), '%Y-%m-%d').date()
|
||||
end_date = datetime.datetime.strptime(str(date_end), '%Y-%m-%d').date()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
uid = f"terminplaner-{appointment_id}@invario.eu"
|
||||
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
|
||||
summary = f"Terminplan für {creator}"
|
||||
description_lines = [
|
||||
f"Buchungslink: {link}",
|
||||
f"Zeitraum: {date_start} bis {date_end}",
|
||||
]
|
||||
if time_span:
|
||||
description_lines.append('Zeitfenster: ' + '; '.join(str(entry) for entry in time_span))
|
||||
if note:
|
||||
description_lines.append('Notiz: ' + str(note))
|
||||
|
||||
ics_lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Inventarsystem//Terminplaner//DE',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'BEGIN:VEVENT',
|
||||
f'UID:{uid}',
|
||||
f'DTSTAMP:{created_at}',
|
||||
f'SUMMARY:{_escape_ics_text(summary)}',
|
||||
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
|
||||
f'URL:{_escape_ics_text(link)}',
|
||||
f'DTSTART;VALUE=DATE:{_format_ics_date(start_date)}',
|
||||
f'DTEND;VALUE=DATE:{_format_ics_date(end_date + timedelta(days=1))}',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
'',
|
||||
]
|
||||
return '\r\n'.join(ics_lines)
|
||||
|
||||
|
||||
def build_client_slot_ics(appointment_id: str, slot_start: str, client_name: str = '') -> str | None:
|
||||
"""Build a single-slot ICS export for a client booking candidate."""
|
||||
item = termin.get_item(appointment_id)
|
||||
if not item:
|
||||
return None
|
||||
|
||||
try:
|
||||
start_dt = datetime.datetime.strptime(str(slot_start).strip(), '%Y-%m-%d %H:%M')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
slot_minutes = int(item.get('slot_lenght') or 0)
|
||||
except Exception:
|
||||
slot_minutes = 0
|
||||
if slot_minutes <= 0:
|
||||
slot_minutes = 45
|
||||
|
||||
end_dt = start_dt + datetime.timedelta(minutes=slot_minutes)
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=str(appointment_id), tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
host = _resolve_public_base_url()
|
||||
link = host + '/terminplaner/client/' + str(appointment_id)
|
||||
if tenant_id:
|
||||
link += f'?tenant={tenant_id}'
|
||||
|
||||
title_name = str(client_name or '').strip() or 'Termin'
|
||||
summary = f"{title_name} - Terminbuchung"
|
||||
description_lines = [
|
||||
f"Buchungslink: {link}",
|
||||
f"Geplanter Termin: {start_dt.strftime('%d.%m.%Y %H:%M')} - {end_dt.strftime('%H:%M')}",
|
||||
]
|
||||
|
||||
uid = f"terminplaner-slot-{appointment_id}-{start_dt.strftime('%Y%m%d%H%M')}@invario.eu"
|
||||
created_at = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ')
|
||||
dt_start = start_dt.strftime('%Y%m%dT%H%M%S')
|
||||
dt_end = end_dt.strftime('%Y%m%dT%H%M%S')
|
||||
|
||||
ics_lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Inventarsystem//Terminplaner Client Slot//DE',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'BEGIN:VEVENT',
|
||||
f'UID:{uid}',
|
||||
f'DTSTAMP:{created_at}',
|
||||
f'SUMMARY:{_escape_ics_text(summary)}',
|
||||
f'DESCRIPTION:{_escape_ics_text(chr(10).join(description_lines))}',
|
||||
f'URL:{_escape_ics_text(link)}',
|
||||
f'DTSTART:{dt_start}',
|
||||
f'DTEND:{dt_end}',
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
'',
|
||||
]
|
||||
return '\r\n'.join(ics_lines)
|
||||
|
||||
|
||||
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="", calendar_enabled: bool=False) -> dict:
|
||||
"""
|
||||
Generates a link for the executive to send to his clients to book a time Slot
|
||||
|
||||
Input:
|
||||
- date_start: start of the time frae area
|
||||
- date_end: end of the time frame area
|
||||
- time_span: Time window for the days as a list [(first day Time Frame), (second day Time frame), (third etc.)]
|
||||
- slots: amount of slots that are available
|
||||
- slot_lenght: the lenght of a slot in minutes
|
||||
|
||||
Output:
|
||||
- link: The link for the user to send to the clients
|
||||
"""
|
||||
normalized_time_span = _normalize_time_span(time_span)
|
||||
normalized_mail = _normalize_mail_list(mail)
|
||||
id = termin.add(date_start, date_end, normalized_time_span, slots, slot_lenght, user, normalized_mail, note, calendar_enabled=calendar_enabled)
|
||||
id_str = str(id)
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=id_str, tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
host = _resolve_public_base_url()
|
||||
link = host + "/terminplaner/client/" + id_str
|
||||
if tenant_id:
|
||||
link += f"?tenant={tenant_id}"
|
||||
subject = f"Terminanfrage von {user}"
|
||||
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
||||
calendar_link = None
|
||||
if calendar_enabled:
|
||||
try:
|
||||
calendar_link = url_for('terminplaner.calendar_export', appointment_id=id_str, tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
host = _resolve_public_base_url()
|
||||
calendar_link = host + "/terminplaner/calendar/" + id_str + ".ics"
|
||||
if tenant_id:
|
||||
calendar_link += f"?tenant={tenant_id}"
|
||||
|
||||
email_body = note_link
|
||||
if calendar_link:
|
||||
email_body += f"\n\nKalendereintrag: {calendar_link}"
|
||||
|
||||
if normalized_mail and cfg.EMAIL_ENABLED:
|
||||
mail_service.send(normalized_mail, subject, email_body)
|
||||
|
||||
return {
|
||||
'appointment_id': id_str,
|
||||
'link': link,
|
||||
'calendar_link': calendar_link,
|
||||
}
|
||||
|
||||
|
||||
def book_slot(id, date_start_time, name):
|
||||
"""
|
||||
Updates slot for the booking per a id
|
||||
|
||||
Input:
|
||||
- id: the id is the id you get from the
|
||||
- date_start_time: the date of the booking that was selected with date and time
|
||||
- name: name that the client gave himself
|
||||
|
||||
Output:
|
||||
- bool: if worked or not
|
||||
"""
|
||||
try:
|
||||
# Retrieve the current appointment
|
||||
item = termin.get_item(id)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
slots = item.get('slots_booked', []) or []
|
||||
if not isinstance(slots, list):
|
||||
slots = []
|
||||
|
||||
capacity = int(item.get('slots', 0) or 0)
|
||||
if capacity and len(slots) >= capacity:
|
||||
return False
|
||||
|
||||
for existing in slots:
|
||||
if isinstance(existing, (list, tuple)) and len(existing) >= 2:
|
||||
if existing[0] == date_start_time and existing[1] == name:
|
||||
return False
|
||||
|
||||
# Append the new booking as a tuple (start_time, name)
|
||||
slots.append((date_start_time, name))
|
||||
|
||||
# Update the appointment in the database
|
||||
success = termin.update(id, slots)
|
||||
return bool(success)
|
||||
except Exception as e:
|
||||
print(f"Error booking slot: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_slot(id, date_start_time, name):
|
||||
"""
|
||||
Remove a booked slot for an appointment.
|
||||
|
||||
Returns True if the removal succeeded, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Prefer DB-level remove if available
|
||||
if hasattr(termin, 'remove_slot'):
|
||||
removed = termin.remove_slot(id, date_start_time, name)
|
||||
if removed:
|
||||
return True
|
||||
|
||||
# Fallback: fetch, filter, and replace the slot list
|
||||
item = termin.get_item(id)
|
||||
if not item:
|
||||
return False
|
||||
|
||||
slots = item.get('slots_booked', []) or []
|
||||
new_slots = []
|
||||
for s in slots:
|
||||
try:
|
||||
# s may be list or tuple like [start_time, name]
|
||||
if isinstance(s, (list, tuple)) and len(s) >= 2 and s[0] == date_start_time and s[1] == name:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
new_slots.append(s)
|
||||
|
||||
success = termin.update(id, new_slots)
|
||||
return bool(success)
|
||||
except Exception as e:
|
||||
print(f"Error removing slot: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_appointment(id):
|
||||
"""
|
||||
Remove an entire appointment by id.
|
||||
"""
|
||||
try:
|
||||
return bool(termin.remove(id))
|
||||
except Exception as e:
|
||||
print(f"Error removing appointment: {e}")
|
||||
return False
|
||||
|
||||
def get_available(id):
|
||||
"""
|
||||
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
|
||||
And checks if there are slots left.
|
||||
|
||||
Input:
|
||||
- id: id of the appointment
|
||||
|
||||
Output:
|
||||
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
|
||||
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
|
||||
"""
|
||||
try:
|
||||
termin_range = termin.get_item(id)
|
||||
if not termin_range:
|
||||
return {}
|
||||
|
||||
date_start = termin_range.get('date_start')
|
||||
date_end = termin_range.get('date_end')
|
||||
time_span = termin_range.get('time_span', [])
|
||||
slot_lenght = termin_range.get('slot_lenght')
|
||||
total_slots = termin_range.get('slots', 0)
|
||||
# Ensure numeric fields are cast to int when stored as strings
|
||||
try:
|
||||
total_slots = int(termin_range.get('slots', 0) or 0)
|
||||
except Exception:
|
||||
total_slots = 0
|
||||
|
||||
try:
|
||||
slot_lenght = int(termin_range.get('slot_lenght') or 0)
|
||||
except Exception:
|
||||
slot_lenght = termin_range.get('slot_lenght')
|
||||
|
||||
booked = termin_range.get('slots_booked', []) or []
|
||||
|
||||
# Normalize booked entries to dicts for easier consumption
|
||||
normalized = []
|
||||
for s in booked:
|
||||
if isinstance(s, (list, tuple)) and len(s) >= 2:
|
||||
normalized.append({'start': s[0], 'name': s[1]})
|
||||
elif isinstance(s, dict):
|
||||
normalized.append(s)
|
||||
else:
|
||||
normalized.append({'value': s})
|
||||
|
||||
slots_used = len(normalized)
|
||||
try:
|
||||
slots_left = max(0, int(total_slots) - slots_used)
|
||||
except Exception:
|
||||
slots_left = max(0, slots_used - slots_used)
|
||||
|
||||
return {
|
||||
'date_start': date_start,
|
||||
'date_end': date_end,
|
||||
'time_span': time_span,
|
||||
'slot_lenght': slot_lenght,
|
||||
'slots_total': total_slots,
|
||||
'slots_booked': normalized,
|
||||
'slots_left': slots_left,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting available slots: {e}")
|
||||
return {}
|
||||
|
||||
def get_available_user(id):
|
||||
"""
|
||||
Gets the available time slots -> more over it returns the time frame and the allready booked slots also the lenght.
|
||||
And checks if there are slots left.
|
||||
|
||||
Input:
|
||||
- id: id of the appointment
|
||||
|
||||
Output:
|
||||
- dict: all the needet information -> [Start_date, End_date, (first day Time Frame,
|
||||
second day Time frame, third etc.), slot lenght, (bookedslots -> list)]
|
||||
"""
|
||||
return get_available(id)
|
||||
|
||||
|
||||
def get_user_upcoming_events(user: str, limit: int = 25) -> list[dict]:
|
||||
"""Return upcoming appointment plans for overview display."""
|
||||
user_name = str(user or '').strip()
|
||||
if not user_name:
|
||||
return []
|
||||
|
||||
appointments = termin.get_upcoming_for_user(user_name, limit=limit)
|
||||
host = _resolve_public_base_url()
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
result = []
|
||||
for item in appointments:
|
||||
appointment_id = str(item.get('_id') or '')
|
||||
if not appointment_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
link = url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
link = host + '/terminplaner/client/' + appointment_id
|
||||
if tenant_id:
|
||||
link += f'?tenant={tenant_id}'
|
||||
|
||||
try:
|
||||
calendar_link = url_for('terminplaner.calendar_export', appointment_id=appointment_id, tenant=tenant_id or None, _external=True)
|
||||
except Exception:
|
||||
calendar_link = host + '/terminplaner/calendar/' + appointment_id + '.ics'
|
||||
if tenant_id:
|
||||
calendar_link += f'?tenant={tenant_id}'
|
||||
|
||||
slots_total = int(item.get('slots', 0) or 0)
|
||||
slots_booked = item.get('slots_booked', []) or []
|
||||
if not isinstance(slots_booked, list):
|
||||
slots_booked = []
|
||||
|
||||
result.append(
|
||||
{
|
||||
'appointment_id': appointment_id,
|
||||
'date_start': str(item.get('date_start') or ''),
|
||||
'date_end': str(item.get('date_end') or ''),
|
||||
'time_span': item.get('time_span', []) or [],
|
||||
'slots_total': slots_total,
|
||||
'slots_booked': len(slots_booked),
|
||||
'slots_left': max(0, slots_total - len(slots_booked)),
|
||||
'note': str(item.get('note') or ''),
|
||||
'link': link,
|
||||
'calendar_link': calendar_link if item.get('calendar_enabled') else None,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,254 @@
|
||||
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
|
||||
from flask import Response
|
||||
import Web.modules.terminplaner.backend_server as appointment_service
|
||||
import Web.modules.database.settings as cfg
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.database.user as us
|
||||
|
||||
# Create a blueprint instance
|
||||
appoint_bp = Blueprint('terminplaner', __name__)
|
||||
|
||||
|
||||
def _require_module_enabled():
|
||||
if not cfg.MODULES.is_enabled('terminplan'):
|
||||
flash('Der Terminplaner ist deaktiviert.', 'info')
|
||||
return redirect(url_for('home'))
|
||||
return None
|
||||
|
||||
|
||||
def _appointment_not_found_response():
|
||||
return render_template(
|
||||
'terminplaner_not_found.html',
|
||||
error_code=404,
|
||||
error_message='Der Termin wurde nicht gefunden.',
|
||||
), 404
|
||||
|
||||
|
||||
def _current_tenant_id():
|
||||
try:
|
||||
from Web.tenant import get_tenant_context
|
||||
ctx = get_tenant_context()
|
||||
if ctx and getattr(ctx, 'tenant_id', None):
|
||||
return str(ctx.tenant_id)
|
||||
except Exception:
|
||||
pass
|
||||
return str(session.get('tenant_id', '') or '').strip()
|
||||
|
||||
@appoint_bp.route('/client/<appointment_id>', methods=['POST', 'GET'])
|
||||
def client(appointment_id):
|
||||
"""
|
||||
The Route for the terminplaner to work with the client
|
||||
"""
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
available = appointment_service.get_available(appointment_id)
|
||||
if not available:
|
||||
return _appointment_not_found_response()
|
||||
|
||||
current_user = str(session.get('username', '') or '').strip()
|
||||
appointment_item = termin.get_item(appointment_id) or {}
|
||||
appointment_owner = str(appointment_item.get('user', '') or '').strip()
|
||||
can_view_booking_names = False
|
||||
if current_user:
|
||||
try:
|
||||
can_view_booking_names = bool(us.check_admin(current_user) or current_user == appointment_owner)
|
||||
except Exception:
|
||||
can_view_booking_names = bool(current_user == appointment_owner)
|
||||
|
||||
available_for_view = dict(available)
|
||||
if not can_view_booking_names:
|
||||
sanitized_bookings = []
|
||||
for booking in (available.get('slots_booked') or []):
|
||||
if isinstance(booking, dict):
|
||||
sanitized_bookings.append({'start': booking.get('start', '')})
|
||||
elif isinstance(booking, (list, tuple)) and len(booking) >= 1:
|
||||
sanitized_bookings.append({'start': booking[0]})
|
||||
else:
|
||||
sanitized_bookings.append({'start': ''})
|
||||
available_for_view['slots_booked'] = sanitized_bookings
|
||||
|
||||
if request.method == 'POST':
|
||||
start_daytime = request.form.get('start_day_time')
|
||||
username = request.form.get('client_name')
|
||||
if not start_daytime or not username:
|
||||
flash('Bitte Name und gewünschte Uhrzeit angeben.', 'error')
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
available=available_for_view,
|
||||
current_user=session.get('username', ''),
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
)
|
||||
|
||||
if appointment_service.book_slot(appointment_id, start_daytime, username):
|
||||
return redirect(
|
||||
url_for(
|
||||
'terminplaner.client_success',
|
||||
appointment_id=appointment_id,
|
||||
tenant=_current_tenant_id() or None,
|
||||
start=start_daytime,
|
||||
name=username,
|
||||
)
|
||||
)
|
||||
|
||||
flash('Der Termin konnte nicht gespeichert werden.', 'error')
|
||||
|
||||
return render_template(
|
||||
'termin_client.html',
|
||||
appointment_id=appointment_id,
|
||||
available=available_for_view,
|
||||
current_user=session.get('username', ''),
|
||||
tenant_id=_current_tenant_id(),
|
||||
can_view_booking_names=can_view_booking_names,
|
||||
)
|
||||
|
||||
|
||||
@appoint_bp.route('/client/success/<appointment_id>', methods=['GET'])
|
||||
def client_success(appointment_id):
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
slot_start = str(request.args.get('start', '') or '').strip()
|
||||
client_name = str(request.args.get('name', '') or '').strip()
|
||||
|
||||
return render_template(
|
||||
'termin_client_success.html',
|
||||
appointment_id=appointment_id,
|
||||
slot_start=slot_start,
|
||||
client_name=client_name,
|
||||
tenant_id=_current_tenant_id(),
|
||||
)
|
||||
|
||||
|
||||
@appoint_bp.route('/delete/<appointment_id>', methods=['POST'])
|
||||
def delete_appointment(appointment_id):
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
if 'username' not in session:
|
||||
flash('Bitte mit einem Konto anmelden.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
appointment = termin.get_item(appointment_id)
|
||||
if not appointment:
|
||||
return _appointment_not_found_response()
|
||||
|
||||
current_user = str(session.get('username', '')).strip()
|
||||
appointment_user = str(appointment.get('user', '')).strip()
|
||||
if not us.check_admin(current_user) and appointment_user != current_user:
|
||||
flash('Sie dürfen diesen Termin nicht löschen.', 'error')
|
||||
return redirect(url_for('terminplaner.main', tenant=_current_tenant_id() or None))
|
||||
|
||||
if termin.remove(appointment_id):
|
||||
flash('Der Terminplan wurde gelöscht.', 'success')
|
||||
else:
|
||||
flash('Der Terminplan konnte nicht gelöscht werden.', 'error')
|
||||
|
||||
return redirect(url_for('terminplaner.main', tenant=_current_tenant_id() or None))
|
||||
|
||||
@appoint_bp.route('/configure', methods=['GET', 'POST'])
|
||||
def configure():
|
||||
"""
|
||||
Route for authenticated persons to configure a new appointment for them
|
||||
"""
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
if 'username' not in session:
|
||||
flash('Bitte mit einem Konto anmelden.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if request.method == "POST":
|
||||
start = request.form.get('start_date')
|
||||
end = request.form.get('end_date')
|
||||
time = request.form.get('time_frame')
|
||||
slots_amount = request.form.get('slots_amounts')
|
||||
slot_lenght = request.form.get('slot_lenght')
|
||||
mail = request.form.get('mail', '')
|
||||
note = request.form.get('note', '')
|
||||
add_to_calendar = request.form.get('add_to_calendar') == 'on'
|
||||
|
||||
if not start or not end or not time or not slots_amount or not slot_lenght:
|
||||
flash('Bitte alle Pflichtfelder ausfüllen.', 'error')
|
||||
return render_template(
|
||||
'termin_configure.html',
|
||||
school_periods=cfg.SCHOOL_PERIODS,
|
||||
generated_link=None,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
)
|
||||
|
||||
result = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note, calendar_enabled=add_to_calendar)
|
||||
flash('Der Terminplan wurde angelegt.', 'success')
|
||||
return render_template(
|
||||
'termin_configure.html',
|
||||
school_periods=cfg.SCHOOL_PERIODS,
|
||||
generated_link=result['link'],
|
||||
calendar_link=result.get('calendar_link'),
|
||||
add_to_calendar=add_to_calendar,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
)
|
||||
elif request.method == "GET":
|
||||
return render_template(
|
||||
'termin_configure.html',
|
||||
school_periods=cfg.SCHOOL_PERIODS,
|
||||
generated_link=None,
|
||||
calendar_link=None,
|
||||
add_to_calendar=False,
|
||||
email_service_enabled=cfg.EMAIL_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
@appoint_bp.route('/calendar/<appointment_id>.ics', methods=['GET'])
|
||||
def calendar_export(appointment_id):
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
ics_content = appointment_service.build_calendar_ics(appointment_id)
|
||||
if not ics_content:
|
||||
return _appointment_not_found_response()
|
||||
|
||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=terminplan-{appointment_id}.ics'
|
||||
return response
|
||||
|
||||
|
||||
@appoint_bp.route('/client_ics/<appointment_id>.ics', methods=['GET'])
|
||||
def client_slot_calendar_export(appointment_id):
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
slot_start = str(request.args.get('start', '') or '').strip()
|
||||
client_name = str(request.args.get('name', '') or '').strip()
|
||||
ics_content = appointment_service.build_client_slot_ics(appointment_id, slot_start, client_name=client_name)
|
||||
if not ics_content:
|
||||
return _appointment_not_found_response()
|
||||
|
||||
response = Response(ics_content, mimetype='text/calendar; charset=utf-8')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=termin-{appointment_id}-{slot_start.replace(" ", "_").replace(":", "")}.ics'
|
||||
return response
|
||||
|
||||
@appoint_bp.route('/')
|
||||
def main():
|
||||
guard = _require_module_enabled()
|
||||
if guard:
|
||||
return guard
|
||||
|
||||
current_user = session.get('username', '')
|
||||
upcoming_events = appointment_service.get_user_upcoming_events(current_user) if current_user else []
|
||||
tenant_id = _current_tenant_id()
|
||||
|
||||
return render_template(
|
||||
'terminplaner.html',
|
||||
school_periods=cfg.SCHOOL_PERIODS,
|
||||
current_user=current_user,
|
||||
upcoming_events=upcoming_events,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
"""
|
||||
Class for all funktions of the executive -> Lehrer
|
||||
"""
|
||||
import datetime
|
||||
import emailservice.email as mail_service
|
||||
import Web.modules.database.termine as termin
|
||||
import Web.modules.database.settings as cfg
|
||||
from tenant import get_tenant_context
|
||||
|
||||
def new(date_start: str, date_end: str, time_span: list, slots: int, slot_lenght: int, user: str, mail: list=[], note:str="") -> str:
|
||||
"""
|
||||
Generates a link for the executive to send to his clients to book a time Slot
|
||||
|
||||
Input:
|
||||
- date_start: start of the time frae area
|
||||
- date_end: end of the time frame area
|
||||
- time_span: Time window for the days as a list [(first day Time Frame), (second day Time frame), (third etc.)]
|
||||
- slots: amount of slots that are available
|
||||
- slot_lenght: the lenght of a slot in minutes
|
||||
|
||||
Output:
|
||||
- link: The link for the user to send to the clients
|
||||
"""
|
||||
id = termin.add(date_start, date_end, time_span, slots, slot_lenght, user, mail, note)
|
||||
|
||||
tenant_context = get_tenant_context()
|
||||
subdomain = ''
|
||||
if tenant_context:
|
||||
subdomain = getattr(tenant_context, 'subdomain', '') or getattr(tenant_context, 'tenant_id', '') or ''
|
||||
|
||||
host = f"https://{subdomain}.invario.eu" if subdomain else "invario.eu"
|
||||
link = host + "/terminplaner/client" + "?" + "client_id=" + id
|
||||
subject = f"Terminanfrage von {user}"
|
||||
note_link = note + f"Bitte klicken sie auf den folgenden Link um einen Termin zu vereinbaren: {link}"
|
||||
mail_service.send(mail, subject, note_link)
|
||||
return link
|
||||
|
||||
|
||||
def book_slot(id, date_start_time, name):
|
||||
"""
|
||||
Updates slot for the booking per a id
|
||||
|
||||
Input:
|
||||
- id: the id is the id you get from the
|
||||
- date_start_time: the date of the booking that was selected with date and time
|
||||
- name: name that the client gave himself
|
||||
|
||||
Output:
|
||||
- bool: if worked or not
|
||||
"""
|
||||
termin.update()
|
||||
@@ -1,5 +1,4 @@
|
||||
flask
|
||||
bson
|
||||
werkzeug
|
||||
gunicorn
|
||||
pymongo
|
||||
|
||||
+100
-35
@@ -129,6 +129,14 @@
|
||||
--module-brand-icon: "📚";
|
||||
}
|
||||
|
||||
html[data-module="terminplan"] {
|
||||
--module-primary-color: #0f4c5c;
|
||||
--module-primary-light: #16697a;
|
||||
--module-accent-color: #2ec4b6;
|
||||
--module-accent-light: rgba(46, 196, 182, 0.1);
|
||||
--module-brand-icon: "📅";
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--module-primary-color: #1a252f;
|
||||
--module-primary-light: #2c3e50;
|
||||
@@ -145,6 +153,12 @@
|
||||
--module-accent-color: #8e44ad;
|
||||
}
|
||||
|
||||
html[data-theme="dark"][data-module="terminplan"] {
|
||||
--module-primary-color: #082f35;
|
||||
--module-primary-light: #0f4c5c;
|
||||
--module-accent-color: #21b6a8;
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
body {
|
||||
font-family: "Manrope", "Segoe UI", "Noto Sans", sans-serif;
|
||||
@@ -1078,29 +1092,73 @@
|
||||
<span class="module-label">Module:</span>
|
||||
<div class="module-tabs">
|
||||
{% if inventory_module_enabled %}
|
||||
<a href="{{ url_for('home') }}"
|
||||
class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}"
|
||||
id="inventoryModule"
|
||||
data-module="inventory">
|
||||
<a href="{{ url_for('home') }}" class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}" id="inventoryModule" data-module="inventory">
|
||||
📦 Inventarsystem
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if inventory_module_enabled and library_module_enabled %}
|
||||
{% if inventory_module_enabled and terminplan_module_enabled %}
|
||||
<div class="module-separator"></div>
|
||||
{% endif %}
|
||||
{% if terminplan_module_enabled %}
|
||||
<a href="{{ url_for('terminplaner.main') }}" class="module-tab {% if CURRENT_MODULE == 'terminplan' %}active{% endif %}" id="terminplanModule" data-module="terminplan">
|
||||
📅 Terminplaner
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if (inventory_module_enabled or terminplan_module_enabled) and library_module_enabled %}
|
||||
<div class="module-separator"></div>
|
||||
{% endif %}
|
||||
{% if library_module_enabled %}
|
||||
<a href="{{ url_for('library_view') }}"
|
||||
class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}"
|
||||
id="libraryModule"
|
||||
data-module="library">
|
||||
<a href="{{ url_for('library_view') }}" class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}" id="libraryModule" data-module="library">
|
||||
📚 Bibliothek
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if 'username' in session %}
|
||||
{% if CURRENT_MODULE != 'library' and inventory_module_enabled %}
|
||||
|
||||
{% if terminplan_module_enabled and CURRENT_MODULE == 'terminplan' %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="terminplanNavbar">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('terminplaner.main') }}" data-icon="📅">Terminplaner</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#terminplanNavContent">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="terminplanNavContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="terminplanNavList">
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link {% if current_path == url_for('terminplaner.main') %}nav-active{% endif %}" href="{{ url_for('terminplaner.main') }}" data-tutorial-tip="Die Terminplaner-Übersicht bündelt die wichtigsten Aktionen.">Übersicht</a>
|
||||
</li>
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<a class="nav-link {% if current_path == url_for('terminplan') %}nav-active{% endif %}" href="{{ url_for('terminplan') }}" data-tutorial-tip="Hier sehen Sie den Kalender mit den bestehenden Terminen.">Kalender</a>
|
||||
</li>
|
||||
{% if current_permissions.pages.get('terminplan', True) and current_permissions.actions.get('can_insert', True) %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link quick-link-pill {% if current_path == url_for('terminplaner.configure') %}nav-active{% endif %}" href="{{ url_for('terminplaner.configure') }}" data-tutorial-tip="Neuen Terminplan erstellen und an Ihr Team versenden.">Neue Planung</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item" data-nav-fixed="true">
|
||||
<button id="themeToggleBtn" class="btn btn-link nav-link px-3" aria-label="Dark Mode umschalten" title="Theme umschalten">
|
||||
<span class="theme-icon-light" style="display: none;">☀️</span>
|
||||
<span class="theme-icon-dark" style="display: none;">🌙</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item dropdown ms-lg-auto">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="termMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">Mehr Optionen</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="termMoreDropdown">
|
||||
{% if current_permissions.pages.get('home', True) %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('home') }}">Inventarsystem</a></li>
|
||||
{% endif %}
|
||||
{% if current_permissions.pages.get('library_view', True) and library_module_enabled %}
|
||||
<li><a class="dropdown-item" href="{{ url_for('library_view') }}">Bibliothek</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% elif CURRENT_MODULE != 'library' and inventory_module_enabled %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="inventoryNavbar">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('home') }}" data-icon="📦">Inventarsystem</a>
|
||||
@@ -1220,7 +1278,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
|
||||
@@ -1334,28 +1392,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" id="loginNavbar">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand py-0" href="{{ url_for('home') }}">
|
||||
{% set school_logo_thumb = school_info.get('logo_thumb') if school_info else '' %}
|
||||
{% set school_logo_path = school_info.get('logo_path') if school_info else '' %}
|
||||
{% if school_logo_thumb or school_logo_path %}
|
||||
{% if school_logo_thumb %}
|
||||
<img src="{{ url_for('uploaded_file', filename=school_logo_thumb) }}" alt="{{ school_info.name or 'Schullogo' }}" class="invario-logo">
|
||||
{% else %}
|
||||
<img src="{{ url_for('uploaded_file', filename=school_logo_path) }}" alt="{{ school_info.name or 'Schullogo' }}" class="invario-logo">
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='img/invario-logo.png') }}" alt="Invario" class="invario-logo">
|
||||
{% endif %}
|
||||
</a>
|
||||
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('impressum') }}">Impressum</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<!-- No navbar for anonymous users on public pages. -->
|
||||
{% endif %}
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
@@ -1369,6 +1406,11 @@
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% if 'username' not in session %}
|
||||
<div class="guest-impressum-footer">
|
||||
<a href="{{ url_for('impressum') }}">Impressum</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Cookie consent banner -->
|
||||
<style>
|
||||
#cookie-banner { position: fixed; bottom: 0; left: 0; right: 0; background: rgba(33,37,41,.98); color: #fff; padding: 14px 16px; display: none; z-index: 2000; box-shadow: 0 -2px 8px rgba(0,0,0,.25); }
|
||||
@@ -1502,6 +1544,29 @@
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.guest-impressum-footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 10px;
|
||||
text-align: center;
|
||||
z-index: 1200;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.guest-impressum-footer a {
|
||||
pointer-events: auto;
|
||||
font-size: 0.76rem;
|
||||
color: rgba(15, 23, 42, 0.72);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.guest-impressum-footer a:hover {
|
||||
color: rgba(15, 23, 42, 0.92);
|
||||
border-bottom-color: rgba(15, 23, 42, 0.52);
|
||||
}
|
||||
|
||||
.notification-toast.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
+61
-50
@@ -10,24 +10,6 @@
|
||||
<h1>📚 Bibliothek</h1>
|
||||
<p class="subtitle">Bücher, CDs und weitere Medien</p>
|
||||
</div>
|
||||
|
||||
<div class="header-controls">
|
||||
<!-- Favorites Toggle -->
|
||||
<div class="view-switch">
|
||||
<button id="favoriteToggle" class="toggle-btn" aria-label="Favoriten anzeigen/verbergen">
|
||||
<span class="toggle-icon">⭐</span>
|
||||
<span class="toggle-label">Favoriten</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="view-switch">
|
||||
<button id="viewModeToggle" class="toggle-btn" aria-label="Ansichtsmodus wechseln">
|
||||
<span class="toggle-icon">ㄷ</span>
|
||||
<span class="toggle-label">Tabelle</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Filter Section -->
|
||||
@@ -205,8 +187,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||
<script>
|
||||
// View mode persistence
|
||||
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
|
||||
@@ -625,50 +606,80 @@ document.getElementById('clearFiltersBtn').addEventListener('click', function()
|
||||
closeAllFilters();
|
||||
});
|
||||
|
||||
// Favorites toggle
|
||||
document.getElementById('favoriteToggle').addEventListener('click', function() {
|
||||
this.classList.toggle('open');
|
||||
// TODO: Implement favorites filtering
|
||||
});
|
||||
|
||||
// Scanner toggle
|
||||
let scanner = null;
|
||||
// Scanner state tracking
|
||||
let isScanning = false;
|
||||
|
||||
document.getElementById('scannerBtn').addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const container = document.getElementById('qrContainer');
|
||||
const isOpen = container.style.display !== 'none';
|
||||
const btn = this;
|
||||
|
||||
if (isOpen) {
|
||||
// Close the scanner
|
||||
container.style.display = 'none';
|
||||
if (scanner) scanner.clear();
|
||||
this.classList.remove('open');
|
||||
this.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
container.style.display = 'block';
|
||||
this.classList.add('open');
|
||||
this.setAttribute('aria-expanded', 'true');
|
||||
Quagga.stop();
|
||||
isScanning = false;
|
||||
|
||||
if (!scanner) {
|
||||
scanner = new Html5Qrcode('qr-reader');
|
||||
scanner.start(
|
||||
{ facingMode: "environment" },
|
||||
{ fps: 10, qrbox: 250 },
|
||||
onScanSuccess,
|
||||
onScanError
|
||||
);
|
||||
}
|
||||
btn.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
// Open the scanner
|
||||
container.style.display = 'block';
|
||||
btn.classList.add('open');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
// Targets the element where the video stream will inject
|
||||
target: document.querySelector('#qr-reader'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment" // Forces back camera
|
||||
},
|
||||
},
|
||||
decoder: {
|
||||
// Optimized for 1D barcodes (e.g., student IDs, member cards)
|
||||
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||
}
|
||||
}, function(err) {
|
||||
if (err) {
|
||||
console.error("Initialization error:", err);
|
||||
alert("Kamera konnte nicht gestartet werden.");
|
||||
return;
|
||||
}
|
||||
Quagga.start();
|
||||
isScanning = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function onScanSuccess(decodedText, decodedResult) {
|
||||
const studentId = decodedText.trim();
|
||||
// Single Quagga event listener for successful scans
|
||||
Quagga.onDetected(function(data) {
|
||||
const decodedText = data.codeResult.code;
|
||||
const studentId = String(decodedText || '').trim();
|
||||
|
||||
// Stop scanning immediately to prevent multiple triggers
|
||||
Quagga.stop();
|
||||
isScanning = false;
|
||||
|
||||
// Reset UI states to closed
|
||||
const container = document.getElementById('qrContainer');
|
||||
const btn = document.getElementById('scannerBtn');
|
||||
if (container) container.style.display = 'none';
|
||||
if (btn) {
|
||||
btn.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
// Route the scanned data to your input
|
||||
document.getElementById('studentIdInput').value = studentId;
|
||||
alert('Ausweis gescannt: ' + studentId);
|
||||
}
|
||||
|
||||
function onScanError(error) {
|
||||
// Silently ignore scan errors
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
<!-- Customizable Filter Panel -->
|
||||
|
||||
<!-- Student card / quick scan workflow -->
|
||||
<div class="library-scan-panel">
|
||||
<div class="library-scan-panel">
|
||||
<div class="library-scan-controls">
|
||||
<select id="scanModeSelect" aria-label="Scan-Modus">
|
||||
<option value="card_only">Nur Ausweis erfassen</option>
|
||||
@@ -458,8 +458,10 @@
|
||||
<div id="scanStatus" class="library-scan-status">
|
||||
Hinweis: Im Schnellmodus zuerst den Schülerausweis scannen, danach den Buch-/Mediencode.
|
||||
</div>
|
||||
<div id="scanReaderWrap" class="library-scan-reader-wrap">
|
||||
<div id="libraryQrReader" class="library-scan-reader"></div>
|
||||
<div id="scanReaderWrap" class="library-scan-reader-wrap" style="display: none;">
|
||||
<div id="libraryQrReader" class="library-scan-reader">
|
||||
<div id="library-scanner-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="filterPanel" class="library-filter-panel">
|
||||
@@ -547,7 +549,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||
<script>
|
||||
// State
|
||||
let libraryItems = [];
|
||||
@@ -847,10 +849,16 @@
|
||||
document.getElementById('detailModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Keep track of the scanner state globally/outer scope
|
||||
let scannerRunning = false;
|
||||
let lastScanValue = null;
|
||||
let lastScanAt = 0;
|
||||
let activeStudentCardId = ''; // Managed globally by your system
|
||||
|
||||
function borrowItem(itemId) {
|
||||
const selectedItem = (libraryItems || []).find(item => item._id === itemId);
|
||||
if (selectedItem && selectedItem.LibraryDisplayStatus === 'damaged') {
|
||||
alert('Dieses Medium ist als defekt/zerstört markiert und kann nicht ausgeliehen werden.');
|
||||
alert('Dieses Medium ist als defekt/zerstör markiert und kann nicht ausgeliehen werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -997,130 +1005,71 @@
|
||||
processQuickToggleScan(scannedCode);
|
||||
}
|
||||
|
||||
function handleScanError() {
|
||||
// Intentionally silent to avoid UI spam while camera searches codes.
|
||||
}
|
||||
|
||||
async function ensureScannerLibraryLoaded() {
|
||||
if (typeof Html5QrcodeScanner !== 'undefined') {
|
||||
return true;
|
||||
// Global Quagga reader hook
|
||||
Quagga.onDetected(function(data) {
|
||||
if (data && data.codeResult && data.codeResult.code) {
|
||||
handleScanSuccess(data.codeResult.code);
|
||||
}
|
||||
|
||||
const sources = [
|
||||
'https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js'
|
||||
];
|
||||
|
||||
for (const src of sources) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[data-scanner-src="${src}"]`);
|
||||
if (existing) {
|
||||
const onLoad = () => resolve();
|
||||
const onError = () => reject(new Error('Script load failed'));
|
||||
existing.addEventListener('load', onLoad, { once: true });
|
||||
existing.addEventListener('error', onError, { once: true });
|
||||
setTimeout(() => {
|
||||
existing.removeEventListener('load', onLoad);
|
||||
existing.removeEventListener('error', onError);
|
||||
if (typeof Html5QrcodeScanner !== 'undefined') {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('Script not available'));
|
||||
}
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.dataset.scannerSrc = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Script load failed'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
if (typeof Html5QrcodeScanner !== 'undefined') {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Scanner library load failed from', src, err);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
async function startScanner() {
|
||||
if (scannerRunning) return;
|
||||
|
||||
const scannerLoaded = await ensureScannerLibraryLoaded();
|
||||
if (!scannerLoaded) {
|
||||
setScanStatus('Scanner-Bibliothek konnte nicht geladen werden.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const readerWrap = document.getElementById('scanReaderWrap');
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
readerWrap.style.display = 'block';
|
||||
if (readerWrap) readerWrap.style.display = 'block';
|
||||
|
||||
try {
|
||||
const formats = [];
|
||||
if (typeof Html5QrcodeSupportedFormats !== 'undefined') {
|
||||
formats.push(
|
||||
Html5QrcodeSupportedFormats.QR_CODE,
|
||||
Html5QrcodeSupportedFormats.EAN_13,
|
||||
Html5QrcodeSupportedFormats.EAN_8,
|
||||
Html5QrcodeSupportedFormats.CODE_128,
|
||||
Html5QrcodeSupportedFormats.CODE_39,
|
||||
Html5QrcodeSupportedFormats.UPC_A,
|
||||
Html5QrcodeSupportedFormats.UPC_E,
|
||||
Html5QrcodeSupportedFormats.ITF,
|
||||
Html5QrcodeSupportedFormats.CODABAR
|
||||
);
|
||||
setScanStatus('Initializing camera...', 'warn');
|
||||
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
// Points to the interior viewport layout box
|
||||
target: document.querySelector('#libraryQrReader'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment" // Force rear mobile lenses
|
||||
},
|
||||
},
|
||||
decoder: {
|
||||
// Limited down precisely to standard library/ID configurations
|
||||
readers: [
|
||||
"code_128_reader",
|
||||
"ean_reader",
|
||||
"code_39_reader",
|
||||
"upc_reader",
|
||||
"codabar_reader",
|
||||
"i2of5_reader" // Matches original 'ITF' format mapping
|
||||
]
|
||||
}
|
||||
}, function(err) {
|
||||
if (err) {
|
||||
console.error('Scanner start failed:', err);
|
||||
if (readerWrap) readerWrap.style.display = 'none';
|
||||
const detail = (err && (err.message || err.name)) ? ` (${err.message || err.name})` : '';
|
||||
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const scannerConfig = {
|
||||
fps: 10,
|
||||
rememberLastUsedCamera: true,
|
||||
aspectRatio: 1.333334
|
||||
};
|
||||
if (formats.length > 0) {
|
||||
scannerConfig.formatsToSupport = formats;
|
||||
}
|
||||
|
||||
scannerInstance = scannerInstance || new Html5QrcodeScanner(
|
||||
'libraryQrReader',
|
||||
scannerConfig,
|
||||
false
|
||||
);
|
||||
|
||||
scannerInstance.render(handleScanSuccess, handleScanError);
|
||||
Quagga.start();
|
||||
scannerRunning = true;
|
||||
toggleBtn.textContent = 'Scanner stoppen';
|
||||
if (toggleBtn) toggleBtn.textContent = 'Scanner stoppen';
|
||||
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
|
||||
} catch (err) {
|
||||
console.error('Scanner start failed:', err);
|
||||
readerWrap.style.display = 'none';
|
||||
const detail = (err && (err.message || err.name)) ? ` (${err.message || err.name})` : '';
|
||||
setScanStatus(`Scanner konnte nicht gestartet werden${detail}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function stopScanner() {
|
||||
if (!scannerRunning || !scannerInstance) return;
|
||||
if (!scannerRunning) return;
|
||||
const readerWrap = document.getElementById('scanReaderWrap');
|
||||
const toggleBtn = document.getElementById('toggleScannerBtn');
|
||||
try {
|
||||
await scannerInstance.clear();
|
||||
} catch (err) {
|
||||
console.error('Scanner stop failed:', err);
|
||||
}
|
||||
|
||||
Quagga.stop();
|
||||
|
||||
scannerRunning = false;
|
||||
scannerInstance = null;
|
||||
readerWrap.style.display = 'none';
|
||||
toggleBtn.textContent = 'Scanner starten';
|
||||
if (readerWrap) readerWrap.style.display = 'none';
|
||||
if (toggleBtn) toggleBtn.textContent = 'Scanner starten';
|
||||
setScanStatus('Scanner gestoppt.', 'warn');
|
||||
}
|
||||
|
||||
|
||||
+103
-168
@@ -269,23 +269,6 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Compact appointment badge on cards */
|
||||
.appointment-badge {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
background-color: #e1f5fe;
|
||||
border-left: 4px solid #03a9f4;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.appointment-badge .appointment-time {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
color: #01579b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Search styles */
|
||||
.search-container {
|
||||
display: flex;
|
||||
@@ -542,8 +525,7 @@
|
||||
|
||||
window.isDebug = false; // Set to true only for development environment
|
||||
</script>
|
||||
|
||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||
<script>
|
||||
// Global state
|
||||
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
|
||||
@@ -552,51 +534,114 @@
|
||||
let codeSearchTerm = '';
|
||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||
|
||||
// QR scanner toggle setup
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const scanButton = document.getElementById('scanButton');
|
||||
// Keep track of the scanner state globally/outer scope
|
||||
let isScanning = false;
|
||||
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||
|
||||
function startScanner(targetCallback) {
|
||||
const qrReader = document.getElementById('qr-reader');
|
||||
let html5QrcodeScanner = null;
|
||||
const statusText = document.getElementById('status');
|
||||
|
||||
if (!scanButton || !qrReader) return;
|
||||
// Store the custom action that should happen when a barcode is found
|
||||
activeScannerCallback = targetCallback;
|
||||
|
||||
const setScannerUi = (isOpen) => {
|
||||
scanButton.classList.toggle('is-active', isOpen);
|
||||
scanButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
scanButton.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||
};
|
||||
// Show the container
|
||||
if (qrReader) qrReader.style.display = 'block';
|
||||
if (statusText) statusText.innerText = "Initializing camera...";
|
||||
|
||||
scanButton.addEventListener('click', function() {
|
||||
if (qrReader.style.display !== 'block') {
|
||||
qrReader.style.display = 'block';
|
||||
|
||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
||||
'qr-reader', { fps: 10, qrbox: 250 }
|
||||
);
|
||||
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
|
||||
// Put scanned code into the search box and trigger search
|
||||
const searchInput = document.getElementById('code-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = decodedText;
|
||||
searchByCode();
|
||||
}
|
||||
|
||||
setScannerUi(false);
|
||||
});
|
||||
|
||||
setScannerUi(true);
|
||||
} else {
|
||||
if (html5QrcodeScanner) {
|
||||
html5QrcodeScanner.clear();
|
||||
}
|
||||
qrReader.style.display = 'none';
|
||||
setScannerUi(false);
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
// Make sure this targets your correct HTML container element
|
||||
target: document.querySelector('#scanner-container'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment" // Force rear camera
|
||||
},
|
||||
},
|
||||
decoder: {
|
||||
// Your required formats mapped to Quagga2 reader strings
|
||||
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||
}
|
||||
}, function(err) {
|
||||
if (err) {
|
||||
console.error("Initialization error:", err);
|
||||
if (statusText) statusText.innerText = "Camera Error";
|
||||
return;
|
||||
}
|
||||
|
||||
Quagga.start();
|
||||
isScanning = true;
|
||||
|
||||
// If we are using the main search button, manage its UI state
|
||||
if (!targetCallback) {
|
||||
setScannerUi(true);
|
||||
}
|
||||
|
||||
if (statusText) statusText.innerText = "Scanning...";
|
||||
});
|
||||
}
|
||||
|
||||
function stopScanner() {
|
||||
Quagga.stop();
|
||||
isScanning = false;
|
||||
activeScannerCallback = null; // Reset the callback routing
|
||||
|
||||
const qrReader = document.getElementById('qr-reader');
|
||||
if (qrReader) qrReader.style.display = 'none';
|
||||
|
||||
setScannerUi(false);
|
||||
}
|
||||
|
||||
function setScannerUi(isOpen) {
|
||||
const scanBtn = document.getElementById('scanButton');
|
||||
if (!scanBtn) return;
|
||||
|
||||
scanBtn.classList.toggle('is-active', isOpen);
|
||||
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||
}
|
||||
|
||||
// Unified global reader callback
|
||||
Quagga.onDetected(function(data) {
|
||||
const barcode = String(data.codeResult.code || '').trim();
|
||||
console.log("Barcode detected:", barcode);
|
||||
|
||||
// Keep a local reference to the active callback before shutting down the engine
|
||||
const currentCallback = activeScannerCallback;
|
||||
|
||||
// Critical: Stop scanning immediately to prevent multiple triggers
|
||||
stopScanner();
|
||||
|
||||
// Route data based on who opened the scanner
|
||||
if (typeof currentCallback === "function") {
|
||||
// Send data directly to the specific edit field's logic
|
||||
currentCallback(barcode);
|
||||
} else {
|
||||
// Fallback Default: Main search box logic
|
||||
const resultText = document.getElementById('barcode-result');
|
||||
if (resultText) resultText.innerText = barcode;
|
||||
|
||||
const searchInput = document.getElementById('code-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = barcode;
|
||||
if (typeof searchByCode === "function") {
|
||||
searchByCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Event Listener for the main standalone search button
|
||||
document.getElementById('scanButton')?.addEventListener('click', function() {
|
||||
if (!isScanning) {
|
||||
// Pass null so it defaults to filling the standard search box
|
||||
startScanner(null);
|
||||
} else {
|
||||
stopScanner();
|
||||
}
|
||||
});
|
||||
|
||||
// Return preferred primary and fallback image URLs based on extension
|
||||
@@ -1006,23 +1051,6 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Add appointment badge if item has upcoming appointments
|
||||
let appointmentBadge = '';
|
||||
if (item.appointments && Array.isArray(item.appointments) && item.appointments.length > 0) {
|
||||
const upcomingAppointment = getUpcomingAppointment(item.appointments);
|
||||
if (upcomingAppointment) {
|
||||
const formattedDate = formatAppointmentDate(upcomingAppointment.date);
|
||||
const startPeriod = formatAppointmentPeriod(upcomingAppointment.start_period);
|
||||
const endPeriod = formatAppointmentPeriod(upcomingAppointment.end_period);
|
||||
|
||||
appointmentBadge = `
|
||||
<div class="appointment-badge">
|
||||
<strong>Geplant für:</strong> ${formattedDate}<br>
|
||||
<span class="appointment-time">${startPeriod}${endPeriod && endPeriod !== startPeriod ? ' - ' + endPeriod : ''}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-content" data-item-id="${item._id}">
|
||||
<h3 class="item-col-name">${item.Name}</h3>
|
||||
@@ -1037,7 +1065,6 @@
|
||||
${imagesHtml}
|
||||
</div>
|
||||
${borrowerBadge}
|
||||
${appointmentBadge}
|
||||
</div>
|
||||
<div class="actions">
|
||||
${isAvailableForBorrow && !item.BlockedNow ?
|
||||
@@ -1614,27 +1641,6 @@
|
||||
`;
|
||||
}
|
||||
|
||||
// Add appointment info panel if item has appointments
|
||||
let appointmentInfoHtml = '';
|
||||
if (item.appointments && Array.isArray(item.appointments) && item.appointments.length > 0) {
|
||||
const upcomingAppointment = getUpcomingAppointment(item.appointments);
|
||||
if (upcomingAppointment) {
|
||||
const formattedDate = formatAppointmentDate(upcomingAppointment.date);
|
||||
const startPeriod = formatAppointmentPeriod(upcomingAppointment.start_period);
|
||||
const endPeriod = formatAppointmentPeriod(upcomingAppointment.end_period);
|
||||
const timeDisplay = startPeriod + (endPeriod && endPeriod !== startPeriod ? ' - ' + endPeriod : '');
|
||||
|
||||
appointmentInfoHtml = `
|
||||
<div class="appointment-info-panel">
|
||||
<h4>Geplanter Termin</h4>
|
||||
<p class="appointment-date">Datum: ${formattedDate}</p>
|
||||
<p class="appointment-time">Zeit: ${timeDisplay}</p>
|
||||
${upcomingAppointment.notes ? `<p class="appointment-notes">Notizen: ${upcomingAppointment.notes}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build modal content HTML
|
||||
modalContent.innerHTML = `
|
||||
<h2>${item.Name}</h2>
|
||||
@@ -1642,7 +1648,6 @@
|
||||
<button type="button" class="bookmark-btn" id="modal-bookmark-btn" title="Merken">${(window.currentFavorites||new Set()).has(item._id) ? '★' : '☆'}</button>
|
||||
</div>
|
||||
${borrowerInfoHtml}
|
||||
${appointmentInfoHtml}
|
||||
|
||||
<div class="modal-image-container">
|
||||
${imagesHtml}
|
||||
@@ -2804,66 +2809,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for appointment display
|
||||
function formatAppointmentDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAppointmentPeriod(period) {
|
||||
if (!period) return '';
|
||||
const periodMap = {
|
||||
'1': '1. Stunde',
|
||||
'2': '2. Stunde',
|
||||
'3': '3. Stunde',
|
||||
'4': '4. Stunde',
|
||||
'5': '5. Stunde',
|
||||
'6': '6. Stunde',
|
||||
'7': '7. Stunde',
|
||||
'8': '8. Stunde',
|
||||
'9': '9. Stunde',
|
||||
'10': '10. Stunde',
|
||||
'pause1': '1. Pause',
|
||||
'pause2': '2. Pause',
|
||||
'mittagspause': 'Mittagspause'
|
||||
};
|
||||
return periodMap[period] || period;
|
||||
}
|
||||
|
||||
function getUpcomingAppointment(appointments) {
|
||||
if (!appointments || !Array.isArray(appointments) || appointments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const today = now.toDateString();
|
||||
|
||||
// Filter appointments that are today or in the future
|
||||
const futureAppointments = appointments.filter(apt => {
|
||||
try {
|
||||
const aptDate = new Date(apt.date);
|
||||
return aptDate.toDateString() === today || aptDate >= now.setHours(0, 0, 0, 0);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (futureAppointments.length === 0) return null;
|
||||
|
||||
// Sort by date and return the earliest
|
||||
futureAppointments.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
return futureAppointments[0];
|
||||
}
|
||||
|
||||
function formatDateForInput(dateString) {
|
||||
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
|
||||
const date = new Date(dateString);
|
||||
@@ -3947,16 +3892,6 @@
|
||||
.borrower-info-panel h4 { color: #856404; margin: 0 0 10px 0; font-size: 1.1rem; }
|
||||
.borrower-name, .borrow-time { margin: 5px 0; color: #856404; font-weight: 500; }
|
||||
|
||||
.appointment-info-panel {
|
||||
background-color: #e1f5fe;
|
||||
border: 1px solid #81d4fa;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.appointment-info-panel h4 { color: #0277bd; margin: 0 0 10px 0; font-size: 1.1rem; }
|
||||
.appointment-date, .appointment-time, .appointment-user { margin: 5px 0; color: #0277bd; font-weight: 500; }
|
||||
|
||||
/* Modal Details Styling (text formatting) */
|
||||
.modal-details { margin: 20px 0; }
|
||||
.detail-group { display: flex; margin-bottom: 10px; align-items: flex-start; }
|
||||
|
||||
+123
-74
@@ -2435,7 +2435,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
</div>
|
||||
<div class="qr-container">
|
||||
<button id="scanButton" class="scan-button" aria-controls="qr-reader" aria-expanded="false">Barcode scannen</button>
|
||||
<div id="qr-reader"></div>
|
||||
<div id="qr-reader" style="display: none;">
|
||||
<h3>Status: <span id="status">Bereit</span></h3>
|
||||
<div id="scanner-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="table-view-header" class="table-view-header" aria-hidden="true">
|
||||
@@ -2729,7 +2732,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
|
||||
window.isDebug = false; // Set to true only for development environment
|
||||
</script>
|
||||
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.js"></script>
|
||||
<script>
|
||||
// Function to check if a file is a video
|
||||
function isVideoFile(filename) {
|
||||
@@ -2739,7 +2742,6 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
}
|
||||
|
||||
// Initialize QR Code scanner and global variables
|
||||
let html5QrcodeScanner = null;
|
||||
let codeSearchTerm = '';
|
||||
let descSearchIds = null; // Set of matching IDs or null when disabled
|
||||
let currentUsername = '';
|
||||
@@ -2768,51 +2770,114 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
// No-op in production
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('scanButton').addEventListener('click', function() {
|
||||
const qrReader = document.getElementById('qr-reader');
|
||||
const scanBtn = document.getElementById('scanButton');
|
||||
|
||||
const setScannerUi = (isOpen) => {
|
||||
if (!scanBtn) return;
|
||||
scanBtn.classList.toggle('is-active', isOpen);
|
||||
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||
};
|
||||
|
||||
if (qrReader.style.display === 'none') {
|
||||
qrReader.style.display = 'block';
|
||||
|
||||
html5QrcodeScanner = new Html5QrcodeScanner(
|
||||
"qr-reader", {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
}
|
||||
);
|
||||
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
|
||||
// Instead of navigating to the URL, put the scanned code in the search box
|
||||
const searchInput = document.getElementById('code-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = decodedText;
|
||||
// Trigger search automatically
|
||||
// Keep track of the scanner state globally/outer scope
|
||||
let isScanning = false;
|
||||
let activeScannerCallback = null; // Tracks which function currently owns the scanner output
|
||||
|
||||
function startScanner(targetCallback) {
|
||||
const qrReader = document.getElementById('qr-reader');
|
||||
const statusText = document.getElementById('status');
|
||||
|
||||
// Store the custom action that should happen when a barcode is found
|
||||
activeScannerCallback = targetCallback;
|
||||
|
||||
// Show the container
|
||||
if (qrReader) qrReader.style.display = 'block';
|
||||
if (statusText) statusText.innerText = "Initializing camera...";
|
||||
|
||||
Quagga.init({
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
// Make sure this targets your correct HTML container element
|
||||
target: document.querySelector('#scanner-container'),
|
||||
constraints: {
|
||||
width: 640,
|
||||
height: 480,
|
||||
facingMode: "environment" // Force rear camera
|
||||
},
|
||||
},
|
||||
decoder: {
|
||||
// Your required formats mapped to Quagga2 reader strings
|
||||
readers: ["code_128_reader", "ean_reader", "code_39_reader", "upc_reader"]
|
||||
}
|
||||
}, function(err) {
|
||||
if (err) {
|
||||
console.error("Initialization error:", err);
|
||||
if (statusText) statusText.innerText = "Camera Error";
|
||||
return;
|
||||
}
|
||||
|
||||
Quagga.start();
|
||||
isScanning = true;
|
||||
|
||||
// If we are using the main search button, manage its UI state
|
||||
if (!targetCallback) {
|
||||
setScannerUi(true);
|
||||
}
|
||||
|
||||
if (statusText) statusText.innerText = "Scanning...";
|
||||
});
|
||||
}
|
||||
|
||||
function stopScanner() {
|
||||
Quagga.stop();
|
||||
isScanning = false;
|
||||
activeScannerCallback = null; // Reset the callback routing
|
||||
|
||||
const qrReader = document.getElementById('qr-reader');
|
||||
if (qrReader) qrReader.style.display = 'none';
|
||||
|
||||
setScannerUi(false);
|
||||
}
|
||||
|
||||
function setScannerUi(isOpen) {
|
||||
const scanBtn = document.getElementById('scanButton');
|
||||
if (!scanBtn) return;
|
||||
|
||||
scanBtn.classList.toggle('is-active', isOpen);
|
||||
scanBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
scanBtn.textContent = isOpen ? 'Scanner schliessen' : 'Barcode scannen';
|
||||
}
|
||||
|
||||
// Unified global reader callback
|
||||
Quagga.onDetected(function(data) {
|
||||
const barcode = String(data.codeResult.code || '').trim();
|
||||
console.log("Barcode detected:", barcode);
|
||||
|
||||
// Keep a local reference to the active callback before shutting down the engine
|
||||
const currentCallback = activeScannerCallback;
|
||||
|
||||
// Critical: Stop scanning immediately to prevent multiple triggers
|
||||
stopScanner();
|
||||
|
||||
// Route data based on who opened the scanner
|
||||
if (typeof currentCallback === "function") {
|
||||
// Send data directly to the specific edit field's logic
|
||||
currentCallback(barcode);
|
||||
} else {
|
||||
// Fallback Default: Main search box logic
|
||||
const resultText = document.getElementById('barcode-result');
|
||||
if (resultText) resultText.innerText = barcode;
|
||||
|
||||
const searchInput = document.getElementById('code-search');
|
||||
if (searchInput) {
|
||||
searchInput.value = barcode;
|
||||
if (typeof searchByCode === "function") {
|
||||
searchByCode();
|
||||
}
|
||||
|
||||
setScannerUi(false);
|
||||
});
|
||||
|
||||
setScannerUi(true);
|
||||
} else {
|
||||
if (html5QrcodeScanner) {
|
||||
html5QrcodeScanner.clear();
|
||||
}
|
||||
qrReader.style.display = 'none';
|
||||
setScannerUi(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Event Listener for the main standalone search button
|
||||
document.getElementById('scanButton')?.addEventListener('click', function() {
|
||||
if (!isScanning) {
|
||||
// Pass null so it defaults to filling the standard search box
|
||||
startScanner(null);
|
||||
} else {
|
||||
stopScanner();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2824,26 +2889,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
return;
|
||||
}
|
||||
|
||||
if (qrReader.style.display !== 'none') {
|
||||
if (html5QrcodeScanner) {
|
||||
html5QrcodeScanner.clear();
|
||||
}
|
||||
qrReader.style.display = 'none';
|
||||
// Toggle close if it's already running
|
||||
if (isScanning && qrReader.style.display !== 'none') {
|
||||
stopScanner();
|
||||
scanEditBtn.textContent = 'Barcode scannen';
|
||||
return;
|
||||
}
|
||||
|
||||
qrReader.style.display = 'block';
|
||||
scanEditBtn.textContent = 'Scanner schließen';
|
||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
});
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
editCodeInput.value = String(decodedText || '').trim();
|
||||
|
||||
// Start scanner with custom logic mapping to the Code input field
|
||||
startScanner(function(decodedText) {
|
||||
editCodeInput.value = decodedText;
|
||||
validateCodeField(editCodeInput, document.getElementById('edit-item-id')?.value || null);
|
||||
scanEditBtn.textContent = 'Barcode scannen';
|
||||
});
|
||||
@@ -2857,26 +2914,18 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
return;
|
||||
}
|
||||
|
||||
if (qrReader.style.display !== 'none') {
|
||||
if (html5QrcodeScanner) {
|
||||
html5QrcodeScanner.clear();
|
||||
}
|
||||
qrReader.style.display = 'none';
|
||||
// Toggle close if it's already running
|
||||
if (isScanning && qrReader.style.display !== 'none') {
|
||||
stopScanner();
|
||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||
return;
|
||||
}
|
||||
|
||||
qrReader.style.display = 'block';
|
||||
scanIsbnBtn.textContent = 'Scanner schließen';
|
||||
html5QrcodeScanner = new Html5QrcodeScanner('qr-reader', {
|
||||
fps: 10,
|
||||
qrbox: 250,
|
||||
rememberLastUsedCamera: true
|
||||
});
|
||||
html5QrcodeScanner.render((decodedText) => {
|
||||
html5QrcodeScanner.clear();
|
||||
qrReader.style.display = 'none';
|
||||
editIsbnInput.value = String(decodedText || '').trim();
|
||||
|
||||
// Start scanner with custom logic mapping to the ISBN input field
|
||||
startScanner(function(decodedText) {
|
||||
editIsbnInput.value = decodedText;
|
||||
scanIsbnBtn.textContent = 'ISBN scannen';
|
||||
if (typeof fetchBookInfo === 'function') {
|
||||
fetchBookInfo('edit');
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Wartungsarbeiten - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-8 col-xl-7">
|
||||
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
|
||||
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
|
||||
<p class="text-uppercase small fw-semibold mb-1 opacity-75">Systemstatus</p>
|
||||
<h1 class="h3 mb-0 fw-bold">Wartungsarbeiten</h1>
|
||||
</div>
|
||||
<div class="card-body p-4 p-md-5 bg-white text-center">
|
||||
<div class="display-3 fw-bold text-warning mb-3">500</div>
|
||||
<p class="lead mb-2">Das System führt gerade Wartungsarbeiten durch.</p>
|
||||
<p class="text-muted mb-4">Bitte versuchen Sie es in wenigen Minuten erneut. Falls das Problem bestehen bleibt, informieren Sie bitte die Administration.</p>
|
||||
|
||||
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2">
|
||||
<a href="{{ url_for('login') }}" class="btn btn-primary btn-lg">Zum Login</a>
|
||||
<a href="javascript:history.back()" class="btn btn-outline-secondary btn-lg">Zurück</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,523 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Termin buchen - Inventarsystem{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.css" rel="stylesheet">
|
||||
<style>
|
||||
#client-slot-calendar {
|
||||
min-height: 520px;
|
||||
}
|
||||
.fc .fc-timegrid-slot-label-cushion,
|
||||
.fc .fc-timegrid-axis-cushion,
|
||||
.fc .fc-col-header-cell-cushion {
|
||||
font-weight: 600;
|
||||
}
|
||||
.slot-selected-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(13, 110, 253, 0.1);
|
||||
color: #0d6efd;
|
||||
padding: .35rem .75rem;
|
||||
font-size: .9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.day-slider-wrap {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: .9rem;
|
||||
padding: .8rem 1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-xxl-11">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<p class="text-uppercase text-muted fw-semibold mb-2">Terminplaner</p>
|
||||
<h1 class="h3 fw-bold mb-3">Termin buchen</h1>
|
||||
<p class="text-muted mb-4">Wählen Sie im Kalender einen freien Slot aus. Den gewählten Termin können Sie danach direkt wie in einem Kalender-Block verschieben.</p>
|
||||
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold">Zeitraum</div>
|
||||
<div>{{ available.date_start }} bis {{ available.date_end }}</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold">Verfügbare Slots</div>
|
||||
<div>{{ available.slots_left }} von {{ available.slots_total }} frei</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold">Slot-Länge</div>
|
||||
<div>{{ available.slot_lenght }} Minuten</div>
|
||||
</div>
|
||||
|
||||
<div class="p-3 rounded-3 bg-light mb-3">
|
||||
<div class="fw-semibold mb-2">Gewählter Termin</div>
|
||||
<div id="selected-slot-badge" class="text-muted small">Noch kein Slot ausgewählt.</div>
|
||||
</div>
|
||||
|
||||
{% if available.slots_booked %}
|
||||
<div class="p-3 rounded-3 bg-light">
|
||||
<div class="fw-semibold mb-2">Bereits gebucht</div>
|
||||
<ul class="mb-0 small">
|
||||
{% for booking in available.slots_booked %}
|
||||
<li>
|
||||
{{ booking.start }}
|
||||
{% if can_view_booking_names and booking.name %}
|
||||
- {{ booking.name }}
|
||||
{% else %}
|
||||
- Belegt
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card border-0 shadow-lg rounded-4 h-100">
|
||||
<div class="card-body p-4 p-md-5 bg-white">
|
||||
<h2 class="h4 fw-bold mb-3">Termin im Kalender auswählen</h2>
|
||||
|
||||
<div class="day-slider-wrap mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
|
||||
<span class="small text-muted">Tag wählen</span>
|
||||
<strong id="day-slider-label" class="small"></strong>
|
||||
</div>
|
||||
<input id="day-slider" type="range" class="form-range m-0" min="0" max="0" value="0">
|
||||
</div>
|
||||
|
||||
<div id="client-slot-calendar" class="mb-4"></div>
|
||||
|
||||
<form id="client-booking-form" method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}" class="vstack gap-3">
|
||||
<div>
|
||||
<label for="start_day_time" class="form-label fw-semibold">Gewünschter Zeitpunkt</label>
|
||||
<input type="text" id="start_day_time" name="start_day_time" class="form-control form-control-lg" placeholder="Bitte im Kalender auswählen" readonly required>
|
||||
<div class="form-text">Klicken Sie auf einen freien Slot oder verschieben Sie den gewählten Block.</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="client_name" class="form-label fw-semibold">Ihr Name</label>
|
||||
<input type="text" id="client_name" name="client_name" class="form-control form-control-lg" placeholder="Vor- und Nachname" required>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Termin buchen</button>
|
||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main', tenant=tenant_id) }}">Zur Übersicht</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="calendarDownloadModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Termin zum Kalender hinzufügen?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">Sie haben einen Termin ausgewählt.</p>
|
||||
<p class="mb-0 small text-muted">Mit einem Klick auf ".ics herunterladen" können Sie den Termin in Apple/Google/Outlook-Kalender importieren.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button id="confirm-booking-only" type="button" class="btn btn-primary">Jetzt buchen</button>
|
||||
<button id="confirm-booking-with-ics" type="button" class="btn btn-success">Buchen + .ics</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const available = {{ available|tojson }};
|
||||
const appointmentId = {{ appointment_id|tojson }};
|
||||
const slider = document.getElementById('day-slider');
|
||||
const sliderLabel = document.getElementById('day-slider-label');
|
||||
const sliderWrap = slider ? slider.closest('.day-slider-wrap') : null;
|
||||
const selectedSlotInput = document.getElementById('start_day_time');
|
||||
const selectedSlotBadge = document.getElementById('selected-slot-badge');
|
||||
const clientNameInput = document.getElementById('client_name');
|
||||
const form = document.getElementById('client-booking-form');
|
||||
const calendarEl = document.getElementById('client-slot-calendar');
|
||||
const modalEl = document.getElementById('calendarDownloadModal');
|
||||
const confirmBookingOnlyBtn = document.getElementById('confirm-booking-only');
|
||||
const confirmBookingWithIcsBtn = document.getElementById('confirm-booking-with-ics');
|
||||
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||
|
||||
const slotLength = Number.parseInt(available.slot_lenght, 10) || 45;
|
||||
const bookedStarts = new Set((available.slots_booked || []).map(function (entry) {
|
||||
return String(entry.start || '').trim();
|
||||
}).filter(Boolean));
|
||||
|
||||
function formatDateForInput(dateObj) {
|
||||
const y = dateObj.getFullYear();
|
||||
const m = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(dateObj.getDate()).padStart(2, '0');
|
||||
const hh = String(dateObj.getHours()).padStart(2, '0');
|
||||
const mm = String(dateObj.getMinutes()).padStart(2, '0');
|
||||
return y + '-' + m + '-' + d + ' ' + hh + ':' + mm;
|
||||
}
|
||||
|
||||
function formatDateReadable(value) {
|
||||
if (!value) return 'Noch kein Slot ausgewählt.';
|
||||
return 'Ausgewählt: ' + value;
|
||||
}
|
||||
|
||||
function dateRangeInclusive(startStr, endStr) {
|
||||
const days = [];
|
||||
const start = new Date(startStr + 'T00:00:00');
|
||||
const end = new Date(endStr + 'T00:00:00');
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return days;
|
||||
}
|
||||
const cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
const y = cursor.getFullYear();
|
||||
const m = String(cursor.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(cursor.getDate()).padStart(2, '0');
|
||||
days.push(y + '-' + m + '-' + d);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
function addMinutes(dateObj, minutes) {
|
||||
return new Date(dateObj.getTime() + minutes * 60000);
|
||||
}
|
||||
|
||||
function formatTimeForCalendar(dateObj) {
|
||||
return String(dateObj.getHours()).padStart(2, '0') + ':' + String(dateObj.getMinutes()).padStart(2, '0') + ':00';
|
||||
}
|
||||
|
||||
function addDays(dateStr, days) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
d.setDate(d.getDate() + days);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return y + '-' + m + '-' + day;
|
||||
}
|
||||
|
||||
function parseTimeSpanEntry(entry) {
|
||||
const value = String(entry || '').trim();
|
||||
let m = value.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})-(\d{2}:\d{2})$/);
|
||||
if (m) {
|
||||
return { date: m[1], from: m[2], to: m[3] };
|
||||
}
|
||||
m = value.match(/^(\d{2}:\d{2})-(\d{2}:\d{2})$/);
|
||||
if (m) {
|
||||
return { date: null, from: m[1], to: m[2] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildCandidateSlots() {
|
||||
const slots = [];
|
||||
const allowedDates = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||
const spans = Array.isArray(available.time_span) ? available.time_span : [];
|
||||
|
||||
spans.forEach(function (entry) {
|
||||
const parsed = parseTimeSpanEntry(entry);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDates = parsed.date ? [parsed.date] : allowedDates;
|
||||
targetDates.forEach(function (date) {
|
||||
const from = new Date(date + 'T' + parsed.from + ':00');
|
||||
const to = new Date(date + 'T' + parsed.to + ':00');
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cursor = new Date(from);
|
||||
while (addMinutes(cursor, slotLength) <= to) {
|
||||
const slotStart = formatDateForInput(cursor);
|
||||
if (!bookedStarts.has(slotStart)) {
|
||||
slots.push({
|
||||
start: slotStart,
|
||||
end: formatDateForInput(addMinutes(cursor, slotLength)),
|
||||
});
|
||||
}
|
||||
cursor = addMinutes(cursor, slotLength);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
const candidateSlots = buildCandidateSlots();
|
||||
const slotStartSet = new Set(candidateSlots.map(function (slot) { return slot.start; }));
|
||||
const allDays = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||
|
||||
// Show the requested UI time window from 08:15 to 20:00 and highlight available slots
|
||||
let slotMinTime = '08:15:00';
|
||||
let slotMaxTime = '20:00:00';
|
||||
if (candidateSlots.length > 0) {
|
||||
const starts = candidateSlots.map(function (slot) {
|
||||
return new Date(slot.start.replace(' ', 'T') + ':00');
|
||||
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
|
||||
const ends = candidateSlots.map(function (slot) {
|
||||
return new Date(slot.end.replace(' ', 'T') + ':00');
|
||||
}).filter(function (d) { return !Number.isNaN(d.getTime()); });
|
||||
|
||||
// Keep the computed min/max for gap calculations below
|
||||
var computedMinStart = null;
|
||||
var computedMaxEnd = null;
|
||||
if (starts.length > 0 && ends.length > 0) {
|
||||
computedMinStart = starts[0];
|
||||
computedMaxEnd = ends[0];
|
||||
starts.forEach(function (d) { if (d < computedMinStart) computedMinStart = d; });
|
||||
ends.forEach(function (d) { if (d > computedMaxEnd) computedMaxEnd = d; });
|
||||
}
|
||||
}
|
||||
|
||||
const visibleStart = allDays[0] || String(available.date_start || '');
|
||||
const visibleEndExclusive = allDays.length > 0
|
||||
? addDays(allDays[allDays.length - 1], 1)
|
||||
: addDays(String(available.date_end || available.date_start || ''), 1);
|
||||
|
||||
const multiDayDuration = Math.max(1, allDays.length || 1);
|
||||
const initialViewName = multiDayDuration === 1 ? 'timeGridDay' : 'timeGridRange';
|
||||
let selectedSlot = '';
|
||||
let selectedEvent = null;
|
||||
let allowImmediateSubmit = false;
|
||||
|
||||
const calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: initialViewName,
|
||||
views: {
|
||||
timeGridRange: {
|
||||
type: 'timeGrid',
|
||||
duration: { days: multiDayDuration },
|
||||
buttonText: 'Zeitraum'
|
||||
}
|
||||
},
|
||||
locale: 'de',
|
||||
firstDay: 1,
|
||||
height: 'auto',
|
||||
allDaySlot: false,
|
||||
editable: true,
|
||||
eventStartEditable: true,
|
||||
eventDurationEditable: false,
|
||||
selectable: false,
|
||||
slotDuration: '00:15:00',
|
||||
snapDuration: '00:15:00',
|
||||
slotMinTime: slotMinTime,
|
||||
slotMaxTime: slotMaxTime,
|
||||
nowIndicator: true,
|
||||
validRange: {
|
||||
start: visibleStart,
|
||||
end: visibleEndExclusive,
|
||||
},
|
||||
visibleRange: {
|
||||
start: visibleStart,
|
||||
end: visibleEndExclusive,
|
||||
},
|
||||
headerToolbar: {
|
||||
left: '',
|
||||
center: 'title',
|
||||
right: multiDayDuration === 1 ? '' : 'timeGridDay,timeGridRange'
|
||||
},
|
||||
events: [],
|
||||
eventDrop: function (info) {
|
||||
if (info.event.id !== 'selected-slot') {
|
||||
return;
|
||||
}
|
||||
const droppedStart = formatDateForInput(info.event.start);
|
||||
if (!slotStartSet.has(droppedStart)) {
|
||||
info.revert();
|
||||
window.alert('Dieser Zeitpunkt ist nicht als freier Slot verfügbar.');
|
||||
return;
|
||||
}
|
||||
applySelectedSlot(droppedStart);
|
||||
},
|
||||
eventClick: function (info) {
|
||||
const slotType = info.event.extendedProps ? info.event.extendedProps.slotType : '';
|
||||
if (slotType !== 'free') {
|
||||
return;
|
||||
}
|
||||
applySelectedSlot(info.event.extendedProps.slotStart || '');
|
||||
}
|
||||
});
|
||||
|
||||
// No background greying: show full calendar skeleton and only mark possible slots
|
||||
|
||||
function updateSliderLabel() {
|
||||
const dateList = dateRangeInclusive(String(available.date_start || ''), String(available.date_end || ''));
|
||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||
sliderLabel.textContent = dateList[idx] || '';
|
||||
}
|
||||
|
||||
function applySelectedSlot(value) {
|
||||
selectedSlot = String(value || '').trim();
|
||||
selectedSlotInput.value = selectedSlot;
|
||||
selectedSlotBadge.innerHTML = selectedSlot
|
||||
? '<span class="slot-selected-chip">' + selectedSlot + '</span>'
|
||||
: 'Noch kein Slot ausgewählt.';
|
||||
|
||||
if (selectedEvent) {
|
||||
selectedEvent.remove();
|
||||
selectedEvent = null;
|
||||
}
|
||||
|
||||
if (!selectedSlot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = new Date(selectedSlot.replace(' ', 'T') + ':00');
|
||||
const end = addMinutes(start, slotLength);
|
||||
selectedEvent = calendar.addEvent({
|
||||
id: 'selected-slot',
|
||||
title: 'Ihr ausgewählter Termin',
|
||||
start: start,
|
||||
end: end,
|
||||
color: '#0d6efd',
|
||||
editable: true,
|
||||
});
|
||||
|
||||
refreshIcsLink();
|
||||
}
|
||||
|
||||
function getIcsDownloadUrl() {
|
||||
const base = {{ url_for('terminplaner.client_slot_calendar_export', appointment_id=appointment_id, tenant=tenant_id)|tojson }};
|
||||
const url = new URL(base, window.location.origin);
|
||||
if (selectedSlot) {
|
||||
url.searchParams.set('start', selectedSlot);
|
||||
}
|
||||
const name = String(clientNameInput.value || '').trim();
|
||||
if (name) {
|
||||
url.searchParams.set('name', name);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function refreshIcsLink() {
|
||||
const icsUrl = getIcsDownloadUrl();
|
||||
if (confirmBookingWithIcsBtn) {
|
||||
confirmBookingWithIcsBtn.setAttribute('data-ics-url', icsUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// No background gaps: keep full skeleton/grid visible
|
||||
|
||||
// Add candidate free slots (blue)
|
||||
candidateSlots.forEach(function (slot) {
|
||||
const start = new Date(slot.start.replace(' ', 'T') + ':00');
|
||||
const end = new Date(slot.end.replace(' ', 'T') + ':00');
|
||||
calendar.addEvent({
|
||||
title: 'Freier Slot',
|
||||
start: start,
|
||||
end: end,
|
||||
color: '#0d6efd',
|
||||
textColor: '#ffffff',
|
||||
editable: false,
|
||||
extendedProps: {
|
||||
slotStart: slot.start,
|
||||
slotType: 'free',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(available.slots_booked || []).forEach(function (booking) {
|
||||
const startStr = String(booking.start || '').trim();
|
||||
if (!startStr) {
|
||||
return;
|
||||
}
|
||||
const start = new Date(startStr.replace(' ', 'T') + ':00');
|
||||
const end = addMinutes(start, slotLength);
|
||||
calendar.addEvent({
|
||||
title: 'Gebucht' + (booking.name ? ' - ' + booking.name : ''),
|
||||
start: start,
|
||||
end: end,
|
||||
color: '#dc3545',
|
||||
editable: false,
|
||||
display: 'block',
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (ev) {
|
||||
if (!selectedSlotInput.value) {
|
||||
ev.preventDefault();
|
||||
window.alert('Bitte zuerst im Kalender einen freien Slot auswählen.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowImmediateSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
if (modal) {
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
|
||||
if (confirmBookingOnlyBtn) {
|
||||
confirmBookingOnlyBtn.addEventListener('click', function () {
|
||||
allowImmediateSubmit = true;
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmBookingWithIcsBtn) {
|
||||
confirmBookingWithIcsBtn.addEventListener('click', function () {
|
||||
const icsUrl = confirmBookingWithIcsBtn.getAttribute('data-ics-url') || getIcsDownloadUrl();
|
||||
if (icsUrl) {
|
||||
window.open(icsUrl, '_blank');
|
||||
}
|
||||
allowImmediateSubmit = true;
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
clientNameInput.addEventListener('input', refreshIcsLink);
|
||||
|
||||
if (sliderWrap) {
|
||||
sliderWrap.style.display = multiDayDuration > 1 ? '' : 'none';
|
||||
}
|
||||
|
||||
slider.max = String(Math.max(0, allDays.length - 1));
|
||||
slider.value = '0';
|
||||
updateSliderLabel();
|
||||
if (allDays.length > 0) {
|
||||
calendar.gotoDate(allDays[0]);
|
||||
}
|
||||
|
||||
slider.addEventListener('input', function () {
|
||||
const idx = Number.parseInt(slider.value, 10) || 0;
|
||||
updateSliderLabel();
|
||||
if (allDays[idx]) {
|
||||
if (calendar.view.type === 'timeGridDay') {
|
||||
calendar.gotoDate(allDays[idx]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
calendar.render();
|
||||
refreshIcsLink();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Buchung erfolgreich</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-a: #0f4c5c;
|
||||
--bg-b: #16697a;
|
||||
--ok: #22c55e;
|
||||
--text: #0f172a;
|
||||
--muted: #475569;
|
||||
--card: #ffffff;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Manrope", "Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at 20% 20%, rgba(255,255,255,0.14), transparent 45%), linear-gradient(135deg, var(--bg-a), var(--bg-b));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.success-window {
|
||||
width: min(900px, 100%);
|
||||
background: var(--card);
|
||||
border-radius: 1.25rem;
|
||||
box-shadow: 0 28px 70px rgba(2, 6, 23, 0.32);
|
||||
padding: clamp(1.5rem, 4vw, 3rem);
|
||||
text-align: center;
|
||||
}
|
||||
.badge {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
margin: 0 auto 1.25rem auto;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: var(--ok);
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 0.9rem 0;
|
||||
font-size: clamp(1.8rem, 5vw, 3rem);
|
||||
color: var(--text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.meta {
|
||||
margin-top: 1.4rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.9rem;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 1.8rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: .75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 0.8rem;
|
||||
padding: .85rem 1.25rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-close {
|
||||
background: #0f4c5c;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-link {
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="success-window" role="main" aria-live="polite">
|
||||
<div class="badge">✓</div>
|
||||
<h1>Buchung erfolgreich</h1>
|
||||
<p>Sie können das Fenster jetzt schließen.</p>
|
||||
|
||||
{% if client_name or slot_start %}
|
||||
<div class="meta">
|
||||
{% if client_name %}
|
||||
<div>Name: {{ client_name }}</div>
|
||||
{% endif %}
|
||||
{% if slot_start %}
|
||||
<div>Termin: {{ slot_start }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-close" type="button" onclick="window.close()">Fenster schließen</button>
|
||||
<a class="btn btn-link" href="{{ url_for('terminplaner.client', appointment_id=appointment_id, tenant=tenant_id) }}">Zurück zur Buchungsseite</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,258 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Terminplan konfigurieren - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8">
|
||||
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
|
||||
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
|
||||
<h1 class="h3 mb-1 fw-bold">Terminplan konfigurieren</h1>
|
||||
<p class="mb-0 opacity-75">Erstellen Sie einen neuen Plan und verschicken Sie anschließend den Buchungslink.</p>
|
||||
</div>
|
||||
<div class="card-body p-4 p-md-5 bg-white">
|
||||
<form method="post" action="{{ url_for('terminplaner.configure') }}" class="vstack gap-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="start_date" class="form-label fw-semibold">Startdatum</label>
|
||||
<input type="date" id="start_date" name="start_date" class="form-control form-control-lg" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="end_date" class="form-label fw-semibold">Enddatum</label>
|
||||
<input type="date" id="end_date" name="end_date" class="form-control form-control-lg" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-4 p-3 p-md-4 bg-light-subtle">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-2">
|
||||
<div>
|
||||
<label class="form-label fw-semibold mb-0">Zeitfenster pro Tag</label>
|
||||
<div class="form-text mb-0">Sobald Start- und Enddatum gesetzt sind, wird für jeden Tag automatisch ein eigener Eintrag erzeugt.</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="build_time_frame">Tage aus Zeitraum erzeugen</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<label for="default_day_start" class="form-label fw-semibold">Standard-Startzeit</label>
|
||||
<input type="time" id="default_day_start" class="form-control" value="08:00">
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<label for="default_day_end" class="form-label fw-semibold">Standard-Endzeit</label>
|
||||
<input type="time" id="default_day_end" class="form-control" value="12:00">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="time_frame_days" class="vstack gap-2"></div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label for="time_frame" class="form-label fw-semibold">Gespeichertes Zeitfenster</label>
|
||||
<textarea id="time_frame" name="time_frame" class="form-control font-monospace" rows="5" placeholder="Wird automatisch aus den Tagen erzeugt" required></textarea>
|
||||
<div class="form-text">Das Formular überträgt die erzeugten Tageszeilen an das Backend. Sie können die Liste hier bei Bedarf noch anpassen.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="slots_amounts" class="form-label fw-semibold">Anzahl Slots</label>
|
||||
<input type="number" id="slots_amounts" name="slots_amounts" class="form-control" min="1" value="1" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="slot_lenght" class="form-label fw-semibold">Slot-Länge in Minuten</label>
|
||||
<input type="number" id="slot_lenght" name="slot_lenght" class="form-control" min="1" value="45" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="mail" class="form-label fw-semibold">Empfänger-Mails</label>
|
||||
<input type="text" id="mail" name="mail" class="form-control" placeholder="adresse1@schule.de, adresse2@schule.de">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="note" class="form-label fw-semibold">Notiz</label>
|
||||
<textarea id="note" name="note" class="form-control" rows="4" placeholder="Optionaler Einführungstext für die Mail"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="add_to_calendar" name="add_to_calendar" {% if add_to_calendar %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="add_to_calendar">Kalendereintrag für den Nutzer erzeugen</label>
|
||||
<div class="form-text">Erzeugt zusätzlich eine .ics-Datei, die in gängige Kalender importiert werden kann.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-sm-row gap-2 pt-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Terminplan erzeugen</button>
|
||||
<a class="btn btn-outline-secondary btn-lg" href="{{ url_for('terminplaner.main') }}">Zur Übersicht</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if generated_link %}
|
||||
<div class="alert alert-success mt-4 shadow-sm rounded-4">
|
||||
<div class="fw-bold mb-1">Buchungslink erstellt</div>
|
||||
<div class="mb-2">
|
||||
{% if email_service_enabled %}
|
||||
Teilen Sie diesen Link mit den Teilnehmenden oder versenden Sie ihn direkt per E-Mail.
|
||||
{% else %}
|
||||
Der E-Mail-Service ist deaktiviert. Teilen Sie diesen Link manuell mit den Teilnehmenden.
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ generated_link }}" class="d-inline-block text-break">{{ generated_link }}</a>
|
||||
{% if calendar_link %}
|
||||
<div class="mt-3">
|
||||
<div class="fw-semibold mb-1">Kalendereintrag</div>
|
||||
<a href="{{ calendar_link }}" class="btn btn-outline-primary btn-sm">.ics herunterladen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const startDateInput = document.getElementById('start_date');
|
||||
const endDateInput = document.getElementById('end_date');
|
||||
const buildButton = document.getElementById('build_time_frame');
|
||||
const daysContainer = document.getElementById('time_frame_days');
|
||||
const timeFrameTextarea = document.getElementById('time_frame');
|
||||
const defaultStartInput = document.getElementById('default_day_start');
|
||||
const defaultEndInput = document.getElementById('default_day_end');
|
||||
|
||||
if (!startDateInput || !endDateInput || !buildButton || !daysContainer || !timeFrameTextarea || !defaultStartInput || !defaultEndInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const weekdayFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
function parseDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = value.split('-').map(Number);
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(parts[0], parts[1] - 1, parts[2]);
|
||||
}
|
||||
|
||||
function formatDateForRow(date) {
|
||||
return weekdayFormatter.format(date);
|
||||
}
|
||||
|
||||
function formatDateForValue(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const copy = new Date(date);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function syncTextarea() {
|
||||
const rows = Array.from(daysContainer.querySelectorAll('[data-day-row]'));
|
||||
const lines = rows.map(function (row) {
|
||||
const dayValue = row.getAttribute('data-day-value') || '';
|
||||
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
||||
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
||||
return `${dayValue} ${startTime}-${endTime}`.trim();
|
||||
}).filter(Boolean);
|
||||
|
||||
timeFrameTextarea.value = lines.join('\n');
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const startDate = parseDate(startDateInput.value);
|
||||
const endDate = parseDate(endDateInput.value);
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
daysContainer.innerHTML = '<div class="text-muted small">Bitte Start- und Enddatum auswählen, damit die Tageszeilen erzeugt werden.</div>';
|
||||
timeFrameTextarea.value = '';
|
||||
endDateInput.setCustomValidity('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (endDate < startDate) {
|
||||
daysContainer.innerHTML = '<div class="text-danger small">Das Enddatum muss nach dem Startdatum liegen.</div>';
|
||||
timeFrameTextarea.value = '';
|
||||
endDateInput.setCustomValidity('Das Enddatum muss nach dem Startdatum liegen.');
|
||||
return;
|
||||
}
|
||||
|
||||
endDateInput.setCustomValidity('');
|
||||
|
||||
const existingValues = new Map();
|
||||
Array.from(daysContainer.querySelectorAll('[data-day-row]')).forEach(function (row) {
|
||||
const dayValue = row.getAttribute('data-day-value');
|
||||
const startTime = row.querySelector('[data-time-start]')?.value || '';
|
||||
const endTime = row.querySelector('[data-time-end]')?.value || '';
|
||||
if (dayValue) {
|
||||
existingValues.set(dayValue, { startTime, endTime });
|
||||
}
|
||||
});
|
||||
|
||||
const defaultStart = defaultStartInput.value || '08:00';
|
||||
const defaultEnd = defaultEndInput.value || '12:00';
|
||||
const rows = [];
|
||||
|
||||
for (let current = startDate; current <= endDate; current = addDays(current, 1)) {
|
||||
const dayValue = formatDateForValue(current);
|
||||
const preserved = existingValues.get(dayValue) || {};
|
||||
const rowStart = preserved.startTime || defaultStart;
|
||||
const rowEnd = preserved.endTime || defaultEnd;
|
||||
|
||||
rows.push(`
|
||||
<div class="border rounded-3 bg-white p-3" data-day-row data-day-value="${dayValue}">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-lg-5">
|
||||
<label class="form-label fw-semibold mb-1">${formatDateForRow(current)}</label>
|
||||
<div class="text-muted small">${dayValue}</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label mb-1">Von</label>
|
||||
<input type="time" class="form-control" value="${rowStart}" data-time-start>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<label class="form-label mb-1">Bis</label>
|
||||
<input type="time" class="form-control" value="${rowEnd}" data-time-end>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
daysContainer.innerHTML = rows.join('');
|
||||
daysContainer.querySelectorAll('input[type="time"]').forEach(function (input) {
|
||||
input.addEventListener('input', syncTextarea);
|
||||
input.addEventListener('change', syncTextarea);
|
||||
});
|
||||
|
||||
syncTextarea();
|
||||
}
|
||||
|
||||
buildButton.addEventListener('click', renderRows);
|
||||
startDateInput.addEventListener('input', renderRows);
|
||||
startDateInput.addEventListener('change', renderRows);
|
||||
endDateInput.addEventListener('input', renderRows);
|
||||
endDateInput.addEventListener('change', renderRows);
|
||||
defaultStartInput.addEventListener('change', syncTextarea);
|
||||
defaultEndInput.addEventListener('change', syncTextarea);
|
||||
|
||||
if (startDateInput.value && endDateInput.value) {
|
||||
renderRows();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+191
-807
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Terminplaner - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-11 col-xl-10">
|
||||
<section class="p-4 p-md-5 rounded-4 shadow-lg" style="background: linear-gradient(135deg, rgba(15,76,92,0.96), rgba(22,105,122,0.92)); color: #fff;">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-start align-items-lg-end">
|
||||
<div>
|
||||
<p class="text-uppercase fw-semibold mb-2" style="letter-spacing: .12em; opacity: .8;">Terminplaner</p>
|
||||
<h1 class="display-6 fw-bold mb-3">Reservierungen und Termine übersichtlich verwalten</h1>
|
||||
<p class="lead mb-0" style="max-width: 60ch; opacity: .95;">Erstellen Sie neue Terminreihen, teilen Sie Buchungslinks und öffnen Sie den Kalender für bestehende Reservierungen.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-light btn-lg fw-semibold" href="{{ url_for('terminplaner.configure', tenant=tenant_id) }}">Neue Planung</a>
|
||||
<a class="btn btn-outline-light btn-lg fw-semibold" href="{{ url_for('terminplan', tenant=tenant_id) }}">Kalender öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="display-6 mb-3">🗓️</div>
|
||||
<h2 class="h4 fw-bold">Kalender</h2>
|
||||
<p class="mb-4 text-muted">Sehen Sie vorhandene Termine, ihre Auslastung und die aktuellen Reservierungen im Kalender.</p>
|
||||
<a class="btn btn-primary w-100" href="{{ url_for('terminplan', tenant=tenant_id) }}">Zum Kalender</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="display-6 mb-3">✍️</div>
|
||||
<h2 class="h4 fw-bold">Neue Planung</h2>
|
||||
<p class="mb-4 text-muted">Erstellen Sie einen neuen Terminplan und verschicken Sie den Buchungslink an Ihre Zielgruppe.</p>
|
||||
<a class="btn btn-outline-primary w-100" href="{{ url_for('terminplaner.configure', tenant=tenant_id) }}">Konfiguration öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card h-100 shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="display-6 mb-3">🔗</div>
|
||||
<h2 class="h4 fw-bold">Buchungslink</h2>
|
||||
<p class="mb-4 text-muted">Die öffentliche Buchungsseite liegt unter dem jeweiligen Terminlink und kann direkt an Teilnehmende weitergegeben werden.</p>
|
||||
<div class="small text-muted">Die Buchungsseite wird aus dem Terminplan automatisch erzeugt.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user %}
|
||||
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
|
||||
<h2 class="h5 fw-bold mb-2">Angemeldet als {{ current_user }}</h2>
|
||||
<p class="mb-0 text-muted">Sie können Termine anlegen, den Kalender prüfen und Buchungslinks verteilen.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-4 rounded-4 bg-white shadow-sm">
|
||||
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center gap-2 mb-3">
|
||||
<h2 class="h5 fw-bold mb-0">Kommende Termine</h2>
|
||||
<span class="text-muted small">Alle offenen Terminpläne dieses Nutzers</span>
|
||||
</div>
|
||||
|
||||
{% if upcoming_events %}
|
||||
<div class="vstack gap-3">
|
||||
{% for event in upcoming_events %}
|
||||
<div class="border rounded-3 p-3">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ event.date_start }} bis {{ event.date_end }}</div>
|
||||
<div class="text-muted small">ID: {{ event.appointment_id }}</div>
|
||||
{% if event.time_span %}
|
||||
<div class="small mt-1">Zeitfenster: {{ event.time_span|join(' | ') }}</div>
|
||||
{% endif %}
|
||||
{% if event.note %}
|
||||
<div class="small mt-1 text-muted">Notiz: {{ event.note }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-lg-end">
|
||||
<div class="small mb-2">Gebucht: <strong>{{ event.slots_booked }}</strong> / {{ event.slots_total }} | Frei: <strong>{{ event.slots_left }}</strong></div>
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-lg-end">
|
||||
<a class="btn btn-sm btn-primary" href="{{ event.link }}" target="_blank" rel="noopener">Client-Link öffnen</a>
|
||||
{% if event.calendar_link %}
|
||||
<a class="btn btn-sm btn-outline-primary" href="{{ event.calendar_link }}">.ics</a>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('terminplaner.delete_appointment', appointment_id=event.appointment_id, tenant=tenant_id) }}" class="d-inline" onsubmit="return confirm('Diesen Terminplan wirklich löschen?');">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Entfernen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-muted">Keine kommenden Termine gefunden.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Termin nicht gefunden - Inventarsystem{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-8 col-xl-7">
|
||||
<div class="card border-0 shadow-lg rounded-4 overflow-hidden">
|
||||
<div class="card-header text-white" style="background: linear-gradient(135deg, #0f4c5c, #16697a);">
|
||||
<p class="text-uppercase small fw-semibold mb-1 opacity-75">Terminplaner</p>
|
||||
<h1 class="h3 mb-0 fw-bold">Termin nicht gefunden</h1>
|
||||
</div>
|
||||
<div class="card-body p-4 p-md-5 bg-white text-center">
|
||||
<div class="display-3 fw-bold text-danger mb-3">404</div>
|
||||
<p class="lead mb-2">Der angeforderte Termin existiert nicht mehr oder der Link ist ungültig.</p>
|
||||
<p class="text-muted mb-4">Bitte prüfen Sie den Link erneut oder öffnen Sie den Terminplaner von der Startseite aus.</p>
|
||||
|
||||
<div class="d-flex flex-column flex-sm-row justify-content-center gap-2">
|
||||
<a href="{{ url_for('home') }}" class="btn btn-primary btn-lg">Zur Startseite</a>
|
||||
<a href="javascript:history.back()" class="btn btn-outline-secondary btn-lg">Zurück</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -212,6 +212,7 @@ def _module_name_candidates(module_name):
|
||||
candidates = [normalized]
|
||||
alias_groups = {
|
||||
'inventory': {'inventory', 'inventar'},
|
||||
'terminplan': {'terminplan', 'terminplaner', 'termin', 'appointments'},
|
||||
'library': {'library', 'bib', 'bibliothek'},
|
||||
'student_cards': {'student_cards', 'studentcards', 'schuelerausweise', 'schueler_ausweise'},
|
||||
}
|
||||
@@ -472,6 +473,20 @@ class TenantContext:
|
||||
if not has_request_context():
|
||||
return None
|
||||
|
||||
# Query parameters are useful for public links that must open a specific tenant
|
||||
# even when the host/subdomain cannot be mapped reliably.
|
||||
tenant_from_query = (
|
||||
request.args.get('tenant', '').strip()
|
||||
or request.args.get('tenant_id', '').strip()
|
||||
or request.args.get('tenantId', '').strip()
|
||||
)
|
||||
if tenant_from_query:
|
||||
matched_tenant = _find_registered_tenant_id(tenant_from_query) or tenant_from_query
|
||||
self.tenant_id = matched_tenant
|
||||
self.config = get_tenant_config(matched_tenant)
|
||||
session['tenant_id'] = matched_tenant
|
||||
return self._get_db_name(matched_tenant)
|
||||
|
||||
# Priority 1: X-Tenant-ID header (for testing/internal APIs)
|
||||
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
|
||||
if tenant_from_header:
|
||||
@@ -530,6 +545,10 @@ class TenantContext:
|
||||
potential_subdomain = parts[0]
|
||||
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
|
||||
matched_tenant = _find_registered_tenant_id(potential_subdomain)
|
||||
if not matched_tenant and potential_subdomain.startswith('school'):
|
||||
matched_tenant = _find_registered_tenant_id('schule' + potential_subdomain[len('school'):])
|
||||
elif not matched_tenant and potential_subdomain.startswith('schule'):
|
||||
matched_tenant = _find_registered_tenant_id('school' + potential_subdomain[len('schule'):])
|
||||
if matched_tenant:
|
||||
self.subdomain = potential_subdomain
|
||||
self.tenant_id = matched_tenant
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"inventory": {
|
||||
"enabled": true
|
||||
},
|
||||
"terminplan": {
|
||||
"enabled": true
|
||||
},
|
||||
"library": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
PYTHON_VERSION: "3.13"
|
||||
OPTIMIZATION_LEVEL: 2
|
||||
working_dir: /app/Web
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "30", "--graceful-timeout", "20", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
+154
-121
@@ -23,59 +23,70 @@ ensure_runtime_config_json() {
|
||||
echo "Warning: moved unexpected directory $config_path to $backup_path"
|
||||
fi
|
||||
|
||||
if [ ! -f "$config_path" ]; then
|
||||
FORCE_REMOVE=false
|
||||
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
||||
FORCE_REMOVE=true
|
||||
TENANT_ID="${3:-}"
|
||||
fi
|
||||
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
cat > "$config_path" <<'EOF'
|
||||
{
|
||||
"ver": "2.6.5",
|
||||
"dbg": false,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000,
|
||||
"mongodb": {
|
||||
"host": "mongodb",
|
||||
"port": 27017,
|
||||
"db": "Inventarsystem"
|
||||
},
|
||||
"modules": {
|
||||
"library": {
|
||||
"enabled": false
|
||||
},
|
||||
"student_cards": {
|
||||
"enabled": false,
|
||||
"default_borrow_days": 14,
|
||||
"max_borrow_days": 365
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
echo "Created default runtime config at $config_path"
|
||||
fi
|
||||
}
|
||||
|
||||
show_help() {
|
||||
echo "Usage: ./manage-tenant.sh [COMMAND] [OPTIONS]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " add <tenant_id> [port] Add a new tenant (initializes database)"
|
||||
echo " trial <tenant_id> [port] [days] Create a 7-day demo tenant that auto-deletes after expiry"
|
||||
echo " remove <tenant_id> Remove a tenant completely (deletes data!)"
|
||||
echo " restart-tenant <id> 'Restart' a single tenant (clears cache/sessions)"
|
||||
echo " restart-all Restart all application containers (zero-downtime reload)"
|
||||
echo " list List active tenants"
|
||||
echo " module <tenant_id> <module>=<on|off>... Enable/disable modules for a tenant"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " ./manage-tenant.sh add school_a 10001"
|
||||
echo " ./manage-tenant.sh trial school_demo 10002 7"
|
||||
echo " ./manage-tenant.sh remove test_tenant"
|
||||
echo " ./manage-tenant.sh module school_a inventory=off library=on"
|
||||
echo " ./manage-tenant.sh restart-all"
|
||||
echo " ./manage-tenant.sh -h"
|
||||
exit 1
|
||||
}
|
||||
if [ "$FORCE_REMOVE" != true ]; then
|
||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||
read confirm
|
||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||
echo "Removal canceled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
tenant_aliases() {
|
||||
local tenant_id="$1"
|
||||
echo "Removing tenant '$TENANT_ID'..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
port_to_remove=""
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
port_to_remove="$({ docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
sys.path.insert(0, '/app/Web')
|
||||
from tenant import delete_tenant, get_tenant_config
|
||||
|
||||
tenant_id = sys.argv[1]
|
||||
tenant_cfg = get_tenant_config(tenant_id)
|
||||
port = tenant_cfg.get('port')
|
||||
|
||||
if not delete_tenant(tenant_id):
|
||||
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if port is not None:
|
||||
print(port)
|
||||
PY
|
||||
} 2>/dev/null)"
|
||||
echo "Tenant '$TENANT_ID' database and config removed."
|
||||
else
|
||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
remove_runtime_port "$port_to_remove"
|
||||
fi
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
|
||||
else
|
||||
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
|
||||
fi
|
||||
local normalized alias
|
||||
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
|
||||
printf '%s\n' "$tenant_id"
|
||||
@@ -201,6 +212,7 @@ existing['modules'] = {
|
||||
'inventory': {'enabled': True},
|
||||
'library': {'enabled': True},
|
||||
'student_cards': {'enabled': True, 'default_borrow_days': 14, 'max_borrow_days': 365},
|
||||
'terminplan': {'enabled': True},
|
||||
}
|
||||
existing['trial'] = trial_config
|
||||
|
||||
@@ -312,58 +324,60 @@ print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
||||
update_runtime_ports() {
|
||||
local new_port="$1"
|
||||
local env_file="$PWD/.docker-build.env"
|
||||
local current_ports=""
|
||||
local port_list=()
|
||||
local unique_ports=()
|
||||
local first_port=""
|
||||
if [ -n "$new_port" ]; then
|
||||
if [ -f "$env_file" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
if [ -z "$current_ports" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
fi
|
||||
local current_ports port_list unique_ports first_port ports_csv
|
||||
|
||||
if [ -z "$new_port" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
current_ports=""
|
||||
if [ -f "$env_file" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
if [ -z "$current_ports" ]; then
|
||||
current_ports="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2; exit}' "$env_file" | tr -d ' ' || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$current_ports" ]; then
|
||||
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
||||
port_list=()
|
||||
unique_ports=()
|
||||
if [ -n "$current_ports" ]; then
|
||||
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
|
||||
fi
|
||||
port_list+=("$new_port")
|
||||
|
||||
for port in "${port_list[@]}"; do
|
||||
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
|
||||
unique_ports+=("$port")
|
||||
fi
|
||||
port_list+=("$new_port")
|
||||
done
|
||||
|
||||
for port in "${port_list[@]}"; do
|
||||
if [ -n "$port" ] && ! printf '%s\n' "${unique_ports[@]}" | grep -qx "$port"; then
|
||||
unique_ports+=("$port")
|
||||
fi
|
||||
done
|
||||
if [ ${#unique_ports[@]} -eq 0 ]; then
|
||||
unique_ports=("$new_port")
|
||||
fi
|
||||
|
||||
if [ ${#unique_ports[@]} -eq 0 ]; then
|
||||
unique_ports=("$new_port")
|
||||
fi
|
||||
first_port="${unique_ports[0]}"
|
||||
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
|
||||
|
||||
first_port="${unique_ports[0]}"
|
||||
local ports_csv
|
||||
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
|
||||
|
||||
if [ ! -f "$env_file" ]; then
|
||||
cat > "$env_file" <<EOF
|
||||
if [ ! -f "$env_file" ]; then
|
||||
cat > "$env_file" <<EOF
|
||||
NUITKA_BUILD=0
|
||||
INVENTAR_HTTP_PORT=$first_port
|
||||
INVENTAR_HTTP_PORTS=$ports_csv
|
||||
EOF
|
||||
else
|
||||
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
||||
else
|
||||
if grep -q '^INVENTAR_HTTP_PORTS=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORTS=.*|INVENTAR_HTTP_PORTS=$ports_csv|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
||||
fi
|
||||
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
||||
fi
|
||||
printf '\nINVENTAR_HTTP_PORTS=%s\n' "$ports_csv" >> "$env_file"
|
||||
fi
|
||||
if grep -q '^INVENTAR_HTTP_PORT=' "$env_file" 2>/dev/null; then
|
||||
sed -i "s|^INVENTAR_HTTP_PORT=.*|INVENTAR_HTTP_PORT=$first_port|" "$env_file"
|
||||
else
|
||||
printf '\nINVENTAR_HTTP_PORT=%s\n' "$first_port" >> "$env_file"
|
||||
fi
|
||||
|
||||
echo "Updated runtime env ports: $ports_csv"
|
||||
fi
|
||||
|
||||
echo "Updated runtime env ports: $ports_csv"
|
||||
}
|
||||
|
||||
sync_tenant_port_map() {
|
||||
@@ -675,50 +689,69 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
|
||||
;;
|
||||
|
||||
remove)
|
||||
FORCE_REMOVE=false
|
||||
if [ "${2:-}" = "--yes" ] || [ "${2:-}" = "-y" ]; then
|
||||
FORCE_REMOVE=true
|
||||
TENANT_ID="${3:-}"
|
||||
fi
|
||||
|
||||
if [ -z "$TENANT_ID" ]; then
|
||||
echo "Error: Please provide a tenant_id to remove."
|
||||
exit 1
|
||||
fi
|
||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||
read confirm
|
||||
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
||||
echo "Removing tenant '$TENANT_ID'..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
docker exec $APP_CONTAINER python3 -c "
|
||||
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from tenant import TenantContext; from Web.modules.database import settings; from pymongo import MongoClient
|
||||
ctx = TenantContext()
|
||||
db_name = ctx._get_db_name(sys.argv[1])
|
||||
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
|
||||
client.drop_database(db_name)
|
||||
print(f'Database for tenant {sys.argv[1]} dropped.')
|
||||
" "$TENANT_ID"
|
||||
echo "Tenant '$TENANT_ID' database removed."
|
||||
else
|
||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||
fi
|
||||
|
||||
port_to_remove=""
|
||||
if [ "$FORCE_REMOVE" != true ]; then
|
||||
echo -n "WARNING: Are you sure you want to permanently delete all data for tenant '$TENANT_ID'? (y/N) "
|
||||
read confirm
|
||||
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
|
||||
echo "Removal canceled."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Removing tenant '$TENANT_ID'..."
|
||||
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
|
||||
port_to_remove=""
|
||||
if [ -n "$APP_CONTAINER" ]; then
|
||||
port_to_remove="$(docker exec "$APP_CONTAINER" python3 - "$TENANT_ID" <<'PY'
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
sys.path.insert(0, '/app/Web')
|
||||
from tenant import delete_tenant, get_tenant_config
|
||||
|
||||
tenant_id = sys.argv[1]
|
||||
tenant_cfg = get_tenant_config(tenant_id)
|
||||
port = tenant_cfg.get('port')
|
||||
|
||||
if not delete_tenant(tenant_id):
|
||||
print(f'Error: failed to delete tenant {tenant_id}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if port is not None:
|
||||
print(port)
|
||||
PY
|
||||
)"
|
||||
echo "Tenant '$TENANT_ID' database and config removed."
|
||||
else
|
||||
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
|
||||
if port_to_remove="$(remove_tenant_port "$TENANT_ID" 2>/dev/null)"; then
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
remove_runtime_port "$port_to_remove"
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
echo "Removed tenant '$TENANT_ID' from config.json and cleaned runtime port $port_to_remove."
|
||||
else
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
echo "Removed tenant '$TENANT_ID' from config.json. No port mapping was present."
|
||||
fi
|
||||
:
|
||||
else
|
||||
echo "Warning: tenant '$TENANT_ID' was not configured in config.json or could not be removed."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
remove_runtime_port "$port_to_remove"
|
||||
fi
|
||||
sync_tenant_port_map
|
||||
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
|
||||
restart_app_container
|
||||
fi
|
||||
if [ -n "$port_to_remove" ]; then
|
||||
echo "Removed tenant '$TENANT_ID' and cleaned runtime port $port_to_remove."
|
||||
else
|
||||
echo "Removal canceled."
|
||||
echo "Removed tenant '$TENANT_ID'. No port mapping was present."
|
||||
fi
|
||||
;;
|
||||
|
||||
|
||||
@@ -572,7 +572,7 @@ write_runtime_compose_override() {
|
||||
services:
|
||||
app:
|
||||
working_dir: /app/Web
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "200", "--max-requests-jitter", "50", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
command: ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "${INVENTAR_WORKERS:-2}", "--threads", "${INVENTAR_THREADS:-2}", "--timeout", "${INVENTAR_WORKER_TIMEOUT:-30}", "--graceful-timeout", "20", "--worker-connections", "${INVENTAR_WORKER_CONNECTIONS:-100}", "--max-requests", "1000", "--max-requests-jitter", "100", "--log-level", "info", "--access-logfile", "-", "--error-logfile", "-"]
|
||||
image: ${APP_IMAGE_VALUE}
|
||||
build: null
|
||||
EOF
|
||||
|
||||
Reference in New Issue
Block a user