extensions-website/extensions/views/public.py

285 lines
11 KiB
Python

from collections import OrderedDict
import logging
import random
from django.contrib.auth import get_user_model
from django.db import connection
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from constants.base import (
EXTENSION_TYPE_SLUGS,
EXTENSION_TYPE_PLURAL,
EXTENSION_TYPE_CHOICES,
)
from extensions.models import Extension, Version, Tag
from files.models import File
from stats.models import ExtensionDownload, VersionDownload
import ratings.models
import teams.models
User = get_user_model()
log = logging.getLogger(__name__)
class ListedExtensionsView(ListView):
model = Extension
queryset = Extension.objects.listed.prefetch_related(
'authors',
'latest_version__files',
'latest_version__tags',
'preview_set',
'preview_set__file',
'ratings',
'team',
).distinct()
context_object_name = 'extensions'
class HomeView(ListedExtensionsView):
paginate_by = 16
template_name = 'extensions/home.html'
def get_context_data(self, **kwargs):
q = super().get_queryset().order_by('-rating_sortkey')
# TODO: Optimize by fetching just the ids, sampling those, then fetching the objects
# and shuffling them (because they'll be always sorted when fetched by ids). See !215
addons_list = list(q.filter(type=EXTENSION_TYPE_CHOICES.BPY)[:24])
themes_list = list(q.filter(type=EXTENSION_TYPE_CHOICES.THEME)[:12])
# Get 8 add-ons, 4 themes, or the minimum available.
addons_sample_size = min(8, len(addons_list))
themes_sample_size = min(4, len(themes_list))
# Randomize a sample of the extensions to list.
context = {
'addons': random.sample(addons_list, addons_sample_size),
'themes': random.sample(themes_list, themes_sample_size),
}
return context
def extension_version_download(request, type_slug, slug, version, filename):
"""A backward-compatible url for downloads.
Assuming only a single file for a given version.
"""
extension_version = get_object_or_404(Version, extension__slug=slug, version=version)
file = extension_version.files.first()
return file_download(request, file.hash, filename)
def file_download(request, filehash, filename):
"""Download an extension version and count downloads.
This method processes urls constructed by Version.get_download_list.
The `filename` parameter is used to pass a file name ending with `.zip`.
This is a convention Blender uses to initiate an extension installation on an HTML anchor
drag&drop.
Also see $arg_filename usage in playbooks/templates/nginx/http.conf
"""
# TODO check file status
file = get_object_or_404(File, hash=filehash, version__isnull=False)
extension_version = file.version.first()
ExtensionDownload.create_from_request(request, object_id=extension_version.extension_id)
VersionDownload.create_from_request(request, object_id=extension_version.pk)
url = file.source.url
return redirect(f'{url}?filename={filename}')
class SearchView(ListedExtensionsView):
paginate_by = 16
template_name = 'extensions/list.html'
default_sort_by = '-rating_sortkey'
sort_by_options = OrderedDict(
[
('-rating_sortkey', _('Rating')),
('-download_count', _('Downloads')),
('-date_approved', _('Newest First')),
('date_approved', _('Oldest First')),
('name', _('Title (A-Z)')),
('-name', _('Title (Z-A)')),
]
)
def _get_type_id_by_slug(self):
return next(k for k, v in EXTENSION_TYPE_SLUGS.items() if v == self.kwargs['type_slug'])
def _get_type_by_slug(self):
return EXTENSION_TYPE_PLURAL[self._get_type_id_by_slug()]
def _get_sort_by(self):
sort_by = self.request.GET.get('sort_by', self.default_sort_by)
if sort_by not in self.sort_by_options:
sort_by = self.default_sort_by
return sort_by
def get_queryset(self):
queryset = super().get_queryset().order_by(self._get_sort_by())
if self.kwargs.get('tag_slug'):
queryset = queryset.filter(
latest_version__tags__slug=self.kwargs['tag_slug']
).distinct()
if self.kwargs.get('team_slug'):
queryset = queryset.filter(team__slug=self.kwargs['team_slug'])
if self.kwargs.get('user_id'):
queryset = queryset.filter(
authors__maintainer__user_id=self.kwargs['user_id']
).distinct()
if self.kwargs.get('type_slug'):
_type = self._get_type_id_by_slug()
queryset = queryset.filter(type=_type)
search_query = self.request.GET.get('q')
if not search_query:
return queryset
# WARNING: full-text search support only on postgres
if connection.vendor == 'postgresql':
queryset = self.postgres_fts(queryset, search_query)
else:
filter = Q()
for token in search_query.split():
filter &= (
Q(slug__icontains=token)
| Q(name__icontains=token)
| Q(description__icontains=token)
| Q(latest_version__tags__name__icontains=token)
)
queryset = queryset.filter(filter).distinct()
return queryset
def postgres_fts(self, queryset, search_query):
"""Postgres full text search (fast) and a fuzzy trigram search (slow) as a fallback.
Searches Extension name and description only, ranking name matches higher.
If we need to extend the functionality, it's better to consider using a different approach,
e.g. introduce meilisearch.
Limits the results size to 32 items (2 pages), assuming that nobody will click through many
pages if we failed to present the vital results on the first page.
Relies on indexed expressions:
CREATE INDEX extensions_fts ON extensions_extension USING
gin ((to_tsvector('english', name) || ' ' || to_tsvector('english', description)));
CREATE INDEX extensions_trgm_gin ON extensions_extension USING
gin((((name)::text || ' '::text) || description) gin_trgm_ops);
"""
with connection.cursor() as cursor:
sql = """
select id
from extensions_extension
where (
(to_tsvector('english', name) || ' ' || to_tsvector('english', description))
@@ websearch_to_tsquery('english', %(query)s)
) and is_listed
order by ts_rank(
to_tsvector('english', name),
websearch_to_tsquery('english', %(query)s)
) desc
limit 32"""
cursor.execute(sql, {'query': search_query})
pks = [row[0] for row in cursor.fetchall()]
if not pks:
# fallback to fuzzy trigram search
sql = """
select id
from extensions_extension
where ((name || ' ' || description) %%> %(query)s)
and is_listed
order by %(query)s <<<-> (name || ' ' || description)
limit 32"""
cursor.execute(sql, {'query': search_query})
pks = [row[0] for row in cursor.fetchall()]
# pks are ordered by ranking, keep that order
# this approach is fine under the assumption that the list is small
return sorted(queryset.filter(pk__in=pks).order_by(), key=lambda x: pks.index(x.pk))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.kwargs.get('user_id'):
context['author'] = get_object_or_404(User, pk=self.kwargs['user_id'])
if self.kwargs.get('tag_slug'):
context['tag'] = get_object_or_404(Tag, slug=self.kwargs['tag_slug'])
if self.kwargs.get('type_slug'):
context['type'] = self._get_type_by_slug()
if self.kwargs.get('team_slug'):
context['team'] = get_object_or_404(teams.models.Team, slug=self.kwargs['team_slug'])
sort_by = self._get_sort_by()
context['sort_by'] = sort_by
context['sort_by_option_name'] = self.sort_by_options.get(sort_by)
context['sort_by_options'] = self.sort_by_options
# Determine which tags to list depending on the context.
tag_type_id = None
if context.get('type'):
tag_type_id = self._get_type_id_by_slug()
elif context.get('tag'):
tag_type_id = context['tag'].type
if tag_type_id:
tags = [
{
'count': t['count'],
'name': t['latest_version__tags__name'],
'slug': t['latest_version__tags__slug'],
}
for t in Extension.objects.listed.select_related('latest_version__tags')
.filter(latest_version__tags__type=tag_type_id)
.values('latest_version__tags__name', 'latest_version__tags__slug')
.annotate(count=Count('id'))
.order_by('latest_version__tags__name')
]
context['tags'] = tags
if 'tag' in context:
# this is silly, but the list is short
tag_slug = context['tag'].slug
for t in tags:
if t['slug'] == tag_slug:
context['current_tag_count'] = t['count']
break
context['total_count'] = super().get_queryset().filter(type=tag_type_id).count()
return context
class ExtensionDetailView(DetailView):
queryset = Extension.objects.listed.prefetch_related(
'authors',
'latest_version__files',
'latest_version__files__validation',
'latest_version__permissions',
'latest_version__platforms',
'latest_version__tags',
'preview_set',
'preview_set__file',
'ratings',
'ratings__ratingreply',
'ratings__ratingreply__user',
'ratings__user',
'team',
).distinct()
context_object_name = 'extension'
template_name = 'extensions/detail.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.user.is_authenticated:
context['my_rating'] = ratings.models.Rating.get_for(
self.request.user.pk, self.object.pk
)
context['is_maintainer'] = self.object.has_maintainer(self.request.user)
extension = context['object']
# Add the image for "og:image" meta to the context
if extension.featured_image and extension.featured_image.is_listed:
context['default_image_path'] = extension.featured_image.thumbnail_1080p_url
return context