Coverage for distro_tracker/core/views.py: 97%
388 statements
« prev ^ index » next coverage.py v6.5.0, created at 2025-01-12 09:15 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2025-01-12 09:15 +0000
1# Copyright 2013 The Distro Tracker Developers
2# See the COPYRIGHT file at the top-level directory of this distribution and
3# at https://deb.li/DTAuthors
4#
5# This file is part of Distro Tracker. It is subject to the license terms
6# in the LICENSE file found in the top-level directory of this
7# distribution and at https://deb.li/DTLicense. No part of Distro Tracker,
8# including this file, may be copied, modified, propagated, or distributed
9# except according to the terms contained in the LICENSE file.
10"""Views for the :mod:`distro_tracker.core` app."""
11import importlib
12from urllib.parse import quote
14from django.conf import settings
15from django.contrib import messages
16from django.core.exceptions import PermissionDenied
17from django.core.mail import send_mail
18from django.db.models import Q
19from django.http import Http404
20from django.shortcuts import get_object_or_404, redirect, render
21from django.urls import reverse, reverse_lazy
22from django.utils.decorators import method_decorator
23from django.views.decorators.cache import cache_control
24from django.views.generic import DeleteView, ListView, TemplateView, View
25from django.views.generic.detail import DetailView
26from django.views.generic.edit import FormView, UpdateView
28from distro_tracker import vendor
29from distro_tracker.accounts.models import UserEmail
30from distro_tracker.accounts.views import LoginRequiredMixin
31from distro_tracker.core.forms import AddTeamMemberForm, CreateTeamForm
32from distro_tracker.core.models import (
33 ActionItem,
34 BinaryPackageName,
35 Keyword,
36 MembershipConfirmation,
37 News,
38 NewsRenderer,
39 PackageName,
40 PseudoPackageName,
41 SourcePackageName,
42 Team,
43 TeamMembership,
44 get_web_package
45)
46from distro_tracker.core.package_tables import create_table
47from distro_tracker.core.panels import get_panels_for_package
48from distro_tracker.core.utils import (
49 distro_tracker_render_to_string,
50 get_or_none,
51 render_to_json_response
52)
53from distro_tracker.core.utils.http import (
54 safe_redirect
55)
58def package_page(request, package_name):
59 """
60 Renders the package page.
61 """
62 package = get_web_package(package_name)
63 if not package:
64 raise Http404
65 if package.get_absolute_url() not in (quote(request.path), request.path):
66 return redirect(package)
68 is_subscribed = False
69 if request.user.is_authenticated:
70 # Check if the user is subscribed to the package
71 is_subscribed = request.user.is_subscribed_to(package)
73 return render(request, 'core/package.html', {
74 'package': package,
75 'panels': get_panels_for_package(package, request),
76 'is_subscribed': is_subscribed,
77 })
80def package_page_redirect(request, package_name):
81 """
82 Catch-all view which tries to redirect the user to a package page
83 """
84 return redirect('dtracker-package-page', package_name=package_name)
87def legacy_package_url_redirect(request, package_hash, package_name):
88 """
89 Redirects access to URLs in the form of the "old" PTS package URLs to the
90 new package URLs.
92 .. note::
93 The "old" package URL is: /<hash>/<package_name>.html
94 """
95 return redirect('dtracker-package-page', package_name=package_name,
96 permanent=True)
99class PackageSearchView(View):
100 """
101 A view which responds to package search queries.
102 """
103 def get(self, request):
104 if 'package_name' not in self.request.GET:
105 raise Http404
106 package_name = self.request.GET.get('package_name').lower().strip()
108 package = get_web_package(package_name)
109 if package is not None:
110 return redirect(package)
111 else:
112 return render(request, 'core/package_search.html', {
113 'package_name': package_name
114 })
117class OpenSearchDescription(View):
118 """
119 Return the open search description XML document allowing
120 browsers to launch searches on the website.
121 """
123 def get(self, request):
124 return render(request, 'core/opensearch-description.xml', {
125 'search_uri': request.build_absolute_uri(
126 reverse('dtracker-package-search')),
127 'autocomplete_uri': request.build_absolute_uri(
128 reverse('dtracker-api-package-autocomplete')),
129 'favicon_uri': request.build_absolute_uri(
130 reverse('dtracker-favicon')),
131 }, content_type='application/opensearchdescription+xml')
134class PackageAutocompleteView(View):
135 """
136 A view which responds to package auto-complete queries.
138 Renders a JSON list of package names matching the given query, meaning
139 their name starts with the given query parameter.
140 """
141 @method_decorator(cache_control(must_revalidate=True, max_age=3600))
142 def get(self, request):
143 if 'q' not in request.GET:
144 raise Http404
145 query_string = request.GET['q']
146 package_type = request.GET.get('package_type', None)
147 MANAGERS = {
148 'pseudo': PseudoPackageName.objects,
149 'source': SourcePackageName.objects,
150 'binary': BinaryPackageName.objects.exclude(source=True),
151 }
152 # When no package type is given include both pseudo and source packages
153 filtered = MANAGERS.get(
154 package_type,
155 PackageName.objects.filter(Q(source=True) | Q(pseudo=True))
156 )
157 filtered = filtered.filter(name__icontains=query_string)
158 # Extract only the name of the package.
159 filtered = filtered.values('name')
160 # Limit the number of packages returned from the autocomplete
161 AUTOCOMPLETE_ITEMS_LIMIT = 100
162 filtered = filtered[:AUTOCOMPLETE_ITEMS_LIMIT]
163 return render_to_json_response([query_string,
164 [package['name']
165 for package in filtered]])
168def news_page(request, news_id, slug=''):
169 """
170 Displays a news item's full content.
171 """
172 news = get_object_or_404(News, pk=news_id)
174 renderer_class = \
175 NewsRenderer.get_renderer_for_content_type(news.content_type)
176 if renderer_class is None: 176 ↛ 177line 176 didn't jump to line 177
177 renderer_class = \
178 NewsRenderer.get_renderer_for_content_type('text/plain')
180 renderer = renderer_class(news)
181 return render(request, 'core/news.html', {
182 'news_renderer': renderer,
183 'news': news,
184 })
187class PackageNews(ListView):
188 """
189 A view which lists all the news of a package.
190 """
191 _DEFAULT_NEWS_LIMIT = 30
192 NEWS_LIMIT = getattr(
193 settings,
194 'DISTRO_TRACKER_NEWS_PANEL_LIMIT',
195 _DEFAULT_NEWS_LIMIT)
197 paginate_by = NEWS_LIMIT
198 template_name = 'core/package_news.html'
199 context_object_name = 'news'
201 def get(self, request, package_name):
202 self.package = get_object_or_404(PackageName, name=package_name)
203 return super(PackageNews, self).get(request, package_name)
205 def get_queryset(self):
206 news = self.package.news_set.prefetch_related('signed_by')
207 return news.order_by('-datetime_created')
209 def get_context_data(self, *args, **kwargs):
210 context = super(PackageNews, self).get_context_data(*args, **kwargs)
211 context['package'] = self.package
212 return context
215class ActionItemJsonView(View):
216 """
217 View renders a :class:`distro_tracker.core.models.ActionItem` in a JSON
218 response.
219 """
220 @method_decorator(cache_control(must_revalidate=True, max_age=3600))
221 def get(self, request, item_pk):
222 item = get_object_or_404(ActionItem, pk=item_pk)
223 return render_to_json_response(item.to_dict())
226class ActionItemView(View):
227 """
228 View renders a :class:`distro_tracker.core.models.ActionItem` in an HTML
229 response.
230 """
231 def get(self, request, item_pk):
232 item = get_object_or_404(ActionItem, pk=item_pk)
233 return render(request, 'core/action-item.html', {
234 'item': item,
235 })
238def legacy_rss_redirect(request, package_hash, package_name):
239 """
240 Redirects old package RSS news feed URLs to the new ones.
241 """
242 return redirect(
243 'dtracker-package-rss-news-feed',
244 package_name=package_name,
245 permanent=True)
248class KeywordsView(View):
249 def get(self, request):
250 return render_to_json_response([
251 keyword.name for keyword in Keyword.objects.order_by('name').all()
252 ])
255class CreateTeamView(LoginRequiredMixin, FormView):
256 model = Team
257 template_name = 'core/team-create.html'
258 form_class = CreateTeamForm
260 def form_valid(self, form):
261 instance = form.save(commit=False)
262 user = self.request.user
263 instance.owner = user
264 instance.save()
265 instance.add_members(user.emails.filter(email=user.main_email))
267 return redirect(instance)
270class TeamDetailsView(DetailView):
271 model = Team
272 template_name = 'core/team.html'
273 table_limit = 20
275 def _create_tables(self):
276 result, implemented = vendor.call(
277 'get_tables_for_team_page', self.object, self.table_limit)
278 if implemented: 278 ↛ 279line 278 didn't jump to line 279, because the condition on line 278 was never true
279 return result
281 return [
282 create_table(
283 slug='general', scope=self.object, limit=self.table_limit),
284 create_table(
285 slug='general', scope=self.object,
286 limit=self.table_limit, tag='tag:bugs'
287 ),
288 ]
290 def get_context_data(self, **kwargs):
291 context = super(TeamDetailsView, self).get_context_data(**kwargs)
292 context['tables'] = self._create_tables()
293 if self.request.user.is_authenticated:
294 context['user_member_of_team'] = self.object.user_is_member(
295 self.request.user)
297 return context
300class DeleteTeamView(DeleteView):
301 model = Team
302 success_url = reverse_lazy('dtracker-team-deleted')
303 template_name = 'core/team-confirm-delete.html'
305 def get_object(self, *args, **kwargs):
306 """
307 Makes sure that the team instance to be deleted is owned by the
308 logged in user.
309 """
310 instance = super(DeleteTeamView, self).get_object(*args, **kwargs)
311 if instance.owner != self.request.user:
312 raise PermissionDenied
313 return instance
316class UpdateTeamView(UpdateView):
317 model = Team
318 form_class = CreateTeamForm
319 template_name = 'core/team-update.html'
321 def get_object(self, *args, **kwargs):
322 """
323 Makes sure that the team instance to be updated is owned by the
324 logged in user.
325 """
326 instance = super(UpdateTeamView, self).get_object(*args, **kwargs)
327 if instance.owner != self.request.user:
328 raise PermissionDenied
330 # Set current maintainer email to the email field in the form
331 if instance.maintainer_email is not None:
332 self.initial.update(
333 {'maintainer_email': instance.maintainer_email.email})
334 return instance
337class AddPackageToTeamView(LoginRequiredMixin, View):
338 def post(self, request, slug):
339 """
340 Adds the package given in the POST parameters to the team.
342 If the currently logged in user is not a team member, a
343 403 Forbidden response is given.
345 Once the package is added, the user is redirected back to the team's
346 page.
347 """
348 team = get_object_or_404(Team, slug=slug)
349 if not team.user_is_member(request.user):
350 # Only team members are allowed to modify the packages followed by
351 # the team.
352 raise PermissionDenied
354 if 'package' in request.POST: 354 ↛ 360line 354 didn't jump to line 360, because the condition on line 354 was never false
355 package_name = request.POST['package']
356 package = get_or_none(PackageName, name=package_name)
357 if package:
358 team.packages.add(package)
360 return redirect('dtracker-team-manage', slug=team.slug)
363class RemovePackageFromTeamView(LoginRequiredMixin, View):
364 def get_team(self, slug):
365 team = get_object_or_404(Team, slug=slug)
366 if not team.user_is_member(self.request.user):
367 # Only team members are allowed to modify the packages followed by
368 # the team.
369 raise PermissionDenied
371 return team
373 def post(self, request, slug):
374 """
375 Removes the package given in the POST parameters from the team.
377 If the currently logged in user is not a team member, a
378 403 Forbidden response is given.
380 Once the package is removed, the user is redirected back to the team's
381 page.
382 """
383 self.request = request
384 team = self.get_team(slug)
386 if 'package' in request.POST: 386 ↛ 392line 386 didn't jump to line 392, because the condition on line 386 was never false
387 package_name = request.POST['package']
388 package = get_or_none(PackageName, name=package_name)
389 if package:
390 team.packages.remove(package)
392 return redirect('dtracker-team-manage', slug=team.slug)
395class JoinTeamView(LoginRequiredMixin, View):
396 """
397 Lets logged in users join a public team.
399 After a user has been added to the team, redirect them back to the
400 team page.
401 """
402 template_name = 'core/team-join-choose-email.html'
404 def get(self, request, slug):
405 team = get_object_or_404(Team, slug=slug)
407 return render(request, self.template_name, {
408 'team': team,
409 })
411 def post(self, request, slug):
412 team = get_object_or_404(Team, slug=slug)
413 if not team.public:
414 # Only public teams can be joined directly by users
415 raise PermissionDenied
417 if 'email' in request.POST:
418 emails = request.POST.getlist('email')
419 # Make sure the user owns the emails
420 user_emails = [e.email for e in request.user.emails.all()]
421 for email in emails:
422 if email not in user_emails:
423 raise PermissionDenied
424 # Add the given emails to the team
425 team.add_members(self.request.user.emails.filter(email__in=emails))
427 return redirect(team)
430class LeaveTeamView(LoginRequiredMixin, View):
431 """
432 Lets logged in users leave teams they are a part of.
433 """
434 def get(self, request, slug):
435 team = get_object_or_404(Team, slug=slug)
436 return redirect(team)
438 def post(self, request, slug):
439 team = get_object_or_404(Team, slug=slug)
440 if not team.user_is_member(request.user):
441 # Leaving a team when you're not already a part of it makes no
442 # sense
443 raise PermissionDenied
445 # Remove all the user's emails from the team
446 team.remove_members(
447 UserEmail.objects.filter(pk__in=request.user.emails.all()))
449 return redirect(team)
452class ManageTeam(LoginRequiredMixin, ListView):
453 """
454 Provides the team owner a method to manually add/remove members of the
455 team.
456 """
457 template_name = 'core/team-manage.html'
458 paginate_by = 20
459 context_object_name = 'members_list'
461 def get_queryset(self):
462 return self.team.members.all().order_by('email')
464 def get_context_data(self, *args, **kwargs):
465 context = super(ManageTeam, self).get_context_data(*args, **kwargs)
466 context['team'] = self.team
467 context['form'] = AddTeamMemberForm()
468 return context
470 def get(self, request, slug):
471 self.team = get_object_or_404(Team, slug=slug)
472 if not self.team.user_is_member(self.request.user):
473 # Only team members are allowed to access the page
474 raise PermissionDenied
475 return super(ManageTeam, self).get(request, slug)
478class RemoveTeamMember(LoginRequiredMixin, View):
479 def post(self, request, slug):
480 self.team = get_object_or_404(Team, slug=slug)
481 if self.team.owner != request.user:
482 raise PermissionDenied
484 if 'email' in request.POST:
485 emails = request.POST.getlist('email')
486 self.team.remove_members(UserEmail.objects.filter(email__in=emails))
488 return redirect('dtracker-team-manage', slug=self.team.slug)
491class AddTeamMember(LoginRequiredMixin, View):
492 def post(self, request, slug):
493 self.team = get_object_or_404(Team, slug=slug)
494 if self.team.owner != request.user:
495 raise PermissionDenied
497 response = redirect('dtracker-team-manage', slug=self.team.slug)
498 form = AddTeamMemberForm(request.POST)
499 if form.is_valid():
500 email = form.cleaned_data['email']
501 # Emails that do not exist should be created
502 user, _ = UserEmail.objects.get_or_create(email=email)
503 if self.team.members.filter(email=user).exists():
504 messages.error(
505 request,
506 ("The email address %s is already a member "
507 "of the team" % email)
508 )
509 return response
511 # The membership is muted by default until the user confirms it
512 membership = self.team.add_members([user], muted=True)[0]
513 confirmation = MembershipConfirmation.objects.create_confirmation(
514 membership=membership)
515 send_mail(
516 'Team Membership Confirmation',
517 distro_tracker_render_to_string(
518 'core/email-team-membership-confirmation.txt',
519 {
520 'confirmation': confirmation,
521 'team': self.team,
522 }),
523 from_email=settings.DISTRO_TRACKER_CONTACT_EMAIL,
524 recipient_list=[email])
526 return response
529class ConfirmMembershipView(View):
530 def get(self, request, confirmation_key):
531 confirmation = get_object_or_404(
532 MembershipConfirmation, confirmation_key=confirmation_key)
533 membership = confirmation.membership
534 membership.muted = False
535 membership.save()
536 # The confirmation is no longer necessary
537 confirmation.delete()
539 return redirect(membership.team)
542class TeamListView(ListView):
543 queryset = Team.objects.filter(public=True).order_by('name')
544 paginate_by = 20
545 template_name = 'core/team-list.html'
546 context_object_name = 'team_list'
549class SetMuteTeamView(LoginRequiredMixin, View):
550 """
551 The view lets users mute or unmute a team membership or a particular
552 package in the membership.
553 """
554 action = 'mute'
556 def post(self, request, slug):
557 team = get_object_or_404(Team, slug=slug)
558 if 'email' not in request.POST:
559 raise Http404
560 user = request.user
561 try:
562 email = user.emails.get(email=request.POST['email'])
563 except UserEmail.DoesNotExist:
564 raise PermissionDenied
566 try:
567 membership = team.team_membership_set.get(user_email=email)
568 except TeamMembership.DoesNotExist:
569 raise Http404
571 if self.action == 'mute':
572 mute = True
573 elif self.action == 'unmute': 573 ↛ 576line 573 didn't jump to line 576, because the condition on line 573 was never false
574 mute = False
575 else:
576 raise Http404
578 if 'package' in request.POST:
579 package = get_object_or_404(PackageName,
580 name=request.POST['package'])
581 membership.set_mute_package(package, mute)
582 else:
583 membership.muted = mute
584 membership.save()
586 _next = request.POST.get('next', None)
587 return safe_redirect(_next, team)
590class SetMembershipKeywords(LoginRequiredMixin, View):
591 """
592 The view lets users set either default membership keywords or
593 package-specific keywords.
594 """
595 def render_response(self):
596 if self.request.headers.get('accept') == 'application/json':
597 return render_to_json_response({
598 'status': 'ok',
599 })
600 _next = self.request.POST.get('next', None)
601 return safe_redirect(_next, self.team)
603 def post(self, request, slug):
604 self.request = request
605 self.team = get_object_or_404(Team, slug=slug)
606 user = request.user
607 mandatory_parameters = ('email', 'keyword[]')
608 if any(param not in request.POST for param in mandatory_parameters):
609 raise Http404
610 try:
611 email = user.emails.get(email=request.POST['email'])
612 except UserEmail.DoesNotExist:
613 raise PermissionDenied
615 try:
616 membership = self.team.team_membership_set.get(user_email=email)
617 except TeamMembership.DoesNotExist:
618 raise Http404
620 keywords = request.POST.getlist('keyword[]')
621 if 'package' in request.POST:
622 package = get_object_or_404(PackageName,
623 name=request.POST['package'])
624 membership.set_keywords(package, keywords)
625 else:
626 membership.set_membership_keywords(keywords)
628 return self.render_response()
631class EditMembershipView(LoginRequiredMixin, ListView):
632 template_name = 'core/edit-team-membership.html'
633 paginate_by = 20
634 context_object_name = 'package_list'
636 def get(self, request, slug):
637 self.team = get_object_or_404(Team, slug=slug)
638 if 'email' not in request.GET:
639 raise Http404
640 user = request.user
641 try:
642 email = user.emails.get(email=request.GET['email'])
643 except UserEmail.DoesNotExist:
644 raise PermissionDenied
646 try:
647 self.membership = \
648 self.team.team_membership_set.get(user_email=email)
649 except TeamMembership.DoesNotExist:
650 raise Http404
652 return super(EditMembershipView, self).get(request, slug)
654 def get_queryset(self):
655 return self.team.packages.all().order_by('name')
657 def get_context_data(self, *args, **kwargs):
658 # Annotate the packages with a boolean indicating whether the package
659 # is muted by the user and a list of keywords specific for the package
660 # membership
661 for pkg in self.object_list:
662 pkg.is_muted = self.membership.is_muted(pkg)
663 pkg.keywords = sorted(
664 self.membership.get_keywords(pkg),
665 key=lambda x: x.name)
666 context = super(EditMembershipView, self).get_context_data(*args,
667 **kwargs)
668 context['membership'] = self.membership
669 return context
672class TeamAutocompleteView(View):
673 """
674 A view which responds to team auto-complete queries.
676 Renders a JSON list of team names matching the given query, meaning
677 their name contains the given query parameter.
678 """
679 @method_decorator(cache_control(must_revalidate=True, max_age=3600))
680 def get(self, request):
681 if 'q' not in request.GET: 681 ↛ 682line 681 didn't jump to line 682, because the condition on line 681 was never true
682 raise Http404
683 query_string = request.GET['q']
684 filtered = Team.objects.filter(
685 Q(name__icontains=query_string) | Q(slug__icontains=query_string))
686 # Extract only the name and slug of the team.
687 filtered = filtered.values('name', 'slug')
688 # Limit the number of teams returned from the autocomplete
689 AUTOCOMPLETE_ITEMS_LIMIT = 100
690 filtered = filtered[:AUTOCOMPLETE_ITEMS_LIMIT]
691 return render_to_json_response({
692 'query_string': query_string,
693 'teams': list(filtered)
694 })
697class TeamSearchView(View):
698 """
699 A view which responds to team search queries.
700 """
701 def get(self, request):
702 if 'query' not in self.request.GET: 702 ↛ 703line 702 didn't jump to line 703, because the condition on line 702 was never true
703 raise Http404
705 query = self.request.GET.get('query')
706 team = self.find_team(query)
707 if team is not None:
708 return redirect(team)
709 else:
710 messages.error(
711 request,
712 ("No team could be identified with the query string %s" % query)
713 )
714 return redirect(reverse('dtracker-team-list'))
716 def find_team(self, query):
717 if Team.objects.filter(slug=query).exists():
718 return Team.objects.filter(slug=query).first()
719 elif Team.objects.filter(name=query).exists():
720 return Team.objects.filter(name=query).first()
721 elif Team.objects.filter(
722 Q(name__icontains=query) | Q(slug__icontains=query)
723 ).count() == 1:
724 return Team.objects.filter(
725 Q(name__icontains=query) | Q(slug__icontains=query)).first()
727 return None
730class TeamPackagesTableView(View):
731 """
732 View renders a :class:`distro_tracker.core.package_tables.BasePackageTable`
733 in an HTML response.
734 """
735 template_name = 'core/team-packages-table.html'
737 def get(self, request, slug, table_slug):
738 team = get_object_or_404(Team, slug=slug)
740 tag = request.GET.get('tag', None)
741 limit = request.GET.get('limit', None)
742 self.table = create_table(
743 slug=table_slug, scope=team, limit=limit, tag=tag)
744 return render(request, self.template_name, {
745 'table': self.table,
746 'team': team
747 })
750class IndexView(TemplateView):
751 template_name = 'core/index.html'
753 def get_context_data(self, **kwargs):
754 context = super(IndexView, self).get_context_data(**kwargs)
755 links = []
756 for app in settings.INSTALLED_APPS:
757 try:
758 urlmodule = importlib.import_module(app + '.tracker_urls')
759 if hasattr(urlmodule, 'frontpagelinks'):
760 links += [(reverse(name), text)
761 for name, text in urlmodule.frontpagelinks]
762 except ImportError:
763 pass
764 context['application_links'] = links
765 return context