feat(backend): watches report alert counts, alerts carry watch params and time away, webhooks their last delivery
This commit is contained in:
1 parent
9da0c06ac1
commit
6a4fd6ad47
4 files changed
+76
-37
No files matched your search
@@ -61,13 +61,16 @@ async def list_webhooks(
|
|||||||
async def create_webhook(
|
async def create_webhook(
|
||||||
pool: FromDishka[asyncpg.Pool], body: WebhookCreate
|
pool: FromDishka[asyncpg.Pool], body: WebhookCreate
|
||||||
) -> WebhookView:
|
) -> WebhookView:
|
||||||
return await webhooks.create_webhook(
|
webhook = await webhooks.create_webhook(
|
||||||
pool,
|
pool,
|
||||||
body.account_id,
|
body.account_id,
|
||||||
_url(body.url),
|
_url(body.url),
|
||||||
_watch_ids(body.watch_ids),
|
_watch_ids(body.watch_ids),
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
)
|
)
|
||||||
|
if webhook is None:
|
||||||
|
raise HTTPException(status_code=500, detail="webhook was not saved")
|
||||||
|
return webhook
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{webhook_id}")
|
@router.put("/{webhook_id}")
|
||||||
|
|||||||
@@ -546,6 +546,8 @@ class PersonRef(BaseModel):
|
|||||||
class WatchItem(WatchView):
|
class WatchItem(WatchView):
|
||||||
chat: ChatRef | None
|
chat: ChatRef | None
|
||||||
person: PersonRef | None
|
person: PersonRef | None
|
||||||
|
alert_count: int
|
||||||
|
last_alert_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
class AlertView(BaseModel):
|
class AlertView(BaseModel):
|
||||||
@@ -562,6 +564,7 @@ class AlertWatch(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
kind: str
|
kind: str
|
||||||
name: str | None
|
name: str | None
|
||||||
|
params: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class AlertMessage(BaseModel):
|
class AlertMessage(BaseModel):
|
||||||
@@ -583,6 +586,7 @@ class AlertItem(BaseModel):
|
|||||||
chat: ChatRef | None
|
chat: ChatRef | None
|
||||||
message: AlertMessage | None
|
message: AlertMessage | None
|
||||||
peer: PersonRef | None
|
peer: PersonRef | None
|
||||||
|
away_since: datetime | None
|
||||||
path: str
|
path: str
|
||||||
|
|
||||||
|
|
||||||
@@ -592,17 +596,6 @@ class AlertPage(BaseModel):
|
|||||||
unseen: int
|
unseen: int
|
||||||
|
|
||||||
|
|
||||||
class WebhookView(BaseModel):
|
|
||||||
id: int
|
|
||||||
account_id: int
|
|
||||||
url: str
|
|
||||||
secret: str
|
|
||||||
enabled: bool
|
|
||||||
watch_ids: list[int] | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class DeliveryView(BaseModel):
|
class DeliveryView(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
webhook_id: int
|
webhook_id: int
|
||||||
@@ -617,6 +610,18 @@ class DeliveryView(BaseModel):
|
|||||||
finished_at: datetime | None
|
finished_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookView(BaseModel):
|
||||||
|
id: int
|
||||||
|
account_id: int
|
||||||
|
url: str
|
||||||
|
secret: str
|
||||||
|
enabled: bool
|
||||||
|
watch_ids: list[int] | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
last_delivery: DeliveryView | None
|
||||||
|
|
||||||
|
|
||||||
class FileShareView(BaseModel):
|
class FileShareView(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
account_id: int
|
account_id: int
|
||||||
|
|||||||
@@ -44,9 +44,15 @@ WITH alert AS (
|
|||||||
SELECT * FROM alert
|
SELECT * FROM alert
|
||||||
""" # noqa: S608
|
""" # noqa: S608
|
||||||
|
|
||||||
|
_WATCH_STATS = """
|
||||||
|
SELECT watch_id, count(*) AS count, max(ts) AS last FROM alerts
|
||||||
|
WHERE watch_id = ANY($1::int[]) GROUP BY watch_id
|
||||||
|
"""
|
||||||
|
|
||||||
_ALERT_ROWS = """
|
_ALERT_ROWS = """
|
||||||
SELECT a.id, a.account_id, a.ts, a.seen, a.payload,
|
SELECT a.id, a.account_id, a.ts, a.seen, a.payload,
|
||||||
w.id AS watch_id, w.kind AS watch_kind, w.name AS watch_name,
|
w.id AS watch_id, w.kind AS watch_kind, w.name AS watch_name,
|
||||||
|
w.params AS watch_params,
|
||||||
m.text AS stored_text,
|
m.text AS stored_text,
|
||||||
m.sender_id AS stored_sender, m.date AS stored_date,
|
m.sender_id AS stored_sender, m.date AS stored_date,
|
||||||
m.deleted_at IS NOT NULL AS stored_deleted,
|
m.deleted_at IS NOT NULL AS stored_deleted,
|
||||||
@@ -225,9 +231,17 @@ async def describe_watches(
|
|||||||
{chat for chat, _ in targets if chat is not None},
|
{chat for chat, _ in targets if chat is not None},
|
||||||
{person for _, person in targets if person is not None},
|
{person for _, person in targets if person is not None},
|
||||||
)
|
)
|
||||||
|
stats = {
|
||||||
|
row["watch_id"]: row
|
||||||
|
for row in await pool.fetch(_WATCH_STATS, [watch.id for watch in watches])
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
WatchItem(
|
WatchItem(
|
||||||
**watch.model_dump(), chat=refs.chat(chat), person=refs.person(person)
|
**watch.model_dump(),
|
||||||
|
chat=refs.chat(chat),
|
||||||
|
person=refs.person(person),
|
||||||
|
alert_count=stats[watch.id]["count"] if watch.id in stats else 0,
|
||||||
|
last_alert_at=stats[watch.id]["last"] if watch.id in stats else None,
|
||||||
)
|
)
|
||||||
for watch, (chat, person) in zip(watches, targets, strict=True)
|
for watch, (chat, person) in zip(watches, targets, strict=True)
|
||||||
]
|
]
|
||||||
@@ -267,11 +281,15 @@ def _alert_item(row: asyncpg.Record, refs: Refs) -> AlertItem:
|
|||||||
seen=row["seen"],
|
seen=row["seen"],
|
||||||
event=event,
|
event=event,
|
||||||
watch=AlertWatch(
|
watch=AlertWatch(
|
||||||
id=row["watch_id"], kind=row["watch_kind"], name=row["watch_name"]
|
id=row["watch_id"],
|
||||||
|
kind=row["watch_kind"],
|
||||||
|
name=row["watch_name"],
|
||||||
|
params=json.loads(row["watch_params"]),
|
||||||
),
|
),
|
||||||
chat=refs.chat(chat_id),
|
chat=refs.chat(chat_id),
|
||||||
message=message,
|
message=message,
|
||||||
peer=refs.person(peer_id),
|
peer=refs.person(peer_id),
|
||||||
|
away_since=payload.get("since"),
|
||||||
path=_path(chat_id, message_id, peer_id),
|
path=_path(chat_id, message_id, peer_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -11,12 +12,21 @@ STATUS_DELIVERED = "delivered"
|
|||||||
STATUS_FAILED = "failed"
|
STATUS_FAILED = "failed"
|
||||||
DELIVERY_LOG = 20
|
DELIVERY_LOG = 20
|
||||||
|
|
||||||
_COLS = "id, account_id, url, secret, enabled, watch_ids, created_at, updated_at"
|
|
||||||
_DELIVERY_COLS = (
|
_DELIVERY_COLS = (
|
||||||
"id, webhook_id, alert_id, event, status, attempts, next_attempt_at, "
|
"id, webhook_id, alert_id, event, status, attempts, next_attempt_at, "
|
||||||
"response_code, last_error, created_at, finished_at"
|
"response_code, last_error, created_at, finished_at"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_HOOKS = f"""
|
||||||
|
SELECT w.id, w.account_id, w.url, w.secret, w.enabled, w.watch_ids,
|
||||||
|
w.created_at, w.updated_at, to_jsonb(d) AS last_delivery
|
||||||
|
FROM webhooks w
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT {_DELIVERY_COLS} FROM webhook_deliveries
|
||||||
|
WHERE webhook_id = w.id ORDER BY id DESC LIMIT 1
|
||||||
|
) d ON true
|
||||||
|
""" # noqa: S608
|
||||||
|
|
||||||
_CLAIM = """
|
_CLAIM = """
|
||||||
UPDATE webhook_deliveries d SET status = 'sending', attempts = d.attempts + 1
|
UPDATE webhook_deliveries d SET status = 'sending', attempts = d.attempts + 1
|
||||||
FROM webhooks w
|
FROM webhooks w
|
||||||
@@ -43,20 +53,24 @@ def new_secret() -> str:
|
|||||||
return secrets.token_hex(32)
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_webhook(row: asyncpg.Record) -> WebhookView:
|
||||||
|
data = dict(row)
|
||||||
|
last = data.pop("last_delivery")
|
||||||
|
return WebhookView(
|
||||||
|
**data, last_delivery=DeliveryView(**json.loads(last)) if last else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def list_webhooks(pool: asyncpg.Pool, account_id: int) -> list[WebhookView]:
|
async def list_webhooks(pool: asyncpg.Pool, account_id: int) -> list[WebhookView]:
|
||||||
rows = await pool.fetch(
|
rows = await pool.fetch(
|
||||||
f"SELECT {_COLS} FROM webhooks WHERE account_id = $1 ORDER BY id", # noqa: S608
|
f"{_HOOKS} WHERE w.account_id = $1 ORDER BY w.id", account_id
|
||||||
account_id,
|
|
||||||
)
|
)
|
||||||
return [WebhookView(**dict(row)) for row in rows]
|
return [_to_webhook(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def get_webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
|
async def get_webhook(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
|
||||||
row = await pool.fetchrow(
|
row = await pool.fetchrow(f"{_HOOKS} WHERE w.id = $1", webhook_id)
|
||||||
f"SELECT {_COLS} FROM webhooks WHERE id = $1", # noqa: S608
|
return _to_webhook(row) if row else None
|
||||||
webhook_id,
|
|
||||||
)
|
|
||||||
return WebhookView(**dict(row)) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def create_webhook(
|
async def create_webhook(
|
||||||
@@ -66,17 +80,17 @@ async def create_webhook(
|
|||||||
watch_ids: list[int] | None,
|
watch_ids: list[int] | None,
|
||||||
*,
|
*,
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> WebhookView:
|
) -> WebhookView | None:
|
||||||
row = await pool.fetchrow(
|
webhook_id = await pool.fetchval(
|
||||||
"INSERT INTO webhooks (account_id, url, secret, enabled, watch_ids) " # noqa: S608
|
"INSERT INTO webhooks (account_id, url, secret, enabled, watch_ids) "
|
||||||
f"VALUES ($1, $2, $3, $4, $5) RETURNING {_COLS}",
|
"VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||||
account_id,
|
account_id,
|
||||||
url,
|
url,
|
||||||
new_secret(),
|
new_secret(),
|
||||||
enabled,
|
enabled,
|
||||||
watch_ids,
|
watch_ids,
|
||||||
)
|
)
|
||||||
return WebhookView(**dict(row))
|
return await get_webhook(pool, webhook_id)
|
||||||
|
|
||||||
|
|
||||||
async def update_webhook(
|
async def update_webhook(
|
||||||
@@ -88,32 +102,31 @@ async def update_webhook(
|
|||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> WebhookView | None:
|
) -> WebhookView | None:
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
row = await conn.fetchrow(
|
found = await conn.fetchval(
|
||||||
"UPDATE webhooks SET url = $2, enabled = $3, watch_ids = $4, " # noqa: S608
|
"UPDATE webhooks SET url = $2, enabled = $3, watch_ids = $4, "
|
||||||
f"updated_at = now() WHERE id = $1 RETURNING {_COLS}",
|
"updated_at = now() WHERE id = $1 RETURNING id",
|
||||||
webhook_id,
|
webhook_id,
|
||||||
url,
|
url,
|
||||||
enabled,
|
enabled,
|
||||||
watch_ids,
|
watch_ids,
|
||||||
)
|
)
|
||||||
if row is not None and not enabled:
|
if found is not None and not enabled:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"UPDATE webhook_deliveries SET status = 'failed', "
|
"UPDATE webhook_deliveries SET status = 'failed', "
|
||||||
"last_error = 'endpoint disabled', finished_at = now() "
|
"last_error = 'endpoint disabled', finished_at = now() "
|
||||||
"WHERE webhook_id = $1 AND status = 'pending'",
|
"WHERE webhook_id = $1 AND status = 'pending'",
|
||||||
webhook_id,
|
webhook_id,
|
||||||
)
|
)
|
||||||
return WebhookView(**dict(row)) if row else None
|
return await get_webhook(pool, webhook_id)
|
||||||
|
|
||||||
|
|
||||||
async def rotate_secret(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
|
async def rotate_secret(pool: asyncpg.Pool, webhook_id: int) -> WebhookView | None:
|
||||||
row = await pool.fetchrow(
|
await pool.execute(
|
||||||
"UPDATE webhooks SET secret = $2, updated_at = now() " # noqa: S608
|
"UPDATE webhooks SET secret = $2, updated_at = now() WHERE id = $1",
|
||||||
f"WHERE id = $1 RETURNING {_COLS}",
|
|
||||||
webhook_id,
|
webhook_id,
|
||||||
new_secret(),
|
new_secret(),
|
||||||
)
|
)
|
||||||
return WebhookView(**dict(row)) if row else None
|
return await get_webhook(pool, webhook_id)
|
||||||
|
|
||||||
|
|
||||||
async def delete_webhook(pool: asyncpg.Pool, webhook_id: int) -> bool:
|
async def delete_webhook(pool: asyncpg.Pool, webhook_id: int) -> bool:
|
||||||
|
|||||||
Reference in new issue
Block a user