diff --git a/aurweb/packages/util.py b/aurweb/packages/util.py
index 696c158f..55149127 100644
--- a/aurweb/packages/util.py
+++ b/aurweb/packages/util.py
@@ -11,6 +11,7 @@ from aurweb import db
from aurweb.models.official_provider import OFFICIAL_BASE, OfficialProvider
from aurweb.models.package import Package
from aurweb.models.package_base import PackageBase
+from aurweb.models.package_comment import PackageComment
from aurweb.models.package_dependency import PackageDependency
from aurweb.models.package_notification import PackageNotification
from aurweb.models.package_relation import PackageRelation
@@ -121,6 +122,13 @@ def get_pkg_or_base(name: str, cls: Union[Package, PackageBase] = PackageBase):
return instance
+def get_pkgbase_comment(pkgbase: PackageBase, id: int) -> PackageComment:
+ comment = pkgbase.comments.filter(PackageComment.ID == id).first()
+ if not comment:
+ raise HTTPException(status_code=int(HTTPStatus.NOT_FOUND))
+ return comment
+
+
@register_filter("out_of_date")
def out_of_date(packages: orm.Query) -> orm.Query:
return packages.filter(PackageBase.OutOfDateTS.isnot(None))
diff --git a/aurweb/routers/packages.py b/aurweb/routers/packages.py
index 8fd7717b..d5c99e8d 100644
--- a/aurweb/routers/packages.py
+++ b/aurweb/routers/packages.py
@@ -1,8 +1,9 @@
+from datetime import datetime
from http import HTTPStatus
from typing import Any, Dict
-from fastapi import APIRouter, Request, Response
-from fastapi.responses import RedirectResponse
+from fastapi import APIRouter, Form, HTTPException, Request, Response
+from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import and_
import aurweb.filters
@@ -11,9 +12,11 @@ import aurweb.models.package_keyword
import aurweb.packages.util
from aurweb import db
+from aurweb.auth import auth_required
from aurweb.models.license import License
from aurweb.models.package import Package
from aurweb.models.package_base import PackageBase
+from aurweb.models.package_comment import PackageComment
from aurweb.models.package_dependency import PackageDependency
from aurweb.models.package_license import PackageLicense
from aurweb.models.package_notification import PackageNotification
@@ -23,8 +26,9 @@ from aurweb.models.package_source import PackageSource
from aurweb.models.package_vote import PackageVote
from aurweb.models.relation_type import CONFLICTS_ID
from aurweb.packages.search import PackageSearch
-from aurweb.packages.util import get_pkg_or_base, query_notified, query_voted
-from aurweb.templates import make_context, render_template
+from aurweb.packages.util import get_pkg_or_base, get_pkgbase_comment, query_notified, query_voted
+from aurweb.scripts.rendercomment import update_comment_render
+from aurweb.templates import make_context, render_raw_template, render_template
router = APIRouter()
@@ -124,7 +128,9 @@ async def make_single_context(request: Request,
context["pkgbase"] = pkgbase
context["packages_count"] = pkgbase.packages.count()
context["keywords"] = pkgbase.keywords
- context["comments"] = pkgbase.comments
+ context["comments"] = pkgbase.comments.order_by(
+ PackageComment.CommentTS.desc()
+ )
context["is_maintainer"] = (request.user.is_authenticated()
and request.user.ID == pkgbase.MaintainerUID)
context["notified"] = request.user.notified(pkgbase)
@@ -201,7 +207,94 @@ async def package_base(request: Request, name: str) -> Response:
@router.get("/pkgbase/{name}/voters")
async def package_base_voters(request: Request, name: str) -> Response:
# Get the PackageBase.
- pkgbase = get_pkgbase(name)
+ pkgbase = get_pkg_or_base(name, PackageBase)
context = make_context(request, "Voters")
context["pkgbase"] = pkgbase
return render_template(request, "pkgbase/voters.html", context)
+
+
+@router.post("/pkgbase/{name}/comments")
+@auth_required(True)
+async def pkgbase_comments_post(
+ request: Request, name: str,
+ comment: str = Form(default=str()),
+ enable_notifications: bool = Form(default=False)):
+ """ Add a new comment. """
+ pkgbase = get_pkg_or_base(name, PackageBase)
+
+ if not comment:
+ raise HTTPException(status_code=int(HTTPStatus.EXPECTATION_FAILED))
+
+ # If the provided comment is different than the record's version,
+ # update the db record.
+ now = int(datetime.utcnow().timestamp())
+ with db.begin():
+ comment = db.create(PackageComment, User=request.user,
+ PackageBase=pkgbase,
+ Comments=comment, RenderedComment=str(),
+ CommentTS=now)
+
+ if enable_notifications and not request.user.notified(pkgbase):
+ db.create(PackageNotification,
+ User=request.user,
+ PackageBase=pkgbase)
+ update_comment_render(comment.ID)
+
+ # Redirect to the pkgbase page.
+ return RedirectResponse(f"/pkgbase/{pkgbase.Name}#comment-{comment.ID}",
+ status_code=int(HTTPStatus.SEE_OTHER))
+
+
+@router.get("/pkgbase/{name}/comments/{id}/form")
+@auth_required(True)
+async def pkgbase_comment_form(request: Request, name: str, id: int):
+ """ Produce a comment form for comment {id}. """
+ pkgbase = get_pkg_or_base(name, PackageBase)
+ comment = pkgbase.comments.filter(PackageComment.ID == id).first()
+ if not comment:
+ return JSONResponse({}, status_code=int(HTTPStatus.NOT_FOUND))
+
+ if not request.user.is_elevated() and request.user != comment.User:
+ return JSONResponse({}, status_code=int(HTTPStatus.UNAUTHORIZED))
+
+ context = await make_single_context(request, pkgbase)
+ context["comment"] = comment
+
+ form = render_raw_template(
+ request, "partials/packages/comment_form.html", context)
+ return JSONResponse({"form": form})
+
+
+@router.post("/pkgbase/{name}/comments/{id}")
+@auth_required(True)
+async def pkgbase_comment_post(
+ request: Request, name: str, id: int,
+ comment: str = Form(default=str()),
+ enable_notifications: bool = Form(default=False)):
+ pkgbase = get_pkg_or_base(name, PackageBase)
+ db_comment = get_pkgbase_comment(pkgbase, id)
+
+ if not comment:
+ raise HTTPException(status_code=int(HTTPStatus.EXPECTATION_FAILED))
+
+ # If the provided comment is different than the record's version,
+ # update the db record.
+ now = int(datetime.utcnow().timestamp())
+ if db_comment.Comments != comment:
+ with db.begin():
+ db_comment.Comments = comment
+ db_comment.Editor = request.user
+ db_comment.EditedTS = now
+
+ db_notif = request.user.notifications.filter(
+ PackageNotification.PackageBaseID == pkgbase.ID
+ ).first()
+ if enable_notifications and not db_notif:
+ db.create(PackageNotification,
+ User=request.user,
+ PackageBase=pkgbase)
+ update_comment_render(db_comment.ID)
+
+ # Redirect to the pkgbase page anchored to the updated comment.
+ return RedirectResponse(f"/pkgbase/{pkgbase.Name}#comment-{db_comment.ID}",
+ status_code=int(HTTPStatus.SEE_OTHER))
diff --git a/templates/partials/packages/comment.html b/templates/partials/packages/comment.html
index 6cf5f319..36696215 100644
--- a/templates/partials/packages/comment.html
+++ b/templates/partials/packages/comment.html
@@ -16,26 +16,38 @@
)
| safe
}}
- {% if is_maintainer %}
-
-
+ {% if comment.Editor %}
+ {% set edited_on = comment.EditedTS | dt | as_timezone(timezone) %}
+
+ ({{ "edited on %s by %s" | tr
+ | format(edited_on.strftime('%Y-%m-%d %H:%M'),
+ '%s' | format(
+ comment.Editor.Username, comment.Editor.Username))
+ | safe
+ }})
+
+ {% endif %}
+ {% if request.user.is_elevated() or pkgbase.Maintainer == request.user %}
+
+
+
+
{% endif %}
-
diff --git a/templates/partials/packages/comment_form.html b/templates/partials/packages/comment_form.html
new file mode 100644
index 00000000..c1c25f87
--- /dev/null
+++ b/templates/partials/packages/comment_form.html
@@ -0,0 +1,46 @@
+{# `action` is assigned the proper route to use for the form action.
+When `comment` is provided (PackageComment), we display an edit form
+for the comment. Otherwise, we display a new form.
+
+Routes:
+ new comment - /pkgbase/{name}/comments
+ edit comment - /pkgbase/{name}/comments/{id}
+#}
+{% set action = "/pkgbase/%s/comments" | format(pkgbase.Name) %}
+{% if comment %}
+ {% set action = "/pkgbase/%s/comments/%d" | format(pkgbase.Name, comment.ID) %}
+{% endif %}
+
+
diff --git a/templates/partials/packages/comments.html b/templates/partials/packages/comments.html
index 39cfb363..7c8a32e5 100644
--- a/templates/partials/packages/comments.html
+++ b/templates/partials/packages/comments.html
@@ -8,44 +8,7 @@
{% if request.user.is_authenticated() %}
{% endif %}
@@ -99,29 +62,28 @@ function handleEditCommentClick(event) {
// The div class="article-content" which contains the comment
const edit_form = parent_element.nextElementSibling;
- const params = new URLSearchParams({
- type: "get-comment-form",
- arg: comment_id,
- base_id: {{ pkgbase.ID }},
- pkgbase_name: {{ pkgbase.Name }}
- });
-
- const url = '/rpc?' + params.toString();
+ const url = "/pkgbase/{{ pkgbase.Name }}/comments/" + comment_id + "/form";
add_busy_indicator(event.target);
fetch(url, {
- method: 'GET'
+ method: 'GET',
+ credentials: 'same-origin'
+ })
+ .then(function(response) {
+ if (!response.ok) {
+ throw Error(response.statusText);
+ }
+ return response.json();
})
- .then(function(response) { return response.json(); })
.then(function(data) {
remove_busy_indicator(event.target);
- if (data.success) {
- edit_form.innerHTML = data.form;
- edit_form.querySelector('textarea').focus();
- } else {
- alert(data.error);
- }
+ edit_form.innerHTML = data.form;
+ edit_form.querySelector('textarea').focus();
+ })
+ .catch(function(error) {
+ remove_busy_indicator(event.target);
+ console.error(error);
});
}
diff --git a/test/test_packages_routes.py b/test/test_packages_routes.py
index 2190dc18..1bfa5fc0 100644
--- a/test/test_packages_routes.py
+++ b/test/test_packages_routes.py
@@ -73,6 +73,7 @@ def setup():
PackageVote.__tablename__,
PackageNotification.__tablename__,
PackageComaintainer.__tablename__,
+ PackageComment.__tablename__,
OfficialProvider.__tablename__
)
@@ -930,3 +931,135 @@ def test_pkgbase_voters(client: TestClient, maintainer: User, package: Package):
root = parse_root(resp.text)
rows = root.xpath('//div[@class="box"]//ul/li')
assert len(rows) == 1
+
+
+def test_pkgbase_comment_not_found(client: TestClient, maintainer: User,
+ package: Package):
+ cookies = {"AURSID": maintainer.login(Request(), "testPassword")}
+ comment_id = 12345 # A non-existing comment.
+ endpoint = f"/pkgbase/{package.PackageBase.Name}/comments/{comment_id}"
+ with client as request:
+ resp = request.post(endpoint, data={
+ "comment": "Failure"
+ }, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.NOT_FOUND)
+
+
+def test_pkgbase_comment_form_unauthorized(client: TestClient, user: User,
+ maintainer: User, package: Package):
+ now = int(datetime.utcnow().timestamp())
+ with db.begin():
+ comment = db.create(PackageComment, PackageBase=package.PackageBase,
+ User=maintainer, Comments="Test",
+ RenderedComment=str(), CommentTS=now)
+
+ cookies = {"AURSID": user.login(Request(), "testPassword")}
+ pkgbasename = package.PackageBase.Name
+ endpoint = f"/pkgbase/{pkgbasename}/comments/{comment.ID}/form"
+ with client as request:
+ resp = request.get(endpoint, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.UNAUTHORIZED)
+
+
+def test_pkgbase_comment_form_not_found(client: TestClient, maintainer: User,
+ package: Package):
+ cookies = {"AURSID": maintainer.login(Request(), "testPassword")}
+ comment_id = 12345 # A non-existing comment.
+ pkgbasename = package.PackageBase.Name
+ endpoint = f"/pkgbase/{pkgbasename}/comments/{comment_id}/form"
+ with client as request:
+ resp = request.get(endpoint, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.NOT_FOUND)
+
+
+def test_pkgbase_comments_missing_comment(client: TestClient, maintainer: User,
+ package: Package):
+ cookies = {"AURSID": maintainer.login(Request(), "testPassword")}
+ endpoint = f"/pkgbase/{package.PackageBase.Name}/comments"
+ with client as request:
+ resp = request.post(endpoint, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.EXPECTATION_FAILED)
+
+
+def test_pkgbase_comments(client: TestClient, maintainer: User,
+ package: Package):
+ cookies = {"AURSID": maintainer.login(Request(), "testPassword")}
+ pkgbasename = package.PackageBase.Name
+ endpoint = f"/pkgbase/{pkgbasename}/comments"
+ with client as request:
+ resp = request.post(endpoint, data={
+ "comment": "Test comment.",
+ "enable_notifications": True
+ }, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.SEE_OTHER)
+
+ expected_prefix = f"/pkgbase/{pkgbasename}"
+ prefix_len = len(expected_prefix)
+ assert resp.headers.get("location")[:prefix_len] == expected_prefix
+
+ with client as request:
+ resp = request.get(resp.headers.get("location"))
+ assert resp.status_code == int(HTTPStatus.OK)
+
+ root = parse_root(resp.text)
+ headers = root.xpath('//h4[@class="comment-header"]')
+ bodies = root.xpath('//div[@class="article-content"]/div/p')
+
+ assert len(headers) == 1
+ assert len(bodies) == 1
+
+ assert bodies[0].text.strip() == "Test comment."
+
+ # Clear up the PackageNotification. This doubles as testing
+ # that the notification was created and clears it up so we can
+ # test enabling it during edit.
+ pkgbase = package.PackageBase
+ db_notif = pkgbase.notifications.filter(
+ PackageNotification.UserID == maintainer.ID
+ ).first()
+ with db.begin():
+ db.session.delete(db_notif)
+
+ # Now, let's edit the comment we just created.
+ comment_id = int(headers[0].attrib["id"].split("-")[-1])
+ endpoint = f"/pkgbase/{pkgbasename}/comments/{comment_id}"
+ with client as request:
+ resp = request.post(endpoint, data={
+ "comment": "Edited comment.",
+ "enable_notifications": True
+ }, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.SEE_OTHER)
+
+ with client as request:
+ resp = request.get(resp.headers.get("location"))
+ assert resp.status_code == int(HTTPStatus.OK)
+
+ root = parse_root(resp.text)
+ headers = root.xpath('//h4[@class="comment-header"]')
+ bodies = root.xpath('//div[@class="article-content"]/div/p')
+
+ assert len(headers) == 1
+ assert len(bodies) == 1
+
+ assert bodies[0].text.strip() == "Edited comment."
+
+ # Ensure that a notification was created.
+ db_notif = pkgbase.notifications.filter(
+ PackageNotification.UserID == maintainer.ID
+ ).first()
+ assert db_notif is not None
+
+ # Don't supply a comment; should return EXPECTATION_FAILED.
+ with client as request:
+ fail_resp = request.post(endpoint, cookies=cookies)
+ assert fail_resp.status_code == int(HTTPStatus.EXPECTATION_FAILED)
+
+ # Now, test the form route, which should return form markup
+ # via JSON.
+ endpoint = f"{endpoint}/form"
+ with client as request:
+ resp = request.get(endpoint, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.OK)
+
+ data = resp.json()
+ assert "form" in data
Add Comment
- + {% include "partials/packages/comment_form.html" %}