From b77b348d549bc63326c82fd21a7f108eeff2d655 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Sun, 1 Nov 2026 20:27:10 +0100 Subject: [PATCH] notyfikacje --- server/README.md | 64 ++++++++++++--- .../e2b6d8f0a4c1_create_notifications.py | 40 ++++++++++ server/app.py | 79 ++++++++++++++++++- server/static/retro.css | 51 ++++++++++++ server/templates/docs.html | 2 + server/templates/home.html | 9 ++- server/templates/notes.html | 1 + server/templates/notifications.html | 55 +++++++++++++ server/templates/sensors.html | 2 + server/tests/test_split_apps.py | 66 ++++++++++++++++ server/user_models.py | 24 ++++++ 11 files changed, 375 insertions(+), 18 deletions(-) create mode 100644 server/alembic_users/versions/e2b6d8f0a4c1_create_notifications.py create mode 100644 server/templates/notifications.html diff --git a/server/README.md b/server/README.md index c763636..fd0c5f0 100644 --- a/server/README.md +++ b/server/README.md @@ -2,9 +2,10 @@ Two Flask services with separate responsibilities and separate databases: -- **main app** (`app.py`) — all user-facing views: login, home, notes, API docs - and the `/sensors` dashboard. It owns the **main app** database - (`main_app.db`: users and notes) and proxies data requests to the sensors API. +- **main app** (`app.py`) — all user-facing views: login, home, notes, + notifications, API docs and the `/sensors` dashboard. It owns the **main app** + database (`main_app.db`: users, notes and notifications) and proxies data + requests to the sensors API. - **sensors app** (`sensors_app.py`) — API only. It owns the **sensors** database (`sensors.db`, tables `sensor` and `sensor_readout`) and has no knowledge of users. Its API is open to every user on the local machine. @@ -34,7 +35,7 @@ server/ ├── auth.py # Flask-Login setup ├── config.py # Config (databases, secret key, sensors URL) ├── models.py # Sensor, SensorReadout (sensors DB) -├── user_models.py # User, Note, NoteItem (main_app.db) +├── user_models.py # User, Note, NoteItem, Notification ├── seed.py # Sample sensor data ├── migrate_legacy_db.py # Import old combined data.db into split DBs ├── run.sh # Development launcher (main/sensors + port) @@ -52,6 +53,7 @@ server/ ├── home.html ├── login.html ├── notes.html + ├── notifications.html ├── docs.html └── sensors.html ``` @@ -102,12 +104,12 @@ After verifying the new databases you can archive or remove `data.db`. ## Users -User accounts and notes live only in `main_app.db` (main app). There is no -public registration; accounts are created by an administrator. +User accounts, notes and notifications live only in `main_app.db` (main app). +There is no public registration; accounts are created by an administrator. -Main app pages (`/`, `/home`, `/notes`, `/api/docs`, `/sensors`) and dashboard -read endpoints (`GET /api/sensors/:id/types`, `/readouts`, `/readouts/hourly`) -require login. Device endpoints (`GET/POST /api/sensors`, +Main app pages (`/`, `/home`, `/notes`, `/notifications`, `/api/docs`, +`/sensors`) and dashboard read endpoints (`GET /api/sensors/:id/types`, +`/readouts`, `/readouts/hourly`) require login. Device endpoints (`GET/POST /api/sensors`, `POST /api/sensors/:id/readouts`) stay open so devices can push data without a browser session. @@ -178,6 +180,34 @@ automatically): alembic -c alembic_users.ini upgrade head ``` +## Notifications + +Each user has their own short text notifications in `main_app.db`. A +notification is either **new** or **read**. The main navigation shows the number +of new notifications next to the `Notifications` link. Opening a notification +from the `/notifications` page marks it as read and moves it below the new ones. + +Create notifications with the CLI (run it as the same user as the service): + +```bash +cd /home/mateusz/prog/yggdrasil/server + +# For one user +sudo -u mateusz .venv/bin/flask --app app create-notification \ + --username admin --message "Backup finished." + +# For every user +sudo -u mateusz .venv/bin/flask --app app create-notification \ + --all --message "Planned maintenance tonight." +``` + +Apply the users database migration once (the production launcher does this +automatically): + +```bash +alembic -c alembic_users.ini upgrade head +``` + ## Running The first argument selects the app (`main` or `sensors`), the second the port. @@ -291,7 +321,7 @@ deployment path or user is different. | Service | Port | Routes | |---|---|---| -| main | 8000 | `/`, `/login`, `/home`, `/notes`, `/sensors`, `/api/docs`, `/api/*` (proxy) | +| main | 8000 | `/`, `/login`, `/home`, `/notes`, `/notifications`, `/sensors`, `/api/docs`, `/api/*` (proxy) | | sensors | 8001 | `/api/*` (sensors and readouts) | ### Tests @@ -322,9 +352,9 @@ Owned by the sensors app. Contains only sensor data. | `value` | Float | Measured value | | `timestamp` | DateTime | Readout timestamp (UTC) | -### main_app.db (users and notes) +### main_app.db (users, notes and notifications) -Owned by the main app. Contains accounts and notes. +Owned by the main app. Contains accounts, notes and notifications. **user** | Column | Type | Description | @@ -354,6 +384,16 @@ Owned by the main app. Contains accounts and notes. | `is_done` | Boolean | Checked state (checked items render last, struck through) | | `created_at` | DateTime | Creation timestamp (UTC) | +**notification** +| Column | Type | Description | +|---|---|---| +| `id` | Integer | Primary key | +| `user_id` | Integer | Foreign key to `user.id` (recipient) | +| `message` | String(500) | Short notification text | +| `is_read` | Boolean | `false` = new, `true` = read | +| `created_at` | DateTime | Creation timestamp (UTC) | +| `read_at` | DateTime | When the notification was read, or `NULL` | + ### Inspecting the databases on Linux The databases are ordinary SQLite files. Use the `sqlite3` CLI (install it with diff --git a/server/alembic_users/versions/e2b6d8f0a4c1_create_notifications.py b/server/alembic_users/versions/e2b6d8f0a4c1_create_notifications.py new file mode 100644 index 0000000..935858d --- /dev/null +++ b/server/alembic_users/versions/e2b6d8f0a4c1_create_notifications.py @@ -0,0 +1,40 @@ +"""create notifications + +Revision ID: e2b6d8f0a4c1 +Revises: c9e5a1b7d3f2 +Create Date: 2026-11-01 18:55:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "e2b6d8f0a4c1" +down_revision: Union[str, Sequence[str], None] = "c9e5a1b7d3f2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notification", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("message", sa.String(length=500), nullable=False), + sa.Column("is_read", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("read_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_notification_user_id"), "notification", ["user_id"], unique=False + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_notification_user_id"), table_name="notification") + op.drop_table("notification") diff --git a/server/app.py b/server/app.py index eb9954f..26ff492 100644 --- a/server/app.py +++ b/server/app.py @@ -14,7 +14,7 @@ from flask_login import current_user, login_required, login_user, logout_user from auth import init_login_manager from config import load_secret_key, sensors_api_url, users_database_uri from sensors_client import SensorsAPIError, SensorsClient -from user_models import Note, NoteItem, User, user_db +from user_models import Note, NoteItem, Notification, User, user_db def create_app(): @@ -53,8 +53,16 @@ def create_app(): return jsonify(data), status @app.context_processor - def inject_service_urls(): - return {"main_api_url": request.url_root.rstrip("/") + "/api"} + def inject_globals(): + unread_notifications = 0 + if current_user.is_authenticated: + unread_notifications = Notification.query.filter_by( + user_id=current_user.id, is_read=False + ).count() + return { + "main_api_url": request.url_root.rstrip("/") + "/api", + "unread_notifications": unread_notifications, + } # --- Pages --- @@ -103,6 +111,40 @@ def create_app(): def api_docs(): return render_template("docs.html", user=current_user) + # --- Notifications (stored in main_app.db) --- + + @app.route("/notifications") + @login_required + def notifications_page(): + notifications = ( + Notification.query.filter_by(user_id=current_user.id) + .order_by( + Notification.is_read.asc(), + Notification.created_at.desc(), + Notification.id.desc(), + ) + .all() + ) + return render_template( + "notifications.html", user=current_user, notifications=notifications + ) + + @app.route("/notifications//read") + @login_required + def notification_read(notification_id): + notification = Notification.query.filter_by( + id=notification_id, user_id=current_user.id + ).first_or_404() + if not notification.is_read: + notification.mark_read() + user_db.session.commit() + return redirect( + url_for( + "notifications_page", + _anchor=f"notification-{notification.id}", + ) + ) + # --- Notes (stored in main_app.db) --- def _note_or_404(note_id): @@ -287,6 +329,37 @@ def create_app(): user_db.session.commit() click.echo(f"Created administrator '{username}'.") + @app.cli.command("create-notification") + @click.option("--username", default=None, help="Recipient username") + @click.option("--all", "all_users", is_flag=True, help="Send to all users") + @click.option("--message", default=None, help="Notification text") + def create_notification(username, all_users, message): + """Create a notification for one user or for all users.""" + if all_users and username: + raise click.ClickException("Use either --username or --all, not both.") + if not all_users and not username: + raise click.ClickException("Provide --username or --all.") + + message = (message or "").strip() + if not message: + message = click.prompt("Message").strip() + if not message: + raise click.ClickException("Message cannot be empty.") + message = message[:500] + + if all_users: + users = User.query.all() + else: + user = User.query.filter_by(username=username).first() + if not user: + raise click.ClickException(f"User '{username}' not found.") + users = [user] + + for user in users: + user_db.session.add(Notification(user_id=user.id, message=message)) + user_db.session.commit() + click.echo(f"Created {len(users)} notification(s).") + return app diff --git a/server/static/retro.css b/server/static/retro.css index 6cdf284..b2d0610 100644 --- a/server/static/retro.css +++ b/server/static/retro.css @@ -100,6 +100,16 @@ nav a { margin-right: 0.2em; } nav a:hover { background: var(--mc-cyan); color: var(--mc-black); } +.nav-badge { + display: inline-block; + background: var(--mc-yellow); + color: var(--mc-black); + border: 1px solid var(--mc-black); + padding: 0 0.35em; + margin-left: 0.25em; + font-size: 0.8em; + line-height: 1.2; +} /* --- Panels and dialogs --- */ .panel { @@ -393,3 +403,44 @@ textarea { .note-delete-form { margin-top: 0.75rem; } .note-delete-form button { background: var(--mc-red); color: var(--mc-bright); } .note-delete-form button:hover { background: var(--mc-red-bright); } + + +/* --- Notifications --- */ +.notification-list { list-style: none; padding: 0; margin: 1rem 0; } +.notification-item { + border: 2px solid var(--mc-dim); + margin: 0.5rem 0; + background: var(--mc-deep); +} +.notification-item.unread { border-left: 6px solid var(--mc-yellow); } +.notification-item.read { opacity: 0.78; } +.notification-item a { + display: block; + padding: 0.5rem 0.7rem; + color: var(--mc-fg); + text-decoration: none; +} +.notification-item a:hover { + background: var(--mc-cyan); + color: var(--mc-black); +} +.notification-message { display: block; margin-bottom: 0.25rem; } +.notification-meta { + display: flex; + align-items: center; + gap: 0.75em; + font-size: 0.8em; + color: var(--mc-dim); +} +.notification-item a:hover .notification-meta { color: var(--mc-black); } +.notification-status { + text-transform: uppercase; + letter-spacing: 1px; + padding: 0 0.45em; + background: var(--mc-dim); + color: var(--mc-black); +} +.notification-item.unread .notification-status { + background: var(--mc-yellow); + color: var(--mc-black); +} diff --git a/server/templates/docs.html b/server/templates/docs.html index 6d93348..6c6ef0b 100644 --- a/server/templates/docs.html +++ b/server/templates/docs.html @@ -13,6 +13,8 @@ Home Sensors Notes + Notifications{% if unread_notifications %} {{ unread_notifications }}{% endif %} + API Log out diff --git a/server/templates/home.html b/server/templates/home.html index 38761b5..a04206c 100644 --- a/server/templates/home.html +++ b/server/templates/home.html @@ -13,6 +13,7 @@ Home Sensors Notes + Notifications{% if unread_notifications %} {{ unread_notifications }}{% endif %} API Log out @@ -24,10 +25,11 @@ -

Tip: press 1, 2, 3 or 0 to navigate.

+

Tip: press 1, 2, 3, 4 or 0 to navigate.