Optimization
This commit is contained in:
@@ -545,6 +545,28 @@ async def remove_gitea_asset(row):
|
|||||||
logger.warning("Gitea asset delete failed for %s: %s", row.get("uid"), exc)
|
logger.warning("Gitea asset delete failed for %s: %s", row.get("uid"), exc)
|
||||||
|
|
||||||
|
|
||||||
|
_pending_gitea_tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _fire_and_forget(coro) -> None:
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
coro.close()
|
||||||
|
return
|
||||||
|
task = loop.create_task(coro)
|
||||||
|
_pending_gitea_tasks.add(task)
|
||||||
|
task.add_done_callback(_pending_gitea_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_gitea_mirror(uid: str) -> None:
|
||||||
|
_fire_and_forget(mirror_attachment_to_gitea(uid))
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_gitea_removal(row: dict) -> None:
|
||||||
|
_fire_and_forget(remove_gitea_asset(row))
|
||||||
|
|
||||||
|
|
||||||
def _unlink_attachment_files(row):
|
def _unlink_attachment_files(row):
|
||||||
stored_name = row.get("stored_name", "")
|
stored_name = row.get("stored_name", "")
|
||||||
directory = row.get("directory", "")
|
directory = row.get("directory", "")
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from devplacepy.attachments import (
|
|||||||
get_attachments,
|
get_attachments,
|
||||||
get_orphan_attachments_batch,
|
get_orphan_attachments_batch,
|
||||||
link_attachments,
|
link_attachments,
|
||||||
mirror_attachment_to_gitea,
|
schedule_gitea_mirror,
|
||||||
remove_gitea_asset,
|
schedule_gitea_removal,
|
||||||
soft_delete_attachment,
|
soft_delete_attachment,
|
||||||
split_attachment_uids,
|
split_attachment_uids,
|
||||||
)
|
)
|
||||||
@@ -96,7 +96,7 @@ async def add_issue_attachment(
|
|||||||
return json_error(400, "No valid attachments to add")
|
return json_error(400, "No valid attachments to add")
|
||||||
link_attachments(owned, "issue", str(number))
|
link_attachments(owned, "issue", str(number))
|
||||||
for uid in owned:
|
for uid in owned:
|
||||||
await mirror_attachment_to_gitea(uid)
|
schedule_gitea_mirror(uid)
|
||||||
audit.record(
|
audit.record(
|
||||||
request,
|
request,
|
||||||
"issue.attachment.add",
|
"issue.attachment.add",
|
||||||
@@ -146,7 +146,7 @@ async def delete_issue_attachment(request: Request, number: int, uid: str):
|
|||||||
)
|
)
|
||||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
return json_error(409, "Attachments cannot be changed on a closed issue")
|
||||||
soft_delete_attachment(uid, deleted_by=user["uid"])
|
soft_delete_attachment(uid, deleted_by=user["uid"])
|
||||||
await remove_gitea_asset(att)
|
schedule_gitea_removal(att)
|
||||||
audit.record(
|
audit.record(
|
||||||
request,
|
request,
|
||||||
"issue.attachment.delete",
|
"issue.attachment.delete",
|
||||||
@@ -194,7 +194,7 @@ async def add_comment_attachment(
|
|||||||
return json_error(400, "No valid attachments to add")
|
return json_error(400, "No valid attachments to add")
|
||||||
link_attachments(owned, "issue_comment", str(cid))
|
link_attachments(owned, "issue_comment", str(cid))
|
||||||
for uid in owned:
|
for uid in owned:
|
||||||
await mirror_attachment_to_gitea(uid)
|
schedule_gitea_mirror(uid)
|
||||||
audit.record(
|
audit.record(
|
||||||
request,
|
request,
|
||||||
"issue.attachment.add",
|
"issue.attachment.add",
|
||||||
@@ -237,7 +237,7 @@ async def delete_comment_attachment(
|
|||||||
if issue.get("state") != STATE_OPEN:
|
if issue.get("state") != STATE_OPEN:
|
||||||
return json_error(409, "Attachments cannot be changed on a closed issue")
|
return json_error(409, "Attachments cannot be changed on a closed issue")
|
||||||
soft_delete_attachment(uid, deleted_by=user["uid"])
|
soft_delete_attachment(uid, deleted_by=user["uid"])
|
||||||
await remove_gitea_asset(att)
|
schedule_gitea_removal(att)
|
||||||
audit.record(
|
audit.record(
|
||||||
request,
|
request,
|
||||||
"issue.attachment.delete",
|
"issue.attachment.delete",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from fastapi import Depends, APIRouter, Request
|
|||||||
from devplacepy.attachments import (
|
from devplacepy.attachments import (
|
||||||
get_orphan_attachments_batch,
|
get_orphan_attachments_batch,
|
||||||
link_attachments,
|
link_attachments,
|
||||||
mirror_attachment_to_gitea,
|
schedule_gitea_mirror,
|
||||||
split_attachment_uids,
|
split_attachment_uids,
|
||||||
)
|
)
|
||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
@@ -73,7 +73,7 @@ async def comment_issue(
|
|||||||
if owned:
|
if owned:
|
||||||
link_attachments(owned, "issue_comment", str(comment_id))
|
link_attachments(owned, "issue_comment", str(comment_id))
|
||||||
for uid in owned:
|
for uid in owned:
|
||||||
await mirror_attachment_to_gitea(uid)
|
schedule_gitea_mirror(uid)
|
||||||
background.submit(_notify_admins, user, number)
|
background.submit(_notify_admins, user, number)
|
||||||
audit.record(
|
audit.record(
|
||||||
request,
|
request,
|
||||||
|
|||||||
+892
-19
File diff suppressed because it is too large
Load Diff
@@ -83,3 +83,88 @@ def test_planning_enqueue_without_numbers_is_all_open(seeded_db):
|
|||||||
refresh_snapshot()
|
refresh_snapshot()
|
||||||
job = get_table("jobs").find_one(uid=uid)
|
job = get_table("jobs").find_one(uid=uid)
|
||||||
assert json.loads(job["payload"])["numbers"] == []
|
assert json.loads(job["payload"])["numbers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_done(uid, result):
|
||||||
|
get_table("jobs").update(
|
||||||
|
{"uid": uid, "status": "done", "result": json.dumps(result)}, ["uid"]
|
||||||
|
)
|
||||||
|
refresh_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
def test_planning_download_serves_the_report_file(seeded_db):
|
||||||
|
from devplacepy.config import PLANNING_REPORTS_DIR
|
||||||
|
from devplacepy.services.jobs import queue
|
||||||
|
|
||||||
|
admin = _admin(seeded_db)
|
||||||
|
uid = queue.enqueue(
|
||||||
|
"planning", {"numbers": []}, "user", "planning-download-owner", "open-tickets-plan"
|
||||||
|
)
|
||||||
|
report_dir = PLANNING_REPORTS_DIR / "ab" / "cd"
|
||||||
|
report_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path = report_dir / f"{uid}.open-tickets-plan.md"
|
||||||
|
report_path.write_text("# Plan\n\nDo the thing.\n", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
_mark_done(
|
||||||
|
uid,
|
||||||
|
{
|
||||||
|
"download_url": f"/issues/planning/{uid}/download",
|
||||||
|
"local_path": str(report_path),
|
||||||
|
"final_name": "open-tickets-plan.md",
|
||||||
|
"markdown": "# Plan\n\nDo the thing.\n",
|
||||||
|
"ai_used": False,
|
||||||
|
"item_count": 0,
|
||||||
|
"bytes_out": report_path.stat().st_size,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = admin.get(f"{BASE_URL}/issues/planning/{uid}/download")
|
||||||
|
assert response.status_code == 200, response.text[:300]
|
||||||
|
assert response.headers["content-type"].startswith("text/markdown")
|
||||||
|
assert "Do the thing." in response.text
|
||||||
|
finally:
|
||||||
|
get_table("jobs").delete(uid=uid)
|
||||||
|
report_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_planning_download_rejects_a_local_path_outside_the_reports_dir(seeded_db):
|
||||||
|
from devplacepy.config import DATA_DIR
|
||||||
|
from devplacepy.services.jobs import queue
|
||||||
|
|
||||||
|
admin = _admin(seeded_db)
|
||||||
|
uid = queue.enqueue(
|
||||||
|
"planning", {"numbers": []}, "user", "planning-traversal-owner", "open-tickets-plan"
|
||||||
|
)
|
||||||
|
escape_path = DATA_DIR / f"escaped-{uid}.md"
|
||||||
|
escape_path.write_text("secret", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
_mark_done(
|
||||||
|
uid,
|
||||||
|
{
|
||||||
|
"download_url": f"/issues/planning/{uid}/download",
|
||||||
|
"local_path": str(escape_path),
|
||||||
|
"final_name": "escaped.md",
|
||||||
|
"markdown": "secret",
|
||||||
|
"ai_used": False,
|
||||||
|
"item_count": 0,
|
||||||
|
"bytes_out": escape_path.stat().st_size,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = admin.get(f"{BASE_URL}/issues/planning/{uid}/download")
|
||||||
|
assert response.status_code == 404
|
||||||
|
finally:
|
||||||
|
get_table("jobs").delete(uid=uid)
|
||||||
|
escape_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_planning_download_pending_job_is_404(seeded_db):
|
||||||
|
from devplacepy.services.jobs import queue
|
||||||
|
|
||||||
|
admin = _admin(seeded_db)
|
||||||
|
uid = queue.enqueue(
|
||||||
|
"planning", {"numbers": []}, "user", "planning-pending-owner", "open-tickets-plan"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = admin.get(f"{BASE_URL}/issues/planning/{uid}/download")
|
||||||
|
assert response.status_code == 404
|
||||||
|
finally:
|
||||||
|
get_table("jobs").delete(uid=uid)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from devplacepy.database import db, get_table, refresh_snapshot
|
||||||
|
from devplacepy.services.devii.tasks.schedule import now_utc, to_iso
|
||||||
|
from devplacepy.utils import generate_uid
|
||||||
|
from tests.conftest import BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_uid():
|
||||||
|
refresh_snapshot()
|
||||||
|
return get_table("users").find_one(username="alice_test")["uid"]
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_admin_task(label, enabled=True):
|
||||||
|
uid = generate_uid()
|
||||||
|
db["devii_tasks"].insert(
|
||||||
|
{
|
||||||
|
"uid": uid,
|
||||||
|
"owner_kind": "user",
|
||||||
|
"owner_id": _admin_uid(),
|
||||||
|
"label": label,
|
||||||
|
"prompt": "work",
|
||||||
|
"enabled": enabled,
|
||||||
|
"status": "pending" if enabled else "disabled",
|
||||||
|
"kind": "interval",
|
||||||
|
"every_seconds": 900,
|
||||||
|
"max_runs": 10,
|
||||||
|
"run_count": 1,
|
||||||
|
"failure_count": 0,
|
||||||
|
"created_at": to_iso(now_utc()),
|
||||||
|
"next_run_at": to_iso(now_utc()),
|
||||||
|
"expires_at": None,
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
def test_devii_tasks_page_renders_for_admin(alice):
|
||||||
|
page, _ = alice
|
||||||
|
page.goto(f"{BASE_URL}/admin/devii-tasks", wait_until="domcontentloaded")
|
||||||
|
page.locator(".admin-toolbar h2:has-text('Devii tasks')").wait_for(state="visible")
|
||||||
|
assert page.locator("table.admin-table").count() == 1
|
||||||
|
assert page.locator("nav.admin-tabs a.admin-tab").count() == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_devii_tasks_member_redirects_to_feed(bob):
|
||||||
|
page, _ = bob
|
||||||
|
page.goto(f"{BASE_URL}/admin/devii-tasks", wait_until="domcontentloaded")
|
||||||
|
assert page.url.rstrip("/") == f"{BASE_URL}/feed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_devii_tasks_disable_flow(alice):
|
||||||
|
page, _ = alice
|
||||||
|
label = f"e2e-disable-{generate_uid()[:8]}"
|
||||||
|
uid = _seed_admin_task(label, enabled=True)
|
||||||
|
try:
|
||||||
|
page.goto(f"{BASE_URL}/admin/devii-tasks", wait_until="domcontentloaded")
|
||||||
|
row = page.locator("table.admin-table tbody tr", has_text=label)
|
||||||
|
row.wait_for(state="visible")
|
||||||
|
row.locator("form.admin-inline-form button:has-text('Disable')").click()
|
||||||
|
page.wait_for_url("**/admin/devii-tasks", wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
refresh_snapshot()
|
||||||
|
after = db["devii_tasks"].find_one(uid=uid)
|
||||||
|
assert after["enabled"] is False
|
||||||
|
assert after["status"] == "disabled"
|
||||||
|
|
||||||
|
page.goto(f"{BASE_URL}/admin/devii-tasks?state=inactive", wait_until="domcontentloaded")
|
||||||
|
disabled_row = page.locator("table.admin-table tbody tr", has_text=label)
|
||||||
|
disabled_row.wait_for(state="visible")
|
||||||
|
assert disabled_row.locator("button:has-text('Disable')").count() == 0
|
||||||
|
finally:
|
||||||
|
db["devii_tasks"].delete(uid=uid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_devii_tasks_delete_flow(alice):
|
||||||
|
page, _ = alice
|
||||||
|
label = f"e2e-delete-{generate_uid()[:8]}"
|
||||||
|
uid = _seed_admin_task(label, enabled=True)
|
||||||
|
try:
|
||||||
|
page.goto(f"{BASE_URL}/admin/devii-tasks", wait_until="domcontentloaded")
|
||||||
|
row = page.locator("table.admin-table tbody tr", has_text=label)
|
||||||
|
row.wait_for(state="visible")
|
||||||
|
|
||||||
|
row.locator("form.admin-inline-form button:has-text('Delete')").click()
|
||||||
|
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
||||||
|
page.wait_for_url("**/admin/devii-tasks", wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
assert page.locator("table.admin-table tbody tr", has_text=label).count() == 0
|
||||||
|
refresh_snapshot()
|
||||||
|
assert db["devii_tasks"].find_one(uid=uid, deleted_at=None) is None
|
||||||
|
finally:
|
||||||
|
db["devii_tasks"].delete(uid=uid)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from tests.conftest import BASE_URL
|
||||||
|
from devplacepy.database import get_table, refresh_snapshot
|
||||||
|
|
||||||
|
_counter_admin_trash = [0]
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_admin_trash(prefix="atr"):
|
||||||
|
_counter_admin_trash[0] += 1
|
||||||
|
return f"{prefix}{int(time.time() * 1000)}{_counter_admin_trash[0]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _bob_key():
|
||||||
|
refresh_snapshot()
|
||||||
|
return get_table("users").find_one(username="bob_test")["api_key"]
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_deleted_post():
|
||||||
|
key = _bob_key()
|
||||||
|
headers = {"Accept": "application/json", "X-API-KEY": key}
|
||||||
|
title = _unique_admin_trash("atrp")
|
||||||
|
created = requests.post(
|
||||||
|
f"{BASE_URL}/posts/create",
|
||||||
|
headers=headers,
|
||||||
|
data={"title": title, "content": "admin trash e2e post body", "topic": "devlog"},
|
||||||
|
).json()["data"]
|
||||||
|
requests.post(
|
||||||
|
f"{BASE_URL}/posts/delete/{created['slug']}", headers=headers, allow_redirects=False
|
||||||
|
)
|
||||||
|
return created, title
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_page_renders_for_admin(alice):
|
||||||
|
page, _ = alice
|
||||||
|
page.goto(f"{BASE_URL}/admin/trash", wait_until="domcontentloaded")
|
||||||
|
page.locator(".admin-toolbar h2:has-text('Trash')").wait_for(state="visible")
|
||||||
|
assert page.locator("table.admin-table").count() == 1
|
||||||
|
assert page.locator("nav.admin-tabs a.admin-tab").count() >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_member_redirects_to_feed(bob):
|
||||||
|
page, _ = bob
|
||||||
|
page.goto(f"{BASE_URL}/admin/trash", wait_until="domcontentloaded")
|
||||||
|
assert page.url.rstrip("/") == f"{BASE_URL}/feed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_restore_removes_row_and_revives_the_post(alice):
|
||||||
|
page, _ = alice
|
||||||
|
post, title = _seed_deleted_post()
|
||||||
|
page.goto(f"{BASE_URL}/admin/trash?table=posts", wait_until="domcontentloaded")
|
||||||
|
row = page.locator("table.admin-table tbody tr", has_text=title)
|
||||||
|
row.wait_for(state="visible")
|
||||||
|
|
||||||
|
row.locator("form.admin-inline-form button:has-text('Restore')").click()
|
||||||
|
page.wait_for_url("**/admin/trash?table=posts", wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
assert page.locator("table.admin-table tbody tr", has_text=title).count() == 0
|
||||||
|
|
||||||
|
refresh_snapshot()
|
||||||
|
revived = get_table("posts").find_one(uid=post["uid"])
|
||||||
|
assert revived["deleted_at"] is None
|
||||||
@@ -640,6 +640,64 @@ def test_admin_edits_other_user(alice):
|
|||||||
reset_notification_prefs(target["uid"])
|
reset_notification_prefs(target["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def _await_notification_enabled(uid, notification_type, channel, expected):
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
deadline = _time.time() + 5.0
|
||||||
|
current = notification_enabled(uid, notification_type, channel)
|
||||||
|
while current != expected and _time.time() < deadline:
|
||||||
|
_time.sleep(0.2)
|
||||||
|
current = notification_enabled(uid, notification_type, channel)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_reset_via_http_clears_custom_prefs(bob):
|
||||||
|
_, user = bob
|
||||||
|
target = _user_notification_prefs(user["username"])
|
||||||
|
set_notification_pref(target["uid"], "mention", "push", False)
|
||||||
|
try:
|
||||||
|
assert _await_notification_enabled(target["uid"], "mention", "push", False) is False
|
||||||
|
|
||||||
|
from devplacepy.database import get_table, refresh_snapshot
|
||||||
|
|
||||||
|
audit_table = get_table("audit_log")
|
||||||
|
before = audit_table.count(
|
||||||
|
event_key="profile.notification.reset", target_uid=target["uid"]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
f"{BASE_URL}/profile/{target['username']}/notifications/reset",
|
||||||
|
headers={"Accept": "application/json", "X-API-KEY": target["api_key"]},
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["redirect"] == f"/profile/{target['username']}?tab=notifications"
|
||||||
|
|
||||||
|
assert _await_notification_enabled(target["uid"], "mention", "push", True) is True
|
||||||
|
|
||||||
|
refresh_snapshot()
|
||||||
|
after = audit_table.count(
|
||||||
|
event_key="profile.notification.reset", target_uid=target["uid"]
|
||||||
|
)
|
||||||
|
assert after == before + 1
|
||||||
|
finally:
|
||||||
|
reset_notification_prefs(target["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_owner_non_admin_reset_forbidden(bob):
|
||||||
|
_, user = bob
|
||||||
|
member = _user_notification_prefs(user["username"])
|
||||||
|
target = _user_notification_prefs("alice_test")
|
||||||
|
response = requests.post(
|
||||||
|
f"{BASE_URL}/profile/{target['username']}/notifications/reset",
|
||||||
|
headers={"Accept": "application/json", "X-API-KEY": member["api_key"]},
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_profile_search_returns_results(alice):
|
def test_profile_search_returns_results(alice):
|
||||||
page, _ = alice
|
page, _ = alice
|
||||||
resp = page.request.get(f"{BASE_URL}/profile/search?q=bob")
|
resp = page.request.get(f"{BASE_URL}/profile/search?q=bob")
|
||||||
|
|||||||
Reference in New Issue
Block a user