Keep the Schwab token alive, and reconnect from the header
The live socket never made a REST call, so the seven-day refresh token expired while the chart still looked fine. A deploy then could not log in. Ping user preferences every six hours, and when the grant is already dead offer a one-click reconnect that writes the token on the existing callback.
This commit is contained in:
parent
d9a272760b
commit
cc25871032
11 changed files with 199 additions and 22 deletions
|
|
@ -9,6 +9,7 @@ from datetime import date as Date
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
|
|
@ -139,9 +140,18 @@ def status(request: Request):
|
||||||
"recent": round(runtime.loop_lag_recent * 1000, 1),
|
"recent": round(runtime.loop_lag_recent * 1000, 1),
|
||||||
"worst": round(runtime.loop_lag_worst * 1000, 1),
|
"worst": round(runtime.loop_lag_worst * 1000, 1),
|
||||||
},
|
},
|
||||||
|
"needs_login": runtime.needs_login(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/schwab/login")
|
||||||
|
def schwab_login(request: Request):
|
||||||
|
settings = request.app.state.runtime.settings
|
||||||
|
if not settings.schwab_api_key or not settings.schwab_app_secret:
|
||||||
|
raise HTTPException(503, "Live source is not configured")
|
||||||
|
return RedirectResponse(request.app.state.runtime.start_schwab_login())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bars")
|
@router.get("/bars")
|
||||||
def bars(
|
def bars(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,32 @@ brokerage this host talks to. Treat it as fixed: changing a registered callback
|
||||||
URL means editing the Schwab app, which can send it back through approval.
|
URL means editing the Schwab app, which can send it back through approval.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
def begin_login(settings) -> object:
|
||||||
|
from schwab.auth import get_auth_context
|
||||||
|
|
||||||
|
return get_auth_context(settings.schwab_api_key, settings.schwab_callback_url)
|
||||||
|
|
||||||
|
|
||||||
|
def complete_login(settings, context, redirect_url: str) -> None:
|
||||||
|
from schwab import auth
|
||||||
|
from schwab.auth import client_from_received_url
|
||||||
|
|
||||||
|
path = settings.schwab_token_path
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_token = getattr(auth, "__make_update_token_func")(str(path))
|
||||||
|
client_from_received_url(
|
||||||
|
settings.schwab_api_key,
|
||||||
|
settings.schwab_app_secret,
|
||||||
|
context,
|
||||||
|
redirect_url,
|
||||||
|
write_token,
|
||||||
|
)
|
||||||
|
|
||||||
PAGE = """<!doctype html>
|
PAGE = """<!doctype html>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Callback</title>
|
<title>Callback</title>
|
||||||
|
|
@ -56,8 +78,22 @@ version of this page is the one you are redirected to.</p>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@router.get("/qt", response_class=HTMLResponse)
|
FAILED = """
|
||||||
def callback(request: Request) -> HTMLResponse:
|
<p>The authorisation code could not be exchanged. Start again from the chart.</p>
|
||||||
|
<p class="muted">Nothing was stored.</p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qt")
|
||||||
|
def callback(request: Request):
|
||||||
|
runtime = getattr(request.app.state, "runtime", None)
|
||||||
|
if runtime is not None and runtime.schwab_login is not None and request.query_params.get("code"):
|
||||||
|
try:
|
||||||
|
runtime.finish_schwab_login(str(request.url))
|
||||||
|
except Exception:
|
||||||
|
page = PAGE.format(heading="Authorisation failed", body=FAILED)
|
||||||
|
return HTMLResponse(page, headers={"Cache-Control": "no-store"}, status_code=400)
|
||||||
|
return RedirectResponse("/", headers={"Cache-Control": "no-store"})
|
||||||
if request.query_params.get("code"):
|
if request.query_params.get("code"):
|
||||||
page = PAGE.format(
|
page = PAGE.format(
|
||||||
heading="Authorisation received",
|
heading="Authorisation received",
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,11 @@ class SchwabSource:
|
||||||
name = "schwab"
|
name = "schwab"
|
||||||
delay_minutes = 0
|
delay_minutes = 0
|
||||||
|
|
||||||
|
# Access tokens last 30 minutes. The refresh token lasts seven days unless
|
||||||
|
# something uses it — the live socket never does, so a quiet week killed
|
||||||
|
# the feed. Hitting REST on this cadence writes a new refresh token to disk.
|
||||||
|
TOKEN_KEEPALIVE_SECONDS = 6 * 3600
|
||||||
|
|
||||||
def __init__(self, settings, stream_client_factory=None):
|
def __init__(self, settings, stream_client_factory=None):
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
# Injectable so the parsing and dispatch can be tested without a socket.
|
# Injectable so the parsing and dispatch can be tested without a socket.
|
||||||
|
|
@ -128,6 +133,7 @@ class SchwabSource:
|
||||||
# exactly as it was: one closed bar a minute.
|
# exactly as it was: one closed bar a minute.
|
||||||
seconds = getattr(settings, "schwab_tick_seconds", 1.0)
|
seconds = getattr(settings, "schwab_tick_seconds", 1.0)
|
||||||
self._tick_seconds = None if seconds is None or seconds < 0 else seconds
|
self._tick_seconds = None if seconds is None or seconds < 0 else seconds
|
||||||
|
self._http = None
|
||||||
|
|
||||||
def supports_history(self) -> bool:
|
def supports_history(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
@ -138,23 +144,44 @@ class SchwabSource:
|
||||||
def supports_stream(self) -> bool:
|
def supports_stream(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _build_stream_client(self):
|
def http_client(self):
|
||||||
|
if self._http is None:
|
||||||
from schwab.auth import client_from_token_file
|
from schwab.auth import client_from_token_file
|
||||||
from schwab.streaming import StreamClient
|
|
||||||
|
|
||||||
settings = self._settings
|
settings = self._settings
|
||||||
if not settings.schwab_token_path.exists():
|
if not settings.schwab_token_path.exists():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"No Schwab token at {settings.schwab_token_path}. "
|
f"No Schwab token at {settings.schwab_token_path}"
|
||||||
"Run: python3 -m scripts.check_schwab"
|
|
||||||
)
|
)
|
||||||
client = client_from_token_file(
|
self._http = client_from_token_file(
|
||||||
str(settings.schwab_token_path),
|
str(settings.schwab_token_path),
|
||||||
settings.schwab_api_key,
|
settings.schwab_api_key,
|
||||||
settings.schwab_app_secret,
|
settings.schwab_app_secret,
|
||||||
asyncio=True,
|
asyncio=True,
|
||||||
)
|
)
|
||||||
return StreamClient(client)
|
return self._http
|
||||||
|
|
||||||
|
def reset_client(self) -> None:
|
||||||
|
self._http = None
|
||||||
|
|
||||||
|
def _build_stream_client(self):
|
||||||
|
from schwab.streaming import StreamClient
|
||||||
|
|
||||||
|
return StreamClient(self.http_client())
|
||||||
|
|
||||||
|
async def refresh_token(self) -> None:
|
||||||
|
await self.http_client().get_user_preferences()
|
||||||
|
|
||||||
|
async def keep_alive(self, interval: float | None = None) -> None:
|
||||||
|
wait = self.TOKEN_KEEPALIVE_SECONDS if interval is None else interval
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
try:
|
||||||
|
await self.refresh_token()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Schwab token keepalive failed")
|
||||||
|
|
||||||
async def stream(self, symbol: str) -> AsyncIterator[Bar]:
|
async def stream(self, symbol: str) -> AsyncIterator[Bar]:
|
||||||
stream_client = self._stream_client_factory()
|
stream_client = self._stream_client_factory()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ class StreamService:
|
||||||
self.source = source
|
self.source = source
|
||||||
self.symbol = symbol
|
self.symbol = symbol
|
||||||
self.status = "disconnected"
|
self.status = "disconnected"
|
||||||
|
self.last_error: str | None = None
|
||||||
self.last_bar_t: int | None = None
|
self.last_bar_t: int | None = None
|
||||||
self._handlers: list[BarHandler] = []
|
self._handlers: list[BarHandler] = []
|
||||||
self._stop = asyncio.Event()
|
self._stop = asyncio.Event()
|
||||||
|
|
@ -44,8 +45,9 @@ class StreamService:
|
||||||
async def run(self) -> None:
|
async def run(self) -> None:
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
try:
|
try:
|
||||||
self.status = "replay" if self.source.name == "replay" else "connected"
|
|
||||||
async for bar in self.source.stream(self.symbol):
|
async for bar in self.source.stream(self.symbol):
|
||||||
|
self.status = "replay" if self.source.name == "replay" else "connected"
|
||||||
|
self.last_error = None
|
||||||
await self._emit(bar)
|
await self._emit(bar)
|
||||||
if self._stop.is_set():
|
if self._stop.is_set():
|
||||||
break
|
break
|
||||||
|
|
@ -53,7 +55,8 @@ class StreamService:
|
||||||
return
|
return
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
|
self.last_error = str(exc)
|
||||||
logger.exception("Market stream failed; reconnecting")
|
logger.exception("Market stream failed; reconnecting")
|
||||||
self.status = "disconnected"
|
self.status = "disconnected"
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ class Runtime:
|
||||||
loop_lag_recent: float = 0.0
|
loop_lag_recent: float = 0.0
|
||||||
_lag_task: asyncio.Task | None = None
|
_lag_task: asyncio.Task | None = None
|
||||||
_rebuild_task: asyncio.Task | None = None
|
_rebuild_task: asyncio.Task | None = None
|
||||||
|
_token_task: asyncio.Task | None = None
|
||||||
|
schwab_login: object | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
|
self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
|
||||||
|
|
@ -409,4 +411,30 @@ class Runtime:
|
||||||
self.settle_after_seed()
|
self.settle_after_seed()
|
||||||
self._lag_task = asyncio.create_task(self.loop_lag_watch(), name="loop-lag")
|
self._lag_task = asyncio.create_task(self.loop_lag_watch(), name="loop-lag")
|
||||||
self._rebuild_task = asyncio.create_task(self.rebuild_watch(), name="level-rebuild")
|
self._rebuild_task = asyncio.create_task(self.rebuild_watch(), name="level-rebuild")
|
||||||
|
keep_alive = getattr(self.stream.source, "keep_alive", None)
|
||||||
|
if keep_alive is not None:
|
||||||
|
self._token_task = asyncio.create_task(keep_alive(), name="token-keepalive")
|
||||||
return asyncio.create_task(self.stream.run(), name="market-stream")
|
return asyncio.create_task(self.stream.run(), name="market-stream")
|
||||||
|
|
||||||
|
def needs_login(self) -> bool:
|
||||||
|
error = self.stream.last_error or ""
|
||||||
|
return "invalid_grant" in error or "Refresh token" in error
|
||||||
|
|
||||||
|
def start_schwab_login(self) -> str:
|
||||||
|
from app.api.schwab_auth import begin_login
|
||||||
|
|
||||||
|
context = begin_login(self.settings)
|
||||||
|
self.schwab_login = context
|
||||||
|
return context.authorization_url
|
||||||
|
|
||||||
|
def finish_schwab_login(self, redirect_url: str) -> None:
|
||||||
|
from app.api.schwab_auth import complete_login
|
||||||
|
|
||||||
|
context = self.schwab_login
|
||||||
|
if context is None:
|
||||||
|
raise RuntimeError("No login in progress")
|
||||||
|
complete_login(self.settings, context, redirect_url)
|
||||||
|
self.schwab_login = None
|
||||||
|
reset = getattr(self.stream.source, "reset_client", None)
|
||||||
|
if reset is not None:
|
||||||
|
reset()
|
||||||
|
|
|
||||||
|
|
@ -1161,3 +1161,17 @@ chart — trendlines, price levels, and comments — and (unless "Hidden levels
|
||||||
still count toward confluence" is on) keeps those lines out of clusters too.
|
still count toward confluence" is on) keeps those lines out of clusters too.
|
||||||
Manual lines stays as the finer control for lines only. Old stored prefs
|
Manual lines stays as the finer control for lines only. Old stored prefs
|
||||||
without the key keep drawings on.
|
without the key keep drawings on.
|
||||||
|
|
||||||
|
### 2026-08-18 — Schwab token keepalive
|
||||||
|
|
||||||
|
The header said DISCONNECTED · SCHWAB on every timeframe. Production logs
|
||||||
|
were `invalid_grant` / refresh token expired. The token file was created
|
||||||
|
2026-08-10 and last written 2026-08-17; the live socket never makes a REST
|
||||||
|
call, so the seven-day refresh token aged out while the chart still looked
|
||||||
|
fine. A deploy then tried to log in and could not.
|
||||||
|
|
||||||
|
The stream now shares one HTTP client and pings user preferences every six
|
||||||
|
hours so a new refresh token is written to the volume. Status grows
|
||||||
|
`needs_login` when the error is a dead grant; the header then offers
|
||||||
|
**reconnect**, which starts the existing `/api/qt` flow and writes the token
|
||||||
|
on callback instead of asking anyone to paste a URL into a prompt.
|
||||||
|
|
|
||||||
|
|
@ -1231,7 +1231,7 @@ enforces for data sources.
|
||||||
| Threadpool routes touching `asyncio.Queue` | Dropped socket wakeups, rare corruption | Post via `call_soon_threadsafe` — `docs/async_refactor.md` P0 |
|
| Threadpool routes touching `asyncio.Queue` | Dropped socket wakeups, rare corruption | Post via `call_soon_threadsafe` — `docs/async_refactor.md` P0 |
|
||||||
| Level rebuilds run CPU-bound on the event loop | 82s startup; a stall every closed bar | Bulk seed, incremental MAs — `docs/async_refactor.md` P1 |
|
| Level rebuilds run CPU-bound on the event loop | 82s startup; a stall every closed bar | Bulk seed, incremental MAs — `docs/async_refactor.md` P1 |
|
||||||
| Alert disarm writes to disk on the loop | Stream stalls when an alert fires | Offload the write — `docs/async_refactor.md` P2 |
|
| Alert disarm writes to disk on the loop | Stream stalls when an alert fires | Offload the write — `docs/async_refactor.md` P2 |
|
||||||
| Schwab token expiry (7 days) | Stream dies | Surface prominently in status bar; document re-auth |
|
| Schwab token expiry (7 days) | Stream dies | Keepalive REST ping writes a new refresh token; header reconnect when the grant is dead |
|
||||||
| Vue reactivity wrapping chart objects | Perf collapse, odd bugs | `shallowRef`/`markRaw` — §9 |
|
| Vue reactivity wrapping chart objects | Perf collapse, odd bugs | `shallowRef`/`markRaw` — §9 |
|
||||||
| LWC v4 tutorials copied | Code silently wrong for v5 | `addSeries(SeriesType, ...)` only |
|
| LWC v4 tutorials copied | Code silently wrong for v5 | `addSeries(SeriesType, ...)` only |
|
||||||
| Viewport derived from `bars.length` | Chart looks frozen; feed is fine | Anchor the view by time, never by logical index — §9 |
|
| Viewport derived from `bars.length` | Chart looks frozen; feed is fine | Anchor the view by time, never by logical index — §9 |
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<header>
|
<header>
|
||||||
<h1><strong>/ES</strong>sent</h1>
|
<h1><strong>/ES</strong>sent</h1>
|
||||||
<div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}</div>
|
<div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}<a v-if="status.needs_login" class="reconnect" href="/api/schwab/login">reconnect</a></div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<section class="chart-shell">
|
<section class="chart-shell">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.45 "IBM Plex
|
||||||
header { height:44px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); margin-bottom:16px; }
|
header { height:44px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); margin-bottom:16px; }
|
||||||
h1 { margin:0; font-size:22px; letter-spacing:-1px; } h1 strong { color:var(--accent); font-weight:600; }
|
h1 { margin:0; font-size:22px; letter-spacing:-1px; } h1 strong { color:var(--accent); font-weight:600; }
|
||||||
.eyebrow { color:var(--muted); font-size:9px; letter-spacing:2px; }
|
.eyebrow { color:var(--muted); font-size:9px; letter-spacing:2px; }
|
||||||
.status { text-transform:uppercase; color:var(--muted); font-size:11px; }.status i { display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--red); margin-right:8px; }.status.connected i,.status.replay i { background:var(--green); box-shadow:0 0 9px var(--green); }
|
.status { text-transform:uppercase; color:var(--muted); font-size:11px; }.status i { display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--red); margin-right:8px; }.status.connected i,.status.replay i { background:var(--green); box-shadow:0 0 9px var(--green); }.status .reconnect { margin-left:10px; color:var(--accent); letter-spacing:.4px; }
|
||||||
main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
|
main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
|
||||||
.chart-shell,aside { background:var(--panel); border:1px solid var(--line); }
|
.chart-shell,aside { background:var(--panel); border:1px solid var(--line); }
|
||||||
.chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); }
|
.chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); }
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,26 @@ def test_the_code_is_not_retained_for_a_later_visitor():
|
||||||
session.get("/api/qt", params={"code": "secret-code"})
|
session.get("/api/qt", params={"code": "secret-code"})
|
||||||
# A second, code-less request must not replay the first one's code.
|
# A second, code-less request must not replay the first one's code.
|
||||||
assert "secret-code" not in session.get("/api/qt").text
|
assert "secret-code" not in session.get("/api/qt").text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pending_login_exchanges_the_code_and_returns_home(monkeypatch):
|
||||||
|
from app.config import Settings
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
runtime = Runtime(Settings())
|
||||||
|
runtime.schwab_login = object()
|
||||||
|
finished = []
|
||||||
|
|
||||||
|
def finish(url):
|
||||||
|
finished.append(url)
|
||||||
|
|
||||||
|
runtime.finish_schwab_login = finish
|
||||||
|
app.state.runtime = runtime
|
||||||
|
response = TestClient(app, follow_redirects=False).get(
|
||||||
|
"/api/qt", params={"code": "abc", "state": "s"}
|
||||||
|
)
|
||||||
|
assert response.status_code in (302, 303, 307)
|
||||||
|
assert response.headers["location"] == "/"
|
||||||
|
assert finished and "code=abc" in finished[0]
|
||||||
|
|
|
||||||
|
|
@ -293,3 +293,39 @@ def test_a_zero_last_price_is_not_a_price():
|
||||||
|
|
||||||
def test_a_zero_price_with_no_trade_markers_is_dropped_entirely():
|
def test_a_zero_price_with_no_trade_markers_is_dropped_entirely():
|
||||||
assert parse_level_one({"content": [{"key": "/ES", "LAST_PRICE": 0}]}) == []
|
assert parse_level_one({"content": [{"key": "/ES", "LAST_PRICE": 0}]}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepalive_hits_the_shared_http_client():
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def get_user_preferences(self):
|
||||||
|
self.calls += 1
|
||||||
|
return type("R", (), {"status_code": 200})()
|
||||||
|
|
||||||
|
source = SchwabSource(Settings())
|
||||||
|
client = FakeClient()
|
||||||
|
source._http = client
|
||||||
|
asyncio.run(source.refresh_token())
|
||||||
|
assert client.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_client_drops_the_cached_http_session():
|
||||||
|
source = SchwabSource(Settings())
|
||||||
|
source._http = object()
|
||||||
|
source.reset_client()
|
||||||
|
assert source._http is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_login_when_the_refresh_token_is_dead(tmp_path):
|
||||||
|
from app.runtime import Runtime
|
||||||
|
|
||||||
|
runtime = Runtime(Settings(manual_lines_path=tmp_path / "lines.json"))
|
||||||
|
assert runtime.needs_login() is False
|
||||||
|
runtime.stream.last_error = (
|
||||||
|
'unsupported_token_type: 400 Bad Request: '
|
||||||
|
'{"error_description":"Refresh token is invalid, expired or revoked",'
|
||||||
|
'"error":"invalid_grant"}'
|
||||||
|
)
|
||||||
|
assert runtime.needs_login() is True
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue