ticket #150 attempt 1
This commit is contained in:
parent
5079f40f46
commit
9d14149f62
@ -60,6 +60,21 @@ REACTABLE_TYPES = {"post", "comment", "gist", "project", "quiz"}
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_by_uid(project_uid: str | None) -> dict | None:
|
||||||
|
if not project_uid:
|
||||||
|
return None
|
||||||
|
project = get_table("projects").find_one(uid=project_uid)
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
slug = project.get("slug") or project["uid"]
|
||||||
|
return {
|
||||||
|
"uid": project["uid"],
|
||||||
|
"name": project.get("title") or project.get("name", ""),
|
||||||
|
"slug": slug,
|
||||||
|
"url": f"/projects/{slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_owner(item: dict | None, user: dict | None) -> bool:
|
def is_owner(item: dict | None, user: dict | None) -> bool:
|
||||||
return bool(item and user and item["user_uid"] == user["uid"])
|
return bool(item and user and item["user_uid"] == user["uid"])
|
||||||
|
|
||||||
@ -512,6 +527,7 @@ def detail_context(
|
|||||||
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
|
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
|
||||||
"bookmarked": detail.get("bookmarked", False),
|
"bookmarked": detail.get("bookmarked", False),
|
||||||
"poll": detail.get("poll"),
|
"poll": detail.get("poll"),
|
||||||
|
"project_link": detail.get("project_link"),
|
||||||
}
|
}
|
||||||
if extra:
|
if extra:
|
||||||
context.update(extra)
|
context.update(extra)
|
||||||
@ -691,6 +707,7 @@ def load_detail(
|
|||||||
"reactions": reactions,
|
"reactions": reactions,
|
||||||
"bookmarked": bookmarked,
|
"bookmarked": bookmarked,
|
||||||
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
|
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
|
||||||
|
"project_link": get_project_by_uid(item.get("project_uid")) if target_type == "post" else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -718,6 +735,8 @@ def enrich_items(
|
|||||||
entry[name] = (
|
entry[name] = (
|
||||||
source(item) if callable(source) else source.get(item["uid"], 0)
|
source(item) if callable(source) else source.get(item["uid"], 0)
|
||||||
)
|
)
|
||||||
|
if key == "post" and item.get("project_uid"):
|
||||||
|
entry["project_link"] = get_project_by_uid(item["project_uid"])
|
||||||
enriched.append(entry)
|
enriched.append(entry)
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
|
|||||||
@ -87,6 +87,7 @@ async def create_rant(request: Request):
|
|||||||
if len(text) > 125000:
|
if len(text) > 125000:
|
||||||
return dr_error("Your rant is too long.")
|
return dr_error("Your rant is too long.")
|
||||||
tags = _parse_tags(params.get("tags"))
|
tags = _parse_tags(params.get("tags"))
|
||||||
|
project_uid = params.get("project_uid") or None
|
||||||
uid, slug = create_content_item(
|
uid, slug = create_content_item(
|
||||||
"posts",
|
"posts",
|
||||||
"post",
|
"post",
|
||||||
@ -95,7 +96,7 @@ async def create_rant(request: Request):
|
|||||||
"title": None,
|
"title": None,
|
||||||
"content": text,
|
"content": text,
|
||||||
"topic": "rant",
|
"topic": "rant",
|
||||||
"project_uid": None,
|
"project_uid": project_uid,
|
||||||
"image": None,
|
"image": None,
|
||||||
"tags": encode_tags(tags),
|
"tags": encode_tags(tags),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -72,6 +72,13 @@ class BadgeOut(_Out):
|
|||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectLinkOut(_Out):
|
||||||
|
uid: str = ""
|
||||||
|
name: Optional[str] = None
|
||||||
|
slug: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class PostOut(_Out):
|
class PostOut(_Out):
|
||||||
uid: str = ""
|
uid: str = ""
|
||||||
slug: Optional[str] = None
|
slug: Optional[str] = None
|
||||||
@ -81,6 +88,7 @@ class PostOut(_Out):
|
|||||||
topic: Optional[str] = None
|
topic: Optional[str] = None
|
||||||
stars: Optional[int] = None
|
stars: Optional[int] = None
|
||||||
image: Optional[str] = None
|
image: Optional[str] = None
|
||||||
|
project_uid: Optional[str] = None
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
updated_at: Optional[str] = None
|
updated_at: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from devplacepy.schemas.content import (
|
|||||||
NotificationOut,
|
NotificationOut,
|
||||||
PollOut,
|
PollOut,
|
||||||
PostOut,
|
PostOut,
|
||||||
|
ProjectLinkOut,
|
||||||
ProjectOut,
|
ProjectOut,
|
||||||
ReactionsOut,
|
ReactionsOut,
|
||||||
UserOut,
|
UserOut,
|
||||||
@ -31,6 +32,7 @@ class FeedItemOut(_Out):
|
|||||||
reactions: ReactionsOut = ReactionsOut()
|
reactions: ReactionsOut = ReactionsOut()
|
||||||
bookmarked: bool = False
|
bookmarked: bool = False
|
||||||
poll: Optional[PollOut] = None
|
poll: Optional[PollOut] = None
|
||||||
|
project_link: Optional[ProjectLinkOut] = None
|
||||||
|
|
||||||
|
|
||||||
class GistItemOut(_Out):
|
class GistItemOut(_Out):
|
||||||
@ -132,6 +134,7 @@ class PostDetailOut(_Out):
|
|||||||
comment_count: Optional[int] = None
|
comment_count: Optional[int] = None
|
||||||
related_posts: list[FeedItemOut] = []
|
related_posts: list[FeedItemOut] = []
|
||||||
topics: list[str] = []
|
topics: list[str] = []
|
||||||
|
project_link: Optional[ProjectLinkOut] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectsOut(_Out):
|
class ProjectsOut(_Out):
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import json
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from devplacepy.avatar import avatar_seed
|
from devplacepy.avatar import avatar_seed
|
||||||
|
from devplacepy.database import get_table
|
||||||
from devplacepy.services.devrant.avatar import avatar_payload
|
from devplacepy.services.devrant.avatar import avatar_payload
|
||||||
from devplacepy.services.devrant.ids import to_unix
|
from devplacepy.services.devrant.ids import to_unix
|
||||||
|
|
||||||
@ -26,6 +27,22 @@ def encode_tags(tags: list) -> str:
|
|||||||
return json.dumps(cleaned)
|
return json.dumps(cleaned)
|
||||||
|
|
||||||
|
|
||||||
|
def _rant_project(post: dict) -> dict | None:
|
||||||
|
project_uid = post.get("project_uid")
|
||||||
|
if not project_uid:
|
||||||
|
return None
|
||||||
|
project = get_table("projects").find_one(uid=project_uid)
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
slug = project.get("slug") or project["uid"]
|
||||||
|
return {
|
||||||
|
"uid": project["uid"],
|
||||||
|
"name": project.get("title") or project.get("name", ""),
|
||||||
|
"slug": slug,
|
||||||
|
"url": f"/projects/{slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def rant_text(post: dict) -> str:
|
def rant_text(post: dict) -> str:
|
||||||
title = (post.get("title") or "").strip()
|
title = (post.get("title") or "").strip()
|
||||||
content = post.get("content") or ""
|
content = post.get("content") or ""
|
||||||
@ -56,6 +73,7 @@ def serialize_rant(
|
|||||||
author = authors.get(post["user_uid"]) or {}
|
author = authors.get(post["user_uid"]) or {}
|
||||||
uid = post["uid"]
|
uid = post["uid"]
|
||||||
username = author.get("username") or ""
|
username = author.get("username") or ""
|
||||||
|
project = _rant_project(post)
|
||||||
return {
|
return {
|
||||||
"id": int(post["id"]),
|
"id": int(post["id"]),
|
||||||
"text": rant_text(post),
|
"text": rant_text(post),
|
||||||
@ -75,6 +93,8 @@ def serialize_rant(
|
|||||||
"user_avatar": avatar_payload(avatar_seed(author)),
|
"user_avatar": avatar_payload(avatar_seed(author)),
|
||||||
"user_avatar_lg": avatar_payload(avatar_seed(author)),
|
"user_avatar_lg": avatar_payload(avatar_seed(author)),
|
||||||
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
|
"editable": bool(viewer and viewer.get("uid") == post["user_uid"]),
|
||||||
|
"project_uid": post.get("project_uid"),
|
||||||
|
"project": project,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -55,6 +55,22 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.post-detail-actions {
|
.post-detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div>
|
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else ''), author_is_admin=is_admin(item.author)) }}</div>
|
||||||
|
|
||||||
|
{% if item.project_link %}
|
||||||
|
<a href="{{ item.project_link.url }}" class="project-link">Project: {{ item.project_link.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if item.attachments %}
|
{% if item.attachments %}
|
||||||
{% include "_attachment_display.html" %}
|
{% include "_attachment_display.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -28,6 +28,10 @@
|
|||||||
|
|
||||||
<div class="post-detail-content rendered-content">{{ render_content(post['content'], author_is_admin=is_admin(author)) }}</div>
|
<div class="post-detail-content rendered-content">{{ render_content(post['content'], author_is_admin=is_admin(author)) }}</div>
|
||||||
|
|
||||||
|
{% if project_link %}
|
||||||
|
<a href="{{ project_link.url }}" class="project-link">Project: {{ project_link.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if attachments %}
|
{% if attachments %}
|
||||||
{% include "_attachment_display.html" %}
|
{% include "_attachment_display.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
from tests.conftest import BASE_URL
|
from tests.conftest import BASE_URL
|
||||||
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
from devplacepy.database import get_table, refresh_snapshot, set_setting
|
||||||
|
from devplacepy.utils import generate_uid, make_combined_slug
|
||||||
|
|
||||||
_counter_dr = [0]
|
_counter_dr = [0]
|
||||||
|
|
||||||
@ -46,10 +48,13 @@ def _register(password="secret123"):
|
|||||||
raise AssertionError("registration did not open")
|
raise AssertionError("registration did not open")
|
||||||
|
|
||||||
|
|
||||||
def _create_rant(params, text="this is a devrant rant body", tags="dev,test"):
|
def _create_rant(params, text="this is a devrant rant body", tags="dev,test", project_uid=None):
|
||||||
|
data = {**params, "rant": text, "tags": tags}
|
||||||
|
if project_uid:
|
||||||
|
data["project_uid"] = project_uid
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{BASE_URL}/api/devrant/rants",
|
f"{BASE_URL}/api/devrant/rants",
|
||||||
data={**params, "rant": text, "tags": tags},
|
data=data,
|
||||||
)
|
)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
@ -235,3 +240,43 @@ def test_search_returns_results_shape(app_server):
|
|||||||
r = requests.get(f"{BASE_URL}/api/devrant/search", params={"term": "uniquesearchtoken"})
|
r = requests.get(f"{BASE_URL}/api/devrant/search", params={"term": "uniquesearchtoken"})
|
||||||
assert r.json()["success"] is True
|
assert r.json()["success"] is True
|
||||||
assert isinstance(r.json()["results"], list)
|
assert isinstance(r.json()["results"], list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rant_serialization_includes_project(app_server):
|
||||||
|
name, params = _register()
|
||||||
|
refresh_snapshot()
|
||||||
|
user = get_table("users").find_one(username=name)
|
||||||
|
uid = user["uid"]
|
||||||
|
project_uid = generate_uid()
|
||||||
|
slug = make_combined_slug("project-for-rant", project_uid)
|
||||||
|
projects = get_table("projects")
|
||||||
|
projects.insert({
|
||||||
|
"uid": project_uid,
|
||||||
|
"user_uid": uid,
|
||||||
|
"slug": slug,
|
||||||
|
"title": "Project For Rant",
|
||||||
|
"description": "project linked to a rant",
|
||||||
|
"project_type": "software",
|
||||||
|
"status": "In Development",
|
||||||
|
"stars": 0,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
})
|
||||||
|
refresh_snapshot()
|
||||||
|
rant_id = _create_rant(
|
||||||
|
params,
|
||||||
|
text="rant with a project link here",
|
||||||
|
tags="dev",
|
||||||
|
project_uid=project_uid,
|
||||||
|
).json()["rant_id"]
|
||||||
|
refresh_snapshot()
|
||||||
|
rant = requests.get(
|
||||||
|
f"{BASE_URL}/api/devrant/rants/{rant_id}",
|
||||||
|
params=params,
|
||||||
|
).json()["rant"]
|
||||||
|
assert rant.get("project_uid") == project_uid
|
||||||
|
assert rant.get("project") is not None
|
||||||
|
assert rant["project"]["uid"] == project_uid
|
||||||
|
assert rant["project"]["name"] == "Project For Rant"
|
||||||
|
assert "/projects/" in rant["project"]["url"]
|
||||||
|
|||||||
@ -716,3 +716,69 @@ def test_short_post_content_rejected(app_server):
|
|||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
assert "/posts/" not in (r.headers.get("location") or "")
|
assert "/posts/" not in (r.headers.get("location") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _new_project(session):
|
||||||
|
return session.post(
|
||||||
|
f"{BASE_URL}/projects/create",
|
||||||
|
headers=JSON_audit_log,
|
||||||
|
data={
|
||||||
|
"title": _unique("aupj"),
|
||||||
|
"description": "project for linked post test",
|
||||||
|
"project_type": "software",
|
||||||
|
"status": "In Development",
|
||||||
|
"platforms": "",
|
||||||
|
},
|
||||||
|
).json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_detail_includes_project_when_linked(app_server):
|
||||||
|
s, _ = _member()
|
||||||
|
project = _new_project(s)
|
||||||
|
project_uid = project["uid"]
|
||||||
|
created = s.post(
|
||||||
|
f"{BASE_URL}/posts/create",
|
||||||
|
headers=JSON_audit_log,
|
||||||
|
data={
|
||||||
|
"title": _unique("aupjpost"),
|
||||||
|
"content": "this post is linked to a project",
|
||||||
|
"topic": "devlog",
|
||||||
|
"project_uid": project_uid,
|
||||||
|
},
|
||||||
|
).json()["data"]
|
||||||
|
slug = created["slug"]
|
||||||
|
detail = s.get(
|
||||||
|
f"{BASE_URL}/posts/{slug}",
|
||||||
|
headers=JSON_audit_log,
|
||||||
|
).json()
|
||||||
|
assert detail.get("project_link") is not None, "linked project must appear in post detail"
|
||||||
|
assert detail["project_link"]["uid"] == project_uid
|
||||||
|
assert detail["project_link"]["name"] is not None
|
||||||
|
assert "/projects/" in detail["project_link"]["url"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_feed_includes_project_when_linked(app_server):
|
||||||
|
s, _ = _member()
|
||||||
|
project = _new_project(s)
|
||||||
|
project_uid = project["uid"]
|
||||||
|
s.post(
|
||||||
|
f"{BASE_URL}/posts/create",
|
||||||
|
headers=JSON_audit_log,
|
||||||
|
data={
|
||||||
|
"title": _unique("aupjfeed"),
|
||||||
|
"content": "this post appears in feed with a project link",
|
||||||
|
"topic": "devlog",
|
||||||
|
"project_uid": project_uid,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
feed = s.get(
|
||||||
|
f"{BASE_URL}/feed",
|
||||||
|
headers=JSON_audit_log,
|
||||||
|
).json()
|
||||||
|
found = False
|
||||||
|
for item in feed["posts"]:
|
||||||
|
if item.get("project_link") is not None and item["project_link"]["uid"] == project_uid:
|
||||||
|
found = True
|
||||||
|
assert "/projects/" in item["project_link"]["url"]
|
||||||
|
break
|
||||||
|
assert found, "feed must include project info for linked posts"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user