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: Two Flask services with separate responsibilities and separate databases:
- **main app** (`app.py`) — all user-facing views: login, home, notes, API docs - **main app** (`app.py`) — all user-facing views: login, home, notes,
and the `/sensors` dashboard. It owns the **main app** database notifications, API docs and the `/sensors` dashboard. It owns the **main app**
(`main_app.db`: users and notes) and proxies data requests to the sensors API. 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** - **sensors app** (`sensors_app.py`) — API only. It owns the **sensors**
database (`sensors.db`, tables `sensor` and `sensor_readout`) and has no 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. knowledge of users. Its API is open to every user on the local machine.
@@ -34,7 +35,7 @@ server/
├── auth.py # Flask-Login setup ├── auth.py # Flask-Login setup
├── config.py # Config (databases, secret key, sensors URL) ├── config.py # Config (databases, secret key, sensors URL)
├── models.py # Sensor, SensorReadout (sensors DB) ├── 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 ├── seed.py # Sample sensor data
├── migrate_legacy_db.py # Import old combined data.db into split DBs ├── migrate_legacy_db.py # Import old combined data.db into split DBs
├── run.sh # Development launcher (main/sensors + port) ├── run.sh # Development launcher (main/sensors + port)
@@ -52,6 +53,7 @@ server/
├── home.html ├── home.html
├── login.html ├── login.html
├── notes.html ├── notes.html
├── notifications.html
├── docs.html ├── docs.html
└── sensors.html └── sensors.html
``` ```
@@ -102,12 +104,12 @@ After verifying the new databases you can archive or remove `data.db`.
## Users ## Users
User accounts and notes live only in `main_app.db` (main app). There is no User accounts, notes and notifications live only in `main_app.db` (main app).
public registration; accounts are created by an administrator. There is no public registration; accounts are created by an administrator.
Main app pages (`/`, `/home`, `/notes`, `/api/docs`, `/sensors`) and dashboard Main app pages (`/`, `/home`, `/notes`, `/notifications`, `/api/docs`,
read endpoints (`GET /api/sensors/:id/types`, `/readouts`, `/readouts/hourly`) `/sensors`) and dashboard read endpoints (`GET /api/sensors/:id/types`,
require login. Device endpoints (`GET/POST /api/sensors`, `/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 `POST /api/sensors/:id/readouts`) stay open so devices can push data without a
browser session. browser session.
@@ -178,6 +180,34 @@ automatically):
alembic -c alembic_users.ini upgrade head 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 ## Running
The first argument selects the app (`main` or `sensors`), the second the port. 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 | | 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) | | sensors | 8001 | `/api/*` (sensors and readouts) |
### Tests ### Tests
@@ -322,9 +352,9 @@ Owned by the sensors app. Contains only sensor data.
| `value` | Float | Measured value | | `value` | Float | Measured value |
| `timestamp` | DateTime | Readout timestamp (UTC) | | `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** **user**
| Column | Type | Description | | 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) | | `is_done` | Boolean | Checked state (checked items render last, struck through) |
| `created_at` | DateTime | Creation timestamp (UTC) | | `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 ### Inspecting the databases on Linux
The databases are ordinary SQLite files. Use the `sqlite3` CLI (install it with 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 auth import init_login_manager
from config import load_secret_key, sensors_api_url, users_database_uri from config import load_secret_key, sensors_api_url, users_database_uri
from sensors_client import SensorsAPIError, SensorsClient 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(): def create_app():
@@ -53,8 +53,16 @@ def create_app():
return jsonify(data), status return jsonify(data), status
@app.context_processor @app.context_processor
def inject_service_urls(): def inject_globals():
return {"main_api_url": request.url_root.rstrip("/") + "/api"} 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 --- # --- Pages ---
@@ -103,6 +111,40 @@ def create_app():
def api_docs(): def api_docs():
return render_template("docs.html", user=current_user) 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) --- # --- Notes (stored in main_app.db) ---
def _note_or_404(note_id): def _note_or_404(note_id):
@@ -287,6 +329,37 @@ def create_app():
user_db.session.commit() user_db.session.commit()
click.echo(f"Created administrator '{username}'.") 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 return app
+51
View File
@@ -100,6 +100,16 @@ nav a {
margin-right: 0.2em; margin-right: 0.2em;
} }
nav a:hover { background: var(--mc-cyan); color: var(--mc-black); } 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 --- */ /* --- Panels and dialogs --- */
.panel { .panel {
@@ -393,3 +403,44 @@ textarea {
.note-delete-form { margin-top: 0.75rem; } .note-delete-form { margin-top: 0.75rem; }
.note-delete-form button { background: var(--mc-red); color: var(--mc-bright); } .note-delete-form button { background: var(--mc-red); color: var(--mc-bright); }
.note-delete-form button:hover { background: var(--mc-red-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('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a> <a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</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> <a href="{{ url_for('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo"> <img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
</nav> </nav>
+6 -3
View File
@@ -13,6 +13,7 @@
<a href="{{ url_for('home') }}">Home</a> <a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a> <a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</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('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a> <a href="{{ url_for('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo"> <img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
@@ -24,10 +25,11 @@
<ul class="mc-menu"> <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('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('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> <li><a href="{{ url_for('logout') }}"><span class="mc-key">0</span>Log out</a></li>
</ul> </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> </div>
<img class="logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo"> <img class="logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
<footer class="statusbar"> <footer class="statusbar">
@@ -43,7 +45,8 @@
const targets = { const targets = {
'1': "{{ url_for('sensors_page') }}", '1': "{{ url_for('sensors_page') }}",
'2': "{{ url_for('notes_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') }}" '0': "{{ url_for('logout') }}"
}; };
if (targets[e.key]) window.location.href = targets[e.key]; 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('home') }}">Home</a>
<a href="{{ url_for('sensors_page') }}">Sensors</a> <a href="{{ url_for('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</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('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a> <a href="{{ url_for('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo"> <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> <body>
<nav> <nav>
<a href="{{ url_for('home') }}">Home</a> <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('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('api_docs') }}">API</a>
<a href="{{ url_for('logout') }}">Log out</a> <a href="{{ url_for('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo"> <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("/home").status_code, 200)
self.assertEqual(self.client.get("/sensors").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("/notes").status_code, 200)
self.assertEqual(self.client.get("/notifications").status_code, 200)
self.assertEqual(self.client.get("/api/docs").status_code, 200) self.assertEqual(self.client.get("/api/docs").status_code, 200)
def test_public_device_api_is_proxied_to_sensors(self): def test_public_device_api_is_proxied_to_sensors(self):
@@ -275,6 +276,71 @@ class MainAppTest(unittest.TestCase):
["second item", "renamed first item"], ["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__": if __name__ == "__main__":
unittest.main() unittest.main()
+24
View File
@@ -79,3 +79,27 @@ class NoteItem(user_db.Model):
) )
note = relationship("Note", back_populates="items") 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)