mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 18:55:48 -07:00
Added Device API Endpoints (Partway)
This commit is contained in:
@@ -24,7 +24,7 @@ except ImportError: # pragma: no cover - fallback for minimal test environments
|
||||
"""Stand-in exception when the requests module is unavailable."""
|
||||
|
||||
def get(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise RuntimeError("The 'requests' library is required for repository hash lookups.")
|
||||
raise self.RequestException("The 'requests' library is required for repository hash lookups.")
|
||||
|
||||
requests = _RequestsStub() # type: ignore
|
||||
|
||||
@@ -83,6 +83,16 @@ def _is_internal_request(remote_addr: Optional[str]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _row_to_site(row: Tuple[Any, ...]) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"description": row[2] or "",
|
||||
"created_at": row[3] or 0,
|
||||
"device_count": row[4] or 0,
|
||||
}
|
||||
|
||||
|
||||
class RepositoryHashCache:
|
||||
"""Lightweight GitHub head cache with on-disk persistence."""
|
||||
|
||||
@@ -338,6 +348,14 @@ class DeviceManagementService:
|
||||
return {"error": "unauthorized"}, 401
|
||||
return None
|
||||
|
||||
def _require_admin(self) -> Optional[Tuple[Dict[str, Any], int]]:
|
||||
user = self._current_user()
|
||||
if not user:
|
||||
return {"error": "unauthorized"}, 401
|
||||
if (user.get("role") or "").lower() != "admin":
|
||||
return {"error": "forbidden"}, 403
|
||||
return None
|
||||
|
||||
def _build_device_payload(
|
||||
self,
|
||||
row: Tuple[Any, ...],
|
||||
@@ -737,6 +755,224 @@ class DeviceManagementService:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Site management helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_sites(self) -> Tuple[Dict[str, Any], int]:
|
||||
conn = self._db_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id,
|
||||
s.name,
|
||||
s.description,
|
||||
s.created_at,
|
||||
COALESCE(ds.cnt, 0) AS device_count
|
||||
FROM sites AS s
|
||||
LEFT JOIN (
|
||||
SELECT site_id, COUNT(*) AS cnt
|
||||
FROM device_sites
|
||||
GROUP BY site_id
|
||||
) AS ds ON ds.site_id = s.id
|
||||
ORDER BY LOWER(s.name) ASC
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
sites = [_row_to_site(row) for row in rows]
|
||||
return {"sites": sites}, 200
|
||||
except Exception as exc:
|
||||
self.logger.debug("Failed to list sites", exc_info=True)
|
||||
return {"error": str(exc)}, 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def create_site(self, name: str, description: str) -> Tuple[Dict[str, Any], int]:
|
||||
if not name:
|
||||
return {"error": "name is required"}, 400
|
||||
now = int(time.time())
|
||||
conn = self._db_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO sites(name, description, created_at) VALUES (?, ?, ?)",
|
||||
(name, description, now),
|
||||
)
|
||||
site_id = cur.lastrowid
|
||||
conn.commit()
|
||||
cur.execute(
|
||||
"SELECT id, name, description, created_at, 0 FROM sites WHERE id = ?",
|
||||
(site_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return {"error": "creation_failed"}, 500
|
||||
return _row_to_site(row), 201
|
||||
except sqlite3.IntegrityError:
|
||||
conn.rollback()
|
||||
return {"error": "name already exists"}, 409
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
self.logger.debug("Failed to create site", exc_info=True)
|
||||
return {"error": str(exc)}, 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_sites(self, ids: List[Any]) -> Tuple[Dict[str, Any], int]:
|
||||
if not isinstance(ids, list) or not all(isinstance(x, (int, str)) for x in ids):
|
||||
return {"error": "ids must be a list"}, 400
|
||||
norm_ids: List[int] = []
|
||||
for value in ids:
|
||||
try:
|
||||
norm_ids.append(int(value))
|
||||
except Exception:
|
||||
continue
|
||||
if not norm_ids:
|
||||
return {"status": "ok", "deleted": 0}, 200
|
||||
conn = self._db_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
placeholders = ",".join("?" * len(norm_ids))
|
||||
cur.execute(
|
||||
f"DELETE FROM device_sites WHERE site_id IN ({placeholders})",
|
||||
tuple(norm_ids),
|
||||
)
|
||||
cur.execute(
|
||||
f"DELETE FROM sites WHERE id IN ({placeholders})",
|
||||
tuple(norm_ids),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": deleted}, 200
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
self.logger.debug("Failed to delete sites", exc_info=True)
|
||||
return {"error": str(exc)}, 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def sites_device_map(self, hostnames: Optional[str]) -> Tuple[Dict[str, Any], int]:
|
||||
filter_set: set[str] = set()
|
||||
if hostnames:
|
||||
for part in hostnames.split(","):
|
||||
candidate = part.strip()
|
||||
if candidate:
|
||||
filter_set.add(candidate)
|
||||
conn = self._db_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if filter_set:
|
||||
placeholders = ",".join("?" * len(filter_set))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT ds.device_hostname, s.id, s.name
|
||||
FROM device_sites ds
|
||||
JOIN sites s ON s.id = ds.site_id
|
||||
WHERE ds.device_hostname IN ({placeholders})
|
||||
""",
|
||||
tuple(filter_set),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ds.device_hostname, s.id, s.name
|
||||
FROM device_sites ds
|
||||
JOIN sites s ON s.id = ds.site_id
|
||||
"""
|
||||
)
|
||||
mapping: Dict[str, Dict[str, Any]] = {}
|
||||
for hostname, site_id, site_name in cur.fetchall():
|
||||
mapping[str(hostname)] = {"site_id": site_id, "site_name": site_name}
|
||||
return {"mapping": mapping}, 200
|
||||
except Exception as exc:
|
||||
self.logger.debug("Failed to build site device map", exc_info=True)
|
||||
return {"error": str(exc)}, 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def assign_devices(self, site_id: Any, hostnames: List[str]) -> Tuple[Dict[str, Any], int]:
|
||||
try:
|
||||
site_id_int = int(site_id)
|
||||
except Exception:
|
||||
return {"error": "invalid site_id"}, 400
|
||||
if not isinstance(hostnames, list) or not all(isinstance(h, str) and h.strip() for h in hostnames):
|
||||
return {"error": "hostnames must be a list of strings"}, 400
|
||||
now = int(time.time())
|
||||
conn = self._db_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT 1 FROM sites WHERE id = ?", (site_id_int,))
|
||||
if not cur.fetchone():
|
||||
return {"error": "site not found"}, 404
|
||||
for hostname in hostnames:
|
||||
hn = hostname.strip()
|
||||
if not hn:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO device_sites(device_hostname, site_id, assigned_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(device_hostname)
|
||||
DO UPDATE SET site_id=excluded.site_id, assigned_at=excluded.assigned_at
|
||||
""",
|
||||
(hn, site_id_int, now),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}, 200
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
self.logger.debug("Failed to assign devices to site", exc_info=True)
|
||||
return {"error": str(exc)}, 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def rename_site(self, site_id: Any, new_name: str) -> Tuple[Dict[str, Any], int]:
|
||||
try:
|
||||
site_id_int = int(site_id)
|
||||
except Exception:
|
||||
return {"error": "invalid id"}, 400
|
||||
if not new_name:
|
||||
return {"error": "new_name is required"}, 400
|
||||
conn = self._db_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE sites SET name = ? WHERE id = ?", (new_name, site_id_int))
|
||||
if cur.rowcount == 0:
|
||||
conn.rollback()
|
||||
return {"error": "site not found"}, 404
|
||||
conn.commit()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id,
|
||||
s.name,
|
||||
s.description,
|
||||
s.created_at,
|
||||
COALESCE(ds.cnt, 0) AS device_count
|
||||
FROM sites AS s
|
||||
LEFT JOIN (
|
||||
SELECT site_id, COUNT(*) AS cnt
|
||||
FROM device_sites
|
||||
GROUP BY site_id
|
||||
) ds ON ds.site_id = s.id
|
||||
WHERE s.id = ?
|
||||
""",
|
||||
(site_id_int,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return {"error": "site not found"}, 404
|
||||
return _row_to_site(row), 200
|
||||
except sqlite3.IntegrityError:
|
||||
conn.rollback()
|
||||
return {"error": "name already exists"}, 409
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
self.logger.debug("Failed to rename site", exc_info=True)
|
||||
return {"error": str(exc)}, 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def repo_current_hash(self) -> Tuple[Dict[str, Any], int]:
|
||||
repo = (request.args.get("repo") or "bunny-lab-io/Borealis").strip()
|
||||
branch = (request.args.get("branch") or "main").strip()
|
||||
@@ -882,6 +1118,59 @@ def register_management(app, adapters: "LegacyServiceAdapters") -> None:
|
||||
payload, status = service.delete_view(view_id)
|
||||
return jsonify(payload), status
|
||||
|
||||
@blueprint.route("/api/sites", methods=["GET"])
|
||||
def _sites_list():
|
||||
payload, status = service.list_sites()
|
||||
return jsonify(payload), status
|
||||
|
||||
@blueprint.route("/api/sites", methods=["POST"])
|
||||
def _sites_create():
|
||||
requirement = service._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
description = (data.get("description") or "").strip()
|
||||
payload, status = service.create_site(name, description)
|
||||
return jsonify(payload), status
|
||||
|
||||
@blueprint.route("/api/sites/delete", methods=["POST"])
|
||||
def _sites_delete():
|
||||
requirement = service._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
data = request.get_json(silent=True) or {}
|
||||
ids = data.get("ids") or []
|
||||
payload, status = service.delete_sites(ids)
|
||||
return jsonify(payload), status
|
||||
|
||||
@blueprint.route("/api/sites/device_map", methods=["GET"])
|
||||
def _sites_device_map():
|
||||
payload, status = service.sites_device_map(request.args.get("hostnames"))
|
||||
return jsonify(payload), status
|
||||
|
||||
@blueprint.route("/api/sites/assign", methods=["POST"])
|
||||
def _sites_assign():
|
||||
requirement = service._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
data = request.get_json(silent=True) or {}
|
||||
payload, status = service.assign_devices(data.get("site_id"), data.get("hostnames") or [])
|
||||
return jsonify(payload), status
|
||||
|
||||
@blueprint.route("/api/sites/rename", methods=["POST"])
|
||||
def _sites_rename():
|
||||
requirement = service._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
data = request.get_json(silent=True) or {}
|
||||
payload, status = service.rename_site(data.get("id"), (data.get("new_name") or "").strip())
|
||||
return jsonify(payload), status
|
||||
|
||||
@blueprint.route("/api/repo/current_hash", methods=["GET"])
|
||||
def _repo_current_hash():
|
||||
payload, status = service.repo_current_hash()
|
||||
|
||||
Reference in New Issue
Block a user