dimicheck-tui 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dimicheck_tui-0.1.0/PKG-INFO +106 -0
- dimicheck_tui-0.1.0/README.md +82 -0
- dimicheck_tui-0.1.0/dimicheck_tui/__init__.py +5 -0
- dimicheck_tui-0.1.0/dimicheck_tui/api.py +92 -0
- dimicheck_tui-0.1.0/dimicheck_tui/app.py +193 -0
- dimicheck_tui-0.1.0/dimicheck_tui/auth.py +158 -0
- dimicheck_tui-0.1.0/dimicheck_tui/cli.py +99 -0
- dimicheck_tui-0.1.0/dimicheck_tui/config.py +26 -0
- dimicheck_tui-0.1.0/dimicheck_tui/token_store.py +158 -0
- dimicheck_tui-0.1.0/dimicheck_tui.egg-info/PKG-INFO +106 -0
- dimicheck_tui-0.1.0/dimicheck_tui.egg-info/SOURCES.txt +16 -0
- dimicheck_tui-0.1.0/dimicheck_tui.egg-info/dependency_links.txt +1 -0
- dimicheck_tui-0.1.0/dimicheck_tui.egg-info/entry_points.txt +2 -0
- dimicheck_tui-0.1.0/dimicheck_tui.egg-info/requires.txt +6 -0
- dimicheck_tui-0.1.0/dimicheck_tui.egg-info/top_level.txt +1 -0
- dimicheck_tui-0.1.0/pyproject.toml +47 -0
- dimicheck_tui-0.1.0/setup.cfg +4 -0
- dimicheck_tui-0.1.0/tests/test_dimicheck_tui.py +205 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dimicheck-tui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DimiCheck terminal dashboard
|
|
5
|
+
Author: DimiCheck
|
|
6
|
+
Project-URL: Homepage, https://dimicheck.com
|
|
7
|
+
Project-URL: Repository, https://github.com/hjun1052/dimicheck4
|
|
8
|
+
Keywords: dimicheck,tui,textual,oauth,school
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Education
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: keyring>=25.0.0
|
|
20
|
+
Requires-Dist: requests==2.31.0
|
|
21
|
+
Requires-Dist: textual>=0.89.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# DimiCheck TUI
|
|
26
|
+
|
|
27
|
+
DimiCheck TUI is a terminal dashboard for DimiCheck students. It uses the public OAuth PKCE flow and calls only the student app API surface for v1.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Use `pipx` for the public install path:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pipx install dimicheck-tui
|
|
35
|
+
dimicheck
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For local development from this checkout:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cd packages/dimicheck-tui
|
|
42
|
+
uv run dimicheck --help
|
|
43
|
+
uv run dimicheck
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Set `DIMICHECK_BASE_URL` when testing against a local or staging server:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cd packages/dimicheck-tui
|
|
50
|
+
DIMICHECK_BASE_URL=http://127.0.0.1:5000 uv run dimicheck
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Authentication
|
|
54
|
+
|
|
55
|
+
The TUI never asks for a password in the terminal. On first launch it opens the browser, signs in through DimiCheck OAuth, and stores tokens through the OS credential store.
|
|
56
|
+
|
|
57
|
+
- macOS: Keychain
|
|
58
|
+
- Windows: Credential Manager
|
|
59
|
+
- Linux: Secret Service compatible keyring
|
|
60
|
+
|
|
61
|
+
If the OS keyring is unavailable, the app fails closed by default. For controlled environments you can explicitly allow a local token file:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
dimicheck --allow-plaintext-token
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
That fallback writes a `0600` permission file. Do not use it on shared machines.
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
dimicheck # open the TUI dashboard
|
|
73
|
+
dimicheck login # browser OAuth login
|
|
74
|
+
dimicheck logout # revoke refresh token and delete local tokens
|
|
75
|
+
dimicheck status get # print current status
|
|
76
|
+
dimicheck status set toilet
|
|
77
|
+
dimicheck status set etc --reason "상담"
|
|
78
|
+
dimicheck schoollife # print today's timetable and meal
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
TUI shortcuts:
|
|
82
|
+
|
|
83
|
+
- `S`: change status
|
|
84
|
+
- `R`: refresh dashboard
|
|
85
|
+
- `L`: logout
|
|
86
|
+
- `Q`: quit
|
|
87
|
+
|
|
88
|
+
## v1 Scope
|
|
89
|
+
|
|
90
|
+
The public v1 supports:
|
|
91
|
+
|
|
92
|
+
- current student status read/write
|
|
93
|
+
- today's schoollife summary
|
|
94
|
+
- OAuth PKCE login/logout
|
|
95
|
+
|
|
96
|
+
It intentionally does not expose routine editing, teacher/admin tools, browser session cookies, remember tokens, or `/api/mcp/state`.
|
|
97
|
+
|
|
98
|
+
## Server Notes
|
|
99
|
+
|
|
100
|
+
The server seeds a dedicated public OAuth client:
|
|
101
|
+
|
|
102
|
+
- client id: `dimicheck-tui-public`
|
|
103
|
+
- redirect URI ports: `45831` through `45835`
|
|
104
|
+
- scopes: `openid basic student_info status.read status.write`
|
|
105
|
+
|
|
106
|
+
Server deployments should happen from the repository root. This package is intentionally separate so the public TUI release does not include Flask, database, or Google Sheets dependencies.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# DimiCheck TUI
|
|
2
|
+
|
|
3
|
+
DimiCheck TUI is a terminal dashboard for DimiCheck students. It uses the public OAuth PKCE flow and calls only the student app API surface for v1.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Use `pipx` for the public install path:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install dimicheck-tui
|
|
11
|
+
dimicheck
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
For local development from this checkout:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd packages/dimicheck-tui
|
|
18
|
+
uv run dimicheck --help
|
|
19
|
+
uv run dimicheck
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Set `DIMICHECK_BASE_URL` when testing against a local or staging server:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
cd packages/dimicheck-tui
|
|
26
|
+
DIMICHECK_BASE_URL=http://127.0.0.1:5000 uv run dimicheck
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Authentication
|
|
30
|
+
|
|
31
|
+
The TUI never asks for a password in the terminal. On first launch it opens the browser, signs in through DimiCheck OAuth, and stores tokens through the OS credential store.
|
|
32
|
+
|
|
33
|
+
- macOS: Keychain
|
|
34
|
+
- Windows: Credential Manager
|
|
35
|
+
- Linux: Secret Service compatible keyring
|
|
36
|
+
|
|
37
|
+
If the OS keyring is unavailable, the app fails closed by default. For controlled environments you can explicitly allow a local token file:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
dimicheck --allow-plaintext-token
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That fallback writes a `0600` permission file. Do not use it on shared machines.
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
dimicheck # open the TUI dashboard
|
|
49
|
+
dimicheck login # browser OAuth login
|
|
50
|
+
dimicheck logout # revoke refresh token and delete local tokens
|
|
51
|
+
dimicheck status get # print current status
|
|
52
|
+
dimicheck status set toilet
|
|
53
|
+
dimicheck status set etc --reason "상담"
|
|
54
|
+
dimicheck schoollife # print today's timetable and meal
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
TUI shortcuts:
|
|
58
|
+
|
|
59
|
+
- `S`: change status
|
|
60
|
+
- `R`: refresh dashboard
|
|
61
|
+
- `L`: logout
|
|
62
|
+
- `Q`: quit
|
|
63
|
+
|
|
64
|
+
## v1 Scope
|
|
65
|
+
|
|
66
|
+
The public v1 supports:
|
|
67
|
+
|
|
68
|
+
- current student status read/write
|
|
69
|
+
- today's schoollife summary
|
|
70
|
+
- OAuth PKCE login/logout
|
|
71
|
+
|
|
72
|
+
It intentionally does not expose routine editing, teacher/admin tools, browser session cookies, remember tokens, or `/api/mcp/state`.
|
|
73
|
+
|
|
74
|
+
## Server Notes
|
|
75
|
+
|
|
76
|
+
The server seeds a dedicated public OAuth client:
|
|
77
|
+
|
|
78
|
+
- client id: `dimicheck-tui-public`
|
|
79
|
+
- redirect URI ports: `45831` through `45835`
|
|
80
|
+
- scopes: `openid basic student_info status.read status.write`
|
|
81
|
+
|
|
82
|
+
Server deployments should happen from the repository root. This package is intentionally separate so the public TUI release does not include Flask, database, or Google Sheets dependencies.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .auth import AuthError, OAuthClient
|
|
9
|
+
from .config import TuiConfig
|
|
10
|
+
from .token_store import TokenSet, TokenStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DimiCheckAPI:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
config: TuiConfig,
|
|
21
|
+
store: TokenStore,
|
|
22
|
+
*,
|
|
23
|
+
auth_client: OAuthClient | None = None,
|
|
24
|
+
session: requests.Session | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.config = config
|
|
27
|
+
self.store = store
|
|
28
|
+
self.session = session or requests.Session()
|
|
29
|
+
self.auth_client = auth_client or OAuthClient(config, session=self.session)
|
|
30
|
+
|
|
31
|
+
def ensure_tokens(self) -> TokenSet:
|
|
32
|
+
tokens = self.store.load()
|
|
33
|
+
if tokens is None:
|
|
34
|
+
self.store.ensure_can_save()
|
|
35
|
+
tokens = self.auth_client.login()
|
|
36
|
+
self.store.save(tokens)
|
|
37
|
+
return tokens
|
|
38
|
+
if tokens.expires_at <= time.time():
|
|
39
|
+
tokens = self.auth_client.refresh(tokens.refresh_token)
|
|
40
|
+
self.store.save(tokens)
|
|
41
|
+
return tokens
|
|
42
|
+
|
|
43
|
+
def logout(self) -> None:
|
|
44
|
+
tokens = self.store.load()
|
|
45
|
+
if tokens:
|
|
46
|
+
self.auth_client.revoke(tokens.refresh_token)
|
|
47
|
+
self.store.delete()
|
|
48
|
+
|
|
49
|
+
def get_status(self) -> dict[str, Any]:
|
|
50
|
+
return self._request_json("GET", "/api/app/status/me")
|
|
51
|
+
|
|
52
|
+
def set_status(self, status_code: str, *, reason: str | None = None) -> dict[str, Any]:
|
|
53
|
+
payload: dict[str, Any] = {"statusCode": status_code}
|
|
54
|
+
if reason:
|
|
55
|
+
payload["reason"] = reason
|
|
56
|
+
return self._request_json("PUT", "/api/app/status", json=payload)
|
|
57
|
+
|
|
58
|
+
def get_schoollife(self) -> dict[str, Any]:
|
|
59
|
+
return self._request_json("GET", "/api/app/schoollife/today")
|
|
60
|
+
|
|
61
|
+
def _request_json(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
62
|
+
tokens = self.ensure_tokens()
|
|
63
|
+
response = self.session.request(
|
|
64
|
+
method,
|
|
65
|
+
f"{self.config.base_url}{path}",
|
|
66
|
+
headers={"Authorization": f"Bearer {tokens.access_token}"},
|
|
67
|
+
timeout=15,
|
|
68
|
+
**kwargs,
|
|
69
|
+
)
|
|
70
|
+
if response.status_code == 401 and tokens.refresh_token:
|
|
71
|
+
try:
|
|
72
|
+
tokens = self.auth_client.refresh(tokens.refresh_token)
|
|
73
|
+
except AuthError as exc:
|
|
74
|
+
self.store.delete()
|
|
75
|
+
raise ApiError("로그인이 만료되었습니다. 다시 로그인해 주세요.") from exc
|
|
76
|
+
self.store.save(tokens)
|
|
77
|
+
response = self.session.request(
|
|
78
|
+
method,
|
|
79
|
+
f"{self.config.base_url}{path}",
|
|
80
|
+
headers={"Authorization": f"Bearer {tokens.access_token}"},
|
|
81
|
+
timeout=15,
|
|
82
|
+
**kwargs,
|
|
83
|
+
)
|
|
84
|
+
if response.status_code >= 400:
|
|
85
|
+
message = response.text
|
|
86
|
+
try:
|
|
87
|
+
payload = response.json()
|
|
88
|
+
message = payload.get("error") or payload.get("message") or message
|
|
89
|
+
except ValueError:
|
|
90
|
+
pass
|
|
91
|
+
raise ApiError(f"HTTP {response.status_code}: {message}")
|
|
92
|
+
return response.json()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Button, Footer, Header, Input, Label, RadioButton, RadioSet, Static
|
|
10
|
+
|
|
11
|
+
from .api import ApiError, DimiCheckAPI
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
STATUS_CHOICES = [
|
|
15
|
+
("section", "교실"),
|
|
16
|
+
("toilet", "화장실(물)"),
|
|
17
|
+
("hallway", "복도"),
|
|
18
|
+
("club", "동아리"),
|
|
19
|
+
("afterschool", "방과후"),
|
|
20
|
+
("project", "프로젝트"),
|
|
21
|
+
("early", "조기입실"),
|
|
22
|
+
("etc", "기타"),
|
|
23
|
+
("absence", "결석(조퇴)"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StatusModal(ModalScreen[tuple[str, str | None] | None]):
|
|
28
|
+
CSS = """
|
|
29
|
+
StatusModal {
|
|
30
|
+
align: center middle;
|
|
31
|
+
}
|
|
32
|
+
#status-dialog {
|
|
33
|
+
width: 56;
|
|
34
|
+
padding: 1 2;
|
|
35
|
+
border: thick $accent;
|
|
36
|
+
background: $surface;
|
|
37
|
+
}
|
|
38
|
+
#reason-input {
|
|
39
|
+
margin-top: 1;
|
|
40
|
+
}
|
|
41
|
+
#status-actions {
|
|
42
|
+
margin-top: 1;
|
|
43
|
+
height: auto;
|
|
44
|
+
}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
with Vertical(id="status-dialog"):
|
|
49
|
+
yield Label("상태 변경")
|
|
50
|
+
with RadioSet(id="status-radio"):
|
|
51
|
+
for code, label in STATUS_CHOICES:
|
|
52
|
+
yield RadioButton(label, id=f"status-{code}")
|
|
53
|
+
yield Input(placeholder="기타 사유", id="reason-input")
|
|
54
|
+
with Horizontal(id="status-actions"):
|
|
55
|
+
yield Button("적용", id="apply", variant="primary")
|
|
56
|
+
yield Button("취소", id="cancel")
|
|
57
|
+
|
|
58
|
+
def on_mount(self) -> None:
|
|
59
|
+
self.query_one("#status-section", RadioButton).value = True
|
|
60
|
+
|
|
61
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
62
|
+
if event.button.id == "cancel":
|
|
63
|
+
self.dismiss(None)
|
|
64
|
+
return
|
|
65
|
+
selected = self.query_one(RadioSet).pressed_button
|
|
66
|
+
status_code = "section"
|
|
67
|
+
if selected and selected.id:
|
|
68
|
+
status_code = selected.id.removeprefix("status-")
|
|
69
|
+
reason = self.query_one("#reason-input", Input).value.strip() or None
|
|
70
|
+
self.dismiss((status_code, reason))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DashboardApp(App[None]):
|
|
74
|
+
TITLE = "DimiCheck"
|
|
75
|
+
BINDINGS = [
|
|
76
|
+
("s", "change_status", "상태 변경"),
|
|
77
|
+
("r", "refresh", "새로고침"),
|
|
78
|
+
("l", "logout", "로그아웃"),
|
|
79
|
+
("q", "quit", "종료"),
|
|
80
|
+
]
|
|
81
|
+
CSS = """
|
|
82
|
+
Screen {
|
|
83
|
+
background: #08111f;
|
|
84
|
+
}
|
|
85
|
+
#page {
|
|
86
|
+
padding: 1 2;
|
|
87
|
+
}
|
|
88
|
+
.card {
|
|
89
|
+
border: round #5aa9ff;
|
|
90
|
+
padding: 1 2;
|
|
91
|
+
margin-bottom: 1;
|
|
92
|
+
background: #0f1f35;
|
|
93
|
+
}
|
|
94
|
+
#hero {
|
|
95
|
+
border: tall #77d970;
|
|
96
|
+
}
|
|
97
|
+
#status-label {
|
|
98
|
+
text-style: bold;
|
|
99
|
+
color: #77d970;
|
|
100
|
+
}
|
|
101
|
+
.muted {
|
|
102
|
+
color: #91a4bd;
|
|
103
|
+
}
|
|
104
|
+
#error {
|
|
105
|
+
color: #ff7b7b;
|
|
106
|
+
}
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, api: DimiCheckAPI) -> None:
|
|
110
|
+
super().__init__()
|
|
111
|
+
self.api = api
|
|
112
|
+
|
|
113
|
+
def compose(self) -> ComposeResult:
|
|
114
|
+
yield Header()
|
|
115
|
+
with Container(id="page"):
|
|
116
|
+
yield Static("불러오는 중...", id="hero", classes="card")
|
|
117
|
+
yield Static("", id="status-card", classes="card")
|
|
118
|
+
yield Static("", id="schoollife-card", classes="card")
|
|
119
|
+
yield Static("", id="error")
|
|
120
|
+
yield Footer()
|
|
121
|
+
|
|
122
|
+
def on_mount(self) -> None:
|
|
123
|
+
self.refresh_dashboard()
|
|
124
|
+
|
|
125
|
+
def action_refresh(self) -> None:
|
|
126
|
+
self.refresh_dashboard()
|
|
127
|
+
|
|
128
|
+
def action_change_status(self) -> None:
|
|
129
|
+
def apply_status(result: tuple[str, str | None] | None) -> None:
|
|
130
|
+
if result is None:
|
|
131
|
+
return
|
|
132
|
+
code, reason = result
|
|
133
|
+
try:
|
|
134
|
+
self.api.set_status(code, reason=reason)
|
|
135
|
+
self.refresh_dashboard()
|
|
136
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
137
|
+
self._show_error(str(exc))
|
|
138
|
+
|
|
139
|
+
self.push_screen(StatusModal(), apply_status)
|
|
140
|
+
|
|
141
|
+
def action_logout(self) -> None:
|
|
142
|
+
try:
|
|
143
|
+
self.api.logout()
|
|
144
|
+
finally:
|
|
145
|
+
self.exit()
|
|
146
|
+
|
|
147
|
+
def refresh_dashboard(self) -> None:
|
|
148
|
+
try:
|
|
149
|
+
status = self.api.get_status()
|
|
150
|
+
schoollife = self.api.get_schoollife()
|
|
151
|
+
except (ApiError, Exception) as exc: # pylint: disable=broad-except
|
|
152
|
+
self._show_error(str(exc))
|
|
153
|
+
return
|
|
154
|
+
self._show_error("")
|
|
155
|
+
self.query_one("#hero", Static).update(self._render_hero(status))
|
|
156
|
+
self.query_one("#status-card", Static).update(self._render_status(status))
|
|
157
|
+
self.query_one("#schoollife-card", Static).update(self._render_schoollife(schoollife))
|
|
158
|
+
|
|
159
|
+
def _render_hero(self, status: dict[str, Any]) -> str:
|
|
160
|
+
today = datetime.now().strftime("%Y년 %m월 %d일")
|
|
161
|
+
identity = f"{status.get('grade')}학년 {status.get('section')}반 {status.get('number')}번"
|
|
162
|
+
return f"DimiCheck\n{today} · {identity}"
|
|
163
|
+
|
|
164
|
+
def _render_status(self, status: dict[str, Any]) -> str:
|
|
165
|
+
label = status.get("statusLabel") or status.get("statusCode") or "-"
|
|
166
|
+
reason = status.get("reason")
|
|
167
|
+
favorite = status.get("favoriteStatus") or "없음"
|
|
168
|
+
lines = ["지금 상태", f"[b green]{label}[/]"]
|
|
169
|
+
if reason:
|
|
170
|
+
lines.append(f"사유 {reason}")
|
|
171
|
+
lines.append(f"즐겨찾기 {favorite}")
|
|
172
|
+
return "\n".join(lines)
|
|
173
|
+
|
|
174
|
+
def _render_schoollife(self, payload: dict[str, Any]) -> str:
|
|
175
|
+
timetable = payload.get("timetable") or {}
|
|
176
|
+
meal = payload.get("meal") or {}
|
|
177
|
+
lessons = timetable.get("lessons") or []
|
|
178
|
+
lines = ["오늘 학교생활"]
|
|
179
|
+
if lessons:
|
|
180
|
+
for lesson in lessons[:8]:
|
|
181
|
+
period = lesson.get("period") or lesson.get("periodName") or "-"
|
|
182
|
+
subject = lesson.get("subject") or lesson.get("name") or "-"
|
|
183
|
+
lines.append(f"{period}교시 {subject}")
|
|
184
|
+
else:
|
|
185
|
+
lines.append(str(timetable.get("message") or "시간표 정보가 없습니다."))
|
|
186
|
+
lunch = meal.get("lunch") or "급식 정보가 없습니다."
|
|
187
|
+
lines.append("")
|
|
188
|
+
lines.append("급식")
|
|
189
|
+
lines.append(str(lunch).replace("\n", " · "))
|
|
190
|
+
return "\n".join(lines)
|
|
191
|
+
|
|
192
|
+
def _show_error(self, message: str) -> None:
|
|
193
|
+
self.query_one("#error", Static).update(message)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from .config import CALLBACK_PATH, TuiConfig
|
|
17
|
+
from .token_store import TokenSet
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthError(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class CallbackResult:
|
|
26
|
+
code: str
|
|
27
|
+
state: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_pkce_pair() -> tuple[str, str]:
|
|
31
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode("ascii")
|
|
32
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
33
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
34
|
+
return verifier, challenge
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _make_callback_handler(result: dict[str, Any]):
|
|
38
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
39
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib callback name
|
|
40
|
+
parsed = urlparse(self.path)
|
|
41
|
+
if parsed.path != CALLBACK_PATH:
|
|
42
|
+
self.send_response(404)
|
|
43
|
+
self.end_headers()
|
|
44
|
+
return
|
|
45
|
+
params = parse_qs(parsed.query)
|
|
46
|
+
error = params.get("error", [""])[0]
|
|
47
|
+
if error:
|
|
48
|
+
result["error"] = error
|
|
49
|
+
else:
|
|
50
|
+
result["code"] = params.get("code", [""])[0]
|
|
51
|
+
result["state"] = params.get("state", [""])[0]
|
|
52
|
+
self.send_response(200)
|
|
53
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
54
|
+
self.end_headers()
|
|
55
|
+
self.wfile.write(
|
|
56
|
+
"<!doctype html><meta charset='utf-8'><title>DimiCheck</title>"
|
|
57
|
+
"<body>로그인이 완료되었습니다. 이 창을 닫고 터미널로 돌아가세요.</body>".encode("utf-8")
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def log_message(self, _format: str, *_args: Any) -> None:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
return CallbackHandler
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class OAuthClient:
|
|
67
|
+
def __init__(self, config: TuiConfig, *, session: requests.Session | None = None) -> None:
|
|
68
|
+
self.config = config
|
|
69
|
+
self.session = session or requests.Session()
|
|
70
|
+
|
|
71
|
+
def login(self, *, open_browser: bool = True, timeout_seconds: int = 180) -> TokenSet:
|
|
72
|
+
server, port, result = self._start_callback_server()
|
|
73
|
+
verifier, challenge = build_pkce_pair()
|
|
74
|
+
state = secrets.token_urlsafe(32)
|
|
75
|
+
authorize_url = self._authorize_url(port=port, state=state, challenge=challenge)
|
|
76
|
+
|
|
77
|
+
thread = threading.Thread(target=server.handle_request, daemon=True)
|
|
78
|
+
thread.start()
|
|
79
|
+
try:
|
|
80
|
+
if open_browser and not webbrowser.open(authorize_url):
|
|
81
|
+
raise AuthError(f"브라우저를 열 수 없습니다. 다음 주소를 직접 여세요: {authorize_url}")
|
|
82
|
+
deadline = time.monotonic() + timeout_seconds
|
|
83
|
+
while thread.is_alive() and time.monotonic() < deadline:
|
|
84
|
+
thread.join(timeout=0.1)
|
|
85
|
+
if thread.is_alive():
|
|
86
|
+
raise AuthError("브라우저 로그인 대기 시간이 초과되었습니다.")
|
|
87
|
+
if result.get("error"):
|
|
88
|
+
raise AuthError(f"OAuth error: {result['error']}")
|
|
89
|
+
callback = CallbackResult(code=str(result.get("code") or ""), state=str(result.get("state") or ""))
|
|
90
|
+
if not callback.code:
|
|
91
|
+
raise AuthError("OAuth callback did not include an authorization code")
|
|
92
|
+
if callback.state != state:
|
|
93
|
+
raise AuthError("OAuth state mismatch")
|
|
94
|
+
return self.exchange_code(code=callback.code, verifier=verifier, redirect_uri=self.config.redirect_uri(port))
|
|
95
|
+
finally:
|
|
96
|
+
server.server_close()
|
|
97
|
+
|
|
98
|
+
def exchange_code(self, *, code: str, verifier: str, redirect_uri: str) -> TokenSet:
|
|
99
|
+
response = self.session.post(
|
|
100
|
+
f"{self.config.base_url}/oauth/token",
|
|
101
|
+
data={
|
|
102
|
+
"grant_type": "authorization_code",
|
|
103
|
+
"client_id": self.config.client_id,
|
|
104
|
+
"code": code,
|
|
105
|
+
"redirect_uri": redirect_uri,
|
|
106
|
+
"code_verifier": verifier,
|
|
107
|
+
},
|
|
108
|
+
timeout=15,
|
|
109
|
+
)
|
|
110
|
+
if response.status_code != 200:
|
|
111
|
+
raise AuthError(f"토큰 교환 실패: HTTP {response.status_code}")
|
|
112
|
+
return TokenSet.from_oauth_response(response.json(), now=time.time())
|
|
113
|
+
|
|
114
|
+
def refresh(self, refresh_token: str) -> TokenSet:
|
|
115
|
+
response = self.session.post(
|
|
116
|
+
f"{self.config.base_url}/oauth/token",
|
|
117
|
+
data={
|
|
118
|
+
"grant_type": "refresh_token",
|
|
119
|
+
"client_id": self.config.client_id,
|
|
120
|
+
"refresh_token": refresh_token,
|
|
121
|
+
},
|
|
122
|
+
timeout=15,
|
|
123
|
+
)
|
|
124
|
+
if response.status_code != 200:
|
|
125
|
+
raise AuthError(f"토큰 갱신 실패: HTTP {response.status_code}")
|
|
126
|
+
return TokenSet.from_oauth_response(response.json(), now=time.time())
|
|
127
|
+
|
|
128
|
+
def revoke(self, token: str) -> None:
|
|
129
|
+
self.session.post(
|
|
130
|
+
f"{self.config.base_url}/oauth/revoke",
|
|
131
|
+
data={"client_id": self.config.client_id, "token": token},
|
|
132
|
+
timeout=15,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def _authorize_url(self, *, port: int, state: str, challenge: str) -> str:
|
|
136
|
+
query = urlencode(
|
|
137
|
+
{
|
|
138
|
+
"response_type": "code",
|
|
139
|
+
"client_id": self.config.client_id,
|
|
140
|
+
"redirect_uri": self.config.redirect_uri(port),
|
|
141
|
+
"scope": self.config.scopes,
|
|
142
|
+
"state": state,
|
|
143
|
+
"code_challenge": challenge,
|
|
144
|
+
"code_challenge_method": "S256",
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
return f"{self.config.base_url}/oauth/authorize?{query}"
|
|
148
|
+
|
|
149
|
+
def _start_callback_server(self) -> tuple[HTTPServer, int, dict[str, Any]]:
|
|
150
|
+
result: dict[str, Any] = {}
|
|
151
|
+
handler = _make_callback_handler(result)
|
|
152
|
+
last_error: OSError | None = None
|
|
153
|
+
for port in self.config.redirect_ports:
|
|
154
|
+
try:
|
|
155
|
+
return HTTPServer(("127.0.0.1", port), handler), port, result
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
last_error = exc
|
|
158
|
+
raise AuthError("사용 가능한 OAuth callback 포트를 찾지 못했습니다.") from last_error
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .api import ApiError, DimiCheckAPI
|
|
9
|
+
from .config import TuiConfig
|
|
10
|
+
from .token_store import TokenStore, TokenStorageError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_api(*, allow_plaintext_token: bool) -> DimiCheckAPI:
|
|
14
|
+
config = TuiConfig.from_env()
|
|
15
|
+
store = TokenStore(account=config.base_url, allow_plaintext_file=allow_plaintext_token)
|
|
16
|
+
return DimiCheckAPI(config, store)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
parser = argparse.ArgumentParser(prog="dimicheck", description="DimiCheck terminal dashboard")
|
|
21
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
22
|
+
parser.add_argument("--allow-plaintext-token", action="store_true", help="OS keyring 실패 시 권한 제한 파일 저장 허용")
|
|
23
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
24
|
+
|
|
25
|
+
subparsers.add_parser("login", help="브라우저 OAuth 로그인")
|
|
26
|
+
subparsers.add_parser("logout", help="토큰 폐기 후 로그아웃")
|
|
27
|
+
subparsers.add_parser("schoollife", help="오늘 학교생활 출력")
|
|
28
|
+
status_parser = subparsers.add_parser("status", help="내 상태 조회/변경")
|
|
29
|
+
status_subparsers = status_parser.add_subparsers(dest="status_command")
|
|
30
|
+
status_subparsers.add_parser("get", help="현재 상태 조회")
|
|
31
|
+
set_parser = status_subparsers.add_parser("set", help="상태 변경")
|
|
32
|
+
set_parser.add_argument("status_code")
|
|
33
|
+
set_parser.add_argument("--reason")
|
|
34
|
+
|
|
35
|
+
args = parser.parse_args(argv)
|
|
36
|
+
api = build_api(allow_plaintext_token=args.allow_plaintext_token)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
if args.command == "login":
|
|
40
|
+
api.ensure_tokens()
|
|
41
|
+
print("로그인 완료")
|
|
42
|
+
return 0
|
|
43
|
+
if args.command == "logout":
|
|
44
|
+
api.logout()
|
|
45
|
+
print("로그아웃 완료")
|
|
46
|
+
return 0
|
|
47
|
+
if args.command == "schoollife":
|
|
48
|
+
print(format_schoollife(api.get_schoollife()))
|
|
49
|
+
return 0
|
|
50
|
+
if args.command == "status":
|
|
51
|
+
if args.status_command == "set":
|
|
52
|
+
payload = api.set_status(args.status_code, reason=args.reason)
|
|
53
|
+
print(format_status(payload.get("status") or payload))
|
|
54
|
+
return 0
|
|
55
|
+
print(format_status(api.get_status()))
|
|
56
|
+
return 0
|
|
57
|
+
from .app import DashboardApp
|
|
58
|
+
|
|
59
|
+
DashboardApp(api).run()
|
|
60
|
+
return 0
|
|
61
|
+
except (ApiError, TokenStorageError, Exception) as exc: # pylint: disable=broad-except
|
|
62
|
+
print(f"dimicheck: {exc}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def format_status(payload: dict) -> str:
|
|
67
|
+
label = payload.get("statusLabel") or payload.get("statusCode") or "-"
|
|
68
|
+
reason = payload.get("reason")
|
|
69
|
+
identity = f"{payload.get('grade')}학년 {payload.get('section')}반 {payload.get('number')}번"
|
|
70
|
+
lines = [f"{identity}", f"상태: {label}"]
|
|
71
|
+
if reason:
|
|
72
|
+
lines.append(f"사유: {reason}")
|
|
73
|
+
return "\n".join(lines)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def format_schoollife(payload: dict) -> str:
|
|
77
|
+
timetable = payload.get("timetable") or {}
|
|
78
|
+
meal = payload.get("meal") or {}
|
|
79
|
+
lines = [f"오늘 학교생활 ({payload.get('date') or '-'})"]
|
|
80
|
+
lessons = timetable.get("lessons") or []
|
|
81
|
+
if lessons:
|
|
82
|
+
for lesson in lessons:
|
|
83
|
+
period = lesson.get("period") or lesson.get("periodName") or "-"
|
|
84
|
+
subject = lesson.get("subject") or lesson.get("name") or "-"
|
|
85
|
+
lines.append(f"{period}교시 {subject}")
|
|
86
|
+
else:
|
|
87
|
+
lines.append(str(timetable.get("message") or "시간표 정보가 없습니다."))
|
|
88
|
+
lines.append("")
|
|
89
|
+
lines.append("급식")
|
|
90
|
+
lines.append(str(meal.get("lunch") or "급식 정보가 없습니다.").replace("\n", " · "))
|
|
91
|
+
errors = payload.get("errors") or {}
|
|
92
|
+
if errors:
|
|
93
|
+
lines.append("")
|
|
94
|
+
lines.append(f"일부 정보를 불러오지 못했습니다: {json.dumps(errors, ensure_ascii=False)}")
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
CLIENT_ID = "dimicheck-tui-public"
|
|
8
|
+
SCOPES = "openid basic student_info status.read status.write"
|
|
9
|
+
REDIRECT_PORTS = (45831, 45832, 45833, 45834, 45835)
|
|
10
|
+
CALLBACK_PATH = "/oauth-callback"
|
|
11
|
+
DEFAULT_BASE_URL = "https://dimicheck.com"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class TuiConfig:
|
|
16
|
+
base_url: str
|
|
17
|
+
client_id: str = CLIENT_ID
|
|
18
|
+
scopes: str = SCOPES
|
|
19
|
+
redirect_ports: tuple[int, ...] = REDIRECT_PORTS
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_env(cls) -> "TuiConfig":
|
|
23
|
+
return cls(base_url=os.getenv("DIMICHECK_BASE_URL", DEFAULT_BASE_URL).rstrip("/"))
|
|
24
|
+
|
|
25
|
+
def redirect_uri(self, port: int) -> str:
|
|
26
|
+
return f"http://127.0.0.1:{port}{CALLBACK_PATH}"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SERVICE_NAME = "dimicheck-tui"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TokenStorageError(RuntimeError):
|
|
15
|
+
"""Raised when tokens cannot be stored safely."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TokenSet:
|
|
20
|
+
access_token: str
|
|
21
|
+
refresh_token: str
|
|
22
|
+
expires_at: float
|
|
23
|
+
scope: str = ""
|
|
24
|
+
token_type: str = "Bearer"
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_oauth_response(cls, payload: dict[str, Any], *, now: float) -> "TokenSet":
|
|
28
|
+
access_token = str(payload.get("access_token") or "")
|
|
29
|
+
refresh_token = str(payload.get("refresh_token") or "")
|
|
30
|
+
if not access_token or not refresh_token:
|
|
31
|
+
raise ValueError("OAuth response did not include access_token and refresh_token")
|
|
32
|
+
expires_in = int(payload.get("expires_in") or 3600)
|
|
33
|
+
return cls(
|
|
34
|
+
access_token=access_token,
|
|
35
|
+
refresh_token=refresh_token,
|
|
36
|
+
expires_at=now + max(expires_in - 30, 1),
|
|
37
|
+
scope=str(payload.get("scope") or ""),
|
|
38
|
+
token_type=str(payload.get("token_type") or "Bearer"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_json(cls, raw: str) -> "TokenSet":
|
|
43
|
+
payload = json.loads(raw)
|
|
44
|
+
return cls(
|
|
45
|
+
access_token=str(payload["access_token"]),
|
|
46
|
+
refresh_token=str(payload["refresh_token"]),
|
|
47
|
+
expires_at=float(payload["expires_at"]),
|
|
48
|
+
scope=str(payload.get("scope") or ""),
|
|
49
|
+
token_type=str(payload.get("token_type") or "Bearer"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def to_json(self) -> str:
|
|
53
|
+
return json.dumps(asdict(self), ensure_ascii=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def default_token_file() -> Path:
|
|
57
|
+
override = os.getenv("DIMICHECK_TOKEN_FILE")
|
|
58
|
+
if override:
|
|
59
|
+
return Path(override).expanduser()
|
|
60
|
+
if os.name == "nt":
|
|
61
|
+
base = Path(os.getenv("APPDATA") or Path.home() / "AppData" / "Roaming")
|
|
62
|
+
return base / "DimiCheck" / "tokens.json"
|
|
63
|
+
return Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "dimicheck" / "tokens.json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TokenStore:
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
account: str,
|
|
71
|
+
allow_plaintext_file: bool = False,
|
|
72
|
+
token_file: Path | None = None,
|
|
73
|
+
keyring_module: Any | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.account = account
|
|
76
|
+
self.allow_plaintext_file = allow_plaintext_file
|
|
77
|
+
self.token_file = token_file or default_token_file()
|
|
78
|
+
self._keyring = keyring_module
|
|
79
|
+
|
|
80
|
+
def load(self) -> TokenSet | None:
|
|
81
|
+
try:
|
|
82
|
+
raw = self._keyring_get()
|
|
83
|
+
except Exception:
|
|
84
|
+
raw = None
|
|
85
|
+
if raw:
|
|
86
|
+
return TokenSet.from_json(raw)
|
|
87
|
+
if not self.allow_plaintext_file or not self.token_file.exists():
|
|
88
|
+
return None
|
|
89
|
+
self._assert_private_file(self.token_file)
|
|
90
|
+
return TokenSet.from_json(self.token_file.read_text(encoding="utf-8"))
|
|
91
|
+
|
|
92
|
+
def ensure_can_save(self) -> None:
|
|
93
|
+
try:
|
|
94
|
+
self._keyring_set("__dimicheck_tui_probe__")
|
|
95
|
+
self._keyring_delete()
|
|
96
|
+
return
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
if not self.allow_plaintext_file:
|
|
99
|
+
raise TokenStorageError(
|
|
100
|
+
"OS keyring에 토큰을 저장할 수 없습니다. Keychain/Credential Manager/Secret Service를 설정하거나 "
|
|
101
|
+
"--allow-plaintext-token 옵션을 명시하세요."
|
|
102
|
+
) from exc
|
|
103
|
+
self.token_file.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
if self.token_file.exists():
|
|
105
|
+
self._assert_private_file(self.token_file)
|
|
106
|
+
else:
|
|
107
|
+
fd = os.open(self.token_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
108
|
+
os.close(fd)
|
|
109
|
+
self.token_file.unlink()
|
|
110
|
+
|
|
111
|
+
def save(self, tokens: TokenSet) -> None:
|
|
112
|
+
raw = tokens.to_json()
|
|
113
|
+
try:
|
|
114
|
+
self._keyring_set(raw)
|
|
115
|
+
return
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
if not self.allow_plaintext_file:
|
|
118
|
+
raise TokenStorageError(
|
|
119
|
+
"OS keyring에 토큰을 저장할 수 없습니다. Keychain/Credential Manager/Secret Service를 설정하거나 "
|
|
120
|
+
"--allow-plaintext-token 옵션을 명시하세요."
|
|
121
|
+
) from exc
|
|
122
|
+
self.token_file.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
fd = os.open(self.token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
124
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
125
|
+
handle.write(raw)
|
|
126
|
+
os.chmod(self.token_file, 0o600)
|
|
127
|
+
|
|
128
|
+
def delete(self) -> None:
|
|
129
|
+
try:
|
|
130
|
+
self._keyring_delete()
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
if self.token_file.exists():
|
|
134
|
+
self.token_file.unlink()
|
|
135
|
+
|
|
136
|
+
def _keyring_module(self) -> Any:
|
|
137
|
+
if self._keyring is not None:
|
|
138
|
+
return self._keyring
|
|
139
|
+
import keyring # type: ignore
|
|
140
|
+
|
|
141
|
+
return keyring
|
|
142
|
+
|
|
143
|
+
def _keyring_get(self) -> str | None:
|
|
144
|
+
return self._keyring_module().get_password(SERVICE_NAME, self.account)
|
|
145
|
+
|
|
146
|
+
def _keyring_set(self, raw: str) -> None:
|
|
147
|
+
self._keyring_module().set_password(SERVICE_NAME, self.account, raw)
|
|
148
|
+
|
|
149
|
+
def _keyring_delete(self) -> None:
|
|
150
|
+
self._keyring_module().delete_password(SERVICE_NAME, self.account)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _assert_private_file(path: Path) -> None:
|
|
154
|
+
if os.name == "nt":
|
|
155
|
+
return
|
|
156
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
157
|
+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
158
|
+
raise TokenStorageError(f"{path} 권한이 너무 넓습니다. chmod 600 후 다시 실행하세요.")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dimicheck-tui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DimiCheck terminal dashboard
|
|
5
|
+
Author: DimiCheck
|
|
6
|
+
Project-URL: Homepage, https://dimicheck.com
|
|
7
|
+
Project-URL: Repository, https://github.com/hjun1052/dimicheck4
|
|
8
|
+
Keywords: dimicheck,tui,textual,oauth,school
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Education
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: keyring>=25.0.0
|
|
20
|
+
Requires-Dist: requests==2.31.0
|
|
21
|
+
Requires-Dist: textual>=0.89.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# DimiCheck TUI
|
|
26
|
+
|
|
27
|
+
DimiCheck TUI is a terminal dashboard for DimiCheck students. It uses the public OAuth PKCE flow and calls only the student app API surface for v1.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Use `pipx` for the public install path:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pipx install dimicheck-tui
|
|
35
|
+
dimicheck
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For local development from this checkout:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
cd packages/dimicheck-tui
|
|
42
|
+
uv run dimicheck --help
|
|
43
|
+
uv run dimicheck
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Set `DIMICHECK_BASE_URL` when testing against a local or staging server:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
cd packages/dimicheck-tui
|
|
50
|
+
DIMICHECK_BASE_URL=http://127.0.0.1:5000 uv run dimicheck
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Authentication
|
|
54
|
+
|
|
55
|
+
The TUI never asks for a password in the terminal. On first launch it opens the browser, signs in through DimiCheck OAuth, and stores tokens through the OS credential store.
|
|
56
|
+
|
|
57
|
+
- macOS: Keychain
|
|
58
|
+
- Windows: Credential Manager
|
|
59
|
+
- Linux: Secret Service compatible keyring
|
|
60
|
+
|
|
61
|
+
If the OS keyring is unavailable, the app fails closed by default. For controlled environments you can explicitly allow a local token file:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
dimicheck --allow-plaintext-token
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
That fallback writes a `0600` permission file. Do not use it on shared machines.
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
dimicheck # open the TUI dashboard
|
|
73
|
+
dimicheck login # browser OAuth login
|
|
74
|
+
dimicheck logout # revoke refresh token and delete local tokens
|
|
75
|
+
dimicheck status get # print current status
|
|
76
|
+
dimicheck status set toilet
|
|
77
|
+
dimicheck status set etc --reason "상담"
|
|
78
|
+
dimicheck schoollife # print today's timetable and meal
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
TUI shortcuts:
|
|
82
|
+
|
|
83
|
+
- `S`: change status
|
|
84
|
+
- `R`: refresh dashboard
|
|
85
|
+
- `L`: logout
|
|
86
|
+
- `Q`: quit
|
|
87
|
+
|
|
88
|
+
## v1 Scope
|
|
89
|
+
|
|
90
|
+
The public v1 supports:
|
|
91
|
+
|
|
92
|
+
- current student status read/write
|
|
93
|
+
- today's schoollife summary
|
|
94
|
+
- OAuth PKCE login/logout
|
|
95
|
+
|
|
96
|
+
It intentionally does not expose routine editing, teacher/admin tools, browser session cookies, remember tokens, or `/api/mcp/state`.
|
|
97
|
+
|
|
98
|
+
## Server Notes
|
|
99
|
+
|
|
100
|
+
The server seeds a dedicated public OAuth client:
|
|
101
|
+
|
|
102
|
+
- client id: `dimicheck-tui-public`
|
|
103
|
+
- redirect URI ports: `45831` through `45835`
|
|
104
|
+
- scopes: `openid basic student_info status.read status.write`
|
|
105
|
+
|
|
106
|
+
Server deployments should happen from the repository root. This package is intentionally separate so the public TUI release does not include Flask, database, or Google Sheets dependencies.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
dimicheck_tui/__init__.py
|
|
4
|
+
dimicheck_tui/api.py
|
|
5
|
+
dimicheck_tui/app.py
|
|
6
|
+
dimicheck_tui/auth.py
|
|
7
|
+
dimicheck_tui/cli.py
|
|
8
|
+
dimicheck_tui/config.py
|
|
9
|
+
dimicheck_tui/token_store.py
|
|
10
|
+
dimicheck_tui.egg-info/PKG-INFO
|
|
11
|
+
dimicheck_tui.egg-info/SOURCES.txt
|
|
12
|
+
dimicheck_tui.egg-info/dependency_links.txt
|
|
13
|
+
dimicheck_tui.egg-info/entry_points.txt
|
|
14
|
+
dimicheck_tui.egg-info/requires.txt
|
|
15
|
+
dimicheck_tui.egg-info/top_level.txt
|
|
16
|
+
tests/test_dimicheck_tui.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dimicheck_tui
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dimicheck-tui"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "DimiCheck terminal dashboard"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "DimiCheck" },
|
|
9
|
+
]
|
|
10
|
+
keywords = ["dimicheck", "tui", "textual", "oauth", "school"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: End Users/Desktop",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Education",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"keyring>=25.0.0",
|
|
23
|
+
"requests==2.31.0",
|
|
24
|
+
"textual>=0.89.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
dimicheck = "dimicheck_tui.cli:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://dimicheck.com"
|
|
37
|
+
Repository = "https://github.com/hjun1052/dimicheck4"
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["setuptools>=68"]
|
|
44
|
+
build-backend = "setuptools.build_meta"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools]
|
|
47
|
+
packages = ["dimicheck_tui"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from dimicheck_tui.api import ApiError, DimiCheckAPI
|
|
7
|
+
from dimicheck_tui.cli import format_schoollife, format_status
|
|
8
|
+
from dimicheck_tui.config import TuiConfig
|
|
9
|
+
from dimicheck_tui.token_store import TokenSet, TokenStorageError, TokenStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeKeyring:
|
|
13
|
+
def __init__(self, *, fail: bool = False):
|
|
14
|
+
self.fail = fail
|
|
15
|
+
self.value = None
|
|
16
|
+
|
|
17
|
+
def get_password(self, _service, _account):
|
|
18
|
+
if self.fail:
|
|
19
|
+
raise RuntimeError("keyring unavailable")
|
|
20
|
+
return self.value
|
|
21
|
+
|
|
22
|
+
def set_password(self, _service, _account, value):
|
|
23
|
+
if self.fail:
|
|
24
|
+
raise RuntimeError("keyring unavailable")
|
|
25
|
+
self.value = value
|
|
26
|
+
|
|
27
|
+
def delete_password(self, _service, _account):
|
|
28
|
+
if self.fail:
|
|
29
|
+
raise RuntimeError("keyring unavailable")
|
|
30
|
+
self.value = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tokens(access="access", refresh="refresh", expires_at=9999999999):
|
|
34
|
+
return TokenSet(access_token=access, refresh_token=refresh, expires_at=expires_at, scope="status.read")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_token_store_uses_keyring():
|
|
38
|
+
keyring = FakeKeyring()
|
|
39
|
+
store = TokenStore(account="https://dimicheck.com", keyring_module=keyring)
|
|
40
|
+
tokens = _tokens()
|
|
41
|
+
|
|
42
|
+
store.save(tokens)
|
|
43
|
+
|
|
44
|
+
loaded = store.load()
|
|
45
|
+
assert loaded == tokens
|
|
46
|
+
|
|
47
|
+
store.delete()
|
|
48
|
+
assert store.load() is None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_token_store_requires_explicit_plaintext_fallback(tmp_path):
|
|
52
|
+
store = TokenStore(
|
|
53
|
+
account="https://dimicheck.com",
|
|
54
|
+
keyring_module=FakeKeyring(fail=True),
|
|
55
|
+
token_file=tmp_path / "tokens.json",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
with pytest.raises(TokenStorageError):
|
|
59
|
+
store.save(_tokens())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_token_store_preflight_requires_explicit_plaintext_fallback(tmp_path):
|
|
63
|
+
store = TokenStore(
|
|
64
|
+
account="https://dimicheck.com",
|
|
65
|
+
keyring_module=FakeKeyring(fail=True),
|
|
66
|
+
token_file=tmp_path / "tokens.json",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
with pytest.raises(TokenStorageError):
|
|
70
|
+
store.ensure_can_save()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_token_store_plaintext_fallback_writes_private_file(tmp_path):
|
|
74
|
+
token_file = tmp_path / "tokens.json"
|
|
75
|
+
store = TokenStore(
|
|
76
|
+
account="https://dimicheck.com",
|
|
77
|
+
allow_plaintext_file=True,
|
|
78
|
+
keyring_module=FakeKeyring(fail=True),
|
|
79
|
+
token_file=token_file,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
store.save(_tokens())
|
|
83
|
+
|
|
84
|
+
assert token_file.exists()
|
|
85
|
+
assert oct(token_file.stat().st_mode & 0o777) == "0o600"
|
|
86
|
+
assert json.loads(token_file.read_text(encoding="utf-8"))["access_token"] == "access"
|
|
87
|
+
assert store.load().refresh_token == "refresh"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_token_store_plaintext_preflight_does_not_leave_probe_file(tmp_path):
|
|
91
|
+
token_file = tmp_path / "tokens.json"
|
|
92
|
+
store = TokenStore(
|
|
93
|
+
account="https://dimicheck.com",
|
|
94
|
+
allow_plaintext_file=True,
|
|
95
|
+
keyring_module=FakeKeyring(fail=True),
|
|
96
|
+
token_file=token_file,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
store.ensure_can_save()
|
|
100
|
+
|
|
101
|
+
assert not token_file.exists()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_cli_formatters_are_human_readable():
|
|
105
|
+
assert format_status({
|
|
106
|
+
"grade": 2,
|
|
107
|
+
"section": 4,
|
|
108
|
+
"number": 7,
|
|
109
|
+
"statusLabel": "화장실(물)",
|
|
110
|
+
}) == "2학년 4반 7번\n상태: 화장실(물)"
|
|
111
|
+
|
|
112
|
+
schoollife = format_schoollife({
|
|
113
|
+
"date": "2026-06-14",
|
|
114
|
+
"timetable": {"lessons": [{"period": 1, "subject": "국어"}]},
|
|
115
|
+
"meal": {"lunch": "김치볶음밥\n요구르트"},
|
|
116
|
+
"errors": {},
|
|
117
|
+
})
|
|
118
|
+
assert "오늘 학교생활 (2026-06-14)" in schoollife
|
|
119
|
+
assert "1교시 국어" in schoollife
|
|
120
|
+
assert "김치볶음밥 · 요구르트" in schoollife
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class FakeStore:
|
|
124
|
+
def __init__(self, tokens):
|
|
125
|
+
self.tokens = tokens
|
|
126
|
+
self.deleted = False
|
|
127
|
+
|
|
128
|
+
def load(self):
|
|
129
|
+
return self.tokens
|
|
130
|
+
|
|
131
|
+
def save(self, tokens):
|
|
132
|
+
self.tokens = tokens
|
|
133
|
+
|
|
134
|
+
def delete(self):
|
|
135
|
+
self.deleted = True
|
|
136
|
+
self.tokens = None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class FakeAuth:
|
|
140
|
+
def __init__(self, refreshed=None, fail=False):
|
|
141
|
+
self.refreshed = refreshed
|
|
142
|
+
self.fail = fail
|
|
143
|
+
|
|
144
|
+
def login(self):
|
|
145
|
+
return self.refreshed
|
|
146
|
+
|
|
147
|
+
def refresh(self, _refresh_token):
|
|
148
|
+
if self.fail:
|
|
149
|
+
from dimicheck_tui.auth import AuthError
|
|
150
|
+
|
|
151
|
+
raise AuthError("refresh failed")
|
|
152
|
+
return self.refreshed
|
|
153
|
+
|
|
154
|
+
def revoke(self, _token):
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class FakeResponse:
|
|
159
|
+
def __init__(self, status_code, payload):
|
|
160
|
+
self.status_code = status_code
|
|
161
|
+
self.payload = payload
|
|
162
|
+
self.text = json.dumps(payload)
|
|
163
|
+
|
|
164
|
+
def json(self):
|
|
165
|
+
return self.payload
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class FakeSession:
|
|
169
|
+
def __init__(self, responses):
|
|
170
|
+
self.responses = list(responses)
|
|
171
|
+
self.requests = []
|
|
172
|
+
|
|
173
|
+
def request(self, method, url, **kwargs):
|
|
174
|
+
self.requests.append((method, url, kwargs))
|
|
175
|
+
return self.responses.pop(0)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_api_refreshes_once_after_unauthorized_response():
|
|
179
|
+
config = TuiConfig(base_url="https://dimicheck.test")
|
|
180
|
+
store = FakeStore(_tokens(access="old", refresh="refresh", expires_at=9999999999))
|
|
181
|
+
refreshed = _tokens(access="new", refresh="new-refresh", expires_at=9999999999)
|
|
182
|
+
session = FakeSession([
|
|
183
|
+
FakeResponse(401, {"error": "invalid_token"}),
|
|
184
|
+
FakeResponse(200, {"statusCode": "section"}),
|
|
185
|
+
])
|
|
186
|
+
api = DimiCheckAPI(config, store, auth_client=FakeAuth(refreshed=refreshed), session=session)
|
|
187
|
+
|
|
188
|
+
payload = api.get_status()
|
|
189
|
+
|
|
190
|
+
assert payload == {"statusCode": "section"}
|
|
191
|
+
assert store.tokens.access_token == "new"
|
|
192
|
+
assert session.requests[0][2]["headers"]["Authorization"] == "Bearer old"
|
|
193
|
+
assert session.requests[1][2]["headers"]["Authorization"] == "Bearer new"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_api_deletes_tokens_when_refresh_fails():
|
|
197
|
+
config = TuiConfig(base_url="https://dimicheck.test")
|
|
198
|
+
store = FakeStore(_tokens(access="old", refresh="refresh", expires_at=9999999999))
|
|
199
|
+
session = FakeSession([FakeResponse(401, {"error": "invalid_token"})])
|
|
200
|
+
api = DimiCheckAPI(config, store, auth_client=FakeAuth(fail=True), session=session)
|
|
201
|
+
|
|
202
|
+
with pytest.raises(ApiError):
|
|
203
|
+
api.get_status()
|
|
204
|
+
|
|
205
|
+
assert store.deleted is True
|