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