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