mirror of
https://gitlab.archlinux.org/archlinux/aurweb.git
synced 2025-02-03 10:43:03 +01:00
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:
parent
bad57ba502
commit
26b1674c9e
10 changed files with 1044 additions and 354 deletions
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue