Spaces:
Sleeping
Sleeping
Added team add to calendar button
Browse files- .codex/caveman.all.md +259 -0
- README.md +12 -2
- app.py +69 -25
- assets/prompt.txt +7 -6
- src/attending.py +15 -43
- src/models.py +8 -0
- src/teams_calendar.py +254 -0
- 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
|
| 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
|
| 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,
|
| 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 |
-
.
|
| 229 |
display: inline-flex;
|
| 230 |
align-items: center;
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
| 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 |
-
.
|
| 244 |
display: inline-flex;
|
| 245 |
align-items: center;
|
| 246 |
justify-content: center;
|
| 247 |
-
width: 1.
|
| 248 |
-
height: 1.
|
|
|
|
| 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
|
| 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
|
| 392 |
-
return render_spotlight(results, query), table
|
| 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
|
| 401 |
sources = "\n".join(f"- [{result['org_name']}]({result['source_url']})" for result in results)
|
| 402 |
-
return render_hero(payload, results), spotlight, table, results,
|
| 403 |
|
| 404 |
|
| 405 |
-
def
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
|
|
|
| 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,
|
| 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,
|
| 435 |
-
search.input(build_view, inputs=[results_state, search], outputs=[spotlight, table
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 `
|
| 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 |
-
|
| 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
|
| 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
|
| 6 |
|
| 7 |
import pandas as pd
|
| 8 |
|
| 9 |
-
from .
|
| 10 |
-
from .
|
| 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", "
|
| 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
|
| 51 |
-
return
|
|
|
|
|
|
|
|
|
|
| 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 = "") ->
|
| 79 |
rows: list[dict[str, object]] = []
|
| 80 |
-
for
|
| 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 |
-
"
|
| 100 |
-
"_result": result_index,
|
| 101 |
-
"_event": event_index,
|
| 102 |
}
|
| 103 |
)
|
| 104 |
|
| 105 |
if not rows:
|
| 106 |
-
return pd.DataFrame(columns=COLS)
|
| 107 |
-
|
| 108 |
-
|
| 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 |
-
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
from src.
|
|
|
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
def sample_results() -> list[dict]:
|
|
@@ -92,8 +95,42 @@ def searchable_results() -> list[dict]:
|
|
| 92 |
]
|
| 93 |
|
| 94 |
|
| 95 |
-
def
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
path = tmp_path / "events.json"
|
| 116 |
-
|
| 117 |
-
|
| 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 |
-
|
| 127 |
-
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
events_path=path,
|
| 136 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
-
assert
|
| 139 |
-
assert
|
| 140 |
-
assert "
|
| 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():
|