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" %} +
- {{ "Latest Comments" | tr }} - -
-- {% 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 %} -
-{{ comment.RenderedComment | safe }}
+{% if comments.count() %} ++ {{ "Latest Comments" | tr }} + +
{{ git_clone_uri_priv | format(pkgbase.Name) }} ({{ "click to copy" | tr }}) + {% endif %} +
{{ "Package Actions" | tr }}
---
-
- {{ "View PKGBUILD" | tr }}
-
- /
-
- {{ "View Changes" | tr }}
-
-
- -
-
- {{ "Download snapshot" | tr }}
-
-
-
-
- {{ "Search wiki" | tr }}
-
-
-
- -
-
- {{ "Flag package out-of-date" | tr }}
-
-
- -
-
-
- -
-
-
- {% if is_maintainer %}
- -
-
- {{ "Manage Co-Maintainers" | tr }}
-
-
- {% endif %}
-
- {% if request.user.is_authenticated() %}
- -
-
- {{ "Submit Request" | tr }}
-
-
- {% endif %}
- {% if is_maintainer %}
- -
-
- {{ "Delete Package" | tr }}
-
-
- -
-
- {{ "Merge Package" | tr }}
-
-
- -
-
-
- {% endif %}
-
-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() }})
++ {% for depender in required_by.all() %} +-
+
+ {{ depender.Package.Name }}
+
+ {{ depender | dep_extra }}
+
+ {% endfor %}
+
+Sources ({{ sources.count() }})
++ {% for source in sources.all() %} +-
+ {{ source.Source }}
+
+ {% endfor %}
+
+Package Details: {{ pkgbase.Name }}
- - {% set result = pkgbase %} - {% set pkgname = "result.Name" %} - {% include "partials/packages/package_actions.html" %} - -{{ git_clone_uri_priv | format(pkgbase.Name) }} ({{ "click to copy" | tr }}) - {% endif %} -
Packages ({{ packages_count }})
-- {% for result in packages %} --
-
- {{ result.Name }}
-
-
- {% endfor %}
-
-