Skip to content

Commit

Permalink
Merge 8a815aa into fde9892
Browse files Browse the repository at this point in the history
  • Loading branch information
babbageLabs committed Dec 12, 2018
2 parents fde9892 + 8a815aa commit 3a76336
Show file tree
Hide file tree
Showing 23 changed files with 351 additions and 12 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,5 @@ db.sqlite3
authors/apps/*/migrations/*

logfile
# search engine index directory
whoosh_index/
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,66 @@ No additional parameters required

`GET /api/tags`

### Search & Filter
The application offers an endpoint for filtering and searching for article
using different criteria as follows.
##### Filter
`GET /api/articles/q/filter/< ?param=value&param2=value >`

The filter backend provides filter by the following parameters:

- title
- title__contains
- description
- description__contains
- slug
- slug__contains
- body
- body__contains
- taglist
- taglist__contains
- createdAt
- createdAt__year__lt
- createdAt__year__gt
- updatedAt
- updatedAt__year__lt
- updatedAt__year__gt
- author__username

##### Search

`GET /api/articles/q/search/?`

The search endpoint employs the Whoosh backend to index the articles to provide
faster search and more search parameters:

- author
- title
- tags
- keywords
- pub_date
- edit_date
- slug

all the above parameters take the following additional options as
`parameter__option` or `parameter__not_option`

```
content
contains
exact
gt
gte
lt
lte
in
startswith
endswith
range
fuzzy
```


## Setting up the application
```
git clone https://github.com/andela/ah-jumanji.git
Expand Down
17 changes: 14 additions & 3 deletions authors/apps/articles/serializers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from rest_framework import serializers

from .models import Articles
from authors.apps.authentication.models import User


class ArticleSerializer(serializers.ModelSerializer):

class Meta:
model = Articles
fields = ("__all__")
Expand Down Expand Up @@ -118,15 +118,14 @@ def deleteArticle(self, passed_slug):
deleted_article = Articles.objects.get(slug=passed_slug)
title = deleted_article.title
deleted_article.delete()
return('Article title: {} deleted successfully'.format(title))
return ('Article title: {} deleted successfully'.format(title))

except Exception as What_is_this:
print('Received error is : {}'.format(What_is_this))
return ("Article does not exist")


class AuthorSerializer(serializers.ModelSerializer):

class Meta:
model = User
fields = ("__all__")
Expand All @@ -141,3 +140,15 @@ def get_author_objects(self, id):
# 'following': profile.following
}
return (author)


class BasicArticleSerializer(serializers.ModelSerializer):
"""A basic article information serializer"""
author = serializers.SerializerMethodField()

class Meta:
model = Articles
exclude = ('id',)

def get_author(self, obj):
return obj.author.username
7 changes: 5 additions & 2 deletions authors/apps/articles/urls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from django.urls import path
from django.urls import path, include
from . import views

urlpatterns = [
path('', views.ArticleView.as_view(), name='articles'),
path('<slug>/', views.ArticleSpecificFunctions.as_view(),
name='articleSpecific')]
name='articleSpecific'),
path('q/', include('authors.apps.search.urls'),
name="filter-articles")
]
20 changes: 19 additions & 1 deletion authors/apps/authentication/tests/factories/authentication.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import factory
from django.contrib.auth import get_user_model
from django.utils.text import slugify
from factory import fuzzy

from authors.apps.articles.models import Articles
from authors.apps.profiles.models import Profile, Following


Expand All @@ -20,7 +23,7 @@ class Meta:

username = factory.Sequence(lambda n: 'test_user%s' % n)
email = factory.LazyAttribute(lambda o: '%s@email.com' % o.username)
password = factory.Faker('password')
password = 'Jake123#'
is_active = True


Expand All @@ -39,3 +42,18 @@ class Meta:

follower = factory.SubFactory(UserFactory2)
followed = factory.SubFactory(UserFactory2)


class ArticlesFactory(factory.DjangoModelFactory):
"""Generate instances of articles in the DB"""

class Meta:
model = Articles
django_get_or_create = ('author', 'title')

title = fuzzy.FuzzyText(length=20, prefix='title ', suffix=' text')
description = fuzzy.FuzzyText(length=20, prefix='description ', )
body = fuzzy.FuzzyText(length=200, prefix='body ', suffix=' text')
tagList = fuzzy.FuzzyChoice(['music', 'tech', 'lifestyle', 'money'])
slug = slugify(title)
author = factory.SubFactory(UserFactory2)
1 change: 0 additions & 1 deletion authors/apps/authentication/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,4 @@
'users/activate/<uidb64>/<token>',
ActivateAPIView.as_view(),
name='activate'),

]
6 changes: 3 additions & 3 deletions authors/apps/profiles/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,20 @@ def update(self, instance, prof_data):
"""
# For every item provided in the payload,
# amend the profile accordingly
for(key, value) in prof_data.items():
for (key, value) in prof_data.items():
setattr(instance.profile, key, value)
instance.save()

return instance


class ProfileSerializer2(serializers.ModelSerializer):
class BasicProfileSerializer(serializers.ModelSerializer):
user = serializers.SerializerMethodField()
following = serializers.SerializerMethodField()

class Meta:
model = Profile
exclude = ('username',)
fields = ('user', 'bio', 'profile_photo', 'following')

def get_user(self, obj):
"""
Expand Down
1 change: 1 addition & 0 deletions authors/apps/profiles/tests/test_follower_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def test_get_user_profile(self, test_user, test_auth_client, test_profile):
reverse('profile-details', args=[test_user.username]))
assert response.status_code == 200
assert 'following' in response.data
logger.error(response.data)
assert response.data['user'] == test_user.username
assert isinstance(response.data['following'], bool)

Expand Down
4 changes: 2 additions & 2 deletions authors/apps/profiles/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# local imports
from authors.apps.profiles.serializers import (
ProfileSerializer, FollowingSerializer, FollowedSerializer,
FollowersSerializer, ProfileSerializer2)
FollowersSerializer, BasicProfileSerializer)

from authors.apps.profiles.models import Profile, Following

Expand Down Expand Up @@ -96,7 +96,7 @@ class GetUserProfile(APIView):
Defines the view for getting a User's profile
"""
permission_classes = (IsAuthenticated,)
serializer_class = ProfileSerializer2
serializer_class = BasicProfileSerializer

def get(self, request, *args, **kwargs):
"""fetch the user profile"""
Expand Down
Empty file added authors/apps/search/__init__.py
Empty file.
Empty file added authors/apps/search/admin.py
Empty file.
5 changes: 5 additions & 0 deletions authors/apps/search/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class SearchConfig(AppConfig):
name = 'authors.apps.search'
23 changes: 23 additions & 0 deletions authors/apps/search/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from django_filters import rest_framework as filters

from authors.apps.articles.models import Articles


class ArticlesFilter(filters.FilterSet):
# Filter for articles by date published,modified, using ISO 8601 formatted
# dates
publish_date = filters.IsoDateTimeFilter(field_name='created')
modified_date = filters.IsoDateTimeFilter(field_name='modified')

class Meta:
model = Articles
fields = {
'title': ['exact', 'contains'],
'slug': ['exact', 'contains'],
'description': ['exact', 'contains'],
'body': ['exact', 'contains'],
'tagList': ['exact', 'contains'],
'createdAt': ['exact', 'year__gt', 'year__lt'],
'updatedAt': ['exact', 'year__gt', 'year__lt'],
'author__username': ['exact', 'contains'],
}
Empty file added authors/apps/search/models.py
Empty file.
44 changes: 44 additions & 0 deletions authors/apps/search/search_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Create your search Indexes here.
"""
This file contains search indexes for Haystack.
SearchIndex objects are the way Haystack determines what data should be placed
in the search index and handles the flow of data in.
"""
import datetime

from haystack import indexes
from authors.apps.articles.models import Articles


class ArticlesIndex(indexes.SearchIndex, indexes.Indexable):
"""
This class contains the Search Index for Articles
"""
text = indexes.CharField(document=True, use_template=False)
author = indexes.CharField(model_attr="author")
title = indexes.CharField(model_attr="title")
tags = indexes.CharField(model_attr="tagList")
keywords = indexes.CharField(model_attr="body")
slug = indexes.CharField(model_attr="slug")
pub_date = indexes.CharField(model_attr='createdAt')
edit_date = indexes.CharField(model_attr='updatedAt')

@staticmethod
def prepare_author(obj):
"""return the username or blank"""
return '' if not obj.author else obj.author.username

@staticmethod
def prepare_autocomplete(obj):
return " ".join((
obj.author.username, obj.title, obj.description
))

def get_model(self):
"""defines the model to be indexed"""
return Articles

def index_queryset(self, using=None):
"""Used when the entire index for model is updated."""
return self.get_model().objects.filter(
createdAt__lte=datetime.datetime.now())
17 changes: 17 additions & 0 deletions authors/apps/search/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from drf_haystack.serializers import HaystackSerializer

from authors.apps.search.search_indexes import ArticlesIndex


class ArticleSearchSerializer(HaystackSerializer):
class Meta:
"""This is the list of indices to be included in this search"""
index_classes = [ArticlesIndex]
# this are the fields in the Index that are included in the search
fields = ['author', 'title', 'tags', 'keywords', 'autocomplete',
'pub_date', 'edit_date', 'slug']
ignore_fields = ["autocomplete"]

field_aliases = {
"q": "autocomplete"
}
Empty file.
78 changes: 78 additions & 0 deletions authors/apps/search/tests/test_search_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import logging

import pytest
from django.utils.http import urlencode
from rest_framework.reverse import reverse

from authors.apps.authentication.tests.factories.authentication import \
ArticlesFactory

logger = logging.getLogger(__file__)


@pytest.mark.django_db
class TestSearchFilter:
@staticmethod
def reverse_querystring(view, urlconf=None, args=None, kwargs=None,
current_app=None, query_kwargs=None):
'''Custom reverse to handle query strings.'''
base_url = reverse(view, urlconf=urlconf, args=args, kwargs=kwargs,
current_app=current_app)
if query_kwargs:
return '{}?{}'.format(base_url, urlencode(query_kwargs))

return base_url

def test_filter_endpoint_anonymous_user(self, test_client):
# add an article to the db
article = ArticlesFactory()
# view the article
logger.error(article.title)
# get the url with query arguments
url = self.reverse_querystring(
view='filter-articles',
query_kwargs={"title": article.title}
)
# search the DB
response = test_client.get(url)
# display the results
logger.error(response.data)

# test assertions
assert response.status_code == 200
assert len(response.data) == 1

def test_filter_endpoint_authenticated_user(self, test_auth_client):
# add an article to the db
article = ArticlesFactory()
# view the article
logger.error(article.title)
# get the url with query arguments
url = self.reverse_querystring(
view='filter-articles',
query_kwargs={"title": article.title}
)
# search the DB
response = test_auth_client.get(url)
# display the results
logger.error(response.data)

# test assertions
assert response.status_code == 200
assert len(response.data) == 1

def test_search_the_database(self, test_client):
# add 3 articles to the db
article = ArticlesFactory()
ArticlesFactory()
ArticlesFactory()

url = self.reverse_querystring(
view='search-articles',
query_kwargs={"title": article.title}
)
url = url.replace('+', '%20')
response = test_client.get(url)

assert response.status_code == 200
assert isinstance(response.data, list)
Loading

0 comments on commit 3a76336

Please sign in to comment.