Fix XSS, CSRF, input validation, and related security issues
This commit is contained in:
@@ -3,11 +3,22 @@ from sqlalchemy import create_engine, func
|
|||||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||||
|
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
import json
|
import re
|
||||||
|
|
||||||
from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog
|
from models import Base, Battery, BatteryPctLog, Device, CapacityTest, ChargeLog
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(val: str) -> str | None:
|
||||||
|
"""Return val if it is a valid YYYY-MM-DD string, else None."""
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
datetime.strptime(val, "%Y-%m-%d")
|
||||||
|
return val
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_object="config"):
|
def create_app(config_object="config"):
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(config_object)
|
app.config.from_object(config_object)
|
||||||
@@ -28,6 +39,9 @@ def create_app(config_object="config"):
|
|||||||
# Home Assistant integration (optional)
|
# Home Assistant integration (optional)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
CSRFProtect(app)
|
||||||
|
|
||||||
from ha_client import HomeAssistantClient
|
from ha_client import HomeAssistantClient
|
||||||
from ha_poller import HaPoller
|
from ha_poller import HaPoller
|
||||||
|
|
||||||
@@ -109,9 +123,14 @@ def create_app(config_object="config"):
|
|||||||
purchase_date = f.get("purchase_date", "").strip() or None
|
purchase_date = f.get("purchase_date", "").strip() or None
|
||||||
storage_location = f.get("storage_location", "").strip() or None
|
storage_location = f.get("storage_location", "").strip() or None
|
||||||
|
|
||||||
existing = db.query(func.count(Battery.id)).filter_by(brand=brand).scalar()
|
existing_labels = [
|
||||||
|
r[0] for r in db.query(Battery.label).filter(Battery.brand == brand).all()
|
||||||
|
]
|
||||||
|
nums = [int(m.group(1)) for lbl in existing_labels
|
||||||
|
if (m := re.search(r'(\d+)$', lbl))]
|
||||||
|
next_num = max(nums, default=0)
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
label = f"{brand} {existing + i + 1:03d}"
|
label = f"{brand} {next_num + i + 1:03d}"
|
||||||
db.add(Battery(label=label, brand=brand, status="available", notes=notes,
|
db.add(Battery(label=label, brand=brand, status="available", notes=notes,
|
||||||
size=size, chemistry=chemistry, capacity_mah=capacity_mah,
|
size=size, chemistry=chemistry, capacity_mah=capacity_mah,
|
||||||
purchase_date=purchase_date, storage_location=storage_location))
|
purchase_date=purchase_date, storage_location=storage_location))
|
||||||
@@ -160,26 +179,26 @@ def create_app(config_object="config"):
|
|||||||
.filter_by(battery_id=battery_id)
|
.filter_by(battery_id=battery_id)
|
||||||
.order_by(BatteryPctLog.recorded_at.desc())
|
.order_by(BatteryPctLog.recorded_at.desc())
|
||||||
.all())
|
.all())
|
||||||
charge_logs_json = json.dumps([
|
charge_logs_data = [
|
||||||
{"id": l.id, "date": l.charged_date, "cycles": l.increment_cycles, "notes": l.notes or ""}
|
{"id": l.id, "date": l.charged_date, "cycles": l.increment_cycles, "notes": l.notes or ""}
|
||||||
for l in charge_logs
|
for l in charge_logs
|
||||||
])
|
]
|
||||||
capacity_tests_json = json.dumps([
|
capacity_tests_data = [
|
||||||
{"id": t.id, "date": t.tested_date, "mah": t.tested_capacity_mah, "notes": t.notes or ""}
|
{"id": t.id, "date": t.tested_date, "mah": t.tested_capacity_mah, "notes": t.notes or ""}
|
||||||
for t in sorted(capacity_tests, key=lambda t: (t.tested_date, t.id), reverse=True)
|
for t in sorted(capacity_tests, key=lambda t: (t.tested_date, t.id), reverse=True)
|
||||||
])
|
]
|
||||||
pct_logs_json = json.dumps([
|
pct_logs_data = [
|
||||||
{"recorded_at": str(l.recorded_at), "pct": l.percentage, "source": l.source or ""}
|
{"recorded_at": str(l.recorded_at), "pct": l.percentage, "source": l.source or ""}
|
||||||
for l in pct_logs
|
for l in pct_logs
|
||||||
])
|
]
|
||||||
return render_template("battery_detail.html", battery=battery,
|
return render_template("battery_detail.html", battery=battery,
|
||||||
storage_locations=storage_locations,
|
storage_locations=storage_locations,
|
||||||
capacity_tests=capacity_tests,
|
capacity_tests=capacity_tests,
|
||||||
charge_logs=charge_logs,
|
charge_logs=charge_logs,
|
||||||
pct_logs=pct_logs,
|
pct_logs=pct_logs,
|
||||||
charge_logs_json=charge_logs_json,
|
charge_logs_data=charge_logs_data,
|
||||||
capacity_tests_json=capacity_tests_json,
|
capacity_tests_data=capacity_tests_data,
|
||||||
pct_logs_json=pct_logs_json)
|
pct_logs_data=pct_logs_data)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Battery — edit notes
|
# Battery — edit notes
|
||||||
@@ -204,7 +223,8 @@ def create_app(config_object="config"):
|
|||||||
battery.chemistry = f.get("chemistry", "").strip() or None
|
battery.chemistry = f.get("chemistry", "").strip() or None
|
||||||
battery.capacity_mah = _int("capacity_mah")
|
battery.capacity_mah = _int("capacity_mah")
|
||||||
battery.charge_cycles = _int("charge_cycles")
|
battery.charge_cycles = _int("charge_cycles")
|
||||||
battery.purchase_date = f.get("purchase_date", "").strip() or None
|
purchase_raw = f.get("purchase_date", "").strip()
|
||||||
|
battery.purchase_date = _parse_date(purchase_raw) if purchase_raw else None
|
||||||
battery.storage_location = f.get("storage_location", "").strip() or None
|
battery.storage_location = f.get("storage_location", "").strip() or None
|
||||||
new_pct = _int("battery_percentage")
|
new_pct = _int("battery_percentage")
|
||||||
if new_pct != battery.battery_percentage:
|
if new_pct != battery.battery_percentage:
|
||||||
@@ -239,10 +259,10 @@ def create_app(config_object="config"):
|
|||||||
if battery is None:
|
if battery is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
mah_raw = request.form.get("tested_capacity_mah", "").strip()
|
mah_raw = request.form.get("tested_capacity_mah", "").strip()
|
||||||
date_val = request.form.get("tested_date", "").strip()
|
date_val = _parse_date(request.form.get("tested_date", "").strip())
|
||||||
notes = request.form.get("notes", "").strip() or None
|
notes = request.form.get("notes", "").strip() or None
|
||||||
if not mah_raw or not date_val:
|
if not mah_raw or not date_val:
|
||||||
flash("Capacity (mAh) and date are required.", "error")
|
flash("Capacity (mAh) and a valid date (YYYY-MM-DD) are required.", "error")
|
||||||
return redirect(url_for("battery_detail", battery_id=battery_id))
|
return redirect(url_for("battery_detail", battery_id=battery_id))
|
||||||
try:
|
try:
|
||||||
mah = int(mah_raw)
|
mah = int(mah_raw)
|
||||||
@@ -281,9 +301,9 @@ def create_app(config_object="config"):
|
|||||||
battery = db.get(Battery, battery_id)
|
battery = db.get(Battery, battery_id)
|
||||||
if battery is None:
|
if battery is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
date_val = request.form.get("charged_date", "").strip()
|
date_val = _parse_date(request.form.get("charged_date", "").strip())
|
||||||
if not date_val:
|
if not date_val:
|
||||||
flash("Date is required.", "error")
|
flash("A valid date (YYYY-MM-DD) is required.", "error")
|
||||||
return redirect(url_for("battery_detail", battery_id=battery_id))
|
return redirect(url_for("battery_detail", battery_id=battery_id))
|
||||||
increment = 1 if request.form.get("increment_cycles") else 0
|
increment = 1 if request.form.get("increment_cycles") else 0
|
||||||
notes = request.form.get("notes", "").strip() or None
|
notes = request.form.get("notes", "").strip() or None
|
||||||
@@ -544,9 +564,9 @@ def create_app(config_object="config"):
|
|||||||
label = field_name.replace("_", " ").title()
|
label = field_name.replace("_", " ").title()
|
||||||
flash(f"Set {label} on {n} batter{'y' if n == 1 else 'ies'}.", "success")
|
flash(f"Set {label} on {n} batter{'y' if n == 1 else 'ies'}.", "success")
|
||||||
elif action == "log_charged":
|
elif action == "log_charged":
|
||||||
date_val = request.form.get("charged_date", "").strip()
|
date_val = _parse_date(request.form.get("charged_date", "").strip())
|
||||||
if not date_val:
|
if not date_val:
|
||||||
flash("Date is required.", "error")
|
flash("A valid date (YYYY-MM-DD) is required.", "error")
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
increment = 1 if request.form.get("increment_cycles") else 0
|
increment = 1 if request.form.get("increment_cycles") else 0
|
||||||
for b in batteries:
|
for b in batteries:
|
||||||
@@ -857,4 +877,4 @@ def create_app(config_object="config"):
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True)
|
app.run(debug=False)
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"sqlite:///batteries.db",
|
"sqlite:///batteries.db",
|
||||||
)
|
)
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod")
|
|
||||||
|
_secret_key = os.environ.get("SECRET_KEY")
|
||||||
|
if not _secret_key:
|
||||||
|
logging.warning(
|
||||||
|
"SECRET_KEY not set — using insecure default. "
|
||||||
|
"Set SECRET_KEY env var before running in production."
|
||||||
|
)
|
||||||
|
SECRET_KEY = _secret_key or "dev-secret-change-in-prod"
|
||||||
|
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
# Home Assistant integration (all optional — app works normally when absent)
|
# Home Assistant integration (all optional — app works normally when absent)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
Flask>=3.0,<4.0
|
Flask>=3.0,<4.0
|
||||||
|
Flask-WTF>=1.2,<2.0
|
||||||
SQLAlchemy>=2.0,<3.0
|
SQLAlchemy>=2.0,<3.0
|
||||||
PyMySQL>=1.1,<2.0
|
PyMySQL>=1.1,<2.0
|
||||||
waitress>=3.0,<4.0
|
waitress>=3.0,<4.0
|
||||||
|
|||||||
@@ -349,6 +349,18 @@
|
|||||||
navigator.serviceWorker.register('/sw.js');
|
navigator.serviceWorker.register('/sw.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject CSRF token into all POST forms
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var token = '{{ csrf_token() }}';
|
||||||
|
document.querySelectorAll('form').forEach(function(form) {
|
||||||
|
if (form.method.toLowerCase() === 'post') {
|
||||||
|
var inp = document.createElement('input');
|
||||||
|
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
|
||||||
|
form.appendChild(inp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
var modal = document.getElementById('confirm-modal');
|
var modal = document.getElementById('confirm-modal');
|
||||||
var msgEl = document.getElementById('confirm-modal-msg');
|
var msgEl = document.getElementById('confirm-modal-msg');
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ function metaSelectChanged(sel, inputId) {
|
|||||||
(function() {
|
(function() {
|
||||||
var canvas = document.getElementById('pct-chart');
|
var canvas = document.getElementById('pct-chart');
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
var rawLogs = {{ pct_logs_json | safe }};
|
var rawLogs = {{ pct_logs_data | tojson }};
|
||||||
// pct_logs_json is ordered newest-first; chart wants oldest-first
|
// pct_logs_json is ordered newest-first; chart wants oldest-first
|
||||||
var logsAsc = rawLogs.slice().reverse();
|
var logsAsc = rawLogs.slice().reverse();
|
||||||
var vals = logsAsc.map(function(l) { return l.pct; });
|
var vals = logsAsc.map(function(l) { return l.pct; });
|
||||||
@@ -472,14 +472,15 @@ function metaSelectChanged(sel, inputId) {
|
|||||||
<style>
|
<style>
|
||||||
.hist-modal { display:none; position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.45); align-items:center; justify-content:center; }
|
.hist-modal { display:none; position:fixed; inset:0; z-index:1000; background:rgba(0,0,0,.45); align-items:center; justify-content:center; }
|
||||||
.hist-modal.open { display:flex; }
|
.hist-modal.open { display:flex; }
|
||||||
.hist-modal-box { background:#fff; border-radius:8px; width:min(700px,96vw); max-height:85vh; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 8px 32px rgba(0,0,0,.2); }
|
.hist-modal-box { background:var(--bg-card,#fff); color:var(--text-body,#222); border-radius:8px; width:min(700px,96vw); max-height:85vh; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 8px 32px rgba(0,0,0,.2); }
|
||||||
.hist-modal-header { display:flex; align-items:center; justify-content:space-between; padding:0.9rem 1.1rem 0.7rem; border-bottom:1px solid var(--border,#e2e8f0); }
|
.hist-modal-header { display:flex; align-items:center; justify-content:space-between; padding:0.9rem 1.1rem 0.7rem; border-bottom:1px solid var(--border,#e2e8f0); }
|
||||||
.hist-modal-header h3 { margin:0; font-size:1.05rem; }
|
.hist-modal-header h3 { margin:0; font-size:1.05rem; color:var(--text-h2,#334155); }
|
||||||
.hist-modal-close { background:none; border:none; font-size:1.2rem; cursor:pointer; color:var(--text-muted,#6b7280); line-height:1; padding:0.2rem 0.4rem; }
|
.hist-modal-close { background:none; border:none; font-size:1.2rem; cursor:pointer; color:var(--text-muted,#6b7280); line-height:1; padding:0.2rem 0.4rem; }
|
||||||
.hist-modal-filters { padding:0.6rem 1.1rem; border-bottom:1px solid var(--border,#e2e8f0); display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; }
|
.hist-modal-filters { padding:0.6rem 1.1rem; border-bottom:1px solid var(--border,#e2e8f0); display:flex; gap:0.75rem; align-items:center; flex-wrap:wrap; }
|
||||||
.hist-modal-filters:empty { display:none; }
|
.hist-modal-filters:empty { display:none; }
|
||||||
.hist-modal-body { overflow-y:auto; flex:1; padding:0.5rem 0.5rem; }
|
.hist-modal-body { overflow-y:auto; flex:1; padding:0.5rem 0.5rem; }
|
||||||
.hist-modal-footer { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1.1rem; border-top:1px solid var(--border,#e2e8f0); font-size:0.85rem; color:var(--text-muted,#6b7280); }
|
.hist-modal-footer { display:flex; align-items:center; justify-content:space-between; padding:0.6rem 1.1rem; border-top:1px solid var(--border,#e2e8f0); font-size:0.85rem; color:var(--text-muted,#6b7280); }
|
||||||
|
.hist-modal-filters select { background:var(--bg-input,#fff); color:var(--text-body,#222); border:1px solid var(--border-input,#d1d5db); border-radius:4px; padding:0.3rem 0.5rem; font-size:0.875rem; font-family:inherit; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Capacity modal -->
|
<!-- Capacity modal -->
|
||||||
@@ -549,6 +550,14 @@ function metaSelectChanged(sel, inputId) {
|
|||||||
var HIST_PAGE = 20;
|
var HIST_PAGE = 20;
|
||||||
var _batteryId = {{ battery.id }};
|
var _batteryId = {{ battery.id }};
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
function openHistModal(id) {
|
function openHistModal(id) {
|
||||||
document.getElementById(id).classList.add('open');
|
document.getElementById(id).classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -618,7 +627,7 @@ function makeModal(cfg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Capacity modal ────────────────────────────────────────────────────────
|
// ── Capacity modal ────────────────────────────────────────────────────────
|
||||||
var _capAll = {{ capacity_tests_json | safe }};
|
var _capAll = {{ capacity_tests_data | tojson }};
|
||||||
var capModal = makeModal({
|
var capModal = makeModal({
|
||||||
all: _capAll,
|
all: _capAll,
|
||||||
bodyId: 'cap-modal-body', prevId: 'cap-prev', nextId: 'cap-next', pageInfoId: 'cap-page-info',
|
bodyId: 'cap-modal-body', prevId: 'cap-prev', nextId: 'cap-next', pageInfoId: 'cap-page-info',
|
||||||
@@ -626,9 +635,9 @@ var capModal = makeModal({
|
|||||||
thead: '<tr><th>Date</th><th>Capacity</th><th>Notes</th><th></th></tr>',
|
thead: '<tr><th>Date</th><th>Capacity</th><th>Notes</th><th></th></tr>',
|
||||||
renderRow: function(r) {
|
renderRow: function(r) {
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td data-label="Date">' + r.date + '</td>' +
|
'<td data-label="Date">' + escHtml(r.date) + '</td>' +
|
||||||
'<td data-label="Capacity">' + r.mah + ' mAh</td>' +
|
'<td data-label="Capacity">' + r.mah + ' mAh</td>' +
|
||||||
'<td data-label="Notes" class="text-muted">' + (r.notes || '—') + '</td>' +
|
'<td data-label="Notes" class="text-muted">' + (escHtml(r.notes) || '—') + '</td>' +
|
||||||
'<td data-label="">' +
|
'<td data-label="">' +
|
||||||
'<form class="inline" method="post" ' +
|
'<form class="inline" method="post" ' +
|
||||||
'action="/battery/' + _batteryId + '/capacity-test/' + r.id + '/delete" ' +
|
'action="/battery/' + _batteryId + '/capacity-test/' + r.id + '/delete" ' +
|
||||||
@@ -645,7 +654,7 @@ document.querySelector('[onclick="openHistModal(\'cap-modal\')"]') &&
|
|||||||
document.querySelector('[onclick="openHistModal(\'cap-modal\')"]').addEventListener('click', function() { capModal.init(); });
|
document.querySelector('[onclick="openHistModal(\'cap-modal\')"]').addEventListener('click', function() { capModal.init(); });
|
||||||
|
|
||||||
// ── Charge modal ──────────────────────────────────────────────────────────
|
// ── Charge modal ──────────────────────────────────────────────────────────
|
||||||
var _chgAll = {{ charge_logs_json | safe }};
|
var _chgAll = {{ charge_logs_data | tojson }};
|
||||||
var chgModal = makeModal({
|
var chgModal = makeModal({
|
||||||
all: _chgAll,
|
all: _chgAll,
|
||||||
bodyId: 'chg-modal-body', prevId: 'chg-prev', nextId: 'chg-next', pageInfoId: 'chg-page-info',
|
bodyId: 'chg-modal-body', prevId: 'chg-prev', nextId: 'chg-next', pageInfoId: 'chg-page-info',
|
||||||
@@ -653,9 +662,9 @@ var chgModal = makeModal({
|
|||||||
thead: '<tr><th>Date</th><th>+Cycle</th><th>Notes</th><th></th></tr>',
|
thead: '<tr><th>Date</th><th>+Cycle</th><th>Notes</th><th></th></tr>',
|
||||||
renderRow: function(r) {
|
renderRow: function(r) {
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td data-label="Date">' + r.date + '</td>' +
|
'<td data-label="Date">' + escHtml(r.date) + '</td>' +
|
||||||
'<td data-label="+Cycle">' + (r.cycles ? '✓' : '—') + '</td>' +
|
'<td data-label="+Cycle">' + (r.cycles ? '✓' : '—') + '</td>' +
|
||||||
'<td data-label="Notes" class="text-muted">' + (r.notes || '—') + '</td>' +
|
'<td data-label="Notes" class="text-muted">' + (escHtml(r.notes) || '—') + '</td>' +
|
||||||
'<td data-label="">' +
|
'<td data-label="">' +
|
||||||
'<form class="inline" method="post" ' +
|
'<form class="inline" method="post" ' +
|
||||||
'action="/battery/' + _batteryId + '/charge-log/' + r.id + '/delete" ' +
|
'action="/battery/' + _batteryId + '/charge-log/' + r.id + '/delete" ' +
|
||||||
@@ -669,7 +678,7 @@ document.querySelector('[onclick="openHistModal(\'chg-modal\')"]') &&
|
|||||||
document.querySelector('[onclick="openHistModal(\'chg-modal\')"]').addEventListener('click', function() { chgModal.init(); });
|
document.querySelector('[onclick="openHistModal(\'chg-modal\')"]').addEventListener('click', function() { chgModal.init(); });
|
||||||
|
|
||||||
// ── Percentage modal ──────────────────────────────────────────────────────
|
// ── Percentage modal ──────────────────────────────────────────────────────
|
||||||
var _pctAll = {{ pct_logs_json | safe }};
|
var _pctAll = {{ pct_logs_data | tojson }};
|
||||||
var pctModal = makeModal({
|
var pctModal = makeModal({
|
||||||
all: _pctAll,
|
all: _pctAll,
|
||||||
bodyId: 'pct-modal-body', prevId: 'pct-prev', nextId: 'pct-next', pageInfoId: 'pct-page-info',
|
bodyId: 'pct-modal-body', prevId: 'pct-prev', nextId: 'pct-next', pageInfoId: 'pct-page-info',
|
||||||
@@ -680,9 +689,9 @@ var pctModal = makeModal({
|
|||||||
? '<span class="badge badge-warning">⚠ ' + r.pct + '%</span>'
|
? '<span class="badge badge-warning">⚠ ' + r.pct + '%</span>'
|
||||||
: r.pct + '%';
|
: r.pct + '%';
|
||||||
return '<tr>' +
|
return '<tr>' +
|
||||||
'<td data-label="Date / Time">' + r.recorded_at + '</td>' +
|
'<td data-label="Date / Time">' + escHtml(r.recorded_at) + '</td>' +
|
||||||
'<td data-label="%">' + pctHtml + '</td>' +
|
'<td data-label="%">' + pctHtml + '</td>' +
|
||||||
'<td data-label="Source" class="text-muted">' + (r.source || '—') + '</td>' +
|
'<td data-label="Source" class="text-muted">' + (escHtml(r.source) || '—') + '</td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ def app():
|
|||||||
SECRET_KEY = "test-secret"
|
SECRET_KEY = "test-secret"
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
TESTING = True
|
TESTING = True
|
||||||
|
WTF_CSRF_ENABLED = False # disable CSRF validation in tests
|
||||||
HOMEASSISTANT_URL = None # prevent HA poller from starting in tests
|
HOMEASSISTANT_URL = None # prevent HA poller from starting in tests
|
||||||
HOMEASSISTANT_API_KEY = None
|
HOMEASSISTANT_API_KEY = None
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ def ha_app():
|
|||||||
SECRET_KEY = "test-secret"
|
SECRET_KEY = "test-secret"
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
TESTING = True
|
TESTING = True
|
||||||
|
WTF_CSRF_ENABLED = False # disable CSRF validation in tests
|
||||||
HOMEASSISTANT_URL = "http://ha.test:8123"
|
HOMEASSISTANT_URL = "http://ha.test:8123"
|
||||||
HOMEASSISTANT_API_KEY = "fake-token"
|
HOMEASSISTANT_API_KEY = "fake-token"
|
||||||
HOMEASSISTANT_POLL_INTERVAL = 300
|
HOMEASSISTANT_POLL_INTERVAL = 300
|
||||||
|
|||||||
Reference in New Issue
Block a user