diff --git a/devplacepy/attachments.py b/devplacepy/attachments.py
index 60ef255..c0666d4 100644
--- a/devplacepy/attachments.py
+++ b/devplacepy/attachments.py
@@ -186,13 +186,14 @@ def link_attachments(uids, target_type, target_uid):
if not uids:
return
attachments = get_table("attachments")
- for uid in uids:
- uid = uid.strip()
- if not uid:
- continue
- existing = attachments.find_one(uid=uid)
- if existing:
- attachments.update({"id": existing["id"], "uid": uid, "target_type": target_type, "target_uid": target_uid}, ["id"])
+ for raw in uids:
+ for uid in str(raw).split(","):
+ uid = uid.strip()
+ if not uid:
+ continue
+ existing = attachments.find_one(uid=uid)
+ if existing:
+ attachments.update({"id": existing["id"], "target_type": target_type, "target_uid": target_uid}, ["id"])
def delete_attachment(uid):
diff --git a/devplacepy/routers/votes.py b/devplacepy/routers/votes.py
index 3304b21..a77988d 100644
--- a/devplacepy/routers/votes.py
+++ b/devplacepy/routers/votes.py
@@ -2,7 +2,7 @@ import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
-from fastapi.responses import RedirectResponse
+from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, update_target_stars, get_target_owner_uid, resolve_object_url
from devplacepy.utils import generate_uid, require_user, create_notification
from devplacepy.models import VoteForm
@@ -48,5 +48,11 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
target_url = resolve_object_url(target_type, target_uid)
create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"], target_url)
+ if request.headers.get("x-requested-with") == "fetch":
+ current = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
+ current_value = int(current["value"]) if current else 0
+ logger.debug("ajax vote response target=%s/%s net=%s value=%s", target_type, target_uid, net, current_value)
+ return JSONResponse({"net": net, "up": up_count, "down": down_count, "value": current_value})
+
referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302)
diff --git a/devplacepy/static/js/VoteManager.js b/devplacepy/static/js/VoteManager.js
index 08d9cf6..90ee210 100644
--- a/devplacepy/static/js/VoteManager.js
+++ b/devplacepy/static/js/VoteManager.js
@@ -1,4 +1,4 @@
-import { Http } from "./Http.js";
+import { Toast } from "./Toast.js";
export class VoteManager {
constructor() {
@@ -6,12 +6,47 @@ export class VoteManager {
}
initVoteButtons() {
- document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
- btn.addEventListener("click", () => {
- const targetUid = btn.dataset.target;
- const targetType = btn.dataset.type || "post";
- Http.postForm(`/votes/${targetType}/${targetUid}`, { value: btn.dataset.vote });
+ document.querySelectorAll('form[action^="/votes/"] button[type="submit"]').forEach((button) => {
+ const form = button.closest("form");
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ this.cast(form, button);
});
});
}
+
+ async cast(form, button) {
+ const action = form.getAttribute("action");
+ const value = form.querySelector('input[name="value"]').value;
+ try {
+ const response = await fetch(action, {
+ method: "POST",
+ headers: {
+ "X-Requested-With": "fetch",
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({ value }),
+ });
+ if (!response.ok) {
+ throw new Error(`vote failed with status ${response.status}`);
+ }
+ const result = await response.json();
+ this.render(action, result);
+ } catch (error) {
+ console.error("vote failed", error);
+ Toast.flash(button, "Error", 1500);
+ }
+ }
+
+ render(action, result) {
+ const targetUid = action.split("/").pop();
+ document.querySelectorAll(`[data-vote-count="${targetUid}"]`).forEach((counter) => {
+ counter.textContent = result.net;
+ });
+ document.querySelectorAll(`form[action="${action}"] button[type="submit"]`).forEach((button) => {
+ const formValue = parseInt(button.closest("form").querySelector('input[name="value"]').value, 10);
+ button.classList.toggle("voted", result.value !== 0 && formValue === result.value);
+ });
+ }
}
diff --git a/devplacepy/templates/_comment_section.html b/devplacepy/templates/_comment_section.html
index c642f6e..becc70e 100644
--- a/devplacepy/templates/_comment_section.html
+++ b/devplacepy/templates/_comment_section.html
@@ -8,7 +8,7 @@
-
+
- {{ post.get('stars', 0) }}
+ {{ post.get('stars', 0) }}
- {{ item.post.get('stars', 0) }}
+ {{ item.post.get('stars', 0) }}
{% endif %}
{% if is_owner %}
diff --git a/devplacepy/templates/projects.html b/devplacepy/templates/projects.html
index 5cc4bc4..1b54498 100644
--- a/devplacepy/templates/projects.html
+++ b/devplacepy/templates/projects.html
@@ -58,7 +58,7 @@
{{ project['title'] }}
diff --git a/tests/test_post.py b/tests/test_post.py
index 3378c07..0c9b19f 100644
--- a/tests/test_post.py
+++ b/tests/test_post.py
@@ -1,3 +1,5 @@
+from playwright.sync_api import expect
+
from tests.conftest import BASE_URL, assert_share_copies
@@ -44,9 +46,7 @@ def test_post_vote_increment(alice):
page, _ = alice
create_post(page, "showcase", "Vote increment test")
page.locator(".post-action-btn.vote-up").first.click()
- page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
- count = page.locator(".post-vote-count").first.text_content().strip()
- assert count == "1", f"expected vote count 1, got {count!r}"
+ expect(page.locator(".post-vote-count").first).to_have_text("1")
def _profile_stars(page, username):
@@ -60,7 +60,7 @@ def test_profile_stars_reflect_content_votes(alice):
before = _profile_stars(page, user["username"])
create_post(page, "devlog", "Reputation contribution post")
page.locator(".post-action-btn.vote-up").first.click()
- page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
+ expect(page.locator(".post-vote-count").first).to_have_text("1")
after = _profile_stars(page, user["username"])
assert after == before + 1, f"expected stars {before + 1}, got {after}"
diff --git a/tests/test_projects.py b/tests/test_projects.py
index ffcaf30..08f4490 100644
--- a/tests/test_projects.py
+++ b/tests/test_projects.py
@@ -1,3 +1,5 @@
+from playwright.sync_api import expect
+
from tests.conftest import BASE_URL, assert_share_copies
@@ -18,9 +20,10 @@ def test_project_vote(alice):
page, _ = alice
_create_project(page, "Votable Project")
star = "form[action*='/votes/project/'] button"
+ count = "form[action*='/votes/project/'] .vote-count-value"
before = int(page.locator(star).first.inner_text().strip("☆ "))
page.locator(star).first.click()
- page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
+ expect(page.locator(count).first).to_have_text(str(before + 1))
after = int(page.locator(star).first.inner_text().strip("☆ "))
assert after == before + 1
diff --git a/tests/test_uploads.py b/tests/test_uploads.py
index 8811267..49047d5 100644
--- a/tests/test_uploads.py
+++ b/tests/test_uploads.py
@@ -1,19 +1,25 @@
import io
-import time
+import re
+import uuid
import requests
from PIL import Image
from tests.conftest import BASE_URL
-def _session():
+def _user(prefix="up"):
s = requests.Session()
- name = f"up_{int(time.time() * 1000)}"
+ name = f"{prefix}_{uuid.uuid4().hex[:10]}"
s.post(f"{BASE_URL}/auth/signup", data={
"username": name,
"email": f"{name}@test.dev",
"password": "secret123",
"confirm_password": "secret123",
}, allow_redirects=True)
+ return s, name
+
+
+def _session():
+ s, _ = _user()
return s
@@ -23,6 +29,12 @@ def _png_bytes():
return buf.getvalue()
+def _upload(s, name="a.png"):
+ r = s.post(f"{BASE_URL}/uploads/upload", files={"file": (name, _png_bytes(), "image/png")})
+ assert r.status_code == 201, r.text
+ return r.json()["uid"]
+
+
def test_upload_allowed_png(app_server):
s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")})
@@ -69,3 +81,96 @@ def test_delete_own_allowed_other_user_forbidden(app_server):
bob = _session()
assert bob.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 403
assert alice.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 200
+
+
+def test_post_links_multiple_attachments(app_server):
+ s = _session()
+ u1, u2 = _upload(s), _upload(s)
+ r = s.post(f"{BASE_URL}/posts/create", data={
+ "content": "Post body with two attachments here",
+ "title": "attach post", "topic": "random",
+ "attachment_uids": f"{u1},{u2}",
+ }, allow_redirects=True)
+ assert r.status_code == 200, r.text[:300]
+ assert "/posts/" in r.url, r.url
+ assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the post"
+
+
+def test_comment_links_multiple_attachments(app_server):
+ s = _session()
+ post = s.post(f"{BASE_URL}/posts/create", data={
+ "content": "Host post for comment attachments", "title": "host", "topic": "random",
+ }, allow_redirects=True)
+ target_uid = re.search(r'name="target_uid"\s+value="([^"]+)"', post.text).group(1)
+
+ u1, u2 = _upload(s), _upload(s)
+ r = s.post(f"{BASE_URL}/comments/create", data={
+ "content": "Comment with attachments",
+ "target_uid": target_uid, "target_type": "post",
+ "attachment_uids": f"{u1},{u2}",
+ }, allow_redirects=True)
+ assert r.status_code == 200, r.text[:300]
+ assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the comment"
+
+
+def test_project_links_multiple_attachments(app_server):
+ s = _session()
+ u1, u2 = _upload(s), _upload(s)
+ r = s.post(f"{BASE_URL}/projects/create", data={
+ "title": "Attach Project", "description": "Project with attachments",
+ "project_type": "software", "platforms": "linux", "status": "In Development",
+ "attachment_uids": f"{u1},{u2}",
+ }, allow_redirects=True)
+ assert r.status_code == 200, r.text[:300]
+ assert "/projects/" in r.url, r.url
+ assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the project"
+
+
+def test_gist_links_multiple_attachments(app_server):
+ s = _session()
+ u1, u2 = _upload(s), _upload(s)
+ r = s.post(f"{BASE_URL}/gists/create", data={
+ "title": "Attach Gist", "description": "Gist with attachments",
+ "source_code": "print('hi')", "language": "python",
+ "attachment_uids": f"{u1},{u2}",
+ }, allow_redirects=True)
+ assert r.status_code == 200, r.text[:300]
+ assert "/gists/" in r.url, r.url
+ assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the gist"
+
+
+def test_bug_links_multiple_attachments(app_server):
+ s = _session()
+ u1, u2 = _upload(s), _upload(s)
+ r = s.post(f"{BASE_URL}/bugs/create", data={
+ "title": "Attach Bug", "description": "Bug report with attachments",
+ "attachment_uids": f"{u1},{u2}",
+ }, allow_redirects=True)
+ assert r.status_code == 200, r.text[:300]
+ assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed on the bug"
+
+
+def test_message_links_multiple_attachments(app_server):
+ alice = _session()
+ bob, bob_name = _user("bob")
+ found = alice.get(f"{BASE_URL}/messages/search", params={"q": bob_name}).json()["results"]
+ bob_uid = found[0]["uid"]
+
+ u1, u2 = _upload(alice), _upload(alice)
+ r = alice.post(f"{BASE_URL}/messages/send", data={
+ "content": "Message with attachments", "receiver_uid": bob_uid,
+ "attachment_uids": f"{u1},{u2}",
+ }, allow_redirects=True)
+ assert r.status_code == 200, r.text[:300]
+ assert u1 in r.text and u2 in r.text, "both attachments must be linked and displayed in the conversation"
+
+
+def test_single_attachment_links_to_post(app_server):
+ s = _session()
+ u1 = _upload(s)
+ r = s.post(f"{BASE_URL}/posts/create", data={
+ "content": "Post body with one attachment", "title": "one", "topic": "random",
+ "attachment_uids": u1,
+ }, allow_redirects=True)
+ assert r.status_code == 200, r.text[:300]
+ assert u1 in r.text, "single attachment must be linked and displayed"