This commit is contained in:
parent
a5c71fd2f8
commit
0cc22eb889
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="comment-vote-btn">+</button>
|
||||
</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'] }}">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="comment-vote-btn">-</button>
|
||||
|
||||
@ -86,12 +86,12 @@
|
||||
<div class="post-votes">
|
||||
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" class="inline-form">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn">
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
{% if user %}
|
||||
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="gist-star-btn">☆ {{ star_count }}</button>
|
||||
<button type="submit" class="gist-star-btn">☆ <span class="vote-count-value" data-vote-count="{{ gist['uid'] }}">{{ star_count }}</span></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_owner %}
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
|
||||
<form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation>
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="gist-card-star">☆ {{ item.gist.get('stars', 0) }}</button>
|
||||
<button type="submit" class="gist-card-star">☆ <span class="vote-count-value" data-vote-count="{{ item.gist['uid'] }}">{{ item.gist.get('stars', 0) }}</span></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn vote-up">+</button>
|
||||
</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">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down">−</button>
|
||||
|
||||
@ -183,7 +183,7 @@
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn vote-up">+</button>
|
||||
</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">
|
||||
<input type="hidden" name="value" value="-1">
|
||||
<button type="submit" class="post-action-btn vote-down">−</button>
|
||||
|
||||
@ -136,7 +136,7 @@
|
||||
{% if user %}
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="project-star-btn">☆ {{ star_count }}</button>
|
||||
<button type="submit" class="project-star-btn">☆ <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ star_count }}</span></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_owner %}
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
<h3 class="project-card-title">{{ project['title'] }}</h3>
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="project-card-star">☆</button>
|
||||
<button type="submit" class="project-card-star">☆ <span class="vote-count-value" data-vote-count="{{ project['uid'] }}">{{ project.get('stars', 0) }}</span></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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}"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user