This commit is contained in:
2026-11-03 08:44:11 +01:00
parent cbf5f20c75
commit 0a9da4f9c5
13 changed files with 422 additions and 5 deletions
+18 -4
View File
@@ -3,9 +3,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, notes, - **main app** (`app.py`) — all user-facing views: login, home, notes,
notifications, clipboard, API docs and the `/sensors` dashboard. It owns the notifications, clipboard, calendar, API docs and the `/sensors` dashboard. It
**main app** database (`main_app.db`: users, notes, notifications and clipboard owns the **main app** database (`main_app.db`: users, notes, notifications and
file metadata) and proxies data requests to the sensors API. clipboard file metadata) 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.
@@ -56,6 +56,7 @@ server/
├── notes.html ├── notes.html
├── notifications.html ├── notifications.html
├── clipboard.html ├── clipboard.html
├── calendar.html
├── docs.html ├── docs.html
└── sensors.html └── sensors.html
``` ```
@@ -187,6 +188,19 @@ automatically):
alembic -c alembic_users.ini upgrade head alembic -c alembic_users.ini upgrade head
``` ```
## Calendar
The main app provides a `/calendar` page with two views for browsing sensor
readouts:
- **month** (`/calendar?view=month&year=YYYY&month=MM`) — calendar grid of the
given month, each day lists readouts (sensor, time, type, value)
- **week** (`/calendar?view=week&date=YYYY-MM-DD`) — list of the seven days of
the week containing the given date
Readouts are fetched from the sensors API through the
`GET /api/sensors/:id/readouts/range?from=...&to=...` endpoint.
## Notes ## Notes
Logged-in users manage their own private short notes at `/notes`. Each note has Logged-in users manage their own private short notes at `/notes`. Each note has
@@ -349,7 +363,7 @@ deployment path or user is different.
| Service | Port | Routes | | Service | Port | Routes |
|---|---|---| |---|---|---|
| main | 8000 | `/`, `/login`, `/home`, `/clipboard`, `/notes`, `/notifications`, `/sensors`, `/api/docs`, `/api/*` (proxy) | | main | 8000 | `/`, `/login`, `/home`, `/clipboard`, `/calendar`, `/notes`, `/notifications`, `/sensors`, `/api/docs`, `/api/*` (proxy) |
| sensors | 8001 | `/api/*` (sensors and readouts) | | sensors | 8001 | `/api/*` (sensors and readouts) |
### Tests ### Tests
+179
View File
@@ -2,7 +2,9 @@ import fcntl
import os import os
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date, datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
import click import click
from flask import ( from flask import (
@@ -49,6 +51,34 @@ def format_bytes(value: int) -> str:
return f"{int(size)} B" return f"{int(size)} B"
LOCAL_TZ_DEFAULT = "Europe/Warsaw"
def _calendar_tz(name):
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo(LOCAL_TZ_DEFAULT)
def _parse_date_arg(raw):
value = (raw or "").strip()
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _day_bounds_utc(day: date, tz):
start_local = datetime(day.year, day.month, day.day)
end_local = start_local + timedelta(days=1)
start_utc = start_local.replace(tzinfo=tz).astimezone(timezone.utc).replace(tzinfo=None)
end_utc = end_local.replace(tzinfo=tz).astimezone(timezone.utc).replace(tzinfo=None)
return start_utc, end_utc
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
@@ -326,6 +356,155 @@ def create_app():
flash(f"Deleted {name}.", "success") flash(f"Deleted {name}.", "success")
return redirect(url_for("clipboard_page")) return redirect(url_for("clipboard_page"))
@app.route("/api/sensors/<int:sensor_id>/readouts/range")
@login_required
def api_sensor_readouts_range(sensor_id):
return _proxy(
lambda: sensors.sensor_readouts_range(sensor_id, request.args.to_dict())
)
@app.route("/calendar")
@login_required
def calendar_page():
view = (request.args.get("view") or "month").strip().lower()
if view not in ("month", "week"):
view = "month"
tz = _calendar_tz(request.args.get("tz") or LOCAL_TZ_DEFAULT)
now_local = datetime.now(tz)
year = now_local.year
month = now_local.month
week_start = None
week_end = None
week_prev = None
week_next = None
days = []
month_weeks = None
if view == "week":
week_date = _parse_date_arg(request.args.get("date")) or now_local.date()
week_start = week_date - timedelta(days=week_date.weekday())
week_end = week_start + timedelta(days=6)
week_prev = week_start - timedelta(days=7)
week_next = week_start + timedelta(days=7)
day_list = [week_start + timedelta(days=i) for i in range(7)]
range_from = _day_bounds_utc(week_start, tz)[0]
range_to = _day_bounds_utc(week_end + timedelta(days=1), tz)[0]
else:
year = int(request.args.get("year") or now_local.year)
month = int(request.args.get("month") or now_local.month)
if not 1 <= month <= 12:
month = 1
first_day = date(year, month, 1)
next_year = year + 1 if month == 12 else year
next_month = 1 if month == 12 else month + 1
last_day = date(next_year, next_month, 1) - timedelta(days=1)
day_list = []
current = first_day
while current <= last_day:
day_list.append(current)
current += timedelta(days=1)
range_from, _ = _day_bounds_utc(first_day, tz)
range_to, _ = _day_bounds_utc(last_day, tz)
week_start = first_day - timedelta(days=first_day.weekday())
week_end = last_day
readouts_by_day = {day: [] for day in day_list}
sensor_names = {}
try:
listed = sensors.list_sensors()
for sensor in listed[1] or []:
sensor_id = int(sensor["id"])
sensor_names[sensor_id] = sensor["name"]
status, payload = sensors.sensor_readouts_range(
sensor_id,
{"from": range_from.isoformat(), "to": range_to.isoformat()},
)
if status != 200:
continue
for item in payload or []:
raw_ts = (item.get("timestamp") or "").rstrip("Z")
ts = datetime.fromisoformat(raw_ts) if raw_ts else None
if ts is None:
continue
local_day = ts.replace(tzinfo=timezone.utc).astimezone(tz).date()
if local_day in readouts_by_day:
readouts_by_day[local_day].append(
{
"id": item.get("id"),
"sensor_id": item.get("sensor_id") or sensor_id,
"type": item.get("type"),
"value": item.get("value"),
"timestamp": ts,
}
)
except SensorsAPIError:
pass
days = [
{"date": day, "readouts": readouts_by_day.get(day, [])}
for day in day_list
]
month_weeks = None
if view == "month":
first_monday = week_start
last_sunday = week_end + timedelta(days=6 - week_end.weekday())
end_exclusive = last_sunday + timedelta(days=1)
weeks = []
week = []
current = first_monday
while current < end_exclusive:
week.append(
{
"date": current,
"in_month": current.month == month,
"readouts": readouts_by_day.get(current, []),
}
)
if current.weekday() == 6:
weeks.append(week)
week = []
current += timedelta(days=1)
if week:
weeks.append(week)
month_weeks = weeks
if month == 1:
prev_year, prev_month = year - 1, 12
else:
prev_year, prev_month = year, month - 1
if month == 12:
next_year, next_month = year + 1, 1
else:
next_year, next_month = year, month + 1
month_names = (
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
)
month_label = f"{month_names[month - 1]} {year}"
return render_template(
"calendar.html",
user=current_user,
view=view,
year=year,
month=month,
week_start=week_start,
week_end=week_end,
days=days,
month_weeks=month_weeks,
sensor_names=sensor_names,
prev_year=prev_year,
prev_month=prev_month,
next_year=next_year,
next_month=next_month,
week_prev=week_prev,
week_next=week_next,
today=now_local.date(),
)
# --- Notifications (stored in main_app.db) --- # --- Notifications (stored in main_app.db) ---
@app.route("/notifications") @app.route("/notifications")
+53
View File
@@ -7,6 +7,20 @@ from config import sensors_database_uri
from models import Sensor, SensorReadout, db from models import Sensor, SensorReadout, db
def _parse_readout_ts(raw: str):
value = (raw or "").strip()
if not value:
return None
if value.endswith("Z"):
value = value[:-1]
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
@@ -78,6 +92,45 @@ def create_app():
} }
), 201 ), 201
@app.route("/api/sensors/<int:sensor_id>/readouts/range")
def api_sensor_readouts_range(sensor_id):
sensor = db.session.get(Sensor, sensor_id)
if not sensor:
return jsonify({"error": "sensor not found"}), 404
from_raw = request.args.get("from")
to_raw = request.args.get("to")
from_ts = _parse_readout_ts(from_raw) if from_raw else None
to_ts = _parse_readout_ts(to_raw) if to_raw else None
if from_ts is None or to_ts is None or to_ts < from_ts:
return jsonify({"error": "from and to are required and valid"}), 400
rows = (
db.session.query(
SensorReadout.id,
SensorReadout.sensor_id,
SensorReadout.type,
SensorReadout.value,
SensorReadout.timestamp,
)
.filter(SensorReadout.sensor_id == sensor_id)
.filter(SensorReadout.timestamp >= from_ts, SensorReadout.timestamp < to_ts)
.order_by(SensorReadout.timestamp)
.all()
)
return jsonify(
[
{
"id": row[0],
"sensor_id": row[1],
"type": row[2],
"value": row[3],
"timestamp": row[4].isoformat() + "Z",
}
for row in rows
]
)
# --- API: dashboard reads (public on the local machine) --- # --- API: dashboard reads (public on the local machine) ---
@app.route("/api/sensors/<int:sensor_id>/types") @app.route("/api/sensors/<int:sensor_id>/types")
+5
View File
@@ -62,6 +62,11 @@ class SensorsClient:
def sensor_readouts(self, sensor_id, params=None): def sensor_readouts(self, sensor_id, params=None):
return self._request("GET", f"/api/sensors/{sensor_id}/readouts", params=params) return self._request("GET", f"/api/sensors/{sensor_id}/readouts", params=params)
def sensor_readouts_range(self, sensor_id, params=None):
return self._request(
"GET", f"/api/sensors/{sensor_id}/readouts/range", params=params
)
def sensor_readouts_hourly(self, sensor_id, params=None): def sensor_readouts_hourly(self, sensor_id, params=None):
return self._request( return self._request(
"GET", f"/api/sensors/{sensor_id}/readouts/hourly", params=params "GET", f"/api/sensors/{sensor_id}/readouts/hourly", params=params
+34
View File
@@ -491,3 +491,37 @@ textarea {
letter-spacing: 0; letter-spacing: 0;
} }
.clipboard-delete-form button:hover { background: var(--mc-red-bright); } .clipboard-delete-form button:hover { background: var(--mc-red-bright); }
/* --- Calendar --- */
.calendar-controls { display: flex; gap: 0.8em; align-items: center; margin-top: 0.5rem; }
.calendar-title-row { display: flex; align-items: center; justify-content: space-between; gap: 0.8em; margin-top: 0.5rem; }
.calendar-grid { display: flex; flex-direction: column; gap: 0.4em; margin-top: 1rem; }
.calendar-week { display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.4em; }
.calendar-day { border: 2px solid var(--mc-dim); background: var(--mc-deep); min-height: 6em; padding: 0.3em; width: 100%; box-sizing: border-box; }
.calendar-day-outside { opacity: 0.55; }
.calendar-day-header { color: var(--mc-yellow); text-transform: uppercase; letter-spacing: 1px; font-size: 0.8em; }
.calendar-readouts { list-style: none; padding: 0; margin: 0.3em 0 0; }
.calendar-event { border-bottom: 1px dashed var(--mc-dim); padding: 0.15em 0; font-size: 0.9em; }
.calendar-event-time { color: var(--mc-cyan); margin-right: 0.4em; }
.calendar-event-sensor { color: var(--mc-bright); }
.calendar-event-type { color: var(--mc-dim); }
.calendar-event-value { float: right; color: var(--mc-yellow); }
.calendar-list { list-style: none; padding: 0; margin: 1rem 0; }
.calendar-day-item { border: 2px solid var(--mc-dim); background: var(--mc-deep); margin: 0.5rem 0; padding: 0.4em 0.6em; min-height: 6em; width: 100%; box-sizing: border-box; }
.calendar-nav { display: flex; gap: 0.8em; margin-top: 0.8rem; }
.calendar-today {
display: inline-block;
text-transform: uppercase;
letter-spacing: 2px;
background: var(--mc-yellow);
color: var(--mc-black);
box-shadow: 4px 4px 0 var(--mc-black);
padding: 0.4em 1.2em;
text-decoration: none;
}
.calendar-today:hover { background: var(--mc-cyan); color: var(--mc-black); }
.calendar-day.calendar-today,
.calendar-day-item.calendar-today {
background: var(--mc-deep);
border: 3px solid var(--mc-yellow);
}
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar — 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('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('calendar_page') }}">Calendar</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</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>Calendar</h1>
<div class="panel">
<h2>Readouts</h2>
<div class="calendar-controls">
<a href="{{ url_for('calendar_page', view='month', year=year, month=month) }}"><span class="mc-key">M</span>Month</a>
<a href="{{ url_for('calendar_page', view='week', date=today.isoformat()) }}"><span class="mc-key">W</span>Week</a>
{% if view == 'month' %}
<a class="calendar-today" href="{{ url_for('calendar_page', view='month', year=today.year, month=today.month) }}"><span class="mc-key">T</span>Today</a>
<a href="{{ url_for('calendar_page', view='month', year=prev_year, month=prev_month) }}">&lt;</a>
<a href="{{ url_for('calendar_page', view='month', year=next_year, month=next_month) }}">&gt;</a>
{% else %}
<a class="calendar-today" href="{{ url_for('calendar_page', view='week', date=today.isoformat()) }}"><span class="mc-key">T</span>Today</a>
{% endif %}
</div>
{% if view == 'month' %}
<div class="calendar-title-row">
<h2 class="calendar-title">{{ month_label }}</h2>
</div>
<div class="calendar-grid">
{% for week in month_weeks %}
<div class="calendar-week">
{% for day in week %}
<div class="calendar-day{% if not day.in_month %} calendar-day-outside{% endif %}{% if day.date == today %} calendar-today{% endif %}">
<div class="calendar-day-header">{{ day.date.strftime("%a %d %b") }}</div>
<ul class="calendar-readouts">
{% for r in day.readouts %}
<li class="calendar-event">
<span class="calendar-event-time">{{ r.timestamp.strftime("%H:%M") }}</span>
<span class="calendar-event-sensor">{{ sensor_names[r.sensor_id] }}</span>
<span class="calendar-event-type">{{ r.type }}</span>
<span class="calendar-event-value">{{ r.value|round(1) }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% else %}
<div class="calendar-title-row">
<h2 class="calendar-title">Tydzień {{ week_start }} – {{ week_end }}</h2>
<div class="calendar-nav">
<a href="{{ url_for('calendar_page', view='week', date=week_prev.isoformat()) }}">&lt;</a>
<a href="{{ url_for('calendar_page', view='week', date=week_next.isoformat()) }}">&gt;</a>
</div>
</div>
<ul class="calendar-list">
{% for day in days %}
<li class="calendar-day-item{% if day.date == today %} calendar-today{% endif %}" id="calendar-{{ day.date }}">
<div class="calendar-day-header">{{ day.date.strftime("%Y-%m-%d (%a)") }}</div>
<ul class="calendar-readouts">
{% for r in day.readouts %}
<li class="calendar-event">
<span class="calendar-event-time">{{ r.timestamp.strftime("%H:%M") }}</span>
<span class="calendar-event-sensor">{{ sensor_names[r.sensor_id] }}</span>
<span class="calendar-event-type">{{ r.type }}</span>
<span class="calendar-event-value">{{ r.value|round(1) }}</span>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<footer class="statusbar">
<span>{{ user.username }}@yggdrasil:~/calendar$</span>
</footer>
</body>
</html>
+1
View File
@@ -14,6 +14,7 @@
<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('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a> <a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('calendar_page') }}">Calendar</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</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>
+1
View File
@@ -14,6 +14,7 @@
<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('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a> <a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('calendar_page') }}">Calendar</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</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>
+4 -1
View File
@@ -14,6 +14,7 @@
<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('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a> <a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('calendar_page') }}">Calendar</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</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>
@@ -29,9 +30,10 @@
<li><a href="{{ url_for('notifications_page') }}"><span class="mc-key">3</span>Notifications{% if unread_notifications %} ({{ unread_notifications }} new){% endif %}</a></li> <li><a href="{{ url_for('notifications_page') }}"><span class="mc-key">3</span>Notifications{% if unread_notifications %} ({{ unread_notifications }} new){% endif %}</a></li>
<li><a href="{{ url_for('api_docs') }}"><span class="mc-key">4</span>API — documentation</a></li> <li><a href="{{ url_for('api_docs') }}"><span class="mc-key">4</span>API — documentation</a></li>
<li><a href="{{ url_for('clipboard_page') }}"><span class="mc-key">5</span>Clipboard — upload and share files</a></li> <li><a href="{{ url_for('clipboard_page') }}"><span class="mc-key">5</span>Clipboard — upload and share files</a></li>
<li><a href="{{ url_for('calendar_page') }}"><span class="mc-key">6</span>Calendar — sensor readings</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>, <b>3</b>, <b>4</b>, <b>5</b> or <b>0</b> to navigate.</p> <p class="hint">Tip: press <b>1</b>, <b>2</b>, <b>3</b>, <b>4</b>, <b>5</b>, <b>6</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">
@@ -50,6 +52,7 @@
'3': "{{ url_for('notifications_page') }}", '3': "{{ url_for('notifications_page') }}",
'4': "{{ url_for('api_docs') }}", '4': "{{ url_for('api_docs') }}",
'5': "{{ url_for('clipboard_page') }}", '5': "{{ url_for('clipboard_page') }}",
'6': "{{ url_for('calendar_page') }}",
'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];
+1
View File
@@ -14,6 +14,7 @@
<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('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a> <a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('calendar_page') }}">Calendar</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</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>
+1
View File
@@ -14,6 +14,7 @@
<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('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a> <a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('calendar_page') }}">Calendar</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</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>
+1
View File
@@ -15,6 +15,7 @@
<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('notes_page') }}">Notes</a>
<a href="{{ url_for('clipboard_page') }}">Clipboard</a> <a href="{{ url_for('clipboard_page') }}">Clipboard</a>
<a href="{{ url_for('calendar_page') }}">Calendar</a>
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a> <a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</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>
+28
View File
@@ -279,6 +279,34 @@ class MainAppTest(unittest.TestCase):
self.assertEqual(Notification.query.filter_by(id=first_id).count(), 0) self.assertEqual(Notification.query.filter_by(id=first_id).count(), 0)
self.assertEqual(Notification.query.filter_by(id=second_id).count(), 1) self.assertEqual(Notification.query.filter_by(id=second_id).count(), 1)
def test_calendar_requires_login(self):
response = self.client.get("/calendar")
self.assertEqual(response.status_code, 302)
self.assertIn("/login", response.headers["Location"])
def test_calendar_month_and_week_views_render(self):
self.login()
month_response = self.client.get("/calendar?view=month")
self.assertEqual(month_response.status_code, 200)
self.assertIn("Calendar", month_response.get_data(as_text=True))
week_response = self.client.get("/calendar?view=week")
self.assertEqual(week_response.status_code, 200)
self.assertIn("Calendar", week_response.get_data(as_text=True))
def test_calendar_readouts_range_endpoint(self):
self.login()
sensor_id = self.create_sensor_and_readout("calendar-test")
response = self.client.get(
f"/api/sensors/{sensor_id}/readouts/range"
"?from=2000-01-01T00:00:00&to=2100-01-01T00:00:00"
)
self.assertEqual(response.status_code, 200)
data = response.get_json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]["sensor_id"], sensor_id)
def test_notes_require_login(self): def test_notes_require_login(self):
response = self.client.get("/notes") response = self.client.get("/notes")
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)