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