Corin1998 commited on
Commit
c0d43a8
·
verified ·
1 Parent(s): 55c8ac3

Create app/main.py

Browse files
Files changed (1) hide show
  1. app/main.py +276 -0
app/main.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Depends, HTTPException, Request, status, Form
2
+ from fastapi.responses import HTMLResponse, FileResponse
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from prometheus_client import Counter, generate_latest, CONTENT_TYPE_LATEST
5
+ from sqlalchemy.orm import Session
6
+ from loguru import logger
7
+ from starlette.middleware.base import BaseHTTPMiddleware
8
+ from starlette.responses import Response
9
+ from starlette.templating import Jinja2Templates
10
+
11
+ from .config import Settings
12
+ from .database import Base, engine, get_db
13
+ from .models import *
14
+ from .schemas import *
15
+ from .security import *
16
+ from .tasks import celery_app, generate_pptx, generate_docx
17
+ from .telemetry import setup_tracing
18
+
19
+ import os
20
+
21
+ settings = Settings()
22
+
23
+ # Observability
24
+ setup_tracing()
25
+
26
+ # DB init
27
+ Base.metadata.create_all(bind=engine)
28
+
29
+ # FastAPI app
30
+ app = FastAPI(title="GrowthOps OS", version="0.1.0")
31
+
32
+ # CORS (tighten as needed)
33
+ app.add_middleware(
34
+ CORSMiddleware,
35
+ allow_origins=["*"],
36
+ allow_credentials=True,
37
+ allow_methods=["*"],
38
+ allow_headers=["*"],
39
+ )
40
+
41
+ # Prometheus counter
42
+ API_CALLS = Counter("growthops_api_calls", "Total API calls", ["path", "method", "status"])
43
+
44
+ # Jinja templates for /admin
45
+ templates = Jinja2Templates(directory="templates")
46
+
47
+ class AuditMiddleware(BaseHTTPMiddleware):
48
+ async def dispatch(self, request: Request, call_next):
49
+ response = None
50
+ error_text = None
51
+ try:
52
+ response = await call_next(request)
53
+ return response
54
+ except Exception as e:
55
+ error_text = str(e)
56
+ logger.exception("Unhandled error")
57
+ raise
58
+ finally:
59
+ try:
60
+ tenant_id = None
61
+ user_id = None
62
+ auth = request.headers.get("authorization", "")
63
+ if auth.startswith("Bearer "):
64
+ token = auth.split(" ", 1)[1]
65
+ try:
66
+ payload = parse_token(token)
67
+ db = next(get_db())
68
+ user = db.query(User).filter(User.email == payload.get("sub")).first()
69
+ if user:
70
+ tenant_id = user.tenant_id
71
+ user_id = user.id
72
+ except Exception:
73
+ pass
74
+
75
+ db = next(get_db())
76
+ al = AuditLog(
77
+ tenant_id=tenant_id,
78
+ user_id=user_id,
79
+ path=request.url.path,
80
+ method=request.method,
81
+ status_code=getattr(response, "status_code", 500),
82
+ meta={"client": request.client.host if request.client else None}
83
+ )
84
+ db.add(al)
85
+ if error_text:
86
+ err = ErrorLog(tenant_id=tenant_id, user_id=user_id, path=request.url.path, error=error_text)
87
+ db.add(err)
88
+ db.commit()
89
+
90
+ API_CALLS.labels(path=request.url.path, method=request.method, status=str(getattr(response, "status_code", 500))).inc()
91
+ except Exception as e:
92
+ logger.error(f"audit failed: {e}")
93
+
94
+ app.add_middleware(AuditMiddleware)
95
+
96
+ @app.get("/healthz")
97
+ def healthz():
98
+ return {"ok": True}
99
+
100
+ @app.get("/metrics")
101
+ def metrics():
102
+ return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
103
+
104
+ # ------------------ Auth ------------------
105
+ @app.post("/auth/bootstrap_admin", response_model=SimpleMessage)
106
+ def bootstrap_admin(body: dict, db: Session = Depends(get_db)):
107
+ email = body.get("email")
108
+ password = body.get("password")
109
+ if not email or not password:
110
+ raise HTTPException(400, "email/password required")
111
+ user = db.query(User).filter(User.email == email).first()
112
+ if user:
113
+ return {"message": "exists"}
114
+
115
+ tenant = db.query(Tenant).filter(Tenant.name == "default").first()
116
+ if not tenant:
117
+ tenant = Tenant(name="default")
118
+ db.add(tenant)
119
+ db.commit()
120
+ db.refresh(tenant)
121
+
122
+ u = User(email=email, password_hash=hash_password(password), tenant_id=tenant.id, is_tenant_admin=True)
123
+ db.add(u)
124
+ db.commit()
125
+ return {"message": "admin created"}
126
+
127
+ @app.post("/auth/register", response_model=UserOut)
128
+ def register(user: UserCreate, current: User = Depends(require_tenant_admin), db: Session = Depends(get_db)):
129
+ if db.query(User).filter(User.email == user.email).first():
130
+ raise HTTPException(400, "Email already exists")
131
+ u = User(email=user.email, password_hash=hash_password(user.password), tenant_id=user.tenant_id, is_tenant_admin=user.is_tenant_admin)
132
+ db.add(u)
133
+ db.commit()
134
+ db.refresh(u)
135
+ return u
136
+
137
+ @app.post("/auth/login", response_model=TokenOut)
138
+ def login(req: LoginRequest, db: Session = Depends(get_db)):
139
+ user = db.query(User).filter(User.email == req.email).first()
140
+ if not user or not verify_password(req.password, user.password_hash):
141
+ raise HTTPException(401, "Invalid credentials")
142
+ token = create_access_token(sub=user.email, expires_in=3600)
143
+ return {"access_token": token, "token_type": "bearer"}
144
+
145
+ @app.get("/auth/me", response_model=UserOut)
146
+ def me(current: User = Depends(get_current_user)):
147
+ return current
148
+
149
+ # ------------------ Tenants / Plans ------------------
150
+ @app.post("/tenants", response_model=TenantOut)
151
+ def create_tenant_api(t: TenantCreate, current: User = Depends(require_tenant_admin), db: Session = Depends(get_db)):
152
+ if db.query(Tenant).filter(Tenant.name == t.name).first():
153
+ raise HTTPException(400, "Tenant exists")
154
+ tenant = Tenant(name=t.name)
155
+ db.add(tenant)
156
+ db.commit()
157
+ db.refresh(tenant)
158
+ return tenant
159
+
160
+ @app.get("/tenants", response_model=list[TenantOut])
161
+ def list_tenants(current: User = Depends(require_tenant_admin), db: Session = Depends(get_db)):
162
+ return db.query(Tenant).all()
163
+
164
+ @app.post("/plans", response_model=SimpleMessage)
165
+ def create_plan(p: PlanCreate, current: User = Depends(require_tenant_admin), db: Session = Depends(get_db)):
166
+ plan = Plan(name=p.name, monthly_quota=p.monthly_quota, features=p.features)
167
+ db.add(plan)
168
+ db.commit()
169
+ return {"message": "ok"}
170
+
171
+ @app.post("/subscriptions", response_model=SimpleMessage)
172
+ def create_subscription(s: SubscriptionCreate, current: User = Depends(require_tenant_admin), db: Session = Depends(get_db)):
173
+ sub = Subscription(tenant_id=s.tenant_id, plan_id=s.plan_id, active=True)
174
+ db.add(sub)
175
+ db.commit()
176
+ return {"message": "ok"}
177
+
178
+ # ------------------ Marketplace-like Apps ------------------
179
+ @app.post("/apps", response_model=SimpleMessage)
180
+ def register_app(a: AppCreate, current: User = Depends(require_tenant_admin), db: Session = Depends(get_db)):
181
+ appm = App(name=a.name, description=a.description, callback_url=a.callback_url)
182
+ db.add(appm)
183
+ db.commit()
184
+ return {"message": "ok"}
185
+
186
+ @app.post("/apps/{app_id}/install", response_model=SimpleMessage)
187
+ def install_app(app_id: int, req: InstallAppRequest, current: User = Depends(require_tenant_admin), db: Session = Depends(get_db)):
188
+ inst = InstalledApp(tenant_id=req.tenant_id, app_id=app_id)
189
+ db.add(inst)
190
+ db.commit()
191
+ return {"message": "installed"}
192
+
193
+ @app.post("/apps/{app_id}/sso", response_model=TokenOut)
194
+ def issue_sso_token(app_id: int, req: SSORequest, current: User = Depends(get_current_user), db: Session = Depends(get_db)):
195
+ token = create_access_token(sub=f"user:{req.user_id}:tenant:{req.tenant_id}", expires_in=req.expires_in)
196
+ return {"access_token": token, "token_type": "bearer"}
197
+
198
+ # ------------------ Jobs (Celery) ------------------
199
+ @app.post("/jobs/pptx", response_model=SimpleMessage)
200
+ def job_pptx(body: JobCreateRequest, current: User = Depends(get_current_user)):
201
+ res = generate_pptx.delay(title=body.payload.get("title", "GrowthOps Report"), items=body.payload.get("items", []))
202
+ return {"message": "queued", "data": {"task_id": res.id}}
203
+
204
+ @app.post("/jobs/docx", response_model=SimpleMessage)
205
+ def job_docx(body: JobCreateRequest, current: User = Depends(get_current_user)):
206
+ res = generate_docx.delay(title=body.payload.get("title", "GrowthOps Summary"), body=body.payload.get("body", "Hello"))
207
+ return {"message": "queued", "data": {"task_id": res.id}}
208
+
209
+ @app.get("/jobs/{task_id}", response_model=SimpleMessage)
210
+ def job_status(task_id: str):
211
+ asyncres = celery_app.AsyncResult(task_id)
212
+ data = {"state": asyncres.state}
213
+ if asyncres.successful():
214
+ data["result"] = asyncres.result
215
+ return {"message": "ok", "data": data}
216
+
217
+ # ------------------ Files ------------------
218
+ @app.get("/files/{filename}")
219
+ def download_file(filename: str):
220
+ path = f"/data/exports/{filename}"
221
+ if not os.path.exists(path):
222
+ raise HTTPException(404, "not found")
223
+ return FileResponse(path, filename=filename)
224
+
225
+ # ------------------ Audit / Errors ------------------
226
+ @app.get("/audit", response_model=list[dict])
227
+ def list_audit(current: User = Depends(require_tenant_admin), db: Session = Depends(get_db)):
228
+ rows = db.query(AuditLog).order_by(AuditLog.id.desc()).limit(200).all()
229
+ return [{
230
+ "id": r.id, "path": r.path, "method": r.method, "status_code": r.status_code, "created_at": r.created_at.isoformat()
231
+ } for r in rows]
232
+
233
+ @app.get("/errors", response_model=list[dict])
234
+ def list_errors(current: User = Depends(require_tenant_admin), db: Session = Depends(get_db)):
235
+ rows = db.query(ErrorLog).order_by(ErrorLog.id.desc()).limit(200).all()
236
+ return [{
237
+ "id": r.id, "path": r.path, "error": r.error, "created_at": r.created_at.isoformat()
238
+ } for r in rows]
239
+
240
+ # ------------------ Admin UI (Jinja + HTMX) ------------------
241
+ @app.get("/", response_class=HTMLResponse)
242
+ def root():
243
+ return HTMLResponse("<meta http-equiv='refresh' content='0; url=/admin'/>")
244
+
245
+ @app.get("/admin", response_class=HTMLResponse)
246
+ def admin_home(request: Request, db: Session = Depends(get_db)):
247
+ tenants = db.query(Tenant).all()
248
+ users = db.query(User).order_by(User.id.desc()).limit(20).all()
249
+ audits = db.query(AuditLog).order_by(AuditLog.id.desc()).limit(10).all()
250
+ return templates.TemplateResponse("admin/home.html", {"request": request, "tenants": tenants, "users": users, "audits": audits})
251
+
252
+ # HTMX fragment: tenants table (GET for initial refresh, POST to create)
253
+ @app.get("/admin/tenants", response_class=HTMLResponse)
254
+ def admin_tenants_fragment(request: Request, db: Session = Depends(get_db)):
255
+ tenants = db.query(Tenant).all()
256
+ return templates.TemplateResponse("admin/tenants_fragment.html", {"request": request, "tenants": tenants})
257
+
258
+ @app.post("/admin/tenants", response_class=HTMLResponse)
259
+ def admin_create_tenant_fragment(
260
+ request: Request,
261
+ name: str = Form(...),
262
+ current: User = Depends(require_tenant_admin),
263
+ db: Session = Depends(get_db),
264
+ ):
265
+ if db.query(Tenant).filter(Tenant.name == name).first():
266
+ raise HTTPException(400, "Tenant exists")
267
+ tenant = Tenant(name=name)
268
+ db.add(tenant)
269
+ db.commit()
270
+ tenants = db.query(Tenant).all()
271
+ return templates.TemplateResponse("admin/tenants_fragment.html", {"request": request, "tenants": tenants})
272
+
273
+ # Entrypoint for HF
274
+ if __name__ == "__main__":
275
+ import uvicorn, os
276
+ uvicorn.run("app.main:app", host="0.0.0.0", port=int(os.getenv("PORT", "7860")), reload=False)