fix(requests): rework handling of requests

This commit changes several things about how we were handling
package requests.

Modifications (requests):
-------------
- `/requests/{id}/close` no longer provides an Accepted selection.
  All manual request closures will cause a rejection.
- Relevent `pkgbase` actions now trigger request closures:
  `/pkgbase/{name}/delete` (deletion), `/pkgbase/{name}/merge` (merge)
  and `/pkgbase/{name}/disown` (orphan).
- Comment fields have been added to
  `/pkgbase/{name}/{delete,merge,disown}`, which is used to set the
  `PackageRequest.ClosureComment` on pending requests. If the comment
  field is left blank, a closure comment is autogenerated.
- Autogenerated request notifications are only sent out once
  as a closure notification.
- Some markup has been fixed.

Modifications (disown/orphan):
-----------------------------
- Orphan requests are now handled through the same path as
  deletion/merge.
- We now check for due date when disowning as non-maintainer;
  previously, this was only done for display and not functionally.
  This check applies to Trusted Users' disowning of a package.

This style of notification flow does reduce our visibility, but
accounting can still be done via the close request; it includes
the action, pkgbase name and the user who accepted it.

Closes #204

Signed-off-by: Kevin Morris <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2021-12-08 17:34:44 -08:00
parent bad57ba502
commit 26b1674c9e
No known key found for this signature in database
GPG key ID: F7E46DED420788F3
10 changed files with 1044 additions and 354 deletions

240
aurweb/packages/requests.py Normal file
View file

@ -0,0 +1,240 @@
from datetime import datetime
from typing import List, Optional, Set
from fastapi import Request
from sqlalchemy import and_, orm
from aurweb import config, db, l10n, util
from aurweb.exceptions import InvariantError
from aurweb.models import PackageBase, PackageRequest, User
from aurweb.models.package_request import ACCEPTED_ID, PENDING_ID, REJECTED_ID
from aurweb.models.request_type import DELETION, DELETION_ID, MERGE, MERGE_ID, ORPHAN, ORPHAN_ID
from aurweb.scripts import notify
class ClosureFactory:
""" A factory class used to autogenerate closure comments. """
REQTYPE_NAMES = {
DELETION_ID: DELETION,
MERGE_ID: MERGE,
ORPHAN_ID: ORPHAN
}
def _deletion_closure(self, requester: User,
pkgbase: PackageBase,
target: PackageBase = None):
return (f"[Autogenerated] Accepted deletion for {pkgbase.Name}.")
def _merge_closure(self, requester: User,
pkgbase: PackageBase,
target: PackageBase = None):
return (f"[Autogenerated] Accepted merge for {pkgbase.Name} "
"into {target.Name}.")
def _orphan_closure(self, requester: User,
pkgbase: PackageBase,
target: PackageBase = None):
return (f"[Autogenerated] Accepted orphan for {pkgbase.Name}.")
def _rejected_merge_closure(self, requester: User,
pkgbase: PackageBase,
target: PackageBase = None):
return (f"[Autogenerated] Another request to merge {pkgbase.Name} "
f"into {target.Name} has rendered this request invalid.")
def get_closure(self, reqtype_id: int,
requester: User,
pkgbase: PackageBase,
target: PackageBase = None,
status: int = ACCEPTED_ID) -> str:
"""
Return a closure comment handled by this class.
:param reqtype_id: RequestType.ID
:param requester: User who is closing a request
:param pkgbase: PackageBase instance related to the request
:param target: Merge request target PackageBase instance
:param status: PackageRequest.Status
"""
reqtype = ClosureFactory.REQTYPE_NAMES.get(reqtype_id)
partial = str()
if status == REJECTED_ID:
partial = "_rejected"
try:
handler = getattr(self, f"{partial}_{reqtype}_closure")
except AttributeError:
raise NotImplementedError("Unsupported 'reqtype_id' value.")
return handler(requester, pkgbase, target)
def update_closure_comment(pkgbase: PackageBase, reqtype_id: int,
comments: str, target: PackageBase = None) -> None:
"""
Update all pending requests related to `pkgbase` with a closure comment.
In order to persist closure comments through `handle_request`'s
algorithm, we must set `PackageRequest.ClosureComment` before calling
it. This function can be used to update the closure comment of all
package requests related to `pkgbase` and `reqtype_id`.
If an empty `comments` string is provided, we no-op out of this.
:param pkgbase: PackageBase instance
:param reqtype_id: RequestType.ID
:param comments: PackageRequest.ClosureComment to update to
:param target: Merge request target PackageBase instance
"""
if not comments:
return
query = pkgbase.requests.filter(
and_(PackageRequest.ReqTypeID == reqtype_id,
PackageRequest.Status == PENDING_ID))
if reqtype_id == MERGE_ID:
query = query.filter(PackageRequest.MergeBaseName == target.Name)
for pkgreq in query:
pkgreq.ClosureComment = comments
def verify_orphan_request(user: User, pkgbase: PackageBase):
""" Verify that an undue orphan request exists in `requests`. """
is_maint = user == pkgbase.Maintainer
if is_maint:
return True
requests = pkgbase.requests.filter(
PackageRequest.ReqTypeID == ORPHAN_ID)
for pkgreq in requests:
idle_time = config.getint("options", "request_idle_time")
time_delta = int(datetime.utcnow().timestamp()) - pkgreq.RequestTS
is_due = pkgreq.Status == PENDING_ID and time_delta > idle_time
if is_due:
# If the requester is the pkgbase maintainer or the
# request is already due, we're good to go: return True.
return True
return False
def close_pkgreq(pkgreq: PackageRequest, closer: User,
pkgbase: PackageBase, target: Optional[PackageBase],
status: int) -> None:
"""
Close a package request with `pkgreq`.Status == `status`.
:param pkgreq: PackageRequest instance
:param closer: `pkgreq`.Closer User instance to update to
:param pkgbase: PackageBase instance which `pkgreq` is about
:param target: Optional PackageBase instance to merge into
:param status: `pkgreq`.Status value to update to
"""
now = int(datetime.utcnow().timestamp())
pkgreq.Status = status
pkgreq.Closer = closer
pkgreq.ClosureComment = (
pkgreq.ClosureComment or ClosureFactory().get_closure(
pkgreq.ReqTypeID, closer, pkgbase, target, status)
)
pkgreq.ClosedTS = now
def handle_request(request: Request, reqtype_id: int,
pkgbase: PackageBase,
target: PackageBase = None) -> List[notify.Notification]:
"""
Handle package requests before performing an action.
The actions we're interested in are disown (orphan), delete and
merge. There is now an automated request generation and closure
notification when a privileged user performs one of these actions
without a pre-existing request. They all commit changes to the
database, and thus before calling, state should be verified to
avoid leaked database records regarding these requests.
Otherwise, we accept and reject requests based on their state
and send out the relevent notifications.
:param requester: User who needs this a `pkgbase` request handled
:param reqtype_id: RequestType.ID
:param pkgbase: PackageBase which the request is about
:param target: Optional target to merge into
"""
notifs: List[notify.Notification] = []
# If it's an orphan request, perform further verification
# regarding existing requests.
if reqtype_id == ORPHAN_ID:
if not verify_orphan_request(request.user, pkgbase):
_ = l10n.get_translator_for_request(request)
raise InvariantError(_(
"No due existing orphan requests to accept for %s."
) % pkgbase.Name)
# Produce a base query for requests related to `pkgbase`, based
# on ReqTypeID matching `reqtype_id`, pending status and a correct
# PackagBaseName column.
query: orm.Query = pkgbase.requests.filter(
and_(PackageRequest.ReqTypeID == reqtype_id,
PackageRequest.Status == PENDING_ID,
PackageRequest.PackageBaseName == pkgbase.Name))
# Build a query for records we should accept. For merge requests,
# this is specific to a matching MergeBaseName. For others, this
# just ends up becoming `query`.
accept_query: orm.Query = query
if target:
# If a `target` was supplied, filter by MergeBaseName
accept_query = query.filter(
PackageRequest.MergeBaseName == target.Name)
# Build an accept list out of `accept_query`.
to_accept: List[PackageRequest] = accept_query.all()
accepted_ids: Set[int] = set(p.ID for p in to_accept)
# Build a reject list out of `query` filtered by IDs not found
# in `to_accept`. That is, unmatched records of the same base
# query properties.
to_reject: List[PackageRequest] = query.filter(
~PackageRequest.ID.in_(accepted_ids)
).all()
# If we have no requests to accept, create a new one.
# This is done to increase tracking of actions occurring
# through the website.
if not to_accept:
with db.begin():
pkgreq = db.create(PackageRequest,
ReqTypeID=reqtype_id,
User=request.user,
PackageBase=pkgbase,
PackageBaseName=pkgbase.Name,
Comments="Autogenerated by aurweb.",
ClosureComment=str())
# If it's a merge request, set MergeBaseName to `target`.Name.
if pkgreq.ReqTypeID == MERGE_ID:
pkgreq.MergeBaseName = target.Name
# Add the new request to `to_accept` and allow standard
# flow to continue afterward.
to_accept.append(pkgreq)
# Update requests with their new status and closures.
with db.begin():
util.apply_all(to_accept, lambda p: close_pkgreq(
p, request.user, pkgbase, target, ACCEPTED_ID))
util.apply_all(to_reject, lambda p: close_pkgreq(
p, request.user, pkgbase, target, REJECTED_ID))
# Create RequestCloseNotifications for all requests involved.
for pkgreq in (to_accept + to_reject):
notif = notify.RequestCloseNotification(
request.user.ID, pkgreq.ID, pkgreq.status_display())
notifs.append(notif)
# Return notifications to the caller for sending.
return notifs

View file

@ -4,19 +4,20 @@ from typing import Any, Dict, List
from fastapi import APIRouter, Form, HTTPException, Query, Request, Response from fastapi import APIRouter, Form, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import case from sqlalchemy import and_, case
import aurweb.filters import aurweb.filters
import aurweb.packages.util import aurweb.packages.util
from aurweb import db, defaults, l10n, logging, models, util from aurweb import db, defaults, l10n, logging, models, util
from aurweb.auth import auth_required, creds from aurweb.auth import auth_required, creds
from aurweb.exceptions import ValidationError from aurweb.exceptions import InvariantError, ValidationError
from aurweb.models.package_request import ACCEPTED_ID, PENDING_ID, REJECTED_ID from aurweb.models.package_request import ACCEPTED_ID, PENDING_ID, REJECTED_ID
from aurweb.models.relation_type import CONFLICTS_ID, PROVIDES_ID, REPLACES_ID from aurweb.models.relation_type import CONFLICTS_ID, PROVIDES_ID, REPLACES_ID
from aurweb.models.request_type import DELETION_ID, MERGE, MERGE_ID from aurweb.models.request_type import DELETION_ID, MERGE_ID, ORPHAN_ID
from aurweb.packages import util as pkgutil from aurweb.packages import util as pkgutil
from aurweb.packages import validate from aurweb.packages import validate
from aurweb.packages.requests import handle_request, update_closure_comment
from aurweb.packages.search import PackageSearch from aurweb.packages.search import PackageSearch
from aurweb.packages.util import get_pkg_or_base, get_pkgbase_comment, get_pkgreq_by_id, query_notified, query_voted from aurweb.packages.util import get_pkg_or_base, get_pkgbase_comment, get_pkgreq_by_id, query_notified, query_voted
from aurweb.scripts import notify, popupdate from aurweb.scripts import notify, popupdate
@ -115,66 +116,30 @@ async def packages(request: Request) -> Response:
return await packages_get(request, context) return await packages_get(request, context)
def create_request_if_missing(requests: List[models.PackageRequest], def delete_package(request: Request, package: models.Package,
reqtype: models.RequestType, merge_into: models.PackageBase = None,
user: models.User, comments: str = str()):
package: models.Package):
now = int(datetime.utcnow().timestamp())
pkgreq = db.query(models.PackageRequest).filter(
models.PackageRequest.PackageBaseName == package.PackageBase.Name
).first()
if not pkgreq:
# No PackageRequest existed. Create one.
comments = "Automatically generated by aurweb."
closure_comment = "Deleted by aurweb."
pkgreq = db.create(models.PackageRequest,
RequestType=reqtype,
PackageBase=package.PackageBase,
PackageBaseName=package.PackageBase.Name,
User=user,
Status=ACCEPTED_ID,
Comments=comments,
ClosureComment=closure_comment,
ClosedTS=now,
Closer=user)
requests.append(pkgreq)
return pkgreq
def delete_package(deleter: models.User, package: models.Package):
notifications = []
requests = []
bases_to_delete = [] bases_to_delete = []
# In all cases, though, just delete the Package in question. target = db.query(models.PackageBase).filter(
if package.PackageBase.packages.count() == 1: models.PackageBase.Name == merge_into
reqtype = db.query(models.RequestType).filter(
models.RequestType.ID == DELETION_ID
).first() ).first()
with db.begin(): notifs = []
pkgreq = create_request_if_missing( # In all cases, though, just delete the Package in question.
requests, reqtype, deleter, package) if package.PackageBase.packages.count() == 1:
pkgreq.Status = ACCEPTED_ID notifs = handle_request(request, DELETION_ID, package.PackageBase,
target=target)
bases_to_delete.append(package.PackageBase) bases_to_delete.append(package.PackageBase)
# Prepare DeleteNotification. with db.begin():
notifications.append( update_closure_comment(package.PackageBase, DELETION_ID, comments,
notify.DeleteNotification(deleter.ID, package.PackageBase.ID) target=target)
)
# For each PackageRequest created, mock up an open and close notification. # Prepare DeleteNotification.
basename = package.PackageBase.Name notifs.append(
for pkgreq in requests: notify.DeleteNotification(request.user.ID, package.PackageBase.ID)
notifications.append(
notify.RequestOpenNotification(
deleter.ID, pkgreq.ID, reqtype.Name,
pkgreq.PackageBase.ID, merge_into=basename or None)
)
notifications.append(
notify.RequestCloseNotification(
deleter.ID, pkgreq.ID, pkgreq.status_display())
) )
# Perform all the deletions. # Perform all the deletions.
@ -183,7 +148,7 @@ def delete_package(deleter: models.User, package: models.Package):
db.delete_all(bases_to_delete) db.delete_all(bases_to_delete)
# Send out all the notifications. # Send out all the notifications.
util.apply_all(notifications, lambda n: n.send()) util.apply_all(notifs, lambda n: n.send())
async def make_single_context(request: Request, async def make_single_context(request: Request,
@ -676,22 +641,26 @@ async def pkgbase_request_post(request: Request, name: str,
auto_orphan_age = aurweb.config.getint("options", "auto_orphan_age") auto_orphan_age = aurweb.config.getint("options", "auto_orphan_age")
auto_delete_age = aurweb.config.getint("options", "auto_delete_age") auto_delete_age = aurweb.config.getint("options", "auto_delete_age")
flagged = pkgbase.OutOfDateTS and pkgbase.OutOfDateTS >= auto_orphan_age ood_ts = pkgbase.OutOfDateTS or 0
flagged = ood_ts and (now - ood_ts) >= auto_orphan_age
is_maintainer = pkgbase.Maintainer == request.user is_maintainer = pkgbase.Maintainer == request.user
outdated = now - pkgbase.SubmittedTS <= auto_delete_age outdated = (now - pkgbase.SubmittedTS) <= auto_delete_age
if type == "orphan" and flagged: if type == "orphan" and flagged:
# This request should be auto-accepted.
with db.begin(): with db.begin():
pkgbase.Maintainer = None pkgbase.Maintainer = None
pkgreq.Status = ACCEPTED_ID pkgreq.Status = ACCEPTED_ID
db.refresh(pkgreq)
notif = notify.RequestCloseNotification( notif = notify.RequestCloseNotification(
request.user.ID, pkgreq.ID, pkgreq.status_display()) request.user.ID, pkgreq.ID, pkgreq.status_display())
notif.send() notif.send()
logger.debug(f"New request #{pkgreq.ID} is marked for auto-orphan.")
elif type == "deletion" and is_maintainer and outdated: elif type == "deletion" and is_maintainer and outdated:
# This request should be auto-accepted.
packages = pkgbase.packages.all() packages = pkgbase.packages.all()
for package in packages: for package in packages:
delete_package(request.user, package) delete_package(request, package)
logger.debug(f"New request #{pkgreq.ID} is marked for auto-deletion.")
# Redirect the submitting user to /packages. # Redirect the submitting user to /packages.
return RedirectResponse("/packages", status_code=HTTPStatus.SEE_OTHER) return RedirectResponse("/packages", status_code=HTTPStatus.SEE_OTHER)
@ -713,31 +682,24 @@ async def requests_close(request: Request, id: int):
@router.post("/requests/{id}/close") @router.post("/requests/{id}/close")
@auth_required() @auth_required()
async def requests_close_post(request: Request, id: int, async def requests_close_post(request: Request, id: int,
reason: int = Form(default=0),
comments: str = Form(default=str())): comments: str = Form(default=str())):
pkgreq = get_pkgreq_by_id(id) pkgreq = get_pkgreq_by_id(id)
if not request.user.is_elevated() and request.user != pkgreq.User:
# `pkgreq`.User can close their own request.
approved = [pkgreq.User]
if not request.user.has_credential(creds.PKGREQ_CLOSE, approved=approved):
# Request user doesn't have permission here: redirect to '/'. # Request user doesn't have permission here: redirect to '/'.
return RedirectResponse("/", status_code=HTTPStatus.SEE_OTHER) return RedirectResponse("/", status_code=HTTPStatus.SEE_OTHER)
context = make_context(request, "Close Request") context = make_context(request, "Close Request")
context["pkgreq"] = pkgreq context["pkgreq"] = pkgreq
if reason not in {ACCEPTED_ID, REJECTED_ID}: now = int(datetime.utcnow().timestamp())
# 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(): with db.begin():
pkgreq.Closer = request.user pkgreq.Closer = request.user
pkgreq.Status = reason
pkgreq.ClosureComment = comments pkgreq.ClosureComment = comments
pkgreq.ClosedTS = now
pkgreq.Status = REJECTED_ID
notify_ = notify.RequestCloseNotification( notify_ = notify.RequestCloseNotification(
request.user.ID, pkgreq.ID, pkgreq.status_display()) request.user.ID, pkgreq.ID, pkgreq.status_display())
@ -932,7 +894,8 @@ async def pkgbase_unvote(request: Request, name: str):
def pkgbase_disown_instance(request: Request, pkgbase: models.PackageBase): def pkgbase_disown_instance(request: Request, pkgbase: models.PackageBase):
disowner = request.user disowner = request.user
notif = notify.DisownNotification(disowner.ID, pkgbase.ID) notifs = [notify.DisownNotification(disowner.ID, pkgbase.ID)]
notifs += handle_request(request, ORPHAN_ID, pkgbase)
if disowner != pkgbase.Maintainer: if disowner != pkgbase.Maintainer:
with db.begin(): with db.begin():
@ -949,7 +912,7 @@ def pkgbase_disown_instance(request: Request, pkgbase: models.PackageBase):
else: else:
pkgbase.Maintainer = None pkgbase.Maintainer = None
notif.send() util.apply_all(notifs, lambda n: n.send())
@router.get("/pkgbase/{name}/disown") @router.get("/pkgbase/{name}/disown")
@ -971,6 +934,7 @@ async def pkgbase_disown_get(request: Request, name: str):
@router.post("/pkgbase/{name}/disown") @router.post("/pkgbase/{name}/disown")
@auth_required() @auth_required()
async def pkgbase_disown_post(request: Request, name: str, async def pkgbase_disown_post(request: Request, name: str,
comments: str = Form(default=str()),
confirm: bool = Form(default=False)): confirm: bool = Form(default=False)):
pkgbase = get_pkg_or_base(name, models.PackageBase) pkgbase = get_pkg_or_base(name, models.PackageBase)
@ -980,15 +944,24 @@ async def pkgbase_disown_post(request: Request, name: str,
return RedirectResponse(f"/pkgbase/{name}", return RedirectResponse(f"/pkgbase/{name}",
HTTPStatus.SEE_OTHER) HTTPStatus.SEE_OTHER)
if not confirm:
context = make_context(request, "Disown Package") context = make_context(request, "Disown Package")
context["pkgbase"] = pkgbase context["pkgbase"] = pkgbase
if not confirm:
context["errors"] = [("The selected packages have not been disowned, " context["errors"] = [("The selected packages have not been disowned, "
"check the confirmation checkbox.")] "check the confirmation checkbox.")]
return render_template(request, "packages/disown.html", context, return render_template(request, "packages/disown.html", context,
status_code=HTTPStatus.BAD_REQUEST) status_code=HTTPStatus.BAD_REQUEST)
with db.begin():
update_closure_comment(pkgbase, ORPHAN_ID, comments)
try:
pkgbase_disown_instance(request, pkgbase) pkgbase_disown_instance(request, pkgbase)
except InvariantError as exc:
context["errors"] = [str(exc)]
return render_template(request, "packages/disown.html", context,
status_code=HTTPStatus.BAD_REQUEST)
return RedirectResponse(f"/pkgbase/{name}", return RedirectResponse(f"/pkgbase/{name}",
status_code=HTTPStatus.SEE_OTHER) status_code=HTTPStatus.SEE_OTHER)
@ -1032,7 +1005,8 @@ async def pkgbase_delete_get(request: Request, name: str):
@router.post("/pkgbase/{name}/delete") @router.post("/pkgbase/{name}/delete")
@auth_required() @auth_required()
async def pkgbase_delete_post(request: Request, name: str, async def pkgbase_delete_post(request: Request, name: str,
confirm: bool = Form(default=False)): confirm: bool = Form(default=False),
comments: str = Form(default=str())):
pkgbase = get_pkg_or_base(name, models.PackageBase) pkgbase = get_pkg_or_base(name, models.PackageBase)
if not request.user.has_credential(creds.PKGBASE_DELETE): if not request.user.has_credential(creds.PKGBASE_DELETE):
@ -1047,10 +1021,20 @@ async def pkgbase_delete_post(request: Request, name: str,
return render_template(request, "packages/delete.html", context, return render_template(request, "packages/delete.html", context,
status_code=HTTPStatus.BAD_REQUEST) status_code=HTTPStatus.BAD_REQUEST)
if comments:
# Update any existing deletion requests' ClosureComment.
with db.begin():
requests = pkgbase.requests.filter(
and_(models.PackageRequest.Status == PENDING_ID,
models.PackageRequest.ReqTypeID == DELETION_ID)
)
for pkgreq in requests:
pkgreq.ClosureComment = comments
# Obtain deletion locks and delete the packages. # Obtain deletion locks and delete the packages.
packages = pkgbase.packages.all() packages = pkgbase.packages.all()
for package in packages: for package in packages:
delete_package(request.user, package) delete_package(request, package, comments=comments)
return RedirectResponse("/packages", status_code=HTTPStatus.SEE_OTHER) return RedirectResponse("/packages", status_code=HTTPStatus.SEE_OTHER)
@ -1190,6 +1174,17 @@ async def packages_adopt(request: Request, package_ids: List[int] = [],
return (True, ["The selected packages have been adopted."]) return (True, ["The selected packages have been adopted."])
def disown_all(request: Request, pkgbases: List[models.PackageBase]) \
-> List[str]:
errors = []
for pkgbase in pkgbases:
try:
pkgbase_disown_instance(request, pkgbase)
except InvariantError as exc:
errors.append(str(exc))
return errors
async def packages_disown(request: Request, package_ids: List[int] = [], async def packages_disown(request: Request, package_ids: List[int] = [],
confirm: bool = False, **kwargs): confirm: bool = False, **kwargs):
if not package_ids: if not package_ids:
@ -1217,9 +1212,9 @@ async def packages_disown(request: Request, package_ids: List[int] = [],
return (False, ["You are not allowed to disown one " return (False, ["You are not allowed to disown one "
"of the packages you selected."]) "of the packages you selected."])
# Now, really disown the bases. # Now, disown all the bases if we can.
for pkgbase in bases: if errors := disown_all(request, bases):
pkgbase_disown_instance(request, pkgbase) return (False, errors)
return (True, ["The selected packages have been disowned."]) return (True, ["The selected packages have been disowned."])
@ -1256,7 +1251,7 @@ async def packages_delete(request: Request, package_ids: List[int] = [],
# using the same method we use in our /pkgbase/{name}/delete route. # using the same method we use in our /pkgbase/{name}/delete route.
for pkg in packages: for pkg in packages:
deleted_pkgs.append(pkg.Name) deleted_pkgs.append(pkg.Name)
delete_package(request.user, pkg) delete_package(request, pkg)
# Log out the fact that this happened for accountability. # Log out the fact that this happened for accountability.
logger.info(f"Privileged user '{request.user.Username}' deleted the " logger.info(f"Privileged user '{request.user.Username}' deleted the "
@ -1341,83 +1336,46 @@ async def pkgbase_merge_get(request: Request, name: str,
def pkgbase_merge_instance(request: Request, pkgbase: models.PackageBase, def pkgbase_merge_instance(request: Request, pkgbase: models.PackageBase,
target: models.PackageBase): target: models.PackageBase, comments: str = str()):
pkgbasename = str(pkgbase.Name) pkgbasename = str(pkgbase.Name)
# Collect requests related to this merge. # Create notifications.
query = pkgbase.requests.filter( notifs = handle_request(request, MERGE_ID, pkgbase, target)
models.PackageRequest.ReqTypeID == MERGE_ID
)
requests = query.filter( # Target votes and notifications sets of user IDs that are
models.PackageRequest.MergeBaseName == target.Name).all() # looking to be migrated.
reject_requests = query.filter( target_votes = set(v.UsersID for v in target.package_votes)
models.PackageRequest.MergeBaseName != target.Name).all() target_notifs = set(n.UserID for n in target.notifications)
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.")
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(
request.user.ID, pkgreq.ID, MERGE,
pkgbase.ID, merge_into=target.Name)
notifs.append(notif)
with db.begin(): with db.begin():
# Merge pkgbase's comments, notifications and votes into target. # Merge pkgbase's comments.
for comment in pkgbase.comments: for comment in pkgbase.comments:
comment.PackageBase = target comment.PackageBase = target
# Merge notifications that don't yet exist in the target.
for notif in pkgbase.notifications: for notif in pkgbase.notifications:
if notif.UserID not in target_notifs:
notif.PackageBase = target notif.PackageBase = target
# Merge votes that don't yet exist in the target.
for vote in pkgbase.package_votes: for vote in pkgbase.package_votes:
if vote.UsersID not in target_votes:
vote.PackageBase = target vote.PackageBase = target
# Run popupdate.
popupdate.run_single(target)
with db.begin(): with db.begin():
# Delete pkgbase and its packages now that everything's merged. # Delete pkgbase and its packages now that everything's merged.
for pkg in pkgbase.packages: for pkg in pkgbase.packages:
db.delete(pkg) db.delete(pkg)
db.delete(pkgbase) db.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(
request.user.ID, pkgreq.ID, pkgreq.status_display())
notifs.append(notif)
# Log this out for accountability purposes. # Log this out for accountability purposes.
logger.info(f"Trusted User '{request.user.Username}' merged " logger.info(f"Trusted User '{request.user.Username}' merged "
f"'{pkgbasename}' into '{target.Name}'.") f"'{pkgbasename}' into '{target.Name}'.")
# Send our notifications array. # Send notifications.
util.apply_all(notifs, lambda n: n.send()) util.apply_all(notifs, lambda n: n.send())
@ -1425,6 +1383,7 @@ def pkgbase_merge_instance(request: Request, pkgbase: models.PackageBase,
@auth_required() @auth_required()
async def pkgbase_merge_post(request: Request, name: str, async def pkgbase_merge_post(request: Request, name: str,
into: str = Form(default=str()), into: str = Form(default=str()),
comments: str = Form(default=str()),
confirm: bool = Form(default=False), confirm: bool = Form(default=False),
next: str = Form(default=str())): next: str = Form(default=str())):
@ -1458,8 +1417,11 @@ async def pkgbase_merge_post(request: Request, name: str,
return render_template(request, "pkgbase/merge.html", context, return render_template(request, "pkgbase/merge.html", context,
status_code=HTTPStatus.BAD_REQUEST) status_code=HTTPStatus.BAD_REQUEST)
with db.begin():
update_closure_comment(pkgbase, MERGE_ID, comments, target=target)
# Merge pkgbase into target. # Merge pkgbase into target.
pkgbase_merge_instance(request, pkgbase, target) pkgbase_merge_instance(request, pkgbase, target, comments=comments)
# Run popupdate on the target. # Run popupdate on the target.
popupdate.run_single(target) popupdate.run_single(target)

View file

@ -2300,3 +2300,7 @@ msgstr ""
#: aurweb/routers/accounts.py #: aurweb/routers/accounts.py
msgid "You do not have permission to change this user's account type to %s." msgid "You do not have permission to change this user's account type to %s."
msgstr "" msgstr ""
#: aurweb/packages/requests.py
msgid "No due existing orphan requests to accept for %s."
msgstr ""

View file

@ -43,6 +43,12 @@
</label> </label>
</p> </p>
<p>
<label for="id_comments">{{ "Comments" | tr }}:</label>
<textarea id="id_comments" name="comments"
rows="5" cols="50"></textarea>
</p>
<p> <p>
<input class="button" <input class="button"
type="submit" type="submit"

View file

@ -36,6 +36,12 @@
<form action="/pkgbase/{{ pkgbase.Name }}/disown" method="post"> <form action="/pkgbase/{{ pkgbase.Name }}/disown" method="post">
<fieldset> <fieldset>
<p>
<label for="id_comments">{{ "Comments" | tr }}:</label>
<textarea id="id_comments" name="comments"
rows="5" cols="50"></textarea>
</p>
<p> <p>
<label class="confirmation"> <label class="confirmation">
<input type="checkbox" name="confirm" value="1" /> <input type="checkbox" name="confirm" value="1" />

View file

@ -49,6 +49,12 @@
/> />
</p> </p>
<p>
<label for="id_comments">{{ "Comments" | tr }}:</label>
<textarea id="id_comments" name="comments"
rows="5" cols="50"></textarea>
</p>
<p> <p>
<label class="confirmation"> <label class="confirmation">
<input type="checkbox" name="confirm" /> <input type="checkbox" name="confirm" />

View file

@ -76,7 +76,6 @@
{% set action = "merge" %} {% set action = "merge" %}
{# Add the 'via' url query parameter. #} {# Add the 'via' url query parameter. #}
{% set temp_q = temp_q | extend_query( {% set temp_q = temp_q | extend_query(
["via", result.ID],
["into", result.MergeBaseName] ["into", result.MergeBaseName]
) %} ) %}
{% endif %} {% endif %}

View file

@ -23,24 +23,6 @@
<form action="/requests/{{ pkgreq.ID }}/close" method="post"> <form action="/requests/{{ pkgreq.ID }}/close" method="post">
<fieldset> <fieldset>
<p>
<label for="id_reason">{{ "Reason" | tr }}:</label>
<select id="id_reason" name="reason">
{# Value 2 == "Accepted" status.
See aurweb.models.package_request. #}
{% if request.user.is_elevated() %}
<option value="2">
{{ "Accepted" | tr }}
</option>
{% endif %}
{# Value 3 == "Rejected" status.
See aurweb.models.package_request. #}
<option value="3">
{{ "Rejected" | tr }}
</option>
</select>
</p>
<p> <p>
<label for="id_comments">{{ "Comments" | tr }}:</label> <label for="id_comments">{{ "Comments" | tr }}:</label>
<textarea id="id_comments" name="comments" <textarea id="id_comments" name="comments"

View file

@ -10,7 +10,7 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import and_ from sqlalchemy import and_
from aurweb import asgi, config, db, defaults from aurweb import asgi, db, defaults
from aurweb.models import License, PackageLicense from aurweb.models import License, PackageLicense
from aurweb.models.account_type import USER_ID, AccountType from aurweb.models.account_type import USER_ID, AccountType
from aurweb.models.dependency_type import DependencyType from aurweb.models.dependency_type import DependencyType
@ -28,6 +28,7 @@ from aurweb.models.package_vote import PackageVote
from aurweb.models.relation_type import CONFLICTS_ID, PROVIDES_ID, REPLACES_ID, RelationType from aurweb.models.relation_type import CONFLICTS_ID, PROVIDES_ID, REPLACES_ID, RelationType
from aurweb.models.request_type import DELETION_ID, MERGE_ID, RequestType from aurweb.models.request_type import DELETION_ID, MERGE_ID, RequestType
from aurweb.models.user import User from aurweb.models.user import User
from aurweb.testing.email import Email
from aurweb.testing.html import get_errors, get_successes, parse_root from aurweb.testing.html import get_errors, get_successes, parse_root
from aurweb.testing.requests import Request from aurweb.testing.requests import Request
@ -126,6 +127,39 @@ def package(maintainer: User) -> Package:
yield package yield package
@pytest.fixture
def pkgbase(package: Package) -> PackageBase:
yield package.PackageBase
@pytest.fixture
def target(maintainer: User) -> PackageBase:
""" Merge target. """
now = int(datetime.utcnow().timestamp())
with db.begin():
pkgbase = db.create(PackageBase, Name="target-package",
Maintainer=maintainer,
Packager=maintainer,
Submitter=maintainer,
ModifiedTS=now)
db.create(Package, PackageBase=pkgbase, Name=pkgbase.Name)
yield pkgbase
@pytest.fixture
def pkgreq(user: User, pkgbase: PackageBase) -> PackageRequest:
""" Yield a PackageRequest related to `pkgbase`. """
with db.begin():
pkgreq = db.create(PackageRequest,
ReqTypeID=DELETION_ID,
User=user,
PackageBase=pkgbase,
PackageBaseName=pkgbase.Name,
Comments=f"Deletion request for {pkgbase.Name}",
ClosureComment=str())
yield pkgreq
@pytest.fixture @pytest.fixture
def comment(user: User, package: Package) -> PackageComment: def comment(user: User, package: Package) -> PackageComment:
pkgbase = package.PackageBase pkgbase = package.PackageBase
@ -296,15 +330,7 @@ def test_package_comments(client: TestClient, user: User, package: Package):
def test_package_requests_display(client: TestClient, user: User, def test_package_requests_display(client: TestClient, user: User,
package: Package): package: Package, pkgreq: PackageRequest):
type_ = db.query(RequestType, RequestType.ID == DELETION_ID).first()
with db.begin():
db.create(PackageRequest, PackageBase=package.PackageBase,
PackageBaseName=package.PackageBase.Name,
User=user, RequestType=type_,
Comments="Test comment.",
ClosureComment=str())
# Test that a single request displays "1 pending request". # Test that a single request displays "1 pending request".
with client as request: with client as request:
resp = request.get(package_endpoint(package)) resp = request.get(package_endpoint(package))
@ -1516,115 +1542,6 @@ def test_pkgbase_request(client: TestClient, user: User, package: Package):
assert resp.status_code == int(HTTPStatus.OK) assert resp.status_code == int(HTTPStatus.OK)
def test_pkgbase_request_post_deletion(client: TestClient, user: User,
package: Package):
endpoint = f"/pkgbase/{package.PackageBase.Name}/request"
cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request:
resp = request.post(endpoint, data={
"type": "deletion",
"comments": "We want to delete this."
}, cookies=cookies, allow_redirects=False)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
pkgreq = db.query(PackageRequest).filter(
PackageRequest.PackageBaseID == package.PackageBase.ID
).first()
assert pkgreq is not None
assert pkgreq.RequestType.Name == "deletion"
assert pkgreq.PackageBaseName == package.PackageBase.Name
assert pkgreq.Comments == "We want to delete this."
def test_pkgbase_request_post_maintainer_deletion(
client: TestClient, maintainer: User, package: Package):
pkgbasename = package.PackageBase.Name
endpoint = f"/pkgbase/{package.PackageBase.Name}/request"
cookies = {"AURSID": maintainer.login(Request(), "testPassword")}
with client as request:
resp = request.post(endpoint, data={
"type": "deletion",
"comments": "We want to delete this."
}, cookies=cookies, allow_redirects=False)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
pkgreq = db.query(PackageRequest).filter(
PackageRequest.PackageBaseName == pkgbasename
).first()
assert pkgreq.Status == ACCEPTED_ID
def test_pkgbase_request_post_orphan(client: TestClient, user: User,
package: Package):
endpoint = f"/pkgbase/{package.PackageBase.Name}/request"
cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request:
resp = request.post(endpoint, data={
"type": "orphan",
"comments": "We want to disown this."
}, cookies=cookies, allow_redirects=False)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
pkgreq = db.query(PackageRequest).filter(
PackageRequest.PackageBaseID == package.PackageBase.ID
).first()
assert pkgreq is not None
assert pkgreq.RequestType.Name == "orphan"
assert pkgreq.PackageBaseName == package.PackageBase.Name
assert pkgreq.Comments == "We want to disown this."
def test_pkgbase_request_post_auto_orphan(client: TestClient, user: User,
package: Package):
now = int(datetime.utcnow().timestamp())
auto_orphan_age = config.getint("options", "auto_orphan_age")
with db.begin():
package.PackageBase.OutOfDateTS = now - auto_orphan_age - 1
endpoint = f"/pkgbase/{package.PackageBase.Name}/request"
cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request:
resp = request.post(endpoint, data={
"type": "orphan",
"comments": "We want to disown this."
}, cookies=cookies, allow_redirects=False)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
pkgreq = db.query(PackageRequest).filter(
PackageRequest.PackageBaseID == package.PackageBase.ID
).first()
assert pkgreq is not None
assert pkgreq.Status == ACCEPTED_ID
def test_pkgbase_request_post_merge(client: TestClient, user: User,
package: Package):
with db.begin():
pkgbase2 = db.create(PackageBase, Name="new-pkgbase",
Submitter=user, Maintainer=user, Packager=user)
target = db.create(Package, PackageBase=pkgbase2,
Name=pkgbase2.Name, Version="1.0.0")
endpoint = f"/pkgbase/{package.PackageBase.Name}/request"
cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request:
resp = request.post(endpoint, data={
"type": "merge",
"merge_into": target.PackageBase.Name,
"comments": "We want to merge this."
}, cookies=cookies, allow_redirects=False)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
pkgreq = db.query(PackageRequest).filter(
PackageRequest.PackageBaseID == package.PackageBase.ID
).first()
assert pkgreq is not None
assert pkgreq.RequestType.Name == "merge"
assert pkgreq.PackageBaseName == package.PackageBase.Name
assert pkgreq.MergeBaseName == target.PackageBase.Name
assert pkgreq.Comments == "We want to merge this."
def test_pkgbase_request_post_not_found(client: TestClient, user: User): def test_pkgbase_request_post_not_found(client: TestClient, user: User):
cookies = {"AURSID": user.login(Request(), "testPassword")} cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request: with client as request:
@ -1718,22 +1635,6 @@ def test_pkgbase_request_post_merge_self_error(client: TestClient, user: User,
assert error.text.strip() == expected 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, def test_requests_close(client: TestClient, user: User,
pkgreq: PackageRequest): pkgreq: PackageRequest):
cookies = {"AURSID": user.login(Request(), "testPassword")} cookies = {"AURSID": user.login(Request(), "testPassword")}
@ -1753,16 +1654,6 @@ def test_requests_close_unauthorized(client: TestClient, maintainer: User,
assert resp.headers.get("location") == "/" 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, def test_requests_close_post_unauthorized(client: TestClient, maintainer: User,
pkgreq: PackageRequest): pkgreq: PackageRequest):
cookies = {"AURSID": maintainer.login(Request(), "testPassword")} cookies = {"AURSID": maintainer.login(Request(), "testPassword")}
@ -1778,9 +1669,8 @@ def test_requests_close_post(client: TestClient, user: User,
pkgreq: PackageRequest): pkgreq: PackageRequest):
cookies = {"AURSID": user.login(Request(), "testPassword")} cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request: with client as request:
resp = request.post(f"/requests/{pkgreq.ID}/close", data={ resp = request.post(f"/requests/{pkgreq.ID}/close",
"reason": REJECTED_ID cookies=cookies, allow_redirects=False)
}, cookies=cookies, allow_redirects=False)
assert resp.status_code == int(HTTPStatus.SEE_OTHER) assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert pkgreq.Status == REJECTED_ID assert pkgreq.Status == REJECTED_ID
@ -1792,9 +1682,8 @@ def test_requests_close_post_rejected(client: TestClient, user: User,
pkgreq: PackageRequest): pkgreq: PackageRequest):
cookies = {"AURSID": user.login(Request(), "testPassword")} cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request: with client as request:
resp = request.post(f"/requests/{pkgreq.ID}/close", data={ resp = request.post(f"/requests/{pkgreq.ID}/close",
"reason": REJECTED_ID cookies=cookies, allow_redirects=False)
}, cookies=cookies, allow_redirects=False)
assert resp.status_code == int(HTTPStatus.SEE_OTHER) assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert pkgreq.Status == REJECTED_ID assert pkgreq.Status == REJECTED_ID
@ -1971,18 +1860,6 @@ def test_pkgbase_vote(client: TestClient, user: User, package: Package):
assert pkgbase.NumVotes == 0 assert pkgbase.NumVotes == 0
def test_pkgbase_disown_as_tu(client: TestClient, tu_user: User,
package: Package):
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
pkgbase = package.PackageBase
endpoint = f"/pkgbase/{pkgbase.Name}/disown"
# But we do here.
with client as request:
resp = request.post(endpoint, data={"confirm": True}, cookies=cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
def test_pkgbase_disown_as_sole_maintainer(client: TestClient, def test_pkgbase_disown_as_sole_maintainer(client: TestClient,
maintainer: User, maintainer: User,
package: Package): package: Package):
@ -2114,6 +1991,38 @@ def test_pkgbase_delete(client: TestClient, tu_user: User, package: Package):
).first() ).first()
assert record is None assert record is None
# Two emails should've been sent out; an autogenerated
# request's accepted notification and a deletion notification.
assert Email.count() == 1
req_close = Email(1).parse()
expr = r"^\[PRQ#\d+\] Deletion Request for [^ ]+ Accepted$"
subject = req_close.headers.get("Subject")
assert re.match(expr, subject)
def test_pkgbase_delete_with_request(client: TestClient, tu_user: User,
pkgbase: PackageBase,
pkgreq: PackageRequest):
# TODO: Test that a previously existing request gets Accepted when
# a TU deleted the package.
# Delete the package as `tu_user` via POST request.
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
endpoint = f"/pkgbase/{pkgbase.Name}/delete"
with client as request:
resp = request.post(endpoint, data={"confirm": True}, cookies=cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert resp.headers.get("location") == "/packages"
# We should've just sent one closure email since `pkgreq` exists.
assert Email.count() == 1
# Make sure it was a closure for the deletion request.
email = Email(1).parse()
expr = r"^\[PRQ#\d+\] Deletion Request for [^ ]+ Accepted$"
assert re.match(expr, email.headers.get("Subject"))
def test_packages_post_unknown_action(client: TestClient, user: User, def test_packages_post_unknown_action(client: TestClient, user: User,
package: Package): package: Package):
@ -2371,9 +2280,11 @@ def test_packages_post_adopt(client: TestClient, user: User,
assert successes[0].text.strip() == expected assert successes[0].text.strip() == expected
def test_packages_post_disown(client: TestClient, user: User, def test_packages_post_disown_as_maintainer(client: TestClient, user: User,
maintainer: User, package: Package): maintainer: User,
# Initially prove that we have a maintainer: `maintainer`. package: Package):
""" Disown packages as a maintainer. """
# Initially prove that we have a maintainer.
assert package.PackageBase.Maintainer is not None assert package.PackageBase.Maintainer is not None
assert package.PackageBase.Maintainer == maintainer assert package.PackageBase.Maintainer == maintainer
@ -2430,9 +2341,24 @@ def test_packages_post_disown(client: TestClient, user: User,
assert successes[0].text.strip() == expected assert successes[0].text.strip() == expected
def test_packages_post_disown(client: TestClient, tu_user: User,
maintainer: User, package: Package):
""" Disown packages as a Trusted User, which cannot bypass idle time. """
cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
with client as request:
resp = request.post("/packages", data={
"action": "disown",
"IDs": [package.ID],
"confirm": True
}, cookies=cookies)
errors = get_errors(resp.text)
expected = r"^No due existing orphan requests to accept for .+\.$"
assert re.match(expected, errors[0].text.strip())
def test_packages_post_delete(caplog: pytest.fixture, client: TestClient, def test_packages_post_delete(caplog: pytest.fixture, client: TestClient,
user: User, tu_user: User, package: Package): user: User, tu_user: User, package: Package):
# First, let's try to use the delete action with no packages IDs. # First, let's try to use the delete action with no packages IDs.
user_cookies = {"AURSID": user.login(Request(), "testPassword")} user_cookies = {"AURSID": user.login(Request(), "testPassword")}
with client as request: with client as request:
@ -2556,23 +2482,19 @@ def test_pkgbase_merge_post_self_invalid(client: TestClient, tu_user: User,
def test_pkgbase_merge_post(client: TestClient, tu_user: User, def test_pkgbase_merge_post(client: TestClient, tu_user: User,
packages: List[Package]): package: Package,
package, target = packages[:2] pkgbase: PackageBase,
target: PackageBase,
pkgreq: PackageRequest):
pkgname = package.Name pkgname = package.Name
pkgbasename = package.PackageBase.Name pkgbasename = pkgbase.Name
# Create a merge request destined for another target. # Create a merge request destined for another target.
# This will allow our test code to exercise closing # This will allow our test code to exercise closing
# such a request after merging the pkgbase in question. # such a request after merging the pkgbase in question.
with db.begin(): with db.begin():
pkgreq = db.create(PackageRequest, pkgreq.ReqTypeID = MERGE_ID
User=tu_user, pkgreq.MergeBaseName = target.Name
ReqTypeID=MERGE_ID,
PackageBase=package.PackageBase,
PackageBaseName=pkgbasename,
MergeBaseName="test",
Comments="Test comment.",
ClosureComment="Test closure.")
# Vote for the package. # Vote for the package.
cookies = {"AURSID": tu_user.login(Request(), "testPassword")} cookies = {"AURSID": tu_user.login(Request(), "testPassword")}
@ -2604,32 +2526,37 @@ def test_pkgbase_merge_post(client: TestClient, tu_user: User,
endpoint = f"/pkgbase/{package.PackageBase.Name}/merge" endpoint = f"/pkgbase/{package.PackageBase.Name}/merge"
with client as request: with client as request:
resp = request.post(endpoint, data={ resp = request.post(endpoint, data={
"into": target.PackageBase.Name, "into": target.Name,
"confirm": True "confirm": True
}, cookies=cookies) }, cookies=cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER) assert resp.status_code == int(HTTPStatus.SEE_OTHER)
loc = resp.headers.get("location") loc = resp.headers.get("location")
assert loc == f"/pkgbase/{target.PackageBase.Name}" assert loc == f"/pkgbase/{target.Name}"
# Two emails should've been sent out.
assert Email.count() == 1
email_body = Email(1).parse().glue()
assert f"Merge Request for {pkgbasename} Accepted" in email_body
# Assert that the original comments, notifs and votes we setup # Assert that the original comments, notifs and votes we setup
# got migrated to target as intended. # got migrated to target as intended.
assert comments == target.PackageBase.comments.all() assert comments == target.comments.all()
assert notifs == target.PackageBase.notifications.all() assert notifs == target.notifications.all()
assert votes == target.PackageBase.package_votes.all() assert votes == target.package_votes.all()
# ...and that the package got deleted. # ...and that the package got deleted.
package = db.query(Package).filter(Package.Name == pkgname).first() package = db.query(Package).filter(Package.Name == pkgname).first()
assert package is None assert package is None
# Our fake target request should have gotten rejected. # Our previously-made request should have gotten accepted.
assert pkgreq.Status == REJECTED_ID assert pkgreq.Status == ACCEPTED_ID
assert pkgreq.Closer is not None assert pkgreq.Closer is not None
# A PackageRequest is always created when merging this way. # A PackageRequest is always created when merging this way.
pkgreq = db.query(PackageRequest).filter( pkgreq = db.query(PackageRequest).filter(
and_(PackageRequest.ReqTypeID == MERGE_ID, and_(PackageRequest.ReqTypeID == MERGE_ID,
PackageRequest.PackageBaseName == pkgbasename, PackageRequest.PackageBaseName == pkgbasename,
PackageRequest.MergeBaseName == target.PackageBase.Name) PackageRequest.MergeBaseName == target.Name)
).first() ).first()
assert pkgreq is not None assert pkgreq is not None

558
test/test_requests.py Normal file
View file

@ -0,0 +1,558 @@
import re
from datetime import datetime
from http import HTTPStatus
from logging import DEBUG
import pytest
from fastapi.testclient import TestClient
from aurweb import asgi, config, db
from aurweb.models import Package, PackageBase, PackageRequest, User
from aurweb.models.account_type import TRUSTED_USER_ID, USER_ID
from aurweb.models.package_notification import PackageNotification
from aurweb.models.package_request import ACCEPTED_ID, PENDING_ID, REJECTED_ID
from aurweb.models.request_type import DELETION_ID, MERGE_ID, ORPHAN_ID
from aurweb.packages.requests import ClosureFactory
from aurweb.testing.email import Email
from aurweb.testing.html import get_errors
from aurweb.testing.requests import Request
@pytest.fixture(autouse=True)
def setup(db_test) -> None:
""" Setup the database. """
return
@pytest.fixture
def client() -> TestClient:
""" Yield a TestClient. """
yield TestClient(app=asgi.app)
def create_user(username: str, email: str) -> User:
"""
Create a user based on `username` and `email`.
:param username: User.Username
:param email: User.Email
:return: User instance
"""
with db.begin():
user = db.create(User, Username=username, Email=email,
Passwd="testPassword", AccountTypeID=USER_ID)
return user
@pytest.fixture
def user() -> User:
""" Yield a User instance. """
user = create_user("test", "test@example.org")
yield user
@pytest.fixture
def auser(user: User) -> User:
""" Yield an authenticated User instance. """
cookies = {"AURSID": user.login(Request(), "testPassword")}
user.cookies = cookies
yield user
@pytest.fixture
def user2() -> User:
""" Yield a secondary non-maintainer User instance. """
user = create_user("test2", "test2@example.org")
yield user
@pytest.fixture
def auser2(user2: User) -> User:
""" Yield an authenticated secondary non-maintainer User instance. """
cookies = {"AURSID": user2.login(Request(), "testPassword")}
user2.cookies = cookies
yield user2
@pytest.fixture
def tu_user() -> User:
""" Yield an authenticated Trusted User instance. """
user = create_user("test_tu", "test_tu@example.org")
with db.begin():
user.AccountTypeID = TRUSTED_USER_ID
cookies = {"AURSID": user.login(Request(), "testPassword")}
user.cookies = cookies
yield user
def create_pkgbase(user: User, name: str) -> PackageBase:
"""
Create a package base based on `user` and `name`.
This function also creates a matching Package record.
:param user: User instance
:param name: PackageBase.Name
:return: PackageBase instance
"""
now = int(datetime.utcnow().timestamp())
with db.begin():
pkgbase = db.create(PackageBase, Name=name,
Maintainer=user, Packager=user,
SubmittedTS=now, ModifiedTS=now)
db.create(Package, Name=pkgbase.Name, PackageBase=pkgbase)
return pkgbase
@pytest.fixture
def pkgbase(user: User) -> PackageBase:
""" Yield a package base. """
pkgbase = create_pkgbase(user, "test-package")
yield pkgbase
@pytest.fixture
def target(user: User) -> PackageBase:
""" Yield a merge target (package base). """
with db.begin():
target = db.create(PackageBase, Name="target-package",
Maintainer=user, Packager=user)
yield target
def create_request(reqtype_id: int, user: User, pkgbase: PackageBase,
comments: str) -> PackageRequest:
"""
Create a package request based on `reqtype_id`, `user`,
`pkgbase` and `comments`.
:param reqtype_id: RequestType.ID
:param user: User instance
:param pkgbase: PackageBase instance
:param comments: PackageRequest.Comments
:return: PackageRequest instance
"""
now = int(datetime.utcnow().timestamp())
with db.begin():
pkgreq = db.create(PackageRequest, ReqTypeID=reqtype_id,
User=user, PackageBase=pkgbase,
PackageBaseName=pkgbase.Name,
RequestTS=now,
Comments=comments,
ClosureComment=str())
return pkgreq
@pytest.fixture
def pkgreq(user: User, pkgbase: PackageBase):
""" Yield a package request. """
pkgreq = create_request(DELETION_ID, user, pkgbase, "Test request.")
yield pkgreq
def create_notification(user: User, pkgbase: PackageBase):
""" Create a notification for a `user` on `pkgbase`. """
with db.begin():
notif = db.create(PackageNotification, User=user, PackageBase=pkgbase)
return notif
def test_request(client: TestClient, auser: User, pkgbase: PackageBase):
""" Test the standard pkgbase request route GET method. """
endpoint = f"/pkgbase/{pkgbase.Name}/request"
with client as request:
resp = request.get(endpoint, cookies=auser.cookies)
assert resp.status_code == int(HTTPStatus.OK)
def test_request_post_deletion(client: TestClient, auser2: User,
pkgbase: PackageBase):
""" Test the POST route for creating a deletion request works. """
endpoint = f"/pkgbase/{pkgbase.Name}/request"
data = {"comments": "Test request.", "type": "deletion"}
with client as request:
resp = request.post(endpoint, data=data, cookies=auser2.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
pkgreq = pkgbase.requests.first()
assert pkgreq is not None
assert pkgreq.ReqTypeID == DELETION_ID
assert pkgreq.Status == PENDING_ID
# A RequestOpenNotification should've been sent out.
assert Email.count() == 1
email = Email(1)
expr = r"^\[PRQ#%d\] Deletion Request for [^ ]+$" % pkgreq.ID
assert re.match(expr, email.headers.get("Subject"))
def test_request_post_deletion_as_maintainer(client: TestClient, auser: User,
pkgbase: PackageBase):
""" Test the POST route for creating a deletion request as maint works. """
endpoint = f"/pkgbase/{pkgbase.Name}/request"
data = {"comments": "Test request.", "type": "deletion"}
with client as request:
resp = request.post(endpoint, data=data, cookies=auser.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
# Check the pkgreq record got created and accepted.
pkgreq = db.query(PackageRequest).first()
assert pkgreq is not None
assert pkgreq.ReqTypeID == DELETION_ID
assert pkgreq.Status == ACCEPTED_ID
# Should've gotten two emails.
assert Email.count() == 2
# A RequestOpenNotification should've been sent out.
email = Email(1)
expr = r"^\[PRQ#%d\] Deletion Request for [^ ]+$" % pkgreq.ID
assert re.match(expr, email.headers.get("Subject"))
# Check the content of the close notification.
email = Email(2)
expr = r"^\[PRQ#%d\] Deletion Request for [^ ]+ Accepted$" % pkgreq.ID
assert re.match(expr, email.headers.get("Subject"))
def test_request_post_deletion_autoaccept(client: TestClient, auser: User,
pkgbase: PackageBase,
caplog: pytest.LogCaptureFixture):
""" Test the request route for deletion as maintainer. """
caplog.set_level(DEBUG)
now = int(datetime.utcnow().timestamp())
auto_delete_age = config.getint("options", "auto_delete_age")
with db.begin():
pkgbase.ModifiedTS = now - auto_delete_age + 100
endpoint = f"/pkgbase/{pkgbase.Name}/request"
data = {"comments": "Test request.", "type": "deletion"}
with client as request:
resp = request.post(endpoint, data=data, cookies=auser.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
pkgreq = db.query(PackageRequest).filter(
PackageRequest.PackageBaseName == pkgbase.Name
).first()
assert pkgreq is not None
assert pkgreq.ReqTypeID == DELETION_ID
assert pkgreq.Status == ACCEPTED_ID
# A RequestOpenNotification should've been sent out.
assert Email.count() == 2
Email.dump()
# Check the content of the open notification.
email = Email(1)
expr = r"^\[PRQ#%d\] Deletion Request for [^ ]+$" % pkgreq.ID
assert re.match(expr, email.headers.get("Subject"))
# Check the content of the close notification.
email = Email(2)
expr = r"^\[PRQ#%d\] Deletion Request for [^ ]+ Accepted$" % pkgreq.ID
assert re.match(expr, email.headers.get("Subject"))
# Check logs.
expr = r"New request #\d+ is marked for auto-deletion."
assert re.search(expr, caplog.text)
def test_request_post_merge(client: TestClient, auser: User,
pkgbase: PackageBase, target: PackageBase):
""" Test the request route for merge as maintainer. """
endpoint = f"/pkgbase/{pkgbase.Name}/request"
data = {
"type": "merge",
"merge_into": target.Name,
"comments": "Test request.",
}
with client as request:
resp = request.post(endpoint, data=data, cookies=auser.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
pkgreq = pkgbase.requests.first()
assert pkgreq is not None
assert pkgreq.ReqTypeID == MERGE_ID
assert pkgreq.Status == PENDING_ID
assert pkgreq.MergeBaseName == target.Name
# A RequestOpenNotification should've been sent out.
assert Email.count() == 1
email = Email(1)
expr = r"^\[PRQ#%d\] Merge Request for [^ ]+$" % pkgreq.ID
assert re.match(expr, email.headers.get("Subject"))
def test_request_post_orphan(client: TestClient, auser: User,
pkgbase: PackageBase):
""" Test the POST route for creating an orphan request works. """
endpoint = f"/pkgbase/{pkgbase.Name}/request"
data = {
"type": "orphan",
"comments": "Test request.",
}
with client as request:
resp = request.post(endpoint, data=data, cookies=auser.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
pkgreq = pkgbase.requests.first()
assert pkgreq is not None
assert pkgreq.ReqTypeID == ORPHAN_ID
assert pkgreq.Status == PENDING_ID
# A RequestOpenNotification should've been sent out.
assert Email.count() == 1
email = Email(1)
expr = r"^\[PRQ#%d\] Orphan Request for [^ ]+$" % pkgreq.ID
assert re.match(expr, email.headers.get("Subject"))
def test_deletion_request(client: TestClient, user: User, tu_user: User,
pkgbase: PackageBase, pkgreq: PackageRequest):
""" Test deleting a package with a preexisting request. """
# `pkgreq`.ReqTypeID is already DELETION_ID.
create_request(DELETION_ID, user, pkgbase, "Other request.")
# Create a notification record for another user. They should then
# also receive a DeleteNotification.
user2 = create_user("test2", "test2@example.org")
create_notification(user2, pkgbase)
endpoint = f"/pkgbase/{pkgbase.Name}/delete"
comments = "Test closure."
data = {"comments": comments, "confirm": True}
with client as request:
resp = request.post(endpoint, data=data, cookies=tu_user.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert resp.headers.get("location") == "/packages"
# Ensure that `pkgreq`.ClosureComment was left alone when specified.
assert pkgreq.ClosureComment == comments
# We should've gotten three emails. Two accepted requests and
# a DeleteNotification.
assert Email.count() == 3
# Both requests should have gotten accepted and had a notification
# sent out for them.
for i in range(Email.count() - 1):
email = Email(i + 1).parse()
expr = r"^\[PRQ#\d+\] Deletion Request for [^ ]+ Accepted$"
assert re.match(expr, email.headers.get("Subject"))
# We should've also had a DeleteNotification sent out.
email = Email(3).parse()
subject = r"^AUR Package deleted: [^ ]+$"
assert re.match(subject, email.headers.get("Subject"))
body = r"%s [1] deleted %s [2]." % (tu_user.Username, pkgbase.Name)
assert body in email.body
def test_deletion_autorequest(client: TestClient, tu_user: User,
pkgbase: PackageBase):
""" Test deleting a package without a request. """
# `pkgreq`.ReqTypeID is already DELETION_ID.
endpoint = f"/pkgbase/{pkgbase.Name}/delete"
data = {"confirm": True}
with client as request:
resp = request.post(endpoint, data=data, cookies=tu_user.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert resp.headers.get("location") == "/packages"
assert Email.count() == 1
email = Email(1).parse()
subject = r"^\[PRQ#\d+\] Deletion Request for [^ ]+ Accepted$"
assert re.match(subject, email.headers.get("Subject"))
assert "[Autogenerated]" in email.body
def test_merge_request(client: TestClient, user: User, tu_user: User,
pkgbase: PackageBase, target: PackageBase,
pkgreq: PackageRequest):
""" Test merging a package with a pre - existing request. """
with db.begin():
pkgreq.ReqTypeID = MERGE_ID
pkgreq.MergeBaseName = target.Name
other_target = create_pkgbase(user, "other-target")
other_request = create_request(MERGE_ID, user, pkgbase, "Other request.")
other_target2 = create_pkgbase(user, "other-target2")
other_request2 = create_request(MERGE_ID, user, pkgbase, "Other request2.")
with db.begin():
other_request.MergeBaseName = other_target.Name
other_request2.MergeBaseName = other_target2.Name
# `pkgreq`.ReqTypeID is already DELETION_ID.
endpoint = f"/pkgbase/{pkgbase.Name}/merge"
comments = "Test merge closure."
data = {"into": target.Name, "comments": comments, "confirm": True}
with client as request:
resp = request.post(endpoint, data=data, cookies=tu_user.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert resp.headers.get("location") == f"/pkgbase/{target.Name}"
# Ensure that `pkgreq`.ClosureComment was left alone when specified.
assert pkgreq.ClosureComment == comments
# We should've gotten 3 emails: an accepting and two rejections.
assert Email.count() == 3
# Assert specific IDs match up in the subjects.
accepted = Email(1).parse()
subj = r"^\[PRQ#%d\] Merge Request for [^ ]+ Accepted$" % pkgreq.ID
assert re.match(subj, accepted.headers.get("Subject"))
# In the accepted case, we already supplied a closure comment,
# which stops one from being autogenerated by the algorithm.
assert "[Autogenerated]" not in accepted.body
# Test rejection emails, which do have autogenerated closures.
rejected = Email(2).parse()
subj = r"^\[PRQ#%d\] Merge Request for [^ ]+ Rejected$" % other_request.ID
assert re.match(subj, rejected.headers.get("Subject"))
assert "[Autogenerated]" in rejected.body
rejected = Email(3).parse()
subj = r"^\[PRQ#%d\] Merge Request for [^ ]+ Rejected$" % other_request2.ID
assert re.match(subj, rejected.headers.get("Subject"))
assert "[Autogenerated]" in rejected.body
def test_merge_autorequest(client: TestClient, user: User, tu_user: User,
pkgbase: PackageBase, target: PackageBase):
""" Test merging a package without a request. """
with db.begin():
pkgreq.ReqTypeID = MERGE_ID
pkgreq.MergeBaseName = target.Name
# `pkgreq`.ReqTypeID is already DELETION_ID.
endpoint = f"/pkgbase/{pkgbase.Name}/merge"
data = {"into": target.Name, "confirm": True}
with client as request:
resp = request.post(endpoint, data=data, cookies=tu_user.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert resp.headers.get("location") == f"/pkgbase/{target.Name}"
# Should've gotten one email; an [Autogenerated] one.
assert Email.count() == 1
# Test accepted merge request notification.
email = Email(1).parse()
subj = r"^\[PRQ#\d+\] Merge Request for [^ ]+ Accepted$"
assert re.match(subj, email.headers.get("Subject"))
assert "[Autogenerated]" in email.body
def test_orphan_request(client: TestClient, user: User, tu_user: User,
pkgbase: PackageBase, pkgreq: PackageRequest):
""" Test the standard orphan request route. """
idle_time = config.getint("options", "request_idle_time")
now = int(datetime.utcnow().timestamp())
with db.begin():
pkgreq.ReqTypeID = ORPHAN_ID
# Set the request time so it's seen as due (idle_time has passed).
pkgreq.RequestTS = now - idle_time - 10
endpoint = f"/pkgbase/{pkgbase.Name}/disown"
comments = "Test orphan closure."
data = {"comments": comments, "confirm": True}
with client as request:
resp = request.post(endpoint, data=data, cookies=tu_user.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert resp.headers.get("location") == f"/pkgbase/{pkgbase.Name}"
# Ensure that `pkgreq`.ClosureComment was left alone when specified.
assert pkgreq.ClosureComment == comments
# Check the email we expect.
assert Email.count() == 1
email = Email(1).parse()
subj = r"^\[PRQ#%d\] Orphan Request for [^ ]+ Accepted$" % pkgreq.ID
assert re.match(subj, email.headers.get("Subject"))
def test_request_post_orphan_autoaccept(client: TestClient, auser: User,
pkgbase: PackageBase,
caplog: pytest.LogCaptureFixture):
""" Test the standard pkgbase request route GET method. """
caplog.set_level(DEBUG)
now = int(datetime.utcnow().timestamp())
auto_orphan_age = config.getint("options", "auto_orphan_age")
with db.begin():
pkgbase.OutOfDateTS = now - auto_orphan_age - 100
endpoint = f"/pkgbase/{pkgbase.Name}/request"
data = {
"type": "orphan",
"comments": "Test request.",
}
with client as request:
resp = request.post(endpoint, data=data, cookies=auser.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
pkgreq = pkgbase.requests.first()
assert pkgreq is not None
assert pkgreq.ReqTypeID == ORPHAN_ID
# A Request(Open|Close)Notification should've been sent out.
assert Email.count() == 2
# Check the first email; should be our open request.
email = Email(1)
expr = r"^\[PRQ#%d\] Orphan Request for [^ ]+$" % pkgreq.ID
assert re.match(expr, email.headers.get("Subject"))
# And the second should be the automated closure.
email = Email(2)
expr = r"^\[PRQ#%d\] Orphan Request for [^ ]+ Accepted$" % pkgreq.ID
assert re.match(expr, email.headers.get("Subject"))
# Check logs.
expr = r"New request #\d+ is marked for auto-orphan."
assert re.search(expr, caplog.text)
def test_orphan_as_maintainer(client: TestClient, auser: User,
pkgbase: PackageBase):
endpoint = f"/pkgbase/{pkgbase.Name}/disown"
data = {"confirm": True}
with client as request:
resp = request.post(endpoint, data=data, cookies=auser.cookies)
assert resp.status_code == int(HTTPStatus.SEE_OTHER)
assert resp.headers.get("location") == f"/pkgbase/{pkgbase.Name}"
assert Email.count() == 1
email = Email(1)
expr = r"^\[PRQ#\d+\] Orphan Request for [^ ]+ Accepted$"
assert re.match(expr, email.headers.get("Subject"))
def test_orphan_without_requests(client: TestClient, tu_user: User,
pkgbase: PackageBase):
""" Test orphans are automatically accepted past a certain date. """
endpoint = f"/pkgbase/{pkgbase.Name}/disown"
data = {"confirm": True}
with client as request:
resp = request.post(endpoint, data=data, cookies=tu_user.cookies)
assert resp.status_code == int(HTTPStatus.BAD_REQUEST)
errors = get_errors(resp.text)
expected = r"^No due existing orphan requests to accept for .+\.$"
assert re.match(expected, errors[0].text.strip())
assert Email.count() == 0
def test_closure_factory_invalid_reqtype_id():
""" Test providing an invalid reqtype_id raises NotImplementedError. """
automated = ClosureFactory()
match = r"^Unsupported '.+' value\.$"
with pytest.raises(NotImplementedError, match=match):
automated.get_closure(666, None, None, None, ACCEPTED_ID)
with pytest.raises(NotImplementedError, match=match):
automated.get_closure(666, None, None, None, REJECTED_ID)