# Copyright 2013 The Distro Tracker Developers
# See the COPYRIGHT file at the top-level directory of this distribution and
# at https://deb.li/DTAuthors
#
# This file is part of Distro Tracker. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution and at https://deb.li/DTLicense. No part of Distro Tracker,
# including this file, may be copied, modified, propagated, or distributed
# except according to the terms contained in the LICENSE file.
"""Views for the :mod:`distro_tracker.core` app."""
import importlib
from urllib.parse import quote
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django.views.generic import DeleteView, ListView, TemplateView, View
from django.views.generic.detail import DetailView
from django.views.generic.edit import FormView, UpdateView
from distro_tracker import vendor
from distro_tracker.accounts.models import UserEmail
from distro_tracker.accounts.views import LoginRequiredMixin
from distro_tracker.core.forms import AddTeamMemberForm, CreateTeamForm
from distro_tracker.core.models import (
ActionItem,
BinaryPackageName,
Keyword,
MembershipConfirmation,
News,
NewsRenderer,
PackageName,
PseudoPackageName,
SourcePackageName,
Team,
TeamMembership,
get_web_package
)
from distro_tracker.core.package_tables import create_table
from distro_tracker.core.panels import get_panels_for_package
from distro_tracker.core.utils import (
distro_tracker_render_to_string,
get_or_none,
render_to_json_response
)
from distro_tracker.core.utils.http import (
safe_redirect
)
[docs]def package_page(request, package_name):
"""
Renders the package page.
"""
package = get_web_package(package_name)
if not package:
raise Http404
if package.get_absolute_url() not in (quote(request.path), request.path):
return redirect(package)
is_subscribed = False
if request.user.is_authenticated:
# Check if the user is subscribed to the package
is_subscribed = request.user.is_subscribed_to(package)
return render(request, 'core/package.html', {
'package': package,
'panels': get_panels_for_package(package, request),
'is_subscribed': is_subscribed,
})
[docs]def package_page_redirect(request, package_name):
"""
Catch-all view which tries to redirect the user to a package page
"""
return redirect('dtracker-package-page', package_name=package_name)
[docs]def legacy_package_url_redirect(request, package_hash, package_name):
"""
Redirects access to URLs in the form of the "old" PTS package URLs to the
new package URLs.
.. note::
The "old" package URL is: /<hash>/<package_name>.html
"""
return redirect('dtracker-package-page', package_name=package_name,
permanent=True)
[docs]class PackageSearchView(View):
"""
A view which responds to package search queries.
"""
[docs] def get(self, request):
if 'package_name' not in self.request.GET:
raise Http404
package_name = self.request.GET.get('package_name').lower().strip()
package = get_web_package(package_name)
if package is not None:
return redirect(package)
else:
return render(request, 'core/package_search.html', {
'package_name': package_name
})
[docs]class OpenSearchDescription(View):
"""
Return the open search description XML document allowing
browsers to launch searches on the website.
"""
[docs] def get(self, request):
return render(request, 'core/opensearch-description.xml', {
'search_uri': request.build_absolute_uri(
reverse('dtracker-package-search')),
'autocomplete_uri': request.build_absolute_uri(
reverse('dtracker-api-package-autocomplete')),
'favicon_uri': request.build_absolute_uri(
reverse('dtracker-favicon')),
}, content_type='application/opensearchdescription+xml')
[docs]class PackageAutocompleteView(View):
"""
A view which responds to package auto-complete queries.
Renders a JSON list of package names matching the given query, meaning
their name starts with the given query parameter.
"""
[docs] @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']
package_type = request.GET.get('package_type', None)
MANAGERS = {
'pseudo': PseudoPackageName.objects,
'source': SourcePackageName.objects,
'binary': BinaryPackageName.objects.exclude(source=True),
}
# When no package type is given include both pseudo and source packages
filtered = MANAGERS.get(
package_type,
PackageName.objects.filter(Q(source=True) | Q(pseudo=True))
)
filtered = filtered.filter(name__icontains=query_string)
# Extract only the name of the package.
filtered = filtered.values('name')
# Limit the number of packages returned from the autocomplete
AUTOCOMPLETE_ITEMS_LIMIT = 100
filtered = filtered[:AUTOCOMPLETE_ITEMS_LIMIT]
return render_to_json_response([query_string,
[package['name']
for package in filtered]])
[docs]def news_page(request, news_id, slug=''):
"""
Displays a news item's full content.
"""
news = get_object_or_404(News, pk=news_id)
renderer_class = \
NewsRenderer.get_renderer_for_content_type(news.content_type)
if renderer_class is None:
renderer_class = \
NewsRenderer.get_renderer_for_content_type('text/plain')
renderer = renderer_class(news)
return render(request, 'core/news.html', {
'news_renderer': renderer,
'news': news,
})
[docs]class PackageNews(ListView):
"""
A view which lists all the news of a package.
"""
_DEFAULT_NEWS_LIMIT = 30
NEWS_LIMIT = getattr(
settings,
'DISTRO_TRACKER_NEWS_PANEL_LIMIT',
_DEFAULT_NEWS_LIMIT)
paginate_by = NEWS_LIMIT
template_name = 'core/package_news.html'
context_object_name = 'news'
[docs] def get(self, request, package_name):
self.package = get_object_or_404(PackageName, name=package_name)
return super(PackageNews, self).get(request, package_name)
[docs] def get_queryset(self):
news = self.package.news_set.prefetch_related('signed_by')
return news.order_by('-datetime_created')
[docs] def get_context_data(self, *args, **kwargs):
context = super(PackageNews, self).get_context_data(*args, **kwargs)
context['package'] = self.package
return context
[docs]class ActionItemJsonView(View):
"""
View renders a :class:`distro_tracker.core.models.ActionItem` in a JSON
response.
"""
[docs] @method_decorator(cache_control(must_revalidate=True, max_age=3600))
def get(self, request, item_pk):
item = get_object_or_404(ActionItem, pk=item_pk)
return render_to_json_response(item.to_dict())
[docs]class ActionItemView(View):
"""
View renders a :class:`distro_tracker.core.models.ActionItem` in an HTML
response.
"""
[docs] def get(self, request, item_pk):
item = get_object_or_404(ActionItem, pk=item_pk)
return render(request, 'core/action-item.html', {
'item': item,
})
[docs]class KeywordsView(View):
[docs] def get(self, request):
return render_to_json_response([
keyword.name for keyword in Keyword.objects.order_by('name').all()
])
[docs]class CreateTeamView(LoginRequiredMixin, FormView):
model = Team
template_name = 'core/team-create.html'
form_class = CreateTeamForm
[docs]class TeamDetailsView(DetailView):
model = Team
template_name = 'core/team.html'
table_limit = 20
def _create_tables(self):
result, implemented = vendor.call(
'get_tables_for_team_page', self.object, self.table_limit)
if implemented:
return result
return [
create_table(
slug='general', scope=self.object, limit=self.table_limit),
create_table(
slug='general', scope=self.object,
limit=self.table_limit, tag='tag:bugs'
),
]
[docs] def get_context_data(self, **kwargs):
context = super(TeamDetailsView, self).get_context_data(**kwargs)
context['tables'] = self._create_tables()
if self.request.user.is_authenticated:
context['user_member_of_team'] = self.object.user_is_member(
self.request.user)
return context
[docs]class DeleteTeamView(DeleteView):
model = Team
success_url = reverse_lazy('dtracker-team-deleted')
template_name = 'core/team-confirm-delete.html'
[docs] def get_object(self, *args, **kwargs):
"""
Makes sure that the team instance to be deleted is owned by the
logged in user.
"""
instance = super(DeleteTeamView, self).get_object(*args, **kwargs)
if instance.owner != self.request.user:
raise PermissionDenied
return instance
[docs]class UpdateTeamView(UpdateView):
model = Team
form_class = CreateTeamForm
template_name = 'core/team-update.html'
[docs] def get_object(self, *args, **kwargs):
"""
Makes sure that the team instance to be updated is owned by the
logged in user.
"""
instance = super(UpdateTeamView, self).get_object(*args, **kwargs)
if instance.owner != self.request.user:
raise PermissionDenied
# Set current maintainer email to the email field in the form
if instance.maintainer_email is not None:
self.initial.update(
{'maintainer_email': instance.maintainer_email.email})
return instance
[docs]class AddPackageToTeamView(LoginRequiredMixin, View):
[docs] def post(self, request, slug):
"""
Adds the package given in the POST parameters to the team.
If the currently logged in user is not a team member, a
403 Forbidden response is given.
Once the package is added, the user is redirected back to the team's
page.
"""
team = get_object_or_404(Team, slug=slug)
if not team.user_is_member(request.user):
# Only team members are allowed to modify the packages followed by
# the team.
raise PermissionDenied
if 'package' in request.POST:
package_name = request.POST['package']
package = get_or_none(PackageName, name=package_name)
if package:
team.packages.add(package)
return redirect('dtracker-team-manage', slug=team.slug)
[docs]class RemovePackageFromTeamView(LoginRequiredMixin, View):
[docs] def get_team(self, slug):
team = get_object_or_404(Team, slug=slug)
if not team.user_is_member(self.request.user):
# Only team members are allowed to modify the packages followed by
# the team.
raise PermissionDenied
return team
[docs] def post(self, request, slug):
"""
Removes the package given in the POST parameters from the team.
If the currently logged in user is not a team member, a
403 Forbidden response is given.
Once the package is removed, the user is redirected back to the team's
page.
"""
self.request = request
team = self.get_team(slug)
if 'package' in request.POST:
package_name = request.POST['package']
package = get_or_none(PackageName, name=package_name)
if package:
team.packages.remove(package)
return redirect('dtracker-team-manage', slug=team.slug)
[docs]class JoinTeamView(LoginRequiredMixin, View):
"""
Lets logged in users join a public team.
After a user has been added to the team, redirect them back to the
team page.
"""
template_name = 'core/team-join-choose-email.html'
[docs] def get(self, request, slug):
team = get_object_or_404(Team, slug=slug)
return render(request, self.template_name, {
'team': team,
})
[docs] def post(self, request, slug):
team = get_object_or_404(Team, slug=slug)
if not team.public:
# Only public teams can be joined directly by users
raise PermissionDenied
if 'email' in request.POST:
emails = request.POST.getlist('email')
# Make sure the user owns the emails
user_emails = [e.email for e in request.user.emails.all()]
for email in emails:
if email not in user_emails:
raise PermissionDenied
# Add the given emails to the team
team.add_members(self.request.user.emails.filter(email__in=emails))
return redirect(team)
[docs]class LeaveTeamView(LoginRequiredMixin, View):
"""
Lets logged in users leave teams they are a part of.
"""
[docs] def get(self, request, slug):
team = get_object_or_404(Team, slug=slug)
return redirect(team)
[docs] def post(self, request, slug):
team = get_object_or_404(Team, slug=slug)
if not team.user_is_member(request.user):
# Leaving a team when you're not already a part of it makes no
# sense
raise PermissionDenied
# Remove all the user's emails from the team
team.remove_members(
UserEmail.objects.filter(pk__in=request.user.emails.all()))
return redirect(team)
[docs]class ManageTeam(LoginRequiredMixin, ListView):
"""
Provides the team owner a method to manually add/remove members of the
team.
"""
template_name = 'core/team-manage.html'
paginate_by = 20
context_object_name = 'members_list'
[docs] def get_queryset(self):
return self.team.members.all().order_by('email')
[docs] def get_context_data(self, *args, **kwargs):
context = super(ManageTeam, self).get_context_data(*args, **kwargs)
context['team'] = self.team
context['form'] = AddTeamMemberForm()
return context
[docs] def get(self, request, slug):
self.team = get_object_or_404(Team, slug=slug)
if not self.team.user_is_member(self.request.user):
# Only team members are allowed to access the page
raise PermissionDenied
return super(ManageTeam, self).get(request, slug)
[docs]class RemoveTeamMember(LoginRequiredMixin, View):
[docs] def post(self, request, slug):
self.team = get_object_or_404(Team, slug=slug)
if self.team.owner != request.user:
raise PermissionDenied
if 'email' in request.POST:
emails = request.POST.getlist('email')
self.team.remove_members(UserEmail.objects.filter(email__in=emails))
return redirect('dtracker-team-manage', slug=self.team.slug)
[docs]class AddTeamMember(LoginRequiredMixin, View):
[docs] def post(self, request, slug):
self.team = get_object_or_404(Team, slug=slug)
if self.team.owner != request.user:
raise PermissionDenied
response = redirect('dtracker-team-manage', slug=self.team.slug)
form = AddTeamMemberForm(request.POST)
if form.is_valid():
email = form.cleaned_data['email']
# Emails that do not exist should be created
user, _ = UserEmail.objects.get_or_create(email=email)
if self.team.members.filter(email=user).exists():
messages.error(
request,
("The email address %s is already a member "
"of the team" % email)
)
return response
# The membership is muted by default until the user confirms it
membership = self.team.add_members([user], muted=True)[0]
confirmation = MembershipConfirmation.objects.create_confirmation(
membership=membership)
send_mail(
'Team Membership Confirmation',
distro_tracker_render_to_string(
'core/email-team-membership-confirmation.txt',
{
'confirmation': confirmation,
'team': self.team,
}),
from_email=settings.DISTRO_TRACKER_CONTACT_EMAIL,
recipient_list=[email])
return response
[docs]class ConfirmMembershipView(View):
[docs] def get(self, request, confirmation_key):
confirmation = get_object_or_404(
MembershipConfirmation, confirmation_key=confirmation_key)
membership = confirmation.membership
membership.muted = False
membership.save()
# The confirmation is no longer necessary
confirmation.delete()
return redirect(membership.team)
[docs]class TeamListView(ListView):
queryset = Team.objects.filter(public=True).order_by('name')
paginate_by = 20
template_name = 'core/team-list.html'
context_object_name = 'team_list'
[docs]class SetMuteTeamView(LoginRequiredMixin, View):
"""
The view lets users mute or unmute a team membership or a particular
package in the membership.
"""
action = 'mute'
[docs] def post(self, request, slug):
team = get_object_or_404(Team, slug=slug)
if 'email' not in request.POST:
raise Http404
user = request.user
try:
email = user.emails.get(email=request.POST['email'])
except UserEmail.DoesNotExist:
raise PermissionDenied
try:
membership = team.team_membership_set.get(user_email=email)
except TeamMembership.DoesNotExist:
raise Http404
if self.action == 'mute':
mute = True
elif self.action == 'unmute':
mute = False
else:
raise Http404
if 'package' in request.POST:
package = get_object_or_404(PackageName,
name=request.POST['package'])
membership.set_mute_package(package, mute)
else:
membership.muted = mute
membership.save()
_next = request.POST.get('next', None)
return safe_redirect(_next, team)
[docs]class SetMembershipKeywords(LoginRequiredMixin, View):
"""
The view lets users set either default membership keywords or
package-specific keywords.
"""
[docs] def render_response(self):
if self.request.headers.get('accept') == 'application/json':
return render_to_json_response({
'status': 'ok',
})
_next = self.request.POST.get('next', None)
return safe_redirect(_next, self.team)
[docs] def post(self, request, slug):
self.request = request
self.team = get_object_or_404(Team, slug=slug)
user = request.user
mandatory_parameters = ('email', 'keyword[]')
if any(param not in request.POST for param in mandatory_parameters):
raise Http404
try:
email = user.emails.get(email=request.POST['email'])
except UserEmail.DoesNotExist:
raise PermissionDenied
try:
membership = self.team.team_membership_set.get(user_email=email)
except TeamMembership.DoesNotExist:
raise Http404
keywords = request.POST.getlist('keyword[]')
if 'package' in request.POST:
package = get_object_or_404(PackageName,
name=request.POST['package'])
membership.set_keywords(package, keywords)
else:
membership.set_membership_keywords(keywords)
return self.render_response()
[docs]class EditMembershipView(LoginRequiredMixin, ListView):
template_name = 'core/edit-team-membership.html'
paginate_by = 20
context_object_name = 'package_list'
[docs] def get(self, request, slug):
self.team = get_object_or_404(Team, slug=slug)
if 'email' not in request.GET:
raise Http404
user = request.user
try:
email = user.emails.get(email=request.GET['email'])
except UserEmail.DoesNotExist:
raise PermissionDenied
try:
self.membership = \
self.team.team_membership_set.get(user_email=email)
except TeamMembership.DoesNotExist:
raise Http404
return super(EditMembershipView, self).get(request, slug)
[docs] def get_queryset(self):
return self.team.packages.all().order_by('name')
[docs] def get_context_data(self, *args, **kwargs):
# Annotate the packages with a boolean indicating whether the package
# is muted by the user and a list of keywords specific for the package
# membership
for pkg in self.object_list:
pkg.is_muted = self.membership.is_muted(pkg)
pkg.keywords = sorted(
self.membership.get_keywords(pkg),
key=lambda x: x.name)
context = super(EditMembershipView, self).get_context_data(*args,
**kwargs)
context['membership'] = self.membership
return context
[docs]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.
"""
[docs] @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)
})
[docs]class TeamSearchView(View):
"""
A view which responds to team search queries.
"""
[docs] 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'))
[docs] 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
[docs]class TeamPackagesTableView(View):
"""
View renders a :class:`distro_tracker.core.package_tables.BasePackageTable`
in an HTML response.
"""
template_name = 'core/team-packages-table.html'
[docs] def get(self, request, slug, table_slug):
team = get_object_or_404(Team, slug=slug)
tag = request.GET.get('tag', None)
limit = request.GET.get('limit', None)
self.table = create_table(
slug=table_slug, scope=team, limit=limit, tag=tag)
return render(request, self.template_name, {
'table': self.table,
'team': team
})
[docs]class IndexView(TemplateView):
template_name = 'core/index.html'
[docs] def get_context_data(self, **kwargs):
context = super(IndexView, self).get_context_data(**kwargs)
links = []
for app in settings.INSTALLED_APPS:
try:
urlmodule = importlib.import_module(app + '.tracker_urls')
if hasattr(urlmodule, 'frontpagelinks'):
links += [(reverse(name), text)
for name, text in urlmodule.frontpagelinks]
except ImportError:
pass
context['application_links'] = links
return context