diff --git a/aurweb/routers/packages.py b/aurweb/routers/packages.py
index 59b691ba..27a30205 100644
--- a/aurweb/routers/packages.py
+++ b/aurweb/routers/packages.py
@@ -13,7 +13,7 @@ from aurweb import db, defaults, l10n, logging, models, util
from aurweb.auth import auth_required
from aurweb.models.package_request import ACCEPTED_ID, PENDING_ID, REJECTED_ID
from aurweb.models.relation_type import CONFLICTS_ID
-from aurweb.models.request_type import DELETION_ID
+from aurweb.models.request_type import DELETION_ID, MERGE, MERGE_ID
from aurweb.packages.search import PackageSearch
from aurweb.packages.util import get_pkg_or_base, get_pkgbase_comment, query_notified, query_voted
from aurweb.scripts import notify, popupdate
@@ -1053,6 +1053,7 @@ async def pkgbase_delete_post(request: Request, name: str,
return render_template(request, "packages/delete.html", context,
status_code=HTTPStatus.BAD_REQUEST)
+ # Obtain deletion locks and delete the packages.
packages = pkgbase.packages.all()
for package in packages:
delete_package(request.user, package)
@@ -1343,3 +1344,137 @@ async def pkgbase_merge_get(request: Request, name: str,
return render_template(request, "pkgbase/merge.html", context,
status_code=status_code)
+
+
+def pkgbase_merge_instance(request: Request, pkgbase: models.PackageBase,
+ target: models.PackageBase):
+ pkgbasename = str(pkgbase.Name)
+
+ # Collect requests related to this merge.
+ query = pkgbase.requests.filter(
+ models.PackageRequest.ReqTypeID == MERGE_ID
+ )
+
+ requests = query.filter(
+ models.PackageRequest.MergeBaseName == target.Name).all()
+ reject_requests = query.filter(
+ models.PackageRequest.MergeBaseName != target.Name).all()
+
+ notifs = [] # Used to keep track of notifications over the function.
+ closure_comment = (f"Merged into package base {target.Name} by "
+ f"{request.user.Username}.")
+ rejected_closure_comment = ("Rejected because another merge request "
+ "for the same package base was accepted.")
+ conn = db.ConnectionExecutor(db.get_engine().raw_connection())
+ if not requests:
+ # If there are no requests, create one owned by request.user.
+ with db.begin():
+ pkgreq = db.create(models.PackageRequest,
+ ReqTypeID=MERGE_ID,
+ User=request.user,
+ PackageBase=pkgbase,
+ PackageBaseName=pkgbasename,
+ MergeBaseName=target.Name,
+ Comments="Generated by aurweb.",
+ Status=ACCEPTED_ID,
+ ClosureComment=closure_comment)
+ requests.append(pkgreq)
+
+ # Add a notification about the opening to our notifs array.
+ notif = notify.RequestOpenNotification(
+ conn, request.user.ID, pkgreq.ID, MERGE,
+ pkgbase.ID, merge_into=target.Name)
+ notifs.append(notif)
+
+ with db.begin():
+ # Merge pkgbase's comments, notifications and votes into target.
+ for comment in pkgbase.comments:
+ comment.PackageBase = target
+ for notif in pkgbase.notifications:
+ notif.PackageBase = target
+ for vote in pkgbase.package_votes:
+ vote.PackageBase = target
+
+ with db.begin():
+ # Delete pkgbase and its packages now that everything's merged.
+ for pkg in pkgbase.packages:
+ db.session.delete(pkg)
+ db.session.delete(pkgbase)
+
+ # Accept merge requests related to this pkgbase and target.
+ for pkgreq in requests:
+ pkgreq.Status = ACCEPTED_ID
+ pkgreq.ClosureComment = closure_comment
+ pkgreq.Closer = request.user
+
+ for pkgreq in reject_requests:
+ pkgreq.Status = REJECTED_ID
+ pkgreq.ClosureComment = rejected_closure_comment
+ pkgreq.Closer = request.user
+
+ all_requests = requests + reject_requests
+ for pkgreq in all_requests:
+ # Create notifications for request closure.
+ notif = notify.RequestCloseNotification(
+ conn, request.user.ID, pkgreq.ID, pkgreq.status_display())
+ notifs.append(notif)
+
+ conn.close()
+
+ # Log this out for accountability purposes.
+ logger.info(f"Trusted User '{request.user.Username}' merged "
+ f"'{pkgbasename}' into '{target.Name}'.")
+
+ # Send our notifications array.
+ util.apply_all(notifs, lambda n: n.send())
+
+
+@router.post("/pkgbase/{name}/merge")
+@auth_required(redirect="/pkgbase/{name}/merge")
+async def pkgbase_merge_post(request: Request, name: str,
+ into: str = Form(default=str()),
+ confirm: bool = Form(default=False),
+ next: str = Form(default=str())):
+
+ pkgbase = get_pkg_or_base(name, models.PackageBase)
+ context = await make_variable_context(request, "Package Merging")
+ context["pkgbase"] = pkgbase
+
+ # TODO: Lookup errors from credential instead of hardcoding them.
+ if not request.user.has_credential("CRED_PKGBASE_MERGE"):
+ context["errors"] = [
+ "Only Trusted Users and Developers can merge packages."]
+ return render_template(request, "pkgbase/merge.html", context,
+ status_code=HTTPStatus.UNAUTHORIZED)
+
+ if not confirm:
+ context["errors"] = ["The selected packages have not been deleted, "
+ "check the confirmation checkbox."]
+ return render_template(request, "pkgbase/merge.html", context,
+ status_code=HTTPStatus.BAD_REQUEST)
+
+ try:
+ target = get_pkg_or_base(into, models.PackageBase)
+ except HTTPException:
+ context["errors"] = [
+ "Cannot find package to merge votes and comments into."]
+ return render_template(request, "pkgbase/merge.html", context,
+ status_code=HTTPStatus.BAD_REQUEST)
+
+ if pkgbase == target:
+ context["errors"] = ["Cannot merge a package base with itself."]
+ return render_template(request, "pkgbase/merge.html", context,
+ status_code=HTTPStatus.BAD_REQUEST)
+
+ # Merge pkgbase into target.
+ pkgbase_merge_instance(request, pkgbase, target)
+
+ # Run popupdate on the target.
+ conn = db.ConnectionExecutor(db.get_engine().raw_connection())
+ popupdate.run_single(conn, target)
+
+ if not next:
+ next = f"/pkgbase/{target.Name}"
+
+ # Redirect to the newly merged into package.
+ return RedirectResponse(next, status_code=HTTPStatus.SEE_OTHER)
diff --git a/templates/pkgbase/merge.html b/templates/pkgbase/merge.html
index d0a50332..41f69916 100644
--- a/templates/pkgbase/merge.html
+++ b/templates/pkgbase/merge.html
@@ -1,6 +1,15 @@
{% extends "partials/layout.html" %}
{% block pageContent %}
+
+ {% if errors %}
+
+ {% for error in errors %}
+ - {{ error | tr }}
+ {% endfor %}
+
+ {% endif %}
+
{{ "Merge Package" | tr }}: {{ pkgbase.Name }}
diff --git a/test/test_packages_routes.py b/test/test_packages_routes.py
index f7f30330..7e67775a 100644
--- a/test/test_packages_routes.py
+++ b/test/test_packages_routes.py
@@ -8,6 +8,7 @@ from unittest import mock
import pytest
from fastapi.testclient import TestClient
+from sqlalchemy import and_
from aurweb import asgi, db, defaults
from aurweb.models.account_type import USER_ID, AccountType
@@ -24,7 +25,7 @@ from aurweb.models.package_relation import PackageRelation
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
+from aurweb.models.request_type import DELETION_ID, MERGE_ID, RequestType
from aurweb.models.user import User
from aurweb.testing import setup_test_db
from aurweb.testing.html import get_errors, get_successes, parse_root
@@ -2391,3 +2392,134 @@ def test_packages_post_delete(caplog: pytest.fixture, client: TestClient,
expected = (f"Privileged user '{tu_user.Username}' deleted the "
f"following packages: {str(packages)}.")
assert expected in caplog.text
+
+
+def test_pkgbase_merge_post_unauthorized(client: TestClient, user: User,
+ package: Package):
+ cookies = {"AURSID": user.login(Request(), "testPassword")}
+ endpoint = f"/pkgbase/{package.PackageBase.Name}/merge"
+ with client as request:
+ resp = request.post(endpoint, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.UNAUTHORIZED)
+
+
+def test_pkgbase_merge_post_unconfirmed(client: TestClient, tu_user: User,
+ package: Package):
+ cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
+ endpoint = f"/pkgbase/{package.PackageBase.Name}/merge"
+ with client as request:
+ resp = request.post(endpoint, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.BAD_REQUEST)
+ errors = get_errors(resp.text)
+ expected = ("The selected packages have not been deleted, "
+ "check the confirmation checkbox.")
+ assert errors[0].text.strip() == expected
+
+
+def test_pkgbase_merge_post_invalid_into(client: TestClient, tu_user: User,
+ package: Package):
+ cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
+ endpoint = f"/pkgbase/{package.PackageBase.Name}/merge"
+ with client as request:
+ resp = request.post(endpoint, data={
+ "into": "not_real",
+ "confirm": True
+ }, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.BAD_REQUEST)
+ errors = get_errors(resp.text)
+ expected = "Cannot find package to merge votes and comments into."
+ assert errors[0].text.strip() == expected
+
+
+def test_pkgbase_merge_post_self_invalid(client: TestClient, tu_user: User,
+ package: Package):
+ cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
+ endpoint = f"/pkgbase/{package.PackageBase.Name}/merge"
+ with client as request:
+ resp = request.post(endpoint, data={
+ "into": package.PackageBase.Name,
+ "confirm": True
+ }, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.BAD_REQUEST)
+ errors = get_errors(resp.text)
+ expected = "Cannot merge a package base with itself."
+ assert errors[0].text.strip() == expected
+
+
+def test_pkgbase_merge_post(client: TestClient, tu_user: User,
+ packages: List[Package]):
+ package, target = packages[:2]
+ pkgname = package.Name
+ pkgbasename = package.PackageBase.Name
+
+ # Create a merge request destined for another target.
+ # This will allow our test code to exercise closing
+ # such a request after merging the pkgbase in question.
+ with db.begin():
+ pkgreq = db.create(PackageRequest,
+ User=tu_user,
+ ReqTypeID=MERGE_ID,
+ PackageBase=package.PackageBase,
+ PackageBaseName=pkgbasename,
+ MergeBaseName="test",
+ Comments="Test comment.",
+ ClosureComment="Test closure.")
+
+ # Vote for the package.
+ cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
+ endpoint = f"/pkgbase/{package.PackageBase.Name}/vote"
+ with client as request:
+ resp = request.post(endpoint, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.SEE_OTHER)
+
+ # Enable notifications.
+ endpoint = f"/pkgbase/{package.PackageBase.Name}/notify"
+ with client as request:
+ resp = request.post(endpoint, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.SEE_OTHER)
+
+ # Comment on the package.
+ endpoint = f"/pkgbase/{package.PackageBase.Name}/comments"
+ with client as request:
+ resp = request.post(endpoint, data={
+ "comment": "Test comment."
+ }, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.SEE_OTHER)
+
+ # Save these relationships for later comparison.
+ comments = package.PackageBase.comments.all()
+ notifs = package.PackageBase.notifications.all()
+ votes = package.PackageBase.package_votes.all()
+
+ # Merge the package into target.
+ endpoint = f"/pkgbase/{package.PackageBase.Name}/merge"
+ with client as request:
+ resp = request.post(endpoint, data={
+ "into": target.PackageBase.Name,
+ "confirm": True
+ }, cookies=cookies)
+ assert resp.status_code == int(HTTPStatus.SEE_OTHER)
+ loc = resp.headers.get("location")
+ assert loc == f"/pkgbase/{target.PackageBase.Name}"
+
+ # Assert that the original comments, notifs and votes we setup
+ # got migrated to target as intended.
+ assert comments == target.PackageBase.comments.all()
+ assert notifs == target.PackageBase.notifications.all()
+ assert votes == target.PackageBase.package_votes.all()
+
+ # ...and that the package got deleted.
+ package = db.query(Package).filter(Package.Name == pkgname).first()
+ assert package is None
+
+ # Our fake target request should have gotten rejected.
+ assert pkgreq.Status == REJECTED_ID
+ assert pkgreq.Closer is not None
+
+ # A PackageRequest is always created when merging this way.
+ pkgreq = db.query(PackageRequest).filter(
+ and_(PackageRequest.ReqTypeID == MERGE_ID,
+ PackageRequest.PackageBaseName == pkgbasename,
+ PackageRequest.MergeBaseName == target.PackageBase.Name)
+ ).first()
+ assert pkgreq is not None