notyfikacje

This commit is contained in:
2026-11-01 20:27:10 +01:00
parent e55af30644
commit b77b348d54
11 changed files with 375 additions and 18 deletions
+52 -12
View File
@@ -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
@@ -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")
+76 -3
View File
@@ -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/<int:notification_id>/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
+51
View File
@@ -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);
}
+2
View File
@@ -13,6 +13,8 @@
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
</nav>
+6 -3
View File
@@ -13,6 +13,7 @@
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
@@ -24,10 +25,11 @@
<ul class="mc-menu">
<li><a href="{{ url_for('sensors_page') }}"><span class="mc-key">1</span>Sensors — charts and readouts</a></li>
<li><a href="{{ url_for('notes_page') }}"><span class="mc-key">2</span>Notes — title, text and checklists</a></li>
<li><a href="{{ url_for('api_docs') }}"><span class="mc-key">3</span>API — documentation</a></li>
<li><a href="{{ url_for('notifications_page') }}"><span class="mc-key">3</span>Notifications{% if unread_notifications %} ({{ unread_notifications }} new){% endif %}</a></li>
<li><a href="{{ url_for('api_docs') }}"><span class="mc-key">4</span>API — documentation</a></li>
<li><a href="{{ url_for('logout') }}"><span class="mc-key">0</span>Log out</a></li>
</ul>
<p class="hint">Tip: press <b>1</b>, <b>2</b>, <b>3</b> or <b>0</b> to navigate.</p>
<p class="hint">Tip: press <b>1</b>, <b>2</b>, <b>3</b>, <b>4</b> or <b>0</b> to navigate.</p>
</div>
<img class="logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
<footer class="statusbar">
@@ -43,7 +45,8 @@
const targets = {
'1': "{{ url_for('sensors_page') }}",
'2': "{{ url_for('notes_page') }}",
'3': "{{ url_for('api_docs') }}",
'3': "{{ url_for('notifications_page') }}",
'4': "{{ url_for('api_docs') }}",
'0': "{{ url_for('logout') }}"
};
if (targets[e.key]) window.location.href = targets[e.key];
+1
View File
@@ -13,6 +13,7 @@
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notifications — Yggdrasil</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
</head>
<body>
<nav>
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
</nav>
<h1>Notifications</h1>
<p class="hint">
{% if unread_notifications %}
{{ unread_notifications }} new notification{{ "s" if unread_notifications != 1 else "" }}.
{% else %}
No new notifications.
{% endif %}
</p>
{% if not notifications %}
<p class="hint">There are no notifications yet.</p>
{% else %}
<ul class="notification-list">
{% for notification in notifications %}
<li id="notification-{{ notification.id }}"
class="notification-item {{ notification.status }}">
<a href="{{ url_for('notification_read', notification_id=notification.id) }}"
title="{{ 'Mark as read' if notification.status == 'new' else 'Read notification' }}">
<span class="notification-message">{{ notification.message }}</span>
<span class="notification-meta">
<span class="notification-status">{{ notification.status }}</span>
<span>{{ notification.created_at.strftime("%Y-%m-%d %H:%M") }}</span>
</span>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<footer class="statusbar">
<span>{{ user.username }}@yggdrasil:~/notifications$</span>
</footer>
</body>
</html>
+2
View File
@@ -12,7 +12,9 @@
<body>
<nav>
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
<a href="{{ url_for('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
+66
View File
@@ -168,6 +168,7 @@ class MainAppTest(unittest.TestCase):
self.assertEqual(self.client.get("/home").status_code, 200)
self.assertEqual(self.client.get("/sensors").status_code, 200)
self.assertEqual(self.client.get("/notes").status_code, 200)
self.assertEqual(self.client.get("/notifications").status_code, 200)
self.assertEqual(self.client.get("/api/docs").status_code, 200)
def test_public_device_api_is_proxied_to_sensors(self):
@@ -275,6 +276,71 @@ class MainAppTest(unittest.TestCase):
["second item", "renamed first item"],
)
def test_notifications_badge_and_mark_read(self):
from user_models import Notification, User, user_db
self.login()
with self.main_app.app_context():
user = User.query.filter_by(username="tester").one()
other = User(username="other-user")
other.set_password("secret")
user_db.session.add(other)
user_db.session.flush()
first = Notification(user_id=user.id, message="first notification")
second = Notification(user_id=user.id, message="second notification")
foreign = Notification(user_id=other.id, message="other user notification")
user_db.session.add_all([first, second, foreign])
user_db.session.commit()
first_id = first.id
foreign_id = foreign.id
home = self.client.get("/home").get_data(as_text=True)
self.assertIn('nav-badge">2</span>', home)
page = self.client.get("/notifications")
html = page.get_data(as_text=True)
self.assertEqual(page.status_code, 200)
self.assertIn("first notification", html)
self.assertIn("second notification", html)
self.assertNotIn("other user notification", html)
self.assertEqual(html.count('class="notification-item new"'), 2)
read = self.client.get(f"/notifications/{first_id}/read")
self.assertEqual(read.status_code, 302)
with self.main_app.app_context():
user_db.session.expire_all()
notification = user_db.session.get(Notification, first_id)
self.assertTrue(notification.is_read)
self.assertIsNotNone(notification.read_at)
page = self.client.get("/notifications")
html = page.get_data(as_text=True)
self.assertIn("1 new notification.", html)
self.assertIn('nav-badge">1</span>', html)
self.assertEqual(html.count('class="notification-item read"'), 1)
self.assertEqual(html.count('class="notification-item new"'), 1)
self.assertEqual(
self.client.get(f"/notifications/{foreign_id}/read").status_code, 404
)
def test_navigation_links_are_present_on_all_pages(self):
self.login()
links = (
'href="/home"',
'href="/sensors"',
'href="/notes"',
'href="/notifications"',
'href="/api/docs"',
'href="/logout"',
)
for path in ("/home", "/sensors", "/notes", "/notifications", "/api/docs"):
html = self.client.get(path).get_data(as_text=True)
for link in links:
self.assertIn(link, html, f"{link} missing on {path}")
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -79,3 +79,27 @@ class NoteItem(user_db.Model):
)
note = relationship("Note", back_populates="items")
class Notification(user_db.Model):
__tablename__ = "notification"
id = user_db.Column(user_db.Integer, primary_key=True)
user_id = user_db.Column(
user_db.Integer, user_db.ForeignKey("user.id"), nullable=False, index=True
)
message = user_db.Column(user_db.String(500), nullable=False)
is_read = user_db.Column(user_db.Boolean, nullable=False, default=False)
created_at = user_db.Column(
user_db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
)
read_at = user_db.Column(user_db.DateTime, nullable=True)
@property
def status(self) -> str:
return "read" if self.is_read else "new"
def mark_read(self) -> None:
if not self.is_read:
self.is_read = True
self.read_at = datetime.now(timezone.utc)