feat: Webhook system + CLI tool #2
114
app/main.py
114
app/main.py
@@ -268,6 +268,7 @@ def startup():
|
||||
from app.models import apikey
|
||||
from app.models import activity
|
||||
from app.models import milestone
|
||||
from app.models import notification
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
@@ -870,6 +871,7 @@ def milestone_progress(milestone_id: int, db: Session = Depends(get_db)):
|
||||
# ============ Export API ============
|
||||
|
||||
import csv
|
||||
import json
|
||||
import io
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
@@ -900,3 +902,115 @@ def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)):
|
||||
headers={"Content-Disposition": "attachment; filename=issues.csv"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ============ Notifications API ============
|
||||
|
||||
from app.models.notification import Notification as NotificationModel
|
||||
|
||||
|
||||
class NotificationResponse(PydanticBaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
type: str
|
||||
title: str
|
||||
message: str | None = None
|
||||
entity_type: str | None = None
|
||||
entity_id: int | None = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
def notify_user(db: Session, user_id: int, ntype: str, title: str, message: str = None, entity_type: str = None, entity_id: int = None):
|
||||
"""Helper to create a notification."""
|
||||
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message, entity_type=entity_type, entity_id=entity_id)
|
||||
db.add(n)
|
||||
db.commit()
|
||||
return n
|
||||
|
||||
|
||||
@app.get("/notifications", response_model=List[NotificationResponse])
|
||||
def list_notifications(user_id: int, unread_only: bool = False, limit: int = 50, db: Session = Depends(get_db)):
|
||||
"""List notifications for a user."""
|
||||
query = db.query(NotificationModel).filter(NotificationModel.user_id == user_id)
|
||||
if unread_only:
|
||||
query = query.filter(NotificationModel.is_read == False)
|
||||
return query.order_by(NotificationModel.created_at.desc()).limit(limit).all()
|
||||
|
||||
|
||||
@app.get("/notifications/count")
|
||||
def notification_count(user_id: int, db: Session = Depends(get_db)):
|
||||
"""Get unread notification count."""
|
||||
count = db.query(NotificationModel).filter(
|
||||
NotificationModel.user_id == user_id,
|
||||
NotificationModel.is_read == False
|
||||
).count()
|
||||
return {"user_id": user_id, "unread": count}
|
||||
|
||||
|
||||
@app.post("/notifications/{notification_id}/read")
|
||||
def mark_read(notification_id: int, db: Session = Depends(get_db)):
|
||||
n = db.query(NotificationModel).filter(NotificationModel.id == notification_id).first()
|
||||
if not n:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
n.is_read = True
|
||||
db.commit()
|
||||
return {"status": "read"}
|
||||
|
||||
|
||||
@app.post("/notifications/read-all")
|
||||
def mark_all_read(user_id: int, db: Session = Depends(get_db)):
|
||||
"""Mark all notifications as read for a user."""
|
||||
db.query(NotificationModel).filter(
|
||||
NotificationModel.user_id == user_id,
|
||||
NotificationModel.is_read == False
|
||||
).update({"is_read": True})
|
||||
db.commit()
|
||||
return {"status": "all_read"}
|
||||
|
||||
|
||||
# ============ Webhook Retry ============
|
||||
|
||||
@app.post("/webhooks/{webhook_id}/retry/{log_id}")
|
||||
def retry_webhook(webhook_id: int, log_id: int, bg: BackgroundTasks, db: Session = Depends(get_db)):
|
||||
"""Retry a failed webhook delivery."""
|
||||
log_entry = db.query(WebhookLog).filter(
|
||||
WebhookLog.id == log_id,
|
||||
WebhookLog.webhook_id == webhook_id
|
||||
).first()
|
||||
if not log_entry:
|
||||
raise HTTPException(status_code=404, detail="Webhook log not found")
|
||||
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
if not wh:
|
||||
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||
|
||||
bg.add_task(fire_webhooks_sync, log_entry.event, json.loads(log_entry.payload), wh.project_id, db)
|
||||
return {"status": "retry_queued", "log_id": log_id}
|
||||
|
||||
|
||||
# ============ Issue Assignment with Notification ============
|
||||
|
||||
@app.post("/issues/{issue_id}/assign")
|
||||
def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db)):
|
||||
"""Assign issue to user and send notification."""
|
||||
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
|
||||
if not issue:
|
||||
raise HTTPException(status_code=404, detail="Issue not found")
|
||||
user = db.query(models.User).filter(models.User.id == assignee_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
old_assignee = issue.assignee_id
|
||||
issue.assignee_id = assignee_id
|
||||
db.commit()
|
||||
db.refresh(issue)
|
||||
|
||||
# Notify assignee
|
||||
notify_user(db, assignee_id, "issue.assigned",
|
||||
f"Issue #{issue.id} assigned to you",
|
||||
f"'{issue.title}' has been assigned to you.",
|
||||
"issue", issue.id)
|
||||
|
||||
return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title}
|
||||
|
||||
20
app/models/notification.py
Normal file
20
app/models/notification.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.core.config import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
type = Column(String(50), nullable=False) # issue.assigned, issue.mentioned, comment.added, milestone.due
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text, nullable=True)
|
||||
entity_type = Column(String(50), nullable=True) # issue, comment, milestone
|
||||
entity_id = Column(Integer, nullable=True)
|
||||
is_read = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User")
|
||||
70
cli.py
70
cli.py
@@ -145,6 +145,54 @@ def cmd_stats(args):
|
||||
print(f" {t}: {c}")
|
||||
|
||||
|
||||
|
||||
|
||||
def cmd_milestones(args):
|
||||
params = f"?project_id={args.project}" if args.project else ""
|
||||
milestones = _request("GET", f"/milestones{params}")
|
||||
if not milestones:
|
||||
print(" No milestones found.")
|
||||
return
|
||||
for m in milestones:
|
||||
status_icon = "🟢" if m["status"] == "open" else "⚫"
|
||||
due = f" (due: {m['due_date'][:10]})" if m.get("due_date") else ""
|
||||
print(f" {status_icon} #{m['id']} {m['title']}{due}")
|
||||
|
||||
|
||||
def cmd_milestone_progress(args):
|
||||
result = _request("GET", f"/milestones/{args.milestone_id}/progress")
|
||||
bar_len = 20
|
||||
filled = int(bar_len * result["progress_pct"] / 100)
|
||||
bar = "█" * filled + "░" * (bar_len - filled)
|
||||
print(f" {result['title']}")
|
||||
print(f" [{bar}] {result['progress_pct']}% ({result['completed']}/{result['total_issues']})")
|
||||
|
||||
|
||||
def cmd_notifications(args):
|
||||
params = [f"user_id={args.user}"]
|
||||
if args.unread:
|
||||
params.append("unread_only=true")
|
||||
qs = "&".join(params)
|
||||
notifs = _request("GET", f"/notifications?{qs}")
|
||||
if not notifs:
|
||||
print(" No notifications.")
|
||||
return
|
||||
for n in notifs:
|
||||
icon = "🔴" if not n["is_read"] else "⚪"
|
||||
print(f" {icon} [{n['type']}] {n['title']}")
|
||||
|
||||
|
||||
def cmd_overdue(args):
|
||||
params = f"?project_id={args.project}" if args.project else ""
|
||||
issues = _request("GET", f"/issues/overdue{params}")
|
||||
if not issues:
|
||||
print(" No overdue issues! 🎉")
|
||||
return
|
||||
for i in issues:
|
||||
due = i.get("due_date", "?")[:10] if i.get("due_date") else "?"
|
||||
print(f" ⏰ #{i['id']} [{i['priority']}] {i['title']} (due: {due})")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="HarborForge CLI")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
@@ -201,6 +249,24 @@ def main():
|
||||
p_stats = sub.add_parser("stats", help="Dashboard stats")
|
||||
p_stats.add_argument("--project", "-p", type=int)
|
||||
|
||||
|
||||
# milestones
|
||||
p_ms = sub.add_parser("milestones", help="List milestones")
|
||||
p_ms.add_argument("--project", "-p", type=int)
|
||||
|
||||
# milestone progress
|
||||
p_msp = sub.add_parser("milestone-progress", help="Show milestone progress")
|
||||
p_msp.add_argument("milestone_id", type=int)
|
||||
|
||||
# notifications
|
||||
p_notif = sub.add_parser("notifications", help="List notifications")
|
||||
p_notif.add_argument("--user", "-u", type=int, required=True)
|
||||
p_notif.add_argument("--unread", action="store_true")
|
||||
|
||||
# overdue
|
||||
p_overdue = sub.add_parser("overdue", help="List overdue issues")
|
||||
p_overdue.add_argument("--project", "-p", type=int)
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
@@ -217,6 +283,10 @@ def main():
|
||||
"search": cmd_search,
|
||||
"transition": cmd_transition,
|
||||
"stats": cmd_stats,
|
||||
"milestones": cmd_milestones,
|
||||
"milestone-progress": cmd_milestone_progress,
|
||||
"notifications": cmd_notifications,
|
||||
"overdue": cmd_overdue,
|
||||
}
|
||||
cmds[args.command](args)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user