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 %} -
-
- - - - -
-
- Edit comment + {% 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 %} +
+
+ + + + +
+
+ Edit comment + +
+
+ + + + + +
+
{% 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 %} + +
+
+

+ {{ "Git commit identifiers referencing commits in the AUR package " + "repository and URLs are converted to links automatically." | tr }} + {{ "%sMarkdown syntax%s is partially supported." | tr + | format('', + "") + | safe }} +

+

+ +

+

+ + {% if comment and not request.user.notified(pkgbase) %} + + + + + {% 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() %}

Add Comment

-
-
-
- - -
-

- {{ - "Git commit identifiers referencing commits in the AUR package" - " repository and URLs are converted to links automatically." - | tr - }} - {{ - "%sMarkdown Syntax%s is partially supported." - | tr - | format('', '') - | safe - }} -

-

- -

-

- - {% if not notifications_enabled %} - - - - - {% endif %} -

-
-
+ {% include "partials/packages/comment_form.html" %}
{% 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