ar0s commited on
Commit
4e3386f
·
1 Parent(s): 29cbecc

Added team add to calendar button

Browse files
Files changed (8) hide show
  1. .codex/caveman.all.md +259 -0
  2. README.md +12 -2
  3. app.py +69 -25
  4. assets/prompt.txt +7 -6
  5. src/attending.py +15 -43
  6. src/models.py +8 -0
  7. src/teams_calendar.py +254 -0
  8. tests/test_golden.py +156 -32
.codex/caveman.all.md ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: caveman
3
+ description: >
4
+ Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
5
+ while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
6
+ wenyan-lite, wenyan-full, wenyan-ultra.
7
+ Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
8
+ "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
9
+ ---
10
+
11
+ Respond terse like smart caveman. All technical substance stay. Only fluff die.
12
+
13
+ ## Persistence
14
+
15
+ ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
16
+
17
+ Default: **full**. Switch: `/caveman lite|full|ultra`.
18
+
19
+ ## Rules
20
+
21
+ Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
22
+
23
+ Pattern: `[thing] [action] [reason]. [next step].`
24
+
25
+ Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
26
+ Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
27
+
28
+ ## Intensity
29
+
30
+ | Level | What change |
31
+ |-------|------------|
32
+ | **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
33
+ | **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
34
+ | **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
35
+ | **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
36
+ | **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
37
+ | **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
38
+
39
+ Example — "Why React component re-render?"
40
+ - lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
41
+ - full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
42
+ - ultra: "Inline obj prop → new ref → re-render. `useMemo`."
43
+ - wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
44
+ - wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
45
+ - wenyan-ultra: "新參照→重繪。useMemo Wrap。"
46
+
47
+ Example — "Explain database connection pooling."
48
+ - lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
49
+ - full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
50
+ - ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
51
+ - wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
52
+ - wenyan-ultra: "池reuse conn。skip handshake → fast。"
53
+
54
+ ## Auto-Clarity
55
+
56
+ Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.
57
+
58
+ Example — destructive op:
59
+ > **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
60
+ > ```sql
61
+ > DROP TABLE users;
62
+ > ```
63
+ > Caveman resume. Verify backup exist first.
64
+
65
+ ## Boundaries
66
+
67
+ Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
68
+
69
+ ---
70
+
71
+ ---
72
+ name: caveman-commit
73
+ description: >
74
+ Ultra-compressed commit message generator. Cuts noise from commit messages while preserving
75
+ intent and reasoning. Conventional Commits format. Subject ≤50 chars, body only when "why"
76
+ isn't obvious. Use when user says "write a commit", "commit message", "generate commit",
77
+ "/commit", or invokes /caveman-commit. Auto-triggers when staging changes.
78
+ ---
79
+
80
+ Write commit messages terse and exact. Conventional Commits format. No fluff. Why over what.
81
+
82
+ ## Rules
83
+
84
+ **Subject line:**
85
+ - `<type>(<scope>): <imperative summary>` — `<scope>` optional
86
+ - Types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore`, `build`, `ci`, `style`, `revert`
87
+ - Imperative mood: "add", "fix", "remove" — not "added", "adds", "adding"
88
+ - ≤50 chars when possible, hard cap 72
89
+ - No trailing period
90
+ - Match project convention for capitalization after the colon
91
+
92
+ **Body (only if needed):**
93
+ - Skip entirely when subject is self-explanatory
94
+ - Add body only for: non-obvious *why*, breaking changes, migration notes, linked issues
95
+ - Wrap at 72 chars
96
+ - Bullets `-` not `*`
97
+ - Reference issues/PRs at end: `Closes #42`, `Refs #17`
98
+
99
+ **What NEVER goes in:**
100
+ - "This commit does X", "I", "we", "now", "currently" — the diff says what
101
+ - "As requested by..." — use Co-authored-by trailer
102
+ - "Generated with Claude Code" or any AI attribution
103
+ - Emoji (unless project convention requires)
104
+ - Restating the file name when scope already says it
105
+
106
+ ## Examples
107
+
108
+ Diff: new endpoint for user profile with body explaining the why
109
+ - ❌ "feat: add a new endpoint to get user profile information from the database"
110
+ - ✅
111
+ ```
112
+ feat(api): add GET /users/:id/profile
113
+
114
+ Mobile client needs profile data without the full user payload
115
+ to reduce LTE bandwidth on cold-launch screens.
116
+
117
+ Closes #128
118
+ ```
119
+
120
+ Diff: breaking API change
121
+ - ✅
122
+ ```
123
+ feat(api)!: rename /v1/orders to /v1/checkout
124
+
125
+ BREAKING CHANGE: clients on /v1/orders must migrate to /v1/checkout
126
+ before 2026-06-01. Old route returns 410 after that date.
127
+ ```
128
+
129
+ ## Auto-Clarity
130
+
131
+ Always include body for: breaking changes, security fixes, data migrations, anything reverting a prior commit. Never compress these into subject-only — future debuggers need the context.
132
+
133
+ ## Boundaries
134
+
135
+ Only generates the commit message. Does not run `git commit`, does not stage files, does not amend. Output the message as a code block ready to paste. "stop caveman-commit" or "normal mode": revert to verbose commit style.
136
+
137
+ ---
138
+
139
+ ---
140
+ name: caveman-review
141
+ description: >
142
+ Ultra-compressed code review comments. Cuts noise from PR feedback while preserving
143
+ the actionable signal. Each comment is one line: location, problem, fix. Use when user
144
+ says "review this PR", "code review", "review the diff", "/review", or invokes
145
+ /caveman-review. Auto-triggers when reviewing pull requests.
146
+ ---
147
+
148
+ Write code review comments terse and actionable. One line per finding. Location, problem, fix. No throat-clearing.
149
+
150
+ ## Rules
151
+
152
+ **Format:** `L<line>: <problem>. <fix>.` — or `<file>:L<line>: ...` when reviewing multi-file diffs.
153
+
154
+ **Severity prefix (optional, when mixed):**
155
+ - `🔴 bug:` — broken behavior, will cause incident
156
+ - `🟡 risk:` — works but fragile (race, missing null check, swallowed error)
157
+ - `🔵 nit:` — style, naming, micro-optim. Author can ignore
158
+ - `❓ q:` — genuine question, not a suggestion
159
+
160
+ **Drop:**
161
+ - "I noticed that...", "It seems like...", "You might want to consider..."
162
+ - "This is just a suggestion but..." — use `nit:` instead
163
+ - "Great work!", "Looks good overall but..." — say it once at the top, not per comment
164
+ - Restating what the line does — the reviewer can read the diff
165
+ - Hedging ("perhaps", "maybe", "I think") — if unsure use `q:`
166
+
167
+ **Keep:**
168
+ - Exact line numbers
169
+ - Exact symbol/function/variable names in backticks
170
+ - Concrete fix, not "consider refactoring this"
171
+ - The *why* if the fix isn't obvious from the problem statement
172
+
173
+ ## Examples
174
+
175
+ ❌ "I noticed that on line 42 you're not checking if the user object is null before accessing the email property. This could potentially cause a crash if the user is not found in the database. You might want to add a null check here."
176
+
177
+ ✅ `L42: 🔴 bug: user can be null after .find(). Add guard before .email.`
178
+
179
+ ❌ "It looks like this function is doing a lot of things and might benefit from being broken up into smaller functions for readability."
180
+
181
+ ✅ `L88-140: 🔵 nit: 50-line fn does 4 things. Extract validate/normalize/persist.`
182
+
183
+ ❌ "Have you considered what happens if the API returns a 429? I think we should probably handle that case."
184
+
185
+ ✅ `L23: 🟡 risk: no retry on 429. Wrap in withBackoff(3).`
186
+
187
+ ## Auto-Clarity
188
+
189
+ Drop terse mode for: security findings (CVE-class bugs need full explanation + reference), architectural disagreements (need rationale, not just a one-liner), and onboarding contexts where the author is new and needs the "why". In those cases write a normal paragraph, then resume terse for the rest.
190
+
191
+ ## Boundaries
192
+
193
+ Reviews only — does not write the code fix, does not approve/request-changes, does not run linters. Output the comment(s) ready to paste into the PR. "stop caveman-review" or "normal mode": revert to verbose review style.
194
+
195
+ ---
196
+
197
+ ---
198
+ name: caveman-help
199
+ description: >
200
+ Quick-reference card for all caveman modes, skills, and commands.
201
+ One-shot display, not a persistent mode. Trigger: /caveman-help,
202
+ "caveman help", "what caveman commands", "how do I use caveman".
203
+ ---
204
+
205
+ # Caveman Help
206
+
207
+ Display this reference card when invoked. One-shot — do NOT change mode, write flag files, or persist anything. Output in caveman style.
208
+
209
+ ## Modes
210
+
211
+ | Mode | Trigger | What change |
212
+ |------|---------|-------------|
213
+ | **Lite** | `/caveman lite` | Drop filler. Keep sentence structure. |
214
+ | **Full** | `/caveman` | Drop articles, filler, pleasantries, hedging. Fragments OK. Default. |
215
+ | **Ultra** | `/caveman ultra` | Extreme compression. Bare fragments. Tables over prose. |
216
+ | **Wenyan-Lite** | `/caveman wenyan-lite` | Classical Chinese style, light compression. |
217
+ | **Wenyan-Full** | `/caveman wenyan` | Full 文言文. Maximum classical terseness. |
218
+ | **Wenyan-Ultra** | `/caveman wenyan-ultra` | Extreme. Ancient scholar on a budget. |
219
+
220
+ Mode stick until changed or session end.
221
+
222
+ ## Skills
223
+
224
+ | Skill | Trigger | What it do |
225
+ |-------|---------|-----------|
226
+ | **caveman-commit** | `/caveman-commit` | Terse commit messages. Conventional Commits. ≤50 char subject. |
227
+ | **caveman-review** | `/caveman-review` | One-line PR comments: `L42: bug: user null. Add guard.` |
228
+ | **caveman-compress** | `/caveman:compress <file>` | Compress .md files to caveman prose. Saves ~46% input tokens. |
229
+ | **caveman-help** | `/caveman-help` | This card. |
230
+
231
+ ## Deactivate
232
+
233
+ Say "stop caveman" or "normal mode". Resume anytime with `/caveman`.
234
+
235
+ ## Configure Default Mode
236
+
237
+ Default mode = `full`. Change it:
238
+
239
+ **Environment variable** (highest priority):
240
+ ```bash
241
+ export CAVEMAN_DEFAULT_MODE=ultra
242
+ ```
243
+
244
+ **Config file** (`~/.config/caveman/config.json`):
245
+ ```json
246
+ { "defaultMode": "lite" }
247
+ ```
248
+
249
+ Set `"off"` to disable auto-activation on session start. User can still activate manually with `/caveman`.
250
+
251
+ Resolution: env var > config file > `full`.
252
+
253
+ ## More
254
+
255
+ Full docs: https://github.com/JuliusBrussee/caveman
256
+
257
+
258
+ ---
259
+
README.md CHANGED
@@ -15,21 +15,31 @@ This app reads `assets/events.json` and shows the upcoming seminar list.
15
  ## Files
16
 
17
  - `app.py`: Gradio UI
18
- - `assets/events.json`: event data read by the app and updated when an attending count is clicked
19
  - `assets/sources.json`: source metadata used for tags
20
  - `assets/prompt.txt`: prompt for your manual event-generation workflow
21
 
22
  ## HF dataset sync
23
 
24
- The app pulls the latest remote events file before rendering and pushes updates back after an attending click.
25
 
26
  - Dataset repo is fixed to `ar0s/robotic-seminars-cache`
27
  - `HF_TOKEN` or `HUGGINGFACE_HUB_TOKEN`: token with dataset write access
28
  - Remote file name is fixed to `events.json`
 
29
  - Manual sync commands:
30
  `python -m src.hf_sync pull`
31
  `python -m src.hf_sync push`
32
 
 
 
 
 
 
 
 
 
 
33
  ## Local run
34
 
35
  ```bash
 
15
  ## Files
16
 
17
  - `app.py`: Gradio UI
18
+ - `assets/events.json`: event data read by the app and updated when a Teams calendar event is created or reused
19
  - `assets/sources.json`: source metadata used for tags
20
  - `assets/prompt.txt`: prompt for your manual event-generation workflow
21
 
22
  ## HF dataset sync
23
 
24
+ The app pulls the latest remote events file before rendering and pushes updates back after a calendar action updates `assets/events.json`.
25
 
26
  - Dataset repo is fixed to `ar0s/robotic-seminars-cache`
27
  - `HF_TOKEN` or `HUGGINGFACE_HUB_TOKEN`: token with dataset write access
28
  - Remote file name is fixed to `events.json`
29
+ - `assets/events.json` schema version is `6`
30
  - Manual sync commands:
31
  `python -m src.hf_sync pull`
32
  `python -m src.hf_sync push`
33
 
34
+ ## Microsoft Teams calendar
35
+
36
+ The last table column opens a shared Teams meeting for the seminar. The first user creates the service-mailbox event; later users are added to the same event and then redirected to it.
37
+
38
+ - `MS_TENANT_ID`: Entra tenant id for the app registration
39
+ - `MS_CLIENT_ID`: Entra app client id
40
+ - `MS_CLIENT_SECRET`: Entra app client secret
41
+ - `MS_SERVICE_MAILBOX`: Microsoft 365 mailbox that owns the shared calendar event
42
+
43
  ## Local run
44
 
45
  ```bash
app.py CHANGED
@@ -3,14 +3,27 @@ from __future__ import annotations
3
  import os
4
  from html import escape
5
  from pathlib import Path
 
6
 
7
  import gradio as gr
8
  from dotenv import load_dotenv
 
 
9
 
10
- from src.attending import COLS, LOCAL_DISPLAY_TZ, filtered_events, format_event_time, inc_attending, results_table
11
  from src.hf_sync import hf_enabled, pull_events
12
  from src.models import infer_category, load_payload
13
  from src.org_colors import org_colors
 
 
 
 
 
 
 
 
 
 
14
 
15
  load_dotenv(Path(__file__).with_name(".env"))
16
 
@@ -225,11 +238,13 @@ CSS = """
225
  font-weight: 400;
226
  }
227
 
228
- .attending-pill {
229
  display: inline-flex;
230
  align-items: center;
231
- gap: 0.45rem;
232
- padding: 0.28rem 0.42rem 0.28rem 0.72rem;
 
 
233
  border: 1px solid rgba(15, 118, 110, 0.16);
234
  border-radius: 999px;
235
  background: #eef4f0;
@@ -238,14 +253,16 @@ CSS = """
238
  font-weight: 700;
239
  line-height: 1;
240
  transition: background 0.2s ease, transform 0.2s ease;
 
241
  }
242
 
243
- .attending-pill span {
244
  display: inline-flex;
245
  align-items: center;
246
  justify-content: center;
247
- width: 1.15rem;
248
- height: 1.15rem;
 
249
  border-radius: 999px;
250
  background: rgba(15, 118, 110, 0.11);
251
  }
@@ -296,11 +313,10 @@ CSS = """
296
  }
297
 
298
  #events-table tbody td:last-child {
299
- cursor: pointer;
300
  text-align: center;
301
  }
302
 
303
- #events-table tbody td:last-child:hover .attending-pill {
304
  background: var(--accent-soft);
305
  transform: translateY(-1px);
306
  }
@@ -388,8 +404,8 @@ def render_spotlight(results: list[dict], query: str = "") -> str:
388
 
389
  def build_view(results: list[dict], query: str = ""):
390
  colors = org_colors(result["org_name"] for result in results)
391
- table, row_map = results_table(results, colors, query)
392
- return render_spotlight(results, query), table, row_map
393
 
394
 
395
  def render():
@@ -397,15 +413,16 @@ def render():
397
  pull_events(EVENTS_PATH)
398
  payload = load_payload(EVENTS_PATH)
399
  results = prepare_results(payload["results"])
400
- spotlight, table, row_map = build_view(results)
401
  sources = "\n".join(f"- [{result['org_name']}]({result['source_url']})" for result in results)
402
- return render_hero(payload, results), spotlight, table, results, row_map, sources
403
 
404
 
405
- def on_select(results: list[dict], row_map: list[tuple[int, int]], query: str, evt: gr.SelectData):
406
- colors = org_colors(result["org_name"] for result in results)
407
- table, results, row_map = inc_attending(evt, results, row_map, colors=colors, query=query, events_path=EVENTS_PATH)
408
- return render_spotlight(results, query), table, results, row_map
 
409
 
410
 
411
  with gr.Blocks(theme=gr.themes.Soft(radius_size="lg"), css=CSS, fill_width=True, title="Robotic seminars") as demo:
@@ -420,24 +437,51 @@ with gr.Blocks(theme=gr.themes.Soft(radius_size="lg"), css=CSS, fill_width=True,
420
  table = gr.Dataframe(
421
  headers=COLS,
422
  datatype=["markdown", "markdown", "markdown", "markdown", "markdown", "markdown"],
423
- column_widths=[180, 420, 240, 170, 260, 110],
424
  max_height=100000,
425
  interactive=False,
426
  wrap=True,
427
  elem_id="events-table",
428
  )
429
  results_state = gr.State([])
430
- row_map_state = gr.State([])
431
  with gr.Accordion("Sources", open=False):
432
  sources_box = gr.Markdown()
433
 
434
- demo.load(render, outputs=[hero, spotlight, table, results_state, row_map_state, sources_box])
435
- search.input(build_view, inputs=[results_state, search], outputs=[spotlight, table, row_map_state])
436
- table.select(
437
- on_select,
438
- inputs=[results_state, row_map_state, search],
439
- outputs=[spotlight, table, results_state, row_map_state],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  )
 
 
441
 
442
 
443
  if __name__ == "__main__":
 
3
  import os
4
  from html import escape
5
  from pathlib import Path
6
+ from urllib.parse import urlencode
7
 
8
  import gradio as gr
9
  from dotenv import load_dotenv
10
+ from fastapi import Request
11
+ from fastapi.responses import RedirectResponse
12
 
13
+ from src.attending import COLS, LOCAL_DISPLAY_TZ, filtered_events, format_event_time, results_table
14
  from src.hf_sync import hf_enabled, pull_events
15
  from src.models import infer_category, load_payload
16
  from src.org_colors import org_colors
17
+ from src.teams_calendar import (
18
+ SESSION_COOKIE,
19
+ SESSIONS,
20
+ auth_url,
21
+ begin_auth_flow,
22
+ consume_auth_flow,
23
+ ensure_user_on_event,
24
+ exchange_code_for_user,
25
+ session_id,
26
+ )
27
 
28
  load_dotenv(Path(__file__).with_name(".env"))
29
 
 
238
  font-weight: 400;
239
  }
240
 
241
+ .calendar-pill {
242
  display: inline-flex;
243
  align-items: center;
244
+ justify-content: space-between;
245
+ gap: 0.7rem;
246
+ min-width: 9.25rem;
247
+ padding: 0.32rem 0.42rem 0.32rem 0.78rem;
248
  border: 1px solid rgba(15, 118, 110, 0.16);
249
  border-radius: 999px;
250
  background: #eef4f0;
 
253
  font-weight: 700;
254
  line-height: 1;
255
  transition: background 0.2s ease, transform 0.2s ease;
256
+ text-decoration: none !important;
257
  }
258
 
259
+ .calendar-pill strong {
260
  display: inline-flex;
261
  align-items: center;
262
  justify-content: center;
263
+ min-width: 1.4rem;
264
+ height: 1.4rem;
265
+ padding: 0 0.3rem;
266
  border-radius: 999px;
267
  background: rgba(15, 118, 110, 0.11);
268
  }
 
313
  }
314
 
315
  #events-table tbody td:last-child {
 
316
  text-align: center;
317
  }
318
 
319
+ #events-table tbody td:last-child .calendar-pill:hover {
320
  background: var(--accent-soft);
321
  transform: translateY(-1px);
322
  }
 
404
 
405
  def build_view(results: list[dict], query: str = ""):
406
  colors = org_colors(result["org_name"] for result in results)
407
+ table = results_table(results, colors, query)
408
+ return render_spotlight(results, query), table
409
 
410
 
411
  def render():
 
413
  pull_events(EVENTS_PATH)
414
  payload = load_payload(EVENTS_PATH)
415
  results = prepare_results(payload["results"])
416
+ spotlight, table = build_view(results)
417
  sources = "\n".join(f"- [{result['org_name']}]({result['source_url']})" for result in results)
418
+ return render_hero(payload, results), spotlight, table, results, sources
419
 
420
 
421
+ def route_url(request: Request, route_name: str, **query: str) -> str:
422
+ base = str(request.url_for(route_name))
423
+ if not query:
424
+ return base
425
+ return f"{base}?{urlencode(query)}"
426
 
427
 
428
  with gr.Blocks(theme=gr.themes.Soft(radius_size="lg"), css=CSS, fill_width=True, title="Robotic seminars") as demo:
 
437
  table = gr.Dataframe(
438
  headers=COLS,
439
  datatype=["markdown", "markdown", "markdown", "markdown", "markdown", "markdown"],
440
+ column_widths=[180, 420, 240, 170, 260, 180],
441
  max_height=100000,
442
  interactive=False,
443
  wrap=True,
444
  elem_id="events-table",
445
  )
446
  results_state = gr.State([])
 
447
  with gr.Accordion("Sources", open=False):
448
  sources_box = gr.Markdown()
449
 
450
+ demo.load(render, outputs=[hero, spotlight, table, results_state, sources_box])
451
+ search.input(build_view, inputs=[results_state, search], outputs=[spotlight, table])
452
+
453
+
454
+ @demo.app.get("/calendar/action", name="calendar_action")
455
+ def calendar_action(request: Request, event_key: str):
456
+ user = SESSIONS.get(request.cookies.get(SESSION_COOKIE, ""))
457
+ if user is None:
458
+ return RedirectResponse(route_url(request, "microsoft_start", event_key=event_key), status_code=302)
459
+ return RedirectResponse(ensure_user_on_event(event_key, user, events_path=EVENTS_PATH), status_code=302)
460
+
461
+
462
+ @demo.app.get("/auth/microsoft/start", name="microsoft_start")
463
+ def microsoft_start(request: Request, event_key: str):
464
+ current_session_id = request.cookies.get(SESSION_COOKIE) or session_id()
465
+ state = begin_auth_flow(current_session_id, event_key)
466
+ response = RedirectResponse(
467
+ auth_url(redirect_uri=str(request.url_for("microsoft_callback")), state=state),
468
+ status_code=302,
469
+ )
470
+ response.set_cookie(SESSION_COOKIE, current_session_id, httponly=True, samesite="lax")
471
+ return response
472
+
473
+
474
+ @demo.app.get("/auth/microsoft/callback", name="microsoft_callback")
475
+ def microsoft_callback(request: Request, code: str, state: str):
476
+ pending = consume_auth_flow(state)
477
+ user = exchange_code_for_user(code, redirect_uri=str(request.url_for("microsoft_callback")))
478
+ SESSIONS[pending["session_id"]] = user
479
+ response = RedirectResponse(
480
+ ensure_user_on_event(pending["event_key"], user, events_path=EVENTS_PATH),
481
+ status_code=302,
482
  )
483
+ response.set_cookie(SESSION_COOKIE, pending["session_id"], httponly=True, samesite="lax")
484
+ return response
485
 
486
 
487
  if __name__ == "__main__":
assets/prompt.txt CHANGED
@@ -17,7 +17,7 @@ Execution requirements:
17
 
18
  - Write only `assets/events.json`.
19
  - Preserve the existing `meta.ttl_hours`.
20
- - Set `meta.schema_version` to `5`.
21
  - Refresh `meta.cached_at` to the current UTC ISO-8601 timestamp.
22
  - `results` must contain exactly one object per source with:
23
  - `org_id`
@@ -45,6 +45,7 @@ Execution requirements:
45
  - `category`
46
  - `tags`
47
  - `attending_count`
 
48
 
49
  Extraction rules:
50
 
@@ -61,18 +62,18 @@ Extraction rules:
61
  - Infer tags primarily from the abstract or description of each event when available, and use the title only as a secondary signal.
62
  - Broad umbrella tags are allowed when appropriate, and one or two more specific tags may be added when they are clearly supported by the event content.
63
 
64
- Attending-count preservation rules:
65
 
66
- - Preserve existing `attending_count` values from the current `assets/events.json` wherever possible.
67
  - Match first on `(org_id, normalized start_time to minute in UTC, event_url)`.
68
- - If there is no exact match, reuse the count only when there is exactly one prior event for the same `(org_id, normalized start_time to minute in UTC)`.
69
- - Otherwise leave `attending_count` at `0`.
70
 
71
  Validation requirements before finishing:
72
 
73
  1. Confirm every `assets/sources.json` id appears exactly once in `assets/events.json.results`.
74
  2. Confirm every `start_time` is ISO-parseable and sorts correctly in UTC after normalization.
75
- 3. Confirm preserved `attending_count` values remain attached to matched events.
76
  4. Confirm every event has `category` and `tags`.
77
  5. Run `conda run -n robotic-seminars pytest -q`.
78
  6. Manually spot-check at least 3 orgs:
 
17
 
18
  - Write only `assets/events.json`.
19
  - Preserve the existing `meta.ttl_hours`.
20
+ - Set `meta.schema_version` to `6`.
21
  - Refresh `meta.cached_at` to the current UTC ISO-8601 timestamp.
22
  - `results` must contain exactly one object per source with:
23
  - `org_id`
 
45
  - `category`
46
  - `tags`
47
  - `attending_count`
48
+ - Preserve `teams_event_id` and `teams_event_web_link` on matched events when they already exist.
49
 
50
  Extraction rules:
51
 
 
62
  - Infer tags primarily from the abstract or description of each event when available, and use the title only as a secondary signal.
63
  - Broad umbrella tags are allowed when appropriate, and one or two more specific tags may be added when they are clearly supported by the event content.
64
 
65
+ Calendar-state preservation rules:
66
 
67
+ - Preserve existing `attending_count`, `teams_event_id`, and `teams_event_web_link` values from the current `assets/events.json` wherever possible.
68
  - Match first on `(org_id, normalized start_time to minute in UTC, event_url)`.
69
+ - If there is no exact match, reuse these values only when there is exactly one prior event for the same `(org_id, normalized start_time to minute in UTC)`.
70
+ - Otherwise leave `attending_count` at `0` and omit `teams_event_id` and `teams_event_web_link`.
71
 
72
  Validation requirements before finishing:
73
 
74
  1. Confirm every `assets/sources.json` id appears exactly once in `assets/events.json.results`.
75
  2. Confirm every `start_time` is ISO-parseable and sorts correctly in UTC after normalization.
76
+ 3. Confirm preserved `attending_count`, `teams_event_id`, and `teams_event_web_link` values remain attached to matched events.
77
  4. Confirm every event has `category` and `tags`.
78
  5. Run `conda run -n robotic-seminars pytest -q`.
79
  6. Manually spot-check at least 3 orgs:
src/attending.py CHANGED
@@ -2,17 +2,16 @@ from __future__ import annotations
2
 
3
  import os
4
  from html import escape
5
- from pathlib import Path
6
 
7
  import pandas as pd
8
 
9
- from .hf_sync import hf_enabled, push_events
10
- from .models import load_payload, parse_dt_utc, write_payload
11
 
12
  LOCAL_DISPLAY_TZ = os.environ.get("LOCAL_DISPLAY_TZ", "Europe/Rome")
13
  WHEN_COL = "When"
14
- COLS = [WHEN_COL, "Event", "Speaker", "Category", "Organization", "Attending"]
15
- DEFAULT_EVENTS_PATH = Path(os.environ.get("EVENTS_PATH", Path(os.environ.get("ASSETS_DIR", "assets")) / "events.json"))
16
 
17
 
18
  def format_event_time(value: str, *, tz_name: str | None = None) -> str:
@@ -47,8 +46,11 @@ def speaker_cell(speaker: str, affiliation: str) -> str:
47
  return escape(speaker or affiliation)
48
 
49
 
50
- def attending_badge(count: int) -> str:
51
- return f"<span class='attending-pill'><strong>{count}</strong><span>+</span></span>"
 
 
 
52
 
53
 
54
  def filtered_events(results: list[dict], query: str = "") -> list[tuple[int, int, dict, dict]]:
@@ -75,9 +77,9 @@ def filtered_events(results: list[dict], query: str = "") -> list[tuple[int, int
75
  return [(result_index, event_index, result, event) for _, _, result_index, event_index, result, event in sorted(matches)]
76
 
77
 
78
- def results_table(results: list[dict], colors: dict[str, str], query: str = "") -> tuple[pd.DataFrame, list[tuple[int, int]]]:
79
  rows: list[dict[str, object]] = []
80
- for result_index, event_index, result, event in filtered_events(results, query):
81
  title = event["title"] or "View event"
82
  source_time = format_event_time(event["start_time"])
83
  local_time = format_event_time(event["start_time"], tz_name=LOCAL_DISPLAY_TZ)
@@ -96,41 +98,11 @@ def results_table(results: list[dict], colors: dict[str, str], query: str = "")
96
  "Speaker": speaker_cell(event["speaker"] or "", event["affiliation"] or ""),
97
  "Category": chip(event["category"], "pill"),
98
  "Organization": org_badge(result["org_name"], colors),
99
- "Attending": attending_badge(event["attending_count"]),
100
- "_result": result_index,
101
- "_event": event_index,
102
  }
103
  )
104
 
105
  if not rows:
106
- return pd.DataFrame(columns=COLS), []
107
-
108
- df = pd.DataFrame(rows).reset_index(drop=True)
109
- row_map = list(zip(df["_result"].astype(int).tolist(), df["_event"].astype(int).tolist()))
110
- return df.drop(columns=["_result", "_event"])[COLS], row_map
111
-
112
-
113
- def inc_attending(
114
- evt,
115
- results: list[dict],
116
- row_map: list[tuple[int, int]],
117
- *,
118
- colors: dict[str, str],
119
- query: str = "",
120
- events_path: str | Path = DEFAULT_EVENTS_PATH,
121
- **_ignored,
122
- ):
123
- if evt and evt.index:
124
- row_index, column_index = map(int, evt.index)
125
- if column_index == COLS.index("Attending"):
126
- result_index, event_index = row_map[row_index]
127
- results[result_index]["events"][event_index]["attending_count"] += 1
128
-
129
- payload = load_payload(events_path)
130
- payload["results"] = results
131
- write_payload(payload, events_path)
132
- if hf_enabled():
133
- push_events(events_path)
134
-
135
- table, row_map = results_table(results, colors, query)
136
- return table, results, row_map
 
2
 
3
  import os
4
  from html import escape
5
+ from urllib.parse import quote
6
 
7
  import pandas as pd
8
 
9
+ from .models import parse_dt_utc
10
+ from .teams_calendar import event_key
11
 
12
  LOCAL_DISPLAY_TZ = os.environ.get("LOCAL_DISPLAY_TZ", "Europe/Rome")
13
  WHEN_COL = "When"
14
+ COLS = [WHEN_COL, "Event", "Speaker", "Category", "Organization", "Calendar"]
 
15
 
16
 
17
  def format_event_time(value: str, *, tz_name: str | None = None) -> str:
 
46
  return escape(speaker or affiliation)
47
 
48
 
49
+ def calendar_badge(current_event_key: str, count: int) -> str:
50
+ return (
51
+ f'<a class="calendar-pill" href="calendar/action?event_key={quote(current_event_key)}">'
52
+ f"<span>Add to calendar</span><strong>{count}</strong></a>"
53
+ )
54
 
55
 
56
  def filtered_events(results: list[dict], query: str = "") -> list[tuple[int, int, dict, dict]]:
 
77
  return [(result_index, event_index, result, event) for _, _, result_index, event_index, result, event in sorted(matches)]
78
 
79
 
80
+ def results_table(results: list[dict], colors: dict[str, str], query: str = "") -> pd.DataFrame:
81
  rows: list[dict[str, object]] = []
82
+ for _, _, result, event in filtered_events(results, query):
83
  title = event["title"] or "View event"
84
  source_time = format_event_time(event["start_time"])
85
  local_time = format_event_time(event["start_time"], tz_name=LOCAL_DISPLAY_TZ)
 
98
  "Speaker": speaker_cell(event["speaker"] or "", event["affiliation"] or ""),
99
  "Category": chip(event["category"], "pill"),
100
  "Organization": org_badge(result["org_name"], colors),
101
+ "Calendar": calendar_badge(event_key(result, event), event["attending_count"]),
 
 
102
  }
103
  )
104
 
105
  if not rows:
106
+ return pd.DataFrame(columns=COLS)
107
+
108
+ return pd.DataFrame(rows).reset_index(drop=True)[COLS]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/models.py CHANGED
@@ -6,13 +6,21 @@ from datetime import datetime, timezone
6
  from pathlib import Path
7
 
8
  DEFAULT_EVENTS_PATH = Path(os.environ.get("EVENTS_PATH", Path(os.environ.get("ASSETS_DIR", "assets")) / "events.json"))
 
9
 
10
 
11
  def load_payload(path: str | Path = DEFAULT_EVENTS_PATH) -> dict:
12
  return json.loads(Path(path).read_text(encoding="utf-8"))
13
 
14
 
 
 
 
 
 
 
15
  def write_payload(payload: dict, path: str | Path = DEFAULT_EVENTS_PATH) -> None:
 
16
  Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
17
 
18
 
 
6
  from pathlib import Path
7
 
8
  DEFAULT_EVENTS_PATH = Path(os.environ.get("EVENTS_PATH", Path(os.environ.get("ASSETS_DIR", "assets")) / "events.json"))
9
+ SCHEMA_VERSION = 6
10
 
11
 
12
  def load_payload(path: str | Path = DEFAULT_EVENTS_PATH) -> dict:
13
  return json.loads(Path(path).read_text(encoding="utf-8"))
14
 
15
 
16
+ def ensure_payload_schema(payload: dict) -> dict:
17
+ meta = payload.setdefault("meta", {})
18
+ meta["schema_version"] = SCHEMA_VERSION
19
+ return payload
20
+
21
+
22
  def write_payload(payload: dict, path: str | Path = DEFAULT_EVENTS_PATH) -> None:
23
+ ensure_payload_schema(payload)
24
  Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
25
 
26
 
src/teams_calendar.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import secrets
8
+ from datetime import timedelta
9
+ from pathlib import Path
10
+ from urllib.parse import quote, urlencode
11
+ from urllib.request import Request, urlopen
12
+
13
+ from .hf_sync import hf_enabled, push_events
14
+ from .models import load_payload, parse_dt_utc, write_payload
15
+
16
+ DEFAULT_EVENTS_PATH = Path(os.environ.get("EVENTS_PATH", Path(os.environ.get("ASSETS_DIR", "assets")) / "events.json"))
17
+ GRAPH_SCOPE = "https://graph.microsoft.com/.default"
18
+ MICROSOFT_AUTH_SCOPE = "openid profile email"
19
+ SESSION_COOKIE = "robotic_seminars_session"
20
+ SESSIONS: dict[str, dict[str, str]] = {}
21
+ PENDING_STATES: dict[str, dict[str, str]] = {}
22
+
23
+
24
+ def required_env(name: str) -> str:
25
+ value = os.environ.get(name, "").strip()
26
+ if not value:
27
+ raise RuntimeError(f"Missing required environment variable: {name}")
28
+ return value
29
+
30
+
31
+ def tenant_id() -> str:
32
+ return required_env("MS_TENANT_ID")
33
+
34
+
35
+ def client_id() -> str:
36
+ return required_env("MS_CLIENT_ID")
37
+
38
+
39
+ def client_secret() -> str:
40
+ return required_env("MS_CLIENT_SECRET")
41
+
42
+
43
+ def service_mailbox() -> str:
44
+ return required_env("MS_SERVICE_MAILBOX")
45
+
46
+
47
+ def microsoft_authority() -> str:
48
+ return f"https://login.microsoftonline.com/{tenant_id()}/oauth2/v2.0"
49
+
50
+
51
+ def session_id() -> str:
52
+ return secrets.token_urlsafe(24)
53
+
54
+
55
+ def begin_auth_flow(session_token: str, event_key: str) -> str:
56
+ state = secrets.token_urlsafe(24)
57
+ PENDING_STATES[state] = {"session_id": session_token, "event_key": event_key}
58
+ return state
59
+
60
+
61
+ def consume_auth_flow(state: str) -> dict[str, str]:
62
+ return PENDING_STATES.pop(state)
63
+
64
+
65
+ def event_key(result: dict, event: dict) -> str:
66
+ signature = "\n".join((result["org_id"], event["start_time"], event.get("event_url") or ""))
67
+ return hashlib.sha256(signature.encode("utf-8")).hexdigest()
68
+
69
+
70
+ def auth_url(*, redirect_uri: str, state: str) -> str:
71
+ query = urlencode(
72
+ {
73
+ "client_id": client_id(),
74
+ "response_type": "code",
75
+ "redirect_uri": redirect_uri,
76
+ "response_mode": "query",
77
+ "scope": MICROSOFT_AUTH_SCOPE,
78
+ "state": state,
79
+ }
80
+ )
81
+ return f"{microsoft_authority()}/authorize?{query}"
82
+
83
+
84
+ def _post_form_json(url: str, form: dict[str, str]) -> dict:
85
+ request = Request(
86
+ url,
87
+ data=urlencode(form).encode("utf-8"),
88
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
89
+ )
90
+ with urlopen(request) as response:
91
+ return json.loads(response.read().decode("utf-8"))
92
+
93
+
94
+ def _request_json(method: str, url: str, *, token: str, body: dict | None = None) -> dict:
95
+ headers = {"Authorization": f"Bearer {token}"}
96
+ data = None
97
+ if body is not None:
98
+ headers["Content-Type"] = "application/json"
99
+ data = json.dumps(body).encode("utf-8")
100
+ request = Request(url, data=data, headers=headers, method=method)
101
+ with urlopen(request) as response:
102
+ text = response.read().decode("utf-8")
103
+ return json.loads(text) if text else {}
104
+
105
+
106
+ def _decode_jwt_payload(token: str) -> dict:
107
+ payload = token.split(".")[1]
108
+ payload += "=" * (-len(payload) % 4)
109
+ return json.loads(base64.urlsafe_b64decode(payload.encode("utf-8")).decode("utf-8"))
110
+
111
+
112
+ def exchange_code_for_user(code: str, *, redirect_uri: str) -> dict[str, str]:
113
+ tokens = _post_form_json(
114
+ f"{microsoft_authority()}/token",
115
+ {
116
+ "client_id": client_id(),
117
+ "client_secret": client_secret(),
118
+ "grant_type": "authorization_code",
119
+ "code": code,
120
+ "redirect_uri": redirect_uri,
121
+ "scope": MICROSOFT_AUTH_SCOPE,
122
+ },
123
+ )
124
+ claims = _decode_jwt_payload(tokens["id_token"])
125
+ if claims["tid"] != tenant_id():
126
+ raise RuntimeError("Signed-in user is outside the configured tenant")
127
+ email = (claims.get("preferred_username") or claims.get("email") or claims.get("upn") or "").lower()
128
+ if not email:
129
+ raise RuntimeError("Signed-in user does not expose an email address")
130
+ return {"email": email, "name": claims.get("name") or claims.get("preferred_username") or ""}
131
+
132
+
133
+ def service_access_token() -> str:
134
+ tokens = _post_form_json(
135
+ f"{microsoft_authority()}/token",
136
+ {
137
+ "client_id": client_id(),
138
+ "client_secret": client_secret(),
139
+ "grant_type": "client_credentials",
140
+ "scope": GRAPH_SCOPE,
141
+ },
142
+ )
143
+ return tokens["access_token"]
144
+
145
+
146
+ def _graph_event_collection_url() -> str:
147
+ return f"https://graph.microsoft.com/v1.0/users/{quote(service_mailbox())}/calendar/events"
148
+
149
+
150
+ def _graph_event_url(event_id: str) -> str:
151
+ return f"https://graph.microsoft.com/v1.0/users/{quote(service_mailbox())}/events/{quote(event_id, safe='')}"
152
+
153
+
154
+ def _attendee_entry(user: dict[str, str]) -> dict:
155
+ return {
156
+ "emailAddress": {
157
+ "address": user["email"],
158
+ "name": user["name"] or user["email"],
159
+ },
160
+ "type": "required",
161
+ }
162
+
163
+
164
+ def _attendee_emails(attendees: list[dict]) -> set[str]:
165
+ return {
166
+ attendee["emailAddress"]["address"].lower()
167
+ for attendee in attendees
168
+ if attendee.get("emailAddress", {}).get("address")
169
+ }
170
+
171
+
172
+ def _attending_count(attendees: list[dict]) -> int:
173
+ return len(_attendee_emails(attendees))
174
+
175
+
176
+ def _event_times(event: dict) -> tuple[dict[str, str], dict[str, str]]:
177
+ start = parse_dt_utc(event["start_time"])
178
+ end = start + timedelta(hours=1)
179
+ return (
180
+ {"dateTime": start.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "UTC"},
181
+ {"dateTime": end.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "UTC"},
182
+ )
183
+
184
+
185
+ def _event_body(result: dict, event: dict) -> dict[str, str]:
186
+ source_url = event.get("event_url") or result["source_url"]
187
+ content = (
188
+ f"<p>{event['title']}</p>"
189
+ f"<p>{result['org_name']}</p>"
190
+ f'<p><a href="{source_url}">Original seminar page</a></p>'
191
+ )
192
+ return {"contentType": "HTML", "content": content}
193
+
194
+
195
+ def _create_event_payload(result: dict, event: dict, user: dict[str, str], current_event_key: str) -> dict:
196
+ start, end = _event_times(event)
197
+ return {
198
+ "subject": event["title"],
199
+ "body": _event_body(result, event),
200
+ "start": start,
201
+ "end": end,
202
+ "location": {"displayName": result["org_name"]},
203
+ "attendees": [_attendee_entry(user)],
204
+ "isOnlineMeeting": True,
205
+ "onlineMeetingProvider": "teamsForBusiness",
206
+ "transactionId": current_event_key,
207
+ }
208
+
209
+
210
+ def _find_event(payload: dict, current_event_key: str) -> tuple[dict, dict]:
211
+ for result in payload["results"]:
212
+ for event in result["events"]:
213
+ if event_key(result, event) == current_event_key:
214
+ return result, event
215
+ raise KeyError(f"Unknown event key: {current_event_key}")
216
+
217
+
218
+ def ensure_user_on_event(
219
+ current_event_key: str,
220
+ user: dict[str, str],
221
+ *,
222
+ events_path: str | Path = DEFAULT_EVENTS_PATH,
223
+ ) -> str:
224
+ payload = load_payload(events_path)
225
+ result, event = _find_event(payload, current_event_key)
226
+ token = service_access_token()
227
+
228
+ if event.get("teams_event_id"):
229
+ graph_event = _request_json("GET", _graph_event_url(event["teams_event_id"]), token=token)
230
+ else:
231
+ graph_event = _request_json(
232
+ "POST",
233
+ _graph_event_collection_url(),
234
+ token=token,
235
+ body=_create_event_payload(result, event, user, current_event_key),
236
+ )
237
+ event["teams_event_id"] = graph_event["id"]
238
+
239
+ attendees = graph_event.get("attendees") or []
240
+ if user["email"] not in _attendee_emails(attendees):
241
+ attendees = attendees + [_attendee_entry(user)]
242
+ graph_event = _request_json(
243
+ "PATCH",
244
+ _graph_event_url(event["teams_event_id"]),
245
+ token=token,
246
+ body={"attendees": attendees},
247
+ )
248
+
249
+ event["teams_event_web_link"] = graph_event["webLink"]
250
+ event["attending_count"] = _attending_count(graph_event.get("attendees") or [])
251
+ write_payload(payload, events_path)
252
+ if hf_enabled():
253
+ push_events(events_path)
254
+ return event["teams_event_web_link"]
tests/test_golden.py CHANGED
@@ -1,9 +1,12 @@
1
  from __future__ import annotations
2
 
3
- from types import SimpleNamespace
 
4
 
5
- from src.attending import COLS, WHEN_COL, inc_attending, results_table
6
- from src.models import infer_category, load_payload, parse_dt_utc, write_payload
 
 
7
 
8
 
9
  def sample_results() -> list[dict]:
@@ -92,8 +95,42 @@ def searchable_results() -> list[dict]:
92
  ]
93
 
94
 
95
- def test_results_table_shows_source_and_local_time():
96
- table, _ = results_table(sample_results(), {"CMU": "#000000"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  assert list(table.columns) == COLS
98
  assert "Apr 13 at 15:00" in table.loc[0, WHEN_COL]
99
  assert "Source: Apr 13 at 9:00" in table.loc[0, WHEN_COL]
@@ -101,46 +138,133 @@ def test_results_table_shows_source_and_local_time():
101
  assert "robotics" in table.loc[0, "Event"]
102
  assert "university" in table.loc[0, "Event"]
103
  assert "CMU" in table.loc[0, "Organization"]
 
 
 
 
104
 
 
 
105
 
106
- def test_results_table_filters_case_insensitively_and_keeps_row_map():
107
- table, row_map = results_table(searchable_results(), {"CMU": "#000000", "Stanford": "#111111"}, query="VISION")
108
  assert table.shape[0] == 2
109
- assert row_map == [(0, 0), (1, 0)]
110
  assert "Vision Event" in table.loc[0, "Event"]
111
  assert "Robot vision in the wild" in table.loc[1, "Event"]
112
 
113
 
114
- def test_inc_attending_updates_results_and_events_file(tmp_path):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  path = tmp_path / "events.json"
116
- payload = {
117
- "meta": {
118
- "schema_version": 5,
119
- "cached_at": "2026-04-12T20:22:12+00:00",
120
- "ttl_hours": 12.0,
121
- },
122
- "results": sample_results(),
123
- }
124
- write_payload(payload, path)
125
 
126
- results = load_payload(path)["results"]
127
- table, row_map = results_table(results, {"CMU": "#000000"}, query="speaker")
128
 
129
- updated_table, updated_results, updated_row_map = inc_attending(
130
- SimpleNamespace(index=(0, COLS.index("Attending"))),
131
- results,
132
- row_map,
133
- colors={"CMU": "#000000"},
134
- query="speaker",
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  events_path=path,
136
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
- assert updated_results[0]["events"][0]["attending_count"] == 1
139
- assert len(updated_table) == 1
140
- assert "attending-pill" in updated_table.loc[0, "Attending"]
141
- assert ">1<" in updated_table.loc[0, "Attending"]
142
- assert updated_row_map == row_map
143
- assert load_payload(path)["results"][0]["events"][0]["attending_count"] == 1
144
 
145
 
146
  def test_parse_dt_utc_normalizes_naive_and_offset_datetimes():
 
1
  from __future__ import annotations
2
 
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
 
6
+ import app
7
+ from src.attending import COLS, WHEN_COL, results_table
8
+ from src.models import SCHEMA_VERSION, infer_category, load_payload, parse_dt_utc, write_payload
9
+ from src.teams_calendar import PENDING_STATES, SESSION_COOKIE, SESSIONS, ensure_user_on_event, event_key
10
 
11
 
12
  def sample_results() -> list[dict]:
 
95
  ]
96
 
97
 
98
+ def sample_payload() -> dict:
99
+ return {
100
+ "meta": {
101
+ "schema_version": 5,
102
+ "cached_at": "2026-04-12T20:22:12+00:00",
103
+ "ttl_hours": 12.0,
104
+ },
105
+ "results": sample_results(),
106
+ }
107
+
108
+
109
+ def first_event_key(results: list[dict]) -> str:
110
+ return event_key(results[0], results[0]["events"][0])
111
+
112
+
113
+ @pytest.fixture(autouse=True)
114
+ def microsoft_env(monkeypatch):
115
+ monkeypatch.setenv("MS_TENANT_ID", "tenant-123")
116
+ monkeypatch.setenv("MS_CLIENT_ID", "client-123")
117
+ monkeypatch.setenv("MS_CLIENT_SECRET", "secret-123")
118
+ monkeypatch.setenv("MS_SERVICE_MAILBOX", "calendar@example.com")
119
+ SESSIONS.clear()
120
+ PENDING_STATES.clear()
121
+
122
+
123
+ @pytest.fixture
124
+ def client():
125
+ with TestClient(app.demo.app) as test_client:
126
+ yield test_client
127
+
128
+
129
+ def test_results_table_shows_source_local_time_and_calendar_badge():
130
+ results = sample_results()
131
+ table = results_table(results, {"CMU": "#000000"})
132
+ current_event_key = first_event_key(results)
133
+
134
  assert list(table.columns) == COLS
135
  assert "Apr 13 at 15:00" in table.loc[0, WHEN_COL]
136
  assert "Source: Apr 13 at 9:00" in table.loc[0, WHEN_COL]
 
138
  assert "robotics" in table.loc[0, "Event"]
139
  assert "university" in table.loc[0, "Event"]
140
  assert "CMU" in table.loc[0, "Organization"]
141
+ assert "Add to calendar" in table.loc[0, "Calendar"]
142
+ assert "calendar-pill" in table.loc[0, "Calendar"]
143
+ assert f"calendar/action?event_key={current_event_key}" in table.loc[0, "Calendar"]
144
+
145
 
146
+ def test_results_table_filters_case_insensitively():
147
+ table = results_table(searchable_results(), {"CMU": "#000000", "Stanford": "#111111"}, query="VISION")
148
 
 
 
149
  assert table.shape[0] == 2
 
150
  assert "Vision Event" in table.loc[0, "Event"]
151
  assert "Robot vision in the wild" in table.loc[1, "Event"]
152
 
153
 
154
+ def test_calendar_action_redirects_to_auth_then_microsoft_login(client):
155
+ response = client.get("/calendar/action", params={"event_key": "abc123"}, follow_redirects=False)
156
+
157
+ assert response.status_code == 302
158
+ assert response.headers["location"].endswith("/auth/microsoft/start?event_key=abc123")
159
+
160
+ start_path = response.headers["location"].replace("http://testserver", "")
161
+ auth_response = client.get(start_path, follow_redirects=False)
162
+
163
+ assert auth_response.status_code == 302
164
+ assert auth_response.headers["location"].startswith(
165
+ "https://login.microsoftonline.com/tenant-123/oauth2/v2.0/authorize?"
166
+ )
167
+ assert "scope=openid+profile+email" in auth_response.headers["location"]
168
+ assert client.cookies.get(SESSION_COOKIE)
169
+ assert len(PENDING_STATES) == 1
170
+
171
+
172
+ def test_microsoft_callback_stores_session_and_redirects_to_calendar_event(client, monkeypatch):
173
+ start_response = client.get("/auth/microsoft/start", params={"event_key": "event-123"}, follow_redirects=False)
174
+ state = next(iter(PENDING_STATES))
175
+
176
+ monkeypatch.setattr(
177
+ app,
178
+ "exchange_code_for_user",
179
+ lambda code, redirect_uri: {"email": "alice@example.com", "name": "Alice"},
180
+ )
181
+ monkeypatch.setattr(
182
+ app,
183
+ "ensure_user_on_event",
184
+ lambda current_event_key, user, events_path: f"https://outlook.test/{current_event_key}/{user['email']}",
185
+ )
186
+
187
+ response = client.get(
188
+ f"/auth/microsoft/callback?code=code-123&state={state}",
189
+ follow_redirects=False,
190
+ )
191
+
192
+ assert start_response.status_code == 302
193
+ assert response.status_code == 302
194
+ assert response.headers["location"] == "https://outlook.test/event-123/alice@example.com"
195
+ session_token = client.cookies.get(SESSION_COOKIE)
196
+ assert SESSIONS[session_token] == {"email": "alice@example.com", "name": "Alice"}
197
+
198
+
199
+ def test_ensure_user_on_event_reuses_shared_event_and_stays_idempotent(tmp_path, monkeypatch):
200
  path = tmp_path / "events.json"
201
+ write_payload(sample_payload(), path)
202
+ current_event_key = first_event_key(load_payload(path)["results"])
 
 
 
 
 
 
 
203
 
204
+ attendees: list[dict] = []
205
+ calls: list[str] = []
206
 
207
+ def fake_request(method: str, url: str, *, token: str, body: dict | None = None) -> dict:
208
+ nonlocal attendees
209
+ calls.append(method)
210
+ if method == "POST":
211
+ attendees = list(body["attendees"])
212
+ return {"id": "teams-event-1", "webLink": "https://outlook.test/teams-event-1", "attendees": attendees}
213
+ if method == "GET":
214
+ return {"id": "teams-event-1", "webLink": "https://outlook.test/teams-event-1", "attendees": attendees}
215
+ if method == "PATCH":
216
+ attendees = list(body["attendees"])
217
+ return {"id": "teams-event-1", "webLink": "https://outlook.test/teams-event-1", "attendees": attendees}
218
+ raise AssertionError(method)
219
+
220
+ monkeypatch.setattr("src.teams_calendar.service_access_token", lambda: "service-token")
221
+ monkeypatch.setattr("src.teams_calendar._request_json", fake_request)
222
+
223
+ first_link = ensure_user_on_event(
224
+ current_event_key,
225
+ {"email": "alice@example.com", "name": "Alice"},
226
  events_path=path,
227
  )
228
+ payload = load_payload(path)
229
+ event = payload["results"][0]["events"][0]
230
+ assert first_link == "https://outlook.test/teams-event-1"
231
+ assert event["teams_event_id"] == "teams-event-1"
232
+ assert event["teams_event_web_link"] == "https://outlook.test/teams-event-1"
233
+ assert event["attending_count"] == 1
234
+ assert payload["meta"]["schema_version"] == SCHEMA_VERSION
235
+
236
+ second_link = ensure_user_on_event(
237
+ current_event_key,
238
+ {"email": "bob@example.com", "name": "Bob"},
239
+ events_path=path,
240
+ )
241
+ event = load_payload(path)["results"][0]["events"][0]
242
+ assert second_link == "https://outlook.test/teams-event-1"
243
+ assert event["attending_count"] == 2
244
+
245
+ third_link = ensure_user_on_event(
246
+ current_event_key,
247
+ {"email": "alice@example.com", "name": "Alice"},
248
+ events_path=path,
249
+ )
250
+ event = load_payload(path)["results"][0]["events"][0]
251
+ assert third_link == "https://outlook.test/teams-event-1"
252
+ assert event["attending_count"] == 2
253
+ assert calls == ["POST", "GET", "PATCH", "GET"]
254
+
255
+
256
+ def test_write_payload_preserves_teams_fields(tmp_path):
257
+ path = tmp_path / "events.json"
258
+ payload = sample_payload()
259
+ payload["results"][0]["events"][0]["teams_event_id"] = "teams-event-1"
260
+ payload["results"][0]["events"][0]["teams_event_web_link"] = "https://outlook.test/teams-event-1"
261
+
262
+ write_payload(payload, path)
263
+ reloaded = load_payload(path)
264
 
265
+ assert reloaded["meta"]["schema_version"] == SCHEMA_VERSION
266
+ assert reloaded["results"][0]["events"][0]["teams_event_id"] == "teams-event-1"
267
+ assert reloaded["results"][0]["events"][0]["teams_event_web_link"] == "https://outlook.test/teams-event-1"
 
 
 
268
 
269
 
270
  def test_parse_dt_utc_normalizes_naive_and_offset_datetimes():