...
 
Commits (5)
......@@ -323,3 +323,16 @@ summary {
summary::-webkit-details-marker {
display: none;
}
form#team-search-form div.input-group {
width: 100%;
}
form#team-search-form .twitter-typeahead {
padding-top: 3px;
width: 100%;
}
form#team-search-form .twitter-typeahead > input {
width: 100% !important;
}
......@@ -71,6 +71,43 @@ $(function() {
}
);
var teams = new Bloodhound({
datumTokenizer: function(data) {
Bloodhound.tokenizers.obj.whitespace(data.slug);
},
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: '/api/teams/search/autocomplete?q=%QUERY',
wildcard: '%QUERY',
rateLimitWait: 500,
transform: function(r) {
return r['teams'];
}
}
});
$('.team-completion').typeahead({
hint: false,
highlight: true,
minLength: 2
},
{
name: 'teams',
displayKey: 'slug',
source: teams,
templates: {
suggestion: function (teams) {
return '<p>' + teams.name + '</p>';
}
}
}
).bind('typeahead:render', function(e) {
var options = $('div.tt-dataset-teams p');
if(options.length == 1){
options.first().addClass('tt-cursor');
}
});
var subscribe_url = $('#subscribe-button').data('url');
var unsubscribe_url = $('#unsubscribe-button').data('url')
var toggle_subscription_buttons = function() {
......
......@@ -14,6 +14,8 @@
{% endblock %}
{% block content %}
{% include 'core/profile-messages.html' %}
{% include 'core/team-search-form.html' with autofocus=1 %}
<ul class="list-group m-t-1">
{% for team in team_list %}
<li class="list-group-item">
......
{% spaceless %}
<div class="row">
<form class="form-inline" id="team-search-form" action="{% url 'dtracker-team-search' %}" method="GET">
<div class="col-lg-6 col-lg-offset-3">
<div class="input-group">
<input type="text" class="form-control team-completion" name="query" id='team-search-input' placeholder="Jump to team..."{% if autofocus %} autofocus='autofocus'{% endif %}>
<div class="input-group-btn">
<button type="submit" class="btn btn-default">Go</button>
</div>
</div>
</div>
</form>
</div>
{% endspaceless %}
......@@ -429,6 +429,70 @@ class PackageAutocompleteViewTest(TestCase):
self.assertEqual(response.status_code, 404)
class TeamAutocompleteViewTest(TestCase):
def setUp(self):
Team.objects.create_with_slug(name='Debian Go Packaging Team')
Team.objects.create_with_slug(name='Debian HPC')
Team.objects.create_with_slug(name='Java Team')
def test_team_autocomplete(self):
"""
Tests the autocomplete functionality when the client asks for teams
"""
response = self.client.get(reverse('dtracker-api-team-autocomplete'),
{'q': 'd'})
response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response['query_string'], 'd')
self.assertEqual(len(response['teams']), 2)
names = [team['name'] for team in response['teams']]
self.assertIn('Debian Go Packaging Team', names)
self.assertIn('Debian HPC', names)
response = self.client.get(reverse('dtracker-api-team-autocomplete'),
{'q': 'team'})
response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response['query_string'], 'team')
self.assertEqual(len(response['teams']), 2)
names = [team['name'] for team in response['teams']]
self.assertIn('Debian Go Packaging Team', names)
self.assertIn('Java Team', names)
def test_team_autocomplete_with_slug(self):
"""
Tests the autocomplete functionality when the client asks for teams
by their slugs
"""
response = self.client.get(reverse('dtracker-api-team-autocomplete'),
{'q': '-team'})
response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response['query_string'], '-team')
self.assertEqual(len(response['teams']), 2)
names = [team['name'] for team in response['teams']]
self.assertIn('Debian Go Packaging Team', names)
self.assertIn('Java Team', names)
def test_query_does_not_match_teams(self):
"""
Tests the autocomplete functionality when the client's query
does not match any team
"""
response = self.client.get(reverse('dtracker-api-team-autocomplete'),
{'q': 'z'})
response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response['query_string'], 'z')
self.assertEqual(len(response['teams']), 0)
def test_no_query_given(self):
"""
Tests the autocomplete when there is no query parameter given.
"""
response = self.client.get(reverse('dtracker-api-package-autocomplete'))
self.assertEqual(response.status_code, 404)
class ActionItemJsonViewTest(TestCase):
"""
Tests for the :class:`distro_tracker.core.views.ActionItemJsonView`.
......@@ -1445,6 +1509,99 @@ class TeamListViewTest(UserAuthMixin, TestCase):
)
class TeamSearchViewTest(TestCase):
"""
Tests for the
:class:`distro_tracker.core.views.TeamSearchView`.
"""
def setUp(self):
self.team_1 = Team.objects.create_with_slug(name='Debian HPC')
self.team_2 = Team.objects.create_with_slug(
name='Debian Go Packaging Team')
def assert_not_found_team(self, response, query="new-team"):
self.assertRedirects(response, reverse('dtracker-team-list'))
message = list(response.context['messages'])[0]
self.assertEqual(message.level_tag, "danger")
self.assertIn(
("No team could be identified with the query string %s" % query),
message.message
)
def test_search_for_existing_team_by_slug(self):
"""
Tests the search for existing team by slug
"""
response = self.client.get(reverse('dtracker-team-search'), {
'query': self.team_1.slug
})
self.assertRedirects(response, self.team_1.get_absolute_url())
def test_search_for_existing_team_by_name(self):
"""
Tests the search for existing team by name
"""
response = self.client.get(reverse('dtracker-team-search'), {
'query': self.team_1.name
})
self.assertRedirects(response, self.team_1.get_absolute_url())
def test_search_for_team_by_partial_name_or_slug(self):
"""
Tests the search for team by partial name or slug
when it identify a single matching team
"""
response = self.client.get(reverse('dtracker-team-search'), {
'query': 'Debian H'
})
self.assertRedirects(response, self.team_1.get_absolute_url())
response = self.client.get(reverse('dtracker-team-search'), {
'query': 'debian-h'
})
self.assertRedirects(response, self.team_1.get_absolute_url())
def test_fail_to_search_for_team_by_partial_name_or_slug(self):
"""
Tests the search for team by partial name or slug
when it identifies multiple matching teams
"""
response = self.client.get(reverse('dtracker-team-search'), {
'query': 'Debian'
}, follow=True)
self.assert_not_found_team(response, 'Debian')
response = self.client.get(reverse('dtracker-team-search'), {
'query': 'debian-'
}, follow=True)
self.assert_not_found_team(response, 'debian-')
def test_team_does_not_exist(self):
"""
Tests the team search when the given team does not exist.
"""
slug = 'does-not-exist'
response = self.client.get(
reverse('dtracker-team-search'), {'query': slug}, follow=True)
self.assert_not_found_team(response, slug)
def test_case_insensitive_team_search(self):
"""
Tests that team search is case insensitive
"""
slug = 'debian-HPC'
response = self.client.get(
reverse('dtracker-team-search'), {'query': slug}, follow=True)
self.assertRedirects(response, self.team_1.get_absolute_url())
def test_search_without_query_parameter(self):
"""
Tests error response when the query parameter is missing
"""
response = self.client.get(reverse('dtracker-package-search'))
self.assertEqual(response.status_code, 404)
class SetMuteTeamViewTest(UserAuthMixin, TestCase):
"""
Tests for the
......
......@@ -668,6 +668,64 @@ class EditMembershipView(LoginRequiredMixin, ListView):
return context
class TeamAutocompleteView(View):
"""
A view which responds to team auto-complete queries.
Renders a JSON list of team names matching the given query, meaning
their name contains the given query parameter.
"""
@method_decorator(cache_control(must_revalidate=True, max_age=3600))
def get(self, request):
if 'q' not in request.GET:
raise Http404
query_string = request.GET['q']
filtered = Team.objects.filter(
Q(name__icontains=query_string) | Q(slug__icontains=query_string))
# Extract only the name and slug of the team.
filtered = filtered.values('name', 'slug')
# Limit the number of teams returned from the autocomplete
AUTOCOMPLETE_ITEMS_LIMIT = 100
filtered = filtered[:AUTOCOMPLETE_ITEMS_LIMIT]
return render_to_json_response({
'query_string': query_string,
'teams': list(filtered)
})
class TeamSearchView(View):
"""
A view which responds to team search queries.
"""
def get(self, request):
if 'query' not in self.request.GET:
raise Http404
query = self.request.GET.get('query')
team = self.find_team(query)
if team is not None:
return redirect(team)
else:
messages.error(
request,
("No team could be identified with the query string %s" % query)
)
return redirect(reverse('dtracker-team-list'))
def find_team(self, query):
if Team.objects.filter(slug=query).exists():
return Team.objects.filter(slug=query).first()
elif Team.objects.filter(name=query).exists():
return Team.objects.filter(name=query).first()
elif Team.objects.filter(
Q(name__icontains=query) | Q(slug__icontains=query)
).count() == 1:
return Team.objects.filter(
Q(name__icontains=query) | Q(slug__icontains=query)).first()
return None
class IndexView(TemplateView):
template_name = 'core/index.html'
......
......@@ -60,6 +60,8 @@ from distro_tracker.core.views import (
PackageAutocompleteView,
PackageNews,
PackageSearchView,
TeamAutocompleteView,
TeamSearchView,
RemovePackageFromTeamView,
RemoveTeamMember,
SetMembershipKeywords,
......@@ -100,6 +102,8 @@ urlpatterns = [
name='dtracker-api-action-item'),
url(r'^api/keywords/$', KeywordsView.as_view(),
name='dtracker-api-keywords'),
url(r'^api/teams/search/autocomplete$', TeamAutocompleteView.as_view(),
name='dtracker-api-team-autocomplete'),
url(r'^admin/', admin.site.urls),
......@@ -211,6 +215,8 @@ urlpatterns = [
url(r'^teams/\+confirm/(?P<confirmation_key>.+)/$',
ConfirmMembershipView.as_view(),
name='dtracker-team-confirm-membership'),
url(r'^team/\+search$', TeamSearchView.as_view(),
name='dtracker-team-search'),
url(r'^teams/(?P<slug>.+)/\+mute/$', SetMuteTeamView.as_view(action='mute'),
name='dtracker-team-mute'),
url(r'^teams/(?P<slug>.+)/\+unmute/$',
......
{% spaceless %}
<a href="https://wiki.debian.org/AppStream">AppStream</a> found metadata issues for packages:
<ul>
{% for bin_package, info in item.extra_data.items %}
<li>
{{ bin_package }}:
<a href="{{ info.url }}">
{% if info.errors %}
<span>{{ info.errors }} error{{ info.errors|pluralize }}</span>
{% if info.warnings %}and{% endif %}
{% endif %}
{% if info.warnings %}
<span>{{ info.warnings }} warning{{ info.warnings|pluralize }}</span>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
You should get rid of them to provide more metadata about this software.
{% endspaceless %}
This diff is collapsed.
......@@ -40,6 +40,7 @@ from distro_tracker.core.models import (
PackageBugStats,
PackageData,
PackageName,
Repository,
SourcePackageDeps,
SourcePackageName
)
......@@ -50,6 +51,7 @@ from distro_tracker.core.utils.http import (
get_resource_text
)
from distro_tracker.core.utils.misc import get_data_checksum
from distro_tracker.core.utils import get_or_none
from distro_tracker.core.utils.packages import (
html_package_list,
package_hashdir,
......@@ -693,6 +695,220 @@ class UpdateLintianStatsTask(BaseTask):
LintianStats.objects.bulk_create(stats)
class UpdateAppStreamStatsTask(BaseTask):
"""
Updates packages' AppStream issue hints data.
"""
ACTION_ITEM_TYPE_NAME = 'appstream-issue-hints'
ITEM_DESCRIPTION = 'AppStream hints: {report}'
ITEM_FULL_DESCRIPTION_TEMPLATE = 'debian/appstream-action-item.html'
def __init__(self, force_update=False, *args, **kwargs):
super(UpdateAppStreamStatsTask, self).__init__(*args, **kwargs)
self.force_update = force_update
self.appstream_action_item_type = \
ActionItemType.objects.create_or_update(
type_name=self.ACTION_ITEM_TYPE_NAME,
full_description_template=self.ITEM_FULL_DESCRIPTION_TEMPLATE)
self._tag_severities = {}
def set_parameters(self, parameters):
if 'force_update' in parameters:
self.force_update = parameters['force_update']
def _load_tag_severities(self):
url = 'https://appstream.debian.org/hints/asgen-hints.json'
json_data = get_resource_text(url, force_update=True)
data = json.loads(json_data)
for tag, info in data.items():
self._tag_severities[tag] = info['severity']
def _load_appstream_hint_stats(self, section, arch, all_stats={}):
url = 'https://appstream.debian.org/hints/sid/{}/Hints-{}.json.gz' \
.format(section, arch)
hints_json = get_resource_text(url, force_update=self.force_update)
hints = json.loads(hints_json)
for hint in hints:
pkid = hint['package']
parts = pkid.split('/')
package_name = parts[0]
# get the source package for this binary package name
src_pkgname = None
if SourcePackageName.objects.exists_with_name(package_name):
package = SourcePackageName.objects.get(name=package_name)
src_pkgname = package.name
elif BinaryPackageName.objects.exists_with_name(package_name):
bin_package = BinaryPackageName.objects.get(name=package_name)
package = bin_package.main_source_package_name
src_pkgname = package.name
else:
src_pkgname = package_name
if src_pkgname not in all_stats:
all_stats[src_pkgname] = {}
if package_name not in all_stats[src_pkgname]:
all_stats[src_pkgname][package_name] = {}
for cid, h in hint['hints'].items():
for e in h:
severity = self._tag_severities[e['tag']]
sevkey = "errors"
if severity == "warning":
sevkey = "warnings"
elif severity == "info":
sevkey = "infos"
if sevkey not in all_stats[src_pkgname][package_name]:
all_stats[src_pkgname][package_name][sevkey] = 1
else:
all_stats[src_pkgname][package_name][sevkey] += 1
return all_stats
def _get_appstream_url(self, package, bin_pkgname):
"""
Returns the AppStream URL for the given PackageName in :package.
"""
src_package = get_or_none(SourcePackageName, pk=package.pk)
if not src_package:
return '#'
if not src_package.main_version:
return '#'
component = 'main'
main_entry = src_package.main_entry
if main_entry:
component = main_entry.component
if not component:
component = 'main'
return (
'https://appstream.debian.org/sid/{}/issues/{}.html'
.format(component, bin_pkgname)
)
def _create_final_stats_report(self, package, package_stats):
"""
Returns a transformed statistics report to be stored in the database.
"""
as_report = package_stats.copy()
for bin_package in list(as_report.keys()):
# we currently don't want to display info-type hints
as_report[bin_package].pop('infos', None)
if as_report[bin_package]:
as_report[bin_package]['url'] = \
self._get_appstream_url(package, bin_package)
else:
as_report.pop(bin_package)
return as_report
def update_action_item(self, package, package_stats):
"""
Updates the :class:`ActionItem` for the given package based on the
AppStream hint statistics given in ``package_stats``.
If the package has errors or warnings an
:class:`ActionItem` is created.
"""
total_warnings = 0
total_errors = 0
for bin_pkgname, info in package_stats.items():
total_warnings += info.get('warnings', 0)
total_errors += info.get('errors', 0)
# Get the old action item for this warning, if it exists.
appstream_action_item = package.get_action_item_for_type(
self.appstream_action_item_type.type_name)
if not total_warnings and not total_errors:
if appstream_action_item:
# If the item previously existed, delete it now since there
# are no longer any warnings/errors.
appstream_action_item.delete()
return
# The item didn't previously have an action item: create it now
if appstream_action_item is None:
appstream_action_item = ActionItem(
package=package,
item_type=self.appstream_action_item_type)
as_report = self._create_final_stats_report(package, package_stats)
if appstream_action_item.extra_data:
old_extra_data = appstream_action_item.extra_data
if old_extra_data == as_report:
# No need to update
return
appstream_action_item.extra_data = as_report
if total_errors and total_warnings:
short_report = '{} error{} and {} warning{}'.format(
total_errors,
's' if total_errors > 1 else '',
total_warnings,
's' if total_warnings > 1 else '')
elif total_errors:
short_report = '{} error{}'.format(
total_errors,
's' if total_errors > 1 else '')
elif total_warnings:
short_report = '{} warning{}'.format(
total_warnings,
's' if total_warnings > 1 else '')
appstream_action_item.short_description = \
self.ITEM_DESCRIPTION.format(report=short_report)
# If there are errors make the item a high severity issue
if total_errors:
appstream_action_item.severity = ActionItem.SEVERITY_HIGH
appstream_action_item.save()
def execute(self):
self._load_tag_severities()
all_stats = {}
repository = Repository.objects.get(default=True)
arch = "amd64"
for component in repository.components:
self._load_appstream_hint_stats(component, arch, all_stats)
if not all_stats:
return
with transaction.atomic():
# Delete obsolete data
PackageData.objects.filter(key='appstream').delete()
packages = PackageName.objects.filter(name__in=all_stats.keys())
packages.prefetch_related('action_items')
stats = []
for package in packages:
package_stats = all_stats[package.name]
stats.append(
PackageData(
package=package,
key='appstream',
value=package_stats
)
)
# Create an ActionItem if there are errors or warnings
self.update_action_item(package, package_stats)
PackageData.objects.bulk_create(stats)
# Remove action items for packages which no longer have associated
# AppStream hints.
ActionItem.objects.delete_obsolete_items(
[self.appstream_action_item_type], all_stats.keys())
class UpdateTransitionsTask(BaseTask):
REJECT_LIST_URL = 'https://ftp-master.debian.org/transitions.yaml'
PACKAGE_TRANSITION_LIST_URL = (
......