diff --git a/server/README.md b/server/README.md index fb8d596..07e609e 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/alembic_users/versions/b7d4f1a2c3e5_create_notes.py b/server/alembic_users/versions/b7d4f1a2c3e5_create_notes.py new file mode 100644 index 0000000..63aa1a0 --- /dev/null +++ b/server/alembic_users/versions/b7d4f1a2c3e5_create_notes.py @@ -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") diff --git a/server/alembic_users/versions/c9e5a1b7d3f2_add_note_title.py b/server/alembic_users/versions/c9e5a1b7d3f2_add_note_title.py new file mode 100644 index 0000000..1040156 --- /dev/null +++ b/server/alembic_users/versions/c9e5a1b7d3f2_add_note_title.py @@ -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") diff --git a/server/app.py b/server/app.py index df1eb5d..8d6c7da 100644 --- a/server/app.py +++ b/server/app.py @@ -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/", 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//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//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//items//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//items//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. diff --git a/server/static/retro.css b/server/static/retro.css index 71b672e..fc62fa3 100644 --- a/server/static/retro.css +++ b/server/static/retro.css @@ -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); } diff --git a/server/templates/docs.html b/server/templates/docs.html index 2c45786..6d93348 100644 --- a/server/templates/docs.html +++ b/server/templates/docs.html @@ -12,6 +12,7 @@ diff --git a/server/templates/home.html b/server/templates/home.html index 4558f39..38761b5 100644 --- a/server/templates/home.html +++ b/server/templates/home.html @@ -12,6 +12,7 @@