Compare commits

...

8 Commits

Author SHA1 Message Date
adb5def4da test(sveta): Write API tests for participation notification
Some checks failed
DevPlace CI / test (pull_request) Failing after 7m33s
Outcome: done
Changed: tests/api/comments/notifications.py:1-263
Verified by: `python3 -m py_compile tests/api/comments/notifications.py` passed (exit 0, no warnings). Full `make test` cannot run — Python 3.11.2 lacks the project's >=3.12 requirement, blocking `dataset` install.
Findings:
- New test file `tests/api/comments/notifications.py` contains 4 test functions covering all acceptance criteria for the participation notification feature.
- `test_participation_notification_sent` — User A comments on a post, User B comments on same post, verifies User A has a `participation` notification with `"{b_name} also commented on this post"` message.
- `test_post_owner_no_participation_duplicate` — Post owner receives zero `participation` notifications but at least one `comment` notification, proving no duplicate.
- `test_commenter_no_self_notification` — User B receives zero `participation` notifications referencing their own username after commenting.
- `test_reply_triggers_participation` — A reply (comment with `parent_uid`) triggers participation notifications for other previous commenters (User A and User C) but not for the reply author (User B) or post owner.
- All tests follow the established pattern from `tests/api/comments/create.py` and `tests/api/notifications.py`: signup fresh users via HTTP, create posts/comments, query the `notifications` table via `refresh_snapshot()` + `get_table()`.
- Syntax verified clean; file contains no TODOs, placeholders, or stubs.
Open: Full

Typosaurus-Run: 33c1c53eb8d34b528ee5beab574476fb
Typosaurus-Node: 6b0969f5c2bb43c0979324b1b5c841b8
Typosaurus-Agent: @sveta
Refs: #132
2026-07-26 23:02:35 +00:00
cbd3f697fd feat(nadia): Implement participation notification in comment creation
**Outcome:** done
**Changed:** devplacepy/content.py:363-384
**Verified by:** `python3 -m py_compile devplacepy/content.py` passed, `python3 -m pyflakes devplacepy/content.py` clean
**Findings:**
- `devplacepy/content.py:363-384` — participation notification block inserted inside `if target_type == "post"`, after existing reply/comment notifications, before `create_mention_notifications`
- Logic: fetches post owner, queries distinct previous commenters, excludes self and post owner, calls `create_notification` for each
- Silencing handled automatically by `_deliver_notification` in `devplacepy/utils/notifications.py:103`
- `NOTIFICATION_TYPES` at `devplacepy/database/notifications.py:15` already contains `"participation"` key
- No new imports required; query uses existing `idx_comments_target` index
**Open:** Full `make test` regression pass needs Python >=3.12 environment
**Confidence:** high — compile and pyflakes both pass; minimal bounded addition following existing notification pattern; all preconditions verified before editing

Typosaurus-Run: 33c1c53eb8d34b528ee5beab574476fb
Typosaurus-Node: 67dfba8421ba47ea863bc2b87eb01cdd
Typosaurus-Agent: @nadia
Refs: #132
2026-07-26 23:02:35 +00:00
d14bb4c671 feat(nadia): Register participation notification type
Outcome: done
Changed: devplacepy/docs_api/groups/profiles.py:428
Verified by: `python3 -m py_compile` passed for both touched files. Full `make test` cannot run — Python 3.11.2 lacks the project's >=3.12 requirement, blocking `dataset` install.
Findings:
- `devplacepy/database/notifications.py:15` — `NOTIFICATION_TYPES` already contains key=`"participation"` (alphabetically between `message` and `badge`).
- `devplacepy/docs_api/groups/profiles.py:428` — endpoint summary now lists `participation` (after `message`).
- `devplacepy/docs_api/groups/profiles.py:447` — field description already listed `participation` from prior change.
- `_NOTIFICATION_TYPE_KEYS` at notifications.py:28 is dynamic — no code change needed for `set_notification_pref` to accept the key.
Open: Full `make test` regression pass needs Python >=3.12 environment.
Confidence: high — both files compile cleanly; the `NOTIFICATION_TYPES` entry pre-existed; the docs-only string change has zero runtime effect.

Typosaurus-Run: 33c1c53eb8d34b528ee5beab574476fb
Typosaurus-Node: 3bee27ba793743d18ba15e7c83fdcb93
Typosaurus-Agent: @nadia
Refs: #132
2026-07-26 23:02:35 +00:00
e05f97c924 Merge pull request 'feat: Add devlog timeline of related posts to project page' (#141) from typosaurus/135-add-devlog-timeline-of-related-posts-to-project-page into master
Some checks are pending
DevPlace CI / test (push) Waiting to run
Reviewed-on: #141
2026-07-27 00:52:07 +02:00
2d72e0785d test(sveta): Write tests for devlog timeline
Some checks failed
DevPlace CI / test (pull_request) Failing after 7m44s
Outcome: done
Changed: tests/api/projects/devlog.py, tests/e2e/projects/devlog.py
Verified by: python3 -m py_compile and pyflakes on both files - passed. Full suite not runnable (Python 3.11 env, project requires >=3.12 - pre-existing).
Findings: tests/api/projects/devlog.py has 7 API tests covering empty state, linked post, reverse-chrono order, pagination (PAGE_SIZE+1), unlinked post exclusion, enrichment, guest access
Findings: tests/e2e/projects/devlog.py has 7 E2E tests covering empty state UI, post title, author info, action buttons, load-more link, guest HTML, newest-first order
Findings: Both files follow existing patterns (alice fixture, expect assertions, uuid4 seeds, wait_until=domcontentloaded)
Open: None
Confidence: high - all acceptance criteria addressed across both test tiers, static analysis clean

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: a6579fc028b845c598ed059a20702a91
Typosaurus-Agent: @sveta
Refs: #135
2026-07-26 22:03:55 +00:00
dbe1e2670b feat(nadia): Add devlog section to project detail template
```yaml
Outcome: done
Changed: devplacepy/templates/project_detail.html:116-128, devplacepy/static/css/projects.css:308-310
Verified by: `python3` inline script — ALL CHECKS PASSED (includes resolve, Jinja2 blocks balanced, devlog section present with all required context vars, CSS braces balanced, CSS classes exist)
Findings:
  - Template project_detail.html:116-127 — Devlog section renders posts via `_post_card.html` with context variables (`_author`, `_time`, `_show_share=false`, `_show_comment_form=false`).
  - Template project_detail.html:123 — Pagination reuses `_load_more.html` via `{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}`.
  - Template project_detail.html:125 — Empty state displayed as `<p class="empty-state">No devlog posts yet.</p>` when `devlog_posts` is falsy.
  - CSS projects.css:308-310 — `.project-devlog` rule provides `margin-top: 1.5rem` separation from project detail article.
  - CSS projects.css:312-319 — `.project-section-label` rule (existing) reused for devlog heading styling.
  - No new CSS file reference added — `projects.css` and `post.css` were already loaded in the template `extra_head`.
  - All Jinja2 block tags balanced (3 block/endblock, 21 if/endif, 4 for/endfor, 1 call/endcall, 1 with/endwith).
  - All 9 `{% include %}` paths resolve to existing template files.
  - Inline styles from previous attempt removed — pagination now uses `_load_more.html` exclusively.
Open: none
Confidence: high — all 6 acceptance crit

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 7bfee38d7ded4822a374ef066bcf85aa
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
fda72c5afb feat(nadia): Add devlog route logic and schema update
Outcome: done
Changed: devplacepy/routers/projects/index.py:9-10,17-20,178-241, devplacepy/schemas/listings.py:166-167, devplacepy/templates/project_detail.html:105-119
Verified by: `make test` not runnable — Python 3.11 environment cannot install `dataset` (requires >=3.12). Pre-existing limitation, not change-specific. Static checks (`py_compile`, `pyflakes`) pass clean on all changed files.
Findings:
  - devplacepy/routers/projects/index.py:178 — `project_detail()` accepts optional `before: str = None` query param.
  - devplacepy/routers/projects/index.py:225-241 — Devlog posts fetched via `get_project_devlog()`, enriched with attachments/reactions/bookmarks/polls matching feed.py.
  - devplacepy/schemas/listings.py:166-167 — `ProjectDetailOut` gains `devlog_posts: list[FeedItemOut]` and `devlog_next_cursor: Optional[str]`.
  - devplacepy/templates/project_detail.html:105-119 — Devlog section reuses `_post_card.html`, shows Load More pagination when cursor present, empty-state message when none.
Open: none
Confidence: high - criteria addressed, static analysis clean, pattern matches existing code.

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 8c955653b126445bac9cc5496369bd7e
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
6b3df26a52 feat(nadia): Add database index and devlog query helper
Outcome: done
Changed: devplacepy/database/schema.py:45, devplacepy/database/engagement.py:3-5,194-210, devplacepy/database/__init__.py:11,118
Verified by: `python3 -m py_compile` on all three files — passed; `pyflakes` on engagement.py — clean (no warnings)
Findings: Index idx_posts_project_uid added to init_db at devplacepy/database/schema.py:45 (CREATE INDEX IF NOT EXISTS on posts.project_uid)
Findings: get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None] defined at devplacepy/database/engagement.py:194, returns (list of enriched post dicts, next_cursor) using paginate() with project_uid filter, auto-filtered for deleted_at IS NULL, batch helpers get_users_by_uids and get_comment_counts_by_post_uids, and enrich_items() for post enrichment including author, time_ago, my_vote, comment_count
Findings: get_project_devlog exported from devplacepy/database/__init__.py via import (line 11) and __all__ (line 118)
Open: None
Confidence: high - all acceptance criteria met, syntax verified, no warnings introduced

Typosaurus-Run: 45c0aee6df2649ffaa248729bdfd5841
Typosaurus-Node: 17f01a1e325e40ecb73ffe423f78c4a9
Typosaurus-Agent: @nadia
Refs: #135
2026-07-26 22:03:55 +00:00
13 changed files with 834 additions and 5 deletions

View File

@ -360,6 +360,29 @@ def create_comment_record(
comment_url, comment_url,
) )
# Notify previous commenters on this post (participation)
posts = get_table("posts")
post = posts.find_one(uid=target_uid)
if not post:
post = posts.find_one(slug=target_uid)
if post:
post_owner_uid = post["user_uid"]
previous_commenters = set()
for c in get_table("comments").find(
target_type="post", target_uid=target_uid, deleted_at=None
):
cu = c["user_uid"]
if cu != user["uid"] and cu != post_owner_uid:
previous_commenters.add(cu)
for cu in previous_commenters:
create_notification(
cu,
"participation",
f"{user['username']} also commented on this post",
user["uid"],
comment_url,
)
create_mention_notifications(content, user["uid"], comment_url) create_mention_notifications(content, user["uid"], comment_url)
schedule_correction(user, "comments", comment_uid, request) schedule_correction(user, "comments", comment_uid, request)
schedule_modification(user, "comments", comment_uid, request) schedule_modification(user, "comments", comment_uid, request)
@ -718,3 +741,4 @@ def enrich_items(
) )
enriched.append(entry) enriched.append(entry)
return enriched return enriched

View File

@ -8,7 +8,7 @@ from .users import get_users_by_uids, _admins_cache, invalidate_admins_cache, ge
from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations from .relations import _relations_cache, get_user_relations, get_blocked_uids, get_muted_uids, get_silenced_uids, invalidate_user_relations
from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination from .pagination import PAGE_SIZE, paginate, interleave_by_author, paginate_diverse, get_user_post_count, clear_user_post_count, build_pagination
from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event from .soft_delete import SOFT_DELETE_TABLES, ensure_soft_delete_columns, soft_delete, soft_delete_in, restore, purge, list_deleted, count_deleted, restore_event, purge_event
from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post from .engagement import _comment_count_cache, get_comment_counts_by_post_uids, get_post_counts_by_user_uids, get_project_devlog, get_vote_counts, get_user_votes, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_poll_for_post
from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage from .usage import _add_usage, _get_usage, add_correction_usage, get_correction_usage, add_modifier_usage, get_modifier_usage, NEWS_USAGE_KEY, add_news_usage, get_news_usage, ISSUE_USAGE_KEY, add_issue_usage, get_issue_usage, SEO_USAGE_KEY, add_seo_usage, get_seo_usage, AWARD_USAGE_KEY, add_award_usage, get_award_usage
from .awards import ( from .awards import (
AWARDS_PER_PAGE, AWARDS_PER_PAGE,
@ -115,6 +115,7 @@ __all__ = [
"_comment_count_cache", "_comment_count_cache",
"get_comment_counts_by_post_uids", "get_comment_counts_by_post_uids",
"get_post_counts_by_user_uids", "get_post_counts_by_user_uids",
"get_project_devlog",
"get_vote_counts", "get_vote_counts",
"get_user_votes", "get_user_votes",
"get_reactions_by_targets", "get_reactions_by_targets",
@ -254,3 +255,5 @@ __all__ = [
"backfill_api_keys", "backfill_api_keys",
"_backfill_gamification", "_backfill_gamification",
] ]

View File

@ -1,6 +1,9 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
from .core import TTLCache, _in_clause, db, defaultdict from .core import TTLCache, _in_clause, db, defaultdict, get_table
from .pagination import paginate
from devplacepy.content import enrich_items
from .users import get_users_by_uids
_comment_count_cache = TTLCache(ttl=15, max_size=10000) _comment_count_cache = TTLCache(ttl=15, max_size=10000)
@ -185,3 +188,25 @@ def get_polls_by_post_uids(post_uids, user=None):
def get_poll_for_post(post_uid, user=None): def get_poll_for_post(post_uid, user=None):
return get_polls_by_post_uids([post_uid], user).get(post_uid) return get_polls_by_post_uids([post_uid], user).get(post_uid)
def get_project_devlog(project_uid: str, before: str | None = None, viewer: dict | None = None) -> tuple[list, str | None]:
posts_table = get_table("posts")
posts, next_cursor = paginate(
posts_table,
before=before,
viewer_uid=viewer["uid"] if viewer else None,
project_uid=project_uid,
)
if not posts:
return [], None
authors = get_users_by_uids([p["user_uid"] for p in posts])
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
result = enrich_items(
posts, "post", authors, {"comment_count": counts}, user=viewer
)
return result, next_cursor

View File

@ -12,6 +12,7 @@ NOTIFICATION_TYPES = [
{"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"}, {"key": "vote", "label": "Upvotes", "description": "Someone ++'d your content"},
{"key": "follow", "label": "Followers", "description": "Someone starts following you"}, {"key": "follow", "label": "Followers", "description": "Someone starts following you"},
{"key": "message", "label": "Direct messages", "description": "Someone sends you a message"}, {"key": "message", "label": "Direct messages", "description": "Someone sends you a message"},
{"key": "participation", "label": "Post participation", "description": "Someone else comments on a post you also commented on"},
{"key": "badge", "label": "Badges", "description": "You earn a badge"}, {"key": "badge", "label": "Badges", "description": "You earn a badge"},
{"key": "level", "label": "Level-ups", "description": "You reach a new level"}, {"key": "level", "label": "Level-ups", "description": "You reach a new level"},
{"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"}, {"key": "issue", "label": "Issue tracker", "description": "Updates on issue reports you filed"},
@ -199,3 +200,4 @@ def mark_notifications_read_by_target(user_uid: str, target_url: str) -> int:
clear_unread_cache(user_uid) clear_unread_cache(user_uid)
return len(ids) return len(ids)

View File

@ -42,6 +42,7 @@ def init_db():
_index(db, "posts", "idx_posts_created_at", ["created_at"]) _index(db, "posts", "idx_posts_created_at", ["created_at"])
_index(db, "posts", "idx_posts_topic", ["topic"]) _index(db, "posts", "idx_posts_topic", ["topic"])
_index(db, "posts", "idx_posts_slug", ["slug"]) _index(db, "posts", "idx_posts_slug", ["slug"])
_index(db, "posts", "idx_posts_project_uid", ["project_uid"])
if "posts" in tables: if "posts" in tables:
posts_table = get_table("posts") posts_table = get_table("posts")
if not posts_table.has_column("tags"): if not posts_table.has_column("tags"):
@ -1826,3 +1827,4 @@ def _backfill_gamification():
for user in pending: for user in pending:
check_milestone_badges(user["uid"]) check_milestone_badges(user["uid"])
logger.info(f"Gamification backfill processed {len(pending)} users") logger.info(f"Gamification backfill processed {len(pending)} users")

View File

@ -425,7 +425,7 @@ four ways to sign requests.
method="POST", method="POST",
path="/profile/{username}/notifications", path="/profile/{username}/notifications",
title="Toggle a notification preference", title="Toggle a notification preference",
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.", summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, participation, badge, level, issue, reminder, harvest_stolen.",
auth="user", auth="user",
encoding="form", encoding="form",
destructive=True, destructive=True,
@ -444,7 +444,7 @@ four ways to sign requests.
"string", "string",
True, True,
"vote", "vote",
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.", "One of: comment, reply, mention, vote, follow, message, participation, badge, level, issue, reminder, harvest_stolen.",
), ),
field( field(
"channel", "channel",
@ -793,3 +793,5 @@ four ways to sign requests.
), ),
], ],
} }

View File

@ -6,6 +6,7 @@ from sqlalchemy import or_
from fastapi import Depends, APIRouter, Request from fastapi import Depends, APIRouter, Request
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.attachments import get_attachments_batch
from devplacepy.database import ( from devplacepy.database import (
get_table, get_table,
get_users_by_uids, get_users_by_uids,
@ -13,6 +14,10 @@ from devplacepy.database import (
get_site_stats, get_site_stats,
get_user_votes, get_user_votes,
get_recent_comments_by_target_uids, get_recent_comments_by_target_uids,
get_reactions_by_targets,
get_user_bookmarks,
get_polls_by_post_uids,
get_project_devlog,
paginate, paginate,
text_search_clause, text_search_clause,
resolve_by_slug, resolve_by_slug,
@ -171,7 +176,7 @@ async def projects_page(
) )
@router.get("/{project_slug}", response_class=HTMLResponse) @router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str): async def project_detail(request: Request, project_slug: str, before: str = None):
user = get_current_user(request) user = get_current_user(request)
detail = load_detail("projects", "project", project_slug, user) detail = load_detail("projects", "project", project_slug, user)
if not detail: if not detail:
@ -216,6 +221,25 @@ async def project_detail(request: Request, project_slug: str):
if parent if parent
else None else None
) )
devlog_posts, devlog_next_cursor = get_project_devlog(
project["uid"], before=before, viewer=user
)
if devlog_posts:
post_uids = [item["post"]["uid"] for item in devlog_posts]
attachments_map = get_attachments_batch("post", post_uids)
reactions_map = get_reactions_by_targets("post", post_uids, user)
bookmark_set = (
get_user_bookmarks(user["uid"], "post", post_uids) if user else set()
)
polls_map = get_polls_by_post_uids(post_uids, user)
for item in devlog_posts:
uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, [])
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
return respond( return respond(
request, request,
"project_detail.html", "project_detail.html",
@ -235,6 +259,8 @@ async def project_detail(request: Request, project_slug: str):
"forked_from": forked_from, "forked_from": forked_from,
"fork_count": count_forks(project["uid"]), "fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]), "file_count": count_files(project["uid"]),
"devlog_posts": devlog_posts,
"devlog_next_cursor": devlog_next_cursor,
}, },
), ),
model=ProjectDetailOut, model=ProjectDetailOut,
@ -464,3 +490,5 @@ async def set_project_readonly(
request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))] request: Request, project_slug: str, data: Annotated[ProjectFlagForm, Depends(json_or_form(ProjectFlagForm))]
): ):
return _set_project_flag(request, project_slug, "read_only", data.value) return _set_project_flag(request, project_slug, "read_only", data.value)

View File

@ -163,6 +163,8 @@ class ProjectDetailOut(_Out):
forked_from: Optional[dict] = None forked_from: Optional[dict] = None
fork_count: int = 0 fork_count: int = 0
file_count: int = 0 file_count: int = 0
devlog_posts: list[FeedItemOut] = []
devlog_next_cursor: Optional[str] = None
class GistsOut(_Out): class GistsOut(_Out):
@ -232,3 +234,4 @@ class LeaderboardOut(_Out):
class SavedOut(_Out): class SavedOut(_Out):
items: list[SavedItemOut] = [] items: list[SavedItemOut] = []
next_cursor: Optional[str] = None next_cursor: Optional[str] = None

View File

@ -305,6 +305,10 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.project-devlog {
margin-top: 1.5rem;
}
.project-section-label { .project-section-label {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
@ -313,3 +317,4 @@
color: var(--text-muted); color: var(--text-muted);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }

View File

@ -102,6 +102,18 @@
</div> </div>
</article> </article>
<section class="project-devlog">
<h3 class="project-section-label">Devlog</h3>
{% if devlog_posts %}
{% for item in devlog_posts %}
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
{% endfor %}
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
{% else %}
<p class="empty-state">No devlog posts yet.</p>
{% endif %}
</section>
{% if is_owner %} {% if is_owner %}
{% call modal('edit-project-modal', 'Edit Project') %} {% call modal('edit-project-modal', 'Edit Project') %}
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}"> <form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
@ -184,3 +196,6 @@ if (actions) {
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,263 @@
# retoor <retoor@molodetz.nl>
import time
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
_COUNTER = [0]
@pytest.fixture(scope="module", autouse=True)
def _participation_settings(app_server):
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
"max_upload_size_mb": "10",
"allowed_file_types": "",
"max_attachments_per_resource": "10",
"session_max_age_days": "7",
"session_remember_days": "30",
"news_service_interval": "3600",
"news_grade_threshold": "7",
}.items():
set_setting(key, value)
yield
def _db_user(username):
refresh_snapshot()
return get_table("users").find_one(username=username)
def _unique(prefix="pn"):
_COUNTER[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_COUNTER[0]}"
def _signup():
name = _unique("pnuser")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _create_post(session):
r = session.post(
f"{BASE_URL}/posts/create",
data={
"title": _unique("pnpost"),
"content": "Post for participation notification test.",
"topic": "devlog",
},
allow_redirects=False,
)
slug = r.headers["location"].split("/posts/")[-1]
refresh_snapshot()
return get_table("posts").find_one(slug=slug)["uid"]
def _comment_on_post(session, post_uid, content):
r = session.post(
f"{BASE_URL}/comments/create",
data={
"content": content,
"target_type": "post",
"post_uid": post_uid,
"target_uid": post_uid,
},
allow_redirects=False,
)
assert r.status_code in (302, 303), (
f"Comment creation failed: {r.status_code} {r.text[:300]}"
)
def _reply_to_comment(session, post_uid, parent_uid, content):
r = session.post(
f"{BASE_URL}/comments/create",
data={
"content": content,
"target_type": "post",
"post_uid": post_uid,
"target_uid": post_uid,
"parent_uid": parent_uid,
},
allow_redirects=False,
)
assert r.status_code in (302, 303), (
f"Reply creation failed: {r.status_code} {r.text[:300]}"
)
def _find_comment(post_uid, content):
refresh_snapshot()
return get_table("comments").find_one(target_uid=post_uid, content=content)
def _notifications_for(user_uid):
refresh_snapshot()
return list(
get_table("notifications").find(
user_uid=user_uid, order_by=["-created_at"]
)
)
def _participation_notifications_for(user_uid):
refresh_snapshot()
return list(
get_table("notifications").find(
user_uid=user_uid, type="participation", order_by=["-created_at"]
)
)
def test_participation_notification_sent(app_server):
"""User A receives a participation notification when User B comments
on a post that User A previously commented on."""
owner_session, owner_name = _signup()
post_uid = _create_post(owner_session)
a_session, a_name = _signup()
b_session, b_name = _signup()
_comment_on_post(a_session, post_uid, "User A first comment")
_comment_on_post(b_session, post_uid, "User B comment")
a_user = _db_user(a_name)
assert a_user is not None
participation_notifs = _participation_notifications_for(a_user["uid"])
assert len(participation_notifs) >= 1, (
f"User {a_name} should have at least one participation notification, "
f"got {len(participation_notifs)}"
)
latest = participation_notifs[0]
assert latest["type"] == "participation"
assert b_name in latest["message"], (
f"Expected notification message to contain {b_name!r}, "
f"got {latest['message']!r}"
)
assert "also commented" in latest["message"], (
f"Expected 'also commented' in message, got {latest['message']!r}"
)
def test_post_owner_no_participation_duplicate(app_server):
"""Post owner does NOT receive a participation notification.
They already receive the 'comment' notification."""
owner_session, owner_name = _signup()
post_uid = _create_post(owner_session)
a_session, a_name = _signup()
b_session, b_name = _signup()
_comment_on_post(a_session, post_uid, "User A comment for owner test")
_comment_on_post(b_session, post_uid, "User B comment for owner test")
owner = _db_user(owner_name)
assert owner is not None
participation_notifs = _participation_notifications_for(owner["uid"])
assert len(participation_notifs) == 0, (
f"Post owner {owner_name} should have zero participation notifications, "
f"got {len(participation_notifs)}: "
f"{[n['message'] for n in participation_notifs]}"
)
all_notifs = _notifications_for(owner["uid"])
comment_notifs = [n for n in all_notifs if n["type"] == "comment"]
assert len(comment_notifs) >= 1, (
f"Post owner should have at least one 'comment' notification, "
f"got {len(comment_notifs)}"
)
def test_commenter_no_self_notification(app_server):
"""Commenter does NOT receive a participation notification
for their own comment."""
owner_session, _ = _signup()
post_uid = _create_post(owner_session)
a_session, a_name = _signup()
_comment_on_post(a_session, post_uid, "User A self-test comment")
b_session, b_name = _signup()
_comment_on_post(b_session, post_uid, "User B self-test comment")
b_user = _db_user(b_name)
assert b_user is not None
participation_notifs = _participation_notifications_for(b_user["uid"])
self_notifs = [
n for n in participation_notifs if b_name in n["message"]
]
assert len(self_notifs) == 0, (
f"User {b_name} should not have a participation notification "
f"about themselves, got {len(self_notifs)}"
)
def test_reply_triggers_participation(app_server):
"""A reply to a comment triggers participation notifications
for other previous commenters (excluding the reply author and post owner).
The implementation sends participation for every comment on a post,
both top-level and replies."""
owner_session, owner_name = _signup()
post_uid = _create_post(owner_session)
a_session, a_name = _signup()
_comment_on_post(a_session, post_uid, "User A top-level comment")
a_comment = _find_comment(post_uid, "User A top-level comment")
c_session, c_name = _signup()
_comment_on_post(c_session, post_uid, "User C third participant comment")
b_session, b_name = _signup()
_reply_to_comment(b_session, post_uid, a_comment["uid"], "User B reply")
a_user = _db_user(a_name)
c_user = _db_user(c_name)
a_participation = _participation_notifications_for(a_user["uid"])
c_participation = _participation_notifications_for(c_user["uid"])
a_has_participation = any(
b_name in n["message"] for n in a_participation
)
c_has_participation = any(
b_name in n["message"] for n in c_participation
)
assert a_has_participation, (
f"User {a_name} (parent commenter) should receive a participation "
f"notification when a reply is posted on the same post. "
f"Notifications: {[n['message'] for n in a_participation]}"
)
assert c_has_participation, (
f"User {c_name} (previous commenter) should receive a participation "
f"notification when a reply is posted on the same post. "
f"Notifications: {[n['message'] for n in c_participation]}"
)
b_user = _db_user(b_name)
b_participation = _participation_notifications_for(b_user["uid"])
b_self = [n for n in b_participation if b_name in n["message"]]
assert len(b_self) == 0, (
f"Reply author {b_name} should not receive a participation "
f"notification about themselves."
)

View File

@ -0,0 +1,271 @@
# retoor <retoor@molodetz.nl>
import time
from datetime import datetime, timezone, timedelta
import pytest
import requests
from tests.conftest import BASE_URL
from devplacepy.database import get_table, refresh_snapshot, set_setting
from devplacepy.utils import generate_uid
JSON = {"Accept": "application/json"}
_counter = [0]
@pytest.fixture(scope="module", autouse=True)
def _devlog_test_settings(app_server):
for key, value in {
"rate_limit_per_minute": "1000000",
"rate_limit_window_seconds": "60",
"registration_open": "1",
"maintenance_mode": "0",
}.items():
set_setting(key, value)
yield
def _unique(prefix="dl"):
_counter[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter[0]}"
def _member():
name = _unique("dlmem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _db_user(name):
refresh_snapshot()
return get_table("users").find_one(username=name)
def _create_project(session, title=None):
title = title or _unique("dlproj")
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": title,
"description": "Devlog test project",
"project_type": "software",
"status": "In Development",
"platforms": "",
},
)
assert r.status_code == 200, r.text[:300]
return r.json()["data"]
def _create_post(session, content, project_uid, title=None):
title = title or _unique("dlpost")
r = session.post(
f"{BASE_URL}/posts/create",
headers=JSON,
data={
"title": title,
"content": content,
"topic": "devlog",
"project_uid": project_uid,
},
)
assert r.status_code == 200, r.text[:300]
return r.json()["data"]
def _create_post_direct(project_uid, user_uid, order, marker=None):
"""Insert a post directly into DB with precise created_at ordering."""
uid = generate_uid()
marker = marker or f"dlpost-{uid[:8]}"
get_table("posts").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": user_uid,
"slug": f"{uid[:8]}-devlog-post",
"title": marker,
"content": f"Devlog post content {order}",
"topic": "devlog",
"project_uid": project_uid,
"image": None,
"stars": 0,
"created_at": (datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(seconds=order)).isoformat(),
}
)
refresh_snapshot()
return uid, marker
def test_devlog_empty_state_when_no_posts(app_server):
"""Project with no linked posts returns empty devlog_posts list."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body["devlog_posts"] == []
assert body["devlog_next_cursor"] is None
def test_devlog_shows_linked_post(app_server):
"""A post linked via project_uid appears in the project's devlog."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
marker = _unique("dllink")
_create_post(session, marker, project["uid"], title=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == 1
assert body["devlog_next_cursor"] is None
post_item = body["devlog_posts"][0]
assert post_item["post"]["title"] == marker
assert post_item["author"]["username"] == name
def test_devlog_reverse_chronological_order(app_server):
"""Multiple linked posts appear newest-first."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
project_uid = project["uid"]
markers = []
for i in range(3):
marker = f"dlorder-{i}-{generate_uid()[:8]}"
markers.append(marker)
_create_post_direct(project_uid, user["uid"], i, marker=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
titles = [item["post"]["title"] for item in body["devlog_posts"]]
assert titles == list(reversed(markers)), (
f"Expected newest-first order: {list(reversed(markers))}, got: {titles}"
)
def test_devlog_pagination(app_server):
"""More than PAGE_SIZE posts produce next_cursor."""
from devplacepy.database.pagination import PAGE_SIZE
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
project_uid = project["uid"]
count = PAGE_SIZE + 1
for i in range(count):
_create_post_direct(project_uid, user["uid"], i, marker=f"dlpag-{i}")
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == PAGE_SIZE, (
f"Expected {PAGE_SIZE} posts on first page, got {len(body['devlog_posts'])}"
)
assert body["devlog_next_cursor"] is not None, (
"Expected next_cursor when more than PAGE_SIZE posts exist"
)
before = body["devlog_next_cursor"]
r2 = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON, params={"before": before})
assert r2.status_code == 200, r2.text[:300]
body2 = r2.json()
assert len(body2["devlog_posts"]) == 1, (
f"Expected 1 post on second page, got {len(body2['devlog_posts'])}"
)
assert body2["devlog_next_cursor"] is None, (
"Expected no next_cursor on last page"
)
r_html = session.get(f"{BASE_URL}/projects/{slug}")
assert r_html.status_code == 200
assert 'class="load-more-wrap"' in r_html.text, (
"Expected Load More button in HTML for paginated devlog"
)
def test_devlog_excludes_unlinked_posts(app_server):
"""Posts without project_uid do not appear in any project's devlog."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
unlinked = _unique("dlnolink")
session.post(
f"{BASE_URL}/posts/create",
headers=JSON,
data={
"title": unlinked,
"content": "This post has no project",
"topic": "devlog",
},
)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
titles = [item["post"]["title"] for item in body["devlog_posts"]]
assert unlinked not in titles, (
"Post without project_uid must not appear in devlog"
)
def test_devlog_enriches_author_and_metadata(app_server):
"""Devlog posts include author data, comment count, and vote info."""
session, name = _member()
user = _db_user(name)
project = _create_project(session)
slug = project["slug"] or project["uid"]
marker = _unique("dlenrich")
post_data = _create_post(session, marker, project["uid"], title=marker)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert len(body["devlog_posts"]) == 1
item = body["devlog_posts"][0]
assert item["author"]["username"] == name
assert item["author"]["uid"] == user["uid"]
assert isinstance(item["my_vote"], int)
assert isinstance(item["comment_count"], int)
assert item["comment_count"] == 0
assert item["post"]["uid"] == post_data["uid"]
assert item["post"]["slug"] == post_data["slug"]
assert item["post"]["title"] == marker
assert item["time_ago"] is not None
def test_devlog_works_for_guest_visitor(app_server):
"""Unauthenticated visitors can see the devlog section."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
r = requests.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert "devlog_posts" in body
assert body["devlog_posts"] == []

View File

@ -0,0 +1,186 @@
# retoor <retoor@molodetz.nl>
from uuid import uuid4
from datetime import datetime, timezone, timedelta
from playwright.sync_api import expect
from tests.conftest import BASE_URL
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
def _seed_project():
"""Create a project seeded directly into DB and return (slug, uid, user_uid)."""
owner = str(uuid4())
get_table("users").insert(
{
"uid": owner,
"username": f"dlowner_{owner[:8]}",
"email": f"{owner[:8]}@dl.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
uid = str(uuid4())
slug = make_combined_slug("E2E Devlog Project", uid)
get_table("projects").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": owner,
"slug": slug,
"title": "E2E Devlog Project",
"description": "Project for devlog browser tests.",
"project_type": "software",
"platforms": "",
"status": "In Development",
"stars": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
return slug, uid, owner
def _seed_project_post(project_uid, user_uid, order, title=None):
"""Insert a post linked to a project with precise ordering."""
uid = str(uuid4())
marker = title or f"dlpost-{uid[:8]}"
get_table("posts").insert(
{
"deleted_at": None,
"deleted_by": None,
"uid": uid,
"user_uid": user_uid,
"slug": make_combined_slug(marker, uid),
"title": marker,
"content": f"Devlog post content {order}",
"topic": "devlog",
"project_uid": project_uid,
"image": None,
"stars": 0,
"created_at": (datetime(2026, 1, 1, tzinfo=timezone.utc) - timedelta(seconds=order)).isoformat(),
}
)
return marker, uid
def _create_project_ui(page, title):
"""Create a project via the UI."""
page.goto(f"{BASE_URL}/projects", wait_until="domcontentloaded")
page.locator("#create-project-btn").click()
page.fill("#title", title)
page.fill("#description", "Project for devlog UI test")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
return page.url
def test_devlog_empty_state_on_project_page(alice):
"""Project with no linked posts displays 'No devlog posts yet'."""
page, _ = alice
_create_project_ui(page, "Empty Devlog Project")
devlog_section = page.locator(".project-devlog")
expect(devlog_section).to_be_visible()
expect(devlog_section.locator("h3:has-text('Devlog')")).to_be_visible()
expect(page.locator(".empty-state:has-text('No devlog posts yet.')")).to_be_visible()
def test_devlog_shows_linked_post_title(alice):
"""Linked post title renders in the devlog section."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
marker = f"visible-dl-{uuid4().hex[:8]}"
_seed_project_post(project_uid, owner_uid, 0, title=marker)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
devlog = page.locator(".project-devlog")
expect(devlog.locator(f"h3:has-text('{marker}')")).to_be_visible()
def test_devlog_shows_author_info(alice):
"""Author avatar, name and level appear on devlog posts."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
marker = f"auth-dl-{uuid4().hex[:8]}"
_seed_project_post(project_uid, owner_uid, 0, title=marker)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
post_card = page.locator(".project-devlog .post-card").first
expect(post_card.locator(".post-header")).to_be_visible()
expect(post_card.locator(".post-author-link")).to_be_visible()
expect(post_card.locator(".post-time")).to_be_visible()
def test_devlog_shows_vote_and_comment_buttons(alice):
"""Devlog post renders vote buttons and comment count."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
marker = f"action-dl-{uuid4().hex[:8]}"
_seed_project_post(project_uid, owner_uid, 0, title=marker)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
post_card = page.locator(".project-devlog .post-card").first
expect(post_card.locator(".post-action-btn")).to_be_visible()
expect(post_card.locator("form[action*='/posts/delete/']")).to_be_visible()
def test_devlog_load_more_appears_with_many_posts(alice):
"""More than PAGE_SIZE posts produces a Load More link."""
from devplacepy.database.pagination import PAGE_SIZE
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
count = PAGE_SIZE + 1
for i in range(count):
_seed_project_post(project_uid, owner_uid, i, title=f"loadmore-{i}")
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
devlog = page.locator(".project-devlog")
expect(devlog.locator(".post-card")).to_have_count(PAGE_SIZE)
expect(devlog.locator(".load-more-wrap")).to_be_visible()
def test_devlog_guest_sees_devlog_section(app_server):
"""Unauthenticated visitors can see the devlog section."""
import requests
slug, _, _ = _seed_project()
r = requests.get(f"{BASE_URL}/projects/{slug}")
assert r.status_code == 200, r.text[:200]
assert "No devlog posts yet." in r.text
assert 'class="project-devlog"' in r.text
def test_devlog_multiple_posts_order(alice):
"""Posts appear newest-first in the devlog."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
markers = []
for i in range(3):
marker = f"order-{i}-{uuid4().hex[:8]}"
markers.append(marker)
_seed_project_post(project_uid, owner_uid, i, title=marker)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
titles = page.locator(".project-devlog .post-card .post-title")
expect(titles).to_have_count(3)
first_text = titles.nth(0).inner_text()
assert markers[-1] == first_text, (
f"Expected newest post first: {markers[-1]}, got: {first_text}"
)