diff --git a/aurweb/auth.py b/aurweb/auth.py
index 26e4073d..2e6674b0 100644
--- a/aurweb/auth.py
+++ b/aurweb/auth.py
@@ -53,10 +53,30 @@ class AnonymousUser:
def is_authenticated():
return False
+ @staticmethod
+ def is_trusted_user():
+ return False
+
+ @staticmethod
+ def is_developer():
+ return False
+
+ @staticmethod
+ def is_elevated():
+ return False
+
@staticmethod
def has_credential(credential):
return False
+ @staticmethod
+ def voted_for(package):
+ return False
+
+ @staticmethod
+ def notified(package):
+ return False
+
class BasicAuthBackend(AuthenticationBackend):
async def authenticate(self, conn: HTTPConnection):
diff --git a/aurweb/filters.py b/aurweb/filters.py
new file mode 100644
index 00000000..bb56c656
--- /dev/null
+++ b/aurweb/filters.py
@@ -0,0 +1,50 @@
+from typing import Any, Dict
+
+import paginate
+
+from jinja2 import pass_context
+
+from aurweb import util
+from aurweb.templates import register_filter
+
+
+@register_filter("pager_nav")
+@pass_context
+def pager_nav(context: Dict[str, Any],
+ page: int, total: int, prefix: str) -> str:
+ page = int(page) # Make sure this is an int.
+
+ pp = context.get("PP", 50)
+
+ # Setup a local query string dict, optionally passed by caller.
+ q = context.get("q", dict())
+
+ search_by = context.get("SeB", None)
+ if search_by:
+ q["SeB"] = search_by
+
+ sort_by = context.get("SB", None)
+ if sort_by:
+ q["SB"] = sort_by
+
+ def create_url(page: int):
+ nonlocal q
+ offset = max(page * pp - pp, 0)
+ qs = util.to_qs(util.extend_query(q, ["O", offset]))
+ return f"{prefix}?{qs}"
+
+ # Use the paginate module to produce our linkage.
+ pager = paginate.Page([], page=page + 1,
+ items_per_page=pp,
+ item_count=total,
+ url_maker=create_url)
+
+ return pager.pager(
+ link_attr={"class": "page"},
+ curpage_attr={"class": "page"},
+ separator=" ",
+ format="$link_first $link_previous ~5~ $link_next $link_last",
+ symbol_first="« First",
+ symbol_previous="‹ Previous",
+ symbol_next="Next ›",
+ symbol_last="Last »")
diff --git a/aurweb/models/user.py b/aurweb/models/user.py
index 70d15f88..28aa613e 100644
--- a/aurweb/models/user.py
+++ b/aurweb/models/user.py
@@ -165,6 +165,15 @@ class User(Base):
aurweb.models.account_type.TRUSTED_USER_AND_DEV_ID
}
+ def is_elevated(self):
+ """ A User is 'elevated' when they have either a
+ Trusted User or Developer AccountType. """
+ return self.AccountType.ID in {
+ aurweb.models.account_type.TRUSTED_USER_ID,
+ aurweb.models.account_type.DEVELOPER_ID,
+ aurweb.models.account_type.TRUSTED_USER_AND_DEV_ID,
+ }
+
def can_edit_user(self, user):
""" Can this account record edit the target user? It must either
be the target user or a user with enough permissions to do so.
diff --git a/aurweb/packages/search.py b/aurweb/packages/search.py
new file mode 100644
index 00000000..854834ee
--- /dev/null
+++ b/aurweb/packages/search.py
@@ -0,0 +1,195 @@
+from sqlalchemy import and_, case, or_, orm
+
+from aurweb import config, db
+from aurweb.models.package import Package
+from aurweb.models.package_base import PackageBase
+from aurweb.models.package_comaintainer import PackageComaintainer
+from aurweb.models.package_keyword import PackageKeyword
+from aurweb.models.package_notification import PackageNotification
+from aurweb.models.package_vote import PackageVote
+from aurweb.models.user import User
+
+DEFAULT_MAX_RESULTS = 2500
+
+
+class PackageSearch:
+ """ A Package search query builder. """
+
+ # A constant mapping of short to full name sort orderings.
+ FULL_SORT_ORDER = {"d": "desc", "a": "asc"}
+
+ def __init__(self, user: User):
+ """ Construct an instance of PackageSearch.
+
+ This constructors performs several steps during initialization:
+ 1. Setup self.query: an ORM query of Package joined by PackageBase.
+ """
+ self.user = user
+ self.query = db.query(Package).join(PackageBase).join(
+ PackageVote,
+ and_(PackageVote.PackageBaseID == PackageBase.ID,
+ PackageVote.UsersID == self.user.ID),
+ isouter=True
+ ).join(
+ PackageNotification,
+ and_(PackageNotification.PackageBaseID == PackageBase.ID,
+ PackageNotification.UserID == self.user.ID),
+ isouter=True
+ )
+ self.ordering = "d"
+
+ # Setup SeB (Search By) callbacks.
+ self.search_by_cb = {
+ "nd": self._search_by_namedesc,
+ "n": self._search_by_name,
+ "b": self._search_by_pkgbase,
+ "N": self._search_by_exact_name,
+ "B": self._search_by_exact_pkgbase,
+ "k": self._search_by_keywords,
+ "m": self._search_by_maintainer,
+ "c": self._search_by_comaintainer,
+ "M": self._search_by_co_or_maintainer,
+ "s": self._search_by_submitter
+ }
+
+ # Setup SB (Sort By) callbacks.
+ self.sort_by_cb = {
+ "n": self._sort_by_name,
+ "v": self._sort_by_votes,
+ "p": self._sort_by_popularity,
+ "w": self._sort_by_voted,
+ "o": self._sort_by_notify,
+ "m": self._sort_by_maintainer,
+ "l": self._sort_by_last_modified
+ }
+
+ def _search_by_namedesc(self, keywords: str) -> orm.Query:
+ self.query = self.query.filter(
+ or_(Package.Name.like(f"%{keywords}%"),
+ Package.Description.like(f"%{keywords}%"))
+ )
+ return self
+
+ def _search_by_name(self, keywords: str) -> orm.Query:
+ self.query = self.query.filter(Package.Name.like(f"%{keywords}%"))
+ return self
+
+ def _search_by_exact_name(self, keywords: str) -> orm.Query:
+ self.query = self.query.filter(Package.Name == keywords)
+ return self
+
+ def _search_by_pkgbase(self, keywords: str) -> orm.Query:
+ self.query = self.query.filter(PackageBase.Name.like(f"%{keywords}%"))
+ return self
+
+ def _search_by_exact_pkgbase(self, keywords: str) -> orm.Query:
+ self.query = self.query.filter(PackageBase.Name == keywords)
+ return self
+
+ def _search_by_keywords(self, keywords: str) -> orm.Query:
+ self.query = self.query.join(PackageKeyword).filter(
+ PackageKeyword.Keyword == keywords
+ )
+ return self
+
+ def _search_by_maintainer(self, keywords: str) -> orm.Query:
+ self.query = self.query.join(
+ User, User.ID == PackageBase.MaintainerUID
+ ).filter(User.Username == keywords)
+ return self
+
+ def _search_by_comaintainer(self, keywords: str) -> orm.Query:
+ self.query = self.query.join(PackageComaintainer).join(
+ User, User.ID == PackageComaintainer.UsersID
+ ).filter(User.Username == keywords)
+ return self
+
+ def _search_by_co_or_maintainer(self, keywords: str) -> orm.Query:
+ self.query = self.query.join(
+ PackageComaintainer,
+ isouter=True
+ ).join(
+ User, or_(User.ID == PackageBase.MaintainerUID,
+ User.ID == PackageComaintainer.UsersID)
+ ).filter(User.Username == keywords)
+ return self
+
+ def _search_by_submitter(self, keywords: str) -> orm.Query:
+ self.query = self.query.join(
+ User, User.ID == PackageBase.SubmitterUID
+ ).filter(User.Username == keywords)
+ return self
+
+ def search_by(self, search_by: str, keywords: str) -> orm.Query:
+ if search_by not in self.search_by_cb:
+ search_by = "nd" # Default: Name, Description
+ callback = self.search_by_cb.get(search_by)
+ result = callback(keywords)
+ return result
+
+ def _sort_by_name(self, order: str):
+ column = getattr(Package.Name, order)
+ self.query = self.query.order_by(column())
+ return self
+
+ def _sort_by_votes(self, order: str):
+ column = getattr(PackageBase.NumVotes, order)
+ self.query = self.query.order_by(column())
+ return self
+
+ def _sort_by_popularity(self, order: str):
+ column = getattr(PackageBase.Popularity, order)
+ self.query = self.query.order_by(column())
+ return self
+
+ def _sort_by_voted(self, order: str):
+ # FIXME: Currently, PHP is destroying this implementation
+ # in terms of performance. We should improve this; there's no
+ # reason it should take _longer_.
+ column = getattr(
+ case([(PackageVote.UsersID == self.user.ID, 1)], else_=0),
+ order
+ )
+ self.query = self.query.order_by(column(), Package.Name.desc())
+ return self
+
+ def _sort_by_notify(self, order: str):
+ # FIXME: Currently, PHP is destroying this implementation
+ # in terms of performance. We should improve this; there's no
+ # reason it should take _longer_.
+ column = getattr(
+ case([(PackageNotification.UserID == self.user.ID, 1)], else_=0),
+ order
+ )
+ self.query = self.query.order_by(column(), Package.Name.desc())
+ return self
+
+ def _sort_by_maintainer(self, order: str):
+ column = getattr(User.Username, order)
+ self.query = self.query.join(
+ User, User.ID == PackageBase.MaintainerUID, isouter=True
+ ).order_by(column())
+ return self
+
+ def _sort_by_last_modified(self, order: str):
+ column = getattr(PackageBase.ModifiedTS, order)
+ self.query = self.query.order_by(column())
+ return self
+
+ def sort_by(self, sort_by: str, ordering: str = "d") -> orm.Query:
+ if sort_by not in self.sort_by_cb:
+ sort_by = "n" # Default: Name.
+ callback = self.sort_by_cb.get(sort_by)
+ if ordering not in self.FULL_SORT_ORDER:
+ ordering = "d" # Default: Descending.
+ ordering = self.FULL_SORT_ORDER.get(ordering)
+ return callback(ordering)
+
+ def results(self) -> orm.Query:
+ # Store the total count of all records found up to limit.
+ limit = (config.getint("options", "max_search_results")
+ or DEFAULT_MAX_RESULTS)
+ self.total_count = self.query.limit(limit).count()
+
+ # Return the query to the user.
+ return self.query
diff --git a/aurweb/routers/packages.py b/aurweb/routers/packages.py
index a20c97b1..3eda2539 100644
--- a/aurweb/routers/packages.py
+++ b/aurweb/routers/packages.py
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy import and_
+import aurweb.filters
import aurweb.models.package_comment
import aurweb.models.package_keyword
import aurweb.packages.util
@@ -21,12 +22,92 @@ from aurweb.models.package_request import PackageRequest
from aurweb.models.package_source import PackageSource
from aurweb.models.package_vote import PackageVote
from aurweb.models.relation_type import CONFLICTS_ID
-from aurweb.packages.util import get_pkgbase
+from aurweb.packages.search import PackageSearch
+from aurweb.packages.util import get_pkgbase, query_notified, query_voted
from aurweb.templates import make_context, render_template
router = APIRouter()
+async def packages_get(request: Request, context: Dict[str, Any]):
+ # Query parameters used in this request.
+ context["q"] = dict(request.query_params)
+
+ # Per page and offset.
+ per_page = context["PP"] = int(request.query_params.get("PP", 50))
+ offset = context["O"] = int(request.query_params.get("O", 0))
+
+ # Query search by.
+ search_by = context["SeB"] = request.query_params.get("SeB", "nd")
+
+ # Query sort by.
+ sort_by = context["SB"] = request.query_params.get("SB", "n")
+
+ # Query sort order.
+ sort_order = request.query_params.get("SO", None)
+
+ # Apply ordering, limit and offset.
+ search = PackageSearch(request.user)
+
+ # For each keyword found in K, apply a search_by filter.
+ # This means that for any sentences separated by spaces,
+ # they are used as if they were ANDed.
+ keywords = context["K"] = request.query_params.get("K", str())
+ keywords = keywords.split(" ")
+ for keyword in keywords:
+ search.search_by(search_by, keyword)
+
+ flagged = request.query_params.get("outdated", None)
+ if flagged:
+ # If outdated was given, set it up in the context.
+ context["outdated"] = flagged
+
+ # When outdated is set to "on," we filter records which do have
+ # an OutOfDateTS. When it's set to "off," we filter out any which
+ # do **not** have OutOfDateTS.
+ criteria = None
+ if flagged == "on":
+ criteria = PackageBase.OutOfDateTS.isnot
+ else:
+ criteria = PackageBase.OutOfDateTS.is_
+
+ # Apply the flag criteria to our PackageSearch.query.
+ search.query = search.query.filter(criteria(None))
+
+ submit = request.query_params.get("submit", "Go")
+ if submit == "Orphans":
+ # If the user clicked the "Orphans" button, we only want
+ # orphaned packages.
+ search.query = search.query.filter(PackageBase.MaintainerUID.is_(None))
+
+ # Apply user-specified specified sort column and ordering.
+ search.sort_by(sort_by, sort_order)
+
+ # If no SO was given, default the context SO to 'a' (Ascending).
+ # By default, if no SO is given, the search should sort by 'd'
+ # (Descending), but display "Ascending" for the Sort order select.
+ if sort_order is None:
+ sort_order = "a"
+ context["SO"] = sort_order
+
+ # Insert search results into the context.
+ results = search.results()
+ context["packages"] = results.limit(per_page).offset(offset)
+ context["packages_voted"] = query_voted(
+ context.get("packages"), request.user)
+ context["packages_notified"] = query_notified(
+ context.get("packages"), request.user)
+ context["packages_count"] = search.total_count
+
+ return render_template(request, "packages.html", context)
+
+
+@router.get("/packages")
+async def packages(request: Request) -> Response:
+ context = make_context(request, "Packages")
+ return await packages_get(request, context)
+
+
async def make_single_context(request: Request,
pkgbase: PackageBase) -> Dict[str, Any]:
""" Make a basic context for package or pkgbase.
diff --git a/aurweb/templates.py b/aurweb/templates.py
index 6a1b6a1c..09be049c 100644
--- a/aurweb/templates.py
+++ b/aurweb/templates.py
@@ -1,5 +1,6 @@
import copy
import functools
+import math
import os
import zoneinfo
@@ -35,6 +36,7 @@ _env.filters["urlencode"] = util.to_qs
_env.filters["quote_plus"] = quote_plus
_env.filters["get_vote"] = util.get_vote
_env.filters["number_format"] = util.number_format
+_env.filters["ceil"] = math.ceil
# Add captcha filters.
_env.filters["captcha_salt"] = captcha.captcha_salt_filter
diff --git a/conf/config.defaults b/conf/config.defaults
index 1c96a55d..988859a0 100644
--- a/conf/config.defaults
+++ b/conf/config.defaults
@@ -22,6 +22,7 @@ aur_location = https://aur.archlinux.org
git_clone_uri_anon = https://aur.archlinux.org/%s.git
git_clone_uri_priv = ssh://aur@aur.archlinux.org/%s.git
max_rpc_results = 5000
+max_search_results = 2500
max_depends = 1000
aur_request_ml = aur-requests@lists.archlinux.org
request_idle_time = 1209600
diff --git a/poetry.lock b/poetry.lock
index 3cc84361..322e250f 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -513,6 +513,14 @@ python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2"
+[[package]]
+name = "paginate"
+version = "0.5.6"
+description = "Divides large result sets into pages for easier browsing"
+category = "main"
+optional = false
+python-versions = "*"
+
[[package]]
name = "pluggy"
version = "0.13.1"
@@ -921,7 +929,7 @@ h11 = ">=0.9.0,<1"
[metadata]
lock-version = "1.1"
python-versions = "*"
-content-hash = "96112731ca21a6ff5d0657c6c40979642bb992ae660ba8d6135421718737c6b0"
+content-hash = "c262ac1160b83593377fb7520d35c4b8ad81e5acff9d0a2060b2b048e3865b78"
[metadata.files]
aiofiles = [
@@ -1328,6 +1336,9 @@ packaging = [
{file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
{file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
]
+paginate = [
+ {file = "paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"},
+]
pluggy = [
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
{file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"},
diff --git a/pyproject.toml b/pyproject.toml
index 8cb276ce..4b530493 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -70,6 +70,7 @@ python-multipart = { version = "0.0.5", python = "^3.9" }
redis = { version = "3.5.3", python = "^3.9" }
requests = { version = "2.26.0", python = "^3.9" }
werkzeug = { version = "2.0.1", python = "^3.9" }
+paginate = { version = "0.5.6", python = "^3.9" }
# SQL
alembic = { version = "1.6.5", python = "^3.9" }
diff --git a/setup.cfg b/setup.cfg
index 1d67ca96..4f2bdf7d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,5 +1,6 @@
[pycodestyle]
max-line-length = 127
+ignore = E741, W503
[flake8]
max-line-length = 127
@@ -25,6 +26,7 @@ max-complexity = 10
per-file-ignores =
aurweb/routers/accounts.py:E741,C901
test/test_ssh_pub_key.py:E501
+ aurweb/routers/packages.py:E741
[isort]
line_length = 127
diff --git a/templates/packages.html b/templates/packages.html
new file mode 100644
index 00000000..8b5b06d1
--- /dev/null
+++ b/templates/packages.html
@@ -0,0 +1,84 @@
+{% extends "partials/layout.html" %}
+
+{% block pageContent %}
+ {% if errors %}
+
+
+ {% for error in errors %}
+
{{ error | tr }}
+ {% endfor %}
+
+ {% include "partials/packages/search.html" %}
+
+ {% else %}
+
+ {% set pages = (packages_count / PP) | ceil %}
+ {% set page = O / PP %}
+
+ {% if success %}
+
+ {% for message in success %}
+
{{ message | tr }}
+ {% endfor %}
+
+ {% endif %}
+
+ {# Search form #}
+ {% include "partials/packages/search.html" %}
+
+
+ {# /packages does things a bit roundabout-wise:
+
+ If SeB is not given, "nd" is the default.
+ If SB is not given, "n" is the default.
+ If SO is not given, "d" is the default.
+
+ However, we depend on flipping SO for column sorting.
+
+ This section sets those defaults for the context if
+ they are not already setup. #}
+ {% if not SeB %}
+ {% set SeB = "nd" %}
+ {% endif %}
+ {% if not SB %}
+ {% set SB = "n" %}
+ {% endif %}
+ {% if not SO %}
+ {% set SO = "d" %}
+ {% endif %}
+
+ {# Pagination widget #}
+ {% with total = packages_count,
+ singular = "%d package found.",
+ plural = "%d packages found.",
+ prefix = "/packages" %}
+ {% include "partials/widgets/pager.html" %}
+ {% endwith %}
+
+ {# Package action form #}
+
+
diff --git a/templates/partials/widgets/pager.html b/templates/partials/widgets/pager.html
new file mode 100644
index 00000000..4809accf
--- /dev/null
+++ b/templates/partials/widgets/pager.html
@@ -0,0 +1,26 @@
+{# A pager widget that can be used for navigation of a number of results.
+
+Inputs required:
+
+ prefix: Request URI prefix used to produce navigation offsets
+ singular: Singular sentence to be translated via tn
+ plural: Plural sentence to be translated via tn
+ PP: The number of results per page
+ O: The current offset value
+ total: The total number of results
+#}
+
+{% set page = ((O / PP) | int) %}
+{% set pages = ((total / PP) | ceil) %}
+
+