Update
All checks were successful
DevPlace CI / test (push) Successful in 6m31s

This commit is contained in:
retoor 2026-06-05 05:36:18 +02:00
parent c826096843
commit c6c0ec6c39
16 changed files with 256 additions and 55 deletions

View File

@ -528,6 +528,21 @@ def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
return row["user_uid"] if row else None
PAGE_SIZE = 25
def paginate(table, *clauses, before=None, order=None, cursor_field="created_at", **filters):
order = order or ["-" + cursor_field]
clauses = list(clauses)
if before:
clauses.append(table.table.columns[cursor_field] < before)
rows = list(table.find(*clauses, **filters, order_by=order, _limit=PAGE_SIZE + 1))
has_more = len(rows) > PAGE_SIZE
rows = rows[:PAGE_SIZE]
next_cursor = rows[-1][cursor_field] if has_more and rows else None
return rows, next_cursor
def build_pagination(page, total, per_page=25):
total_pages = max(1, __import__("math").ceil(total / per_page))
page = max(1, min(page, total_pages))

View File

@ -1,7 +1,7 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats, get_top_authors
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats, get_top_authors, paginate
from devplacepy.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.templating import templates
@ -11,8 +11,6 @@ from devplacepy.seo import list_page_seo
logger = logging.getLogger(__name__)
router = APIRouter()
PAGE_SIZE = 25
def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None):
posts_table = get_table("posts")
@ -25,22 +23,10 @@ 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
clauses = [posts_table.table.columns.user_uid.in_(following)]
if before:
clauses.append(posts_table.table.columns.created_at < before)
posts = list(posts_table.find(*clauses, order_by=order, _limit=PAGE_SIZE + 1))
posts, next_cursor = paginate(posts_table, posts_table.table.columns.user_uid.in_(following), before=before, order=order)
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 = posts[-1]["created_at"] if has_more and posts else None
filters = {"topic": topic} if topic else {}
posts, next_cursor = paginate(posts_table, before=before, order=order, **filters)
if not posts:
return [], next_cursor

View File

@ -3,7 +3,7 @@ from typing import Annotated
from fastapi import APIRouter, Request, Form
from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages, paginate
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, not_found, XP_GIST
@ -23,7 +23,7 @@ LANGUAGES = [
]
def get_gists_list(user_uid=None, language=None, viewer=None):
def get_gists_list(user_uid=None, language=None, before=None, viewer=None):
gists_table = get_table("gists")
filters = {}
if user_uid:
@ -31,20 +31,20 @@ def get_gists_list(user_uid=None, language=None, viewer=None):
if language:
filters["language"] = language
all_gists = list(gists_table.find(**filters, order_by=["-created_at"]))
total = gists_table.count(**filters)
gists, next_cursor = paginate(gists_table, before=before, **filters)
if not all_gists:
return []
if not gists:
return [], next_cursor, total
users_map = get_users_by_uids([g["user_uid"] for g in all_gists])
return enrich_items(all_gists, "gist", users_map, user=viewer)
users_map = get_users_by_uids([g["user_uid"] for g in gists])
return enrich_items(gists, "gist", users_map, user=viewer), next_cursor, total
@router.get("", response_class=HTMLResponse)
async def gists_page(request: Request, language: str = None, user_uid: str = None):
async def gists_page(request: Request, language: str = None, user_uid: str = None, before: str = None):
user = get_current_user(request)
gists_data = get_gists_list(user_uid, language, viewer=user)
total_count = len(gists_data)
gists_data, next_cursor, total_count = get_gists_list(user_uid, language, before, viewer=user)
seo_ctx = list_page_seo(
request,
title="Gists",
@ -60,6 +60,7 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
"user": user,
"gists": gists_data,
"total_count": total_count,
"next_cursor": next_cursor,
"current_language": language,
"languages": LANGUAGES,
"gist_language_codes": get_gist_languages(),

View File

@ -2,7 +2,7 @@ import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, paginate
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago, not_found
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema, list_page_seo
@ -14,16 +14,19 @@ NEWS_MAX_AGE_DAYS = 4
@router.get("", response_class=HTMLResponse)
async def news_page(request: Request):
async def news_page(request: Request, before: str = None):
user = get_current_user(request)
cutoff = (datetime.now(timezone.utc) - timedelta(days=NEWS_MAX_AGE_DAYS)).isoformat()
news_table = get_table("news")
articles = list(news_table.find(
articles, next_cursor = paginate(
news_table,
before=before,
order=["-grade", "-synced_at"],
cursor_field="synced_at",
status="published",
synced_at={">=": cutoff},
order_by=["-grade", "-synced_at"],
))
)
article_uids = [a["uid"] for a in articles]
images_by_news = get_news_images_by_uids(article_uids)
@ -49,6 +52,7 @@ async def news_page(request: Request):
"request": request,
"user": user,
"articles": enriched,
"next_cursor": next_cursor,
})

View File

@ -4,7 +4,7 @@ from sqlalchemy import or_
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT
@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None, viewer: dict = None):
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None, before: str = None, viewer: dict = None):
projects = get_table("projects")
filters = {}
@ -31,17 +31,18 @@ def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = Non
clauses.append(or_(projects.table.columns.title.ilike(like), projects.table.columns.description.ilike(like)))
order = ["-stars", "-created_at"] if tab == "popular" else ["-created_at"]
all_projects = list(projects.find(*clauses, **filters, order_by=order))
total = projects.count(*clauses, **filters)
page, next_cursor = paginate(projects, *clauses, before=before, order=order, **filters)
if all_projects:
users_map = get_users_by_uids([p["user_uid"] for p in all_projects])
my_votes = get_user_votes(viewer["uid"], [p["uid"] for p in all_projects]) if viewer else {}
for p in all_projects:
if page:
users_map = get_users_by_uids([p["user_uid"] for p in page])
my_votes = get_user_votes(viewer["uid"], [p["uid"] for p in page]) if viewer else {}
for p in page:
author = users_map.get(p["user_uid"])
p["author_name"] = author["username"] if author else "Unknown"
p["my_vote"] = my_votes.get(p["uid"], 0)
return all_projects
return page, next_cursor, total
@router.get("", response_class=HTMLResponse)
@ -51,15 +52,16 @@ async def projects_page(
search: str = "",
user_uid: str = None,
project_type: str = None,
before: str = None,
):
user = get_current_user(request)
projects = get_projects_list(tab, search, user_uid, project_type, viewer=user)
projects, next_cursor, total_count = get_projects_list(tab, search, user_uid, project_type, before, viewer=user)
total_members = get_site_stats()["total_members"]
seo_ctx = list_page_seo(
request,
title="Projects",
description=f"Explore {len(projects)} developer projects on DevPlace. Games, software, mobile apps and more.",
description=f"Explore {total_count} developer projects on DevPlace. Games, software, mobile apps and more.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
@ -73,7 +75,8 @@ async def projects_page(
"current_tab": tab,
"search": search,
"project_type": project_type,
"total_count": len(projects),
"total_count": total_count,
"next_cursor": next_cursor,
"total_members": total_members,
})

View File

@ -0,0 +1,5 @@
{% if next_cursor %}
<div class="load-more-wrap">
<a href="{{ request.url.path }}?{{ request.url.include_query_params(before=next_cursor).query }}" class="btn btn-secondary btn-sm"><span class="icon">&#x1F4C4;</span>Load More</a>
</div>
{% endif %}

View File

@ -61,11 +61,7 @@
{% endfor %}
</div>
{% if next_cursor %}
<div class="load-more-wrap">
<a href="/feed?before={{ next_cursor }}{% if current_tab %}&tab={{ current_tab }}{% endif %}{% if current_topic %}&topic={{ current_topic }}{% endif %}" class="btn btn-secondary btn-sm"><span class="icon">&#x1F4C4;</span>Load More</a>
</div>
{% endif %}
{% include "_load_more.html" %}
</div>
<aside class="feed-right">

View File

@ -79,6 +79,8 @@
<div class="empty-state empty-state-full">No gists found. Create one!</div>
{% endfor %}
</div>
{% include "_load_more.html" %}
</div>
</div>

View File

@ -43,6 +43,8 @@
</article>
{% endfor %}
</div>
{% include "_load_more.html" %}
{% else %}
<div class="news-empty">
<div class="news-empty-icon">&#x1F4F0;</div>

View File

@ -40,7 +40,7 @@
<h1>Projects</h1>
<span class="subtitle">Discover amazing projects from the DevPlace community</span>
</div>
<div class="projects-count">Showing {{ total_count }} of {{ total_count }} projects</div>
<div class="projects-count">Showing {{ projects|length }} of {{ total_count }} projects</div>
</div>
<div class="projects-tabs">
@ -90,6 +90,8 @@
<div class="empty-state empty-state-full">No projects found. Create one!</div>
{% endfor %}
</div>
{% include "_load_more.html" %}
</div>
</div>

View File

@ -1,8 +1,34 @@
import re
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_posts(count):
owner = str(uuid4())
topic = f"pag{owner[:8]}"
get_table("users").insert({
"uid": owner, "username": f"pag_{owner[:8]}", "email": f"{owner[:8]}@test.devplace",
"password_hash": "x", "role": "Member", "is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
content = f"Paginated post {i}"
posts.insert({
"uid": uid, "user_uid": owner, "slug": make_combined_slug(content, uid),
"title": None, "content": content, "topic": topic, "project_uid": None,
"image": None, "stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
})
return topic
def test_feed_vote_voted_state_persists(alice):
@ -208,6 +234,25 @@ def test_create_post_cancel_modal(alice):
assert not modal.is_visible()
def test_feed_pagination_first_page(alice):
page, _ = alice
topic = _seed_posts(26)
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
assert page.locator(".post-card").count() == 25
assert page.is_visible(".load-more-wrap")
def test_feed_pagination_load_more(alice):
page, _ = alice
topic = _seed_posts(26)
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
page.click(".load-more-wrap a")
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
assert f"topic={topic}" in page.url
assert page.locator(".post-card").count() == 1
assert not page.is_visible(".load-more-wrap")
def test_feed_public_access(page, app_server):
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
assert page.is_visible("text=Topics")

View File

@ -1,9 +1,33 @@
import re
import time
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_gists(count):
owner = str(uuid4())
get_table("users").insert({
"uid": owner, "username": f"pag_{owner[:8]}", "email": f"{owner[:8]}@test.devplace",
"password_hash": "x", "role": "Member", "is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
gists = get_table("gists")
for i in range(count):
uid = str(uuid4())
title = f"Pag Gist {i}"
gists.insert({
"uid": uid, "user_uid": owner, "slug": make_combined_slug(title, uid),
"title": title, "language": "python", "description": "paginated", "stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
})
return owner
def _set_cm_value(page, value):
@ -201,6 +225,33 @@ def test_gist_listing_shows_created_gist(alice, app_server):
assert page.is_visible(f"text={title}")
def test_gist_pagination_first_page(alice):
page, _ = alice
owner = _seed_gists(26)
page.goto(f"{BASE_URL}/gists?user_uid={owner}", wait_until="domcontentloaded")
assert page.locator(".gist-card").count() == 25
assert page.is_visible(".load-more-wrap")
def test_gist_pagination_load_more(alice):
page, _ = alice
owner = _seed_gists(26)
page.goto(f"{BASE_URL}/gists?user_uid={owner}", wait_until="domcontentloaded")
page.click(".load-more-wrap a")
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
assert f"user_uid={owner}" in page.url
assert page.locator(".gist-card").count() == 1
assert not page.is_visible(".load-more-wrap")
def test_gist_no_pagination_below_page_size(alice):
page, _ = alice
owner = _seed_gists(10)
page.goto(f"{BASE_URL}/gists?user_uid={owner}", wait_until="domcontentloaded")
assert page.locator(".gist-card").count() == 10
assert not page.is_visible(".load-more-wrap")
def test_gist_voted_state_persists(alice):
page, _ = alice
_create_gist(page, title="Voted State Gist")

View File

@ -1,11 +1,30 @@
import pytest
from uuid import uuid4
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_news_paginated(count):
news_table = get_table("news")
base = datetime.now(timezone.utc)
marker = uuid4().hex[:8]
titles = []
for i in range(count):
uid = str(uuid4())
title = f"Pag News {marker} {i:02d}"
titles.append(title)
news_table.insert({
"uid": uid, "slug": make_combined_slug(title, uid), "title": title,
"external_id": f"pag_{marker}_{i}", "grade": 10,
"status": "published", "show_on_landing": 0, "source_name": "PagSource",
"url": "https://example.com", "description": "paginated news",
"synced_at": (base - timedelta(seconds=i)).isoformat(),
})
return titles
def test_news_detail_share_button(page, news_article):
slug = news_article.get("slug") or news_article["uid"]
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
@ -34,7 +53,7 @@ def seed_news():
@pytest.fixture
def news_article(app_server):
seed_news()
article = get_table("news").find_one(order_by=["-synced_at"])
article = get_table("news").find_one(external_id="test_news_0")
return article
@ -74,6 +93,24 @@ def test_news_comment(alice, news_article):
assert page.is_visible("text=Test comment on news article")
def test_news_pagination_first_page(page, app_server):
_seed_news_paginated(30)
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
assert page.locator(".news-card").count() == 25
assert page.is_visible(".load-more-wrap")
def test_news_pagination_load_more(page, app_server):
titles = _seed_news_paginated(30)
page.goto(f"{BASE_URL}/news", wait_until="domcontentloaded")
newest = titles[0]
assert page.is_visible(f".news-card-title:has-text('{newest}')")
page.click(".load-more-wrap a")
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
assert not page.is_visible(f".news-card-title:has-text('{newest}')")
assert page.locator(".news-card").count() >= 1
def test_news_detail_guest(page, news_article):
slug = news_article.get("slug") or news_article["uid"]
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")

View File

@ -229,7 +229,7 @@ def test_post_edit_submit(alice):
page.fill("#edit-title", "Edited Title")
page.fill("#edit-content", "Edited content for the post")
page.click("button:has-text('Save Changes')")
expect(page.locator("text=Edited Title")).to_be_visible()
expect(page.locator(".post-detail-title:has-text('Edited Title')")).to_be_visible()
def test_post_across_all_topics(alice):

View File

@ -1,8 +1,33 @@
import re
from uuid import uuid4
from datetime import datetime, timedelta, timezone
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_projects(count):
owner = str(uuid4())
get_table("users").insert({
"uid": owner, "username": f"pag_{owner[:8]}", "email": f"{owner[:8]}@test.devplace",
"password_hash": "x", "role": "Member", "is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
projects = get_table("projects")
for i in range(count):
uid = str(uuid4())
title = f"Pag Project {i}"
projects.insert({
"uid": uid, "user_uid": owner, "slug": make_combined_slug(title, uid),
"title": title, "description": "paginated", "project_type": "software",
"status": "In Development", "stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
})
return owner
def _create_project(page, title):
@ -186,6 +211,33 @@ def test_project_platform_presets(alice):
assert tag.is_visible()
def test_project_pagination_first_page(alice):
page, _ = alice
owner = _seed_projects(26)
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
assert page.locator(".project-card").count() == 25
assert page.is_visible(".load-more-wrap")
def test_project_pagination_load_more(alice):
page, _ = alice
owner = _seed_projects(26)
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
page.click(".load-more-wrap a")
page.wait_for_url(lambda url: "before=" in url, wait_until="domcontentloaded")
assert f"user_uid={owner}" in page.url
assert page.locator(".project-card").count() == 1
assert not page.is_visible(".load-more-wrap")
def test_project_pagination_count_reflects_total(alice):
page, _ = alice
owner = _seed_projects(26)
page.goto(f"{BASE_URL}/projects?user_uid={owner}", wait_until="domcontentloaded")
count_text = page.text_content(".projects-count")
assert "Showing 25 of 26 projects" in count_text
def test_projects_count(alice):
page, _ = alice
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")

View File

@ -15,7 +15,7 @@ def test_post_content_is_sanitized(alice):
page.locator(".post-detail-content p").first.wait_for(state="visible")
html = content.inner_html().lower()
assert "<script" not in html
assert "onerror" not in html
assert "<img" not in html
assert not fired, f"XSS payload executed: {fired}"
@ -31,5 +31,5 @@ def test_comment_content_is_sanitized(alice):
page.locator(".comment-text p").first.wait_for(state="visible")
html = comment.inner_html().lower()
assert "<script" not in html
assert "onerror" not in html
assert "<img" not in html
assert not fired, f"XSS payload executed: {fired}"