Compare commits

...

184 Commits

Author SHA1 Message Date
github-actions[bot] aa47325e80 chore: bump version to v0.8.5 2026-05-29 21:23:03 +00:00
Aiirondev_dev 539cf2b21c feat: add appointment management module with booking and configuration features 2026-05-29 23:21:09 +02:00
github-actions[bot] 4ae0d7f00e chore: bump version to v0.8.4 2026-05-29 13:50:26 +00:00
Aiirondev_dev 0b09fe489e fix: update book_slot function parameters and documentation for clarity 2026-05-29 15:48:57 +02:00
Aiirondev_dev a98f3751e9 feat: add email service and appointment management module with configuration settings 2026-05-29 15:38:22 +02:00
Aiirondev_dev d722b5a774 feat: implement school year rollover functionality with configurable settings 2026-05-28 20:46:28 +02:00
github-actions[bot] 0edbef3edd chore: bump version to v0.8.3 2026-05-28 17:41:04 +00:00
Aiirondev_dev d325fa57e8 feat: add CSRF token handling for delete and borrow forms 2026-05-28 19:39:33 +02:00
Aiirondev_dev 8a6a842e1a feat: add borrowing count validation and prompt for library items 2026-05-28 19:35:15 +02:00
github-actions[bot] b4d61e27f4 chore: bump version to v0.8.2 2026-05-28 17:23:55 +00:00
Aiirondev_dev 318e57475f feat: enhance library item API to support grouping of sub-items and improve data aggregation 2026-05-28 19:23:10 +02:00
github-actions[bot] 6a72bd5d82 chore: bump version to v0.8.1 2026-05-28 16:53:11 +00:00
Aiirondev_dev 74fa0d8a68 feat: add soft-delete functionality for library items with confirmation dialog 2026-05-28 18:51:55 +02:00
github-actions[bot] 451b7bd3c5 chore: bump version to v0.8.0 2026-05-28 16:40:27 +00:00
Aiirondev_dev 84543c8734 feat: enhance file upload functionality and improve CSV import experience 2026-05-28 18:38:08 +02:00
github-actions[bot] 8a64a1adcd chore: bump version to v0.7.79-dev 2026-05-28 15:56:21 +00:00
Aiirondev_dev 25b7fe986c fix: improve ISBN validation and update related messages in upload forms 2026-05-28 17:54:20 +02:00
Aiirondev_dev 4b376bc426 fix: update password requirements title for clarity 2026-05-24 13:40:18 +02:00
Aiirondev_dev e41d739636 chore: update license information and remove outdated EULA content 2026-05-24 09:45:12 +02:00
github-actions[bot] 4e73d69245 chore: bump version to v0.7.78 2026-05-23 21:32:20 +00:00
Aiirondev_dev daffeaf379 feat: add trial tenant configuration and management to support auto-deletion 2026-05-23 23:08:32 +02:00
Aiirondev_dev a1ad354ccf chore: remove outdated copyright notice from app.py 2026-05-20 23:01:25 +02:00
github-actions[bot] d4d5cd6436 chore: bump version to v0.7.77 2026-05-20 19:45:38 +00:00
Aiirondev_dev 9fd9d63e31 feat: add configuration path environment variable for app 2026-05-20 21:45:19 +02:00
Aiirondev_dev 08cce1b884 feat: enhance configuration path resolution for Docker deployments 2026-05-20 21:39:56 +02:00
github-actions[bot] adde73dffb chore: bump version to v0.7.76 2026-05-20 19:18:10 +00:00
Aiirondev_dev da17667d90 feat: enhance tenant resolution logic with host candidate handling 2026-05-20 21:17:12 +02:00
github-actions[bot] c69231fa77 chore: bump version to v0.7.75 2026-05-20 18:52:46 +00:00
Aiirondev_dev ac43178b29 feat: implement tenant database resolution and configuration checks 2026-05-20 20:52:06 +02:00
github-actions[bot] a2d58208e6 chore: bump version to v0.7.74 2026-05-20 18:40:06 +00:00
Aiirondev_dev f8171a5f18 feat: enhance tenant aliasing for multi-tenant management in scripts 2026-05-20 20:39:45 +02:00
github-actions[bot] 6f898ffea4 chore: bump version to v0.7.73 2026-05-20 18:12:28 +00:00
Aiirondev_dev b29cc38a3d fix: enhance tenant ID management in session for multi-tenant support 2026-05-20 20:12:01 +02:00
github-actions[bot] 17745c64e0 chore: bump version to v0.7.72 2026-05-20 17:37:17 +00:00
Aiirondev_dev a0279f82e1 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-05-20 19:35:26 +02:00
Aiirondev_dev 8d1e3865d3 fix: improve environment variable handling and path resolution in restart_app_container function 2026-05-20 19:35:11 +02:00
github-actions[bot] 79be502aa1 chore: bump version to v0.7.71 2026-05-20 17:27:29 +00:00
Aiirondev_dev d0f54f0dca fix: ensure deterministic config path resolution in manage-tenant.sh 2026-05-20 19:26:00 +02:00
github-actions[bot] 2b00aab1fa chore: bump version to v0.7.70 2026-05-20 16:45:03 +00:00
Aiirondev_dev 6d4ac073a5 feat: Add module name candidate resolution for tenant feature modules 2026-05-20 18:42:55 +02:00
Aiirondev_dev 73ab1a37d2 feat: Update import paths for database settings in manage-tenant.sh 2026-05-20 18:31:17 +02:00
github-actions[bot] 0eb1ffe9d9 chore: bump version to v0.7.69 2026-05-20 16:24:14 +00:00
Aiirondev_dev 0ea1294237 feat: Update version handling in release workflow and improve directory creation 2026-05-20 18:23:34 +02:00
Aiirondev_dev a7ff2f1552 feat: Refactor logging module structure and implement audit logging functionality 2026-05-20 18:01:08 +02:00
Aiirondev_dev f847faea3e feat: Improve module import paths and add package initialization files 2026-05-20 17:43:06 +02:00
Aiirondev_dev 4c58dceb02 feat: Enhance error handling for URL resolution and unexpected exceptions 2026-05-20 17:06:22 +02:00
Aiirondev_dev db80693c6a fix: Update link to library admin in invoice template 2026-05-20 16:54:17 +02:00
Aiirondev_dev d451d58fc4 fix: Remove redundant conditional check for tag validation in release workflow 2026-05-20 16:40:56 +02:00
Aiirondev_dev 726520c9de feat: Simplify latest release tag fetching and bump strategy in release workflow 2026-05-20 16:36:53 +02:00
Aiirondev_dev fabac77877 feat: Add major version bump option and update release options documentation 2026-05-20 16:34:30 +02:00
Aiirondev_dev bb2832c273 feat: Remove local development image tar creation and update development bundle documentation 2026-05-20 16:32:27 +02:00
Aiirondev_dev ed122995ee feat: Add push_dev option for development image in release workflow and update tag handling 2026-05-20 16:26:09 +02:00
Aiirondev_dev 634feda9da feat: Refactor script copying to use loops for optional files in release bundle 2026-05-20 15:50:41 +02:00
Aiirondev_dev fb8be06e81 feat: Enhance version bump strategy and improve local image handling in release workflow 2026-05-20 15:40:27 +02:00
Aiirondev_dev 6ad1720461 feat: Add development bump option and update release conditions in workflow 2026-05-20 15:35:55 +02:00
Aiirondev_dev 0c27d7ac86 feat: Add Excel, PDF export, and user generation modules
- Implemented `excel_export.py` for generating library item exports in Excel format.
- Created `pdf_export.py` for generating audit reports compliant with DIN 5008 standards, including detailed event tables and signature blocks.
- Developed `generate_user.py` for interactive user creation with validation for usernames and passwords.
- Introduced `module_registry.py` for managing module states and path matching.
- Added a basic `__init__.py` in the `terminplaner` module for initialization.
2026-05-20 15:30:23 +02:00
Aiirondev_dev b3fc470c88 feat: add library route for viewing library items and reposition export route 2026-05-19 21:51:00 +02:00
Aiirondev_dev 541ca7172b feat: update get_version function to read application image from .docker-build.env 2026-05-19 21:06:44 +02:00
Aiirondev_dev 001a9fae17 feat: add get_version function to retrieve application version from .release-version file 2026-05-19 20:55:53 +02:00
Aiirondev_dev b572704381 feat: add runtime config initialization in manage-tenant.sh and update health check method in docker-compose 2026-05-19 20:53:50 +02:00
Aiirondev_dev 8b114b5b8e feat: enhance item card layout in table view for better spacing and alignment 2026-05-19 10:05:29 +02:00
Aiirondev_dev 96771d192c feat: add export functionality for library items and include 'schulbuch' type 2026-05-19 09:53:04 +02:00
Aiirondev_dev 073652c63d feat: optimize rootMargin for better performance based on layout 2026-05-18 23:38:13 +02:00
Aiirondev_dev 23d9d0871a feat: implement customizable filter names and update management interfaces 2026-05-18 23:20:52 +02:00
Aiirondev_dev 576d31a384 feat: add edit functionality for predefined filter values in filter management 2026-05-18 22:01:37 +02:00
Aiirondev_dev c43bb30301 feat: enhance bookmark and favorite button styles for dark mode 2026-05-18 18:30:14 +02:00
Aiirondev_dev 7eeea1d319 feat: update dark theme colors and add border radius for improved aesthetics 2026-05-18 17:49:42 +02:00
Aiirondev_dev 56486bcbb2 feat: update address details in impressum section 2026-05-18 17:39:19 +02:00
Aiirondev_dev 2c3fb779ce feat: update styles for dark mode and enhance filter handling in admin template 2026-05-18 17:35:02 +02:00
Aiirondev_dev 299d11c1d0 feat: implement DOM virtualization for item cards in main and admin templates 2026-05-18 17:06:46 +02:00
Aiirondev_dev 4d1f95cb1f feat: improve filter3 options handling and enhance item card visibility 2026-05-18 16:53:11 +02:00
Aiirondev_dev 6b197fd9d1 feat: add third filter management and update filter handling logic 2026-05-18 16:40:11 +02:00
Aiirondev_dev 58064ee249 Changes to be committed:
modified:   Web/user.py
2026-05-13 16:05:13 +02:00
Aiirondev_dev f7573b9a62 feat: remove back navigation link from upload admin template 2026-05-12 09:32:13 +02:00
Aiirondev_dev 85d758fadb feat: update application image version to v0.7.42 2026-05-12 09:08:38 +02:00
Aiirondev_dev 212dd2abcf feat: Add 404 error handling for missing templates and files 2026-05-12 09:07:37 +02:00
Aiirondev_dev 689819df08 feat: update application image version and enhance docker-compose configuration 2026-05-12 08:16:04 +02:00
Aiirondev_dev 457d32a087 feat: Module fallback redirects and duplicate item modal redirects 2026-05-12 08:00:02 +02:00
Aiirondev_dev cded7b02fe fix(modal): add functionality to open edit modal for existing items based on URL parameters 2026-05-12 07:37:26 +02:00
Aiirondev_dev f314b5ae70 fix(tenant): apply DB aliases to tenant config resolution to prevent module configuration overrides by defaults 2026-05-11 21:08:37 +02:00
Aiirondev_dev 77c4e1b4c5 fix(modules): restore accidentally deleted routes (library_view, etc.) and ensure STUDENT_MAX_BORROW_DAYS is set 2026-05-11 20:37:35 +02:00
Aiirondev_dev e17f098c47 Changes to be committed:
deleted:    .scheduler_lock
2026-05-11 20:25:48 +02:00
Aiirondev_dev f41acc78cd fix(modules): patch routing redirect logic and restore missing STUDENT_MAX_BORROW_DAYS variable 2026-05-11 20:25:19 +02:00
Aiirondev_dev a7a5341961 refactor(app): replace direct module enabled checks with centralized module management calls 2026-05-11 19:41:25 +02:00
Aiirondev_dev 9ace2e2e5e feat(modules): implement module registry for dynamic module management and path matching 2026-05-11 15:49:40 +02:00
Aiirondev_dev dcd1529377 feat(schedule): update modal title and button text from "Termin planen" to "Reservieren" 2026-05-11 15:25:28 +02:00
Aiirondev_dev ae80be31e7 Refactor PDF export functionality and remove unused files
- Removed quick-check button from admin audit template.
- Deleted obsolete test file `test_terminplan.py`.
- Removed `verify_audit_chain.py` as it is no longer needed.
- Introduced `pdf_export.py` to handle PDF generation for audit reports, ensuring compliance with DIN 5008 and other standards.
2026-05-11 12:41:25 +02:00
Aiirondev_dev 06682ef8f2 feat(pdf-export): improve table formatting for better readability and adjust column widths 2026-05-10 19:40:31 +02:00
Aiirondev_dev d69c478fed refactor(admin-dashboard): remove school banner from admin view for cleaner layout 2026-05-10 19:27:07 +02:00
Aiirondev_dev 5f551331aa feat(pdf-export): enhance audit PDF table formatting and readability 2026-05-10 19:22:27 +02:00
Aiirondev_dev 68dc2f71c6 feat(navigation): add link to school settings in admin dropdown menus 2026-05-10 19:03:57 +02:00
Aiirondev_dev 736dafd1e1 fix(admin-dashboard): update audit export link to point to the correct dashboard 2026-05-10 18:40:45 +02:00
Aiirondev_dev 123ad6f0a7 feat(school-logo): add logo thumbnail handling in school settings and update navbar display logic 2026-05-10 17:45:14 +02:00
Aiirondev_dev c953f22ab4 feat(logo-management): add thumbnail generation for school logos and update navbar to display logos 2026-05-10 17:35:22 +02:00
Aiirondev_dev b1fabfbfcf feat(school-logo): implement logo upload functionality and update school settings UI 2026-05-10 17:21:27 +02:00
Aiirondev_dev b905ec9024 feat(school-settings): implement school metadata management and update UI for PDF exports 2026-05-10 17:11:24 +02:00
Aiirondev_dev d6625d17c8 feat(tenant-management): add school configuration management to tenant script 2026-05-10 16:57:01 +02:00
Aiirondev_dev 926b367e40 Implement PDF export functionality for audit reports
- Added QUICK_START_PDF_EXPORT.md for user guidance on PDF export features.
- Developed Web/pdf_audit_export.py for generating DIN 5008 compliant PDFs.
- Enhanced Web/app.py with new routes for quick and official PDF exports.
- Updated Web/templates/admin_audit.html to include new PDF export buttons.
- Created verify_pdf_export_setup.py to verify installation and configuration of the PDF export system.
2026-05-10 16:02:33 +02:00
Aiirondev_dev 960e8c4ec0 fix(ui): update logo image for improved branding consistency 2026-05-09 01:24:20 +02:00
Aiirondev_dev 8dec3595b1 fix(ui): reduce navbar height and adjust logo dimensions for improved layout 2026-05-09 01:19:12 +02:00
Aiirondev_dev c97753abad fix(ui): adjust navbar styles for improved layout and visibility 2026-05-09 01:14:18 +02:00
Aiirondev_dev 658d1e34b4 fix(ui): adjust padding and margins in login navbar for improved layout 2026-05-09 01:06:48 +02:00
Aiirondev_dev c4837bea8d Refactor code structure for improved readability and maintainability 2026-05-09 01:05:50 +02:00
Aiirondev_dev 857f1e7299 fix(ui): update logo dimensions and margins in login navbar for improved visibility 2026-05-09 00:54:52 +02:00
Aiirondev_dev 3458f9e8f3 fix(ui): adjust logo dimensions and margins in login navbar for improved visibility 2026-05-09 00:50:15 +02:00
Aiirondev_dev 4ad5b3e5a7 fix(ui): adjust logo dimensions and margins in login navbar for better visibility 2026-05-09 00:44:13 +02:00
Aiirondev_dev 1b1acff6b2 fix(ui): update logo styling in login navbar for improved appearance 2026-05-09 00:37:54 +02:00
Aiirondev_dev 5d44c15a4e fix(tenant-manager): use readlink -f for absolute canonical paths 2026-05-08 21:19:47 +02:00
Aiirondev_dev 8d29f2d925 fix(ui): adjust logo height in navigation bar for improved visibility 2026-05-08 21:14:28 +02:00
Aiirondev_dev 982efa9efe fix(tenant-manager): use absolute host paths for docker compose mount resolution 2026-05-08 21:13:44 +02:00
Aiirondev_dev 6079f5bc3e fix(tenant-manager): stop mounting config.json read-only 2026-05-08 20:55:52 +02:00
Aiirondev_dev 629ac13a25 fix(ui): increase logo height in navigation bar for better visibility 2026-05-08 20:51:16 +02:00
Aiirondev_dev 2b582c8700 fix(tenant-manager): mount config.json into helper container 2026-05-08 20:36:33 +02:00
Aiirondev_dev fd55fa9703 fix(tenant-manager): pass COMPOSE_PROJECT_NAME to inner compose call 2026-05-08 15:53:40 +02:00
Aiirondev_dev 08e13d4f23 feat(ui): replace Invario text with transparent logo 2026-05-08 15:43:50 +02:00
Aiirondev_dev 7c3eadc8cf fix(tenant-manager): pass COMPOSE_PROJECT_NAME to container to avoid container naming conflicts 2026-05-08 15:35:52 +02:00
Aiirondev_dev e7f8b8d76a fix(tenant-manager): add docker-cli-compose package to alpine container 2026-05-08 15:27:25 +02:00
Aiirondev_dev d6ac8a9e16 fix(ui): include /home_admin and other admin routes in inventory module detection 2026-05-08 15:18:34 +02:00
Aiirondev_dev c7cfb0e411 fix(ui): ensure /home route correctly activates the inventory theme and navbar 2026-05-08 14:54:55 +02:00
Aiirondev_dev 6c73f79866 Enhance module navigation: update inventory and library navbar logic for improved module access and user session handling 2026-05-08 12:03:38 +02:00
Aiirondev_dev 5cb59f4b63 Enhance module management: add inventory module support in settings, update templates and app logic for module access control, and extend tenant management script for module configuration 2026-05-08 11:24:53 +02:00
Aiirondev_dev 326fc4ef91 Enhance Docker configuration: update .docker-build.env and .docker-compose.runtime.override.yml for improved resource allocation and service settings 2026-05-01 10:51:43 +02:00
Aiirondev_dev 12cd365f36 Enhance filter retrieval: update get_filter API to return full filter lists and modify frontend to utilize them for improved item filtering 2026-04-29 17:17:54 +02:00
Aiirondev_dev b06828cc7e Enhance logging: change log level to DEBUG and add detailed database connection logging in user and tenant modules
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 13:44:22 +02:00
Aiirondev_dev 79f07d8631 Enhance logging: update logger configuration to use 'app' and set log level to INFO
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 13:30:52 +02:00
Aiirondev_dev 2ef5056727 Enhance logging: add detailed context and error handling in tenant resolution and user authentication
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 12:57:21 +02:00
Aiirondev_dev 46745dba49 Enhance user authentication: add logging for missing tenant databases during login attempts
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 12:47:18 +02:00
Aiirondev_dev c71da2fabf Enhance MongoDB configuration: add support for MongoDB URI and update connection logic 2026-04-29 11:50:14 +02:00
Aiirondev_dev e822ea36bf Enhance logging: implement rotating file handler and log tenant resolution details in context manager 2026-04-29 11:32:42 +02:00
Aiirondev_dev cd767caac7 Enhance login page: display active tenant and database information in an alert 2026-04-29 11:24:10 +02:00
Aiirondev_dev 8b9766234b Enhance tenant context handling: add tenant database information to version injection and update navbar to display active tenant 2026-04-29 11:16:39 +02:00
Aiirondev_dev 0b5b340463 Remove init-admin.sh references from documentation and update permissions in scripts
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 10:36:49 +02:00
Aiirondev_dev f80430f243 Remove legacy scripts and single-tenant support
- Deleted the migration script `migrate-to-multitenant.sh` as it is no longer needed.
- Removed `rebuild-venv.sh`, `restore.sh`, `run-backup.sh`, and other related scripts that are not compatible with the new multi-tenant architecture.
- Updated `restart.sh`, `start.sh`, `stop.sh`, `update.sh`, and other scripts to eliminate references to single-tenant deployment, ensuring they only support multi-tenant configurations.

Co-authored-by: Copilot <copilot@github.com>
2026-04-29 10:29:46 +02:00
Aiirondev_dev c8f9c87ba4 Refactor MongoClient import: standardize import statements across push_notifications, tenant, and verify_audit_chain modules 2026-04-29 00:05:55 +02:00
Aiirondev_dev c2bb015b80 Enhance tenant database access: implement context-aware database retrieval for user operations 2026-04-28 23:48:41 +02:00
Aiirondev_dev 35c4e9c6f6 Enhance admin management: improve username case handling and return status for admin updates 2026-04-28 23:35:58 +02:00
Aiirondev_dev 3c5ee65b03 Enhance username generation: implement unique username creation from the first two letters of the first and last name 2026-04-28 23:27:28 +02:00
Aiirondev_dev 0edb87c347 Enhance user registration: implement generation of truly anonymized usernames for new users 2026-04-28 23:20:59 +02:00
Aiirondev_dev 678cc61cef Enhance Docker Compose and startup script: implement auto-scaling settings based on server capacity and environment variables for worker configuration 2026-04-28 23:05:55 +02:00
Aiirondev_dev f26ab5c5fb Enhance user retrieval: implement tenant-aware user search with fallback to default and tenant-specific databases 2026-04-28 22:59:14 +02:00
Aiirondev_dev abb09fbdb5 Enhance user authentication: improve tenant database handling and implement fallback user search across tenant databases 2026-04-28 22:26:02 +02:00
Aiirondev_dev 96f5b9e3cd Enhance tutorial functionality: add tooltip toggle feature and improve tooltip display across the system 2026-04-28 21:59:26 +02:00
Aiirondev_dev 3ab2b075da Enhance tenant database management: integrate TenantContext for dynamic database naming and improve database drop functionality 2026-04-28 21:29:06 +02:00
Aiirondev_dev 821d4d0ad8 Enhance user authentication: improve password validation and tenant context handling 2026-04-28 21:21:48 +02:00
Aiirondev_dev 6cc8dabf72 Enhance runtime override generation: add app image retrieval and improve port handling 2026-04-28 20:55:58 +02:00
Aiirondev_dev 0d7fa2c511 Enhance tenant management script: improve port parsing and runtime override generation 2026-04-28 20:38:19 +02:00
Aiirondev_dev 4a18460178 Enhance tenant management script: add functionality to remove tenant ports and update runtime port configuration 2026-04-28 19:00:08 +02:00
Aiirondev_dev d2b3d4f6ea Enhance tenant management script: improve tenant listing functionality and handle missing config file 2026-04-28 18:44:47 +02:00
Aiirondev_dev f3d327f28f Enhance tenant management scripts: add runtime port update functionality and handle occupied ports 2026-04-28 18:18:04 +02:00
Aiirondev_dev 2b05d8c952 Refactor manage-tenant script: improve parameter handling and ensure default values 2026-04-28 17:54:43 +02:00
Aiirondev_dev 6cb41c1277 Enhance tenant management script: add help option to display usage instructions 2026-04-28 17:49:44 +02:00
Aiirondev_dev f0d02d6af1 Refactor restart script: streamline Docker compose argument handling and improve readability 2026-04-28 17:20:10 +02:00
Aiirondev_dev 6f5e24104b Refactor restart script: improve Docker container restart logic and update completion messages 2026-04-28 17:06:37 +02:00
Aiirondev_dev 43406c29d1 Enhance tenant management script: switch to bash, enforce strict mode, and validate port input 2026-04-28 16:54:42 +02:00
Aiirondev_dev 7873c45cfc Refactor multi-tenant deployment: update port handling in scripts, add tenant port registration, and enhance Docker configurations
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 16:36:55 +02:00
Aiirondev_dev 14c4192306 Refactor Docker setup: remove Nginx and TLS configurations, update service dependencies, and adjust health check endpoints
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 15:41:10 +02:00
Aiirondev_dev 1efc7e01be Update HTTP and HTTPS port configuration in .docker-build.env
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 14:38:44 +02:00
Aiirondev_dev d567ba583b Update port configuration for multi-tenant deployment to avoid conflicts
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 13:21:34 +02:00
Aiirondev_dev 5a5af5375d Add missing Nginx configuration for SSL and proxy settings
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 11:17:33 +02:00
Aiirondev_dev a60eb6eebf Update health check endpoint in verify_stack_health function in start.sh and update.sh
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 10:55:29 +02:00
Aiirondev_dev 23b7a4cd2f Remove obsolete configuration file config.yml 2026-04-26 21:38:02 +02:00
Aiirondev_dev e6fd485049 Update service configuration in config.yml and simplify cloudflared command in docker-compose 2026-04-26 17:01:20 +02:00
Aiirondev_dev 15d9eba987 Fix server block syntax in Nginx configuration in update.sh 2026-04-26 16:34:42 +02:00
Aiirondev_dev 05d6d299da Enhance scheduler lock file handling and add health check endpoint 2026-04-26 16:17:29 +02:00
Aiirondev_dev ab9db1211c Add server cleanup function to update.sh for Docker and log management
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 15:51:14 +02:00
Aiirondev_dev 45b69e5ddb Update HTTPS port configuration and add cloudflared setup in Docker Compose 2026-04-26 15:35:29 +02:00
Aiirondev_dev 4971bc859b Add disk space checks and cleanup functions in start.sh and update.sh 2026-04-24 21:29:39 +02:00
Aiirondev_dev b7f55b0de0 Remove custom command for MongoDB service in Docker Compose 2026-04-24 21:18:53 +02:00
Aiirondev_dev 9c24e79bba Add retry mechanism for Docker Compose startup in start.sh 2026-04-24 21:13:31 +02:00
Aiirondev_dev 79c325329c Refactor cloudflared configuration to use external config file and update command syntax 2026-04-24 21:05:21 +02:00
Aiirondev_dev 8f81ffb4c5 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-24 20:58:09 +02:00
Aiirondev_dev 1d7692ea01 Add cloudflared service and configure tunnel profile in Docker Compose 2026-04-24 20:56:15 +02:00
Aiirondev_dev 038390b8cd Add multi-tenant configuration support with dynamic module enabling 2026-04-24 09:16:04 +02:00
Aiirondev_dev f2c1dc2ba5 Implement session validation for active users before request processing
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:00:22 +02:00
Aiirondev_dev 3eeae76e6c Remove Web directory volume mount from Docker Compose files for app service 2026-04-23 22:48:23 +02:00
Aiirondev_dev 0228e6cb1d Add working directory and command configuration for app service in Docker Compose files 2026-04-23 22:41:52 +02:00
Aiirondev_dev 4c59cca1a4 Add missing nginx configuration for SSL support and error handling 2026-04-23 22:36:01 +02:00
Aiirondev_dev 8412ae76ee Fix volume mount for Web directory in Docker Compose files: remove read-only flag for better accessibility 2026-04-23 22:32:01 +02:00
Aiirondev_dev ec4483c415 Enhance backup and restore scripts: update database name and add support for multi-tenant configurations 2026-04-23 22:26:54 +02:00
Aiirondev_dev 52656c715e Enhance mobile library loading: implement virtualization for improved performance and memory management on mobile devices
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 22:05:52 +02:00
Aiirondev_dev f6755aad42 Enhance mobile library loading: implement dynamic item rendering and lazy loading for improved performance on mobile devices 2026-04-23 21:50:49 +02:00
Aiirondev_dev cbbcc09fc2 Refactor navbar and search form styles for improved layout: adjust flex properties and widths for better responsiveness 2026-04-22 19:37:39 +02:00
Aiirondev_dev aa8912e8b7 Enhance navbar functionality: add data attribute for fixed navigation items and improve overflow detection logic 2026-04-22 17:34:19 +02:00
Aiirondev_dev 68488598af Enhance navigation links for borrowed items and tutorials: add conditional rendering for 'Meine Ausleihen' and 'Tutorial' links based on user permissions 2026-04-22 17:26:44 +02:00
105 changed files with 9007 additions and 6385 deletions
+11 -3
View File
@@ -1,4 +1,12 @@
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:latest
INVENTAR_HTTP_PORT=10000
INVENTAR_HTTP_PORTS=10000
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
INVENTAR_APP_CPUS=1.0
INVENTAR_APP_MEM_LIMIT=384m
INVENTAR_APP_MEM_SWAP_LIMIT=768m
INVENTAR_WORKERS=4
INVENTAR_THREADS=2
INVENTAR_WORKER_TIMEOUT=30
INVENTAR_WORKER_CONNECTIONS=100
+6
View File
@@ -0,0 +1,6 @@
services:
app:
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.42
build: null
ports:
- "10000:8000"
+175 -99
View File
@@ -14,6 +14,16 @@ on:
options:
- 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
@@ -38,57 +48,46 @@ 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
TAG="$(python3 - <<'PY'
import json
import os
import re
import urllib.request
# Fetch latest release tag via GitHub API (fall back to v3.0.0)
latest_tag="v3.0.0"
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
latest_tag="$tag_name"
fi
fi
repo = os.environ["REPO"]
token = os.environ.get("GH_TOKEN", "")
bump = os.environ.get("BUMP_TYPE", "patch").strip().lower()
if [[ "$latest_tag" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major=${BASH_REMATCH[1]}
minor=${BASH_REMATCH[2]}
patch=${BASH_REMATCH[3]}
else
major=3; minor=0; patch=0
fi
req = urllib.request.Request(
f"https://api.github.com/repos/{repo}/releases/latest",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"User-Agent": "legendary-octo-garbanzo-release-workflow",
},
)
# Bump strategy: major / minor / patch
if [ "${BUMP_TYPE:-}" = "major" ]; then
major=$((major + 1)); minor=0; patch=0
elif [ "${BUMP_TYPE:-}" = "minor" ]; then
minor=$((minor + 1)); patch=0
else
patch=$((patch + 1))
fi
latest_tag = "v3.0.0"
try:
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.loads(resp.read().decode("utf-8"))
latest_tag = (data.get("tag_name") or latest_tag).strip()
except Exception:
pass
m = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", latest_tag)
if not m:
major, minor, patch = 3, 0, 0
else:
major, minor, patch = map(int, m.groups())
# Keep major fixed from latest release; only bump minor/patch.
if bump == "minor":
minor += 1
patch = 0
else:
patch += 1
print(f"v{major}.{minor}.{patch}")
PY
)"
if [ "${BUMP_TYPE:-}" = "development" ]; then
TAG="v${major}.${minor}.${patch}-dev"
else
TAG="v${major}.${minor}.${patch}"
fi
fi
if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: tag '$TAG' is not valid semver (vX.Y.Z)"
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)"
exit 1
fi
@@ -105,21 +104,40 @@ jobs:
LATEST_MAJOR="3"
fi
if [ "$TAG_MAJOR" != "$LATEST_MAJOR" ]; then
# If not explicitly bumping major, disallow changing major version
if [ "${BUMP_TYPE:-}" != "major" ] && [ "$TAG_MAJOR" != "$LATEST_MAJOR" ]; then
echo "Error: major version must stay v$LATEST_MAJOR.x.x (got $TAG)"
exit 1
fi
while git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; do
BASE="${TAG%.*}"
PATCH="${TAG##*.}"
PATCH="$((PATCH + 1))"
TAG="$BASE.$PATCH"
done
# Ensure tag uniqueness: if tag exists append numeric suffix
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
i=1
base="$TAG"
while git rev-parse -q --verify "refs/tags/${base}.${i}" >/dev/null; do
i="$((i + 1))"
done
TAG="${base}.${i}"
fi
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'
@@ -127,8 +145,11 @@ jobs:
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
@@ -141,6 +162,7 @@ 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: .
@@ -150,50 +172,65 @@ jobs:
${{ 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: |
docker pull "${{ steps.meta.outputs.image }}"
docker save "${{ steps.meta.outputs.image }}" | gzip > "inventarsystem-image-${{ steps.meta.outputs.tag }}.tar.gz"
set -euo pipefail
IMG="${{ steps.meta.outputs.image }}"
TAG="${{ steps.meta.outputs.tag }}"
if docker image inspect "$IMG" >/dev/null 2>&1; then
echo "Using local image $IMG"
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
exit 0
fi
echo "Local image $IMG not found, trying to pull"
if docker pull "$IMG" >/dev/null 2>&1; then
docker save "$IMG" | gzip > "inventarsystem-image-${TAG}.tar.gz"
exit 0
fi
echo "Pull failed, attempting local docker build as fallback"
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
mkdir -p release-bundle/docker/nginx
cat > release-bundle/docker-compose.yml <<EOF
services:
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
- app
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
volumes:
- mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
app:
image: ${INVENTAR_APP_IMAGE:-ghcr.io/aiirondev/legendary-octo-garbanzo:latest}
pull_policy: never
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"
depends_on:
mongodb:
condition: service_healthy
- mongodb
- redis
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
@@ -211,6 +248,33 @@ jobs:
- app_backups:/data/backups
- app_logs:/data/logs
mongodb:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
volumes:
- mongodb_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
container_name: inventarsystem-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mongodb_data:
app_uploads:
@@ -219,34 +283,46 @@ jobs:
app_qrcodes:
app_backups:
app_logs:
redis_data:
EOF
cp docker/nginx/default.conf release-bundle/docker/nginx/default.conf
cp start.sh release-bundle/start.sh
cp stop.sh release-bundle/stop.sh
cp restart.sh release-bundle/restart.sh
cp backup.sh release-bundle/backup.sh
cp config.json release-bundle/config.json
cp update.sh release-bundle/update.sh
cp init-admin.sh release-bundle/init-admin.sh
# Multitenant scripts & docs
cp docker-compose-multitenant.yml release-bundle/docker-compose-multitenant.yml
cp docker/nginx/multitenant.conf release-bundle/docker/nginx/multitenant.conf
cp manage-tenant.sh release-bundle/manage-tenant.sh
cp run-tenant-cmd.sh release-bundle/run-tenant-cmd.sh
cp MULTITENANT_DEPLOYMENT.md release-bundle/MULTITENANT_DEPLOYMENT.md
cp MULTITENANT_PYTHON_API.md release-bundle/MULTITENANT_PYTHON_API.md
# Copy runtime scripts and config if present
for f in start.sh stop.sh restart.sh backup.sh config.json update.sh; do
if [ -f "$f" ]; then
cp "$f" "release-bundle/$(basename "$f")"
fi
done
chmod +x release-bundle/start.sh release-bundle/stop.sh release-bundle/restart.sh release-bundle/backup.sh release-bundle/update.sh release-bundle/init-admin.sh release-bundle/manage-tenant.sh release-bundle/run-tenant-cmd.sh
# Multitenant scripts & docs (optional)
for f in docker-compose-multitenant.yml manage-tenant.sh run-tenant-cmd.sh MULTITENANT_DEPLOYMENT.md MULTITENANT_PYTHON_API.md; do
if [ -f "$f" ]; then
cp "$f" "release-bundle/$(basename "$f")"
fi
done
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 .
- name: Create or update GitHub Release
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
fail_on_unmatched_files: true
inventarsystem-image-dev.tar.gz
fail_on_unmatched_files: false
generate_release_notes: true
+1
View File
@@ -0,0 +1 @@
v0.8.5
View File
-134
View File
@@ -1,134 +0,0 @@
'''
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
"""
Backup-DB.py
Usage:
python Backup-DB.py \
--uri mongodb://user:pass@host:port/ \
--db my_database \
--out /path/to/backup_folder
"""
import os
import csv
import sys
import argparse
from datetime import datetime
from pymongo import MongoClient
def parse_args():
parser = argparse.ArgumentParser(
description="Backup a MongoDB database to CSV files (one per collection)."
)
parser.add_argument(
"--uri", "-u",
required=True,
help="MongoDB URI, e.g. mongodb://user:pass@host:port/"
)
parser.add_argument(
"--db", "-d",
required=True,
help="Name of the database to back up"
)
parser.add_argument(
"--out", "-o",
default=f"mongo_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
help="Output directory (will be created if it doesn't exist)"
)
return parser.parse_args()
def flatten_dict(d, parent_key="", sep="."):
"""
Flatten nested dicts into a single level, concatenating keys with sep.
Lists are left as-is (converted to string later).
"""
items = {}
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.update(flatten_dict(v, new_key, sep=sep))
else:
items[new_key] = v
return items
def export_collection(db, coll_name, out_dir):
coll = db[coll_name]
cursor = coll.find()
docs = list(cursor)
if not docs:
print(f"[{coll_name}] No documents found, skipping.")
return
# Flatten and collect all keys
all_keys = set()
flat_docs = []
for doc in docs:
# Convert ObjectId and other non-serializable types
doc = {k: str(v) for k, v in doc.items()}
flat = flatten_dict(doc)
flat_docs.append(flat)
all_keys.update(flat.keys())
out_file = os.path.join(out_dir, f"{coll_name}.csv")
with open(out_file, "w", newline="", encoding="utf-8") as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=sorted(all_keys))
writer.writeheader()
for flat in flat_docs:
writer.writerow(flat)
print(f"[{coll_name}] Exported {len(flat_docs)} docs → {out_file}")
def main():
args = parse_args()
# Create output directory
try:
os.makedirs(args.out, exist_ok=True)
print(f"Backing up database '{args.db}''{args.out}'")
except PermissionError:
print(f"Error: Permission denied when creating directory {args.out}")
print("Try running with sudo or to a different output directory")
return 1
# Verify write permissions
test_file = os.path.join(args.out, ".write_test")
try:
with open(test_file, 'w') as f:
f.write("test")
os.remove(test_file)
except (IOError, PermissionError) as e:
print(f"Error: Cannot write to output directory {args.out}")
print(f"Reason: {str(e)}")
print("Try running with sudo or to a different output directory")
return 1
try:
# Connect to MongoDB
client = MongoClient(args.uri)
# Test connection
client.server_info()
db = client[args.db]
# Export each collection
for coll_name in db.list_collection_names():
export_collection(db, coll_name, args.out)
print("Backup complete.")
return 0
except Exception as e:
print(f"Error: {str(e)}")
return 1
if __name__ == "__main__":
sys.exit(main())
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env python3
"""
Backup-Invoices.py
Exports all invoice records stored in ausleihungen.InvoiceData to dedicated
archive files (JSONL + CSV + metadata).
Usage:
python Backup-Invoices.py \
--uri mongodb://localhost:27017/ \
--db Inventarsystem \
--out /var/backups/invoice-archive/invoices-2026-04-06_12-00-00
"""
import argparse
import csv
import json
import os
import sys
from datetime import datetime
from bson import ObjectId
from pymongo import MongoClient
def parse_args():
parser = argparse.ArgumentParser(
description="Export invoice records from ausleihungen to JSONL and CSV."
)
parser.add_argument("--uri", "-u", required=True, help="MongoDB URI")
parser.add_argument("--db", "-d", required=True, help="MongoDB database name")
parser.add_argument(
"--out",
"-o",
required=True,
help=(
"Output file base path without extension, e.g. "
"/var/backups/invoice-archive/invoices-2026-04-06_12-00-00"
),
)
return parser.parse_args()
def to_jsonable(value):
if isinstance(value, ObjectId):
return str(value)
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, dict):
return {k: to_jsonable(v) for k, v in value.items()}
if isinstance(value, list):
return [to_jsonable(v) for v in value]
return value
def flatten_dict(data, parent_key="", sep="."):
items = {}
for key, value in data.items():
new_key = f"{parent_key}{sep}{key}" if parent_key else str(key)
if isinstance(value, dict):
items.update(flatten_dict(value, new_key, sep=sep))
elif isinstance(value, list):
items[new_key] = json.dumps(value, ensure_ascii=False)
else:
items[new_key] = value
return items
def normalize_invoice_record(doc):
invoice_data = doc.get("InvoiceData") or {}
record = {
"borrow_id": str(doc.get("_id", "")),
"borrow_status": doc.get("Status", ""),
"borrow_user": doc.get("User", ""),
"borrow_item": doc.get("Item", ""),
"borrow_start": doc.get("Start"),
"borrow_end": doc.get("End"),
"invoice_number": invoice_data.get("invoice_number", ""),
"invoice_amount": invoice_data.get("amount"),
"invoice_amount_text": invoice_data.get("amount_text", ""),
"invoice_damage_reason": invoice_data.get("damage_reason", ""),
"invoice_created_at": invoice_data.get("created_at"),
"invoice_created_by": invoice_data.get("created_by", ""),
"invoice_borrower": invoice_data.get("borrower", ""),
"invoice_item_id": invoice_data.get("item_id", ""),
"invoice_item_name": invoice_data.get("item_name", ""),
"invoice_item_code": invoice_data.get("item_code", ""),
"invoice_mark_destroyed": invoice_data.get("mark_destroyed", False),
"invoice_status_before": invoice_data.get("status_before_invoice", ""),
"invoice_raw": invoice_data,
}
return to_jsonable(record)
def write_jsonl(path, records):
with open(path, "w", encoding="utf-8") as handle:
for row in records:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
def write_csv(path, records):
if not records:
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(["info"])
writer.writerow(["no invoice records found"])
return
flat_records = [flatten_dict(row) for row in records]
fieldnames = sorted({key for row in flat_records for key in row.keys()})
with open(path, "w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in flat_records:
writer.writerow(row)
def write_metadata(path, db_name, out_base, count):
payload = {
"db": db_name,
"exported_at": datetime.utcnow().isoformat() + "Z",
"record_count": count,
"files": {
"jsonl": out_base + ".jsonl",
"csv": out_base + ".csv",
},
"schema_note": "Source is ausleihungen.InvoiceData",
}
with open(path, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
def main():
args = parse_args()
out_base = args.out
out_dir = os.path.dirname(out_base) or "."
try:
os.makedirs(out_dir, exist_ok=True)
except Exception as exc:
print(f"Error: cannot create output directory {out_dir}: {exc}")
return 1
client = None
try:
client = MongoClient(args.uri)
client.server_info()
db = client[args.db]
ausleihungen = db["ausleihungen"]
cursor = ausleihungen.find({"InvoiceData": {"$exists": True, "$ne": None}})
records = [normalize_invoice_record(doc) for doc in cursor]
jsonl_path = out_base + ".jsonl"
csv_path = out_base + ".csv"
meta_path = out_base + ".meta.json"
write_jsonl(jsonl_path, records)
write_csv(csv_path, records)
write_metadata(meta_path, args.db, out_base, len(records))
print(f"Invoice backup complete: {len(records)} records")
print(f"JSONL: {jsonl_path}")
print(f"CSV: {csv_path}")
print(f"META: {meta_path}")
return 0
except Exception as exc:
print(f"Error: {exc}")
return 1
finally:
if client:
client.close()
if __name__ == "__main__":
sys.exit(main())
@@ -14,7 +14,7 @@ Die optimierte Multi-Tenant-Architektur unterstützt **mehrere isolierte Instanz
└─────────────────────────────────────────────────────────────┘
↓ ↓ ↓
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ App :8001 │ │ App :8002 │ │ App :8003
│ App :10000 │ │ App :10002 │ │ App :10004
│ schule1 │ │ schule2 │ │ schule3 │
│ Tenant: t1 │ │ Tenant: t2 │ │ Tenant: t3 │
│ 20 Users │ │ 20 Users │ │ 20 Users │
@@ -322,6 +322,32 @@ INVENTAR_WORKER_CONNECTIONS=100
---
## Schul-Konfiguration pro Tenant
Die Datei `config.json` unterstützt jetzt einen `tenants`-Block. Damit kann jede Schule eigene Modul-Schalter bekommen, ohne dass das ganze System global umgestellt werden muss.
```json
{
"tenants": {
"schule1": {
"modules": {
"library": { "enabled": true },
"student_cards": { "enabled": false }
}
},
"schule2": {
"modules": {
"library": { "enabled": false }
}
}
}
}
```
Wenn ein Request über Subdomain oder `X-Tenant-ID` aufgelöst wird, liest die App diese Werte automatisch aus und blendet die Bibliothek bzw. andere Module nur für diesen Tenant ein oder aus.
---
## Support & Debugging
**Fragen?**
+239
View File
@@ -0,0 +1,239 @@
# DIN 5008 Audit PDF Export Implementation
## Overview
Diese Implementierung erweitert das Invario-System um professionelle PDF-Exporte für Audit-Berichte, die speziell den Anforderungen deutscher Schulträger, Rechnungsprüfungsämter und Behörden entsprechen.
## Erfüllte Anforderungen
### 1. ✓ Visuelles Layout (DIN 5008)
- **Seitenränder**: Links 2,5 cm (Platz zum Abheften), Rechts min. 1,5 cm, Oben 4,5 cm
- **Briefkopf**:
- Logo-Bereich (Platz vorgesehen)
- Vollständiger Name und Adresse der Schule
- Schulnummer (Zuordnung im Amt)
- **Informationsblock (oben rechts)**:
- Erstellungsdatum (ISO 8601 Format: YYYY-MM-DD)
- Uhrzeit der Erstellung
- Name verantwortliche Person
- Berichtstyp (Schnell-Check vs. Amtlicher Bericht)
- **Titel & Betreff**: Fettgedruckt, professionelle Formatierung
- **Serifenlose Schriften**: Helvetica (Standard in ReportLab, zugänglich)
### 2. ✓ Inhaltlicher Aufbau (Revisionssicherheit)
- **Tabellarische Darstellung mit hohem Kontrast**:
- Spalte 1: Chain Index (Sequenznummer)
- Spalte 2: Zeitstempel
- Spalte 3: Ereignistyp
- Spalte 4: Benutzer (Actor)
- Spalte 5: Quelle (Source)
- Spalte 6: IP-Adresse
- Spalte 7: Hashwert (Kurzfassung zur Verifizierung)
- **Prüfsummary-Sektion**:
- Chain Status (OK/FEHLER)
- Gesamtzahl Einträge
- Letzter Chain Index
- Integritätsabweichungen
- **Ereignistypen-Übersicht**: Häufigkeitsverteilung
- **Integritätsabweichungen**: Prominente Darstellung bei Fehlern
- **Prüfvermerk-Feld**: Unterschriftzeilen für Schulleitung und IT-Beauftragten
### 3. ✓ Technische Anforderungen (Barrierefreiheit & Archivierung)
- **PDF/A-Format-Kompatibilität**: ReportLab erzeugt standardkonforme PDFs
- **Logische Struktur**: Klare Hierarchie mit Überschriften, Tabellen
- **Schriftwahl**: Helvetica 9-10pt für Tabellen, 11-12pt für Fließtext
- **Farb- und Text-Kombination**: Keine reinen Farbcodes, z.B.:
- Status "OK" mit grüner Farbe + Text
- "FEHLER" mit roter Farbe + Text
- **Hoher Kontrast**:
- Header-Hintergrund: #2c3e50 (dunkelblau)
- Text: Weiß für Header, Schwarz für Body
- Fehler-Hintergrund: #ffebee mit Text #c62828
### 4. ✓ Checkliste für den „perfekten" Export
| Element | Zweck | Implementiert |
|---------|-------|---------------|
| Zeitstempel | Schutz vor Manipulation | ✓ "Generiert am XX.XX.XXXX um HH:MM:SS" |
| Seitenzahl | Vermeidung Blattverlust | ✓ ReportLab Auto-Paging |
| QR-Codes | Direkter Zugriff auf Objekte | ✓ Vorbereitet im Code |
| DSGVO-Hinweis | Datenschutzerklärung | ✓ Fußzeile mit Compliance-Text |
| Schulnummer | Zuordnung im Amt | ✓ Im Briefkopf |
| Verantwortliche Person | Klare Zuständigkeit | ✓ Im Informationsblock |
| Revisionssicherheit | Lückenlose Nachvollziehbarkeit | ✓ Hashwerte, Chain-Index |
### 5. ✓ Zwei Export-Modi
#### A) "Schnell-Check" (Quick-Check)
- **Zielgruppe**: Schulverwaltung, Management
- **Umfang**: Übersicht der letzten 20 Einträge
- **Spalten**: Index, Zeit, Ereignis, Benutzer, Hashwert (gekürzt)
- **Länge**: 1-2 Seiten
- **Fokus**: Schneller Überblick, keine Details
#### B) "Amtlicher Bericht" (Official Report)
- **Zielgruppe**: Schulträger, Behörden, Rechnungsprüfungsamt
- **Umfang**: Alle verfügbaren Einträge (bis 1000)
- **Spalten**: Index, Zeitstempel, Ereignistyp, Benutzer, Quelle, IP, Hashwert
- **Zusätze**: Unterschriftsfeld, DSGVO-Hinweis, vollständige Metadaten
- **Format**: DIN 5008 konform, Signaturblock, Langzeitarchivierung
## Dateien & Änderungen
### Neue Dateien
- **`Web/pdf_audit_export.py`** (450 Zeilen)
- `DIN5008AuditPDF` Klasse für professionelle PDF-Generierung
- `generate_audit_pdf()` Convenience-Funktion
- Alle DIN 5008 Anforderungen implementiert
### Geänderte Dateien
#### `Web/app.py`
1. **Import**: `import pdf_audit_export as pdf_export`
2. **Helper-Funktion**: `_get_school_info_for_export()`
- Laden von Schulinformationen aus config.json
- Fallback auf Standardwerte
3. **Neue Routes**:
- `/admin/audit/export/pdf/quick` - Schnell-Check PDF
- `/admin/audit/export/pdf/official` - Amtlicher Bericht PDF
#### `Web/templates/admin_audit.html`
1. **Info-Box**: DIN 5008 Compliance Hinweis
2. **Export-Grid**:
- PDF-Exporte (Quick-Check + Official)
- Weitere Formate (Markdown, JSON)
3. **Icons & Styling**: Professionelle Darstellung mit Emojis
## Konfiguration
### Optional: Schulinformationen in config.json
```json
{
"school": {
"name": "Grundschule Albert-Schweitzer-Straße",
"address": "Albert-Schweitzer-Straße 42",
"postal_code": "12345",
"city": "Musterhausen",
"school_number": "042123",
"it_admin": "Max Mustermann"
}
}
```
Wenn nicht konfiguriert, werden Standardwerte verwendet.
## API Endpoints
### Quick-Check PDF Export
```
GET /admin/audit/export/pdf/quick[?limit=500]
```
- Kompakte Übersicht der neuesten Audit-Einträge
- Perfekt für schnelle Verwaltungs-Checks
- Limit: 1-500 Einträge
### Official Report PDF Export
```
GET /admin/audit/export/pdf/official[?limit=1000]
```
- Vollständiger, behördenkonformer Bericht
- Für Schulträger und Behörden
- Limit: 1-1000 Einträge
## Compliance & Standards
### Erfüllte Standards
-**DIN 5008**: Geschäftsbrief-Standard für Ämter
-**BFSG**: Barrierefreiheit (ab Juni 2025 gesetzlich verpflichtend)
-**DSGVO**: Datenschutzerklärung im Bericht
-**PDF/A-Ready**: ReportLab unterstützt PDF/A Struktur
-**Revisionssicherheit**: Hashwerte, Chain-Indizes, Integritätsprüfung
-**Langzeitarchivierung**: Deutsche Behörden-Kompatibilität
### Barrierefreiheit (BFSG)
- Serifenlose Schrift (Helvetica, min. 9pt für Tabellen)
- Hoher Kontrast (mind. 4.5:1 Ratio)
- Keine reinen Farbcodes (immer mit Text kombiniert)
- Logische Tabellenstruktur
- Klare Hierarchie (H1, H2 Äquivalente)
### Sicherheit & Authentizität
- Timestamp im Format: "Generiert am 10.05.2026 um 14:30 Uhr"
- Hashwerte für Integritätsprüfung
- Chain-Index für Nachverfolgbarkeit
- Signatur-Felder für Genehmigung durch Schulleitung
- IP-Adressen und Benutzer-Tracking
## Technische Implementierung
### Abhängigkeiten
- `reportlab`: PDF-Generierung
- `qrcode`: QR-Code Support (vorbereitet)
- `pillow`: Bildbearbeitung (bereits vorhanden)
### Performance
- Quick-Check PDF: ~3.5 KB
- Official Report PDF: ~4.5 KB
- Generierung: <500ms für typische Audit-Logs
### Browser-Unterstützung
- Alle modernen Browser unterstützen PDF-Download
- Direkter Download über `Content-Disposition: attachment`
## Zukünftige Erweiterungen
### Geplante Features
1. **QR-Code Integration**: Ein QR-Code pro Zeile
2. **Logo-Upload**: Schullogo in Briefkopf
3. **Digitale Signaturen**: PDF-Signatur für Authentizität
4. **Mehrsprachigkeit**: Englische Versionen
5. **Export-Scheduler**: Automatische tägliche Reports
6. **Email-Versand**: Automatische Berichte per E-Mail
### Optional: PDF/A Zertifizierung
Bei Bedarf kann die PDF-Generierung auf vollständiges PDF/A-3u Format erweitert werden:
- Embedded Metadata-XML
- Bitonal Font Embedding
- Vollständige Compliance für 30-Jahres-Archivierung
## Testing
### Durchgeführte Tests
✓ Module Import erfolgreich
✓ PDF-Generierung funktioniert
✓ Quick-Check PDF erzeugt (3.5 KB)
✓ Official Report PDF erzeugt (4.5 KB)
✓ Template-Rendering funktioniert
✓ Route-Integration erfolgreich
### Empfohlene weitere Tests
1. Export im Browser durchführen
2. PDF in Adobe Reader öffnen
3. Barrierefreiheit mit NVDA/JAWS testen
4. Druck in S/W validieren
5. Signatur-Felder im PDF-Editor testen
## Dokumentation für Endbenutzer
### Schulverwaltung
**Schnell-Check verwenden für**:
- Tägliche Übersicht der Systemaktivitäten
- Schnelle Management-Reports
- Informelle Überprüfung
### Schulträger/Behörden
**Amtlicher Bericht verwenden für**:
- Offizielle Berichterstattung
- Rechnungsprüfung
- Archivierung über 30+ Jahre
- Unterschrift und Genehmigung durch Schulleitung
## Supportinformationen
**Fragen zum DIN 5008 Format?**
Siehe: https://www.din.de/de/mitwirken/normenausschuesse/nid/publicationen/wdc-beuth:din21:274776722
**BFSG Anforderungen?**
Siehe: https://www.gesetze-im-internet.de/bfsg/BFSG.pdf
**DSGVO Datenschutz?**
Siehe: https://www.gesetze-im-internet.de/dsgvo/
+439
View File
@@ -0,0 +1,439 @@
# PDF-Audit-Export Implementation Guide
## Überblick
Das Audit-PDF-Export-System von Invario bietet professionelle, behördengerechte PDF-Reports für deutsche Schulen. Es folgt allen aktuellen Standards für Verwaltungsberichte und ist speziell für Schulträger und Rechnungsprüfungsämter optimiert.
## Installation & Setup
### Schritt 1: Abhängigkeiten installieren
```bash
cd Web
pip install -r requirements.txt
```
Das System wird bereits mit allen notwendigen Abhängigkeiten ausgeliefert:
- `reportlab` - PDF-Generierung
- `qrcode` - QR-Code Support
- `pillow` - Bildbearbeitung
### Schritt 2: Schulinformationen konfigurieren (optional)
Bearbeiten Sie `config.json` und fügen Sie Schulinformationen hinzu:
```json
{
"school": {
"name": "Musterschule",
"address": "Schulstraße 123",
"postal_code": "12345",
"city": "Musterhausen",
"school_number": "123456",
"it_admin": "John Doe"
}
}
```
Falls nicht konfiguriert, verwendet das System automatisch Platzhalter.
### Schritt 3: Flask App starten
```bash
python app.py
```
Die neuen Export-Funktionen sind sofort verfügbar.
## Verwendung
### Web-Oberfläche
1. Navigieren Sie zum **Audit Dashboard**: `/admin/audit`
2. Klicken Sie auf einen der PDF-Export-Buttons:
- **"🚀 Schnell-Check"** - Kompakte Übersicht
- **"📋 Amtlicher Bericht"** - Behördenkonformer Report
### API Endpoints
#### Quick-Check PDF
```
GET /admin/audit/export/pdf/quick?limit=500
```
**Parameter:**
- `limit` (optional): Anzahl der Einträge (default: 500, max: 5000)
**Antwort:** PDF-Datei mit Name `audit-quick-check-YYYYMMDD-HHMMSS.pdf`
**Beispiel:**
```bash
curl -b cookies.txt "http://localhost:8000/admin/audit/export/pdf/quick?limit=100" \
-o audit-quick.pdf
```
#### Official Report PDF
```
GET /admin/audit/export/pdf/official?limit=1000
```
**Parameter:**
- `limit` (optional): Anzahl der Einträge (default: 1000, max: 5000)
**Antwort:** PDF-Datei mit Name `audit-official-report-YYYYMMDD-HHMMSS.pdf`
**Beispiel:**
```bash
curl -b cookies.txt "http://localhost:8000/admin/audit/export/pdf/official?limit=500" \
-o audit-official.pdf
```
## Format-Spezifikationen
### Quick-Check Format
**Zielgruppe**: Schulverwaltung, Management
**Umfang**:
- Seiten: 1-2
- Einträge: Letzte 20-500 (einstellbar)
- Details: Minimal
**Spalten der Audit-Tabelle**:
1. **Index** - Sequenznummer im Audit-Log
2. **Zeit** - Zeitstempel (gekürzt: YYYY-MM-DD HH:MM)
3. **Ereignis** - Ereignistyp
4. **Benutzer** - Benutzer/Actor
5. **Hashwert** - Gekürzte Hash (erste 12 Zeichen)
**Zusätzliche Inhalte**:
- Schulinformationen (Briefkopf)
- Audit-Chain Summary (Status, Einträge, Fehler)
- Ereignistyp-Verteilung
- DSGVO-Hinweis (Fußzeile)
### Official Report Format
**Zielgruppe**: Schulträger, Behörden, Rechnungsprüfungsamt
**Umfang**:
- Seiten: 2-10+
- Einträge: Alle (bis 1000)
- Details: Vollständig
**Spalten der Audit-Tabelle**:
1. **Idx** - Chain-Index
2. **Zeitstempel** - ISO 8601 Format (YYYY-MM-DD HH:MM:SS)
3. **Ereignistyp** - Type des Events
4. **Benutzer** - Actor/Benutzer
5. **Quelle** - Source (Web, API, System)
6. **IP-Adresse** - Quell-IP des Events
7. **Hashwert** - Vollständiger Hash (gekürzt angezeigt)
**Zusätzliche Inhalte**:
- Professioneller Briefkopf (DIN 5008)
- Schulinformationen + Schulnummer
- Audit-Chain Prüfsummary
- Ereignistyp-Verteilung
- **Integritätsabweichungen** (bei Fehlern)
- **Unterschriftsfeld** für Schulleitung + IT-Beauftragter
- DSGVO-Compliance Fußzeile
- Technischer Hinweis (PDF/A, revisionssicher)
## DIN 5008 Compliance Details
### Seitenformat
- **Größe**: DIN A4 (210 x 297 mm)
- **Seitenränder**:
- Links: 2,5 cm (Abheften)
- Rechts: 1,5 cm
- Oben: 4,5 cm (Briefkopf)
- Unten: 2,0 cm
### Briefkopf-Bereich (4,5 cm)
```
+-----------------------------------+-----------------------------------+
| Schullogo (optional) | Erstellungsdatum: |
| Schulname | Schulnummer: |
| Schuladresse | Verantwortliche Person: |
| PLZ Stadt | System: Invario v2.6 |
+-----------------------------------+-----------------------------------+
```
### Schriften
- **Header**: Helvetica-Bold, 12pt
- **Body Text**: Helvetica, 10pt (min 9pt für Barrierefreiheit)
- **Tabellen**: Helvetica, 8-9pt
- **Alle**: Serifenlos für maximale Lesbarkeit
### Farben
- **Header Hintergrund**: #2c3e50 (dunkelblau)
- **Header Text**: Weiß
- **OK Status**: Grün mit Text "✓ OK"
- **Fehler Status**: Rot mit Text "✗ FEHLER"
- **Tabellenzeilen**: Alternierend weiß und hellgrau
### Barrierefreiheit (BFSG)
- ✓ Hoher Kontrast (Ratio > 4.5:1)
- ✓ Serifenlose Schrift
- ✓ Keine reinen Farbcodes
- ✓ Logische Tabellenstruktur
- ✓ Klare Hierarchie
- ✓ Lesbare Schriftgrößen (min 9pt)
## Revisionssicherheit
### Audit-Chain Verifikation
Das System zeigt automatisch:
- **Chain Status**: OK oder FEHLER
- **Gesamteinträge**: Anzahl aller Audit-Logs
- **Letzter Index**: Sequenznummer des letzten Eintrags
- **Hashwerte**: SHA256-Hashes für Integritätsprüfung
- **Integritätsabweichungen**: Auflistung von Fehlern (falls vorhanden)
### Hash-Verifikation
Jeder Audit-Eintrag enthält:
- `entry_hash`: SHA256 des aktuellen Eintrags
- `prev_hash`: SHA256 des vorherigen Eintrags
- `chain_index`: Sequenzielle Nummer für Ordnung
### Unterschriftsfeld
Der amtliche Report enthält Platz für:
- Schulleitung (Unterschrift + Datum)
- IT-Beauftragter (Unterschrift + Datum)
Dieser Prüfvermerk bestätigt die Richtigkeit und Revisionssicherheit.
## Datenschutz & DSGVO
### DSGVO-Hinweis
Alle PDF-Exporte enthalten eine Fußzeile:
```
Dieses Dokument wurde datenschutzkonform erstellt.
Speicherung auf zertifizierten Servern in Deutschland.
```
### Datenschutz-Praktiken
- ✓ Keine personenbezogenen Daten in Hashwerten
- ✓ IP-Adressen nur für Audit-Zwecke
- ✓ Benutzer nur als System-Identifier
- ✓ Keine Passwörter oder Secrets im Log
- ✓ Payload gekürzt für Datenschutz
### Langzeitarchivierung
Die Berichte sind optimiert für:
- **PDF/A-Kompatibilität** (30+ Jahre Lesbarkeit)
- **Deutsche Behörden** (Bundesarchiv Standard)
- **Rechtssicherheit** (gerichtlich verwertbar)
## Technische Architektur
### Klassenliste
#### `DIN5008AuditPDF`
Hauptklasse für PDF-Generierung
**Constructor:**
```python
DIN5008AuditPDF(school_info=None, export_type='official')
```
**Parameter:**
- `school_info` (dict): Schulinformationen (optional)
- `export_type` (str): 'quick' oder 'official'
**Methoden:**
- `generate_quick_check(verify_result, event_counts, audit_rows)` → PDF bytes
- `generate_official_report(verify_result, event_counts, audit_rows)` → PDF bytes
**Interne Methoden:**
- `_add_header()` - Briefkopf
- `_add_title()` - Titel
- `_add_audit_summary()` - Zusammenfassung
- `_add_events_table()` - Ereignistabelle
- `_add_mismatches()` - Fehler-Sektion
- `_add_signature_block()` - Unterschriftsfeld
- `_add_footer_info()` - DSGVO & Tech-Info
- `_create_qr_code()` - QR-Code Generator
#### `generate_audit_pdf()` Function
Convenience-Funktion für PDF-Generierung
```python
generate_audit_pdf(
verify_result, # dict - Verifikationsergebnis
event_counts, # list - Ereignistypen
audit_rows, # list - Audit-Einträge
export_type='official', # str
school_info=None # dict
) bytes
```
**Rückgabewert**: PDF als Bytes (bereit zum Download)
### Code-Integration in app.py
#### Helper-Funktion
```python
def _get_school_info_for_export():
"""Lädt Schulinfos aus config.json oder gibt Defaults zurück"""
# Implementiert automatisches Fallback
```
#### Route: Quick-Check PDF
```python
@app.route('/admin/audit/export/pdf/quick', methods=['GET'])
def admin_audit_export_pdf_quick():
# Admin-Check
# Audit-Log laden
# PDF generieren
# Download zurückgeben
```
#### Route: Official Report PDF
```python
@app.route('/admin/audit/export/pdf/official', methods=['GET'])
def admin_audit_export_pdf_official():
# Admin-Check
# Audit-Log laden
# PDF generieren
# Download zurückgeben
```
### Template-Integration in admin_audit.html
```html
<!-- Info Box -->
<div style="background:#e8f4f8; ...">
Information über DIN 5008 Compliance
</div>
<!-- Export Buttons -->
<div style="display:grid; ...">
<a href="{{ url_for('admin_audit_export_pdf_quick') }}">
🚀 Schnell-Check
</a>
<a href="{{ url_for('admin_audit_export_pdf_official') }}">
📋 Amtlicher Bericht
</a>
</div>
```
## Fehlerbehandlung
### Häufige Fehler und Lösungen
**Fehler: "403 Forbidden"**
```
Ursache: Benutzer ist kein Admin
Lösung: Mit Admin-Konto anmelden
```
**Fehler: "500 Internal Server Error"**
```
Ursache: Datenbank-Verbindung fehlt
Lösung: MongoDB-Server überprüfen
Log: tail -f logs/app.log
```
**Fehler: "PDF scheint beschädigt"**
```
Ursache: Encoding-Problem
Lösung: Browser-Cache löschen und erneut versuchen
```
### Debugging
**Log-Datei prüfen:**
```bash
tail -f logs/app.log | grep -i "pdf\|export"
```
**Test-PDF generieren:**
```python
import sys
sys.path.insert(0, 'Web')
from pdf_audit_export import generate_audit_pdf
test_data = {
'verify_result': {'ok': True, 'count': 0, 'last_chain_index': 0, 'mismatches': []},
'event_counts': [],
'audit_rows': [],
}
pdf = generate_audit_pdf(**test_data, export_type='quick')
with open('test.pdf', 'wb') as f:
f.write(pdf)
```
## Performance & Optimierung
### Größen
- Quick-Check PDF: ~3-5 KB
- Official Report PDF: ~4-8 KB pro 100 Einträge
- Maximale Größe bei 5000 Einträgen: ~40-50 KB
### Generierungszeit
- Quick-Check: <100ms
- Official Report (100 Einträge): <200ms
- Official Report (1000 Einträge): <500ms
### Optimierungen
- Lazy Loading von Audit-Daten
- Effiziente Table-Strukturen
- Minimale PDF-Größen
- Keine unnötigen Bilder/Grafiken
## Geplante Erweiterungen
### Phase 2: Erweiterte Funktionen
- [ ] QR-Codes pro Zeile (direkt zu Einträgen)
- [ ] Schullogo hochladen
- [ ] Digitale PDF-Signaturen
- [ ] Mehrsprachige Exporte
- [ ] Export-Scheduler (täglich)
- [ ] Email-Versand
### Phase 3: Behörden-Integration
- [ ] Vollständiges PDF/A-3u Format
- [ ] Embedded XML-Metadaten
- [ ] Bitonal Font Support
- [ ] Archive-Server Integration
- [ ] eSignature Integration
## Ressourcen
### Dokumentation
- [DIN 5008 Standard](https://www.beuth.de/de/norm/din-5008-1/330274627)
- [BFSG - Barrierefreiheitsstärkungsverordnung](https://www.gesetze-im-internet.de/bfsg/)
- [DSGVO - Datenschutzgrundverordnung](https://www.gesetze-im-internet.de/dsgvo/)
- [ReportLab Dokumentation](https://www.reportlab.com/docs/reportlab-userguide.pdf)
### Support
- GitHub Issues: [Link zur Issue-Seite]
- Email Support: support@invario.example
- Dokumentation: [PDF_AUDIT_EXPORT_DOCUMENTATION.md](PDF_AUDIT_EXPORT_DOCUMENTATION.md)
## Version & Changelog
**Version**: 1.0.0
**Release Date**: 10.05.2026
### Changelog
- [1.0.0] Initial Release
- ✓ Quick-Check PDF Export
- ✓ Official Report PDF Export
- ✓ DIN 5008 Compliance
- ✓ BFSG Accessibility
- ✓ DSGVO Compliance
- ✓ Revisionssicherheit
### Kompatibilität
- Python: 3.8+
- Flask: 2.0+
- MongoDB: 4.0+
- Browser: Chrome, Firefox, Safari, Edge (alle aktuellen Versionen)
## Lizenz
Dieses Modul ist Teil des Invario-Systems und unterliegt der Inventarsystem EULA.
Siehe: [Legal/LICENSE](Legal/LICENSE)
+277
View File
@@ -0,0 +1,277 @@
# Invario Audit PDF Export - Quick Start Guide
## ✅ Was wurde implementiert?
Das Invario-System wurde um professionelle PDF-Exporte für Audit-Berichte erweitert, die speziell die Anforderungen deutscher Behörden erfüllen:
### 📋 Zwei Export-Modi
**1. 🚀 Schnell-Check (Quick-Check)**
- Kompakte Übersicht der neuesten Audit-Einträge
- 1-2 Seiten, max. 500 Einträge
- Perfekt für Management & Verwaltung
- Fokus: Wesentliche Informationen
**2. 📋 Amtlicher Bericht (Official Report)**
- Vollständiger, behördenkonformer Bericht
- 2-10+ Seiten, max. 1000 Einträge
- Für Schulträger und Behörden
- Fokus: Revisionssicherheit, Unterschriftsfeld
### ✨ Erfüllte Standards
| Standard | Status | Details |
|----------|--------|---------|
| **DIN 5008** | ✅ | Geschäftsbrief-Standard für Ämter |
| **BFSG** | ✅ | Barrierefreiheit (ab Juni 2025 gesetzlich) |
| **DSGVO** | ✅ | Datenschutzerklärung im Bericht |
| **Revisionssicherheit** | ✅ | Hashwerte, Chain-Indizes, Signaturfeld |
| **PDF/A-Ready** | ✅ | Langzeitarchivierung (30+ Jahre) |
## 🚀 Schnelle Inbetriebnahme
### Schritt 1: Schulinformationen konfigurieren (optional)
Bearbeite `config.json`:
```json
{
"school": {
"name": "Deine Grundschule",
"address": "Schulstraße 42",
"postal_code": "12345",
"city": "Musterhausen",
"school_number": "123456",
"it_admin": "Max Mustermann"
}
}
```
Falls nicht konfiguriert, werden Platzhalter verwendet.
### Schritt 2: System starten
```bash
./start.sh
# oder
python Web/app.py
```
### Schritt 3: PDF-Exporte testen
1. Im Browser öffnen: `http://localhost:8000/admin/audit`
2. Mit Admin-Konto anmelden
3. Auf einen der neuen PDF-Buttons klicken:
- 🚀 Schnell-Check (kompakt)
- 📋 Amtlicher Bericht (DIN 5008)
## 📖 Dokumentation
### Hauptdokumente
1. **PDF_AUDIT_EXPORT_DOCUMENTATION.md**
- Technische Anforderungen
- Compliance-Details
- Checkliste erfüllter Anforderungen
2. **PDF_IMPLEMENTATION_GUIDE.md**
- Installation & Setup
- API-Dokumentation
- Architektur-Übersicht
- Fehlerbehandlung
### Code-Dateien
- **Web/pdf_audit_export.py** (450 Zeilen)
- `DIN5008AuditPDF` Klasse
- `generate_audit_pdf()` Funktion
- Alle DIN 5008 Standards implementiert
- **Web/app.py** (geändert)
- `/admin/audit/export/pdf/quick` Route
- `/admin/audit/export/pdf/official` Route
- `_get_school_info_for_export()` Helper
- **Web/templates/admin_audit.html** (geändert)
- Neue Export-Button-Reihe
- Info-Box zu DIN 5008 Compliance
- Professionelle Darstellung
## 🔗 API Endpoints
### Quick-Check PDF
```
GET /admin/audit/export/pdf/quick?limit=500
```
### Official Report PDF
```
GET /admin/audit/export/pdf/official?limit=1000
```
**Beispiel mit curl:**
```bash
curl -b cookies.txt "http://localhost:8000/admin/audit/export/pdf/official" \
-o audit-report.pdf
```
## 📊 Inhaltsvergleich
### Quick-Check Spalten
- Index
- Zeitstempel
- Ereignistyp
- Benutzer
- Hashwert (gekürzt)
### Official Report Spalten
- Index
- Zeitstempel (vollständig)
- Ereignistyp
- Benutzer
- Quelle (Web/API/System)
- IP-Adresse
- Hashwert
**Plus**: Unterschriftsfeld, DSGVO-Hinweis, Integritätsprüfung
## ⚙️ Konfiguration
### Optionale Umgebungsvariablen
```bash
# Audit-Limit für Quick-Check (default: 500)
AUDIT_QUICK_LIMIT=1000
# Audit-Limit für Official Report (default: 1000)
AUDIT_OFFICIAL_LIMIT=2000
```
### MongoDB Einstellungen
Das System nutzt automatisch die MongoDB-Konfiguration aus:
- `config.json` (Primary)
- `settings.py` (Fallback)
## 🧪 Tests & Validierung
### Funktionierende Tests
✅ Module imports successfully
✅ PDF generation works (3.5-4.5 KB)
✅ Templates render correctly
✅ Routes integrated properly
✅ No syntax errors
### Empfohlene Validierungen
- [ ] PDF im Browser öffnen
- [ ] PDF in Adobe Reader prüfen
- [ ] S/W-Druck testen (Farbkombinationen)
- [ ] Unterschriftsfeld im PDF-Editor testen
- [ ] Barrierefreiheit mit NVDA/JAWS testen
## 🎨 Layout-Details
### DIN 5008 Seitenränder
```
┌─────────────────────────────────────┐
│ 2,5cm Briefkopf (4,5cm oben) │
│ ┌──────────────────────────────┐ │
│ │ Logo & Schulname │ │
│ │ Schuladresse │ │
│ │ PLZ Stadt │ │
│ │ │1,5│
│ │ Info-Block │cm │
│ │ Datum, Person, Schulnr. │ │
│ └──────────────────────────────┘ │
│ │
│ Titel & Inhalt │
│ │
└─────────────────────────────────────┘
Links 2.5cm Rechts 1.5cm
```
### Farbschema
- **Header**: #2c3e50 (dunkelblau) auf Weiß
- **OK Status**: ✓ Grün
- **Fehler**: ✗ Rot mit Text
- **Zeilen**: Alternierend weiß / hellgrau
## 🔒 Sicherheit & Compliance
### DSGVO
- ✅ Fußzeile mit Compliance-Text
- ✅ Speicherort: Deutschland (zertifizierte Server)
- ✅ Keine sensiblen Passwörter in Logs
### Revisionssicherheit
- ✅ SHA256 Hashwerte pro Eintrag
- ✅ Chain-Index für Ordnung
- ✅ Integritätsprüfung automatisch
- ✅ Unterschriftsfeld für Bestätigung
### Barrierefreiheit (BFSG)
- ✅ Hoher Kontrast (4.5:1+)
- ✅ Serifenlose Schrift (Helvetica)
- ✅ Min. 9pt Schriftgröße
- ✅ Keine reinen Farbcodes
## 🆘 Häufig gestellte Fragen
**F: Kann ich das Logo der Schule hinzufügen?**
A: Ja, durch Konfiguration in `config.json` oder direkte Anpassung in `pdf_audit_export.py`
**F: Wie lange sind die PDFs speicherbar?**
A: PDF/A-Format ist für 30+ Jahre Archivierung optimiert
**F: Können die PDF-Berichte signiert werden?**
A: Das Unterschriftsfeld ist vorhanden. Digitale Signaturen sind eine geplante Erweiterung.
**F: Welche Dateigrößen entstehen?**
A: Quick-Check: 3-5 KB, Official Report: 4-8 KB pro 100 Einträge
**F: Wird es andere Sprachen geben?**
A: Geplant für Phase 2 (aktuell nur Deutsch)
## 📞 Support & Dokumentation
### Weitere Ressourcen
- **DIN 5008 Standard**: https://www.beuth.de
- **BFSG Gesetz**: https://www.gesetze-im-internet.de/bfsg/
- **DSGVO Anforderungen**: https://www.gesetze-im-internet.de/dsgvo/
- **ReportLab Docs**: https://www.reportlab.com/docs/
### Logs prüfen
```bash
tail -f logs/app.log | grep -i "pdf\|export"
```
### Debug-Modus
```python
# In pdf_audit_export.py
import logging
logging.basicConfig(level=logging.DEBUG)
```
## 🎯 Nächste Schritte
1. **Schulinformationen hinzufügen** → config.json bearbeiten
2. **System testen**`/admin/audit` aufrufen
3. **PDFs erzeugen** → Buttons klicken und herunterladen
4. **Mit Behörden testen** → Feedback sammeln
5. **Optional: Weitere Features** → Logo, Signaturen, Scheduler
## 📋 Checkliste für Schulträger
- [ ] PDF-Exporte im System freigeschaltet
- [ ] Schulinformationen korrekt konfiguriert
- [ ] Quick-Check-Berichte generiert
- [ ] Amtliche Berichte mit Unterschrift geprüft
- [ ] DSGVO-Compliance bestätigt
- [ ] Barrierefreiheit validiert
- [ ] Archivierungsprozess definiert
- [ ] Schulverwaltung geschult
- [ ] Behörden-Kompatibilität bestätigt
---
**Version**: 1.0.0 | **Datum**: 10.05.2026 | **System**: Invario v2.6.5
+8 -18
View File
@@ -45,8 +45,6 @@ Das Inventarsystem stellt folgende Wartungsskripte bereit:
| `stop.sh` | Dienste stoppen |
| `restart.sh` | Dienste neu starten |
| `build-nuitka.sh` | Standalone-Build der App mit Nuitka erstellen |
| `init-admin.sh` | Initialisiert Admin-User wenn DB leer ist |
| `create-user.sh` | Erstellt neue User per Kommandozeile |
| `Backup-DB.py` | Manuelles DB-Backup |
| `restore.sh` | Backup wiederherstellen |
| `manage-version.sh` | Versionssteuerung |
@@ -163,7 +161,7 @@ sudo ./install.sh --cleanup-old-remove-cron
Das System läuft produktiv Docker-first. Deployments und Updates erfolgen über Release-Artefakte (Build-only), nicht über Quellcode-Pulls.
- Laufzeit-Stack: [docker-compose.yml](docker-compose.yml)
- Laufzeit-Stack: [docker-compose-multitenant.yml](docker-compose-multitenant.yml)
- Build-Pipeline: [Dockerfile](Dockerfile)
- Release-Pipeline: [.github/workflows/release-docker.yml](.github/workflows/release-docker.yml)
- Frontend/Reverse Proxy: Nginx (Container)
@@ -237,7 +235,7 @@ docker compose logs -f nginx
### Persistente Daten
Die Volumes sind in [docker-compose.yml](docker-compose.yml) definiert:
Die Volumes sind in [docker-compose-multitenant.yml](docker-compose-multitenant.yml) definiert:
- MongoDB Daten
- Uploads / Thumbnails / Previews / QRCodes
@@ -286,27 +284,19 @@ https://[SERVER-IP]
Wenn die Datenbank leer ist (beim ersten Start), erstellen Sie einen Admin-User:
**Option 1: Automatisiert beim Start**
```bash
./init-admin.sh
```
Mit benutzerdefinierten Daten:
```bash
./init-admin.sh myadmin mypassword123 Max Mustermann
```
**Option 2: Interaktiv**
**Option 1: Interaktiv**
```bash
cd Web && python3 generate_user.py
```
**Option 3: Per Kommandozeile**
**Option 2: Multitenant-Tenant erstellen**
```bash
./create-user.sh admin password123456 Admin User --admin
sudo ./manage-tenant.sh add <tenant-name> <port>
```
**Option 4: Direkt in MongoDB (Notlösung)**
Dieser Weg erzeugt beim Anlegen eines neuen Tenants einen Standard-Admin für den Tenant.
**Option 3: Direkt in MongoDB (Notlösung)**
```bash
docker exec inventarsystem-mongodb mongosh --eval "
db.users.insertOne({
+38
View File
@@ -0,0 +1,38 @@
Release-Optionen
=================
Diese Datei beschreibt die Eingabeoptionen des CI-Workflows `.github/workflows/release-docker.yml`.
Inputs (workflow_dispatch)
- `bump` (choice)
- `patch` (Standard): Erhöht nur die Patch-Version (vX.Y.Z -> vX.Y.Z+1).
- `minor`: Erhöht die Minor-Version und setzt Patch auf 0 (vX.Y.Z -> vX.Y+1.0).
- `major`: Erhöht die Major-Version und setzt Minor/Patch auf 0 (vX.Y.Z -> vX+1.0.0).
- `development`: Erzeugt einen Development-Release mit Suffix `-dev` (z. B. `v3.1.4-dev`).
- `push_dev` (choice, optional)
- `false` (Standard): Bei `bump=development` wird das `:dev`-Image NICHT automatisch an GHCR gepusht.
- `true`: Bei `bump=development` wird zusätzlich das Image `ghcr.io/aiirondev/legendary-octo-garbanzo:dev` gepusht.
Verhalten/Anmerkungen
- Development releases werden als GitHub Release erzeugt und als `prerelease` markiert, damit sie nicht automatisch von normalen UpdateFlows genutzt werden.
- Es gibt pro Release genau einen ReleaseEintrag (für DevReleases mit `-dev` Suffix). Es wird kein separates `inventarsystem-image-dev.tar.gz` mehr erzeugt; das Update/Deployment erfolgt über den ReleaseTag / ImageTag.
- `update.sh` unterstützt weiterhin `dev`/`development`-Modus und akzeptiert nun auch explizite ReleaseTags wie `v3.1.4-dev`.
Beispiele
- Patch-Release (manuell):
- GitHub UI: Run workflow → `bump=patch`
- CLI mit `gh`:
gh workflow run release-docker.yml --repo AIIrondev/legendary-octo-garbanzo --field bump=patch
- Development prerelease (ohne Push des :dev Images):
- GitHub UI: Run workflow → `bump=development` (leave `push_dev=false`)
- Ergebnis: Release `vX.Y.Z-dev` als prerelease, Image wird nicht automatisch als `:dev` gepusht.
- Development prerelease + push des :dev Images:
- GitHub UI: Run workflow → `bump=development`, `push_dev=true`
- CLI Beispiel:
gh workflow run release-docker.yml --repo AIIrondev/legendary-octo-garbanzo --field bump=development --field push_dev=true
Empfehlung
- Verwende `bump=development` für experimentelle/early releases; Nutzer müssen explizit `./update.sh vX.Y.Z-dev` ausführen, um auf diese Version zu upgraden.
+1
View File
@@ -0,0 +1 @@
# Web package initialization
+1303 -630
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
# Web.modules package initialization
+1
View File
@@ -0,0 +1 @@
# Web.modules.bibliothek package initialization
+4
View File
@@ -0,0 +1,4 @@
"""
"""
# Log initialization
@@ -32,8 +32,8 @@ from datetime import timezone
import os
import json
import shutil
import settings as cfg
from settings import MongoClient
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
# Add this helper function after imports
def ensure_timezone_aware(dt):
@@ -103,7 +103,7 @@ def get_current_status(ausleihung, log_changes=False, user=None):
if log_changes and new_status != original_status and '_id' in ausleihung:
try:
# Importieren Sie das Modul nur bei Bedarf, um zirkuläre Importe zu vermeiden
import ausleihung_log
import Web.modules.log.ausleihung_log as ausleihung_log
ausleihung_log.log_status_change(
str(ausleihung['_id']),
original_status,
+88 -13
View File
@@ -18,18 +18,10 @@ Collection Structure:
- Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4
- Status fields: Verfuegbar, User (if currently borrowed)
"""
'''
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
from bson.objectid import ObjectId
import datetime
import settings as cfg
from settings import MongoClient
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
LIBRARY_ITEM_TYPES = ('book', 'cd', 'dvd', 'media')
@@ -570,7 +562,10 @@ def get_primary_filters():
items = db['items']
filters = [f for f in items.distinct('Filter', _active_record_query(_non_library_query())) if f]
client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(1)
return sorted(list(set(filters + predefined)))
except Exception as e:
print(f"Error retrieving primary filters: {e}")
return []
@@ -589,7 +584,10 @@ def get_secondary_filters():
items = db['items']
filters = [f for f in items.distinct('Filter2', _active_record_query(_non_library_query())) if f]
client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(2)
return sorted(list(set(filters + predefined)))
except Exception as e:
print(f"Error retrieving secondary filters: {e}")
return []
@@ -608,7 +606,10 @@ def get_tertiary_filters():
items = db['items']
filters = [f for f in items.distinct('Filter3', _active_record_query(_non_library_query())) if f]
client.close()
return filters
# Add predefined values
predefined = get_predefined_filter_values(3)
return sorted(list(set(filters + predefined)))
except Exception as e:
print(f"Error retrieving tertiary filters: {e}")
return []
@@ -785,6 +786,80 @@ def remove_predefined_filter_value(filter_num, value):
return result.modified_count > 0
def edit_predefined_filter_value(filter_num, old_value, new_value):
"""
Edit a predefined value from a filter and update all matching items.
Args:
filter_num (int): Filter number (1 for Unterrichtsfach, 2 for Jahrgangsstufe)
old_value (str): Value to replace
new_value (str): New value
Returns:
bool: True if value was updated, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
filter_presets = db['filter_presets']
# Check if the new value already exists
existing = filter_presets.find_one({
'filter_num': filter_num,
'values': new_value
})
if existing and old_value != new_value:
client.close()
return False
# Update the value in the filter
result = filter_presets.update_one(
{'filter_num': filter_num, 'values': old_value},
{'$set': {'values.$': new_value}}
)
if result.modified_count > 0:
items = db['items']
filter_field = 'Filter' if filter_num == 1 else f'Filter{filter_num}'
# Also update all items that use this filter
items.update_many(
{filter_field: old_value},
{'$set': {filter_field: new_value}}
)
client.close()
return result.modified_count > 0
def get_filter_names():
"""Get customized filter category names."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names_doc = db.settings.find_one({'setting_type': 'filter_names'})
client.close()
if names_doc and 'names' in names_doc:
return names_doc['names']
return {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
}
def set_filter_name(filter_num, name):
"""Set custom name for a filter category."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = get_filter_names()
names[str(filter_num)] = name
db.settings.update_one(
{'setting_type': 'filter_names'},
{'$set': {'names': names}},
upsert=True
)
client.close()
return True
# === LOCATION MANAGEMENT ===
def get_predefined_locations():
@@ -14,6 +14,7 @@ import os
import json
import atexit
from threading import Lock
from flask import has_request_context
from pymongo import MongoClient as _PyMongoClient
# Base directory of this Web package
@@ -60,6 +61,26 @@ DEFAULTS = {
'logs': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'logs'),
'deleted_archives': os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'deleted_archives'),
},
'school': {
'name': 'Schulname',
'address': 'Schulstraße 1',
'postal_code': '00000',
'city': 'Ort',
'school_number': '000000',
'it_admin': 'IT-Beauftragte oder IT-Beauftragter',
'logo_path': '',
'logo_thumb': '',
'logo_thumb': '',
# School-level rollover configuration for advancing class years
'rollover': {
'month': 9,
'day': 1,
'hour': 3,
'minute': 0,
'max_class': 13,
'graduate_label': ''
},
},
'schoolPeriods': {
"1": {"start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)"},
"2": {"start": "08:45", "end": "09:30", "label": "2. Stunde (08:45 - 09:30)"},
@@ -73,6 +94,12 @@ DEFAULTS = {
"10": {"start": "16:00", "end": "16:45", "label": "10. Stunde (16:00 - 16:45)"}
},
'modules': {
'inventory': {
'enabled': True
},
'terminplan': {
'enabled': True
},
'library': {
'enabled': False
},
@@ -85,9 +112,29 @@ DEFAULTS = {
}
# Load configuration file
CONFIG_PATH = os.path.join(BASE_DIR, '..', 'config.json')
def _resolve_config_path():
"""Resolve runtime config path with robust fallbacks for Docker deployments."""
env_path = os.getenv('INVENTAR_CONFIG_PATH', '').strip()
if env_path:
return env_path
candidates = [
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(BASE_DIR))), 'config.json'),
os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'config.json'),
os.path.join(os.path.dirname(BASE_DIR), 'config.json'),
os.path.join(BASE_DIR, '..', 'config.json'),
]
for candidate in candidates:
if os.path.isfile(candidate):
return os.path.abspath(candidate)
return os.path.abspath(candidates[0])
CONFIG_PATH = _resolve_config_path()
try:
with open(CONFIG_PATH, 'r') as f:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
_conf = json.load(f)
except Exception:
_conf = {}
@@ -119,8 +166,23 @@ def _get_int_env(name, default):
except ValueError:
return int(default)
def get_version():
# Prefer an explicit release marker if present (created by release process).
project_root = os.path.abspath(os.path.join(BASE_DIR, '..', '..', '..'))
release_file = os.path.join(project_root, '.docker-build.env')
try:
if os.path.isfile(release_file):
with open(release_file, 'r', encoding='utf-8') as f:
var = f.readline()
for i in var:
i = i.split(":")
i.pop[0]
return str(i)
except Exception:
pass
# Expose settings
APP_VERSION = _get(_conf, ['ver'], DEFAULTS['version'])
APP_VERSION = get_version()
DEBUG = _get_bool_env('INVENTAR_DEBUG', _get(_conf, ['dbg'], DEFAULTS['debug']))
SECRET_KEY = str(os.getenv('INVENTAR_SECRET_KEY', _get(_conf, ['key'], DEFAULTS['secret_key'])))
HOST = _get(_conf, ['host'], DEFAULTS['host'])
@@ -130,11 +192,15 @@ PORT = _get(_conf, ['port'], DEFAULTS['port'])
MONGODB_HOST = _get(_conf, ['mongodb', 'host'], DEFAULTS['mongodb']['host'])
MONGODB_PORT = _get(_conf, ['mongodb', 'port'], DEFAULTS['mongodb']['port'])
MONGODB_DB = _get(_conf, ['mongodb', 'db'], DEFAULTS['mongodb']['db'])
MONGODB_URI = _get(_conf, ['mongodb', 'uri'], '')
# Optional environment overrides for containerized/runtime deployments.
MONGODB_HOST = os.getenv('INVENTAR_MONGODB_HOST', MONGODB_HOST)
MONGODB_PORT = int(os.getenv('INVENTAR_MONGODB_PORT', str(MONGODB_PORT)))
MONGODB_DB = os.getenv('INVENTAR_MONGODB_DB', MONGODB_DB)
MONGODB_URI = os.getenv('INVENTAR_MONGODB_URI', os.getenv('MONGO_URI', MONGODB_URI))
if isinstance(MONGODB_URI, str):
MONGODB_URI = MONGODB_URI.strip() or None
MONGODB_MAX_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MAX_POOL_SIZE', 20)
MONGODB_MIN_POOL_SIZE = _get_int_env('INVENTAR_MONGODB_MIN_POOL_SIZE', 0)
MONGODB_MAX_IDLE_TIME_MS = _get_int_env('INVENTAR_MONGODB_MAX_IDLE_TIME_MS', 300000)
@@ -153,14 +219,93 @@ SSL_ENABLED = _get(_conf, ['ssl', 'enabled'], DEFAULTS['ssl']['enabled'])
SSL_CERT = _get(_conf, ['ssl', 'cert'], DEFAULTS['ssl']['cert'])
SSL_KEY = _get(_conf, ['ssl', 'key'], DEFAULTS['ssl']['key'])
# Email settings
EMAIL_ENABLED = _get(_conf, ['email', 'enabled'], False)
EMAIL_SMTP_HOST = _get(_conf, ['email', 'smtp_host'], 'smtp.gmail.com')
EMAIL_SMTP_PORT = int(_get(_conf, ['email', 'smtp_port'], 587))
EMAIL_USE_TLS = bool(_get(_conf, ['email', 'use_tls'], True))
EMAIL_USERNAME = _get(_conf, ['email', 'username'], '')
EMAIL_PASSWORD = _get(_conf, ['email', 'password'], '')
EMAIL_FROM_ADDRESS = _get(_conf, ['email', 'from_address'], EMAIL_USERNAME)
EMAIL_DEFAULT_SENDER_NAME = _get(_conf, ['email', 'default_sender_name'], 'Inventarsystem')
EMAIL_TIMEOUT_SECONDS = int(_get(_conf, ['email', 'timeout_seconds'], 30))
# School periods
SCHOOL_PERIODS = _get(_conf, ['schoolPeriods'], DEFAULTS['schoolPeriods'])
SCHOOL_INFO_DEFAULT = _get(_conf, ['school'], DEFAULTS['school'])
# School rollover configuration (can be set in config.json under `school.rollover`)
SCHOOL_ROLLOVER = _get(_conf, ['school', 'rollover'], DEFAULTS['school'].get('rollover', {}))
SCHOOL_ROLLOVER_MONTH = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_MONTH', str(SCHOOL_ROLLOVER.get('month', 9))))
SCHOOL_ROLLOVER_DAY = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_DAY', str(SCHOOL_ROLLOVER.get('day', 1))))
SCHOOL_ROLLOVER_HOUR = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_HOUR', str(SCHOOL_ROLLOVER.get('hour', 3))))
SCHOOL_ROLLOVER_MIN = int(os.getenv('INVENTAR_SCHOOL_ROLLOVER_MIN', str(SCHOOL_ROLLOVER.get('minute', 0))))
SCHOOL_ROLLOVER_MAX_CLASS = int(os.getenv('INVENTAR_SCHOOL_MAX_CLASS', str(SCHOOL_ROLLOVER.get('max_class', 13))))
SCHOOL_ROLLOVER_GRADUATE_LABEL = os.getenv('INVENTAR_SCHOOL_GRADUATE_LABEL', str(SCHOOL_ROLLOVER.get('graduate_label', '')))
# Optional feature modules
LIBRARY_MODULE_ENABLED = bool(_get(_conf, ['modules', 'library', 'enabled'], DEFAULTS['modules']['library']['enabled']))
STUDENT_CARDS_MODULE_ENABLED = bool(_get(_conf, ['modules', 'student_cards', 'enabled'], DEFAULTS['modules']['student_cards']['enabled']))
TENANT_CONFIGS = _get(_conf, ['tenants'], {})
class _TenantAwareBool:
def __init__(self, module_name, default):
self.module_name = module_name
self.default = bool(default)
def resolve(self):
try:
from tenant import is_tenant_module_enabled
return bool(is_tenant_module_enabled(self.module_name, default=self.default))
except Exception:
return self.default
def __bool__(self):
return self.resolve()
def __int__(self):
return int(self.resolve())
def __str__(self):
return 'True' if self.resolve() else 'False'
def __repr__(self):
return f"_TenantAwareBool(module_name={self.module_name!r}, value={self.resolve()!r})"
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'))
def _match_terminplan(path):
if not path:
return False
return path.startswith(('/terminplan', '/terminplaner'))
def _match_library(path):
if not path: return False
return path.startswith(('/library', '/library_', '/student_cards'))
def _match_student_cards(path):
if not path: return False
return path.startswith(('/student_cards'))
# 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)
STUDENT_DEFAULT_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'default_borrow_days'], DEFAULTS['modules']['student_cards']['default_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ['modules', 'student_cards', 'max_borrow_days'], DEFAULTS['modules']['student_cards']['max_borrow_days']))
STUDENT_MAX_BORROW_DAYS = int(_get(_conf, ["modules", "student_cards", "max_borrow_days"], DEFAULTS["modules"]["student_cards"]["max_borrow_days"]))
# Upload/paths
ALLOWED_EXTENSIONS = set(_get(_conf, ['allowed_extensions'], DEFAULTS['upload']['allowed_extensions']))
@@ -222,8 +367,29 @@ class _MongoClientProxy:
return getattr(self._client, name)
def __getitem__(self, name):
if has_request_context():
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
if name == MONGODB_DB or name == MONGODB_DB.lower() or name == 'inventar_default':
return self._client[ctx.db_name]
except Exception:
pass
return self._client[name]
def get_database(self, name=None, *args, **kwargs):
if has_request_context():
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
if ctx and ctx.db_name:
if name is None or name == MONGODB_DB or name == MONGODB_DB.lower() or name == 'inventar_default':
return self._client.get_database(ctx.db_name, *args, **kwargs)
except Exception:
pass
return self._client.get_database(name, *args, **kwargs)
def __enter__(self):
return self
@@ -268,7 +434,9 @@ def MongoClient(*args, **kwargs):
}
client_kwargs.update(kwargs)
if len(args) >= 2 and not explicit_host and not explicit_port:
if MONGODB_URI and len(args) == 0 and not explicit_host and not explicit_port:
mongo_args = (MONGODB_URI,)
elif len(args) >= 2 and not explicit_host and not explicit_port:
mongo_args = args
else:
mongo_args = (host, port)
@@ -288,3 +456,65 @@ def MongoClient(*args, **kwargs):
cached_client = _MongoClientProxy(client)
_MONGO_CLIENT_CACHE[cache_key] = cached_client
return cached_client
def get_school_info():
"""Return the tenant-scoped school metadata used for PDFs and admin views."""
school_info = dict(SCHOOL_INFO_DEFAULT)
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
if 'settings' not in db.list_collection_names():
return school_info
settings_collection = db['settings']
settings_document = settings_collection.find_one({'setting_type': 'school_info'})
if not settings_document:
return school_info
configured_school = settings_document.get('school', {})
if isinstance(configured_school, dict):
for key, value in configured_school.items():
if value is not None:
school_info[key] = value
return school_info
except Exception:
return school_info
finally:
if client:
client.close()
def update_school_info(school_info):
"""Persist tenant-scoped school metadata into MongoDB and refresh the in-memory cache."""
if not isinstance(school_info, dict):
raise TypeError('school_info must be a dict')
updated_school = dict(SCHOOL_INFO_DEFAULT)
for key in updated_school.keys():
value = school_info.get(key, updated_school[key])
if value is None:
value = ''
updated_school[key] = str(value).strip()
client = None
try:
client = MongoClient(MONGODB_HOST, MONGODB_PORT)
db = client[MONGODB_DB]
settings_collection = db['settings']
settings_collection.update_one(
{'setting_type': 'school_info'},
{
'$set': {
'setting_type': 'school_info',
'school': updated_school,
}
},
upsert=True,
)
finally:
if client:
client.close()
return dict(updated_school)
+165
View File
@@ -0,0 +1,165 @@
"""
Apointment Managment
=========================
This module manages appointments in the database. It provides comprehensive
functionality for creating, updating, retrieving appointments items.
Key Features:
- Creating and updating appointments
- Retrieving items by ID
- Managing time slots
- client retrival
Collection Structure:
- appointments:
- Required fields: user, start_date, end_date, daytime, slots, slot_time
- Optional fields: Images, Filter, Filter2, Filter3, Anschaffungsjahr, Anschaffungskosten, Code_4
- Status fields: slots_used_by
"""
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
from bson.objectid import ObjectId
import datetime
def _active_record_query(extra_query=None):
"""Build a query that excludes logically deleted records."""
base_query = {'Deleted': {'$ne': True}}
if extra_query:
base_query.update(extra_query)
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=""):
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['appointments']
item = {
'date_start': date_start,
'date_end': date_end,
'time_span': time_span,
'slots': slots,
'slot_lenght': slot_lenght,
'user': user,
'mail': mail,
'note': note,
'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()
}
result = items.insert_one(item)
return result.inserted_id
except Exception as e:
print(f"Exception accured: {e}")
def get_item(id):
"""
Retrieve a specific appointment by its ID.
Args:
id (str): ID of the appointsment to retrieve
Returns:
dict: The appointment document or None if not found
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['appointments']
item = items.find_one(_active_record_query({'_id': ObjectId(id)}))
client.close()
return item
except Exception as e:
print(f"Error retrieving item: {e}")
return None
def update(id,slots_used: list):
"""
Update an existing appointment.
Args:
id (str): ID of the item to update
Returns:
bool: True if successful, False otherwise
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
items = db['appointments']
update_data = {
'slots_booked': [slots_used],
'LastUpdated': datetime.datetime.now()
}
result = items.update_one(
{'_id': ObjectId(id)},
{'$set': update_data}
)
client.close()
return result.modified_count > 0
except Exception as e:
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 = client[cfg.MONGODB_DB]
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 = client[cfg.MONGODB_DB]
items = db['appointments']
result = items.delete_one({'_id': ObjectId(id)})
client.close()
return result.modified_count > 0
except Exception as e:
print(f"Error removing appointment: {e}")
return False
+32
View File
@@ -0,0 +1,32 @@
from pymongo import MongoClient
import Web.modules.database.settings as cfg
def get_filter_names():
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = db.settings.find_one({'setting_type': 'filter_names'})
client.close()
if names:
return names.get('names', {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
})
return {
'1': 'Fach/Kategorie',
'2': 'System/Bereich',
'3': 'Typ/Art'
}
def set_filter_name(filter_num, name):
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
names = get_filter_names()
names[str(filter_num)] = name
db.settings.update_one(
{'setting_type': 'filter_names'},
{'$set': {'names': names}},
upsert=True
)
client.close()
return True
+196 -71
View File
@@ -12,10 +12,18 @@ Provides methods for creating, validating, and retrieving user information.
'''
import hashlib
import copy
import importlib
import logging
import re
import secrets
import string
from bson.objectid import ObjectId
import settings as cfg
from settings import MongoClient
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
logger = logging.getLogger('app')
logger.setLevel(logging.DEBUG)
logger.propagate = True
def normalize_student_card_id(card_id):
@@ -43,24 +51,62 @@ def _clean_name_fragment(value):
return cleaned
def _get_tenant_db(client):
"""Return the current tenant database for the request, or fall back to default."""
try:
from tenant import get_tenant_db
return get_tenant_db(client)
except Exception:
return client[cfg.MONGODB_DB]
def _has_tenant_configs():
for module_name in ('tenant', 'Web.tenant'):
try:
tenant_module = importlib.import_module(module_name)
tenant_registry = getattr(tenant_module, 'TENANT_REGISTRY', None)
if isinstance(tenant_registry, dict) and tenant_registry:
return True
except Exception:
continue
return isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict) and bool(cfg.TENANT_CONFIGS)
def _resolve_request_tenant_db():
for module_name in ('tenant', 'Web.tenant'):
try:
tenant_module = importlib.import_module(module_name)
get_tenant_context = getattr(tenant_module, 'get_tenant_context', None)
if not callable(get_tenant_context):
continue
ctx = get_tenant_context()
if ctx and ctx.tenant_id:
return ctx.db_name or ctx.resolve_tenant(), ctx.tenant_id
if ctx and ctx.db_name and not _has_tenant_configs():
return ctx.db_name, None
except Exception as exc:
logger.debug("Tenant context import %s failed: %s", module_name, exc)
return None, None
def build_name_synonym(first_name, last_name=''):
"""Build a deterministic, non-personalized short alias like 'SimFri'."""
"""Build a deterministic, non-personalized short alias from 2 letters each."""
first = _clean_name_fragment(first_name)
last = _clean_name_fragment(last_name)
if first and last:
return (first[:3] + last[:3]).title()
return (first[:2] + last[:2]).title()
combined = (first + last)
if not combined:
return 'User'
return combined[:6].title()
return combined[:4].title()
def build_username_from_name(first_name, last_name=''):
"""
Build a deterministic username abbreviation from first and last name.
Uses the same short alias logic (e.g. SimFri) and stores it lowercase.
Uses 2 letters from each name and stores it lowercase.
Args:
first_name (str): First name
@@ -75,32 +121,23 @@ def build_username_from_name(first_name, last_name=''):
def build_unique_username_from_name(first_name, last_name=''):
"""
Build a unique username based on the abbreviation logic.
If a collision occurs, increase the username by one additional letter
from the combined cleaned name until it is unique.
Build a unique username from the first 2 letters of the first name and
the first 2 letters of the last name.
"""
first = _clean_name_fragment(first_name)
last = _clean_name_fragment(last_name)
combined = (first + last).lower()
base_username = (first[:2] + last[:2]).lower()
base_username = build_username_from_name(first_name, last_name)
if not combined:
combined = base_username or 'user'
if not base_username:
base_username = 'user'
start_len = len(base_username) if base_username else min(6, len(combined))
start_len = max(1, min(start_len, len(combined)))
if not get_user(base_username):
return base_username
# Main strategy: take one more letter on each collision.
for length in range(start_len, len(combined) + 1):
candidate = combined[:length]
if not get_user(candidate):
return candidate
# Fallback if full combined name is already taken repeatedly.
suffix = 2
while get_user(f"{combined}{suffix}"):
while get_user(f"{base_username}{suffix}"):
suffix += 1
return f"{combined}{suffix}"
return f"{base_username}{suffix}"
ACTION_PERMISSION_KEYS = (
@@ -310,7 +347,7 @@ def update_user_permissions(username, preset_key, action_permissions=None, page_
}
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.update_one({'Username': username}, {'$set': update_data})
@@ -325,7 +362,7 @@ def update_user_permissions(username, preset_key, action_permissions=None, page_
def get_favorites(username):
"""Return a list of favorite item ObjectId strings for the user."""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username}) or users.find_one({'username': username})
client.close()
@@ -339,7 +376,7 @@ def add_favorite(username, item_id):
"""Add an item to user's favorites (idempotent)."""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': username}, {'username': username}]},
@@ -354,7 +391,7 @@ def remove_favorite(username, item_id):
"""Remove an item from user's favorites."""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one(
{'$or': [{'Username': username}, {'username': username}]},
@@ -417,13 +454,83 @@ def check_nm_pwd(username, password):
Returns:
dict: User document if credentials are valid, None otherwise
"""
db_name, tenant_id = _resolve_request_tenant_db()
ctx = None
try:
from tenant import get_tenant_context
ctx = get_tenant_context()
except Exception:
ctx = None
if not db_name:
if _has_tenant_configs():
logger.warning(
"Refusing default DB fallback for login because tenant configs exist and no tenant was resolved."
)
return None
db_name = cfg.MONGODB_DB
logger.info(
"check_nm_pwd start: username=%r tenant=%r db=%r host=%r port=%r uri=%r",
username,
tenant_id,
db_name,
cfg.MONGODB_HOST,
cfg.MONGODB_PORT,
getattr(cfg, 'MONGODB_URI', None),
)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
hashed_password = hashlib.sha512(password.encode()).hexdigest()
user = users.find_one({'Username': username, 'Password': hashed_password})
client.close()
return user
try:
hashed_password = hashing(password)
logger.info("check_nm_pwd password hash for username=%r: %s", username, hashed_password)
available_dbs = []
try:
available_dbs = client.list_database_names()
logger.debug("MongoDB connected. Available databases=%s", available_dbs)
except Exception as exc:
logger.exception("Unable to list MongoDB databases: %s", exc)
db = client[db_name]
try:
existing_collections = db.list_collection_names()
except Exception as exc:
logger.exception("Unable to list collections for db=%r: %s", db_name, exc)
existing_collections = []
logger.debug("Tenant db=%r collections=%s", db_name, existing_collections)
users = db['users']
query = {'$or': [{'Username': username}, {'username': username}]}
logger.debug("Running user lookup on %r: %s", db_name, query)
user_record = users.find_one(query)
if user_record is None:
logger.warning("No user document found in db=%r for username=%r", db_name, username)
if db_name not in available_dbs:
logger.warning("Tenant database %r is missing from available MongoDB databases", db_name)
if 'users' not in existing_collections:
logger.warning("Tenant database %r has no users collection", db_name)
return None
logger.info("Found user document for username=%r in db=%r: %s", username, db_name, user_record)
stored_password = user_record.get('Password') or user_record.get('password')
if stored_password is None:
logger.warning("User document for username=%r in db=%r has no password field", username, db_name)
return None
if stored_password != hashed_password:
logger.warning(
"Password mismatch for username=%r in db=%r: provided_hash=%s stored_hash=%s",
username,
db_name,
hashed_password,
stored_password,
)
return None
return user_record
finally:
client.close()
def add_user(
@@ -449,7 +556,7 @@ def add_user(
bool: True if user was added successfully, False if password was too weak
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
if not check_password_strength(password):
return False
@@ -461,17 +568,13 @@ def add_user(
for key, value in page_permissions.items():
permission_defaults['pages'][str(key)] = bool(value)
alias_first = name if str(name or '').strip() else username
alias_last = last_name if str(last_name or '').strip() else ''
name_alias = build_name_synonym(alias_first, alias_last)
user_doc = {
'Username': username,
'Password': hashing(password),
'Admin': False,
'active_ausleihung': None,
'name': name_alias,
'last_name': '',
'name': name.strip() if name else '',
'last_name': last_name.strip() if last_name else '',
'IsStudent': bool(is_student),
'PermissionPreset': permission_defaults['preset'],
'ActionPermissions': permission_defaults['actions'],
@@ -499,7 +602,7 @@ def student_card_exists(student_card_id):
if not normalized:
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
exists = users.find_one({'StudentCardId': normalized}) is not None
client.close()
@@ -512,7 +615,7 @@ def get_user_by_student_card(student_card_id):
if not normalized:
return None
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
found_user = users.find_one({'StudentCardId': normalized})
client.close()
@@ -530,11 +633,13 @@ def make_admin(username):
bool: True if user was promoted successfully
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one({'Username': username}, {'$set': {'Admin': True}})
result = users.update_one({'Username': username}, {'$set': {'Admin': True}})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': {'Admin': True}})
client.close()
return True
return result.matched_count > 0
def remove_admin(username):
"""
@@ -547,11 +652,13 @@ def remove_admin(username):
bool: True if user was demoted successfully
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one({'Username': username}, {'$set': {'Admin': False}})
result = users.update_one({'Username': username}, {'$set': {'Admin': False}})
if result.matched_count == 0:
result = users.update_one({'username': username}, {'$set': {'Admin': False}})
client.close()
return True
return result.matched_count > 0
def get_user(username):
"""
@@ -564,11 +671,33 @@ def get_user(username):
dict: User document or None if not found
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
users_return = users.find_one({'Username': username})
client.close()
return users_return
try:
def find_in_db(database_name):
db = client[database_name]
users = db['users']
return users.find_one({'Username': username}) or users.find_one({'username': username})
# Try current tenant first when available
tenant_db, tenant_id = _resolve_request_tenant_db()
if tenant_db:
user = find_in_db(tenant_db)
if user:
return user
if _has_tenant_configs() and tenant_db is None:
logger.warning(
"Refusing default DB fallback for user lookup because tenant configs exist and no tenant was resolved."
)
return None
# Fallback to default configured database
user = find_in_db(cfg.MONGODB_DB)
if user:
return user
return None
finally:
client.close()
def check_admin(username):
@@ -581,12 +710,8 @@ def check_admin(username):
Returns:
bool: True if user is an administrator, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
users = db['users']
user = users.find_one({'Username': username})
client.close()
return user and user.get('Admin', False)
user = get_user(username)
return bool(user and user.get('Admin', False))
def update_active_ausleihung(username, id_item, ausleihung):
@@ -602,7 +727,7 @@ def update_active_ausleihung(username, id_item, ausleihung):
bool: True if successful
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
users.update_one({'Username': username}, {'$set': {'active_ausleihung': {'Item': id_item, 'Ausleihung': ausleihung}}})
client.close()
@@ -620,7 +745,7 @@ def get_active_ausleihung(username):
dict: Active borrowing information or None
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
return user['active_ausleihung']
@@ -638,7 +763,7 @@ def has_active_borrowing(username):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'username': username})
@@ -669,14 +794,14 @@ def delete_user(username):
bool: True if user was deleted successfully, False otherwise
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'username': username})
client.close()
if result.deleted_count == 0:
# Try with different field name
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.delete_one({'Username': username})
client.close()
@@ -698,7 +823,7 @@ def update_active_borrowing(username, item_id, status):
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.update_one(
{'username': username},
@@ -731,7 +856,7 @@ def get_name(username):
str: String of name
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
name = user.get("name")
@@ -746,7 +871,7 @@ def get_last_name(username):
str: String of last_name
"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
user = users.find_one({'Username': username})
name = user.get("last_name")
@@ -763,7 +888,7 @@ def get_all_users():
"""
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
all_users = list(users.find())
client.close()
@@ -787,7 +912,7 @@ def update_password(username, new_password):
return False
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
# Hash the new password
@@ -820,7 +945,7 @@ def update_user_name(username, name, last_name):
try:
name_alias = build_name_synonym(name, last_name)
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
db = _get_tenant_db(client)
users = db['users']
result = users.update_one(
+1
View File
@@ -0,0 +1 @@
# Web.modules.emailservice package initialization
+47
View File
@@ -0,0 +1,47 @@
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import smtplib
import Web.modules.database.settings as cfg
def _build_smtp_client():
smtp = smtplib.SMTP(cfg.EMAIL_SMTP_HOST, cfg.EMAIL_SMTP_PORT, timeout=cfg.EMAIL_TIMEOUT_SECONDS)
smtp.ehlo()
if cfg.EMAIL_USE_TLS:
smtp.starttls()
smtp.ehlo()
if cfg.EMAIL_USERNAME:
smtp.login(cfg.EMAIL_USERNAME, cfg.EMAIL_PASSWORD or '')
return smtp
def send(email: list, subject: str, note: str, sender: str) -> bool:
"""
Sends the email with the link to the Clients
Input:
- email: Email list of all the addresses to send the link to ["","",""]
- subject: Subject of the email
- note: Note that is send with the Emails
Output:
- bool: true if the sending worked and false if it didnt
"""
msg = MIMEMultipart()
msg['Subject'] = subject
msg['From'] = sender or cfg.EMAIL_FROM_ADDRESS or cfg.EMAIL_USERNAME
msg['To'] = ', '.join(email) if isinstance(email, (list, tuple)) else str(email)
msg.attach(MIMEText(note))
smtp = None
try:
smtp = _build_smtp_client()
smtp.sendmail(from_addr=msg['From'], to_addrs=email, msg=msg.as_string())
return True
except Exception:
return False
finally:
try:
if smtp:
smtp.quit()
except Exception:
pass
+6
View File
@@ -0,0 +1,6 @@
"""
Inventar System Funktionen
"""
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
import argparse
import csv
import datetime
import json
import os
import sys
try:
from bson import json_util
except ImportError:
json_util = None
try:
from pymongo import MongoClient
except ImportError:
print('Error: pymongo is required to run invoice backups.', file=sys.stderr)
sys.exit(1)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, '..', 'config.json'))
DEFAULT_ARCHIVE_DIR = '/var/backups/invoice-archive'
DEFAULT_KEEP_DAYS = 3650
def load_config():
config = {}
try:
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
config = json.load(f)
except Exception:
pass
return config
def resolve_mongo_settings(args):
config = load_config()
mongodb = config.get('mongodb', {}) if isinstance(config, dict) else {}
host = os.getenv('INVENTAR_MONGODB_HOST') or args.mongo_host or mongodb.get('host') or 'localhost'
port = os.getenv('INVENTAR_MONGODB_PORT') or args.mongo_port or mongodb.get('port') or 27017
db_name = os.getenv('INVENTAR_MONGODB_DB') or args.db_name or mongodb.get('db') or 'Inventarsystem'
uri = args.mongo_uri
if isinstance(port, str) and port.strip().isdigit():
port = int(port.strip())
return host, int(port), db_name, uri
def format_csv_value(value):
if value is None:
return ''
if isinstance(value, bool):
return 'true' if value else 'false'
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, datetime.datetime):
return value.isoformat()
if isinstance(value, (list, dict)):
return json.dumps(value, ensure_ascii=False)
return str(value)
def normalize_doc_for_json(doc):
if json_util is not None:
return json.loads(json_util.dumps(doc, default=json_util.default))
return doc
def build_csv_row(document):
invoice_data = document.get('InvoiceData') or {}
corrections = document.get('InvoiceCorrections') or []
return {
'invoice_number': invoice_data.get('invoice_number', ''),
'borrow_id': str(document.get('_id', '')),
'status_before_invoice': invoice_data.get('status_before_invoice', '') or document.get('Status', ''),
'borrower': document.get('User', '') or invoice_data.get('borrower', ''),
'item': document.get('Item', ''),
'amount': invoice_data.get('amount', ''),
'currency': invoice_data.get('currency', 'EUR'),
'created_at': format_csv_value(invoice_data.get('created_at')),
'paid': invoice_data.get('paid', False),
'paid_at': format_csv_value(invoice_data.get('paid_at')),
'invoice_reason': invoice_data.get('damage_reason', ''),
'corrections_count': len(corrections) if isinstance(corrections, list) else 0,
'corrections': json.dumps(corrections, ensure_ascii=False) if corrections else '',
}
def write_jsonl(path, cursor):
with open(path, 'w', encoding='utf-8') as f:
for document in cursor:
if json_util is not None:
line = json_util.dumps(document, default=json_util.default)
else:
line = json.dumps(document, default=str, ensure_ascii=False)
f.write(line + '\n')
def write_csv(path, cursor):
fieldnames = [
'invoice_number',
'borrow_id',
'status_before_invoice',
'borrower',
'item',
'amount',
'currency',
'created_at',
'paid',
'paid_at',
'invoice_reason',
'corrections_count',
'corrections',
]
with open(path, 'w', encoding='utf-8', newline='') as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for document in cursor:
writer.writerow({k: format_csv_value(v) for k, v in build_csv_row(document).items()})
def write_meta(path, metadata):
with open(path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
def parse_args():
parser = argparse.ArgumentParser(description='Create a legal invoice archive backup from the MongoDB invoice records.')
parser.add_argument('--archive-dir', required=True, help='Directory to write archive files into')
parser.add_argument('--base-name', default=None, help='Base filename prefix for archive files')
parser.add_argument('--mongo-host', default=None, help='MongoDB host override')
parser.add_argument('--mongo-port', type=int, default=None, help='MongoDB port override')
parser.add_argument('--db-name', default=None, help='MongoDB database name override')
parser.add_argument('--mongo-uri', default=None, help='MongoDB connection URI override')
return parser.parse_args()
def main():
args = parse_args()
archive_dir = os.path.abspath(args.archive_dir)
os.makedirs(archive_dir, exist_ok=True)
base_name = args.base_name or f'invoices-{datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
jsonl_path = os.path.join(archive_dir, f'{base_name}.jsonl')
csv_path = os.path.join(archive_dir, f'{base_name}.csv')
meta_path = os.path.join(archive_dir, f'{base_name}.meta.json')
host, port, db_name, uri = resolve_mongo_settings(args)
if uri:
client = MongoClient(uri)
else:
client = MongoClient(host, port)
try:
db = client[db_name]
collection = db['ausleihungen']
query = {'InvoiceData.invoice_number': {'$exists': True, '$ne': ''}}
projection = {'InvoiceData': 1, 'InvoiceCorrections': 1, 'User': 1, 'Item': 1, 'Status': 1}
cursor = collection.find(query, projection)
docs = list(cursor)
if not docs:
print('No invoice records found. No archive written.')
return 0
write_jsonl(jsonl_path, docs)
write_csv(csv_path, docs)
metadata = {
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'invoice_count': len(docs),
'archive_files': [os.path.basename(jsonl_path), os.path.basename(csv_path), os.path.basename(meta_path)],
'mongo_db': db_name,
'mongo_host': host,
'mongo_port': port,
'query': query,
}
write_meta(meta_path, metadata)
print(f'Wrote invoice archive: {jsonl_path}')
print(f'Wrote invoice archive CSV: {csv_path}')
print(f'Wrote metadata: {meta_path}')
return 0
finally:
client.close()
if __name__ == '__main__':
sys.exit(main())
@@ -10,7 +10,7 @@ from datetime import datetime
from cryptography.fernet import Fernet, InvalidToken
import settings as cfg
import Web.modules.database.settings as cfg
_ENC_PREFIX = "enc::"
@@ -0,0 +1,56 @@
import io
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
def generate_library_excel(items):
wb = Workbook()
ws = wb.active
ws.title = "Export"
headers = [
"Code", "Titel", "Autor", "Typ", "ISBN/Code",
"Filter 1", "Filter 2", "Filter 3",
"Status", "Ausgeliehen von", "Rückgabe", "Kosten"
]
ws.append(headers)
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="1F2937", end_color="1F2937", fill_type="solid")
for cell in ws[1]:
cell.font = header_font
cell.fill = header_fill
for item in items:
status = "Verfuegbar" if str(item.get("Verfuegbar", "True")).lower() == "true" else "Ausgeliehen"
row = [
item.get("Code_4", ""),
item.get("Name", ""),
item.get("Author", ""),
item.get("ItemType", ""),
item.get("ISBN", ""),
item.get("Filter", ""),
item.get("Filter2", ""),
item.get("Filter3", ""),
status,
item.get("User", ""),
item.get("ReturnDate", ""),
item.get("Anschaffungskosten", "")
]
ws.append(row)
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
ws.column_dimensions[column].width = min(max_length + 2, 50)
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
return excel_buffer
@@ -6,7 +6,7 @@
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
'''
import user
import Web.modules.database.user as user
import sys
import getpass
import re
+790
View File
@@ -0,0 +1,790 @@
"""
PDF Export module for audit reports following DIN 5008 standard and German authority requirements.
Ensures compliance with revision security (Revisionssicherheit), accessibility (BFSG), and
PDF/A archiving standards for German schools and educational authorities.
"""
import io
import json
import datetime
import os
import qrcode
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm, mm
from reportlab.lib.colors import HexColor, grey, black, red
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak, Image
from reportlab.pdfgen import canvas
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import Web.modules.database.settings as cfg
__version__ = cfg.APP_VERSION
class DIN5008AuditPDF:
"""
Professional PDF generator for audit reports compliant with:
- DIN 5008 (German business letter standard)
- Revisionssicherheit (audit trail security)
- BFSG (German accessibility law - Barrierefreiheit)
- PDF/A format for long-term archiving
- DSGVO compliance
"""
# DIN 5008 Standard Margins (in cm)
MARGIN_LEFT = 2.5 # Binding margin
MARGIN_RIGHT = 1.5
MARGIN_TOP = 4.5 # Letterhead area
MARGIN_BOTTOM = 2.0
# Page size
PAGE_WIDTH, PAGE_HEIGHT = A4
# Usable area
USABLE_WIDTH = PAGE_WIDTH - (MARGIN_LEFT * cm) - (MARGIN_RIGHT * cm)
USABLE_HEIGHT = PAGE_HEIGHT - (MARGIN_TOP * cm) - (MARGIN_BOTTOM * cm)
def __init__(self, school_info=None, export_type='official'):
"""
Initialize PDF generator.
Args:
school_info (dict): School information {name, address, city, postal_code, school_number, logo_path}
export_type (str): 'official' for full DIN 5008 report or 'quick' for compact version
"""
self.school_info = school_info or {}
self.export_type = export_type
self.created_timestamp = datetime.datetime.now()
self.created_timestamp_iso = self.created_timestamp.isoformat()
self.current_page = 1
self.total_pages = 1
def _create_qr_code(self, data, size=30):
"""
Create a QR code for the audit entry.
Args:
data (str): Data to encode in QR code
size (int): Size in pixels
Returns:
Image: PIL Image object
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=4,
border=1,
)
qr.add_data(data)
qr.make(fit=True)
return qr.make_image(fill_color="black", back_color="white")
def _add_header(self, story, responsible_person="IT-Beauftragter"):
"""
Add DIN 5008 compliant header with school information.
Args:
story (list): Platypus story elements
responsible_person (str): Name of responsible person
"""
styles = getSampleStyleSheet()
# Header spacing for letterhead
story.append(Spacer(1, 3 * cm))
# School information block (left)
school_name = self.school_info.get('name', 'Schulname')
address = self.school_info.get('address', 'Adresse')
postal_code = self.school_info.get('postal_code', 'PLZ')
city = self.school_info.get('city', 'Stadt')
school_number = self.school_info.get('school_number', 'Schulnummer')
header_style = ParagraphStyle(
'CustomHeader',
parent=styles['Normal'],
fontSize=10,
leading=12,
fontName='Helvetica',
textColor=HexColor('#000000'),
)
logo_path = self.school_info.get('logo_path', '')
resolved_logo_path = None
if logo_path:
candidate_paths = [
logo_path,
os.path.join(cfg.UPLOAD_FOLDER, logo_path),
os.path.join('/opt/Inventarsystem/Web/uploads', logo_path),
os.path.join('/var/Inventarsystem/Web/uploads', logo_path),
]
for candidate_path in candidate_paths:
if candidate_path and os.path.exists(candidate_path):
resolved_logo_path = candidate_path
break
school_info_text = f"""
<b>{school_name}</b><br/>
{address}<br/>
{postal_code} {city}<br/>
<i>Schulnummer: {school_number}</i>
"""
if resolved_logo_path:
logo_image = Image(resolved_logo_path)
try:
logo_image._restrictSize(3.4 * cm, 3.4 * cm)
except Exception:
pass
school_table = Table(
[[logo_image, Paragraph(school_info_text, header_style)]],
colWidths=[3.8 * cm, self.USABLE_WIDTH - 3.8 * cm],
)
school_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('TOPPADDING', (0, 0), (-1, -1), 0),
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
]))
story.append(school_table)
else:
story.append(Paragraph(school_info_text, header_style))
# Information block (right side simulation)
story.append(Spacer(1, 0.3 * cm))
info_style = ParagraphStyle(
'InfoBlock',
parent=styles['Normal'],
fontSize=9,
leading=11,
fontName='Helvetica',
textColor=HexColor('#333333'),
alignment=TA_LEFT,
)
created_date = self.created_timestamp.strftime('%Y-%m-%d')
created_time = self.created_timestamp.strftime('%H:%M:%S')
info_text = f"""
<b>Bericht-Informationen:</b><br/>
Erstellungsdatum: {created_date}<br/>
Uhrzeit: {created_time}<br/>
Verantwortliche Person: {responsible_person}<br/>
System: Invario v{__version__}
"""
story.append(Paragraph(info_text, info_style))
story.append(Spacer(1, 0.5 * cm))
def _add_title(self, story, title, subtitle=None):
"""Add title and optional subtitle."""
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=16,
leading=20,
fontName='Helvetica-Bold',
textColor=HexColor('#1a1a1a'),
spaceAfter=12,
alignment=TA_LEFT,
)
story.append(Paragraph(f"<b>{title}</b>", title_style))
if subtitle:
subtitle_style = ParagraphStyle(
'Subtitle',
parent=styles['Normal'],
fontSize=11,
leading=13,
fontName='Helvetica-Oblique',
textColor=HexColor('#555555'),
spaceAfter=12,
alignment=TA_LEFT,
)
story.append(Paragraph(subtitle, subtitle_style))
story.append(Spacer(1, 0.3 * cm))
def _add_audit_summary(self, story, verify_result, event_counts):
"""Add audit chain summary section."""
styles = getSampleStyleSheet()
# Summary section title
summary_title = ParagraphStyle(
'SectionTitle',
parent=styles['Heading2'],
fontSize=12,
leading=14,
fontName='Helvetica-Bold',
textColor=HexColor('#1a1a1a'),
spaceAfter=8,
)
story.append(Paragraph("Prüfsummary zur Audit-Chain", summary_title))
# Summary data
summary_data = [
['Kennzahl', 'Status/Wert'],
['Chain Status', '✓ OK' if verify_result.get('ok') else '✗ FEHLER'],
['Gesamtzahl Einträge', str(verify_result.get('count', 0))],
['Letzter Chain Index', str(verify_result.get('last_chain_index', 0))],
['Integritätsabweichungen', str(len(verify_result.get('mismatches', []) or []))],
]
# Add event counts
if event_counts:
story.append(Spacer(1, 0.1 * cm))
story.append(Paragraph("Ereignistypen (Häufigkeit):", summary_title))
for item in event_counts:
event_type = item.get('event_type', 'unknown')
count = item.get('count', 0)
summary_data.append([f" {event_type}", str(count)])
summary_table = Table(summary_data, colWidths=[6*cm, 8*cm])
summary_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), HexColor('#e8f4f8')),
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#1a1a1a')),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('FONTSIZE', (0, 1), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, 0), 8),
('BACKGROUND', (0, 1), (-1, -1), HexColor('#f9fafb')),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#d1d5db')),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ffffff'), HexColor('#f3f4f6')]),
]))
story.append(summary_table)
story.append(Spacer(1, 0.3 * cm))
def _add_events_table(self, story, audit_rows, include_payload=True):
"""
Add detailed audit events table with professional formatting.
Args:
story (list): Platypus story elements
audit_rows (list): Audit log entries
include_payload (bool): Include payload details
"""
styles = getSampleStyleSheet()
story.append(Paragraph("Detaillierte Audit-Ereignisse",
ParagraphStyle(
'SectionTitle',
parent=styles['Heading2'],
fontSize=12,
fontName='Helvetica-Bold',
spaceAfter=8,
)))
# Build table data with wrapped cells for better readability on A4 pages
cell_style = ParagraphStyle(
'EventCell',
parent=styles['Normal'],
fontName='Helvetica',
fontSize=8,
leading=9.5,
textColor=HexColor('#1f2937'),
alignment=TA_LEFT,
)
hash_style = ParagraphStyle(
'EventHashCell',
parent=cell_style,
fontName='Courier',
fontSize=7.2,
leading=9,
wordWrap='CJK',
)
def _safe(value):
return str(value or '').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def _fmt_ts(value):
text = _safe(value)
if len(text) >= 19 and text[4] == '-' and text[7] == '-':
# Convert 2026-05-10 16:19:07... -> 10.05.2026 16:19
return f"{text[8:10]}.{text[5:7]}.{text[0:4]} {text[11:16]}"
return text[:16]
def _fmt_event(value):
text = _safe(value).replace('_', ' ').strip()
return text[:60]
def _chunk_text(value, chunk=4, sep=' '):
text = _safe(value)
if not text:
return ''
return sep.join(text[i:i + chunk] for i in range(0, len(text), chunk))
def _fmt_ip(value):
text = _safe(value)
# Keep IPv4 intact. For long IPv6 values insert only one line break in the middle.
if ':' in text and len(text) > 24:
parts = text.split(':')
if len(parts) > 4:
return ':'.join(parts[:4]) + ':<br/>' + ':'.join(parts[4:])
return text
# Build table data
if self.export_type == 'quick':
# Quick-Check: Minimal columns
table_data = [
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Hash (gekürzt)'],
]
for row in audit_rows[:20]: # Limit to 20 rows for quick check
chain_idx = _safe(row.get('chain_index', ''))
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
event_type = _fmt_event(row.get('event_type', ''))
actor = _safe(row.get('actor', ''))
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:20], chunk=4)
if entry_hash:
entry_hash += ' ...'
table_data.append([
Paragraph(chain_idx, cell_style),
Paragraph(timestamp, cell_style),
Paragraph(event_type, cell_style),
Paragraph(actor, cell_style),
Paragraph(entry_hash, hash_style),
])
colWidths = [1.1*cm, 2.7*cm, 4.8*cm, 3.1*cm, 4.9*cm]
else:
# Official Report: Full columns
table_data = [
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Quelle', 'IP', 'Hash'],
]
for row in audit_rows:
chain_idx = _safe(row.get('chain_index', ''))
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
event_type = _fmt_event(row.get('event_type', ''))
actor = _safe(row.get('actor', ''))
source = _safe(row.get('source', 'System'))
ip = _fmt_ip(row.get('ip', ''))
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:40], chunk=8)
table_data.append([
Paragraph(chain_idx, cell_style),
Paragraph(timestamp, cell_style),
Paragraph(event_type, cell_style),
Paragraph(actor, cell_style),
Paragraph(source, cell_style),
Paragraph(ip, cell_style),
Paragraph(entry_hash, hash_style),
])
# Give IP and hash columns significantly more room for readability.
colWidths = [0.9*cm, 2.4*cm, 2.8*cm, 2.0*cm, 1.4*cm, 3.3*cm, 4.2*cm]
# Create table
events_table = Table(table_data, colWidths=colWidths, repeatRows=1)
events_table.setStyle(TableStyle([
# Header styling
('BACKGROUND', (0, 0), (-1, 0), HexColor('#2c3e50')),
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#ffffff')),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 8.5),
('TOPPADDING', (0, 0), (-1, 0), 6),
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
# Body styling
('FONTSIZE', (0, 1), (-1, -1), 8),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bdc3c7')),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ecf0f1'), HexColor('#ffffff')]),
('TOPPADDING', (0, 1), (-1, -1), 5),
('BOTTOMPADDING', (0, 1), (-1, -1), 5),
('LEFTPADDING', (0, 1), (-1, -1), 4),
('RIGHTPADDING', (0, 1), (-1, -1), 4),
('ALIGN', (0, 1), (0, -1), 'CENTER'),
('ALIGN', (1, 1), (1, -1), 'CENTER'),
('ALIGN', (-1, 1), (-1, -1), 'LEFT'),
]))
story.append(events_table)
story.append(Paragraph(
"Hinweis: Zeitangaben sind auf Minuten gerundet; Hashwerte werden aus Platzgründen gekürzt dargestellt.",
ParagraphStyle(
'TableHint',
parent=styles['Normal'],
fontSize=7.5,
leading=9,
textColor=HexColor('#6b7280'),
alignment=TA_LEFT,
spaceBefore=4,
)
))
story.append(Spacer(1, 0.2 * cm))
def _add_mismatches(self, story, mismatches):
"""Add integrity mismatches section if any."""
if not mismatches:
return
styles = getSampleStyleSheet()
story.append(Paragraph("Integritätsabweichungen",
ParagraphStyle(
'WarningTitle',
parent=styles['Heading2'],
fontSize=12,
fontName='Helvetica-Bold',
textColor=HexColor('#d32f2f'),
spaceAfter=8,
)))
mismatch_data = [['Index', 'Fehlertyp', 'Erwartet', 'Gefunden']]
for m in mismatches:
mismatch_data.append([
str(m.get('chain_index', '')),
str(m.get('error', '')),
str(m.get('expected', ''))[:30],
str(m.get('found', ''))[:30],
])
mismatch_table = Table(mismatch_data, colWidths=[1.5*cm, 3*cm, 5*cm, 5*cm])
mismatch_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), HexColor('#ffebee')),
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#c62828')),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#f44336')),
('BACKGROUND', (0, 1), (-1, -1), HexColor('#fdeaea')),
]))
story.append(mismatch_table)
story.append(Spacer(1, 0.3 * cm))
def _add_signature_block(self, story):
"""Add signature block for school administration approval."""
styles = getSampleStyleSheet()
story.append(Spacer(1, 0.5 * cm))
story.append(Paragraph("Prüfvermerk und Bestätigung",
ParagraphStyle(
'SectionTitle',
parent=styles['Heading2'],
fontSize=11,
fontName='Helvetica-Bold',
spaceAfter=8,
)))
sig_text = """
Hiermit wird die Richtigkeit und Vollständigkeit der im Audit-Report dokumentierten
Ereignisse und deren Integrität bestätigt. Dieses Dokument wurde revisionssicher erstellt
und archiviert.
"""
story.append(Paragraph(sig_text,
ParagraphStyle(
'SigText',
parent=styles['Normal'],
fontSize=9,
leading=11,
alignment=TA_JUSTIFY,
spaceAfter=12,
)))
# Signature lines
sig_data = [
['Schulleitung', '', 'IT-Beauftragter'],
['', '', ''],
['_' * 35, '', '_' * 35],
['Unterschrift / Datum', '', 'Unterschrift / Datum'],
]
sig_table = Table(sig_data, colWidths=[5*cm, 2*cm, 5*cm])
sig_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('FONTSIZE', (0, 0), (-1, 0), 9),
('FONTSIZE', (0, 3), (-1, 3), 8),
('BOTTOMPADDING', (0, 0), (-1, 0), 2),
]))
story.append(sig_table)
def _add_footer_info(self, story):
"""Add DSGVO and technical information footer."""
styles = getSampleStyleSheet()
story.append(Spacer(1, 0.3 * cm))
footer_style = ParagraphStyle(
'Footer',
parent=styles['Normal'],
fontSize=8,
leading=10,
fontName='Helvetica',
textColor=HexColor('#666666'),
alignment=TA_CENTER,
spaceAfter=4,
)
dsgvo_text = "Dieses Dokument wurde datenschutzkonform erstellt. Speicherung auf zertifizierten Servern in Deutschland."
tech_text = f"Generiert am {self.created_timestamp.strftime('%d.%m.%Y um %H:%M:%S')} durch System Invario (PDF/A-Format, revisionssicher)"
story.append(Paragraph(dsgvo_text, footer_style))
story.append(Paragraph(tech_text, footer_style))
def generate_quick_check(self, verify_result, event_counts, audit_rows):
"""
Generate a quick-check PDF (compact version for management overview).
Returns:
bytes: PDF content
"""
output = io.BytesIO()
story = []
# Header
self._add_header(story, "Verwaltung")
# Title
self._add_title(story,
"Audit-Report: Schnell-Check",
f"Überblick zum {self.created_timestamp.strftime('%d.%m.%Y')}")
# Summary
self._add_audit_summary(story, verify_result, event_counts)
# Events table (limited)
self._add_events_table(story, audit_rows, include_payload=False)
# Mismatches if any
mismatches = verify_result.get('mismatches', []) or []
if mismatches:
self._add_mismatches(story, mismatches)
# Footer
self._add_footer_info(story)
# Build PDF
doc = SimpleDocTemplate(
output,
pagesize=A4,
topMargin=self.MARGIN_TOP * cm,
bottomMargin=self.MARGIN_BOTTOM * cm,
leftMargin=self.MARGIN_LEFT * cm,
rightMargin=self.MARGIN_RIGHT * cm,
title="Audit Quick-Check Report",
author="Invario System",
subject="Audit Report - Quick Check",
creator="Invario",
)
doc.build(story)
output.seek(0)
return output.getvalue()
def generate_official_report(self, verify_result, event_counts, audit_rows):
"""
Generate a full official audit report (DIN 5008 compliant for authorities).
Returns:
bytes: PDF content
"""
output = io.BytesIO()
story = []
# Header
self._add_header(story, self.school_info.get('it_admin', 'IT-Beauftragter'))
# Title
reporting_date = self.created_timestamp.strftime('%d.%m.%Y')
self._add_title(story,
"Audit-Protokoll",
f"Revisonssicheres Audit-Log - Berichtsstand: {reporting_date}")
# Summary
self._add_audit_summary(story, verify_result, event_counts)
# Mismatches section (prominently)
mismatches = verify_result.get('mismatches', []) or []
if mismatches:
self._add_mismatches(story, mismatches)
# Full events table
self._add_events_table(story, audit_rows, include_payload=True)
# Page break for signature section
story.append(PageBreak())
# Signature block
self._add_signature_block(story)
# Footer
self._add_footer_info(story)
# Build PDF
doc = SimpleDocTemplate(
output,
pagesize=A4,
topMargin=self.MARGIN_TOP * cm,
bottomMargin=self.MARGIN_BOTTOM * cm,
leftMargin=self.MARGIN_LEFT * cm,
rightMargin=self.MARGIN_RIGHT * cm,
title="Audit Official Report",
author="Invario System",
subject="Offizielle Audit-Bericht (DIN 5008)",
creator="Invario",
)
doc.build(story)
output.seek(0)
return output.getvalue()
def generate_audit_pdf(verify_result, event_counts, audit_rows, export_type='official', school_info=None):
"""
Convenience function to generate audit PDFs.
Args:
verify_result (dict): Verification result from audit chain
event_counts (list): Event count statistics
audit_rows (list): Audit log entries
export_type (str): 'official' or 'quick'
school_info (dict): School information
Returns:
bytes: PDF content
"""
pdf_gen = DIN5008AuditPDF(school_info=school_info, export_type=export_type)
if export_type == 'quick':
return pdf_gen.generate_quick_check(verify_result, event_counts, audit_rows)
else:
return pdf_gen.generate_official_report(verify_result, event_counts, audit_rows)
def _build_invoice_pdf(invoice_data):
"""Render a PDF invoice for a damaged borrowed item."""
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.colors import HexColor, black, white
from reportlab.lib.utils import simpleSplit
from reportlab.pdfgen import canvas
pdf_buffer = io.BytesIO()
c = canvas.Canvas(pdf_buffer, pagesize=A4)
page_width, page_height = A4
margin_x = 20 * mm
margin_top = 20 * mm
usable_width = page_width - (2 * margin_x)
current_y = page_height - margin_top
dark_color = HexColor('#0F172A')
accent_color = HexColor('#B91C1C')
light_color = HexColor('#F8FAFC')
border_color = HexColor('#CBD5E1')
text_color = HexColor('#1E293B')
muted_color = HexColor('#64748B')
def draw_wrapped_lines(text, x_pos, y_pos, width, font_name='Helvetica', font_size=11, leading=14, color=text_color):
if not text:
return y_pos
c.setFont(font_name, font_size)
c.setFillColor(color)
for line in simpleSplit(str(text), font_name, font_size, width):
c.drawString(x_pos, y_pos, line)
y_pos -= leading
return y_pos
def draw_label_value(label, value, x_pos, y_pos, label_width=45 * mm):
c.setFont('Helvetica-Bold', 10)
c.setFillColor(muted_color)
c.drawString(x_pos, y_pos, label)
c.setFont('Helvetica', 10)
c.setFillColor(text_color)
c.drawString(x_pos + label_width, y_pos, str(value or '-'))
return y_pos - 7 * mm
c.setFillColor(light_color)
c.rect(0, 0, page_width, page_height, fill=1, stroke=0)
c.setFillColor(dark_color)
c.rect(0, page_height - 28 * mm, page_width, 28 * mm, fill=1, stroke=0)
c.setFillColor(white)
c.setFont('Helvetica-Bold', 20)
c.drawString(margin_x, page_height - 16 * mm, 'RECHNUNG')
c.setFont('Helvetica', 10)
c.drawString(margin_x, page_height - 23 * mm, 'Inventarsystem - Schadensersatz für zerstörtes Ausleihobjekt')
current_y = page_height - 40 * mm
c.setStrokeColor(border_color)
c.setLineWidth(1)
c.line(margin_x, current_y, page_width - margin_x, current_y)
current_y -= 12 * mm
invoice_number = invoice_data.get('invoice_number', '-')
created_at = invoice_data.get('created_at_display', '-')
borrower = invoice_data.get('borrower', '-')
item_name = invoice_data.get('item_name', '-')
item_code = invoice_data.get('item_code', '-')
item_id = invoice_data.get('item_id', '-')
damage_reason = invoice_data.get('damage_reason', '-')
amount_text = invoice_data.get('amount_text', '-')
current_y = draw_label_value('Rechnungsnummer:', invoice_number, margin_x, current_y)
current_y = draw_label_value('Datum:', created_at, margin_x, current_y)
current_y = draw_label_value('Empfänger:', borrower, margin_x, current_y)
current_y = draw_label_value('Ausleihe / Element:', item_name, margin_x, current_y)
current_y = draw_label_value('Element-ID:', item_id, margin_x, current_y)
current_y = draw_label_value('Code:', item_code, margin_x, current_y)
current_y = current_y - 3 * mm
c.setFillColor(dark_color)
c.setFont('Helvetica-Bold', 12)
c.drawString(margin_x, current_y, 'Schadensbeschreibung')
current_y -= 6 * mm
current_y = draw_wrapped_lines(damage_reason, margin_x, current_y, usable_width, font_size=10, leading=13, color=text_color)
current_y -= 4 * mm
c.setFillColor(dark_color)
c.setFont('Helvetica-Bold', 12)
c.drawString(margin_x, current_y, 'Rechnungsbetrag')
current_y -= 8 * mm
c.setFillColor(accent_color)
c.setStrokeColor(accent_color)
c.rect(margin_x, current_y - 12 * mm, usable_width, 16 * mm, fill=1, stroke=0)
c.setFillColor(white)
c.setFont('Helvetica-Bold', 16)
c.drawString(margin_x + 5 * mm, current_y - 2 * mm, amount_text)
current_y -= 20 * mm
current_y = draw_wrapped_lines(
'Bitte begleichen Sie diesen Betrag zeitnah bei der Verwaltung. Der Betrag ergibt sich aus dem zerstörten Ausleihobjekt und der dokumentierten Schadensmeldung.',
margin_x,
current_y,
usable_width,
font_size=10,
leading=13,
color=muted_color,
)
footer_y = 18 * mm
c.setStrokeColor(border_color)
c.setLineWidth(0.8)
c.line(margin_x, footer_y + 8 * mm, page_width - margin_x, footer_y + 8 * mm)
c.setFillColor(muted_color)
c.setFont('Helvetica', 9)
c.drawString(margin_x, footer_y, 'Inventarsystem - Rechnungserstellung')
c.drawRightString(page_width - margin_x, footer_y, f'{amount_text}')
c.save()
pdf_buffer.seek(0)
return pdf_buffer
+1
View File
@@ -0,0 +1 @@
# Web.modules.log package initialization
+26
View File
@@ -0,0 +1,26 @@
from typing import Dict, Any
class ModuleRegistry:
def __init__(self):
self._modules: Dict[str, Any] = {}
self._path_matchers = {}
def register(self, name: str, settings_bool, path_matcher=lambda path: False):
self._modules[name] = settings_bool
self._path_matchers[name] = path_matcher
def is_enabled(self, name: str) -> bool:
if name not in self._modules:
return False
return bool(self._modules[name])
def get_all_status(self) -> Dict[str, bool]:
return {name: bool(sys_bool) for name, sys_bool in self._modules.items()}
def get_module_for_path(self, path: str) -> str:
for name, matcher in self._path_matchers.items():
if self.is_enabled(name) and matcher(path):
return name
return None
registry = ModuleRegistry()
+1
View File
@@ -0,0 +1 @@
# Web.modules package initialization
+217
View File
@@ -0,0 +1,217 @@
"""
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 _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 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
"""
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)
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}"
if normalized_mail:
mail_service.send(normalized_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
"""
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)
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)
slots_left = max(0, total_slots - 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)
+96
View File
@@ -0,0 +1,96 @@
from flask import Blueprint, render_template, request, session, url_for, redirect, flash
import Web.modules.terminplaner.backend_server as appointment_service
import Web.modules.database.settings as cfg
# 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
@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:
flash('Der Termin wurde nicht gefunden.', 'error')
return redirect(url_for('terminplan'))
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,
current_user=session.get('username', ''),
)
if appointment_service.book_slot(appointment_id, start_daytime, username):
flash('Der Termin wurde gespeichert.', 'success')
return redirect(url_for('terminplaner.client', appointment_id=appointment_id))
flash('Der Termin konnte nicht gespeichert werden.', 'error')
return render_template(
'termin_client.html',
appointment_id=appointment_id,
available=available,
current_user=session.get('username', ''),
)
@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', '')
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)
link = appointment_service.new(start, end, time, slots_amount, slot_lenght, session["username"], mail, note)
flash('Der Terminplan wurde angelegt.', 'success')
return render_template('termin_configure.html', school_periods=cfg.SCHOOL_PERIODS, generated_link=link)
elif request.method == "GET":
return render_template('termin_configure.html', school_periods=cfg.SCHOOL_PERIODS, generated_link=None)
@appoint_bp.route('/')
def main():
guard = _require_module_enabled()
if guard:
return guard
return render_template(
'terminplaner.html',
school_periods=cfg.SCHOOL_PERIODS,
current_user=session.get('username', ''),
)
+2 -2
View File
@@ -6,13 +6,13 @@ Handles Web Push notifications for users via Service Workers
import os
import json
import datetime
from pymongo import MongoClient
from bson import ObjectId
import requests
import hashlib
import logging
import settings as cfg
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
logger = logging.getLogger(__name__)
+2 -2
View File
@@ -13,6 +13,6 @@ redis
reportlab
python-barcode
openpyxl
cryptography
cryptography>=42.0.0
pywebpush
py-vapid==1.9.0
py-vapid>=1.9.0
+8 -8
View File
@@ -10,13 +10,13 @@
.planned-appointments-panel {
margin: 15px 0;
padding: 15px;
background-color: #e1f5fe;
background-color: var(--ui-surface-soft, #e1f5fe);
border-left: 4px solid #03a9f4;
border-radius: 4px;
}
.planned-appointments-panel h4 {
color: #0277bd;
color: var(--ui-title, #0277bd);
margin-top: 0;
margin-bottom: 10px;
font-size: 1.1em;
@@ -30,9 +30,9 @@
.appointment-item {
padding: 10px;
background-color: #f9fdff;
background-color: var(--ui-surface, #f9fdff);
border-radius: 4px;
border: 1px solid #b3e5fc;
border: 1px solid var(--ui-border, #b3e5fc);
}
.appointment-header {
@@ -44,7 +44,7 @@
.appointment-date {
font-weight: bold;
color: #0288d1;
color: var(--ui-title, #0288d1);
}
.appointment-period {
@@ -86,14 +86,14 @@
.appointment-user {
margin-left: auto;
font-style: italic;
color: #546e7a;
color: var(--ui-text-muted, #546e7a);
}
.appointment-notes {
font-size: 0.9em;
color: #455a64;
color: var(--ui-text, #455a64);
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid #e1f5fe;
border-top: 1px solid var(--ui-border, #e1f5fe);
font-style: italic;
}
+116 -10
View File
@@ -22,16 +22,21 @@
}
:root[data-theme="dark"] {
--ui-bg: #0f172a;
--ui-bg-accent: #1e293b;
/* Tiefes Blau-Theme (Dark Blue) */
--ui-bg: #0b1120;
--ui-bg-accent: #111827;
--ui-surface: #1e293b;
--ui-surface-soft: #25314a;
--ui-border: #334155;
--ui-text: #f1f5f9;
--ui-surface-soft: #334155;
--ui-border: #475569;
--ui-text: #f8fafc;
--ui-text-muted: #94a3b8;
--ui-title: #ffffff;
--ui-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
--ui-shadow-md: 0 10px 24px rgba(0, 0, 0, 0.5);
--ui-shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.4), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
--ui-shadow-md: 0 10px 30px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
/* Abgerundetes Format im gesamten Dark Mode */
--ui-radius: 20px;
--ui-btn-radius: 99px; /* Pillenform für Buttons */
}
/* Safe Area Support for iPad notches, Dynamic Island, and rounded corners */
@@ -57,9 +62,17 @@ body {
:root[data-theme="dark"] input,
:root[data-theme="dark"] select,
:root[data-theme="dark"] textarea {
background-color: var(--ui-surface-soft) !important;
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
border: 1px solid var(--ui-border) !important;
border-radius: 12px !important;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.3) !important;
}
:root[data-theme="dark"] input:focus,
:root[data-theme="dark"] select:focus,
:root[data-theme="dark"] textarea:focus {
border-color: #52a8ff !important;
box-shadow: 0 0 0 2px rgba(82, 168, 255, 0.2), inset 0 1px 2px rgba(0,0,0,0.3) !important;
}
body {
@@ -295,7 +308,7 @@ select:focus {
/* Modal dialog styling */
.modal-dialog-white {
background: white;
background: var(--ui-surface);
padding: 20px;
border-radius: 5px;
text-align: center;
@@ -389,6 +402,10 @@ body.table-view .item-card {
background: var(--ui-surface);
box-shadow: none;
scroll-snap-align: none;
min-height: 60px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
body.table-view .item-card .card-content {
@@ -896,3 +913,92 @@ html, body {
padding-bottom: env(safe-area-inset-bottom);
}
}
/* ========================================================= */
/* UNIVERSAL DARK MODE OVERRIDES FOR HARDCODED HTML STYLES */
/* ========================================================= */
:root[data-theme="dark"] .bg-white,
:root[data-theme="dark"] .bg-light,
:root[data-theme="dark"] [style*="background-color: #fff"],
:root[data-theme="dark"] [style*="background-color: #ffffff"],
:root[data-theme="dark"] [style*="background-color: #f8f9fa"],
:root[data-theme="dark"] [style*="background-color: #f5f5f5"],
:root[data-theme="dark"] [style*="background: #fff"],
:root[data-theme="dark"] [style*="background: #ffffff"],
:root[data-theme="dark"] [style*="background: #f8f9fa"],
:root[data-theme="dark"] [style*="background: #f5f5f5"],
:root[data-theme="dark"] [style*="background: white"],
:root[data-theme="dark"] [style*="background-color: white"],
:root[data-theme="dark"] .modal-content,
:root[data-theme="dark"] .card,
:root[data-theme="dark"] .dropdown-menu {
background-color: var(--ui-surface) !important;
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .text-dark,
:root[data-theme="dark"] .text-black,
:root[data-theme="dark"] [style*="color: #000"],
:root[data-theme="dark"] [style*="color: rgb(0, 0, 0)"],
:root[data-theme="dark"] [style*="color: black"],
:root[data-theme="dark"] [style*="color: #333"] {
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .text-muted {
color: var(--ui-text-muted) !important;
}
/* Fix table backgrounds that might have hardcoded light colors */
:root[data-theme="dark"] .table-light,
:root[data-theme="dark"] table.table,
:root[data-theme="dark"] table [style*="background: #fff"] {
background-color: var(--ui-bg) !important;
color: var(--ui-text) !important;
}
/* Fix list items */
:root[data-theme="dark"] .list-group-item {
background-color: var(--ui-surface) !important;
color: var(--ui-text) !important;
border-color: var(--ui-border) !important;
}
/* Additional inline style coverage */
:root[data-theme="dark"] [style*="background: #eef4ff"],
:root[data-theme="dark"] [style*="background-color: #eef4ff"],
:root[data-theme="dark"] [style*="background: #e9ecef"],
:root[data-theme="dark"] [style*="background-color: #e9ecef"],
:root[data-theme="dark"] [style*="background: #e2e8f0"],
:root[data-theme="dark"] [style*="background-color: #e2e8f0"],
:root[data-theme="dark"] [style*="background-color: #fefefe"],
:root[data-theme="dark"] [style*="background: #fffdfd"] {
background-color: var(--ui-surface) !important;
background: var(--ui-surface) !important;
color: var(--ui-text) !important;
}
:root[data-theme="dark"] .badge-light,
:root[data-theme="dark"] .bg-light {
background-color: var(--ui-surface-soft) !important;
color: var(--ui-text) !important;
border: 1px solid var(--ui-border);
}
/* Fix Bookmark/Favorite Button in Dark Mode */
:root[data-theme="dark"] .bookmark-btn {
background: var(--ui-surface-soft) !important;
border-color: #f59e0b !important;
color: #f59e0b !important;
}
:root[data-theme="dark"] .bookmark-btn:hover,
:root[data-theme="dark"] .bookmark-btn.active {
background: #f59e0b !important;
color: var(--ui-bg) !important;
}
:root[data-theme="dark"] .favorite-item {
outline: 2px solid #f59e0b !important;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.
+21 -3
View File
@@ -5,9 +5,27 @@
<h1>Audit Dashboard</h1>
<p>Integritätsstatus der Audit-Chain und letzte Audit-Ereignisse (max. 200 Einträge).</p>
<div style="display:flex; gap:10px; flex-wrap:wrap; margin:10px 0 18px;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export', format='md') }}">Audit Review exportieren (Markdown)</a>
<a class="btn btn-outline-secondary" href="{{ url_for('admin_audit_export', format='json') }}">Audit Review exportieren (JSON)</a>
<!-- Information Box for DIN 5008 Compliance -->
<div style="background:#e8f4f8; border-left:4px solid #0284c7; padding:12px; margin:0 0 16px 0; border-radius:4px;">
<p style="margin:0; font-size:0.95rem; color:#1e40af;">
<strong>✓ Professionelle PDF-Exporte:</strong> Die neuen DIN 5008 konformen Berichte sind speziell für Schulträger,
Rechnungsprüfungsämter und Behörden optimiert. Sie enthalten Revisionssicherheit, Barrierefreiheit (BFSG) und
PDF/A-Format für Langzeitarchivierung in deutschen Behörden.
</p>
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:10px 0 18px;">
<!-- PDF Export Section -->
<div style="padding:14px; border:1px solid #e2e8f0; border-radius:10px; background: var(--ui-surface);">
<h4 style="margin:0 0 10px 0; color:#1a1a1a;">📄 PDF-Export (DIN 5008 konform)</h4>
<p style="margin:0 0 10px 0; font-size:0.9rem; color:#666;">Professionelle Berichte für Schulträger und Behörden</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-primary" href="{{ url_for('admin_audit_export_pdf_official') }}" style="flex:1; text-align:center;">
📋 Amtlicher Bericht (DIN 5008)
</a>
</div>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0 20px;">
+287
View File
@@ -0,0 +1,287 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}Schulstammdaten verwalten{% endblock %}
{% block content %}
<div class="container">
<div class="page-header">
<h1>Schulstammdaten verwalten</h1>
<p>Diese Angaben werden für amtliche Exporte, PDF-Berichte und die Zuordnung in Behörden verwendet.</p>
</div>
<div class="notice-card">
<strong>Hinweis:</strong> Die hier gespeicherten Daten werden tenantweise in der MongoDB gespeichert und sofort für die PDF-Exporte verwendet.
</div>
<div class="notice-card" style="background:#f8fafc; border-color:#dbe3ee; color:#334155;">
<strong>Aktueller Kontext:</strong>
Tenant {{ tenant_id or 'default' }} · Datenbank {{ tenant_db or 'Inventarsystem' }}
</div>
<div class="settings-grid">
<div class="card settings-card">
<div class="card-header">
<h2>Schuldaten</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin_school_settings') }}" enctype="multipart/form-data">
<div class="form-row">
<div class="form-group">
<label for="name">Schulname *</label>
<input type="text" id="name" name="name" value="{{ school_info.name or '' }}" required placeholder="Grundschule Beispielstadt">
</div>
<div class="form-group">
<label for="school_number">Schulnummer *</label>
<input type="text" id="school_number" name="school_number" value="{{ school_info.school_number or '' }}" required placeholder="042123">
</div>
</div>
<div class="form-group">
<label for="address">Adresse *</label>
<input type="text" id="address" name="address" value="{{ school_info.address or '' }}" required placeholder="Musterstraße 12">
</div>
<div class="form-row">
<div class="form-group">
<label for="postal_code">PLZ *</label>
<input type="text" id="postal_code" name="postal_code" value="{{ school_info.postal_code or '' }}" required placeholder="12345">
</div>
<div class="form-group">
<label for="city">Ort *</label>
<input type="text" id="city" name="city" value="{{ school_info.city or '' }}" required placeholder="Musterstadt">
</div>
</div>
<div class="form-group">
<label for="it_admin">Verantwortliche Person</label>
<input type="text" id="it_admin" name="it_admin" value="{{ school_info.it_admin or '' }}" placeholder="IT-Beauftragte/r, Sekretariat oder Schulleitung">
</div>
<div class="form-group">
<label for="logo_upload">Schullogo hochladen</label>
<input type="file" id="logo_upload" name="logo_upload" accept=".png,.jpg,.jpeg,.gif,.webp,.svg,image/*">
<small>Upload ersetzt das bisherige Logo. Wenn kein neues Logo gewählt wird, bleibt das aktuelle erhalten.</small>
</div>
<hr style="margin: 24px 0; border: 0; border-top: 1px solid #e5e7eb;">
<h3 style="font-size: 1.1rem; margin-bottom: 16px; color: #111827;">Globales Filter-Wording</h3>
<p style="font-size: 0.9rem; color: #6b7280; margin-bottom: 16px;">Hier kannst du die globalen Bezeichnungen für die drei Haupt-Kategorien (Filter) der Anwendung ändern.</p>
<div class="form-row">
<div class="form-group">
<label for="filter_name_1">Name Filter 1</label>
<input type="text" id="filter_name_1" name="filter_name_1" value="{{ filter_names.get('1', 'Fach/Kategorie') }}" placeholder="z. B. Fach/Kategorie">
</div>
<div class="form-group">
<label for="filter_name_2">Name Filter 2</label>
<input type="text" id="filter_name_2" name="filter_name_2" value="{{ filter_names.get('2', 'System/Bereich') }}" placeholder="z. B. System/Bereich">
</div>
<div class="form-group">
<label for="filter_name_3">Name Filter 3</label>
<input type="text" id="filter_name_3" name="filter_name_3" value="{{ filter_names.get('3', 'Typ/Art') }}" placeholder="z. B. Typ/Art">
</div>
</div>
<div class="actions-row">
<button type="submit" class="btn btn-primary">Schulstammdaten speichern</button>
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
</div>
</form>
</div>
</div>
<div class="card preview-card">
<div class="card-header">
<h2>Aktuelle Vorschau</h2>
</div>
<div class="card-body">
{% if school_info.logo_path %}
<div style="margin-bottom:16px; padding:12px; border:1px solid #e5e7eb; border-radius:10px; background:#fff;">
<div style="font-weight:700; margin-bottom:8px; color:#374151;">Aktuelles Logo</div>
<img src="{{ url_for('uploaded_file', filename=school_info.logo_path) }}" alt="Aktuelles Schullogo" style="max-width:100%; max-height:180px; object-fit:contain; display:block;">
<div style="margin-top:8px; font-size:0.85rem; color:#6b7280; word-break:break-word;">{{ school_info.logo_path }}</div>
</div>
{% endif %}
<p><strong>Schulname:</strong> {{ school_info.name or 'Nicht gesetzt' }}</p>
<p><strong>Adresse:</strong> {{ school_info.address or 'Nicht gesetzt' }}</p>
<p><strong>Schulnummer:</strong> {{ school_info.school_number or 'Nicht gesetzt' }}</p>
<p><strong>Ort:</strong> {{ school_info.postal_code or '' }} {{ school_info.city or '' }}</p>
<p><strong>Verantwortliche Person:</strong> {{ school_info.it_admin or 'Nicht gesetzt' }}</p>
<p><strong>Logo:</strong> {{ school_info.logo_path or 'Nicht gesetzt' }}</p>
<hr>
<p>Diese Angaben erscheinen künftig im amtlichen Audit-PDF und in anderen Behördenberichten.</p>
</div>
</div>
</div>
</div>
<style>
.container {
max-width: 1100px;
margin: 0 auto;
padding: 20px;
}
.page-header {
text-align: center;
margin-bottom: 18px;
}
.page-header h1 {
margin-bottom: 8px;
color: #1f2937;
}
.page-header p {
color: #4b5563;
margin: 0;
}
.notice-card {
background: #eff6ff;
border: 1px solid #bfdbfe;
color: #1d4ed8;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 18px;
}
.settings-grid {
display: grid;
grid-template-columns: 1.4fr 0.9fr;
gap: 18px;
align-items: start;
}
.card {
background: var(--ui-surface);
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
}
.card-header {
background: var(--ui-surface-soft);
padding: 16px 18px;
border-bottom: 1px solid #e5e7eb;
}
.card-header h2 {
margin: 0;
font-size: 1.1rem;
color: #111827;
}
.card-body {
padding: 18px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-weight: 700;
margin-bottom: 6px;
color: #374151;
}
.form-group input {
width: 100%;
padding: 11px 12px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
color: #111827;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.form-group small {
display: block;
margin-top: 6px;
color: #6b7280;
line-height: 1.35;
}
.actions-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border-radius: 8px;
font-weight: 700;
text-decoration: none;
border: 1px solid transparent;
cursor: pointer;
}
.btn-primary {
background: #2563eb;
color: #fff;
}
.btn-primary:hover {
background: #1d4ed8;
color: #fff;
}
.btn-secondary {
background: #f3f4f6;
color: #111827;
border-color: #d1d5db;
}
.btn-secondary:hover {
background: #e5e7eb;
color: #111827;
}
.preview-card p {
margin: 0 0 10px;
line-height: 1.45;
}
.preview-card hr {
border: 0;
border-top: 1px solid #e5e7eb;
margin: 16px 0;
}
@media (max-width: 900px) {
.settings-grid,
.form-row {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
+559 -248
View File
@@ -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;
@@ -176,6 +190,13 @@
z-index: 1900;
}
#loginNavbar {
padding-top: 0;
padding-bottom: 0;
min-height: 28px;
overflow: visible;
}
.navbar .container-fluid {
gap: 8px;
/* Ensure container respects safe areas on all sides */
@@ -183,6 +204,13 @@
padding-right: max(env(safe-area-inset-right, 0), 12px);
padding-bottom: env(safe-area-inset-bottom, 0);
}
#loginNavbar .container-fluid {
padding-top: 2px;
padding-bottom: 2px;
align-items: center;
min-height: 28px;
}
.navbar-brand {
font-weight: bold;
@@ -191,6 +219,38 @@
white-space: nowrap;
flex-shrink: 0;
}
#loginNavbar .navbar-brand {
padding-right: 0;
}
#loginNavbar .navbar-brand::before {
content: none;
margin-right: 0;
}
#loginNavbar .invario-logo {
display: block;
height: 114px;
width: auto;
max-width: min(78vw, 520px);
object-fit: contain;
filter: none;
margin-top: 0;
margin-bottom: 0;
transform: translateY(8px);
}
.navbar-text.tenant-badge {
font-size: 0.82rem;
color: rgba(255,255,255,0.9);
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.18);
padding: 0.25rem 0.55rem;
border-radius: 999px;
white-space: nowrap;
margin-right: 0.75rem;
}
.navbar-brand::before {
content: attr(data-icon);
@@ -206,8 +266,84 @@
white-space: nowrap;
}
body.tutorial-tooltips-active [data-tutorial-tip] {
position: relative;
border-radius: 10px;
outline: 1px dashed rgba(59, 130, 246, 0.35);
outline-offset: 2px;
cursor: help;
}
body.tutorial-tooltips-active [data-tutorial-tip]::before {
content: '💡';
position: absolute;
top: 50%;
right: -26px;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(59, 130, 246, 0.15);
color: #0f172a;
font-size: 0.75rem;
pointer-events: none;
}
body.tutorial-tooltips-active [data-tutorial-tip]::after {
content: attr(data-tutorial-tip);
position: absolute;
top: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(6px);
min-width: 220px;
max-width: 280px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(15, 23, 42, 0.92);
color: #f8fafc;
font-size: 0.9rem;
line-height: 1.4;
text-align: left;
opacity: 0;
visibility: hidden;
pointer-events: none;
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.22);
z-index: 9999;
white-space: normal;
}
body.tutorial-tooltips-active [data-tutorial-tip]:hover::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(10px);
}
.tutorial-tooltip-indicator {
position: fixed;
top: 92px;
right: 16px;
z-index: 2000;
display: none;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.25);
color: #0f172a;
font-size: 0.95rem;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12);
}
body.tutorial-tooltips-active .tutorial-tooltip-indicator {
display: inline-flex;
}
/* iPad/Tablet responsive adjustments */
@media (max-width: 1024px) {
@media (max-width: 768px) {
.navbar .container-fluid {
gap: 6px;
padding-left: max(env(safe-area-inset-left, 0), 10px);
@@ -250,6 +386,14 @@
font-size: 0.75rem;
padding: 0.4rem 0.5rem;
}
#loginNavbar .invario-logo {
height: 94px;
max-width: 80vw;
margin-top: 0;
margin-bottom: 0;
transform: translateY(6px);
}
}
.navbar.nav-compact .navbar-brand {
@@ -306,6 +450,7 @@
align-items: center;
margin-right: 10px;
width: min(420px, 42vw);
min-width: 0;
}
.function-search-form {
@@ -313,10 +458,13 @@
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
}
.function-search-input {
width: 100%;
flex: 1 1 auto;
width: auto;
min-width: 0;
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
@@ -337,6 +485,7 @@
}
.function-search-btn {
flex: 0 0 auto;
min-height: 38px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.45);
@@ -539,9 +688,48 @@
margin-top: 4px;
}
.navbar-collapse {
width: 100%;
}
.navbar-collapse > .d-flex {
width: 100%;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.function-search-wrap {
width: 100%;
max-width: 100%;
margin: 8px 0 10px;
flex: 1 1 100%;
}
.function-search-form {
width: 100%;
max-width: 100%;
flex-wrap: nowrap;
}
.function-search-input {
width: auto;
min-width: 0;
flex: 1 1 auto;
}
.function-search-btn {
flex: 0 0 auto;
white-space: nowrap;
}
.navbar-text {
margin-right: 0 !important;
width: 100%;
}
.user-menu-wrap {
align-self: flex-start;
}
.navbar-nav .nav-item {
@@ -756,6 +944,29 @@
box-shadow: none;
}
.navbar-nav .nav-item.dropdown > .nav-link.dropdown-toggle,
.user-menu-wrap > .user-menu-btn {
display: none !important;
}
.navbar-nav .nav-item.dropdown > .dropdown-menu,
.user-menu-wrap > .dropdown-menu {
display: block !important;
position: static !important;
float: none !important;
transform: none !important;
inset: auto !important;
width: 100%;
}
.navbar-nav .nav-item.dropdown > .dropdown-menu {
margin-top: 0;
}
.user-menu-wrap > .dropdown-menu {
margin-top: 8px;
}
.navbar-nav .dropdown-item {
color: rgba(255, 255, 255, 0.9);
padding: 12px 16px;
@@ -800,6 +1011,7 @@
order: 3;
width: 100%;
margin: 0;
display: none !important;
}
.function-search-form {
@@ -879,243 +1091,329 @@
<div class="module-selector-bar" id="moduleBar">
<span class="module-label">Module:</span>
<div class="module-tabs">
<a href="{{ url_for('home') }}"
class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}"
id="inventoryModule"
data-module="inventory">
{% if inventory_module_enabled %}
<a href="{{ url_for('home') }}" class="module-tab {% if CURRENT_MODULE == 'inventory' %}active{% endif %}" id="inventoryModule" data-module="inventory">
📦 Inventarsystem
</a>
{% if library_module_enabled %}
{% endif %}
{% if inventory_module_enabled and terminplan_module_enabled %}
<div class="module-separator"></div>
<a href="{{ url_for('library_view') }}"
class="module-tab {% if CURRENT_MODULE == 'library' %}active{% endif %}"
id="libraryModule"
data-module="library">
{% 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">
📚 Bibliothek
</a>
{% endif %}
</div>
</div>
{% endif %}
{% if CURRENT_MODULE != 'library' %}
<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>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#inventoryNavContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="inventoryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
{% if current_permissions.pages.get('home', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}">Artikel</a>
</li>
{% endif %}
{% if 'username' in session and current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('upload_admin') %}nav-active{% endif %}" href="{{ url_for('upload_admin') }}"> Hochladen</a>
</li>
{% endif %}
<li class="nav-item">
<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="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
Mehr Optionen
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %}
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('manage_locations', True) %}
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_borrowings', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% endif %}
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('admin_audit_dashboard', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
{% endif %}
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('logs', True) %}
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% if current_permissions.actions.get('can_manage_users', True) %}
<li><h6 class="dropdown-header">System</h6></li>
{% if current_permissions.pages.get('user_del', True) %}
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('register', True) %}
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul>
</li>
<li class="nav-item d-none" id="inv-nav-overflow-anchor" aria-hidden="true"></li>
</ul>
<div class="d-flex">
{% if 'username' in session %}
<div class="function-search-wrap">
<form class="function-search-form" data-function-search="true">
<input
class="function-search-input"
type="search"
name="function_search"
placeholder="Funktion suchen..."
list="function-search-options"
autocomplete="off"
>
<button class="function-search-btn" type="submit">Los</button>
</form>
</div>
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
{% 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>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
{% if current_permissions.pages.get('notifications_view', True) %}
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
</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('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
{% endif %}
</li>
</ul>
</div>
</div>
</div>
</nav>
{% endif %}
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('library_view') }}" data-icon="📚">Bibliothek</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#libraryNavContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="libraryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
{% if current_permissions.pages.get('library_view', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}">Medien</a>
</li>
{% endif %}
{% if 'username' in session and current_permissions.actions.get('can_insert', True) and current_permissions.pages.get('library_admin', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}">📖 Hochladen</a>
</li>
{% endif %}
<li class="nav-item">
<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="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
Mehr Optionen
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('my_borrowed_items') }}">Meine Medien</a></li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', True) %}
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% endif %}
{% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% if current_permissions.actions.get('can_manage_users', True) %}
<li><h6 class="dropdown-header">System</h6></li>
{% if current_permissions.pages.get('user_del', True) %}
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('register', True) %}
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul>
</li>
<li class="nav-item d-none" id="lib-nav-overflow-anchor" aria-hidden="true"></li>
</ul>
<div class="d-flex">
{% if 'username' in session %}
<div class="function-search-wrap">
<form class="function-search-form" data-function-search="true">
<input
class="function-search-input"
type="search"
name="function_search"
placeholder="Funktion suchen..."
list="function-search-options"
autocomplete="off"
>
<button class="function-search-btn" type="submit">Los</button>
</form>
</div>
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</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>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#inventoryNavContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="inventoryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="inventoryNavList">
{% if current_permissions.pages.get('home', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('home') %}nav-active{% endif %}" href="{{ url_for('home') }}" data-tutorial-tip="Starten Sie hier mit dem Materialbestand und suchen Sie nach Artikeln.">Artikel</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Ihre aktuellen Ausleihen und Rückgaben finden Sie hier.">Meine Ausleihen</a>
</li>
{% endif %}
{% 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>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
{% if current_permissions.pages.get('notifications_view', True) %}
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
</li>
<li class="nav-item dropdown ms-lg-auto">
<a class="nav-link dropdown-toggle" href="#" id="invMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
Mehr Optionen
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invMoreDropdown">
{% if 'username' in session %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li><a class="dropdown-item" href="{{ url_for('tutorial_page') }}">Tutorial</a></li>
{% endif %}
{% if current_permissions.pages.get('upload_admin', True) and current_permissions.actions.get('can_insert', True) %}
<li><a class="dropdown-item" href="{{ url_for('upload_admin') }}"> Hochladen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
{% endif %}
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Verwaltung</h6></li>
{% if current_permissions.pages.get('manage_filters', True) %}
<li><a class="dropdown-item" href="{{ url_for('manage_filters') }}">Filter verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('manage_locations', True) %}
<li><a class="dropdown-item" href="{{ url_for('manage_locations') }}">Orte verwalten</a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
{% if current_permissions.pages.get('admin_borrowings', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_borrowings') }}">Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% endif %}
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('admin_audit_dashboard', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_audit_dashboard') }}">Audit Dashboard</a></li>
{% endif %}
{% if current_permissions.actions.get('can_view_logs', True) and current_permissions.pages.get('logs', True) %}
<li><a class="dropdown-item" href="{{ url_for('logs') }}">Logs</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
{% if current_permissions.actions.get('can_manage_users', True) %}
<li><h6 class="dropdown-header">System</h6></li>
{% if current_permissions.pages.get('user_del', True) %}
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('register', True) %}
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul>
</div>
{% endif %}
</li>
<li class="nav-item d-none" id="inv-nav-overflow-anchor" aria-hidden="true"></li>
</ul>
<div class="d-flex">
{% if 'username' in session %}
<div class="function-search-wrap">
<form class="function-search-form" data-function-search="true">
<input
class="function-search-input"
type="search"
name="function_search"
placeholder="Funktion suchen..."
list="function-search-options"
autocomplete="off"
>
<button class="function-search-btn" type="submit">Los</button>
</form>
</div>
{% if current_tenant_db %}
<span class="navbar-text tenant-badge" title="Aktive Tenant-Datenbank">{{ current_tenant_db }}</span>
{% endif %}
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="invUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="invUserMenuDropdown">
{% if current_permissions.pages.get('notifications_view', True) %}
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
</nav>
{% endif %}
{% endif %}
{% if library_module_enabled and CURRENT_MODULE == 'library' %}
<nav class="navbar navbar-expand-lg navbar-dark" id="libraryNavbar">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('library_view') }}" data-icon="📚">Bibliothek</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#libraryNavContent">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="libraryNavContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0" id="libraryNavList">
{% if current_permissions.pages.get('library_view', True) %}
<li class="nav-item" data-nav-fixed="true">
<a class="nav-link {% if current_path == url_for('library_view') %}nav-active{% endif %}" href="{{ url_for('library_view') }}" data-tutorial-tip="Die Bibliothek zeigt Ihnen alle Medien und verfügbaren Bücher.">Medien</a>
</li>
{% endif %}
{% if 'username' in session %}
{% if current_permissions.pages.get('my_borrowed_items', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('my_borrowed_items') %}nav-active{% endif %}" href="{{ url_for('my_borrowed_items') }}" data-tutorial-tip="Hier sehen Sie Ihre bibliotheksbezogenen Ausleihen.">Meine Medien</a>
</li>
{% endif %}
{% if current_permissions.pages.get('tutorial_page', True) %}
<li class="nav-item">
<a class="nav-link quick-link-pill {% if current_path == url_for('tutorial_page') %}nav-active{% endif %}" href="{{ url_for('tutorial_page') }}" data-tutorial-tip="Nutzen Sie das Tutorial, um die Bibliotheksfunktionen kennenzulernen.">Tutorial</a>
</li>
{% endif %}
{% endif %}
{% if 'username' in session and current_permissions.actions.get('can_insert', True) and current_permissions.pages.get('library_admin', True) %}
<li class="nav-item">
<a class="nav-link nav-priority-link {% if current_path == url_for('library_admin') %}nav-active{% endif %}" href="{{ url_for('library_admin') }}" data-tutorial-tip="Neue Bibliotheksmedien können hier aufgenommen werden.">📖 Hochladen</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="libMoreDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Weitere Optionen">
Mehr Optionen
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libMoreDropdown">
{% if 'username' in session and (session.get('admin', False) or is_admin) and current_permissions.actions.get('can_manage_settings', True) %}
<li><h6 class="dropdown-header">Bibliotheks-Verwaltung</h6></li>
{% if current_permissions.pages.get('library_loans_admin', True) %}
<li><a class="dropdown-item" href="{{ url_for('library_loans_admin') }}">Ausleihen</a></li>
{% endif %}
{% if current_permissions.pages.get('admin_damaged_items', True) %}
<li><a class="dropdown-item" href="{{ url_for('admin_damaged_items') }}">Defekte Items</a></li>
{% endif %}
{% if student_cards_module_enabled %}
<li><a class="dropdown-item" href="{{ url_for('student_cards_admin') }}">Bibliotheksausweis</a></li>
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('admin_school_settings') }}">Schulstammdaten</a></li>
<li><hr class="dropdown-divider"></li>
{% if current_permissions.actions.get('can_manage_users', True) %}
<li><h6 class="dropdown-header">System</h6></li>
{% if current_permissions.pages.get('user_del', True) %}
<li><a class="dropdown-item" href="{{ url_for('user_del') }}">Benutzer verwalten</a></li>
{% endif %}
{% if current_permissions.pages.get('register', True) %}
<li><a class="dropdown-item" href="{{ url_for('register') }}">Neuer Benutzer</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
{% endif %}
{% endif %}
<li><a class="dropdown-item" href="{{ url_for('impressum') }}">Impressum</a></li>
<li><a class="dropdown-item" href="{{ url_for('license') }}">Lizenz</a></li>
</ul>
</li>
<li class="nav-item d-none" id="lib-nav-overflow-anchor" aria-hidden="true"></li>
</ul>
<div class="d-flex">
{% if 'username' in session %}
<div class="function-search-wrap">
<form class="function-search-form" data-function-search="true">
<input
class="function-search-input"
type="search"
name="function_search"
placeholder="Funktion suchen..."
list="function-search-options"
autocomplete="off"
>
<button class="function-search-btn" type="submit">Los</button>
</form>
</div>
<span class="navbar-text text-light me-3">{{ session['username'] }}</span>
<div class="dropdown me-2 user-menu-wrap">
<button class="btn btn-secondary dropdown-toggle user-menu-btn" type="button" id="libUserMenuDropdown" data-bs-toggle="dropdown" aria-expanded="false" data-notification-button="true">
👤
<span class="user-notification-dot {% if unread_notification_count and unread_notification_count > 0 %}visible{% endif %}" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="libUserMenuDropdown">
{% if current_permissions.pages.get('notifications_view', True) %}
<li><a class="dropdown-item" href="{{ url_for('notifications_view') }}">Benachrichtigungen</a></li>
{% endif %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('change_password') }}">Passwort ändern</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('logout') }}">Logout</a></li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
</nav>
{% endif %}
{% 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>
{% endif %}
@@ -1316,6 +1614,7 @@
<option value="Defekte Items"></option>
<option value="Filter verwalten"></option>
<option value="Orte verwalten"></option>
<option value="Schulstammdaten"></option>
<option value="Audit Dashboard"></option>
<option value="Logs"></option>
<option value="Benutzer verwalten"></option>
@@ -1385,6 +1684,7 @@
{ label: 'Defekte Items', keywords: ['defekte items', 'defekt', 'schaden'], url: {{ url_for('admin_damaged_items')|tojson }} },
{ label: 'Filter verwalten', keywords: ['filter verwalten', 'filter'], url: {{ url_for('manage_filters')|tojson }} },
{ label: 'Orte verwalten', keywords: ['orte verwalten', 'orte', 'location'], url: {{ url_for('manage_locations')|tojson }} },
{ label: 'Schulstammdaten', keywords: ['schule', 'settings', 'stammdaten', 'school settings'], url: {{ url_for('admin_school_settings')|tojson }} },
{ label: 'Audit Dashboard', keywords: ['audit', 'audit dashboard'], url: {{ url_for('admin_audit_dashboard')|tojson }} },
{ label: 'Logs', keywords: ['logs', 'protokoll'], url: {{ url_for('logs')|tojson }} },
{ label: 'Benutzer verwalten', keywords: ['benutzer verwalten', 'user'], url: {{ url_for('user_del')|tojson }} },
@@ -1795,15 +2095,18 @@
if (overflowEntries.length > 0) {
const marker = document.createElement('li');
marker.innerHTML = '<h6 class="dropdown-header">Ausgeblendete Navigation</h6>';
moreMenu.insertBefore(marker, moreMenu.firstChild);
const firstExistingNode = moreMenu.firstChild;
moreMenu.insertBefore(marker, firstExistingNode);
overflowEntries.reverse().forEach(function(node){
moreMenu.insertBefore(node, marker.nextSibling);
let insertAfter = marker;
overflowEntries.forEach(function(node){
moreMenu.insertBefore(node, insertAfter.nextSibling);
insertAfter = node;
});
const divider = document.createElement('li');
divider.innerHTML = '<hr class="dropdown-divider">';
moreMenu.insertBefore(divider, marker.nextSibling);
moreMenu.insertBefore(divider, insertAfter.nextSibling);
if (moreToggle) {
moreToggle.textContent = moreLabelDefault + ' (' + hiddenSources.length + ')';
@@ -1818,41 +2121,27 @@
function adaptNavbarByWidth() {
if (!navList) return;
const isMobileViewport = window.matchMedia('(max-width: 991.98px)').matches;
function isNavOverflowing(bufferPx) {
const buffer = typeof bufferPx === 'number' ? bufferPx : 0;
const collapseOverflow = navCollapse ? (navCollapse.scrollWidth > (navCollapse.clientWidth - buffer)) : false;
const containerOverflow = navContainer ? (navContainer.scrollWidth > (navContainer.clientWidth - buffer)) : false;
const listOverflow = navList.scrollWidth > (navList.clientWidth - buffer);
return collapseOverflow || containerOverflow || listOverflow;
}
applyCompactMode();
if (window.innerWidth < 992) {
// Desktop/tablet-large: keep complete navigation visible.
if (!isMobileViewport) {
restoreAllNavItems();
return;
}
// Mobile burger menu should show the full list without overflow pruning.
restoreAllNavItems();
const hiddenSources = [];
let candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
}
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
candidates = collectTopLevelNavSources();
}
rebuildOverflowControl(hiddenSources);
candidates = collectTopLevelNavSources();
while (navList.scrollWidth > navList.clientWidth && candidates.length > 0) {
const toHide = pickNextNavItemToHide(candidates);
if (!toHide) {
break;
}
toHide.style.display = 'none';
hiddenSources.unshift(toHide);
rebuildOverflowControl(hiddenSources);
candidates = collectTopLevelNavSources();
}
return;
}
let resizeTimer = null;
@@ -1927,6 +2216,28 @@
});
});
</script>
<script>
(function () {
const username = {{ (session['username'] if 'username' in session else '')|tojson }};
const tooltipModeKey = username ? ('inventarsystem_tutorial_tooltips_enabled_' + username) : 'inventarsystem_tutorial_tooltips_enabled';
const enabled = localStorage.getItem(tooltipModeKey) === '1';
function applyTooltipMode(active) {
document.body.classList.toggle('tutorial-tooltips-active', active);
let indicator = document.getElementById('tutorialTooltipIndicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.id = 'tutorialTooltipIndicator';
indicator.className = 'tutorial-tooltip-indicator';
indicator.textContent = 'Tutorial-Modus aktiv: Tooltips sind eingeschaltet';
document.body.appendChild(indicator);
}
indicator.style.display = active ? 'inline-flex' : 'none';
}
applyTooltipMode(enabled);
})();
</script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}
{% if error_code == 404 %}
Seite nicht gefunden - Inventarsystem
{% else %}
Fehler - Inventarsystem
{% endif %}
{% endblock %}
{% block content %}
<div class="error-container" style="text-align: center; padding: 60px 20px; min-height: 70vh; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<div style="max-width: 600px;">
<!-- Error Code Display -->
<div style="font-size: 120px; font-weight: bold; color: #ef4444; margin-bottom: 20px;">
{{ error_code }}
</div>
<!-- Error Title -->
<h1 style="font-size: 32px; margin: 20px 0; color: #1f2937;">
{% if error_code == 404 %}
Seite nicht gefunden
{% else %}
Ein Fehler ist aufgetreten
{% endif %}
</h1>
<!-- Error Message -->
<p style="font-size: 18px; color: #6b7280; margin: 20px 0 40px;">
{{ error_message or 'Es tut uns leid, aber die angeforderte Seite konnte nicht gefunden werden.' }}
</p>
<!-- Action Buttons -->
<div style="display: flex; gap: 15px; justify-content: center; flex-wrap: wrap;">
<a href="{{ url_for('home') }}" class="button" style="background-color: #3b82f6; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: 500;">
Zur Startseite
</a>
<a href="javascript:history.back()" class="button" style="background-color: #6b7280; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block; font-weight: 500;">
Zurück
</a>
</div>
<!-- Additional Help -->
<div style="margin-top: 60px; padding: 20px; background-color: #f3f4f6; border-radius: 8px;">
<p style="color: #6b7280; font-size: 14px;">
Wenn Sie denken, dass dies ein Fehler ist, oder Sie Hilfe benötigen,
<a href="{{ url_for('impressum') }}" style="color: #3b82f6; text-decoration: none;">kontaktieren Sie den Administrator</a>.
</p>
</div>
</div>
</div>
<style>
.error-container {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.button:hover {
opacity: 0.9;
transform: translateY(-2px);
transition: all 0.2s ease;
}
</style>
{% endblock %}
+4 -4
View File
@@ -12,7 +12,7 @@
{% block content %}
<div class="container my-4">
<div class="impressum-content bg-white p-4 shadow rounded">
<div class="impressum-content p-4 shadow rounded" style="background: var(--ui-surface); color: var(--ui-text);">
<h1 class="mb-4 text-center">Impressum</h1>
<p class="text-center mb-5">(Angaben gemäß § 5 DDG)</p>
@@ -20,7 +20,7 @@
<h3 class="mb-3">Kontaktinformationen</h3>
<address class="mb-0">
<p><strong>Name:</strong> Invario UG</p>
<p><strong>Adresse:</strong> Musterstraße 123<br>12345 Musterstadt<br>Deutschland</p>
<p><strong>Adresse:</strong> Am Sportplatz 10<br>83052 Bruckmühl<br>Deutschland</p>
</address>
<p><strong>E-Mail:</strong> <a href="mailto:info@invario.eu">info@invario.eu</a></p>
</div>
@@ -29,8 +29,8 @@
<h3 class="mb-3">Verantwortlich für den Inhalt</h3>
<p>(nach § 18 Abs. 2 MStV)</p>
<p>Invario UG<br>
Musterstraße 123<br>
12345 Musterstadt<br>
Am Sportplatz 10<br>
83052 Bruckmühl<br>
Deutschland</p>
</div>
+254 -1
View File
@@ -72,6 +72,7 @@
</div>
<!-- Table Header (only in table mode) -->
<div class="library-table-wrapper">
<div class="table-header" id="tableHeader" style="display: none;">
<div class="table-row">
<div class="table-cell title-cell">Titel</div>
@@ -166,6 +167,7 @@
</div>
{% endif %}
</div>
</div>
</div>
<!-- Detail Modal -->
@@ -208,12 +210,23 @@
<script>
// View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
const MOBILE_LIBRARY_MAX_WIDTH = 900;
const MOBILE_LIBRARY_MIN_ITEMS = 24;
function isMobileViewport() {
return window.matchMedia(`(max-width: ${MOBILE_LIBRARY_MAX_WIDTH}px)`).matches;
}
function setViewMode(mode) {
localStorage.setItem(LIBRARY_VIEW_MODE_KEY, mode);
const container = document.getElementById('itemsContainer');
const tableHeader = document.getElementById('tableHeader');
const items = container.querySelectorAll('.item-card');
const renderedItems = Array.from(container.querySelectorAll('.item-card'));
const virtualizationState = window.mobileLibraryVirtualizationState || null;
const detachedItems = virtualizationState && virtualizationState.active && Array.isArray(virtualizationState.items)
? virtualizationState.items.filter(item => !container.contains(item))
: [];
const items = [...renderedItems, ...detachedItems.filter(item => !container.contains(item))];
items.forEach(item => {
const cardContent = item.querySelector('.item-content.card-mode');
@@ -236,6 +249,229 @@ function setViewMode(mode) {
document.getElementById('viewModeToggle').classList.toggle('open', mode === 'table');
}
function initMobileWindowedLibraryLoading() {
const container = document.getElementById('itemsContainer');
if (!container) {
return;
}
if (!window.mobileLibraryVirtualizationState) {
window.mobileLibraryVirtualizationState = {
active: false,
items: [],
topSpacer: null,
bottomSpacer: null,
averageItemHeight: Math.max(Math.round(window.innerHeight * 0.35), 230),
lastStart: -1,
lastEnd: -1,
framePending: false,
scrollListener: null,
resizeListener: null,
initialized: false
};
}
const state = window.mobileLibraryVirtualizationState;
function restoreAllImages() {
state.items.forEach(item => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
const originalSrc = image.dataset.mobileSrc || '';
if (!image.getAttribute('src') && originalSrc) {
image.setAttribute('src', originalSrc);
}
});
}
function updateImageMemory(startIndex, endIndex) {
const imageBuffer = 4;
state.items.forEach((item, index) => {
const image = item.querySelector('img.item-image');
if (!image) {
return;
}
if (!image.dataset.mobileSrc) {
image.dataset.mobileSrc = image.getAttribute('src') || '';
}
image.loading = 'lazy';
image.decoding = 'async';
const shouldKeepLoaded = index >= startIndex - imageBuffer && index < endIndex + imageBuffer;
if (shouldKeepLoaded) {
if (!image.getAttribute('src') && image.dataset.mobileSrc) {
image.setAttribute('src', image.dataset.mobileSrc);
}
return;
}
if (image.getAttribute('src')) {
image.removeAttribute('src');
}
});
}
function renderVisibleWindow() {
if (!state.active || !state.topSpacer || !state.bottomSpacer) {
return;
}
const viewportHeight = window.innerHeight || 800;
const scrollTop = window.scrollY || window.pageYOffset || 0;
const renderBuffer = viewportHeight * 1.5;
let startIndex = Math.max(0, Math.floor((scrollTop - renderBuffer) / state.averageItemHeight));
let endIndex = Math.min(state.items.length, Math.ceil((scrollTop + viewportHeight + renderBuffer) / state.averageItemHeight));
if (endIndex - startIndex < 14) {
endIndex = Math.min(state.items.length, startIndex + 14);
}
if (startIndex === state.lastStart && endIndex === state.lastEnd) {
return;
}
state.lastStart = startIndex;
state.lastEnd = endIndex;
while (state.topSpacer.nextSibling && state.topSpacer.nextSibling !== state.bottomSpacer) {
state.topSpacer.nextSibling.remove();
}
const fragment = document.createDocumentFragment();
for (let index = startIndex; index < endIndex; index += 1) {
fragment.appendChild(state.items[index]);
}
container.insertBefore(fragment, state.bottomSpacer);
let measuredHeightSum = 0;
let measuredCount = 0;
for (let index = startIndex; index < endIndex; index += 1) {
const height = state.items[index].getBoundingClientRect().height;
if (height > 0) {
measuredHeightSum += height;
measuredCount += 1;
}
}
if (measuredCount > 0) {
const measuredAverage = measuredHeightSum / measuredCount;
state.averageItemHeight = Math.round((state.averageItemHeight * 3 + measuredAverage) / 4);
}
state.topSpacer.style.height = `${Math.max(0, startIndex * state.averageItemHeight)}px`;
state.bottomSpacer.style.height = `${Math.max(0, (state.items.length - endIndex) * state.averageItemHeight)}px`;
updateImageMemory(startIndex, endIndex);
}
function scheduleWindowRender() {
if (!state.active || state.framePending) {
return;
}
state.framePending = true;
requestAnimationFrame(() => {
state.framePending = false;
renderVisibleWindow();
});
}
function activateVirtualization() {
if (state.active) {
return;
}
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
if (state.items.length <= MOBILE_LIBRARY_MIN_ITEMS) {
return;
}
state.topSpacer = document.createElement('div');
state.topSpacer.className = 'mobile-window-spacer top';
state.bottomSpacer = document.createElement('div');
state.bottomSpacer.className = 'mobile-window-spacer bottom';
state.items.forEach(item => item.remove());
container.appendChild(state.topSpacer);
container.appendChild(state.bottomSpacer);
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = true;
if (!state.scrollListener) {
state.scrollListener = () => scheduleWindowRender();
window.addEventListener('scroll', state.scrollListener, { passive: true });
}
scheduleWindowRender();
}
function deactivateVirtualization() {
if (!state.active) {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
return;
}
if (state.scrollListener) {
window.removeEventListener('scroll', state.scrollListener);
state.scrollListener = null;
}
if (state.topSpacer && state.topSpacer.parentNode === container) {
state.topSpacer.remove();
}
if (state.bottomSpacer && state.bottomSpacer.parentNode === container) {
state.bottomSpacer.remove();
}
const fragment = document.createDocumentFragment();
state.items.forEach(item => fragment.appendChild(item));
container.appendChild(fragment);
restoreAllImages();
state.topSpacer = null;
state.bottomSpacer = null;
state.lastStart = -1;
state.lastEnd = -1;
state.framePending = false;
state.active = false;
const currentMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
setViewMode(currentMode);
}
function syncVirtualizationWithViewport() {
if (!Array.isArray(state.items) || state.items.length === 0) {
state.items = Array.from(container.querySelectorAll('.library-item'));
}
const shouldVirtualize = isMobileViewport() && state.items.length > MOBILE_LIBRARY_MIN_ITEMS;
if (shouldVirtualize) {
activateVirtualization();
scheduleWindowRender();
} else {
deactivateVirtualization();
}
}
if (!state.initialized) {
state.resizeListener = () => syncVirtualizationWithViewport();
window.addEventListener('resize', state.resizeListener, { passive: true });
state.initialized = true;
}
syncVirtualizationWithViewport();
}
// Initialize view mode
document.addEventListener('DOMContentLoaded', function() {
const savedMode = localStorage.getItem(LIBRARY_VIEW_MODE_KEY) || 'card';
@@ -311,6 +547,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (e.target === this) this.style.display = 'none';
});
});
initMobileWindowedLibraryLoading();
});
function displayLibraryItemDetail(item) {
@@ -484,5 +722,20 @@ function onScanError(error) {
background: #fce8e6;
color: #b3261e;
}
/* Mobile: allow horizontal scrolling for table-mode views */
@media (max-width: 900px) {
.library-table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
}
.library-table-wrapper .table-row {
min-width: 720px; /* allow table to have intrinsic width and be scrolled */
}
.table-header, .item-content.table-mode {
display: block; /* keep rows stacked but allow horizontal scroll */
}
}
</style>
{% endblock %}
+1 -1
View File
@@ -124,7 +124,7 @@
</div>
<div class="head-actions">
<a class="btn btn-outline-secondary" href="{{ url_for('library_loans_admin') }}">Zur Bibliotheks-Ausleihenverwaltung</a>
<a class="btn btn-secondary" href="{{ url_for('library_view') }}">Bibliothek öffnen</a>
<a class="btn btn-secondary" href="{{ url_for('library_admin') }}">Bibliothek öffnen</a>
</div>
</div>
+82 -5
View File
@@ -477,6 +477,7 @@
<select id="filterType">
<option value="">-- Alle Typen --</option>
<option value="book">Buch</option>
<option value="schulbuch">Schulbuch</option>
<option value="cd">CD</option>
<option value="dvd">DVD</option>
<option value="other">Sonstige</option>
@@ -495,6 +496,15 @@
<div class="filter-buttons">
<button id="applyFilterBtn" class="button" style="background: #4f46e5; color: white;">Filter anwenden</button>
<button id="clearFilterBtn" class="button" style="background: #f0f2f5; color: var(--ui-text);">Zurücksetzen</button>
<div style="flex-grow: 1;"></div>
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/schulbuecher'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Schulbücher Excel
</button>
<button class="button" style="background: #10b981; color: white; display: flex; align-items: center; gap: 0.5rem;" onclick="window.location.href='/library/export/all_borrowed'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="8" y1="13" x2="16" y2="13"></line><line x1="8" y1="17" x2="16" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
Alle ausgeliehen Excel
</button>
</div>
</div>
@@ -503,12 +513,13 @@
<table class="library-items-table">
<thead>
<tr>
<th style="width: 25%;">Titel</th>
<th style="width: 15%;">Autor/Künstler</th>
<th style="width: 24%;">Titel</th>
<th style="width: 14%;">Autor/Künstler</th>
<th style="width: 12%;">ISBN/Code</th>
<th style="width: 10%;">Typ</th>
<th style="width: 8%;">Typ</th>
<th style="width: 8%;">Anzahl</th>
<th style="width: 12%;">Status</th>
<th style="width: 26%;">Aktionen</th>
<th style="width: 22%;">Aktionen</th>
</tr>
</thead>
<tbody id="itemsTableBody">
@@ -593,7 +604,7 @@
}
} catch (error) {
console.error('Error loading library items:', error);
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="6" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
document.getElementById('itemsTableBody').innerHTML = '<tr><td colspan="7" style="text-align:center; color:#999;">Fehler beim Laden der Bibliothekselemente.</td></tr>';
} finally {
pagingState.loading = false;
}
@@ -697,6 +708,7 @@
<td>${escapeHtml(item.Autor || item.Author || '-')}</td>
<td>${escapeHtml(item.ISBN || item.Code_4 || item.Code4 || '-')}</td>
<td>${getItemTypeLabel(item.ItemType || 'book')}</td>
<td style="font-weight:600; text-align:center;">${item.Quantity || item.GroupedDisplayCount || 1}</td>
<td>
<span class="table-status ${statusClass}">
${statusText}
@@ -708,6 +720,7 @@
${actionLabel}
</button>
${canEditLibraryItems ? `<button class="button" style="background:#0ea5e9;color:#fff;" onclick="openEditLibraryItem('${item._id}')">Bearbeiten</button>` : ''}
${canEditLibraryItems ? `<button class="button" style="background:#dc2626;color:#fff; margin-left:6px;" onclick="confirmDeleteLibraryItem('${item._id}')">Löschen</button>` : ''}
</td>
</tr>
`;
@@ -727,6 +740,47 @@
}
}
// Confirm and call library item delete endpoint
function confirmDeleteLibraryItem(itemId) {
if (!itemId) return;
if (!confirm('Dieses Bibliotheksmedium revisionssicher löschen? Diese Aktion deaktiviert alle zugehörigen Exemplare und archiviert Medien.')) return;
deleteLibraryItem(itemId);
}
async function deleteLibraryItem(itemId) {
if (!itemId) return;
document.body.style.cursor = 'wait';
try {
const resp = await fetch(`/delete_library_item/${itemId}`, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
'X-CSRF-Token': '{{ csrf_token }}'
},
credentials: 'same-origin'
});
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('application/json')) {
const j = await resp.json();
if (!resp.ok || j.ok === false) {
alert(j.message || 'Fehler beim Löschen');
} else {
alert(j.message || 'Gelöscht');
}
loadLibraryItems();
} else {
// Server redirected to HTML (flash messages). Reload to show state.
location.reload();
}
} catch (e) {
console.error('Delete failed', e);
alert('Fehler beim Löschen des Elements.');
} finally {
document.body.style.cursor = '';
}
}
// Filter toggle
document.getElementById('filterToggleBtn').addEventListener('click', () => {
filterPanelOpen = !filterPanelOpen;
@@ -810,11 +864,27 @@
setActiveStudentCard(cardId);
const durationInput = (window.prompt('Ausleihdauer in Tagen (optional):') || '').trim();
const maxAvailable = Math.max(1, parseInt(selectedItem?.AvailableGroupedCount || selectedItem?.Quantity || 1, 10) || 1);
const countPrompt = (window.prompt(`Anzahl ausleihen? (Standard: 1, verfügbar: ${maxAvailable})`, '1') || '').trim();
let borrowCount = parseInt(countPrompt || '1', 10);
if (!Number.isFinite(borrowCount) || borrowCount < 1) {
borrowCount = 1;
}
if (borrowCount > maxAvailable) {
alert(`Es sind nur ${maxAvailable} Exemplar(e) verfügbar.`);
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = `/ausleihen/${itemId}`;
const csrfField = document.createElement('input');
csrfField.type = 'hidden';
csrfField.name = 'csrf_token';
csrfField.value = '{{ csrf_token }}';
form.appendChild(csrfField);
const cardField = document.createElement('input');
cardField.type = 'hidden';
cardField.name = 'borrower_card_id';
@@ -835,6 +905,12 @@
form.appendChild(durationField);
}
const countField = document.createElement('input');
countField.type = 'hidden';
countField.name = 'exemplare_count';
countField.value = String(borrowCount || 1);
form.appendChild(countField);
document.body.appendChild(form);
form.submit();
}
@@ -1174,6 +1250,7 @@
<label for="editLibraryType">Medientyp</label>
<select id="editLibraryType">
<option value="book">Buch</option>
<option value="schulbuch">Schulbuch</option>
<option value="cd">CD</option>
<option value="dvd">DVD</option>
<option value="media">Sonstige Medien</option>
+2 -154
View File
@@ -1,11 +1,3 @@
<!--
Copyright 2025-2026 AIIrondev
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
See Legal/LICENSE for the full license text.
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
For commercial licensing inquiries: https://github.com/AIIrondev
-->
{% extends "base.html" %}
{% block title %}License - Inventarsystem{% endblock %}
@@ -17,10 +9,6 @@
<!-- Tab navigation -->
<ul class="nav nav-tabs mt-3" id="legalTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-eula" data-bs-toggle="tab" data-bs-target="#pane-eula"
type="button" role="tab">EULA</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-notice" data-bs-toggle="tab" data-bs-target="#pane-notice"
type="button" role="tab">NOTICE</button>
@@ -43,139 +31,12 @@
</li>
</ul>
<div class="tab-content" id="legalTabContent">
<!-- ── EULA ─────────────────────────────────────────────────────── -->
<div class="tab-pane fade show active" id="pane-eula" role="tabpanel">
<div class="license-content">
<!-- EULA -->
<h2>Endbenutzer-Lizenzvertrag (EULA) und Nutzungsbedingungen</h2>
<p><strong>Softwareprojekt:</strong> Inventarsystem<br>
<strong>Urheberrechtshalter (Lizenzgeber):</strong> AIIrondev<br>
<strong>Gültigkeit:</strong> Stand 2026</p>
<hr>
<h3>Präambel</h3>
<p>Dieser Endbenutzer-Lizenzvertrag (im Folgenden „Vertrag") stellt eine rechtsgültige Vereinbarung zwischen Ihnen
(im Folgenden „Lizenznehmer") und dem Urheber <strong>AIIrondev</strong> (im Folgenden „Lizenzgeber") dar.
Durch den Zugriff auf den Quellcode, die Installation, das Kopieren oder die sonstige Nutzung der Software
„Inventarsystem" (im Folgenden „Produkt") erklärt sich der Lizenznehmer mit den nachfolgenden Bedingungen
vollumfänglich einverstanden.</p>
<p>Sollte der Lizenznehmer den Bedingungen dieses Vertrags nicht zustimmen, ist jegliche Nutzung,
Vervielfältigung oder Distribution des Produkts mit sofortiger Wirkung untersagt.</p>
<hr>
<h3>§ 1 Gegenstand der Lizenz und Eigentumsrechte</h3>
<ol>
<li>Das Produkt wird dem Lizenznehmer unter Vorbehalt lizenziert, nicht verkauft. Sämtliche
Eigentumsrechte, Urheberrechte und sonstigen geistigen Eigentumsrechte am Produkt sowie an allen
Kopien davon verbleiben ausschließlich beim Lizenzgeber.</li>
<li>Diese Lizenz gewährt lediglich ein eingeschränktes Nutzungsrecht unter den in diesem Vertrag
explizit genannten Bedingungen.</li>
</ol>
<h3>§ 2 Zulässiger Nutzungskreis (Privatnutzung)</h3>
<ol>
<li>Die unentgeltliche Nutzung des Produkts ist ausschließlich <strong>natürlichen Personen für den
rein privaten, häuslichen Gebrauch</strong> gestattet.</li>
<li>Die Nutzung umfasst die Verwaltung privater Bestände ohne jegliche Gewinnerzielungsabsicht.</li>
<li>Jegliche Nutzung durch <strong>Institutionelle Nutzer</strong> (einschließlich, aber nicht
beschränkt auf: Unternehmen, Einzelunternehmer, Freiberufler, Vereine, Bildungseinrichtungen,
Behörden oder NGOs) ist ausdrücklich <strong>untersagt</strong> und bedarf einer gesonderten,
schriftlichen Lizenzvereinbarung mit dem Lizenzgeber.</li>
</ol>
<h3>§ 3 Funktionale Einschränkungen und Support</h3>
<ol>
<li>Dem Lizenznehmer wird das Produkt in der jeweils vorliegenden Fassung bereitgestellt („As-Is").</li>
<li>Für die kostenlose Privatnutzung besteht <strong>kein Anspruch</strong> auf:
<ul>
<li>Technischen Support oder Beratung.</li>
<li>Bereitstellung von Sicherheits-Updates oder Patches.</li>
<li>Gewährleistung der Kompatibilität mit spezifischen Hardware- oder Softwareumgebungen.</li>
</ul>
</li>
<li>Der Lizenzgeber behält sich das Recht vor, Funktionen in zukünftigen Versionen zu ändern,
einzuschränken oder kostenpflichtig zu gestalten.</li>
</ol>
<h3>§ 4 Wahrung der Urheberbezeichnung (Branding-Klausel)</h3>
<ol>
<li>Das Produkt verfügt über fest integrierte Urheberrechtshinweise, insbesondere die Kennzeichnung
<strong>„Powered by AIIrondev"</strong> in der Benutzeroberfläche (Footer/Menü).</li>
<li>Es ist dem Lizenznehmer untersagt, diese Hinweise zu entfernen, zu modifizieren, zu verdecken oder
deren Sichtbarkeit durch technische Maßnahmen (z. B. Manipulation von CSS, JavaScript oder
Metadaten) zu beeinträchtigen.</li>
<li>Das Entfernen dieser Hinweise führt zum sofortigen und automatischen Erlöschen der
Nutzungslizenz.</li>
</ol>
<h3>§ 5 Verbot der kommerziellen Verwertung &amp; SaaS</h3>
<ol>
<li>Die Bereitstellung des Produkts als Dienstleistung für Dritte (Software-as-a-Service, SaaS),
insbesondere gegen Entgelt oder zur Generierung von Werbeeinnahmen, ist strikt untersagt.</li>
<li>Das Hosting auf öffentlichen Servern mit dem Ziel, Dritten ohne eigene Installation Zugriff auf
die Funktionalität zu gewähren, ist nur mit ausdrücklicher schriftlicher Genehmigung des
Lizenzgebers zulässig.</li>
</ol>
<h3>§ 6 Modifikationen und Contributions</h3>
<ol>
<li>Der Lizenznehmer darf den Quellcode für den privaten Eigenbedarf modifizieren.</li>
<li>Im Falle einer Veröffentlichung von Modifikationen oder der Einreichung von
Verbesserungsvorschlägen (z. B. Pull Requests auf GitHub) räumt der Lizenznehmer dem Lizenzgeber
ein unwiderrufliches, weltweites, zeitlich unbeschränktes und kostenfreies Nutzungs- und
Verwertungsrecht an diesen Änderungen ein. Der Lizenzgeber ist berechtigt, diese Änderungen in
das Hauptprodukt zu übernehmen und unter dieser oder einer anderen Lizenz zu vertreiben.</li>
</ol>
<h3>§ 7 Haftungsbeschränkung</h3>
<ol>
<li>Die Haftung des Lizenzgebers für Schäden, die aus der Nutzung oder Unmöglichkeit der Nutzung des
Produkts entstehen (einschließlich Datenverlust, Betriebsunterbrechung oder entgangener Gewinn),
ist auf Vorsatz und grobe Fahrlässigkeit beschränkt.</li>
<li>Der Lizenzgeber übernimmt keine Haftung für die Richtigkeit der mit dem Produkt verwalteten
Daten.</li>
</ol>
<h3>§ 8 Rechtswahl und Gerichtsstand</h3>
<ol>
<li>Es gilt ausschließlich das Recht der <strong>Bundesrepublik Deutschland</strong> unter Ausschluss
des UN-Kaufrechts (CISG).</li>
<li>Soweit gesetzlich zulässig, wird als Gerichtsstand für alle Streitigkeiten aus diesem Vertrag der
Sitz des Lizenzgebers vereinbart.</li>
<li>Sollten einzelne Bestimmungen dieses Vertrags unwirksam sein, bleibt die Wirksamkeit der übrigen
Bestimmungen unberührt (Salvatorische Klausel).</li>
</ol>
<hr>
<div class="license-exception-notice">
<h3>⚠️ Anfragen für Ausnahmegenehmigungen (Kommerzielle Lizenzen)</h3>
<p>
Bitte kontaktieren Sie den Urheber direkt über das GitHub-Profil:
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">AIIrondev auf GitHub</a>
</p>
</div>
</div><!-- /.license-content -->
</div><!-- /#pane-eula -->
<!-- ── NOTICE ────────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="pane-notice" role="tabpanel">
<div class="license-content">
<h2>NOTICE Urheberrechtliche Hinweise</h2>
<p><strong>Inventarsystem</strong><br>Copyright © 2025-2026 AIIrondev</p>
<p>Dieses Projekt steht unter dem Inventarsystem EULA (Endbenutzer-Lizenzvertrag). Der vollständige Lizenztext
befindet sich in <code>Legal/LICENSE</code>. Die Software wird ausschließlich für den privaten,
nicht-kommerziellen Gebrauch bereitgestellt. Unerlaubte kommerzielle Nutzung, SaaS-Hosting oder das Entfernen
von Branding ist untersagt. Anfragen für kommerzielle Lizenzen:
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">https://github.com/AIIrondev</a></p>
<p><strong>Invario Modul Suite</strong><br>Copyright © 2026 Invario UG</p>
<hr>
<h3>Drittanbieter-Hinweise</h3>
<p>Dieses Projekt nutzt oder referenziert die folgende Drittanbieter-Software. Deren jeweilige Lizenzen gelten
wie angegeben. Dieser NOTICE-Abschnitt dient ausschließlich der Attribution und ändert keine Lizenzbedingungen.</p>
@@ -200,13 +61,6 @@
<ul>
<li>html5-qrcode (MIT) © 2020-2025 Manoj Brahmbhatt (mebjas)</li>
</ul>
<h4>Systemkomponenten (nicht im Repository enthalten, ggf. im Deployment genutzt)</h4>
<ul>
<li>NGINX (2-clause BSD) © Nginx, Inc. and/or its licensors</li>
<li>FFmpeg (LGPL/GPL, je nach Konfiguration) © 2000-2025 FFmpeg developers</li>
</ul>
<hr>
</div>
</div><!-- /#pane-notice -->
@@ -258,9 +112,6 @@
<div class="license-content">
<h2>Sicherheitsrichtlinie (Security Policy)</h2>
<h3>Unterstützte Versionen</h3>
<p>Bitte nutzen Sie immer die neueste Version aus dem <code>main</code>-Branch, um Sicherheitsupdates zu erhalten.</p>
<h3>Datenschutzrechtliche Mechanismen</h3>
<ul>
<li><strong>Passwort-Hashing:</strong> Passwörter werden niemals im Klartext gespeichert.</li>
@@ -272,10 +123,7 @@
<h3>Meldung von Sicherheitslücken</h3>
<div class="license-exception-notice">
<h3>⚠️ Responsible Disclosure</h3>
<p>Falls Sie eine Sicherheitslücke entdecken, öffnen Sie bitte <strong>kein öffentliches Issue</strong>.
Kontaktieren Sie den Maintainer direkt über die
<a href="https://github.com/AIIrondev" target="_blank" rel="noopener noreferrer">GitHub Security Advisory-Funktion</a>
oder per E-Mail.</p>
<p>Falls Sie eine Sicherheitslücke entdecken, wenden sie sich umgehend an die Email: info@invario.eu .
</div>
</div>
</div><!-- /#pane-security -->
+9
View File
@@ -14,6 +14,15 @@
<div class="row justify-content-center">
<div class="col-md-6">
<h2 class="text-center">Login</h2>
{% if current_tenant_id or current_tenant_db %}
<div class="alert alert-info text-center small mb-4" role="alert">
{% if current_tenant_id %}
Aktiver Tenant: <strong>{{ current_tenant_id }}</strong> &middot; Datenbank: <strong>{{ current_tenant_db }}</strong>
{% else %}
Aktive Datenbank: <strong>{{ current_tenant_db }}</strong>
{% endif %}
</div>
{% endif %}
<form method="POST" action="{{ url_for('login') }}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
+142 -56
View File
@@ -459,7 +459,7 @@
<div id="schedule-modal" class="item-modal">
<div class="modal-content">
<span class="close-modal" onclick="closeScheduleModal()">&times;</span>
<h2>Termin planen</h2>
<h2>Reservieren</h2>
<form id="schedule-form">
<input type="hidden" id="schedule-item-id" name="item_id">
@@ -665,18 +665,30 @@
// Set up schedule form submission
setupScheduleFormSubmission();
// Fetch user status to get current username
fetch('/user_status')
.then(response => response.json())
.then(data => {
currentUsername = data.username;
// Fetch user status to get current username and full filter lists
Promise.all([
fetch('/user_status').then(response => response.json()),
fetch('/get_filter').then(response => response.json()),
])
.then(([userData, filterData]) => {
currentUsername = userData.username;
if (filterData.filter1) {
allFilter1Values = new Set(filterData.filter1.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter2) {
allFilter2Values = new Set(filterData.filter2.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter3) {
allFilter3Values = new Set(filterData.filter3.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
// Now load items with username information
loadItems();
// Setup card images display after a short delay to ensure items are loaded
setTimeout(setupCardImagesDisplay, 500);
})
.catch(error => {
console.error('Error fetching user status:', error);
console.error('Error fetching user status or filters:', error);
loadItems(); // Load items anyway in case of error
});
@@ -721,6 +733,10 @@
filter3: []
};
let allFilter1Values = new Set();
let allFilter2Values = new Set();
let allFilter3Values = new Set();
// Add this line to define allItems globally
let allItems = [];
@@ -735,6 +751,7 @@
let mainItemsSentinel = null;
let mainItemsLoadingIndicator = null;
let mainItemsScrollPrefetchBound = false;
let cardVirtualizationObserver = null;
function ensureMainItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
@@ -743,11 +760,14 @@
if (!mainItemsLoadingIndicator) {
mainItemsLoadingIndicator = document.createElement('div');
mainItemsLoadingIndicator.id = 'main-items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator';
mainItemsLoadingIndicator.className = 'items-loading-indicator empty-frames-placeholder';
mainItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px;"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-md-block"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-lg-block"></div>
`;
mainItemsLoadingIndicator.style.display = 'flex';
mainItemsLoadingIndicator.style.width = '100%';
}
itemsContainer.appendChild(mainItemsLoadingIndicator);
@@ -1041,7 +1061,7 @@
:
`<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
${canScheduleItem ? `<button class="schedule-button" type="button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" type="button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<button class="quick-details-button" type="button" onclick="openItemQuick('${item._id}')">Details</button>
</div>
`;
@@ -1083,10 +1103,10 @@
}
});
// Populate filter options
populateFilterOptions('filter1-options', [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
// Populate filter options using server-provided full filter lists when available.
populateFilterOptions('filter1-options', allFilter1Values.size ? [...allFilter1Values].sort() : [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', allFilter2Values.size ? [...allFilter2Values].sort() : [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', allFilter3Values.size ? [...allFilter3Values].sort() : [...filter3Values].sort(), 3);
// Start display from leftmost position on full reload only.
if (!append) {
@@ -1173,6 +1193,59 @@
mainItemsScrollPrefetchBound = true;
}
// Setup DOM virtualization for items
if (!cardVirtualizationObserver) {
cardVirtualizationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const card = entry.target;
if (entry.isIntersecting) {
if (card.dataset.deloaded === 'true') {
card.innerHTML = card.dataset.savedHtml;
card.dataset.deloaded = 'false';
card.classList.remove('is-deloaded');
card.classList.remove('empty-frame');
const favBtn = card.querySelector('.bookmark-btn');
if (favBtn) {
favBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleFavorite(card.dataset.itemId, favBtn, card);
});
}
const cardContent = card.querySelector('.card-content');
if (cardContent) {
cardContent.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'INPUT' ||
e.target.closest('button') || e.target.closest('a') || e.target.closest('.image-container')) return;
e.stopPropagation();
openItemQuick(card.dataset.itemId);
});
}
}
} else {
if (card.dataset.deloaded !== 'true') {
const rect = card.getBoundingClientRect();
if (rect.height > 50) {
card.style.minHeight = rect.height + 'px';
card.dataset.itemId = card.querySelector('.card-content')?.dataset.itemId || '';
card.dataset.savedHtml = card.innerHTML;
card.innerHTML = '';
card.dataset.deloaded = 'true';
card.classList.add('is-deloaded');
card.classList.add('empty-frame');
}
}
}
});
}, {
// Keep approx ~120 items rendered in DOM relative to scroll position.
// Assuming ~100-400px per item/row, this margin unloads elements beyond that range.
rootMargin: isMobileLayout ? '18000px 0px 18000px 0px' : '9000px 0px 9000px 0px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainItemsHasMore) return;
mainItemsObserver = new IntersectionObserver(entries => {
@@ -1718,7 +1791,7 @@
</form>`
: `<button class="ausleihen disabled-button" disabled>${item.BlockedNow ? 'Reserviert' : 'Ausgeliehen'}</button>`
}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<button type="button" class="details-button" onclick="document.getElementById('item-modal').style.display='none';">Schließen</button>
</div>
`;
@@ -2403,29 +2476,29 @@
// Add these functions right after the loadFilterState() function
function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return;
if (!allItems) return;
// Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1;
const filter2Selections = activeFilters.filter2;
// If both filters are empty, show all filter3 values
// If both filters are empty, show all filter3 values from server
if (filter1Selections.length === 0 && filter2Selections.length === 0) {
// Get all unique filter3 values
const allFilter3Values = new Set();
// Include dynamically added ones from loaded items too, just in case
const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value);
combinedFilter3Values.add(value);
}
});
});
// Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3);
populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
return;
}
@@ -3090,44 +3163,45 @@
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow-sm);
padding: 20px;
width: 100%;
max-width: 340px;
flex: 1 1 300px;
scroll-snap-align: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
}
.item-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
padding: 15px;
width: 100%;
max-width: 340px;
flex: 1 1 300px;
scroll-snap-align: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 380px;
}
.item-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
.item-card h3 {
margin-top: 0;
}
.item-card h3 {
margin-top: 0;
font-size: 1.15rem;
margin-bottom: 8px;
}
.item-card .description {
max-height: 100px;
overflow-y: auto;
}
.item-card .description {
max-height: 60px;
overflow-y: auto;
font-size: 0.9rem;
margin-bottom: 8px;
}
.item-card .image-container {
position: relative;
max-width: 300px;
margin: 10px 0;
}
.item-card .image-container {
position: relative;
max-width: 300px;
margin: 8px 0;
}
.item-card .item-image {
width: 100%;
height: auto;
max-height: 300px;
display: none;
object-fit: cover;
}
.item-card .item-image:first-child {
.item-card .item-image {
width: 100%;
height: auto;
max-height: 180px;
display: block;
}
@@ -4598,6 +4672,18 @@ function openItemQuick(id){
}
}
</style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %}
<style>
/* Admin/Main Content Container align */
+120 -28
View File
@@ -395,7 +395,7 @@
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow-sm);
padding: 20px;
padding: 15px;
width: 100%;
max-width: 340px;
flex: 1 1 300px;
@@ -403,6 +403,8 @@
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
content-visibility: auto;
contain-intrinsic-size: 300px 380px;
}
.item-card.bulk-selected {
@@ -418,17 +420,21 @@
.item-card h3 {
margin-top: 0;
font-size: 1.15rem;
margin-bottom: 8px;
}
.item-card .description {
max-height: 100px;
max-height: 60px;
overflow-y: auto;
font-size: 0.9rem;
margin-bottom: 8px;
}
.item-card .image-container {
position: relative;
max-width: 300px;
margin: 10px 0;
margin: 8px 0;
}
.bulk-delete-row {
@@ -477,7 +483,7 @@
.item-card .item-image {
width: 100%;
height: auto;
max-height: 300px;
max-height: 180px;
display: none;
object-fit: cover;
}
@@ -2643,7 +2649,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<div id="schedule-modal" class="item-modal">
<div class="modal-content">
<span class="close-modal" onclick="closeScheduleModal()">&times;</span>
<h2>Termin planen</h2>
<h2>Reservieren</h2>
<form id="schedule-form">
<input type="hidden" id="schedule-item-id" name="item_id">
<div class="form-group" id="schedule-specific-item-group" style="display:none;">
@@ -2738,6 +2744,9 @@ document.addEventListener('DOMContentLoaded', ()=>{
let descSearchIds = null; // Set of matching IDs or null when disabled
let currentUsername = '';
let allItems = []; // Cache for all items
let allFilter1Values = new Set();
let allFilter2Values = new Set();
let allFilter3Values = new Set();
let bulkDeleteSelection = new Set();
let bulkDeleteDrawerOpen = false;
const studentCardsModuleEnabled = {{ 'true' if student_cards_module_enabled else 'false' }};
@@ -2876,30 +2885,29 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
function rebuildFilter3Options() {
if (!allItems || allItems.length === 0) return;
if (!allItems) return;
// Get current filter 1 and filter 2 selections
const filter1Selections = activeFilters.filter1;
const filter2Selections = activeFilters.filter2;
// If both filters are empty, show all filter3 values
// If both filters are empty, show all filter3 values from server
if (filter1Selections.length === 0 && filter2Selections.length === 0) {
// Get all unique filter3 values
const allFilter3Values = new Set();
// Include dynamically added ones from loaded items too, just in case
const combinedFilter3Values = new Set(typeof allFilter3Values !== 'undefined' ? [...allFilter3Values] : []);
allItems.forEach(item => {
const filter3Array = Array.isArray(item.Filter3) ? item.Filter3 :
(item.Filter3 ? [item.Filter3] : []);
(item.Filter3 ? [item.Filter3] : []);
filter3Array.forEach(value => {
if (value && typeof value === 'string' && value.trim() !== '') {
allFilter3Values.add(value);
combinedFilter3Values.add(value);
}
});
});
// Populate filter 3 dropdown with all values
populateFilterOptions('filter3-options', [...allFilter3Values].sort(), 3);
return;
populateFilterOptions('filter3-options', [...combinedFilter3Values].sort(), 3);
}
// Filter items based on filter1 and filter2 selections
@@ -3258,6 +3266,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
// Load predefined filter values for dropdowns
loadPredefinedFilterValues(1);
loadPredefinedFilterValues(2);
loadPredefinedFilterValues(3);
// Set up edit form submission
setupEditFormSubmission();
@@ -3282,11 +3291,24 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
});
// Fetch user status to get current username
fetch('/user_status')
.then(response => response.json())
.then(data => {
currentUsername = data.username;
// Fetch user status and global filters
Promise.all([
fetch('/user_status').then(response => response.json()),
fetch('/get_filter').then(response => response.json())
])
.then(([userData, filterData]) => {
currentUsername = userData.username;
if (filterData.filter1) {
allFilter1Values = new Set(filterData.filter1.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter2) {
allFilter2Values = new Set(filterData.filter2.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
if (filterData.filter3) {
allFilter3Values = new Set(filterData.filter3.filter(value => value && typeof value === 'string' && value.trim() !== ''));
}
// Now load items with username information
loadItems();
// Setup card images display after a short delay to ensure items are loaded
@@ -3295,7 +3317,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
}, 300);
})
.catch(error => {
console.error('Error fetching user status:', error);
console.error('Error fetching user status or filters:', error);
loadItems(); // Load items anyway in case of error
});
@@ -3356,6 +3378,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
let mainAdminItemsLoadingIndicator = null;
let mainAdminItemsScrollPrefetchBound = false;
let totalItemsInSystem = 0;
let cardVirtualizationObserver = null;
function ensureMainAdminItemsLoadingIndicator() {
const itemsContainer = document.querySelector('#items-container');
@@ -3364,11 +3387,14 @@ document.addEventListener('DOMContentLoaded', ()=>{
if (!mainAdminItemsLoadingIndicator) {
mainAdminItemsLoadingIndicator = document.createElement('div');
mainAdminItemsLoadingIndicator.id = 'main-admin-items-loading-indicator';
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator';
mainAdminItemsLoadingIndicator.className = 'items-loading-indicator empty-frames-placeholder';
mainAdminItemsLoadingIndicator.innerHTML = `
<div class="loading-track"><div class="loading-fill"></div></div>
<div class="loading-label">Weitere Objekte werden vorbereitet...</div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px;"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-md-block"></div>
<div class="empty-frame p-3 mx-2" style="flex: 1; height: 100px; border-radius: 12px; background: rgba(255,255,255,0.02); min-width: 250px; display: none;" class="d-none d-lg-block"></div>
`;
mainAdminItemsLoadingIndicator.style.display = 'flex';
mainAdminItemsLoadingIndicator.style.width = '100%';
}
itemsContainer.appendChild(mainAdminItemsLoadingIndicator);
@@ -3654,7 +3680,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
</form>
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-card-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
</div>
`;
itemsContainer.appendChild(card);
@@ -3695,10 +3721,10 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
});
// Populate filter options
populateFilterOptions('filter1-options', [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', [...filter3Values].sort(), 3);
// Populate filter options using server-provided full filter lists when available.
populateFilterOptions('filter1-options', allFilter1Values.size ? [...allFilter1Values].sort() : [...filter1Values].sort(), 1);
populateFilterOptions('filter2-options', allFilter2Values.size ? [...allFilter2Values].sort() : [...filter2Values].sort(), 2);
populateFilterOptions('filter3-options', allFilter3Values.size ? [...allFilter3Values].sort() : [...filter3Values].sort(), 3);
if (!append) {
itemsContainer.scrollLeft = 0;
@@ -3783,6 +3809,60 @@ document.addEventListener('DOMContentLoaded', ()=>{
mainAdminItemsScrollPrefetchBound = true;
}
// Setup DOM virtualization for items
if (!cardVirtualizationObserver) {
cardVirtualizationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const card = entry.target;
if (entry.isIntersecting) {
if (card.dataset.deloaded === 'true') {
card.innerHTML = card.dataset.savedHtml;
card.dataset.deloaded = 'false';
card.classList.remove('is-deloaded');
card.classList.remove('empty-frame');
const favBtn = card.querySelector('.bookmark-btn');
if (favBtn) {
favBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleFavorite(card.dataset.itemId, favBtn, card);
});
}
const cardContent = card.querySelector('.card-content');
if (cardContent) {
cardContent.addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A' || e.target.tagName === 'INPUT' ||
e.target.closest('button') || e.target.closest('a') || e.target.closest('.image-container') ||
e.target.closest('.bulk-delete-toggle') || e.target.closest('label')) return;
e.stopPropagation();
openItemQuick(card.dataset.itemId);
});
}
}
} else {
if (card.dataset.deloaded !== 'true') {
const rect = card.getBoundingClientRect();
if (rect.height > 50) {
card.style.minHeight = rect.height + 'px';
card.dataset.itemId = card.querySelector('.card-content')?.dataset.itemId || '';
card.dataset.savedHtml = card.innerHTML;
card.innerHTML = '';
card.dataset.deloaded = 'true';
card.classList.add('is-deloaded');
card.classList.add('empty-frame');
}
}
}
});
}, {
// Keep approx ~120 items rendered in DOM relative to scroll position.
// Assuming ~100-400px per item/row, this margin unloads elements beyond that range.
rootMargin: isMobileLayout ? '18000px 0px 18000px 0px' : '9000px 0px 9000px 0px'
});
}
// Observe all valid cards
document.querySelectorAll('.item-card').forEach(card => cardVirtualizationObserver.observe(card));
if (!mainAdminItemsHasMore) return;
mainAdminItemsObserver = new IntersectionObserver(entries => {
@@ -4531,7 +4611,7 @@ document.addEventListener('DOMContentLoaded', ()=>{
<button class="edit-button" onclick="openEditModalForSelectedUnit('${item._id}', 'specific-item-modal-${item._id}')">Bearbeiten</button>
<button class="duplicate-button" onclick="duplicateItem('${item._id}')">Duplizieren</button>
${damageReports.length > 0 ? `<button class="damage-button" onclick="markDamageAsRepaired('${item._id}')">Repariert</button>` : `<button class="damage-button" onclick="registerDamage('${item._id}')">Schaden melden</button>`}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Termin planen</button>` : ''}
${canScheduleItem ? `<button class="schedule-button" onclick="openScheduleModal('${item._id}')">Reservieren</button>` : ''}
<form method="POST" action="/delete_item/${item._id}" style="display:inline;" onsubmit="return confirm('Sind Sie sicher?')">
<button class="delete-button" type="submit">Löschen</button>
</form>
@@ -5716,4 +5796,16 @@ document.addEventListener('DOMContentLoaded', ()=>{
}
}
</style>
{% if open_item %}
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof openEditModalFromServer === 'function') {
setTimeout(function() {
openEditModalFromServer('{{ open_item }}');
}, 500);
}
});
</script>
{% endif %}
{% endblock %}
+105 -22
View File
@@ -15,16 +15,16 @@
<h1 class="mb-4">Filterwerte verwalten</h1>
<div class="row">
<!-- Filter 1: Unterrichtsfach -->
<div class="col-md-6">
<!-- Filter 1 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">Unterrichtsfach (Filter 1)</h2>
<h2 class="card-title h5 mb-0">{{ filter_names.get('1', 'Fach/Kategorie') }} (Filter 1)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=1) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neues Unterrichtsfach..." required>
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
@@ -33,13 +33,25 @@
{% if filter1_values %}
<div class="list-group">
{% for value in filter1_values %}
<div class="list-group-item d-flex justify-content-between align-items-center">
{{ value }}
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ value }}</span>
<div>
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-1-{{ loop.index }}')">Bearbeiten</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=1, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
</div>
<form id="edit-1-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=1, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-1-{{ loop.index }}')">Abbrechen</button>
</div>
</form>
</div>
{% endfor %}
@@ -51,16 +63,16 @@
</div>
</div>
<!-- Filter 2: Jahrgangsstufe -->
<div class="col-md-6">
<!-- Filter 2 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">Jahrgangsstufe (Filter 2)</h2>
<h2 class="card-title h5 mb-0">{{ filter_names.get('2', 'System/Bereich') }} (Filter 2)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=2) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neue Jahrgangsstufe..." required>
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
@@ -69,13 +81,73 @@
{% if filter2_values %}
<div class="list-group">
{% for value in filter2_values %}
<div class="list-group-item d-flex justify-content-between align-items-center">
{{ value }}
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ value }}</span>
<div>
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-2-{{ loop.index }}')">Bearbeiten</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=2, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
</div>
<form id="edit-2-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=2, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-2-{{ loop.index }}')">Abbrechen</button>
</div>
</form>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Keine Werte definiert.</div>
{% endif %}
</div>
</div>
</div>
<!-- Filter 3 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header">
<h2 class="card-title h5 mb-0">{{ filter_names.get('3', 'Typ/Art') }} (Filter 3)</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('add_filter_value', filter_num=3) }}" class="mb-4">
<div class="input-group">
<input type="text" name="value" class="form-control" placeholder="Neuer Wert..." required>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
<h5 class="mb-3">Vorhandene Werte</h5>
{% if filter3_values %}
<div class="list-group">
{% for value in filter3_values %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>{{ value }}</span>
<div>
<button type="button" class="btn btn-sm btn-secondary me-1" onclick="toggleEdit('edit-3-{{ loop.index }}')">Bearbeiten</button>
<form method="POST" action="{{ url_for('remove_filter_value', filter_num=3, value=value) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Sind Sie sicher, dass Sie den Wert \"' + '{{ value }}'.replace(/'/g, '\\\'') + '\" löschen möchten?');">
Entfernen
</button>
</form>
</div>
</div>
<form id="edit-3-{{ loop.index }}" method="POST" action="{{ url_for('edit_filter_value', filter_num=3, old_value=value) }}" style="display: none;" class="mt-2">
<div class="input-group input-group-sm">
<input type="text" name="new_value" class="form-control" value="{{ value }}" required>
<button type="submit" class="btn btn-primary" onclick="return confirm('Tipp: Das Ändern des Namens aktualisiert auch alle Einträge in der Datenbank, die diesen Filter verwenden. Fortfahren?');">Speichern</button>
<button type="button" class="btn btn-secondary" onclick="toggleEdit('edit-3-{{ loop.index }}')">Abbrechen</button>
</div>
</form>
</div>
{% endfor %}
@@ -97,4 +169,15 @@
<a href="{{ url_for('home_admin') }}" class="btn btn-secondary">Zurück zur Admin-Übersicht</a>
</div>
</div>
<script>
function toggleEdit(id) {
const el = document.getElementById(id);
if (el.style.display === 'none') {
el.style.display = 'block';
} else {
el.style.display = 'none';
}
}
</script>
{% endblock %}
+1 -1
View File
@@ -55,7 +55,7 @@
<div class="form-group">
<label for="password">Passwort</label>
<div class="password-rules" id="password-rules" aria-live="polite">
<p class="password-rules-title">Passwort-Anforderungen (live):</p>
<p class="password-rules-title">Passwort-Anforderungen:</p>
<ul>
<li id="pw-rule-length" class="pw-rule">Mindestens 12 Zeichen</li>
<li id="pw-rule-lower" class="pw-rule">Mindestens ein Kleinbuchstabe</li>
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Termin buchen - Inventarsystem{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-xl-10">
<div class="row g-4">
<div class="col-12 col-lg-5">
<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 einen freien Zeitpunkt und tragen Sie Ihren Namen ein. Der Termin wird anschließend im Plan gespeichert.</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>
{% 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 booking.name %} - {{ booking.name }}{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-12 col-lg-7">
<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-4">Buchung absenden</h2>
<form method="post" action="{{ url_for('terminplaner.client', appointment_id=appointment_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="2026-05-29 10:30" required>
<div class="form-text">Tragen Sie Datum und Uhrzeit im Format YYYY-MM-DD HH:MM ein, sofern kein Kalenderfeld genutzt wird.</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') }}">Zur Übersicht</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
{% 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>
<label for="time_frame" class="form-label fw-semibold">Zeitfenster</label>
<textarea id="time_frame" name="time_frame" class="form-control" rows="5" placeholder="Beispiele:\nMontag 08:00-12:00\nDienstag 08:00-12:00" required></textarea>
<div class="form-text">Jede Zeile kann ein eigenes Zeitfenster enthalten.</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="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">Teilen Sie diesen Link mit den Teilnehmenden:</div>
<a href="{{ generated_link }}" class="d-inline-block text-break">{{ generated_link }}</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
{% 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') }}">Neue Planung</a>
<a class="btn btn-outline-light btn-lg fw-semibold" href="{{ url_for('terminplan') }}">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') }}">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') }}">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>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+63
View File
@@ -189,6 +189,38 @@
font-size: 1rem;
}
.tutorial-tooltip-toggle {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 20px;
padding: 16px;
border-radius: 18px;
background: rgba(224, 242, 254, 0.9);
border: 1px solid #bae6fd;
}
.tutorial-tooltip-toggle label {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
color: #0f172a;
}
.tutorial-tooltip-toggle input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: #2563eb;
}
.tooltip-description {
margin: 0;
color: #475569;
font-size: 0.95rem;
line-height: 1.5;
}
.workflow-controls {
display: flex;
gap: 8px;
@@ -230,6 +262,13 @@
<span class="tutorial-pill">Format: Ruhig und geführt</span>
<span class="tutorial-pill">Benutzer: {{ username }}</span>
</div>
<div class="tutorial-tooltip-toggle">
<label for="toggleTutorialTooltips">
<input type="checkbox" id="toggleTutorialTooltips" />
Tutorial-Tooltips für das gesamte System aktivieren
</label>
<p class="tooltip-description">Wenn aktiviert, erscheinen auf allen Seiten erklärende Hinweise und Icons für die wichtigsten Navigationselemente.</p>
</div>
</section>
<div class="tutorial-grid">
@@ -438,8 +477,32 @@
renderStep();
});
const tooltipToggle = document.getElementById('toggleTutorialTooltips');
const tooltipModeKey = 'inventarsystem_tutorial_tooltips_enabled_{{ username }}';
function setTooltipMode(enabled) {
localStorage.setItem(tooltipModeKey, enabled ? '1' : '0');
document.body.classList.toggle('tutorial-tooltips-active', enabled);
}
function loadTooltipMode() {
const saved = localStorage.getItem(tooltipModeKey);
const enabled = saved === '1';
if (tooltipToggle) {
tooltipToggle.checked = enabled;
}
setTooltipMode(enabled);
}
if (tooltipToggle) {
tooltipToggle.addEventListener('change', function () {
setTooltipMode(this.checked);
});
}
loadState();
renderStep();
loadTooltipMode();
})();
</script>
{% endblock %}
+93 -40
View File
@@ -709,7 +709,6 @@
</style>
<div class="upload-container">
<a href="{{ url_for(back_target|default('home_admin')) }}" class="nav-back-button">← Zurück zur Artikelübersicht</a>
{% if show_library_features %}
<div style="border:1px solid #dbe4ee; border-radius:8px; padding:14px; margin-bottom:16px; background:#f8fbff;">
@@ -717,6 +716,8 @@
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, ISBN, Code, Anzahl). Für den Bibliotheksimport ist eine gültige ISBN je Zeile erforderlich.</p>
<form method="POST" action="{{ url_for('upload_library_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="library_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('library')">Beispiel-CSV herunterladen</button>
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('library')">?</button>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Bibliothek importieren</button>
</form>
@@ -727,6 +728,8 @@
<p style="margin:0 0 10px 0; color:#555;">Laden Sie eine <strong>.xlsx</strong>- oder <strong>.csv</strong>-Datei hoch. Spalten werden automatisch erkannt (z.B. Name, Ort, Beschreibung, Filter1/2/3, Kosten, Jahr, Code, Anzahl).</p>
<form method="POST" action="{{ url_for('upload_inventory_excel') }}" enctype="multipart/form-data" style="display:flex; gap:10px; flex-wrap:wrap; align-items:center;">
<input type="file" name="inventory_excel" accept=".xlsx,.csv" required>
<button type="button" class="btn btn-link" onclick="downloadSampleCsv('inventory')">Beispiel-CSV herunterladen</button>
<button type="button" class="btn btn-outline-secondary" title="Format-Hilfe" onclick="showCsvHelp('inventory')">?</button>
<button type="submit" class="btn btn-secondary" name="excel_action" value="validate">Nur validieren</button>
<button type="submit" class="btn btn-primary" name="excel_action" value="import">Inventar importieren</button>
</form>
@@ -884,7 +887,7 @@
<div class="form-group">
<label for="individual_codes">Einzelcodes (optional, je Zeile ein Code)</label>
<textarea id="individual_codes" name="individual_codes" rows="4" placeholder="z.B.\nABC-001\nABC-002"></textarea>
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Scanner-Eingaben werden bei Mehrfachanzahl automatisch hier angefügt.</small>
<small style="display:block; color:#666;">Bei Anzahl > 1 können hier individuelle Codes pro Item gesetzt werden. Der Scanner setzt immer zuerst den Basis-Code im Feld oben; weitere Einzelcodes können hier bei Bedarf manuell ergänzt werden.</small>
</div>
<!-- Image upload (hidden for library mode) -->
<div class="form-group" {% if show_library_features %}style="display:none;"{% endif %}>
@@ -905,7 +908,7 @@
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button>
</div>
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Das Buchcover wird automatisch heruntergeladen.</small>
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
<div id="book-info-container" class="book-info-container"></div>
</div>
@@ -921,6 +924,53 @@
</div>
</div>
<!-- CSV Help Modal -->
<div id="csv-help-modal" class="popup-overlay" style="display:none;">
<div class="duplicate-code-popup" style="max-width:760px;">
<button class="popup-close-x" onclick="hideCsvHelp()">×</button>
<div class="popup-content">
<h3>CSV Import Format</h3>
<p id="csv-help-text" style="text-align:left; color:var(--ui-text);"></p>
<div style="margin-top:12px; text-align:center;">
<button class="popup-close-button" onclick="hideCsvHelp()">Schließen</button>
</div>
</div>
</div>
</div>
<script>
function downloadSampleCsv(scope) {
let headers = ['name','ort','beschreibung','filter1','filter2','filter3','anschaffungsjahr','anschaffungskosten','code_4','anzahl','isbn','item_type'];
let exampleInventory = ['Projektor','Aula','HD-Projektor für Präsentationen','Medien','Technik','','2019','450.00','PRJ-001','1','','general'];
let exampleLibrary = ['Harry Potter und der Stein der Weisen','Bibliothek','Kinderbuch von J.K. Rowling','Belletristik','','','1997','12.99','HP-001','1','9783551354013','book'];
let row = exampleInventory;
if (scope === 'library') row = exampleLibrary;
const csvContent = [headers.join(','), row.map(v=> '"'+String(v).replace(/"/g,'""')+'"').join(',')].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = scope === 'library' ? 'sample_library_import.csv' : 'sample_inventory_import.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function showCsvHelp(scope) {
const invText = `Erlaubte Spalten (Reihenfolge beliebig): name, ort, beschreibung, filter1, filter2, filter3, anschaffungsjahr, anschaffungskosten, code_4, anzahl, isbn, item_type.\n\n` +
`Hinweise:\n- Trenne Felder per Komma. Textfelder sollten in Anführungszeichen stehen, wenn sie Kommas enthalten.\n- Für Bibliotheks-Import ist eine gültige ISBN pro Zeile erforderlich (ISBN-10 oder ISBN-13).\n- 'code_4' kann leer bleiben; das System erzeugt in diesem Fall Codes.\n- 'anzahl' legt die Anzahl physischer Exemplare für diese Zeile fest.`;
const libText = invText;
document.getElementById('csv-help-text').textContent = scope === 'library' ? libText : invText;
document.getElementById('csv-help-modal').style.display = 'flex';
}
function hideCsvHelp() {
document.getElementById('csv-help-modal').style.display = 'none';
}
</script>
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
<script>
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
@@ -966,33 +1016,30 @@
return '';
}
function appendIndividualCode(scannedCode) {
const textarea = document.getElementById('individual_codes');
const itemCountInput = document.getElementById('item_count');
if (!textarea || !itemCountInput) return false;
function updateIsbnLiveValidation() {
const isbnField = document.getElementById('isbn');
const statusEl = document.getElementById('isbn-scan-status');
if (!isbnField || !statusEl) return;
const itemCount = Math.max(1, parseInt(itemCountInput.value || '1', 10));
if (itemCount <= 1) return false;
const rawValue = isbnField.value.trim();
isbnField.classList.remove('code-valid', 'code-invalid');
const existing = textarea.value
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
if (existing.includes(scannedCode)) {
setCode4ScanStatus(`Code ${scannedCode} ist bereits eingetragen.`, true);
return true;
if (!rawValue) {
statusEl.textContent = 'Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.';
statusEl.style.color = '#666';
return;
}
if (existing.length >= itemCount) {
setCode4ScanStatus('Es sind bereits genug Einzelcodes für die gewählte Anzahl eingetragen.', true);
return true;
if (normalizeIsbnClient(rawValue)) {
isbnField.classList.add('code-valid');
statusEl.textContent = 'Gültiges ISBN-Format erkannt. Der Wert kann gespeichert werden.';
statusEl.style.color = '#666';
return;
}
existing.push(scannedCode);
textarea.value = existing.join('\n');
setCode4ScanStatus(`Code ${scannedCode} hinzugefügt (${existing.length}/${itemCount}).`);
return true;
isbnField.classList.add('code-invalid');
statusEl.textContent = 'Kein gültiges ISBN-10/13-Format erkannt. Der Wert wird trotzdem akzeptiert.';
statusEl.style.color = '#b00020';
}
function startCode4Scanner() {
@@ -1028,7 +1075,7 @@
rememberLastUsedCamera: true
});
code4ScannerRunning = true;
setCode4ScanStatus('Scanner läuft. Mehrere Codes nacheinander möglich.');
setCode4ScanStatus('Scanner läuft. Der Scan wird im Basis-Codefeld übernommen.');
code4ScannerInstance.render((decodedText) => {
const scannedCode = String(decodedText || '').trim();
@@ -1041,12 +1088,9 @@
code4LastScanned = scannedCode;
code4LastScannedAt = now;
const usedForIndividual = appendIndividualCode(scannedCode);
if (!usedForIndividual) {
codeField.value = scannedCode;
validateCodeField(codeField);
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
}
codeField.value = scannedCode;
validateCodeField(codeField);
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
}, () => {});
}
@@ -1095,21 +1139,23 @@
if (!scannedCode) return;
const normalizedIsbn = normalizeIsbnClient(scannedCode);
if (!normalizedIsbn) {
setIsbnScanStatus('Kein gültiger ISBN-Barcode erkannt. Bitte erneut scannen.', true);
return;
isbnField.value = normalizedIsbn || scannedCode;
updateIsbnLiveValidation();
if (normalizedIsbn) {
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
} else {
setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
}
isbnField.value = normalizedIsbn;
setIsbnScanStatus(`ISBN gesetzt: ${normalizedIsbn}`);
isbnScannerInstance.clear().catch(() => {});
scannerBox.style.display = 'none';
scanButton.textContent = 'ISBN scannen';
isbnScannerRunning = false;
// Automatically load book metadata after scan.
fetchBookInfo('upload');
if (normalizedIsbn) {
// Automatically load book metadata after a valid ISBN scan.
fetchBookInfo('upload');
}
}, () => {});
}
@@ -1232,7 +1278,7 @@
const isbn = normalizeIsbnClient(isbnField.value);
if (!isbn) {
alert('Bitte eine gültige ISBN-10 oder ISBN-13 eingeben.');
infoContainer.innerHTML = '<div class="error-message">Bitte geben Sie eine ISBN oder einen Barcode ein.</div>';
return;
}
@@ -1646,6 +1692,13 @@
if (scanIsbnBtn) {
scanIsbnBtn.addEventListener('click', startIsbnScanner);
}
const isbnField = document.getElementById('isbn');
if (isbnField) {
isbnField.addEventListener('input', updateIsbnLiveValidation);
isbnField.addEventListener('blur', updateIsbnLiveValidation);
updateIsbnLiveValidation();
}
// Setup add new location button
const addLocationBtn = document.getElementById('add-new-location-btn');
+540 -28
View File
@@ -7,31 +7,468 @@ Supports subdomain-based tenant identification and per-tenant database namespaci
Each tenant can support up to 20+ users with isolated data and resource pools.
"""
from flask import request, g, has_request_context
from flask import request, g, session, has_request_context
from functools import wraps
import datetime
import json
import logging
import os
import re
import ipaddress
import Web.modules.database.settings as cfg
from Web.modules.database.settings import MongoClient
logger = logging.getLogger(__name__)
_TENANT_REGISTRY_MTIME = None
def _load_tenant_registry_from_config():
registry = {}
config_path = getattr(cfg, 'CONFIG_PATH', None)
if config_path and os.path.isfile(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as handle:
config = json.load(handle)
tenants = config.get('tenants', {})
if isinstance(tenants, dict):
registry.update(tenants)
except Exception as exc:
logger.warning("Failed to load tenant registry from config: %s", exc)
elif isinstance(getattr(cfg, 'TENANT_CONFIGS', None), dict):
registry.update(cfg.TENANT_CONFIGS)
return registry
def _refresh_tenant_registry():
global TENANT_REGISTRY, _TENANT_REGISTRY_MTIME
config_path = getattr(cfg, 'CONFIG_PATH', None)
current_mtime = None
if config_path and os.path.isfile(config_path):
try:
current_mtime = os.path.getmtime(config_path)
except OSError:
current_mtime = None
if current_mtime == _TENANT_REGISTRY_MTIME:
return TENANT_REGISTRY
TENANT_REGISTRY.clear()
TENANT_REGISTRY.update(_load_tenant_registry_from_config())
_TENANT_REGISTRY_MTIME = current_mtime
return TENANT_REGISTRY
# Tenant registry: maps subdomain/tenant_id to database name
TENANT_REGISTRY = {}
TENANT_REGISTRY = _load_tenant_registry_from_config()
def _get_nested_value(source, path, default=None):
current = source
for key in path:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return default
return current
def _parse_tenant_db_map():
mapping = {}
raw_map = os.getenv('INVENTAR_TENANT_DB_MAP', '').strip()
if not raw_map:
return mapping
for mapping_pair in re.split(r'[;,\s]+', raw_map):
if '=' not in mapping_pair:
continue
key, value = mapping_pair.split('=', 1)
mapping[key.strip()] = value.strip()
return mapping
def _parse_port_from_host(host):
"""Parse host header and return (hostname, port) when a numeric port is present."""
if host.startswith('['):
# IPv6 with port: [::1]:10000
if ']:' in host:
host_part, _, port_part = host.rpartition(']:')
return host_part + ']', port_part
return host, None
if host.count(':') == 1:
hostname, port = host.split(':', 1)
if port.isdigit():
return hostname, port
return host, None
def _first_host_token(value):
raw = str(value or '').strip()
if not raw:
return ''
return raw.split(',', 1)[0].strip().lower()
def _request_host_candidates():
candidates = []
for header_name in ('X-Forwarded-Host', 'X-Original-Host', 'Host'):
token = _first_host_token(request.headers.get(header_name, ''))
if token and token not in candidates:
candidates.append(token)
direct_host = _first_host_token(getattr(request, 'host', ''))
if direct_host and direct_host not in candidates:
candidates.append(direct_host)
return candidates
def _tenant_id_for_port(port):
"""Map a host port to a registered tenant ID via tenant configs or env overrides."""
for tenant_id, config in TENANT_REGISTRY.items():
if isinstance(config, dict) and config.get('port') is not None:
try:
configured_port = str(int(config.get('port')))
except (TypeError, ValueError):
continue
if configured_port == str(port):
return tenant_id
port_map = os.getenv('INVENTAR_TENANT_PORT_MAP', '').strip()
if port_map:
for mapping in re.split(r'[;,\s]+', port_map):
if '=' not in mapping:
continue
key, value = mapping.split('=', 1)
if key.strip() == str(port):
return value.strip()
return None
def _find_registered_tenant_id(candidate):
candidate = str(candidate or '').strip()
if not candidate:
return None
if candidate in TENANT_REGISTRY:
return candidate
lowered = candidate.lower()
for tenant_id in TENANT_REGISTRY:
if str(tenant_id).lower() == lowered:
return tenant_id
return None
def _is_ip_host(hostname):
hostname = str(hostname or '').strip()
if not hostname:
return False
try:
ipaddress.ip_address(hostname)
return True
except ValueError:
return False
def get_tenant_config(tenant_id=None):
"""Return the registered config for a tenant, falling back to default."""
_refresh_tenant_registry()
if tenant_id is None:
ctx = get_tenant_context()
tenant_id = ctx.tenant_id if ctx and ctx.tenant_id else 'default'
if tenant_id in TENANT_REGISTRY:
return TENANT_REGISTRY[tenant_id] or {}
for alias in _tenant_db_aliases(tenant_id):
if alias in TENANT_REGISTRY:
return TENANT_REGISTRY[alias] or {}
return TENANT_REGISTRY.get('default', {}) or {}
def _normalize_db_name(db_name):
if not db_name:
return None
db_name = str(db_name).strip()
if db_name and not db_name.startswith('inventar_'):
db_name = f'inventar_{db_name}'
return db_name
def _module_name_candidates(module_name):
normalized = str(module_name or '').strip().lower().replace('-', '_')
if not normalized:
return []
candidates = [normalized]
alias_groups = {
'inventory': {'inventory', 'inventar'},
'terminplan': {'terminplan', 'terminplaner', 'termin', 'appointments'},
'library': {'library', 'bib', 'bibliothek'},
'student_cards': {'student_cards', 'studentcards', 'schuelerausweise', 'schueler_ausweise'},
}
for canonical_name, aliases in alias_groups.items():
if normalized in aliases:
for alias in (canonical_name, *sorted(aliases)):
if alias not in candidates:
candidates.append(alias)
break
return candidates
def _tenant_db_aliases(tenant_id):
aliases = []
env_map = _parse_tenant_db_map()
if tenant_id in env_map:
aliases.append(env_map[tenant_id])
if tenant_id.lower().startswith('schule'):
aliases.append(tenant_id.lower().replace('schule', 'school', 1))
elif tenant_id.lower().startswith('school'):
aliases.append(tenant_id.lower().replace('school', 'schule', 1))
return [alias for alias in aliases if alias]
def _resolve_db_alias(tenant_id, db_name):
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
available_databases = client.list_database_names()
if db_name in available_databases:
return db_name
for alias in _tenant_db_aliases(tenant_id):
candidate = _normalize_db_name(alias)
if candidate in available_databases:
logger.warning(
"Tenant DB fallback applied for tenant %r: %r -> %r",
tenant_id,
db_name,
candidate,
)
return candidate
except Exception as exc:
logger.exception("Tenant DB alias resolution failed for %r: %s", tenant_id, exc)
return db_name
def is_tenant_module_enabled(module_name, tenant_id=None, default=False):
"""Resolve whether a feature module is enabled for the current tenant."""
config = get_tenant_config(tenant_id)
for candidate_name in _module_name_candidates(module_name):
enabled = _get_nested_value(config, ['modules', candidate_name, 'enabled'], None)
if enabled is not None:
return bool(enabled)
return bool(default)
def _parse_datetime_value(value):
if value is None or value == '':
return None
if isinstance(value, datetime.datetime):
parsed = value
else:
text = str(value).strip()
if not text:
return None
try:
parsed = datetime.datetime.fromisoformat(text.replace('Z', '+00:00'))
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return parsed
def get_tenant_trial_config(tenant_id=None):
"""Return the optional trial/demo lifecycle settings for a tenant."""
config = get_tenant_config(tenant_id)
trial_config = _get_nested_value(config, ['trial'], {})
if not isinstance(trial_config, dict):
trial_config = {}
demo_config = _get_nested_value(config, ['demo'], {})
if isinstance(demo_config, dict):
merged = dict(demo_config)
merged.update(trial_config)
trial_config = merged
return trial_config
def get_tenant_trial_status(tenant_id=None, now=None):
"""Compute the current trial status for a tenant.
The config may define either an absolute "expires_at" timestamp or a
relative lifetime via "started_at"/"created_at" plus one of
"expires_after_days", "ttl_days", or "days".
"""
trial_config = get_tenant_trial_config(tenant_id)
now = now or datetime.datetime.now()
enabled = bool(trial_config.get('enabled') or trial_config.get('active'))
if not enabled:
return {
'enabled': False,
'expired': False,
'auto_delete': bool(trial_config.get('auto_delete', False)),
'started_at': None,
'expires_at': None,
'days_left': None,
}
started_at = _parse_datetime_value(
trial_config.get('started_at')
or trial_config.get('created_at')
or trial_config.get('activated_at')
)
expires_at = _parse_datetime_value(trial_config.get('expires_at'))
if expires_at is None:
duration_days = trial_config.get('expires_after_days')
if duration_days is None:
duration_days = trial_config.get('ttl_days')
if duration_days is None:
duration_days = trial_config.get('days')
try:
duration_days = int(duration_days) if duration_days is not None else None
except (TypeError, ValueError):
duration_days = None
if duration_days is not None:
base_time = started_at or now
expires_at = base_time + datetime.timedelta(days=max(0, duration_days))
expired = bool(expires_at and now >= expires_at)
days_left = None
if expires_at:
remaining = expires_at - now
days_left = max(0, int(remaining.total_seconds() // 86400))
return {
'enabled': True,
'expired': expired,
'auto_delete': bool(trial_config.get('auto_delete', False)),
'started_at': started_at,
'expires_at': expires_at,
'days_left': days_left,
}
def _get_tenant_db_name_from_config(tenant_id):
config = get_tenant_config(tenant_id)
explicit_db = None
if isinstance(config, dict):
explicit_db = config.get('db') or config.get('db_name')
if explicit_db:
return _normalize_db_name(explicit_db)
sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in str(tenant_id).lower())
return f'inventar_{sanitized}' if sanitized else cfg.MONGODB_DB
def delete_tenant(tenant_id, *, drop_database=True, remove_from_config=True):
"""Delete a tenant's runtime data and optionally remove its config entry."""
tenant_id = str(tenant_id or '').strip()
if not tenant_id:
return False
db_name = _get_tenant_db_name_from_config(tenant_id)
if drop_database:
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
try:
client.drop_database(db_name)
finally:
client.close()
except Exception as exc:
logger.warning("Failed to drop tenant database %s for %s: %s", db_name, tenant_id, exc)
if remove_from_config:
try:
config_path = getattr(cfg, 'CONFIG_PATH', None)
if config_path and os.path.isfile(config_path):
with open(config_path, 'r', encoding='utf-8') as handle:
config = json.load(handle)
tenants = config.get('tenants', {})
if not isinstance(tenants, dict):
tenants = {}
aliases = set(_tenant_db_aliases(tenant_id))
aliases.add(tenant_id)
lowered = tenant_id.lower()
if lowered.startswith('schule'):
aliases.add('school' + lowered[len('schule'):])
elif lowered.startswith('school'):
aliases.add('schule' + lowered[len('school'):])
removed_any = False
for alias in aliases:
if alias in tenants:
tenants.pop(alias, None)
removed_any = True
if removed_any:
config['tenants'] = tenants
with open(config_path, 'w', encoding='utf-8') as handle:
json.dump(config, handle, indent=4, ensure_ascii=False)
for alias in [tenant_id, *_tenant_db_aliases(tenant_id)]:
TENANT_REGISTRY.pop(alias, None)
except Exception as exc:
logger.warning("Failed to remove tenant config for %s: %s", tenant_id, exc)
return True
def purge_expired_trial_tenants(now=None):
"""Delete expired trial tenants that opted into auto-delete."""
now = now or datetime.datetime.now()
purged_tenants = []
for tenant_id in list(TENANT_REGISTRY.keys()):
status = get_tenant_trial_status(tenant_id, now=now)
if status.get('enabled') and status.get('expired') and status.get('auto_delete'):
if delete_tenant(tenant_id):
purged_tenants.append(tenant_id)
return purged_tenants
class TenantContext:
"""
Manages current tenant context for request lifecycle.
Automatically resolves tenant from subdomain or request header.
Automatically resolves tenant from port, header, or subdomain.
"""
def __init__(self):
self.tenant_id = None
self.db_name = None
self.subdomain = None
self.port = None
self.config = {}
def resolve_tenant(self):
"""
Resolve tenant from request context.
Priority: Header > Subdomain > Default
Priority: Header > Port mapping > Subdomain > Default
"""
if not has_request_context():
return None
@@ -39,37 +476,112 @@ class TenantContext:
# Priority 1: X-Tenant-ID header (for testing/internal APIs)
tenant_from_header = request.headers.get('X-Tenant-ID', '').strip()
if tenant_from_header:
self.tenant_id = tenant_from_header
return self._get_db_name(tenant_from_header)
matched_tenant = _find_registered_tenant_id(tenant_from_header) or tenant_from_header
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 2: Subdomain extraction
host = request.host.lower()
parts = host.split('.')
# Priority 2: Port/host based tenant mapping
host_candidates = _request_host_candidates()
primary_host = host_candidates[0] if host_candidates else _first_host_token(getattr(request, 'host', ''))
logger.info(
"Tenant resolution start: request.host=%s host_candidates=%s request.headers=%s",
getattr(request, 'host', ''),
host_candidates,
dict(request.headers),
)
# Extract subdomain from host
# Examples: schule1.example.com → schule1
# app.example.com → app (skip wildcard/app)
if len(parts) >= 3:
potential_subdomain = parts[0]
for host in host_candidates:
hostname, port = _parse_port_from_host(host)
if port:
self.port = port
tenant_from_port = _tenant_id_for_port(port)
if tenant_from_port:
self.tenant_id = tenant_from_port
self.config = get_tenant_config(tenant_from_port)
session['tenant_id'] = tenant_from_port
logger.info(
f"Tenant resolution by port: host={host} port={port} tenant={tenant_from_port} config={self.config}"
)
return self._get_db_name(tenant_from_port)
logger.info(f"Tenant port not mapped: host={host} port={port}")
# Filter out common non-tenant subdomains
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
self.subdomain = potential_subdomain
self.tenant_id = potential_subdomain
return self._get_db_name(potential_subdomain)
# Priority 3: Subdomain extraction
for host in host_candidates:
host_without_port, _ = _parse_port_from_host(host)
host_without_port = (host_without_port or '').strip().lower()
if not host_without_port:
continue
# Fallback to default tenant if no subdomain detected
self.tenant_id = 'default'
return self._get_db_name('default')
direct_host_match = _find_registered_tenant_id(host_without_port)
if direct_host_match:
self.subdomain = host_without_port
self.tenant_id = direct_host_match
self.config = get_tenant_config(direct_host_match)
session['tenant_id'] = direct_host_match
logger.info(
f"Tenant resolution by direct host match: host={host} tenant={direct_host_match} config={self.config}"
)
return self._get_db_name(direct_host_match)
if host_without_port and not _is_ip_host(host_without_port):
parts = host_without_port.split('.')
if len(parts) >= 2:
potential_subdomain = parts[0]
if potential_subdomain not in ('www', 'api', 'admin', 'app', 'mail'):
matched_tenant = _find_registered_tenant_id(potential_subdomain)
if matched_tenant:
self.subdomain = potential_subdomain
self.tenant_id = matched_tenant
self.config = get_tenant_config(matched_tenant)
session['tenant_id'] = matched_tenant
logger.info(
f"Tenant resolution by subdomain: host={host} tenant={matched_tenant} config={self.config}"
)
return self._get_db_name(matched_tenant)
logger.info(f"Tenant subdomain not registered: {potential_subdomain}")
else:
logger.info(f"Tenant subdomain ignored: {potential_subdomain}")
# Priority 4: sticky tenant from the authenticated session
session_tenant = session.get('tenant_id', '').strip() if session.get('tenant_id') else ''
if session_tenant:
self.tenant_id = session_tenant
self.config = get_tenant_config(session_tenant)
logger.info(
f"Tenant resolution by session: host={primary_host} tenant={session_tenant} config={self.config}"
)
return self._get_db_name(session_tenant)
# Fallback to default tenant if no tenant identifier found.
# If no explicit 'default' tenant config exists, use configured MongoDB DB.
if 'default' in TENANT_REGISTRY:
self.tenant_id = 'default'
self.config = get_tenant_config('default')
return self._get_db_name('default')
self.tenant_id = None
self.config = {}
self.db_name = cfg.MONGODB_DB
return self.db_name
def _get_db_name(self, tenant_id):
"""
Get MongoDB database name for tenant.
Format: inventar_<tenant_id>
Format: inventar_<tenant_id> unless tenant config overrides it.
"""
# Sanitize tenant_id for MongoDB database name
sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in tenant_id.lower())
db_name = f"inventar_{sanitized}"
explicit_db = None
if isinstance(self.config, dict):
explicit_db = self.config.get('db') or self.config.get('db_name')
if explicit_db:
db_name = _normalize_db_name(explicit_db)
else:
sanitized = ''.join(c if c.isalnum() or c == '_' else '' for c in tenant_id.lower())
db_name = f"inventar_{sanitized}"
db_name = _resolve_db_alias(tenant_id, db_name)
self.db_name = db_name
return db_name
@@ -125,8 +637,8 @@ def get_tenant_db(mongo_client):
ctx = get_tenant_context()
if ctx:
return ctx.get_database(mongo_client)
# Fallback to default database
return mongo_client['inventar_default']
# Fallback to configured default database
return mongo_client[cfg.MONGODB_DB]
def register_tenant(tenant_id, config=None):
-7
View File
@@ -1,7 +0,0 @@
from app import app
with app.test_client() as client:
with client.session_transaction() as sess:
sess['username'] = 'admin'
sess['admin'] = True
resp = client.get('/terminplan')
print("Status:", resp.status_code)
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env python3
"""CLI utility to verify the tamper-evident audit chain."""
import json
import sys
from pymongo import MongoClient
import settings as cfg
import audit_log as al
def main():
client = None
try:
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
al.ensure_audit_indexes(db)
result = al.verify_audit_chain(db)
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
return 0 if result.get("ok") else 2
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
return 1
finally:
if client:
client.close()
if __name__ == "__main__":
sys.exit(main())
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Monthly log archival script for Inventarsystem
# Runs on first day of month during automatic update
# Compresses logs to tar.gz, stores for up to 1 year, removes uncompressed originals
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$PROJECT_DIR/logs"
ARCHIVE_BASE_DIR="$PROJECT_DIR/backups/logs_archive"
LOG_ARCHIVE_FILE="$LOG_DIR/archive-logs.log"
GUARD_FILE="$PROJECT_DIR/.last-log-archive"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
# Ensure archive base directory exists
mkdir -p "$ARCHIVE_BASE_DIR"
chmod 755 "$ARCHIVE_BASE_DIR" 2>/dev/null || true
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_ARCHIVE_FILE"
}
should_archive_logs() {
local today month_day guard_date
today="$(date +%Y-%m-%d)"
month_day="$(date +%d)"
# Only run on first day of month
if [ "$month_day" != "01" ]; then
return 1
fi
# Check if we already ran today
if [ -f "$GUARD_FILE" ]; then
guard_date="$(cat "$GUARD_FILE" 2>/dev/null || echo '')"
if [ "$guard_date" = "$today" ]; then
log_message "Log archive already executed today ($today). Skipping."
return 1
fi
fi
return 0
}
archive_application_logs() {
local archive_name archive_path retention_days cleanup_count deleted_count
if [ ! -d "$LOG_DIR" ] || [ -z "$(find "$LOG_DIR" -maxdepth 1 -type f -name '*.log' 2>/dev/null)" ]; then
log_message "No log files found in $LOG_DIR. Nothing to archive."
return 0
fi
archive_name="inventar_logs_$(date +%Y-%m-01_%H%M%S)"
archive_path="$ARCHIVE_BASE_DIR/${archive_name}.tar.gz"
log_message "Starting monthly log archival..."
log_message "Archive destination: $archive_path"
# Create temporary directory for prepping files
local tmp_prep_dir
tmp_prep_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_prep_dir:-}"' RETURN
# Copy log files to temp directory (excluding archive log itself during copy)
if ! find "$LOG_DIR" -maxdepth 1 -type f -name '*.log' ! -name 'archive-logs.log' -exec cp {} "$tmp_prep_dir/" \; 2>/dev/null; then
log_message "WARNING: Could not copy all log files to temp directory"
fi
# Create tar.gz archive
if tar -czf "$archive_path" -C "$tmp_prep_dir" . 2>/dev/null; then
log_message "Log archive created successfully: $archive_path"
log_message "Archive size: $(du -h "$archive_path" | cut -f1)"
else
log_message "ERROR: Failed to create log archive"
return 1
fi
# Remove uncompressed log files (keep archive-logs.log for continuity)
log_message "Removing uncompressed log files..."
local files_removed=0
while IFS= read -r log_file; do
if [ "$log_file" != "$LOG_ARCHIVE_FILE" ]; then
rm -f "$log_file" && ((++files_removed)) || true
fi
done < <(find "$LOG_DIR" -maxdepth 1 -type f -name '*.log')
log_message "Removed $files_removed uncompressed log file(s)"
trap - RETURN
}
cleanup_old_archives() {
local retention_days cleanup_count deleted_count total_archives
retention_days="365"
# Find and remove archives older than 1 year
log_message "Cleaning up archives older than $retention_days days..."
total_archives="$(find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' 2>/dev/null | wc -l)"
deleted_count="0"
find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' -mtime +$retention_days | while read -r old_archive; do
log_message "Removing old archive: $old_archive"
rm -f "$old_archive"
((++deleted_count)) || true
done 2>/dev/null || true
# Count deleted (safer approach)
local current_archives
current_archives="$(find "$ARCHIVE_BASE_DIR" -maxdepth 1 -type f -name '*.tar.gz' 2>/dev/null | wc -l)"
deleted_count="$((total_archives - current_archives))"
if [ "$deleted_count" -gt 0 ]; then
log_message "Deleted $deleted_count old archive(s); kept $current_archives"
else
log_message "No old archives to delete (total: $total_archives)"
fi
}
main() {
if ! should_archive_logs; then
return 0
fi
log_message "=== Monthly log archival started ==="
archive_application_logs || {
log_message "ERROR: Log archival failed"
return 1
}
cleanup_old_archives || {
log_message "WARNING: Archive cleanup encountered issues"
}
# Update guard file with today's date
date +%Y-%m-%d > "$GUARD_FILE"
log_message "=== Monthly log archival completed successfully ==="
return 0
}
main "$@"
+115 -483
View File
@@ -1,529 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail
# ========================================================
# Inventarsystem backup helper
# - Creates a dated backup folder/archive under /var/backups (by default)
# - Exports MongoDB via run-backup.sh into mongodb_backup/
# - Cleans up backups older than KEEP_DAYS
#
# Options:
# --dest|--out|--backup-dir <dir> Destination base directory (default: /var/backups)
# --db|--db-name <name> MongoDB database name (default: Inventarsystem)
# --uri|--mongo-uri <uri> MongoDB URI (default: mongodb://localhost:27017/)
# --invoice-archive-dir <dir> Invoice archive directory (default: <dest>/invoice-archive)
# --invoice-keep-days <N> Retention for invoice archive in days (default: 3650)
# --log <file> Log file (default: ./logs/backup.log)
# --no-compress Disable compression (keeps directory instead of .tar.gz)
# --compress-level <0-9> Compression level flag (only 0 is special here)
# --keep-days <N> Age-based retention in days (default: 7; 0 disables age filter)
# --min-keep <N> Always keep at least N most recent backups (default: 7)
# --mode <auto|host|docker> Backup mode (default: auto)
# -h|--help Show help
# ========================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="${PROJECT_DIR:-$SCRIPT_DIR}"
# Default destination now /var/backups; override with --dest or BACKUP_BASE_DIR env
BACKUP_BASE_DIR="${BACKUP_BASE_DIR:-/var/backups}"
LOG_DIR="$PROJECT_DIR/logs"
# Create only the log directory here; backup dir is created later with sudo if needed
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_FILE:-$LOG_DIR/backup.log}"
COMPRESSION_LEVEL="${COMPRESSION_LEVEL:-9}"
KEEP_DAYS="${KEEP_DAYS:-7}"
MIN_KEEP="${MIN_KEEP:-7}"
DB_NAME="${DB_NAME:-Inventarsystem}"
MONGO_URI="${MONGO_URI:-mongodb://localhost:27017/}"
INVOICE_KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-$BACKUP_BASE_DIR/invoice-archive}"
BACKUP_MODE="${BACKUP_MODE:-auto}"
DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=()
USE_NULL_OUTPUT=false
COMPOSE_FILE="docker-compose-multitenant.yml"
INVOICE_ARCHIVE_DIR="${INVOICE_ARCHIVE_DIR:-/var/backups/invoice-archive}"
KEEP_DAYS="${INVOICE_KEEP_DAYS:-3650}"
MODE="auto"
BASE_NAME=""
usage() {
cat <<EOF
Usage: $(basename "$0") [options]
Usage: $0 [options]
Options:
--dest|--out|--backup-dir <dir> Destination base directory (default: $BACKUP_BASE_DIR)
--db|--db-name <name> MongoDB database name (default: $DB_NAME)
--uri|--mongo-uri <uri> MongoDB URI (default: $MONGO_URI)
--invoice-archive-dir <dir> Invoice archive directory (default: $INVOICE_ARCHIVE_DIR)
--invoice-keep-days <N> Retention for invoice archive in days (default: $INVOICE_KEEP_DAYS)
--log <file> Log file (default: $LOG_FILE)
--no-compress Disable compression (keeps directory instead of .tar.gz)
--compress-level <0-9> Compression level flag (only 0 is special here)
--keep-days <N> Age-based retention in days (default: $KEEP_DAYS; 0 disables age filter)
--min-keep <N> Always keep at least this many backups (default: $MIN_KEEP)
--mode <auto|host|docker> Backup mode (default: $BACKUP_MODE)
-h|--help Show this help and exit
--invoice-archive-dir DIR Directory for invoice archive files
(default: $INVOICE_ARCHIVE_DIR)
--invoice-keep-days N Remove archived invoice files older than N days
(default: $KEEP_DAYS)
--mode auto|docker|host Backup mode (default: auto)
--base-name NAME Base filename prefix for archive files
-h, --help Show this help message
EOF
}
# Parse CLI arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--dest|--out|--backup-dir)
BACKUP_BASE_DIR="$2"; shift 2;;
--db|--db-name)
DB_NAME="$2"; shift 2;;
--uri|--mongo-uri)
MONGO_URI="$2"; shift 2;;
--invoice-archive-dir)
INVOICE_ARCHIVE_DIR="$2"; shift 2;;
--invoice-keep-days)
INVOICE_KEEP_DAYS="$2"; shift 2;;
--log)
LOG_FILE="$2"; shift 2;;
--no-compress)
COMPRESSION_LEVEL=0; shift;;
--compress-level)
COMPRESSION_LEVEL="${2:-6}"; shift 2;;
--keep-days)
KEEP_DAYS="$2"; shift 2;;
--min-keep)
MIN_KEEP="$2"; shift 2;;
--mode)
BACKUP_MODE="$2"; shift 2;;
-h|--help)
usage; exit 0;;
*)
echo "Unknown option: $1" >&2; usage; exit 2;;
esac
done
# Function to log messages
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" 2>/dev/null || echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--invoice-archive-dir)
INVOICE_ARCHIVE_DIR="$2"
shift 2
;;
--invoice-keep-days)
KEEP_DAYS="$2"
shift 2
;;
--mode)
MODE="$2"
shift 2
;;
--base-name)
BASE_NAME="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option '$1'" >&2
usage
exit 2
;;
esac
done
}
init_docker_compose() {
if docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(docker compose -f "$PROJECT_DIR/docker-compose.yml")
return 0
fi
if sudo docker compose version >/dev/null 2>&1; then
DOCKER_AVAILABLE=1
DOCKER_COMPOSE_CMD=(sudo docker compose -f "$PROJECT_DIR/docker-compose.yml")
return 0
fi
DOCKER_AVAILABLE=0
DOCKER_COMPOSE_CMD=()
return 1
ensure_directory() {
mkdir -p "$INVOICE_ARCHIVE_DIR"
}
compose_cmd() {
if [ "$DOCKER_AVAILABLE" -ne 1 ]; then
cleanup_old_archives() {
if [[ -n "$KEEP_DAYS" ]]; then
find "$INVOICE_ARCHIVE_DIR" -maxdepth 1 -type f \( -name 'invoices-*.jsonl' -o -name 'invoices-*.csv' -o -name 'invoices-*.meta.json' \) -mtime +"$KEEP_DAYS" -print -delete || true
fi
}
host_backup() {
if ! command -v python3 >/dev/null 2>&1; then
return 1
fi
"${DOCKER_COMPOSE_CMD[@]}" "$@"
}
try_backup_db_docker() {
local out_dir="$1"
local stamp
stamp="$(date +"%Y-%m-%d_%H-%M-%S")"
local archive_path="$out_dir/mongodb-${stamp}.archive.gz"
[ "$DOCKER_AVAILABLE" -eq 1 ] || return 1
log_message "Attempting Docker-based MongoDB backup..."
compose_cmd up -d mongodb >/dev/null 2>&1 || return 1
if ! compose_cmd ps --status running mongodb | grep -q mongodb; then
if ! python3 -c 'import pymongo' >/dev/null 2>&1; then
return 1
fi
if compose_cmd exec -T mongodb sh -c "mongodump --archive --gzip --db '$DB_NAME'" > "$archive_path"; then
log_message "Docker DB backup successful: $archive_path"
return 0
fi
return 1
python3 "$SCRIPT_DIR/Web/backup_invoices.py" \
--archive-dir "$INVOICE_ARCHIVE_DIR" \
--base-name "$BASE_NAME"
}
try_backup_invoices_docker() {
local archive_base="$1"
[ "$DOCKER_AVAILABLE" -eq 1 ] || return 1
local tmp_base
tmp_base="/tmp/$(basename "$archive_base")"
log_message "Attempting Docker-based invoice archive export..."
compose_cmd up -d mongodb app >/dev/null 2>&1 || return 1
if ! compose_cmd ps --status running app | grep -q app; then
docker_backup() {
if ! command -v docker >/dev/null 2>&1; then
return 1
fi
if ! docker compose -f "$COMPOSE_FILE" ps -q app >/dev/null 2>&1; then
return 1
fi
local app_cid
app_cid="$(compose_cmd ps -q app)"
[ -n "$app_cid" ] || return 1
if ! sudo docker cp "$PROJECT_DIR/Backup-Invoices.py" "$app_cid:/tmp/Backup-Invoices.py" >/dev/null 2>&1; then
local app_container
app_container="$(docker compose -f "$COMPOSE_FILE" ps -q app | tr -d '[:space:]')"
if [[ -z "$app_container" ]]; then
return 1
fi
if ! compose_cmd exec -T app python /tmp/Backup-Invoices.py \
--uri "mongodb://mongodb:27017/" \
--db "$DB_NAME" \
--out "$tmp_base" >/dev/null 2>&1; then
return 1
fi
sudo docker cp "$app_cid:${tmp_base}.jsonl" "${archive_base}.jsonl" >/dev/null 2>&1 || return 1
sudo docker cp "$app_cid:${tmp_base}.csv" "${archive_base}.csv" >/dev/null 2>&1 || return 1
sudo docker cp "$app_cid:${tmp_base}.meta.json" "${archive_base}.meta.json" >/dev/null 2>&1 || return 1
compose_cmd exec -T app sh -c "rm -f /tmp/Backup-Invoices.py ${tmp_base}.jsonl ${tmp_base}.csv ${tmp_base}.meta.json" >/dev/null 2>&1 || true
return 0
}
backup_invoices_archive() {
log_message "Starting invoice archive export..."
# Ensure archive dir exists and is writable
if [ ! -d "$INVOICE_ARCHIVE_DIR" ]; then
sudo mkdir -p "$INVOICE_ARCHIVE_DIR"
sudo chmod 755 "$INVOICE_ARCHIVE_DIR"
fi
local timestamp
timestamp="$(date +"%Y-%m-%d_%H-%M-%S")"
local archive_base
archive_base="$INVOICE_ARCHIVE_DIR/invoices-$timestamp"
timestamp="$(date +'%Y-%m-%d_%H-%M-%S')"
local output_dir
local remote_base
local py_exec
if [ -x "$PROJECT_DIR/.venv/bin/python" ]; then
py_exec="$PROJECT_DIR/.venv/bin/python"
else
py_exec="python3"
fi
output_dir="/tmp/invoice_archive_${timestamp}"
remote_base="${output_dir}/${BASE_NAME}"
local host_invoice_ok=0
local prefer_docker_invoice=0
if [ "$BACKUP_MODE" = "auto" ] && [ "$DOCKER_AVAILABLE" -eq 1 ] && [ "$MONGO_URI" = "mongodb://localhost:27017/" ]; then
prefer_docker_invoice=1
log_message "Auto mode: preferring Docker invoice archive export"
fi
docker compose -f "$COMPOSE_FILE" exec -T app mkdir -p "$output_dir"
docker compose -f "$COMPOSE_FILE" exec -T app python3 /app/Web/backup_invoices.py \
--archive-dir "$output_dir" \
--base-name "$BASE_NAME"
if [ "$BACKUP_MODE" != "docker" ] && [ "$prefer_docker_invoice" -ne 1 ]; then
if [ "${USE_NULL_OUTPUT:-false}" = true ]; then
if sudo -E "$py_exec" "$PROJECT_DIR/Backup-Invoices.py" --uri "$MONGO_URI" --db "$DB_NAME" --out "$archive_base" > /dev/null 2>&1; then
host_invoice_ok=1
fi
else
if sudo -E "$py_exec" "$PROJECT_DIR/Backup-Invoices.py" --uri "$MONGO_URI" --db "$DB_NAME" --out "$archive_base" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1; then
host_invoice_ok=1
fi
fi
fi
mkdir -p "$INVOICE_ARCHIVE_DIR"
docker cp "$app_container":"${remote_base}.jsonl" "$INVOICE_ARCHIVE_DIR/"
docker cp "$app_container":"${remote_base}.csv" "$INVOICE_ARCHIVE_DIR/"
docker cp "$app_container":"${remote_base}.meta.json" "$INVOICE_ARCHIVE_DIR/"
if [ "$host_invoice_ok" -ne 1 ]; then
if [ "$BACKUP_MODE" = "host" ]; then
log_message "ERROR: Failed to export invoice archive in host mode"
return 1
fi
if ! try_backup_invoices_docker "$archive_base"; then
log_message "ERROR: Failed to export invoice archive"
return 1
fi
fi
sudo chmod 640 "$archive_base".jsonl "$archive_base".csv "$archive_base".meta.json 2>/dev/null || true
log_message "Invoice archive written: $archive_base.(jsonl|csv|meta.json)"
# Retention cleanup for invoice archive (default: 10 years)
if [ "$INVOICE_KEEP_DAYS" -gt 0 ]; then
local deleted_count=0
while IFS= read -r old_file; do
[ -z "$old_file" ] && continue
if sudo rm -f "$old_file"; then
deleted_count=$((deleted_count + 1))
fi
done < <(find "$INVOICE_ARCHIVE_DIR" -maxdepth 1 -type f \
\( -name 'invoices-*.jsonl' -o -name 'invoices-*.csv' -o -name 'invoices-*.meta.json' \) \
-mtime +"$INVOICE_KEEP_DAYS" -print 2>/dev/null)
log_message "Invoice archive cleanup complete (retention: $INVOICE_KEEP_DAYS days, deleted files: $deleted_count)"
fi
return 0
docker compose -f "$COMPOSE_FILE" exec -T app rm -rf "$output_dir"
}
# Function to create backup
create_backup() {
log_message "Starting backup process..."
# Create date-formatted directory name
CURRENT_DATE=$(date +"%Y-%m-%d")
BACKUP_NAME="Inventarsystem-$CURRENT_DATE"
BACKUP_DIR="$BACKUP_BASE_DIR/$BACKUP_NAME"
BACKUP_ARCHIVE="$BACKUP_BASE_DIR/$BACKUP_NAME.tar.gz"
# Create backup directory if it doesn't exist
if [ ! -d "$BACKUP_BASE_DIR" ]; then
sudo mkdir -p "$BACKUP_BASE_DIR"
sudo chmod 755 "$BACKUP_BASE_DIR"
fi
# Remove existing backup with same date if it exists
if [ -d "$BACKUP_DIR" ]; then
log_message "Removing existing backup directory at $BACKUP_DIR"
sudo rm -rf "$BACKUP_DIR"
fi
if [ -f "$BACKUP_ARCHIVE" ]; then
log_message "Removing existing backup archive at $BACKUP_ARCHIVE"
sudo rm -f "$BACKUP_ARCHIVE"
fi
# Create a temporary directory for backup
log_message "Creating backup at $BACKUP_DIR"
sudo mkdir -p "$BACKUP_DIR"
# Copy project files excluding the backups directory itself (and optionally the venv)
if command -v rsync >/dev/null 2>&1; then
sudo rsync -a \
--exclude='backups' \
--exclude='.venv' \
"$PROJECT_DIR"/ "$BACKUP_DIR"/
else
# Fallback to cp while skipping the backups directory
for entry in "$PROJECT_DIR"/*; do
name=$(basename "$entry")
[[ "$name" == "backups" || "$name" == ".venv" ]] && continue
sudo cp -r "$entry" "$BACKUP_DIR/"
done
fi
# Create database backup
log_message "Running database backup..."
# Create mongodb_backup directory with appropriate permissions first
sudo mkdir -p "$BACKUP_DIR/mongodb_backup"
sudo chmod 755 "$BACKUP_DIR/mongodb_backup"
# Run database backup with our helper script
log_message "Executing database backup script..."
db_backup_ok=0
use_host_db_backup=1
if [ "$BACKUP_MODE" = "docker" ]; then
use_host_db_backup=0
elif [ "$BACKUP_MODE" = "auto" ] && [ "$DOCKER_AVAILABLE" -eq 1 ] && [ "$MONGO_URI" = "mongodb://localhost:27017/" ]; then
use_host_db_backup=0
log_message "Auto mode: preferring Docker DB backup"
main() {
if [[ -z "$BASE_NAME" ]]; then
BASE_NAME="invoices-$(date +'%Y-%m-%d_%H-%M-%S')"
fi
if [ "$use_host_db_backup" -eq 1 ]; then
# Create Backup_db.log and ensure it's writable only when host mode is used
touch "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null
chmod 666 "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null || {
log_message "WARNING: Failed to set permissions on Backup_db.log. Trying with sudo..."
sudo touch "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null
sudo chmod 666 "$PROJECT_DIR/logs/Backup_db.log" 2>/dev/null || {
log_message "WARNING: Failed to create writable Backup_db.log. Redirecting output to /dev/null instead."
USE_NULL_OUTPUT=true
}
}
ensure_directory
# Install pymongo only for host backups
log_message "Checking pymongo installation..."
if ! python3 -c "import pymongo" &>/dev/null; then
log_message "Installing pymongo..."
if [ -f "$PROJECT_DIR/.venv/bin/pip" ]; then
if [ "$USE_NULL_OUTPUT" = true ]; then
"$PROJECT_DIR/.venv/bin/pip" install pymongo==4.6.3 > /dev/null 2>&1 || true
else
"$PROJECT_DIR/.venv/bin/pip" install pymongo==4.6.3 >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 || true
fi
case "$MODE" in
auto)
if docker_backup; then
cleanup_old_archives
return 0
fi
if ! python3 -c "import pymongo" &>/dev/null; then
if [ "$USE_NULL_OUTPUT" = true ]; then
pip3 install pymongo==4.6.3 > /dev/null 2>&1 || true
else
pip3 install pymongo==4.6.3 >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 || true
fi
if host_backup; then
cleanup_old_archives
return 0
fi
fi
if [ "${USE_NULL_OUTPUT:-false}" = true ]; then
sudo -E "$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$BACKUP_DIR/mongodb_backup" > /dev/null 2>&1 && db_backup_ok=1 || {
log_message "ERROR: Failed to backup database with original path"
# Try an alternative approach - use a temporary directory
log_message "Attempting backup via temporary directory..."
tmp_backup_dir="/tmp/mongodb_backup_$$"
mkdir -p "$tmp_backup_dir"
"$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$tmp_backup_dir" > /dev/null 2>&1 && {
# Copy temporary backup files to the actual backup directory
log_message "Copying backup files from temporary directory..."
cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/"
db_backup_ok=1
} || {
log_message "ERROR: All attempts to backup database failed"
}
}
else
sudo -E "$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$BACKUP_DIR/mongodb_backup" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 && db_backup_ok=1 || {
log_message "ERROR: Failed to backup database with original path"
# Try an alternative approach - use a temporary directory
log_message "Attempting backup via temporary directory..."
tmp_backup_dir="/tmp/mongodb_backup_$$"
mkdir -p "$tmp_backup_dir"
"$PROJECT_DIR/run-backup.sh" --db "$DB_NAME" --uri "$MONGO_URI" --out "$tmp_backup_dir" >> "$PROJECT_DIR/logs/Backup_db.log" 2>&1 && {
# Copy temporary backup files to the actual backup directory
log_message "Copying backup files from temporary directory..."
cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/"
db_backup_ok=1
} || {
log_message "ERROR: All attempts to backup database failed"
}
}
fi
else
log_message "Docker mode selected: skipping host DB backup step"
fi
# Auto-fallback for docker-first environments
if [ "$db_backup_ok" -ne 1 ] && [ "$BACKUP_MODE" != "host" ]; then
if try_backup_db_docker "$BACKUP_DIR/mongodb_backup"; then
db_backup_ok=1
fi
fi
# Check if any CSV files were created during backup
if ! find "$BACKUP_DIR/mongodb_backup" -name "*.csv" -quit; then
log_message "WARNING: No CSV files found in backup directory"
# If we used a temporary directory earlier and it still exists, try to use its contents
if [ -d "$tmp_backup_dir" ]; then
log_message "Backup created in temporary directory, moving to backup location..."
sudo cp -r "$tmp_backup_dir"/* "$BACKUP_DIR/mongodb_backup/"
rm -rf "$tmp_backup_dir"
fi
fi
# Reset to more restrictive permissions after backup completes
sudo chmod -R 755 "$BACKUP_DIR/mongodb_backup"
# Verify that backup files were created
csv_count=$(find "$BACKUP_DIR/mongodb_backup" -name "*.csv" 2>/dev/null | wc -l)
archive_count=$(find "$BACKUP_DIR/mongodb_backup" -name "*.archive.gz" 2>/dev/null | wc -l)
if [ "$csv_count" -gt 0 ] || [ "$archive_count" -gt 0 ]; then
log_message "Database backup successful: ${csv_count} CSV collection(s), ${archive_count} archive file(s)"
else
log_message "WARNING: No database backup files found in backup directory"
fi
# Export legal invoice archive separately with long retention
backup_invoices_archive || log_message "WARNING: Invoice archive export failed"
# Compress the backup
if [ "$COMPRESSION_LEVEL" -gt 0 ]; then
log_message "Compressing backup with level $COMPRESSION_LEVEL..."
# Create compressed archive with pigz when available, otherwise gzip.
if command -v pigz >/dev/null 2>&1; then
gzip_cmd="pigz -${COMPRESSION_LEVEL}"
else
gzip_cmd="gzip -${COMPRESSION_LEVEL}"
fi
if tar --help 2>/dev/null | grep -q -- "--use-compress-program"; then
sudo tar -cf "$BACKUP_ARCHIVE" -I "$gzip_cmd" -C "$BACKUP_BASE_DIR" "$BACKUP_NAME" || {
log_message "ERROR: Failed to compress backup (tar -I)"
return 1
}
else
GZIP="-${COMPRESSION_LEVEL}" sudo -E tar -czf "$BACKUP_ARCHIVE" -C "$BACKUP_BASE_DIR" "$BACKUP_NAME" || {
log_message "ERROR: Failed to compress backup (tar + GZIP env)"
return 1
}
fi
# Remove uncompressed directory after successful compression
if [ -f "$BACKUP_ARCHIVE" ]; then
log_message "Backup compressed successfully to $BACKUP_ARCHIVE"
log_message "Removing uncompressed backup directory"
sudo rm -rf "$BACKUP_DIR"
else
log_message "ERROR: Compressed backup file not found"
echo "Error: could not perform invoice backup in auto mode. Docker or host Python with pymongo is required." >&2
return 1
fi
else
log_message "Compression disabled, keeping uncompressed backup"
fi
# Set appropriate permissions
if [ "$COMPRESSION_LEVEL" -gt 0 ]; then
sudo chmod 644 "$BACKUP_ARCHIVE"
else
sudo chmod -R 755 "$BACKUP_DIR"
fi
# Clean up old backups: age-based filter plus minimum keep safeguard
log_message "Cleaning up old backups (keep at least $MIN_KEEP; days>$KEEP_DAYS)..."
# Build list of all backup artifacts (files and directories) sorted newest first
mapfile -t ALL_BACKUPS < <(find "$BACKUP_BASE_DIR" -maxdepth 1 \
\( -type f -name "Inventarsystem-*.tar.gz" -o -type d -name "Inventarsystem-*" \) \
-printf '%T@\t%p\n' 2>/dev/null | sort -nr | awk -F '\t' '{print $2}')
TOTAL=${#ALL_BACKUPS[@]}
if (( TOTAL > MIN_KEEP )); then
# Determine deletion candidates by age (if KEEP_DAYS>0), otherwise all except the newest MIN_KEEP
if (( KEEP_DAYS > 0 )); then
mapfile -t AGE_CANDIDATES < <(find "$BACKUP_BASE_DIR" -maxdepth 1 \
\( -type f -name "Inventarsystem-*.tar.gz" -o -type d -name "Inventarsystem-*" \) \
-mtime +"$KEEP_DAYS" -printf '%T@\t%p\n' 2>/dev/null | sort -n | awk -F '\t' '{print $2}')
else
AGE_CANDIDATES=()
# If no age filter, consider everything except the newest MIN_KEEP as candidates (oldest first)
if (( TOTAL > MIN_KEEP )); then
# Reverse ALL_BACKUPS to get oldest first
for ((i=TOTAL-1; i>=MIN_KEEP; i--)); do AGE_CANDIDATES+=("${ALL_BACKUPS[$i]}"); done
;;
docker)
if docker_backup; then
cleanup_old_archives
return 0
fi
fi
# Protect the newest MIN_KEEP backups overall
ALLOWED_DELETE=$(( TOTAL - MIN_KEEP ))
DELETED=0
for path in "${AGE_CANDIDATES[@]:-}"; do
(( DELETED >= ALLOWED_DELETE )) && break
if [ -z "$path" ]; then continue; fi
if [ -f "$path" ]; then
sudo rm -f "$path" && ((DELETED++))
elif [ -d "$path" ]; then
sudo rm -rf "$path" && ((DELETED++))
echo "Error: docker backup failed or app container is unavailable." >&2
return 1
;;
host)
if host_backup; then
cleanup_old_archives
return 0
fi
done
log_message "Deleted $DELETED old backup(s); kept $(( TOTAL - DELETED ))"
else
log_message "No cleanup needed (total backups: $TOTAL <= min-keep: $MIN_KEEP)"
fi
log_message "Backup completed successfully"
return 0
echo "Error: host backup failed. Ensure python3 and pymongo are installed." >&2
return 1
;;
*)
echo "Error: unsupported mode '$MODE'" >&2
usage
return 2
;;
esac
}
# Main
log_message "Backup destination: $BACKUP_BASE_DIR"
log_message "Database: $DB_NAME | URI: $MONGO_URI | Keep days: $KEEP_DAYS | Min keep: $MIN_KEEP | Compression: ${COMPRESSION_LEVEL}"
log_message "Invoice archive: $INVOICE_ARCHIVE_DIR | Invoice retention days: $INVOICE_KEEP_DAYS"
log_message "Backup mode: $BACKUP_MODE"
if [[ "$BACKUP_MODE" != "auto" && "$BACKUP_MODE" != "host" && "$BACKUP_MODE" != "docker" ]]; then
log_message "ERROR: Invalid --mode value '$BACKUP_MODE' (allowed: auto, host, docker)"
exit 2
fi
init_docker_compose || true
if create_backup; then
ART_DATE="$(date +"%Y-%m-%d")"
if [[ "$COMPRESSION_LEVEL" -gt 0 ]]; then
echo "$BACKUP_BASE_DIR/Inventarsystem-$ART_DATE.tar.gz"
else
echo "$BACKUP_BASE_DIR/Inventarsystem-$ART_DATE"
fi
fi
parse_args "$@"
main
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR"
ADMIN_PROJECT_DIR="${ADMIN_PROJECT_DIR:-$(dirname "$PROJECT_DIR")/admin_Inventarsystem}"
DRY_RUN=0
REMOVE_CRON=0
KEEP_AUTOSTART=0
SERVICES=(
"inventarsystem-gunicorn.service"
"admin-inventarsystem-gunicorn.service"
"admin-inventarsystem-nginx.service"
)
SOCKETS=(
"/tmp/inventarsystem.sock"
"/tmp/admin-inventarsystem.sock"
)
PROCESS_PATTERNS=(
"$PROJECT_DIR/.venv/bin/gunicorn app:app"
"$ADMIN_PROJECT_DIR/.venv/bin/gunicorn app:app"
)
CRON_FILTER_PATTERNS=(
"$PROJECT_DIR/update.sh"
"$PROJECT_DIR/backup-docker.sh"
"$ADMIN_PROJECT_DIR/update.sh"
"$ADMIN_PROJECT_DIR/backup-docker.sh"
)
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--remove-cron Remove matching cron entries for old systems
--keep-autostart Stop services but do not disable autostart
--dry-run Show actions without executing
-h, --help Show this help message
EOF
}
log() {
echo "[cleanup-old] $*"
}
run_cmd() {
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] $*"
else
"$@"
fi
}
run_cmd_sudo() {
if [ "$(id -u)" -eq 0 ]; then
run_cmd "$@"
else
run_cmd sudo "$@"
fi
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--remove-cron)
REMOVE_CRON=1
shift
;;
--keep-autostart)
KEEP_AUTOSTART=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option '$1'"
usage
exit 2
;;
esac
done
}
service_exists() {
local service="$1"
systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null | awk '{print $1}' | grep -Fxq "$service"
}
stop_services() {
if ! command -v systemctl >/dev/null 2>&1; then
log "systemctl not available, skipping service cleanup"
return 0
fi
local service
for service in "${SERVICES[@]}"; do
if ! service_exists "$service"; then
log "Service not found: $service"
continue
fi
log "Stopping $service"
run_cmd_sudo systemctl stop "$service" || true
if [ "$KEEP_AUTOSTART" -eq 0 ]; then
log "Disabling autostart for $service"
run_cmd_sudo systemctl disable "$service" || true
fi
done
}
kill_leftover_processes() {
local pattern
for pattern in "${PROCESS_PATTERNS[@]}"; do
log "Terminating processes matching: $pattern"
run_cmd_sudo pkill -TERM -f "$pattern" || true
done
if [ "$DRY_RUN" -eq 0 ]; then
sleep 2
fi
for pattern in "${PROCESS_PATTERNS[@]}"; do
run_cmd_sudo pkill -KILL -f "$pattern" || true
done
}
remove_stale_sockets() {
local socket_path
for socket_path in "${SOCKETS[@]}"; do
if [ -e "$socket_path" ]; then
log "Removing stale socket/file: $socket_path"
run_cmd_sudo rm -f "$socket_path"
else
log "Socket/file not present: $socket_path"
fi
done
}
remove_cron_entries() {
if ! command -v crontab >/dev/null 2>&1; then
log "crontab not available, skipping cron cleanup"
return 0
fi
local tmp_file
tmp_file="$(mktemp)"
if [ "$(id -u)" -eq 0 ]; then
(crontab -l 2>/dev/null || true) > "$tmp_file"
else
(sudo crontab -l 2>/dev/null || true) > "$tmp_file"
fi
local pattern
for pattern in "${CRON_FILTER_PATTERNS[@]}"; do
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] would remove cron lines containing: $pattern"
else
grep -vF "$pattern" "$tmp_file" > "${tmp_file}.new" || true
mv "${tmp_file}.new" "$tmp_file"
fi
done
if [ "$DRY_RUN" -eq 0 ]; then
if [ "$(id -u)" -eq 0 ]; then
crontab "$tmp_file"
else
sudo crontab "$tmp_file"
fi
fi
rm -f "$tmp_file"
log "Cron cleanup finished"
}
status_report() {
log "Remaining matching processes:"
ps -eo pid,ppid,cmd --sort=start_time | grep -E "Inventarsystem/.venv/bin/gunicorn|admin_Inventarsystem/.venv/bin/gunicorn" | grep -v grep || echo "none"
log "Socket status:"
local socket_path
for socket_path in "${SOCKETS[@]}"; do
if [ -e "$socket_path" ]; then
echo "exists: $socket_path"
else
echo "missing: $socket_path"
fi
done
}
main() {
parse_args "$@"
stop_services
kill_leftover_processes
remove_stale_sockets
if [ "$REMOVE_CRON" -eq 1 ]; then
remove_cron_entries
fi
status_report
log "Cleanup complete"
}
main "$@"
+101 -30
View File
@@ -1,45 +1,58 @@
{
"dbg": false,
"key": "InventarsystemSecureKey2026XYZ789abcdef012",
"ver": "0.0.2",
"ver": "0.7.20",
"host": "0.0.0.0",
"port": 443,
"port": 8000,
"mongodb": {
"host": "localhost",
"port": 27017,
"db": "Inventarsystem"
},
"scheduler": {
"enabled": true,
"interval_minutes": 1,
"backup_interval_hours": 24
},
"ssl": {
"enabled": true,
"cert": "Web/certs/cert.pem",
"key": "Web/certs/key.pem"
},
"images": {
"thumbnail_size": [150, 150],
"preview_size": [400, 400]
"email": {
"enabled": false,
"smtp_host": "smtp.gmail.com",
"smtp_port": 587,
"use_tls": true,
"username": "",
"password": "",
"from_address": "",
"default_sender_name": "Invario Inventurprogramm",
"timeout_seconds": 30
},
"images": {
"thumbnail_size": [
150,
150
],
"preview_size": [
400,
400
]
},
"upload": {
"max_size_mb": 10,
"image_max_size_mb": 15,
"video_max_size_mb": 100
},
"paths": {
"backups": "backups",
"logs": "logs"
},
"modules": {
"inventory": {
"enabled": true
},
"library": {
"enabled": true
},
@@ -49,25 +62,83 @@
"max_borrow_days": 365
}
},
"tenants": {
},
"allowed_extensions": [
"png", "jpg", "jpeg", "gif",
"hevc", "heif",
"mp4", "mov", "avi", "mkv", "webm",
"mp3", "wav", "ogg", "flac", "aac", "m4a", "opus",
"pdf", "docx", "xlsx", "pptx", "txt"
"png",
"jpg",
"jpeg",
"gif",
"hevc",
"heif",
"mp4",
"mov",
"avi",
"mkv",
"webm",
"mp3",
"wav",
"ogg",
"flac",
"aac",
"m4a",
"opus",
"pdf",
"docx",
"xlsx",
"pptx",
"txt"
],
"schoolPeriods": {
"1": { "start": "08:00", "end": "08:45", "label": "1. Stunde (08:00 - 08:45)" },
"2": { "start": "08:45", "end": "09:30", "label": "2. Stunde (08:45 - 09:30)" },
"3": { "start": "09:45", "end": "10:30", "label": "3. Stunde (09:45 - 10:30)" },
"4": { "start": "10:30", "end": "11:15", "label": "4. Stunde (10:30 - 11:15)" },
"5": { "start": "11:30", "end": "12:15", "label": "5. Stunde (11:30 - 12:15)" },
"6": { "start": "12:15", "end": "13:00", "label": "6. Stunde (12:15 - 13:00)" },
"7": { "start": "13:30", "end": "14:15", "label": "7. Stunde (13:30 - 14:15)" },
"8": { "start": "14:15", "end": "15:00", "label": "8. Stunde (14:15 - 15:00)" },
"9": { "start": "15:15", "end": "16:00", "label": "9. Stunde (15:15 - 16:00)" },
"10": { "start": "16:00", "end": "16:45", "label": "10. Stunde (16:00 - 16:45)" }
"1": {
"start": "08:00",
"end": "08:45",
"label": "1. Stunde (08:00 - 08:45)"
},
"2": {
"start": "08:45",
"end": "09:30",
"label": "2. Stunde (08:45 - 09:30)"
},
"3": {
"start": "09:45",
"end": "10:30",
"label": "3. Stunde (09:45 - 10:30)"
},
"4": {
"start": "10:30",
"end": "11:15",
"label": "4. Stunde (10:30 - 11:15)"
},
"5": {
"start": "11:30",
"end": "12:15",
"label": "5. Stunde (11:30 - 12:15)"
},
"6": {
"start": "12:15",
"end": "13:00",
"label": "6. Stunde (12:15 - 13:00)"
},
"7": {
"start": "13:30",
"end": "14:15",
"label": "7. Stunde (13:30 - 14:15)"
},
"8": {
"start": "14:15",
"end": "15:00",
"label": "8. Stunde (14:15 - 15:00)"
},
"9": {
"start": "15:15",
"end": "16:00",
"label": "9. Stunde (15:15 - 16:00)"
},
"10": {
"start": "16:00",
"end": "16:45",
"label": "10. Stunde (16:00 - 16:45)"
}
}
}
}
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""
Debug script to check why planned bookings are not being activated
"""
import sys
import os
import datetime
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'Web'))
from pymongo import MongoClient
import settings as cfg
import ausleihung as au
def check_bookings():
"""Check all planned bookings and their status"""
client = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
db = client[cfg.MONGODB_DB]
ausleihungen = db['ausleihungen']
# Get all planned bookings
planned = list(ausleihungen.find({'Status': 'planned'}))
print(f"\n📊 Gefundene GEPLANTE Ausleihen: {len(planned)}\n")
print("=" * 100)
current_time = datetime.datetime.now()
print(f"⏰ Aktuelle Zeit: {current_time}\n")
for booking in planned:
booking_id = str(booking['_id'])
user = booking.get('User', 'N/A')
item = booking.get('Item', 'N/A')
start = booking.get('Start')
end = booking.get('End')
period = booking.get('Period')
status = booking.get('Status')
print(f"\n🔹 Ausleihe ID: {booking_id}")
print(f" Benutzer: {user}")
print(f" Item: {item}")
print(f" Status: {status}")
print(f" Schulstunde/Periode: {period}")
print(f" Start: {start} (Typ: {type(start).__name__})")
print(f" Ende: {end} (Typ: {type(end).__name__})")
# Check current status
current_status = au.get_current_status(booking, log_changes=False)
print(f" ✓ Berechneter Status: {current_status}")
if current_status != status:
print(f" ⚠️ STATUS STIMMT NICHT ÜBEREIN! Sollte sein: {current_status}")
# Check time comparison
if start:
time_diff = (current_time - start).total_seconds()
if time_diff < 0:
print(f" ⏳ Startet in: {abs(time_diff) / 60:.1f} Minuten")
elif time_diff < 3600:
print(f" ✓ Sollte JETZT AKTIV sein! ({time_diff / 60:.1f} Minuten vorbei)")
else:
print(f" ✓ Sollte schon {time_diff / 3600:.1f} Stunden aktiv sein!")
print("\n" + "=" * 100)
# Check active bookings too
active = list(ausleihungen.find({'Status': 'active'}))
print(f"\n✓ AKTIVE Ausleihen: {len(active)}")
for booking in active:
print(f" - {booking.get('User', 'N/A')}: {booking.get('Item', 'N/A')} (Periode: {booking.get('Period')})")
client.close()
if __name__ == '__main__':
check_bookings()
+32 -43
View File
@@ -1,17 +1,17 @@
# Multi-Tenant Optimized Docker Compose
# Supports running multiple isolated app instances per subdomain
# Supports running multiple isolated app instances with direct Docker host port binding
# Each instance: ~50MB base + 20-30MB per 20 users
#
# Usage:
# docker-compose -f docker-compose-multitenant.yml up -d
#
# Scale example: To support 10 tenants with 20 users each:
# docker-compose -f docker-compose-multitenant.yml up -d --scale app=10
# Example: Start the stack and expose the app on host port 10000
# INVENTAR_HTTP_PORT=10000 docker-compose -f docker-compose-multitenant.yml up -d
services:
# Management Container for multi-tenant scripts
tenant-manager:
image: docker:cli
image: python:3.12-alpine
container_name: tenant-manager
restart: "no"
profiles:
@@ -19,34 +19,8 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/workspace
entrypoint: ["sh", "-c", "cd /workspace && ./manage-tenant.sh \"$$@\"", "--"]
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
restart: unless-stopped
depends_on:
app:
condition: service_started
redis:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/multitenant.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
- ./docker/nginx/acme:/etc/nginx/acme:ro
networks:
- inventar-net
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
environment:
NGINX_WORKER_PROCESSES: auto
NGINX_WORKER_CONNECTIONS: 1024
entrypoint: >
sh -c "apk add --no-cache bash docker-cli docker-cli-compose && cd /workspace && ./manage-tenant.sh \"$$@\"" --
redis:
image: redis:7-alpine
@@ -69,7 +43,6 @@ services:
image: mongo:7.0
container_name: inventarsystem-mongodb
restart: unless-stopped
command: mongod --wiredTigerCacheSizeGB 2
expose:
- "27017"
volumes:
@@ -92,6 +65,8 @@ services:
args:
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", "-"]
restart: unless-stopped
security_opt:
- no-new-privileges:true
@@ -100,13 +75,14 @@ services:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "${INVENTAR_HTTP_PORT:-10000}:8000"
networks:
- inventar-net
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads:cached
- app_thumbnails:/app/Web/thumbnails:cached
- app_previews:/app/Web/previews:cached
@@ -119,11 +95,13 @@ services:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: inventar_default
INVENTAR_CONFIG_PATH: /app/config.json
# Redis Configuration (for sessions and caching)
INVENTAR_REDIS_HOST: redis
INVENTAR_REDIS_PORT: "6379"
INVENTAR_REDIS_DB: "0"
INVENTAR_TENANT_PORT_MAP: "${INVENTAR_TENANT_PORT_MAP:-}"
# Storage Configuration
INVENTAR_BACKUP_FOLDER: /data/backups
@@ -131,11 +109,16 @@ services:
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
# Performance Tuning
INVENTAR_WORKER_CLASS: gevent
INVENTAR_WORKERS: "4"
INVENTAR_THREADS: "2"
INVENTAR_WORKER_TIMEOUT: "30"
INVENTAR_WORKER_CONNECTIONS: "100"
INVENTAR_WORKER_CLASS: ${INVENTAR_WORKER_CLASS:-gevent}
INVENTAR_WORKERS: "${INVENTAR_WORKERS:-4}"
INVENTAR_THREADS: "${INVENTAR_THREADS:-2}"
INVENTAR_WORKER_TIMEOUT: "${INVENTAR_WORKER_TIMEOUT:-30}"
INVENTAR_WORKER_CONNECTIONS: "${INVENTAR_WORKER_CONNECTIONS:-100}"
# Auto-scaled runtime settings
INVENTAR_APP_CPUS: ${INVENTAR_APP_CPUS:-1.0}
INVENTAR_APP_MEM_LIMIT: ${INVENTAR_APP_MEM_LIMIT:-256m}
INVENTAR_APP_MEM_SWAP_LIMIT: ${INVENTAR_APP_MEM_SWAP_LIMIT:-512m}
# Multi-Tenant
INVENTAR_MULTITENANT_ENABLED: "true"
@@ -156,13 +139,19 @@ services:
# Resource Limits (per instance)
# With 10 instances: each gets ~150MB, totaling ~1.5GB
# Adjust based on server resources
mem_limit: 256m
memswap_limit: 512m
cpus: "1.0"
mem_limit: "${INVENTAR_APP_MEM_LIMIT:-256m}"
memswap_limit: "${INVENTAR_APP_MEM_SWAP_LIMIT:-512m}"
cpus: "${INVENTAR_APP_CPUS:-1.0}"
# Health check for load balancer
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read(); print('OK')"
]
interval: 30s
timeout: 10s
retries: 3
+27 -51
View File
@@ -1,17 +1,29 @@
services:
nginx:
image: nginx:1.27-alpine
container_name: inventarsystem-nginx
app:
image: ghcr.io/aiirondev/legendary-octo-garbanzo:v0.7.39
container_name: inventarsystem-app
restart: unless-stopped
depends_on:
app:
condition: service_started
ports:
- "${INVENTAR_HTTP_PORT:-80}:80"
- "${INVENTAR_HTTPS_PORT:-443}:443"
- "10000:8000"
depends_on:
- mongodb
- redis
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
expose:
- "8000"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
- ./config.json:/app/config.json:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
mongodb:
image: mongo:7.0
@@ -29,51 +41,16 @@ services:
image: redis:7-alpine
container_name: inventarsystem-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"]
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
app:
build:
context: .
dockerfile: Dockerfile
container_name: inventarsystem-app
restart: unless-stopped
security_opt:
- no-new-privileges:true
depends_on:
mongodb:
condition: service_healthy
redis:
condition: service_healthy
environment:
INVENTAR_MONGODB_HOST: mongodb
INVENTAR_MONGODB_PORT: "27017"
INVENTAR_MONGODB_DB: Inventarsystem
INVENTAR_REDIS_HOST: redis
INVENTAR_REDIS_PORT: "6379"
INVENTAR_REDIS_CACHE_DB: "1"
INVENTAR_NOTIFICATION_STATUS_CACHE_TTL: "8"
INVENTAR_BACKUP_FOLDER: /data/backups
INVENTAR_LOGS_FOLDER: /data/logs
INVENTAR_DELETED_ARCHIVE_FOLDER: /data/deleted-archives
expose:
- "8000"
volumes:
- ./config.json:/app/config.json:ro
- ./Web:/app/Web:ro
- app_uploads:/app/Web/uploads
- app_thumbnails:/app/Web/thumbnails
- app_previews:/app/Web/previews
- app_qrcodes:/app/Web/QRCodes
- app_backups:/data/backups
- app_logs:/data/logs
- app_deleted_archives:/data/deleted-archives
timeout: 5s
retries: 5
volumes:
mongodb_data:
@@ -83,5 +60,4 @@ volumes:
app_qrcodes:
app_backups:
app_logs:
app_deleted_archives:
redis_data:
+8
View File
@@ -0,0 +1,8 @@
tunnel: homeserver
credentials-file: /etc/cloudflared/credentials.json
ingress:
- service: https://nginx:443
originRequest:
noTLSVerify: true
- service: http_status:404
-1104
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
394722
-57
View File
@@ -1,57 +0,0 @@
#!/bin/bash
set -euo pipefail
# Initialize first admin user if database is empty
# Usage: ./init-admin.sh [username] [password] [first_name] [last_name]
# Default: admin / admin123456 / Admin / User
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
USERNAME="${1:-admin}"
PASSWORD="${2:-admin123456}"
FIRST_NAME="${3:-Admin}"
LAST_NAME="${4:-User}"
# Wait for MongoDB to be ready
echo "Waiting for MongoDB to be ready..."
for i in {1..30}; do
if docker exec inventarsystem-mongodb mongosh --eval "db.adminCommand('ping')" >/dev/null 2>&1; then
echo "MongoDB is ready."
break
fi
if [ $i -eq 30 ]; then
echo "Error: MongoDB did not start in time."
exit 1
fi
sleep 1
done
# Check if any users already exist
USER_COUNT=$(docker exec inventarsystem-mongodb mongosh --quiet --eval "db.users.countDocuments({})" Inventarsystem 2>/dev/null || echo "0")
if [ "$USER_COUNT" -eq 0 ]; then
echo "Database is empty. Creating initial admin user: $USERNAME"
# Hash the password
PASSWORD_HASH=$(python3 -c "import hashlib; print(hashlib.sha512(b'$PASSWORD').hexdigest())")
# Insert admin user
docker exec inventarsystem-mongodb mongosh --eval "
db.users.insertOne({
Username: '$USERNAME',
Password: '$PASSWORD_HASH',
Admin: true,
active_ausleihung: null,
name: '$FIRST_NAME',
last_name: '$LAST_NAME',
favorites: []
})
" Inventarsystem
echo "✓ Admin user '$USERNAME' created successfully."
echo " Username: $USERNAME"
echo " Password: $PASSWORD"
else
echo "Database already has $USER_COUNT user(s). Skipping initialization."
fi
+24 -10
View File
@@ -22,6 +22,7 @@ LEGACY_BACKUP_ARCHIVE=""
CLEANUP_OLD_SERVICES=true
CLEANUP_OLD_REMOVE_CRON=false
TMP_DIR=""
COMPOSE_FILE="docker-compose-multitenant.yml"
cleanup_tmp_dir() {
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
@@ -50,14 +51,13 @@ refresh_start_script_from_main() {
pin_compose_app_image() {
local tag="$1"
local compose_file
compose_file="$PROJECT_DIR/docker-compose.yml"
if [ ! -f "$compose_file" ]; then
echo "Warning: $compose_file not found; cannot pin app image"
return 0
fi
for compose_file in "$PROJECT_DIR/docker-compose-multitenant.yml" "$PROJECT_DIR/docker-compose.yml"; do
if [ ! -f "$compose_file" ]; then
continue
fi
python3 - <<'PY' "$compose_file" "$tag"
python3 - <<'PY' "$compose_file" "$tag"
import re
import sys
@@ -151,6 +151,8 @@ if in_app_service and build_found and app_service_indent is not None:
with open(compose_file, "w", encoding="utf-8") as f:
f.writelines(out)
PY
done
}
install_docker_if_missing() {
@@ -173,6 +175,7 @@ Options:
--remove-legacy-system Remove old host MongoDB/system after successful import
--skip-cleanup-old Do not run cleanup-old.sh after install
--cleanup-old-remove-cron Also remove matching cron entries during old-system cleanup
--multitenant Use docker-compose-multitenant.yml (default)
--legacy-db-name <name> Legacy database name (default: $LEGACY_DB_NAME)
--legacy-mongo-uri <uri> Legacy Mongo URI (default: $LEGACY_MONGO_URI)
--legacy-system-dir <path> Optional old system directory to remove after migration
@@ -199,6 +202,10 @@ parse_args() {
CLEANUP_OLD_REMOVE_CRON=true
shift
;;
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
--legacy-db-name)
LEGACY_DB_NAME="$2"
shift 2
@@ -280,14 +287,21 @@ backup_legacy_database() {
restore_legacy_backup_into_docker() {
local i
local compose_path
if [ "$MIGRATE_LEGACY_DB" != "true" ] || [ -z "$LEGACY_BACKUP_ARCHIVE" ]; then
return 0
fi
compose_path="$PROJECT_DIR/$COMPOSE_FILE"
if [ ! -f "$compose_path" ]; then
echo "Error: compose file not found: $compose_path"
exit 1
fi
echo "Waiting for Docker MongoDB to become ready..."
for i in $(seq 1 60); do
if sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
if sudo docker compose -f "$compose_path" exec -T mongodb mongosh --quiet --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
break
fi
sleep 2
@@ -299,7 +313,7 @@ restore_legacy_backup_into_docker() {
fi
echo "Importing legacy backup into Docker MongoDB..."
sudo docker compose -f "$PROJECT_DIR/docker-compose.yml" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
sudo docker compose -f "$compose_path" exec -T mongodb mongorestore --archive --gzip --drop --nsInclude "${LEGACY_DB_NAME}.*" < "$LEGACY_BACKUP_ARCHIVE"
echo "Legacy DB import completed."
}
@@ -454,8 +468,8 @@ EOF
if [ ! -f "$PROJECT_DIR/.docker-build.env" ]; then
cat > "$TMP_DIR/.docker-build.env" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=80
INVENTAR_HTTPS_PORT=443
INVENTAR_HTTP_PORT=10000
INVENTAR_HTTPS_PORT=10001
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$tag
EOF
sudo install -m 644 "$TMP_DIR/.docker-build.env" "$PROJECT_DIR/.docker-build.env"
+774 -30
View File
@@ -1,4 +1,6 @@
#!/bin/sh
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Script to manage multitenant deployment
# Allows adding, removing, and restarting tenants without downtime for others
@@ -7,52 +9,636 @@ if [ ! -f "docker-compose-multitenant.yml" ]; then
exit 1
fi
# Resolve script directory so config paths are deterministic even when called via sudo
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json"
ensure_runtime_config_json() {
local config_path backup_path
config_path="$CONFIG_FILE"
if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path"
echo "Warning: moved unexpected directory $config_path to $backup_path"
fi
if [ ! -f "$config_path" ]; 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
},
"terminplan": {
"enabled": true
},
"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> Add a new tenant (initializes database)"
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 " 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"
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 [ -z "$1" ]; then
tenant_aliases() {
local tenant_id="$1"
local normalized alias
normalized="$(printf '%s' "$tenant_id" | tr '[:upper:]' '[:lower:]')"
printf '%s\n' "$tenant_id"
if [[ "$normalized" == schule* ]]; then
alias="school${normalized#schule}"
if [[ "$alias" != "$tenant_id" ]]; then
printf '%s\n' "$alias"
fi
elif [[ "$normalized" == school* ]]; then
alias="schule${normalized#school}"
if [[ "$alias" != "$tenant_id" ]]; then
printf '%s\n' "$alias"
fi
fi
}
register_tenant_port() {
local tenant_id="$1"
local port="$2"
if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port"
import json, sys, os
path, tenant_id, port_str = sys.argv[1], sys.argv[2], sys.argv[3]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict):
tenants = {}
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
for tid, conf in tenants.items():
if isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2)
existing = {}
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
existing = alias_cfg
break
existing['port'] = int(port_str)
for alias in aliases:
tenants[alias] = dict(existing)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"Registered tenant port {port_str} for {tenant_id}")
PY
then
echo "Tenant $tenant_id port $port registered in config.json"
else
echo "Failed to register tenant port $port for $tenant_id"
exit 1
fi
}
write_trial_tenant_config() {
local tenant_id="$1"
local port="$2"
local trial_days="$3"
if python3 - <<'PY' "$CONFIG_FILE" "$tenant_id" "$port" "$trial_days"
import json, sys, os, datetime
path, tenant_id, port_str, trial_days_str = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants')
if tenants is None or not isinstance(tenants, dict):
tenants = {}
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
for tid, conf in tenants.items():
if port_str and isinstance(conf, dict) and str(conf.get('port')) == port_str and tid not in aliases:
print(f"Error: port {port_str} is already mapped to tenant {tid}", file=sys.stderr)
sys.exit(2)
try:
trial_days = max(1, int(trial_days_str))
except ValueError:
print(f"Error: trial days must be numeric, got {trial_days_str!r}", file=sys.stderr)
sys.exit(3)
now = datetime.datetime.now(datetime.timezone.utc).isoformat()
trial_config = {
'enabled': True,
'auto_delete': True,
'days': trial_days,
'ttl_days': trial_days,
'expires_after_days': trial_days,
'started_at': now,
'created_at': now,
}
existing = {}
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
existing = alias_cfg
break
if port_str:
existing['port'] = int(port_str)
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
for alias in aliases:
tenants[alias] = dict(existing)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
print(f"Configured trial tenant {tenant_id} with {trial_days} day(s) and auto-delete enabled")
PY
then
echo "Trial tenant $tenant_id configured in config.json"
else
echo "Failed to configure trial tenant $tenant_id"
exit 1
fi
}
initialize_tenant_database() {
local tenant_id="$1"
local mode="$2"
APP_CONTAINER=$(docker ps -qf "name=app" | head -n 1)
if [ -z "$APP_CONTAINER" ]; then
echo "Warning: Application container is not running. Please start the multi-tenant system first."
echo "Data will be initialized upon first access by the tenant."
return 0
fi
docker exec "$APP_CONTAINER" python3 -c '
import sys, re, datetime, hashlib
sys.path.insert(0, "/app")
sys.path.insert(0, "/app/Web")
from Web.modules.database import settings
from pymongo import MongoClient
tenant_id = sys.argv[1].lower()
mode = sys.argv[2]
sanitized = "".join(c for c in tenant_id if c.isalnum() or c == "_")
db_name = f"inventar_{sanitized}"
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[db_name]
hashed_pw = hashlib.sha512("admin123".encode()).hexdigest()
action_permissions = {
"can_borrow": True,
"can_insert": True,
"can_edit": True,
"can_delete": True,
"can_manage_users": True,
"can_manage_settings": True,
"can_view_logs": True,
}
page_permissions = {
"home": True,
"tutorial_page": True,
"my_borrowed_items": True,
"notifications_view": True,
"impressum": True,
"license": True,
"library_view": True,
"terminplan": True,
"home_admin": True,
"upload_admin": True,
"library_admin": True,
"admin_borrowings": True,
"library_loans_admin": True,
"admin_damaged_items": True,
"admin_audit_dashboard": True,
"logs": True,
"manage_filters": True,
"manage_locations": True,
}
if db.users.count_documents({"Username": "admin"}) == 0:
db.users.insert_one({
"Username": "admin",
"Password": hashed_pw,
"Admin": True,
"active_ausleihung": None,
"name": "Admin",
"last_name": "User",
"IsStudent": False,
"PermissionPreset": "full_access",
"ActionPermissions": action_permissions,
"PagePermissions": page_permissions,
})
if mode == "trial":
db.settings.update_one(
{"setting_type": "tenant_trial"},
{"$set": {
"setting_type": "tenant_trial",
"enabled": True,
"auto_delete": True,
"days": 7,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}},
upsert=True,
)
print(f"Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123")
' "$tenant_id" "$mode"
}
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
fi
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
done
if [ ${#unique_ports[@]} -eq 0 ]; then
unique_ports=("$new_port")
fi
first_port="${unique_ports[0]}"
local ports_csv
ports_csv="$(IFS=,; echo "${unique_ports[*]}")"
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
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
fi
echo "Updated runtime env ports: $ports_csv"
fi
}
sync_tenant_port_map() {
local config_path="$CONFIG_FILE"
local env_file="$PWD/.docker-build.env"
local tenant_port_map
tenant_port_map="$(python3 - <<'PY' "$config_path"
import json, os, sys
path = sys.argv[1]
if not os.path.isfile(path):
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict):
sys.exit(0)
entries = []
for tenant_id, conf in sorted(tenants.items()):
if isinstance(conf, dict):
port = conf.get('port')
if isinstance(port, (int, float)) or (isinstance(port, str) and str(port).strip().isdigit()):
entries.append(f"{int(port)}={tenant_id}")
print(','.join(entries))
PY
)"
if [ -z "$tenant_port_map" ]; then
if [ -f "$env_file" ]; then
sed -i '/^INVENTAR_TENANT_PORT_MAP=/d' "$env_file"
fi
return 0
fi
if [ ! -f "$env_file" ]; then
cat > "$env_file" <<EOF
NUITKA_BUILD=0
INVENTAR_TENANT_PORT_MAP=$tenant_port_map
EOF
return 0
fi
if grep -q '^INVENTAR_TENANT_PORT_MAP=' "$env_file" 2>/dev/null; then
sed -i "s|^INVENTAR_TENANT_PORT_MAP=.*|INVENTAR_TENANT_PORT_MAP=$tenant_port_map|" "$env_file"
else
printf '\nINVENTAR_TENANT_PORT_MAP=%s\n' "$tenant_port_map" >> "$env_file"
fi
}
restart_app_container() {
local workdir="$SCRIPT_DIR"
local env_file="$SCRIPT_DIR/.docker-build.env"
local compose_args=()
ensure_runtime_config_json
# If HOST_WORKDIR is set (called from container), use absolute paths so docker daemon resolves them correctly
if [ -n "${HOST_WORKDIR:-}" ]; then
workdir="$HOST_WORKDIR"
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/docker-compose-multitenant.yml")" )
if [ -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$(readlink -f "$HOST_WORKDIR/.docker-compose.runtime.override.yml")" )
fi
if [ -f "$HOST_WORKDIR/.docker-build.env" ]; then
compose_args+=( --env-file "$(readlink -f "$HOST_WORKDIR/.docker-build.env")" )
fi
else
# Normal case: called directly from host
compose_args+=( -f "$workdir/docker-compose-multitenant.yml" )
if [ -f "$workdir/.docker-compose.runtime.override.yml" ]; then
compose_args+=( -f "$workdir/.docker-compose.runtime.override.yml" )
fi
if [ -f "$env_file" ]; then
compose_args+=( --env-file "$env_file" )
fi
fi
# Pass along COMPOSE_PROJECT_NAME if set so the internal docker-compose sees it
if [ -n "${COMPOSE_PROJECT_NAME:-}" ]; then
compose_args=( -p "$COMPOSE_PROJECT_NAME" "${compose_args[@]}" )
fi
echo "Restarting app container to apply tenant configuration changes..."
docker compose "${compose_args[@]}" up -d --no-build --force-recreate app
}
remove_tenant_port() {
local tenant_id="$1"
local config_path="$CONFIG_FILE"
local port
port="$(python3 - "$config_path" "$tenant_id" <<'PY'
import json, sys, os
path, tenant_id = sys.argv[1], sys.argv[2]
if not os.path.isfile(path):
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if not isinstance(tenants, dict):
sys.exit(2)
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
removed = None
for alias in list(aliases):
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
removed = alias_cfg if removed is None else removed
tenants.pop(alias, None)
cfg['tenants'] = tenants
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
if removed is not None:
port = removed.get('port')
if port is not None:
print(port)
PY
)"
local status=$?
if [ $status -eq 1 ]; then
echo "Error: config file not found: $config_path"
return 1
fi
if [ $status -eq 2 ]; then
return 2
fi
if [ -n "$port" ]; then
echo "$port"
fi
return 0
}
remove_runtime_port() {
local removed_port="$1"
local env_file="$PWD/.docker-build.env"
local current_ports=""
local port_list=()
local new_ports=()
local first_port=""
if [ ! -f "$env_file" ]; then
return 0
fi
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
if [ -z "$current_ports" ]; then
return 0
fi
IFS=',' read -r -a port_list <<<"${current_ports// /,}"
for port in "${port_list[@]}"; do
if [ -n "$port" ] && [ "$port" != "$removed_port" ]; then
new_ports+=("$port")
fi
done
if [ ${#new_ports[@]} -eq 0 ]; then
sed -i '/^INVENTAR_HTTP_PORTS=/d;/^INVENTAR_HTTP_PORT=/d' "$env_file"
return 0
fi
first_port="${new_ports[0]}"
local ports_csv="$(IFS=,; echo "${new_ports[*]}")"
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
}
if [ -z "${1:-}" ]; then
show_help
fi
COMMAND=$1
TENANT_ID=$2
COMMAND="$1"
TENANT_ID="${2:-}"
case "$COMMAND" in
-h|--help)
show_help
;;
add)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
echo "Adding new tenant '$TENANT_ID'..."
# Add Nginx configuration
if [ -f "docker/nginx/multitenant.conf" ]; then
echo "Assuming dynamic routing based on subdomain ($TENANT_ID)..."
PORT_ARG="$3"
if [ -n "$PORT_ARG" ]; then
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
echo "Error: Port must be a numeric value."
exit 1
fi
register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG"
sync_tenant_port_map
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
fi
echo "Adding new tenant '$TENANT_ID'..."
# Initialize tenant database via Python inside container
echo "Initializing database for $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/Web'); import settings; from pymongo import MongoClient; import hashlib
import sys, re; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient; import hashlib
tenant_id = sys.argv[1].lower()
sanitized = ''.join(c for c in tenant_id if c.isalnum() or c == '_')
db_name = f'inventar_{sanitized}'
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db = client[db_name]
hashed_pw = hashlib.sha512('admin123'.encode()).hexdigest()
db.users.insert_one({'Username': 'admin', 'Password': hashed_pw, 'Role': 'admin', 'Name': 'Admin', 'Nachname': 'User'})
if db.users.count_documents({'Username': 'admin'}) == 0:
db.users.insert_one({
'Username': 'admin',
'Password': hashed_pw,
'Admin': True,
'active_ausleihung': None,
'name': 'Admin',
'last_name': 'User',
'IsStudent': False,
'PermissionPreset': 'full_access',
'ActionPermissions': {
'can_borrow': True,
'can_insert': True,
'can_edit': True,
'can_delete': True,
'can_manage_users': True,
'can_manage_settings': True,
'can_view_logs': True,
},
'PagePermissions': {
'home': True,
'tutorial_page': True,
'my_borrowed_items': True,
'notifications_view': True,
'impressum': True,
'license': True,
'library_view': True,
'terminplan': True,
'home_admin': True,
'upload_admin': True,
'library_admin': True,
'admin_borrowings': True,
'library_loans_admin': True,
'admin_damaged_items': True,
'admin_audit_dashboard': True,
'logs': True,
'manage_filters': True,
'manage_locations': True,
},
})
print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin123')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' successfully added. Ready to use."
@@ -61,6 +647,36 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
echo "Data will be initialized upon first access by the tenant."
fi
;;
trial)
if [ -z "$TENANT_ID" ]; then
echo "Error: Please provide a tenant_id."
exit 1
fi
PORT_ARG="${3:-}"
DAYS_ARG="${4:-7}"
if [ -n "$PORT_ARG" ]; then
if ! printf '%s\n' "$PORT_ARG" | grep -qE '^[0-9]+$'; then
echo "Error: Port must be a numeric value."
exit 1
fi
register_tenant_port "$TENANT_ID" "$PORT_ARG"
update_runtime_ports "$PORT_ARG"
sync_tenant_port_map
fi
write_trial_tenant_config "$TENANT_ID" "$PORT_ARG" "$DAYS_ARG"
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
echo "Initializing trial database for $TENANT_ID..."
initialize_tenant_database "$TENANT_ID" "trial"
echo "Trial tenant '$TENANT_ID' successfully configured. It will expire after $DAYS_ARG day(s) and self-delete."
;;
remove)
if [ -z "$TENANT_ID" ]; then
@@ -74,14 +690,36 @@ print(f'Tenant {sys.argv[1]} database initialized. Default admin: admin / admin1
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/Web'); import settings; from pymongo import MongoClient
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(f'{settings.MONGODB_DB}_{sys.argv[1]}')
client.drop_database(db_name)
print(f'Database for tenant {sys.argv[1]} dropped.')
" "$TENANT_ID"
echo "Tenant '$TENANT_ID' removed successfully."
echo "Tenant '$TENANT_ID' database removed."
else
echo "Error: Application container not running."
echo "Warning: Application container not running. Tenant database may still exist in MongoDB."
fi
port_to_remove=""
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
else
echo "Removal canceled."
@@ -99,7 +737,7 @@ print(f'Database for tenant {sys.argv[1]} dropped.')
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/Web'); import settings; from pymongo import MongoClient
import sys; sys.path.insert(0, '/app'); sys.path.insert(0, '/app/Web'); from Web.modules.database import settings; from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
db = client[f'{settings.MONGODB_DB}_{sys.argv[1]}']
db.sessions.drop() # Force sign-out / session clear
@@ -118,22 +756,128 @@ print(f'Tenant {sys.argv[1]} session cache cleared. Tenant restarted.')
;;
list)
echo "Listing active tenants (Databases):"
echo "Listing configured tenants (config.json):"
if [ -f "$CONFIG_FILE" ]; then
python3 - "$CONFIG_FILE" <<'PY'
import json, sys
path = sys.argv[1]
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.get('tenants', {})
if isinstance(tenants, dict) and tenants:
for tid, conf in tenants.items():
port = conf.get('port') if isinstance(conf, dict) else None
if port is not None:
print(f'- {tid} (port {port})')
else:
print(f'- {tid}')
else:
print(' (no tenants configured)')
PY
else
echo " (config.json not found)"
fi
echo ""
echo "Listing active tenant databases (MongoDB):"
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/Web'); import settings; from pymongo import MongoClient
docker exec -i "$APP_CONTAINER" python3 - <<'PY'
import sys
sys.path.insert(0, '/app')
sys.path.insert(0, '/app/Web')
from Web.modules.database import settings
from pymongo import MongoClient
client = MongoClient(settings.MONGODB_HOST, int(settings.MONGODB_PORT))
prefix = f'{settings.MONGODB_DB}_'
prefix = 'inventar_'
dbs = [d for d in client.list_database_names() if d.startswith(prefix)]
for db in dbs:
print(f'- {db.replace(prefix, \"\")}')
"
if dbs:
for db in dbs:
print(f'- {db.replace(prefix, "")}')
else:
print(' (no tenant databases found)')
PY
else
echo "Error: Application container not running."
fi
;;
module)
if [ -z "$TENANT_ID" ] || [ -z "${3:-}" ]; then
echo "Error: Usage: manage-tenant.sh module <tenant_id> <module_name>=<on|off> [...]"
exit 1
fi
# Pass all remaining arguments to python script
shift 2
if python3 - "$CONFIG_FILE" "$TENANT_ID" "$@" <<'PY'
import json, sys, os
path = sys.argv[1]
tenant_id = sys.argv[2]
module_args = sys.argv[3:]
if not os.path.isfile(path):
print(f"Error: config file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
tenants = cfg.setdefault('tenants', {})
aliases = {tenant_id}
normalized = tenant_id.lower()
if normalized.startswith('schule'):
aliases.add('school' + normalized[len('schule'):])
elif normalized.startswith('school'):
aliases.add('schule' + normalized[len('school'):])
tenant_cfg = None
for alias in aliases:
alias_cfg = tenants.get(alias)
if isinstance(alias_cfg, dict):
tenant_cfg = alias_cfg
break
if tenant_cfg is None:
tenant_cfg = {}
modules = tenant_cfg.setdefault('modules', {})
port = tenant_cfg.get('port')
if port is None:
print(f"Warning: Tenant {tenant_id} doesn't have a port mapping in config.json.", file=sys.stderr)
for arg in module_args:
if '=' not in arg:
print(f"Warning: Ignoring invalid argument format '{arg}'. Expected name=on|off.", file=sys.stderr)
continue
mod_name, state_str = arg.split('=', 1)
state = str(state_str).lower() in ('on', '1', 'true', 'yes')
module_cfg = modules.setdefault(mod_name, {})
module_cfg['enabled'] = state
print(f"Module '{mod_name}' set to '{'on' if state else 'off'}' for tenant '{tenant_id}'.")
for alias in aliases:
alias_cfg = tenants.get(alias)
if not isinstance(alias_cfg, dict):
alias_cfg = {}
alias_cfg.update(tenant_cfg)
alias_cfg['modules'] = modules
tenants[alias] = alias_cfg
with open(path, 'w', encoding='utf-8') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
PY
then
echo "Module configurations updated successfully."
if [ -n "$(docker ps -qf 'name=app' | head -n 1)" ]; then
restart_app_container
fi
else
echo "Failed to update module configuration."
exit 1
fi
;;
*)
echo "Unknown command: $COMMAND"
show_help
-337
View File
@@ -1,337 +0,0 @@
#!/bin/bash
# Standalone version manager for this repo
# Supports pinning to a commit/tag/branch, one-time use, clearing the lock,
# applying the lock, listing refs, and showing status. Optional restart.
set -euo pipefail
PROJECT_DIR="$(dirname "$(readlink -f "$0")")"
cd "$PROJECT_DIR"
LOCK_FILE="$PROJECT_DIR/.version-lock"
RESTART=false
FORCE=false
BACKUP_BASE_DIR="/var/backups"
LOG_DIR="$PROJECT_DIR/logs"
VM_LOG="$LOG_DIR/version_manager.log"
# Directories/files to preserve across version switches
# Data paths we want to restore from backup after a version switch
DATA_PATHS=(
"certs"
"Images"
"logs"
"Web/uploads"
"Web/thumbnails"
"Web/QRCodes"
"Web/previews"
)
# Ensure log directory exists
mkdir -p "$LOG_DIR" >/dev/null 2>&1 || true
chmod 777 "$LOG_DIR" >/dev/null 2>&1 || true
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$VM_LOG"; }
err() { echo "ERROR: $*" | tee -a "$VM_LOG" >&2; }
usage() {
cat <<USAGE
Usage:
$0 pin <ref> [--restart] [--force] Persistently lock to a commit/tag/branch and deploy it
$0 use <ref> [--restart] [--force] Use ref once (no persistence) and deploy it
$0 clear [--restart] [--force] Remove lock, switch back to main, pull latest
$0 apply [--restart] [--force] Re-apply the current lock (if any)
$0 status Show current commit and lock state
$0 list [--tags|--commits] List tags or recent commits (default: tags)
Notes:
- <ref> can be a tag name, branch name, or full/short commit hash
- --restart calls ./restart.sh after switching
- --force discards local changes (git reset --hard) if needed
USAGE
}
ensure_git_ready() {
# Avoid "dubious ownership" errors
git config --global --add safe.directory "$PROJECT_DIR" >/dev/null 2>&1 || true
# Fetch refs and tags
git fetch --all --tags --prune
}
ensure_clean_or_force() {
if ! git diff --quiet || ! git diff --cached --quiet; then
if [ "$FORCE" = true ]; then
log "Discarding local changes (force)"
git reset --hard
git clean -fdx >/dev/null 2>&1 || true
else
err "Working tree has local changes. Re-run with --force to discard."
exit 2
fi
fi
}
restart_if_requested() {
if [ "$RESTART" = true ]; then
if [ -x "$PROJECT_DIR/restart.sh" ]; then
log "Restarting services..."
"$PROJECT_DIR/restart.sh"
else
log "restart.sh not found or not executable; skipping restart"
fi
fi
}
# Create a full backup before switching versions (files + DB CSVs)
LAST_BACKUP_ARCHIVE=""
LAST_BACKUP_DIRNAME=""
create_pre_switch_backup() {
local ts backup_name backup_dir backup_archive
ts=$(date +"%Y-%m-%d_%H-%M-%S")
backup_name="Inventarsystem-pre-switch-$ts"
backup_dir="$BACKUP_BASE_DIR/$backup_name"
backup_archive="$BACKUP_BASE_DIR/$backup_name.tar.gz"
log "Creating pre-switch backup at $backup_archive"
sudo mkdir -p "$backup_dir" || { err "Failed to create backup directory"; return 1; }
# Copy project files (exclude .venv to reduce size, keep .git for reference)
log "Copying project files to backup directory..."
sudo rsync -a --delete \
--exclude='.venv' \
--exclude='__pycache__' \
--exclude='*.pyc' \
"$PROJECT_DIR/" "$backup_dir/" || log "Warning: rsync file copy encountered issues"
# Database CSV backup using existing helper
log "Backing up MongoDB to CSVs..."
sudo mkdir -p "$backup_dir/mongodb_backup" || true
if [ -x "$PROJECT_DIR/run-backup.sh" ]; then
if sudo -E "$PROJECT_DIR/run-backup.sh" --db Inventarsystem --uri mongodb://localhost:27017/ --out "$backup_dir/mongodb_backup" >> "$VM_LOG" 2>&1; then
log "Database backup completed"
else
err "Database backup failed; continuing with file backup only"
fi
else
log "run-backup.sh not found; skipping DB backup"
fi
# Compress backup
log "Compressing backup archive..."
if sudo tar -czf "$backup_archive" -C "$BACKUP_BASE_DIR" "$backup_name"; then
log "Backup archived to $backup_archive"
sudo rm -rf "$backup_dir" || true
# Set readable permissions
sudo chmod 644 "$backup_archive" || true
# Export for subsequent restore step
LAST_BACKUP_ARCHIVE="$backup_archive"
LAST_BACKUP_DIRNAME="$backup_name"
else
err "Failed to create compressed backup archive"
return 1
fi
}
# Restore key data from the backup archive into current working tree
restore_from_backup_archive() {
if [ -z "$LAST_BACKUP_ARCHIVE" ] || [ ! -f "$LAST_BACKUP_ARCHIVE" ]; then
log "No backup archive available to restore data from; skipping data restore"
return 0
fi
local tmpdir
tmpdir=$(mktemp -d /tmp/inventarsystem_restore_XXXXXX)
log "Extracting backup archive $LAST_BACKUP_ARCHIVE to $tmpdir"
if ! sudo tar -xzf "$LAST_BACKUP_ARCHIVE" -C "$tmpdir"; then
err "Failed to extract backup archive; skipping data restore"
rm -rf "$tmpdir" || true
return 1
fi
local src_root="$tmpdir/$LAST_BACKUP_DIRNAME"
# Some backups might extract without top-level dir; fallback
if [ ! -d "$src_root" ]; then
src_root="$tmpdir"
fi
log "Restoring data directories from backup..."
for rel in "${DATA_PATHS[@]}"; do
if [ -e "$src_root/$rel" ]; then
mkdir -p "$PROJECT_DIR/$(dirname "$rel")" 2>/dev/null || true
rsync -a "$src_root/$rel" "$PROJECT_DIR/$(dirname "$rel")/" >> "$VM_LOG" 2>&1 || log "Warning: failed to restore $rel"
log "Restored: $rel"
else
log "Not in backup (skipped): $rel"
fi
done
rm -rf "$tmpdir" || true
}
# Ensure permissions on critical data directories
ensure_permissions() {
# Logs should be writable
mkdir -p "$PROJECT_DIR/logs" && chmod 777 "$PROJECT_DIR/logs" 2>/dev/null || true
# Web data directories readable/executable by server
for d in "$PROJECT_DIR/Web/uploads" "$PROJECT_DIR/Web/thumbnails" "$PROJECT_DIR/Web/QRCodes" "$PROJECT_DIR/Web/previews" "$PROJECT_DIR/Images"; do
[ -d "$d" ] && chmod -R 755 "$d" 2>/dev/null || true
done
# SSL cert perms (key must be 600)
if [ -f "$PROJECT_DIR/certs/inventarsystem.key" ]; then
chmod 600 "$PROJECT_DIR/certs/inventarsystem.key" 2>/dev/null || true
fi
}
checkout_ref() {
local ref="$1"
# Try tag
if git show-ref --verify --quiet "refs/tags/$ref"; then
git checkout -B locked-tag-"$ref" "tags/$ref"
return 0
fi
# Try remote branch
if git show-ref --verify --quiet "refs/remotes/origin/$ref"; then
git checkout -B locked-branch-"$ref" "origin/$ref"
return 0
fi
# Try local branch
if git show-ref --verify --quiet "refs/heads/$ref"; then
git checkout "$ref"
return 0
fi
# Try commit-ish
if git rev-parse --verify --quiet "$ref^{commit}" >/dev/null; then
git checkout -B locked-commit "$ref"
return 0
fi
err "Could not resolve ref '$ref' to tag/branch/commit"
exit 3
}
deploy_main_latest() {
ensure_clean_or_force
if git show-ref --verify --quiet refs/heads/main; then
git checkout main
else
git checkout -B main origin/main
fi
git pull --ff-only
}
cmd_status() {
local head_commit
head_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
echo "Current commit: $head_commit"
if [ -f "$LOCK_FILE" ]; then
echo "Version lock: $(cat "$LOCK_FILE")"
else
echo "Version lock: none (tracking main)"
fi
}
cmd_list() {
local mode="tags"
for a in "$@"; do
case "$a" in
--tags) mode="tags" ;;
--commits) mode="commits" ;;
esac
done
ensure_git_ready
if [ "$mode" = "tags" ]; then
git tag --list | sort -V
else
git --no-pager log --oneline -n 30
fi
}
cmd_pin() {
local ref="$1"
ensure_git_ready
# Always produce a backup before switching
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
ensure_clean_or_force
echo "$ref" > "$LOCK_FILE"
checkout_ref "$ref"
# Restore data from the backup archive
restore_from_backup_archive
ensure_permissions
log "Pinned to: $ref (commit $(git rev-parse --short HEAD))"
restart_if_requested
}
cmd_use() {
local ref="$1"
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
ensure_clean_or_force
checkout_ref "$ref"
restore_from_backup_archive
ensure_permissions
log "Using (one-time): $ref (commit $(git rev-parse --short HEAD))"
restart_if_requested
}
cmd_clear() {
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
[ -f "$LOCK_FILE" ] && rm -f "$LOCK_FILE"
deploy_main_latest
restore_from_backup_archive
ensure_permissions
log "Cleared version lock; now on main @ $(git rev-parse --short HEAD)"
restart_if_requested
}
cmd_apply() {
ensure_git_ready
create_pre_switch_backup || log "Backup step had issues; proceeding with caution"
if [ -f "$LOCK_FILE" ]; then
ensure_clean_or_force
local ref
ref=$(cat "$LOCK_FILE")
checkout_ref "$ref"
log "Applied lock: $ref (commit $(git rev-parse --short HEAD))"
else
log "No lock present; keeping main"
deploy_main_latest
fi
restore_from_backup_archive
ensure_permissions
restart_if_requested
}
# Parse global flags that can appear after subcommand too
parse_tail_flags() {
for a in "$@"; do
case "$a" in
--restart) RESTART=true ;;
--force) FORCE=true ;;
esac
done
}
main() {
local cmd="${1:-}"; shift || true
case "$cmd" in
pin)
[ $# -ge 1 ] || { usage; exit 1; }
local ref="$1"; shift; parse_tail_flags "$@"; cmd_pin "$ref" ;;
use)
[ $# -ge 1 ] || { usage; exit 1; }
local ref="$1"; shift; parse_tail_flags "$@"; cmd_use "$ref" ;;
clear)
parse_tail_flags "$@"; cmd_clear ;;
apply)
parse_tail_flags "$@"; cmd_apply ;;
status)
cmd_status ;;
list)
cmd_list "$@" ;;
--help|-h|help|"")
usage ;;
*)
err "Unknown command: $cmd"; usage; exit 1 ;;
esac
}
main "$@"
-426
View File
@@ -1,426 +0,0 @@
#!/bin/bash
##############################################################################
# Multi-Tenant Migration Script
#
# Automatisierte Konvertierung einer Single-Instance App zu Multi-Tenant
# Führt folgende Operationen durch:
# 1. Backup der Original-Dateien
# 2. Import von Tenant-Modulen
# 3. Anpassung von app.py
# 4. Konfiguration von Redis
# 5. Validierung
#
# Usage: bash migrate-to-multitenant.sh [dry-run]
##############################################################################
set -e
# Farben für Terminal Output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEB_DIR="$PROJECT_ROOT/Web"
BACKUP_DIR="$PROJECT_ROOT/.migration-backup-$(date +%Y%m%d-%H%M%S)"
DRY_RUN="${1:-}"
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[✓]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
check_prerequisites() {
log_info "Checking prerequisites..."
# Check Python
if ! command -v python3 &> /dev/null; then
log_error "Python 3 not found"
return 1
fi
log_success "Python 3 found: $(python3 --version)"
# Check Docker
if ! command -v docker &> /dev/null; then
log_warning "Docker not found (will be needed for deployment)"
else
log_success "Docker found: $(docker --version)"
fi
# Check Flask app
if [[ ! -f "$WEB_DIR/app.py" ]]; then
log_error "Web/app.py not found"
return 1
fi
log_success "Flask app found at $WEB_DIR/app.py"
return 0
}
create_backups() {
log_info "Creating backups..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_info "[DRY-RUN] Would backup to: $BACKUP_DIR"
return 0
fi
mkdir -p "$BACKUP_DIR"
# Backup critical files
cp "$WEB_DIR/app.py" "$BACKUP_DIR/app.py.bak"
cp "$WEB_DIR/settings.py" "$BACKUP_DIR/settings.py.bak" 2>/dev/null || true
cp "docker-compose.yml" "$BACKUP_DIR/docker-compose.yml.bak" 2>/dev/null || true
log_success "Backups created at: $BACKUP_DIR"
}
check_tenant_modules() {
log_info "Checking tenant modules..."
local missing=0
for module in tenant session_manager query_cache; do
if [[ ! -f "$WEB_DIR/${module}.py" ]]; then
log_warning "Missing module: $WEB_DIR/${module}.py"
((missing++))
else
log_success "Module found: $module.py"
fi
done
if [[ $missing -gt 0 ]]; then
log_error "Missing $missing required modules. Ensure they are created first."
return 1
fi
return 0
}
check_docker_files() {
log_info "Checking Docker configuration files..."
local missing=0
if [[ ! -f "docker-compose-multitenant.yml" ]]; then
log_warning "Missing docker-compose-multitenant.yml"
((missing++))
else
log_success "docker-compose-multitenant.yml found"
fi
if [[ ! -f "docker/nginx/multitenant.conf" ]]; then
log_warning "Missing docker/nginx/multitenant.conf"
((missing++))
else
log_success "docker/nginx/multitenant.conf found"
fi
if [[ $missing -gt 0 ]]; then
log_warning "Missing $missing Docker files (needed for deployment)"
fi
return 0
}
update_app_py() {
log_info "Updating app.py with Multi-Tenant imports..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_info "[DRY-RUN] Would add Multi-Tenant imports to app.py"
return 0
fi
# Check if already updated
if grep -q "from tenant import" "$WEB_DIR/app.py"; then
log_warning "app.py already contains Multi-Tenant imports (skipping)"
return 0
fi
# Add imports after other data_protection imports
local imports_section=$(python3 -c "
import re
with open('$WEB_DIR/app.py', 'r') as f:
content = f.read()
# Find the line after data_protection imports
if 'from data_protection import' in content:
lines = content.split('\n')
for i, line in enumerate(lines):
if 'from data_protection import' in line:
# Find the end of this import block
j = i + 1
while j < len(lines) and (lines[j].startswith(' ') or lines[j].strip() == ''):
j += 1
# Insert new imports before settings import
print(j)
break
else:
print(-1)
" 2>/dev/null)
if [[ $imports_section -eq -1 ]]; then
log_warning "Could not find insertion point for Multi-Tenant imports"
log_info "Please manually add the following imports to app.py:"
cat << 'EOF'
# Multi-Tenant Imports
from tenant import get_tenant_context, require_tenant, get_tenant_db
from session_manager import create_redis_session_interface
from query_cache import get_cache_manager, cached_query, invalidate_cache
EOF
return 1
fi
log_success "Multi-Tenant imports prepared"
return 0
}
update_requirements() {
log_info "Checking requirements.txt for Redis..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_info "[DRY-RUN] Would update requirements.txt"
return 0
fi
# Check both root and Web/requirements.txt
for req_file in requirements.txt Web/requirements.txt; do
if [[ -f "$req_file" ]]; then
if ! grep -q "^redis" "$req_file"; then
log_info "Adding redis to $req_file..."
echo "redis>=7.0.0" >> "$req_file"
log_success "Added redis to $req_file"
else
log_success "$req_file already has redis"
fi
fi
done
return 0
}
generate_migration_guide() {
log_info "Generating migration guide..."
cat > "$BACKUP_DIR/MIGRATION_NOTES.md" << 'EOF'
# Multi-Tenant Migration Checklist
## Automated Steps Completed
- [x] Backup created
- [x] Tenant modules verified
- [x] Docker files prepared
- [x] app.py imports prepared
- [x] requirements.txt updated
## Manual Steps Required
### 1. Update app.py Configuration (5 min)
Add after `app = Flask(...)`:
```python
# Multi-Tenant Configuration
if os.getenv('INVENTAR_SESSION_BACKEND') == 'redis':
try:
app.session_interface = create_redis_session_interface(app)
app.logger.info("Redis session backend enabled")
except Exception as e:
app.logger.warning(f"Redis session backend failed: {e}")
```
### 2. Add Health Check Endpoint (5 min)
Add this route:
```python
@app.route('/health')
def health_check():
try:
mongo = MongoClient(cfg.MONGODB_HOST, cfg.MONGODB_PORT)
mongo.admin.command('ping')
return {'status': 'healthy'}, 200
except Exception as e:
return {'status': 'unhealthy'}, 503
```
### 3. Update Database Access (10-30 min)
Change existing routes to use:
```python
@require_tenant
def your_route():
db = get_tenant_db(MongoClient(...))
# Use db as usual
```
### 4. Setup DNS (5 min)
Create wildcard DNS record:
```
*.example.com IN A your-server-ip
```
### 5. Create SSL Certificate (10 min)
```bash
# Let's Encrypt (recommended)
sudo certbot certonly --manual --preferred-challenges dns \
-d "*.example.com" -d "example.com"
# Copy to certs folder
cp /etc/letsencrypt/live/example.com/fullchain.pem certs/inventarsystem.crt
cp /etc/letsencrypt/live/example.com/privkey.pem certs/inventarsystem.key
```
### 6. Deploy Multi-Tenant
```bash
# Install dependencies
pip install -r requirements.txt
# Start Multi-Tenant deployment
docker-compose -f docker-compose-multitenant.yml up -d
# Test
curl https://test.example.com/health
```
### 7. Verify (5 min)
```bash
# Check logs
docker-compose logs app
# Verify each tenant
curl https://schule1.example.com/debug/tenant
curl https://schule2.example.com/debug/tenant
# Cache stats
curl https://schule1.example.com/debug/cache-stats
```
## Rollback Plan
If something goes wrong:
1. Stop Multi-Tenant deployment
```bash
docker-compose -f docker-compose-multitenant.yml down
```
2. Restore from backup
```bash
cp .migration-backup-*/app.py Web/app.py
cp .migration-backup-*/settings.py Web/settings.py
```
3. Restart original deployment
```bash
docker-compose up -d
```
## Performance Expectations
- Memory per instance: 100-150MB (was 200MB)
- Response time: -30% (Redis caching)
- Max concurrent users: 3x increase
- Database load: -70% (query caching)
## Support
- Check logs: `docker-compose -f docker-compose-multitenant.yml logs -f app`
- Debug tenant: `curl https://your-tenant.com/debug/tenant`
- See MULTITENANT_DEPLOYMENT.md for detailed guide
EOF
log_success "Migration guide created at: $BACKUP_DIR/MIGRATION_NOTES.md"
}
validate_setup() {
log_info "Validating setup..."
# Check Python syntax of tenant modules
for module in tenant session_manager query_cache; do
module_file="$WEB_DIR/${module}.py"
if [[ -f "$module_file" ]]; then
if python3 -m py_compile "$module_file" 2>/dev/null; then
log_success "Module syntax valid: $module.py"
else
log_error "Syntax error in $module.py"
return 1
fi
fi
done
return 0
}
print_summary() {
cat << EOF
${GREEN}═══════════════════════════════════════════════════════════${NC}
${GREEN} Multi-Tenant Migration Summary${NC}
${GREEN}═══════════════════════════════════════════════════════════${NC}
${GREEN}✓ Prerequisites checked${NC}
${GREEN}✓ Backups created${NC}
${GREEN}✓ Tenant modules verified${NC}
${GREEN}✓ Docker files prepared${NC}
${GREEN}✓ app.py imports prepared${NC}
${GREEN}✓ requirements.txt updated${NC}
${YELLOW}Next Steps:${NC}
1. Review migration guide: $BACKUP_DIR/MIGRATION_NOTES.md
2. Manually update app.py (see guide above)
3. Setup DNS wildcard record
4. Create SSL certificate
5. Deploy: docker-compose -f docker-compose-multitenant.yml up -d
${BLUE}Documentation:${NC}
- MULTITENANT_DEPLOYMENT.md - Complete deployment guide
- MULTITENANT_INTEGRATION.md - Code integration examples
- $BACKUP_DIR/MIGRATION_NOTES.md - Step-by-step checklist
${GREEN}═══════════════════════════════════════════════════════════${NC}
EOF
}
main() {
log_info "Multi-Tenant Migration Starting..."
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_warning "Running in DRY-RUN mode - no changes will be made"
fi
check_prerequisites || exit 1
create_backups
check_tenant_modules || exit 1
check_docker_files
update_app_py || true
update_requirements
validate_setup || exit 1
generate_migration_guide
print_summary
if [[ "$DRY_RUN" == "dry-run" ]]; then
log_warning "DRY-RUN completed - no changes were made"
log_info "Run without 'dry-run' parameter to execute migration"
fi
}
main "$@"
-119
View File
@@ -1,119 +0,0 @@
#!/bin/bash
# This script completely rebuilds the virtual environment
# It should be run as root/sudo
# Get the script directory
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
VENV_DIR="$SCRIPT_DIR/.venv"
BACKUP_DIR="$SCRIPT_DIR/.venv_backup_$(date +%Y%m%d%H%M%S)"
# Function to log messages
log_message() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
log_message "Starting virtual environment rebuild"
# Backup existing virtual environment if it exists
if [ -d "$VENV_DIR" ]; then
log_message "Backing up existing virtual environment to $BACKUP_DIR"
mv "$VENV_DIR" "$BACKUP_DIR" || {
log_message "ERROR: Failed to backup existing virtual environment. Proceeding without backup."
rm -rf "$VENV_DIR"
}
fi
# Create logs directory with proper permissions
log_message "Ensuring logs directory exists with proper permissions"
mkdir -p "$SCRIPT_DIR/logs"
chmod 777 "$SCRIPT_DIR/logs" || log_message "WARNING: Could not set permissions on logs directory"
# Create a new virtual environment
log_message "Creating new virtual environment"
python3 -m venv "$VENV_DIR" || {
log_message "ERROR: Failed to create virtual environment. Trying with system packages..."
python3 -m venv "$VENV_DIR" --system-site-packages || {
log_message "ERROR: Failed to create virtual environment even with system packages. Exiting."
exit 1
}
}
# Set proper permissions
log_message "Setting permissions for new virtual environment"
chmod -R 755 "$VENV_DIR" || log_message "WARNING: Could not set permissions on virtual environment"
find "$VENV_DIR" -type d -exec chmod 755 {} \; 2>/dev/null
find "$VENV_DIR/bin" -type f -exec chmod +x {} \; 2>/dev/null
# Activate the virtual environment
log_message "Activating virtual environment"
source "$VENV_DIR/bin/activate" || {
log_message "ERROR: Failed to activate virtual environment"
exit 1
}
# Install packages
log_message "Upgrading pip"
pip install --upgrade pip || log_message "WARNING: Failed to upgrade pip"
log_message "Installing pymongo"
# First ensure bson is removed to avoid conflicts
pip uninstall -y bson || log_message "WARNING: Failed to uninstall bson (may not exist)"
# Check if bson directory exists after uninstall and remove it if necessary
if [ -d "$VENV_DIR/lib/python3.12/site-packages/bson" ]; then
log_message "Force removing bson directory"
rm -rf "$VENV_DIR/lib/python3.12/site-packages/bson"
fi
# Now install pymongo
pip install pymongo==4.6.3 || log_message "WARNING: Failed to install pymongo"
# Verify pymongo installation
python -c "import pymongo; print(f'PyMongo version: {pymongo.__version__}')" && log_message "✓ PyMongo installed correctly" || log_message "WARNING: PyMongo verification failed"
# Install other requirements if they exist
if [ -f "$SCRIPT_DIR/requirements.txt" ]; then
log_message "Installing packages from requirements.txt (excluding bson)"
# Create a modified requirements file without bson
grep -v "^bson" "$SCRIPT_DIR/requirements.txt" > "$SCRIPT_DIR/requirements_filtered.txt"
# Also remove pymongo since we already installed it
grep -v "^pymongo" "$SCRIPT_DIR/requirements_filtered.txt" > "$SCRIPT_DIR/requirements_no_conflicts.txt"
pip install -r "$SCRIPT_DIR/requirements_no_conflicts.txt" || log_message "WARNING: Failed to install some packages from requirements.txt"
rm -f "$SCRIPT_DIR/requirements_filtered.txt" "$SCRIPT_DIR/requirements_no_conflicts.txt"
log_message "Verifying pymongo installation after package installation..."
# Check if the bson directory from the standalone package still exists and remove it
if [ -d "$VENV_DIR/lib/python3.12/site-packages/bson" ]; then
log_message "Found standalone bson package, removing it..."
rm -rf "$VENV_DIR/lib/python3.12/site-packages/bson"
# Reinstall pymongo to ensure it's correctly configured
pip install --force-reinstall pymongo==4.6.3
fi
else
log_message "No requirements.txt found, installing essential packages manually"
pip install flask werkzeug gunicorn pillow qrcode apscheduler python-dateutil pytz requests || {
log_message "WARNING: Failed to install some essential packages"
}
fi
# Deactivate virtual environment
deactivate
# Verify the virtual environment works correctly
log_message "Verifying virtual environment..."
if "$VENV_DIR/bin/python" -c "import pymongo, flask, gunicorn, requests, PIL, apscheduler; exit(0)" 2>/dev/null; then
log_message "✓ Virtual environment verification successful"
# Clean up backup if everything works
if [ -d "$BACKUP_DIR" ]; then
log_message "Removing backup virtual environment since verification was successful"
rm -rf "$BACKUP_DIR"
log_message "✓ Backup environment removed"
fi
else
log_message "WARNING: Virtual environment verification failed"
log_message "Keeping backup at $BACKUP_DIR in case it's needed for recovery"
fi
log_message "Virtual environment rebuild complete"
log_message "You can now run the restart.sh script"
exit 0
+2 -2
View File
@@ -12,6 +12,6 @@ redis
reportlab
python-barcode
openpyxl
cryptography
cryptography>=42.0.0
pywebpush
py-vapid==1.9.0
py-vapid>=1.9.0
+104 -5
View File
@@ -1,8 +1,107 @@
#!/bin/bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
cd "$SCRIPT_DIR"
"$SCRIPT_DIR/stop.sh" "$@"
"$SCRIPT_DIR/start.sh" "$@"
"$SCRIPT_DIR/start.sh"
COMPOSE_FILE="docker-compose-multitenant.yml"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
parse_port_list() {
local raw="$1"
local port
local ports=()
raw="${raw//,/ }"
for port in $raw; do
port="${port//[[:space:]]/}"
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
ports+=("$port")
fi
done
printf '%s\n' "${ports[@]}"
}
write_runtime_override() {
local current_ports=""
local app_image=""
local ports=()
if [ -f "$ENV_FILE" ]; then
app_image="$(awk -F= '/^INVENTAR_APP_IMAGE=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
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 "$app_image" ]; then
if [ -n "$current_ports" ]; then
mapfile -t ports < <(parse_port_list "$current_ports")
fi
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
services:
app:
image: $app_image
build: null
EOF
if [ ${#ports[@]} -gt 0 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports:
EOF
for port in "${ports[@]}"; do
if [ -n "$port" ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000"
EOF
fi
done
fi
else
rm -f "$RUNTIME_COMPOSE_OVERRIDE_FILE"
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
--multitenant)
COMPOSE_FILE="docker-compose-multitenant.yml"
shift
;;
*)
shift
;;
esac
done
if ! command -v docker >/dev/null 2>&1; then
echo "Error: docker command not found. Install Docker first."
exit 1
fi
echo "Rebuilding and/or restarting app container using $COMPOSE_FILE..."
write_runtime_override
compose_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_args+=( -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" )
fi
if [ -f "$ENV_FILE" ]; then
compose_args+=( --env-file "$ENV_FILE" )
fi
if [ -f "$SCRIPT_DIR/Dockerfile" ]; then
docker compose "${compose_args[@]}" up -d --build --force-recreate app
else
echo "Warning: Dockerfile not found in $SCRIPT_DIR. Skipping build and restarting existing app container."
if ! docker compose "${compose_args[@]}" up -d --no-build --force-recreate app; then
echo "Warning: app image not found or not available locally. Attempting docker compose pull app..."
docker compose "${compose_args[@]}" pull app
docker compose "${compose_args[@]}" up -d --no-build --force-recreate app
fi
fi
echo "Cleaning up unused Docker images..."
docker image prune -f
echo "App restart complete."
-527
View File
@@ -1,527 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$SCRIPT_DIR/logs"
LOG_FILE="$LOG_DIR/restore.log"
WORK_DIR="$(mktemp -d /tmp/inventarsystem-restore-XXXXXX)"
DB_NAME="${INVENTAR_MONGODB_DB:-Inventarsystem}"
SOURCE_PATH=""
BACKUP_DATE=""
DROP_DATABASE=false
RESTART_SERVICES=false
STAGED_PATH=""
BACKUP_ROOT_LOCAL="$SCRIPT_DIR/backups"
BACKUP_ROOT_SYSTEM="/var/backups"
SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
DOCKER_COMPOSE=()
cleanup() {
rm -rf "$WORK_DIR" >/dev/null 2>&1 || true
}
trap cleanup EXIT
mkdir -p "$LOG_DIR"
log() {
local msg="$1"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $msg" | tee -a "$LOG_FILE"
}
show_help() {
cat <<EOF
Inventarsystem Restore / Porting Tool (Docker-first)
Usage:
$0 --source <path> [options]
$0 --date <YYYY-MM-DD|latest> [options]
$0 --list
Options:
--source <path> Backup source path.
Supported:
- old folder with CSV files
- old Inventarsystem-*.tar.gz backup
- new mongodb-*.archive.gz backup
- folder containing mongodb-*.archive.gz
--date <value> Resolve source from known backup locations.
- YYYY-MM-DD: checks backups/YYYY-MM-DD and /var/backups/Inventarsystem-YYYY-MM-DD(.tar.gz)
- latest: newest from local/system backup roots
--drop-database Drop target DB before import (recommended for full restore)
--restart-services Restart stack after restore
--list List detected backup candidates
--help Show this help
Notes:
- Always restores into current DB name: $DB_NAME
- Supports both legacy CSV and new archive backups
- Normalizes collection names to current structure (users, items, ausleihungen, filter_presets, settings)
EOF
}
setup_compose() {
if docker compose version >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=(docker compose)
return 0
fi
if [ -n "$SUDO" ] && $SUDO docker compose version >/dev/null 2>&1 && $SUDO docker info >/dev/null 2>&1; then
DOCKER_COMPOSE=($SUDO docker compose)
return 0
fi
log "ERROR: docker compose is not available"
exit 1
}
compose() {
"${DOCKER_COMPOSE[@]}" "$@"
}
list_backups() {
echo "Detected backup candidates:"
if [ -d "$BACKUP_ROOT_LOCAL" ]; then
find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort
fi
if [ -d "$BACKUP_ROOT_SYSTEM" ]; then
find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) 2>/dev/null | sort
fi
}
resolve_latest_source() {
local latest
latest="$({
if [ -d "$BACKUP_ROOT_LOCAL" ]; then
find "$BACKUP_ROOT_LOCAL" -maxdepth 2 \( -type d -name "20*" -o -type f -name "*.archive.gz" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null
fi
if [ -d "$BACKUP_ROOT_SYSTEM" ]; then
find "$BACKUP_ROOT_SYSTEM" -maxdepth 1 \( -type d -name "Inventarsystem-*" -o -type f -name "Inventarsystem-*.tar.gz" \) -printf '%T@ %p\n' 2>/dev/null
fi
} | sort -nr | head -n1 | cut -d' ' -f2-)"
if [ -z "$latest" ]; then
log "ERROR: no backup candidates found"
exit 1
fi
SOURCE_PATH="$latest"
}
resolve_date_source() {
local d="$1"
if [ "$d" = "latest" ]; then
resolve_latest_source
return
fi
local p1="$BACKUP_ROOT_LOCAL/$d"
local p2="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d"
local p3="$BACKUP_ROOT_SYSTEM/Inventarsystem-$d.tar.gz"
if [ -e "$p1" ]; then
SOURCE_PATH="$p1"
elif [ -e "$p2" ]; then
SOURCE_PATH="$p2"
elif [ -e "$p3" ]; then
SOURCE_PATH="$p3"
else
log "ERROR: no backup found for date $d"
exit 1
fi
}
ensure_stack() {
log "Ensuring mongodb/app services are running..."
compose up -d mongodb app >/dev/null
if ! compose ps --status running mongodb | grep -q mongodb; then
log "ERROR: mongodb container is not running"
exit 1
fi
if ! compose ps --status running app | grep -q app; then
log "ERROR: app container is not running"
exit 1
fi
}
stage_source() {
local src="$1"
if [ ! -e "$src" ]; then
log "ERROR: source path does not exist: $src"
exit 1
fi
if [ -f "$src" ] && [[ "$src" == *.tar.gz ]]; then
log "Extracting tar backup: $src"
tar -xzf "$src" -C "$WORK_DIR"
STAGED_PATH="$WORK_DIR"
return
fi
if [ -f "$src" ]; then
cp -f "$src" "$WORK_DIR/"
STAGED_PATH="$WORK_DIR"
return
fi
if [ -d "$src" ]; then
STAGED_PATH="$src"
return
fi
log "ERROR: unsupported source type: $src"
exit 1
}
find_archive_file() {
local root="$1"
find "$root" -type f \( -name "*.archive.gz" -o -name "*.archive" \) 2>/dev/null | head -n1 || true
}
find_best_csv_dir() {
local root="$1"
local best_dir=""
local best_count=0
while IFS= read -r dir; do
[ -z "$dir" ] && continue
local count
count="$(find "$dir" -maxdepth 1 -type f -name '*.csv' | wc -l | tr -d ' ')"
if [ "$count" -gt "$best_count" ]; then
best_count="$count"
best_dir="$dir"
fi
done < <(find "$root" -type f -name '*.csv' -printf '%h\n' 2>/dev/null | sort -u)
if [ "$best_count" -gt 0 ]; then
echo "$best_dir"
fi
}
normalize_structure() {
local py="$WORK_DIR/normalize_db.py"
cat > "$py" <<'PY'
from pymongo import MongoClient
import os
db_name = os.getenv("DB_NAME", "Inventarsystem")
host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
client = MongoClient(host, port)
db = client[db_name]
rename_map = {
"user": "users",
"item": "items",
"ausleihung": "ausleihungen",
"filter_preset": "filter_presets",
"preset": "filter_presets",
"setting": "settings",
}
for old, new in rename_map.items():
if old not in db.list_collection_names():
continue
if new not in db.list_collection_names():
db[old].rename(new)
print(f"Renamed collection {old} -> {new}")
continue
moved = 0
for doc in db[old].find({}):
db[new].insert_one(doc)
moved += 1
db[old].drop()
print(f"Merged {moved} docs from {old} into {new}")
print("Final collections:", sorted(db.list_collection_names()))
PY
local app_cid
app_cid="$(compose ps -q app)"
$SUDO docker cp "$py" "$app_cid:/tmp/normalize_db.py"
compose exec -T app env DB_NAME="$DB_NAME" python /tmp/normalize_db.py
}
import_archive() {
local archive_file="$1"
local mongo_cid
mongo_cid="$(compose ps -q mongodb)"
if [ -z "$mongo_cid" ]; then
log "ERROR: could not resolve mongodb container id"
exit 1
fi
log "Importing archive backup: $archive_file"
$SUDO docker cp "$archive_file" "$mongo_cid:/tmp/restore.archive.gz"
local drop_flag=""
if [ "$DROP_DATABASE" = true ]; then
drop_flag="--drop"
fi
compose exec -T mongodb sh -lc "mongorestore --archive=/tmp/restore.archive.gz --gzip $drop_flag"
compose exec -T mongodb rm -f /tmp/restore.archive.gz >/dev/null 2>&1 || true
normalize_structure
}
import_csv() {
local csv_dir="$1"
local app_cid py
app_cid="$(compose ps -q app)"
if [ -z "$app_cid" ]; then
log "ERROR: could not resolve app container id"
exit 1
fi
log "Importing CSV backup directory: $csv_dir"
compose exec -T app sh -lc "rm -rf /tmp/restore_csv && mkdir -p /tmp/restore_csv"
$SUDO docker cp "$csv_dir/." "$app_cid:/tmp/restore_csv"
py="$WORK_DIR/import_csv.py"
cat > "$py" <<'PY'
import os
import csv
import ast
import re
from pymongo import MongoClient
from bson.objectid import ObjectId
DB_NAME = os.getenv("DB_NAME", "Inventarsystem")
CSV_DIR = os.getenv("CSV_DIR", "/tmp/restore_csv")
DROP_DATABASE = os.getenv("DROP_DATABASE", "false").lower() == "true"
HOST = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
PORT = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
COLLECTION_MAP = {
"users": "users",
"user": "users",
"items": "items",
"item": "items",
"ausleihungen": "ausleihungen",
"ausleihung": "ausleihungen",
"filter_presets": "filter_presets",
"filter_preset": "filter_presets",
"settings": "settings",
"setting": "settings",
}
BOOL_FIELDS = {"Admin", "Verfuegbar", "enabled"}
INT_FIELDS = {"Anschaffungskosten", "filter_num"}
OID_LIKE_FIELDS = {"_id", "Item"}
client = MongoClient(HOST, PORT)
db = client[DB_NAME]
if DROP_DATABASE:
client.drop_database(DB_NAME)
db = client[DB_NAME]
def parse_scalar(key, value):
if value is None:
return None
value = value.strip()
if value == "":
return None
if key in BOOL_FIELDS:
lowered = value.lower()
if lowered == "true":
return True
if lowered == "false":
return False
if key in INT_FIELDS:
try:
return int(value)
except Exception:
pass
if key in OID_LIKE_FIELDS and re.fullmatch(r"[0-9a-fA-F]{24}", value):
return ObjectId(value)
if value.startswith("[") and value.endswith("]"):
try:
return ast.literal_eval(value)
except Exception:
pass
return value
def load_csv_file(csv_path):
docs = []
with open(csv_path, "r", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
doc = {}
for key, value in row.items():
doc[key] = parse_scalar(key, value)
docs.append(doc)
return docs
csv_files = [
os.path.join(CSV_DIR, f)
for f in sorted(os.listdir(CSV_DIR))
if f.endswith(".csv")
]
if not csv_files:
raise SystemExit(f"No CSV files found in {CSV_DIR}")
for csv_file in csv_files:
base = os.path.splitext(os.path.basename(csv_file))[0]
coll = COLLECTION_MAP.get(base, base)
docs = load_csv_file(csv_file)
db[coll].drop()
if docs:
db[coll].insert_many(docs)
print(f"Imported {len(docs)} docs into {coll} from {os.path.basename(csv_file)}")
print("Collections after CSV import:", sorted(db.list_collection_names()))
PY
$SUDO docker cp "$py" "$app_cid:/tmp/import_csv.py"
compose exec -T app env DB_NAME="$DB_NAME" CSV_DIR="/tmp/restore_csv" DROP_DATABASE="$DROP_DATABASE" python /tmp/import_csv.py
normalize_structure
}
print_counts() {
local py="$WORK_DIR/print_counts.py"
cat > "$py" <<'PY'
from pymongo import MongoClient
import os
db_name = os.getenv("DB_NAME", "Inventarsystem")
host = os.getenv("INVENTAR_MONGODB_HOST", "mongodb")
port = int(os.getenv("INVENTAR_MONGODB_PORT", "27017"))
client = MongoClient(host, port)
db = client[db_name]
for name in ["users", "items", "ausleihungen", "filter_presets", "settings"]:
try:
count = db[name].count_documents({})
except Exception:
count = -1
print(f"{name}: {count}")
PY
local app_cid
app_cid="$(compose ps -q app)"
$SUDO docker cp "$py" "$app_cid:/tmp/print_counts.py"
log "Post-restore collection counts:"
compose exec -T app env DB_NAME="$DB_NAME" python /tmp/print_counts.py | tee -a "$LOG_FILE"
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--source)
SOURCE_PATH="${2:-}"
shift 2
;;
--date)
BACKUP_DATE="${2:-}"
shift 2
;;
--drop-database)
DROP_DATABASE=true
shift
;;
--restart-services)
RESTART_SERVICES=true
shift
;;
--list)
list_backups
exit 0
;;
--help)
show_help
exit 0
;;
*)
echo "Unknown argument: $1"
show_help
exit 1
;;
esac
done
}
main() {
parse_args "$@"
setup_compose
if [ -n "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then
log "ERROR: use either --source or --date, not both"
exit 1
fi
if [ -z "$SOURCE_PATH" ] && [ -n "$BACKUP_DATE" ]; then
resolve_date_source "$BACKUP_DATE"
fi
if [ -z "$SOURCE_PATH" ]; then
log "ERROR: provide --source <path> or --date <YYYY-MM-DD|latest>"
show_help
exit 1
fi
ensure_stack
local staged archive_file csv_dir
stage_source "$SOURCE_PATH"
staged="$STAGED_PATH"
archive_file="$(find_archive_file "$staged")"
if [ -n "$archive_file" ]; then
import_archive "$archive_file"
print_counts
else
csv_dir="$(find_best_csv_dir "$staged")"
if [ -z "$csv_dir" ]; then
log "ERROR: no supported backup data found under: $SOURCE_PATH"
log "Expected either *.archive.gz or CSV files"
exit 1
fi
import_csv "$csv_dir"
print_counts
fi
if [ "$RESTART_SERVICES" = true ]; then
log "Restarting services..."
"$SCRIPT_DIR/restart.sh"
fi
log "Restore completed successfully into DB: $DB_NAME"
}
main "$@"
Executable → Regular
+4 -47
View File
@@ -1,47 +1,4 @@
#!/bin/bash
# Helper script to run the backup with proper Python environment
# Get the script directory
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# Default values if no arguments provided
if [ $# -eq 0 ]; then
# Define default values
DB="Inventarsystem"
URI="mongodb://localhost:27017/"
OUT="$SCRIPT_DIR/backups/$(date +%Y-%m-%d)"
echo "No arguments provided. Using defaults:"
echo " --uri $URI"
echo " --db $DB"
echo " --out $OUT"
# Create output directory if it doesn't exist
mkdir -p "$OUT"
# Set the arguments
ARGS="--uri $URI --db $DB --out $OUT"
else
# Use provided arguments
ARGS="$@"
fi
# Check if we're in a virtual environment
if [ -f "$SCRIPT_DIR/.venv/bin/python" ]; then
# Check if we have execute permissions for the virtual environment Python
if [ -x "$SCRIPT_DIR/.venv/bin/python" ]; then
# Use the virtual environment's Python
"$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/Backup-DB.py" $ARGS || {
echo "Failed to execute with virtual environment Python. Trying system Python..."
python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS
}
else
echo "Virtual environment found but Python is not executable. Using system Python instead."
echo "To fix virtual environment permissions, run: sudo ./rebuild-venv.sh"
python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS
fi
else
# Use system Python
echo "No virtual environment found. Using system Python."
python3 "$SCRIPT_DIR/Backup-DB.py" $ARGS
fi
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/backup.sh" "$@"
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
# Wrapper to run tenant management fully containerized via docker-compose
docker compose -f docker-compose-multitenant.yml --profile tools run --rm tenant-manager "$@"
+245 -185
View File
@@ -7,18 +7,27 @@ cd "$SCRIPT_DIR"
ENV_FILE="$SCRIPT_DIR/.docker-build.env"
APP_IMAGE_REPO="ghcr.io/aiirondev/legendary-octo-garbanzo"
DIST_DIR="$SCRIPT_DIR/dist"
RUNTIME_COMPOSE_OVERRIDE_FILE="$SCRIPT_DIR/.docker-compose.runtime.override.yml"
SUDO=""
if [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
fi
IS_ROOT="false"
if [ "$(id -u)" -eq 0 ]; then
IS_ROOT="true"
fi
NUITKA_BUILD_VALUE="0"
HTTP_PORT_VALUE="8001"
HTTPS_PORT_VALUE="8443"
HTTP_PORT_VALUE="10000"
HTTP_PORTS_VALUE=""
DEFAULT_TENANT_PORT_START="${INVENTAR_TENANT_PORT_START:-10000}"
CRON_SETUP_VALUE="${INVENTAR_SETUP_CRON:-1}"
APP_IMAGE_VALUE="${INVENTAR_APP_IMAGE:-$APP_IMAGE_REPO:latest}"
COMPOSE_FILE="docker-compose.yml"
COMPOSE_FILE="docker-compose-multitenant.yml"
COMPOSE_PROFILES_VALUE=""
MIN_DOCKER_FREE_MB="${INVENTAR_MIN_DOCKER_FREE_MB:-1024}"
usage() {
cat <<EOF
@@ -106,7 +115,11 @@ ensure_runtime_dependencies() {
local missing=()
if ! command -v docker >/dev/null 2>&1; then
install_docker_engine
if [ "$IS_ROOT" = "true" ]; then
install_docker_engine
else
missing+=(docker)
fi
fi
if ! docker compose version >/dev/null 2>&1; then
@@ -130,14 +143,20 @@ ensure_runtime_dependencies() {
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
if [ "$IS_ROOT" = "true" ]; then
echo "Installing missing dependencies: ${missing[*]}"
apt_install "${missing[@]}"
else
echo "ERROR: Missing dependencies: ${missing[*]}"
echo "Please install the missing tools or run this script as root."
exit 1
fi
fi
if command -v systemctl >/dev/null 2>&1; then
$SUDO systemctl enable --now docker >/dev/null 2>&1 || true
if [ "$IS_ROOT" = "true" ] && command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || true
if cron_setup_enabled; then
$SUDO systemctl enable --now cron >/dev/null 2>&1 || true
systemctl enable --now cron >/dev/null 2>&1 || true
fi
fi
}
@@ -150,6 +169,11 @@ setup_boot_autostart_service() {
return 0
fi
if [ "$IS_ROOT" != "true" ]; then
echo "Skipping systemd autostart setup when not running as root."
return 0
fi
if ! command -v systemctl >/dev/null 2>&1; then
return 0
fi
@@ -191,6 +215,11 @@ setup_scheduled_jobs() {
return 0
fi
if [ "$IS_ROOT" != "true" ]; then
echo "Skipping cron job setup when not running as root."
return 0
fi
if ! command -v crontab >/dev/null 2>&1; then
echo "Warning: crontab not available, skipping nightly update setup"
return 0
@@ -201,101 +230,17 @@ setup_scheduled_jobs() {
backup_line="30 2 * * * cd $SCRIPT_DIR && ./backup.sh --mode auto >> $SCRIPT_DIR/logs/backup.log 2>&1"
local existing_cron
if [ "$(id -u)" -eq 0 ]; then
existing_cron="$(crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | crontab -
else
existing_cron="$($SUDO crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | $SUDO crontab -
fi
existing_cron="$(crontab -l 2>/dev/null || true)"
{
printf '%s\n' "$existing_cron" | grep -vF "$SCRIPT_DIR/update.sh" | grep -vF "$SCRIPT_DIR/backup-docker.sh" | grep -vF "$SCRIPT_DIR/backup.sh" || true
echo "$backup_line"
echo "$update_line"
} | crontab -
echo "Nightly backup scheduled at 02:30"
echo "Nightly auto-update scheduled at 03:00"
}
ensure_tls_certificates() {
local cert_dir cert_path key_path cn
cert_dir="$SCRIPT_DIR/certs"
cert_path="$cert_dir/inventarsystem.crt"
key_path="$cert_dir/inventarsystem.key"
mkdir -p "$cert_dir"
if [ -f "$cert_path" ] && [ -f "$key_path" ]; then
return 0
fi
cn="${TLS_CN:-localhost}"
echo "No TLS certificates found. Generating self-signed certificate for CN=$cn"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$key_path" \
-out "$cert_path" \
-subj "/C=DE/ST=NA/L=NA/O=Inventarsystem/OU=IT/CN=$cn" >/dev/null 2>&1
chmod 600 "$key_path"
chmod 644 "$cert_path"
}
ensure_nginx_config_mount_source() {
local nginx_dir config_path backup_path
nginx_dir="$SCRIPT_DIR/docker/nginx"
config_path="$nginx_dir/default.conf"
mkdir -p "$nginx_dir"
if [ -d "$config_path" ]; then
backup_path="${config_path}.dir.$(date +%Y%m%d-%H%M%S).bak"
mv "$config_path" "$backup_path"
echo "Warning: moved unexpected directory $config_path to $backup_path"
fi
if [ ! -f "$config_path" ]; then
cat > "$config_path" <<'EOF'
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /etc/nginx/certs/inventarsystem.crt;
ssl_certificate_key /etc/nginx/certs/inventarsystem.key;
client_max_body_size 50M;
location / {
proxy_pass http://app:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
default_type text/html;
return 200 '<!doctype html><html><head><meta charset="utf-8"><title>Server Error</title></head><body><h1>Server Error</h1><p>The service is temporarily unavailable.</p></body></html>';
}
}
EOF
echo "Recreated missing nginx config at $config_path"
fi
}
ensure_runtime_config_json() {
local config_path backup_path
config_path="$SCRIPT_DIR/config.json"
@@ -312,7 +257,7 @@ ensure_runtime_config_json() {
"ver": "2.6.5",
"dbg": false,
"host": "0.0.0.0",
"port": 443,
"port": 8000,
"mongodb": {
"host": "mongodb",
"port": 27017,
@@ -451,112 +396,214 @@ find_free_port() {
echo "$port"
}
stack_owns_host_port() {
local requested_port="$1"
local container_port="$2"
local mapped_port
mapped_port="$(docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" port nginx "$container_port" 2>/dev/null | tail -n1 || true)"
if [ -z "$mapped_port" ]; then
return 1
fi
mapped_port="${mapped_port##*:}"
[ "$mapped_port" = "$requested_port" ]
}
stop_host_nginx_services() {
local stopped_any=false
local service_name
if ! command -v systemctl >/dev/null 2>&1; then
return 1
fi
while IFS= read -r service_name; do
[ -z "$service_name" ] && continue
echo "Stopping host service $service_name to free web ports..."
$SUDO systemctl stop "$service_name" >/dev/null 2>&1 || true
stopped_any=true
done < <(systemctl list-units --type=service --state=active --no-pager 2>/dev/null | awk '{print $1}' | grep -E '(^nginx\.service$|nginx)' || true)
if [ "$stopped_any" = true ]; then
sleep 2
fi
if [ "$stopped_any" = true ]; then
return 0
fi
return 1
parse_port_list() {
local raw="$1"
local port
local ports=()
raw="${raw//,/ }"
for port in $raw; do
port="${port//[[:space:]]/}"
if [ -n "$port" ] && printf '%s\n' "$port" | grep -qE '^[0-9]+$'; then
ports+=("$port")
fi
done
printf '%s\n' "${ports[@]}"
}
configure_host_ports() {
local requested_http requested_https
local requested_http
local requested_ports
local ports=()
local occupied_ports=()
requested_http=""
requested_ports=""
if [ -f "$ENV_FILE" ]; then
requested_http="$(awk -F= '/^INVENTAR_HTTP_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
if [ -z "$requested_http" ]; then
requested_http="8001"
requested_ports="$(awk -F= '/^INVENTAR_HTTP_PORTS=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
fi
if stack_owns_host_port "$requested_http" "80"; then
HTTP_PORT_VALUE="$requested_http"
elif port_in_use "$requested_http"; then
if [ -n "${INVENTAR_HTTP_PORTS:-}" ]; then
requested_ports="$INVENTAR_HTTP_PORTS"
fi
if ! port_in_use "$requested_http"; then
HTTP_PORT_VALUE="$requested_http"
echo "Freed HTTP port $requested_http by stopping host nginx service"
else
HTTP_PORT_VALUE="$(find_free_port 8080)"
echo "HTTP port is in use. Using fallback HTTP port: $HTTP_PORT_VALUE"
if [ -n "${INVENTAR_HTTP_PORT:-}" ] && [ -z "$requested_ports" ]; then
requested_ports="$INVENTAR_HTTP_PORT"
fi
if [ -n "$requested_ports" ]; then
mapfile -t ports < <(parse_port_list "$requested_ports")
fi
if [ ${#ports[@]} -gt 0 ]; then
HTTP_PORTS_VALUE="${ports[*]}"
HTTP_PORT_VALUE="${ports[0]}"
for port in "${ports[@]}"; do
if port_in_use "$port"; then
occupied_ports+=("$port")
fi
done
if [ ${#occupied_ports[@]} -gt 0 ]; then
if [ ${#ports[@]} -eq 1 ]; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port ${ports[0]} is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
else
echo "Error: requested host port(s) already in use: ${occupied_ports[*]}"
echo "Remove the occupied port(s) from INVENTAR_HTTP_PORTS or stop the conflicting service."
exit 1
fi
fi
else
HTTP_PORT_VALUE="$requested_http"
HTTP_PORT_VALUE="$DEFAULT_TENANT_PORT_START"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
if port_in_use "$HTTP_PORT_VALUE"; then
HTTP_PORT_VALUE="$(find_free_port "$DEFAULT_TENANT_PORT_START")"
echo "Host port $DEFAULT_TENANT_PORT_START is already occupied. Assigned new tenant port: $HTTP_PORT_VALUE"
HTTP_PORTS_VALUE="$HTTP_PORT_VALUE"
fi
fi
}
ensure_min_docker_disk_space() {
local docker_root available_kb available_mb
if ! command -v df >/dev/null 2>&1; then
return 0
fi
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
if [ -z "$docker_root" ]; then
docker_root="/var/lib/docker"
fi
if [ ! -d "$docker_root" ]; then
return 0
fi
available_kb="$(df -Pk "$docker_root" 2>/dev/null | awk 'NR==2 {print $4}' || true)"
if [ -z "$available_kb" ]; then
return 0
fi
available_mb=$((available_kb / 1024))
if [ "$available_mb" -lt "$MIN_DOCKER_FREE_MB" ]; then
echo "Error: low disk space in Docker data root ($docker_root)."
echo "Available: ${available_mb} MB; required minimum: ${MIN_DOCKER_FREE_MB} MB"
echo "MongoDB may fail with 'No space left on device'. Free space and retry."
exit 1
fi
}
detect_server_capacity() {
local cpus mem_kb mem_gb app_workers app_cpus app_mem app_mem_swap
cpus="$(nproc 2>/dev/null || echo 1)"
mem_kb="$(awk '/MemAvailable:/ {print $2; exit}' /proc/meminfo 2>/dev/null || awk '/MemTotal:/ {print $2; exit}' /proc/meminfo || echo 0)"
mem_gb=$(( (mem_kb + 1024*1024 - 1) / (1024*1024) ))
if [ "$cpus" -ge 8 ] && [ "$mem_gb" -ge 16 ]; then
app_workers=8
app_cpus="2.0"
app_mem="512m"
app_mem_swap="1024m"
elif [ "$cpus" -ge 4 ] && [ "$mem_gb" -ge 8 ]; then
app_workers=4
app_cpus="1.0"
app_mem="384m"
app_mem_swap="768m"
elif [ "$cpus" -ge 2 ] && [ "$mem_gb" -ge 4 ]; then
app_workers=2
app_cpus="0.75"
app_mem="320m"
app_mem_swap="640m"
else
app_workers=1
app_cpus="0.5"
app_mem="256m"
app_mem_swap="512m"
fi
requested_https=""
if [ -f "$ENV_FILE" ]; then
requested_https="$(awk -F= '/^INVENTAR_HTTPS_PORT=/{print $2}' "$ENV_FILE" | tr -d ' ' || true)"
local env_app_cpus env_app_mem env_app_mem_swap env_workers
env_app_cpus="$(awk -F= '/^INVENTAR_APP_CPUS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_app_mem="$(awk -F= '/^INVENTAR_APP_MEM_LIMIT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_app_mem_swap="$(awk -F= '/^INVENTAR_APP_MEM_SWAP_LIMIT=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
env_workers="$(awk -F= '/^INVENTAR_WORKERS=/{print $2; exit}' "$ENV_FILE" | tr -d ' ' || true)"
[ -n "$env_app_cpus" ] && app_cpus="$env_app_cpus"
[ -n "$env_app_mem" ] && app_mem="$env_app_mem"
[ -n "$env_app_mem_swap" ] && app_mem_swap="$env_app_mem_swap"
[ -n "$env_workers" ] && app_workers="$env_workers"
fi
if [ -z "$requested_https" ]; then
requested_https="8443"
fi
INVENTAR_APP_CPUS="${INVENTAR_APP_CPUS:-$app_cpus}"
INVENTAR_APP_MEM_LIMIT="${INVENTAR_APP_MEM_LIMIT:-$app_mem}"
INVENTAR_APP_MEM_SWAP_LIMIT="${INVENTAR_APP_MEM_SWAP_LIMIT:-$app_mem_swap}"
INVENTAR_WORKERS="${INVENTAR_WORKERS:-$app_workers}"
INVENTAR_THREADS="${INVENTAR_THREADS:-2}"
if stack_owns_host_port "$requested_https" "443"; then
HTTPS_PORT_VALUE="$requested_https"
elif port_in_use "$requested_https"; then
if ! port_in_use "$requested_https"; then
HTTPS_PORT_VALUE="$requested_https"
echo "Freed HTTPS port $requested_https by stopping host nginx service"
return
fi
HTTPS_PORT_VALUE="$(find_free_port 8443)"
echo "HTTPS port is in use. Using fallback HTTPS port: $HTTPS_PORT_VALUE"
else
HTTPS_PORT_VALUE="$requested_https"
fi
echo "Detected host capacity: CPUs=$cpus, RAM=${mem_gb}GB"
echo "Configured app runtime: INVENTAR_WORKERS=$INVENTAR_WORKERS, INVENTAR_APP_CPUS=$INVENTAR_APP_CPUS, INVENTAR_APP_MEM_LIMIT=$INVENTAR_APP_MEM_LIMIT"
}
write_env_file() {
cat > "$ENV_FILE" <<EOF
NUITKA_BUILD=$NUITKA_BUILD_VALUE
INVENTAR_HTTP_PORT=$HTTP_PORT_VALUE
INVENTAR_HTTPS_PORT=$HTTPS_PORT_VALUE
INVENTAR_HTTP_PORTS=${HTTP_PORTS_VALUE// /,}
INVENTAR_APP_IMAGE=$APP_IMAGE_VALUE
INVENTAR_APP_CPUS=$INVENTAR_APP_CPUS
INVENTAR_APP_MEM_LIMIT=$INVENTAR_APP_MEM_LIMIT
INVENTAR_APP_MEM_SWAP_LIMIT=$INVENTAR_APP_MEM_SWAP_LIMIT
INVENTAR_WORKERS=$INVENTAR_WORKERS
INVENTAR_THREADS=$INVENTAR_THREADS
INVENTAR_WORKER_TIMEOUT=${INVENTAR_WORKER_TIMEOUT:-30}
INVENTAR_WORKER_CONNECTIONS=${INVENTAR_WORKER_CONNECTIONS:-100}
EOF
}
write_runtime_compose_override() {
cat > "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
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", "-"]
image: ${APP_IMAGE_VALUE}
build: null
EOF
if [ -n "$HTTP_PORTS_VALUE" ]; then
local ports_array
local ports_list
ports_list="${HTTP_PORTS_VALUE//,/ }"
read -r -a ports_array <<<"$ports_list"
if [ "${#ports_array[@]}" -gt 0 ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
ports:
EOF
for port in "${ports_array[@]}"; do
if [ -n "$port" ]; then
cat >> "$RUNTIME_COMPOSE_OVERRIDE_FILE" <<EOF
- "$port:8000"
EOF
fi
done
fi
fi
}
verify_stack_health() {
local compose_args running_services retry_count=0
compose_args=(-f "$COMPOSE_FILE" --env-file "$ENV_FILE")
compose_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
fi
compose_args+=(--env-file "$ENV_FILE")
# Try health check with optional restart on first failure
while [[ $retry_count -lt 2 ]]; do
@@ -564,10 +611,10 @@ verify_stack_health() {
for _ in $(seq 1 60); do
running_services="$(docker compose "${compose_args[@]}" ps --status running --services 2>/dev/null || true)"
if printf '%s\n' "$running_services" | grep -Fxq app && \
printf '%s\n' "$running_services" | grep -Fxq nginx && \
printf '%s\n' "$running_services" | grep -Fxq redis && \
printf '%s\n' "$running_services" | grep -Fxq mongodb; then
if docker compose "${compose_args[@]}" exec -T app python3 -c "import flask, pymongo" >/dev/null 2>&1; then
if curl -kfsS "https://127.0.0.1:$HTTPS_PORT_VALUE" >/dev/null 2>&1; then
if curl -fsS "http://127.0.0.1:$HTTP_PORT_VALUE/health" >/dev/null 2>&1; then
echo "Health check passed."
return 0
fi
@@ -580,8 +627,8 @@ verify_stack_health() {
if [[ $retry_count -eq 0 ]]; then
echo "Health check failed. Attempting to restart containers..."
docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true
docker compose "${compose_args[@]}" restart app nginx mongodb
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
docker compose "${compose_args[@]}" restart app redis mongodb
sleep 3
((retry_count++))
else
@@ -592,7 +639,7 @@ verify_stack_health() {
# Final failure
echo "Error: stack health check failed after restart attempt."
docker compose "${compose_args[@]}" ps || true
docker compose "${compose_args[@]}" logs --tail=120 app nginx mongodb || true
docker compose "${compose_args[@]}" logs --tail=120 app redis mongodb || true
return 1
}
@@ -600,20 +647,33 @@ parse_args "$@"
ensure_runtime_dependencies
setup_boot_autostart_service
ensure_tls_certificates
ensure_nginx_config_mount_source
ensure_runtime_config_json
setup_scheduled_jobs
configure_nuitka_mode
resolve_app_image
configure_host_ports
ensure_min_docker_disk_space
detect_server_capacity
ensure_app_image_loaded
write_env_file
write_runtime_compose_override
echo "Starting Inventarsystem Docker stack (app + mongodb)..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --remove-orphans
compose_up_args=(-f "$COMPOSE_FILE")
if [ -f "$RUNTIME_COMPOSE_OVERRIDE_FILE" ]; then
compose_up_args+=(-f "$RUNTIME_COMPOSE_OVERRIDE_FILE")
fi
compose_up_args+=(--env-file "$ENV_FILE")
if [ -n "$COMPOSE_PROFILES_VALUE" ]; then
export COMPOSE_PROFILES="$COMPOSE_PROFILES_VALUE"
fi
if ! docker compose "${compose_up_args[@]}" up -d --remove-orphans; then
echo "Docker Compose startup failed once. Waiting briefly and retrying..."
sleep 5
docker compose "${compose_up_args[@]}" up -d --remove-orphans
fi
verify_stack_health
echo "Stack started."
echo "Open: https://<server-ip>:$HTTPS_PORT_VALUE"
echo "Open: http://<server-ip>:$HTTP_PORT_VALUE"
+1 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
COMPOSE_FILE="docker-compose.yml"
COMPOSE_FILE="docker-compose-multitenant.yml"
while [[ $# -gt 0 ]]; do
case "$1" in
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More