From 3a4c6b14d538c5e4bc70dc9101a534c6d9d3c291 Mon Sep 17 00:00:00 2001 From: retoor Date: Fri, 29 May 2026 00:45:07 +0200 Subject: [PATCH] Update --- AGENTS.md | 1 - Makefile | 5 +- README.md | 1 - devplacepy/attachments.py | 6 ++- devplacepy/cli.py | 48 ++++++------------ devplacepy/routers/feed.py | 28 +++++------ devplacepy/routers/notifications.py | 16 +++++- devplacepy/routers/votes.py | 5 +- devplacepy/templates/notifications.html | 6 +++ tests/test_notifications.py | 65 +++++++++++++++++++++++++ 10 files changed, 121 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f91041a..8b45379 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,6 @@ make dev # uvicorn --reload on port 10500, backlog 4096 make prod # uvicorn with 2 workers, backlog 8192 (production) make test # Playwright integration + unit tests (fail-fast -x) make test-headed # same tests in visible browser -make demo # full-journey GUI demo (headed) make locust # Locust load test (interactive web UI) make locust-headless # Locust in headless CLI mode (for CI) ``` diff --git a/Makefile b/Makefile index 06fbf50..e1e25a5 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ LOCUST_SPAWN_RATE ?= 5 LOCUST_RUN_TIME ?= 120s DEVPLACE_RATE_LIMIT ?= 1000000 -.PHONY: install dev clean test test-headed demo locust locust-headless +.PHONY: install dev clean test test-headed locust locust-headless install: pip install -e . @@ -24,9 +24,6 @@ test: test-headed: PLAYWRIGHT_HEADLESS=0 python -m pytest tests/ -v --tb=line -x -demo: - PLAYWRIGHT_HEADLESS=0 python -m pytest tests/test_demo.py -v -s --tb=line -x - locust: export DEVPLACE_DATABASE_URL="sqlite:///$(LOCUST_DB)"; \ export DEVPLACE_RATE_LIMIT=$(DEVPLACE_RATE_LIMIT); \ diff --git a/README.md b/README.md index ebb67eb..db98f50 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ make install # pip install -e . make dev # uvicorn --reload on port 10500 make test # Playwright integration + unit tests, headless, fail-fast make test-headed # same tests in visible browser -make demo # full-journey GUI demo (headed) ``` Open `http://localhost:10500`. diff --git a/devplacepy/attachments.py b/devplacepy/attachments.py index c0666d4..d092401 100644 --- a/devplacepy/attachments.py +++ b/devplacepy/attachments.py @@ -250,7 +250,11 @@ def get_attachments_batch(target_type, uids): def _row_to_attachment(row): stored_name = row.get("stored_name", "") directory = row.get("directory", "") - thumb_name = f"{Path(stored_name).stem}_thumb.jpg" if row.get("has_thumbnail") else None + thumb_name = None + if row.get("has_thumbnail"): + stem = Path(stored_name).stem + matches = sorted((ATTACHMENTS_DIR / directory).glob(f"{stem}_thumb.*")) + thumb_name = matches[0].name if matches else f"{stem}_thumb.jpg" return { "uid": row["uid"], "original_filename": row.get("original_filename", ""), diff --git a/devplacepy/cli.py b/devplacepy/cli.py index c552654..664c241 100644 --- a/devplacepy/cli.py +++ b/devplacepy/cli.py @@ -58,43 +58,22 @@ def cmd_news_sanitize(args): def cmd_attachments_prune(args): + from datetime import datetime, timezone, timedelta from devplacepy.database import db - from devplacepy.config import STATIC_DIR - deleted_records = 0 - deleted_files = 0 - freed_bytes = 0 + from devplacepy.attachments import delete_attachment - if "attachments" in db.tables: - orphans = list(db["attachments"].find(resource_uid="")) - orphans += list(db["attachments"].find(resource_type="")) - seen = set() - unique_orphans = [] - for o in orphans: - if o["uid"] not in seen: - seen.add(o["uid"]) - unique_orphans.append(o) + if "attachments" not in db.tables: + print("Attachments table does not exist") + return - for att in unique_orphans: - sp = att.get("storage_path", "") - if sp: - fp = STATIC_DIR / "uploads" / sp - try: - if fp.exists(): - freed_bytes += fp.stat().st_size - fp.unlink() - deleted_files += 1 - parent = fp.parent - if parent.exists() and not any(parent.iterdir()): - parent.rmdir() - grandparent = parent.parent - if grandparent.exists() and not any(grandparent.iterdir()): - grandparent.rmdir() - except Exception as e: - print(f" Error deleting {sp}: {e}") - db["attachments"].delete(id=att["id"]) - deleted_records += 1 - - print(f"Pruned {deleted_records} orphan records, {deleted_files} files, {freed_bytes / 1024:.1f} KB freed") + cutoff = (datetime.now(timezone.utc) - timedelta(hours=args.hours)).isoformat() + orphans = [ + att for att in db["attachments"].find(target_type="", target_uid="") + if att.get("created_at", "") < cutoff + ] + for att in orphans: + delete_attachment(att["uid"]) + print(f"Pruned {len(orphans)} orphan attachment(s) older than {args.hours}h") def main(): @@ -123,6 +102,7 @@ def main(): attachments = sub.add_parser("attachments", help="Attachment management") att_sub = attachments.add_subparsers(title="action", dest="action") att_prune = att_sub.add_parser("prune", help="Remove orphaned attachment records and files") + att_prune.add_argument("--hours", type=int, default=24, help="Only prune orphans older than this many hours") att_prune.set_defaults(func=cmd_attachments_prune) args = parser.parse_args() diff --git a/devplacepy/routers/feed.py b/devplacepy/routers/feed.py index 997503e..2aab7e9 100644 --- a/devplacepy/routers/feed.py +++ b/devplacepy/routers/feed.py @@ -16,10 +16,7 @@ PAGE_SIZE = 25 def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None): posts_table = get_table("posts") - - filters = {} - if topic: - filters["topic"] = topic + order = ["-stars", "-created_at"] if tab == "trending" else ["-created_at"] if tab == "following": if not user: @@ -28,23 +25,22 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None following = [f["following_uid"] for f in follows.find(follower_uid=user["uid"])] if not following: return [], None - posts = list(posts_table.find(posts_table.table.columns.user_uid.in_(following), order_by=["-created_at"], _limit=PAGE_SIZE)) - else: - order = ["-created_at"] - if tab == "trending": - order = ["-stars", "-created_at"] + clauses = [posts_table.table.columns.user_uid.in_(following)] if before: - posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1)) - posts = [p for p in posts if p["created_at"] < before][:PAGE_SIZE] - else: - posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1)) + clauses.append(posts_table.table.columns.created_at < before) + posts = list(posts_table.find(*clauses, order_by=order, _limit=PAGE_SIZE + 1)) + else: + filters = {} + if topic: + filters["topic"] = topic + if before: + filters["created_at"] = {"<": before} + posts = list(posts_table.find(**filters, order_by=order, _limit=PAGE_SIZE + 1)) has_more = len(posts) > PAGE_SIZE posts = posts[:PAGE_SIZE] - next_cursor = None - if has_more and posts: - next_cursor = posts[-1]["created_at"] + next_cursor = posts[-1]["created_at"] if has_more and posts else None if not posts: return [], next_cursor diff --git a/devplacepy/routers/notifications.py b/devplacepy/routers/notifications.py index 05f8785..5a917ff 100644 --- a/devplacepy/routers/notifications.py +++ b/devplacepy/routers/notifications.py @@ -10,6 +10,8 @@ from devplacepy.seo import base_seo_context logger = logging.getLogger(__name__) router = APIRouter() +PAGE_SIZE = 25 + def _group_label(created_at: str) -> str: try: @@ -29,15 +31,24 @@ def _group_label(created_at: str) -> str: @router.get("", response_class=HTMLResponse) -async def notifications_page(request: Request): +async def notifications_page(request: Request, before: str = None): user = require_user(request) + next_cursor = None try: notifications_table = get_table("notifications") + filters = {"user_uid": user["uid"]} + if before: + filters["created_at"] = {"<": before} raw_notifications = list( - notifications_table.find(user_uid=user["uid"], order_by=["-created_at"]) + notifications_table.find(**filters, order_by=["-created_at"], _limit=PAGE_SIZE + 1) ) + has_more = len(raw_notifications) > PAGE_SIZE + raw_notifications = raw_notifications[:PAGE_SIZE] + if has_more and raw_notifications: + next_cursor = raw_notifications[-1]["created_at"] + enriched = [] if raw_notifications: from devplacepy.database import get_users_by_uids @@ -83,6 +94,7 @@ async def notifications_page(request: Request): "request": request, "user": user, "notification_groups": groups, + "next_cursor": next_cursor, }) diff --git a/devplacepy/routers/votes.py b/devplacepy/routers/votes.py index a77988d..6fe51d4 100644 --- a/devplacepy/routers/votes.py +++ b/devplacepy/routers/votes.py @@ -21,11 +21,13 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota votes = get_table("votes") existing = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type) + did_upvote = False if existing: if int(existing["value"]) == value: votes.delete(id=existing["id"]) else: votes.update({"id": existing["id"], "value": value}, ["id"]) + did_upvote = value == 1 else: votes.insert({ "uid": generate_uid(), @@ -35,6 +37,7 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota "value": value, "created_at": datetime.now(timezone.utc).isoformat(), }) + did_upvote = value == 1 up_count = votes.count(target_uid=target_uid, value=1) down_count = votes.count(target_uid=target_uid, value=-1) @@ -42,7 +45,7 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota update_target_stars(target_type, target_uid, net) - if value == 1 and target_type in NOTIFY_ON_VOTE: + if did_upvote and target_type in NOTIFY_ON_VOTE: owner_uid = get_target_owner_uid(target_type, target_uid) if owner_uid and owner_uid != user["uid"]: target_url = resolve_object_url(target_type, target_uid) diff --git a/devplacepy/templates/notifications.html b/devplacepy/templates/notifications.html index 9db59cd..87d3b84 100644 --- a/devplacepy/templates/notifications.html +++ b/devplacepy/templates/notifications.html @@ -39,5 +39,11 @@
No notifications yet
{% endfor %} + + {% if next_cursor %} +
+ 📄Load More +
+ {% endif %} {% endblock %} \ No newline at end of file diff --git a/tests/test_notifications.py b/tests/test_notifications.py index be5e9f0..8e2e243 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -1,4 +1,69 @@ +from datetime import datetime, timedelta, timezone from tests.conftest import BASE_URL +from devplacepy.database import get_table +from devplacepy.utils import generate_uid + + +def test_notifications_pagination(alice): + page, _ = alice + alice_row = get_table("users").find_one(username="alice_test") + notifications_table = get_table("notifications") + notifications_table.delete(user_uid=alice_row["uid"]) + + now = datetime.now(timezone.utc) + for i in range(30): + notifications_table.insert({ + "uid": generate_uid(), + "user_uid": alice_row["uid"], + "type": "test", + "message": f"pagination-test-msg-{i:02d}", + "related_uid": alice_row["uid"], + "target_url": "/feed", + "read": False, + "created_at": (now - timedelta(seconds=i)).isoformat(), + }) + + page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded") + cards = page.locator(".notification-card") + assert cards.count() == 25, f"expected 25 cards on page 1, got {cards.count()}" + + load_more = page.locator(".load-more-wrap a") + load_more.wait_for(state="visible", timeout=5000) + href = load_more.get_attribute("href") + assert href and "before=" in href, f"Load More should carry cursor: {href}" + + page.goto(f"{BASE_URL}{href}", wait_until="domcontentloaded") + cards2 = page.locator(".notification-card") + assert cards2.count() == 5, f"expected 5 cards on page 2, got {cards2.count()}" + assert page.locator(".load-more-wrap").count() == 0, "Load More should be absent on final page" + + notifications_table.delete(user_uid=alice_row["uid"]) + + +def test_notifications_no_load_more_when_under_page_size(alice): + page, _ = alice + alice_row = get_table("users").find_one(username="alice_test") + notifications_table = get_table("notifications") + notifications_table.delete(user_uid=alice_row["uid"]) + + now = datetime.now(timezone.utc) + for i in range(3): + notifications_table.insert({ + "uid": generate_uid(), + "user_uid": alice_row["uid"], + "type": "test", + "message": f"small-set-msg-{i}", + "related_uid": alice_row["uid"], + "target_url": "/feed", + "read": False, + "created_at": (now - timedelta(seconds=i)).isoformat(), + }) + + page.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded") + assert page.locator(".notification-card").count() == 3 + assert page.locator(".load-more-wrap").count() == 0 + + notifications_table.delete(user_uid=alice_row["uid"]) def test_notifications_page_loads(alice):