Skip to content

refactor: migrate web framework from Flask to FastAPI#333

Open
Bogay wants to merge 11 commits intomainfrom
fastapi
Open

refactor: migrate web framework from Flask to FastAPI#333
Bogay wants to merge 11 commits intomainfrom
fastapi

Conversation

@Bogay
Copy link
Copy Markdown
Member

@Bogay Bogay commented Apr 15, 2026

Replace Flask/Blueprint with FastAPI/APIRouter across all route modules. Switch to Pydantic v2 request schemas, FastAPI Depends for auth, and httpx/Starlette TestClient in tests. All 485 tests pass.

Replace Flask/Blueprint with FastAPI/APIRouter across all route modules.
Switch to Pydantic v2 request schemas, FastAPI Depends for auth, and
httpx/Starlette TestClient in tests. All 485 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the API layer from Flask/Blueprint to FastAPI/APIRouter, updating request/response utilities and the full test suite to use Starlette/httpx semantics while keeping existing API envelopes and auth behavior.

Changes:

  • Replaced Flask app + blueprints with a FastAPI app + routers, using Depends for auth and dependency injection.
  • Updated request/response utilities to FastAPI/Starlette equivalents (cookies, redirects, validation errors).
  • Refactored tests to use starlette.testclient.TestClient / httpx-style response APIs (.json(), .content, files=...) and removed Flask app-context usage.

Reviewed changes

Copilot reviewed 42 out of 43 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
tests/unittest/submission/test_penalty.py Removes Flask app_context() usage around submission creation.
tests/unittest/submission/test_get_output.py Removes Flask app_context() usage; keeps output assertions.
tests/unittest/submission/test_check_code.py Removes Flask app_context() usage; keeps zip validation assertions.
tests/test_utils_request.py Deletes Flask-specific request parsing utility tests.
tests/test_use_announcement.py Switches response parsing to rv.json() and adjusts DELETE calls for TestClient.
tests/test_test.py Updates logging expectations and logger configuration for FastAPI module logger.
tests/test_submission.py Updates URL shapes, request files= usage, and response parsing for TestClient/httpx.
tests/test_scoreboard.py Removes Flask app_context() usage; uses rv.json() for assertions.
tests/test_ranking.py Updates response parsing to rv.json().
tests/test_profile.py Updates response parsing to rv.json().
tests/test_problem_stats.py Removes Flask app_context() usage and updates assertions to rv.json() / rv.content.
tests/test_problem.py Updates multipart upload format (files=), response parsing, and binary response checks.
tests/test_post.py Updates response parsing and DELETE request style for TestClient.
tests/test_mongo_utils.py Removes Flask app_context() usage for warning-log test.
tests/test_homework.py Replaces FlaskClient typing/usages with TestClient cookies + response parsing updates.
tests/test_health.py Updates response parsing to rv.json().
tests/test_get_homewrok_problem.py Replaces FlaskClient typing/usages with TestClient cookies + response parsing updates.
tests/test_course.py Updates response parsing and DELETE request style for TestClient.
tests/test_copycat.py Updates response parsing and switches request mocking from requests to httpx.
tests/test_auth.py Updates response parsing/cookies, adjusts request parameter passing, and skips Flask-only verify-link tests.
tests/conftest.py Migrates fixtures to FastAPI TestClient and adjusts test seeding and response JSON behavior.
tests/base_tester.py Updates request helper to work with TestClient responses (rv.json()).
pyproject.toml Removes Flask/gunicorn/requests; adds FastAPI/uvicorn/httpx/python-multipart dependencies.
mongo/utils.py Replaces current_app.logger usage with stdlib logging.
mongo/submission.py Migrates HTTP client to httpx and replaces Flask testing flag checks with env var.
mongo/base.py Replaces Flask current_app.logger fallback with module logger.
model/utils/response.py Reimplements HTTP response helpers using FastAPI/Starlette response classes.
model/utils/request.py Replaces Flask decorators with FastAPI Depends-based helpers (get_doc, get_ip).
model/user.py Migrates user endpoints to FastAPI routers and dependency-based auth.
model/test.py Migrates test endpoints to FastAPI routers and module logger usage.
model/submission.py Migrates submission endpoints to FastAPI, including file uploads and config endpoints.
model/ranking.py Migrates ranking endpoint to FastAPI.
model/profile.py Migrates profile endpoints to FastAPI.
model/problem.py Migrates problem endpoints to FastAPI, including multipart handling and streaming downloads.
model/post.py Migrates post endpoints to FastAPI and factors shared targeting logic.
model/homework.py Migrates homework endpoints to FastAPI and mounts course homework list on course router.
model/health.py Migrates health endpoint to FastAPI.
model/course.py Migrates course endpoints to FastAPI and splits CRUD operations into separate handlers.
model/copycat.py Migrates copycat endpoints to FastAPI and switches HTTP fetching to httpx.
model/announcement.py Migrates announcements endpoints to FastAPI and shares announcement formatting.
app.py Replaces Flask app factory with FastAPI app creation + exception handlers + router inclusion.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread model/auth.py
Comment thread model/submission.py Outdated
Comment thread model/submission.py Outdated
Comment thread model/copycat.py
Comment thread tests/conftest.py Outdated
Comment thread tests/conftest.py Outdated
Comment thread mongo/submission.py
Comment thread mongo/submission.py Outdated
Comment thread app.py
Bogay and others added 4 commits April 16, 2026 02:22
- Call setup_smtp() in create_app() so SMTP misconfiguration is caught at startup
- Remove SERVER_NAME check from setup_smtp() — not needed in FastAPI since
  get_verify_link() uses request.url_for() instead of Flask's url_for()
- Add is_testing() helper in mongo/utils.py to safely interpret the TESTING
  env var; raw os.getenv('TESTING') was truthy for any string including "false"
- Replace all os.getenv('TESTING') call sites with is_testing()
- Remove redundant login_required + identity_verify double-dependency on admin
  endpoints; identity_verify already depends on login_required internally

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Replace resp.ok (requests API) with resp.is_success (httpx API) in
  target_sandbox
- Remove global httpx.Response.json monkey-patch; fix test_redirect to
  not call rv.json() on a 302 response
- Inject httpx.Client via method args through submit/send/target_sandbox/
  rejudge for testability; add explicit timeouts (5s status check,
  5s connect / 30s read for submission POST)
- Handle httpx.RequestError in update_config so unreachable sandboxes
  fail fast instead of hanging the request
- Remove legacy `import httpx as rq` alias
- Add unit tests for target_sandbox using httpx MockTransport

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
rv.json() raised on binary responses (e.g. ZIP testcase download)
after the global httpx.Response.json monkey-patch was removed.
Move the safe fallback to BaseTester.request where it belongs.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 43 out of 44 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (2)

mongo/submission.py:304

  • target_sandbox() creates an httpx.Client when none is provided but never closes it, which can leak connections/file descriptors over time. Also, client.get(...) can raise httpx.RequestError (DNS/connection/timeouts) and currently would bubble up and potentially break submission flows; handle request exceptions by logging and skipping that sandbox.
    def target_sandbox(self, client: httpx.Client | None = None):
        if client is None:
            client = httpx.Client(timeout=5.0)
        load = 10**3  # current min load
        tar = None  # target
        for sb in self.config().sandbox_instances:
            resp = client.get(f'{sb.url}/status')
            if not resp.is_success:
                self.logger.warning(f'sandbox {sb.name} status exception')
                self.logger.warning(
                    f'status code: {resp.status_code}\n '
                    f'body: {resp.text}', )
                continue

mongo/submission.py:463

  • send() constructs an httpx.Client when client is None but doesn't close it, and the subsequent client.post(...) call can raise httpx.RequestError which is not handled. Consider using a context-managed client when created internally (or ensure it’s closed in a finally) and convert transport errors into a logged failure/False return.
    def send(self, client: httpx.Client | None = None) -> bool:
        '''
        send code to sandbox
        '''
        if self.handwritten:
            logging.warning(f'try to send a handwritten {self}')
            return False
        if client is None:
            client = httpx.Client(timeout=httpx.Timeout(5.0, read=30.0))
        # TODO: Ensure problem is ready to submitted
        # if not Problem(self.problem).is_test_case_ready():
        #     raise TestCaseNotFound(self.problem.problem_id)
        # setup post body
        files = {
            'src': io.BytesIO(b"".join(self._get_code_raw())),
        }
        # look for the target sandbox
        tar = self.target_sandbox(client=client)
        if tar is None:
            self.logger.error(f'can not target a sandbox for {repr(self)}')
            return False
        # save token for validation
        Submission.assign_token(self.id, tar.token)
        post_data = {
            'token': tar.token,
            'checker': 'print("not implement yet. qaq")',
            'problem_id': self.problem_id,
            'language': self.language,
        }
        judge_url = f'{tar.url}/submit/{self.id}'
        # send submission to snadbox for judgement
        self.logger.info(f'send {self} to {tar.name}')
        resp = client.post(
            judge_url,
            data=post_data,
            files=files,
        )
        self.logger.info(f'recieve {self} resp from sandbox')
        return self.sandbox_resp_handler(resp)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread model/utils/response.py
Comment on lines +27 to +33
def HTTPResponse(
message: str = '',
status_code: int = 200,
status: str = 'ok',
data: Any = None,
cookies: dict = {},
) -> JSONResponse:
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTPResponse uses a mutable default argument (cookies: dict = {}). Even if it's not mutated today, this pattern can lead to cross-request state leakage if any future code mutates the dict. Use cookies: dict | None = None and treat None as an empty dict inside the function.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take a look at this

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed.

Comment thread model/utils/response.py Outdated
Comment on lines +45 to +51
def HTTPRedirect(
location: str,
status_code: int = 302,
cookies: dict = {},
) -> RedirectResponse:
resp = RedirectResponse(location, status_code=status_code)
return _apply_cookies(resp, cookies)
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTPRedirect also uses a mutable default argument (cookies: dict = {}), with the same risk of shared state across calls. Prefer cookies: dict | None = None and normalize internally.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take a look at this

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed.

Comment thread model/problem.py Outdated
Comment thread tests/conftest.py
Comment on lines 38 to +52
def app(tmp_path):
from app import app as flask_app
app = flask_app()
app.config['TESTING'] = True
app.config['SERVER_NAME'] = 'test.test'
os.environ['TESTING'] = '1'
from app import app as fastapi_app, _seed_db
mongomock.gridfs.enable_gridfs_integration()

# Re-run seed in case a prior setup_class dropped the DB
_seed_db()

# modify submission config for testing
# use tmp dir to save user source code
submission_tmp_dir = (tmp_path / Submission.config().TMP_DIR).absolute()
submission_tmp_dir.mkdir(exist_ok=True)
Submission.config().TMP_DIR = submission_tmp_dir

return app
return fastapi_app
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The app test fixture sets os.environ['TESTING'] = '1' but never restores the prior value. This can leak test-mode behavior across modules/tests (or when reusing the same process). Prefer using pytest’s monkeypatch.setenv/delenv (or a yield fixture that restores the previous env value).

Copilot uses AI. Check for mistakes.
@as535364
Copy link
Copy Markdown
Member

概述

大規模且一致的遷移:Flask Blueprints → APIRouter、裝飾器式 @login_requiredDepends(login_required)@parse_body/@parse_query → FastAPI 自動綁定 Pydantic schema、requestshttpxFlaskClientstarlette.testclient.TestClient。自訂的 NOJException + handler 保留了原本 {status, message, data} 的錯誤回應格式。共 44 個檔案,+2490/-2576,485 個 tests 全數通過。handler 的簽名比 Flask 版本乾淨不少 — 移除 @Request.cookies/@parse_body 這類疊多層裝飾器的寫法是實質的可讀性改善。

正確性

可能的破壞性變更 — trailing slash。 舊版 app 有 app.url_map.strict_slashes = False,client 打 /ann/ann/ 都能通。新版把 route 宣告為 @ann_router.get('') 加上 prefix /ann,實際路徑就只有精確的 /ann。Starlette 不會自動 redirect。任何 frontend 或第三方呼叫 /ann//problem//submission//course/ 等等結尾帶斜線的路徑會開始回 404。解法三選一:(a) 盤點呼叫端、(b) 每個 handler 都同時加 '''/' 兩個裝飾器、(c) 加一個去除 trailing slash 的 middleware。這是整個 PR 最大的風險點。

External link 產生方式有 regression。 get_verify_link 舊版透過 Flask 的 SERVER_NAME / APPLICATION_ROOT 產出 https://api.noj.tw/...。新版改用 str(request.url_for('active_redirect', ...)),用的是當下這個 request 的 host。在 Cloudflare/Caddy 後面時那是內部 hostname,除非安裝 ProxyHeadersMiddleware 且 uvicorn 用 --proxy-headers --forwarded-allow-ips 啟動。結果就是 prod 環境的 email 驗證連結和密碼還原連結會指到內部 URL。需要設定 proxy-headers 或改用明確的 base URL,而且 SERVER_NAME 這個環境變數被廢棄這件事應該寫進 PR description 或 deploy 文件。

NOJException 漏掉 detail super().__init__(status_code=status_code) 沒有傳 detail=message。萬一哪天自訂 handler 沒註冊到(或某個 subrouter 繞過),FastAPI 預設 handler 會回 {"detail": null}。很便宜的防線:super().__init__(status_code=status_code, detail=message)

TESTING 環境變數在 test 間會外洩。 conftest.pyos.environ['TESTING'] = '1' 但沒清掉。舊的 current_app.config['TESTING'] 是綁在每個 app instance 上。之後如果有人在同一個 process 起一個非 test 的 app,就會誤判成 testing mode。改成 monkeypatch.setenv('TESTING','1') 或在 finalizer 清掉。

raise_server_exceptions=False 會吞掉 500。 真的未處理的 exception 會變成 500 response 而不是讓 test fail,除非 assertion 有明確檢查 status_code ≠ 500。為了可移植性可以理解,但這份 diff 範圍這麼大,漏掉潛在 bug 的機率會升高。考慮翻成 True,然後預期會丟 exception 的地方明確處理。

小問題:_apply_cookies 去尾綴的做法。 k.replace('_httponly', '') 會取代所有出現的地方,不是只有 suffix。改用 removesuffix('_httponly')。目前沒有 key 會踩到這個坑,但還是個地雷。

小問題:mutable default args。 HTTPResponse/HTTPRedirect/_apply_cookies 裡的 cookies: dict = {}(目前都是唯讀,不會炸,但依然是 smell)。

專案慣例契合度

  • __all__*_api*_router 更名整份 PR 都有套用 — 好。
  • yapf 格式有維持。
  • raise HTTPException vs return HTTPError 現在混用:dependencies 用 raise(必須),handlers 用 return(也可)。建議未來統一全部用 raise,邏輯比較單純。
  • user_options_router 是在重現 Flask 的 before_request 來處理 OPTIONS preflight。FastAPI/Starlette 有一等公民的 CORSMiddleware 可處理 — 建議後續 PR 換掉。
  • @ann_router.get('')@ann_router.get('/{ann_id}') 疊在同一個 function 在 FastAPI 是可運作的,但會在 OpenAPI 產出兩個 operation 指向同一個 function。想讓文件乾淨的話可以拆開或加 include_in_schema
  • check/{item} 做 catch-all 只為了回 400 是從 Flask 搬過來的怪寫法 — 自然 404 比較 idiomatic,但維持原行為也說得通。

效能

  • httpx.Client(timeout=5.0)target_sandbox()send() 裡是每個 submission 建一個。建 client 不便宜(有 connection pool 之類的 setup)。建議用 module-level httpx.Clienthttpx.AsyncClient 跨 call 複用;傳進來的 client= 參數保留給 test 注入用(test 那邊已經這樣寫了)。
  • target_sandbox 還是同步輪詢每個 sandbox — 跟舊版一樣,不是 regression,但改成 async 是便宜的效能提升。
  • FastAPI + uvicorn 在 JSON I/O 一般比 Flask + gunicorn-gthread 快;要確認 gunicorn.conf.py 有改成用 uvicorn.workers.UvicornWorker(diff 裡沒看到 — pyproject.toml 拿掉 gunicorn 加了 uvicorn;確認 prod Dockerfile 的 CMD 也有同步更新)。

測試覆蓋

  • 新增 tests/unittest/submission/test_target_sandbox.pyhttpx.MockTransport,是不錯的加分(+84 行)。
  • 刪掉整份 tests/test_utils_request.py(-193 行)是合理的(測的是 Flask-specific 的 decorator 已經不存在),但是 — 替代品 get_docget_ip、作為 Depends 的 login_required沒有直接的 unit test。所有覆蓋都是 integration 級別。至少應該補:get_ipcf-connecting-ipX-Forwarded-Forclient.host 的優先順序)和 get_doc(DoesNotExist → 404、ValidationError → 400)。

安全性

  • JWT/cookie auth flow 有保留;piann cookie 的取值方式完全一致。
  • get_ip 的優先順序(cf-connecting-ipX-Forwarded-For 最後一項 → client.host)在 Cloudflare/Caddy 後面時沒問題,但如果後端哪天直接對外,這些 header 就是攻擊者可控的。這個風險舊版就有 — 不是 regression。建議在 CLAUDE.md 記錄 trust boundary。
  • RequestValidationError handler 只回一個通用的 'Invalid request body' 不 echo 任何 data — 好,不會洩漏 schema 細節。

建議

Approve with changes。合併前必修:

  1. Trailing slash 破壞性變更 — 跟 frontend 對齊,或加相容性層。
  2. request.url_for 產生的 external link 在 reverse proxy 後需要 proxy-headers 設定(deploy 文件也要註明 SERVER_NAME 已不再使用)。
  3. NOJException(..., detail=message) 作為防線。
  4. conftest.py 清理 TESTING 環境變數。

Nice-to-have(後續 PR):

  • 共享的 module-level httpx.Client
  • user_options_router 換成 CORSMiddleware
  • 為新的 dependency helpers 補 unit test。
  • TestClient 翻回 raise_server_exceptions=True

此 review 由 Claude Opus 4.6 Max Effort 產生,明早會再人工 review。

Comment thread model/auth.py
raise NOJException('Invalid Token', 403)
user = User(json['data']['username'])
if json['data'].get('userId') != user.user_id:
raise NOJException('Authorization Expired', 403)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it affect front end?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does the Auth Expiration check if userIds are matched?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's copied from original flask implementation. IIRC we wrote this error message because user_id is derived from username and password. user_id mismatch means the password has been changed since last time this jwt issued.

Back-End/model/auth.py

Lines 69 to 70 in fa79115

if json['data'].get('userId') != user.user_id:
return HTTPError('Authorization Expired', 403)

Bogay and others added 6 commits April 17, 2026 01:05
Replace gunicorn with uvicorn to match the FastAPI (ASGI) migration.
Use UVICORN_* env vars for runtime configuration so workers and other
flags can be tuned per deployment without rebuilding the image.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Using mutable objects (dict/list) as default argument values is a
well-known Python footgun — the same object is shared across all calls.
Replace with None and normalise to an empty collection in the body.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Using isinstance(case_upload, UploadFile) is more precise than
hasattr(..., 'read') and consistent with how file fields are handled
elsewhere (e.g. submission.py). Also returns a clear 400 when the
field is missing rather than propagating an AttributeError.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
A single httpx.Client is created at app startup via lifespan and stored
in app.state. Route handlers that trigger sandbox communication receive
it via Depends(get_http_client) and pass it down to submission.submit()
and submission.rejudge(). This ensures consistent timeout configuration
(Timeout(5.0, read=30.0)) and allows connection pool reuse across
requests.

The mongo layer keeps its client | None = None signatures so it remains
usable outside the FastAPI context (tests, scripts, copycat).

Update the client fixture to use a context manager so the lifespan runs
during tests and app.state.http_client is properly initialised.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
request.form() returns starlette.datastructures.UploadFile, not
fastapi.UploadFile — they are different classes, so isinstance would
always return False for valid file uploads.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 48 out of 49 changed files in this pull request and generated 6 comments.

Comments suppressed due to low confidence (1)

mongo/submission.py:305

  • target_sandbox() creates a new httpx.Client when client is None but never closes it, and client.get(...) can raise httpx.RequestError which will currently bubble up and potentially fail the whole submission flow. Prefer requiring a caller-managed client (from app lifespan) or creating the client with a context manager/try/finally to close it, and handle request exceptions by treating the sandbox as unhealthy.
    def target_sandbox(self, client: httpx.Client | None = None):
        if client is None:
            client = httpx.Client(timeout=5.0)
        load = 10**3  # current min load
        tar = None  # target
        for sb in self.config().sandbox_instances:
            resp = client.get(f'{sb.url}/status')
            if not resp.is_success:
                self.logger.warning(f'sandbox {sb.name} status exception')
                self.logger.warning(
                    f'status code: {resp.status_code}\n '
                    f'body: {resp.text}', )
                continue
            resp = resp.json()

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread model/announcement.py
Comment on lines +42 to +63
@course_router.get('/{course_name}/ann')
def get_course_announcements(course_name: str, user=Depends(login_required)):
try:
anns = Announcement.ann_list(user.obj, course_name or 'Public')
anns = Announcement.ann_list(user.obj, course_name)
except (DoesNotExist, ValidationError):
return HTTPError('Cannot Access a Announcement', 403)
if anns is None:
return HTTPError('Announcement Not Found', 404)
data = [{
'annId': str(an.id),
'title': an.title,
'createTime': int(an.create_time.timestamp()),
'updateTime': int(an.update_time.timestamp()),
'creator': an.creator.info,
'updater': an.updater.info,
'markdown': an.markdown,
'pinned': an.pinned
} for an in anns if ann_id is None or str(an.id) == ann_id]
data = [_format_ann(an) for an in anns]
return HTTPResponse('Announcement List', data=data)


@ann_api.post('/')
@login_required
@parse_body(CreateAnnouncementBody)
def create_announcement(user, body: CreateAnnouncementBody):
# Create a new announcement
@ann_router.get('/{course_name}/{ann_id}')
def get_ann_by_id(course_name: str, ann_id: str, user=Depends(login_required)):
try:
anns = Announcement.ann_list(user.obj, course_name)
except (DoesNotExist, ValidationError):
return HTTPError('Cannot Access a Announcement', 403)
if anns is None:
return HTTPError('Announcement Not Found', 404)
data = [_format_ann(an) for an in anns if str(an.id) == ann_id]
return HTTPResponse('Announcement List', data=data)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The route @ann_router.get('/{course_name}/{ann_id}') currently captures requests like /ann/Public/ann (previously the course announcement list endpoint) and then filters announcements by ann_id == 'ann', which will typically return an empty list and changes the API semantics. Add an explicit list route for /{course_name}/ann (or move list handling fully under /course/{course_name}/ann and update callers/tests), and ensure the more-specific list route is registered before the generic /{course_name}/{ann_id} route to avoid shadowing.

Copilot uses AI. Check for mistakes.
Comment thread model/copycat.py
Comment on lines 110 to 115
def get_report_by_url(url: str):
try:
response = requests.get(url)
response = httpx.get(url)
return response.text
except (requests.exceptions.MissingSchema,
requests.exceptions.InvalidSchema):
except httpx.InvalidURL:
return 'No report.'
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_report_by_url() calls httpx.get(url) without a timeout and only handles httpx.InvalidURL. Network errors/timeouts (e.g., httpx.RequestError) will currently raise and can fail the request; also a missing timeout can hang a worker. Pass an explicit timeout and handle httpx.RequestError by returning a safe fallback (or surfacing a controlled HTTPError).

Copilot uses AI. Check for mistakes.
Comment thread Dockerfile
Comment on lines +49 to +63
CMD ["uvicorn", "app:app"]

# 'production' stage uses the clean 'python-base' stage and copyies
# in only our runtime deps that were installed in the 'builder-base'
FROM python-base AS production
ENV UVICORN_HOST=0.0.0.0 \
UVICORN_PORT=8080 \
UVICORN_WORKERS=4

COPY --from=builder-base $VENV_PATH $VENV_PATH
COPY ./ /app

WORKDIR /app
EXPOSE 8080
CMD ["gunicorn", "app:app()", "-c", "gunicorn.conf.py"]
CMD ["uvicorn", "app:app"]
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CMD ["uvicorn", "app:app"] ignores the UVICORN_HOST/UVICORN_PORT/UVICORN_WORKERS env vars; uvicorn will default to 127.0.0.1:8000, which typically makes the container unreachable and also runs a single worker in production. Pass --host/--port (and --workers in production, --reload in development) explicitly in the CMD/ENTRYPOINT, or add a small entrypoint script that maps these env vars to uvicorn flags.

Copilot uses AI. Check for mistakes.
Comment thread app.py
Comment on lines +14 to +16
app.state.http_client = httpx.Client(timeout=httpx.Timeout(5.0, read=30.0))
yield
app.state.http_client.close()
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lifespan() creates a synchronous httpx.Client and stores it on app.state. Any async endpoints that use this client will perform blocking I/O on the event loop. Prefer httpx.AsyncClient here (and close it with await aclose()), or ensure all handlers that use it are def (sync) so Starlette runs them in a threadpool.

Suggested change
app.state.http_client = httpx.Client(timeout=httpx.Timeout(5.0, read=30.0))
yield
app.state.http_client.close()
app.state.http_client = httpx.AsyncClient(
timeout=httpx.Timeout(5.0, read=30.0))
yield
await app.state.http_client.aclose()

Copilot uses AI. Check for mistakes.
Comment thread model/submission.py
Comment on lines +377 to 399
@submission_router.put('/{submission_id}')
async def update_submission(
submission_id: str,
code: Optional[UploadFile] = File(default=None),
user=Depends(login_required),
submission: Submission = get_doc('submission_id', Submission),
http_client: httpx.Client = Depends(get_http_client),
):
if submission.status >= 0:
return HTTPError(
f'{submission} has finished judgement.',
403,
)
# if user not equal, reject
return HTTPError(f'{submission} has finished judgement.', 403)
if not secrets.compare_digest(submission.user.username, user.username):
return HTTPError('user not equal!', 403)
# if source code not found
if code is None:
return HTTPError(
f'can not find the source file',
400,
)
# or empty file
if len(code.read()) == 0:
return HTTPError('can not find the source file', 400)
content = await code.read()
if len(content) == 0:
return HTTPError('empty file', 400)
code.seek(0)
# has been uploaded
code_file = io.BytesIO(content)
if submission.has_code():
return HTTPError(
f'{submission} has been uploaded source file!',
403,
)
return HTTPError(f'{submission} has been uploaded source file!', 403)
try:
success = submission.submit(code)
success = submission.submit(code_file, client=http_client)
except FileExistsError:
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_submission is async def but calls submission.submit(..., client=http_client) where http_client is a synchronous httpx.Client and submit/send/target_sandbox do synchronous network I/O. This can block the event loop under load. Consider switching to an httpx.AsyncClient + async implementations, or run the blocking submit/send logic in a threadpool (e.g., run_in_threadpool) after reading the upload bytes.

Copilot uses AI. Check for mistakes.
Comment thread model/submission.py
Comment on lines 399 to 400
except FileExistsError:
exit(10086)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching FileExistsError and calling exit(10086) will terminate the entire API process (all in-flight requests) if this exception ever occurs. This should return an HTTP error (or raise a handled exception) instead of exiting.

Suggested change
except FileExistsError:
exit(10086)
except FileExistsError as e:
return HTTPError(str(e), 409)

Copilot uses AI. Check for mistakes.
Comment thread Dockerfile
WORKDIR /app
EXPOSE 8080
CMD ["gunicorn", "app:app()", "-c", "gunicorn.conf.py"]
CMD ["uvicorn", "app:app"]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CMD ["uvicorn", "app:app", "--proxy-headers", "--forwarded-allow-ips=*"]

Comment thread pyproject.toml
minio = "^7.2.15"
python-ulid = "^3.0.0"
pydantic = "^2.0"
fastapi = ">=0.115"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ is better than >= (?

Comment thread app.py
{
'status': 'err',
'message': 'Invalid request body',
'data': None,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread model/problem.py
request: Request,
user: User = identity_verify(0, 1),
):
problem = Problem(problem_id)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sync method in async method

Comment thread model/problem.py
problem = Problem(problem_id)
if not problem:
raise NOJException('Problem not found', 404)
if not problem.permission(user, problem.Permission.MANAGE):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sync method in async method

Comment thread model/problem.py
except Exception:
return HTTPError('Invalid or missing arguments.', 400)
try:
Problem.edit_problem(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sync method in async method

Comment thread model/problem.py
return HTTPError('missing or invalid form field: case', 400)
case = BytesIO(await case_upload.read())
try:
problem.update_test_case(case)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sync method in async method

Comment thread model/submission.py
def update_submission(user, submission: Submission, code):
# validate this reques
@submission_router.put('/{submission_id}')
async def update_submission(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove async or accept Copilot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants