interns_manager / app.py
banao-tech's picture
Update app.py
26bd1a6 verified
"""
app.py β€” HuggingFace Spaces entry point
Pure FastAPI + uvicorn. No Gradio.
Slack webhook at /slack/events.
Admin endpoints at /admin/*.
"""
import os
import hmac
import hashlib
import time
import threading
import json
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import router
import scheduler as sched
import sheets
# ─────────────────────────────────────────────
# LIFESPAN
# ─────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
import asyncio
async def delayed_setup():
await asyncio.sleep(5)
try:
sheets.setup_sheets()
except Exception as e:
print(f"setup_sheets warning: {e}")
asyncio.create_task(delayed_setup())
sched.start_scheduler()
yield
# ─────────────────────────────────────────────
# APP
# ─────────────────────────────────────────────
app = FastAPI(
title="Intern Management Agent",
description="Slack agent managing intern pipeline β€” Week 0 through Probation.",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # restrict to your dashboard URL in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
CHANNEL_ID = os.environ.get("SLACK_CHANNEL_ID", "")
# ─────────────────────────────────────────────
# SLACK SIGNATURE VERIFICATION
# ─────────────────────────────────────────────
def verify_slack_signature(body: bytes, timestamp: str, signature: str) -> bool:
if not timestamp or not signature:
return False
if abs(time.time() - int(timestamp)) > 300:
return False
sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
computed = "v0=" + hmac.new(
SLACK_SIGNING_SECRET.encode(),
sig_basestring.encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(computed, signature)
# ─────────────────────────────────────────────
# SLACK EVENTS
# ─────────────────────────────────────────────
@app.post("/slack/events", summary="Slack event webhook")
async def slack_events(request: Request):
try:
body = await request.body()
if not body:
return JSONResponse({"ok": True})
payload = json.loads(body)
# URL verification challenge β€” must respond before signature check
if payload.get("type") == "url_verification":
return JSONResponse({"challenge": payload["challenge"]})
# Verify all other requests
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
signature = request.headers.get("X-Slack-Signature", "")
if not verify_slack_signature(body, timestamp, signature):
return JSONResponse({"error": "Invalid signature"}, status_code=403)
if payload.get("type") == "event_callback":
event = payload.get("event", {})
if event.get("type") == "message":
thread = threading.Thread(target=router.route_event, args=(event,))
thread.daemon = True
thread.start()
return JSONResponse({"ok": True})
except json.JSONDecodeError:
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
# ─────────────────────────────────────────────
# ADMIN β€” ADD CANDIDATE
# ─────────────────────────────────────────────
class CandidateIn(BaseModel):
slack_user_id: str
name: str
cohort_id: str
ft_slack_id: str
hr_slack_id: str
week0_start_date: str
stage: str = "week0" # week0 | intern | probation_w1 | probation_w2
department: str = "general" # ai | hr | sales | product | general
channel_id: str = "" # Slack channel ID where intern posts reports
@app.post("/admin/add_candidate", summary="Register a new intern")
async def add_candidate(data: CandidateIn):
try:
candidate = sheets.create_candidate(
slack_user_id=data.slack_user_id,
name=data.name,
cohort_id=data.cohort_id,
ft_slack_id=data.ft_slack_id,
hr_slack_id=data.hr_slack_id,
week0_start_date=data.week0_start_date,
stage=data.stage,
department=data.department,
channel_id=data.channel_id,
)
return JSONResponse({"ok": True, "candidate_id": candidate["candidate_id"]})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
# ─────────────────────────────────────────────
# ADMIN β€” LOG COMMITMENT
# ─────────────────────────────────────────────
class CommitmentIn(BaseModel):
candidate_id: str
description: str
due_date: str
due_time: str
@app.post("/admin/log_commitment", summary="Log a Week 2 delivery commitment")
async def log_commitment(data: CommitmentIn):
try:
commitment_id = sheets.log_commitment(
candidate_id=data.candidate_id,
description=data.description,
due_date=data.due_date,
due_time=data.due_time,
)
return JSONResponse({"ok": True, "commitment_id": commitment_id})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
# ─────────────────────────────────────────────
# HEALTH
# ─────────────────────────────────────────────
# ─────────────────────────────────────────────
# INTERN MANAGEMENT β€” GET / UPDATE / DELETE
# ─────────────────────────────────────────────
class InternUpdate(BaseModel):
name: str | None = None
stage: str | None = None
department: str | None = None
channel_id: str | None = None
ft_slack_id: str | None = None
hr_slack_id: str | None = None
week0_start_date: str | None = None
status: str | None = None
cohort_id: str | None = None
@app.get("/interns", summary="List all interns β€” filter by department")
async def get_interns(department: str = None):
"""
Returns all active interns.
Optional query param: ?department=ai
Example: /interns?department=ai
"""
try:
candidates = sheets.get_active_candidates()
if department:
candidates = [
c for c in candidates
if c.get("department", "").lower() == department.lower()
]
return JSONResponse({"ok": True, "count": len(candidates), "interns": candidates})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/interns/{candidate_id}", summary="Get a single intern by candidate ID")
async def get_intern(candidate_id: str):
try:
candidate = sheets.get_candidate_by_id(candidate_id)
if not candidate:
return JSONResponse({"error": "Intern not found"}, status_code=404)
return JSONResponse({"ok": True, "intern": candidate})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.patch("/interns/{candidate_id}", summary="Update intern fields")
async def update_intern(candidate_id: str, data: InternUpdate):
"""
Update one or more fields of an intern.
Only pass the fields you want to change.
"""
try:
candidate = sheets.get_candidate_by_id(candidate_id)
if not candidate:
return JSONResponse({"error": "Intern not found"}, status_code=404)
updates = data.dict(exclude_none=True)
if not updates:
return JSONResponse({"error": "No fields to update"}, status_code=400)
for field, value in updates.items():
sheets.update_candidate_field(candidate_id, field, value)
return JSONResponse({"ok": True, "updated": updates})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.delete("/interns/{candidate_id}", summary="Remove an intern from the pipeline")
async def delete_intern(candidate_id: str):
"""
Marks the intern as eliminated and inactive.
Does not delete the row β€” preserves audit trail.
"""
try:
candidate = sheets.get_candidate_by_id(candidate_id)
if not candidate:
return JSONResponse({"error": "Intern not found"}, status_code=404)
sheets.update_candidate_field(candidate_id, "status", "eliminated")
return JSONResponse({
"ok": True,
"message": f"{candidate.get('name')} removed from pipeline. Row preserved for audit."
})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/admin/trigger_agenda", summary="Manually trigger morning agenda prompt")
async def trigger_agenda():
"""Trigger the agenda prompt manually β€” bypasses working day check."""
try:
def run():
try:
sched.job_morning_agenda_prompt(force=True)
except Exception as e:
print(f"[trigger_agenda] error: {e}")
thread = threading.Thread(target=run)
thread.daemon = True
thread.start()
return JSONResponse({"ok": True, "message": "Agenda prompt triggered"})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/admin/trigger_summary", summary="Manually trigger EOD summary and miss check")
async def trigger_summary():
"""Trigger the 11:30 PM miss check and summary manually β€” for testing."""
try:
import threading
import handlers
thread = threading.Thread(target=handlers.handle_missed_report_check)
thread.daemon = True
thread.start()
return JSONResponse({"ok": True, "message": "Miss check and summary triggered"})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/health", summary="Health check")
async def health():
return JSONResponse({"status": "ok"})