Update
Some checks failed
DevPlace CI / test (push) Has been cancelled

This commit is contained in:
retoor 2026-05-29 00:45:07 +02:00
parent 51832664c4
commit 3a4c6b14d5
10 changed files with 121 additions and 60 deletions

View File

@ -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)
```

View File

@ -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); \

View File

@ -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`.

View File

@ -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", ""),

View File

@ -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()

View File

@ -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

View File

@ -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,
})

View File

@ -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)

View File

@ -39,5 +39,11 @@
<div class="empty-state">No notifications yet</div>
{% endfor %}
</div>
{% if next_cursor %}
<div class="load-more-wrap">
<a href="/notifications?before={{ next_cursor }}" class="btn btn-secondary btn-sm"><span class="icon">&#x1F4C4;</span>Load More</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -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):