iurbinah commited on
Commit
720e020
Β·
1 Parent(s): 8d72de3

Add Lab Logs tab: markdown notes with SQLite storage

Browse files
Files changed (3) hide show
  1. app.py +111 -3
  2. requirements.txt +1 -0
  3. templates/pages/logs.html +200 -0
app.py CHANGED
@@ -4,12 +4,15 @@ Stack: Flask Β· Jinja2 Β· vanilla JS/CSS Β· gunicorn
4
  """
5
 
6
  import os
 
 
7
  from flask import (
8
  Flask, render_template, request, redirect,
9
- url_for, session, flash, jsonify,
10
  )
11
  from werkzeug.middleware.proxy_fix import ProxyFix
12
  from dotenv import load_dotenv
 
13
 
14
  load_dotenv()
15
 
@@ -25,6 +28,8 @@ app.config.update(
25
 
26
  ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "bpel123")
27
 
 
 
28
  # ── oTree configuration ────────────────────────────────────────────
29
  OTREE_SESSION_URL = os.getenv(
30
  "OTREE_SESSION_URL",
@@ -38,10 +43,45 @@ OTREE_SESSION_URL = os.getenv(
38
  # ─────────────────────────────────────────────────────────────────
39
  SIDEBAR_PAGES = [
40
  ("page_session", "πŸ–₯️", "Session"),
41
- # ("page_example", "πŸ“Š", "Example"), # ← uncomment to add pages
42
  ]
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  # ── Auth guard ──────────────────────────────────────────────────────
46
  @app.before_request
47
  def require_login():
@@ -90,7 +130,75 @@ def page_session():
90
  )
91
 
92
 
93
- # ── API helpers (expand as needed) ──────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  @app.route("/api/health")
95
  def api_health():
96
  return jsonify(status="ok")
 
4
  """
5
 
6
  import os
7
+ import sqlite3
8
+ from datetime import datetime, timezone
9
  from flask import (
10
  Flask, render_template, request, redirect,
11
+ url_for, session, flash, jsonify, g,
12
  )
13
  from werkzeug.middleware.proxy_fix import ProxyFix
14
  from dotenv import load_dotenv
15
+ import markdown
16
 
17
  load_dotenv()
18
 
 
28
 
29
  ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "bpel123")
30
 
31
+ DB_PATH = os.getenv("DB_PATH", os.path.join(os.path.dirname(__file__), "lab.db"))
32
+
33
  # ── oTree configuration ────────────────────────────────────────────
34
  OTREE_SESSION_URL = os.getenv(
35
  "OTREE_SESSION_URL",
 
43
  # ─────────────────────────────────────────────────────────────────
44
  SIDEBAR_PAGES = [
45
  ("page_session", "πŸ–₯️", "Session"),
46
+ ("page_logs", "πŸ“", "Lab Logs"),
47
  ]
48
 
49
 
50
+ # ── SQLite helpers ──────────────────────────────────────────────────
51
+ def get_db():
52
+ if "db" not in g:
53
+ g.db = sqlite3.connect(DB_PATH)
54
+ g.db.row_factory = sqlite3.Row
55
+ g.db.execute("PRAGMA journal_mode=WAL")
56
+ return g.db
57
+
58
+
59
+ @app.teardown_appcontext
60
+ def close_db(exc):
61
+ db = g.pop("db", None)
62
+ if db is not None:
63
+ db.close()
64
+
65
+
66
+ def init_db():
67
+ db = sqlite3.connect(DB_PATH)
68
+ db.execute("""
69
+ CREATE TABLE IF NOT EXISTS logs (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ title TEXT NOT NULL,
72
+ body TEXT NOT NULL DEFAULT '',
73
+ author TEXT NOT NULL DEFAULT '',
74
+ created_at TEXT NOT NULL,
75
+ updated_at TEXT NOT NULL
76
+ )
77
+ """)
78
+ db.commit()
79
+ db.close()
80
+
81
+
82
+ init_db()
83
+
84
+
85
  # ── Auth guard ──────────────────────────────────────────────────────
86
  @app.before_request
87
  def require_login():
 
130
  )
131
 
132
 
133
+ # ── Lab Logs page ───────────────────────────────────────────────────
134
+ @app.route("/logs")
135
+ def page_logs():
136
+ db = get_db()
137
+ rows = db.execute(
138
+ "SELECT * FROM logs ORDER BY created_at DESC"
139
+ ).fetchall()
140
+ logs = []
141
+ for r in rows:
142
+ logs.append({
143
+ "id": r["id"],
144
+ "title": r["title"],
145
+ "body": r["body"],
146
+ "body_html": markdown.markdown(
147
+ r["body"], extensions=["fenced_code", "tables", "nl2br"]
148
+ ),
149
+ "author": r["author"],
150
+ "created_at": r["created_at"],
151
+ "updated_at": r["updated_at"],
152
+ })
153
+ return render_template("pages/logs.html", active_page="page_logs", logs=logs)
154
+
155
+
156
+ # ── Lab Logs API ────────────────────────────────────────────────────
157
+ @app.route("/api/logs", methods=["POST"])
158
+ def api_logs_create():
159
+ data = request.get_json(silent=True) or {}
160
+ title = (data.get("title") or "").strip()
161
+ if not title:
162
+ return jsonify(error="Title is required."), 400
163
+ body = (data.get("body") or "").strip()
164
+ author = (data.get("author") or "").strip()
165
+ now = datetime.now(timezone.utc).isoformat()
166
+ db = get_db()
167
+ cur = db.execute(
168
+ "INSERT INTO logs (title, body, author, created_at, updated_at) VALUES (?,?,?,?,?)",
169
+ (title, body, author, now, now),
170
+ )
171
+ db.commit()
172
+ return jsonify(id=cur.lastrowid), 201
173
+
174
+
175
+ @app.route("/api/logs/<int:log_id>", methods=["PUT"])
176
+ def api_logs_update(log_id):
177
+ data = request.get_json(silent=True) or {}
178
+ title = (data.get("title") or "").strip()
179
+ if not title:
180
+ return jsonify(error="Title is required."), 400
181
+ body = (data.get("body") or "").strip()
182
+ author = (data.get("author") or "").strip()
183
+ now = datetime.now(timezone.utc).isoformat()
184
+ db = get_db()
185
+ db.execute(
186
+ "UPDATE logs SET title=?, body=?, author=?, updated_at=? WHERE id=?",
187
+ (title, body, author, now, log_id),
188
+ )
189
+ db.commit()
190
+ return jsonify(ok=True)
191
+
192
+
193
+ @app.route("/api/logs/<int:log_id>", methods=["DELETE"])
194
+ def api_logs_delete(log_id):
195
+ db = get_db()
196
+ db.execute("DELETE FROM logs WHERE id=?", (log_id,))
197
+ db.commit()
198
+ return jsonify(ok=True)
199
+
200
+
201
+ # ── API helpers ─────────────────────────────────────────────────────
202
  @app.route("/api/health")
203
  def api_health():
204
  return jsonify(status="ok")
requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  flask==3.1.3
2
  gunicorn==23.0.0
 
3
  python-dotenv==1.1.0
 
1
  flask==3.1.3
2
  gunicorn==23.0.0
3
+ markdown==3.8
4
  python-dotenv==1.1.0
templates/pages/logs.html ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Lab Logs β€” Lab Portal{% endblock %}
4
+
5
+ {% block header %}
6
+ <div class="page-header" style="display:flex; align-items:center; justify-content:space-between;">
7
+ <div>
8
+ <h1>Lab Logs</h1>
9
+ <p>Markdown notes visible to all lab members.</p>
10
+ </div>
11
+ <button class="btn btn-primary" onclick="openEditor()">+ New note</button>
12
+ </div>
13
+ {% endblock %}
14
+
15
+ {% block extra_css %}
16
+ <style>
17
+ /* ── Note cards ── */
18
+ .log-list { display: flex; flex-direction: column; gap: 1rem; }
19
+ .log-card { position: relative; }
20
+ .log-card h2 { font-size: 1.05rem; margin-bottom: .15rem; }
21
+ .log-meta {
22
+ font-size: .78rem;
23
+ color: var(--text-dim);
24
+ margin-bottom: .75rem;
25
+ display: flex;
26
+ gap: 1rem;
27
+ }
28
+ .log-body { font-size: .9rem; line-height: 1.65; }
29
+ .log-body h1, .log-body h2, .log-body h3 { margin: .75rem 0 .35rem; }
30
+ .log-body p { margin-bottom: .5rem; }
31
+ .log-body ul, .log-body ol { padding-left: 1.4rem; margin-bottom: .5rem; }
32
+ .log-body code {
33
+ background: var(--bg); padding: .1rem .35rem; border-radius: 4px; font-size: .85em;
34
+ }
35
+ .log-body pre {
36
+ background: var(--bg); padding: .75rem 1rem; border-radius: 8px;
37
+ overflow-x: auto; margin-bottom: .75rem;
38
+ }
39
+ .log-body pre code { background: none; padding: 0; }
40
+ .log-body table { border-collapse: collapse; margin-bottom: .75rem; width: 100%; }
41
+ .log-body th, .log-body td {
42
+ border: 1px solid var(--border); padding: .35rem .6rem; text-align: left; font-size: .85rem;
43
+ }
44
+ .log-body th { background: var(--surface2); }
45
+ .log-actions {
46
+ position: absolute; top: 1.25rem; right: 1.25rem;
47
+ display: flex; gap: .4rem;
48
+ }
49
+ .log-actions button {
50
+ background: var(--surface2); border: 1px solid var(--border);
51
+ color: var(--text-dim); border-radius: 6px; padding: .3rem .55rem;
52
+ cursor: pointer; font-size: .78rem; transition: color .15s, border-color .15s;
53
+ }
54
+ .log-actions button:hover { color: var(--text); border-color: var(--text-dim); }
55
+ .log-actions button.del:hover { color: var(--danger); border-color: var(--danger); }
56
+
57
+ .empty-state {
58
+ text-align: center; color: var(--text-dim); padding: 4rem 1rem;
59
+ font-size: .95rem;
60
+ }
61
+
62
+ /* ── Modal editor ── */
63
+ .modal-overlay {
64
+ display: none;
65
+ position: fixed; inset: 0;
66
+ background: rgba(0,0,0,.55);
67
+ z-index: 100;
68
+ align-items: center; justify-content: center;
69
+ }
70
+ .modal-overlay.open { display: flex; }
71
+ .modal {
72
+ background: var(--surface);
73
+ border: 1px solid var(--border);
74
+ border-radius: 14px;
75
+ width: 95%; max-width: 620px;
76
+ padding: 1.75rem;
77
+ }
78
+ .modal h2 { font-size: 1.1rem; margin-bottom: 1rem; }
79
+ .modal .field { margin-bottom: 1rem; }
80
+ .modal label {
81
+ display: block; font-size: .82rem; color: var(--text-dim); margin-bottom: .3rem;
82
+ }
83
+ .modal input, .modal textarea {
84
+ width: 100%; padding: .55rem .75rem; border-radius: 8px;
85
+ border: 1px solid var(--border); background: var(--bg);
86
+ color: var(--text); font-size: .9rem; font-family: inherit;
87
+ outline: none; transition: border-color .15s;
88
+ }
89
+ .modal input:focus, .modal textarea:focus { border-color: var(--accent); }
90
+ .modal textarea { min-height: 200px; resize: vertical; }
91
+ .modal-footer { display: flex; gap: .6rem; justify-content: flex-end; margin-top: 1.25rem; }
92
+ </style>
93
+ {% endblock %}
94
+
95
+ {% block content %}
96
+ <div class="log-list" id="log-list">
97
+ {% if not logs %}
98
+ <div class="empty-state" id="empty-state">No notes yet. Click <strong>+ New note</strong> to get started.</div>
99
+ {% endif %}
100
+ {% for log in logs %}
101
+ <div class="card log-card" data-id="{{ log.id }}">
102
+ <div class="log-actions">
103
+ <button onclick="editLog({{ log.id }})">Edit</button>
104
+ <button class="del" onclick="deleteLog({{ log.id }})">Delete</button>
105
+ </div>
106
+ <h2>{{ log.title }}</h2>
107
+ <div class="log-meta">
108
+ {% if log.author %}<span>by {{ log.author }}</span>{% endif %}
109
+ <span>{{ log.created_at[:16].replace('T', ' ') }} UTC</span>
110
+ </div>
111
+ <div class="log-body">{{ log.body_html | safe }}</div>
112
+ </div>
113
+ {% endfor %}
114
+ </div>
115
+
116
+ <!-- Editor modal -->
117
+ <div class="modal-overlay" id="modal">
118
+ <div class="modal">
119
+ <h2 id="modal-title">New note</h2>
120
+ <input type="hidden" id="edit-id" value="" />
121
+ <div class="field">
122
+ <label for="note-title">Title</label>
123
+ <input id="note-title" placeholder="e.g. Session 12 setup notes" />
124
+ </div>
125
+ <div class="field">
126
+ <label for="note-author">Author <span style="color:var(--text-dim)">(optional)</span></label>
127
+ <input id="note-author" placeholder="Your name" />
128
+ </div>
129
+ <div class="field">
130
+ <label for="note-body">Body <span style="color:var(--text-dim)">(Markdown supported)</span></label>
131
+ <textarea id="note-body" placeholder="Write your note here..."></textarea>
132
+ </div>
133
+ <div class="modal-footer">
134
+ <button class="btn btn-outline" onclick="closeEditor()">Cancel</button>
135
+ <button class="btn btn-primary" id="save-btn" onclick="saveNote()">Save</button>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ {% endblock %}
140
+
141
+ {% block extra_js %}
142
+ <script>
143
+ const modal = document.getElementById("modal");
144
+
145
+ /* ── Existing log data for editing ── */
146
+ const logsData = {{ logs | tojson }};
147
+
148
+ function openEditor(id) {
149
+ document.getElementById("edit-id").value = id || "";
150
+ document.getElementById("modal-title").textContent = id ? "Edit note" : "New note";
151
+ if (id) {
152
+ const log = logsData.find(l => l.id === id);
153
+ if (log) {
154
+ document.getElementById("note-title").value = log.title;
155
+ document.getElementById("note-author").value = log.author;
156
+ document.getElementById("note-body").value = log.body;
157
+ }
158
+ } else {
159
+ document.getElementById("note-title").value = "";
160
+ document.getElementById("note-author").value = "";
161
+ document.getElementById("note-body").value = "";
162
+ }
163
+ modal.classList.add("open");
164
+ document.getElementById("note-title").focus();
165
+ }
166
+
167
+ function closeEditor() { modal.classList.remove("open"); }
168
+
169
+ async function saveNote() {
170
+ const id = document.getElementById("edit-id").value;
171
+ const title = document.getElementById("note-title").value.trim();
172
+ const author = document.getElementById("note-author").value.trim();
173
+ const body = document.getElementById("note-body").value;
174
+ if (!title) { alert("Title is required."); return; }
175
+
176
+ const url = id ? `/api/logs/${id}` : "/api/logs";
177
+ const method = id ? "PUT" : "POST";
178
+ const res = await fetch(url, {
179
+ method,
180
+ headers: {"Content-Type": "application/json"},
181
+ body: JSON.stringify({title, author, body}),
182
+ });
183
+ if (res.ok) { location.reload(); }
184
+ else { alert("Save failed."); }
185
+ }
186
+
187
+ function editLog(id) { openEditor(id); }
188
+
189
+ async function deleteLog(id) {
190
+ if (!confirm("Delete this note?")) return;
191
+ const res = await fetch(`/api/logs/${id}`, {method: "DELETE"});
192
+ if (res.ok) { location.reload(); }
193
+ }
194
+
195
+ /* Close modal on Escape */
196
+ document.addEventListener("keydown", e => { if (e.key === "Escape") closeEditor(); });
197
+ /* Close modal on overlay click */
198
+ modal.addEventListener("click", e => { if (e.target === modal) closeEditor(); });
199
+ </script>
200
+ {% endblock %}