notyfikacje
This commit is contained in:
+52
-12
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user