File size: 9,388 Bytes
62bdda7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ef6e86
 
62bdda7
 
7ef6e86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62bdda7
 
7ef6e86
 
 
 
 
 
 
 
 
62bdda7
7ef6e86
 
 
 
 
62bdda7
7ef6e86
62bdda7
 
 
7ef6e86
62bdda7
 
 
 
7ef6e86
 
62bdda7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ef6e86
62bdda7
 
 
 
 
7ef6e86
 
 
 
 
 
 
 
 
62bdda7
 
 
 
7ef6e86
62bdda7
7ef6e86
 
 
 
 
62bdda7
7ef6e86
62bdda7
 
 
 
 
 
 
 
 
 
7ef6e86
 
 
 
 
62bdda7
 
7ef6e86
 
62bdda7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
import os
from pathlib import Path
from datetime import datetime, timedelta
import logging
import requests

import gradio as gr

# -------------------------------------------------------------
# Configure logging
# -------------------------------------------------------------
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# -------------------------------------------------------------
# Minimal Mindbody API wrapper
# -------------------------------------------------------------
class MindbodyClient:
    """Thin wrapper around the MINDBODY Public API (v6).

    Only the endpoints needed for this demo are implemented. Extend freely!
    """

    def __init__(self, api_key: str, site_id: str, source_name: str | None = None):
        self.base_url = "https://api.mindbodyonline.com/public/v6"
        self.headers = {
            "API-Key": api_key,
            "SiteId": site_id,
            "Content-Type": "application/json",
        }
        if source_name:
            self.headers["SourceName"] = source_name

    # --------------------------- internal helpers ---------------------------
    def _get(self, path: str, params: dict | None = None):
        url = f"{self.base_url}{path}"
        logger.debug("GET %s", url)
        resp = requests.get(url, headers=self.headers, params=params, timeout=30)
        resp.raise_for_status()
        return resp.json()

    def _post(self, path: str, payload: dict):
        url = f"{self.base_url}{path}"
        logger.debug("POST %s", url)
        resp = requests.post(url, headers=self.headers, json=payload, timeout=30)
        resp.raise_for_status()
        return resp.json()

    def get_class_descriptions(self, location_id: int | str):
        params = {"locationIds": location_id}
        return self._get("/class/classdescriptions", params)

    def get_classes(
        self,
        *,
        location_id: int | str | None = None,
        start_iso: str | None = None,
        end_iso: str | None = None,
        class_ids: list[int] | None = None,
    ):
        """Return scheduled classes.  Accepts *either* a location or explicit classIds."""
        params: dict[str, str | list[int]] = {}
        if location_id is not None:
            params["locationIds"] = location_id
        if class_ids:
            params["classIds"] = class_ids
        if start_iso:
            params["startDateTime"] = start_iso
        if end_iso:
            params["endDateTime"] = end_iso
        return self._get("/class/classes", params)

    def book_class(
        self,
        *,
        class_id: int,
        client_id: str,
        cross_regional: bool = False,
        require_payment: bool = False,
        send_email: bool = False,
    ):
        """Book a client into a class *after* verifying availability."""
        # 1) quick availability check
        info = self.get_classes(class_ids=[class_id])
        cls = (info.get("Classes") or [None])[0]
        if not cls:
            raise ValueError(f"Class {class_id} not found")
        if not cls.get("IsAvailable", False):
            raise ValueError("Class is not open for online booking")
        if cls.get("TotalBooked", 0) >= cls.get("MaxCapacity", 0):
            raise ValueError("Class is already full")

        # 2) book it
        payload = {
            "ClientId": client_id,
            "ClassId": class_id,
            "CrossRegionalBooking": cross_regional,
            "RequirePayment": require_payment,
            "SendEmail": send_email,
        }
        return self._post("/class/addclienttoclass", payload)


# -------------------------------------------------------------
# Global state (kept super-simple for a single-user MCP demo)
# -------------------------------------------------------------
mb_client: MindbodyClient | None = None


# -------------------------------------------------------------
# Helper functions wired to Gradio components
# -------------------------------------------------------------

def initialize_mindbody_client():
    """Instantiate the global MindbodyClient reading credentials from ./config/.env"""
    global mb_client
    api_key = os.getenv("MINDBODY_API_KEY")
    site_id = os.getenv("MINDBODY_SITE_ID")
    source_name = os.getenv("MINDBODY_SOURCE_NAME")  # optional

    if not api_key or not site_id:
        return "❌ MINDBODY_API_KEY or MINDBODY_SITE_ID missing in .env"

    try:
        mb_client = MindbodyClient(api_key, site_id, source_name)
        return "✅ Mindbody client initialised. Ready to query!"
    except Exception as exc:
        logger.exception("Failed initialising Mindbody client")
        return f"❌ Initialisation error: {exc}"


def get_training_types(location_id: str | int):
    """Fetch human-readable list of class description names."""
    if not mb_client:
        return "❌ Authenticate first."

    try:
        res = mb_client.get_class_descriptions(location_id)
        descs = res.get("ClassDescriptions", [])
        if not descs:
            return "⚠️ No training types found for this location."

        lines = [f"- {d.get('Name')} (ID: {d.get('Id')})" for d in descs]
        return "🏷️ Training Types\n" + "\n".join(lines)
    except Exception as exc:
        logger.exception("Error while fetching class descriptions")
        return f"❌ Error: {exc}"


def get_schedule(location_id: str | int, days: float):
    """Return upcoming classes in a human-friendly Markdown list."""
    if not mb_client:
        return "❌ Authenticate first."

    try:
        now = datetime.utcnow()
        start_iso = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
        end_iso = (now + timedelta(days=int(days))).replace(
            hour=23, minute=59, second=59, microsecond=0
        ).isoformat()

        res = mb_client.get_classes(
            location_id=location_id, start_iso=start_iso, end_iso=end_iso
        )
        classes = res.get("Classes", [])
        if not classes:
            return "⚠️ No classes in the selected window."

        lines: list[str] = []
        for c in classes:
            when = c["StartDateTime"][:16].replace("T", " ")  # YYYY-MM-DD HH:MM
            name = c.get("ClassDescription", {}).get("Name") or f"ID {c['ClassDescriptionId']}"
            ok = "✅" if c["IsAvailable"] else "❌"
            cid = c["Id"]
            lines.append(f"{when} | {name} | {ok} | ClassId: {cid}")

        return "### 📅 Schedule\n" + "\n".join(lines)
    except Exception as exc:
        logger.exception("Error while fetching schedule")
        return f"❌ Error: {exc}"


def book_class_gr(class_id: int, client_id: str, cross_regional: bool):
    if not mb_client:
        return "❌ Authenticate first."

    try:
        res = mb_client.book_class(
            class_id=class_id,
            client_id=client_id,
            cross_regional=cross_regional,
        )
        visit = res.get("Visit", {})
        status = visit.get("AppointmentStatus", "OK")
        readable = visit.get("StartDateTime", "")[:16].replace("T", " ")
        return f"🎉 **Booked {readable} – status {status}**\n\n```json\n{res}\n```"
    except Exception as exc:
        logger.exception("Booking failed")
        return f"❌ Could not book: {exc}"


# -------------------------------------------------------------
# Gradio UI definition
# -------------------------------------------------------------
with gr.Blocks(title="Mindbody Class Booker") as demo:
    gr.Markdown("# 🏋️‍♀️ MINDBODY Class Explorer & Booker")
    gr.Markdown("Authenticate, browse classes by location, and secure your spot – all in one place.")

    # ---------- Auth ----------
    with gr.Row():
        auth_out = gr.Textbox(label="Auth Status", interactive=False)
        auth_btn = gr.Button("Authenticate")

    # ---------- Training types ----------
    with gr.Row():
        loc_in = gr.Textbox(label="Location ID", placeholder="e.g. -99")
        types_out = gr.Textbox(label="Training Types", lines=10, interactive=False)
        types_btn = gr.Button("Get Training Types")

    # ---------- Schedule ----------
    with gr.Row():
        days_in = gr.Number(value=7, label="Days Ahead")
        sched_out = gr.Textbox(label="Class Schedule", lines=12, interactive=False)
        sched_btn = gr.Button("Get Schedule")

    # ---------- Booking ----------
    gr.Markdown("## Book a Class")
    with gr.Row():
        class_id_in = gr.Number(label="Class ID")
        client_id_in = gr.Textbox(label="Client ID")
        cross_in = gr.Checkbox(label="Cross-regional booking", value=False)
        book_out = gr.Textbox(label="Booking Result", lines=10, interactive=False)
        book_btn = gr.Button("Book Class")

    # ---------- Wiring ----------
    auth_btn.click(fn=initialize_mindbody_client, outputs=auth_out)
    types_btn.click(fn=get_training_types, inputs=loc_in, outputs=types_out)
    sched_btn.click(fn=get_schedule, inputs=[loc_in, days_in], outputs=sched_out)
    book_btn.click(fn=book_class_gr, inputs=[class_id_in, client_id_in, cross_in], outputs=book_out)

# -------------------------------------------------------------
# Launch
# -------------------------------------------------------------
if __name__ == "__main__":
    # On HuggingFace or other MCP hosts, `mcp_server=True` makes Gradio bind
    demo.launch(mcp_server=True)