calendar
This commit is contained in:
+18
-4
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) }}"><</a>
|
||||||
|
<a href="{{ url_for('calendar_page', view='month', year=next_year, month=next_month) }}">></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()) }}"><</a>
|
||||||
|
<a href="{{ url_for('calendar_page', view='week', date=week_next.isoformat()) }}">></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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user