diff --git a/aurweb/packages/requests.py b/aurweb/packages/requests.py new file mode 100644 index 00000000..d68f9688 --- /dev/null +++ b/aurweb/packages/requests.py @@ -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 diff --git a/aurweb/routers/packages.py b/aurweb/routers/packages.py index fb9498d4..eb4cab74 100644 --- a/aurweb/routers/packages.py +++ b/aurweb/routers/packages.py @@ -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) diff --git a/po/aurweb.pot b/po/aurweb.pot index 0b3996a8..f6d1377b 100644 --- a/po/aurweb.pot +++ b/po/aurweb.pot @@ -2300,3 +2300,7 @@ msgstr "" #: aurweb/routers/accounts.py msgid "You do not have permission to change this user's account type to %s." msgstr "" + +#: aurweb/packages/requests.py +msgid "No due existing orphan requests to accept for %s." +msgstr "" diff --git a/templates/packages/delete.html b/templates/packages/delete.html index 6e882d05..8910cce9 100644 --- a/templates/packages/delete.html +++ b/templates/packages/delete.html @@ -43,6 +43,12 @@

+

+ + +

+

+

+ + +

+

+

+ + +

+

-

- - -

-