A production REST API for Fittable, a scheduling and workforce management platform for university student workers. The system integrates with the university's proprietary LMS (KLAS) via a reverse-engineered auth flow, syncs academic timetables, and generates official Korean-language PDF work documents.
graph TB
subgraph client["Client"]
FE["Next.js\nVercel"]
end
subgraph ec2["AWS EC2"]
direction TB
API["FastAPI\nAsync REST API"]
AUTH["Auth Layer\nKLAS Session + JWT"]
SVC["Service Layer\nshift · holiday · profile · timetable"]
PDF["PDF Engine\nReportLab + NotoSansKR"]
SS["Sheets Service\ngspread"]
end
subgraph storage["Supabase"]
DB[("PostgreSQL\nasyncpg + pgbouncer")]
end
subgraph external["External Services"]
KLAS["KLAS Portal\nUniversity LMS"]
GS["Google Sheets\nSchedule Export"]
GD["Google Drive\nFile Storage"]
SNT["Sentry\nError Tracking"]
end
FE -- "HTTPS / REST" --> API
API --> AUTH
AUTH --> SVC
SVC --> DB
SVC --> PDF
SVC --> SS
AUTH -. "RSA login\nHTML scraping" .-> KLAS
SS --> GS
SS --> GD
API -. "error events" .-> SNT
style ec2 fill:#1a1a2e,stroke:#FF9900,color:#fff
style storage fill:#1a1a2e,stroke:#3FCF8E,color:#fff
style external fill:#1a1a2e,stroke:#888,color:#fff
style client fill:#1a1a2e,stroke:#009688,color:#fff
KLAS has no public API. The login flow was reverse-engineered from the browser — RSA-encrypted credentials, multi-step handshake, and HTML scraping.
sequenceDiagram
autonumber
participant C as Client
participant API as FastAPI
participant KLAS as KLAS Portal
participant DB as PostgreSQL
C->>API: POST /api/auth/login<br/>{student_id, password}
rect rgb(30, 40, 60)
Note over API,KLAS: Step 1 — Establish browser-like session
API->>KLAS: GET /LoginForm
KLAS-->>API: Set-Cookie: SESSION
end
rect rgb(30, 40, 60)
Note over API,KLAS: Step 2 — RSA key exchange
API->>KLAS: POST /LoginSecurity
KLAS-->>API: RSA public key (PEM)
Note over API: Serialize {loginId, loginPwd}<br/>→ JSON → RSA-PKCS1v15 encrypt<br/>→ base64 encode
end
rect rgb(30, 40, 60)
Note over API,KLAS: Step 3 — Lockout check + login
API->>KLAS: POST /LoginCaptcha {encryptedToken}
KLAS-->>API: failCount (abort if > 2)
API->>KLAS: POST /LoginConfirm {encryptedToken}
KLAS-->>API: {errorCount: 0} + authenticated SESSION cookie
end
rect rgb(30, 50, 40)
Note over API,KLAS: Step 4 — Profile scrape
API->>KLAS: GET /Profile page
KLAS-->>API: HTML (name, major, DOB, gender, nationality)
API->>KLAS: GET DID portal (token from iframe)
KLAS-->>API: HTML with base64 profile image
Note over API: Regex-parse all fields
end
API->>DB: UPSERT user (status: pending on first login)
DB-->>API: user record
Note over API: Store KLASService instance<br/>in memory keyed by token_urlsafe()<br/>Issue JWT for DB-backed routes
API-->>C: {token: KLAS session,<br/>access_token: JWT}
%%{init: {'themeVariables': {'fontSize': '11px'}}}%%
flowchart TD
A([Admin]) -->|JWT Bearer| B["POST /api/shifts\nPUT /api/shifts/{id}\nDELETE /api/shifts/{id}"]
B --> C{"Validate\nCurrentUser dep"}
C -->|401| ERR1[Unauthorized]
C -->|approved / admin| D[shift_service]
D --> E[("PostgreSQL")]
E --> F["GET /api/shifts\n?start=&end=&user_id="]
F --> G{Export type?}
G -->|"GET /shifts/export/pdf\n?room=102|103|all"| H["pdf_schedule.py\nReportLab calendar grid\nper-room color highlights"]
H --> I["Download\nwork_schedule_YYYY-MM-DD.pdf"]
G -->|"POST /shifts/export/sheet"| J["schedule_sheet_service\ngspread + service account"]
J --> K["Google Sheets\nCalendar grid format"]
G -->|JSON| L["Frontend\nSchedule UI"]
E --> M["GET /api/shifts/hours-summary\n?month=&year="]
M --> N["Hours per user\n+ work status PDF"]
style ERR1 fill:#5a1a1a,color:#fff
style I fill:#1a3a1a,color:#fff
style K fill:#1a3a2a,color:#fff
%%{init: {'themeVariables': {'fontSize': '11px'}}}%%
graph TD
A([Student]) -->|KLAS session token| B["GET /api/timetable\n?year=&semester="]
B --> C{Session valid?}
C -->|invalid| ERR["401 Expired\nLogin again"]
C -->|valid| E["Resolve KLASService\nfrom in-memory store"]
E --> F["POST KLAS /TimeTable API\n{searchYear, searchHakgi}"]
F --> G["Raw JSON response\nwtSubj_N · wtTime · wtSpan_N"]
G --> H["parse_timetable()\ntime index → HH:MM\nday index → Mon–Sat"]
H --> I["UPSERT TimetableEntry rows\ninto PostgreSQL"]
I --> J[("PostgreSQL\ntimetable_entries")]
J --> K["Shift conflict detection\non scheduling"]
style ERR fill:#5a1a1a,color:#fff
%%{init: {'themeVariables': {'fontSize': '11px'}}}%%
erDiagram
USER {
uuid id PK
string student_id UK
string name
string major
string room_no
string work_category
string role "admin or worker"
string status "pending · approved · active"
}
SHIFT {
uuid id PK
uuid user_id FK
uuid created_by FK
date date
time start_time
time end_time
string note
datetime updated_at
}
TIMETABLE_ENTRY {
uuid id PK
uuid user_id FK
string course_code
string course_title
string day
int day_num "0=Mon … 5=Sat"
time start_time
time end_time
string location
string professor
int year
string semester "1 or 2"
}
HOLIDAY {
uuid id PK
date date UK
string name
string local_name
string type "Public holiday · Observance"
string local_type "공휴일 · 기념일"
string source "google · manual"
}
USER ||--o{ SHIFT : "assigned to"
USER ||--o{ SHIFT : "created by"
USER ||--o{ TIMETABLE_ENTRY : "has"
All routes are prefixed /api:
| Prefix | Description |
|---|---|
/auth |
KLAS login/logout, /me current user |
/timetable |
Proxy to KLAS timetable + university academic calendar |
/shifts |
CRUD, hours summary, PDF export, Google Sheets sync |
/profile |
Profile read/update, room assignment |
/users |
Admin: user listing, approval workflow |
/holidays |
Holiday CRUD (sourced from Google Calendar or manual) |
/work-log |
Korean work log PDF (교비근로 근무일지) |
Interactive docs: /docs (Swagger dark theme) · /redoc
Async end-to-end — FastAPI with async def handlers, SQLAlchemy 2.0 async engine, asyncpg driver. pgbouncer compatibility is handled at the engine level (statement_cache_size=0, query param stripped at startup) and mirrored in Alembic's migration engine.
Dual auth — KLAS session token (in-memory, holds a live requests.Session) for portal-proxying endpoints; JWT (Bearer or httpOnly cookie) for all database-backed routes. Both surface as typed dependency annotations (CurrentUser, CurrentUserFromKlas, AdminUser).
Korean PDF fonts — Font loading resolves: PDF_FONT_PATH env var → locally cached file → auto-download from Google Fonts GitHub (Noto Sans KR, OFL license). Clean EC2 deploys get a working font without committing binary files.
Admin approval gate — Users are created on first KLAS login with status=pending. Shifts can only be assigned to approved/active workers.
uv venv && source .venv/bin/activate
uv pip install -r requirements.txt
cp .env.example .env # fill in DATABASE_URL, JWT_SECRET, KLAS_* URLs
alembic upgrade head
uvicorn app.main:app --reloadDATABASE_URL uses the Supabase connection string with the postgresql+asyncpg:// scheme (Session mode pooler URL for pgbouncer compatibility).
| Variable | Description |
|---|---|
DATABASE_URL |
postgresql+asyncpg://user:pass@host/db |
JWT_SECRET |
JWT signing secret |
KLAS_* |
University portal endpoint URLs (7 vars) |
ADMIN_STUDENT_ID |
Student ID granted admin role on first login |
GOOGLE_SERVICE_ACCOUNT_FILE |
Path to GCP service account JSON |
PDF_FONT_PATH |
NotoSansKR-Regular.ttf path (optional, auto-downloaded) |
SENTRY_DSN |
Sentry DSN (optional) |
Health check: GET /health — verifies DB connectivity.