pulseapi/entries/views.py (369 lines of code) (raw):
"""
Views to get entries
"""
import base64
import operator
import django_filters
from functools import reduce
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.base import ContentFile
from django.conf import settings
from django.db import models
from django.db.models import Q
from django_filters.rest_framework import (
DjangoFilterBackend,
FilterSet
)
from rest_framework import status
from rest_framework.compat import distinct
from rest_framework.decorators import action, api_view
from rest_framework.filters import (
OrderingFilter,
SearchFilter
)
from rest_framework.generics import (
ListCreateAPIView,
RetrieveAPIView,
ListAPIView,
get_object_or_404
)
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from rest_framework.parsers import JSONParser
from pulseapi.entries.models import Entry, ModerationState
from pulseapi.entries.serializers import (
EntrySerializerWithV1Creators,
EntrySerializerWithCreators,
ModerationStateSerializer,
)
from .serializers import (
ProjectEntrySerializer,
NewsEntrySerializer,
CurriculumEntrySerializer,
InfoEntrySerializer,
SessionEntrySerializer
)
from pulseapi.profiles.models import UserBookmarks
from pulseapi.utility.userpermissions import is_staff_address
@api_view(['PUT'])
def toggle_bookmark(request, entryid, **kwargs):
"""
Toggle whether or not this user "bookmarked" the url-indicated entry.
This is currently defined outside of the entry class, as functionality
that is technically independent of entries themselves. We might
change this in the future.
"""
user = request.user
if user.is_authenticated:
profile = user.profile
entry = get_object_or_404(Entry, id=entryid)
# find out if there is already a {user,entry,(timestamp)} triple
bookmarks = entry.bookmarked_by.filter(profile=profile)
# if there is a bookmark, remove it. Otherwise, make one.
if bookmarks:
bookmarks.delete()
else:
UserBookmarks.objects.create(entry=entry, profile=profile)
return Response("Toggled bookmark.", status=status.HTTP_204_NO_CONTENT)
return Response("Anonymous bookmarks cannot be saved.", status=status.HTTP_403_FORBIDDEN)
@api_view(['PUT'])
def toggle_featured(request, entryid, **kwargs):
"""
Toggle the featured status of an entry.
"""
user = request.user
if user.has_perm('entries.change_entry'):
entry = get_object_or_404(Entry, id=entryid)
entry.featured = not entry.featured
entry.save()
return Response("Toggled featured status.",
status=status.HTTP_204_NO_CONTENT)
return Response(
"You don't have permission to change entry featured status.",
status=status.HTTP_403_FORBIDDEN)
@api_view(['PUT'])
def toggle_moderation(request, entryid, stateid, **kwargs):
"""
Toggle the moderation state for a specific entry,
based on moderation state id values. These values
can be obtained via /api/pulse/entries/moderation-states
which returns id:name pairs for each available state.
"""
user = request.user
if user.has_perm('entries.change_entry') is True:
entry = get_object_or_404(Entry, id=entryid)
moderation_state = get_object_or_404(ModerationState, id=stateid)
entry.moderation_state = moderation_state
entry.save()
return Response("Updated moderation state.", status=status.HTTP_204_NO_CONTENT)
return Response(
"You do not have permission to change entry moderation states.",
status=status.HTTP_403_FORBIDDEN
)
def post_validate(request):
"""
Security helper function to ensure that a post request is session, CSRF, and nonce protected
"""
user = request.user
csrf_token = False
nonce = False
if request.data:
csrf_token = request.data.get('csrfmiddlewaretoken', False)
nonce = request.data.get('nonce', False)
else:
csrf_token = request.POST.get('csrfmiddlewaretoken', False)
nonce = request.POST.get('nonce', False)
# ignore post attempts without a CSRF token
if csrf_token is False:
return "No CSRF token in POST data."
# ignore post attempts without a known form id
if nonce is False:
return "No form identifier in POST data."
# ignore post attempts by clients that are not logged in
if not user.is_authenticated:
return "Anonymous posting is not supported."
# ignore unexpected post attempts (i.e. missing the session-based unique form id)
if nonce != request.session['nonce']:
# invalidate the nonce entirely, so people can't retry until there's an id collision
request.session['nonce'] = False
return "Forms cannot be auto-resubmitted (e.g. by reloading)."
return True
class EntriesPagination(PageNumberPagination):
"""
Add support for pagination and custom page size
"""
# page size decided in https://github.com/mozilla/network-pulse-api/issues/39
page_size = 48
page_size_query_param = 'page_size'
max_page_size = 1000
class EntryCustomFilter(FilterSet):
"""
We add custom filtering to allow you to filter by:
* Tag - pass the `?tag=` query parameter
* Issue - pass the `?issue=` query parameter
* Help Type - pass the `?help_type=` query parameter
* Featured - `?featured=True` (or False) - both capitalied
Accepts only one filter value i.e. one tag and/or one
category.
"""
tag = django_filters.CharFilter(
field_name='tags__name',
lookup_expr='iexact',
)
issue = django_filters.CharFilter(
field_name='issues__name',
lookup_expr='iexact',
)
help_type = django_filters.CharFilter(
field_name='help_types__name',
lookup_expr='iexact',
)
has_help_types = django_filters.BooleanFilter(
field_name='help_types',
lookup_expr='isnull',
exclude=True,
)
featured = django_filters.BooleanFilter(
field_name='featured'
)
class Meta:
"""
Required Meta class
"""
model = Entry
fields = ['tags', 'issues', 'featured', ]
class EntryView(RetrieveAPIView):
"""
A view to retrieve individual entries
"""
queryset = Entry.objects.public().with_related()
pagination_class = None
parser_classes = (
JSONParser,
)
def get_serializer_class(self):
request = self.request
if request and request.version == settings.API_VERSIONS['version_1']:
return EntrySerializerWithV1Creators
return EntrySerializerWithCreators
def get_serializer_context(self):
return {
'user': self.request.user
}
class BookmarkedEntries(ListAPIView):
pagination_class = EntriesPagination
parser_classes = (
JSONParser,
)
def get_queryset(self):
user = self.request.user
if user.is_authenticated is False:
return Entry.objects.none()
bookmarks = UserBookmarks.objects.filter(profile=user.profile)
return Entry.objects.filter(bookmarked_by__in=bookmarks).order_by('-bookmarked_by__timestamp')
def get_serializer_class(self):
request = self.request
if request and request.version == settings.API_VERSIONS['version_1']:
return EntrySerializerWithV1Creators
return EntrySerializerWithCreators
def get_serializer_context(self):
return {
'user': self.request.user
}
# When people POST to this route, we want to do some
# custom validation involving CSRF and nonce validation,
# so we intercept the POST handling a little.
@action(detail=True, methods=['post'])
def post(self, request, *args, **kwargs):
validation_result = post_validate(request)
if validation_result is True:
# invalidate the nonce, so this form cannot be
# resubmitted with the current id
request.session['nonce'] = False
user = request.user
entryids = self.request.query_params.get('ids', None)
def bookmark_entry(entryid):
# find the entry for this id
try:
entry = Entry.objects.get(id=entryid)
except ObjectDoesNotExist:
return
# find out if there is already a {user,entry,(timestamp)} triple
profile = user.profile
bookmarks = entry.bookmarked_by.filter(profile=profile)
# make a bookmark if there isn't one already
if not bookmarks:
UserBookmarks.objects.create(entry=entry, profile=profile)
if entryids is not None and user.is_authenticated:
for entryid in entryids.split(','):
bookmark_entry(entryid)
return Response("Entries bookmarked.", status=status.HTTP_204_NO_CONTENT)
else:
return Response(
"post validation failed",
status=status.HTTP_403_FORBIDDEN
)
class ModerationStateView(ListAPIView):
"""
A view to retrieve all moderation states
"""
queryset = ModerationState.objects.all()
serializer_class = ModerationStateSerializer
parser_classes = (
JSONParser,
)
# see https://stackoverflow.com/questions/60326973
class SearchWithNormalTagFiltering(SearchFilter):
"""
This is a custom search filter that allows the same kind of
matching that DRF v3.6.3 allowed wrt many to many relations,
where multiple terms have to all match, but do _not_ need
to match against single m2m relations, so a ?search=a,b
will match an entry with two tags a and b, but not with
single tag a or tag b.
"""
def required_m2m_optimization(self, view):
return getattr(view, 'use_m2m_optimization', True)
def get_search_fields(self, view, request):
# For DRF versions >=3.9.2 remove this method,
# as it already has get_search_fields built in.
return getattr(view, 'search_fields', None)
def chained_queryset_filter(self, queryset, search_terms, orm_lookups):
for search_term in search_terms:
queries = [
models.Q(**{orm_lookup: search_term})
for orm_lookup in orm_lookups
]
queryset = queryset.filter(reduce(operator.or_, queries))
return queryset
def optimized_queryset_filter(self, queryset, search_terms, orm_lookups):
conditions = []
for search_term in search_terms:
queries = [
models.Q(**{orm_lookup: search_term})
for orm_lookup in orm_lookups
]
conditions.append(reduce(operator.or_, queries))
return queryset.filter(reduce(operator.and_, conditions))
def filter_queryset(self, request, queryset, view):
search_fields = self.get_search_fields(view, request)
search_terms = self.get_search_terms(request)
if not search_fields or not search_terms:
return queryset
orm_lookups = [
self.construct_search(str(search_field))
for search_field in search_fields
]
base = queryset
if self.required_m2m_optimization(view):
queryset = self.optimized_queryset_filter(queryset, search_terms, orm_lookups)
else:
queryset = self.chained_queryset_filter(queryset, search_terms, orm_lookups)
if self.must_call_distinct(queryset, search_fields):
# Filtering against a many-to-many field requires us to
# call queryset.distinct() in order to avoid duplicate items
# in the resulting queryset.
# We try to avoid this if possible, for performance reasons.
queryset = distinct(queryset, base)
return queryset
class EntriesListView(ListCreateAPIView):
"""
A view that permits a GET to allow listing all the entries
in the database
**Route** - `/entries`
#Query Parameters -
- `?search=` - Search by title, description, get_involved, interest,
creator, and tag.
- `?ids=` - Filter only for entries with specific ids. Argument
must be a comma-separated list of integer ids.
- `?tag=` - Allows filtering entries by a specific tag
- `?issue=` - Allows filtering entries by a specific issue
- `?help_type=` - Allows filtering entries by a specific help type
- `?has_help_types=<True or False>` - Filter entries by whether they have
help types or not. Note that `True`
or `False` is case-sensitive.
- `?featured=True` (or False) - both capitalied. Boolean is set in admin UI
- `?page=` - Page number, defaults to 1
- `?page_size=` - Number of results on a page. Defaults to 48
- `?ordering=` - Property you'd like to order the results by. Prepend with
`-` to reverse. e.g. `?ordering=-title`
- `?moderationstate=` - Filter results to only show the indicated moderation
state, by name. This will only filter if the calling
user has moderation permissions.
"""
pagination_class = EntriesPagination
filter_backends = (
DjangoFilterBackend,
SearchWithNormalTagFiltering,
OrderingFilter,
)
filter_class = EntryCustomFilter
search_fields = (
'title',
'description',
'get_involved',
'interest',
'tags__name',
)
# Forces DRF to use pre-3.6.4 matching for many-to-many relations, so that
# searching for multiple terms will find only entry that match all terms,
# but many-to-many will resolve on the m2m set, not "one relation in the set".
use_m2m_optimization = False
parser_classes = (
JSONParser,
)
# Custom queryset handling: if the route was called as
# /entries/?ids=1,2,3,4,... or /entries/?creators=a,b,c...
# only return entries filtered on those property values.
#
# Otherwise, return all entries (with pagination).
def get_queryset(self):
user = self.request.user
# Get all entries: if this is a normal call without a
# specific moderation state, we return the set of
# public entries. However, if moderation state is
# explicitly requrested, and the requesting user has
# permissions to change entries by virtue of being
# either a moderator or superuser, we return all
# entries, filtered for the indicated moderation state.
queryset = False
query_params = self.request.query_params
modstate = query_params.get('moderationstate', None)
if modstate is not None:
is_superuser = user.is_superuser
is_moderator = user.has_perm('entries.change_entry')
if is_superuser is True or is_moderator is True:
mvalue = ModerationState.objects.get(name=modstate)
if mvalue is not None:
queryset = Entry.objects.filter(moderation_state=mvalue)
if queryset is False:
queryset = Entry.objects.public().with_related().by_active_profile()
# If the query was for a set of specific entries,
# filter the query set further.
ids = query_params.get('ids', None)
if ids is not None:
try:
ids = [int(x) for x in ids.split(',')]
queryset = queryset.filter(pk__in=ids)
except ValueError:
pass
creators = query_params.get('creators', None)
# If the query was for a set of entries by specifc creators,
# filter the query set further.
if creators is not None:
creator_names = creators.split(',')
# Filter only those entries by looking at their relationship
# with creators (using the 'related_creators' field, which is
# the OrderedCreatorRecord relation), and then getting each
# relation's associated "creator" and making sure that the
# creator's name is in the list of creator names specified
# in the query string.
#
# This is achieved by Django by relying on namespace manging,
# explained in the python documentation, specifically here:
#
# https://docs.python.org/3/tutorial/classes.html#private-variables-and-class-local-references
queryset = queryset.filter(
Q(related_entry_creators__profile__custom_name__in=creator_names) |
Q(related_entry_creators__profile__related_user__name__in=creator_names)
)
return queryset
def get_serializer_class(self):
request = self.request
if request and request.version == settings.API_VERSIONS['version_1']:
return EntrySerializerWithV1Creators
return EntrySerializerWithCreators
def get_serializer_context(self):
return {
'user': self.request.user
}
# When people POST to this route, we want to do some
# custom validation involving CSRF and nonce validation,
# so we intercept the POST handling a little.
@action(detail=True, methods=['post'])
def post(self, request, *args, **kwargs):
request_data = request.data
user = request.user if hasattr(request, 'user') else None
validation_result = post_validate(request)
if validation_result is True:
# invalidate the nonce, so this form cannot be
# resubmitted with the current id
request.session['nonce'] = False
'''
If there is a thumbnail, and it was sent as part of an
application/json payload, then we need to unpack a thumbnail
object payload and convert it to a Python ContentFile payload
instead. We use a try/catch because the optional nature means
we need to check using "if hasattr(request.data,'thumbnail'):"
as we as "if request.data['thumnail']" and these are pretty
much mutually exclusive patterns. A try/pass make far more sense.
'''
try:
thumbnail = request_data['thumbnail']
# do we actually need to repack as ContentFile?
if thumbnail['name'] and thumbnail['base64']:
name = thumbnail['name']
encdata = thumbnail['base64']
proxy = ContentFile(base64.b64decode(encdata), name=name)
request_data['thumbnail'] = proxy
except KeyError:
pass
# we also want to make sure that tags are properly split
# on commas, in case we get e.g. ['a', 'b' 'c,d']
if 'tags' in request_data:
tags = request_data['tags']
filtered_tags = []
for tag in tags:
if ',' in tag:
filtered_tags = filtered_tags + tag.split(',')
else:
filtered_tags.append(tag)
request_data['tags'] = filtered_tags
serializer = self.get_serializer_class()(
data=request_data,
context={'user': user},
)
if serializer.is_valid():
# ensure that the published_by is always the user doing
# the posting, and set 'featured' to false.
#
# see https://github.com/mozilla/network-pulse-api/issues/83
moderation_state = ModerationState.objects.get(
name='Pending'
)
if is_staff_address(user.email):
moderation_state = ModerationState.objects.get(
name='Approved'
)
# save the entry
saved_entry = serializer.save(
published_by=user,
featured=False,
moderation_state=moderation_state
)
return Response({'status': 'submitted', 'id': saved_entry.id})
else:
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
else:
return Response(
"post validation failed - {}".format(validation_result),
status=status.HTTP_400_BAD_REQUEST
)
class ProjectEntriesListView(EntriesListView):
def get_serializer_class(self):
return ProjectEntrySerializer
class NewsEntriesListView(EntriesListView):
def get_serializer_class(self):
return NewsEntrySerializer
class CurriculumEntriesListView(EntriesListView):
def get_serializer_class(self):
return CurriculumEntrySerializer
class InfoEntriesListView(EntriesListView):
def get_serializer_class(self):
return InfoEntrySerializer
class SessionEntriesListView(EntriesListView):
def get_serializer_class(self):
return SessionEntrySerializer