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

View file

@ -4,19 +4,20 @@ from typing import Any, Dict, List
from fastapi import APIRouter, Form, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from sqlalchemy import case
from sqlalchemy import and_, case
import aurweb.filters
import aurweb.packages.util
from aurweb import db, defaults, l10n, logging, models, util
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.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 validate
from aurweb.packages.requests import handle_request, update_closure_comment
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.scripts import notify, popupdate
@ -115,66 +116,30 @@ async def packages(request: Request) -> Response:
return await packages_get(request, context)
def create_request_if_missing(requests: List[models.PackageRequest],
reqtype: models.RequestType,
user: models.User,
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 = []
def delete_package(request: Request, package: models.Package,
merge_into: models.PackageBase = None,
comments: str = str()):
bases_to_delete = []
target = db.query(models.PackageBase).filter(
models.PackageBase.Name == merge_into
).first()
notifs = []
# In all cases, though, just delete the Package in question.
if package.PackageBase.packages.count() == 1:
reqtype = db.query(models.RequestType).filter(
models.RequestType.ID == DELETION_ID
).first()
with db.begin():
pkgreq = create_request_if_missing(
requests, reqtype, deleter, package)
pkgreq.Status = ACCEPTED_ID
notifs = handle_request(request, DELETION_ID, package.PackageBase,
target=target)
bases_to_delete.append(package.PackageBase)
# Prepare DeleteNotification.
notifications.append(
notify.DeleteNotification(deleter.ID, package.PackageBase.ID)
)
with db.begin():
update_closure_comment(package.PackageBase, DELETION_ID, comments,
target=target)
# For each PackageRequest created, mock up an open and close notification.
basename = package.PackageBase.Name
for pkgreq in requests:
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())
# Prepare DeleteNotification.
notifs.append(
notify.DeleteNotification(request.user.ID, package.PackageBase.ID)
)
# Perform all the deletions.
@ -183,7 +148,7 @@ def delete_package(deleter: models.User, package: models.Package):
db.delete_all(bases_to_delete)
# 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,
@ -676,22 +641,26 @@ async def pkgbase_request_post(request: Request, name: str,
auto_orphan_age = aurweb.config.getint("options", "auto_orphan_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
outdated = now - pkgbase.SubmittedTS <= auto_delete_age
outdated = (now - pkgbase.SubmittedTS) <= auto_delete_age
if type == "orphan" and flagged:
# This request should be auto-accepted.
with db.begin():
pkgbase.Maintainer = None
pkgreq.Status = ACCEPTED_ID
db.refresh(pkgreq)
notif = notify.RequestCloseNotification(
request.user.ID, pkgreq.ID, pkgreq.status_display())
notif.send()
logger.debug(f"New request #{pkgreq.ID} is marked for auto-orphan.")
elif type == "deletion" and is_maintainer and outdated:
# This request should be auto-accepted.
packages = pkgbase.packages.all()
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.
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")
@auth_required()
async def requests_close_post(request: Request, id: int,
reason: int = Form(default=0),
comments: str = Form(default=str())):
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 '/'.
return RedirectResponse("/", status_code=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
now = int(datetime.utcnow().timestamp())
with db.begin():
pkgreq.Closer = request.user
pkgreq.Status = reason
pkgreq.ClosureComment = comments
pkgreq.ClosedTS = now
pkgreq.Status = REJECTED_ID
notify_ = notify.RequestCloseNotification(
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):
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:
with db.begin():
@ -949,7 +912,7 @@ def pkgbase_disown_instance(request: Request, pkgbase: models.PackageBase):
else:
pkgbase.Maintainer = None
notif.send()
util.apply_all(notifs, lambda n: n.send())
@router.get("/pkgbase/{name}/disown")
@ -971,6 +934,7 @@ async def pkgbase_disown_get(request: Request, name: str):
@router.post("/pkgbase/{name}/disown")
@auth_required()
async def pkgbase_disown_post(request: Request, name: str,
comments: str = Form(default=str()),
confirm: bool = Form(default=False)):
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}",
HTTPStatus.SEE_OTHER)
context = make_context(request, "Disown Package")
context["pkgbase"] = pkgbase
if not confirm:
context = make_context(request, "Disown Package")
context["pkgbase"] = pkgbase
context["errors"] = [("The selected packages have not been disowned, "
"check the confirmation checkbox.")]
return render_template(request, "packages/disown.html", context,
status_code=HTTPStatus.BAD_REQUEST)
pkgbase_disown_instance(request, pkgbase)
with db.begin():
update_closure_comment(pkgbase, ORPHAN_ID, comments)
try:
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}",
status_code=HTTPStatus.SEE_OTHER)
@ -1032,7 +1005,8 @@ async def pkgbase_delete_get(request: Request, name: str):
@router.post("/pkgbase/{name}/delete")
@auth_required()
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)
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,
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.
packages = pkgbase.packages.all()
for package in packages:
delete_package(request.user, package)
delete_package(request, package, comments=comments)
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."])
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] = [],
confirm: bool = False, **kwargs):
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 "
"of the packages you selected."])
# Now, really disown the bases.
for pkgbase in bases:
pkgbase_disown_instance(request, pkgbase)
# Now, disown all the bases if we can.
if errors := disown_all(request, bases):
return (False, errors)
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.
for pkg in packages:
deleted_pkgs.append(pkg.Name)
delete_package(request.user, pkg)
delete_package(request, pkg)
# Log out the fact that this happened for accountability.
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,
target: models.PackageBase):
target: models.PackageBase, comments: str = str()):
pkgbasename = str(pkgbase.Name)
# Collect requests related to this merge.
query = pkgbase.requests.filter(
models.PackageRequest.ReqTypeID == MERGE_ID
)
# Create notifications.
notifs = handle_request(request, MERGE_ID, pkgbase, target)
requests = query.filter(
models.PackageRequest.MergeBaseName == target.Name).all()
reject_requests = query.filter(
models.PackageRequest.MergeBaseName != target.Name).all()
# Target votes and notifications sets of user IDs that are
# looking to be migrated.
target_votes = set(v.UsersID for v in target.package_votes)
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.")
with db.begin():
# Merge pkgbase's comments.
for comment in pkgbase.comments:
comment.PackageBase = target
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():
# Merge pkgbase's comments, notifications and votes into target.
for comment in pkgbase.comments:
comment.PackageBase = target
for notif in pkgbase.notifications:
# Merge notifications that don't yet exist in the target.
for notif in pkgbase.notifications:
if notif.UserID not in target_notifs:
notif.PackageBase = target
for vote in pkgbase.package_votes:
# Merge votes that don't yet exist in the target.
for vote in pkgbase.package_votes:
if vote.UsersID not in target_votes:
vote.PackageBase = target
with db.begin():
# Delete pkgbase and its packages now that everything's merged.
for pkg in pkgbase.packages:
db.delete(pkg)
db.delete(pkgbase)
# Run popupdate.
popupdate.run_single(target)
# 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)
with db.begin():
# Delete pkgbase and its packages now that everything's merged.
for pkg in pkgbase.packages:
db.delete(pkg)
db.delete(pkgbase)
# Log this out for accountability purposes.
logger.info(f"Trusted User '{request.user.Username}' merged "
f"'{pkgbasename}' into '{target.Name}'.")
# Send our notifications array.
# Send notifications.
util.apply_all(notifs, lambda n: n.send())
@ -1425,6 +1383,7 @@ def pkgbase_merge_instance(request: Request, pkgbase: models.PackageBase,
@auth_required()
async def pkgbase_merge_post(request: Request, name: str,
into: str = Form(default=str()),
comments: str = Form(default=str()),
confirm: bool = Form(default=False),
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,
status_code=HTTPStatus.BAD_REQUEST)
with db.begin():
update_closure_comment(pkgbase, MERGE_ID, comments, target=target)
# Merge pkgbase into target.
pkgbase_merge_instance(request, pkgbase, target)
pkgbase_merge_instance(request, pkgbase, target, comments=comments)
# Run popupdate on the target.
popupdate.run_single(target)