leaf-portal 0.0.0.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leaf_portal-0.0.0.5/PKG-INFO +15 -0
- leaf_portal-0.0.0.5/leaf_portal/__init__.py +0 -0
- leaf_portal-0.0.0.5/leaf_portal/__main__.py +16 -0
- leaf_portal-0.0.0.5/leaf_portal/alarm_checker.py +223 -0
- leaf_portal-0.0.0.5/leaf_portal/auth.py +158 -0
- leaf_portal-0.0.0.5/leaf_portal/db.py +117 -0
- leaf_portal-0.0.0.5/leaf_portal/deploy.sql +1000 -0
- leaf_portal-0.0.0.5/leaf_portal/images/icon.icns +0 -0
- leaf_portal-0.0.0.5/leaf_portal/images/icon.ico +0 -0
- leaf_portal-0.0.0.5/leaf_portal/images/icon.svg +70 -0
- leaf_portal-0.0.0.5/leaf_portal/layout.py +103 -0
- leaf_portal-0.0.0.5/leaf_portal/mail.py +54 -0
- leaf_portal-0.0.0.5/leaf_portal/main.py +72 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/__init__.py +0 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/admin/__init__.py +0 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/admin/access_management.py +484 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/admin/departments.py +163 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/admin/dept_members.py +307 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/admin/mapper.py +271 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/admin/organisations.py +169 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/admin/settings.py +113 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/admin/users.py +416 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/alarms.py +764 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/api.py +239 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/dashboard.py +87 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/data/__init__.py +0 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/data/explorer.py +306 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/data/plots.py +242 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/login.py +341 -0
- leaf_portal-0.0.0.5/leaf_portal/pages/tokens.py +299 -0
- leaf_portal-0.0.0.5/pyproject.toml +39 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: leaf-portal
|
|
3
|
+
Version: 0.0.0.5
|
|
4
|
+
Summary: LEAF Portal
|
|
5
|
+
Requires-Python: >=3.12,<4.0
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
10
|
+
Requires-Dist: asyncpg (>=0.29.0)
|
|
11
|
+
Requires-Dist: bcrypt (>=4.2.0)
|
|
12
|
+
Requires-Dist: nicegui[testing] (>=3.8.0)
|
|
13
|
+
Requires-Dist: pandas (>=2.0.0)
|
|
14
|
+
Requires-Dist: plotly (>=5.20.0)
|
|
15
|
+
Requires-Dist: python-dotenv (>=1.0.0)
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Entry point for `leaf-portal` CLI and `python -m leaf_portal`."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import runpy
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
pkg_dir = Path(__file__).parent
|
|
10
|
+
if str(pkg_dir) not in sys.path:
|
|
11
|
+
sys.path.insert(0, str(pkg_dir))
|
|
12
|
+
runpy.run_path(str(pkg_dir / "main.py"), run_name="__main__")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if __name__ == "__main__":
|
|
16
|
+
main()
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
import db
|
|
6
|
+
import mail
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# Base polling interval — how often the loop wakes up to see which rules are due.
|
|
11
|
+
# Individual rules control their own check_interval (stored in alarm_rule.check_interval).
|
|
12
|
+
_BASE_INTERVAL = 60 # seconds
|
|
13
|
+
|
|
14
|
+
_OPERATORS = {
|
|
15
|
+
"<": lambda v, t: v < t,
|
|
16
|
+
">": lambda v, t: v > t,
|
|
17
|
+
"<=": lambda v, t: v <= t,
|
|
18
|
+
">=": lambda v, t: v >= t,
|
|
19
|
+
"=": lambda v, t: v == t,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _condition_met(operator: str, value: float, threshold: float) -> bool:
|
|
24
|
+
fn = _OPERATORS.get(operator)
|
|
25
|
+
return fn(value, threshold) if fn else False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _check_rules() -> None:
|
|
29
|
+
# Only fetch rules whose check_interval has elapsed since last_checked_at
|
|
30
|
+
rules = await db.fetch(
|
|
31
|
+
"""
|
|
32
|
+
SELECT id, name, department_id, entity, metric, operator, threshold, check_interval
|
|
33
|
+
FROM alarm_rule
|
|
34
|
+
WHERE enabled = TRUE
|
|
35
|
+
AND (
|
|
36
|
+
last_checked_at IS NULL
|
|
37
|
+
OR last_checked_at < NOW() - (check_interval || ' seconds')::INTERVAL
|
|
38
|
+
)
|
|
39
|
+
"""
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
for rule in rules:
|
|
43
|
+
rule_id = str(rule["id"])
|
|
44
|
+
rule_name = rule["name"]
|
|
45
|
+
department_id = str(rule["department_id"])
|
|
46
|
+
entity = rule["entity"]
|
|
47
|
+
metric = rule["metric"]
|
|
48
|
+
operator = rule["operator"]
|
|
49
|
+
threshold = rule["threshold"]
|
|
50
|
+
|
|
51
|
+
# Stamp last_checked_at immediately so concurrent checker instances don't double-fire
|
|
52
|
+
await db.execute(
|
|
53
|
+
"UPDATE alarm_rule SET last_checked_at = NOW() WHERE id = $1::uuid",
|
|
54
|
+
rule_id,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if operator == "no_data":
|
|
58
|
+
if metric:
|
|
59
|
+
latest = await db.fetchrow(
|
|
60
|
+
"""
|
|
61
|
+
SELECT MAX(time) AS last_seen
|
|
62
|
+
FROM sensor_data
|
|
63
|
+
WHERE department_id = $1::uuid
|
|
64
|
+
AND entity = $2
|
|
65
|
+
AND metric = $3
|
|
66
|
+
""",
|
|
67
|
+
department_id,
|
|
68
|
+
entity,
|
|
69
|
+
metric,
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
latest = await db.fetchrow(
|
|
73
|
+
"""
|
|
74
|
+
SELECT MAX(time) AS last_seen
|
|
75
|
+
FROM sensor_data
|
|
76
|
+
WHERE department_id = $1::uuid
|
|
77
|
+
AND entity = $2
|
|
78
|
+
""",
|
|
79
|
+
department_id,
|
|
80
|
+
entity,
|
|
81
|
+
)
|
|
82
|
+
if latest is None or latest["last_seen"] is None:
|
|
83
|
+
breach = True
|
|
84
|
+
current_value = None
|
|
85
|
+
occurred_at = None
|
|
86
|
+
else:
|
|
87
|
+
minutes_ago = (
|
|
88
|
+
datetime.now(timezone.utc) - latest["last_seen"]
|
|
89
|
+
).total_seconds() / 60
|
|
90
|
+
breach = minutes_ago > threshold
|
|
91
|
+
current_value = round(minutes_ago, 1)
|
|
92
|
+
occurred_at = latest["last_seen"]
|
|
93
|
+
else:
|
|
94
|
+
# Most recent sensor reading for this rule's target
|
|
95
|
+
latest = await db.fetchrow(
|
|
96
|
+
"""
|
|
97
|
+
SELECT value, time
|
|
98
|
+
FROM sensor_data
|
|
99
|
+
WHERE department_id = $1::uuid
|
|
100
|
+
AND entity = $2
|
|
101
|
+
AND metric = $3
|
|
102
|
+
ORDER BY time DESC
|
|
103
|
+
LIMIT 1
|
|
104
|
+
""",
|
|
105
|
+
department_id,
|
|
106
|
+
entity,
|
|
107
|
+
metric,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if latest is None:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
current_value = latest["value"]
|
|
114
|
+
occurred_at = latest["time"]
|
|
115
|
+
breach = _condition_met(operator, current_value, threshold)
|
|
116
|
+
|
|
117
|
+
await db.execute(
|
|
118
|
+
"UPDATE alarm_rule SET last_checked_value = $1 WHERE id = $2::uuid",
|
|
119
|
+
current_value,
|
|
120
|
+
rule_id,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Last alarm event for this rule (determines current state)
|
|
124
|
+
last_event = await db.fetchrow(
|
|
125
|
+
"""
|
|
126
|
+
SELECT status
|
|
127
|
+
FROM alarm_event
|
|
128
|
+
WHERE rule_id = $1::uuid
|
|
129
|
+
ORDER BY occurred_at DESC
|
|
130
|
+
LIMIT 1
|
|
131
|
+
""",
|
|
132
|
+
rule_id,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
last_status = last_event["status"] if last_event else None
|
|
136
|
+
|
|
137
|
+
if breach and last_status != "triggered":
|
|
138
|
+
await db.execute(
|
|
139
|
+
"INSERT INTO alarm_event (rule_id, status, value) VALUES ($1::uuid, 'triggered', $2)",
|
|
140
|
+
rule_id,
|
|
141
|
+
current_value,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
recipients = await db.fetch(
|
|
145
|
+
"""
|
|
146
|
+
SELECT ua.email, ua.name
|
|
147
|
+
FROM alarm_recipient ar
|
|
148
|
+
JOIN user_account ua ON ua.id = ar.user_id
|
|
149
|
+
WHERE ar.rule_id = $1::uuid
|
|
150
|
+
""",
|
|
151
|
+
rule_id,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
subject = f"LEAF Alarm: {rule_name}"
|
|
155
|
+
if operator == "no_data":
|
|
156
|
+
metric_line = f"Metric : {metric}\n" if metric else ""
|
|
157
|
+
body = (
|
|
158
|
+
f'Alarm "{rule_name}" has been triggered.\n\n'
|
|
159
|
+
f"Entity : {entity}\n"
|
|
160
|
+
+ metric_line
|
|
161
|
+
+ f"Condition : no data for > {threshold:.0f} minutes\n"
|
|
162
|
+
) + (
|
|
163
|
+
f"Silent for: {current_value} minutes\n"
|
|
164
|
+
f"Last seen : {occurred_at.strftime('%Y-%m-%d %H:%M:%S UTC')}\n"
|
|
165
|
+
if occurred_at
|
|
166
|
+
else "Last seen : never\n"
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
body = (
|
|
170
|
+
f'Alarm "{rule_name}" has been triggered.\n\n'
|
|
171
|
+
f"Entity : {entity}\n"
|
|
172
|
+
f"Metric : {metric}\n"
|
|
173
|
+
f"Condition : {metric} {operator} {threshold}\n"
|
|
174
|
+
f"Value : {current_value}\n"
|
|
175
|
+
f"Time : {occurred_at.strftime('%Y-%m-%d %H:%M:%S UTC')}\n"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
for recipient in recipients:
|
|
179
|
+
try:
|
|
180
|
+
await mail.send_email(
|
|
181
|
+
to=recipient["email"], subject=subject, body=body
|
|
182
|
+
)
|
|
183
|
+
except Exception:
|
|
184
|
+
logger.exception(
|
|
185
|
+
"Failed to send alarm email to %s for rule %s",
|
|
186
|
+
recipient["email"],
|
|
187
|
+
rule_id,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
logger.info(
|
|
191
|
+
"Alarm triggered: rule=%s entity=%s metric=%s operator=%s",
|
|
192
|
+
rule_id,
|
|
193
|
+
entity,
|
|
194
|
+
metric,
|
|
195
|
+
operator,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
elif not breach and last_status == "triggered":
|
|
199
|
+
await db.execute(
|
|
200
|
+
"INSERT INTO alarm_event (rule_id, status, value) VALUES ($1::uuid, 'resolved', $2)",
|
|
201
|
+
rule_id,
|
|
202
|
+
current_value,
|
|
203
|
+
)
|
|
204
|
+
logger.info(
|
|
205
|
+
"Alarm resolved: rule=%s entity=%s metric=%s operator=%s",
|
|
206
|
+
rule_id,
|
|
207
|
+
entity,
|
|
208
|
+
metric,
|
|
209
|
+
operator,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
async def alarm_checker_loop() -> None:
|
|
214
|
+
logger.info("Alarm checker waiting for schema...")
|
|
215
|
+
while not await db.schema_applied():
|
|
216
|
+
await asyncio.sleep(5)
|
|
217
|
+
logger.info("Alarm checker started (base interval=%ds)", _BASE_INTERVAL)
|
|
218
|
+
while True:
|
|
219
|
+
try:
|
|
220
|
+
await _check_rules()
|
|
221
|
+
except Exception:
|
|
222
|
+
logger.exception("Alarm checker error")
|
|
223
|
+
await asyncio.sleep(_BASE_INTERVAL)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import bcrypt
|
|
2
|
+
import secrets
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from nicegui import app
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import db
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def verify_login(email: str, password: str) -> Optional[dict]:
|
|
10
|
+
"""Return user dict if credentials are valid, else None."""
|
|
11
|
+
row = await db.fetchrow(
|
|
12
|
+
"""
|
|
13
|
+
SELECT id, email, name, is_superadmin, password_hash
|
|
14
|
+
FROM user_account
|
|
15
|
+
WHERE email = $1
|
|
16
|
+
""",
|
|
17
|
+
email.lower().strip(),
|
|
18
|
+
)
|
|
19
|
+
if not row or not row["password_hash"]:
|
|
20
|
+
return None
|
|
21
|
+
if bcrypt.checkpw(password.encode(), row["password_hash"].encode()):
|
|
22
|
+
await db.execute(
|
|
23
|
+
"UPDATE user_account SET last_login_at = now() WHERE id = $1",
|
|
24
|
+
row["id"],
|
|
25
|
+
)
|
|
26
|
+
return {**dict(row), "id": str(row["id"])}
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def hash_password(password: str) -> str:
|
|
31
|
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def set_password(user_id: str, password: str) -> None:
|
|
35
|
+
hashed = await hash_password(password)
|
|
36
|
+
await db.execute(
|
|
37
|
+
"UPDATE user_account SET password_hash = $1 WHERE id = $2",
|
|
38
|
+
hashed,
|
|
39
|
+
user_id,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def current_user() -> Optional[dict]:
|
|
44
|
+
return app.storage.user.get("user")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def sync_session() -> None:
|
|
48
|
+
"""Re-fetch the current user from the DB and update the session cache."""
|
|
49
|
+
if not db.is_connected():
|
|
50
|
+
return
|
|
51
|
+
user = current_user()
|
|
52
|
+
if not user:
|
|
53
|
+
return
|
|
54
|
+
fresh = await db.fetchrow(
|
|
55
|
+
"SELECT id, email, name, is_superadmin FROM user_account WHERE id = $1",
|
|
56
|
+
user["id"],
|
|
57
|
+
)
|
|
58
|
+
if fresh:
|
|
59
|
+
dept_admin_row = await db.fetchrow(
|
|
60
|
+
"SELECT 1 FROM user_department WHERE user_id = $1::uuid AND role = 'admin' LIMIT 1",
|
|
61
|
+
str(fresh["id"]),
|
|
62
|
+
)
|
|
63
|
+
app.storage.user["user"] = {
|
|
64
|
+
**dict(fresh),
|
|
65
|
+
"id": str(fresh["id"]),
|
|
66
|
+
"is_dept_admin": dept_admin_row is not None,
|
|
67
|
+
}
|
|
68
|
+
else:
|
|
69
|
+
# User was deleted — clear session
|
|
70
|
+
app.storage.user.clear()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_authenticated() -> bool:
|
|
74
|
+
if not db.is_connected():
|
|
75
|
+
return False
|
|
76
|
+
return current_user() is not None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_superadmin() -> bool:
|
|
80
|
+
user = current_user()
|
|
81
|
+
return bool(user and user.get("is_superadmin"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_dept_admin() -> bool:
|
|
85
|
+
user = current_user()
|
|
86
|
+
return bool(user and user.get("is_dept_admin"))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def get_admin_department_ids(user_id: str) -> list:
|
|
90
|
+
"""Return department IDs where the user has role='admin'."""
|
|
91
|
+
rows = await db.fetch(
|
|
92
|
+
"SELECT department_id FROM user_department WHERE user_id = $1::uuid AND role = 'admin'",
|
|
93
|
+
user_id,
|
|
94
|
+
)
|
|
95
|
+
return [str(r["department_id"]) for r in rows]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def create_reset_token(email: str) -> Optional[str]:
|
|
99
|
+
"""Generate a password reset token for the given email. Returns token or None if not found."""
|
|
100
|
+
row = await db.fetchrow(
|
|
101
|
+
"SELECT id FROM user_account WHERE email = $1",
|
|
102
|
+
email.lower().strip(),
|
|
103
|
+
)
|
|
104
|
+
if not row:
|
|
105
|
+
return None
|
|
106
|
+
token = secrets.token_urlsafe(32)
|
|
107
|
+
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
|
108
|
+
await db.execute(
|
|
109
|
+
"INSERT INTO password_reset_token (user_id, token, expires_at) VALUES ($1, $2, $3)",
|
|
110
|
+
row["id"],
|
|
111
|
+
token,
|
|
112
|
+
expires_at,
|
|
113
|
+
)
|
|
114
|
+
return token
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def verify_reset_token(token: str) -> Optional[dict]:
|
|
118
|
+
"""Return user + token info if the token is valid, unused, and not expired."""
|
|
119
|
+
row = await db.fetchrow(
|
|
120
|
+
"""
|
|
121
|
+
SELECT ua.id, ua.email, ua.name, prt.id AS token_id
|
|
122
|
+
FROM password_reset_token prt
|
|
123
|
+
JOIN user_account ua ON ua.id = prt.user_id
|
|
124
|
+
WHERE prt.token = $1
|
|
125
|
+
AND prt.used_at IS NULL
|
|
126
|
+
AND prt.expires_at > now()
|
|
127
|
+
""",
|
|
128
|
+
token,
|
|
129
|
+
)
|
|
130
|
+
return dict(row) if row else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def consume_reset_token(token_id: str, user_id: str, new_password: str) -> None:
|
|
134
|
+
"""Mark the token as used and update the user's password atomically."""
|
|
135
|
+
hashed = await hash_password(new_password)
|
|
136
|
+
await db.execute(
|
|
137
|
+
"UPDATE password_reset_token SET used_at = now() WHERE id = $1",
|
|
138
|
+
token_id,
|
|
139
|
+
)
|
|
140
|
+
await db.execute(
|
|
141
|
+
"UPDATE user_account SET password_hash = $1 WHERE id = $2",
|
|
142
|
+
hashed,
|
|
143
|
+
user_id,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def get_user_organisations(user_id: str) -> list:
|
|
148
|
+
"""Return organisations the user is a member of (with role)."""
|
|
149
|
+
return await db.fetch(
|
|
150
|
+
"""
|
|
151
|
+
SELECT i.id, i.name, ui.role
|
|
152
|
+
FROM user_organisation ui
|
|
153
|
+
JOIN organisation i ON i.id = ui.organisation_id
|
|
154
|
+
WHERE ui.user_id = $1
|
|
155
|
+
ORDER BY i.name
|
|
156
|
+
""",
|
|
157
|
+
user_id,
|
|
158
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import importlib.resources
|
|
2
|
+
import os
|
|
3
|
+
import asyncpg
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
_pool: Optional[asyncpg.Pool] = None
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def init_pool() -> None:
|
|
10
|
+
global _pool
|
|
11
|
+
_pool = await asyncpg.create_pool(
|
|
12
|
+
host=os.environ.get("PGHOST", "timescaledb"),
|
|
13
|
+
port=int(os.environ.get("PGPORT", 5432)),
|
|
14
|
+
database=os.environ.get("PGDATABASE", "leaf"),
|
|
15
|
+
user=os.environ.get("PGUSER", "postgres"),
|
|
16
|
+
password=os.environ.get("PGPASSWORD", ""),
|
|
17
|
+
min_size=2,
|
|
18
|
+
max_size=10,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_connected() -> bool:
|
|
23
|
+
return _pool is not None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def schema_applied() -> bool:
|
|
27
|
+
"""Return True if our schema tables exist (no exceptions raised)."""
|
|
28
|
+
if _pool is None:
|
|
29
|
+
return False
|
|
30
|
+
result = await fetchval(
|
|
31
|
+
"SELECT EXISTS (SELECT 1 FROM information_schema.tables"
|
|
32
|
+
" WHERE table_schema = 'public' AND table_name = 'user_account')"
|
|
33
|
+
)
|
|
34
|
+
return bool(result)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def connect(
|
|
38
|
+
host: str, port: int, user: str, password: str, database: str
|
|
39
|
+
) -> None:
|
|
40
|
+
global _pool
|
|
41
|
+
_pool = await asyncpg.create_pool(
|
|
42
|
+
host=host,
|
|
43
|
+
port=port,
|
|
44
|
+
database=database,
|
|
45
|
+
user=user,
|
|
46
|
+
password=password,
|
|
47
|
+
min_size=2,
|
|
48
|
+
max_size=10,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def apply_schema() -> None:
|
|
53
|
+
sql = importlib.resources.files("leaf_portal").joinpath("deploy.sql").read_text()
|
|
54
|
+
async with _pool.acquire() as conn:
|
|
55
|
+
await conn.execute(sql)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def close_pool() -> None:
|
|
59
|
+
if _pool:
|
|
60
|
+
await _pool.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def fetch(sql: str, *args) -> list:
|
|
64
|
+
async with _pool.acquire() as conn:
|
|
65
|
+
return await conn.fetch(sql, *args)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def fetchrow(sql: str, *args) -> Optional[asyncpg.Record]:
|
|
69
|
+
async with _pool.acquire() as conn:
|
|
70
|
+
return await conn.fetchrow(sql, *args)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def fetchval(sql: str, *args):
|
|
74
|
+
async with _pool.acquire() as conn:
|
|
75
|
+
return await conn.fetchval(sql, *args)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def execute(sql: str, *args) -> str:
|
|
79
|
+
async with _pool.acquire() as conn:
|
|
80
|
+
return await conn.execute(sql, *args)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def get_setting(key: str, namespace: str = "general") -> str | None:
|
|
84
|
+
if _pool is None:
|
|
85
|
+
return None
|
|
86
|
+
return await fetchval(
|
|
87
|
+
"SELECT value FROM setting WHERE namespace = $1 AND key = $2",
|
|
88
|
+
namespace,
|
|
89
|
+
key,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def set_setting(key: str, value: str, namespace: str = "general") -> None:
|
|
94
|
+
await execute(
|
|
95
|
+
"""
|
|
96
|
+
INSERT INTO setting (namespace, key, value)
|
|
97
|
+
VALUES ($1, $2, $3)
|
|
98
|
+
ON CONFLICT (namespace, key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()
|
|
99
|
+
""",
|
|
100
|
+
namespace,
|
|
101
|
+
key,
|
|
102
|
+
value,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def fetch_as_user(email: str, sql: str, *args) -> list:
|
|
107
|
+
"""
|
|
108
|
+
Execute a query with RLS scoped to the given user email.
|
|
109
|
+
Uses SET LOCAL inside a transaction so the setting is connection-safe.
|
|
110
|
+
Requires a non-superuser DB account; superusers bypass RLS.
|
|
111
|
+
"""
|
|
112
|
+
async with _pool.acquire() as conn:
|
|
113
|
+
async with conn.transaction():
|
|
114
|
+
await conn.execute(
|
|
115
|
+
"SELECT set_config('app.current_user_email', $1, true)", email
|
|
116
|
+
)
|
|
117
|
+
return await conn.fetch(sql, *args)
|