Author SHA1 Message Date
admin 3b54f5ecc0 feat: move user management to protected config file 2026-07-28 13:46:05 +02:00
admin 2276a124b8 chore: update beta build information 2026-07-28 13:05:15 +02:00
admin 216f53c641 feat: add in-app release notes and what's new dialog 2026-07-28 12:59:12 +02:00
admin 8903452e96 feat: integrate user manual into help section 2026-07-28 12:15:38 +02:00
admin 41dbb6297c docs: complete user manual v1.0 (without screenshots) 2026-07-28 08:40:06 +02:00
admin 1ac9b08233 Refine capacity summary and analysis presentation 2026-07-27 15:21:02 +02:00
admin 1827c6de6c Improve capacity analysis presentation 2026-07-27 14:50:38 +02:00
admin 4f4bdf8d72 Merge main into transport capacity optimizer 2026-07-27 11:12:31 +02:00
admin 3b44048250 Fix build info generation 2026-07-27 11:11:32 +02:00
admin fe267b8766 Merge version 0.4 2026-07-27 11:05:48 +02:00
admin ea23f3f35d Ignore runtime-generated files 2026-07-27 10:51:52 +02:00
admin 30ed0bb79b Ignore runtime-generated files 2026-07-27 10:50:44 +02:00
admin c2e4f64d82 Bug fix 2026-07-07 16:50:10 +02:00
admin 7a27fb85c4 Extend Transport Optimizer 2026-07-07 16:40:30 +02:00
admin 4670666421 Restore diameter result and uncertainty display 2026-07-07 16:20:27 +02:00
admin dcca3fa042 Use password hashes for authentication 2026-07-07 16:16:50 +02:00
admin 8f64760add show build on website footer 2026-07-07 16:12:12 +02:00
admin ffa34815cd Remove legacy admin UI from RollCalc 2026-07-07 12:20:09 +02:00
martin ac6840c759 Merge pull request 'Chore/systemd service prep' (#1) from chore/systemd-service-prep into main
Reviewed-on: #1
2026-07-07 09:46:36 +02:00
19 changed files with 3217 additions and 1293 deletions
+6
View File
@@ -32,3 +32,9 @@ node_modules/
# Backup files
*.bak
*.backup
# runtime-generated files
access_log.json
# User and passwords
config/users.json
+4 -4
View File
@@ -31,11 +31,11 @@ Primary files:
## Planned Direction
- The existing admin UI/API in RollCalc is planned for removal.
- Do not expand or repair the admin area unless explicitly requested as a temporary measure.
- The previous admin UI/API has been removed from RollCalc.
- Do not reintroduce admin screens or admin APIs unless explicitly requested as a temporary measure.
- Keep login/access logging in RollCalc.
- Administration and maintenance of `article-data.json` should move to a separate application.
- Treat `templates/admin.html` and `/api/admin/*` expectations as legacy/transitional, not as target architecture.
- Treat article-data administration as out of scope for RollCalc.
## Code Style
@@ -73,7 +73,7 @@ These credentials are hardcoded and are a known risk; do not introduce more secr
- Access logging writes to `access_log.json` by reading and rewriting the whole file.
- The active UI logic is largely inline in `templates/roll_calculator.html`.
- Several `static/*.js` files appear to be older, alternative, or integration modules. Confirm script inclusion before modifying them.
- The admin template and backend admin routes are inconsistent and scheduled for removal rather than expansion.
- The previous admin template and backend admin route have been removed; do not rebuild them by default.
- The disclaimer confirmation is client-side only.
## Safety Notes
+116 -18
View File
@@ -14,13 +14,17 @@ Backend:
- `app.py` creates the Flask app.
- HTTP Basic Auth is implemented with `Flask-HTTPAuth`.
- Users are currently hardcoded in `BETA_USERS` and `ADMIN_USERS`.
- Users are configured in local deployment file `config/users.json` with Werkzeug password hashes. This file is ignored by Git.
- `/` renders the active calculator template.
- `/help` renders the authenticated Help landing page.
- `/help/user-manual` renders `docs/de/user_manual.md` server-side inside the RollCalc Help layout.
- `/help/recent-changes` renders `docs/de/recent_changes.md` server-side inside the RollCalc Help layout.
- `/help/user-manual/screenshots/<path:filename>` serves German manual screenshots from `docs/de/screenshots/`.
- `/static/<path:filename>` is intended to serve static files behind Basic Auth.
- `/api/health` returns health/version information.
- `/api/user` returns the authenticated user and admin flag.
- `/admin/logs` returns access logs for admin users.
- `/api/user` returns the authenticated user.
- Access events are appended to `access_log.json`.
- `build_info.json` is loaded at startup and exposed to all templates as `build_info`.
Frontend:
@@ -29,6 +33,111 @@ Frontend:
- `window.ARTICLE_DATA` is loaded from `/static/article-data.json`.
- `window.APP_CONFIG` is currently defined inline for forklift rules.
- A disclaimer modal blocks use until the user checks the confirmation checkbox.
- The footer displays build metadata: version, branch, commit, and timestamp.
## Build Info
Build/deployment metadata is read from:
```text
build_info.json
```
Expected fields:
- `version`
- `branch`
- `commit`
- `timestamp`
If `build_info.json` is missing, malformed, or does not contain a usable value, RollCalc falls back to `unknown` for that value and continues serving the site.
The data is loaded centrally in `app.py` and made available to every template as `build_info`.
## Help and User Manual
The in-app Help area keeps `docs/de/user_manual.md` as the leading German documentation source. RollCalc reads the Markdown file on each `/help/user-manual` request and renders it server-side with `markdown-it-py`.
The documentation structure is prepared for language-specific manuals:
```text
docs/
de/
user_manual.md
screenshots/
```
Additional languages can follow the same structure later. The current public route still opens the German manual and no language switcher is implemented.
Rendering rules:
- Raw HTML from the Markdown source is disabled.
- Heading ids and the table of contents are generated from the Markdown content.
- Relative image references such as `screenshots/disclaimer.png` are rewritten to the authenticated manual screenshot route.
- Screenshots belong in the active language's `screenshots/` directory, currently `docs/de/screenshots/`; the screenshot route must remain limited to that directory.
The Help pages use the same HTTP Basic Auth protection as the calculator.
### Recent Changes and What's New
Full user-facing release notes are maintained in:
```text
docs/de/recent_changes.md
```
The calculator header links directly to `/help/user-manual`. The Help landing page lists only existing documents, currently User Manual and Recent Changes.
The startup What's New dialog uses `build_info.version` and is suppressed when the version is empty or `unknown`. The last acknowledged version is stored only in browser `localStorage` under:
```text
rollcalc_last_seen_version
```
The short dialog item list is maintained separately in:
```text
static/recent_changes.json
```
This JSON is a compact summary for the dialog, not the complete release history. Keep it aligned with the most relevant current entries from `docs/de/recent_changes.md`.
## Authentication
RollCalc uses HTTP Basic Auth. User entries are stored in the local deployment file:
```text
config/users.json
```
This file is intentionally ignored by Git. The versioned file `config/users.example.json` documents the JSON format only and must not contain real usernames, hashes, or passwords.
The file stores username-to-password-hash mappings:
```json
{
"username": "<hash>"
}
```
Password verification uses `werkzeug.security.check_password_hash`. Users are managed with:
```bash
python3 scripts/manage_users.py
```
The management tool provides a text menu for listing users, creating users, changing passwords, and deleting users. New hashes use the existing RollCalc method `pbkdf2:sha256:600000`.
The Flask app requires a readable, valid, non-empty `config/users.json` at startup and does not silently create an empty user file. If the file is missing or invalid, startup should fail with a message pointing to `python3 scripts/manage_users.py`.
Passwords must not be logged. Password hashes should only be stored in `config/users.json` or a future protected deployment-specific secret source.
Deployment notes:
- The systemd/service user must be able to read `config/users.json`.
- Suitable server permissions can be owner `martin`, group `www-data`, mode `640`, adjusted to the actual deployment users.
- Do not hard-code deployment owners or groups in Python.
- Back up the productive `config/users.json` before deploying or replacing a server instance.
## Roll Geometry
@@ -184,25 +293,14 @@ This is client-side only. It is not persisted, logged, or enforced server-side.
## Admin Area State
Current backend route:
RollCalc no longer contains an admin UI or admin API. The previous `/admin/logs` route and `templates/admin.html` have been removed.
```text
/admin/logs
```
Current `templates/admin.html` expects:
```text
/api/admin/stats
/api/admin/logs
```
Those `/api/admin/*` endpoints are not currently implemented in `app.py`, and there is no route rendering `templates/admin.html`.
Login/access logging remains part of RollCalc, but log viewing and article-data administration should not be implemented inside this app. Maintenance of `article-data.json` is planned for a separate application.
## Known Technical Risks
- Hardcoded plaintext credentials in `app.py`.
- Basic Auth only; no sessions or role framework beyond user dictionaries.
- Password hashes are currently configured in `app.py`; this should eventually move to a protected external configuration or secrets mechanism.
- Basic Auth only; no sessions or role framework beyond the user dictionary.
- `access_log.json` is rewritten on every logged request and is not concurrency-safe.
- No log rotation or retention policy is implemented.
- Flask's default static route may conflict with the intended authenticated `/static/<path:filename>` behavior; verify effective routing before relying on protected static files.
+153 -21
View File
@@ -9,6 +9,7 @@ This README is intended for developers maintaining the project, not for end user
- Python 3
- Flask 2.3.3
- Flask-HTTPAuth 4.8.0
- markdown-it-py 3.0.0 for server-side Markdown rendering
- Vanilla HTML, CSS, and JavaScript
- JSON files for product data and forklift/load rules
@@ -42,7 +43,7 @@ If port `5000` is already occupied, start through Flask's CLI without changing f
flask --app app run --host 127.0.0.1 --port 5001
```
The app uses HTTP Basic Auth. Current credentials are defined in `BETA_USERS` and `ADMIN_USERS` in `app.py`.
The app uses HTTP Basic Auth. Current users are defined in a local deployment file, `config/users.json`, with Werkzeug password hashes. This file is intentionally not versioned.
## Project Layout
@@ -51,14 +52,16 @@ The app uses HTTP Basic Auth. Current credentials are defined in `BETA_USERS` an
├── app.py
├── requirements.txt
├── README.md
├── build_info.json
├── access_log.json
├── config.json
├── article-data_.json
├── fix_article_data.py
├── service-worker.js
├── templates/
│ ├── help_index.html
│ ├── roll_calculator.html
│ └── admin.html
│ └── user_manual.html
├── static/
│ ├── article-data.json
│ ├── config.json
@@ -71,6 +74,9 @@ The app uses HTTP Basic Auth. Current credentials are defined in `BETA_USERS` an
│ ├── rollcalc_stddev_ranges.js
│ └── rollcalc_stddev_integration.js
└── docs/
├── de/
│ ├── screenshots/
│ └── user_manual.md
├── DEPLOYMENT_GUIDE.md
├── QOL_UPDATE_SUMMARY.md
├── STDDEV_IMPLEMENTATION_CHECKLIST.md
@@ -86,15 +92,138 @@ Implemented routes:
| Route | Methods | Auth | Purpose |
| --- | --- | --- | --- |
| `/` | `GET` | Basic Auth | Renders `templates/roll_calculator.html`. |
| `/help` | `GET` | Basic Auth | Renders the Help landing page. |
| `/help/user-manual` | `GET` | Basic Auth | Reads `docs/de/user_manual.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
| `/help/recent-changes` | `GET` | Basic Auth | Reads `docs/de/recent_changes.md`, renders it as HTML, and displays it in the RollCalc Help layout. |
| `/help/user-manual/screenshots/<path:filename>` | `GET` | Basic Auth | Serves German user-manual screenshots from `docs/de/screenshots/` only. |
| `/static/<path:filename>` | `GET` | Basic Auth | Intended protected static-file serving from `static/`. |
| `/api/health` | `GET` | Basic Auth | Returns app health and version. |
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user and admin flag. |
| `/admin/logs` | `GET` | Basic Auth plus admin check | Returns `access_log.json` contents. |
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. |
Authentication is implemented with `Flask-HTTPAuth`. The current code merges `BETA_USERS` and `ADMIN_USERS` and performs direct plaintext string comparison.
Authentication is implemented with `Flask-HTTPAuth`. The current code loads the local `config/users.json` deployment file and checks password hashes with `werkzeug.security.check_password_hash`; plaintext passwords are not stored in the application.
## User Management
Manage users with the interactive CLI:
```bash
python3 scripts/manage_users.py
```
The menu supports:
- List users
- Create user
- Change password
- Delete user
User data is stored in:
```text
config/users.json
```
`config/users.json` is local deployment data and is ignored by Git. The versioned file `config/users.example.json` documents the format only and must not contain real usernames, hashes, or passwords.
The file contains only username-to-password-hash mappings:
```json
{
"username": "<hash>"
}
```
Passwords are entered with `getpass`, are never echoed, and are never stored in plaintext. New and changed passwords use the existing RollCalc hash method:
```text
pbkdf2:sha256:600000
```
Do not commit real passwords or print them in logs.
On a server, ensure that the process running RollCalc can read `config/users.json`. A suitable deployment setup can be:
```text
owner: martin
group: www-data
mode: 640
```
The exact owner and group depend on the deployment. Do not hard-code them in the application. Before deploying or replacing a server instance, back up the productive `config/users.json` file.
Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back.
## Help and User Manual
RollCalc exposes an authenticated Help area at:
```text
/help
```
The user manual is available at:
```text
/help/user-manual
```
Recent changes are available at:
```text
/help/recent-changes
```
`docs/de/user_manual.md` is the leading source for the German user manual. The app reads this Markdown file on request and renders it server-side with `markdown-it-py`; no generated static HTML copy is maintained. Raw HTML in the Markdown source is disabled during rendering.
`docs/de/recent_changes.md` is the leading source for full German release notes. It is rendered through the same authenticated Markdown infrastructure.
The documentation directory is prepared for additional languages:
```text
docs/
de/
user_manual.md
screenshots/
```
The public route `/help/user-manual` currently opens the German manual. No language switcher is implemented yet.
Screenshots referenced from the manual as `screenshots/filename.png` should be placed in:
```text
docs/de/screenshots/
```
The application rewrites those relative Markdown image paths to the authenticated manual screenshot route, which is limited to the active language's screenshot directory.
### What's New Dialog
The calculator page shows a compact What's New dialog once per build version. The current version comes from `build_info.version`; if that value is empty or `unknown`, the dialog is not shown.
The last acknowledged version is stored only in the browser:
```text
rollcalc_last_seen_version
```
The short dialog summary is maintained in:
```text
static/recent_changes.json
```
Keep `docs/de/recent_changes.md` as the complete release-note source and `static/recent_changes.json` as the short 3-5 item summary for the startup dialog.
## User Manual PDF Export
Generate the current PDF version of the German user manual with:
```bash
./scripts/build_user_manual_pdf.sh
```
The script renders `docs/de/user_manual.md` to `docs/de/user_manual.pdf` with Pandoc. The Markdown file remains the leading source.
## Frontend Entry Point
The active UI is `templates/roll_calculator.html`.
@@ -103,6 +232,7 @@ The template contains:
- Page layout and all main CSS.
- A disclaimer modal shown after Basic Auth login and before calculator use.
- Footer build metadata from `build_info`.
- Global state:
- `window.ARTICLE_DATA`
- `window.APP_CONFIG`
@@ -243,20 +373,9 @@ These provide or describe range calculations for material thickness, area weight
### Admin UI
There are admin-oriented HTML files:
RollCalc no longer contains an admin UI or admin API. Login/access logging remains in the backend, but logs are not exposed through a RollCalc admin screen.
- `templates/admin.html`
- `admin.html`
- `admin_simple.html`
The backend currently exposes `/admin/logs`, but `templates/admin.html` expects:
```text
/api/admin/stats
/api/admin/logs
```
Those `/api/admin/*` routes are not implemented in `app.py` at the time this README was written. There is also no route currently rendering `templates/admin.html`.
Administration and maintenance of `article-data.json` is planned for a separate application. RollCalc should continue to consume `static/article-data.json` read-only.
## Static Assets and Data Files
@@ -274,6 +393,19 @@ JSON audit log written by `app.py`.
Important implementation detail: each logged access reads and rewrites the entire JSON file. This is simple but not concurrency-safe and can become inefficient as the file grows.
### `build_info.json`
Deployment/build metadata displayed in the site footer.
Expected fields:
- `version`
- `branch`
- `commit`
- `timestamp`
`app.py` loads this file centrally at startup and exposes it to all templates as `build_info`. If the file is missing, invalid, or a field is empty, the affected values fall back to `unknown` and the website continues to work.
### `fix_article_data.py`
Utility script that updates relative frontend fetch/register paths to Flask-style `/static/...` paths and checks that key static files exist.
@@ -285,15 +417,15 @@ Utility script that updates relative frontend fetch/register paths to Flask-styl
- There are duplicated or legacy-looking files with similar names. Before editing JavaScript under `static/`, confirm it is actually referenced by the active template.
- The documentation under `docs/` contains deployment and feature notes, but some filenames and route assumptions may not match the current app exactly.
- The Flask dev server is used for local development only. Production should use a WSGI server.
- Use `scripts/manage_users.py` to add users, rotate passwords, or delete users.
## Known Risks and Maintenance Items
- Credentials are hardcoded in `app.py` and should be moved to environment variables or a secrets manager.
- Passwords are stored in plaintext and compared directly.
- Password hashes are currently stored in `config/users.json`; a secrets manager or protected deployment-specific config would be more robust for production.
- `access_log.json` is not safe for concurrent writes.
- `access_log.json` grows without rotation or retention limits.
- The custom `/static/<path:filename>` route is intended to protect static files, but Flask also creates a default static route unless disabled. Verify effective route behavior before relying on static-file protection.
- Admin frontend and backend routes are currently inconsistent.
- RollCalc no longer includes an admin UI/API; article data administration belongs in a separate application.
- There is no visible automated test suite.
- The main template is large and mixes layout, styling, data loading, calculations, and UI behavior.
- The disclaimer confirmation is client-side only and is not persisted or audited server-side.
-604
View File
@@ -1,604 +0,0 @@
[
{
"timestamp": "2026-05-28T13:06:19.854940",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:06:58.223722",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:07:05.902636",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:07:51.345366",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:09:37.481260",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:14:02.867188",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:28:08.386403",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:29:06.541184",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:30:47.979071",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:46:05.447815",
"username": "beta",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T13:46:19.317688",
"username": "beta",
"endpoint": "/admin",
"method": "GET",
"status": 403
},
{
"timestamp": "2026-05-28T14:42:29.477028",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.374462",
"username": "admin",
"endpoint": "/admin",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.411860",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:43:31.562482",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:01.420524",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:01.438789",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.773466",
"username": "admin",
"endpoint": "/admin",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.817782",
"username": "admin",
"endpoint": "/api/admin/stats",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-05-28T14:44:13.894128",
"username": "admin",
"endpoint": "/api/admin/logs",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-06-03T05:20:48.808775",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:48.451519",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:48.676701",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:54.481445",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:31:58.011356",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:32:48.747837",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:33:07.753648",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:42:11.820817",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:43:37.598978",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T07:47:39.689201",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T08:25:47.585793",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:32:59.419063",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:35:05.236655",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:40:47.890997",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:41:02.461403",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:42:11.914989",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:42:57.125161",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T09:54:34.067541",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T10:02:39.927663",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:00:50.808697",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:28.615255",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:40.011779",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:09:53.681061",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:10:06.334747",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:17:18.862106",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:44:00.451943",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:44:20.277301",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:49:56.333142",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:50:11.692907",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:56:07.569410",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:59:03.417506",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T12:59:21.102453",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:04:52.131022",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:05:09.915771",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:09:55.899370",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:10:20.387853",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:10:20.736005",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:12:48.924277",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:15:13.236624",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:17:32.470202",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:23:23.613251",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:23:24.369105",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:26:48.783516",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:47:07.888072",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:52:38.653855",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:53:02.662397",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T13:57:19.316896",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:05:28.671663",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:06:26.413418",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:06:42.715163",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:12:15.337499",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:12:19.510992",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-01T14:13:17.018760",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:28:30.320494",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:28:46.119436",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:36:55.439798",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T11:41:31.868950",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T16:07:56.334163",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-02T16:08:38.097844",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:39:44.284544",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:41:57.673023",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-03T08:42:19.877155",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T10:34:02.710459",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:14:58.113794",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:15:20.071507",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
},
{
"timestamp": "2026-07-06T13:15:32.538576",
"username": "mtazl",
"endpoint": "/",
"method": "GET",
"status": 200
}
]
+343 -40
View File
@@ -3,12 +3,27 @@ Naue Roll Calculator - Beta
Flask app with HTTP Basic Authentication
"""
from flask import Flask, render_template, request, send_file, send_from_directory, jsonify
from flask import (
Flask,
abort,
jsonify,
render_template,
request,
send_file,
send_from_directory,
url_for,
)
from flask_httpauth import HTTPBasicAuth
from markdown_it import MarkdownIt
from markupsafe import Markup
from werkzeug.security import check_password_hash
from functools import wraps
import os
from datetime import datetime
import json
import re
import socket
import unicodedata
app = Flask(__name__)
auth = HTTPBasicAuth()
@@ -17,33 +32,143 @@ auth = HTTPBasicAuth()
# CONFIGURATION
# ============================================================================
# Benutzer für Beta-Phase (in Produktion aus env-Variablen laden!)
BETA_USERS = {
"beta": "rollcalc_beta_2026", # ⚠️ ÄNDERN!
"naue": "naue_access_2026", # ⚠️ ÄNDERN!
"cniehues": "beta_test_2026", # ⚠️ ÄNDERN!
"lvollmert": "Geheim!", # ⚠️ ÄNDERN!
"mtazl": "rollcalc", # ⚠️ ÄNDERN!
"controlling": "beta2026" # ⚠️ ÄNDERN!
}
# Optional: Admin-Features (z.B. Logs, Stats)
ADMIN_USERS = {
"admin": "admin_secure_pwd_2026" # ⚠️ ÄNDERN!
}
# Logging für Auditing
LOG_FILE = "access_log.json"
BUILD_INFO_FILE = "build_info.json"
USER_CONFIG_FILE = os.path.join(app.root_path, "config", "users.json")
DOCS_DIR = os.path.join(app.root_path, "docs")
DEFAULT_DOC_LANGUAGE = "de"
USER_MANUAL_FILENAME = "user_manual.md"
RECENT_CHANGES_FILENAME = "recent_changes.md"
WHATS_NEW_SUMMARY_FILE = os.path.join(app.root_path, "static", "recent_changes.json")
UNKNOWN_BUILD_INFO = {
"version": "unknown",
"branch": "unknown",
"commit": "unknown",
"timestamp": "unknown"
}
# ============================================================================
# BUILD INFO
# ============================================================================
def load_build_info():
"""Load deployment/build metadata for templates."""
try:
with open(BUILD_INFO_FILE, "r") as f:
data = json.load(f)
except Exception as e:
print(f"[Build Info Error] {e}")
return UNKNOWN_BUILD_INFO.copy()
if not isinstance(data, dict):
return UNKNOWN_BUILD_INFO.copy()
build_info = UNKNOWN_BUILD_INFO.copy()
for key in build_info:
value = data.get(key)
if isinstance(value, str) and value.strip():
build_info[key] = value.strip()
return build_info
BUILD_INFO = load_build_info()
def load_whats_new_summary():
"""Load compact release notes for the What's New dialog."""
try:
with open(WHATS_NEW_SUMMARY_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"[What's New Error] {e}")
return []
items = data.get("items") if isinstance(data, dict) else None
if not isinstance(items, list):
return []
return [
item.strip()
for item in items
if isinstance(item, str) and item.strip()
][:5]
WHATS_NEW_SUMMARY = load_whats_new_summary()
@app.context_processor
def inject_build_info():
"""Make build metadata and release-note summary available in templates."""
return {
"build_info": BUILD_INFO,
"whats_new_summary": WHATS_NEW_SUMMARY
}
# ============================================================================
# AUTHENTICATION
# ============================================================================
class UserConfigError(RuntimeError):
"""Raised when the user configuration is missing or invalid."""
def load_user_hashes(path=USER_CONFIG_FILE):
"""Load user password hashes from config/users.json."""
command_hint = "Run python3 scripts/manage_users.py to initialize or repair it."
if not os.path.exists(path):
raise UserConfigError(
f"User config file is missing: {path}. {command_hint}"
)
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
raise UserConfigError(
f"Invalid JSON in user config file {path}: {e}. {command_hint}"
) from e
except Exception as e:
raise UserConfigError(
f"Could not read user config file {path}: {e}. {command_hint}"
) from e
if not isinstance(data, dict):
raise UserConfigError(
f"Invalid user config file {path}: root element must be an object. "
f"{command_hint}"
)
if not data:
raise UserConfigError(
f"User config file {path} does not contain any users. {command_hint}"
)
users = {}
for username, value in data.items():
if not isinstance(username, str) or not username.strip():
raise UserConfigError(
f"Invalid user config file {path}: usernames must be strings. "
f"{command_hint}"
)
if not isinstance(value, str) or not value.strip():
raise UserConfigError(
f"Invalid user config file {path}: password hash for "
f"user {username!r} must be a string. {command_hint}"
)
users[username.strip()] = value.strip()
return users
USER_PASSWORD_HASHES = load_user_hashes()
@auth.verify_password
def verify_password(username, password):
"""Verify HTTP Basic Auth credentials"""
all_users = {**BETA_USERS, **ADMIN_USERS}
if username in all_users and all_users[username] == password:
password_hash = USER_PASSWORD_HASHES.get(username)
if not password_hash:
return None
if password_hash and check_password_hash(password_hash, password):
return username
return None
@@ -67,10 +192,119 @@ def log_access(username, endpoint, method, status=200):
except Exception as e:
print(f"[Logging Error] {e}")
def find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0"):
"""Find the first available TCP port at or above start_port."""
for port in range(start_port, start_port + max_attempts):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((host, port))
except OSError:
continue
return port
raise RuntimeError(
f"No free port found from {start_port} to "
f"{start_port + max_attempts - 1}"
)
# ============================================================================
# ROUTES
# ============================================================================
def slugify_heading(text):
"""Create stable, URL-friendly heading ids for rendered Markdown."""
normalized = unicodedata.normalize("NFKD", text)
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_text).strip("-").lower()
return slug or "section"
def build_manual_renderer():
"""Build a Markdown renderer with raw HTML disabled."""
renderer = MarkdownIt("commonmark", {"html": False})
renderer.enable("table")
return renderer
def get_document_path(filename, language=DEFAULT_DOC_LANGUAGE):
"""Return the Markdown path for a language-specific help document."""
return os.path.join(DOCS_DIR, language, filename)
def get_manual_path(language=DEFAULT_DOC_LANGUAGE):
"""Return the Markdown path for a language-specific manual."""
return get_document_path(USER_MANUAL_FILENAME, language)
def get_manual_screenshot_dir(language=DEFAULT_DOC_LANGUAGE):
"""Return the screenshot directory for a language-specific manual."""
return os.path.join(DOCS_DIR, language, "screenshots")
def rewrite_manual_asset_paths(markdown_text, language=DEFAULT_DOC_LANGUAGE):
"""Route relative manual screenshot links through the protected docs route."""
screenshot_url = url_for("manual_screenshot", filename="")
return markdown_text.replace("](screenshots/", f"]({screenshot_url}")
def add_heading_ids_and_toc(tokens):
"""Attach ids to headings and create a compact table of contents."""
toc = []
used_slugs = {}
for index, token in enumerate(tokens):
if token.type != "heading_open":
continue
level = int(token.tag[1])
inline_token = tokens[index + 1] if index + 1 < len(tokens) else None
title = (
inline_token.content
if inline_token and inline_token.type == "inline"
else ""
)
base_slug = slugify_heading(title)
count = used_slugs.get(base_slug, 0)
used_slugs[base_slug] = count + 1
slug = base_slug if count == 0 else f"{base_slug}-{count + 1}"
token.attrSet("id", slug)
if level <= 2:
toc.append({
"level": level,
"title": title,
"id": slug
})
return toc
def render_markdown_document(filename, language=DEFAULT_DOC_LANGUAGE):
"""Read and render a Markdown help document into HTML and a generated TOC."""
renderer = build_manual_renderer()
document_path = get_document_path(filename, language)
with open(document_path, "r", encoding="utf-8") as document_file:
markdown_text = document_file.read()
markdown_text = rewrite_manual_asset_paths(markdown_text, language)
tokens = renderer.parse(markdown_text)
toc = add_heading_ids_and_toc(tokens)
html = renderer.renderer.render(tokens, renderer.options, {})
return Markup(html), toc
def render_user_manual(language=DEFAULT_DOC_LANGUAGE):
"""Read and render the Markdown user manual."""
return render_markdown_document(USER_MANUAL_FILENAME, language)
def render_recent_changes(language=DEFAULT_DOC_LANGUAGE):
"""Read and render the Markdown recent changes document."""
return render_markdown_document(RECENT_CHANGES_FILENAME, language)
@app.route("/", methods=["GET"])
@auth.login_required
def index():
@@ -78,6 +312,92 @@ def index():
log_access(auth.current_user(), "/", "GET")
return render_template("roll_calculator.html")
@app.route("/help", methods=["GET"])
@auth.login_required
def help_index():
"""Help landing page - requires authentication."""
log_access(auth.current_user(), "/help", "GET")
return render_template("help_index.html")
@app.route("/help/user-manual", methods=["GET"])
@auth.login_required
def user_manual():
"""Render the Markdown user manual inside the RollCalc layout."""
log_access(auth.current_user(), "/help/user-manual", "GET")
try:
manual_html, toc = render_user_manual(DEFAULT_DOC_LANGUAGE)
return render_template(
"user_manual.html",
document_title="User Manual",
document_subtitle="RollCalc technical user documentation",
error_title="User Manual unavailable",
manual_html=manual_html,
toc=toc,
render_error=None
)
except Exception:
app.logger.exception("Could not render user manual")
return render_template(
"user_manual.html",
document_title="User Manual",
document_subtitle="RollCalc technical user documentation",
error_title="User Manual unavailable",
manual_html=None,
toc=[],
render_error=(
"The user manual could not be loaded. "
"Please contact the RollCalc maintainer if the problem persists."
)
), 500
@app.route("/help/recent-changes", methods=["GET"])
@auth.login_required
def recent_changes():
"""Render recent changes inside the RollCalc Help layout."""
log_access(auth.current_user(), "/help/recent-changes", "GET")
try:
changes_html, toc = render_recent_changes(DEFAULT_DOC_LANGUAGE)
return render_template(
"user_manual.html",
document_title="Recent Changes",
document_subtitle="RollCalc release notes",
error_title="Recent Changes unavailable",
manual_html=changes_html,
toc=toc,
render_error=None
)
except Exception:
app.logger.exception("Could not render recent changes")
return render_template(
"user_manual.html",
document_title="Recent Changes",
document_subtitle="RollCalc release notes",
error_title="Recent Changes unavailable",
manual_html=None,
toc=[],
render_error=(
"Recent changes could not be loaded. "
"Please contact the RollCalc maintainer if the problem persists."
)
), 500
@app.route("/help/user-manual/screenshots/<path:filename>", methods=["GET"])
@auth.login_required
def manual_screenshot(filename, language=DEFAULT_DOC_LANGUAGE):
"""Serve user-manual screenshots from the selected language directory."""
log_access(
auth.current_user(),
f"/help/user-manual/screenshots/{filename}",
"GET"
)
try:
return send_from_directory(get_manual_screenshot_dir(language), filename)
except Exception:
app.logger.warning("Missing or inaccessible manual screenshot: %s", filename)
abort(404)
@app.route("/static/<path:filename>", methods=["GET"])
@auth.login_required
def serve_static(filename):
@@ -96,30 +416,12 @@ def health_check():
def get_user():
"""Get current authenticated user info"""
user = auth.current_user()
is_admin = user in ADMIN_USERS
return jsonify({
"username": user,
"authenticated": True,
"is_admin": is_admin,
"timestamp": datetime.now().isoformat()
}), 200
@app.route("/admin/logs", methods=["GET"])
@auth.login_required
def get_logs():
"""View access logs - admin only"""
user = auth.current_user()
if user not in ADMIN_USERS:
log_access(user, "/admin/logs", "GET", 403)
return jsonify({"error": "Unauthorized"}), 403
log_access(user, "/admin/logs", "GET")
logs = []
if os.path.exists(LOG_FILE):
with open(LOG_FILE, "r") as f:
logs = json.load(f)
return jsonify({"logs": logs}), 200
# ============================================================================
# ERROR HANDLERS
# ============================================================================
@@ -146,21 +448,22 @@ def internal_error(e):
if __name__ == "__main__":
os.makedirs("templates", exist_ok=True)
os.makedirs("static", exist_ok=True)
port = find_free_port(start_port=5000, max_attempts=10, host="0.0.0.0")
print("""
print(f"""
╔═══════════════════════════════════════════════════════════╗
║ Naue Roll Calculator - V14 + QoL Update ║
║ HTTP Basic Auth Enabled ║
║ ║
║ ⚠️ BEFORE PRODUCTION: Update passwords in app.py ║
║ ║
║ Starten auf: http://localhost:5000 ║
║ Starten auf: http://localhost:{port:<5} ║
╚═══════════════════════════════════════════════════════════╝
""")
app.run(
host="0.0.0.0",
port=5000,
port=port,
debug=False,
use_reloader=False
)
+6
View File
@@ -0,0 +1,6 @@
{
"version": "v0.4-dev",
"branch": "feature/transport-capacity-optimizer",
"commit": "216f53c",
"timestamp": "2026-07-28T13:02:23+02:00"
}
+3
View File
@@ -0,0 +1,3 @@
{
"example-user": "pbkdf2:sha256:600000$example-salt$example-hash"
}
+39
View File
@@ -0,0 +1,39 @@
# Documentation Guidelines
## Language
The primary documentation language is German.
An English version will be maintained as a translation of the German master document.
## UI terminology
All user interface terms remain in English.
Examples:
- Direct Calculation
- Extrapolation
- Roll Diameter
- Product Length
- Capacity Summary
- Capacity Analysis
- Governing Constraint
Do not translate UI labels.
## Style
- Technical user manual
- Workflow-oriented
- Explain why, not only how
- Engineering language
- No marketing language
## Workflow
1. Write German chapter.
2. Technical review.
3. Add screenshots.
4. Approve.
5. Translate to English.
+9
View File
@@ -0,0 +1,9 @@
# Recent Changes
## Version 0.4
- User Manual integrated into RollCalc.
- Help area added for internal documentation.
- Capacity Analysis presentation improved.
- Governing Constraint highlighted more clearly.
- Remaining Capacity added to the Capacity Summary.
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,3 +5,4 @@ click==8.1.7
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.3
markdown-it-py==3.0.0
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Interactive RollCalc user management."""
import json
import os
import stat
import sys
from getpass import getpass
from pathlib import Path
from werkzeug.security import generate_password_hash
ROOT_DIR = Path(__file__).resolve().parents[1]
USER_FILE = ROOT_DIR / "config" / "users.json"
HASH_METHOD = "pbkdf2:sha256:600000"
class UserManagementError(Exception):
"""Expected user-management error."""
def ensure_user_file(path=USER_FILE):
"""Create an empty user file if needed."""
path = Path(path)
if path.exists():
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{}\n", encoding="utf-8")
set_restrictive_permissions(path)
def set_restrictive_permissions(path):
"""Set user-only read/write permissions where supported."""
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except OSError as exc:
print(f"Warning: could not set restrictive permissions: {exc}")
def load_users(path=USER_FILE):
"""Load user hashes from JSON."""
path = Path(path)
ensure_user_file(path)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise UserManagementError(f"Invalid JSON in {path}: {exc}") from exc
except OSError as exc:
raise UserManagementError(f"Could not read {path}: {exc}") from exc
if not isinstance(data, dict):
raise UserManagementError(f"Invalid user file format in {path}: expected object")
users = {}
for username, password_hash in data.items():
if not isinstance(username, str) or not username.strip():
raise UserManagementError(f"Invalid username in {path}")
if not isinstance(password_hash, str) or not password_hash.strip():
raise UserManagementError(f"Invalid password hash for user {username!r}")
users[username.strip()] = password_hash.strip()
return users
def save_users(users, path=USER_FILE):
"""Persist user hashes to JSON."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
existing_mode = None
if path.exists():
try:
existing_mode = stat.S_IMODE(path.stat().st_mode)
except OSError:
existing_mode = None
temp_path = path.with_name(f".{path.name}.tmp")
try:
temp_path.write_text(
json.dumps(users, indent=2, sort_keys=True) + "\n",
encoding="utf-8"
)
if existing_mode is not None:
os.chmod(temp_path, existing_mode)
else:
os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR)
os.replace(temp_path, path)
except OSError as exc:
try:
temp_path.unlink()
except OSError:
pass
raise UserManagementError(f"Could not write {path}: {exc}") from exc
if existing_mode is None:
set_restrictive_permissions(path)
def hash_password(password):
"""Hash a password with RollCalc's established hash method."""
return generate_password_hash(password, method=HASH_METHOD)
def add_user(users, username, password):
"""Add a user to a user dictionary."""
username = username.strip()
if not username:
raise UserManagementError("Username must not be empty.")
if username in users:
raise UserManagementError(f'User "{username}" already exists.')
users[username] = hash_password(password)
def update_password(users, username, password):
"""Update an existing user's password hash."""
username = username.strip()
if username not in users:
raise UserManagementError(f'User "{username}" does not exist.')
users[username] = hash_password(password)
def remove_user(users, username):
"""Remove an existing user."""
username = username.strip()
if username not in users:
raise UserManagementError(f'User "{username}" does not exist.')
del users[username]
def prompt_password_pair():
"""Read and validate a repeated password."""
password = getpass("Password: ")
repeat = getpass("Repeat password: ")
if password != repeat:
raise UserManagementError("Passwords do not match.")
if not password:
raise UserManagementError("Password must not be empty.")
return password
def list_users(users):
"""Print existing usernames without hashes."""
print("\nExisting users\n")
if not users:
print("(none)")
else:
for username in sorted(users):
print(f"- {username}")
print()
def create_user_interactive(users):
"""Create a user from prompts."""
username = input("Username: ").strip()
password = prompt_password_pair()
add_user(users, username, password)
print(f'User "{username}" created.')
def change_password_interactive(users):
"""Change a user's password from prompts."""
username = input("Username: ").strip()
password = prompt_password_pair()
update_password(users, username, password)
print(f'Password for "{username}" changed.')
def delete_user_interactive(users):
"""Delete a user after confirmation."""
username = input("Username: ").strip()
if username not in users:
raise UserManagementError(f'User "{username}" does not exist.')
confirmation = input(f'Delete user "{username}"? yes/no: ').strip().lower()
if confirmation != "yes":
print("Delete cancelled.")
return
remove_user(users, username)
print(f'User "{username}" deleted.')
def print_menu():
"""Print the main menu."""
print(
"\n"
"--------------------------------------------------\n\n"
"RollCalc User Management\n\n"
"1 - List users\n"
"2 - Create user\n"
"3 - Change password\n"
"4 - Delete user\n"
"5 - Exit\n\n"
"--------------------------------------------------"
)
def run_menu(path=USER_FILE):
"""Run the interactive menu."""
while True:
try:
users = load_users(path)
except UserManagementError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print_menu()
choice = input("Select option: ").strip()
try:
if choice == "1":
list_users(users)
continue
if choice == "2":
create_user_interactive(users)
elif choice == "3":
change_password_interactive(users)
elif choice == "4":
delete_user_interactive(users)
elif choice == "5":
print("Exit.")
return 0
else:
print("Unknown option.")
continue
save_users(users, path)
except UserManagementError as exc:
print(f"Error: {exc}")
def main():
"""CLI entry point."""
return run_menu()
if __name__ == "__main__":
raise SystemExit(main())
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION="${1:-v0.4-dev}"
cd "$PROJECT_ROOT"
cat > build_info.json <<EOF
{
"version": "$VERSION",
"branch": "$(git branch --show-current)",
"commit": "$(git rev-parse --short HEAD)",
"timestamp": "$(date -Iseconds)"
}
EOF
+8
View File
@@ -0,0 +1,8 @@
{
"items": [
"User Manual integrated",
"Capacity Analysis improved",
"Governing Constraint clarified",
"Remaining Capacity added"
]
}
-568
View File
@@ -1,568 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#0066cc">
<title>Admin Panel – Roll Calculator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
background: #f4f6f9;
color: #333;
min-height: 100vh;
}
/* ---- HEADER ---- */
.admin-header {
background: linear-gradient(135deg, #003366 0%, #0066cc 100%);
color: white;
padding: 20px 32px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.admin-header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: 0.5px;
}
.admin-header .subtitle {
font-size: 12px;
opacity: 0.85;
}
.admin-header .user-info {
font-size: 12px;
opacity: 0.9;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.admin-header .user-info strong {
opacity: 1;
font-weight: 600;
}
/* ---- CONTAINER ---- */
.admin-container {
max-width: 1300px;
margin: 30px auto;
padding: 0 20px 60px;
}
/* ---- STATS GRID ---- */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
border-left: 4px solid #0066cc;
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.stat-card.warning {
border-left-color: #f0b429;
}
.stat-card.danger {
border-left-color: #dc3545;
}
.stat-card .label {
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 8px;
font-weight: 600;
}
.stat-card .value {
font-size: 36px;
font-weight: 700;
color: #0066cc;
line-height: 1;
}
.stat-card.warning .value {
color: #f0b429;
}
.stat-card.danger .value {
color: #dc3545;
}
.stat-card .sublabel {
font-size: 11px;
color: #999;
margin-top: 8px;
}
/* ---- LOGS SECTION ---- */
.logs-section {
background: white;
border-radius: 10px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.logs-header h2 {
font-size: 18px;
color: #003366;
font-weight: 700;
}
.controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
background: #0066cc;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
transition: background 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: #0052a3;
}
.btn.secondary {
background: #e0e0e0;
color: #333;
}
.btn.secondary:hover {
background: #d0d0d0;
}
.btn-refresh {
animation: spin 1s linear infinite;
}
.btn-refresh.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ---- TABLE ---- */
.logs-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.logs-table thead {
background: #f0f4fa;
border-bottom: 2px solid #0066cc;
}
.logs-table th {
padding: 12px;
text-align: left;
font-weight: 600;
color: #333;
}
.logs-table td {
padding: 10px 12px;
border-bottom: 1px solid #e8ecf0;
}
.logs-table tbody tr {
transition: background 0.15s;
}
.logs-table tbody tr:hover {
background: #f8f9fa;
}
.status-200 {
color: #28a745;
font-weight: 600;
background: #f0f9f6;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
.status-401 {
color: #dc3545;
font-weight: 600;
background: #fff0f0;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
.status-403 {
color: #f0b429;
font-weight: 600;
background: #fffbea;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
/* ---- LOADING / EMPTY ---- */
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.empty {
text-align: center;
padding: 40px;
color: #999;
font-style: italic;
}
.error {
background: #fff0f0;
color: #dc3545;
padding: 12px;
border-radius: 5px;
border-left: 4px solid #dc3545;
margin: 12px 0;
}
/* ---- FILTERS ---- */
.filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-input {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 5px;
font-size: 13px;
}
.filter-input:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0,102,204,0.1);
}
/* ---- FOOTER ---- */
.admin-footer {
text-align: center;
font-size: 11px;
color: #aaa;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e8ecf0;
}
/* ---- RESPONSIVE ---- */
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.admin-header .user-info {
align-items: flex-start;
}
.stats-grid {
grid-template-columns: 1fr;
}
.logs-header {
flex-direction: column;
align-items: flex-start;
}
.controls {
width: 100%;
}
.btn {
flex: 1;
justify-content: center;
}
.logs-table {
font-size: 12px;
}
.logs-table th, .logs-table td {
padding: 8px;
}
}
</style>
</head>
<body>
<!-- HEADER -->
<div class="admin-header">
<div>
<h1>🔐 Admin Panel</h1>
<div class="subtitle">Roll Calculator – Naue GmbH & Co. KG</div>
</div>
<div class="user-info">
<span>Angemeldet als:</span>
<strong id="authUser">admin</strong>
</div>
</div>
<!-- MAIN CONTAINER -->
<div class="admin-container">
<!-- STATISTICS -->
<div class="stats-grid" id="statsGrid">
<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: #999;">
⏳ Statistiken werden geladen...
</div>
</div>
<!-- LOGS SECTION -->
<div class="logs-section">
<!-- HEADER -->
<div class="logs-header">
<h2>📋 Access Logs</h2>
<div class="controls">
<button class="btn" id="refreshBtn" onclick="refreshData()">
<span id="refreshIcon">🔄</span> Aktualisieren
</button>
<button class="btn secondary" onclick="downloadLogs()">
⬇️ Download
</button>
</div>
</div>
<!-- FILTERS -->
<div class="filters">
<input
type="text"
class="filter-input"
placeholder="Nach Benutzer filtern..."
id="filterUser"
onkeyup="filterLogs()"
>
<input
type="text"
class="filter-input"
placeholder="Nach Endpoint filtern..."
id="filterEndpoint"
onkeyup="filterLogs()"
>
</div>
<!-- TABLE -->
<div id="errorContainer"></div>
<table class="logs-table">
<thead>
<tr>
<th>Zeitstempel</th>
<th>Benutzer</th>
<th>Endpoint</th>
<th>Methode</th>
<th style="width: 80px;">Status</th>
</tr>
</thead>
<tbody id="logsList">
<tr>
<td colspan="5" class="loading">Logs werden geladen...</td>
</tr>
</tbody>
</table>
</div>
<!-- FOOTER -->
<div class="admin-footer">
ℹ️ Logs werden automatisch alle 30 Sekunden aktualisiert |
Letztes Update: <span id="lastUpdate">-</span>
</div>
</div>
<!-- SCRIPTS -->
<script>
let allLogs = [];
// Get authenticated user from API
async function getAuthUser() {
try {
const res = await fetch('/api/user');
const data = await res.json();
document.getElementById('authUser').textContent = data.username || 'admin';
} catch (e) {
console.warn('Konnte Benutzerinformation nicht laden');
}
}
// Refresh Data (Statistiken + Logs)
async function refreshData() {
const btn = document.getElementById('refreshBtn');
const icon = document.getElementById('refreshIcon');
try {
icon.style.animation = 'spin 1s linear infinite';
// Load Statistics
const statsRes = await fetch('/api/admin/stats');
if (!statsRes.ok) throw new Error('Statistiken konnten nicht geladen werden');
const stats = await statsRes.json();
// Load Logs
const logsRes = await fetch('/api/admin/logs');
if (!logsRes.ok) throw new Error('Logs konnten nicht geladen werden');
const logsData = await logsRes.json();
allLogs = logsData.logs || [];
// Render Statistics
renderStats(stats);
// Render Logs
renderLogs(allLogs);
// Update timestamp
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString('de-DE');
// Clear error
document.getElementById('errorContainer').innerHTML = '';
} catch (error) {
console.error('Fehler beim Laden der Daten:', error);
document.getElementById('errorContainer').innerHTML =
'<div class="error">❌ ' + error.message + '</div>';
} finally {
icon.style.animation = 'none';
}
}
// Render Statistics
function renderStats(stats) {
const html = `
<div class="stat-card">
<div class="label">📊 Gesamte Anfragen</div>
<div class="value">${stats.total_requests || 0}</div>
</div>
<div class="stat-card">
<div class="label">👥 Eindeutige Benutzer</div>
<div class="value">${stats.unique_users || 0}</div>
</div>
<div class="stat-card danger">
<div class="label">⛔ Fehlgeschlagene Logins</div>
<div class="value">${stats.failed_logins || 0}</div>
</div>
`;
document.getElementById('statsGrid').innerHTML = html;
}
// Render Logs
function renderLogs(logs) {
if (!logs || logs.length === 0) {
document.getElementById('logsList').innerHTML =
'<tr><td colspan="5" class="empty">Keine Logs verfügbar</td></tr>';
return;
}
const html = logs.reverse().map(log => {
const time = new Date(log.timestamp).toLocaleString('de-DE');
const statusClass = `status-${log.status}`;
return `
<tr>
<td>${time}</td>
<td><strong>${log.username}</strong></td>
<td><code>${log.endpoint}</code></td>
<td>${log.method}</td>
<td><span class="${statusClass}">${log.status}</span></td>
</tr>
`;
}).join('');
document.getElementById('logsList').innerHTML = html;
}
// Filter Logs
function filterLogs() {
const userFilter = document.getElementById('filterUser').value.toLowerCase();
const endpointFilter = document.getElementById('filterEndpoint').value.toLowerCase();
const filtered = allLogs.filter(log =>
log.username.toLowerCase().includes(userFilter) &&
log.endpoint.toLowerCase().includes(endpointFilter)
);
renderLogs(filtered);
}
// Download Logs as JSON
function downloadLogs() {
const data = JSON.stringify(allLogs, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `access-logs-${new Date().toISOString()}.json`;
a.click();
window.URL.revokeObjectURL(url);
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
getAuthUser();
refreshData();
// Auto-refresh every 30 seconds
setInterval(refreshData, 30000);
});
</script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="RollCalc Help">
<title>RollCalc Help</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #2f3437; min-height: 100vh; }
.help-header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.help-header-inner { max-width: 980px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.help-header h1 { font-size: 24px; margin-bottom: 5px; }
.help-header p { font-size: 13px; opacity: 0.9; }
.help-nav { display: flex; gap: 10px; flex-wrap: wrap; }
.help-nav a { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.help-nav a:hover { background: rgba(255,255,255,0.18); }
.help-container { max-width: 980px; margin: 30px auto; padding: 0 16px 60px; }
.help-intro { margin-bottom: 22px; line-height: 1.55; color: #4b555a; }
.help-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 18px; }
.help-card { background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
.help-card h2 { color: #1F5438; font-size: 18px; margin-bottom: 10px; }
.help-card p { color: #4f5b60; font-size: 14px; line-height: 1.55; margin-bottom: 18px; }
.help-card a { display: inline-block; background: #2BAC70; color: #fff; text-decoration: none; border-radius: 6px; padding: 10px 16px; font-size: 14px; font-weight: 700; }
.help-card a:hover { background: #1F5438; }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
@media (max-width: 640px) {
.help-header { padding: 18px 16px; }
.help-header-inner { align-items: flex-start; flex-direction: column; }
}
</style>
</head>
<body>
<header class="help-header">
<div class="help-header-inner">
<div>
<h1>RollCalc Help</h1>
<p>Documentation for roll calculation and transport capacity analysis</p>
</div>
<nav class="help-nav" aria-label="Help navigation">
<a href="{{ url_for('index') }}">Calculator</a>
</nav>
</div>
</header>
<main class="help-container">
<p class="help-intro">
This area provides internal technical documentation for RollCalc. The documents are rendered from the maintained Markdown sources.
</p>
<section class="help-grid" aria-label="Available help documents">
<article class="help-card">
<h2>User Manual</h2>
<p>
Workflow-oriented manual for roll calculation, handling evaluation, Load Optimizer usage, and interpretation of Capacity Summary and Capacity Analysis.
</p>
<a href="{{ url_for('user_manual') }}">Open User Manual</a>
</article>
<article class="help-card">
<h2>Recent Changes</h2>
<p>
Compact release notes for relevant user-facing changes in RollCalc.
</p>
<a href="{{ url_for('recent_changes') }}">Open Recent Changes</a>
</article>
</section>
</main>
<footer class="footer">
RollCalc Beta &copy; Naue
<div class="footer-build-info">
Version {{ build_info.version }} |
Branch {{ build_info.branch }} |
Commit {{ build_info.commit }} |
Built {{ build_info.timestamp }}
</div>
</footer>
</body>
</html>
+340 -38
View File
@@ -9,8 +9,11 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; }
.header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.header-inner { max-width: 900px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.header h1 { font-size: 24px; margin-bottom: 5px; }
.header p { font-size: 13px; opacity: 0.9; }
.header-help-link { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.header-help-link:hover { background: rgba(255,255,255,0.18); }
.container { max-width: 900px; margin: 30px auto; padding: 0 16px 60px; }
.card { background: #fff; border-radius: 10px; box-shadow: 0 2px 12px rgba(0,0,0,0.07); margin-bottom: 24px; overflow: hidden; }
.card-header { background: #f0f4fa; padding: 16px 22px; border-bottom: 1px solid #e0e8f0; }
@@ -48,6 +51,9 @@
.result-label { font-size: 12px; color: #1F5438; font-weight: 600; text-transform: uppercase; }
.result-value { font-size: 28px; font-weight: 700; color: #1F5438; line-height: 1.2; }
.result-range { font-size: 12px; color: #1F5438; margin-top: 4px; font-weight: 500; }
.result-range div { margin-top: 2px; }
.result-range .range-warning { color: #9a6700; font-weight: 700; }
.result-range .range-ok { color: #1F5438; font-weight: 700; }
.forklift-check { border-radius: 7px; padding: 14px 16px; margin-top: 14px; font-size: 13px; line-height: 1.6; display: none; }
.forklift-check.show { display: block; }
.forklift-check.fc-ok { background: #eafaf1; border: 1.5px solid #28a745; }
@@ -60,6 +66,7 @@
.fc-req-nok { color: #dc3545; font-weight: 600; }
.formula-box { background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 14px; font-size: 12px; color: #666; margin-top: 18px; font-family: 'Courier New', monospace; }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; margin-top: 30px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
.disclaimer-overlay {
position: fixed;
inset: 0;
@@ -139,6 +146,59 @@
background: #b8c7bf;
cursor: not-allowed;
}
.whats-new-overlay {
position: fixed;
inset: 0;
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(20, 28, 34, 0.55);
}
.whats-new-overlay.is-hidden { display: none; }
.whats-new-dialog {
width: min(520px, 100%);
background: #fff;
border-radius: 8px;
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
overflow: hidden;
}
.whats-new-header {
padding: 16px 20px;
background: #1F5438;
color: #fff;
}
.whats-new-header h2 { font-size: 18px; margin: 0 0 4px; }
.whats-new-version { font-size: 12px; opacity: 0.88; }
.whats-new-content {
padding: 18px 20px 8px;
font-size: 14px;
line-height: 1.5;
color: #333;
}
.whats-new-content ul { margin: 8px 0 0 20px; }
.whats-new-content li { margin-bottom: 5px; }
.whats-new-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px 18px;
border-top: 1px solid #e0e8f0;
background: #f8fafa;
}
.whats-new-btn {
border: none;
border-radius: 6px;
padding: 9px 14px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.whats-new-btn-primary { background: #2BAC70; color: #fff; }
.whats-new-btn-primary:hover { background: #1F5438; }
.whats-new-btn-secondary { background: #fff; color: #1F5438; border: 1px solid #a8c8ba; }
.whats-new-btn-secondary:hover { background: #eef4f1; }
/* ============================================
LOAD OPTIMIZER STYLES
@@ -285,6 +345,23 @@
border-left: 4px solid #2BAC70;
}
.lo-results-summary h4 { margin-top: 0; color: #333; font-size: 14px; margin-bottom: 12px; }
.lo-summary-section {
padding-top: 12px;
margin-top: 12px;
border-top: 1px solid #e6ece9;
}
.lo-summary-section:first-of-type {
padding-top: 0;
margin-top: 0;
border-top: 0;
}
.lo-summary-section-title {
margin-bottom: 8px;
color: #1F5438;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.lo-summary-item {
display: flex;
justify-content: space-between;
@@ -293,9 +370,23 @@
font-size: 13px;
}
.lo-summary-item:last-child { margin-bottom: 0; }
.lo-summary-item.lo-governing-summary {
background: #eef8f2;
border-left: 3px solid #2BAC70;
padding: 8px 10px;
border-radius: 4px;
}
.lo-value-highlight { color: #2BAC70; font-weight: bold; font-size: 16px; }
.lo-value-number { color: #1F5438; font-weight: bold; }
.lo-util-group { display: flex; flex-direction: column; gap: 4px; text-align: right; }
.lo-reserve-note {
margin-top: 4px;
color: #666;
font-size: 12px;
line-height: 1.35;
text-align: right;
max-width: 300px;
}
.lo-results-table {
background: white;
padding: 16px;
@@ -311,10 +402,15 @@
font-weight: 600;
color: #333;
border-bottom: 2px solid #2BAC70;
white-space: normal;
}
.lo-results-table td { padding: 8px; border-bottom: 1px solid #e0e0e0; }
.lo-results-table tbody tr:hover { background: #f9f9f9; }
.lo-results-table tbody tr.lo-recommended { background: #fffacd; font-weight: bold; }
.lo-results-table tbody tr.lo-governing {
background: #eef8f2;
font-weight: 600;
border-left: 3px solid #2BAC70;
}
.lo-warning {
background: #fff8e1;
border: 1px solid #f0b429;
@@ -392,6 +488,11 @@
}
.lo-sideview-section.show { display: block; }
.lo-sideview-section h4 { margin: 0 0 12px 0; font-size: 14px; color: #333; }
.lo-sideview-context {
margin: -6px 0 12px;
color: #666;
font-size: 12px;
}
.lo-sideview-wrap {
background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden;
}
@@ -403,6 +504,10 @@
.margin-color { background: repeating-linear-gradient(45deg,#fff7e0,#fff7e0 3px,#ffe8a0 3px,#ffe8a0 6px); border-color:#f0b429; }
@media (max-width: 768px) {
.header { padding: 18px 16px; }
.header-inner { align-items: flex-start; }
.whats-new-actions { flex-direction: column; }
.whats-new-btn { width: 100%; }
.lo-accordion-header h3 { font-size: 14px; }
.lo-preset-buttons { flex-direction: column; }
.lo-preset-btn { width: 100%; }
@@ -446,9 +551,30 @@
</div>
</div>
<div class="whats-new-overlay is-hidden" id="whatsNewOverlay" role="dialog" aria-modal="true" aria-labelledby="whatsNewTitle">
<div class="whats-new-dialog">
<div class="whats-new-header">
<h2 id="whatsNewTitle">What’s New</h2>
<div class="whats-new-version">Version: <span id="whatsNewVersion">–</span></div>
</div>
<div class="whats-new-content">
<ul id="whatsNewList"></ul>
</div>
<div class="whats-new-actions">
<button class="whats-new-btn whats-new-btn-secondary" id="whatsNewOpenChangesBtn" type="button">Open Recent Changes</button>
<button class="whats-new-btn whats-new-btn-primary" id="whatsNewContinueBtn" type="button">Continue</button>
</div>
</div>
</div>
<div class="header">
<h1>Naue Roll Diameter Calculator</h1>
<p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p>
<div class="header-inner">
<div>
<h1>Naue Roll Diameter Calculator</h1>
<p>Roll Diameter • Product Length • Roll Weight • Load Optimizer</p>
</div>
<a class="header-help-link" href="{{ url_for('user_manual') }}">User Manual</a>
</div>
</div>
<div class="container">
@@ -571,6 +697,27 @@
<span class="unit-label">kg</span>
</div>
</div>
<div class="form-group">
<label>Max. Roll Diameter</label>
<div class="input-with-unit">
<input type="number" id="d-target-di" placeholder="mm" min="0" step="1">
<span class="unit-label">mm</span>
</div>
</div>
<div class="form-group">
<label>Max. Roll Weight</label>
<div class="input-with-unit">
<input type="number" id="d-max-weight" placeholder="kg" min="0" step="1">
<span class="unit-label">kg</span>
</div>
</div>
<div class="form-group">
<label>Minimum Product Length</label>
<div class="input-with-unit">
<input type="number" id="d-min-le" placeholder="m" min="0" step="0.1">
<span class="unit-label">m</span>
</div>
</div>
</div>
</div>
@@ -782,38 +929,58 @@
<div class="lo-results" id="resultsContainer">
<div class="lo-results-summary">
<h4>Results:</h4>
<div class="lo-summary-item">
<span><strong>Limiting Factor:</strong></span>
<span id="limitingFactorValue" class="lo-value-highlight">–</span>
</div>
<div class="lo-summary-item">
<span><strong>Total Rolls:</strong></span>
<div style="text-align:right;">
<span id="totalRollsValue" class="lo-value-highlight">–</span>
<small id="rollsDetailValue" style="display:block;color:#888;"></small>
<h4>Capacity Summary:</h4>
<div class="lo-summary-section">
<div class="lo-summary-section-title">Governing Constraint</div>
<div class="lo-summary-item lo-governing-summary">
<span><strong>Constraint:</strong></span>
<span id="limitingFactorValue" class="lo-value-highlight">–</span>
</div>
</div>
<div class="lo-summary-item">
<span><strong>Utilization:</strong></span>
<div class="lo-util-group">
<div>Weight: <span id="weightUtilValue" class="lo-value-number">–</span> %</div>
<div>Volume: <span id="volumeUtilValue" class="lo-value-number">–</span> %</div>
<div>Product: <span id="productM2Value" class="lo-value-number">–</span> m²</div>
<div class="lo-summary-section">
<div class="lo-summary-section-title">Transport Result</div>
<div class="lo-summary-item">
<span><strong>Total Rolls:</strong></span>
<div style="text-align:right;">
<span id="totalRollsValue" class="lo-value-highlight">–</span>
<small id="rollsDetailValue" style="display:block;color:#888;"></small>
</div>
</div>
<div class="lo-summary-item">
<span><strong>Utilization:</strong></span>
<div class="lo-util-group">
<div>Weight Utilization: <span id="weightUtilValue" class="lo-value-number">–</span> %</div>
<div>Volume Utilization: <span id="volumeUtilValue" class="lo-value-number">–</span> %</div>
<div>Transported Product Area: <span id="productM2Value" class="lo-value-number">–</span> m²</div>
</div>
</div>
</div>
<div class="lo-summary-section">
<div class="lo-summary-section-title">Remaining Capacity</div>
<div class="lo-summary-item">
<span><strong>Remaining Capacity:</strong></span>
<div class="lo-util-group">
<div>Remaining Payload: <span id="remainingPayloadValue" class="lo-value-number">–</span> kg</div>
<div>Unused Payload: <span id="unusedPayloadValue" class="lo-value-number">–</span> %</div>
<div>Unused Volume: <span id="unusedVolumeValue" class="lo-value-number">–</span> %</div>
<div id="capacityInterpretationValue" class="lo-reserve-note"></div>
</div>
</div>
</div>
</div>
<div class="lo-results-table">
<h4>Comparison Table:</h4>
<h4>Capacity Analysis:</h4>
<table id="comparisonTable">
<thead>
<tr>
<th>Scenario</th>
<th>Rolls</th>
<th>kg</th>
<th>m²</th>
<th>Util. %</th>
<th>Constraint</th>
<th>Max Rolls</th>
<th>Total Weight</th>
<th>Transported Area [m²]</th>
<th>Payload Util. %</th>
</tr>
</thead>
<tbody></tbody>
@@ -822,6 +989,7 @@
<div class="lo-sideview-section" id="sideViewSection">
<h4>🔍 Front View – Cross-Section</h4>
<p class="lo-sideview-context">Cross-section of the actual loading configuration.</p>
<div class="lo-sideview-wrap">
<svg id="sideViewSVG" width="100%" viewBox="0 0 800 300" xmlns="http://www.w3.org/2000/svg">
<text x="400" y="130" text-anchor="middle" fill="#aaa" font-size="14">Run a calculation to see the side view.</text>
@@ -844,6 +1012,9 @@
<div class="footer">
Naue GmbH & Co. KG · Roll Diameter Calculator · Internal Application
<div class="footer-build-info">
Version {{ build_info.version }} · Branch {{ build_info.branch }} · Commit {{ build_info.commit }} · Built {{ build_info.timestamp }}
</div>
</div>
<script>
@@ -989,21 +1160,69 @@ document.getElementById('d-calc').addEventListener('click', () => {
resRange.textContent = '';
document.getElementById('d-le').value = L_calc.toFixed(2);
} else if (mode === 'target') {
if (!d || !t) { alert('Please enter d and t.'); return; }
const D_target = D || 800;
const L_calc = (Math.PI / (4 * t)) * (D_target*D_target - d*d) / 1000;
resLabel.textContent = 'Length for Target Diameter ' + D_target + ' mm';
resVal.textContent = L_calc.toFixed(2) + ' m';
resRange.textContent = '';
document.getElementById('d-le').value = L_calc.toFixed(2);
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
const maxWeight = parseFloat(document.getElementById('d-max-weight').value) || 0;
const minLength = parseFloat(document.getElementById('d-min-le').value) || 0;
const D_target = parseFloat(document.getElementById('d-target-di').value) || 0;
if (!d || !t || !D_target || !wi || !aw || !maxWeight) {
alert('Please enter d, t, max. roll diameter, roll width, area weight, and max. roll weight.');
return;
}
if (D_target <= d) {
alert('Max. roll diameter must be larger than core diameter.');
return;
}
const L_diameter = (Math.PI / (4 * t)) * (D_target*D_target - d*d) / 1000;
const L_weight = maxWeight * 1000 / (aw * wi);
const L_allowed = Math.min(L_diameter, L_weight);
const D_result = Math.sqrt(d*d + (4*L_allowed*1000*t)/Math.PI);
const W_result = aw * L_allowed * wi / 1000;
const diameterUtil = D_result / D_target * 100;
const weightUtil = W_result / maxWeight * 100;
const lengthDiff = minLength > 0 ? L_allowed - minLength : null;
const epsilon = 0.0001;
let limitingFactor = 'Diameter + Weight';
if (L_diameter < L_weight - epsilon) {
limitingFactor = 'Diameter';
} else if (L_weight < L_diameter - epsilon) {
limitingFactor = 'Weight';
}
resLabel.textContent = 'Maximum Allowed Product Length';
resVal.textContent = L_allowed.toFixed(2) + ' m';
const minLengthText = minLength > 0 ? minLength.toFixed(2) + ' m' : 'not set';
const diffText = lengthDiff !== null ? lengthDiff.toFixed(2) + ' m' : 'n/a';
let minStatus = '<div>Minimum length not set</div>';
if (minLength > 0 && L_allowed < minLength) {
minStatus = '<div class="range-warning">Warning: maximum allowed length is below the minimum product length.</div>';
} else if (minLength > 0) {
minStatus = '<div class="range-ok">Minimum length OK</div>';
}
resRange.innerHTML =
'<div>Resulting Roll Diameter: ' + D_result.toFixed(1) + ' mm</div>' +
'<div>Resulting weight: ' + W_result.toFixed(1) + ' kg</div>' +
'<div>Minimum length: ' + minLengthText + '</div>' +
'<div>Difference: ' + diffText + '</div>' +
'<div>Limiting factor: ' + limitingFactor + '</div>' +
'<div>Utilization: Diameter ' + diameterUtil.toFixed(1) + '% | Weight ' + weightUtil.toFixed(1) + '%</div>' +
minStatus;
document.getElementById('d-le').value = L_allowed.toFixed(2);
document.getElementById('d-wo').value = W_result.toFixed(1);
}
const wi = parseFloat(document.getElementById('d-wi').value) || 0;
const aw = parseFloat(document.getElementById('d-aw').value) || 0;
const L_final = parseFloat(document.getElementById('d-le').value) || 0;
if (wi > 0 && aw > 0 && L_final > 0) {
const weight = (aw * L_final * wi / 1000).toFixed(1);
document.getElementById('d-wo').value = weight;
const weight = aw * L_final * wi / 1000;
document.getElementById('d-wo').value = weight.toFixed(1);
checkForklift(weight, d, category);
} else {
document.getElementById('forkliftCheck').classList.remove('show');
@@ -1013,7 +1232,7 @@ document.getElementById('d-calc').addEventListener('click', () => {
});
document.getElementById('d-reset').addEventListener('click', () => {
['d-co', 'd-th', 'd-tol', 'd-le', 'd-di', 'd-wi', 'd-aw', 'd-wo'].forEach(id => {
['d-co', 'd-th', 'd-tol', 'd-le', 'd-di', 'd-wi', 'd-aw', 'd-wo', 'd-target-di', 'd-max-weight', 'd-min-le'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = id === 'd-co' ? '150' : '';
});
@@ -1274,6 +1493,14 @@ class LoadOptimizerUI {
showResults(res, maxW) {
const m2PerRoll = this.optimizer.state.m2PerRoll || 0;
const remainingPayload = Math.max(0, maxW - res.finalWeight);
const unusedPayload = Math.max(0, 100 - res.utilizationWeight);
const unusedVolume = Math.max(0, 100 - res.utilizationVolume);
const interpretations = {
geometry: 'Geometry prevents loading additional rolls although payload capacity remains available.',
weight: 'Payload limit is reached before the available geometry or volume is fully used.',
volume: 'Volume capacity is reached before payload or geometric capacity is fully used.'
};
document.getElementById('limitingFactorValue').textContent = res.limiting.name;
document.getElementById('totalRollsValue').textContent = res.finalRolls;
document.getElementById('rollsDetailValue').textContent =
@@ -1281,16 +1508,21 @@ class LoadOptimizerUI {
document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight);
document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume);
document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0);
document.getElementById('remainingPayloadValue').textContent = this.optimizer.fmt(remainingPayload, 0);
document.getElementById('unusedPayloadValue').textContent = this.optimizer.fmt(unusedPayload);
document.getElementById('unusedVolumeValue').textContent = this.optimizer.fmt(unusedVolume);
document.getElementById('capacityInterpretationValue').textContent =
interpretations[res.limiting.type] || '';
const tbody = document.querySelector('#comparisonTable tbody');
tbody.innerHTML = '';
res.scenarios.forEach(sc => {
const tr = document.createElement('tr');
const lim = (sc.type === res.limiting.type);
if (lim) tr.classList.add('lo-recommended');
if (lim) tr.classList.add('lo-governing');
const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–';
const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–';
tr.innerHTML =
'<td>'+ sc.name +(lim?' ★':'')+'</td>'+
'<td>'+ sc.name +(lim?' &rarr; Governing Constraint':'')+'</td>'+
'<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+
'<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+
'<td>'+ scM2 +'</td>'+
@@ -1412,12 +1644,81 @@ document.addEventListener('DOMContentLoaded', () => {
window.loadOptimizerUI = new LoadOptimizerUI();
});
const ROLLCALC_BUILD_VERSION = {{ build_info.version|tojson }};
const ROLLCALC_WHATS_NEW_ITEMS = {{ whats_new_summary|tojson }};
const ROLLCALC_LAST_SEEN_VERSION_KEY = 'rollcalc_last_seen_version';
function shouldShowWhatsNew(currentVersion, storedVersion) {
return !!currentVersion &&
currentVersion !== 'unknown' &&
currentVersion !== storedVersion;
}
function markWhatsNewSeen(currentVersion) {
if (!currentVersion || currentVersion === 'unknown') return;
try {
localStorage.setItem(ROLLCALC_LAST_SEEN_VERSION_KEY, currentVersion);
} catch (e) {
console.warn('Could not store RollCalc release note state', e);
}
}
function renderWhatsNewItems(items) {
const list = document.getElementById('whatsNewList');
if (!list) return;
list.innerHTML = '';
items.forEach(item => {
const li = document.createElement('li');
li.textContent = item;
list.appendChild(li);
});
}
function maybeShowWhatsNew() {
const overlay = document.getElementById('whatsNewOverlay');
const versionValue = document.getElementById('whatsNewVersion');
const continueBtn = document.getElementById('whatsNewContinueBtn');
const openChangesBtn = document.getElementById('whatsNewOpenChangesBtn');
const items = Array.isArray(ROLLCALC_WHATS_NEW_ITEMS)
? ROLLCALC_WHATS_NEW_ITEMS
: [];
if (!overlay || !versionValue || !continueBtn || !openChangesBtn || items.length === 0) return;
let storedVersion = null;
try {
storedVersion = localStorage.getItem(ROLLCALC_LAST_SEEN_VERSION_KEY);
} catch (e) {
storedVersion = null;
}
if (!shouldShowWhatsNew(ROLLCALC_BUILD_VERSION, storedVersion)) return;
versionValue.textContent = ROLLCALC_BUILD_VERSION;
renderWhatsNewItems(items);
overlay.classList.remove('is-hidden');
continueBtn.addEventListener('click', () => {
markWhatsNewSeen(ROLLCALC_BUILD_VERSION);
overlay.classList.add('is-hidden');
}, { once: true });
openChangesBtn.addEventListener('click', () => {
markWhatsNewSeen(ROLLCALC_BUILD_VERSION);
window.location.href = {{ url_for('recent_changes')|tojson }};
}, { once: true });
}
document.addEventListener('DOMContentLoaded', () => {
const overlay = document.getElementById('disclaimerOverlay');
const checkbox = document.getElementById('disclaimerAccepted');
const confirmBtn = document.getElementById('disclaimerConfirmBtn');
if (!overlay || !checkbox || !confirmBtn) return;
if (!overlay || !checkbox || !confirmBtn) {
maybeShowWhatsNew();
return;
}
checkbox.addEventListener('change', () => {
confirmBtn.disabled = !checkbox.checked;
@@ -1426,6 +1727,7 @@ document.addEventListener('DOMContentLoaded', () => {
confirmBtn.addEventListener('click', () => {
if (!checkbox.checked) return;
overlay.classList.add('is-hidden');
maybeShowWhatsNew();
});
});
</script>
+110
View File
@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="RollCalc {{ document_title }}">
<title>RollCalc {{ document_title }}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f4f6f9; color: #2f3437; min-height: 100vh; }
.help-header { background: linear-gradient(135deg, #1F5438 0%, #2BAC70 100%); color: #fff; padding: 20px 32px; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.help-header-inner { max-width: 1180px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.help-header h1 { font-size: 24px; margin-bottom: 5px; }
.help-header p { font-size: 13px; opacity: 0.9; }
.help-nav { display: flex; gap: 10px; flex-wrap: wrap; }
.help-nav a { color: #fff; border: 1px solid rgba(255,255,255,0.58); border-radius: 5px; padding: 7px 13px; text-decoration: none; font-size: 13px; font-weight: 700; background: rgba(255,255,255,0.08); }
.help-nav a:hover { background: rgba(255,255,255,0.18); }
.manual-shell { max-width: 1180px; margin: 28px auto; padding: 0 16px 60px; }
.manual-layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 24px; align-items: start; }
.manual-toc { position: sticky; top: 18px; background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); max-height: calc(100vh - 36px); overflow: auto; }
.manual-toc h2 { color: #1F5438; font-size: 14px; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0; }
.manual-toc ul { list-style: none; }
.manual-toc li { margin: 0 0 8px; }
.manual-toc a { color: #34443b; text-decoration: none; font-size: 13px; line-height: 1.35; }
.manual-toc a:hover { color: #1F5438; text-decoration: underline; }
.manual-toc .toc-level-2 { padding-left: 14px; }
.manual-content { background: #fff; border: 1px solid #dfe7e2; border-radius: 8px; padding: 34px 42px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); line-height: 1.62; font-size: 15px; }
.manual-content h1, .manual-content h2, .manual-content h3, .manual-content h4 { color: #1F5438; line-height: 1.25; scroll-margin-top: 18px; }
.manual-content h1 { font-size: 30px; margin: 0 0 24px; padding-bottom: 14px; border-bottom: 2px solid #dfe7e2; }
.manual-content h2 { font-size: 22px; margin: 34px 0 14px; padding-top: 4px; }
.manual-content h3 { font-size: 17px; margin: 26px 0 10px; color: #244b39; }
.manual-content h4 { font-size: 15px; margin: 20px 0 8px; color: #244b39; }
.manual-content p { margin: 0 0 14px; }
.manual-content ul, .manual-content ol { margin: 0 0 16px 24px; }
.manual-content li { margin: 5px 0; }
.manual-content a { color: #1F5438; font-weight: 600; }
.manual-content code { background: #eef4f1; border: 1px solid #d7e4de; border-radius: 4px; padding: 1px 4px; font-family: Consolas, 'Courier New', monospace; font-size: 0.92em; }
.manual-content pre { background: #f5f7f6; border: 1px solid #dfe7e2; border-radius: 6px; padding: 14px; margin: 16px 0; overflow-x: auto; }
.manual-content pre code { background: transparent; border: 0; padding: 0; }
.manual-content blockquote { margin: 18px 0; padding: 14px 18px; border-left: 4px solid #2BAC70; background: #f1f8f5; color: #34443b; }
.manual-content blockquote p:last-child, .manual-content blockquote ul:last-child { margin-bottom: 0; }
.manual-content table { width: 100%; border-collapse: collapse; margin: 18px 0; display: block; overflow-x: auto; }
.manual-content th, .manual-content td { border: 1px solid #d7e4de; padding: 9px 11px; text-align: left; vertical-align: top; }
.manual-content th { background: #eef4f1; color: #1F5438; }
.manual-content img { max-width: 100%; height: auto; border: 1px solid #dfe7e2; border-radius: 6px; background: #f8fafa; display: block; margin: 18px 0 8px; }
.manual-error { background: #fff; border: 1px solid #e6c7c7; border-left: 4px solid #b84a4a; border-radius: 8px; padding: 20px; color: #5a2d2d; box-shadow: 0 2px 12px rgba(0,0,0,0.05); }
.footer { text-align: center; font-size: 11px; color: #aaa; padding: 18px; }
.footer-build-info { margin-top: 6px; color: #bbb; }
@media (max-width: 840px) {
.help-header { padding: 18px 16px; }
.help-header-inner { align-items: flex-start; flex-direction: column; }
.manual-layout { grid-template-columns: 1fr; }
.manual-toc { position: static; max-height: none; }
.manual-content { padding: 24px 20px; }
.manual-content h1 { font-size: 25px; }
.manual-content h2 { font-size: 20px; }
}
</style>
</head>
<body>
<header class="help-header">
<div class="help-header-inner">
<div>
<h1>{{ document_title }}</h1>
<p>{{ document_subtitle }}</p>
</div>
<nav class="help-nav" aria-label="Help navigation">
<a href="{{ url_for('index') }}">Calculator</a>
<a href="{{ url_for('help_index') }}">Help</a>
</nav>
</div>
</header>
<main class="manual-shell">
{% if render_error %}
<section class="manual-error" role="alert">
<strong>{{ error_title }}</strong>
<p>{{ render_error }}</p>
</section>
{% else %}
<div class="manual-layout">
<nav class="manual-toc" aria-label="Table of contents">
<h2>Contents</h2>
<ul>
{% for entry in toc %}
<li class="toc-level-{{ entry.level }}">
<a href="#{{ entry.id }}">{{ entry.title }}</a>
</li>
{% endfor %}
</ul>
</nav>
<article class="manual-content">
{{ manual_html }}
</article>
</div>
{% endif %}
</main>
<footer class="footer">
RollCalc Beta &copy; Naue
<div class="footer-build-info">
Version {{ build_info.version }} |
Branch {{ build_info.branch }} |
Commit {{ build_info.commit }} |
Built {{ build_info.timestamp }}
</div>
</footer>
</body>
</html>