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 <kevr@0cost.org>
This commit is contained in:
Kevin Morris 2021-07-15 22:48:26 -07:00
parent 2d3d03e01e
commit ae3d302c47
22 changed files with 1166 additions and 254 deletions

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

117
aurweb/packages/util.py Normal file
View file

@ -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 += "<em>("
string += ", ".join([
f'<a href="{package_link(pkg)}">{pkg.Name}</a>'
for pkg in providers
])
if has_providers:
string += ")</em>"
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

View file

@ -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)

View file

@ -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. """

14
aurweb/testing/html.py Normal file
View file

@ -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)

View file

@ -0,0 +1,23 @@
{% extends "partials/layout.html" %}
{% block pageContent %}
{% include "partials/packages/search.html" %}
<div id="pkgdetails" class="box">
<h2>{{ 'Package Details' | tr }}: {{ pkgbase.Name }} {{ pkgbase.packages.first().Version }}</h2>
{% set result = pkgbase %}
{% include "partials/packages/actions.html" %}
{% set show_package_details = True %}
{% include "partials/packages/details.html" %}
<div id="metadata">
{% include "partials/packages/package_metadata.html" %}
</div>
</div>
{% set pkgname = result.Name %}
{% set pkgbase_id = result.ID %}
{% set comments = comments %}
{% include "partials/packages/comments.html" %}
{% endblock %}

View file

@ -0,0 +1,162 @@
<!--
This partial requires result.Name to render
-->
<div id="detailslinks" class="listing">
<div id="actionlist">
<h4>{{ "Package Actions" | tr }}</h4>
<ul class="small">
<li>
<a href="/cgit/aur.git/tree/PKGBUILD?h={{ result.Name }}">
{{ "View PKGBUILD" | tr }}
</a>
/
<a href="/cgit/aur.git/log/?h={{ result.Name }}">
{{ "View Changes" | tr }}
</a>
</li>
<li>
<a href="/cgit/aur.git/snapshot/{{ result.Name }}.tar.gz">
{{ "Download snapshot" | tr }}
</a>
<li>
<a href="https://wiki.archlinux.org/title/Special:Search?search={{ result.Name }}">
{{ "Search wiki" | tr }}
</a>
</li>
{% if not request.user.is_authenticated() %}
{% if not out_of_date %}
<li>
<a href="/pkgbase/{{ result.Name }}/flag/">
{{ "Flag package out-of-date" | tr }}
</a>
</li>
{% else %}
<li>
<span class="flagged">
{% set ood_ts = result.OutOfDateTS | dt | as_timezone(timezone) %}
{{
"Flagged out-of-date (%s)"
| tr | format(ood_ts.strftime("%Y-%m-%d"))
}}
</span>
</li>
{% endif %}
<li>
<a href="/login?next={{ request.url.path | urlencode }}">
{{ "Vote for this package" | tr }}
</a>
</li>
<li>
<a href="/login?next={{ request.url.path | urlencode }}">
{{ "Enable notifications" | tr }}
</a>
</li>
{% else %}
{% if not out_of_date %}
<li>
<a href="/pkgbase/{{ result.Name }}/flag/">
{{ "Flag package out-of-date" | tr }}
</a>
</li>
{% else %}
<li>
<span class="flagged">
{% set ood_ts = result.OutOfDateTS | dt | as_timezone(timezone) %}
{{
"Flagged out-of-date (%s)"
| tr | format(ood_ts.strftime("%Y-%m-%d"))
}}
</span>
</li>
<li>
<form action="/pkgbase/{{ result.Name }}/unflag" method="post">
<input class="button text-button"
type="submit"
name="do_UnFlag"
value="{{ 'Unflag package' | tr }}"
/>
</form>
</li>
{% endif %}
<li>
{% if not voted %}
<form action="/pkgbase/{{ result.Name }}/vote/" method="post">
<input type="submit"
class="button text-button"
name="do_Vote"
value="{{ 'Vote for this package' | tr }}" />
</form>
{% else %}
<form action="/pkgbase/{{ result.Name }}/unvote/" method="post">
<input type="submit"
class="button text-button"
name="do_UnVote"
value="{{ 'Remove vote' | tr }}" />
</form>
{% endif %}
</li>
<li>
{% if notified %}
<form action="/pkgbase/{{ result.Name }}/unnotify/" method="post">
<input type="submit"
class="button text-button"
name="do_UnNotify"
value="{{ 'Disable notifications' | tr }}"
/>
</form>
{% else %}
<form action="/pkgbase/{{ result.Name }}/notify/" method="post">
<input type="submit"
class="button text-button"
name="do_Notify"
value="{{ 'Enable notifications' | tr }}"
/>
</form>
{% endif %}
</li>
{% endif %}
</form>
{% if is_maintainer %}
<li>
<a href="/pkgbase/{{ result.Name }}/comaintainers/">
{{ "Manage Co-Maintainers" | tr }}
</a>
</li>
{% endif %}
<li><span class="flagged"></span></li>
<li>
{% if not request.user.is_authenticated() %}
<a href="/login?next={{ '/pkgbase/%s/request' | format(result.Name) | urlencode }}">
{{ "Submit Request" | tr }}
</a>
{% else %}
<a href="/pkgbase/{{ result.Name }}/request/">
{{ "Submit Request" | tr }}
</a>
{% endif %}
</li>
{% if is_maintainer %}
<li>
<a href="/pkgbase/{{ result.Name }}/delete/">
{{ "Delete Package" | tr }}
</a>
</li>
<li>
<a href="/pkgbase/{{ result.Name }}/merge/">
{{ "Merge Package" | tr }}
</a>
</li>
<li>
<form action="/pkgbase/{{ result.Name }}/disown/" method="post">
<input type="submit"
class="button text-button"
name="do_Disown"
value="{{ 'Disown Package' | tr }}"
/>
</form>
</li>
{% endif %}
</ul>
</div>
</div>

View file

@ -0,0 +1,48 @@
<h4 id="comment-{{ comment.ID }}" class="comment-header">
{% 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(
('<a href="/account/%s" title="%s">%s</a>' | format(
comment.User.Username,
view_account_info,
comment.User.Username
)) if request.user.is_authenticated() else
(comment.User.Username),
'<a href="#comment-%s" class="date">%s</a>' | format(
comment.ID,
commented_at.strftime("%Y-%m-%d %H:%M")
)
)
| safe
}}
{% if is_maintainer %}
<form class="delete-comment-form" method="post" action="/pkgbase/{{ pkgname }}/">
<fieldset style="display:inline;">
<input type="hidden" name="action" value="do_DeleteComment" />
<input type="hidden" name="comment_id" value="{{ comment.ID }}"/>
<input type="hidden" name="return_to" value="/pkgbase/{{ pkgname }}/"/>
<input type="image" class="delete-comment" src="/images/x.min.svg" width="11" height="11" alt="{{ 'Delete comment' | tr }}" title="{{ 'Delete comment' | tr }}" name="submit" value="1" />
</fieldset>
</form>
<a href="/pkgbase/{{ pkgname }}/edit-comment/?comment_id={{ comment.ID }}" class="edit-comment" title="Edit comment"><img src="/images/pencil.min.svg" alt="Edit comment" width="11" height="11"></a>
{% endif %}
<form class="pin-comment-form" method="post" action="/pkgbase/{{ pkgname }}/">
<fieldset style="display:inline;">
<input type="hidden" name="action" value="do_PinComment"/>
<input type="hidden" name="comment_id" value="{{ comment.ID }}"/>
<input type="hidden" name="package_base" value="{{ pkgbase_id }}"/>
<input type="hidden" name="return_to" value="/pkgbase/{{ pkgname }}/"/>
<input type="image" class="pin-comment" src="/images/pin.min.svg" width="11" height="11" alt="{{ 'Pin comment' | tr }}" title="{{ 'Pin comment' | tr }}" name="submit" value="1"/>
</fieldset>
</form>
</h4>
<div id="comment-{{ comment.ID }}-content" class="article-content">
<div>
{% if comment.RenderedComment %}
{{ comment.RenderedComment | safe }}
{% else %}
{{ comment.Comments }}
{% endif %}
</div>
</div>

View file

@ -5,6 +5,7 @@
- comments (list)
-->
{% if request.user.is_authenticated() %}
<div id="generic-form" class="box">
<h2>Add Comment</h2>
<form action="/pkgbase/{{ pkgname }}/" method="post">
@ -31,61 +32,103 @@
</p>
<p>
<input type="submit" value="{{ 'Add Comment' | tr }}"/>
{% if not notifications_enabled %}
<span class="comment-enable-notifications">
<input id="id_enable_notifications"
type="checkbox"
name="enable_notifications"
/>
<label for="id_enable_notifications">
{{ "Enable notifications" | tr }}
</label>
</span>
{% endif %}
</p>
</fieldset>
</form>
</div>
{% endif %}
<div class="comments package-comments">
{% if comments.count() %}
<div class="comments package-comments">
<div class="comments-header">
<h3>
<span class="text">{{ "Latest Comments" | tr }}</span>
<span class="arrow"></span>
</h3>
</div>
{% for comment in comments %}
<h4 id="comment-{{ comment.ID }}" class="comment-header">
{% 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(
'<a href="/account/%s" title="%s">%s</a>' | format(
comment.User.Username,
view_account_info,
comment.User.Username
),
'<a href="#comment-%s" class="date">%s</a>' | format(
comment.ID,
commented_at.strftime("%Y-%m-%d %H:%M")
)
)
| safe
}}
{% if is_maintainer %}
<form class="delete-comment-form" method="post" action="/pkgbase/{{ pkgname }}/">
<fieldset style="display:inline;">
<input type="hidden" name="action" value="do_DeleteComment" />
<input type="hidden" name="comment_id" value="{{ comment.ID }}"/>
<input type="hidden" name="return_to" value="/pkgbase/{{ pkgname }}/"/>
<input type="image" class="delete-comment" src="/images/x.min.svg" width="11" height="11" alt="{{ 'Delete comment' | tr }}" title="{{ 'Delete comment' | tr }}" name="submit" value="1" />
</fieldset>
</form>
<a href="/pkgbase/{{ pkgname }}/edit-comment/?comment_id={{ comment.ID }}" class="edit-comment" title="Edit comment"><img src="/images/pencil.min.svg" alt="Edit comment" width="11" height="11"></a>
{% endif %}
<form class="pin-comment-form" method="post" action="/pkgbase/{{ pkgname }}/">
<fieldset style="display:inline;">
<input type="hidden" name="action" value="do_PinComment"/>
<input type="hidden" name="comment_id" value="{{ comment.ID }}"/>
<input type="hidden" name="package_base" value="{{ pkgbase_id }}"/>
<input type="hidden" name="return_to" value="/pkgbase/{{ pkgname }}/"/>
<input type="image" class="pin-comment" src="/images/pin.min.svg" width="11" height="11" alt="{{ 'Pin comment' | tr }}" title="{{ 'Pin comment' | tr }}" name="submit" value="1"/>
</fieldset>
</form>
</h4>
<div id="comment-{{ comment.ID }}-content" class="article-content">
<div>
<p>{{ comment.RenderedComment | safe }}</p>
</div>
</div>
{% for comment in comments.all() %}
{% include "partials/packages/comment.html" %}
{% endfor %}
</div>
</div>
{% endif %}
<script type="text/javascript" nonce="{{ request.user.nonce }}">
function add_busy_indicator(sibling) {
const img = document.createElement('img');
img.src = "/images/ajax-loader.gif";
img.classList.add('ajax-loader');
img.style.height = 11;
img.style.width = 16;
img.alt = "Busy…";
sibling.insertAdjacentElement('afterend', img);
}
function remove_busy_indicator(sibling) {
const elem = sibling.nextElementSibling;
elem.parentNode.removeChild(elem);
}
function getParentsUntil(elem, className) {
// Limit to 10 depth
for ( ; elem && elem !== document; elem = elem.parentNode) {
if (elem.matches(className)) {
break;
}
}
return elem;
}
function handleEditCommentClick(event) {
event.preventDefault();
const parent_element = getParentsUntil(event.target, '.comment-header');
const parent_id = parent_element.id;
const comment_id = parent_id.substr(parent_id.indexOf('-') + 1);
// The div class="article-content" which contains the comment
const edit_form = parent_element.nextElementSibling;
const params = new URLSearchParams({
type: "get-comment-form",
arg: comment_id,
base_id: {{ pkgbase.ID }},
pkgbase_name: {{ pkgbase.Name }}
});
const url = '/rpc?' + params.toString();
add_busy_indicator(event.target);
fetch(url, {
method: 'GET'
})
.then(function(response) { return response.json(); })
.then(function(data) {
remove_busy_indicator(event.target);
if (data.success) {
edit_form.innerHTML = data.form;
edit_form.querySelector('textarea').focus();
} else {
alert(data.error);
}
});
}
document.addEventListener('DOMContentLoaded', function() {
const divs = document.querySelectorAll('.edit-comment');;
for (let div of divs) {
div.addEventListener('click', handleEditCommentClick);
}
});
</script>

View file

@ -0,0 +1,145 @@
<table id="pkginfo">
<tr>
<th>{{ "Git Clone URL" | tr }}:</th>
<td>
<a class="copy" href="{{ git_clone_uri_anon | format(pkgbase.Name) }}">{{ git_clone_uri_anon | format(pkgbase.Name) }}</a> ({{ "read-only" | tr }}, {{ "click to copy" | tr }})
{% if is_maintainer %}
<br /> <a class="copy" href="{{ git_clone_uri_priv | format(pkgbase.Name) }}">{{ git_clone_uri_priv | format(pkgbase.Name) }}</a> ({{ "click to copy" | tr }})
{% endif %}
</td>
</tr>
{% if show_package_details | default(False) %}
<tr>
<th>{{ "Package Base" | tr }}:</th>
<td class="wrap">
<a href="/pkgbase/{{ pkgbase.Name }}">
{{ pkgbase.Name }}
</a>
</td>
</tr>
<tr>
<th>{{ "Description" | tr }}:</th>
<td class="wrap">{{ pkgbase.packages.first().Description }}</td>
</tr>
<tr>
<th>{{ "Upstream URL" | tr }}:</th>
<td class="wrap">
{% set pkg = pkgbase.packages.first() %}
{% if pkg.URL %}
<a href="{{ pkg.URL }}">{{ pkg.URL }}</a>
{% else %}
{{ "None" | tr }}
{% endif %}
</td>
</tr>
{% endif %}
{% if pkgbase.keywords.count() %}
<tr>
<th>{{ "Keywords" | tr }}:</th>
{% if is_maintainer %}
<td>
<form method="update"
action="/pkgbase/{{ pkgbase.Name }}/keywords/"
>
<div>
<input type="text"
name="keywords"
value="{{ pkgbase.keywords | join(' ', attribute='Keyword') }}"
/>
<input type="submit" value="{{ 'Update' | tr }}"/>
</div>
</form>
</td>
{% else %}
<td>
{% for keyword in pkgbase.keywords %}
<a class="keyword"
href="/packages/?K={{ keyword.Keyword }}&amp;SB=p"
>
{{ keyword.Keyword }}
</a>
{% endfor %}
</td>
{% endif %}
</tr>
{% endif %}
{% if licenses and licenses.count() and show_package_details | default(False) %}
<tr>
<th>{{ "Licenses" | tr }}:</th>
<td>{{ licenses | join(', ', attribute='Name') | default('None' | tr) }} </td>
</tr>
{% endif %}
{% if show_package_details | default(False) %}
<tr>
<th>{{ "Conflicts" | tr }}:</th>
<td class="wrap">
{{ conflicts | join(', ', attribute='RelName') }}
</td>
</tr>
{% endif %}
<tr>
<th>{{ "Submitter" | tr }}:</th>
<td>
{% if request.user.is_authenticated() %}
<a href="/account/{{ pkgbase.Submitter.Username }}">
{{ pkgbase.Submitter.Username | default("None" | tr) }}
</a>
{% else %}
{{ pkgbase.Submitter.Username | default("None" | tr) }}
{% endif %}
</td>
</tr>
<tr>
<th>{{ "Maintainer" | tr }}:</th>
<td>
{% if request.user.is_authenticated() %}
<a href="/account/{{ pkgbase.Maintainer.Username }}">
{{ pkgbase.Maintainer.Username | default("None" | tr) }}
</a>
{% else %}
{{ pkgbase.Maintainer.Username | default("None" | tr) }}
{% endif %}
</td>
</tr>
<tr>
<th>{{ "Last Packager" | tr }}:</th>
<td>
{% if request.user.is_authenticated() %}
<a href="/account/{{ pkgbase.Packager.Username }}">
{{ pkgbase.Packager.Username | default("None" | tr) }}
</a>
{% else %}
{{ pkgbase.Packager.Username | default("None" | tr) }}
{% endif %}
</td>
</tr>
<tr>
<th>{{ "Votes" | tr }}:</th>
{% if not is_maintainer %}
<td>{{ pkgbase.package_votes.count() }}</td>
{% else %}
<td>
<a href="/pkgbase/{{ pkgbase.Name }}/voters">
{{ pkgbase.package_votes.count() }}
</a>
</td>
{% endif %}
</tr>
<tr>
<th>{{ "Popularity" | tr }}:</th>
<td>{{ pkgbase.Popularity | number_format(6 if pkgbase.Popularity <= 0.2 else 2) }}</td>
</tr>
<tr>
{% set submitted = pkgbase.SubmittedTS | dt | as_timezone(timezone) %}
<th>{{ "First Submitted" | tr }}:</th>
<td>{{ "%s" | format(submitted.strftime("%Y-%m-%d %H:%M")) }}</td>
</tr>
<tr>
<th>{{ "Last Updated" | tr }}:</th>
{% set updated = pkgbase.ModifiedTS | dt | as_timezone(timezone) %}
<td>{{ "%s" | format(updated.strftime("%Y-%m-%d %H:%M")) }}</td>
</tr>
</table>
<script type="text/javascript" src="/static/js/copy.js"></script>

View file

@ -1,87 +0,0 @@
<!--
This partial requires pkgname to render
-->
<div id="detailslinks" class="listing">
<div id="actionlist">
<h4>{{ "Package Actions" | tr }}</h4>
<ul class="small">
<li>
<a href="/cgit/aur.git/tree/PKGBUILD?h={{ pkgname }}">
{{ "View PKGBUILD" | tr }}
</a>
/
<a href="/cgit/aur.git/log/?h={{ pkgname }}">
{{ "View Changes" | tr }}
</a>
</li>
<li>
<a href="/cgit/aur.git/snapshot/{{ pkgname }}.tar.gz">
{{ "Download snapshot" | tr }}
</a>
<li>
<a href="https://wiki.archlinux.org/title/Special:Search?search={{ pkgname }}">
{{ "Search wiki" | tr }}
</a>
</li>
<li><span class="flagged"></span></li>
<li>
<a href="/pkgbase/{{ pkgname }}/flag/">
{{ "Flag package out-of-date" | tr }}
</a>
</li>
<li>
<form action="/pkgbase/{{ pkgname }}/vote/" method="post">
<input type="submit"
class="button text-button"
name="do_Vote"
value="{{ 'Vote for this package' | tr }}" />
</form>
</li>
<li>
<form action="/pkgbase/{{ pkgname }}/unnotify/" method="post">
<input type="submit"
class="button text-button"
name="do_UnNotify"
value="{{ 'Disable notifications' | tr }}"
/>
</form>
</li>
{% if is_maintainer %}
<li>
<a href="/pkgbase/{{ pkgname }}/comaintainers/">
{{ "Manage Co-Maintainers" | tr }}
</a>
</li>
{% endif %}
<li><span class="flagged"></span></li>
{% if request.user.is_authenticated() %}
<li>
<a href="/pkgbase/{{ pkgname }}/request/">
{{ "Submit Request" | tr }}
</a>
</li>
{% endif %}
{% if is_maintainer %}
<li>
<a href="/pkgbase/{{ pkgname }}/delete/">
{{ "Delete Package" | tr }}
</a>
</li>
<li>
<a href="/pkgbase/{{ pkgname }}/merge/">
{{ "Merge Package" | tr }}
</a>
</li>
<li>
<form action="/pkgbase/{{ pkgname }}/disown/" method="post">
<input type="submit"
class="button text-button"
name="do_Disown"
value="{{ 'Disown Package' | tr }}"
/>
</form>
</li>
{% endif %}
</ul>
</div>
</div>

View file

@ -0,0 +1,54 @@
<div id="pkgdeps" class="listing">
<h3>Dependencies ({{ dependencies.count() }})</h3>
<ul id="pkgdepslist">
{% for dep in dependencies.all() %}
<li>
{% set broken = not dep.is_package() %}
{% if broken %}
<span class="broken">
{% else %}
<a href="{{ dep.DepName | pkgname_link }}">
{% endif %}
{{ dep.DepName }}
{% if broken %}
</span>
{% else %}
</a>
{% endif %}
{{ dep.Package | provides_list(dep.DepName) | safe }}
{% set extra = dep | dep_extra %}
{% if extra %}
<em>{{ dep | dep_extra_desc }}</em>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<div id="pkgreqs" class="listing">
<h3>Required by ({{ required_by.count() }})</h3>
<ul id="pkgreqslist">
{% for depender in required_by.all() %}
<li>
<a href="{{ depender.Package | package_link }}">
{{ depender.Package.Name }}
</a>
<em>{{ depender | dep_extra }}</em>
</li>
{% endfor %}
</ul>
</div>
<div id="pkgfiles" class="listing">
<h3>Sources ({{ sources.count() }})</h3>
</div>
<div>
<ul id="pkgsrcslist">
{% for source in sources.all() %}
<li>
<a href="{{ source.Source }}">{{ source.Source }}</a>
</li>
{% endfor %}
</ul>
</div>

View file

@ -1,95 +0,0 @@
{% extends "partials/layout.html" %}
{% block pageContent %}
{% include "partials/packages/search.html" %}
<div id="pkgdetails" class="box">
<h2>Package Details: {{ pkgbase.Name }}</h2>
{% set result = pkgbase %}
{% set pkgname = "result.Name" %}
{% include "partials/packages/package_actions.html" %}
<table id="pkginfo">
<tr>
<th>{{ "Git Clone URL" | tr }}:</th>
<td>
<a class="copy" href="{{ git_clone_uri_anon | format(pkgbase.Name) }}">{{ git_clone_uri_anon | format(pkgbase.Name) }}</a> ({{ "read-only" | tr }}, {{ "click to copy" | tr }})
{% if is_maintainer %}
<br /> <a class="copy" href="{{ git_clone_uri_priv | format(pkgbase.Name) }}">{{ git_clone_uri_priv | format(pkgbase.Name) }}</a> ({{ "click to copy" | tr }})
{% endif %}
</td>
</tr>
<tr>
<th>{{ "Keywords" | tr }}:</th>
{% if is_maintainer %}
<td>
<form method="post" action="/pkgbase/{{ pkgbase.Name }}/">
<div>
<input type="hidden" name="action" value="do_SetKeywords" />
<input type="text" name="keywords" value="{{ pkgbase.keywords | join(' ', attribute='Keyword') }}"/>
<input type="submit" value="Update"/>
</div>
</form>
</td>
{% else %}
<td>
{% for item in pkgbase.keywords %}
<a class="keyword" href="/packages/?K={{ item.Keyword }}&amp;SB=p">{{ item.Keyword }}</a>
{% endfor %}
</td>
{% endif %}
</tr>
<tr>
<th>{{ "Submitter" | tr }}:</th>
<td>{{ pkgbase.Submitter.Username | default("None") }}</td>
</tr>
<tr>
<th>{{ "Maintainer" | tr }}:</th>
<td>{{ pkgbase.Maintainer.Username | default("None") }}</td>
</tr>
<tr>
<th>{{ "Last Packager" | tr }}:</th>
<td>{{ pkgbase.Packager.Username | default("None") }}</td>
</tr>
<tr>
<th>{{ "Votes" | tr }}:</th>
<td>{{ pkgbase.NumVotes }}</td>
</tr>
<tr>
<th>{{ "Popularity" | tr }}:</th>
<td>{{ '%0.2f' % pkgbase.Popularity | float }}</td>
</tr>
<tr>
{% set submitted = pkgbase.SubmittedTS | dt | as_timezone(timezone) %}
<th>{{ "First Submitted" | tr }}:</th>
<td>{{ "%s" | tr | format(submitted.strftime("%Y-%m-%d %H:%M")) }}</td>
</tr>
<tr>
<th>{{ "Last Updated" | tr }}:</th>
{% set updated = pkgbase.ModifiedTS | dt | as_timezone(timezone) %}
<td>{{ "%s" | tr | format(updated.strftime("%Y-%m-%d %H:%M")) }}</td>
</tr>
</table>
<div id="metadata">
<div id="pkgs" class="listing">
<!-- This needs to be replaced with the real implementation. -->
<h3>Packages ({{ packages_count }})</h3>
<ul>
{% for result in packages %}
<li>
<a href="/packages/{{ result.Name }}/"
title="{{ 'View packages details for' | tr }} {{ result.Name }}">
{{ result.Name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% set pkgname = result.Name %}
{% set pkgbase_id = result.ID %}
{% set comments = comments %}
{% include "partials/packages/comments.html" %}
{% endblock %}

View file

@ -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

View file

@ -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

View file

@ -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

15
test/test_templates.py Normal file
View file

@ -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

6
web/html/js/copy.js Normal file
View file

@ -0,0 +1,6 @@
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('.copy').addEventListener('click', function(e) {
e.preventDefault();
navigator.clipboard.writeText(event.target.text);
});
});