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: Two Flask services with separate responsibilities and separate databases:
- **main app** (`app.py`) — all user-facing views: login, home, API docs and - **main app** (`app.py`) — all user-facing views: login, home, notes, API docs
the `/sensors` dashboard. It owns the **users** database (`main_app.db`) and and the `/sensors` dashboard. It owns the **main app** database
proxies data requests to the sensors API. (`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** - **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.
@@ -28,13 +28,13 @@ browser ──> main app :8000 ──HTTP──> sensors API :8001
``` ```
server/ 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_app.py # Sensors app: API + sensors database
├── sensors_client.py # HTTP client used by the main app ├── sensors_client.py # HTTP client used by the main app
├── 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 (main_app.db) ├── user_models.py # User, Note, NoteItem (main_app.db)
├── 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)
@@ -51,6 +51,7 @@ server/
└── templates/ └── templates/
├── home.html ├── home.html
├── login.html ├── login.html
├── notes.html
├── docs.html ├── docs.html
└── sensors.html └── sensors.html
``` ```
@@ -101,11 +102,11 @@ After verifying the new databases you can archive or remove `data.db`.
## Users ## Users
User accounts live only in `main_app.db` (main app). There is no public User accounts and notes live only in `main_app.db` (main app). There is no
registration; accounts are created by an administrator. public registration; accounts are created by an administrator.
Main app pages (`/`, `/home`, `/api/docs`, `/sensors`) and dashboard read Main app pages (`/`, `/home`, `/notes`, `/api/docs`, `/sensors`) and dashboard
endpoints (`GET /api/sensors/:id/types`, `/readouts`, `/readouts/hourly`) read endpoints (`GET /api/sensors/:id/types`, `/readouts`, `/readouts/hourly`)
require login. Device endpoints (`GET/POST /api/sensors`, 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.
@@ -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 The service does not need to be restarted; the new account is used on the next
login. 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 ## 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.
@@ -270,7 +286,7 @@ deployment path or user is different.
| Service | Port | Routes | | 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) | | sensors | 8001 | `/api/*` (sensors and readouts) |
### Tests ### Tests
@@ -301,9 +317,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) ### main_app.db (users and notes)
Owned by the main app. Contains only accounts. Owned by the main app. Contains accounts and notes.
**user** **user**
| Column | Type | Description | | Column | Type | Description |
@@ -314,6 +330,25 @@ Owned by the main app. Contains only accounts.
| `is_admin` | Boolean | Administrator flag | | `is_admin` | Boolean | Administrator flag |
| `created_at` | DateTime | Creation timestamp (UTC) | | `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 ### 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,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 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 User, user_db from user_models import Note, NoteItem, User, user_db
def create_app(): def create_app():
@@ -103,6 +103,101 @@ 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)
# --- 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 --- # --- API: proxied to the sensors service ---
# The browser only talks to this app; the sensors service owns the # The browser only talks to this app; the sensors service owns the
# sensor database and the actual API implementation. # 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. */ /* Hide the native thin caret only when the JS block takes over. */
input.js-caret { caret-color: transparent; } input.js-caret { caret-color: transparent; }
input, select { input, select, textarea {
font: inherit; font: inherit;
background: var(--mc-black); background: var(--mc-black);
color: var(--mc-bright); color: var(--mc-bright);
@@ -163,7 +163,7 @@ input, select {
padding: 0.3em 0.5em; padding: 0.3em 0.5em;
width: 100%; width: 100%;
} }
input:focus, select:focus { input:focus, select:focus, textarea:focus {
outline: none; outline: none;
border-color: var(--mc-yellow); border-color: var(--mc-yellow);
} }
@@ -313,3 +313,67 @@ canvas {
border: 3px double var(--mc-bright); border: 3px double var(--mc-bright);
padding: 0.5rem; 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> <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('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</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
@@ -12,6 +12,7 @@
<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('sensors_page') }}">Sensors</a>
<a href="{{ url_for('notes_page') }}">Notes</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">
@@ -22,10 +23,11 @@
<p>Select an item from the menu:</p> <p>Select an item from the menu:</p>
<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('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> <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> 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> </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">
@@ -40,7 +42,8 @@
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag)) return; if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag)) return;
const targets = { const targets = {
'1': "{{ url_for('sensors_page') }}", '1': "{{ url_for('sensors_page') }}",
'2': "{{ url_for('api_docs') }}", '2': "{{ url_for('notes_page') }}",
'3': "{{ 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];
+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> <body>
<nav> <nav>
<a href="{{ url_for('home') }}">Home</a> <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('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">
+72
View File
@@ -167,6 +167,7 @@ class MainAppTest(unittest.TestCase):
self.assertEqual(self.login().status_code, 302) self.assertEqual(self.login().status_code, 302)
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("/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):
@@ -195,6 +196,77 @@ class MainAppTest(unittest.TestCase):
self.assertEqual(hourly.status_code, 200) self.assertEqual(hourly.status_code, 200)
self.assertEqual(len(hourly.get_json()), 1) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+56
View File
@@ -2,6 +2,7 @@ from datetime import datetime, timezone
from flask_login import UserMixin from flask_login import UserMixin
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import relationship
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
user_db = SQLAlchemy() user_db = SQLAlchemy()
@@ -23,3 +24,58 @@ class User(UserMixin, user_db.Model):
def check_password(self, password: str) -> bool: def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password) 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")