This commit is contained in:
2026-11-01 17:17:58 +01:00
parent dc9d5a750e
commit 7675acd81e
11 changed files with 528 additions and 18 deletions
+47 -12
View File
@@ -2,9 +2,9 @@
Two Flask services with separate responsibilities and separate databases:
- **main app** (`app.py`) — all user-facing views: login, home, API docs and
the `/sensors` dashboard. It owns the **users** database (`main_app.db`) and
proxies data requests to the sensors API.
- **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.
- **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.
@@ -28,13 +28,13 @@ browser ──> main app :8000 ──HTTP──> sensors API :8001
```
server/
├── app.py # Main app: views + API proxy
├── app.py # Main app: views, notes + API proxy
├── sensors_app.py # Sensors app: API + sensors database
├── sensors_client.py # HTTP client used by the main app
├── auth.py # Flask-Login setup
├── config.py # Config (databases, secret key, sensors URL)
├── models.py # Sensor, SensorReadout (sensors DB)
├── user_models.py # User (main_app.db)
├── user_models.py # User, Note, NoteItem (main_app.db)
├── seed.py # Sample sensor data
├── migrate_legacy_db.py # Import old combined data.db into split DBs
├── run.sh # Development launcher (main/sensors + port)
@@ -51,6 +51,7 @@ server/
└── templates/
├── home.html
├── login.html
├── notes.html
├── docs.html
└── sensors.html
```
@@ -101,11 +102,11 @@ After verifying the new databases you can archive or remove `data.db`.
## Users
User accounts live only in `main_app.db` (main app). There is no public
registration; accounts are created by an administrator.
User accounts and notes live only in `main_app.db` (main app). There is no
public registration; accounts are created by an administrator.
Main app pages (`/`, `/home`, `/api/docs`, `/sensors`) and dashboard read
endpoints (`GET /api/sensors/:id/types`, `/readouts`, `/readouts/hourly`)
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`,
`POST /api/sensors/:id/readouts`) stay open so devices can push data without a
browser session.
@@ -161,6 +162,21 @@ sudo -u mateusz env USERS_DB_PATH=/data/main_app.db \
The service does not need to be restarted; the new account is used on the next
login.
## Notes
Logged-in users manage their own private short notes at `/notes`. Each note has
a title, a text body and an optional checklist. Unchecked items are shown first;
when an item is checked it is saved as done, moves to the bottom of the checklist
and is displayed with a line through it.
Notes are stored in `main_app.db` in the `note` and `note_item` tables. 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.
@@ -270,7 +286,7 @@ deployment path or user is different.
| Service | Port | Routes |
|---|---|---|
| main | 8000 | `/`, `/login`, `/home`, `/sensors`, `/api/docs`, `/api/*` (proxy) |
| main | 8000 | `/`, `/login`, `/home`, `/notes`, `/sensors`, `/api/docs`, `/api/*` (proxy) |
| sensors | 8001 | `/api/*` (sensors and readouts) |
### Tests
@@ -301,9 +317,9 @@ Owned by the sensors app. Contains only sensor data.
| `value` | Float | Measured value |
| `timestamp` | DateTime | Readout timestamp (UTC) |
### main_app.db (users)
### main_app.db (users and notes)
Owned by the main app. Contains only accounts.
Owned by the main app. Contains accounts and notes.
**user**
| Column | Type | Description |
@@ -314,6 +330,25 @@ Owned by the main app. Contains only accounts.
| `is_admin` | Boolean | Administrator flag |
| `created_at` | DateTime | Creation timestamp (UTC) |
**note**
| Column | Type | Description |
|---|---|---|
| `id` | Integer | Primary key |
| `user_id` | Integer | Foreign key to `user.id` (note owner) |
| `title` | String(200) | Note title (existing notes get `Untitled`) |
| `content` | Text | Note body |
| `created_at` | DateTime | Creation timestamp (UTC) |
| `updated_at` | DateTime | Last modification timestamp (UTC) |
**note_item**
| Column | Type | Description |
|---|---|---|
| `id` | Integer | Primary key |
| `note_id` | Integer | Foreign key to `note.id` |
| `text` | String(255) | Checklist item text |
| `is_done` | Boolean | Checked state (checked items render last, struck through) |
| `created_at` | DateTime | Creation timestamp (UTC) |
### Inspecting the databases on Linux
The databases are ordinary SQLite files. Use the `sqlite3` CLI (install it with
@@ -0,0 +1,53 @@
"""create notes
Revision ID: b7d4f1a2c3e5
Revises: 1a7c3e5d9b21
Create Date: 2026-11-01 17:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "b7d4f1a2c3e5"
down_revision: Union[str, Sequence[str], None] = "1a7c3e5d9b21"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"note",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_note_user_id"), "note", ["user_id"], unique=False)
op.create_table(
"note_item",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("note_id", sa.Integer(), nullable=False),
sa.Column("text", sa.String(length=255), nullable=False),
sa.Column("is_done", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["note_id"], ["note.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_note_item_note_id"), "note_item", ["note_id"], unique=False
)
def downgrade() -> None:
op.drop_index(op.f("ix_note_item_note_id"), table_name="note_item")
op.drop_table("note_item")
op.drop_index(op.f("ix_note_user_id"), table_name="note")
op.drop_table("note")
@@ -0,0 +1,35 @@
"""add note title
Revision ID: c9e5a1b7d3f2
Revises: b7d4f1a2c3e5
Create Date: 2026-11-01 17:45:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "c9e5a1b7d3f2"
down_revision: Union[str, Sequence[str], None] = "b7d4f1a2c3e5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"note",
sa.Column(
"title",
sa.String(length=200),
nullable=False,
server_default="Untitled",
),
)
def downgrade() -> None:
with op.batch_alter_table("note") as batch_op:
batch_op.drop_column("title")
+96 -1
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 User, user_db
from user_models import Note, NoteItem, User, user_db
def create_app():
@@ -103,6 +103,101 @@ def create_app():
def api_docs():
return render_template("docs.html", user=current_user)
# --- Notes (stored in main_app.db) ---
def _note_or_404(note_id):
return Note.query.filter_by(
id=note_id, user_id=current_user.id
).first_or_404()
def _note_redirect(note_id):
return redirect(
url_for("notes_page", _anchor=f"note-{note_id}")
)
@app.route("/notes")
@login_required
def notes_page():
notes = (
Note.query.filter_by(user_id=current_user.id)
.order_by(Note.updated_at.desc(), Note.id.desc())
.all()
)
return render_template("notes.html", user=current_user, notes=notes)
@app.route("/notes", methods=["POST"])
@login_required
def notes_create():
title = request.form.get("title", "").strip()
content = request.form.get("content", "").strip()
if not title or not content:
return redirect(url_for("notes_page"))
note = Note(
user_id=current_user.id,
title=title[:200],
content=content[:2000],
)
user_db.session.add(note)
user_db.session.commit()
return _note_redirect(note.id)
@app.route("/notes/<int:note_id>", methods=["POST"])
@login_required
def notes_update(note_id):
note = _note_or_404(note_id)
title = request.form.get("title", "").strip()
content = request.form.get("content", "").strip()
if title and content:
note.title = title[:200]
note.content = content[:2000]
note.touch()
user_db.session.commit()
return _note_redirect(note.id)
@app.route("/notes/<int:note_id>/delete", methods=["POST"])
@login_required
def notes_delete(note_id):
note = _note_or_404(note_id)
user_db.session.delete(note)
user_db.session.commit()
return redirect(url_for("notes_page"))
@app.route("/notes/<int:note_id>/items", methods=["POST"])
@login_required
def notes_add_item(note_id):
note = _note_or_404(note_id)
text = request.form.get("text", "").strip()
if text:
item = NoteItem(note=note, text=text[:255])
note.touch()
user_db.session.add(item)
user_db.session.commit()
return _note_redirect(note.id)
@app.route(
"/notes/<int:note_id>/items/<int:item_id>/toggle", methods=["POST"]
)
@login_required
def notes_toggle_item(note_id, item_id):
note = _note_or_404(note_id)
item = NoteItem.query.filter_by(id=item_id, note_id=note.id).first_or_404()
item.is_done = request.form.get("is_done") in {"1", "true", "on", "yes"}
note.touch()
user_db.session.commit()
return _note_redirect(note.id)
@app.route(
"/notes/<int:note_id>/items/<int:item_id>/delete", methods=["POST"]
)
@login_required
def notes_delete_item(note_id, item_id):
note = _note_or_404(note_id)
item = NoteItem.query.filter_by(id=item_id, note_id=note.id).first_or_404()
user_db.session.delete(item)
note.touch()
user_db.session.commit()
return _note_redirect(note.id)
# --- API: proxied to the sensors service ---
# The browser only talks to this app; the sensors service owns the
# sensor database and the actual API implementation.
+66 -2
View File
@@ -155,7 +155,7 @@ label { color: var(--mc-bright); letter-spacing: 1px; }
/* Hide the native thin caret only when the JS block takes over. */
input.js-caret { caret-color: transparent; }
input, select {
input, select, textarea {
font: inherit;
background: var(--mc-black);
color: var(--mc-bright);
@@ -163,7 +163,7 @@ input, select {
padding: 0.3em 0.5em;
width: 100%;
}
input:focus, select:focus {
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--mc-yellow);
}
@@ -313,3 +313,67 @@ canvas {
border: 3px double var(--mc-bright);
padding: 0.5rem;
}
/* --- Notes --- */
textarea {
resize: vertical;
min-height: 4.5em;
}
.note-card {
background: var(--mc-deep);
border: 3px double var(--mc-bright);
box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.45);
padding: 1rem 1.2rem;
margin-top: 1rem;
}
.note-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.note-meta { color: var(--mc-dim); font-size: 0.8em; }
.checklist { list-style: none; padding: 0; margin: 1rem 0; }
.checklist li {
display: flex;
align-items: center;
gap: 0.5em;
border-bottom: 1px dashed var(--mc-dim);
padding: 0.15em 0;
}
.check-form {
flex: 1;
flex-direction: row;
align-items: center;
gap: 0.6em;
margin: 0;
}
.check-form input[type="checkbox"] {
width: auto;
margin: 0;
accent-color: var(--mc-yellow);
}
.item-text { flex: 1; overflow-wrap: anywhere; }
.checklist li.done .item-text {
text-decoration: line-through;
color: var(--mc-dim);
}
.item-delete-form { margin: 0; }
.item-delete-form button {
background: transparent;
color: var(--mc-red-bright);
box-shadow: none;
padding: 0.1em 0.4em;
letter-spacing: 0;
}
.item-delete-form button:hover { background: var(--mc-red); color: var(--mc-bright); }
.add-item-form {
flex-direction: row;
align-items: center;
gap: 0.5em;
}
.add-item-form input { flex: 1; }
.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); }
+1
View File
@@ -12,6 +12,7 @@
<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('logout') }}">Log out</a>
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
</nav>
+6 -3
View File
@@ -12,6 +12,7 @@
<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('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">
@@ -22,10 +23,11 @@
<p>Select an item from the menu:</p>
<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('api_docs') }}"><span class="mc-key">2</span>API — documentation</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('logout') }}"><span class="mc-key">0</span>Log out</a></li>
</ul>
<p class="hint">Tip: press <b>1</b>, <b>2</b> or <b>0</b> to navigate.</p>
<p class="hint">Tip: press <b>1</b>, <b>2</b>, <b>3</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">
@@ -40,7 +42,8 @@
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag)) return;
const targets = {
'1': "{{ url_for('sensors_page') }}",
'2': "{{ url_for('api_docs') }}",
'2': "{{ url_for('notes_page') }}",
'3': "{{ url_for('api_docs') }}",
'0': "{{ url_for('logout') }}"
};
if (targets[e.key]) window.location.href = targets[e.key];
+95
View File
@@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes — 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('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>Notes</h1>
<div class="panel">
<h2>New note</h2>
<form method="post" action="{{ url_for('notes_create') }}">
<label for="new-note-title">Title</label>
<input type="text" id="new-note-title" name="title" maxlength="200"
placeholder="Note title" required>
<label for="new-note-content">Text</label>
<textarea id="new-note-content" name="content" rows="3" maxlength="2000"
placeholder="Write a short note..." required></textarea>
<button type="submit">[ Add note ]</button>
</form>
</div>
{% if not notes %}
<p class="hint">No notes yet. Add your first note above.</p>
{% endif %}
{% for note in notes %}
<section class="note-card" id="note-{{ note.id }}">
<form method="post" action="{{ url_for('notes_update', note_id=note.id) }}">
<label for="note-title-{{ note.id }}">Title</label>
<input type="text" id="note-title-{{ note.id }}" name="title"
value="{{ note.title }}" maxlength="200" required>
<label for="note-content-{{ note.id }}">Text</label>
<textarea id="note-content-{{ note.id }}" name="content" rows="2"
maxlength="2000" required>{{ note.content }}</textarea>
<div class="note-actions">
<button type="submit">[ Save ]</button>
<span class="note-meta">{{ note.updated_at.strftime("%Y-%m-%d %H:%M") }}</span>
</div>
</form>
<ul class="checklist">
{% for item in note.ordered_items %}
<li class="{{ 'done' if item.is_done else '' }}">
<form method="post"
action="{{ url_for('notes_toggle_item', note_id=note.id, item_id=item.id) }}"
class="check-form">
<input type="checkbox" name="is_done" value="1"
{% if item.is_done %}checked{% endif %}
onchange="this.form.submit()"
aria-label="Mark '{{ item.text }}' as done">
<span class="item-text">{{ item.text }}</span>
<noscript><button type="submit">[ Update ]</button></noscript>
</form>
<form method="post"
action="{{ url_for('notes_delete_item', note_id=note.id, item_id=item.id) }}"
class="item-delete-form">
<button type="submit" title="Delete checklist item"
aria-label="Delete checklist item">[x]</button>
</form>
</li>
{% endfor %}
</ul>
<form method="post" action="{{ url_for('notes_add_item', note_id=note.id) }}"
class="add-item-form">
<input type="text" name="text" maxlength="255" required
placeholder="New checklist item">
<button type="submit">[ Add item ]</button>
</form>
<form method="post" action="{{ url_for('notes_delete', note_id=note.id) }}"
class="note-delete-form">
<button type="submit">[ Delete note ]</button>
</form>
</section>
{% endfor %}
<footer class="statusbar">
<span>{{ user.username }}@yggdrasil:~/notes$</span>
</footer>
</body>
</html>
+1
View File
@@ -12,6 +12,7 @@
<body>
<nav>
<a href="{{ url_for('home') }}">Home</a>
<a href="{{ url_for('notes_page') }}">Notes</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">
+72
View File
@@ -167,6 +167,7 @@ class MainAppTest(unittest.TestCase):
self.assertEqual(self.login().status_code, 302)
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("/api/docs").status_code, 200)
def test_public_device_api_is_proxied_to_sensors(self):
@@ -195,6 +196,77 @@ class MainAppTest(unittest.TestCase):
self.assertEqual(hourly.status_code, 200)
self.assertEqual(len(hourly.get_json()), 1)
def test_notes_require_login(self):
response = self.client.get("/notes")
self.assertEqual(response.status_code, 302)
self.assertIn("/login", response.headers["Location"])
def test_notes_and_checklist_are_stored_in_main_db(self):
from user_models import Note, NoteItem, User, user_db
self.login()
created = self.client.post(
"/notes", data={"title": "Test title", "content": "My short note"}
)
self.assertEqual(created.status_code, 302)
with self.main_app.app_context():
user = User.query.filter_by(username="tester").one()
note = Note.query.filter_by(user_id=user.id).one()
note_id = note.id
self.assertEqual(note.title, "Test title")
self.assertEqual(note.content, "My short note")
tables = set(inspect(user_db.engine).get_table_names())
self.assertIn("note", tables)
self.assertIn("note_item", tables)
self.assertEqual(
self.client.post(
f"/notes/{note_id}/items", data={"text": "first item"}
).status_code,
302,
)
self.assertEqual(
self.client.post(
f"/notes/{note_id}/items", data={"text": "second item"}
).status_code,
302,
)
with self.main_app.app_context():
first, second = (
NoteItem.query.filter_by(note_id=note_id).order_by(NoteItem.id).all()
)
first_id = first.id
self.assertEqual(
self.client.post(
f"/notes/{note_id}/items/{first_id}/toggle",
data={"is_done": "1"},
).status_code,
302,
)
page = self.client.get("/notes")
html = page.get_data(as_text=True)
self.assertEqual(page.status_code, 200)
self.assertIn('class="done"', html)
self.assertIn('value="Test title"', html)
self.assertLess(html.index("second item"), html.index("first item"))
with self.main_app.app_context():
user_db.session.expire_all()
items = (
NoteItem.query.filter_by(note_id=note_id).order_by(NoteItem.id).all()
)
self.assertTrue(items[0].is_done)
self.assertFalse(items[1].is_done)
self.assertEqual(
[item.text for item in items[0].note.ordered_items],
["second item", "first item"],
)
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -2,6 +2,7 @@ from datetime import datetime, timezone
from flask_login import UserMixin
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import relationship
from werkzeug.security import check_password_hash, generate_password_hash
user_db = SQLAlchemy()
@@ -23,3 +24,58 @@ class User(UserMixin, user_db.Model):
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
class Note(user_db.Model):
__tablename__ = "note"
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
)
title = user_db.Column(
user_db.String(200), nullable=False, server_default="Untitled"
)
content = user_db.Column(user_db.Text, nullable=False)
created_at = user_db.Column(
user_db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
)
updated_at = user_db.Column(
user_db.DateTime,
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
items = relationship(
"NoteItem",
back_populates="note",
cascade="all, delete-orphan",
)
@property
def ordered_items(self):
"""Unchecked items first, checked items last; stable within each group."""
return sorted(self.items, key=lambda item: (item.is_done, item.id or 0))
def touch(self) -> None:
self.updated_at = datetime.now(timezone.utc)
class NoteItem(user_db.Model):
__tablename__ = "note_item"
id = user_db.Column(user_db.Integer, primary_key=True)
note_id = user_db.Column(
user_db.Integer,
user_db.ForeignKey("note.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
text = user_db.Column(user_db.String(255), nullable=False)
is_done = 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)
)
note = relationship("Note", back_populates="items")