diff --git a/aurweb/routers/packages.py b/aurweb/routers/packages.py
index 231f953b..a3effb36 100644
--- a/aurweb/routers/packages.py
+++ b/aurweb/routers/packages.py
@@ -22,7 +22,7 @@ from aurweb.models.package_dependency import PackageDependency
from aurweb.models.package_license import PackageLicense
from aurweb.models.package_notification import PackageNotification
from aurweb.models.package_relation import PackageRelation
-from aurweb.models.package_request import PENDING_ID, PackageRequest
+from aurweb.models.package_request import ACCEPTED_ID, PENDING_ID, REJECTED_ID, PackageRequest
from aurweb.models.package_source import PackageSource
from aurweb.models.package_vote import PackageVote
from aurweb.models.relation_type import CONFLICTS_ID
@@ -651,3 +651,53 @@ async def pkgbase_request_post(request: Request, name: str,
# Redirect the submitting user to /packages.
return RedirectResponse("/packages",
status_code=int(HTTPStatus.SEE_OTHER))
+
+
+@router.get("/requests/{id}/close")
+@auth_required(True)
+async def requests_close(request: Request, id: int):
+ pkgreq = db.query(PackageRequest).filter(PackageRequest.ID == id).first()
+ if not request.user.is_elevated() and request.user != pkgreq.User:
+ # Request user doesn't have permission here: redirect to '/'.
+ return RedirectResponse("/", status_code=int(HTTPStatus.SEE_OTHER))
+
+ context = make_context(request, "Close Request")
+ context["pkgreq"] = pkgreq
+ return render_template(request, "requests/close.html", context)
+
+
+@router.post("/requests/{id}/close")
+@auth_required(True)
+async def requests_close_post(request: Request, id: int,
+ reason: int = Form(default=0),
+ comments: str = Form(default=str())):
+ pkgreq = db.query(PackageRequest).filter(PackageRequest.ID == id).first()
+ if not request.user.is_elevated() and request.user != pkgreq.User:
+ # Request user doesn't have permission here: redirect to '/'.
+ return RedirectResponse("/", status_code=int(HTTPStatus.SEE_OTHER))
+
+ context = make_context(request, "Close Request")
+ context["pkgreq"] = pkgreq
+
+ if reason not in {ACCEPTED_ID, REJECTED_ID}:
+ # If the provided reason is not valid, send the user back to
+ # the closure form with a BAD_REQUEST status.
+ return render_template(request, "requests/close.html", context,
+ status_code=HTTPStatus.BAD_REQUEST)
+
+ if not request.user.is_elevated():
+ # If we're closing the request as the user who created it,
+ # the reason should just be a REJECTION.
+ reason = REJECTED_ID
+
+ with db.begin():
+ pkgreq.Closer = request.user
+ pkgreq.Status = reason
+ pkgreq.ClosureComment = comments
+
+ conn = db.ConnectionExecutor(db.get_engine().raw_connection())
+ notify_ = notify.RequestCloseNotification(
+ conn, request.user.ID, pkgreq.ID, pkgreq.status_display())
+ notify_.send()
+
+ return RedirectResponse("/requests", status_code=int(HTTPStatus.SEE_OTHER))
diff --git a/templates/requests/close.html b/templates/requests/close.html
new file mode 100644
index 00000000..7862064a
--- /dev/null
+++ b/templates/requests/close.html
@@ -0,0 +1,60 @@
+{% extends "partials/layout.html" %}
+
+{% block pageContent %}
+
+
{{ "Close Request" | tr }}: {{ pkgreq.PackageBaseName }}
+
+
+ {{
+ "Use this form to close the request for package base %s%s%s."
+ | tr | format("", pkgreq.PackageBaseName, "")
+ | safe
+ }}
+
+
+
+ {{ "Note" | tr }}:
+ {{
+ "The comments field can be left empty. However, it is highly "
+ "recommended to add a comment when rejecting a request."
+ | tr
+ }}
+
+
+
+
+
+{% endblock %}
diff --git a/test/test_packages_routes.py b/test/test_packages_routes.py
index 5353d3bf..5afe011a 100644
--- a/test/test_packages_routes.py
+++ b/test/test_packages_routes.py
@@ -20,7 +20,7 @@ from aurweb.models.package_dependency import PackageDependency
from aurweb.models.package_keyword import PackageKeyword
from aurweb.models.package_notification import PackageNotification
from aurweb.models.package_relation import PackageRelation
-from aurweb.models.package_request import PackageRequest
+from aurweb.models.package_request import ACCEPTED_ID, REJECTED_ID, PackageRequest
from aurweb.models.package_vote import PackageVote
from aurweb.models.relation_type import PROVIDES_ID, RelationType
from aurweb.models.request_type import DELETION_ID, RequestType
@@ -1581,3 +1581,87 @@ def test_pkgbase_request_post_merge_self_error(client: TestClient, user: User,
error = root.xpath('//ul[@class="errorlist"]/li')[0]
expected = "You cannot merge a package base into itself."
assert error.text.strip() == expected
+
+
+@pytest.fixture
+def pkgreq(user: User, package: Package) -> PackageRequest:
+ reqtype = db.query(RequestType).filter(
+ RequestType.ID == DELETION_ID
+ ).first()
+ with db.begin():
+ pkgreq = db.create(PackageRequest,
+ RequestType=reqtype,
+ User=user,
+ PackageBase=package.PackageBase,
+ PackageBaseName=package.PackageBase.Name,
+ Comments=str(),
+ ClosureComment=str())
+ yield pkgreq
+
+
+def test_requests_close(client: TestClient, user: User,
+ pkgreq: PackageRequest):
+ cookies = {"AURSID": user.login(Request(), "testPassword")}
+ with client as request:
+ resp = request.get(f"/requests/{pkgreq.ID}/close", cookies=cookies,
+ allow_redirects=False)
+ assert resp.status_code == int(HTTPStatus.OK)
+
+
+def test_requests_close_unauthorized(client: TestClient, maintainer: User,
+ pkgreq: PackageRequest):
+ cookies = {"AURSID": maintainer.login(Request(), "testPassword")}
+ with client as request:
+ resp = request.get(f"/requests/{pkgreq.ID}/close", cookies=cookies,
+ allow_redirects=False)
+ assert resp.status_code == int(HTTPStatus.SEE_OTHER)
+ assert resp.headers.get("location") == "/"
+
+
+def test_requests_close_post_invalid_reason(client: TestClient, user: User,
+ pkgreq: PackageRequest):
+ cookies = {"AURSID": user.login(Request(), "testPassword")}
+ with client as request:
+ resp = request.post(f"/requests/{pkgreq.ID}/close", data={
+ "reason": 0
+ }, cookies=cookies, allow_redirects=False)
+ assert resp.status_code == int(HTTPStatus.BAD_REQUEST)
+
+
+def test_requests_close_post_unauthorized(client: TestClient, maintainer: User,
+ pkgreq: PackageRequest):
+ cookies = {"AURSID": maintainer.login(Request(), "testPassword")}
+ with client as request:
+ resp = request.post(f"/requests/{pkgreq.ID}/close", data={
+ "reason": ACCEPTED_ID
+ }, cookies=cookies, allow_redirects=False)
+ assert resp.status_code == int(HTTPStatus.SEE_OTHER)
+ assert resp.headers.get("location") == "/"
+
+
+def test_requests_close_post(client: TestClient, user: User,
+ pkgreq: PackageRequest):
+ cookies = {"AURSID": user.login(Request(), "testPassword")}
+ with client as request:
+ resp = request.post(f"/requests/{pkgreq.ID}/close", data={
+ "reason": REJECTED_ID
+ }, cookies=cookies, allow_redirects=False)
+ assert resp.status_code == int(HTTPStatus.SEE_OTHER)
+
+ assert pkgreq.Status == REJECTED_ID
+ assert pkgreq.Closer == user
+ assert pkgreq.ClosureComment == str()
+
+
+def test_requests_close_post_rejected(client: TestClient, user: User,
+ pkgreq: PackageRequest):
+ cookies = {"AURSID": user.login(Request(), "testPassword")}
+ with client as request:
+ resp = request.post(f"/requests/{pkgreq.ID}/close", data={
+ "reason": REJECTED_ID
+ }, cookies=cookies, allow_redirects=False)
+ assert resp.status_code == int(HTTPStatus.SEE_OTHER)
+
+ assert pkgreq.Status == REJECTED_ID
+ assert pkgreq.Closer == user
+ assert pkgreq.ClosureComment == str()