diff --git a/src/sdlc_hub/modules/wiki/api.py b/src/sdlc_hub/modules/wiki/api.py index d9eac34..476852c 100644 --- a/src/sdlc_hub/modules/wiki/api.py +++ b/src/sdlc_hub/modules/wiki/api.py @@ -73,8 +73,9 @@ def h_page(hub, conn, m, q, body): end = text.find("\n---\n", 4) text = text[end + 5:].lstrip("\n") if end != -1 else text atts = [{"id": a["id"], "name": a["name"], "state": a["state"], "size": a["size"], - "error": a["error"]} for a in conn.execute( - "SELECT id, name, state, size, error FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", (label, code))] + "error": a["error"], "image": attachments.is_image(a["url"])} for a in conn.execute( + "SELECT id, name, state, size, error, url FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", + (label, code))] return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"], "markdown": text, "attachments": atts} @@ -122,7 +123,7 @@ def h_attachment_fetch(hub, conn, m, q, body): if not isinstance(author, str): raise ApiError(422, "bad_request", "author — строка") try: - info = attachments.request_fetch(conn, int_group(m), author) + info = attachments.request_fetch(conn, int_group(m), author, hub.files_dir) except KeyError: raise ApiError(404, "not_found", f"вложение {m.group(1)}") from None queued = info.pop("queued") diff --git a/src/sdlc_hub/modules/wiki/attachments.py b/src/sdlc_hub/modules/wiki/attachments.py index dd9e2e5..818ffa7 100644 --- a/src/sdlc_hub/modules/wiki/attachments.py +++ b/src/sdlc_hub/modules/wiki/attachments.py @@ -1,10 +1,18 @@ """Вложения вики по запросу: состояние, постановка wiki_fetch и хуки его результата (spec wiki «Вложения вики по запросу»). Адрес вложения хаб берёт только из своей таблицы wiki_attachment — из запросов клиента он не приходит.""" -from urllib.parse import quote +from urllib.parse import quote, unquote, urlsplit from ...core import db, files, queue FIELDS = "id, label, page_code, url, name, state, file, size, error" +IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp") # SVG — нет: активный формат из недоверенного источника + + +def is_image(url: str | None) -> bool: + """Картинка ли вложение — по имени файла в адресе Eva: имя вложения (alt/title/текст ссылки) бывает без расширения.""" + if not url: + return False + return unquote(urlsplit(url).path.rsplit("/", 1)[-1]).lower().endswith(IMAGE_EXT) def _public(row) -> dict: @@ -33,15 +41,17 @@ def _active(conn, attachment_id: int) -> bool: "AND json_extract(params, '$.attachment_id') = ?", (attachment_id,)).fetchone() is not None -def request_fetch(conn, attachment_id: int, author: str | None) -> dict: +def request_fetch(conn, attachment_id: int, author: str | None, files_dir) -> dict: """Поставить скачивание. Одна транзакция: `ready` и `fetching` отвечают как есть; иначе state = fetching и пакет - wiki_fetch. `fetching` без живой команды (пакет отменён) — зависшее состояние: ставится новый пакет. + wiki_fetch. `fetching` без живой команды (пакет отменён) — зависшее состояние: ставится новый пакет. `ready`, чьего + файла нет в files/, — тоже нескачанное (T8.2); files_dir None — файл не сверяется. Ответ — {id, name, page_code, url, state, size, error, queued}. KeyError — вложения нет.""" with db.transaction(conn): row = conn.execute(f"SELECT {FIELDS} FROM wiki_attachment WHERE id = ?", (attachment_id,)).fetchone() if row is None: raise KeyError(attachment_id) - if row["state"] == "ready" or (row["state"] == "fetching" and _active(conn, attachment_id)): + on_disk = files_dir is None or files.size(files_dir, row["file"]) is not None + if (row["state"] == "ready" and on_disk) or (row["state"] == "fetching" and _active(conn, attachment_id)): return dict(_public(row), queued=False) title = f"Вложение вики {file_name(_public(row))}" queue.insert_batch(conn, title, author, [{"type": "wiki_fetch", "params": {"attachment_id": row["id"], "url": row["url"]}}], diff --git a/tests/test_wiki_api.py b/tests/test_wiki_api.py index 743ac74..6cfe05f 100644 --- a/tests/test_wiki_api.py +++ b/tests/test_wiki_api.py @@ -10,7 +10,8 @@ import pytest from sdlc_hub.core import files from sdlc_hub.core.client import HubError -from test_wiki_convert import FILE_REF, FILE_URL, THUMB_REF, _html +from sdlc_hub.modules.wiki import attachments +from test_wiki_convert import FILE_REF, FILE_URL, IMG_REF, THUMB_REF, _html from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура ATTS = [{"ref": THUMB_REF, "url": FILE_URL, "name": "model.xlsx"}, {"ref": FILE_REF, "url": FILE_URL, "name": "model.xlsx"}] @@ -62,7 +63,7 @@ def test_status_tree_page(api): p = api.client.get("/wiki/pages/DOC-3", project="AWPCA") assert p["title"] == "Статусы" and p["url"].endswith("DOC-3") and p["synced_at"] assert f"attachment:{aid}" in p["markdown"] and not p["markdown"].startswith("---") - assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None, "error": None}] + assert p["attachments"] == [{"id": aid, "name": "model.xlsx", "state": "none", "size": None, "error": None, "image": False}] assert err(api.client.get, "/wiki/pages/DOC-99", project="AWPCA").status == 404 @@ -237,3 +238,43 @@ def test_sync_project_not_string_422(api, project): e = err(api.client.post, "/wiki/sync", {"project": project}) assert e.status == 422 and e.error == "bad_request" and e.detail == "project — строка" assert api.rows("SELECT id FROM batch") == before # пакета не создано + + +# пакет D (#135): T8.2 — вложение без файла, признак картинки +def _ready(api, aid, data=b"hello"): + api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + f = upload(api, data) + api.report(fetch_cmd(api), "done", {"file": f["file"], "size": f["size"]}) + return f["file"] + + +def test_fetch_ready_file_missing_requeues(api): + aid = synced(api) + sha = _ready(api, aid) + (api.wiki_dir.parent / "files" / sha).unlink() + r = api.client.post(f"/wiki/attachments/{aid}/fetch", {}) + assert r["state"] == "fetching" + assert fetch_cmd(api)["params"]["attachment_id"] == aid + + +def test_fetch_ready_file_present_no_batch(api): + aid = synced(api) + _ready(api, aid) + n = len(api.rows("SELECT id FROM batch")) + assert api.client.post(f"/wiki/attachments/{aid}/fetch", {})["state"] == "ready" + assert len(api.rows("SELECT id FROM batch")) == n + + +def test_page_attachment_image_flag(api): + html = f'

файл

' + synced(api, html, [{"ref": FILE_REF, "url": FILE_URL, "name": "attachment"}, + {"ref": IMG_REF, "url": "https://eva.example.test" + IMG_REF, "name": "Схема без расширения"}]) + atts = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"] + assert [(a["name"], a["image"]) for a in atts] == [("attachment", False), ("Схема без расширения", True)] + + +@pytest.mark.parametrize("url,expected", [("https://e/files/a/x.PNG?version=1", True), ("https://e/files/a/x.svg", False), + ("https://e/files/a/%D1%81.jpeg", True), (None, False), + ("https://e/files/a/x.png.meta/", False), ("https://e/files/a/x.xlsx", False)]) +def test_is_image(url, expected): + assert attachments.is_image(url) is expected