From ae3d302c47a0429a5cfd70445f62f54214f34606 Mon Sep 17 00:00:00 2001 From: Kevin Morris Date: Thu, 15 Jul 2021 22:48:26 -0700 Subject: [PATCH] implement /packages/{name} as its own route A few things added with this commit: - aurweb.packages.util - A module providing package and pkgbase helpers. - aurweb.template.register_filter - A decorator that can be used to register a filter: @register_filter("some_filter") def f(): pass Additionally, template partials have been split off a bit differently. Changes: - /packages/{name} is defined in packages/show.html. - partials/packages/package_actions.html is now partials/packages/actions.html. - partials/packages/details.html has been added. - partials/packages/comments.html has been added. - partials/packages/comment.html has been added. - models.dependency_type additions: name and id constants. - models.relation_type additions: name and id constants. - models.official_provider additions: base official url constant. Signed-off-by: Kevin Morris --- aurweb/models/dependency_type.py | 16 + aurweb/models/official_provider.py | 2 + aurweb/models/package_dependency.py | 9 + aurweb/models/relation_type.py | 13 + aurweb/packages/__init__.py | 0 aurweb/packages/util.py | 117 ++++++++ aurweb/routers/packages.py | 101 +++++-- aurweb/templates.py | 27 ++ aurweb/testing/html.py | 14 + templates/packages/show.html | 23 ++ templates/partials/packages/actions.html | 162 ++++++++++ templates/partials/packages/comment.html | 48 +++ templates/partials/packages/comments.html | 145 +++++---- templates/partials/packages/details.html | 145 +++++++++ .../partials/packages/package_actions.html | 87 ------ .../partials/packages/package_metadata.html | 54 ++++ templates/pkgbase.html | 95 ------ test/test_package_dependency.py | 7 + test/test_packages_routes.py | 283 ++++++++++++++++++ test/test_packages_util.py | 51 ++++ test/test_templates.py | 15 + web/html/js/copy.js | 6 + 22 files changed, 1166 insertions(+), 254 deletions(-) create mode 100644 aurweb/packages/__init__.py create mode 100644 aurweb/packages/util.py create mode 100644 aurweb/testing/html.py create mode 100644 templates/packages/show.html create mode 100644 templates/partials/packages/actions.html create mode 100644 templates/partials/packages/comment.html create mode 100644 templates/partials/packages/details.html delete mode 100644 templates/partials/packages/package_actions.html create mode 100644 templates/partials/packages/package_metadata.html delete mode 100644 templates/pkgbase.html create mode 100644 test/test_packages_routes.py create mode 100644 test/test_packages_util.py create mode 100644 test/test_templates.py create mode 100644 web/html/js/copy.js diff --git a/aurweb/models/dependency_type.py b/aurweb/models/dependency_type.py index 71acf368..3b5fafcc 100644 --- a/aurweb/models/dependency_type.py +++ b/aurweb/models/dependency_type.py @@ -1,7 +1,13 @@ from sqlalchemy import Column, Integer +from aurweb import db from aurweb.models.declarative import Base +DEPENDS = "depends" +MAKEDEPENDS = "makedepends" +CHECKDEPENDS = "checkdepends" +OPTDEPENDS = "optdepends" + class DependencyType(Base): __tablename__ = "DependencyTypes" @@ -12,3 +18,13 @@ class DependencyType(Base): def __init__(self, Name: str = None): self.Name = Name + + +DEPENDS_ID = db.query(DependencyType).filter( + DependencyType.Name == DEPENDS).first().ID +MAKEDEPENDS_ID = db.query(DependencyType).filter( + DependencyType.Name == MAKEDEPENDS).first().ID +CHECKDEPENDS_ID = db.query(DependencyType).filter( + DependencyType.Name == CHECKDEPENDS).first().ID +OPTDEPENDS_ID = db.query(DependencyType).filter( + DependencyType.Name == OPTDEPENDS).first().ID diff --git a/aurweb/models/official_provider.py b/aurweb/models/official_provider.py index 756be843..e53556b9 100644 --- a/aurweb/models/official_provider.py +++ b/aurweb/models/official_provider.py @@ -3,6 +3,8 @@ from sqlalchemy.exc import IntegrityError from aurweb.models.declarative import Base +OFFICIAL_BASE = "https://aur.archlinux.org" + class OfficialProvider(Base): __tablename__ = "OfficialProviders" diff --git a/aurweb/models/package_dependency.py b/aurweb/models/package_dependency.py index b7bee246..0e5b028b 100644 --- a/aurweb/models/package_dependency.py +++ b/aurweb/models/package_dependency.py @@ -61,3 +61,12 @@ class PackageDependency(Base): self.DepDesc = DepDesc self.DepCondition = DepCondition self.DepArch = DepArch + + def is_package(self) -> bool: + from aurweb import db + from aurweb.models.official_provider import OfficialProvider + from aurweb.models.package import Package + pkg = db.query(Package, Package.Name == self.DepName) + official = db.query(OfficialProvider, + OfficialProvider.Name == self.DepName) + return pkg.count() > 0 or official.count() > 0 diff --git a/aurweb/models/relation_type.py b/aurweb/models/relation_type.py index 319fb7f4..71b6adbb 100644 --- a/aurweb/models/relation_type.py +++ b/aurweb/models/relation_type.py @@ -1,7 +1,12 @@ from sqlalchemy import Column, Integer +from aurweb import db from aurweb.models.declarative import Base +CONFLICTS = "conflicts" +PROVIDES = "provides" +REPLACES = "replaces" + class RelationType(Base): __tablename__ = "RelationTypes" @@ -12,3 +17,11 @@ class RelationType(Base): def __init__(self, Name: str = None): self.Name = Name + + +CONFLICTS_ID = db.query(RelationType).filter( + RelationType.Name == CONFLICTS).first().ID +PROVIDES_ID = db.query(RelationType).filter( + RelationType.Name == PROVIDES).first().ID +REPLACES_ID = db.query(RelationType).filter( + RelationType.Name == REPLACES).first().ID diff --git a/aurweb/packages/__init__.py b/aurweb/packages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aurweb/packages/util.py b/aurweb/packages/util.py new file mode 100644 index 00000000..6681d479 --- /dev/null +++ b/aurweb/packages/util.py @@ -0,0 +1,117 @@ +from http import HTTPStatus + +from fastapi import HTTPException +from sqlalchemy import and_ + +from aurweb import db +from aurweb.models.official_provider import OFFICIAL_BASE, OfficialProvider +from aurweb.models.package import Package +from aurweb.models.package_base import PackageBase +from aurweb.models.package_dependency import PackageDependency +from aurweb.models.package_relation import PackageRelation +from aurweb.models.relation_type import PROVIDES_ID, RelationType +from aurweb.templates import register_filter + + +def dep_depends_extra(dep: PackageDependency) -> str: + """ A function used to produce extra text for dependency display. """ + return str() + + +def dep_makedepends_extra(dep: PackageDependency) -> str: + """ A function used to produce extra text for dependency display. """ + return "(make)" + + +def dep_checkdepends_extra(dep: PackageDependency) -> str: + """ A function used to produce extra text for dependency display. """ + return "(check)" + + +def dep_optdepends_extra(dep: PackageDependency) -> str: + """ A function used to produce extra text for dependency display. """ + return "(optional)" + + +@register_filter("dep_extra") +def dep_extra(dep: PackageDependency) -> str: + """ Some dependency types have extra text added to their + display. This function provides that output. However, it + **assumes** that the dep passed is bound to a valid one + of: depends, makedepends, checkdepends or optdepends. """ + f = globals().get(f"dep_{dep.DependencyType.Name}_extra") + return f(dep) + + +@register_filter("dep_extra_desc") +def dep_extra_desc(dep: PackageDependency) -> str: + extra = dep_extra(dep) + return extra + f" – {dep.DepDesc}" + + +@register_filter("pkgname_link") +def pkgname_link(pkgname: str) -> str: + base = "/".join([OFFICIAL_BASE, "packages"]) + pkg = db.query(Package).filter(Package.Name == pkgname) + official = db.query(OfficialProvider).filter( + OfficialProvider.Name == pkgname) + if not pkg.count() or official.count(): + return f"{base}/?q={pkgname}" + return f"/packages/{pkgname}" + + +@register_filter("package_link") +def package_link(package: Package) -> str: + base = "/".join([OFFICIAL_BASE, "packages"]) + official = db.query(OfficialProvider).filter( + OfficialProvider.Name == package.Name) + if official.count(): + return f"{base}/?q={package.Name}" + return f"/packages/{package.Name}" + + +@register_filter("provides_list") +def provides_list(package: Package, depname: str) -> list: + providers = db.query(Package).join( + PackageRelation).join(RelationType).filter( + and_( + PackageRelation.RelName == depname, + RelationType.ID == PROVIDES_ID + ) + ) + + string = str() + has_providers = providers.count() > 0 + + if has_providers: + string += "(" + + string += ", ".join([ + f'{pkg.Name}' + for pkg in providers + ]) + + if has_providers: + string += ")" + + return string + + +def get_pkgbase(name: str) -> PackageBase: + """ Get a PackageBase instance by its name or raise a 404 if + it can't be foudn in the database. + + :param name: PackageBase.Name + :raises HTTPException: With status code 404 if PackageBase doesn't exist + :return: PackageBase instance + """ + pkgbase = db.query(PackageBase).filter(PackageBase.Name == name).first() + if not pkgbase: + raise HTTPException(status_code=int(HTTPStatus.NOT_FOUND)) + + provider = db.query(OfficialProvider).filter( + OfficialProvider.Name == name).first() + if provider: + raise HTTPException(status_code=int(HTTPStatus.NOT_FOUND)) + + return pkgbase diff --git a/aurweb/routers/packages.py b/aurweb/routers/packages.py index 0bd19041..9650df85 100644 --- a/aurweb/routers/packages.py +++ b/aurweb/routers/packages.py @@ -1,39 +1,98 @@ from http import HTTPStatus +from typing import Any, Dict -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Request, Response from fastapi.responses import RedirectResponse +from sqlalchemy import and_ -import aurweb.models.package import aurweb.models.package_comment import aurweb.models.package_keyword +import aurweb.packages.util from aurweb import db +from aurweb.models.license import License +from aurweb.models.package import Package from aurweb.models.package_base import PackageBase +from aurweb.models.package_dependency import PackageDependency +from aurweb.models.package_license import PackageLicense +from aurweb.models.package_notification import PackageNotification +from aurweb.models.package_relation import PackageRelation +from aurweb.models.package_source import PackageSource +from aurweb.models.package_vote import PackageVote +from aurweb.models.relation_type import CONFLICTS_ID, RelationType +from aurweb.packages.util import get_pkgbase from aurweb.templates import make_variable_context, render_template router = APIRouter() -@router.get("/packages/{package}") -async def package_base(request: Request, package: str): - package = db.query(PackageBase).filter(PackageBase.Name == package).first() - if not package: - raise HTTPException(status_code=int(HTTPStatus.NOT_FOUND)) +async def make_single_context(request: Request, + pkgbase: PackageBase) -> Dict[str, Any]: + """ Make a basic context for package or pkgbase. - context = await make_variable_context(request, package.Name) - context["git_clone_uri_anon"] = aurweb.config.get("options", "git_clone_uri_anon") - context["git_clone_uri_priv"] = aurweb.config.get("options", "git_clone_uri_priv") - context["pkgbase"] = package - context["packages"] = package.packages.all() - context["packages_count"] = package.packages.count() - context["keywords"] = package.keywords.all() - context["comments"] = package.comments.all() - context["is_maintainer"] = request.user.is_authenticated() \ - and request.user.Username == package.Maintainer.Username + :param request: FastAPI request + :param pkgbase: PackageBase instance + :return: A pkgbase context without specific differences + """ + context = await make_variable_context(request, pkgbase.Name) + context["git_clone_uri_anon"] = aurweb.config.get("options", + "git_clone_uri_anon") + context["git_clone_uri_priv"] = aurweb.config.get("options", + "git_clone_uri_priv") + context["pkgbase"] = pkgbase + context["packages_count"] = pkgbase.packages.count() + context["keywords"] = pkgbase.keywords + context["comments"] = pkgbase.comments + context["is_maintainer"] = (request.user.is_authenticated() + and request.user == pkgbase.Maintainer) + context["notified"] = db.query( + PackageNotification).join(PackageBase).filter( + and_(PackageBase.ID == pkgbase.ID, + PackageNotification.UserID == request.user.ID)).count() > 0 - return render_template(request, "pkgbase.html", context) + context["out_of_date"] = bool(pkgbase.OutOfDateTS) + + context["voted"] = pkgbase.package_votes.filter( + PackageVote.UsersID == request.user.ID).count() > 0 + + context["notifications_enabled"] = db.query( + PackageNotification).join(PackageBase).filter( + PackageBase.ID == pkgbase.ID).count() > 0 + + return context -@router.get("/pkgbase/{package}") -async def package_base_redirect(request: Request, package: str): - return RedirectResponse(f"/packages/{package}") +@router.get("/packages/{name}") +async def package(request: Request, name: str) -> Response: + # Get the PackageBase. + pkgbase = get_pkgbase(name) + + # Add our base information. + context = await make_single_context(request, pkgbase) + + # Package sources. + sources = db.query(PackageSource).join(Package).filter( + Package.PackageBaseID == pkgbase.ID) + context["sources"] = sources + + # Package dependencies. + dependencies = db.query(PackageDependency).join(Package).filter( + Package.PackageBaseID == pkgbase.ID) + context["dependencies"] = dependencies + + # Package requirements (other packages depend on this one). + required_by = db.query(PackageDependency).join(Package).filter( + PackageDependency.DepName == pkgbase.Name).order_by( + Package.Name.asc()) + context["required_by"] = required_by + + licenses = db.query(License).join(PackageLicense).join(Package).filter( + PackageLicense.PackageID == pkgbase.packages.first().ID) + context["licenses"] = licenses + + conflicts = db.query(PackageRelation).join(RelationType).join(Package).join(PackageBase).filter( + and_(RelationType.ID == CONFLICTS_ID, + PackageBase.ID == pkgbase.ID)) + context["conflicts"] = conflicts + + return render_template(request, "packages/show.html", context) diff --git a/aurweb/templates.py b/aurweb/templates.py index 8b507425..fa7aa039 100644 --- a/aurweb/templates.py +++ b/aurweb/templates.py @@ -1,9 +1,11 @@ import copy +import functools import os import zoneinfo from datetime import datetime from http import HTTPStatus +from typing import Callable from urllib.parse import quote_plus import jinja2 @@ -40,6 +42,31 @@ env.filters["captcha_cmdline"] = captcha.captcha_cmdline_filter env.filters["account_url"] = util.account_url +def register_filter(name: str) -> Callable: + """ A decorator that can be used to register a filter. + + Example + @register_filter("some_filter") + def some_filter(some_value: str) -> str: + return some_value.replace("-", "_") + + Jinja2 + {{ 'blah-blah' | some_filter }} + + :param name: Filter name + :return: Callable used for filter + """ + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + if name in env.filters: + raise KeyError(f"Jinja already has a filter named '{name}'") + env.filters[name] = wrapper + return wrapper + return decorator + + def make_context(request: Request, title: str, next: str = None): """ Create a context for a jinja2 TemplateResponse. """ diff --git a/aurweb/testing/html.py b/aurweb/testing/html.py new file mode 100644 index 00000000..d5f0c256 --- /dev/null +++ b/aurweb/testing/html.py @@ -0,0 +1,14 @@ +from io import StringIO + +from lxml import etree + +parser = etree.HTMLParser() + + +def parse_root(html: str) -> etree.Element: + """ Parse an lxml.etree.ElementTree root from html content. + + :param html: HTML markup + :return: etree.Element + """ + return etree.parse(StringIO(html), parser) diff --git a/templates/packages/show.html b/templates/packages/show.html new file mode 100644 index 00000000..7a5aae2d --- /dev/null +++ b/templates/packages/show.html @@ -0,0 +1,23 @@ +{% extends "partials/layout.html" %} + +{% block pageContent %} + {% include "partials/packages/search.html" %} +
+

{{ 'Package Details' | tr }}: {{ pkgbase.Name }} {{ pkgbase.packages.first().Version }}

+ + {% set result = pkgbase %} + {% include "partials/packages/actions.html" %} + + {% set show_package_details = True %} + {% include "partials/packages/details.html" %} + +
+ {% include "partials/packages/package_metadata.html" %} +
+
+ + {% set pkgname = result.Name %} + {% set pkgbase_id = result.ID %} + {% set comments = comments %} + {% include "partials/packages/comments.html" %} +{% endblock %} diff --git a/templates/partials/packages/actions.html b/templates/partials/packages/actions.html new file mode 100644 index 00000000..87db3a3f --- /dev/null +++ b/templates/partials/packages/actions.html @@ -0,0 +1,162 @@ + + diff --git a/templates/partials/packages/comment.html b/templates/partials/packages/comment.html new file mode 100644 index 00000000..6cf5f319 --- /dev/null +++ b/templates/partials/packages/comment.html @@ -0,0 +1,48 @@ +

+ {% set commented_at = comment.CommentTS | dt | as_timezone(timezone) %} + {% set view_account_info = 'View account information for %s' | tr | format(comment.User.Username) %} + {{ + "%s commented on %s" | tr | format( + ('%s' | format( + comment.User.Username, + view_account_info, + comment.User.Username + )) if request.user.is_authenticated() else + (comment.User.Username), + '%s' | format( + comment.ID, + commented_at.strftime("%Y-%m-%d %H:%M") + ) + ) + | safe + }} + {% if is_maintainer %} +
+
+ + + + +
+
+ Edit comment + {% endif %} +
+
+ + + + + +
+
+

+
+
+ {% if comment.RenderedComment %} + {{ comment.RenderedComment | safe }} + {% else %} + {{ comment.Comments }} + {% endif %} +
+
diff --git a/templates/partials/packages/comments.html b/templates/partials/packages/comments.html index 39b0d2b4..f1bc020d 100644 --- a/templates/partials/packages/comments.html +++ b/templates/partials/packages/comments.html @@ -5,6 +5,7 @@ - comments (list) --> +{% if request.user.is_authenticated() %}

Add Comment

@@ -31,61 +32,103 @@

+ {% if not notifications_enabled %} + + + + + {% endif %}

+{% endif %} -
-
-

- {{ "Latest Comments" | tr }} - -

-
- {% for comment in comments %} -

- {% set commented_at = comment.CommentTS | dt | as_timezone(timezone) %} - {% set view_account_info = 'View account information for %s' | tr | format(comment.User.Username) %} - {{ - "%s commented on %s" | tr | format( - '%s' | format( - comment.User.Username, - view_account_info, - comment.User.Username - ), - '%s' | format( - comment.ID, - commented_at.strftime("%Y-%m-%d %H:%M") - ) - ) - | safe - }} - {% if is_maintainer %} -
-
- - - - -
-
- Edit comment - {% endif %} -
-
- - - - - -
-
-

-
-
-

{{ comment.RenderedComment | safe }}

+{% if comments.count() %} +
+
+

+ {{ "Latest Comments" | tr }} + +

+ {% for comment in comments.all() %} + {% include "partials/packages/comment.html" %} + {% endfor %}
- {% endfor %} -
+{% endif %} + + diff --git a/templates/partials/packages/details.html b/templates/partials/packages/details.html new file mode 100644 index 00000000..a25e9c9e --- /dev/null +++ b/templates/partials/packages/details.html @@ -0,0 +1,145 @@ + + + + + + {% if show_package_details | default(False) %} + + + + + + + + + + + + + {% endif %} + {% if pkgbase.keywords.count() %} + + + {% if is_maintainer %} + + {% else %} + + {% endif %} + + {% endif %} + {% if licenses and licenses.count() and show_package_details | default(False) %} + + + + + {% endif %} + {% if show_package_details | default(False) %} + + + + + {% endif %} + + + + + + + + + + + + + + + {% if not is_maintainer %} + + {% else %} + + {% endif %} + + + + + + + {% set submitted = pkgbase.SubmittedTS | dt | as_timezone(timezone) %} + + + + + + {% set updated = pkgbase.ModifiedTS | dt | as_timezone(timezone) %} + + +
{{ "Git Clone URL" | tr }}: + {{ git_clone_uri_anon | format(pkgbase.Name) }} ({{ "read-only" | tr }}, {{ "click to copy" | tr }}) + {% if is_maintainer %} +
{{ git_clone_uri_priv | format(pkgbase.Name) }} ({{ "click to copy" | tr }}) + {% endif %} +
{{ "Package Base" | tr }}: + + {{ pkgbase.Name }} + +
{{ "Description" | tr }}:{{ pkgbase.packages.first().Description }}
{{ "Upstream URL" | tr }}: + {% set pkg = pkgbase.packages.first() %} + {% if pkg.URL %} + {{ pkg.URL }} + {% else %} + {{ "None" | tr }} + {% endif %} +
{{ "Keywords" | tr }}: +
+
+ + +
+
+
+ {% for keyword in pkgbase.keywords %} + + {{ keyword.Keyword }} + + {% endfor %} +
{{ "Licenses" | tr }}:{{ licenses | join(', ', attribute='Name') | default('None' | tr) }}
{{ "Conflicts" | tr }}: + {{ conflicts | join(', ', attribute='RelName') }} +
{{ "Submitter" | tr }}: + {% if request.user.is_authenticated() %} + + {{ pkgbase.Submitter.Username | default("None" | tr) }} + + {% else %} + {{ pkgbase.Submitter.Username | default("None" | tr) }} + {% endif %} +
{{ "Maintainer" | tr }}: + {% if request.user.is_authenticated() %} + + {{ pkgbase.Maintainer.Username | default("None" | tr) }} + + {% else %} + {{ pkgbase.Maintainer.Username | default("None" | tr) }} + {% endif %} +
{{ "Last Packager" | tr }}: + {% if request.user.is_authenticated() %} + + {{ pkgbase.Packager.Username | default("None" | tr) }} + + {% else %} + {{ pkgbase.Packager.Username | default("None" | tr) }} + {% endif %} +
{{ "Votes" | tr }}:{{ pkgbase.package_votes.count() }} + + {{ pkgbase.package_votes.count() }} + +
{{ "Popularity" | tr }}:{{ pkgbase.Popularity | number_format(6 if pkgbase.Popularity <= 0.2 else 2) }}
{{ "First Submitted" | tr }}:{{ "%s" | format(submitted.strftime("%Y-%m-%d %H:%M")) }}
{{ "Last Updated" | tr }}:{{ "%s" | format(updated.strftime("%Y-%m-%d %H:%M")) }}
+ + + diff --git a/templates/partials/packages/package_actions.html b/templates/partials/packages/package_actions.html deleted file mode 100644 index 4e7da882..00000000 --- a/templates/partials/packages/package_actions.html +++ /dev/null @@ -1,87 +0,0 @@ - - diff --git a/templates/partials/packages/package_metadata.html b/templates/partials/packages/package_metadata.html new file mode 100644 index 00000000..767e25a9 --- /dev/null +++ b/templates/partials/packages/package_metadata.html @@ -0,0 +1,54 @@ +
+

Dependencies ({{ dependencies.count() }})

+
    + {% for dep in dependencies.all() %} +
  • + {% set broken = not dep.is_package() %} + {% if broken %} + + {% else %} + + {% endif %} + {{ dep.DepName }} + {% if broken %} + + {% else %} + + {% endif %} + {{ dep.Package | provides_list(dep.DepName) | safe }} + {% set extra = dep | dep_extra %} + {% if extra %} + {{ dep | dep_extra_desc }} + {% endif %} +
  • + {% endfor %} +
+
+ +
+

Required by ({{ required_by.count() }})

+ +
+ +
+

Sources ({{ sources.count() }})

+
+ +
+ +
diff --git a/templates/pkgbase.html b/templates/pkgbase.html deleted file mode 100644 index d608fa2e..00000000 --- a/templates/pkgbase.html +++ /dev/null @@ -1,95 +0,0 @@ -{% extends "partials/layout.html" %} - -{% block pageContent %} - {% include "partials/packages/search.html" %} -
-

Package Details: {{ pkgbase.Name }}

- - {% set result = pkgbase %} - {% set pkgname = "result.Name" %} - {% include "partials/packages/package_actions.html" %} - - - - - - - - - {% if is_maintainer %} - - {% else %} - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - {% set submitted = pkgbase.SubmittedTS | dt | as_timezone(timezone) %} - - - - - - {% set updated = pkgbase.ModifiedTS | dt | as_timezone(timezone) %} - - -
{{ "Git Clone URL" | tr }}: - {{ git_clone_uri_anon | format(pkgbase.Name) }} ({{ "read-only" | tr }}, {{ "click to copy" | tr }}) - {% if is_maintainer %} -
{{ git_clone_uri_priv | format(pkgbase.Name) }} ({{ "click to copy" | tr }}) - {% endif %} -
{{ "Keywords" | tr }}: -
-
- - - -
-
-
- {% for item in pkgbase.keywords %} - {{ item.Keyword }} - {% endfor %} -
{{ "Submitter" | tr }}:{{ pkgbase.Submitter.Username | default("None") }}
{{ "Maintainer" | tr }}:{{ pkgbase.Maintainer.Username | default("None") }}
{{ "Last Packager" | tr }}:{{ pkgbase.Packager.Username | default("None") }}
{{ "Votes" | tr }}:{{ pkgbase.NumVotes }}
{{ "Popularity" | tr }}:{{ '%0.2f' % pkgbase.Popularity | float }}
{{ "First Submitted" | tr }}:{{ "%s" | tr | format(submitted.strftime("%Y-%m-%d %H:%M")) }}
{{ "Last Updated" | tr }}:{{ "%s" | tr | format(updated.strftime("%Y-%m-%d %H:%M")) }}
- -
-
- -

Packages ({{ packages_count }})

- -
-
-
- {% set pkgname = result.Name %} - {% set pkgbase_id = result.ID %} - {% set comments = comments %} - {% include "partials/packages/comments.html" %} -{% endblock %} diff --git a/test/test_package_dependency.py b/test/test_package_dependency.py index d39091aa..e28f1781 100644 --- a/test/test_package_dependency.py +++ b/test/test_package_dependency.py @@ -77,6 +77,13 @@ def test_package_dependencies(): assert pkgdep in optdepends.package_dependencies assert pkgdep in package.package_dependencies + assert not pkgdep.is_package() + + base = create(PackageBase, Name=pkgdep.DepName, Maintainer=user) + create(Package, PackageBase=base, Name=pkgdep.DepName) + + assert pkgdep.is_package() + def test_package_dependencies_null_package_raises_exception(): from aurweb.db import session diff --git a/test/test_packages_routes.py b/test/test_packages_routes.py new file mode 100644 index 00000000..f9592238 --- /dev/null +++ b/test/test_packages_routes.py @@ -0,0 +1,283 @@ +from datetime import datetime +from http import HTTPStatus + +import pytest + +from fastapi.testclient import TestClient + +from aurweb import asgi, db +from aurweb.models.account_type import USER_ID, AccountType +from aurweb.models.dependency_type import DependencyType +from aurweb.models.official_provider import OfficialProvider +from aurweb.models.package import Package +from aurweb.models.package_base import PackageBase +from aurweb.models.package_comment import PackageComment +from aurweb.models.package_dependency import PackageDependency +from aurweb.models.package_keyword import PackageKeyword +from aurweb.models.package_relation import PackageRelation +from aurweb.models.relation_type import PROVIDES_ID, RelationType +from aurweb.models.user import User +from aurweb.testing import setup_test_db +from aurweb.testing.html import parse_root +from aurweb.testing.requests import Request + + +def package_endpoint(package: Package) -> str: + return f"/packages/{package.Name}" + + +def create_package(pkgname: str, maintainer: User, + autocommit: bool = True) -> Package: + pkgbase = db.create(PackageBase, + Name=pkgname, + Maintainer=maintainer, + autocommit=False) + return db.create(Package, Name=pkgbase.Name, PackageBase=pkgbase, + autocommit=autocommit) + + +def create_package_dep(package: Package, depname: str, + dep_type_name: str = "depends", + autocommit: bool = True) -> PackageDependency: + dep_type = db.query(DependencyType, + DependencyType.Name == dep_type_name).first() + return db.create(PackageDependency, + DependencyType=dep_type, + Package=package, + DepName=depname, + autocommit=autocommit) + + +def create_package_rel(package: Package, + relname: str, + autocommit: bool = True) -> PackageRelation: + rel_type = db.query(RelationType, + RelationType.ID == PROVIDES_ID).first() + return db.create(PackageRelation, + RelationType=rel_type, + Package=package, + RelName=relname) + + +@pytest.fixture(autouse=True) +def setup(): + setup_test_db( + User.__tablename__, + Package.__tablename__, + PackageBase.__tablename__, + PackageDependency.__tablename__, + PackageRelation.__tablename__, + PackageKeyword.__tablename__, + OfficialProvider.__tablename__ + ) + + +@pytest.fixture +def client() -> TestClient: + """ Yield a FastAPI TestClient. """ + yield TestClient(app=asgi.app) + + +@pytest.fixture +def user() -> User: + """ Yield a user. """ + account_type = db.query(AccountType, AccountType.ID == USER_ID).first() + yield db.create(User, Username="test", + Email="test@example.org", + Passwd="testPassword", + AccountType=account_type) + + +@pytest.fixture +def maintainer() -> User: + """ Yield a specific User used to maintain packages. """ + account_type = db.query(AccountType, AccountType.ID == USER_ID).first() + yield db.create(User, Username="test_maintainer", + Email="test_maintainer@example.org", + Passwd="testPassword", + AccountType=account_type) + + +@pytest.fixture +def package(maintainer: User) -> Package: + """ Yield a Package created by user. """ + pkgbase = db.create(PackageBase, + Name="test-package", + Maintainer=maintainer) + yield db.create(Package, + PackageBase=pkgbase, + Name=pkgbase.Name) + + +def test_package_not_found(client: TestClient): + with client as request: + resp = request.get("/packages/not_found") + assert resp.status_code == int(HTTPStatus.NOT_FOUND) + + +def test_package_official_not_found(client: TestClient, package: Package): + """ When a Package has a matching OfficialProvider record, it is not + hosted on AUR, but in the official repositories. Getting a package + with this kind of record should return a status code 404. """ + db.create(OfficialProvider, + Name=package.Name, + Repo="core", + Provides=package.Name) + + with client as request: + resp = request.get(package_endpoint(package)) + assert resp.status_code == int(HTTPStatus.NOT_FOUND) + + +def test_package(client: TestClient, package: Package): + """ Test a single /packages/{name} route. """ + with client as request: + + resp = request.get(package_endpoint(package)) + assert resp.status_code == int(HTTPStatus.OK) + + root = parse_root(resp.text) + h2 = root.find('.//div[@id="pkgdetails"]/h2') + + sections = h2.text.split(":") + assert sections[0] == "Package Details" + + name, version = sections[1].lstrip().split(" ") + assert name == package.Name + version == package.Version + + rows = root.findall('.//table[@id="pkginfo"]//tr') + row = rows[1] # Second row is our target. + + pkgbase = row.find("./td/a") + assert pkgbase.text.strip() == package.PackageBase.Name + + +def test_package_comments(client: TestClient, user: User, package: Package): + now = (datetime.utcnow().timestamp()) + comment = db.create(PackageComment, PackageBase=package.PackageBase, + User=user, Comments="Test comment", CommentTS=now) + + cookies = {"AURSID": user.login(Request(), "testPassword")} + with client as request: + resp = request.get(package_endpoint(package), cookies=cookies) + assert resp.status_code == int(HTTPStatus.OK) + + root = parse_root(resp.text) + expected = [ + comment.Comments + ] + comments = root.xpath('.//div[contains(@class, "package-comments")]' + '/div[@class="article-content"]/div/text()') + for i, row in enumerate(expected): + assert comments[i].strip() == row + + +def test_package_authenticated(client: TestClient, user: User, + package: Package): + """ We get the same here for either authenticated or not + authenticated. Form inputs are presented to maintainers. + This process also occurs when pkgbase.html is rendered. """ + cookies = {"AURSID": user.login(Request(), "testPassword")} + with client as request: + resp = request.get(package_endpoint(package), cookies=cookies) + assert resp.status_code == int(HTTPStatus.OK) + + expected = [ + "View PKGBUILD", + "View Changes", + "Download snapshot", + "Search wiki", + "Flag package out-of-date", + "Vote for this package", + "Enable notifications", + "Submit Request" + ] + for expected_text in expected: + assert expected_text in resp.text + + +def test_package_authenticated_maintainer(client: TestClient, + maintainer: User, + package: Package): + cookies = {"AURSID": maintainer.login(Request(), "testPassword")} + with client as request: + resp = request.get(package_endpoint(package), cookies=cookies) + assert resp.status_code == int(HTTPStatus.OK) + + expected = [ + "View PKGBUILD", + "View Changes", + "Download snapshot", + "Search wiki", + "Flag package out-of-date", + "Vote for this package", + "Enable notifications", + "Manage Co-Maintainers", + "Submit Request", + "Delete Package", + "Merge Package", + "Disown Package" + ] + for expected_text in expected: + assert expected_text in resp.text + + +def test_package_dependencies(client: TestClient, maintainer: User, + package: Package): + # Create a normal dependency of type depends. + dep_pkg = create_package("test-dep-1", maintainer, autocommit=False) + dep = create_package_dep(package, dep_pkg.Name, autocommit=False) + + # Also, create a makedepends. + make_dep_pkg = create_package("test-dep-2", maintainer, autocommit=False) + make_dep = create_package_dep(package, make_dep_pkg.Name, + dep_type_name="makedepends", + autocommit=False) + + # And... a checkdepends! + check_dep_pkg = create_package("test-dep-3", maintainer, autocommit=False) + check_dep = create_package_dep(package, check_dep_pkg.Name, + dep_type_name="checkdepends", + autocommit=False) + + # Geez. Just stop. This is optdepends. + opt_dep_pkg = create_package("test-dep-4", maintainer, autocommit=False) + opt_dep = create_package_dep(package, opt_dep_pkg.Name, + dep_type_name="optdepends", + autocommit=False) + + broken_dep = create_package_dep(package, "test-dep-5", + dep_type_name="depends", + autocommit=False) + + # Create an official provider record. + db.create(OfficialProvider, Name="test-dep-99", + Repo="core", Provides="test-dep-99", + autocommit=False) + official_dep = create_package_dep(package, "test-dep-99", + autocommit=False) + + # Also, create a provider who provides our test-dep-99. + provider = create_package("test-provider", maintainer, autocommit=False) + create_package_rel(provider, dep.DepName) + + with client as request: + resp = request.get(package_endpoint(package)) + assert resp.status_code == int(HTTPStatus.OK) + + root = parse_root(resp.text) + + expected = [ + dep.DepName, + make_dep.DepName, + check_dep.DepName, + opt_dep.DepName, + official_dep.DepName + ] + pkgdeps = root.findall('.//ul[@id="pkgdepslist"]/li/a') + for i, expectation in enumerate(expected): + assert pkgdeps[i].text.strip() == expectation + + broken_node = root.find('.//ul[@id="pkgdepslist"]/li/span') + assert broken_node.text.strip() == broken_dep.DepName diff --git a/test/test_packages_util.py b/test/test_packages_util.py new file mode 100644 index 00000000..17978490 --- /dev/null +++ b/test/test_packages_util.py @@ -0,0 +1,51 @@ +import pytest + +from fastapi.testclient import TestClient + +from aurweb import asgi, db +from aurweb.models.account_type import USER_ID, AccountType +from aurweb.models.official_provider import OFFICIAL_BASE, OfficialProvider +from aurweb.models.package import Package +from aurweb.models.package_base import PackageBase +from aurweb.models.user import User +from aurweb.packages import util +from aurweb.testing import setup_test_db + + +@pytest.fixture(autouse=True) +def setup(): + setup_test_db( + User.__tablename__, + Package.__tablename__, + PackageBase.__tablename__, + OfficialProvider.__tablename__ + ) + + +@pytest.fixture +def maintainer() -> User: + account_type = db.query(AccountType, AccountType.ID == USER_ID).first() + yield db.create(User, Username="test_maintainer", + Email="test_maintainer@examepl.org", + Passwd="testPassword", + AccountType=account_type) + + +@pytest.fixture +def package(maintainer: User) -> Package: + pkgbase = db.create(PackageBase, Name="test-pkg", Maintainer=maintainer) + yield db.create(Package, Name=pkgbase.Name, PackageBase=pkgbase) + + +@pytest.fixture +def client() -> TestClient: + yield TestClient(app=asgi.app) + + +def test_package_link(client: TestClient, maintainer: User, package: Package): + db.create(OfficialProvider, + Name=package.Name, + Repo="core", + Provides=package.Name) + expected = f"{OFFICIAL_BASE}/packages/?q={package.Name}" + assert util.package_link(package) == expected diff --git a/test/test_templates.py b/test/test_templates.py new file mode 100644 index 00000000..8e3017b4 --- /dev/null +++ b/test/test_templates.py @@ -0,0 +1,15 @@ +import pytest + +from aurweb.templates import register_filter + + +@register_filter("func") +def func(): pass + + +def test_register_filter_exists_key_error(): + """ Most instances of register_filter are tested through module + imports or template renders, so we only test failures here. """ + with pytest.raises(KeyError): + @register_filter("func") + def some_func(): pass diff --git a/web/html/js/copy.js b/web/html/js/copy.js new file mode 100644 index 00000000..f46299b3 --- /dev/null +++ b/web/html/js/copy.js @@ -0,0 +1,6 @@ +document.addEventListener('DOMContentLoaded', function() { + document.querySelector('.copy').addEventListener('click', function(e) { + e.preventDefault(); + navigator.clipboard.writeText(event.target.text); + }); +});