hoja-cli 0.21.4__py3-none-any.whl
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.
- VERSION +1 -0
- google_workspace/__init__.py +19 -0
- google_workspace/client.py +253 -0
- google_workspace/config.py +39 -0
- google_workspace/errors.py +19 -0
- google_workspace/mcp_server.py +100 -0
- google_workspace/oauth.py +131 -0
- google_workspace/principal.py +83 -0
- google_workspace/py.typed +0 -0
- google_workspace/tools.py +64 -0
- google_workspace/writes.py +44 -0
- hoja/__init__.py +29 -0
- hoja/__main__.py +6 -0
- hoja/auth.py +42 -0
- hoja/cli.py +161 -0
- hoja/cli_state.py +55 -0
- hoja/client.py +268 -0
- hoja/commands/__init__.py +4 -0
- hoja/commands/auth_cmd.py +271 -0
- hoja/commands/comment.py +149 -0
- hoja/commands/cycle.py +75 -0
- hoja/commands/init.py +108 -0
- hoja/commands/issue.py +398 -0
- hoja/commands/label.py +163 -0
- hoja/commands/memory.py +240 -0
- hoja/commands/project.py +56 -0
- hoja/commands/raw.py +50 -0
- hoja/commands/team.py +102 -0
- hoja/commands/update.py +162 -0
- hoja/config.py +362 -0
- hoja/errors.py +107 -0
- hoja/filters.py +150 -0
- hoja/graphql/__init__.py +5 -0
- hoja/graphql/fragments.py +138 -0
- hoja/graphql/mutations.py +160 -0
- hoja/graphql/queries.py +245 -0
- hoja/mcp/__init__.py +27 -0
- hoja/mcp/content.py +151 -0
- hoja/mcp/prompts.py +298 -0
- hoja/mcp/resources.py +213 -0
- hoja/mcp/server.py +189 -0
- hoja/memory_board.py +370 -0
- hoja/models/__init__.py +37 -0
- hoja/models/base.py +64 -0
- hoja/models/cycle.py +32 -0
- hoja/models/enums.py +62 -0
- hoja/models/issue.py +99 -0
- hoja/models/label.py +17 -0
- hoja/models/project.py +60 -0
- hoja/models/team.py +31 -0
- hoja/models/user.py +32 -0
- hoja/models/workflow.py +65 -0
- hoja/output.py +410 -0
- hoja/skill_content/commands.md +222 -0
- hoja/skill_content/examples.md +193 -0
- hoja/skill_content/filters.md +101 -0
- hoja/skill_content/output-formats.md +92 -0
- hoja/skill_content/overview.md +123 -0
- hoja/skill_content/workflows.md +174 -0
- hoja_cli-0.21.4.dist-info/METADATA +279 -0
- hoja_cli-0.21.4.dist-info/RECORD +107 -0
- hoja_cli-0.21.4.dist-info/WHEEL +4 -0
- hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
- hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
- hoja_server/README.md +135 -0
- hoja_server/__init__.py +11 -0
- hoja_server/abac.py +116 -0
- hoja_server/agent_chat.py +280 -0
- hoja_server/agent_conversations.py +91 -0
- hoja_server/agent_durable.py +332 -0
- hoja_server/agent_prompt.py +117 -0
- hoja_server/agent_threads.py +96 -0
- hoja_server/agent_uploads.py +60 -0
- hoja_server/app.py +2189 -0
- hoja_server/cadence.py +231 -0
- hoja_server/cli.py +191 -0
- hoja_server/cloudflare_realtime.py +110 -0
- hoja_server/comment_policy.py +70 -0
- hoja_server/connector_actions.py +119 -0
- hoja_server/connectors_api.py +375 -0
- hoja_server/db.py +1816 -0
- hoja_server/denials.py +18 -0
- hoja_server/deployment.py +28 -0
- hoja_server/email_adapter.py +94 -0
- hoja_server/google_login.py +217 -0
- hoja_server/google_oidc.py +342 -0
- hoja_server/identity_transfer.py +210 -0
- hoja_server/mcp_oauth.py +623 -0
- hoja_server/mcp_tools.py +579 -0
- hoja_server/meeting_policy.py +179 -0
- hoja_server/meetings.py +1332 -0
- hoja_server/meetings_api.py +909 -0
- hoja_server/model_providers.py +205 -0
- hoja_server/realtime.py +690 -0
- hoja_server/resolvers.py +1281 -0
- hoja_server/schema.graphql +477 -0
- hoja_server/schema_sdl.py +8 -0
- hoja_server/secrets_box.py +39 -0
- hoja_server/signup_config.py +292 -0
- hoja_server/signup_gate.py +393 -0
- hoja_server/signup_policy.py +97 -0
- hoja_server/signup_store.py +577 -0
- hoja_server/store.py +2179 -0
- hoja_server/sync.py +249 -0
- hoja_server/tenant_context.py +131 -0
- hoja_server/turnstile.py +81 -0
- hoja_server/writer.py +1262 -0
hoja_server/store.py
ADDED
|
@@ -0,0 +1,2179 @@
|
|
|
1
|
+
"""Store — org-scoped data access + serialization to Linear camelCase shapes.
|
|
2
|
+
|
|
3
|
+
Every method takes an org_id and NEVER returns rows from other orgs → tenant
|
|
4
|
+
isolation by construction. Serializers emit exactly the camelCase field names
|
|
5
|
+
hoja's Pydantic models expect.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import hmac
|
|
12
|
+
import secrets
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
|
|
15
|
+
from graphql import GraphQLError
|
|
16
|
+
from sqlalchemy import and_, func, or_, select
|
|
17
|
+
from sqlalchemy.engine import Engine
|
|
18
|
+
|
|
19
|
+
from hoja_server.db import (
|
|
20
|
+
api_key,
|
|
21
|
+
attachment,
|
|
22
|
+
change_event,
|
|
23
|
+
browser_session,
|
|
24
|
+
comment,
|
|
25
|
+
cycle,
|
|
26
|
+
issue,
|
|
27
|
+
issue_activity,
|
|
28
|
+
inbox_notification,
|
|
29
|
+
initiative,
|
|
30
|
+
initiative_project,
|
|
31
|
+
organization,
|
|
32
|
+
global_identity,
|
|
33
|
+
organization_membership,
|
|
34
|
+
identity_credential,
|
|
35
|
+
membership_invitation,
|
|
36
|
+
new_id,
|
|
37
|
+
issue_label,
|
|
38
|
+
issue_label_link,
|
|
39
|
+
issue_subscriber,
|
|
40
|
+
project,
|
|
41
|
+
project_update,
|
|
42
|
+
project_member,
|
|
43
|
+
project_team,
|
|
44
|
+
team,
|
|
45
|
+
team_member,
|
|
46
|
+
token_hash,
|
|
47
|
+
password_credential,
|
|
48
|
+
user,
|
|
49
|
+
user_preference,
|
|
50
|
+
workflow_state,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def change_event_dict(r, *, with_org: bool = False) -> dict:
|
|
55
|
+
"""change_event row → ChangeEvent; recipientId only for per-user events (notifications)."""
|
|
56
|
+
out = {"seq": r["seq"], "entity": r["entity"], "entityId": r["entity_id"],
|
|
57
|
+
"action": r["action"], "teamId": r["team_id"]}
|
|
58
|
+
if r.get("recipient_id") is not None:
|
|
59
|
+
out["recipientId"] = r["recipient_id"]
|
|
60
|
+
if with_org:
|
|
61
|
+
out["organization_id"] = r["organization_id"]
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _conn_rows(conn, stmt) -> list[dict]:
|
|
66
|
+
return [dict(r._mapping) for r in conn.execute(stmt)]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _one(conn, stmt) -> dict | None:
|
|
70
|
+
r = conn.execute(stmt).first()
|
|
71
|
+
return dict(r._mapping) if r else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalize_expiry(value: str | None):
|
|
75
|
+
"""Return a UTC ISO timestamp, None for no expiry, or False when invalid or already past."""
|
|
76
|
+
if value is None:
|
|
77
|
+
return None
|
|
78
|
+
if not isinstance(value, str):
|
|
79
|
+
raise GraphQLError("expiresAt must be an ISO 8601 datetime string.",
|
|
80
|
+
extensions={"code": "BAD_USER_INPUT", "field": "expiresAt"})
|
|
81
|
+
try:
|
|
82
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
83
|
+
except ValueError:
|
|
84
|
+
return False
|
|
85
|
+
if parsed.tzinfo is None:
|
|
86
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
87
|
+
parsed = parsed.astimezone(timezone.utc)
|
|
88
|
+
return parsed.isoformat() if parsed > datetime.now(timezone.utc) else False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_MAX_SEARCH_TERM = 256
|
|
92
|
+
_PBKDF2_ITERATIONS = 600_000
|
|
93
|
+
_LEGACY_PBKDF2_ITERATIONS = 310_000
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _check_search_term(value) -> str:
|
|
97
|
+
if not isinstance(value, str):
|
|
98
|
+
raise GraphQLError("Search term must be a string.", extensions={"code": "BAD_USER_INPUT"})
|
|
99
|
+
if len(value) > _MAX_SEARCH_TERM:
|
|
100
|
+
raise GraphQLError(f"Search term must be at most {_MAX_SEARCH_TERM} characters.",
|
|
101
|
+
extensions={"code": "BAD_USER_INPUT"})
|
|
102
|
+
return value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _like_contains(value: str) -> str:
|
|
106
|
+
"""Escape LIKE wildcards so user text matches literally (pair with escape="\\")."""
|
|
107
|
+
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
108
|
+
return f"%{escaped}%"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
PUBLIC, RESTRICTED, PRIVATE = "public", "restricted", "private"
|
|
112
|
+
TEAM_ACCESS_LEVELS = (PUBLIC, RESTRICTED, PRIVATE)
|
|
113
|
+
TEAM_ROLES = ("owner", "member")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _member_team_ids(viewer_id: str):
|
|
117
|
+
"""Subquery of the team ids this viewer holds a team_member row on."""
|
|
118
|
+
return select(team_member.c.team_id).where(team_member.c.user_id == viewer_id)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def visibility_holds(team_cols, viewer_id: str):
|
|
122
|
+
"""SQL predicate over one team row: may ``viewer_id`` see it?
|
|
123
|
+
|
|
124
|
+
The single definition of the visibility rule (decision 2 of the brief):
|
|
125
|
+
public — any member of the organization;
|
|
126
|
+
restricted — explicit members, plus members of the parent team (they may also join);
|
|
127
|
+
private — explicit members only.
|
|
128
|
+
|
|
129
|
+
Departments are not an access boundary, so a restricted team looks at its direct
|
|
130
|
+
parent only; the membership cascade keeps an explicit row on every ancestor, which
|
|
131
|
+
is what makes a grandparent member count here too. Everything else (reads, boards,
|
|
132
|
+
search, attachments, realtime) is expressed through this predicate.
|
|
133
|
+
"""
|
|
134
|
+
members = _member_team_ids(viewer_id)
|
|
135
|
+
return or_(
|
|
136
|
+
team_cols["visibility"] == PUBLIC,
|
|
137
|
+
team_cols["id"].in_(members),
|
|
138
|
+
and_(team_cols["visibility"] == RESTRICTED, team_cols["parent_team_id"].in_(members)),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def visible_team_select(org_id: str, viewer_id: str, *, alias: str = "visible_team"):
|
|
143
|
+
"""Subquery of the team ids in ``org_id`` that ``viewer_id`` may see."""
|
|
144
|
+
vt = team.alias(alias)
|
|
145
|
+
cols = {"id": vt.c.id, "visibility": vt.c.visibility, "parent_team_id": vt.c.parent_team_id}
|
|
146
|
+
return select(vt.c.id).where(and_(vt.c.organization_id == org_id, visibility_holds(cols, viewer_id)))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _viewer_is_admin(conn, org_id: str, viewer_id: str) -> bool:
|
|
150
|
+
return bool(conn.execute(select(user.c.admin).where(and_(
|
|
151
|
+
user.c.id == viewer_id, user.c.organization_id == org_id))).scalar())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def visible_team_clause(conn, org_id: str, viewer_id: str, team_id_col):
|
|
155
|
+
"""Restrict team_id_col to teams the viewer may see. None means unrestricted (admin)."""
|
|
156
|
+
if _viewer_is_admin(conn, org_id, viewer_id):
|
|
157
|
+
return None
|
|
158
|
+
return team_id_col.in_(visible_team_select(org_id, viewer_id))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def team_ancestor_ids(conn, org_id: str, team_id: str) -> list[str]:
|
|
162
|
+
"""The team followed by its parents, root last (nesting is capped at three levels)."""
|
|
163
|
+
chain, current = [], team_id
|
|
164
|
+
while current and current not in chain:
|
|
165
|
+
parent = conn.execute(select(team.c.parent_team_id).where(and_(
|
|
166
|
+
team.c.id == current, team.c.organization_id == org_id))).scalar()
|
|
167
|
+
chain.append(current)
|
|
168
|
+
current = parent
|
|
169
|
+
return chain
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def team_descendant_ids(conn, org_id: str, team_id: str) -> list[str]:
|
|
173
|
+
"""The team followed by every team beneath it, breadth first."""
|
|
174
|
+
seen, queue = [team_id], [team_id]
|
|
175
|
+
while queue:
|
|
176
|
+
children = list(conn.execute(select(team.c.id).where(and_(
|
|
177
|
+
team.c.organization_id == org_id, team.c.parent_team_id == queue.pop(0)))).scalars())
|
|
178
|
+
for child in children:
|
|
179
|
+
if child not in seen:
|
|
180
|
+
seen.append(child)
|
|
181
|
+
queue.append(child)
|
|
182
|
+
return seen
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def team_visible(conn, org_id: str, viewer_id: str, team_id: str) -> bool:
|
|
186
|
+
stmt = select(team.c.id).where(and_(team.c.id == team_id, team.c.organization_id == org_id))
|
|
187
|
+
clause = visible_team_clause(conn, org_id, viewer_id, team.c.id)
|
|
188
|
+
if clause is not None:
|
|
189
|
+
stmt = stmt.where(clause)
|
|
190
|
+
return conn.execute(stmt).first() is not None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _scope_teams(conn, stmt, org_id, viewer_id, team_id_col, *, nullable=False):
|
|
194
|
+
"""Apply team visibility when a viewer is given. viewer_id=None is an internal, unscoped call."""
|
|
195
|
+
if viewer_id is None:
|
|
196
|
+
return stmt
|
|
197
|
+
clause = visible_team_clause(conn, org_id, viewer_id, team_id_col)
|
|
198
|
+
if clause is None:
|
|
199
|
+
return stmt
|
|
200
|
+
return stmt.where(or_(team_id_col.is_(None), clause) if nullable else clause)
|
|
201
|
+
|
|
202
|
+
def _scope_projects(conn, stmt, org_id, viewer_id):
|
|
203
|
+
"""Hide projects whose every team link is invisible to the viewer.
|
|
204
|
+
|
|
205
|
+
A project with no team links keeps the historical behaviour and stays org-wide:
|
|
206
|
+
it has no team to be private about. A project linked only to teams the viewer
|
|
207
|
+
cannot see disappears, mirroring Linear's rule that a project becomes private
|
|
208
|
+
once its last public team is removed.
|
|
209
|
+
"""
|
|
210
|
+
if viewer_id is None:
|
|
211
|
+
return stmt
|
|
212
|
+
clause = visible_team_clause(conn, org_id, viewer_id, team.c.id)
|
|
213
|
+
if clause is None:
|
|
214
|
+
return stmt
|
|
215
|
+
visible_ids = (
|
|
216
|
+
select(project_team.c.project_id)
|
|
217
|
+
.select_from(project_team.join(team, team.c.id == project_team.c.team_id))
|
|
218
|
+
.where(clause)
|
|
219
|
+
)
|
|
220
|
+
any_linked = select(project_team.c.project_id)
|
|
221
|
+
return stmt.where(or_(~project.c.id.in_(any_linked), project.c.id.in_(visible_ids)))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Store:
|
|
225
|
+
def __init__(self, engine: Engine) -> None:
|
|
226
|
+
self.engine = engine
|
|
227
|
+
|
|
228
|
+
def is_initialized(self) -> bool:
|
|
229
|
+
"""Whether this database already contains any workspace."""
|
|
230
|
+
with self.engine.connect() as conn:
|
|
231
|
+
return conn.execute(select(organization.c.id).limit(1)).first() is not None
|
|
232
|
+
|
|
233
|
+
def user_preferences(self, user_id: str) -> dict[str, str] | None:
|
|
234
|
+
"""Read preference values for a verified user profile."""
|
|
235
|
+
with self.engine.connect() as conn:
|
|
236
|
+
if not conn.execute(select(user.c.id).where(
|
|
237
|
+
user.c.id == user_id, user.c.active.is_(True),
|
|
238
|
+
user.c.archived_at.is_(None))).first():
|
|
239
|
+
return None
|
|
240
|
+
rows = conn.execute(select(user_preference.c.preference_key,
|
|
241
|
+
user_preference.c.value).where(
|
|
242
|
+
user_preference.c.user_id == user_id)).all()
|
|
243
|
+
return {row.preference_key: row.value for row in rows}
|
|
244
|
+
|
|
245
|
+
def set_user_preference(self, user_id: str, preference_key: str, value: str) -> bool:
|
|
246
|
+
allowed = {"theme": {"light", "dark"}, "fontSize": {"90", "100", "110"},
|
|
247
|
+
"notification:issueCreated": {"true", "false"},
|
|
248
|
+
"notification:issueUpdated": {"true", "false"}}
|
|
249
|
+
if preference_key not in allowed or value not in allowed[preference_key]:
|
|
250
|
+
return False
|
|
251
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
252
|
+
with self.engine.begin() as conn:
|
|
253
|
+
if not conn.execute(select(user.c.id).where(
|
|
254
|
+
user.c.id == user_id, user.c.active.is_(True),
|
|
255
|
+
user.c.archived_at.is_(None))).first():
|
|
256
|
+
return False
|
|
257
|
+
exists = conn.execute(select(user_preference.c.user_id).where(and_(
|
|
258
|
+
user_preference.c.user_id == user_id,
|
|
259
|
+
user_preference.c.preference_key == preference_key))).first()
|
|
260
|
+
if exists:
|
|
261
|
+
conn.execute(user_preference.update().where(and_(
|
|
262
|
+
user_preference.c.user_id == user_id,
|
|
263
|
+
user_preference.c.preference_key == preference_key)).values(
|
|
264
|
+
value=value, updated_at=now))
|
|
265
|
+
else:
|
|
266
|
+
conn.execute(user_preference.insert().values(
|
|
267
|
+
user_id=user_id, preference_key=preference_key,
|
|
268
|
+
value=value, updated_at=now))
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------ auth
|
|
272
|
+
def change_events_after(self, org_id: str, after_seq: int, limit: int = 200,
|
|
273
|
+
viewer_id: str | None = None) -> list[dict]:
|
|
274
|
+
"""Events for teams the viewer can see. Without a viewer only team-less events are returned."""
|
|
275
|
+
with self.engine.connect() as conn:
|
|
276
|
+
stmt = select(change_event).where(and_(
|
|
277
|
+
change_event.c.organization_id == org_id, change_event.c.seq > after_seq))
|
|
278
|
+
if viewer_id is None:
|
|
279
|
+
stmt = stmt.where(and_(change_event.c.team_id.is_(None),
|
|
280
|
+
change_event.c.recipient_id.is_(None)))
|
|
281
|
+
else:
|
|
282
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, change_event.c.team_id, nullable=True)
|
|
283
|
+
# Per-user events (notifications) reach only their recipient.
|
|
284
|
+
stmt = stmt.where(or_(change_event.c.recipient_id.is_(None),
|
|
285
|
+
change_event.c.recipient_id == viewer_id))
|
|
286
|
+
rows = conn.execute(stmt.order_by(change_event.c.seq).limit(limit)).mappings().all()
|
|
287
|
+
return [change_event_dict(r) for r in rows]
|
|
288
|
+
|
|
289
|
+
def change_org_ids_after(self, after_seq: int, limit: int = 1000) -> list[dict]:
|
|
290
|
+
"""Cheap global change scan for the realtime broadcaster: seq + org only."""
|
|
291
|
+
with self.engine.connect() as conn:
|
|
292
|
+
rows = conn.execute(
|
|
293
|
+
select(change_event.c.seq, change_event.c.organization_id)
|
|
294
|
+
.where(change_event.c.seq > after_seq)
|
|
295
|
+
.order_by(change_event.c.seq)
|
|
296
|
+
.limit(limit)
|
|
297
|
+
).all()
|
|
298
|
+
return [{"seq": r[0], "organization_id": r[1]} for r in rows]
|
|
299
|
+
|
|
300
|
+
def latest_change_seq(self, org_id: str) -> int:
|
|
301
|
+
"""Newest change_event seq for an org (0 when none)."""
|
|
302
|
+
with self.engine.connect() as conn:
|
|
303
|
+
return int(conn.execute(
|
|
304
|
+
select(func.max(change_event.c.seq))
|
|
305
|
+
.where(change_event.c.organization_id == org_id)
|
|
306
|
+
).scalar() or 0)
|
|
307
|
+
|
|
308
|
+
def change_events_after_global(self, after_seq: int, limit: int = 5000) -> list[dict]:
|
|
309
|
+
"""All orgs' raw change events after seq (broadcaster feed)."""
|
|
310
|
+
with self.engine.connect() as conn:
|
|
311
|
+
rows = conn.execute(
|
|
312
|
+
select(change_event)
|
|
313
|
+
.where(change_event.c.seq > after_seq)
|
|
314
|
+
.order_by(change_event.c.seq)
|
|
315
|
+
.limit(limit)
|
|
316
|
+
).mappings().all()
|
|
317
|
+
return [change_event_dict(r, with_org=True) for r in rows]
|
|
318
|
+
|
|
319
|
+
def visible_team_ids(self, org_id: str, viewer_id: str) -> frozenset | None:
|
|
320
|
+
"""Viewer-visible team ids, or None when unrestricted (admin)."""
|
|
321
|
+
with self.engine.connect() as conn:
|
|
322
|
+
if _viewer_is_admin(conn, org_id, viewer_id):
|
|
323
|
+
return None
|
|
324
|
+
rows = conn.execute(visible_team_select(org_id, viewer_id)).all()
|
|
325
|
+
return frozenset(r[0] for r in rows)
|
|
326
|
+
|
|
327
|
+
def max_change_seq(self) -> int:
|
|
328
|
+
"""Newest change_event seq across every org (0 when none)."""
|
|
329
|
+
with self.engine.connect() as conn:
|
|
330
|
+
return int(conn.execute(select(func.max(change_event.c.seq))).scalar() or 0)
|
|
331
|
+
|
|
332
|
+
def change_events_after_orgs(self, heads: dict[str, int], limit: int = 5000) -> list[dict]:
|
|
333
|
+
"""Raw events newer than a per-org cursor (gap-free when seqs are commit-ordered per org)."""
|
|
334
|
+
out: list[dict] = []
|
|
335
|
+
items = list(heads.items())
|
|
336
|
+
with self.engine.connect() as conn:
|
|
337
|
+
for start in range(0, len(items), 200):
|
|
338
|
+
chunk = items[start:start + 200]
|
|
339
|
+
clause = or_(*[and_(change_event.c.organization_id == org,
|
|
340
|
+
change_event.c.seq > seq) for org, seq in chunk])
|
|
341
|
+
rows = conn.execute(select(change_event).where(clause)
|
|
342
|
+
.order_by(change_event.c.seq).limit(limit)).mappings().all()
|
|
343
|
+
out.extend(change_event_dict(r, with_org=True) for r in rows)
|
|
344
|
+
out.sort(key=lambda e: e["seq"])
|
|
345
|
+
return out
|
|
346
|
+
|
|
347
|
+
def visibility_map(self, org_id: str, user_ids) -> dict[str, frozenset | None]:
|
|
348
|
+
"""visible_team_ids for many viewers with three queries instead of one per viewer.
|
|
349
|
+
|
|
350
|
+
Mirrors ``visibility_holds`` exactly: admins see everything (None); everyone
|
|
351
|
+
else sees public teams, the teams they hold a membership row on, and restricted
|
|
352
|
+
teams whose parent they belong to.
|
|
353
|
+
"""
|
|
354
|
+
uids = list(dict.fromkeys(user_ids))
|
|
355
|
+
if not uids:
|
|
356
|
+
return {}
|
|
357
|
+
with self.engine.connect() as conn:
|
|
358
|
+
admins: set[str] = set()
|
|
359
|
+
known: set[str] = set()
|
|
360
|
+
for start in range(0, len(uids), 500):
|
|
361
|
+
chunk = uids[start:start + 500]
|
|
362
|
+
for uid, admin in conn.execute(select(user.c.id, user.c.admin).where(and_(
|
|
363
|
+
user.c.organization_id == org_id, user.c.id.in_(chunk)))):
|
|
364
|
+
known.add(uid)
|
|
365
|
+
if admin:
|
|
366
|
+
admins.add(uid)
|
|
367
|
+
public = frozenset(r[0] for r in conn.execute(select(team.c.id).where(and_(
|
|
368
|
+
team.c.organization_id == org_id, team.c.visibility == PUBLIC))))
|
|
369
|
+
restricted_parent: dict[str, str] = {
|
|
370
|
+
tid: parent for tid, parent in conn.execute(select(team.c.id, team.c.parent_team_id).where(and_(
|
|
371
|
+
team.c.organization_id == org_id, team.c.visibility == RESTRICTED,
|
|
372
|
+
team.c.parent_team_id.is_not(None))))
|
|
373
|
+
}
|
|
374
|
+
member_of: dict[str, set[str]] = {}
|
|
375
|
+
for start in range(0, len(uids), 500):
|
|
376
|
+
chunk = uids[start:start + 500]
|
|
377
|
+
for uid, tid in conn.execute(
|
|
378
|
+
select(team_member.c.user_id, team_member.c.team_id)
|
|
379
|
+
.join(team, team.c.id == team_member.c.team_id)
|
|
380
|
+
.where(and_(team.c.organization_id == org_id,
|
|
381
|
+
team_member.c.user_id.in_(chunk)))):
|
|
382
|
+
member_of.setdefault(uid, set()).add(tid)
|
|
383
|
+
visible_restricted: dict[str, set[str]] = {}
|
|
384
|
+
for tid, parent in restricted_parent.items():
|
|
385
|
+
for uid in uids:
|
|
386
|
+
if parent in member_of.get(uid, ()):
|
|
387
|
+
visible_restricted.setdefault(uid, set()).add(tid)
|
|
388
|
+
out: dict[str, frozenset | None] = {}
|
|
389
|
+
for uid in uids:
|
|
390
|
+
if uid in admins:
|
|
391
|
+
out[uid] = None
|
|
392
|
+
elif uid not in known:
|
|
393
|
+
out[uid] = frozenset()
|
|
394
|
+
else:
|
|
395
|
+
out[uid] = (public | frozenset(member_of.get(uid, ()))
|
|
396
|
+
| frozenset(visible_restricted.get(uid, ())))
|
|
397
|
+
return out
|
|
398
|
+
|
|
399
|
+
def validate_credentials(self, key_hashes, session_hashes) -> dict[tuple[str, str], tuple[str, str]]:
|
|
400
|
+
"""Still-valid credentials → (user_id, organization_id), in a couple of batched queries.
|
|
401
|
+
|
|
402
|
+
Used to revalidate every open realtime socket at once instead of one auth
|
|
403
|
+
lookup (and api-key touch write) per socket per interval.
|
|
404
|
+
"""
|
|
405
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
406
|
+
out: dict[tuple[str, str], tuple[str, str]] = {}
|
|
407
|
+
keys, sessions = list(set(key_hashes)), list(set(session_hashes))
|
|
408
|
+
with self.engine.connect() as conn:
|
|
409
|
+
for start in range(0, len(keys), 500):
|
|
410
|
+
chunk = keys[start:start + 500]
|
|
411
|
+
for th, uid, org in conn.execute(
|
|
412
|
+
select(api_key.c.token_hash, api_key.c.user_id, api_key.c.organization_id)
|
|
413
|
+
.join(user, and_(user.c.id == api_key.c.user_id,
|
|
414
|
+
user.c.organization_id == api_key.c.organization_id))
|
|
415
|
+
.where(and_(api_key.c.token_hash.in_(chunk), api_key.c.revoked_at.is_(None),
|
|
416
|
+
or_(api_key.c.expires_at.is_(None), api_key.c.expires_at > now),
|
|
417
|
+
user.c.active.is_(True), user.c.archived_at.is_(None)))):
|
|
418
|
+
out[("key", th)] = (uid, org)
|
|
419
|
+
for start in range(0, len(sessions), 500):
|
|
420
|
+
chunk = sessions[start:start + 500]
|
|
421
|
+
for th, uid, org in conn.execute(
|
|
422
|
+
select(browser_session.c.token_hash, browser_session.c.user_id,
|
|
423
|
+
browser_session.c.organization_id)
|
|
424
|
+
.join(user, and_(user.c.id == browser_session.c.user_id,
|
|
425
|
+
user.c.organization_id == browser_session.c.organization_id))
|
|
426
|
+
.join(organization_membership, and_(
|
|
427
|
+
organization_membership.c.profile_user_id == browser_session.c.user_id,
|
|
428
|
+
organization_membership.c.organization_id == browser_session.c.organization_id,
|
|
429
|
+
organization_membership.c.active.is_(True)))
|
|
430
|
+
.where(and_(browser_session.c.token_hash.in_(chunk),
|
|
431
|
+
browser_session.c.revoked_at.is_(None),
|
|
432
|
+
browser_session.c.expires_at > now,
|
|
433
|
+
user.c.active.is_(True), user.c.archived_at.is_(None)))):
|
|
434
|
+
out[("session", th)] = (uid, org)
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
def resolve_token(self, token: str) -> dict | None:
|
|
438
|
+
"""token → {user_id, organization_id} or None."""
|
|
439
|
+
th = token_hash(token)
|
|
440
|
+
with self.engine.connect() as conn:
|
|
441
|
+
row = _one(
|
|
442
|
+
conn,
|
|
443
|
+
select(api_key)
|
|
444
|
+
.join(
|
|
445
|
+
user,
|
|
446
|
+
and_(
|
|
447
|
+
user.c.id == api_key.c.user_id,
|
|
448
|
+
user.c.organization_id == api_key.c.organization_id,
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
.where(
|
|
452
|
+
and_(
|
|
453
|
+
api_key.c.token_hash == th,
|
|
454
|
+
api_key.c.revoked_at.is_(None),
|
|
455
|
+
or_(api_key.c.expires_at.is_(None),
|
|
456
|
+
api_key.c.expires_at > datetime.now(timezone.utc).isoformat()),
|
|
457
|
+
user.c.active.is_(True),
|
|
458
|
+
user.c.archived_at.is_(None),
|
|
459
|
+
)
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
return row
|
|
463
|
+
|
|
464
|
+
def touch_api_key(self, key_id: str, last_used_at: str | None = None) -> None:
|
|
465
|
+
"""Record use, at most once per hour, to avoid a write on every request."""
|
|
466
|
+
now = datetime.now(timezone.utc)
|
|
467
|
+
threshold = (now - timedelta(hours=1)).isoformat()
|
|
468
|
+
if last_used_at is not None and last_used_at >= threshold:
|
|
469
|
+
return # recent enough: skip the write transaction (a global lock on SQLite)
|
|
470
|
+
with self.engine.begin() as conn:
|
|
471
|
+
conn.execute(api_key.update().where(and_(
|
|
472
|
+
api_key.c.id == key_id,
|
|
473
|
+
or_(api_key.c.last_used_at.is_(None), api_key.c.last_used_at < threshold),
|
|
474
|
+
)).values(last_used_at=now.isoformat()))
|
|
475
|
+
|
|
476
|
+
def list_api_keys(self, org_id: str, user_id: str) -> list[dict]:
|
|
477
|
+
"""Return metadata for this user's keys in the active workspace only."""
|
|
478
|
+
with self.engine.connect() as conn:
|
|
479
|
+
return _conn_rows(conn, select(
|
|
480
|
+
api_key.c.id, api_key.c.label, api_key.c.access, api_key.c.hint,
|
|
481
|
+
api_key.c.expires_at.label("expiresAt"),
|
|
482
|
+
api_key.c.last_used_at.label("lastUsedAt"),
|
|
483
|
+
api_key.c.created_at.label("createdAt"),
|
|
484
|
+
api_key.c.revoked_at.label("revokedAt"),
|
|
485
|
+
).where(and_(api_key.c.organization_id == org_id,
|
|
486
|
+
api_key.c.user_id == user_id)).order_by(api_key.c.created_at))
|
|
487
|
+
|
|
488
|
+
def create_api_key(self, org_id: str, user_id: str, label: str, access: str,
|
|
489
|
+
expires_at: str | None = None) -> tuple[dict, str] | None:
|
|
490
|
+
from hoja_server.db import gen_token
|
|
491
|
+
clean_label = label.strip()
|
|
492
|
+
if not clean_label or len(clean_label) > 80:
|
|
493
|
+
return None
|
|
494
|
+
if access not in ("read", "read_write"):
|
|
495
|
+
return None
|
|
496
|
+
expires_at = _normalize_expiry(expires_at)
|
|
497
|
+
if expires_at is False:
|
|
498
|
+
return None
|
|
499
|
+
raw_token = gen_token()
|
|
500
|
+
key_id = new_id()
|
|
501
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
502
|
+
with self.engine.begin() as conn:
|
|
503
|
+
if not conn.execute(select(user.c.id).where(and_(
|
|
504
|
+
user.c.id == user_id, user.c.organization_id == org_id,
|
|
505
|
+
user.c.active.is_(True), user.c.archived_at.is_(None)))).first():
|
|
506
|
+
return None
|
|
507
|
+
conn.execute(api_key.insert().values(
|
|
508
|
+
id=key_id, token_hash=token_hash(raw_token), label=clean_label,
|
|
509
|
+
hint=raw_token[-4:], access=access, expires_at=expires_at,
|
|
510
|
+
user_id=user_id, organization_id=org_id, created_at=now))
|
|
511
|
+
return {"id": key_id, "label": clean_label, "access": access, "hint": raw_token[-4:],
|
|
512
|
+
"expiresAt": expires_at, "lastUsedAt": None,
|
|
513
|
+
"createdAt": now, "revokedAt": None}, raw_token
|
|
514
|
+
|
|
515
|
+
def revoke_api_key(self, org_id: str, user_id: str, key_id: str) -> bool:
|
|
516
|
+
with self.engine.begin() as conn:
|
|
517
|
+
result = conn.execute(api_key.update().where(and_(
|
|
518
|
+
api_key.c.id == key_id, api_key.c.organization_id == org_id,
|
|
519
|
+
api_key.c.user_id == user_id, api_key.c.revoked_at.is_(None),
|
|
520
|
+
)).values(revoked_at=datetime.now(timezone.utc).isoformat()))
|
|
521
|
+
return bool(result.rowcount)
|
|
522
|
+
|
|
523
|
+
def create_browser_session_for_token(self, token: str, days: int = 14) -> tuple[str, dict] | None:
|
|
524
|
+
"""Create a browser session for the owner of a valid API key."""
|
|
525
|
+
identity = self.resolve_token(token)
|
|
526
|
+
if not identity:
|
|
527
|
+
return None
|
|
528
|
+
raw = secrets.token_urlsafe(48)
|
|
529
|
+
now = datetime.now(timezone.utc)
|
|
530
|
+
expires = now + timedelta(days=days)
|
|
531
|
+
with self.engine.begin() as conn:
|
|
532
|
+
conn.execute(browser_session.insert().values(
|
|
533
|
+
id=new_id(), token_hash=token_hash(raw), user_id=identity["user_id"],
|
|
534
|
+
organization_id=identity["organization_id"], created_at=now.isoformat(),
|
|
535
|
+
expires_at=expires.isoformat()))
|
|
536
|
+
return raw, {"user_id": identity["user_id"],
|
|
537
|
+
"organization_id": identity["organization_id"],
|
|
538
|
+
"expires_at": expires.isoformat()}
|
|
539
|
+
|
|
540
|
+
@staticmethod
|
|
541
|
+
def _password_digest(password: str, salt: bytes) -> str:
|
|
542
|
+
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, _PBKDF2_ITERATIONS)
|
|
543
|
+
return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
|
|
544
|
+
|
|
545
|
+
@staticmethod
|
|
546
|
+
def _verify_password(password: str, stored) -> bool:
|
|
547
|
+
"""Verify iteration-tagged hashes and legacy 3-part hashes (implicit 310k iterations)."""
|
|
548
|
+
parts = stored.split("$") if isinstance(stored, str) else []
|
|
549
|
+
try:
|
|
550
|
+
if len(parts) == 4 and parts[0] == "pbkdf2_sha256":
|
|
551
|
+
iterations, salt_hex, digest_hex = int(parts[1]), parts[2], parts[3]
|
|
552
|
+
elif len(parts) == 3 and parts[0] == "pbkdf2_sha256":
|
|
553
|
+
iterations, salt_hex, digest_hex = _LEGACY_PBKDF2_ITERATIONS, parts[1], parts[2]
|
|
554
|
+
else:
|
|
555
|
+
return False
|
|
556
|
+
if not 1 <= iterations <= 10_000_000:
|
|
557
|
+
return False
|
|
558
|
+
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt_hex), iterations)
|
|
559
|
+
return hmac.compare_digest(digest, bytes.fromhex(digest_hex))
|
|
560
|
+
except (ValueError, TypeError):
|
|
561
|
+
return False
|
|
562
|
+
|
|
563
|
+
@staticmethod
|
|
564
|
+
def _reject_unverified(password: str) -> None:
|
|
565
|
+
"""Burn one PBKDF2 round when no credential exists, so unknown accounts take as long as wrong passwords."""
|
|
566
|
+
hashlib.pbkdf2_hmac("sha256", password.encode(), b"\x00" * 16, _PBKDF2_ITERATIONS)
|
|
567
|
+
|
|
568
|
+
def set_password(self, api_token: str, password: str) -> bool:
|
|
569
|
+
if len(password) < 12:
|
|
570
|
+
raise ValueError("Password must be at least 12 characters.")
|
|
571
|
+
identity = self.resolve_token(api_token)
|
|
572
|
+
if not identity:
|
|
573
|
+
return False
|
|
574
|
+
salt = secrets.token_bytes(16)
|
|
575
|
+
encoded = self._password_digest(password, salt)
|
|
576
|
+
now = datetime.now(timezone.utc)
|
|
577
|
+
with self.engine.begin() as conn:
|
|
578
|
+
member = conn.execute(select(organization_membership.c.identity_id).where(
|
|
579
|
+
organization_membership.c.profile_user_id == identity["user_id"])).first()
|
|
580
|
+
if member:
|
|
581
|
+
existing = conn.execute(select(identity_credential.c.identity_id).where(
|
|
582
|
+
identity_credential.c.identity_id == member[0])).first()
|
|
583
|
+
values = {"identity_id": member[0], "password_hash": encoded,
|
|
584
|
+
"created_at": now.isoformat(), "updated_at": now.isoformat()}
|
|
585
|
+
credential_table = identity_credential
|
|
586
|
+
key_col = identity_credential.c.identity_id
|
|
587
|
+
credential_key = member[0]
|
|
588
|
+
else:
|
|
589
|
+
existing = conn.execute(select(password_credential.c.user_id).where(
|
|
590
|
+
password_credential.c.user_id == identity["user_id"])).first()
|
|
591
|
+
values = {"user_id": identity["user_id"], "password_hash": encoded,
|
|
592
|
+
"created_at": now.isoformat(), "updated_at": now.isoformat()}
|
|
593
|
+
credential_table = password_credential
|
|
594
|
+
key_col = password_credential.c.user_id
|
|
595
|
+
credential_key = identity["user_id"]
|
|
596
|
+
if existing:
|
|
597
|
+
conn.execute(credential_table.update().where(
|
|
598
|
+
key_col == credential_key).values(
|
|
599
|
+
password_hash=encoded, updated_at=now.isoformat()))
|
|
600
|
+
else:
|
|
601
|
+
conn.execute(credential_table.insert().values(**values))
|
|
602
|
+
# A password change ends every browser session for this identity's profiles.
|
|
603
|
+
profile_ids = ([row[0] for row in conn.execute(select(organization_membership.c.profile_user_id).where(
|
|
604
|
+
organization_membership.c.identity_id == member[0]))]
|
|
605
|
+
if member else [identity["user_id"]])
|
|
606
|
+
conn.execute(browser_session.update().where(and_(
|
|
607
|
+
browser_session.c.user_id.in_(profile_ids),
|
|
608
|
+
browser_session.c.revoked_at.is_(None))).values(revoked_at=now.isoformat()))
|
|
609
|
+
return True
|
|
610
|
+
|
|
611
|
+
def workspaces_for_email(self, email: str) -> list[dict]:
|
|
612
|
+
with self.engine.connect() as conn:
|
|
613
|
+
rows = conn.execute(select(
|
|
614
|
+
organization_membership.c.identity_id,
|
|
615
|
+
organization_membership.c.organization_id,
|
|
616
|
+
organization_membership.c.profile_user_id,
|
|
617
|
+
organization.c.name, organization.c.url_key,
|
|
618
|
+
user.c.email, user.c.name.label("profile_name"), user.c.admin,
|
|
619
|
+
).join(global_identity, global_identity.c.id == organization_membership.c.identity_id)
|
|
620
|
+
.join(organization, organization.c.id == organization_membership.c.organization_id)
|
|
621
|
+
.join(user, and_(user.c.id == organization_membership.c.profile_user_id,
|
|
622
|
+
user.c.organization_id == organization_membership.c.organization_id))
|
|
623
|
+
.where(and_(global_identity.c.email == email.strip().lower(),
|
|
624
|
+
organization_membership.c.active.is_(True),
|
|
625
|
+
user.c.active.is_(True), user.c.archived_at.is_(None)))).mappings().all()
|
|
626
|
+
return [dict(row) for row in rows]
|
|
627
|
+
|
|
628
|
+
def matching_login_workspaces(self, email: str) -> list[dict]:
|
|
629
|
+
"""Return selectable workspaces; credential verification happens after selection."""
|
|
630
|
+
return [{"organizationId": item["organization_id"],
|
|
631
|
+
"name": item["name"], "urlKey": item["url_key"]}
|
|
632
|
+
for item in self.workspaces_for_email(email)]
|
|
633
|
+
|
|
634
|
+
def create_session_for_membership(self, identity_id: str, profile_user_id: str,
|
|
635
|
+
organization_id: str, days: int = 14) -> tuple[str, dict] | tuple[None, None]:
|
|
636
|
+
selected = self.switch_membership(identity_id, organization_id)
|
|
637
|
+
if not selected or selected["user_id"] != profile_user_id:
|
|
638
|
+
return None, None
|
|
639
|
+
raw = secrets.token_urlsafe(48)
|
|
640
|
+
now = datetime.now(timezone.utc)
|
|
641
|
+
expires = now + timedelta(days=days)
|
|
642
|
+
with self.engine.begin() as conn:
|
|
643
|
+
conn.execute(browser_session.insert().values(
|
|
644
|
+
id=new_id(), token_hash=token_hash(raw), user_id=profile_user_id,
|
|
645
|
+
organization_id=organization_id, created_at=now.isoformat(),
|
|
646
|
+
expires_at=expires.isoformat()))
|
|
647
|
+
return raw, {"expires_at": expires.isoformat(), "user_id": profile_user_id,
|
|
648
|
+
"organization_id": organization_id, "identity_id": identity_id}
|
|
649
|
+
|
|
650
|
+
def create_browser_session(self, email: str, password: str, *, organization_ref: str | None = None, days: int = 14) -> tuple[str, dict] | None:
|
|
651
|
+
candidates = self.workspaces_for_email(email)
|
|
652
|
+
if organization_ref:
|
|
653
|
+
selected_workspace = next((r for r in candidates if
|
|
654
|
+
r["organization_id"] == organization_ref or r["url_key"] == organization_ref), None)
|
|
655
|
+
if not selected_workspace:
|
|
656
|
+
return None
|
|
657
|
+
else:
|
|
658
|
+
selected_workspace = candidates[0] if len(candidates) == 1 else None
|
|
659
|
+
if len(candidates) != 1:
|
|
660
|
+
# A shared identity credential authenticates once; an explicit
|
|
661
|
+
# workspace selects the profile only after membership is verified.
|
|
662
|
+
if not selected_workspace:
|
|
663
|
+
if candidates or not organization_ref:
|
|
664
|
+
self._reject_unverified(password)
|
|
665
|
+
return None
|
|
666
|
+
else:
|
|
667
|
+
identity_id = selected_workspace["identity_id"]
|
|
668
|
+
with self.engine.connect() as conn:
|
|
669
|
+
credential = conn.execute(select(identity_credential).where(
|
|
670
|
+
identity_credential.c.identity_id == identity_id
|
|
671
|
+
)).mappings().first()
|
|
672
|
+
if not credential:
|
|
673
|
+
self._reject_unverified(password)
|
|
674
|
+
return None
|
|
675
|
+
if not self._verify_password(password, credential["password_hash"]):
|
|
676
|
+
return None
|
|
677
|
+
raw = secrets.token_urlsafe(48)
|
|
678
|
+
now = datetime.now(timezone.utc)
|
|
679
|
+
expires = now + timedelta(days=days)
|
|
680
|
+
with self.engine.begin() as conn:
|
|
681
|
+
conn.execute(browser_session.insert().values(
|
|
682
|
+
id=new_id(), token_hash=token_hash(raw),
|
|
683
|
+
user_id=selected_workspace["profile_user_id"],
|
|
684
|
+
organization_id=selected_workspace["organization_id"],
|
|
685
|
+
created_at=now.isoformat(), expires_at=expires.isoformat()))
|
|
686
|
+
return raw, {"user_id": selected_workspace["profile_user_id"],
|
|
687
|
+
"identity_id": identity_id,
|
|
688
|
+
"organization_id": selected_workspace["organization_id"],
|
|
689
|
+
"expires_at": expires.isoformat()}
|
|
690
|
+
# Pre-revision-6 fallback only; duplicate identities remain isolated.
|
|
691
|
+
with self.engine.connect() as conn:
|
|
692
|
+
legacy = conn.execute(select(password_credential.c.password_hash,
|
|
693
|
+
user.c.id.label("profile_user_id"), user.c.organization_id,
|
|
694
|
+
user.c.email, organization.c.url_key)
|
|
695
|
+
.join(user, user.c.id == password_credential.c.user_id)
|
|
696
|
+
.join(organization, organization.c.id == user.c.organization_id)
|
|
697
|
+
.where(and_(func.lower(user.c.email) == email.strip().lower(), user.c.active.is_(True),
|
|
698
|
+
user.c.archived_at.is_(None),
|
|
699
|
+
or_(user.c.organization_id == organization_ref,
|
|
700
|
+
organization.c.url_key == organization_ref)))).mappings().first()
|
|
701
|
+
if not legacy:
|
|
702
|
+
self._reject_unverified(password)
|
|
703
|
+
return None
|
|
704
|
+
if not self._verify_password(password, legacy["password_hash"]):
|
|
705
|
+
return None
|
|
706
|
+
raw = secrets.token_urlsafe(48)
|
|
707
|
+
now = datetime.now(timezone.utc)
|
|
708
|
+
expires = now + timedelta(days=days)
|
|
709
|
+
with self.engine.begin() as conn:
|
|
710
|
+
conn.execute(browser_session.insert().values(
|
|
711
|
+
id=new_id(), token_hash=token_hash(raw), user_id=legacy["profile_user_id"],
|
|
712
|
+
organization_id=legacy["organization_id"], created_at=now.isoformat(),
|
|
713
|
+
expires_at=expires.isoformat()))
|
|
714
|
+
return raw, {"user_id": legacy["profile_user_id"],
|
|
715
|
+
"organization_id": legacy["organization_id"], "expires_at": expires.isoformat()}
|
|
716
|
+
selected = selected_workspace or candidates[0]
|
|
717
|
+
with self.engine.connect() as conn:
|
|
718
|
+
credential = conn.execute(select(identity_credential).where(
|
|
719
|
+
identity_credential.c.identity_id == selected["identity_id"])).mappings().first()
|
|
720
|
+
if not credential:
|
|
721
|
+
# Backward-compatible fallback before migration/identity setup.
|
|
722
|
+
credential = conn.execute(select(password_credential).where(
|
|
723
|
+
password_credential.c.user_id == selected["profile_user_id"])).mappings().first()
|
|
724
|
+
if not credential:
|
|
725
|
+
self._reject_unverified(password)
|
|
726
|
+
return None
|
|
727
|
+
if not self._verify_password(password, credential["password_hash"]):
|
|
728
|
+
return None
|
|
729
|
+
raw = secrets.token_urlsafe(48)
|
|
730
|
+
now = datetime.now(timezone.utc)
|
|
731
|
+
expires = now + timedelta(days=days)
|
|
732
|
+
with self.engine.begin() as conn:
|
|
733
|
+
conn.execute(browser_session.insert().values(
|
|
734
|
+
id=new_id(), token_hash=token_hash(raw), user_id=selected["profile_user_id"],
|
|
735
|
+
organization_id=selected["organization_id"], created_at=now.isoformat(),
|
|
736
|
+
expires_at=expires.isoformat()))
|
|
737
|
+
return raw, {"user_id": selected["profile_user_id"], "identity_id": selected["identity_id"],
|
|
738
|
+
"organization_id": selected["organization_id"], "expires_at": expires.isoformat()}
|
|
739
|
+
|
|
740
|
+
def resolve_browser_session(self, raw: str) -> dict | None:
|
|
741
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
742
|
+
with self.engine.connect() as conn:
|
|
743
|
+
session = _one(conn, select(browser_session).join(user, and_(
|
|
744
|
+
user.c.id == browser_session.c.user_id,
|
|
745
|
+
user.c.organization_id == browser_session.c.organization_id))
|
|
746
|
+
.where(and_(browser_session.c.token_hash == token_hash(raw),
|
|
747
|
+
browser_session.c.revoked_at.is_(None), browser_session.c.expires_at > now,
|
|
748
|
+
user.c.active.is_(True), user.c.archived_at.is_(None))))
|
|
749
|
+
if not session:
|
|
750
|
+
return None
|
|
751
|
+
membership = _one(conn, select(organization_membership).where(and_(
|
|
752
|
+
organization_membership.c.profile_user_id == session["user_id"],
|
|
753
|
+
organization_membership.c.organization_id == session["organization_id"],
|
|
754
|
+
organization_membership.c.active.is_(True))))
|
|
755
|
+
if not membership:
|
|
756
|
+
return None
|
|
757
|
+
session["identity_id"] = membership["identity_id"]
|
|
758
|
+
return session
|
|
759
|
+
|
|
760
|
+
def revoke_browser_session(self, raw: str) -> None:
|
|
761
|
+
with self.engine.begin() as conn:
|
|
762
|
+
conn.execute(browser_session.update().where(and_(
|
|
763
|
+
browser_session.c.token_hash == token_hash(raw),
|
|
764
|
+
browser_session.c.revoked_at.is_(None))).values(
|
|
765
|
+
revoked_at=datetime.now(timezone.utc).isoformat()))
|
|
766
|
+
|
|
767
|
+
# ------------------------------------------------------------ serializers
|
|
768
|
+
def ser_user(self, row: dict | None, ctx_user_id: str | None = None) -> dict | None:
|
|
769
|
+
if not row:
|
|
770
|
+
return None
|
|
771
|
+
return {
|
|
772
|
+
"id": row["id"], "name": row.get("name"), "displayName": row.get("display_name"),
|
|
773
|
+
"email": row.get("email"), "active": bool(row.get("active", True)),
|
|
774
|
+
"isMe": row["id"] == ctx_user_id, "admin": bool(row.get("admin", False)),
|
|
775
|
+
"avatarUrl": row.get("avatar_url"), "url": row.get("url"),
|
|
776
|
+
"timezone": row.get("timezone"), "statusEmoji": row.get("status_emoji"),
|
|
777
|
+
"statusLabel": row.get("status_label"),
|
|
778
|
+
"createdAt": row.get("created_at"), "updatedAt": row.get("updated_at"),
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
def ser_state(self, row: dict | None) -> dict | None:
|
|
782
|
+
if not row:
|
|
783
|
+
return None
|
|
784
|
+
return {
|
|
785
|
+
"id": row["id"], "name": row["name"], "color": row.get("color"),
|
|
786
|
+
"description": row.get("description"), "position": row.get("position"),
|
|
787
|
+
"type": row["type"], "createdAt": row.get("created_at"),
|
|
788
|
+
"updatedAt": row.get("updated_at"), "_team_id": row.get("team_id"),
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
def ser_team(self, row: dict | None) -> dict | None:
|
|
792
|
+
if not row:
|
|
793
|
+
return None
|
|
794
|
+
auto_started = row.get("auto_add_started", True)
|
|
795
|
+
auto_completed = row.get("auto_add_completed", True)
|
|
796
|
+
auto_backlog = row.get("auto_add_backlog", True)
|
|
797
|
+
return {
|
|
798
|
+
"id": row["id"], "name": row["name"], "key": row["key"],
|
|
799
|
+
"description": row.get("description"), "color": row.get("color"),
|
|
800
|
+
"icon": row.get("icon"),
|
|
801
|
+
"visibility": row.get("visibility") or PUBLIC,
|
|
802
|
+
"parentTeamId": row.get("parent_team_id"),
|
|
803
|
+
"timezone": row.get("timezone"), "createdAt": row.get("created_at"),
|
|
804
|
+
"updatedAt": row.get("updated_at"), "_id": row["id"],
|
|
805
|
+
"autoAddStarted": True if auto_started is None else bool(auto_started),
|
|
806
|
+
"autoAddCompleted": True if auto_completed is None else bool(auto_completed),
|
|
807
|
+
"autoAddBacklog": True if auto_backlog is None else bool(auto_backlog),
|
|
808
|
+
"_active_cycle_id": row.get("active_cycle_id"),
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
def ser_label(self, row: dict | None) -> dict | None:
|
|
812
|
+
if not row:
|
|
813
|
+
return None
|
|
814
|
+
return {
|
|
815
|
+
"id": row["id"], "name": row["name"], "color": row.get("color"),
|
|
816
|
+
"description": row.get("description"), "createdAt": row.get("created_at"),
|
|
817
|
+
"updatedAt": row.get("updated_at"),
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
def ser_cycle(self, row: dict | None) -> dict | None:
|
|
821
|
+
if not row:
|
|
822
|
+
return None
|
|
823
|
+
return {
|
|
824
|
+
"id": row["id"], "name": row.get("name"), "number": row.get("number"),
|
|
825
|
+
"description": row.get("description"),
|
|
826
|
+
"startsAt": row.get("starts_at"), "endsAt": row.get("ends_at"),
|
|
827
|
+
"completedAt": row.get("completed_at"), "progress": row.get("progress"),
|
|
828
|
+
"createdAt": row.get("created_at"), "updatedAt": row.get("updated_at"),
|
|
829
|
+
"_team_id": row.get("team_id"),
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
def ser_project(self, row: dict | None) -> dict | None:
|
|
833
|
+
if not row:
|
|
834
|
+
return None
|
|
835
|
+
return {
|
|
836
|
+
"id": row["id"], "name": row["name"], "description": row.get("description"),
|
|
837
|
+
"slugId": row.get("slug_id"), "icon": row.get("icon"), "color": row.get("color"),
|
|
838
|
+
"state": row.get("state"), "progress": row.get("progress"),
|
|
839
|
+
"startDate": row.get("start_date"), "targetDate": row.get("target_date"),
|
|
840
|
+
"url": row.get("url"), "createdAt": row.get("created_at"),
|
|
841
|
+
"updatedAt": row.get("updated_at"),
|
|
842
|
+
"_lead_id": row.get("lead_id"), "_creator_id": row.get("creator_id"),
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
def ser_comment(self, row: dict | None) -> dict | None:
|
|
846
|
+
if not row:
|
|
847
|
+
return None
|
|
848
|
+
return {
|
|
849
|
+
"id": row["id"], "body": row["body"], "url": row.get("url"),
|
|
850
|
+
"createdAt": row.get("created_at"), "updatedAt": row.get("updated_at"),
|
|
851
|
+
"editedAt": row.get("updated_at") if (row.get("updated_at") or "") > (row.get("created_at") or "") else None,
|
|
852
|
+
"_user_id": row.get("user_id"),
|
|
853
|
+
"_archived": row.get("archived_at") is not None,
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
def ser_attachment(self, row: dict | None) -> dict | None:
|
|
857
|
+
if not row:
|
|
858
|
+
return None
|
|
859
|
+
return {
|
|
860
|
+
"id": row["id"], "url": row.get("url"), "title": row.get("title"),
|
|
861
|
+
"subtitle": row.get("subtitle"), "createdAt": row.get("created_at"),
|
|
862
|
+
"_issue_id": row.get("issue_id"),
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
def ser_issue(self, row: dict | None) -> dict | None:
|
|
866
|
+
if not row:
|
|
867
|
+
return None
|
|
868
|
+
return {
|
|
869
|
+
"id": row["id"], "identifier": row["identifier"], "title": row["title"],
|
|
870
|
+
"description": row.get("description"), "priority": row.get("priority", 0),
|
|
871
|
+
"priorityLabel": row.get("priority_label"), "estimate": row.get("estimate"),
|
|
872
|
+
"url": row.get("url"), "branchName": row.get("branch_name"),
|
|
873
|
+
"number": row.get("number"), "createdAt": row.get("created_at"),
|
|
874
|
+
"updatedAt": row.get("updated_at"), "dueDate": row.get("due_date"),
|
|
875
|
+
"completedAt": row.get("completed_at"), "canceledAt": row.get("canceled_at"),
|
|
876
|
+
"startedAt": row.get("started_at"), "snoozedUntilAt": row.get("snoozed_until_at"),
|
|
877
|
+
"stateChangedAt": row.get("state_changed_at"),
|
|
878
|
+
# ids for lazy field resolvers
|
|
879
|
+
"_state_id": row.get("state_id"), "_assignee_id": row.get("assignee_id"),
|
|
880
|
+
"_creator_id": row.get("creator_id"), "_team_id": row.get("team_id"),
|
|
881
|
+
"_project_id": row.get("project_id"), "_cycle_id": row.get("cycle_id"),
|
|
882
|
+
"_parent_id": row.get("parent_id"), "_org_id": row.get("organization_id"),
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
# ------------------------------------------------------------------ reads
|
|
886
|
+
def get_user(self, org_id: str, user_id: str) -> dict | None:
|
|
887
|
+
with self.engine.connect() as conn:
|
|
888
|
+
return _one(conn, select(user).where(
|
|
889
|
+
and_(user.c.organization_id == org_id, user.c.id == user_id)))
|
|
890
|
+
|
|
891
|
+
def viewer(self, org_id: str, user_id: str) -> dict | None:
|
|
892
|
+
return self.ser_user(self.get_user(org_id, user_id), user_id)
|
|
893
|
+
|
|
894
|
+
def membership_for_profile(self, org_id: str, user_id: str) -> dict | None:
|
|
895
|
+
with self.engine.connect() as conn:
|
|
896
|
+
return _one(conn, select(organization_membership).where(and_(
|
|
897
|
+
organization_membership.c.organization_id == org_id,
|
|
898
|
+
organization_membership.c.profile_user_id == user_id,
|
|
899
|
+
organization_membership.c.active.is_(True))))
|
|
900
|
+
|
|
901
|
+
def identity_workspaces(self, identity_id: str) -> list[dict]:
|
|
902
|
+
with self.engine.connect() as conn:
|
|
903
|
+
rows = conn.execute(select(
|
|
904
|
+
organization_membership.c.id,
|
|
905
|
+
organization_membership.c.organization_id.label("organizationId"),
|
|
906
|
+
organization.c.name, organization.c.url_key.label("urlKey"),
|
|
907
|
+
user.c.admin,
|
|
908
|
+
).join(organization, organization.c.id == organization_membership.c.organization_id)
|
|
909
|
+
.join(user, and_(user.c.id == organization_membership.c.profile_user_id,
|
|
910
|
+
user.c.organization_id == organization_membership.c.organization_id))
|
|
911
|
+
.where(and_(organization_membership.c.identity_id == identity_id,
|
|
912
|
+
organization_membership.c.active.is_(True),
|
|
913
|
+
user.c.active.is_(True), user.c.archived_at.is_(None)))
|
|
914
|
+
.order_by(organization.c.name)).mappings().all()
|
|
915
|
+
return [{"id": r["id"], "organizationId": r["organizationId"],
|
|
916
|
+
"name": r["name"], "urlKey": r["urlKey"],
|
|
917
|
+
"role": "admin" if r["admin"] else "member"} for r in rows]
|
|
918
|
+
|
|
919
|
+
def switch_membership(self, identity_id: str | None, organization_ref: str) -> dict | None:
|
|
920
|
+
if not identity_id:
|
|
921
|
+
return None
|
|
922
|
+
with self.engine.connect() as conn:
|
|
923
|
+
row = conn.execute(select(
|
|
924
|
+
organization_membership.c.profile_user_id,
|
|
925
|
+
organization_membership.c.organization_id,
|
|
926
|
+
organization.c.id.label("id"), organization.c.name,
|
|
927
|
+
organization.c.url_key.label("urlKey"),
|
|
928
|
+
).join(organization, organization.c.id == organization_membership.c.organization_id)
|
|
929
|
+
.join(user, and_(user.c.id == organization_membership.c.profile_user_id,
|
|
930
|
+
user.c.organization_id == organization_membership.c.organization_id))
|
|
931
|
+
.where(and_(organization_membership.c.identity_id == identity_id,
|
|
932
|
+
organization.c.id == organization_ref,
|
|
933
|
+
organization_membership.c.active.is_(True),
|
|
934
|
+
user.c.active.is_(True), user.c.archived_at.is_(None)))).mappings().first()
|
|
935
|
+
if not row:
|
|
936
|
+
return None
|
|
937
|
+
return {"user_id": row["profile_user_id"], "organization_id": row["organization_id"],
|
|
938
|
+
"organization": {"id": row["id"], "name": row["name"], "urlKey": row["urlKey"]}}
|
|
939
|
+
|
|
940
|
+
def create_membership_invitation(self, org_id: str, profile_user_id: str,
|
|
941
|
+
creator_profile_id: str, hours: int = 72) -> str | None:
|
|
942
|
+
raw = secrets.token_urlsafe(32)
|
|
943
|
+
now = datetime.now(timezone.utc)
|
|
944
|
+
with self.engine.begin() as conn:
|
|
945
|
+
valid = conn.execute(select(user.c.id).where(and_(
|
|
946
|
+
user.c.id == profile_user_id, user.c.organization_id == org_id,
|
|
947
|
+
user.c.active.is_(True), user.c.archived_at.is_(None),
|
|
948
|
+
))).first()
|
|
949
|
+
if not valid:
|
|
950
|
+
return None
|
|
951
|
+
# Replace any still-pending invitation for this profile so it remains one-time.
|
|
952
|
+
conn.execute(membership_invitation.delete().where(and_(
|
|
953
|
+
membership_invitation.c.profile_user_id == profile_user_id,
|
|
954
|
+
membership_invitation.c.accepted_at.is_(None),
|
|
955
|
+
)))
|
|
956
|
+
conn.execute(membership_invitation.insert().values(
|
|
957
|
+
id=new_id(), token_hash=token_hash(raw), organization_id=org_id,
|
|
958
|
+
profile_user_id=profile_user_id, created_by_profile_id=creator_profile_id,
|
|
959
|
+
created_at=now.isoformat(),
|
|
960
|
+
expires_at=(now + timedelta(hours=hours)).isoformat()))
|
|
961
|
+
return raw
|
|
962
|
+
|
|
963
|
+
def pending_membership_invitations(self, org_id: str) -> list[dict]:
|
|
964
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
965
|
+
with self.engine.connect() as conn:
|
|
966
|
+
stmt = select(user.c.id, user.c.name, user.c.email, membership_invitation.c.expires_at).select_from(
|
|
967
|
+
membership_invitation.join(user, user.c.id == membership_invitation.c.profile_user_id)
|
|
968
|
+
).where(and_(
|
|
969
|
+
membership_invitation.c.organization_id == org_id,
|
|
970
|
+
membership_invitation.c.accepted_at.is_(None),
|
|
971
|
+
membership_invitation.c.expires_at > now,
|
|
972
|
+
)).order_by(user.c.name)
|
|
973
|
+
return [{"userId": r.id, "name": r.name, "email": r.email, "expiresAt": r.expires_at}
|
|
974
|
+
for r in conn.execute(stmt).mappings().all()]
|
|
975
|
+
|
|
976
|
+
def revoke_membership_invitation(self, org_id: str, profile_user_id: str) -> bool:
|
|
977
|
+
with self.engine.begin() as conn:
|
|
978
|
+
result = conn.execute(membership_invitation.delete().where(and_(
|
|
979
|
+
membership_invitation.c.organization_id == org_id,
|
|
980
|
+
membership_invitation.c.profile_user_id == profile_user_id,
|
|
981
|
+
membership_invitation.c.accepted_at.is_(None),
|
|
982
|
+
)))
|
|
983
|
+
return result.rowcount > 0
|
|
984
|
+
|
|
985
|
+
def set_member_active(self, org_id: str, actor_id: str, user_id: str, active: bool) -> dict | None:
|
|
986
|
+
"""Deactivate or reactivate a member. Refuses to strand the workspace."""
|
|
987
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
988
|
+
with self.engine.begin() as conn:
|
|
989
|
+
target = conn.execute(select(user.c.id, user.c.admin, user.c.active).where(and_(
|
|
990
|
+
user.c.organization_id == org_id, user.c.id == user_id,
|
|
991
|
+
user.c.archived_at.is_(None),
|
|
992
|
+
))).first()
|
|
993
|
+
if not target:
|
|
994
|
+
return None
|
|
995
|
+
if not active:
|
|
996
|
+
if user_id == actor_id:
|
|
997
|
+
return None
|
|
998
|
+
if target.admin and target.active:
|
|
999
|
+
other_admins = conn.execute(select(user.c.id).where(and_(
|
|
1000
|
+
user.c.organization_id == org_id, user.c.admin.is_(True),
|
|
1001
|
+
user.c.active.is_(True), user.c.archived_at.is_(None),
|
|
1002
|
+
user.c.id != user_id,
|
|
1003
|
+
)).with_for_update()).all()
|
|
1004
|
+
if not other_admins:
|
|
1005
|
+
return None
|
|
1006
|
+
conn.execute(user.update().where(user.c.id == user_id).values(
|
|
1007
|
+
active=bool(active), updated_at=now))
|
|
1008
|
+
return self.get_user(org_id, user_id)
|
|
1009
|
+
|
|
1010
|
+
def accept_membership_invitation(self, identity_id: str, raw: str,
|
|
1011
|
+
invitee_password: str) -> dict | None:
|
|
1012
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
1013
|
+
with self.engine.begin() as conn:
|
|
1014
|
+
invite = conn.execute(select(membership_invitation).where(and_(
|
|
1015
|
+
membership_invitation.c.token_hash == token_hash(raw),
|
|
1016
|
+
membership_invitation.c.accepted_at.is_(None),
|
|
1017
|
+
membership_invitation.c.expires_at > now,
|
|
1018
|
+
))).mappings().first()
|
|
1019
|
+
if not invite:
|
|
1020
|
+
return None
|
|
1021
|
+
already = conn.execute(select(organization_membership.c.id).where(and_(
|
|
1022
|
+
organization_membership.c.identity_id == identity_id,
|
|
1023
|
+
organization_membership.c.organization_id == invite["organization_id"],
|
|
1024
|
+
))).first()
|
|
1025
|
+
if already:
|
|
1026
|
+
return None
|
|
1027
|
+
profile = conn.execute(select(user.c.id).where(and_(
|
|
1028
|
+
user.c.id == invite["profile_user_id"],
|
|
1029
|
+
user.c.organization_id == invite["organization_id"],
|
|
1030
|
+
user.c.active.is_(True), user.c.archived_at.is_(None),
|
|
1031
|
+
))).first()
|
|
1032
|
+
if not profile:
|
|
1033
|
+
return None
|
|
1034
|
+
identity_email = conn.execute(select(global_identity.c.email).where(
|
|
1035
|
+
global_identity.c.id == identity_id)).scalar_one_or_none()
|
|
1036
|
+
target_identity = conn.execute(select(organization_membership.c.identity_id).where(
|
|
1037
|
+
organization_membership.c.profile_user_id == profile[0])).scalar_one_or_none()
|
|
1038
|
+
target_credential = conn.execute(select(identity_credential).where(
|
|
1039
|
+
identity_credential.c.identity_id == target_identity)).mappings().first() if target_identity else None
|
|
1040
|
+
profile_email = conn.execute(select(user.c.email).where(
|
|
1041
|
+
user.c.id == profile[0])).scalar_one_or_none()
|
|
1042
|
+
if (not identity_email or not profile_email or identity_id == target_identity or
|
|
1043
|
+
identity_email.strip().lower() != profile_email.strip().lower() or
|
|
1044
|
+
not target_credential):
|
|
1045
|
+
return None
|
|
1046
|
+
if not self._verify_password(invitee_password, target_credential["password_hash"]):
|
|
1047
|
+
return None
|
|
1048
|
+
conn.execute(organization_membership.update().where(
|
|
1049
|
+
organization_membership.c.profile_user_id == profile[0]).values(
|
|
1050
|
+
identity_id=identity_id, active=True))
|
|
1051
|
+
conn.execute(membership_invitation.update().where(
|
|
1052
|
+
membership_invitation.c.id == invite["id"]).values(
|
|
1053
|
+
accepted_at=now, accepted_identity_id=identity_id))
|
|
1054
|
+
org_row = conn.execute(select(organization.c.id, organization.c.name,
|
|
1055
|
+
organization.c.url_key).where(
|
|
1056
|
+
organization.c.id == invite["organization_id"])).mappings().first()
|
|
1057
|
+
return {"id": org_row["id"], "name": org_row["name"],
|
|
1058
|
+
"urlKey": org_row["url_key"]} if org_row else None
|
|
1059
|
+
|
|
1060
|
+
def accept_new_member_invitation(self, raw: str, email: str,
|
|
1061
|
+
password: str) -> tuple[str, dict] | None:
|
|
1062
|
+
"""Set credentials for a pre-created invited profile and consume its code."""
|
|
1063
|
+
if len(password) < 12:
|
|
1064
|
+
raise ValueError("Password must be at least 12 characters.")
|
|
1065
|
+
now = datetime.now(timezone.utc)
|
|
1066
|
+
salt = secrets.token_bytes(16)
|
|
1067
|
+
digest = self._password_digest(password, salt)
|
|
1068
|
+
identity_id = new_id()
|
|
1069
|
+
session_token = secrets.token_urlsafe(48)
|
|
1070
|
+
normalized_email = email.strip().lower()
|
|
1071
|
+
with self.engine.begin() as conn:
|
|
1072
|
+
invite = conn.execute(select(membership_invitation).where(and_(
|
|
1073
|
+
membership_invitation.c.token_hash == token_hash(raw),
|
|
1074
|
+
membership_invitation.c.accepted_at.is_(None),
|
|
1075
|
+
membership_invitation.c.expires_at > now.isoformat(),
|
|
1076
|
+
))).mappings().first()
|
|
1077
|
+
if not invite:
|
|
1078
|
+
return None
|
|
1079
|
+
profile = conn.execute(select(user).where(and_(
|
|
1080
|
+
user.c.id == invite["profile_user_id"],
|
|
1081
|
+
user.c.organization_id == invite["organization_id"],
|
|
1082
|
+
user.c.active.is_(True), user.c.archived_at.is_(None),
|
|
1083
|
+
func.lower(user.c.email) == normalized_email,
|
|
1084
|
+
))).mappings().first()
|
|
1085
|
+
if not profile:
|
|
1086
|
+
return None
|
|
1087
|
+
membership = conn.execute(select(organization_membership).where(and_(
|
|
1088
|
+
organization_membership.c.profile_user_id == profile["id"],
|
|
1089
|
+
organization_membership.c.organization_id == invite["organization_id"],
|
|
1090
|
+
organization_membership.c.active.is_(True),
|
|
1091
|
+
))).mappings().first()
|
|
1092
|
+
if not membership:
|
|
1093
|
+
return None
|
|
1094
|
+
credential = conn.execute(select(identity_credential).where(
|
|
1095
|
+
identity_credential.c.identity_id == membership["identity_id"])).first()
|
|
1096
|
+
if credential:
|
|
1097
|
+
return None
|
|
1098
|
+
conn.execute(identity_credential.insert().values(
|
|
1099
|
+
identity_id=membership["identity_id"], password_hash=digest,
|
|
1100
|
+
created_at=now.isoformat(), updated_at=now.isoformat()))
|
|
1101
|
+
conn.execute(membership_invitation.update().where(
|
|
1102
|
+
membership_invitation.c.id == invite["id"]).values(
|
|
1103
|
+
accepted_at=now.isoformat(), accepted_identity_id=membership["identity_id"]))
|
|
1104
|
+
expires = now + timedelta(days=14)
|
|
1105
|
+
conn.execute(browser_session.insert().values(
|
|
1106
|
+
id=new_id(), token_hash=token_hash(session_token), user_id=profile["id"],
|
|
1107
|
+
organization_id=invite["organization_id"], created_at=now.isoformat(),
|
|
1108
|
+
expires_at=expires.isoformat()))
|
|
1109
|
+
organization_row = conn.execute(select(organization).where(
|
|
1110
|
+
organization.c.id == invite["organization_id"])).mappings().first()
|
|
1111
|
+
if not organization_row:
|
|
1112
|
+
return None
|
|
1113
|
+
return session_token, {"user_id": profile["id"], "organization_id": invite["organization_id"],
|
|
1114
|
+
"identity_id": membership["identity_id"],
|
|
1115
|
+
"organization": {"id": organization_row["id"],
|
|
1116
|
+
"name": organization_row["name"],
|
|
1117
|
+
"urlKey": organization_row["url_key"]}}
|
|
1118
|
+
|
|
1119
|
+
def organization_members(self, org_id: str) -> list[dict]:
|
|
1120
|
+
with self.engine.connect() as conn:
|
|
1121
|
+
return _conn_rows(conn, select(user).where(and_(
|
|
1122
|
+
user.c.organization_id == org_id,
|
|
1123
|
+
user.c.archived_at.is_(None),
|
|
1124
|
+
)).order_by(user.c.name, user.c.email))
|
|
1125
|
+
|
|
1126
|
+
def set_member_admin_role(self, org_id: str, user_id: str, is_admin: bool) -> dict | None:
|
|
1127
|
+
with self.engine.begin() as conn:
|
|
1128
|
+
target = conn.execute(select(user.c.admin).where(and_(
|
|
1129
|
+
user.c.organization_id == org_id, user.c.id == user_id,
|
|
1130
|
+
user.c.archived_at.is_(None),
|
|
1131
|
+
))).first()
|
|
1132
|
+
if not target:
|
|
1133
|
+
return None
|
|
1134
|
+
if target[0] and not is_admin:
|
|
1135
|
+
admins = conn.execute(select(user.c.id).where(and_(
|
|
1136
|
+
user.c.organization_id == org_id, user.c.admin.is_(True),
|
|
1137
|
+
user.c.archived_at.is_(None),
|
|
1138
|
+
)).with_for_update()).all()
|
|
1139
|
+
if len(admins) <= 1:
|
|
1140
|
+
return None
|
|
1141
|
+
result = conn.execute(user.update().where(and_(
|
|
1142
|
+
user.c.organization_id == org_id, user.c.id == user_id,
|
|
1143
|
+
user.c.archived_at.is_(None),
|
|
1144
|
+
)).values(admin=bool(is_admin), updated_at=datetime.now(timezone.utc).isoformat()))
|
|
1145
|
+
if not result.rowcount:
|
|
1146
|
+
return None
|
|
1147
|
+
return self.get_user(org_id, user_id)
|
|
1148
|
+
|
|
1149
|
+
def invite_member(self, org_id: str, name: str, email: str, is_admin: bool = False,
|
|
1150
|
+
team_ids: list[str] | None = None) -> tuple[dict, str] | None:
|
|
1151
|
+
from hoja_server.db import api_key as api_key_table, gen_token, new_id, token_hash
|
|
1152
|
+
|
|
1153
|
+
normalized_email = email.strip().lower()
|
|
1154
|
+
clean_name = name.strip()
|
|
1155
|
+
if not clean_name or "@" not in normalized_email:
|
|
1156
|
+
return None
|
|
1157
|
+
raw_token = gen_token()
|
|
1158
|
+
uid = new_id()
|
|
1159
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
1160
|
+
with self.engine.begin() as conn:
|
|
1161
|
+
if conn.execute(select(global_identity.c.id).where(
|
|
1162
|
+
global_identity.c.email == normalized_email)).first():
|
|
1163
|
+
return None
|
|
1164
|
+
if conn.execute(select(user.c.id).where(and_(
|
|
1165
|
+
user.c.organization_id == org_id,
|
|
1166
|
+
func.lower(user.c.email) == normalized_email,
|
|
1167
|
+
user.c.archived_at.is_(None),
|
|
1168
|
+
))).first():
|
|
1169
|
+
return None
|
|
1170
|
+
conn.execute(user.insert().values(
|
|
1171
|
+
id=uid, organization_id=org_id, name=clean_name, display_name=clean_name,
|
|
1172
|
+
email=normalized_email, active=True, admin=bool(is_admin),
|
|
1173
|
+
created_at=now, updated_at=now,
|
|
1174
|
+
))
|
|
1175
|
+
identity_id = new_id()
|
|
1176
|
+
conn.execute(global_identity.insert().values(
|
|
1177
|
+
id=identity_id, email=normalized_email, created_at=now))
|
|
1178
|
+
conn.execute(organization_membership.insert().values(
|
|
1179
|
+
id=new_id(), identity_id=identity_id, organization_id=org_id,
|
|
1180
|
+
profile_user_id=uid, active=True, created_at=now))
|
|
1181
|
+
conn.execute(api_key_table.insert().values(
|
|
1182
|
+
id=new_id(), token_hash=token_hash(raw_token), label="member invite",
|
|
1183
|
+
user_id=uid, organization_id=org_id, created_at=now,
|
|
1184
|
+
))
|
|
1185
|
+
seats: dict[str, None] = {}
|
|
1186
|
+
for team_id in dict.fromkeys(team_ids or []):
|
|
1187
|
+
# Same rule as set_team_membership: a sub-team seat implies its ancestors.
|
|
1188
|
+
for ancestor in self._team_and_ancestors(conn, org_id, team_id):
|
|
1189
|
+
seats[ancestor] = None
|
|
1190
|
+
for team_id in seats:
|
|
1191
|
+
conn.execute(team_member.insert().values(
|
|
1192
|
+
team_id=team_id, user_id=uid, role="member"))
|
|
1193
|
+
return self.get_user(org_id, uid), raw_token
|
|
1194
|
+
|
|
1195
|
+
def workspace(self, org_id: str) -> dict | None:
|
|
1196
|
+
with self.engine.connect() as conn:
|
|
1197
|
+
return _one(conn, select(organization.c.id, organization.c.name,
|
|
1198
|
+
organization.c.url_key.label("urlKey")).where(
|
|
1199
|
+
organization.c.id == org_id))
|
|
1200
|
+
|
|
1201
|
+
def update_workspace(self, org_id: str, values: dict) -> dict | None:
|
|
1202
|
+
changes = {}
|
|
1203
|
+
if values.get("name") is not None:
|
|
1204
|
+
name = values["name"].strip()
|
|
1205
|
+
if not name:
|
|
1206
|
+
return None
|
|
1207
|
+
changes["name"] = name
|
|
1208
|
+
if values.get("urlKey") is not None:
|
|
1209
|
+
url_key = values["urlKey"].strip().lower()
|
|
1210
|
+
if not url_key or not url_key.replace("-", "").isalnum():
|
|
1211
|
+
return None
|
|
1212
|
+
changes["url_key"] = url_key
|
|
1213
|
+
if not changes:
|
|
1214
|
+
return self.workspace(org_id)
|
|
1215
|
+
changes["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
1216
|
+
with self.engine.begin() as conn:
|
|
1217
|
+
result = conn.execute(organization.update().where(
|
|
1218
|
+
organization.c.id == org_id).values(**changes))
|
|
1219
|
+
if not result.rowcount:
|
|
1220
|
+
return None
|
|
1221
|
+
return self.workspace(org_id)
|
|
1222
|
+
|
|
1223
|
+
def workspace_update(self, org_id: str, values: dict) -> dict | None:
|
|
1224
|
+
"""Compatibility alias for the existing workspace settings update."""
|
|
1225
|
+
return self.update_workspace(org_id, values)
|
|
1226
|
+
|
|
1227
|
+
def teams(self, org_id: str, key: str | None = None, viewer_id: str | None = None) -> list[dict]:
|
|
1228
|
+
with self.engine.connect() as conn:
|
|
1229
|
+
stmt = select(team).where(and_(
|
|
1230
|
+
team.c.organization_id == org_id, team.c.archived_at.is_(None)))
|
|
1231
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
|
|
1232
|
+
if key:
|
|
1233
|
+
stmt = stmt.where(team.c.key == key.upper())
|
|
1234
|
+
stmt = stmt.order_by(team.c.key)
|
|
1235
|
+
return [self.ser_team(r) for r in _conn_rows(conn, stmt)]
|
|
1236
|
+
|
|
1237
|
+
def team_by_id_or_key(self, org_id: str, ident: str, viewer_id: str | None = None) -> dict | None:
|
|
1238
|
+
with self.engine.connect() as conn:
|
|
1239
|
+
stmt = select(team).where(and_(
|
|
1240
|
+
team.c.organization_id == org_id,
|
|
1241
|
+
or_(team.c.id == ident, team.c.key == ident.upper())))
|
|
1242
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
|
|
1243
|
+
return _one(conn, stmt) # raw row (field resolvers need _id / active_cycle_id)
|
|
1244
|
+
|
|
1245
|
+
def team_visible_for(self, org_id: str, viewer_id: str | None, team_id: str | None) -> bool:
|
|
1246
|
+
if not team_id:
|
|
1247
|
+
return True
|
|
1248
|
+
with self.engine.connect() as conn:
|
|
1249
|
+
return team_visible(conn, org_id, viewer_id, team_id)
|
|
1250
|
+
|
|
1251
|
+
def team_states(self, org_id: str, team_id: str, viewer_id: str | None = None) -> list[dict]:
|
|
1252
|
+
with self.engine.connect() as conn:
|
|
1253
|
+
if viewer_id is not None and not team_visible(conn, org_id, viewer_id, team_id):
|
|
1254
|
+
return []
|
|
1255
|
+
stmt = select(workflow_state).where(and_(
|
|
1256
|
+
workflow_state.c.organization_id == org_id,
|
|
1257
|
+
workflow_state.c.team_id == team_id,
|
|
1258
|
+
workflow_state.c.archived_at.is_(None))).order_by(workflow_state.c.position)
|
|
1259
|
+
return [self.ser_state(r) for r in _conn_rows(conn, stmt)]
|
|
1260
|
+
|
|
1261
|
+
def workflow_states(self, org_id: str, flt: dict | None = None, viewer_id: str | None = None) -> list[dict]:
|
|
1262
|
+
with self.engine.connect() as conn:
|
|
1263
|
+
stmt = select(workflow_state).where(and_(
|
|
1264
|
+
workflow_state.c.organization_id == org_id,
|
|
1265
|
+
workflow_state.c.archived_at.is_(None),
|
|
1266
|
+
))
|
|
1267
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, workflow_state.c.team_id)
|
|
1268
|
+
if flt:
|
|
1269
|
+
if "team" in flt:
|
|
1270
|
+
team_ids = _resolve_team_ids(conn, org_id, flt["team"])
|
|
1271
|
+
stmt = stmt.where(
|
|
1272
|
+
workflow_state.c.team_id.in_(team_ids or ["__none__"])
|
|
1273
|
+
)
|
|
1274
|
+
for field, column in (
|
|
1275
|
+
("type", workflow_state.c.type),
|
|
1276
|
+
("name", workflow_state.c.name),
|
|
1277
|
+
):
|
|
1278
|
+
if field in flt:
|
|
1279
|
+
clause = _apply_str_cmp(column, flt[field])
|
|
1280
|
+
if clause is not None:
|
|
1281
|
+
stmt = stmt.where(clause)
|
|
1282
|
+
return [
|
|
1283
|
+
self.ser_state(r)
|
|
1284
|
+
for r in _conn_rows(conn, stmt.order_by(workflow_state.c.position))
|
|
1285
|
+
]
|
|
1286
|
+
|
|
1287
|
+
def set_team_membership(self, org_id: str, team_id: str, user_id: str, *, add: bool,
|
|
1288
|
+
role: str = "member") -> None:
|
|
1289
|
+
"""Add or remove a team member, honouring the parent/child membership rules.
|
|
1290
|
+
|
|
1291
|
+
Adding to a sub-team also adds the person to every ancestor, because a
|
|
1292
|
+
department is a reporting structure and a sub-team member belongs to the
|
|
1293
|
+
department above it. Removal never cascades: dropping someone from a parent
|
|
1294
|
+
while they are still on a descendant is refused instead of silently taking
|
|
1295
|
+
away their access to the sub-team.
|
|
1296
|
+
"""
|
|
1297
|
+
with self.engine.begin() as conn:
|
|
1298
|
+
if add:
|
|
1299
|
+
if role not in TEAM_ROLES:
|
|
1300
|
+
raise GraphQLError("role must be one of owner, member",
|
|
1301
|
+
extensions={"code": "BAD_USER_INPUT", "field": "role"})
|
|
1302
|
+
for ancestor in self._team_and_ancestors(conn, org_id, team_id):
|
|
1303
|
+
exists = conn.execute(select(team_member.c.user_id).where(and_(
|
|
1304
|
+
team_member.c.team_id == ancestor, team_member.c.user_id == user_id))).first()
|
|
1305
|
+
if not exists:
|
|
1306
|
+
conn.execute(team_member.insert().values(
|
|
1307
|
+
team_id=ancestor, user_id=user_id,
|
|
1308
|
+
role=role if ancestor == team_id else "member"))
|
|
1309
|
+
return
|
|
1310
|
+
descendants = self._team_descendants(conn, org_id, team_id)
|
|
1311
|
+
still_on = [tid for tid in descendants if tid != team_id and conn.execute(
|
|
1312
|
+
select(team_member.c.user_id).where(and_(
|
|
1313
|
+
team_member.c.team_id == tid, team_member.c.user_id == user_id))).first()]
|
|
1314
|
+
if still_on:
|
|
1315
|
+
names = ", ".join(sorted(
|
|
1316
|
+
r[0] for r in conn.execute(select(team.c.key).where(team.c.id.in_(still_on)))))
|
|
1317
|
+
raise GraphQLError(
|
|
1318
|
+
f"This person is still a member of the sub-team(s) {names}. "
|
|
1319
|
+
"Remove them from those first.",
|
|
1320
|
+
extensions={"code": "BAD_USER_INPUT", "field": "teamId",
|
|
1321
|
+
"subTeams": names})
|
|
1322
|
+
conn.execute(team_member.delete().where(and_(
|
|
1323
|
+
team_member.c.team_id == team_id, team_member.c.user_id == user_id)))
|
|
1324
|
+
|
|
1325
|
+
@staticmethod
|
|
1326
|
+
def _team_and_ancestors(conn, org_id: str, team_id: str) -> list[str]:
|
|
1327
|
+
return team_ancestor_ids(conn, org_id, team_id)
|
|
1328
|
+
|
|
1329
|
+
@staticmethod
|
|
1330
|
+
def _team_descendants(conn, org_id: str, team_id: str) -> list[str]:
|
|
1331
|
+
return team_descendant_ids(conn, org_id, team_id)
|
|
1332
|
+
|
|
1333
|
+
def team_members(self, org_id: str, team_id: str, viewer_id: str | None = None) -> list[dict]:
|
|
1334
|
+
with self.engine.connect() as conn:
|
|
1335
|
+
if viewer_id is not None and not team_visible(conn, org_id, viewer_id, team_id):
|
|
1336
|
+
return []
|
|
1337
|
+
stmt = select(user).select_from(
|
|
1338
|
+
user.join(team_member, team_member.c.user_id == user.c.id)
|
|
1339
|
+
).where(and_(team_member.c.team_id == team_id, user.c.organization_id == org_id))
|
|
1340
|
+
return [self.ser_user(r) for r in _conn_rows(conn, stmt)]
|
|
1341
|
+
|
|
1342
|
+
def team_children(self, org_id: str, team_id: str, viewer_id: str | None = None) -> list[dict]:
|
|
1343
|
+
"""Direct sub-teams, scoped like every other team read."""
|
|
1344
|
+
with self.engine.connect() as conn:
|
|
1345
|
+
if viewer_id is not None and not team_visible(conn, org_id, viewer_id, team_id):
|
|
1346
|
+
return []
|
|
1347
|
+
stmt = select(team).where(and_(
|
|
1348
|
+
team.c.organization_id == org_id,
|
|
1349
|
+
team.c.parent_team_id == team_id,
|
|
1350
|
+
team.c.archived_at.is_(None)))
|
|
1351
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
|
|
1352
|
+
return [self.ser_team(r) for r in _conn_rows(conn, stmt.order_by(team.c.key))]
|
|
1353
|
+
|
|
1354
|
+
def team_cycles(self, org_id: str, team_id: str, viewer_id: str | None = None) -> list[dict]:
|
|
1355
|
+
with self.engine.connect() as conn:
|
|
1356
|
+
if viewer_id is not None and not team_visible(conn, org_id, viewer_id, team_id):
|
|
1357
|
+
return []
|
|
1358
|
+
stmt = select(cycle).where(and_(
|
|
1359
|
+
cycle.c.organization_id == org_id, cycle.c.team_id == team_id)).order_by(cycle.c.number)
|
|
1360
|
+
return [self.ser_cycle(r) for r in _conn_rows(conn, stmt)]
|
|
1361
|
+
|
|
1362
|
+
def cycles(self, org_id: str, flt: dict | None = None, viewer_id: str | None = None) -> list[dict]:
|
|
1363
|
+
with self.engine.connect() as conn:
|
|
1364
|
+
stmt = select(cycle).where(cycle.c.organization_id == org_id)
|
|
1365
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, cycle.c.team_id)
|
|
1366
|
+
if flt:
|
|
1367
|
+
if "team" in flt:
|
|
1368
|
+
team_ids = _resolve_team_ids(conn, org_id, flt["team"])
|
|
1369
|
+
stmt = stmt.where(cycle.c.team_id.in_(team_ids or ["__none__"]))
|
|
1370
|
+
for field, column in (("id", cycle.c.id), ("name", cycle.c.name)):
|
|
1371
|
+
if field in flt:
|
|
1372
|
+
clause = _apply_str_cmp(column, flt[field])
|
|
1373
|
+
if clause is not None:
|
|
1374
|
+
stmt = stmt.where(clause)
|
|
1375
|
+
cycle_ids = _resolve_cycle_ids(conn, org_id, flt)
|
|
1376
|
+
if cycle_ids is not None:
|
|
1377
|
+
stmt = stmt.where(cycle.c.id.in_(cycle_ids or ["__none__"]))
|
|
1378
|
+
return [
|
|
1379
|
+
self.ser_cycle(r)
|
|
1380
|
+
for r in _conn_rows(conn, stmt.order_by(cycle.c.number))
|
|
1381
|
+
]
|
|
1382
|
+
|
|
1383
|
+
def team_cadence_snapshot(self, org_id: str, team_id: str, viewer_id: str) -> dict:
|
|
1384
|
+
"""Cadence view for read-only API keys: reads persisted state and never reconciles."""
|
|
1385
|
+
with self.engine.connect() as conn:
|
|
1386
|
+
if not team_visible(conn, org_id, viewer_id, team_id):
|
|
1387
|
+
raise ValueError("Team not found in this workspace.")
|
|
1388
|
+
row = _one(conn, select(team).where(and_(team.c.organization_id == org_id, team.c.id == team_id)))
|
|
1389
|
+
cycle_rows = _conn_rows(conn, select(cycle).where(and_(
|
|
1390
|
+
cycle.c.organization_id == org_id, cycle.c.team_id == team_id)).order_by(cycle.c.number))
|
|
1391
|
+
zone_name = row.get("timezone") or "UTC"
|
|
1392
|
+
if not row.get("cadence_enabled"):
|
|
1393
|
+
return {"enabled": False, "weekday": None, "timezone": zone_name, "policyVersion": 1,
|
|
1394
|
+
"activeCycle": None, "upcomingCycles": []}
|
|
1395
|
+
active = next((c for c in cycle_rows if c["id"] == row.get("active_cycle_id")), None)
|
|
1396
|
+
after_number = active["number"] if active else 0
|
|
1397
|
+
upcoming = [c for c in cycle_rows if c["number"] > after_number][:2]
|
|
1398
|
+
return {"enabled": True, "weekday": row.get("cadence_weekday"), "timezone": zone_name,
|
|
1399
|
+
"policyVersion": row.get("cadence_policy_version"),
|
|
1400
|
+
"activeCycle": self.ser_cycle(active), "upcomingCycles": [self.ser_cycle(c) for c in upcoming]}
|
|
1401
|
+
|
|
1402
|
+
def issue_by_id_or_identifier(self, org_id: str, ident: str, viewer_id: str | None = None) -> dict | None:
|
|
1403
|
+
with self.engine.connect() as conn:
|
|
1404
|
+
stmt = select(issue).where(and_(
|
|
1405
|
+
issue.c.organization_id == org_id,
|
|
1406
|
+
or_(issue.c.id == ident, issue.c.identifier == ident.upper())))
|
|
1407
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
|
|
1408
|
+
return self.ser_issue(_one(conn, stmt))
|
|
1409
|
+
|
|
1410
|
+
def issue_children(
|
|
1411
|
+
self, org_id: str, issue_id: str, viewer_id: str | None = None,
|
|
1412
|
+
after: tuple[str, str] | None = None, limit: int | None = None,
|
|
1413
|
+
) -> list[dict]:
|
|
1414
|
+
with self.engine.connect() as conn:
|
|
1415
|
+
stmt = select(issue).where(and_(
|
|
1416
|
+
issue.c.organization_id == org_id,
|
|
1417
|
+
issue.c.parent_id == issue_id,
|
|
1418
|
+
issue.c.archived_at.is_(None),
|
|
1419
|
+
))
|
|
1420
|
+
if after:
|
|
1421
|
+
created_at, cursor_id = after
|
|
1422
|
+
stmt = stmt.where(or_(
|
|
1423
|
+
issue.c.created_at > created_at,
|
|
1424
|
+
and_(issue.c.created_at == created_at, issue.c.id > cursor_id),
|
|
1425
|
+
))
|
|
1426
|
+
stmt = stmt.order_by(issue.c.created_at, issue.c.id)
|
|
1427
|
+
if limit is not None:
|
|
1428
|
+
stmt = stmt.limit(limit)
|
|
1429
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
|
|
1430
|
+
return [self.ser_issue(row) for row in _conn_rows(conn, stmt)]
|
|
1431
|
+
|
|
1432
|
+
def inbox_notifications(self, org_id: str, user_id: str, after=None, limit=51,
|
|
1433
|
+
unread_only=False, archived=False) -> list[dict]:
|
|
1434
|
+
with self.engine.connect() as conn:
|
|
1435
|
+
disabled_kinds = [key.removeprefix("notification:") for key, value
|
|
1436
|
+
in self.user_preferences(user_id).items()
|
|
1437
|
+
if key.startswith("notification:") and value == "false"]
|
|
1438
|
+
stmt = select(inbox_notification).where(and_(
|
|
1439
|
+
inbox_notification.c.organization_id == org_id,
|
|
1440
|
+
inbox_notification.c.recipient_id == user_id,
|
|
1441
|
+
inbox_notification.c.archived_at.is_not(None) if archived else inbox_notification.c.archived_at.is_(None),
|
|
1442
|
+
))
|
|
1443
|
+
if disabled_kinds:
|
|
1444
|
+
stmt = stmt.where(inbox_notification.c.kind.not_in(disabled_kinds))
|
|
1445
|
+
if unread_only:
|
|
1446
|
+
stmt = stmt.where(inbox_notification.c.read_at.is_(None))
|
|
1447
|
+
if after:
|
|
1448
|
+
ts, notification_id = after
|
|
1449
|
+
stmt = stmt.where(inbox_notification.c.created_at <= ts).where(or_(inbox_notification.c.created_at < ts,
|
|
1450
|
+
and_(inbox_notification.c.created_at == ts, inbox_notification.c.id < notification_id)))
|
|
1451
|
+
return _conn_rows(conn, stmt.order_by(
|
|
1452
|
+
inbox_notification.c.created_at.desc(), inbox_notification.c.id.desc()).limit(limit))
|
|
1453
|
+
|
|
1454
|
+
def inbox_unread_count(self, org_id: str, user_id: str) -> int:
|
|
1455
|
+
from sqlalchemy import func
|
|
1456
|
+
with self.engine.connect() as conn:
|
|
1457
|
+
conditions = [
|
|
1458
|
+
inbox_notification.c.organization_id == org_id,
|
|
1459
|
+
inbox_notification.c.recipient_id == user_id,
|
|
1460
|
+
inbox_notification.c.read_at.is_(None),
|
|
1461
|
+
inbox_notification.c.archived_at.is_(None),
|
|
1462
|
+
]
|
|
1463
|
+
disabled_kinds = [key.removeprefix("notification:") for key, value
|
|
1464
|
+
in self.user_preferences(user_id).items()
|
|
1465
|
+
if key.startswith("notification:") and value == "false"]
|
|
1466
|
+
if disabled_kinds:
|
|
1467
|
+
conditions.append(inbox_notification.c.kind.not_in(disabled_kinds))
|
|
1468
|
+
return conn.execute(select(func.count()).select_from(inbox_notification).where(
|
|
1469
|
+
and_(*conditions))).scalar_one()
|
|
1470
|
+
|
|
1471
|
+
def update_profile_name(self, org_id: str, user_id: str, name: str) -> dict | None:
|
|
1472
|
+
clean_name = name.strip()
|
|
1473
|
+
if not clean_name or len(clean_name) > 80:
|
|
1474
|
+
return None
|
|
1475
|
+
with self.engine.begin() as conn:
|
|
1476
|
+
result = conn.execute(user.update().where(and_(
|
|
1477
|
+
user.c.id == user_id, user.c.organization_id == org_id,
|
|
1478
|
+
user.c.active.is_(True), user.c.archived_at.is_(None),
|
|
1479
|
+
)).values(name=clean_name, display_name=clean_name,
|
|
1480
|
+
updated_at=datetime.now(timezone.utc).isoformat()))
|
|
1481
|
+
if not result.rowcount:
|
|
1482
|
+
return None
|
|
1483
|
+
return self.ser_user(self.get_user(org_id, user_id), user_id)
|
|
1484
|
+
|
|
1485
|
+
def set_notification_preference(self, user_id: str, kind: str, enabled: bool) -> bool:
|
|
1486
|
+
if kind not in {"issueCreated", "issueUpdated"}:
|
|
1487
|
+
return False
|
|
1488
|
+
return self.set_user_preference(user_id, f"notification:{kind}",
|
|
1489
|
+
"true" if enabled else "false")
|
|
1490
|
+
|
|
1491
|
+
def inbox_mark(self, org_id: str, user_id: str, notification_id: str, column: str) -> bool:
|
|
1492
|
+
if column not in ("read_at", "archived_at"):
|
|
1493
|
+
raise ValueError("Unsupported notification state.")
|
|
1494
|
+
from hoja_server.db import now_iso
|
|
1495
|
+
target = inbox_notification.c[column]
|
|
1496
|
+
with self.engine.begin() as conn:
|
|
1497
|
+
result = conn.execute(inbox_notification.update().where(and_(
|
|
1498
|
+
inbox_notification.c.id == notification_id,
|
|
1499
|
+
inbox_notification.c.organization_id == org_id,
|
|
1500
|
+
inbox_notification.c.recipient_id == user_id,
|
|
1501
|
+
target.is_(None),
|
|
1502
|
+
)).values({column: now_iso()}))
|
|
1503
|
+
return result.rowcount > 0 or conn.execute(select(inbox_notification.c.id).where(and_(
|
|
1504
|
+
inbox_notification.c.id == notification_id,
|
|
1505
|
+
inbox_notification.c.organization_id == org_id,
|
|
1506
|
+
inbox_notification.c.recipient_id == user_id,
|
|
1507
|
+
))).first() is not None
|
|
1508
|
+
|
|
1509
|
+
def inbox_mark_all_read(self, org_id: str, user_id: str) -> bool:
|
|
1510
|
+
from hoja_server.db import now_iso
|
|
1511
|
+
with self.engine.begin() as conn:
|
|
1512
|
+
conn.execute(inbox_notification.update().where(and_(
|
|
1513
|
+
inbox_notification.c.organization_id == org_id,
|
|
1514
|
+
inbox_notification.c.recipient_id == user_id,
|
|
1515
|
+
inbox_notification.c.read_at.is_(None),
|
|
1516
|
+
)).values(read_at=now_iso()))
|
|
1517
|
+
return True
|
|
1518
|
+
|
|
1519
|
+
def my_issue_activity(self, org_id: str, viewer_id: str, after=None, limit=51) -> list[dict]:
|
|
1520
|
+
with self.engine.connect() as conn:
|
|
1521
|
+
stmt = select(issue_activity).join(issue, and_(
|
|
1522
|
+
issue_activity.c.issue_id == issue.c.id,
|
|
1523
|
+
issue_activity.c.organization_id == issue.c.organization_id,
|
|
1524
|
+
)).where(and_(
|
|
1525
|
+
issue_activity.c.organization_id == org_id,
|
|
1526
|
+
issue.c.organization_id == org_id,
|
|
1527
|
+
issue.c.archived_at.is_(None),
|
|
1528
|
+
or_(issue.c.assignee_id == viewer_id, issue.c.creator_id == viewer_id,
|
|
1529
|
+
issue_activity.c.actor_id == viewer_id),
|
|
1530
|
+
))
|
|
1531
|
+
# Activity carries `changes`, which can repeat a title or body: scope it like the issue itself.
|
|
1532
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
|
|
1533
|
+
if after:
|
|
1534
|
+
ts, event_id = after
|
|
1535
|
+
stmt = stmt.where(issue_activity.c.created_at <= ts).where(or_(issue_activity.c.created_at < ts,
|
|
1536
|
+
and_(issue_activity.c.created_at == ts, issue_activity.c.id < event_id)))
|
|
1537
|
+
rows = _conn_rows(conn, stmt.order_by(
|
|
1538
|
+
issue_activity.c.created_at.desc(), issue_activity.c.id.desc()).limit(limit))
|
|
1539
|
+
for row in rows:
|
|
1540
|
+
row["changes"] = json.loads(row["changes"]) if row.get("changes") else None
|
|
1541
|
+
return rows
|
|
1542
|
+
|
|
1543
|
+
def project_issue_activity(self, org_id: str, project_id: str, viewer_id: str | None = None,
|
|
1544
|
+
after=None, limit=51, event_types: list[str] | None = None) -> list[dict]:
|
|
1545
|
+
"""Events on the issues currently in a project, newest first, limited to teams the viewer can see."""
|
|
1546
|
+
with self.engine.connect() as conn:
|
|
1547
|
+
stmt = select(issue_activity).join(issue, and_(
|
|
1548
|
+
issue_activity.c.issue_id == issue.c.id,
|
|
1549
|
+
issue_activity.c.organization_id == issue.c.organization_id,
|
|
1550
|
+
)).where(and_(
|
|
1551
|
+
issue_activity.c.organization_id == org_id,
|
|
1552
|
+
issue.c.organization_id == org_id,
|
|
1553
|
+
issue.c.project_id == project_id,
|
|
1554
|
+
))
|
|
1555
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
|
|
1556
|
+
if event_types:
|
|
1557
|
+
stmt = stmt.where(issue_activity.c.event_type.in_(event_types))
|
|
1558
|
+
if after:
|
|
1559
|
+
ts, event_id = after
|
|
1560
|
+
stmt = stmt.where(issue_activity.c.created_at <= ts).where(or_(issue_activity.c.created_at < ts,
|
|
1561
|
+
and_(issue_activity.c.created_at == ts, issue_activity.c.id < event_id)))
|
|
1562
|
+
rows = _conn_rows(conn, stmt.order_by(
|
|
1563
|
+
issue_activity.c.created_at.desc(), issue_activity.c.id.desc()).limit(limit))
|
|
1564
|
+
for row in rows:
|
|
1565
|
+
row["changes"] = json.loads(row["changes"]) if row.get("changes") else None
|
|
1566
|
+
return rows
|
|
1567
|
+
|
|
1568
|
+
def issue_activity_for(self, org_id: str, issue_id: str, after=None, limit=51) -> list[dict]:
|
|
1569
|
+
with self.engine.connect() as conn:
|
|
1570
|
+
stmt = select(issue_activity).where(and_(
|
|
1571
|
+
issue_activity.c.organization_id == org_id,
|
|
1572
|
+
issue_activity.c.issue_id == issue_id,
|
|
1573
|
+
))
|
|
1574
|
+
if after:
|
|
1575
|
+
ts, event_id = after
|
|
1576
|
+
stmt = stmt.where(issue_activity.c.created_at <= ts).where(or_(issue_activity.c.created_at < ts,
|
|
1577
|
+
and_(issue_activity.c.created_at == ts, issue_activity.c.id < event_id)))
|
|
1578
|
+
rows = _conn_rows(conn, stmt.order_by(
|
|
1579
|
+
issue_activity.c.created_at.desc(), issue_activity.c.id.desc()).limit(limit))
|
|
1580
|
+
for row in rows:
|
|
1581
|
+
row["changes"] = json.loads(row["changes"]) if row.get("changes") else None
|
|
1582
|
+
return rows
|
|
1583
|
+
|
|
1584
|
+
def labels(self, org_id: str, team_id: str | None = None, name: str | None = None,
|
|
1585
|
+
viewer_id: str | None = None) -> list[dict]:
|
|
1586
|
+
"""Labels in the org. With a team_id, its own plus the ones it inherits from ancestors."""
|
|
1587
|
+
with self.engine.connect() as conn:
|
|
1588
|
+
stmt = select(issue_label).where(and_(
|
|
1589
|
+
issue_label.c.organization_id == org_id, issue_label.c.archived_at.is_(None)))
|
|
1590
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue_label.c.team_id, nullable=True)
|
|
1591
|
+
if team_id:
|
|
1592
|
+
stmt = stmt.where(issue_label.c.team_id.in_(team_ancestor_ids(conn, org_id, team_id)))
|
|
1593
|
+
if name:
|
|
1594
|
+
stmt = stmt.where(issue_label.c.name == name)
|
|
1595
|
+
return [self.ser_label(r) for r in _conn_rows(conn, stmt.order_by(issue_label.c.name))]
|
|
1596
|
+
|
|
1597
|
+
def issue_labels_for(self, org_id: str, issue_id: str) -> list[dict]:
|
|
1598
|
+
with self.engine.connect() as conn:
|
|
1599
|
+
stmt = select(issue_label).select_from(
|
|
1600
|
+
issue_label.join(issue_label_link, issue_label_link.c.label_id == issue_label.c.id)
|
|
1601
|
+
).where(and_(issue_label_link.c.issue_id == issue_id,
|
|
1602
|
+
issue_label.c.organization_id == org_id))
|
|
1603
|
+
return [self.ser_label(r) for r in _conn_rows(conn, stmt)]
|
|
1604
|
+
|
|
1605
|
+
# ------------------------------------------------------------ batch loaders
|
|
1606
|
+
# One query per list page instead of one per row (GraphQL resolvers prime them).
|
|
1607
|
+
def issue_labels_for_many(self, org_id: str, issue_ids: list[str]) -> dict[str, list[dict]]:
|
|
1608
|
+
out: dict[str, list[dict]] = {i: [] for i in issue_ids}
|
|
1609
|
+
with self.engine.connect() as conn:
|
|
1610
|
+
for start in range(0, len(issue_ids), 500):
|
|
1611
|
+
chunk = issue_ids[start:start + 500]
|
|
1612
|
+
rows = conn.execute(select(issue_label_link.c.issue_id, issue_label).select_from(
|
|
1613
|
+
issue_label.join(issue_label_link, issue_label_link.c.label_id == issue_label.c.id)
|
|
1614
|
+
).where(and_(issue_label_link.c.issue_id.in_(chunk),
|
|
1615
|
+
issue_label.c.organization_id == org_id))).mappings().all()
|
|
1616
|
+
for r in rows:
|
|
1617
|
+
row = dict(r)
|
|
1618
|
+
out[row.pop("issue_id")].append(self.ser_label(row))
|
|
1619
|
+
return out
|
|
1620
|
+
|
|
1621
|
+
def issue_subscribers_for_many(self, org_id: str, issue_ids: list[str]) -> dict[str, list[dict]]:
|
|
1622
|
+
out: dict[str, list[dict]] = {i: [] for i in issue_ids}
|
|
1623
|
+
with self.engine.connect() as conn:
|
|
1624
|
+
for start in range(0, len(issue_ids), 500):
|
|
1625
|
+
chunk = issue_ids[start:start + 500]
|
|
1626
|
+
rows = conn.execute(select(issue_subscriber.c.issue_id.label("_issue_id"), user).select_from(
|
|
1627
|
+
user.join(issue_subscriber, issue_subscriber.c.user_id == user.c.id)
|
|
1628
|
+
).where(and_(issue_subscriber.c.issue_id.in_(chunk),
|
|
1629
|
+
user.c.organization_id == org_id))).mappings().all()
|
|
1630
|
+
for r in rows:
|
|
1631
|
+
row = dict(r)
|
|
1632
|
+
out[row.pop("_issue_id")].append(self.ser_user(row))
|
|
1633
|
+
return out
|
|
1634
|
+
|
|
1635
|
+
def users_by_ids(self, org_id: str, user_ids: list[str]) -> dict[str, dict]:
|
|
1636
|
+
out: dict[str, dict] = {}
|
|
1637
|
+
with self.engine.connect() as conn:
|
|
1638
|
+
for start in range(0, len(user_ids), 500):
|
|
1639
|
+
chunk = user_ids[start:start + 500]
|
|
1640
|
+
for row in _conn_rows(conn, select(user).where(and_(
|
|
1641
|
+
user.c.organization_id == org_id, user.c.id.in_(chunk)))):
|
|
1642
|
+
out[row["id"]] = row
|
|
1643
|
+
return out
|
|
1644
|
+
|
|
1645
|
+
def issues_by_ids(self, org_id: str, issue_ids: list[str], viewer_id: str | None = None) -> dict[str, dict]:
|
|
1646
|
+
out: dict[str, dict] = {}
|
|
1647
|
+
with self.engine.connect() as conn:
|
|
1648
|
+
for start in range(0, len(issue_ids), 500):
|
|
1649
|
+
chunk = issue_ids[start:start + 500]
|
|
1650
|
+
stmt = select(issue).where(and_(issue.c.organization_id == org_id, issue.c.id.in_(chunk)))
|
|
1651
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
|
|
1652
|
+
for row in _conn_rows(conn, stmt):
|
|
1653
|
+
out[row["id"]] = self.ser_issue(row)
|
|
1654
|
+
return out
|
|
1655
|
+
|
|
1656
|
+
def projects_by_ids(self, org_id: str, project_ids: list[str],
|
|
1657
|
+
viewer_id: str | None = None) -> dict[str, dict]:
|
|
1658
|
+
out: dict[str, dict] = {}
|
|
1659
|
+
with self.engine.connect() as conn:
|
|
1660
|
+
for start in range(0, len(project_ids), 500):
|
|
1661
|
+
chunk = project_ids[start:start + 500]
|
|
1662
|
+
stmt = select(project).where(and_(
|
|
1663
|
+
project.c.organization_id == org_id, project.c.id.in_(chunk),
|
|
1664
|
+
project.c.archived_at.is_(None)))
|
|
1665
|
+
stmt = _scope_projects(conn, stmt, org_id, viewer_id)
|
|
1666
|
+
for row in _conn_rows(conn, stmt):
|
|
1667
|
+
out[row["id"]] = self.ser_project(row)
|
|
1668
|
+
return out
|
|
1669
|
+
|
|
1670
|
+
def project_progress_many(self, org_id: str, project_ids: list[str]) -> dict[str, float]:
|
|
1671
|
+
"""Percent (0-100) of each project's non-canceled, non-archived issues that are completed.
|
|
1672
|
+
|
|
1673
|
+
Derived from issues on every read (one grouped query per batch) because the stored
|
|
1674
|
+
project.progress column is never updated after creation."""
|
|
1675
|
+
out: dict[str, float] = {p: 0.0 for p in project_ids}
|
|
1676
|
+
with self.engine.connect() as conn:
|
|
1677
|
+
for start in range(0, len(project_ids), 500):
|
|
1678
|
+
chunk = project_ids[start:start + 500]
|
|
1679
|
+
stmt = select(issue.c.project_id, workflow_state.c.type, func.count()).select_from(
|
|
1680
|
+
issue.join(workflow_state, workflow_state.c.id == issue.c.state_id)
|
|
1681
|
+
).where(and_(issue.c.organization_id == org_id, issue.c.project_id.in_(chunk),
|
|
1682
|
+
issue.c.archived_at.is_(None))).group_by(issue.c.project_id, workflow_state.c.type)
|
|
1683
|
+
done: dict[str, int] = {}
|
|
1684
|
+
scope: dict[str, int] = {}
|
|
1685
|
+
for pid, kind, n in conn.execute(stmt):
|
|
1686
|
+
if kind == "canceled":
|
|
1687
|
+
continue
|
|
1688
|
+
scope[pid] = scope.get(pid, 0) + n
|
|
1689
|
+
if kind == "completed":
|
|
1690
|
+
done[pid] = done.get(pid, 0) + n
|
|
1691
|
+
for pid, total in scope.items():
|
|
1692
|
+
out[pid] = round(done.get(pid, 0) * 100.0 / total, 2) if total else 0.0
|
|
1693
|
+
return out
|
|
1694
|
+
|
|
1695
|
+
def project_teams_many(self, org_id: str, project_ids: list[str],
|
|
1696
|
+
viewer_id: str | None = None) -> dict[str, list[dict]]:
|
|
1697
|
+
out: dict[str, list[dict]] = {p: [] for p in project_ids}
|
|
1698
|
+
with self.engine.connect() as conn:
|
|
1699
|
+
for start in range(0, len(project_ids), 500):
|
|
1700
|
+
chunk = project_ids[start:start + 500]
|
|
1701
|
+
stmt = select(project_team.c.project_id.label("_project_id"), team).select_from(
|
|
1702
|
+
team.join(project_team, project_team.c.team_id == team.c.id)
|
|
1703
|
+
).where(and_(project_team.c.project_id.in_(chunk), team.c.organization_id == org_id,
|
|
1704
|
+
team.c.archived_at.is_(None)))
|
|
1705
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
|
|
1706
|
+
for r in conn.execute(stmt).mappings().all():
|
|
1707
|
+
row = dict(r)
|
|
1708
|
+
out[row.pop("_project_id")].append(self.ser_team(row))
|
|
1709
|
+
return out
|
|
1710
|
+
|
|
1711
|
+
def issue_subscribers_for(self, org_id: str, issue_id: str, limit: int | None = None) -> list[dict]:
|
|
1712
|
+
with self.engine.connect() as conn:
|
|
1713
|
+
stmt = select(user).select_from(
|
|
1714
|
+
user.join(issue_subscriber, issue_subscriber.c.user_id == user.c.id)
|
|
1715
|
+
).where(and_(issue_subscriber.c.issue_id == issue_id, user.c.organization_id == org_id))
|
|
1716
|
+
if limit:
|
|
1717
|
+
stmt = stmt.limit(limit)
|
|
1718
|
+
return [self.ser_user(r) for r in _conn_rows(conn, stmt)]
|
|
1719
|
+
|
|
1720
|
+
def comments_for(self, org_id: str, issue_id: str, limit: int | None = None) -> list[dict]:
|
|
1721
|
+
with self.engine.connect() as conn:
|
|
1722
|
+
stmt = select(comment).where(and_(
|
|
1723
|
+
comment.c.organization_id == org_id, comment.c.issue_id == issue_id,
|
|
1724
|
+
comment.c.archived_at.is_(None))).order_by(comment.c.created_at)
|
|
1725
|
+
if limit:
|
|
1726
|
+
stmt = stmt.limit(limit)
|
|
1727
|
+
return [self.ser_comment(r) for r in _conn_rows(conn, stmt)]
|
|
1728
|
+
|
|
1729
|
+
def issue_attachments_for(self, org_id: str, issue_id: str, limit: int | None = None) -> list[dict]:
|
|
1730
|
+
with self.engine.connect() as conn:
|
|
1731
|
+
stmt = select(attachment).where(and_(
|
|
1732
|
+
attachment.c.organization_id == org_id,
|
|
1733
|
+
attachment.c.issue_id == issue_id)).order_by(attachment.c.created_at)
|
|
1734
|
+
if limit:
|
|
1735
|
+
stmt = stmt.limit(limit)
|
|
1736
|
+
return [self.ser_attachment(r) for r in _conn_rows(conn, stmt)]
|
|
1737
|
+
|
|
1738
|
+
def upload_visible(self, org_id: str, viewer_id: str, file_id: str) -> bool:
|
|
1739
|
+
"""May this viewer fetch an uploaded file?
|
|
1740
|
+
|
|
1741
|
+
Uploads are org-wide blobs, so the file itself carries no team. When any
|
|
1742
|
+
attachment row points at it, the file takes the visibility of the *most*
|
|
1743
|
+
permissive of those issues; a file no attachment references (an avatar, an
|
|
1744
|
+
in-flight upload) stays readable by any member of the organization.
|
|
1745
|
+
"""
|
|
1746
|
+
with self.engine.connect() as conn:
|
|
1747
|
+
issue_ids = list(conn.execute(select(attachment.c.issue_id).where(and_(
|
|
1748
|
+
attachment.c.organization_id == org_id,
|
|
1749
|
+
attachment.c.url.contains(file_id),
|
|
1750
|
+
attachment.c.issue_id.is_not(None)))).scalars())
|
|
1751
|
+
if not issue_ids:
|
|
1752
|
+
return True
|
|
1753
|
+
stmt = select(issue.c.id).where(and_(
|
|
1754
|
+
issue.c.organization_id == org_id, issue.c.id.in_(issue_ids)))
|
|
1755
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
|
|
1756
|
+
return conn.execute(stmt).first() is not None
|
|
1757
|
+
|
|
1758
|
+
def projects(self, org_id: str, flt: dict | None = None,
|
|
1759
|
+
after: tuple[str, str] | None = None, limit: int | None = None,
|
|
1760
|
+
viewer_id: str | None = None) -> list[dict]:
|
|
1761
|
+
with self.engine.connect() as conn:
|
|
1762
|
+
stmt = select(project).where(and_(
|
|
1763
|
+
project.c.organization_id == org_id, project.c.archived_at.is_(None)))
|
|
1764
|
+
stmt = _apply_project_filter(stmt, flt, conn, org_id)
|
|
1765
|
+
stmt = _scope_projects(conn, stmt, org_id, viewer_id)
|
|
1766
|
+
if after:
|
|
1767
|
+
stmt = stmt.where(project.c.created_at >= after[0]).where((project.c.created_at > after[0]) |
|
|
1768
|
+
((project.c.created_at == after[0]) & (project.c.id > after[1])))
|
|
1769
|
+
stmt = stmt.order_by(project.c.created_at, project.c.id)
|
|
1770
|
+
if limit is not None:
|
|
1771
|
+
stmt = stmt.limit(limit)
|
|
1772
|
+
return [self.ser_project(r) for r in _conn_rows(conn, stmt)]
|
|
1773
|
+
|
|
1774
|
+
def cycle_by_id(self, org_id: str, cycle_id: str, viewer_id: str | None = None) -> dict | None:
|
|
1775
|
+
with self.engine.connect() as conn:
|
|
1776
|
+
stmt = select(cycle).where(and_(
|
|
1777
|
+
cycle.c.organization_id == org_id, cycle.c.id == cycle_id))
|
|
1778
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, cycle.c.team_id)
|
|
1779
|
+
return self.ser_cycle(_one(conn, stmt))
|
|
1780
|
+
|
|
1781
|
+
def project_by_id(self, org_id: str, pid: str, viewer_id: str | None = None) -> dict | None:
|
|
1782
|
+
with self.engine.connect() as conn:
|
|
1783
|
+
stmt = select(project).where(and_(
|
|
1784
|
+
project.c.organization_id == org_id, project.c.id == pid,
|
|
1785
|
+
project.c.archived_at.is_(None)))
|
|
1786
|
+
stmt = _scope_projects(conn, stmt, org_id, viewer_id)
|
|
1787
|
+
return self.ser_project(_one(conn, stmt))
|
|
1788
|
+
|
|
1789
|
+
def initiatives(self, org_id: str, after: tuple[str, str] | None = None, status: str | None = None, limit: int = 51) -> list[dict]:
|
|
1790
|
+
stmt = select(initiative).where(and_(initiative.c.organization_id == org_id, initiative.c.archived_at.is_(None)))
|
|
1791
|
+
if status:
|
|
1792
|
+
stmt = stmt.where(initiative.c.status == status)
|
|
1793
|
+
if after:
|
|
1794
|
+
stmt = stmt.where(initiative.c.created_at <= after[0]).where((initiative.c.created_at < after[0]) | ((initiative.c.created_at == after[0]) & (initiative.c.id < after[1])))
|
|
1795
|
+
with self.engine.connect() as conn:
|
|
1796
|
+
rows = _conn_rows(conn, stmt.order_by(initiative.c.created_at.desc(), initiative.c.id.desc()).limit(limit))
|
|
1797
|
+
return [self.ser_initiative(conn, row) for row in rows]
|
|
1798
|
+
|
|
1799
|
+
def initiative_by_id(self, org_id: str, ident: str) -> dict | None:
|
|
1800
|
+
with self.engine.connect() as conn:
|
|
1801
|
+
row = _one(conn, select(initiative).where(and_(initiative.c.organization_id == org_id, initiative.c.id == ident, initiative.c.archived_at.is_(None))))
|
|
1802
|
+
return self.ser_initiative(conn, row) if row else None
|
|
1803
|
+
|
|
1804
|
+
@staticmethod
|
|
1805
|
+
def ser_initiative(conn, row) -> dict:
|
|
1806
|
+
result = {"id": row["id"], "name": row["name"], "summary": row["summary"], "status": row["status"], "priority": row["priority"], "targetDate": row["target_date"], "createdAt": row["created_at"], "updatedAt": row["updated_at"], "_owner_id": row["owner_id"], "_creator_id": row["creator_id"], "_org_id": row["organization_id"], "owner": None, "creator": None}
|
|
1807
|
+
for field in ("owner", "creator"):
|
|
1808
|
+
uid = row[f"{field}_id"]
|
|
1809
|
+
if uid:
|
|
1810
|
+
user_row = _one(conn, select(user).where(and_(user.c.organization_id == row["organization_id"], user.c.id == uid)))
|
|
1811
|
+
result[field] = Store.__new__(Store).ser_user(user_row) if user_row else None
|
|
1812
|
+
ids = list(conn.execute(select(project.c.id).select_from(project.join(initiative_project, initiative_project.c.project_id == project.c.id)).where(and_(initiative_project.c.organization_id == row["organization_id"], initiative_project.c.initiative_id == row["id"], project.c.organization_id == row["organization_id"], project.c.archived_at.is_(None)))).scalars())
|
|
1813
|
+
result["projects"] = {"nodes": [Store.__new__(Store).ser_project(_one(conn, select(project).where(project.c.id == pid))) for pid in ids], "pageInfo": {"hasNextPage": False, "hasPreviousPage": False, "startCursor": None, "endCursor": None}}
|
|
1814
|
+
return result
|
|
1815
|
+
|
|
1816
|
+
def project_updates(self, org_id: str, after: tuple[str, str] | None = None,
|
|
1817
|
+
project_id: str | None = None, limit: int = 51) -> list[dict]:
|
|
1818
|
+
"""Return organization-scoped update posts newest-first, with stable id tie-breaks."""
|
|
1819
|
+
stmt = select(project_update).where(project_update.c.organization_id == org_id)
|
|
1820
|
+
stmt = stmt.join(project, and_(project.c.id == project_update.c.project_id,
|
|
1821
|
+
project.c.organization_id == org_id,
|
|
1822
|
+
project.c.archived_at.is_(None)))
|
|
1823
|
+
if project_id:
|
|
1824
|
+
stmt = stmt.where(project_update.c.project_id == project_id)
|
|
1825
|
+
if after:
|
|
1826
|
+
created_at, update_id = after
|
|
1827
|
+
stmt = stmt.where(project_update.c.created_at <= created_at).where(or_(project_update.c.created_at < created_at,
|
|
1828
|
+
and_(project_update.c.created_at == created_at,
|
|
1829
|
+
project_update.c.id < update_id)))
|
|
1830
|
+
stmt = stmt.order_by(project_update.c.created_at.desc(), project_update.c.id.desc()).limit(limit)
|
|
1831
|
+
with self.engine.connect() as conn:
|
|
1832
|
+
rows = _conn_rows(conn, stmt)
|
|
1833
|
+
result = []
|
|
1834
|
+
for row in rows:
|
|
1835
|
+
row["_project_id"] = row["project_id"]
|
|
1836
|
+
row["_actor_id"] = row.get("actor_id")
|
|
1837
|
+
row["createdAt"] = row["created_at"]
|
|
1838
|
+
result.append(row)
|
|
1839
|
+
return result
|
|
1840
|
+
|
|
1841
|
+
def project_update_project(self, org_id: str, pid: str) -> dict | None:
|
|
1842
|
+
return self.project_by_id(org_id, pid)
|
|
1843
|
+
|
|
1844
|
+
def project_members(self, org_id: str, project_id: str) -> list[dict]:
|
|
1845
|
+
with self.engine.connect() as conn:
|
|
1846
|
+
stmt = select(user).select_from(
|
|
1847
|
+
user.join(project_member, project_member.c.user_id == user.c.id)
|
|
1848
|
+
).where(and_(project_member.c.project_id == project_id, user.c.organization_id == org_id))
|
|
1849
|
+
return [self.ser_user(r) for r in _conn_rows(conn, stmt)]
|
|
1850
|
+
|
|
1851
|
+
def project_teams(self, org_id: str, project_id: str, viewer_id: str | None = None) -> list[dict]:
|
|
1852
|
+
with self.engine.connect() as conn:
|
|
1853
|
+
stmt = select(team).select_from(
|
|
1854
|
+
team.join(project_team, project_team.c.team_id == team.c.id)
|
|
1855
|
+
).where(and_(
|
|
1856
|
+
project_team.c.project_id == project_id,
|
|
1857
|
+
team.c.organization_id == org_id,
|
|
1858
|
+
team.c.archived_at.is_(None),
|
|
1859
|
+
))
|
|
1860
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
|
|
1861
|
+
return [self.ser_team(r) for r in _conn_rows(conn, stmt)]
|
|
1862
|
+
|
|
1863
|
+
def state_by_id(self, org_id: str, sid: str) -> dict | None:
|
|
1864
|
+
with self.engine.connect() as conn:
|
|
1865
|
+
return self.ser_state(_one(conn, select(workflow_state).where(and_(
|
|
1866
|
+
workflow_state.c.organization_id == org_id, workflow_state.c.id == sid))))
|
|
1867
|
+
|
|
1868
|
+
def raw_team(self, org_id: str, tid: str, viewer_id: str | None = None) -> dict | None:
|
|
1869
|
+
with self.engine.connect() as conn:
|
|
1870
|
+
stmt = select(team).where(and_(team.c.organization_id == org_id, team.c.id == tid))
|
|
1871
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, team.c.id)
|
|
1872
|
+
return _one(conn, stmt)
|
|
1873
|
+
|
|
1874
|
+
# --------------------------------------------------------------- issue list
|
|
1875
|
+
def issues(self, org_id: str, viewer_id: str, flt: dict | None,
|
|
1876
|
+
order_by: str = "updatedAt", after: tuple[str, str] | None = None,
|
|
1877
|
+
limit: int | None = None) -> list[dict]:
|
|
1878
|
+
with self.engine.connect() as conn:
|
|
1879
|
+
stmt = select(issue).where(and_(
|
|
1880
|
+
issue.c.organization_id == org_id, issue.c.archived_at.is_(None)))
|
|
1881
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
|
|
1882
|
+
stmt = _apply_issue_filter(conn, stmt, org_id, viewer_id, flt)
|
|
1883
|
+
col = issue.c.updated_at if order_by == "updatedAt" else issue.c.created_at
|
|
1884
|
+
stmt = stmt.order_by(col.desc(), issue.c.id.desc())
|
|
1885
|
+
if after:
|
|
1886
|
+
from sqlalchemy import or_
|
|
1887
|
+
stmt = stmt.where(col <= after[0]).where(or_(col < after[0], and_(col == after[0], issue.c.id < after[1])))
|
|
1888
|
+
if limit is not None:
|
|
1889
|
+
stmt = stmt.limit(limit)
|
|
1890
|
+
return [self.ser_issue(r) for r in _conn_rows(conn, stmt)]
|
|
1891
|
+
|
|
1892
|
+
def search_issues(self, org_id: str, term: str, viewer_id: str | None = None) -> list[dict]:
|
|
1893
|
+
needle = _like_contains(_check_search_term(term))
|
|
1894
|
+
with self.engine.connect() as conn:
|
|
1895
|
+
stmt = select(issue).where(and_(
|
|
1896
|
+
issue.c.organization_id == org_id, issue.c.archived_at.is_(None),
|
|
1897
|
+
or_(issue.c.title.ilike(needle, escape="\\"),
|
|
1898
|
+
issue.c.description.ilike(needle, escape="\\"),
|
|
1899
|
+
issue.c.identifier.ilike(needle, escape="\\"))))
|
|
1900
|
+
stmt = _scope_teams(conn, stmt, org_id, viewer_id, issue.c.team_id)
|
|
1901
|
+
stmt = stmt.order_by(issue.c.updated_at.desc())
|
|
1902
|
+
return [self.ser_issue(r) for r in _conn_rows(conn, stmt)]
|
|
1903
|
+
|
|
1904
|
+
|
|
1905
|
+
# ============================================================ filter compilers
|
|
1906
|
+
def _apply_str_cmp(col, cmp: dict):
|
|
1907
|
+
clauses = []
|
|
1908
|
+
if "eq" in cmp:
|
|
1909
|
+
clauses.append(col == cmp["eq"])
|
|
1910
|
+
if "neq" in cmp:
|
|
1911
|
+
clauses.append(col != cmp["neq"])
|
|
1912
|
+
if "in" in cmp:
|
|
1913
|
+
clauses.append(col.in_(cmp["in"]))
|
|
1914
|
+
if "nin" in cmp:
|
|
1915
|
+
clauses.append(col.notin_(cmp["nin"]))
|
|
1916
|
+
if "contains" in cmp:
|
|
1917
|
+
clauses.append(col.ilike(_like_contains(_check_search_term(cmp["contains"])), escape="\\"))
|
|
1918
|
+
if "containsIgnoreCase" in cmp:
|
|
1919
|
+
clauses.append(col.ilike(_like_contains(_check_search_term(cmp["containsIgnoreCase"])), escape="\\"))
|
|
1920
|
+
if "eqIgnoreCase" in cmp:
|
|
1921
|
+
clauses.append(func.lower(col) == _check_search_term(cmp["eqIgnoreCase"]).lower())
|
|
1922
|
+
return and_(*clauses) if clauses else None
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
def _apply_num_cmp(col, cmp: dict):
|
|
1926
|
+
clauses = []
|
|
1927
|
+
if "eq" in cmp:
|
|
1928
|
+
clauses.append(col == cmp["eq"])
|
|
1929
|
+
if "neq" in cmp:
|
|
1930
|
+
clauses.append(col != cmp["neq"])
|
|
1931
|
+
if "gt" in cmp:
|
|
1932
|
+
clauses.append(col > cmp["gt"])
|
|
1933
|
+
if "lt" in cmp:
|
|
1934
|
+
clauses.append(col < cmp["lt"])
|
|
1935
|
+
if "gte" in cmp:
|
|
1936
|
+
clauses.append(col >= cmp["gte"])
|
|
1937
|
+
if "lte" in cmp:
|
|
1938
|
+
clauses.append(col <= cmp["lte"])
|
|
1939
|
+
if "in" in cmp:
|
|
1940
|
+
clauses.append(col.in_(cmp["in"]))
|
|
1941
|
+
if "nin" in cmp:
|
|
1942
|
+
clauses.append(col.notin_(cmp["nin"]))
|
|
1943
|
+
return and_(*clauses) if clauses else None
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def _apply_date_cmp(col, cmp: dict):
|
|
1947
|
+
# ISO strings sort lexicographically → string comparison is correct
|
|
1948
|
+
clauses = []
|
|
1949
|
+
if "eq" in cmp:
|
|
1950
|
+
clauses.append(col == cmp["eq"])
|
|
1951
|
+
if "gt" in cmp:
|
|
1952
|
+
clauses.append(col > cmp["gt"])
|
|
1953
|
+
if "lt" in cmp:
|
|
1954
|
+
clauses.append(col < cmp["lt"])
|
|
1955
|
+
if "gte" in cmp:
|
|
1956
|
+
clauses.append(col >= cmp["gte"])
|
|
1957
|
+
if "lte" in cmp:
|
|
1958
|
+
clauses.append(col <= cmp["lte"])
|
|
1959
|
+
return and_(*clauses) if clauses else None
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
def _apply_project_filter(stmt, flt: dict | None, conn, org_id: str):
|
|
1963
|
+
if not flt:
|
|
1964
|
+
return stmt
|
|
1965
|
+
if "slugId" in flt:
|
|
1966
|
+
c = _apply_str_cmp(project.c.slug_id, flt["slugId"])
|
|
1967
|
+
if c is not None:
|
|
1968
|
+
stmt = stmt.where(c)
|
|
1969
|
+
if "name" in flt:
|
|
1970
|
+
c = _apply_str_cmp(project.c.name, flt["name"])
|
|
1971
|
+
if c is not None:
|
|
1972
|
+
stmt = stmt.where(c)
|
|
1973
|
+
if "state" in flt:
|
|
1974
|
+
c = _apply_str_cmp(project.c.state, flt["state"])
|
|
1975
|
+
if c is not None:
|
|
1976
|
+
stmt = stmt.where(c)
|
|
1977
|
+
if "team" in flt:
|
|
1978
|
+
team_ids = _resolve_team_ids(conn, org_id, flt["team"])
|
|
1979
|
+
project_ids = select(project_team.c.project_id).where(
|
|
1980
|
+
project_team.c.team_id.in_(team_ids or ["__none__"]))
|
|
1981
|
+
stmt = stmt.where(project.c.id.in_(project_ids))
|
|
1982
|
+
return stmt
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
def _resolve_team_ids(conn, org_id: str, cmp: dict) -> list[str]:
|
|
1986
|
+
stmt = select(team.c.id).where(team.c.organization_id == org_id)
|
|
1987
|
+
if "key" in cmp:
|
|
1988
|
+
kc = _apply_str_cmp(team.c.key, cmp["key"])
|
|
1989
|
+
if kc is not None:
|
|
1990
|
+
stmt = stmt.where(kc)
|
|
1991
|
+
if "id" in cmp:
|
|
1992
|
+
ic = _apply_str_cmp(team.c.id, cmp["id"])
|
|
1993
|
+
if ic is not None:
|
|
1994
|
+
stmt = stmt.where(ic)
|
|
1995
|
+
if "name" in cmp:
|
|
1996
|
+
nc = _apply_str_cmp(team.c.name, cmp["name"])
|
|
1997
|
+
if nc is not None:
|
|
1998
|
+
stmt = stmt.where(nc)
|
|
1999
|
+
return [r[0] for r in conn.execute(stmt)]
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
def _resolve_state_ids(conn, org_id: str, cmp: dict) -> list[str]:
|
|
2003
|
+
stmt = select(workflow_state.c.id).where(workflow_state.c.organization_id == org_id)
|
|
2004
|
+
if "name" in cmp:
|
|
2005
|
+
c = _apply_str_cmp(workflow_state.c.name, cmp["name"])
|
|
2006
|
+
if c is not None:
|
|
2007
|
+
stmt = stmt.where(c)
|
|
2008
|
+
if "type" in cmp:
|
|
2009
|
+
c = _apply_str_cmp(workflow_state.c.type, cmp["type"])
|
|
2010
|
+
if c is not None:
|
|
2011
|
+
stmt = stmt.where(c)
|
|
2012
|
+
if "team" in cmp:
|
|
2013
|
+
team_ids = _resolve_team_ids(conn, org_id, cmp["team"])
|
|
2014
|
+
stmt = stmt.where(workflow_state.c.team_id.in_(team_ids or ["__none__"]))
|
|
2015
|
+
return [r[0] for r in conn.execute(stmt)]
|
|
2016
|
+
|
|
2017
|
+
|
|
2018
|
+
def _resolve_user_ids(conn, org_id: str, cmp: dict) -> list[str]:
|
|
2019
|
+
stmt = select(user.c.id).where(user.c.organization_id == org_id)
|
|
2020
|
+
if "id" in cmp:
|
|
2021
|
+
c = _apply_str_cmp(user.c.id, cmp["id"])
|
|
2022
|
+
if c is not None:
|
|
2023
|
+
stmt = stmt.where(c)
|
|
2024
|
+
if "email" in cmp:
|
|
2025
|
+
c = _apply_str_cmp(user.c.email, cmp["email"])
|
|
2026
|
+
if c is not None:
|
|
2027
|
+
stmt = stmt.where(c)
|
|
2028
|
+
if "displayName" in cmp:
|
|
2029
|
+
c = _apply_str_cmp(user.c.display_name, cmp["displayName"])
|
|
2030
|
+
if c is not None:
|
|
2031
|
+
stmt = stmt.where(c)
|
|
2032
|
+
return [r[0] for r in conn.execute(stmt)]
|
|
2033
|
+
|
|
2034
|
+
|
|
2035
|
+
def _resolve_cycle_ids(conn, org_id: str, cmp: dict) -> list[str] | None:
|
|
2036
|
+
"""Resolve active/next cycle booleans; return None when neither is requested."""
|
|
2037
|
+
requested = {"isActive", "isNext"}.intersection(cmp)
|
|
2038
|
+
if not requested:
|
|
2039
|
+
return None
|
|
2040
|
+
all_ids = set(
|
|
2041
|
+
conn.execute(
|
|
2042
|
+
select(cycle.c.id).where(cycle.c.organization_id == org_id)
|
|
2043
|
+
).scalars()
|
|
2044
|
+
)
|
|
2045
|
+
matches: set[str] | None = None
|
|
2046
|
+
team_rows = conn.execute(
|
|
2047
|
+
select(team.c.id, team.c.active_cycle_id).where(
|
|
2048
|
+
team.c.organization_id == org_id
|
|
2049
|
+
)
|
|
2050
|
+
).all()
|
|
2051
|
+
for field in requested:
|
|
2052
|
+
positive: set[str] = set()
|
|
2053
|
+
if field == "isActive":
|
|
2054
|
+
positive = {row[1] for row in team_rows if row[1]}
|
|
2055
|
+
else:
|
|
2056
|
+
for team_id, active_cycle_id in team_rows:
|
|
2057
|
+
rows = conn.execute(
|
|
2058
|
+
select(cycle.c.id)
|
|
2059
|
+
.where(
|
|
2060
|
+
and_(
|
|
2061
|
+
cycle.c.organization_id == org_id,
|
|
2062
|
+
cycle.c.team_id == team_id,
|
|
2063
|
+
)
|
|
2064
|
+
)
|
|
2065
|
+
.order_by(cycle.c.number)
|
|
2066
|
+
).scalars().all()
|
|
2067
|
+
if not rows:
|
|
2068
|
+
continue
|
|
2069
|
+
if active_cycle_id in rows:
|
|
2070
|
+
index = rows.index(active_cycle_id) + 1
|
|
2071
|
+
if index < len(rows):
|
|
2072
|
+
positive.add(rows[index])
|
|
2073
|
+
elif not active_cycle_id:
|
|
2074
|
+
positive.add(rows[0])
|
|
2075
|
+
comparator = cmp[field] or {}
|
|
2076
|
+
if "eq" in comparator:
|
|
2077
|
+
expected = bool(comparator["eq"])
|
|
2078
|
+
elif "neq" in comparator:
|
|
2079
|
+
expected = not bool(comparator["neq"])
|
|
2080
|
+
else:
|
|
2081
|
+
continue
|
|
2082
|
+
field_matches = positive if expected else all_ids - positive
|
|
2083
|
+
matches = field_matches if matches is None else matches.intersection(field_matches)
|
|
2084
|
+
return sorted(matches or [])
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
def _apply_issue_filter(conn, stmt, org_id, viewer_id, flt: dict | None):
|
|
2088
|
+
if not flt:
|
|
2089
|
+
return stmt
|
|
2090
|
+
if "team" in flt:
|
|
2091
|
+
ids = _resolve_team_ids(conn, org_id, flt["team"])
|
|
2092
|
+
stmt = stmt.where(issue.c.team_id.in_(ids or ["__none__"]))
|
|
2093
|
+
if "state" in flt:
|
|
2094
|
+
ids = _resolve_state_ids(conn, org_id, flt["state"])
|
|
2095
|
+
stmt = stmt.where(issue.c.state_id.in_(ids or ["__none__"]))
|
|
2096
|
+
for who, col in (("assignee", issue.c.assignee_id), ("creator", issue.c.creator_id)):
|
|
2097
|
+
if who in flt:
|
|
2098
|
+
ids = _resolve_user_ids(conn, org_id, flt[who])
|
|
2099
|
+
stmt = stmt.where(col.in_(ids or ["__none__"]))
|
|
2100
|
+
if "project" in flt:
|
|
2101
|
+
cmp = flt["project"]
|
|
2102
|
+
if "id" in cmp:
|
|
2103
|
+
c = _apply_str_cmp(issue.c.project_id, cmp["id"])
|
|
2104
|
+
if c is not None:
|
|
2105
|
+
stmt = stmt.where(c)
|
|
2106
|
+
elif "name" in cmp:
|
|
2107
|
+
sub = select(project.c.id).where(project.c.organization_id == org_id)
|
|
2108
|
+
nc = _apply_str_cmp(project.c.name, cmp["name"])
|
|
2109
|
+
if nc is not None:
|
|
2110
|
+
sub = sub.where(nc)
|
|
2111
|
+
stmt = stmt.where(issue.c.project_id.in_([r[0] for r in conn.execute(sub)] or ["__none__"]))
|
|
2112
|
+
if "cycle" in flt:
|
|
2113
|
+
cycle_filter = flt["cycle"]
|
|
2114
|
+
sub = select(cycle.c.id).where(cycle.c.organization_id == org_id)
|
|
2115
|
+
for field, column in (("id", cycle.c.id), ("name", cycle.c.name)):
|
|
2116
|
+
if field in cycle_filter:
|
|
2117
|
+
clause = _apply_str_cmp(column, cycle_filter[field])
|
|
2118
|
+
if clause is not None:
|
|
2119
|
+
sub = sub.where(clause)
|
|
2120
|
+
cycle_ids = {row[0] for row in conn.execute(sub)}
|
|
2121
|
+
boolean_ids = _resolve_cycle_ids(conn, org_id, cycle_filter)
|
|
2122
|
+
if boolean_ids is not None:
|
|
2123
|
+
cycle_ids.intersection_update(boolean_ids)
|
|
2124
|
+
stmt = stmt.where(issue.c.cycle_id.in_(cycle_ids or ["__none__"]))
|
|
2125
|
+
if "parent" in flt:
|
|
2126
|
+
cmp = flt["parent"] or {}
|
|
2127
|
+
if "id" in cmp:
|
|
2128
|
+
c = _apply_str_cmp(issue.c.parent_id, cmp["id"])
|
|
2129
|
+
if c is not None:
|
|
2130
|
+
stmt = stmt.where(c)
|
|
2131
|
+
if "priority" in flt:
|
|
2132
|
+
c = _apply_num_cmp(issue.c.priority, flt["priority"])
|
|
2133
|
+
if c is not None:
|
|
2134
|
+
stmt = stmt.where(c)
|
|
2135
|
+
if "number" in flt:
|
|
2136
|
+
c = _apply_num_cmp(issue.c.number, flt["number"])
|
|
2137
|
+
if c is not None:
|
|
2138
|
+
stmt = stmt.where(c)
|
|
2139
|
+
if "identifier" in flt:
|
|
2140
|
+
c = _apply_str_cmp(issue.c.identifier, flt["identifier"])
|
|
2141
|
+
if c is not None:
|
|
2142
|
+
stmt = stmt.where(c)
|
|
2143
|
+
if "title" in flt:
|
|
2144
|
+
c = _apply_str_cmp(issue.c.title, flt["title"])
|
|
2145
|
+
if c is not None:
|
|
2146
|
+
stmt = stmt.where(c)
|
|
2147
|
+
if "description" in flt:
|
|
2148
|
+
c = _apply_str_cmp(issue.c.description, flt["description"])
|
|
2149
|
+
if c is not None:
|
|
2150
|
+
stmt = stmt.where(c)
|
|
2151
|
+
for datef, col in (("updatedAt", issue.c.updated_at), ("createdAt", issue.c.created_at),
|
|
2152
|
+
("dueDate", issue.c.due_date)):
|
|
2153
|
+
if datef in flt:
|
|
2154
|
+
c = _apply_date_cmp(col, flt[datef])
|
|
2155
|
+
if c is not None:
|
|
2156
|
+
stmt = stmt.where(c)
|
|
2157
|
+
if "labels" in flt and "name" in flt["labels"]:
|
|
2158
|
+
sub = select(issue_label_link.c.issue_id).select_from(
|
|
2159
|
+
issue_label_link.join(issue_label, issue_label.c.id == issue_label_link.c.label_id)
|
|
2160
|
+
).where(issue_label.c.organization_id == org_id)
|
|
2161
|
+
nc = _apply_str_cmp(issue_label.c.name, flt["labels"]["name"])
|
|
2162
|
+
if nc is not None:
|
|
2163
|
+
sub = sub.where(nc)
|
|
2164
|
+
stmt = stmt.where(issue.c.id.in_([r[0] for r in conn.execute(sub)] or ["__none__"]))
|
|
2165
|
+
for sub_filter in flt.get("and") or []:
|
|
2166
|
+
stmt = _apply_issue_filter(conn, stmt, org_id, viewer_id, sub_filter)
|
|
2167
|
+
if flt.get("or"):
|
|
2168
|
+
alternatives = []
|
|
2169
|
+
for sub_filter in flt["or"]:
|
|
2170
|
+
subquery = select(issue.c.id).where(and_(
|
|
2171
|
+
issue.c.organization_id == org_id,
|
|
2172
|
+
issue.c.archived_at.is_(None),
|
|
2173
|
+
))
|
|
2174
|
+
subquery = _apply_issue_filter(
|
|
2175
|
+
conn, subquery, org_id, viewer_id, sub_filter
|
|
2176
|
+
)
|
|
2177
|
+
alternatives.append(issue.c.id.in_(subquery))
|
|
2178
|
+
stmt = stmt.where(or_(*alternatives))
|
|
2179
|
+
return stmt
|