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 @@ - {{ item.votes.up - item.votes.down }} + {{ item.votes.up - item.votes.down }}
diff --git a/devplacepy/templates/feed.html b/devplacepy/templates/feed.html index bff8f56..3a0698e 100644 --- a/devplacepy/templates/feed.html +++ b/devplacepy/templates/feed.html @@ -86,12 +86,12 @@
- + - {{ item.post.get('stars', 0) }} + {{ item.post.get('stars', 0) }}
- +
diff --git a/devplacepy/templates/gist_detail.html b/devplacepy/templates/gist_detail.html index 82974d0..e5495a4 100644 --- a/devplacepy/templates/gist_detail.html +++ b/devplacepy/templates/gist_detail.html @@ -47,7 +47,7 @@ {% if user %}
- +
{% endif %} {% if is_owner %} diff --git a/devplacepy/templates/gists.html b/devplacepy/templates/gists.html index 983160f..ae0f559 100644 --- a/devplacepy/templates/gists.html +++ b/devplacepy/templates/gists.html @@ -54,7 +54,7 @@

{{ item.gist['title'] }}

- +
diff --git a/devplacepy/templates/post.html b/devplacepy/templates/post.html index 7d55a17..ab65c9e 100644 --- a/devplacepy/templates/post.html +++ b/devplacepy/templates/post.html @@ -41,7 +41,7 @@ - {{ post.get('stars', 0) }} + {{ post.get('stars', 0) }}
diff --git a/devplacepy/templates/profile.html b/devplacepy/templates/profile.html index 86a08fe..551d87b 100644 --- a/devplacepy/templates/profile.html +++ b/devplacepy/templates/profile.html @@ -183,7 +183,7 @@
- {{ item.post.get('stars', 0) }} + {{ item.post.get('stars', 0) }}
diff --git a/devplacepy/templates/project_detail.html b/devplacepy/templates/project_detail.html index 450e414..973adca 100644 --- a/devplacepy/templates/project_detail.html +++ b/devplacepy/templates/project_detail.html @@ -136,7 +136,7 @@ {% if user %} - +
{% 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"