Update
Some checks failed
DevPlace CI / test (push) Failing after 1m30s

This commit is contained in:
retoor 2026-05-27 21:06:18 +02:00
parent a5c71fd2f8
commit 0cc22eb889
14 changed files with 182 additions and 32 deletions

View File

@ -186,13 +186,14 @@ def link_attachments(uids, target_type, target_uid):
if not uids: if not uids:
return return
attachments = get_table("attachments") attachments = get_table("attachments")
for uid in uids: for raw in uids:
uid = uid.strip() for uid in str(raw).split(","):
if not uid: uid = uid.strip()
continue if not uid:
existing = attachments.find_one(uid=uid) continue
if existing: existing = attachments.find_one(uid=uid)
attachments.update({"id": existing["id"], "uid": uid, "target_type": target_type, "target_uid": target_uid}, ["id"]) if existing:
attachments.update({"id": existing["id"], "target_type": target_type, "target_uid": target_uid}, ["id"])
def delete_attachment(uid): def delete_attachment(uid):

View File

@ -2,7 +2,7 @@ import logging
from typing import Annotated from typing import Annotated
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form 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.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.utils import generate_uid, require_user, create_notification
from devplacepy.models import VoteForm 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) 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) 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") referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302) return RedirectResponse(url=referer, status_code=302)

View File

@ -1,4 +1,4 @@
import { Http } from "./Http.js"; import { Toast } from "./Toast.js";
export class VoteManager { export class VoteManager {
constructor() { constructor() {
@ -6,12 +6,47 @@ export class VoteManager {
} }
initVoteButtons() { initVoteButtons() {
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => { document.querySelectorAll('form[action^="/votes/"] button[type="submit"]').forEach((button) => {
btn.addEventListener("click", () => { const form = button.closest("form");
const targetUid = btn.dataset.target; button.addEventListener("click", (event) => {
const targetType = btn.dataset.type || "post"; event.preventDefault();
Http.postForm(`/votes/${targetType}/${targetUid}`, { value: btn.dataset.vote }); 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);
});
}
} }

View File

@ -8,7 +8,7 @@
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn">+</button> <button type="submit" class="comment-vote-btn">+</button>
</form> </form>
<span class="comment-vote-count">{{ item.votes.up - item.votes.down }}</span> <span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}"> <form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn">-</button> <button type="submit" class="comment-vote-btn">-</button>

View File

@ -86,12 +86,12 @@
<div class="post-votes"> <div class="post-votes">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form"> <form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">+</button> <button type="submit" class="post-action-btn vote-up">+</button>
</form> </form>
<span class="post-vote-count">{{ item.post.get('stars', 0) }}</span> <span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form"> <form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down" data-vote="-1" data-target="{{ item.post['uid'] }}" data-type="post"></button> <button type="submit" class="post-action-btn vote-down"></button>
</form> </form>
</div> </div>
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn"> <a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">

View File

@ -47,7 +47,7 @@
{% if user %} {% if user %}
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;"> <form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="gist-star-btn">&#x2606; {{ star_count }}</button> <button type="submit" class="gist-star-btn">&#x2606; <span class="vote-count-value" data-vote-count="{{ gist['uid'] }}">{{ star_count }}</span></button>
</form> </form>
{% endif %} {% endif %}
{% if is_owner %} {% if is_owner %}

View File

@ -54,7 +54,7 @@
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3> <h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
<form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation> <form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation>
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="gist-card-star">&#x2606; {{ item.gist.get('stars', 0) }}</button> <button type="submit" class="gist-card-star">&#x2606; <span class="vote-count-value" data-vote-count="{{ item.gist['uid'] }}">{{ item.gist.get('stars', 0) }}</span></button>
</form> </form>
</div> </div>

View File

@ -41,7 +41,7 @@
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button> <button type="submit" class="post-action-btn vote-up">+</button>
</form> </form>
<span class="post-vote-count">{{ post.get('stars', 0) }}</span> <span class="post-vote-count" data-vote-count="{{ post['uid'] }}">{{ post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form"> <form method="POST" action="/votes/post/{{ post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button> <button type="submit" class="post-action-btn vote-down"></button>

View File

@ -183,7 +183,7 @@
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up">+</button> <button type="submit" class="post-action-btn vote-up">+</button>
</form> </form>
<span class="post-vote-count">{{ item.post.get('stars', 0) }}</span> <span class="post-vote-count" data-vote-count="{{ item.post['uid'] }}">{{ item.post.get('stars', 0) }}</span>
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form"> <form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="-1"> <input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down"></button> <button type="submit" class="post-action-btn vote-down"></button>

View File

@ -136,7 +136,7 @@
{% if user %} {% if user %}
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;"> <form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="project-star-btn">&#x2606; {{ star_count }}</button> <button type="submit" class="project-star-btn">&#x2606; <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ star_count }}</span></button>
</form> </form>
{% endif %} {% endif %}
{% if is_owner %} {% if is_owner %}

View File

@ -58,7 +58,7 @@
<h3 class="project-card-title">{{ project['title'] }}</h3> <h3 class="project-card-title">{{ project['title'] }}</h3>
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form"> <form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">
<input type="hidden" name="value" value="1"> <input type="hidden" name="value" value="1">
<button type="submit" class="project-card-star">&#x2606;</button> <button type="submit" class="project-card-star">&#x2606; <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ project.get('stars', 0) }}</span></button>
</form> </form>
</div> </div>

View File

@ -1,3 +1,5 @@
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies from tests.conftest import BASE_URL, assert_share_copies
@ -44,9 +46,7 @@ def test_post_vote_increment(alice):
page, _ = alice page, _ = alice
create_post(page, "showcase", "Vote increment test") create_post(page, "showcase", "Vote increment test")
page.locator(".post-action-btn.vote-up").first.click() 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")
count = page.locator(".post-vote-count").first.text_content().strip()
assert count == "1", f"expected vote count 1, got {count!r}"
def _profile_stars(page, username): def _profile_stars(page, username):
@ -60,7 +60,7 @@ def test_profile_stars_reflect_content_votes(alice):
before = _profile_stars(page, user["username"]) before = _profile_stars(page, user["username"])
create_post(page, "devlog", "Reputation contribution post") create_post(page, "devlog", "Reputation contribution post")
page.locator(".post-action-btn.vote-up").first.click() 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"]) after = _profile_stars(page, user["username"])
assert after == before + 1, f"expected stars {before + 1}, got {after}" assert after == before + 1, f"expected stars {before + 1}, got {after}"

View File

@ -1,3 +1,5 @@
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies from tests.conftest import BASE_URL, assert_share_copies
@ -18,9 +20,10 @@ def test_project_vote(alice):
page, _ = alice page, _ = alice
_create_project(page, "Votable Project") _create_project(page, "Votable Project")
star = "form[action*='/votes/project/'] button" star = "form[action*='/votes/project/'] button"
count = "form[action*='/votes/project/'] .vote-count-value"
before = int(page.locator(star).first.inner_text().strip("")) before = int(page.locator(star).first.inner_text().strip(""))
page.locator(star).first.click() 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("")) after = int(page.locator(star).first.inner_text().strip(""))
assert after == before + 1 assert after == before + 1

View File

@ -1,19 +1,25 @@
import io import io
import time import re
import uuid
import requests import requests
from PIL import Image from PIL import Image
from tests.conftest import BASE_URL from tests.conftest import BASE_URL
def _session(): def _user(prefix="up"):
s = requests.Session() 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={ s.post(f"{BASE_URL}/auth/signup", data={
"username": name, "username": name,
"email": f"{name}@test.dev", "email": f"{name}@test.dev",
"password": "secret123", "password": "secret123",
"confirm_password": "secret123", "confirm_password": "secret123",
}, allow_redirects=True) }, allow_redirects=True)
return s, name
def _session():
s, _ = _user()
return s return s
@ -23,6 +29,12 @@ def _png_bytes():
return buf.getvalue() 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): def test_upload_allowed_png(app_server):
s = _session() s = _session()
r = s.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}) 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() bob = _session()
assert bob.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 403 assert bob.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 403
assert alice.delete(f"{BASE_URL}/uploads/delete/{uid}").status_code == 200 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"