Create app.py
Browse files
app.py
ADDED
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from pathlib import Path
|
3 |
+
from datetime import datetime, timedelta
|
4 |
+
import logging
|
5 |
+
import requests
|
6 |
+
|
7 |
+
import gradio as gr
|
8 |
+
|
9 |
+
# -------------------------------------------------------------
|
10 |
+
# Configure logging
|
11 |
+
# -------------------------------------------------------------
|
12 |
+
logging.basicConfig(level=logging.INFO)
|
13 |
+
logger = logging.getLogger(__name__)
|
14 |
+
|
15 |
+
# -------------------------------------------------------------
|
16 |
+
# Minimal Mindbody API wrapper
|
17 |
+
# -------------------------------------------------------------
|
18 |
+
class MindbodyClient:
|
19 |
+
"""Thin wrapper around the MINDBODY Public API (v6).
|
20 |
+
|
21 |
+
Only the endpoints needed for this demo are implemented. Extend freely!
|
22 |
+
"""
|
23 |
+
|
24 |
+
def __init__(self, api_key: str, site_id: str, source_name: str | None = None):
|
25 |
+
self.base_url = "https://api.mindbodyonline.com/public/v6"
|
26 |
+
self.headers = {
|
27 |
+
"API-Key": api_key,
|
28 |
+
"SiteId": site_id,
|
29 |
+
"Content-Type": "application/json",
|
30 |
+
}
|
31 |
+
if source_name:
|
32 |
+
self.headers["SourceName"] = source_name
|
33 |
+
|
34 |
+
# --------------------------- internal helpers ---------------------------
|
35 |
+
def _get(self, path: str, params: dict | None = None):
|
36 |
+
url = f"{self.base_url}{path}"
|
37 |
+
logger.debug("GET %s", url)
|
38 |
+
resp = requests.get(url, headers=self.headers, params=params, timeout=30)
|
39 |
+
resp.raise_for_status()
|
40 |
+
return resp.json()
|
41 |
+
|
42 |
+
def _post(self, path: str, payload: dict):
|
43 |
+
url = f"{self.base_url}{path}"
|
44 |
+
logger.debug("POST %s", url)
|
45 |
+
resp = requests.post(url, headers=self.headers, json=payload, timeout=30)
|
46 |
+
resp.raise_for_status()
|
47 |
+
return resp.json()
|
48 |
+
|
49 |
+
# --------------------------- public endpoints --------------------------
|
50 |
+
def get_class_descriptions(self, location_id: str | int):
|
51 |
+
"""Return **types of training** offered at a location."""
|
52 |
+
params = {"LocationIds": location_id}
|
53 |
+
return self._get("/class/classdescriptions", params)
|
54 |
+
|
55 |
+
def get_classes(self, location_id: str | int, start_iso: str, end_iso: str):
|
56 |
+
"""Return **scheduled classes** for a date range."""
|
57 |
+
params = {
|
58 |
+
"LocationIds": location_id,
|
59 |
+
"StartDateTime": start_iso,
|
60 |
+
"EndDateTime": end_iso,
|
61 |
+
}
|
62 |
+
return self._get("/class/classes", params)
|
63 |
+
|
64 |
+
def book_class(self, class_id: int, client_id: str, cross_regional: bool = False):
|
65 |
+
"""Book a client into a class *after* verifying availability."""
|
66 |
+
# 1) confirm availability
|
67 |
+
info = self._get("/class/classes", {"ClassIds": class_id})
|
68 |
+
if not info.get("Classes"):
|
69 |
+
raise ValueError("Class ID not found")
|
70 |
+
|
71 |
+
cls = info["Classes"][0]
|
72 |
+
if not cls.get("IsAvailable", False):
|
73 |
+
raise ValueError("Class is not available for online booking")
|
74 |
+
if cls.get("TotalBooked", 0) >= cls.get("MaxCapacity", 0):
|
75 |
+
raise ValueError("Class is already full")
|
76 |
+
|
77 |
+
# 2) book
|
78 |
+
payload = {
|
79 |
+
"ClientId": client_id,
|
80 |
+
"ClassId": class_id,
|
81 |
+
"CrossRegionalBooking": cross_regional,
|
82 |
+
}
|
83 |
+
return self._post("/class/addclienttoclass", payload)
|
84 |
+
|
85 |
+
|
86 |
+
# -------------------------------------------------------------
|
87 |
+
# Global state (kept super-simple for a single-user MCP demo)
|
88 |
+
# -------------------------------------------------------------
|
89 |
+
mb_client: MindbodyClient | None = None
|
90 |
+
|
91 |
+
|
92 |
+
# -------------------------------------------------------------
|
93 |
+
# Helper functions wired to Gradio components
|
94 |
+
# -------------------------------------------------------------
|
95 |
+
|
96 |
+
def initialize_mindbody_client():
|
97 |
+
"""Instantiate the global MindbodyClient reading credentials from ./config/.env"""
|
98 |
+
global mb_client
|
99 |
+
api_key = os.getenv("MINDBODY_API_KEY")
|
100 |
+
site_id = os.getenv("MINDBODY_SITE_ID")
|
101 |
+
source_name = os.getenv("MINDBODY_SOURCE_NAME") # optional
|
102 |
+
|
103 |
+
if not api_key or not site_id:
|
104 |
+
return "❌ MINDBODY_API_KEY or MINDBODY_SITE_ID missing in .env"
|
105 |
+
|
106 |
+
try:
|
107 |
+
mb_client = MindbodyClient(api_key, site_id, source_name)
|
108 |
+
return "✅ Mindbody client initialised. Ready to query!"
|
109 |
+
except Exception as exc:
|
110 |
+
logger.exception("Failed initialising Mindbody client")
|
111 |
+
return f"❌ Initialisation error: {exc}"
|
112 |
+
|
113 |
+
|
114 |
+
def get_training_types(location_id: str | int):
|
115 |
+
"""Fetch human-readable list of class description names."""
|
116 |
+
if not mb_client:
|
117 |
+
return "❌ Authenticate first."
|
118 |
+
|
119 |
+
try:
|
120 |
+
res = mb_client.get_class_descriptions(location_id)
|
121 |
+
descs = res.get("ClassDescriptions", [])
|
122 |
+
if not descs:
|
123 |
+
return "⚠️ No training types found for this location."
|
124 |
+
|
125 |
+
lines = [f"- {d.get('Name')} (ID: {d.get('Id')})" for d in descs]
|
126 |
+
return "🏷️ Training Types\n" + "\n".join(lines)
|
127 |
+
except Exception as exc:
|
128 |
+
logger.exception("Error while fetching class descriptions")
|
129 |
+
return f"❌ Error: {exc}"
|
130 |
+
|
131 |
+
|
132 |
+
def get_schedule(location_id: str | int, days: int):
|
133 |
+
"""Return upcoming classes in a human-friendly Markdown list."""
|
134 |
+
if not mb_client:
|
135 |
+
return "❌ Authenticate first."
|
136 |
+
|
137 |
+
try:
|
138 |
+
start_iso = datetime.now().strftime("%Y-%m-%dT00:00:00")
|
139 |
+
end_iso = (datetime.now() + timedelta(days=int(days))).strftime("%Y-%m-%dT23:59:59")
|
140 |
+
res = mb_client.get_classes(location_id, start_iso, end_iso)
|
141 |
+
classes = res.get("Classes", [])
|
142 |
+
if not classes:
|
143 |
+
return "⚠️ No classes in the selected window."
|
144 |
+
|
145 |
+
formatted = []
|
146 |
+
for c in classes:
|
147 |
+
dt = c.get("StartDateTime", "?")
|
148 |
+
name = c.get("ClassDescription", {}).get("Name", "🆔" + str(c.get("ClassDescriptionId")))
|
149 |
+
ok = "✅" if c.get("IsAvailable") else "❌"
|
150 |
+
cid = c.get("Id")
|
151 |
+
formatted.append(f"{dt} | {name} | {ok} | ClassId: {cid}")
|
152 |
+
|
153 |
+
return "📅 Schedule\n" + "\n".join(formatted)
|
154 |
+
except Exception as exc:
|
155 |
+
logger.exception("Error while fetching schedule")
|
156 |
+
return f"❌ Error: {exc}"
|
157 |
+
|
158 |
+
|
159 |
+
def book_class_gr(class_id: int, client_id: str, cross_regional: bool):
|
160 |
+
"""Book a class through the UI."""
|
161 |
+
if not mb_client:
|
162 |
+
return "❌ Authenticate first."
|
163 |
+
|
164 |
+
try:
|
165 |
+
res = mb_client.book_class(class_id, client_id, cross_regional)
|
166 |
+
visit = res.get("Visit", {})
|
167 |
+
status = visit.get("AppointmentStatus", "OK")
|
168 |
+
return f"🎉 Booked! Status: {status}\n\nFull response:\n{res}"
|
169 |
+
except Exception as exc:
|
170 |
+
logger.exception("Booking failed")
|
171 |
+
return f"❌ Could not book: {exc}"
|
172 |
+
|
173 |
+
|
174 |
+
# -------------------------------------------------------------
|
175 |
+
# Gradio UI definition
|
176 |
+
# -------------------------------------------------------------
|
177 |
+
with gr.Blocks(title="Mindbody Class Booker") as demo:
|
178 |
+
gr.Markdown("# 🏋️♀️ MINDBODY Class Explorer & Booker")
|
179 |
+
gr.Markdown("Authenticate, browse classes by location, and secure your spot – all in one place.")
|
180 |
+
|
181 |
+
# ---------- Auth ----------
|
182 |
+
with gr.Row():
|
183 |
+
auth_out = gr.Textbox(label="Auth Status", interactive=False)
|
184 |
+
auth_btn = gr.Button("Authenticate")
|
185 |
+
|
186 |
+
# ---------- Training types ----------
|
187 |
+
with gr.Row():
|
188 |
+
loc_in = gr.Textbox(label="Location ID", placeholder="e.g. -99")
|
189 |
+
types_out = gr.Textbox(label="Training Types", lines=10, interactive=False)
|
190 |
+
types_btn = gr.Button("Get Training Types")
|
191 |
+
|
192 |
+
# ---------- Schedule ----------
|
193 |
+
with gr.Row():
|
194 |
+
days_in = gr.Number(value=7, label="Days Ahead")
|
195 |
+
sched_out = gr.Textbox(label="Class Schedule", lines=12, interactive=False)
|
196 |
+
sched_btn = gr.Button("Get Schedule")
|
197 |
+
|
198 |
+
# ---------- Booking ----------
|
199 |
+
gr.Markdown("## Book a Class")
|
200 |
+
with gr.Row():
|
201 |
+
class_id_in = gr.Number(label="Class ID")
|
202 |
+
client_id_in = gr.Textbox(label="Client ID")
|
203 |
+
cross_in = gr.Checkbox(label="Cross-regional booking", value=False)
|
204 |
+
book_out = gr.Textbox(label="Booking Result", lines=10, interactive=False)
|
205 |
+
book_btn = gr.Button("Book Class")
|
206 |
+
|
207 |
+
# ---------- Wiring ----------
|
208 |
+
auth_btn.click(fn=initialize_mindbody_client, outputs=auth_out)
|
209 |
+
types_btn.click(fn=get_training_types, inputs=loc_in, outputs=types_out)
|
210 |
+
sched_btn.click(fn=get_schedule, inputs=[loc_in, days_in], outputs=sched_out)
|
211 |
+
book_btn.click(fn=book_class_gr, inputs=[class_id_in, client_id_in, cross_in], outputs=book_out)
|
212 |
+
|
213 |
+
# -------------------------------------------------------------
|
214 |
+
# Launch
|
215 |
+
# -------------------------------------------------------------
|
216 |
+
if __name__ == "__main__":
|
217 |
+
# On HuggingFace or other MCP hosts, `mcp_server=True` makes Gradio bind
|
218 |
+
demo.launch(mcp_server=True)
|