views.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. # Copyright 2013 The Distro Tracker Developers
  2. # See the COPYRIGHT file at the top-level directory of this distribution and
  3. # at http://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 http://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."""
  11. from __future__ import unicode_literals
  12. import importlib
  13. from django.conf import settings
  14. from django.db.models import Q
  15. from django.shortcuts import render, redirect
  16. from django.shortcuts import get_object_or_404
  17. from django.http import Http404
  18. from django.utils.decorators import method_decorator
  19. from django.views.generic import View
  20. from django.views.generic.edit import FormView
  21. from django.views.generic.edit import UpdateView
  22. from django.views.generic.detail import DetailView
  23. from django.views.generic import DeleteView
  24. from django.views.generic import ListView
  25. from django.views.generic import TemplateView
  26. from django.views.decorators.cache import cache_control
  27. from django.core.mail import send_mail
  28. from django.core.exceptions import PermissionDenied
  29. from django.core.urlresolvers import reverse, reverse_lazy
  30. from django.utils.http import urlquote
  31. from distro_tracker.core.models import get_web_package
  32. from distro_tracker.core.forms import CreateTeamForm
  33. from distro_tracker.core.forms import AddTeamMemberForm
  34. from distro_tracker.core.utils import render_to_json_response
  35. from distro_tracker.core.models import SourcePackageName, PackageName
  36. from distro_tracker.core.models import PseudoPackageName, BinaryPackageName
  37. from distro_tracker.core.models import ActionItem
  38. from distro_tracker.core.models import News, NewsRenderer
  39. from distro_tracker.core.models import Keyword
  40. from distro_tracker.core.models import Team
  41. from distro_tracker.core.models import TeamMembership
  42. from distro_tracker.core.models import MembershipConfirmation
  43. from distro_tracker.core.panels import get_panels_for_package
  44. from distro_tracker.accounts.views import LoginRequiredMixin
  45. from distro_tracker.accounts.models import UserEmail
  46. from distro_tracker.core.utils import get_or_none
  47. from distro_tracker.core.utils import distro_tracker_render_to_string
  48. def package_page(request, package_name):
  49. """
  50. Renders the package page.
  51. """
  52. package = get_web_package(package_name)
  53. if not package:
  54. raise Http404
  55. if package.get_absolute_url() not in (urlquote(request.path), request.path):
  56. return redirect(package)
  57. is_subscribed = False
  58. if request.user.is_authenticated():
  59. # Check if the user is subscribed to the package
  60. is_subscribed = request.user.is_subscribed_to(package)
  61. return render(request, 'core/package.html', {
  62. 'package': package,
  63. 'panels': get_panels_for_package(package, request),
  64. 'is_subscribed': is_subscribed,
  65. })
  66. def package_page_redirect(request, package_name):
  67. """
  68. Catch-all view which tries to redirect the user to a package page
  69. """
  70. return redirect('dtracker-package-page', package_name=package_name)
  71. def legacy_package_url_redirect(request, package_hash, package_name):
  72. """
  73. Redirects access to URLs in the form of the "old" PTS package URLs to the
  74. new package URLs.
  75. .. note::
  76. The "old" package URL is: /<hash>/<package_name>.html
  77. """
  78. return redirect('dtracker-package-page', package_name=package_name,
  79. permanent=True)
  80. class PackageSearchView(View):
  81. """
  82. A view which responds to package search queries.
  83. """
  84. def get(self, request):
  85. if 'package_name' not in self.request.GET:
  86. raise Http404
  87. package_name = self.request.GET.get('package_name').lower()
  88. package = get_web_package(package_name)
  89. if package is not None:
  90. return redirect(package)
  91. else:
  92. return render(request, 'core/package_search.html', {
  93. 'package_name': package_name
  94. })
  95. class OpenSearchDescription(View):
  96. """
  97. Return the open search description XML document allowing
  98. browsers to launch searches on the website.
  99. """
  100. def get(self, request):
  101. return render(request, 'core/opensearch-description.xml', {
  102. 'search_uri': request.build_absolute_uri(
  103. reverse('dtracker-package-search')),
  104. 'autocomplete_uri': request.build_absolute_uri(
  105. reverse('dtracker-api-package-autocomplete')),
  106. 'favicon_uri': request.build_absolute_uri(
  107. reverse('dtracker-favicon')),
  108. }, content_type='application/opensearchdescription+xml')
  109. class PackageAutocompleteView(View):
  110. """
  111. A view which responds to package auto-complete queries.
  112. Renders a JSON list of package names matching the given query, meaning
  113. their name starts with the given query parameter.
  114. """
  115. @method_decorator(cache_control(must_revalidate=True, max_age=3600))
  116. def get(self, request):
  117. if 'q' not in request.GET:
  118. raise Http404
  119. query_string = request.GET['q']
  120. package_type = request.GET.get('package_type', None)
  121. MANAGERS = {
  122. 'pseudo': PseudoPackageName.objects,
  123. 'source': SourcePackageName.objects,
  124. 'binary': BinaryPackageName.objects.exclude(source=True),
  125. }
  126. # When no package type is given include both pseudo and source packages
  127. filtered = MANAGERS.get(
  128. package_type,
  129. PackageName.objects.filter(Q(source=True) | Q(pseudo=True))
  130. )
  131. filtered = filtered.filter(name__icontains=query_string)
  132. # Extract only the name of the package.
  133. filtered = filtered.values('name')
  134. # Limit the number of packages returned from the autocomplete
  135. AUTOCOMPLETE_ITEMS_LIMIT = 100
  136. filtered = filtered[:AUTOCOMPLETE_ITEMS_LIMIT]
  137. return render_to_json_response([query_string,
  138. [package['name']
  139. for package in filtered]])
  140. def news_page(request, news_id):
  141. """
  142. Displays a news item's full content.
  143. """
  144. news = get_object_or_404(News, pk=news_id)
  145. renderer_class = \
  146. NewsRenderer.get_renderer_for_content_type(news.content_type)
  147. if renderer_class is None:
  148. renderer_class = \
  149. NewsRenderer.get_renderer_for_content_type('text/plain')
  150. renderer = renderer_class(news)
  151. return render(request, 'core/news.html', {
  152. 'news_renderer': renderer,
  153. 'news': news,
  154. })
  155. class PackageNews(ListView):
  156. """
  157. A view which lists all the news of a package.
  158. """
  159. _DEFAULT_NEWS_LIMIT = 30
  160. NEWS_LIMIT = getattr(
  161. settings,
  162. 'DISTRO_TRACKER_NEWS_PANEL_LIMIT',
  163. _DEFAULT_NEWS_LIMIT)
  164. paginate_by = NEWS_LIMIT
  165. template_name = 'core/package_news.html'
  166. context_object_name = 'news'
  167. def get(self, request, package_name):
  168. self.package = get_object_or_404(PackageName, name=package_name)
  169. return super(PackageNews, self).get(request, package_name)
  170. def get_queryset(self):
  171. news = self.package.news_set.prefetch_related('signed_by')
  172. return news.order_by('-datetime_created')
  173. def get_context_data(self, *args, **kwargs):
  174. context = super(PackageNews, self).get_context_data(*args, **kwargs)
  175. context['package'] = self.package
  176. return context
  177. class ActionItemJsonView(View):
  178. """
  179. View renders a :class:`distro_tracker.core.models.ActionItem` in a JSON
  180. response.
  181. """
  182. @method_decorator(cache_control(must_revalidate=True, max_age=3600))
  183. def get(self, request, item_pk):
  184. item = get_object_or_404(ActionItem, pk=item_pk)
  185. return render_to_json_response(item.to_dict())
  186. class ActionItemView(View):
  187. """
  188. View renders a :class:`distro_tracker.core.models.ActionItem` in an HTML
  189. response.
  190. """
  191. def get(self, request, item_pk):
  192. item = get_object_or_404(ActionItem, pk=item_pk)
  193. return render(request, 'core/action-item.html', {
  194. 'item': item,
  195. })
  196. def legacy_rss_redirect(request, package_hash, package_name):
  197. """
  198. Redirects old package RSS news feed URLs to the new ones.
  199. """
  200. return redirect(
  201. 'dtracker-package-rss-news-feed',
  202. package_name=package_name,
  203. permanent=True)
  204. class KeywordsView(View):
  205. def get(self, request):
  206. return render_to_json_response([
  207. keyword.name for keyword in Keyword.objects.order_by('name').all()
  208. ])
  209. class CreateTeamView(LoginRequiredMixin, FormView):
  210. model = Team
  211. template_name = 'core/team-create.html'
  212. form_class = CreateTeamForm
  213. def form_valid(self, form):
  214. instance = form.save(commit=False)
  215. user = self.request.user
  216. instance.owner = user
  217. instance.save()
  218. instance.add_members(user.emails.filter(email=user.main_email))
  219. return redirect(instance)
  220. class TeamDetailsView(DetailView):
  221. model = Team
  222. template_name = 'core/team.html'
  223. def get_context_data(self, **kwargs):
  224. context = super(TeamDetailsView, self).get_context_data(**kwargs)
  225. if self.request.user.is_authenticated():
  226. context['user_member_of_team'] = self.object.user_is_member(
  227. self.request.user)
  228. return context
  229. class DeleteTeamView(DeleteView):
  230. model = Team
  231. success_url = reverse_lazy('dtracker-team-deleted')
  232. template_name = 'core/team-confirm-delete.html'
  233. def get_object(self, *args, **kwargs):
  234. """
  235. Makes sure that the team instance to be deleted is owned by the
  236. logged in user.
  237. """
  238. instance = super(DeleteTeamView, self).get_object(*args, **kwargs)
  239. if instance.owner != self.request.user:
  240. raise PermissionDenied
  241. return instance
  242. class UpdateTeamView(UpdateView):
  243. model = Team
  244. form_class = CreateTeamForm
  245. template_name = 'core/team-update.html'
  246. def get_object(self, *args, **kwargs):
  247. """
  248. Makes sure that the team instance to be updated is owned by the
  249. logged in user.
  250. """
  251. instance = super(UpdateTeamView, self).get_object(*args, **kwargs)
  252. if instance.owner != self.request.user:
  253. raise PermissionDenied
  254. return instance
  255. class AddPackageToTeamView(LoginRequiredMixin, View):
  256. def post(self, request, slug):
  257. """
  258. Adds the package given in the POST parameters to the team.
  259. If the currently logged in user is not a team member, a
  260. 403 Forbidden response is given.
  261. Once the package is added, the user is redirected back to the team's
  262. page.
  263. """
  264. team = get_object_or_404(Team, slug=slug)
  265. if not team.user_is_member(request.user):
  266. # Only team mebers are allowed to modify the packages followed by
  267. # the team.
  268. raise PermissionDenied
  269. if 'package' in request.POST:
  270. package_name = request.POST['package']
  271. package = get_or_none(PackageName, name=package_name)
  272. if package:
  273. team.packages.add(package)
  274. return redirect(team)
  275. class RemovePackageFromTeamView(LoginRequiredMixin, View):
  276. template_name = 'core/team-remove-package-confirm.html'
  277. def get_team(self, slug):
  278. team = get_object_or_404(Team, slug=slug)
  279. if not team.user_is_member(self.request.user):
  280. # Only team mebers are allowed to modify the packages followed by
  281. # the team.
  282. raise PermissionDenied
  283. return team
  284. def get(self, request, slug):
  285. self.request = request
  286. team = self.get_team(slug)
  287. if 'package' not in request.GET:
  288. raise Http404
  289. package_name = request.GET['package']
  290. package = get_or_none(PackageName, name=package_name)
  291. return render(self.request, self.template_name, {
  292. 'package': package,
  293. 'team': team,
  294. })
  295. def post(self, request, slug):
  296. """
  297. Removes the package given in the POST parameters from the team.
  298. If the currently logged in user is not a team member, a
  299. 403 Forbidden response is given.
  300. Once the package is removed, the user is redirected back to the team's
  301. page.
  302. """
  303. self.request = request
  304. team = self.get_team(slug)
  305. if 'package' in request.POST:
  306. package_name = request.POST['package']
  307. package = get_or_none(PackageName, name=package_name)
  308. if package:
  309. team.packages.remove(package)
  310. return redirect(team)
  311. class JoinTeamView(LoginRequiredMixin, View):
  312. """
  313. Lets logged in users join a public team.
  314. After a user has been added to the team, redirect them back to the
  315. team page.
  316. """
  317. template_name = 'core/team-join-choose-email.html'
  318. def get(self, request, slug):
  319. team = get_object_or_404(Team, slug=slug)
  320. return render(request, self.template_name, {
  321. 'team': team,
  322. })
  323. def post(self, request, slug):
  324. team = get_object_or_404(Team, slug=slug)
  325. if not team.public:
  326. # Only public teams can be joined directly by users
  327. raise PermissionDenied
  328. if 'email' in request.POST:
  329. emails = request.POST.getlist('email')
  330. # Make sure the user owns the emails
  331. user_emails = [e.email for e in request.user.emails.all()]
  332. for email in emails:
  333. if email not in user_emails:
  334. raise PermissionDenied
  335. # Add the given emails to the team
  336. team.add_members(self.request.user.emails.filter(email__in=emails))
  337. return redirect(team)
  338. class LeaveTeamView(LoginRequiredMixin, View):
  339. """
  340. Lets logged in users leave teams they are a part of.
  341. """
  342. def get(self, request, slug):
  343. team = get_object_or_404(Team, slug=slug)
  344. return redirect(team)
  345. def post(self, request, slug):
  346. team = get_object_or_404(Team, slug=slug)
  347. if not team.user_is_member(request.user):
  348. # Leaving a team when you're not already a part of it makes no
  349. # sense
  350. raise PermissionDenied
  351. # Remove all the user's emails from the team
  352. team.remove_members(
  353. UserEmail.objects.filter(pk__in=request.user.emails.all()))
  354. return redirect(team)
  355. class ManageTeamMembers(LoginRequiredMixin, ListView):
  356. """
  357. Provides the team owner a method to manually add/remove members of the
  358. team.
  359. """
  360. template_name = 'core/team-manage.html'
  361. paginate_by = 20
  362. context_object_name = 'members_list'
  363. def get_queryset(self):
  364. return self.team.members.all().order_by('email')
  365. def get_context_data(self, *args, **kwargs):
  366. context = super(ManageTeamMembers, self).get_context_data(*args,
  367. **kwargs)
  368. context['team'] = self.team
  369. context['form'] = AddTeamMemberForm()
  370. return context
  371. def get(self, request, slug):
  372. self.team = get_object_or_404(Team, slug=slug)
  373. # Make sure only the owner can access this page
  374. if self.team.owner != request.user:
  375. raise PermissionDenied
  376. return super(ManageTeamMembers, self).get(request, slug)
  377. class RemoveTeamMember(LoginRequiredMixin, View):
  378. def post(self, request, slug):
  379. self.team = get_object_or_404(Team, slug=slug)
  380. if self.team.owner != request.user:
  381. raise PermissionDenied
  382. if 'email' in request.POST:
  383. emails = request.POST.getlist('email')
  384. self.team.remove_members(UserEmail.objects.filter(email__in=emails))
  385. return redirect('dtracker-team-manage', slug=self.team.slug)
  386. class AddTeamMember(LoginRequiredMixin, View):
  387. def post(self, request, slug):
  388. self.team = get_object_or_404(Team, slug=slug)
  389. if self.team.owner != request.user:
  390. raise PermissionDenied
  391. form = AddTeamMemberForm(request.POST)
  392. if form.is_valid():
  393. email = form.cleaned_data['email']
  394. # Emails that do not exist should be created
  395. user, _ = UserEmail.objects.get_or_create(email=email)
  396. # The membership is muted by default until the user confirms it
  397. membership = self.team.add_members([user], muted=True)[0]
  398. confirmation = MembershipConfirmation.objects.create_confirmation(
  399. membership=membership)
  400. send_mail(
  401. 'Team Membership Confirmation',
  402. distro_tracker_render_to_string(
  403. 'core/email-team-membership-confirmation.txt',
  404. {
  405. 'confirmation': confirmation,
  406. 'team': self.team,
  407. }),
  408. from_email=settings.DISTRO_TRACKER_CONTACT_EMAIL,
  409. recipient_list=[email])
  410. return redirect('dtracker-team-manage', slug=self.team.slug)
  411. class ConfirmMembershipView(View):
  412. template_name = 'core/membership-confirmation.html'
  413. def get(self, request, confirmation_key):
  414. confirmation = get_object_or_404(
  415. MembershipConfirmation, confirmation_key=confirmation_key)
  416. membership = confirmation.membership
  417. membership.muted = False
  418. membership.save()
  419. # The confirmation is no longer necessary
  420. confirmation.delete()
  421. return redirect(membership.team)
  422. return render(request, self.template_name, {
  423. 'membership': membership,
  424. })
  425. class TeamListView(ListView):
  426. queryset = Team.objects.filter(public=True).order_by('name')
  427. paginate_by = 20
  428. template_name = 'core/team-list.html'
  429. context_object_name = 'team_list'
  430. class SetMuteTeamView(LoginRequiredMixin, View):
  431. """
  432. The view lets users mute or unmute a team membership or a particular
  433. package in the membership.
  434. """
  435. action = 'mute'
  436. def post(self, request, slug):
  437. team = get_object_or_404(Team, slug=slug)
  438. if 'email' not in request.POST:
  439. raise Http404
  440. user = request.user
  441. try:
  442. email = user.emails.get(email=request.POST['email'])
  443. except UserEmail.DoesNotExist:
  444. raise PermissionDenied
  445. try:
  446. membership = team.team_membership_set.get(user_email=email)
  447. except TeamMembership.DoesNotExist:
  448. raise Http404
  449. if self.action == 'mute':
  450. mute = True
  451. elif self.action == 'unmute':
  452. mute = False
  453. else:
  454. raise Http404
  455. if 'package' in request.POST:
  456. package = get_object_or_404(PackageName,
  457. name=request.POST['package'])
  458. membership.set_mute_package(package, mute)
  459. else:
  460. membership.muted = mute
  461. membership.save()
  462. if 'next' in request.POST:
  463. return redirect(request.POST['next'])
  464. else:
  465. return redirect(team)
  466. class SetMembershipKeywords(LoginRequiredMixin, View):
  467. """
  468. The view lets users set either default membership keywords or
  469. package-specific keywords.
  470. """
  471. def render_response(self):
  472. if self.request.is_ajax():
  473. return render_to_json_response({
  474. 'status': 'ok',
  475. })
  476. if 'next' in self.request.POST:
  477. return redirect(self.request.POST['next'])
  478. else:
  479. return redirect(self.team)
  480. def post(self, request, slug):
  481. self.request = request
  482. self.team = get_object_or_404(Team, slug=slug)
  483. user = request.user
  484. mandatory_parameters = ('email', 'keyword[]')
  485. if any(param not in request.POST for param in mandatory_parameters):
  486. raise Http404
  487. try:
  488. email = user.emails.get(email=request.POST['email'])
  489. except UserEmail.DoesNotExist:
  490. raise PermissionDenied
  491. try:
  492. membership = self.team.team_membership_set.get(user_email=email)
  493. except TeamMembership.DoesNotExist:
  494. raise Http404
  495. keywords = request.POST.getlist('keyword[]')
  496. if 'package' in request.POST:
  497. package = get_object_or_404(PackageName,
  498. name=request.POST['package'])
  499. membership.set_keywords(package, keywords)
  500. else:
  501. membership.set_membership_keywords(keywords)
  502. return self.render_response()
  503. class EditMembershipView(LoginRequiredMixin, ListView):
  504. template_name = 'core/edit-team-membership.html'
  505. paginate_by = 20
  506. context_object_name = 'package_list'
  507. def get(self, request, slug):
  508. self.team = get_object_or_404(Team, slug=slug)
  509. if 'email' not in request.GET:
  510. raise Http404
  511. user = request.user
  512. try:
  513. email = user.emails.get(email=request.GET['email'])
  514. except UserEmail.DoesNotExist:
  515. raise PermissionDenied
  516. try:
  517. self.membership = \
  518. self.team.team_membership_set.get(user_email=email)
  519. except TeamMembership.DoesNotExist:
  520. raise Http404
  521. return super(EditMembershipView, self).get(request, slug)
  522. def get_queryset(self):
  523. return self.team.packages.all().order_by('name')
  524. def get_context_data(self, *args, **kwargs):
  525. # Annotate the packages with a boolean indicating whether the package
  526. # is muted by the user and a list of keywords specific for the package
  527. # membership
  528. for pkg in self.object_list:
  529. pkg.is_muted = self.membership.is_muted(pkg)
  530. pkg.keywords = sorted(
  531. self.membership.get_keywords(pkg),
  532. key=lambda x: x.name)
  533. context = super(EditMembershipView, self).get_context_data(*args,
  534. **kwargs)
  535. context['membership'] = self.membership
  536. return context
  537. class IndexView(TemplateView):
  538. template_name = 'core/index.html'
  539. def get_context_data(self, **kwargs):
  540. context = super(IndexView, self).get_context_data(**kwargs)
  541. links = []
  542. for app in settings.INSTALLED_APPS:
  543. try:
  544. urlmodule = importlib.import_module(app + '.tracker_urls')
  545. if hasattr(urlmodule, 'frontpagelinks'):
  546. links += [(reverse(name), text)
  547. for name, text in urlmodule.frontpagelinks]
  548. except ImportError:
  549. pass
  550. context['application_links'] = links
  551. return context