localeye.cam

← Back

module.py

floor_plan version 1.0 · 11 KB

1"""Floor plan custom module: a clickable map of the building with its areas and cameras.
2
3web(api) the Map page (/x/floor_plan/), the floor plan pictures (/x/floor_plan/plan/<id>) and the API
4 (/api/x/floor_plan/...). Everyone signed in can use the map - they only get the cameras they may see; making and
5 editing maps is for admins. No recorder part: live video and recordings are the NVR's own (the page uses them).
6"""
7import json
8import logging
9import os
10import secrets
11import shutil
12import time
13
14from flask import abort, request, send_file
15
16from .mapdata import DEFAULT_ASPECT, MAX_NAME, MAX_PLAN_BYTES, PLAN_TYPES, clean, for_viewer, plan_info
17
18TABLE = """CREATE TABLE IF NOT EXISTS floor_plans (
19 id INTEGER PRIMARY KEY AUTOINCREMENT,
20 name TEXT NOT NULL,
21 data TEXT NOT NULL, -- {"sections": [...], "cameras": [...]} (mapdata.py)
22 plan TEXT, -- the floor plan picture's file name in the module's data folder (NULL = none yet)
23 plan_w INTEGER,
24 plan_h INTEGER,
25 aspect REAL NOT NULL, -- height / width of the map (the plan's, or DEFAULT_ASPECT)
26 version INTEGER NOT NULL DEFAULT 1, -- +1 on every save: two people editing at once can't overwrite each other unseen
27 updated_at REAL NOT NULL
28)"""
29MAX_MAPS = 50
30LOG = logging.getLogger("localeye.custom.floor_plan")
31OLD_ID, OLD_TABLE = "warehouse_map", "warehouse_maps" # this module's old name: its maps and pictures are brought over
32
33
34def _bring_over(api, plans):
35 """A server that had the module as "Warehouse map": move its maps into floor_plans (only while floor_plans is still empty;
36 copied and the old table emptied in one go - same ids, so the pictures' names still match) and copy the floor plan pictures
37 from the old data folder (any a map uses that aren't here yet). The old data folder is left as it was. (The old table stays,
38 empty: a module can't drop a table with AUTOINCREMENT - SQLite would have to touch sqlite_sequence, which the fence refuses.)"""
39 if api.db.one("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (OLD_TABLE,)):
40 moved = api.db.one(f"SELECT COUNT(*) AS n FROM {OLD_TABLE}")["n"]
41 if moved and not api.db.one("SELECT COUNT(*) AS n FROM floor_plans")["n"]:
42 api.db.script(f"""BEGIN;
43 INSERT INTO floor_plans(id, name, data, plan, plan_w, plan_h, aspect, version, updated_at)
44 SELECT id, name, data, plan, plan_w, plan_h, aspect, version, updated_at FROM {OLD_TABLE};
45 DELETE FROM {OLD_TABLE};
46 COMMIT;""")
47 LOG.info(f"brought {moved} map(s) over from Warehouse map (this module's old name)")
48 old = os.path.join(os.path.dirname(api.data_dir), OLD_ID, "plans")
49 if not os.path.isdir(old):
50 return
51 for r in api.db.query("SELECT plan FROM floor_plans WHERE plan IS NOT NULL"):
52 name = os.path.basename(r["plan"])
53 src, dst = os.path.join(old, name), os.path.join(plans, name)
54 if os.path.isfile(src) and not os.path.exists(dst):
55 os.makedirs(plans, exist_ok=True)
56 shutil.copyfile(src, dst + ".part")
57 os.replace(dst + ".part", dst)
58
59
60def web(api):
61 api.db.script(TABLE)
62 plans = os.path.join(api.data_dir, "plans")
63 try:
64 _bring_over(api, plans)
65 except Exception as exc: # never stop the page over it (the move is all or nothing)
66 LOG.warning(f"couldn't bring the maps over from Warehouse map: {exc}")
67
68 def cams():
69 """The cameras the person asking may see, view-only ones too (live only, no recordings): [{stream, name, view_only?}].
70 An NVR from before cameras(view_only=...) gives just the recording ones."""
71 try:
72 return api.cameras(view_only=True)
73 except TypeError:
74 return api.cameras()
75
76 def known():
77 """Streams the person asking may see (admins: every camera) -> name."""
78 return {c["stream"]: c["name"] for c in cams()}
79
80 def get(mid):
81 row = api.db.one("SELECT * FROM floor_plans WHERE id=?", (mid,))
82 if not row:
83 abort(404)
84 return row
85
86 def public(row, full=True):
87 out = {"id": row["id"], "name": row["name"], "aspect": row["aspect"], "version": row["version"],
88 "plan": f"/x/{api.id}/plan/{row['id']}?v={row['version']}" if row["plan"] else None,
89 "plan_size": [row["plan_w"], row["plan_h"]] if row["plan"] else None, "updated_at": row["updated_at"]}
90 if full:
91 try:
92 data = json.loads(row["data"] or "{}")
93 except ValueError:
94 data = {}
95 out["data"] = for_viewer(data, set(known()))
96 return out
97
98 def body():
99 b = request.get_json(silent=True)
100 return b if isinstance(b, dict) else {}
101
102 @api.route("/")
103 def page():
104 return api.render("map.html", can_edit=api.is_admin(), version=api.manifest.get("version", "1"))
105
106 @api.route("/api/maps")
107 def maps():
108 rows = api.db.query("SELECT * FROM floor_plans ORDER BY name COLLATE NOCASE, id")
109 return {"ok": True, "maps": [public(r, full=False) for r in rows], "can_edit": api.is_admin(),
110 "cameras": [{"stream": c["stream"], "name": c["name"], "view_only": bool(c.get("view_only"))} for c in cams()]}
111
112 @api.route("/api/maps/<int:mid>")
113 def one_map(mid):
114 return {"ok": True, "map": public(get(mid))}
115
116 @api.route("/api/maps", methods=["POST"], admin=True)
117 def create():
118 if api.db.one("SELECT COUNT(*) AS n FROM floor_plans")["n"] >= MAX_MAPS:
119 return {"ok": False, "error": f"{MAX_MAPS} maps at most"}, 400
120 name = " ".join(str(body().get("name") or "").split())[:MAX_NAME] or "Main floor"
121 mid, _ = api.db.execute("INSERT INTO floor_plans(name, data, aspect, updated_at) VALUES(?,?,?,?)",
122 (name, json.dumps({"sections": [], "cameras": []}), DEFAULT_ASPECT, time.time()))
123 api.audit("map_created", name, id=mid)
124 return {"ok": True, "map": public(get(mid))}
125
126 @api.route("/api/maps/<int:mid>", methods=["PUT"], admin=True)
127 def save(mid):
128 row, b = get(mid), body()
129 if b.get("version") != row["version"]:
130 return {"ok": False, "conflict": True,
131 "error": "Someone else saved this map while you were editing it. Reload to see their changes (yours aren't saved)."}, 409
132 try:
133 data = clean(b.get("data"), set(known()))
134 except ValueError as exc:
135 return {"ok": False, "error": f"Not saved: {exc}"}, 400
136 name = " ".join(str(b.get("name") or row["name"]).split())[:MAX_NAME] or row["name"]
137 n = api.db.execute("UPDATE floor_plans SET name=?, data=?, version=version+1, updated_at=? WHERE id=? AND version=?",
138 (name, json.dumps(data, separators=(",", ":")), time.time(), mid, row["version"]))[1]
139 if not n:
140 return {"ok": False, "conflict": True, "error": "Someone else saved this map at the same moment. Reload to see their changes."}, 409
141 api.audit("map_saved", name, id=mid, areas=len(data["sections"]), cameras=len(data["cameras"]))
142 return {"ok": True, "map": public(get(mid))}
143
144 @api.route("/api/maps/<int:mid>", methods=["DELETE"], admin=True)
145 def delete(mid):
146 row = get(mid)
147 _remove_plan(row)
148 api.db.execute("DELETE FROM floor_plans WHERE id=?", (mid,))
149 _sweep()
150 api.audit("map_deleted", row["name"], id=mid)
151 return {"ok": True}
152
153 # ---------------------------------------------------------------- the floor plan picture
154 def _remove_plan(row):
155 if row["plan"]:
156 try:
157 os.remove(os.path.join(plans, row["plan"]))
158 except OSError:
159 pass # e.g. Windows: someone is downloading it right now - _sweep() gets it later
160
161 def _sweep():
162 """Delete pictures no map uses any more (one that couldn't be deleted at the time, a half-finished upload)."""
163 try:
164 names = os.listdir(plans)
165 except OSError:
166 return
167 used = {r["plan"] for r in api.db.query("SELECT plan FROM floor_plans WHERE plan IS NOT NULL")}
168 for n in names:
169 path = os.path.join(plans, n)
170 try:
171 if n not in used and not (n.endswith(".part") and time.time() - os.path.getmtime(path) < 3600):
172 os.remove(path)
173 except OSError:
174 pass
175
176 @api.route("/api/maps/<int:mid>/plan", methods=["POST"], admin=True)
177 def upload_plan(mid):
178 row = get(mid)
179 if (request.content_length or 0) > MAX_PLAN_BYTES + 64 * 1024:
180 return {"ok": False, "error": f"That picture is too big ({MAX_PLAN_BYTES // (1024 * 1024)} MB at most)"}, 413
181 f = request.files.get("plan")
182 if f is None:
183 return {"ok": False, "error": "Choose a picture of the floor plan first"}, 400
184 data = f.read(MAX_PLAN_BYTES + 1)
185 if len(data) > MAX_PLAN_BYTES:
186 return {"ok": False, "error": f"That picture is too big ({MAX_PLAN_BYTES // (1024 * 1024)} MB at most)"}, 413
187 try:
188 kind, w, h = plan_info(data[:256 * 1024])
189 except ValueError as exc:
190 return {"ok": False, "error": str(exc)}, 400
191 os.makedirs(plans, exist_ok=True)
192 name = f"map{mid}-{secrets.token_hex(6)}.{kind}"
193 tmp = os.path.join(plans, name + ".part")
194 with open(tmp, "wb") as out:
195 out.write(data)
196 os.replace(tmp, os.path.join(plans, name))
197 _remove_plan(row)
198 api.db.execute("UPDATE floor_plans SET plan=?, plan_w=?, plan_h=?, aspect=?, version=version+1, updated_at=? WHERE id=?",
199 (name, w, h, round(h / w, 5), time.time(), mid))
200 _sweep()
201 api.audit("plan_uploaded", row["name"], id=mid, size=f"{w}x{h}")
202 return {"ok": True, "map": public(get(mid))}
203
204 @api.route("/api/maps/<int:mid>/plan", methods=["DELETE"], admin=True)
205 def remove_plan(mid):
206 row = get(mid)
207 _remove_plan(row)
208 api.db.execute("UPDATE floor_plans SET plan=NULL, plan_w=NULL, plan_h=NULL, version=version+1, updated_at=? WHERE id=?",
209 (time.time(), mid))
210 _sweep()
211 api.audit("plan_removed", row["name"], id=mid)
212 return {"ok": True, "map": public(get(mid))}
213
214 @api.route("/plan/<int:mid>")
215 def plan_file(mid):
216 row = get(mid)
217 if not row["plan"]:
218 abort(404)
219 path = os.path.join(plans, os.path.basename(row["plan"]))
220 if not os.path.isfile(path):
221 abort(404)
222 resp = send_file(path, mimetype=PLAN_TYPES.get(row["plan"].rsplit(".", 1)[-1], "application/octet-stream"), conditional=True)
223 resp.headers["Cache-Control"] = "private, max-age=86400" # the URL changes (?v=) whenever the picture does
224 resp.headers["X-Content-Type-Options"] = "nosniff"
225 return resp