diff --git a/LICENSE b/LICENSE
index 8dada3e..a76190a 100644
--- a/LICENSE
+++ b/LICENSE
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
- Copyright {yyyy} {name of copyright owner}
+ Copyright 2016 District Data Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/Makefile b/Makefile
index ed54281..f1989c5 100644
--- a/Makefile
+++ b/Makefile
@@ -15,7 +15,7 @@ DJANGO_POSTFIX := --settings=$(DJANGO_SETTINGS_MODULE) --pythonpath=$(PYTHONPATH
# Development Settings
LOCAL_SETTINGS := development
-DJANGO_LOCAL_SETTINGS_MODULE = $(PROJECT).settings
+DJANGO_LOCAL_SETTINGS_MODULE = $(PROJECT).settings.$(LOCAL_SETTINGS)
DJANGO_LOCAL_POSTFIX := --settings=$(DJANGO_LOCAL_SETTINGS_MODULE) --pythonpath=$(PYTHONPATH)
# Testing Settings
@@ -24,7 +24,7 @@ DJANGO_TEST_SETTINGS_MODULE = $(PROJECT).settings.$(TEST_SETTINGS)
DJANGO_TEST_POSTFIX := --settings=$(DJANGO_TEST_SETTINGS_MODULE) --pythonpath=$(PYTHONPATH)
# Apps to test
-APPS := minent
+APPS := minent fugato stream users voting
# Export targets not associated with files
.PHONY: test showenv coverage bootstrap pip virtualenv clean virtual_env_set truncate
diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..b1ec29c
--- /dev/null
+++ b/Procfile
@@ -0,0 +1 @@
+web: gunicorn minent.wsgi --log-file -
diff --git a/README.md b/README.md
index d55ba0a..b96a847 100644
--- a/README.md
+++ b/README.md
@@ -103,22 +103,24 @@ The image used in this README, [Answers][answers.jpg] by [Francisco Martins](htt
## Changelog
-The release versions that are sent to the Python package index (PyPI) are also tagged in Github. You can see the tags through the Github web application and download the tarball of the version you'd like. Additionally PyPI will host the various releases of Trinket (eventually).
+The release versions that are sent to the Python package index (PyPI) are also tagged in Github. You can see the tags through the Github web application and download the tarball of the version you'd like. Additionally PyPI will host the various releases of Minimum Entropy (eventually).
The versioning uses a three part version system, "a.b.c" - "a" represents a major release that may not be backwards compatible. "b" is incremented on minor releases that may contain extra features, but are backwards compatible. "c" releases are bug fixes or other micro changes that developers should feel free to immediately update to.
-### Version 0.1
+### Version 1.0 Beta 1
-* **tag**: [v0.1](#)
-* **deployment**: Pending
-* **commit**: [pending](#)
+* **tag**: [v1.0b1](https://github.com/DistrictDataLabs/minimum-entropy/releases/tag/v1.0b1)
+* **deployment**: Tuesday, July 5, 2016
+* **commit**: [see tag](#)
+
+This beta release for Version 1.0 simply moves the code over from Kyudo and modifies it to remove the research components and only present a question and answer system. Things are not perfect since the app was designed for a different research project. However, the core functionality - asking questions and answering them with Markdown, as well as up and down voting exists. This is a good start to beta to our faculty to see what they think!
[travis_img]: https://travis-ci.org/DistrictDataLabs/minimum-entropy.svg
[travis_href]: https://travis-ci.org/DistrictDataLabs/minimum-entropy
[waffle_img]: https://badge.waffle.io/DistrictDataLabs/minimum-entropy.png?label=ready&title=Ready
[waffle_href]: https://waffle.io/DistrictDataLabs/minimum-entropy
-[coveralls_img]: https://coveralls.io/repos/DistrictDataLabs/minimum-entropy/badge.svg
-[coveralls_href]: https://coveralls.io/r/DistrictDataLabs/minimum-entropy
+[coveralls_img]: https://coveralls.io/repos/github/DistrictDataLabs/minimum-entropy/badge.svg?branch=master
+[coveralls_href]:https://coveralls.io/github/DistrictDataLabs/minimum-entropy?branch=master
[answers.jpg]: https://flic.kr/p/82Ub7z
diff --git a/docs/about.md b/docs/about.md
index f08246a..43a44cc 100644
--- a/docs/about.md
+++ b/docs/about.md
@@ -49,10 +49,10 @@ The release versions that are sent to the Python package index (PyPI) are also t
The versioning uses a three part version system, "a.b.c" - "a" represents a major release that may not be backwards compatible. "b" is incremented on minor releases that may contain extra features, but are backwards compatible. "c" releases are bug fixes or other micro changes that developers should feel free to immediately update to.
-### Version 0.1
+### Version 1.0 Beta 1
-* **tag**: [v0.1](#)
-* **deployment**: When?
-* **commit**: (see tag)
+* **tag**: [v1.0b1](https://github.com/DistrictDataLabs/minimum-entropy/releases/tag/v1.0b1)
+* **deployment**: Tuesday, July 5, 2016
+* **commit**: [see tag](#)
-What is it?
+This beta release for Version 1.0 simply moves the code over from Kyudo and modifies it to remove the research components and only present a question and answer system. Things are not perfect since the app was designed for a different research project. However, the core functionality - asking questions and answering them with Markdown, as well as up and down voting exists. This is a good start to beta to our faculty to see what they think!
diff --git a/fugato/__init__.py b/fugato/__init__.py
new file mode 100644
index 0000000..eb7038b
--- /dev/null
+++ b/fugato/__init__.py
@@ -0,0 +1,20 @@
+# fugato
+# The fugato app is designed to collect questions.
+#
+# Author: Benjamin Bengfort
+# Created: Thu Oct 23 14:07:41 2014 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: __init__.py [] benjamin@bengfort.com $
+
+"""
+The fugato app is designed to collect questions.
+"""
+
+##########################################################################
+## Configuration
+##########################################################################
+
+default_app_config = 'fugato.apps.FugatoConfig'
diff --git a/fugato/admin.py b/fugato/admin.py
new file mode 100644
index 0000000..ea62208
--- /dev/null
+++ b/fugato/admin.py
@@ -0,0 +1,29 @@
+# fugato.admin
+# Description of the app for the admin site and CMS.
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jul 05 20:04:50 2016 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: admin.py [] benjamin@bengfort.com $
+
+"""
+Description of the app for the admin site and CMS.
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+
+from django.contrib import admin
+from fugato.models import Question, Answer
+
+##########################################################################
+## Register Models
+##########################################################################
+
+admin.site.register(Question)
+admin.site.register(Answer)
diff --git a/fugato/apps.py b/fugato/apps.py
new file mode 100644
index 0000000..669eed3
--- /dev/null
+++ b/fugato/apps.py
@@ -0,0 +1,31 @@
+# fugato.apps
+# Describes the Fugato application for Django
+#
+# Author: Benjamin Bengfort
+# Created: Wed Mar 04 15:38:51 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: apps.py [] benjamin@bengfort.com $
+
+"""
+Describes the Fugato application for Django
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.apps import AppConfig
+
+##########################################################################
+## Fugato Config
+##########################################################################
+
+class FugatoConfig(AppConfig):
+ name = 'fugato'
+ verbose_name = "Fugato"
+
+ def ready(self):
+ import fugato.signals
diff --git a/fugato/exceptions.py b/fugato/exceptions.py
new file mode 100644
index 0000000..1a279f7
--- /dev/null
+++ b/fugato/exceptions.py
@@ -0,0 +1,29 @@
+# fugato.exceptions
+# Custom exceptions for API
+#
+# Author: Benjamin Bengfort
+# Created: Wed Jan 21 14:59:27 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: exceptions.py [] benjamin@bengfort.com $
+
+"""
+Custom exceptions for API
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from rest_framework.exceptions import APIException
+
+##########################################################################
+## API Exceptions
+##########################################################################
+
+class DuplicateQuestion(APIException):
+
+ status_code = 400
+ default_detail = "question has already been asked"
diff --git a/fugato/managers.py b/fugato/managers.py
new file mode 100644
index 0000000..0bcfe12
--- /dev/null
+++ b/fugato/managers.py
@@ -0,0 +1,45 @@
+# fugato.managers
+# Custom managers for the fugato models
+#
+# Author: Benjamin Bengfort
+# Created: Wed Jul 22 14:03:52 2015 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: managers.py [] benjamin@bengfort.com $
+
+"""
+Custom managers for the fugato models
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.db import models
+from minent.utils import signature
+
+##########################################################################
+## Questions Manager
+##########################################################################
+
+class QuestionManager(models.Manager):
+
+ def dedupe(self, raise_for_exceptions=False, **kwargs):
+ """
+ Essentially a GET or CREATE method that checks if a duplicate
+ question already exists in the database by its signature. If
+ raise_for_exceptions is True, then will raise a DuplicateQuestion
+ exception, otherwise it will return None.
+
+ Returns question, created where created is a Boolean
+ """
+ qsig = signature(kwargs['text'])
+ query = self.filter(signature=qsig)
+ if query.exists():
+ if raise_for_exceptions:
+ raise DuplicateQuestion()
+ return query.first(), False
+
+ return self.create(**kwargs), True
diff --git a/fugato/migrations/0001_initial.py b/fugato/migrations/0001_initial.py
new file mode 100644
index 0000000..0fa2f97
--- /dev/null
+++ b/fugato/migrations/0001_initial.py
@@ -0,0 +1,66 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-07-06 00:28
+from __future__ import unicode_literals
+
+import autoslug.fields
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import model_utils.fields
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Answer',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
+ ('text', models.TextField(help_text='Edit in Markdown')),
+ ('text_rendered', models.TextField(editable=False)),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'get_latest_by': 'created',
+ 'db_table': 'answers',
+ },
+ ),
+ migrations.CreateModel(
+ name='Question',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
+ ('text', models.CharField(max_length=512)),
+ ('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='text', unique=True)),
+ ('signature', models.CharField(editable=False, max_length=28, unique=True)),
+ ('details', models.TextField(blank=True, default=None, help_text='Edit in Markdown', null=True)),
+ ('details_rendered', models.TextField(blank=True, default=None, editable=False, null=True)),
+ ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to=settings.AUTH_USER_MODEL)),
+ ('related', models.ManyToManyField(related_name='_question_related_+', to='fugato.Question')),
+ ],
+ options={
+ 'get_latest_by': 'created',
+ 'db_table': 'questions',
+ },
+ ),
+ migrations.AddField(
+ model_name='answer',
+ name='question',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='fugato.Question'),
+ ),
+ migrations.AddField(
+ model_name='answer',
+ name='related',
+ field=models.ManyToManyField(related_name='_answer_related_+', to='fugato.Answer'),
+ ),
+ ]
diff --git a/fugato/migrations/__init__.py b/fugato/migrations/__init__.py
new file mode 100644
index 0000000..1fe832f
--- /dev/null
+++ b/fugato/migrations/__init__.py
@@ -0,0 +1,18 @@
+# fugato.migrations
+# Database migrations for the fugato application
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jul 05 20:03:22 2016 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: __init__.py [] benjamin@bengfort.com $
+
+"""
+Database migrations for the fugato application
+"""
+
+##########################################################################
+## Imports
+##########################################################################
diff --git a/fugato/models.py b/fugato/models.py
new file mode 100644
index 0000000..48d8569
--- /dev/null
+++ b/fugato/models.py
@@ -0,0 +1,92 @@
+# fugato.models
+# Models for the fugato app
+#
+# Author: Benjamin Bengfort
+# Created: Thu Oct 23 14:05:24 2014 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: models.py [] benjamin@bengfort.com $
+
+"""
+Models for the fugato app.
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+import time
+
+from django.db import models
+from voting.models import Vote
+from minent.utils import nullable
+from autoslug import AutoSlugField
+from fugato.managers import QuestionManager
+from model_utils.models import TimeStampedModel
+from django.core.urlresolvers import reverse
+from django.contrib.contenttypes.fields import GenericRelation
+
+##########################################################################
+## Qustion and Answer Models
+##########################################################################
+
+class Question(TimeStampedModel):
+
+ text = models.CharField( max_length=512, null=False ) # The text of the question
+ slug = AutoSlugField( populate_from='text', unique=True ) # The slug of the question
+ signature = models.CharField( max_length=28, unique=True, editable=False ) # The normalized signature
+ details = models.TextField( help_text="Edit in Markdown", **nullable ) # Additional details about the question
+ details_rendered = models.TextField( editable=False, **nullable ) # HTML rendered details text from MD
+ related = models.ManyToManyField( 'self', editable=True, blank=True ) # Links between related questions
+ author = models.ForeignKey( 'auth.User', related_name='questions' ) # The author of the question
+ votes = GenericRelation( Vote, related_query_name='questions' ) # Vote on whether or not the question is relevant
+
+ ## Set custom manager on Question
+ objects = QuestionManager()
+
+ def get_absolute_url(self):
+ """
+ Return the detail view of the Question object
+ """
+ return reverse('question', kwargs={'slug': self.slug})
+
+ def get_api_detail_url(self):
+ """
+ Returns the API detail endpoint for the object
+ """
+ return reverse('api:question-detail', args=(self.pk,))
+
+ class Meta:
+ db_table = "questions"
+ get_latest_by = 'created'
+
+ def __str__(self):
+ return self.text
+
+
+class Answer(TimeStampedModel):
+
+ text = models.TextField( # The text of the answer (markdown)
+ null=False, blank=False,
+ help_text="Edit in Markdown"
+ )
+ text_rendered = models.TextField( editable=False, null=False ) # HTML rendered details of the question
+ related = models.ManyToManyField( 'self' ) # Links between related responses
+ author = models.ForeignKey( 'auth.User', related_name="answers" ) # The author of the answer
+ question = models.ForeignKey( 'fugato.Question', related_name="answers" ) # The question this answer answers
+ votes = GenericRelation( Vote, related_query_name='answers' ) # Votes for the goodness of the answer
+
+ class Meta:
+ db_table = "answers"
+ get_latest_by = 'created'
+
+ def get_api_detail_url(self):
+ """
+ Returns the API detail endpoint for the object
+ """
+ return reverse('api:answer-detail', args=(self.pk,))
+
+ def __str__(self):
+ return self.text
diff --git a/fugato/serializers.py b/fugato/serializers.py
new file mode 100644
index 0000000..ba84c30
--- /dev/null
+++ b/fugato/serializers.py
@@ -0,0 +1,116 @@
+# fugato.serializers
+# JSON Serializers for the Fugato app
+#
+# Author: Benjamin Bengfort
+# Created: Thu Oct 23 15:03:36 2014 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: serializers.py [] benjamin@bengfort.com $
+
+"""
+JSON Serializers for the Fugato app
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from fugato.models import *
+from fugato.exceptions import *
+from users.serializers import *
+
+from minent.utils import signature
+from rest_framework import serializers
+
+##########################################################################
+## Question Serializers
+##########################################################################
+
+class QuestionSerializer(serializers.HyperlinkedModelSerializer):
+ """
+ Serializes the Question object for use in the API.
+ """
+
+ author = UserSerializer(
+ default=serializers.CurrentUserDefault(),
+ read_only=True,
+ )
+
+ page_url = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Question
+ fields = ('url', 'text', 'author', 'page_url', 'details', 'details_rendered')
+ extra_kwargs = {
+ 'url': {'view_name': 'api:question-detail',},
+ 'details_rendered': {'read_only': True},
+ }
+
+ ######################################################################
+ ## Serializer Methods
+ ######################################################################
+
+ def get_page_url(self, obj):
+ """
+ Returns the models' detail absolute url.
+ """
+ return obj.get_absolute_url()
+
+ #####################################################################
+ ## Override create and update for API
+ ######################################################################
+
+ def create(self, validated_data):
+ """
+ Override the create method to deal with duplicate questions and
+ other API-specific errors that can happen on Question creation.
+ """
+
+ ## Check to make sure there is no duplicate
+ qsig = signature(validated_data['text'])
+ if Question.objects.filter(signature=qsig).exists():
+ raise DuplicateQuestion()
+
+ ## Create the model as before
+ return super(QuestionSerializer, self).create(validated_data)
+
+ def update(self, instance, validated_data):
+ """
+ Override the update method to perform non-duplication checks that
+ aren't instance-specific and to determine if other fields should
+ be updated like the parse or the concepts.
+
+ Currently this is simply the default behavior.
+
+ TODO:
+ - Check if reparsing needs to be performed
+ - Check if concepts need to be dealt with
+ - Check if the question text has changed and what to do
+ """
+ return super(QuestionSerializer, self).update(instance, validated_data)
+
+
+##########################################################################
+## Answer Serializers
+##########################################################################
+
+class AnswerSerializer(serializers.HyperlinkedModelSerializer):
+ """
+ Serializes the Answer object for use in the API.
+ """
+
+ author = UserSerializer(
+ default=serializers.CurrentUserDefault(),
+ read_only=True,
+ )
+
+ class Meta:
+ model = Answer
+ fields = ('url', 'text', 'text_rendered', 'author', 'question', 'created', 'modified')
+ read_only_fields = ('text_rendered', 'author')
+ extra_kwargs = {
+ 'url': {'view_name': 'api:answer-detail',},
+ 'question': {'view_name': 'api:question-detail',}
+ }
diff --git a/fugato/signals.py b/fugato/signals.py
new file mode 100644
index 0000000..77fee9f
--- /dev/null
+++ b/fugato/signals.py
@@ -0,0 +1,87 @@
+# fugato.signals
+# Signals for the fugato app
+#
+# Author: Benjamin Bengfort
+# Created: Wed Mar 04 15:18:41 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: signals.py [] benjamin@bengfort.com $
+
+"""
+Signals for the fugato app
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from stream.signals import stream
+from django.dispatch import receiver
+from django.db.models.signals import pre_save, post_save
+
+from django.conf import settings
+from minent.utils import signature, nullable, htmlize
+from fugato.models import Question, Answer
+
+##########################################################################
+## Question Signals
+##########################################################################
+
+@receiver(pre_save, sender=Question)
+def question_normalization(sender, instance, *args, **kwargs):
+ instance.signature = signature(instance.text)
+
+
+@receiver(pre_save, sender=Question)
+def question_render_markdown(sender, instance, *args, **kwargs):
+ if instance.details == "":
+ instance.details = None
+
+ if instance.details is not None:
+ instance.details_rendered = htmlize(instance.details)
+ else:
+ instance.details_rendered = None
+
+
+@receiver(post_save, sender=Question)
+def send_asked_activity_signal(sender, instance, created, **kwargs):
+ """
+ Sends the "asked" activity to the stream on Question create
+ """
+ if created:
+ joined = {
+ 'sender': sender,
+ 'actor': instance.author,
+ 'verb': 'ask',
+ 'target': instance,
+ 'timestamp': instance.created,
+ }
+ stream.send(**joined)
+
+##########################################################################
+## Answer Signals
+##########################################################################
+
+@receiver(pre_save, sender=Answer)
+def answer_render_markdown(sender, instance, *args, **kwargs):
+ if instance.text is not None:
+ instance.text_rendered = htmlize(instance.text)
+
+
+@receiver(post_save, sender=Answer)
+def send_answered_activity_signal(sender, instance, created, **kwargs):
+ """
+ Sends the "answered" activity to the stream on Question create
+ """
+ if created:
+ activity = {
+ 'sender': sender,
+ 'actor': instance.author,
+ 'verb': 'answer',
+ 'theme': instance,
+ 'target': instance.question,
+ 'timestamp': instance.created,
+ }
+ stream.send(**activity)
diff --git a/fugato/tests.py b/fugato/tests.py
new file mode 100644
index 0000000..1201d8e
--- /dev/null
+++ b/fugato/tests.py
@@ -0,0 +1,417 @@
+# fugato.tests
+# Tests the fugato app
+#
+# Author: Benjamin Bengfort
+# Created: Fri Jan 23 07:27:20 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: tests.py [] benjamin@bengfort.com $
+
+"""
+Tests the fugato app
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from unittest import skip
+from fugato.models import *
+from voting.models import *
+from stream.signals import stream
+from stream.models import StreamItem
+from django.test import TestCase, Client
+from django.contrib.auth.models import User
+from rest_framework import status
+from rest_framework.test import APIClient
+from urllib.parse import urlsplit
+from django.contrib.contenttypes.models import ContentType
+
+try:
+ from unittest.mock import MagicMock
+except ImportError:
+ from mock import MagicMock
+
+##########################################################################
+## Fixtures
+##########################################################################
+
+fixtures = {
+ 'user': {
+ 'username': 'jdoe',
+ 'first_name': 'John',
+ 'last_name': 'Doe',
+ 'email': 'jdoe@example.com',
+ 'password': 'supersecret',
+ },
+ 'voter' : {
+ 'username': 'bobbyd',
+ 'first_name': 'Bob',
+ 'last_name': 'Dylan',
+ 'email': 'bobby@example.com',
+ 'password': 'dontguessthis',
+ },
+ 'question': {
+ 'text': 'Why did the chicken cross the road?',
+ 'author': None
+ },
+ 'answer': {
+ 'question': None,
+ 'author': None,
+ 'text': 'To get to the other side.',
+ }
+}
+
+##########################################################################
+## Fugato models tests
+##########################################################################
+
+class QuestionModelTest(TestCase):
+
+ def setUp(self):
+ self.user = User.objects.create_user(**fixtures['user'])
+ fixtures['question']['author'] = self.user
+
+ def test_question_ask_send_stream(self):
+ """
+ Assert that when a question is created it sends the "ask" stream signal
+ """
+ handler = MagicMock()
+ stream.connect(handler)
+ question = Question.objects.create(**fixtures['question'])
+
+ # Ensure that the signal was sent once with required arguments
+ handler.assert_called_once_with(verb='ask', sender=Question,
+ timestamp=question.created, actor=self.user,
+ target=question, signal=stream)
+
+ def test_question_asked_activity(self):
+ """
+ Assert that when a question is asked, there is an activity stream item
+ """
+ question = Question.objects.create(**fixtures['question'])
+ target_content_type = ContentType.objects.get_for_model(question)
+ target_object_id = question.id
+
+ query = StreamItem.objects.filter(verb='ask', actor=self.user,
+ target_content_type=target_content_type, target_object_id=target_object_id)
+ self.assertEqual(query.count(), 1, "no stream item created!")
+
+class AnswerModelTest(TestCase):
+
+ def setUp(self):
+ self.user = User.objects.create_user(**fixtures['user'])
+ fixtures['question']['author'] = self.user
+ fixtures['answer']['author'] = self.user
+
+ self.question = Question.objects.create(**fixtures['question'])
+ fixtures['answer']['question'] = self.question
+
+ def test_question_answer_send_stream(self):
+ """
+ Assert that when an Answer is created it sends the "answer" stream signal
+ """
+ handler = MagicMock()
+ stream.connect(handler)
+ answer = Answer.objects.create(**fixtures['answer'])
+
+ # Ensure that the signal was sent once with required arguments
+ handler.assert_called_once_with(verb='answer', sender=Answer,
+ timestamp=answer.created, actor=self.user, theme=answer,
+ target=self.question, signal=stream)
+
+ def test_question_answered_activity(self):
+ """
+ Assert that when a question is answered, there is an activity stream item
+ """
+ answer = Answer.objects.create(**fixtures['answer'])
+ target_content_type = ContentType.objects.get_for_model(answer.question)
+ target_object_id = answer.question.id
+ theme_content_type = ContentType.objects.get_for_model(answer)
+ theme_object_id = answer.id
+
+ query = {
+ 'verb': 'answer',
+ 'actor': self.user,
+ 'target_content_type': target_content_type,
+ 'target_object_id': target_object_id,
+ 'theme_content_type': theme_content_type,
+ 'theme_object_id': theme_object_id,
+ }
+
+ query = StreamItem.objects.filter(**query)
+ self.assertEqual(query.count(), 1, "no stream item created!")
+
+
+##########################################################################
+## Fugato API Views tests
+##########################################################################
+
+class QuestionAPIViewSetTest(TestCase):
+
+ def setUp(self):
+ self.user = User.objects.create_user(**fixtures['user'])
+ fixtures['question']['author'] = self.user
+ self.client = APIClient()
+
+ def login(self):
+ credentials = {
+ 'username': fixtures['user']['username'],
+ 'password': fixtures['user']['password'],
+ }
+
+ return self.client.login(**credentials)
+
+ def logout(self):
+ return self.client.logout();
+
+ def test_question_list_auth(self):
+ """
+ Assert GET /api/question/ returns 403 when not logged in
+ """
+ endpoint = reverse('api:question-list')
+ response = self.client.get(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_question_create_auth(self):
+ """
+ Assert POST /api/question/ returns 403 when not logged in
+ """
+ endpoint = reverse('api:question-list')
+ response = self.client.post(endpoint, {'text': 'Where are my keys?'}, format='json')
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_question_retrieve_auth(self):
+ """
+ Assert GET /api/question/:id/ returns 403 when not logged in
+ """
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url()
+ response = self.client.get(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_question_update_auth(self):
+ """
+ Assert PUT /api/question/:id/ returns 403 when not logged in
+ """
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url()
+ response = self.client.put(endpoint, {'text': 'Why did the bear cross the road?'}, format='json')
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_question_delete_auth(self):
+ """
+ Assert DELETE /api/question/:id/ returns 403 when not logged in
+ """
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url()
+ response = self.client.delete(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_question_vote_post_auth(self):
+ """
+ Assert POST /api/question/:id/vote returns 403 when not logged in
+ """
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url() + "vote/"
+
+ response = self.client.post(endpoint, {'vote': 1}, format='json')
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_question_answers_list_auth(self):
+ """
+ Assert GET /api/question/:id/answers returns 403 when not logged in
+ """
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url() + "answers/"
+
+ response = self.client.get(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_question_vote_get_auth(self):
+ """
+ Assert GET /api/question/:id/vote returns a 400
+ """
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url() + "vote/"
+
+ response = self.client.get(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ self.login()
+
+ response = self.client.get(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+ @skip("pending implementation")
+ def test_question_list(self):
+ """
+ Test GET /api/question/ returns question list
+ """
+ self.login()
+
+ endpoint = reverse('api:question-list')
+ response = self.client.get(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ @skip("pending implementation")
+ def test_question_create(self):
+ """
+ Test POST /api/question/ creates a question
+ """
+ self.login()
+
+ endpoint = reverse('api:question-list')
+ response = self.client.post(endpoint, {'text': 'Where are my keys?'}, format='json')
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ @skip("pending implementation")
+ def test_question_retrieve(self):
+ """
+ Test GET /api/question/:id/ returns a question detail
+ """
+ self.login()
+
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url()
+ response = self.client.get(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ @skip("pending implementation")
+ def test_question_update(self):
+ """
+ Test PUT /api/question/:id/ updates a question
+ """
+ self.login()
+
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url()
+ response = self.client.put(endpoint, {'text': 'Why did the bear cross the road?'}, format='json')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ def test_question_delete_auth(self):
+ """
+ Test DELETE /api/question/:id/ deletes a question
+ """
+ self.login()
+
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url()
+ response = self.client.delete(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+ response = self.client.get(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+ self.assertFalse(Question.objects.filter(pk=question.pk).exists())
+
+ def test_question_create_vote(self):
+ """
+ Assert POST /api/question/:id/vote creates a vote for a user
+ """
+ self.login()
+
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url() + "vote/"
+
+ self.assertEqual(question.votes.count(), 0)
+
+ response = self.client.post(endpoint, {'vote': 1}, format='json')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ expected = {'created': True, 'status': 'vote recorded', 'display': 'upvote'}
+ self.assertDictContainsSubset(expected, response.data)
+
+ self.assertEqual(question.votes.count(), 1)
+
+ def test_question_update_vote(self):
+ """
+ Assert POST /api/question/:id/vote updates if already voted
+ """
+ self.login()
+
+ question = Question.objects.create(**fixtures['question'])
+ vote, _ = Vote.objects.punch_ballot(content=question, user=self.user, vote=1)
+ endpoint = question.get_api_detail_url() + "vote/"
+
+ self.assertEqual(question.votes.count(), 1)
+
+ response = self.client.post(endpoint, {'vote': -1}, format='json')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ expected = {'created': False, 'status': 'vote recorded', 'display': 'downvote'}
+ self.assertDictContainsSubset(expected, response.data)
+
+ self.assertEqual(question.votes.count(), 1)
+
+ def test_question_vote_response(self):
+ """
+ Ensure POST /api/question/:id/vote response contains expected data
+ """
+ self.login()
+
+ question = Question.objects.create(**fixtures['question'])
+ endpoint = question.get_api_detail_url() + "vote/"
+
+ self.assertEqual(question.votes.count(), 0)
+
+ response = self.client.post(endpoint, {'vote': 1}, format='json')
+ expected = {
+ 'created': True,
+ 'status': 'vote recorded',
+ 'display': 'upvote',
+ 'upvotes': 1, # Required for Question FE app (resets button counts)
+ 'downvotes': 0, # Required for Question FE app (resets button counts)
+ }
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ for key, val in expected.items():
+ self.assertIn(key, response.data)
+ self.assertEqual(val, response.data[key])
+
+ @skip("pending implementation")
+ def test_question_answers_list(self):
+ """
+ Ensure GET /api/question/:id/answers response works
+ """
+ pass
+
+class AnswerAPIViewSetTest(TestCase):
+
+ def setUp(self):
+ self.usera = User.objects.create_user(**fixtures['user'])
+ self.userb = User.objects.create_user(**fixtures['voter'])
+
+ fixtures['question']['author'] = self.usera
+ fixtures['answer']['author'] = self.userb
+
+ self.question = Question.objects.create(**fixtures['question'])
+ fixtures['answer']['question'] = self.question
+
+ self.client = APIClient()
+
+ def login(self):
+ credentials = {
+ 'username': fixtures['user']['username'],
+ 'password': fixtures['user']['password'],
+ }
+
+ return self.client.login(**credentials)
+
+ def logout(self):
+ return self.client.logout();
+
+ def test_answer_url_view_kwarg(self):
+ """
+ Check that the answer provides a url
+ """
+ answer = Answer.objects.create(**fixtures['answer'])
+ endpoint = answer.get_api_detail_url()
+
+ self.login()
+ response = self.client.get(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertIn('url', response.data)
+
+ url = urlsplit(response.data.get('url', '')).path
+ self.assertEqual(url, endpoint)
diff --git a/fugato/views.py b/fugato/views.py
new file mode 100644
index 0000000..6f0fac8
--- /dev/null
+++ b/fugato/views.py
@@ -0,0 +1,135 @@
+# fugato.views
+# Views for the Fugato app
+#
+# Author: Benjamin Bengfort
+# Created: Thu Oct 23 15:05:12 2014 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: views.py [] benjamin@bengfort.com $
+
+"""
+Views for the Fugato app
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from fugato.models import *
+from voting.models import Vote
+from fugato.serializers import *
+from voting.serializers import *
+from rest_framework import viewsets
+from users.mixins import LoginRequired
+from users.permissions import IsAuthorOrReadOnly
+from django.views.generic import DetailView, TemplateView
+
+from rest_framework import status
+from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.decorators import detail_route, list_route
+
+##########################################################################
+## HTTP Generated Views
+##########################################################################
+
+class QuestionDetail(LoginRequired, DetailView):
+
+ model = Question
+ template_name = "fugato/question.html"
+ context_object_name = "question"
+
+
+##########################################################################
+## API HTTP/JSON Views
+##########################################################################
+
+class QuestionTypeaheadViewSet(viewsets.ViewSet):
+ """
+ Endpoint for returning a typeahead of question texts.
+ """
+
+ def list(self, request):
+ queryset = Question.objects.values_list('text', flat=True)
+ return Response(queryset)
+
+
+class QuestionViewSet(viewsets.ModelViewSet):
+
+ queryset = Question.objects.order_by('-created')
+ serializer_class = QuestionSerializer
+
+ @detail_route(methods=['post'], permission_classes=[IsAuthenticated])
+ def vote(self, request, pk=None):
+ """
+ Note that the upvotes and downvotes keys are required by the front-end
+ """
+ question = self.get_object()
+ serializer = VotingSerializer(data=request.data, context={'request': request})
+ if serializer.is_valid():
+
+ kwargs = {
+ 'content': question,
+ 'user': request.user,
+ 'vote': serializer.validated_data['vote'],
+ }
+
+ _, created = Vote.objects.punch_ballot(**kwargs)
+ response = serializer.data
+ response.update({'status': 'vote recorded', 'created': created,
+ 'upvotes': question.votes.upvotes().count(),
+ 'downvotes': question.votes.downvotes().count()})
+ return Response(response)
+ else:
+ return Response(serializer.errors,
+ status=status.HTTP_400_BAD_REQUEST)
+
+ @detail_route(methods=['get'], permission_classes=[IsAuthenticated])
+ def answers(self, request, pk=None):
+ """
+ Returns a list of all answers associated with the question
+ """
+ question = self.get_object()
+ answers = question.answers.order_by('created') # TODO: order by vote count
+ page = self.paginate_queryset(answers)
+
+ if page is not None:
+ serializer = AnswerSerializer(page, context={'request': request})
+ paginator = self.pagination_class()
+ return self.get_paginated_response(serializer.data)
+
+ serializer = AnswerSerializer(answers, context={'request': request})
+ return Response(serializer.data)
+
+
+class AnswerViewSet(viewsets.ModelViewSet):
+
+ queryset = Answer.objects.order_by('-created')
+ serializer_class = AnswerSerializer
+
+ @detail_route(methods=['post'], permission_classes=[IsAuthenticated])
+ def vote(self, request, pk=None):
+ """
+ Note that the upvotes and downvotes keys are required by the front-end
+ """
+ answer = self.get_object()
+ serializer = VotingSerializer(data=request.data, context={'request': request})
+ if serializer.is_valid():
+
+ kwargs = {
+ 'content': answer,
+ 'user': request.user,
+ 'vote': serializer.validated_data['vote'],
+ }
+
+ _, created = Vote.objects.punch_ballot(**kwargs)
+ response = serializer.data
+ response.update({'status': 'vote recorded', 'created': created,
+ 'upvotes': answer.votes.upvotes().count(),
+ 'downvotes': answer.votes.downvotes().count()})
+ return Response(response)
+ else:
+ return Response(serializer.errors,
+ status=status.HTTP_400_BAD_REQUEST)
diff --git a/minent/assets/css/minent.css b/minent/assets/css/minent.css
new file mode 100644
index 0000000..3d79936
--- /dev/null
+++ b/minent/assets/css/minent.css
@@ -0,0 +1,242 @@
+/* global stylesheet for the minimum-entropy application */
+
+html,
+body {
+ height: 100%;
+ /* The html and body elements cannot have any padding or margin. */
+ font-family: 'Open Sans', sans-serif;
+}
+
+body {
+ padding-top: 70px;
+}
+
+/* Wrapper for page content to push down footer */
+#wrap {
+ min-height: 100%;
+ height: auto;
+ /* Negative indent footer by its height */
+ margin: 0 auto -76px;
+ /* Pad bottom by footer height */
+ padding: 0 0 106px;
+}
+
+/* Set the fixed height of the footer here */
+#footer {
+ background-color: #fff;
+ border-top: 1px solid #eee;
+ height: 76px;
+ padding: 30px 15px;
+ font-size: 16px;
+ font-family: 'Open Sans Condensed' sans-serif;
+}
+
+#footer p {
+ margin: 0;
+}
+
+/* Helper classes */
+
+.text-justify {
+ text-align: justify;
+}
+
+.modal {
+ z-index: 1200;
+}
+
+.img-brand {
+ width: 22px;
+ height: 22px;
+ margin: -2px 6px 0px 0px;
+ float: left;
+}
+
+/* Specific Styles */
+
+.tab-pane {
+ padding-top: 10px;
+}
+
+#splash-box {
+ min-height: 400px;
+ margin-top: 10px;
+ text-align: center;
+}
+
+img.navbar-gravatar {
+ margin: -2px 6px 0 0;
+ float: left;
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ border-radius: 4px;
+ width: 22px;
+ height: 22px;
+}
+
+/* Editable items */
+
+.editable-item:hover a.edit-link:after{
+ content: "edit";
+}
+
+.editable-item a.edit-link {
+ font-size: small;
+ margin-left 6px;
+}
+
+
+/* Number stats inline */
+
+.number-stats li {
+ width: 32%;
+}
+
+.number-stats a {
+ display: block;
+ color: #333;
+ width: 100%;
+ text-align: center;
+ text-decoration: none;
+}
+
+.number-stats a:hover {
+ color: #428bca;
+ text-decoration: none;
+}
+
+.number-stats a span {
+ display: block;
+ width: 100%;
+ text-align: center;
+}
+
+.statistic {
+ font-size: 1.8em;
+ font-weight: bold;
+ margin: 0;
+}
+
+.statlabel {
+ font-size: 0.7em;
+ color: #999;
+ margin: 0;
+}
+
+.question-details {
+ min-height: 30px;
+}
+
+/* Questions and Answers */
+
+.question {
+ padding-bottom: 12px;
+ border-bottom: 1px solid #eee;
+ margin-bottom: 8px;
+}
+
+.question-byline, .answer-info {
+ height: 34px;
+}
+
+.question-byline img, .answer-info img {
+ width: 32px;
+ height: 32px;
+ float: left;
+ margin-right: 8px;
+
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px
+}
+
+.question-byline p, .answer-info p {
+ margin: -2px 0px 0px 0px;
+}
+
+.question-details {
+ margin: 10px 0px;
+}
+
+/* Typeahead Boostrap Style Fix */
+span.twitter-typeahead {
+ width: 100%;
+}
+.input-group span.twitter-typeahead {
+ display: block !important;
+}
+.input-group span.twitter-typeahead .tt-dropdown-menu {
+ top: 32px !important;
+}
+.input-group.input-group-lg span.twitter-typeahead .tt-dropdown-menu {
+ top: 44px !important;
+}
+.input-group.input-group-sm span.twitter-typeahead .tt-dropdown-menu {
+ top: 28px !important;
+}
+
+.tt-query {
+ -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+ -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+}
+
+.tt-hint {
+ color: #999
+}
+
+.tt-menu {
+ max-height: 150px;
+ overflow-y: auto;
+ margin-top: 4px;
+ padding: 4px 0;
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ -webkit-border-radius: 4px;
+ -moz-border-radius: 4px;
+ border-radius: 4px;
+ -webkit-box-shadow: 0 5px 10px rgba(0,0,0,.2);
+ -moz-box-shadow: 0 5px 10px rgba(0,0,0,.2);
+ box-shadow: 0 5px 10px rgba(0,0,0,.2);
+}
+
+.tt-suggestion {
+ padding: 3px 20px;
+ line-height: 24px;
+}
+
+.tt-suggestion.tt-cursor {
+ color: #fff;
+ background-color: #0097cf;
+
+}
+
+.tt-suggestion p {
+ margin: 0;
+}
+
+/* D3 Graphs */
+
+.node {
+ stroke: #fff;
+ stroke-width: 1.5px;
+}
+
+.link {
+ stroke: #999;
+ stroke-opacity: .6;
+}
+
+/* Google Maps */
+
+#google_canvas {
+ height:200px;
+}
+#google_canvas h1 {
+ font-size:16px;
+}
+#google_canvas h2 {
+ font-size:14px;
+ font-weight:300;
+}
diff --git a/minent/assets/css/profile.css b/minent/assets/css/profile.css
new file mode 100644
index 0000000..e829e6a
--- /dev/null
+++ b/minent/assets/css/profile.css
@@ -0,0 +1,75 @@
+/* Profile specific styles */
+
+#profile-sidebar h2 {
+ font-size: 1.7em;
+ margin-bottom: 2px;
+}
+
+#profile-sidebar h3 {
+ margin-top: 0;
+ font-weight: normal;
+ font-size: 1.4em;
+}
+
+#profile-sidebar img {
+ width: 100%;
+}
+
+#profile-sidebar ul {
+ font-size: .99em;
+}
+
+#profile-sidebar ul li {
+ margin-bottom: 3px;
+}
+
+#profile-sidebar ul i, span.fa {
+ margin-right: 8px;
+ width: 18px;
+ text-align: center;
+}
+
+#profile-sidebar ul i {
+ font-size: .94em;
+ /*color: #999;*/
+}
+
+.gravatar {
+ position: relative;
+}
+
+.gravatar .mask {
+ text-align: center;
+ opacity: 0;
+ position: absolute;
+ bottom: 0; left: 0;
+ background-color: rgba(0,0,0,0.75);
+ width: 100%;
+ padding: 10px 0;
+
+ border-bottom-left-radius: 6px;
+ border-bottom-right-radius: 6px;
+ -webkit-border-bottom-left-radius: 6px;
+ -webkit-border-bottom-right-radius: 6px;
+ -moz-border-bottom-left-radius: 6px;
+ -moz-border-bottom-right-radius: 6px;
+}
+
+.gravatar .mask a {
+ color: white;
+ width: 100%;
+ height: 100%;
+}
+
+.gravatar:hover .mask {
+ opacity: 1;
+
+ -webkit-transition: opacity 0.3s ease-in-out;
+ -moz-transition: opacity 0.3s ease-in-out;
+ transition: opacity 0.3s ease-in-out;
+}
+
+ul.activity-stream li {
+ margin: 10px 0;
+ color: #333;
+}
diff --git a/minent/assets/favicon.png b/minent/assets/favicon.png
new file mode 100644
index 0000000..aceffda
Binary files /dev/null and b/minent/assets/favicon.png differ
diff --git a/minent/assets/humans.txt b/minent/assets/humans.txt
new file mode 100644
index 0000000..c6cbf5e
--- /dev/null
+++ b/minent/assets/humans.txt
@@ -0,0 +1,23 @@
+/* TEAM */
+
+ Developer: Benjamin Bengfort
+ Contact: bbengfort [at] districtdatalabs.com
+ Twitter: @bbengfort
+ From: Washington, DC
+
+ Developer: Tony Ojeda
+ Contact: tojeda [at] districtdatalabs.com
+ Twitter: @tonyojeda3
+ From: Washington, DC
+
+ Developer: Rebecca Bilbro
+ Contact: rbilbro [at] districtdatalabs.com
+ Twitter: @rebeccabilbro
+ From: Washington, DC
+
+/* SITE */
+
+ Last update: 2016/07/05
+ Language: English
+ Doctype: HTML5
+ IDE: Atom Editor 1.8.0
diff --git a/minent/assets/img/ddl-logo.png b/minent/assets/img/ddl-logo.png
new file mode 100644
index 0000000..3993254
Binary files /dev/null and b/minent/assets/img/ddl-logo.png differ
diff --git a/minent/assets/img/error_background.png b/minent/assets/img/error_background.png
new file mode 100644
index 0000000..bee0627
Binary files /dev/null and b/minent/assets/img/error_background.png differ
diff --git a/minent/assets/img/loader.gif b/minent/assets/img/loader.gif
new file mode 100644
index 0000000..3b234e2
Binary files /dev/null and b/minent/assets/img/loader.gif differ
diff --git a/minent/assets/img/logo.png b/minent/assets/img/logo.png
new file mode 100644
index 0000000..be0948c
Binary files /dev/null and b/minent/assets/img/logo.png differ
diff --git a/minent/assets/js/app/main.js b/minent/assets/js/app/main.js
new file mode 100644
index 0000000..313a216
--- /dev/null
+++ b/minent/assets/js/app/main.js
@@ -0,0 +1,29 @@
+/*
+ * Main entry point to the Minimum Entropy application
+ */
+
+define([
+ './models/fugato',
+ './views/list'
+],
+function(QuestionCollection, QuestionListView) {
+
+ var view = new QuestionListView();
+
+ return {
+ view: view,
+
+ start: function() {
+ // Do the CSRf AJAX Modification
+ var csrfToken = $('input[name="csrfmiddlewaretoken"]').val();
+ $.ajaxSetup({headers: {"X-CSRFToken": csrfToken}});
+
+ console.log("Minimum Entropy is started and ready");
+ },
+
+ stop: function() {
+ console.log("Minimum Entropy has stopped")
+ }
+ }
+
+});
diff --git a/minent/assets/js/app/models/fugato.js b/minent/assets/js/app/models/fugato.js
new file mode 100644
index 0000000..181a557
--- /dev/null
+++ b/minent/assets/js/app/models/fugato.js
@@ -0,0 +1,69 @@
+/**
+ * app/models/fugato.js
+ * Models for Fugato (Questions and Answers)
+ *
+ * Author: Benjamin Bengfort
+ * Created: Thu Oct 30 13:43:41 2014 -0400
+ *
+ * Copyright (C) 2014 District Data Labs
+ * For license information, see LICENSE.txt
+ *
+ * ID: fugato.js [] benjamin@bengfort.com $
+ */
+
+// JS Hint directives and strict mode
+/* globals exports,__filename */
+'use strict';
+
+define([
+ "backbone",
+ "underscore"
+], function(Backbone, _) {
+
+ // Question Model
+ var QuestionModel = Backbone.Model.extend({
+ defaults: {
+ text: null,
+ author: null,
+ created: null,
+ modified: null
+ }
+ });
+
+ var QuestionCollectionMeta = Backbone.Collection.extend({
+ defaults: {
+ count: 0,
+ next: null,
+ previous: null
+ }
+ });
+
+ var QuestionCollection = Backbone.Collection.extend({
+ url: "/api/questions/",
+ model: QuestionModel,
+ comparator: function(m) {
+ return -Date.parse(m.get('created'));
+ },
+
+ initialize: function() {
+ this.meta = new QuestionCollectionMeta();
+ },
+
+ parse: function(data) {
+ var results = data.results;
+ delete data.results;
+ this.meta.set(data);
+ return results;
+ },
+
+ sync: function() {
+ return Backbone.sync.apply(Backbone, arguments);
+ }
+
+ });
+
+ QuestionCollection.QuestionModel = QuestionModel;
+ QuestionCollection.QuestionCollectionMeta = QuestionCollectionMeta;
+ return QuestionCollection;
+
+});
diff --git a/minent/assets/js/app/templates/question.html b/minent/assets/js/app/templates/question.html
new file mode 100644
index 0000000..07343de
--- /dev/null
+++ b/minent/assets/js/app/templates/question.html
@@ -0,0 +1 @@
+<%- text %> (<%- created %>)
diff --git a/minent/assets/js/app/views/list.js b/minent/assets/js/app/views/list.js
new file mode 100644
index 0000000..526d175
--- /dev/null
+++ b/minent/assets/js/app/views/list.js
@@ -0,0 +1,56 @@
+define(
+function(require, exports, module) {
+
+ var Backbone = require('backbone');
+ var _ = require('underscore');
+ var QuestionCollection = require('../models/fugato')
+ var QuestionView = require('./question');
+
+ var QuestionListView = Backbone.View.extend({
+
+ el: "#questionApp",
+
+ initialize: function() {
+ this.input = this.$('input#query');
+ this.questions = new QuestionCollection;
+ this.views = [];
+
+ this.listenTo(this.questions, "sync", this.render);
+ this.listenTo(this.questions, "remove", function() { this.questions.fetch(); });
+ this.questions.fetch();
+ },
+
+ submitQuestion: function(event) {
+ event.preventDefault();
+ var query = this.input.val();
+
+ if (!query) { return; }
+
+ this.questions.create({text:query}, {wait:true});
+ this.input.val('');
+
+ return false;
+ },
+
+ render: function() {
+ var ul = this.$("#question-list");
+ _.invoke(this.views, "remove");
+ this.views.length = 0;
+
+ this.questions.each(function(model, idx) {
+ var item = new QuestionView({model:model});
+ item.render();
+ ul.append(item.$el);
+ this.views.push(item);
+ }, this);
+ },
+
+ events: {
+ "submit form": "submitQuestion"
+ }
+
+ });
+
+ return QuestionListView;
+
+});
diff --git a/minent/assets/js/app/views/question.js b/minent/assets/js/app/views/question.js
new file mode 100644
index 0000000..5924b2b
--- /dev/null
+++ b/minent/assets/js/app/views/question.js
@@ -0,0 +1,22 @@
+define([
+ 'backbone',
+ 'underscore',
+ 'text!../templates/question.html'
+],
+function(Backbone, _, questionHtml) {
+
+ var QuestionView = Backbone.View.extend({
+ tagName: "li",
+ template: _.template(questionHtml),
+
+ render: function() {
+ var html = this.template(this.model.toJSON());
+ this.$el.html(html);
+ return this;
+ }
+
+ });
+
+ return QuestionView;
+
+});
diff --git a/minent/assets/js/config/require.js b/minent/assets/js/config/require.js
new file mode 100644
index 0000000..35c487b
--- /dev/null
+++ b/minent/assets/js/config/require.js
@@ -0,0 +1,45 @@
+/**
+ * config/require.js
+ * Configuration for Require.js
+ *
+ * Copyright (C) 2016 District Data Labs
+ * For license information, see LICENSE.txt
+ *
+ * Author: Benjamin Bengfort
+ * Created: Wed Jan 22 23:52:24 2014 -0500
+ *
+ * ID: require.js [] benjamin@bengfort.com $
+ */
+
+requirejs.config({
+ baseUrl: '/assets/js',
+ paths: {
+ 'underscore': '//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min',
+ 'jquery': '//code.jquery.com/jquery-1.11.3.min',
+ 'bootstrap': '//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min',
+ 'backbone': '//cdnjs.cloudflare.com/ajax/libs/backbone.js/1.2.1/backbone-min',
+ 'text': '//cdnjs.cloudflare.com/ajax/libs/require-text/2.0.12/text.min',
+ 'mustache': '//cdnjs.cloudflare.com/ajax/libs/mustache.js/2.1.2/mustache.min',
+ 'moment': '//cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.3/moment.min',
+ 'typeahead': '//cdnjs.cloudflare.com/ajax/libs/typeahead.js/0.11.1/typeahead.bundle.min',
+ },
+ shim: {
+ 'underscore': {
+ exports: '_'
+ },
+ 'jquery': {
+ exports: '$'
+ },
+ 'backbone': {
+ deps: ['jquery', 'underscore'],
+ exports: 'Backbone'
+ },
+ 'bootstrap': {
+ deps: ['jquery']
+ },
+ 'typeahead': {
+ deps: ['jquery'],
+ exports: 'Bloodhound'
+ }
+ }
+});
diff --git a/minent/assets/js/utils/ask.js b/minent/assets/js/utils/ask.js
new file mode 100644
index 0000000..03e9146
--- /dev/null
+++ b/minent/assets/js/utils/ask.js
@@ -0,0 +1,104 @@
+/**
+ * utils/ask.js
+ * Javascript that runs the ask question dialogs
+ *
+ * Copyright (C) 2016 District Data Labs
+ * For license information, see LICENSE.txt
+ *
+ * Author: Benjamin Bengfort
+ * Created: Fri Jan 16 11:48:50 2015 -0500
+ *
+ * ID: hotkeys.js [] benjamin@bengfort.com $
+ */
+
+
+(function($) {
+ $(document).ready(function() {
+
+ var csrfToken = $('input[name="csrfmiddlewaretoken"]').val();
+ $.ajaxSetup({headers: {"X-CSRFToken": csrfToken}});
+ console.log("ask application ready");
+
+ // Form elements for ease of handling
+ var askQuestionModal = $("#askQuestionModal");
+ var askQuestionForm = $("#askQuestionForm");
+ var txtQuestion = $("#txtQuestion");
+ var helpBlock = $("#txtQuestionHelpBlock");
+ var btnAskQuestionBack = $("#btnAskQuestionBack");
+ var btnAskQuestionNext = $("#btnAskQuestionNext");
+ var question = "";
+ var questionsEndpoint = "/api/questions/";
+
+ // Capture enter in Question textarea and submit
+ txtQuestion.keydown(function(event) {
+ if (event.keyCode == 13) {
+ event.preventDefault();
+ askQuestionForm.submit();
+ return false;
+ }
+ });
+
+ // Handle question form submission
+ askQuestionForm.submit(function(event) {
+ event.preventDefault();
+ question = txtQuestion.val();
+
+ if (question == "") {
+ // No question has been entered
+ askQuestionForm.addClass('has-error');
+ helpBlock.text("Please enter a question");
+
+ } else {
+ // Disable the text area and the next button
+ txtQuestion.attr('disabled', 'disabled');
+ btnAskQuestionNext.attr('disabled',' disabled');
+
+ // Submit to similar questions endpoint
+ $.post(
+ questionsEndpoint,
+ {
+ "text": question
+ },
+ onQuestionPostSuccess
+ ).fail(onQuestionPostFailure);
+
+ }
+
+ return false;
+
+ });
+
+ function onQuestionPostSuccess(data) {
+ if (data.page_url) {
+ window.location.href = data.page_url;
+ } else {
+ console.log("Success!");
+ askQuestionModal.modal("hide");
+ }
+ }
+
+ function onQuestionPostFailure(jqXHR, textStatus, errorThrown) {
+ reason = jqXHR.responseJSON.detail;
+ askQuestionForm.addClass('has-error');
+ helpBlock.text(reason);
+
+ question = "";
+ txtQuestion.val('');
+ txtQuestion.removeAttr('disabled');
+ btnAskQuestionNext.removeAttr('disabled');
+ }
+
+ // On modal close, reset the form back to original state
+ askQuestionModal.on('hidden.bs.modal', function(event) {
+ question = "";
+ txtQuestion.val('');
+ txtQuestion.removeAttr('disabled');
+ helpBlock.text("");
+ btnAskQuestionNext.removeAttr('disabled');
+ btnAskQuestionBack.removeAttr('disabled');
+ btnAskQuestionBack.addClass('invisible');
+ askQuestionForm.removeClass('has-error');
+ });
+
+ });
+})(jQuery);
diff --git a/minent/assets/js/utils/hotkeys.js b/minent/assets/js/utils/hotkeys.js
new file mode 100644
index 0000000..22c4469
--- /dev/null
+++ b/minent/assets/js/utils/hotkeys.js
@@ -0,0 +1,26 @@
+/**
+ * utils/hotkeys.js
+ * Keyboard hotkeys for the Maximum Entropy App
+ *
+ * Copyright (C) 2016 District Data Labs
+ * For license information, see LICENSE.txt
+ *
+ * Author: Benjamin Bengfort
+ * Created: Wed Jan 22 23:52:24 2014 -0500
+ *
+ * ID: hotkeys.js [] benjamin@bengfort.com $
+ */
+
+
+(function($) {
+ $(document).ready(function() {
+
+ $(document).keyup(function(e) {
+ if (e.keyCode == 27) {
+ e.preventDefault();
+ window.location = "/admin/";
+ }
+ });
+
+ });
+})(jQuery);
diff --git a/minent/assets/robots.txt b/minent/assets/robots.txt
new file mode 100644
index 0000000..f8a0a9b
--- /dev/null
+++ b/minent/assets/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /admin
diff --git a/minent/settings/base.py b/minent/settings/base.py
index dbb1e76..1da818f 100644
--- a/minent/settings/base.py
+++ b/minent/settings/base.py
@@ -99,6 +99,7 @@ def environ_setting(name, default=None):
## Application definition
INSTALLED_APPS = [
# Django apps
+ 'grappelli', # Must come before admin
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@@ -107,8 +108,14 @@ def environ_setting(name, default=None):
'django.contrib.staticfiles',
# Third party apps
+ 'social.apps.django_app.default',
+ 'rest_framework',
# Minimum Entropy apps
+ 'stream', # Implements an activity stream for the app
+ 'users', # Handles Google OAuth and Profiles
+ 'fugato', # Initial query collection app
+ 'voting', # Handles the upvoting and downvoting of objects
]
## Request Handling
@@ -121,6 +128,7 @@ def environ_setting(name, default=None):
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
+ 'social.apps.django_app.middleware.SocialAuthExceptionMiddleware',
]
## Internationalization
@@ -190,6 +198,53 @@ def environ_setting(name, default=None):
},
]
+LOGIN_URL = '/login/google-oauth2/'
+LOGIN_REDIRECT_URL = '/app'
+
+## Support for Social Auth authentication backends
+AUTHENTICATION_BACKENDS = (
+ 'social.backends.google.GoogleOAuth2',
+ 'django.contrib.auth.backends.ModelBackend',
+)
+
+## Social authentication strategy
+SOCIAL_AUTH_STRATEGY = 'social.strategies.django_strategy.DjangoStrategy'
+SOCIAL_AUTH_STORAGE = 'social.apps.django_app.default.models.DjangoStorage'
+
+## Google-specific authentication keys
+SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = environ_setting("GOOGLE_OAUTH2_CLIENT_ID", "")
+SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = environ_setting("GOOGLE_OAUTH2_CLIENT_SECRET", "")
+
+LOGIN_REDIRECT_URL = "home"
+
+## Error handling
+SOCIAL_AUTH_LOGIN_ERROR_URL = "login"
+SOCIAL_AUTH_GOOGLE_OAUTH2_SOCIAL_AUTH_RAISE_EXCEPTIONS = False
+SOCIAL_AUTH_RAISE_EXCEPTIONS = False
+
+##########################################################################
+## Django REST Framework
+##########################################################################
+
+REST_FRAMEWORK = {
+
+ ## API Authentication
+ 'DEFAULT_AUTHENTICATION_CLASSES': (
+ 'rest_framework.authentication.SessionAuthentication',
+ ),
+
+ ## Default permissions to access the API
+ 'DEFAULT_PERMISSION_CLASSES': (
+ 'rest_framework.permissions.IsAuthenticated',
+ ),
+
+ ## Pagination in the API
+ 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
+ 'PAGINATE_BY': 50,
+ 'PAGINATE_BY_PARAM': 'per_page',
+ 'MAX_PAGINATE_BY': 200,
+}
+
##########################################################################
## Logging and Error Reporting
##########################################################################
diff --git a/minent/settings/production.py b/minent/settings/production.py
index e00abf3..54607e9 100644
--- a/minent/settings/production.py
+++ b/minent/settings/production.py
@@ -28,7 +28,11 @@
DEBUG = False
## Hosts
-ALLOWED_HOSTS = ['ddl-minent.herokuapp.com', 'minent.districtdatalabs.com']
+ALLOWED_HOSTS = [
+ 'minimum-entropy.herokuapp.com',
+ 'minent.districtdatalabs.com',
+ 'minimum-entropy.districtdatalabs.com',
+]
## Static files served by WhiteNoise
STATIC_ROOT = os.path.join(PROJECT, 'staticfiles')
diff --git a/minent/settings/testing.py b/minent/settings/testing.py
index a6ddb26..43d9262 100644
--- a/minent/settings/testing.py
+++ b/minent/settings/testing.py
@@ -41,3 +41,18 @@
'PORT': environ_setting('DB_PORT', '5432'),
},
}
+
+STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
+
+## Content without? side effects
+MEDIA_ROOT = "/tmp/minimum-entropy/media"
+STATIC_ROOT = "/tmp/minimum-entropy/static"
+
+##########################################################################
+## Django REST Framework
+##########################################################################
+
+REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = (
+ 'rest_framework.authentication.SessionAuthentication',
+ 'rest_framework.authentication.BasicAuthentication',
+)
diff --git a/minent/templates/404.html b/minent/templates/404.html
new file mode 100644
index 0000000..4aeeae4
--- /dev/null
+++ b/minent/templates/404.html
@@ -0,0 +1,6 @@
+{% extends 'error.html' %}
+
+{% block error_code %}404 Not Found{% endblock %}
+{% block error_details %}
+ Unfortunately, we couldn't find what you were looking for.
+{% endblock %}
diff --git a/minent/templates/500.html b/minent/templates/500.html
new file mode 100644
index 0000000..05aaaf0
--- /dev/null
+++ b/minent/templates/500.html
@@ -0,0 +1,6 @@
+{% extends 'error.html' %}
+
+{% block error_code %}500 Server Error{% endblock %}
+{% block error_details %}
+ Something went very wrong on the server, the administrators have been notified.
+{% endblock %}
diff --git a/minent/templates/admin/login.html b/minent/templates/admin/login.html
new file mode 100644
index 0000000..cd071bd
--- /dev/null
+++ b/minent/templates/admin/login.html
@@ -0,0 +1 @@
+{% extends 'registration/login.html' %}
diff --git a/minent/templates/app/index.html b/minent/templates/app/index.html
new file mode 100644
index 0000000..259c52c
--- /dev/null
+++ b/minent/templates/app/index.html
@@ -0,0 +1,70 @@
+{% extends 'page.html' %}
+{% load staticfiles %}
+{# This page displays the question feeds app #}
+
+ {% block content %}
+
+
+
+
+
+
+
+
+
+ {% for question in question_list %}
+
+
+
+
+
asked by {{ question.author.get_full_name }}
+
asked on {{ question.created|date }}
+
+
+ {% if question.details %}
+ {{ question.details_rendered|safe }}
+ {% endif %}
+
+
+
+ {{ question.answers.count }} answers
+ {{ question.votes.count }} votes
+ {{ question.topics.count }} topics
+
+
+
+ {% endfor %}
+
+
+
+
+
+ {% endblock %}
+
+ {% block javascripts %}
+
+ {{ block.super }}
+
+ {% endblock %}
diff --git a/minent/templates/base.html b/minent/templates/base.html
new file mode 100644
index 0000000..156601c
--- /dev/null
+++ b/minent/templates/base.html
@@ -0,0 +1,52 @@
+{% load staticfiles %}
+
+
+
+ {% block meta %}
+
+
+
+
+
+
+ {% endblock %}
+
+ {% block title %}Minimum Entropy{% endblock %}
+
+
+
+
+ {% block stylesheets %}
+
+
+
+ {% endblock %}
+
+
+
+ {% block body %}
+ {% endblock %}
+
+ {% block javascripts %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endblock %}
+
+ {% include 'snippets/analytics.html' %}
+
+
+
diff --git a/minent/templates/components/footer.html b/minent/templates/components/footer.html
new file mode 100644
index 0000000..e324445
--- /dev/null
+++ b/minent/templates/components/footer.html
@@ -0,0 +1,20 @@
+
diff --git a/minent/templates/components/modals.html b/minent/templates/components/modals.html
new file mode 100644
index 0000000..6ff670d
--- /dev/null
+++ b/minent/templates/components/modals.html
@@ -0,0 +1,39 @@
+
+ {% csrf_token %}
+
+
+
+
+
diff --git a/minent/templates/components/navbar.html b/minent/templates/components/navbar.html
new file mode 100644
index 0000000..84e528e
--- /dev/null
+++ b/minent/templates/components/navbar.html
@@ -0,0 +1,85 @@
+{% load staticfiles %}
+
+
+
+
+
+
+
+
+ {% if user.is_authenticated %}
+
+ {% endif %}
+
+
+
+
+
+
+
diff --git a/minent/templates/error.html b/minent/templates/error.html
new file mode 100644
index 0000000..f6df4f1
--- /dev/null
+++ b/minent/templates/error.html
@@ -0,0 +1,31 @@
+{% extends 'base.html' %}
+{% load staticfiles %}
+
+{% block stylesheets %}
+ {{ block.super }}
+
+{% endblock %}
+
+{% block body %}
+
+
+
+
+
+
Whoops!
+
{% block error_code %}{% endblock %}
+
+ {% block error_details %}
+
We're currently performing some scheduled maintenance.
+ The site will be back up shortly!
+ {% endblock %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/minent/templates/fugato/question.html b/minent/templates/fugato/question.html
new file mode 100644
index 0000000..1c2f483
--- /dev/null
+++ b/minent/templates/fugato/question.html
@@ -0,0 +1,470 @@
+{% extends 'page.html' %}
+{% load staticfiles %}
+{% load votable %}
+{# Displays all the information about a particular question #}
+
+ {% block stylesheets %}
+ {{ block.super }}
+
+ {% endblock %}
+
+ {% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for answer in question.answers.all %}
+
+
+
+
+
+ {% if answer.author.get_full_name %}
+
answered by {{ answer.author.get_full_name }}
+ {% else %}
+
answered by {{ answer.author.username }}
+ {% endif %}
+
answered on {{ answer.created|date }}
+
+
+
+
+ {{ answer.text_rendered|safe }}
+
+
+
+
+ {% current_user_vote answer as voted %}
+
+
+ |
+ {{ answer.votes.upvotes.count }}
+
+
+
+ |
+ {{ answer.votes.downvotes.count }}
+
+
+
+ {% empty %}
+
+
Be the first to answer this question!
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% endblock %}
+
+ {% block modals %}
+ {{ block.super }}
+ {% endblock %}
+
+ {% block javascripts %}
+
+ {{ block.super }}
+
+
+
+
+ {% endblock %}
diff --git a/minent/templates/page.html b/minent/templates/page.html
new file mode 100644
index 0000000..09e1275
--- /dev/null
+++ b/minent/templates/page.html
@@ -0,0 +1,29 @@
+{% extends 'base.html' %}
+
+{% block body %}
+
+
+
+ {% block navbar %}
+ {% include 'components/navbar.html' %}
+ {% endblock %}
+
+
+
+ {% block content %}
+ {% endblock %}
+
+
+
+
+
+
+ {% block modals %}
+ {% include 'components/modals.html' %}
+ {% endblock %}
+
+
+ {% block footer %}
+ {% include 'components/footer.html' %}
+ {% endblock %}
+{% endblock %}
diff --git a/minent/templates/registration/components/edit-profile-modal.html b/minent/templates/registration/components/edit-profile-modal.html
new file mode 100644
index 0000000..33fe535
--- /dev/null
+++ b/minent/templates/registration/components/edit-profile-modal.html
@@ -0,0 +1,56 @@
+
+
diff --git a/minent/templates/registration/components/set-password-modal.html b/minent/templates/registration/components/set-password-modal.html
new file mode 100644
index 0000000..d8b0894
--- /dev/null
+++ b/minent/templates/registration/components/set-password-modal.html
@@ -0,0 +1,30 @@
+
diff --git a/minent/templates/registration/logged_out.html b/minent/templates/registration/logged_out.html
new file mode 100644
index 0000000..abeeaaa
--- /dev/null
+++ b/minent/templates/registration/logged_out.html
@@ -0,0 +1,8 @@
+{% extends 'registration/login.html' %}
+
+{% block login-alert %}
+
+ ×
+ Secured! You have successfully been logged out.
+
+{% endblock %}
diff --git a/minent/templates/registration/login.html b/minent/templates/registration/login.html
new file mode 100644
index 0000000..1b21f6c
--- /dev/null
+++ b/minent/templates/registration/login.html
@@ -0,0 +1,161 @@
+{% extends "page.html" %}
+{% load staticfiles %}
+
+{% block content %}
+
+
+
+
+
+ {% block login-panel %}
+
+
+
+ {% block login-heading %}
+
{% block login-title %}Enter Access Controlled Area{% endblock %}
+ {% endblock %}
+
+
+
+ {% block login-body %}
+
+ {% block login-alert %}
+ {% if form.errors %}
+
+ ×
+ Whoops! Credentials invalid. Please try again.
+
+ {% endif %}
+
+ {% if messages %}
+ {% for message in messages %}
+ {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}
+
+ ×
+ Unauthorized! {{ message }}
+
+ {% endif %}
+ {% endfor %}
+ {% endif %}
+
+ {% if next %}
+ {% if user.is_authenticated %}
+
+ ×
+ Warning! Your account does not have access to this page.
+
+ {% else %}
+
+ ×
+ Warning! You must login to access Minimum Entropy.
+
+ {% endif %}
+ {% endif %}
+ {% endblock %}
+
+ {% block login-form %}
+
+
+
+
+
+
+
+
+
+
+
+ Sign In
+
+
+ {% csrf_token %}
+
+
+
+
+
+ {% endblock %}
+
+ {% endblock %}
+
+
+
+
+
+ {% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
So you want special access behind the scenes eh?
+
Turns out that's fairly easy, so long as you're a member of the District Data Labs faculty. All you have to do is sign in with Google using your @districtdatalabs.com email address and you'll be given access. If you'd like to set a password so that you don't have to use Google, you can do so in the administrative interface. Note if you can login but still can't access the admin, please email Ben.
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/minent/templates/registration/password_reset_complete.html b/minent/templates/registration/password_reset_complete.html
new file mode 100644
index 0000000..6d78da8
--- /dev/null
+++ b/minent/templates/registration/password_reset_complete.html
@@ -0,0 +1,8 @@
+{% extends 'registration/login.html' %}
+
+{% block login-alert %}
+
+ ×
+ Confirmed! You have successfully changed your password.
+
+{% endblock %}
diff --git a/minent/templates/registration/password_reset_confirm.html b/minent/templates/registration/password_reset_confirm.html
new file mode 100644
index 0000000..5c0b558
--- /dev/null
+++ b/minent/templates/registration/password_reset_confirm.html
@@ -0,0 +1,57 @@
+{% extends 'registration/login.html' %}
+{% load staticfiles %}
+
+{% block login-alert %}
+{% endblock %}
+
+{% block login-form %}
+
+
+
+
+
+
+
+
+
+ Change Password
+
+ {% csrf_token %}
+
+
+
+
+
+{% endblock %}
+
+
+{% block login-footer %}
+
+{% endblock %}
diff --git a/minent/templates/registration/password_reset_done.html b/minent/templates/registration/password_reset_done.html
new file mode 100644
index 0000000..5b592a8
--- /dev/null
+++ b/minent/templates/registration/password_reset_done.html
@@ -0,0 +1,31 @@
+{% extends 'registration/login.html' %}
+{% load staticfiles %}
+
+{% block login-alert %}
+{% endblock %}
+
+{% block login-form %}
+
+
+
+
+
+
Password Email Sent
+
We've emailed you instructions for setting your password.
+ You should be receiving them shortly.
+ If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder.
+
+
+{% endblock %}
+
+
+{% block login-footer %}
+
+{% endblock %}
diff --git a/minent/templates/registration/password_reset_form.html b/minent/templates/registration/password_reset_form.html
new file mode 100644
index 0000000..0dc8161
--- /dev/null
+++ b/minent/templates/registration/password_reset_form.html
@@ -0,0 +1,47 @@
+{% extends 'registration/login.html' %}
+{% load staticfiles %}
+
+{% block login-alert %}
+{% endblock %}
+
+{% block login-form %}
+
+
+
+
+
+
+
+
+ Send Reset Token
+
+ {% csrf_token %}
+
+
+
+
+
+{% endblock %}
+
+
+{% block login-footer %}
+
+{% endblock %}
diff --git a/minent/templates/registration/profile.html b/minent/templates/registration/profile.html
new file mode 100644
index 0000000..6a32c66
--- /dev/null
+++ b/minent/templates/registration/profile.html
@@ -0,0 +1,289 @@
+{% extends 'page.html' %}
+{% load staticfiles %}
+
+{% block stylesheets %}
+ {{ block.super }}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit Profile
+
+
+
+
+ Password
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for activity in activity_stream %}
+
+ {{ activity.timesince }} ago
+ {% autoescape off %}
+ {% if activity.target %}
+ {% if activity.theme %}
+ {{ activity.get_actor_html }} {{ activity.get_verb_display }} {{ activity.get_theme_html }} on {{ activity.get_target_html }}
+ {% else %}
+ {{ activity.get_actor_html }} {{ activity.get_verb_display }} {{ activity.get_target_html }}
+ {% endif %}
+
+ {% elif activity.theme %}
+ {{ activity.get_actor_html }} {{ activity.get_verb_display }} {{ activity.get_theme_html }}
+ {% else %}
+ {{ activity.get_actor_html }} {{ activity.get_verb_display }}
+ {% endif %}
+ {% endautoescape %}
+
+ {% empty %}
+ No activities recorded yet
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block modals %}
+ {{ block.super }}
+ {% include 'registration/components/edit-profile-modal.html' %}
+ {% include 'registration/components/set-password-modal.html' %}
+{% endblock %}
+
+{% block javascripts %}
+ {{ block.super }}
+
+
+{% endblock %}
diff --git a/minent/templates/rest_framework/api.html b/minent/templates/rest_framework/api.html
new file mode 100644
index 0000000..7f18c8f
--- /dev/null
+++ b/minent/templates/rest_framework/api.html
@@ -0,0 +1,49 @@
+{% extends "rest_framework/base.html" %}
+{% load staticfiles %}
+
+{% block title %}Minimum Entropy API{% endblock %}
+
+{% block bootstrap_theme %}
+
+
+{% endblock %}
+
+{% block branding %}
+
+
+ Minimum Entropy beta
+
+{% endblock %}
+
+{% block userlinks %}
+{% if user.is_authenticated %}
+
+
+ {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user.username }}{% endif %}
+
+
+
+{% else %}
+ Log in
+{% endif %}
+{% endblock %}
+
+{% block footer %}
+
+{% endblock %}
diff --git a/minent/templates/site/index.html b/minent/templates/site/index.html
new file mode 100644
index 0000000..18a083f
--- /dev/null
+++ b/minent/templates/site/index.html
@@ -0,0 +1,36 @@
+{% extends 'page.html' %}
+{% load staticfiles %}
+{# This page displays a tiny splash page with information #}
+
+ {% block content %}
+
+
+
+
+
+
+
+
Welcome to Minimum Entropy!
+
Please login to get started
+
+
+ Username
+
+
+
+ Password
+
+
+ Sign in
+ {% csrf_token %}
+
+
Or
+
+ Sign in with Google
+
+
+
+
+
+
+ {% endblock %}
diff --git a/minent/templates/site/legal/legal-page.html b/minent/templates/site/legal/legal-page.html
new file mode 100644
index 0000000..39ff8be
--- /dev/null
+++ b/minent/templates/site/legal/legal-page.html
@@ -0,0 +1,84 @@
+{% extends 'page.html' %}
+
+{% block stylesheets %}
+ {{ block.super }}
+
+{% endblock %}
+
+{% block navbar %}
+
+
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ {% block legal-content %}{% endblock %}
+
+
+
+{% endblock %}
+
+{% block footer %}
+
+
+{% endblock %}
diff --git a/minent/templates/site/legal/privacy.html b/minent/templates/site/legal/privacy.html
new file mode 100644
index 0000000..20dd655
--- /dev/null
+++ b/minent/templates/site/legal/privacy.html
@@ -0,0 +1,51 @@
+{% extends 'site/legal/legal-page.html' %}
+
+ {% block legal-header %}
+ Privacy Policy
+ Last Updated: August 21, 2015
+ {% endblock %}
+
+ {% block legal-content %}
+ Your privacy is very important to us. Accordingly, we have developed this Policy in order for you to understand how we collect, use, communicate and disclose and make use of personal information. The following outlines our privacy policy.
+
+
+ Before or at the time of collecting personal information, we will identify the purposes for which information is being collected.
+ We will collect and use of personal information solely with the objective of fulfilling those purposes specified by us and for other compatible purposes, unless we obtain the consent of the individual concerned or as required by law.
+ We will only retain personal information as long as necessary for the fulfillment of those purposes.
+ We will collect personal information by lawful and fair means and, where appropriate, with the knowledge or consent of the individual concerned.
+ Personal data should be relevant to the purposes for which it is to be used, and, to the extent necessary for those purposes, should be accurate, complete, and up-to-date.
+ We will protect personal information by reasonable security safeguards against loss or theft, as well as unauthorized access, disclosure, copying, use or modification.
+ We will make readily available to customers information about our policies and practices relating to the management of personal information.
+
+
+
+ We are committed to conducting our business in accordance with these principles in order to ensure that the confidentiality of personal information is protected and maintained.
+
+ Information Collection and Use
+
+ Our primary goal in collecting information is to provide and improve our Site, App, and Services. We would like to deliver a user-customized experience on our site, allowing users to administer their Membership and enable users to enjoy and easily navigate the Site or App.
+
+ Personally Identifiable Information
+
+ When you register or create an account with us through the Site, or as a user of a Service provided by us, or through any Mobile App, we will ask you for personally identifiable information and you will become a member ("Member") of the site. This information refers to information that can be used to contact or identify you ("Personal Information"). Personal Information includes, but is not limited to, your name, phone number, email address, and home and business postal addresses. We use this information only to provide Services and administer your inquiries.
+
+ We may also collect other information as part of the registration for use in administration and personalization of your account. This information is "Non-Identifying Information" like your role in education. We use your Personal Information and, in some cases, your Non-Identifying Information to provide you a Service, complete your transactions, and administer your inquiries.
+
+ We will also use your Personal Information to contact you with newsletters, marketing, or promotional materials, and other information that may be of interest to you. If you decide at any time that you no longer with to receive such communications from us, please follow the unsubscribe instructions provided in any communications update.
+
+ Changing or Deleting your Information
+
+ All Members may review, update, correct, or delete any Personal Information in their user profile under the "My Account" section of the Site or by contacting us. If you completely delete all such information, then your account may become deactivated. You can also request the deletion of your account, which will anonymize all Personal Information and restrict the username associated with the Member from being used again.
+
+ International Transfer
+
+ Your information may be transferred to, and maintained on, computers located outside of your state, province, country or other governmental jurisdiction where the privacy laws may not be as protective as those in your jurisdiction. If you are located outside the United States and choose to provide information to us, our website transfers Personal Information to the United States and processes it there. Your consent to these Terms of Use, followed by your submission of such information represents your agreement to that transfer.
+
+ Our Policy Toward Children
+
+ This Site is not directed to children under 18. We do not knowingly collect personally identifiable information from children under 13. If a parent or guardian becomes aware that his or her child has provided us Personal Information without their consent, he or she should contact us at admin@districtdatalabs.com . If we become aware that a child under 13 has provided us with Personal Information, we will delete such information from our databases.
+
+ Modification
+
+ It is our policy to post any changes we make to our Privacy Policy on this page. If we make material changes to how we treat our users' personal information, we will notify you by e-mail to the e-mail address specified in your account. The date this Privacy Policy was last revised is identified at the top of the page. You are responsible for ensuring we have an up-to-date active and deliverable e-mail address for you, and for periodically visiting our Website and this Privacy Policy to check for any changes.
+ {% endblock %}
diff --git a/minent/templates/site/legal/terms.html b/minent/templates/site/legal/terms.html
new file mode 100644
index 0000000..6f1dd30
--- /dev/null
+++ b/minent/templates/site/legal/terms.html
@@ -0,0 +1,122 @@
+{% extends 'site/legal/legal-page.html' %}
+
+ {% block legal-header %}
+ Terms and Conditions of Use
+ Last Updated: August 21, 2015
+ {% endblock %}
+
+ {% block legal-content %}
+
+
+ Use of Site
+
+ By accessing this website or any website owned by District Data Labs , you are agreeing to be bound to all of the terms, conditions, and notices contained or referenced in this Terms and Conditions of Use and all applicable laws and regulations. You also agree that you are responsible for compliance with any applicable local laws. If you do not agree to these terms, you are prohibited from using or accessing this site or any other site owned by District Data Labs . District Data Labs reserves the right to update or revise these Terms of Use. Your continued use of this Site following the posting of any changes to the Terms of Use constitutes acceptance of those changes.
+
+ Permission is granted to temporarily download one copy of the materials on District Data Labs 's Websites for viewing only. This is a grant of a license, not a transfer of a title. Under this licenses you may not:
+
+
+ Modify or copy the materials
+ Use the materials for any commercial purpose, or any public display (commercial or non-commercial)
+ Attempt to decompile or reverse engineer any software contained or provided through District Data Labs 's Website
+ Remove any copyright or proprietary notations from the material
+ Transfer the materials to another person or "mirror" any materials on any other server including data accessed through our APIS
+
+
+
+ District Data Labs has the right to terminate this license if you violate any of these restrictions, and upon termination you are no longer allowed to view these materials and must destroy any downloaded content in either print or electronic format.
+
+
+
+
+ Modification
+
+ It is our policy to post any changes we make to our terms of use on this page. If we make material changes to how we treat our users' personal information, we will notify you by e-mail to the e-mail address specified in your account. The date these Terms of Use was last revised is identified at the top of the page. You are responsible for ensuring we have an up-to-date active and deliverable e-mail address for you, and for periodically visiting our Website and this terms of use to check for any changes.
+
+
+
+
+ Copyright
+
+ The entire content of this Site is protected by copyright. You may not copy, distribute, or create derivative works from any part of this website (including its graphics, pictorial matter, and text) without the prior written consent of District Data Labs unless otherwise expressly permitted by the Sites.
+
+
+
+
+
+ Trademarks
+
+ District Data Labs owns names, logos, designs, titles, words, or phrases within this Site are trademarks, service marks, or trade names of District Data Labs or its affiliated companies and may not be used without prior written permission. District Data Labs claims no interest in marks owned by entities not affiliated with District Data Labs which may appear on this Site.
+
+
+
+
+ Contributed Content
+
+ Users posting content to the Site and District Data Labs 's Social Media pages linked within are solely responsible for all content and any infringement, defamation, or other claims resulting from or related thereto. District Data Labs reserves the right to remove or refuse to post any content that is offensive, indecent, or otherwise objectionable, and makes no guarantee of the accuracy, integrity, or quality of posted content.
+
+
+
+ Account Registration
+
+ In order to access certain features of this Site and Services and to post any Content on the Site or through the Services, you must register to create an account ("Account") through the Site, or through a Service provided by us for use with our Site.
+
+ During the registration process, you will be required to provide certain information and you will establish a username and password. You agree to provide accurate, current, and complete information as required during the registration process. You also agree to ensure, by updating, the information remains accurate, current, and complete. District Data Labs reserves the right to suspend or terminate your Account if information provided during the registration process or thereafter proves to be inaccurate, not current, or incomplete.
+
+ You are responsible for safeguarding your password. You agree not to disclose your password to any third party and take sole responsibility for any activities or actions under your Account, whether or not your have authorized such activities or actions. If you think your account has been accessed in any unauthorized way, you will notify District Data Labs immediately.
+
+ Termination and Account Cancellation
+
+ District Data Labs will have the right to suspend or disable your Account if you breach any of these Terms of Service, at our sole discretion and without any prior notice to you. District Data Labs reserves the right to revoke your access to and use of this Site, Services, and Content at any time, with or without cause.
+
+ You may also cancel your Account at any time by sending an email to admin@districtdatalabs.com or by using the "delete account" option under the "My Account" section of the website. When your account is canceled, we set all personal information except your username to "Anonymous" and remove the ability to login with that username and any password. The username will be considered unavailable, and no one will be able to create or use an account with the username of the cancelled account.
+
+
+
+
+ Privacy
+
+ See District Data Labs 's Privacy Policy for information and notices concerning collection and use of your personal information.
+
+
+
+ District Data Labs Mailing List
+
+ Should you submit your contact information through the "Sign Up" link, you agree to receive periodic emails and possible postal mail relating to news and updates regarding District Data Labs efforts and the efforts of like-minded organizations. You may discontinue receipt of such emails and postal mail through the “unsubscribe” provisions included in the promotional emails.
+
+
+
+
+ No Endorsements
+
+ Any links on this Site to third party websites are not an endorsement, sponsorship, or recommendation of the third parties or the third parties' ideas, products, or services. Similarly, any references in this Site to third parties and their products or services do not constitute an endorsement, sponsorship, or recommendation. If you follow links to third party websites, or any other companies or organizations affiliated or unaffiliated with District Data Labs , you are subject to the terms and conditions and privacy policies of those sites, and District Data Labs marks no warranty or representations regarding those sites. Further, District Data Labs is not responsible for the content of third party or affiliated company sites or any actions, inactions, results, or damages caused by visiting those sites.
+
+
+
+
+ Governing Law
+
+ This Site was designed for and is operated in the United States. Regardless of where the Site is viewed, you are responsible for compliance with applicable local laws.
+
+ You and District Data Labs agree that the laws of the District of Columbia will apply to all matters arising from or relating to use of this Website, whether for claims in contract, tort, or otherwise, without regard to conflicts of laws principles.
+
+ International Transfer
+
+ Your information may be transferred to, and maintained on, computers located outside of your state, province, country or other governmental jurisdiction where the privacy laws may not be as protective as those in your jurisdiction. If you are located outside the United States and choose to provide information to us, District Data Labs transfers Personal Information to the United States and processes it there. Your consent to these Terms of Use, followed by your submission of such information represents your agreement to that transfer.
+
+
+
+
+ Disclaimer
+
+ The materials on District Data Labs 's Website are provided "as is" without any kind of warranty. The material on this Website is not a warranty as to any product or service provided by District Data Labs or any affiliated or unaffiliated organization.
+
+ District Data Labs is not liable for any errors, delays, inaccuracies, or omissions in this Website or any Website that are linked to or referenced by this Website. Under no circumstances shall District Data Labs be liable for any damages, including indirect, incidental, special, or consequential damages that result from the use of, or inability to use, this Website.
+
+
+
+
+ Agrement
+
+ These Terms of Use constitute the entire agreement between you and District Data Labs with respect to your use of this Site and supersede all prior or contemporaneous communications and proposals, whether oral, written, or electronic, between you and District Data Labs with respect to this Site. If any provision(s) of these Terms of Use are held invalid or unenforceable, those provisions shall be construed in a manner consistent with applicable law to reflect, as nearly as possible, the original intentions of the parties, and the remaining provisions shall remain in full force and effect.
+
+ {% endblock %}
diff --git a/minent/templates/snippets/analytics.html b/minent/templates/snippets/analytics.html
new file mode 100644
index 0000000..14d9ae5
--- /dev/null
+++ b/minent/templates/snippets/analytics.html
@@ -0,0 +1,11 @@
+
+
diff --git a/minent/tests/test_init.py b/minent/tests/test_init.py
index 1c01150..a1bbcc3 100644
--- a/minent/tests/test_init.py
+++ b/minent/tests/test_init.py
@@ -23,7 +23,7 @@
## Module variables
##########################################################################
-EXPECTED_VERSION = "0.1"
+EXPECTED_VERSION = "1.0b1"
##########################################################################
## Initialization Tests
diff --git a/minent/tests/test_utils.py b/minent/tests/test_utils.py
new file mode 100644
index 0000000..0080372
--- /dev/null
+++ b/minent/tests/test_utils.py
@@ -0,0 +1,77 @@
+# minent.tests.test_utils
+# Tests for the utility module of minimum-entropy
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jun 23 11:53:06 2015 -0400
+#
+# Copyright (C) 2015 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: test_utils.py [] benjamin@bengfort.com $
+
+"""
+Tests for the utility module of minimum-entropy
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from minent.utils import *
+from unittest import TestCase
+
+##########################################################################
+## Utilities Testing
+##########################################################################
+
+class UtilsTests(TestCase):
+ """
+ Test the minimum-entropy utilities library
+ """
+
+ def test_normalize(self):
+ """
+ Test the normalization function
+ """
+
+ self.assertNotIn(" ", normalize("a b c d e f g"), "should not contain spaces")
+ self.assertNotIn("A", normalize("AAAAA AAA AA 9AA8"), "should not contain uppercase")
+ self.assertNotIn(".", normalize("no.punctuation."), "should not contain punctuation")
+ self.assertNotIn("-", normalize("no-punctuation-"), "should not contain punctuation")
+
+ def test_normalize_question(self):
+ """
+ Test question normalization
+ """
+
+ testa = "Who is faster, a T-Rex or a Velociraptor?"
+ testb = "who is faster? A t-rex or a velociraptor?"
+
+ self.assertEqual(normalize(testa), normalize(testb))
+
+ def test_signature(self):
+ """
+ Test the text signature method
+ """
+
+ self.assertEqual(len(signature("here I am")), 28, "should be base64 encoded SHA1 hash length")
+ self.assertEqual(signature("the rain in spain"), b"QKv9wgxE3wSgRQevr3h1S0cg468=", "should compute the correct SHA1 hash")
+
+ def test_question_signature(self):
+ """
+ Test questions with same signature
+ """
+
+ testa = "Who is faster, a T-Rex or a Velociraptor?"
+ testb = "who is faster? A t-rex or a velociraptor?"
+
+ self.assertEqual(signature(testa), signature(testb))
+
+ def test_htmlize(self):
+ """
+ Test the htmlize function
+ """
+
+ self.assertEqual(htmlize("http://www.google.com/"), 'http://www.google.com/
', "linkify didn't work")
+ self.assertNotIn(""), "clean didn't work")
+ self.assertIn("", htmlize("- item 1\n- item 2\n"), "markdown didn't work")
diff --git a/minent/urls.py b/minent/urls.py
index d933ad7..99eea5f 100644
--- a/minent/urls.py
+++ b/minent/urls.py
@@ -30,14 +30,51 @@
## Imports
##########################################################################
-from django.conf.urls import url
+from django.conf.urls import url, include
from django.contrib import admin
+from rest_framework import routers
+from django.views.generic import TemplateView
+
+from users.views import *
+from minent.views import *
+from fugato.views import *
+
+##########################################################################
+## Endpoint Discovery
+##########################################################################
+
+## API
+router = routers.DefaultRouter()
+router.register(r'users', UserViewSet)
+router.register(r'questions', QuestionViewSet)
+router.register(r'answers', AnswerViewSet)
+router.register(r'status', HeartbeatViewSet, "status")
+router.register(r'typeahead', QuestionTypeaheadViewSet, "typeahead")
##########################################################################
## Minimum Entropy URL Patterns
##########################################################################
urlpatterns = [
+ ## Admin site
+ url(r'^grappelli/', include('grappelli.urls')),
url(r'^admin/', admin.site.urls),
+
+ ## Static pages
+ url(r'^$', SplashPage.as_view(), name='home'),
+ url(r'^terms/$', TemplateView.as_view(template_name='site/legal/terms.html'), name='terms'),
+ url(r'^privacy/$', TemplateView.as_view(template_name='site/legal/privacy.html'), name='privacy'),
+
+ ## Application Pages
+ url(r'^app/$', WebAppView.as_view(), name='app-root'),
+ url(r'^q/(?P[\w-]+)/$', QuestionDetail.as_view(), name='question'),
+
+ ## Authentication
+ url('', include('social.apps.django_app.urls', namespace='social')),
+ url('', include('django.contrib.auth.urls')),
+ url(r'^profile/$', ProfileView.as_view(), name='profile'),
+
+ ## REST API Urls
+ url(r'^api/', include(router.urls, namespace="api")),
]
diff --git a/minent/utils.py b/minent/utils.py
new file mode 100644
index 0000000..9941048
--- /dev/null
+++ b/minent/utils.py
@@ -0,0 +1,160 @@
+# minent.utils
+# Project level utilities
+#
+# Author: Benjamin Bengfort
+# Created: Thu Oct 23 14:09:04 2014 -0400
+#
+# Copyright (C) 2014 Bengfort.com
+# For license information, see LICENSE.txt
+#
+# ID: utils.py [] benjamin@bengfort.com $
+
+"""
+Project level utilities
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+import re
+import time
+import base64
+import bleach
+import hashlib
+
+from functools import wraps
+from markdown import markdown
+from dateutil.relativedelta import relativedelta
+
+##########################################################################
+## Utilities
+##########################################################################
+
+## Nullable kwargs for models
+nullable = { 'blank': True, 'null': True, 'default':None }
+
+## Not nullable kwargs for models
+notnullable = { 'blank': False, 'null': False }
+
+##########################################################################
+## Helper functions
+##########################################################################
+
+def normalize(text):
+ """
+ Normalizes the text by removing all punctuation and spaces as well as
+ making the string completely lowercase.
+ """
+ return re.sub(r'[^a-z0-9]+', '', text.lower())
+
+
+def signature(text):
+ """
+ This helper method normalizes text and takes the SHA1 hash of it,
+ returning the base64 encoded result. The normalization method includes
+ the removal of punctuation and white space as well as making the case
+ completely lowercase. These signatures will help us discover textual
+ similarities between questions.
+ """
+ text = normalize(text).encode('utf-8')
+ return base64.b64encode(hashlib.sha1(text).digest())
+
+
+def htmlize(text):
+ """
+ This helper method renders Markdown then uses Bleach to sanitize it as
+ well as convert all links to actual links.
+ """
+ text = bleach.clean(text, strip=True) # Clean the text by stripping bad HTML tags
+ text = markdown(text) # Convert the markdown to HTML
+ text = bleach.linkify(text) # Add links from the text and add nofollow to existing links
+
+ return text
+
+
+##########################################################################
+## Memoization
+##########################################################################
+
+def memoized(fget):
+ """
+ Return a property attribute for new-style classes that only calls its
+ getter on the first access. The result is stored and on subsequent
+ accesses is returned, preventing the need to call the getter any more.
+
+ https://github.com/estebistec/python-memoized-property
+ """
+ attr_name = '_{0}'.format(fget.__name__)
+
+ @wraps(fget)
+ def fget_memoized(self):
+ if not hasattr(self, attr_name):
+ setattr(self, attr_name, fget(self))
+ return getattr(self, attr_name)
+
+ return property(fget_memoized)
+
+
+##########################################################################
+## Timer functions
+##########################################################################
+
+class Timer(object):
+ """
+ A context object timer. Usage:
+ >>> with Timer() as timer:
+ ... do_something()
+ >>> print timer.interval
+ """
+
+ def __init__(self, wall_clock=True):
+ """
+ If wall_clock is True then use time.time() to get the number of
+ actually elapsed seconds. If wall_clock is False, use time.clock to
+ get the process time instead.
+ """
+ self.wall_clock = wall_clock
+ self.time = time.time if wall_clock else time.clock
+
+ def __enter__(self):
+ self.start = self.time()
+ return self
+
+ def __exit__(self, type, value, tb):
+ self.finish = self.time()
+ self.interval = self.finish - self.start
+
+ def __str__(self):
+ return humanizedelta(seconds=self.interval)
+
+
+def timeit(func, wall_clock=True):
+ """
+ Returns the number of seconds that a function took along with the result
+ """
+ @wraps(func)
+ def timer_wrapper(*args, **kwargs):
+ """
+ Inner function that uses the Timer context object
+ """
+ with Timer(wall_clock) as timer:
+ result = func(*args, **kwargs)
+
+ return result, timer
+ return timer_wrapper
+
+
+def humanizedelta(*args, **kwargs):
+ """
+ Wrapper around dateutil.relativedelta (same construtor args) and returns
+ a humanized string representing the detla in a meaningful way.
+ """
+ delta = relativedelta(*args, **kwargs)
+ attrs = ('years', 'months', 'days', 'hours', 'minutes', 'seconds')
+ parts = [
+ '%d %s' % (getattr(delta, attr), getattr(delta, attr) > 1 and attr or attr[:-1])
+ for attr in attrs if getattr(delta, attr)
+ ]
+
+ return " ".join(parts)
diff --git a/minent/version.py b/minent/version.py
index f1552a8..cb7d371 100644
--- a/minent/version.py
+++ b/minent/version.py
@@ -18,11 +18,11 @@
##########################################################################
__version_info__ = {
- 'major': 0,
- 'minor': 1,
+ 'major': 1,
+ 'minor': 0,
'micro': 0,
- 'releaselevel': 'final',
- 'serial': 0,
+ 'releaselevel': 'beta',
+ 'serial': 1,
}
@@ -30,11 +30,20 @@ def get_version(short=False):
"""
Returns the version from the version info.
"""
- assert __version_info__['releaselevel'] in ('alpha', 'beta', 'final')
- vers = ["%(major)i.%(minor)i" % __version_info__, ]
+ if __version_info__['releaselevel'] not in ('alpha', 'beta', 'final'):
+ raise ValueError(
+ "unknown release level '{}', select alpha, beta, or final.".format(
+ __version_info__['releaselevel']
+ )
+ )
+
+ vers = ["{major}.{minor}".format(**__version_info__)]
+
if __version_info__['micro']:
- vers.append(".%(micro)i" % __version_info__)
+ vers.append(".{micro}".format(**__version_info__))
+
if __version_info__['releaselevel'] != 'final' and not short:
- vers.append('%s%i' % (__version_info__['releaselevel'][0],
- __version_info__['serial']))
+ vers.append('{}{}'.format(__version_info__['releaselevel'][0],
+ __version_info__['serial']))
+
return ''.join(vers)
diff --git a/minent/views.py b/minent/views.py
new file mode 100644
index 0000000..7918329
--- /dev/null
+++ b/minent/views.py
@@ -0,0 +1,86 @@
+# minent.views
+# Views for the project and application that don't require models
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jul 05 14:53:03 2016 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: views.py [] benjamin@bengfort.com $
+
+"""
+Views for the project and application that don't require models
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+import minent
+
+from datetime import datetime
+from django.shortcuts import redirect
+from users.mixins import LoginRequired
+from django.views.generic import TemplateView
+
+from rest_framework import viewsets
+from rest_framework.response import Response
+from rest_framework.permissions import AllowAny
+
+from fugato.models import Question ## TODO: remove this
+
+##########################################################################
+## Application Views
+##########################################################################
+
+class SplashPage(TemplateView):
+ """
+ Main splash page for the app. Although this is essentially a simple
+ webpage with no need for extra context, this view does check if the
+ user is logged in, and if so, immediately redirects them to the app.
+ """
+
+ template_name = "site/index.html"
+
+ def dispatch(self, request, *args, **kwargs):
+ """
+ If a user is authenticated, redirect to the Application, otherwise
+ serve normal template view as expected.
+ """
+ if request.user.is_authenticated():
+ return redirect('app-root', permanent=False)
+ return super(SplashPage, self).dispatch(request, *args, **kwargs)
+
+
+class WebAppView(LoginRequired, TemplateView):
+ """
+ Authenticated web application view that serves all context and content
+ to kick off the Backbone front-end application.
+ """
+
+ template_name = "app/index.html"
+
+ def get_context_data(self, **kwargs):
+ context = super(WebAppView, self).get_context_data(**kwargs)
+ context['question_list'] = Question.objects.order_by('-modified')
+ return context
+
+
+##########################################################################
+## API Views for this application
+##########################################################################
+
+class HeartbeatViewSet(viewsets.ViewSet):
+ """
+ Endpoint for heartbeat checking, including the status and version.
+ """
+
+ permission_classes = (AllowAny,)
+
+ def list(self, request):
+ return Response({
+ "status": "ok",
+ "version": minent.get_version(),
+ "timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ })
diff --git a/requirements.txt b/requirements.txt
index 7a75ec3..c026739 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,6 +7,9 @@ django-jsonfield==1.0.0
django-model-utils==2.5
djangorestframework==3.3.3
whitenoise==3.2
+gunicorn==19.6.0
+
+## Database Requirements
dj-database-url==0.4.1
psycopg2==2.6.1
@@ -15,6 +18,15 @@ Markdown==2.6.6
bleach==1.4.3
html5lib==0.9999999
+## Social Authentication
+python-social-auth==0.2.19
+defusedxml==0.4.1
+oauthlib==1.1.2
+PyJWT==1.4.0
+python3-openid==3.0.10
+requests-oauthlib==0.6.1
+requests==2.10.0
+
## Other Dependencies
six==1.10.0
python-dateutil==2.5.3
@@ -27,3 +39,5 @@ coverage==4.1
pip==8.1.2
setuptools==24.0.2
wheel==0.29.0
+
+## The following requirements were added by pip freeze:
diff --git a/runtime.txt b/runtime.txt
new file mode 100644
index 0000000..78082e3
--- /dev/null
+++ b/runtime.txt
@@ -0,0 +1 @@
+python-3.5.1
diff --git a/stream/__init__.py b/stream/__init__.py
new file mode 100644
index 0000000..7fe0f65
--- /dev/null
+++ b/stream/__init__.py
@@ -0,0 +1,25 @@
+# stream
+# An app that implements an Activity Stream for minimum-entropy
+#
+# Author: Benjamin Bengfort
+# Created: Wed Feb 04 10:21:07 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: __init__.py [] benjamin@bengfort.com $
+
+"""
+An app that implements an Activity Stream for minimum-entropy.
+
+Activity Streams (or newsfeeds) are user specific events on a system. They
+are becoming more popular, and even have a W3C specification!
+
+See http://www.w3.org/TR/2014/WD-activitystreams-core-20141023/
+"""
+
+##########################################################################
+## Configuration
+##########################################################################
+
+default_app_config = 'stream.apps.StreamConfig'
diff --git a/stream/admin.py b/stream/admin.py
new file mode 100644
index 0000000..34f6864
--- /dev/null
+++ b/stream/admin.py
@@ -0,0 +1,22 @@
+# stream.admin
+# Admin site configuration for activity stream
+#
+# Author: Benjamin Bengfort
+# Created:
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: admin.py [] benjamin@bengfort.com $
+
+"""
+Admin site configuration for activity stream
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.contrib import admin
+
+# Register your models here.
diff --git a/stream/apps.py b/stream/apps.py
new file mode 100644
index 0000000..eddb695
--- /dev/null
+++ b/stream/apps.py
@@ -0,0 +1,28 @@
+# stream.apps
+# Describes the Stream application for Django
+#
+# Author: Benjamin Bengfort
+# Created: Wed Mar 04 23:25:07 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: apps.py [] benjamin@bengfort.com $
+
+"""
+Describes the Stream application for Django
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.apps import AppConfig
+
+##########################################################################
+## Freebase Config
+##########################################################################
+
+class StreamConfig(AppConfig):
+ name = 'stream'
+ verbose_name = "Activity Stream"
diff --git a/stream/managers.py b/stream/managers.py
new file mode 100644
index 0000000..d253dde
--- /dev/null
+++ b/stream/managers.py
@@ -0,0 +1,37 @@
+# stream.managers
+# Custom manager for stream item objects
+#
+# Author: Benjamin Bengfort
+# Created: Wed Feb 04 11:09:16 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: managers.py [] benjamin@bengfort.com $
+
+"""
+Custom manager for stream item objects
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.db import models
+
+##########################################################################
+## StreamItem Manager
+##########################################################################
+
+class StreamItemManager(models.Manager):
+
+ def user_stream(self, user, privacy=False):
+ """
+ Returns a queryset containing a specific user's feed. If privacy
+ is set to True, then only returns public items for the user's feed.
+ """
+ queryset = self.filter(actor=user)
+ if privacy:
+ queryset = queryset.filter(public=True)
+
+ return queryset.order_by('timestamp')
diff --git a/stream/migrations/0001_initial.py b/stream/migrations/0001_initial.py
new file mode 100644
index 0000000..e45a935
--- /dev/null
+++ b/stream/migrations/0001_initial.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-07-06 00:28
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='StreamItem',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('target_object_id', models.PositiveIntegerField(blank=True, default=None, null=True)),
+ ('theme_object_id', models.PositiveIntegerField(blank=True, default=None, null=True)),
+ ('public', models.BooleanField(default=True)),
+ ('verb', models.CharField(choices=[('join', 'joined'), ('view', 'viewed'), ('upvote', 'up voted'), ('downvote', 'down voted'), ('ask', 'asked'), ('answer', 'answered')], max_length=20)),
+ ('details', models.TextField(blank=True, default=None, null=True)),
+ ('timestamp', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
+ ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_stream', to=settings.AUTH_USER_MODEL)),
+ ('target_content_type', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='targets', to='contenttypes.ContentType')),
+ ('theme_content_type', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='themes', to='contenttypes.ContentType')),
+ ],
+ options={
+ 'verbose_name_plural': 'activity stream items',
+ 'verbose_name': 'activity stream item',
+ 'ordering': ('-timestamp',),
+ 'db_table': 'activity_stream',
+ },
+ ),
+ ]
diff --git a/stream/migrations/__init__.py b/stream/migrations/__init__.py
new file mode 100644
index 0000000..72e6ebb
--- /dev/null
+++ b/stream/migrations/__init__.py
@@ -0,0 +1,18 @@
+# stream.migrations
+# Migrations for the Activity Stream
+#
+# Author: Benjamin Bengfort
+# Created: Wed Feb 04 10:23:41 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: __init__.py []benjamin@bengfort.com $
+
+"""
+Migrations for the Activity Stream
+"""
+
+##########################################################################
+## Imports
+##########################################################################
diff --git a/stream/models.py b/stream/models.py
new file mode 100644
index 0000000..a87ee1e
--- /dev/null
+++ b/stream/models.py
@@ -0,0 +1,161 @@
+# stream.models
+# Database models for the Activity Stream Items
+#
+# Author: Benjamin Bengfort
+# Created: Wed Feb 04 10:24:36 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: models.py [] benjamin@bengfort.com $
+
+"""
+Database models for the Activity Stream items
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.db import models
+from model_utils import Choices
+from django.utils.timesince import timesince
+from minent.utils import nullable, notnullable
+from stream.managers import StreamItemManager
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.utils import timezone as datetime
+
+##########################################################################
+## Activity Stream models
+##########################################################################
+
+class StreamItem(models.Model):
+ """
+ Contains a relationship between a user and any other content item via
+ a Generic relationship. It can then be used to describe an action
+ model as follows:
+
+
+
+
+
+ For example:
+
+ <1 minute ago>
+ <2 hours ago>
+ on
+
+ Much of this data type is created automatically (e.g. not interacted
+ with by users except through views). A secondary table is used to
+ store the activity stream to ensure that it can be quickly loaded,
+ even though many of the items in question already have a relationship
+ to some user!
+ """
+
+ ## Potential actions (verbs) for the activity stream
+ ## DB storage is the infinitive, display is past tense
+ VERBS = Choices(
+ ('join', 'joined'),
+ ('view', 'viewed'),
+ ('upvote', 'up voted'),
+ ('downvote', 'down voted'),
+ ('ask', 'asked'),
+ ('answer', 'answered'),
+ )
+
+ ## Relationship to the user (the actor)
+ actor = models.ForeignKey( 'auth.User', related_name='activity_stream' ) # The actor causing the event
+
+ ## Generic relationship to a target
+ target_content_type = models.ForeignKey( ContentType, related_name="targets", **nullable )
+ target_object_id = models.PositiveIntegerField( **nullable )
+ target = GenericForeignKey( 'target_content_type', 'target_object_id' )
+
+ ## Generic relationship to a theme (action object)
+ theme_content_type = models.ForeignKey( ContentType, related_name="themes", **nullable )
+ theme_object_id = models.PositiveIntegerField( **nullable )
+ theme = GenericForeignKey( 'theme_content_type', 'theme_object_id' )
+
+ ## Meta data concerning the activity
+ public = models.BooleanField( default=True ) # May appear in public feeds?
+ verb = models.CharField( max_length=20, choices=VERBS ) # The "verb" or "action" or "event"
+ details = models.TextField( **nullable ) # Additional details about the action
+ timestamp = models.DateTimeField( default=datetime.now, db_index=True ) # The timestamp of the action (note no created and modified)
+
+ ## A custom manager for the StreamItem
+ objects = StreamItemManager()
+
+ ## Database setup and meta
+ class Meta:
+ app_label = 'stream'
+ db_table = 'activity_stream'
+ ordering = ('-timestamp',)
+ verbose_name = 'activity stream item'
+ verbose_name_plural = 'activity stream items'
+
+ ######################################################################
+ ## Methods on the Stream Item
+ ######################################################################
+
+ def timesince(self, now=None):
+ """
+ Returns a string representation of the time since the timestamp.
+ """
+ return timesince(self.timestamp, now).encode('utf8').replace(b'\xc2\xa0', b' ').decode('utf8')
+
+ def get_object_url(self, obj):
+ """
+ Returns the URL of an object by using the `get_absolute_url` method
+ otherwise returns None. (Shouldn't raise an error).
+ """
+ if hasattr(obj, 'get_absolute_url') and callable(obj.get_absolute_url):
+ return obj.get_absolute_url()
+ return None
+
+ def get_actor_url(self):
+ return self.get_object_url(self.actor)
+
+ def get_target_url(self):
+ return self.get_object_url(self.target)
+
+ def get_theme_url(self):
+ return self.get_absolute_url(self.theme)
+
+ def get_object_html(self, obj, strfunc=str):
+ """
+ Returns an HTML representation of an object, basically an anchor
+ to the object's absolute URL or just the plain string representation.
+ """
+ href = self.get_object_url(obj)
+ if href is None:
+ return strfunc(obj)
+ return u' %s ' % (href, strfunc(obj), strfunc(obj))
+
+ def get_actor_html(self):
+ return self.get_object_html(self.actor, lambda actor: actor.username)
+
+ def get_target_html(self):
+ return self.get_object_html(self.target)
+
+ def get_theme_html(self):
+ return self.get_object_html(self.theme)
+
+ def __str__(self):
+ context = {
+ 'actor': self.actor.username,
+ 'verb': self.get_verb_display(),
+ 'theme': self.theme,
+ 'target': self.target,
+ 'timesince': self.timesince(),
+ }
+
+ if self.target:
+ if self.theme:
+ return "%(actor)s %(verb)s %(theme)s on %(target)s %(timesince)s ago" % context
+ return "%(actor)s %(verb)s %(target)s %(timesince)s ago" % context
+
+ if self.theme:
+ return "%(actor)s %(verb)s %(theme)s %(timesince)s ago" % context
+
+ return "%(actor)s %(verb)s %(timesince)s ago" % context
diff --git a/stream/signals.py b/stream/signals.py
new file mode 100644
index 0000000..de122c0
--- /dev/null
+++ b/stream/signals.py
@@ -0,0 +1,82 @@
+# stream.signals
+# Signal attachment for various models to activity stream
+#
+# Author: Benjamin Bengfort
+# Created: Wed Feb 04 12:20:10 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: signals.py [] benjamin@bengfort.com $
+
+"""
+Signal attachment for various models to activity stream
+
+Proposed API for the activity stream:
+
+ 1. To use the activity stream: `from stream import stream`
+ 2. On post_save send the stream signal: stream.send(sender, **kwargs)
+ 3. Typically the sender will be the target, but might also be the actor
+ 4. The stream handler will capture the signal and create a new StreamActivity
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from stream.models import StreamItem
+from django.dispatch import Signal, receiver
+from django.contrib.contenttypes.models import ContentType
+
+##########################################################################
+## Module Constants
+##########################################################################
+
+signal_args = (
+ 'actor', 'verb', 'theme', 'target', 'details', 'timestamp', 'public'
+)
+
+##########################################################################
+## Stream Signal
+##########################################################################
+
+stream = Signal(providing_args=signal_args)
+
+##########################################################################
+## Stream Receiver
+##########################################################################
+
+@receiver(stream)
+def stream_handler(sender, **kwargs):
+ """
+ The stream handler creates StreamItems from signals sent by the actors
+ or targets that want to register their activity in the stream.
+ """
+
+ ## assertions for required arguments
+ assert 'actor' in kwargs
+ assert 'verb' in kwargs
+
+ ## Create the keyword arguments for creating the activity stream
+ activity = {
+ 'actor': kwargs.get('actor'),
+ 'verb': kwargs.get('verb'),
+ }
+
+ ## Other arguments (don't include if not present)
+ for other in ('details', 'timestamp', 'public'):
+ if other in kwargs:
+ activity[other] = kwargs[other]
+
+ ## Handle content types
+ for generic in ('theme', 'target'):
+ if generic in kwargs:
+ ctypekey = '%s_content_type' % generic # Create generic content_type attribute
+ objidval = '%s_object_id' % generic # Create generic object_id attribute
+ content = kwargs[generic] # Get the generic content from the kwargs
+
+ activity[ctypekey] = ContentType.objects.get_for_model(content)
+ activity[objidval] = content.id
+
+ ## Create the StreamItem
+ StreamItem.objects.create(**activity)
diff --git a/stream/tests.py b/stream/tests.py
new file mode 100644
index 0000000..64fe385
--- /dev/null
+++ b/stream/tests.py
@@ -0,0 +1,165 @@
+# stream.tests
+# Testing the Activity Stream library
+#
+# Author: Benjamin Bengfort
+# Created: Wed Feb 04 11:18:38 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: tests.py [] benjamin@bengfort.com $
+
+"""
+Testing the Activity Stream library
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from unittest import skip
+from django.test import TestCase
+from stream.models import *
+from fugato.models import *
+from voting.models import *
+from django.contrib.auth.models import User
+from django.utils import timezone as datetime
+from datetime import timedelta
+
+##########################################################################
+## Fixtures
+##########################################################################
+
+fixtures = {
+ 'user': {
+ 'username': 'jdoe',
+ 'first_name': 'John',
+ 'last_name': 'Doe',
+ 'email': 'jdoe@example.com',
+ 'password': 'supersecret',
+ },
+ 'voter' : {
+ 'username': 'bobbyd',
+ 'first_name': 'Bob',
+ 'last_name': 'Dylan',
+ 'email': 'bobby@example.com',
+ 'password': 'dontguessthis',
+ },
+ 'question': {
+ 'text': 'Why is the sky blue?',
+ 'author': None
+ },
+ 'annotation': {
+ 'text': 'sky',
+ 'question': None,
+ 'user': None,
+ }
+}
+
+##########################################################################
+## Stream Model Tests
+##########################################################################
+
+class StreamItemModelTest(TestCase):
+
+ def setUp(self):
+ self.user = User.objects.create_user(**fixtures['user'])
+ self.voter = User.objects.create_user(**fixtures['voter'])
+
+ fixtures['question']['author'] = self.user
+ self.question = Question.objects.create(**fixtures['question'])
+
+ fixtures['annotation']['user'] = self.user
+ fixtures['annotation']['question'] = self.question
+
+ def one_minute_ago(self):
+ """
+ Helper function to return a datetime one minute ago
+ """
+ return datetime.now() - timedelta(minutes=1)
+
+ @skip("pending implementation")
+ def test_user_feed(self):
+ """
+ Check that a user's feed is accessible
+ """
+ pass
+
+ @skip("pending implementation")
+ def test_user_feed_privacy(self):
+ """
+ Assert that a user's public only feed is accessible
+ """
+ pass
+
+ @skip("pending implementation")
+ def test_no_empty_verb(self):
+ """
+ Ensure that no empty verb can be added to the database
+ """
+ pass
+
+ def test_actor_verb(self):
+ """
+ Test a StreamItem with only an actor and a verb
+ """
+
+ event = StreamItem.objects.create(**{
+ 'actor': self.user,
+ 'verb': StreamItem.VERBS.join,
+ 'timestamp': self.one_minute_ago(),
+ })
+
+ expected = u'jdoe joined 1 minute ago'
+
+ self.assertEqual(str(event), expected)
+
+ def test_actor_verb_target(self):
+ """
+ Test a StreamItem with an actor, verb, and target
+ """
+
+ event = StreamItem.objects.create(**{
+ 'actor': self.user,
+ 'verb': StreamItem.VERBS.ask,
+ 'target': self.question,
+ 'timestamp': self.one_minute_ago(),
+ })
+
+ expected = u'jdoe asked Why is the sky blue? 1 minute ago'
+
+ self.assertEqual(str(event), expected)
+
+ def test_actor_verb_theme(self):
+ """
+ Test a StreamItem with an actor, verb, and theme
+ """
+
+ event = StreamItem.objects.create(**{
+ 'actor': self.user,
+ 'verb': StreamItem.VERBS.upvote,
+ 'theme': self.question,
+ 'timestamp': self.one_minute_ago(),
+ })
+
+ expected = u'jdoe up voted Why is the sky blue? 1 minute ago'
+
+ self.assertEqual(str(event), expected)
+
+ @skip("annotation doesn't exist in minimum-entropy")
+ def test_actor_verb_target_theme(self):
+ """
+ Test a StreamItem with an actor, verb, theme and target
+ """
+
+ event = StreamItem.objects.create(**{
+ 'actor': self.user,
+ 'verb': StreamItem.VERBS.annotate,
+ 'target': self.question,
+ 'theme': self.annotation,
+ 'timestamp': self.one_minute_ago(),
+ })
+
+ expected = u'jdoe annotated sky on Why is the sky blue? 1 minute ago'
+
+ self.assertEqual(str(event), expected)
diff --git a/stream/validators.py b/stream/validators.py
new file mode 100644
index 0000000..508e24d
--- /dev/null
+++ b/stream/validators.py
@@ -0,0 +1,40 @@
+# stream.validators
+# Custom validators for the activity stream
+#
+# Author: Benjamin Bengfort
+# Created: Wed Feb 04 11:41:24 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: validators.py [] benjamin@bengfort.com $
+
+"""
+Custom validators for the activity stream
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from model_utils import Choices
+from django.core.exceptions import ValidationError
+
+##########################################################################
+## Choices Validator
+##########################################################################
+
+class ChoiceValidator(object):
+ """
+ Validates a Django field to ensure that it is set as one of the choices
+ """
+
+ def __init__(self, choices):
+ if not isinstance(choices, Choices):
+ choices = Choices(choices)
+
+ self.choices = choices
+
+ def __call__(self, value):
+ if value not in self.choices:
+ raise ValidationError("%s is not one of the choices!" % value)
diff --git a/stream/views.py b/stream/views.py
new file mode 100644
index 0000000..85c368c
--- /dev/null
+++ b/stream/views.py
@@ -0,0 +1,22 @@
+# stream.views
+# Views for the Activity Stream
+#
+# Author: Benjamin Bengfort
+# Created:
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: views.py [] benjamin@bengfort.com $
+
+"""
+Views for the Activity Stream
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.shortcuts import render
+
+# Create your views here.
diff --git a/users/__init__.py b/users/__init__.py
new file mode 100644
index 0000000..a677685
--- /dev/null
+++ b/users/__init__.py
@@ -0,0 +1,24 @@
+# users
+# Application for user profiles
+#
+# Author: Benjamin Bengfort
+# Created: Wed Mar 04 23:28:21 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: __init__.py [] benjamin@bengfort.com $
+
+"""
+Application for user profiles (extend django.contrib.auth)
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+##########################################################################
+## Configuration
+##########################################################################
+
+default_app_config = 'users.apps.UsersConfig'
diff --git a/users/admin.py b/users/admin.py
new file mode 100644
index 0000000..b9b7fc0
--- /dev/null
+++ b/users/admin.py
@@ -0,0 +1,52 @@
+# user.admin
+# Update the admin interface with the Profile
+#
+# Author: Benjamin Bengfort
+# Created: Thu Jan 15 16:51:57 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: admin.py [] benjamin@bengfort.com $
+
+"""
+Update the admin interface with the Profile
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.models import User
+from users.models import Profile
+
+##########################################################################
+## Inline Adminstration
+##########################################################################
+
+
+class ProfileInline(admin.StackedInline):
+ """
+ Inline administration descriptor for profile object
+ """
+
+ model = Profile
+ can_delete = False
+ verbose_name_plural = 'profile'
+
+
+class UserAdmin(UserAdmin):
+ """
+ Define new User admin
+ """
+
+ inlines = (ProfileInline, )
+
+##########################################################################
+## Register Admin
+##########################################################################
+
+admin.site.unregister(User)
+admin.site.register(User, UserAdmin)
diff --git a/users/apps.py b/users/apps.py
new file mode 100644
index 0000000..4e0597c
--- /dev/null
+++ b/users/apps.py
@@ -0,0 +1,31 @@
+# users.apps
+# Describes the Users application for Django
+#
+# Author: Benjamin Bengfort
+# Created: Wed Mar 04 23:29:51 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: apps.py [] benjamin@bengfort.com $
+
+"""
+Describes the Users application for Django
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.apps import AppConfig
+
+##########################################################################
+## Freebase Config
+##########################################################################
+
+class UsersConfig(AppConfig):
+ name = 'users'
+ verbose_name = "User Profiles"
+
+ def ready(self):
+ import users.signals
diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py
new file mode 100644
index 0000000..9022da7
--- /dev/null
+++ b/users/migrations/0001_initial.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-07-06 00:28
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Profile',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('email_hash', models.CharField(editable=False, max_length=32)),
+ ('biography', models.CharField(blank=True, default=None, max_length=255, null=True)),
+ ('organization', models.CharField(blank=True, default=None, max_length=255, null=True)),
+ ('location', models.CharField(blank=True, default=None, max_length=255, null=True)),
+ ('user', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py
new file mode 100644
index 0000000..d334071
--- /dev/null
+++ b/users/migrations/__init__.py
@@ -0,0 +1,18 @@
+# users.migrations
+# The users application database migrations.
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jul 05 19:15:02 2016 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: __init__.py [] benjamin@bengfort.com $
+
+"""
+The users application database migrations.
+"""
+
+##########################################################################
+## Imports
+##########################################################################
diff --git a/users/mixins.py b/users/mixins.py
new file mode 100644
index 0000000..c632ef7
--- /dev/null
+++ b/users/mixins.py
@@ -0,0 +1,68 @@
+# users.mixins
+# Authentication Mixins
+#
+# Author: Benjamin Bengfort
+# Created: Fri May 16 15:40:50 2014 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: mixins.py [] benjamin@bengfort.com $
+
+"""
+Authentication Mixins
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.core.urlresolvers import reverse_lazy
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.decorators import user_passes_test
+from django.utils.decorators import method_decorator
+
+##########################################################################
+## Helper functions
+##########################################################################
+
+PROFILE_URL = reverse_lazy('profile')
+
+def is_member(user):
+ """
+ Determines if the logged in user is an authorized member since anyone
+ can "register" via the Google OAuth API - once registered, we need
+ some other way to give them access or not; namely by having them be a
+ part of the Member group.
+ """
+ if user:
+ return user.groups.filter(name='Member').count() > 0
+ return False
+
+# The Members only decorator only allows users in that pass is_member
+members_only = user_passes_test(is_member, login_url=PROFILE_URL)
+
+##########################################################################
+## Mixins
+##########################################################################
+
+class MembershipRequired(object):
+ """
+ Ensures that user must be authenticated in order to access view. They
+ must additionally also be part of the Member group - e.g. a complete
+ authentication and accepted registration.
+ """
+
+ @method_decorator(login_required)
+ @method_decorator(members_only)
+ def dispatch(self, *args, **kwargs):
+ return super(MembershipRequired, self).dispatch(*args, **kwargs)
+
+
+class LoginRequired(object):
+ """
+ Ensures that user must be authenticated in order to access view.
+ """
+ @method_decorator(login_required)
+ def dispatch(self, *args, **kwargs):
+ return super(LoginRequired, self).dispatch(*args, **kwargs)
diff --git a/users/models.py b/users/models.py
new file mode 100644
index 0000000..101281b
--- /dev/null
+++ b/users/models.py
@@ -0,0 +1,73 @@
+# users.models
+# Contains additional User profile data but no authentication
+#
+# Author: Benjamin Bengfort
+# Created: Thu Jan 15 16:50:01 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: models.py [] benjamin@bengfort.com $
+
+"""
+Contains additional User profile data but no authentication
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+
+from django.db import models
+from minent.utils import nullable
+from urllib.parse import urlencode
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
+
+##########################################################################
+## UserProfile model
+##########################################################################
+
+
+class Profile(models.Model):
+
+ user = models.OneToOneField(User, editable=False)
+ email_hash = models.CharField(max_length=32, editable=False)
+ biography = models.CharField(max_length=255, **nullable)
+ organization = models.CharField(max_length=255, **nullable)
+ location = models.CharField(max_length=255, **nullable)
+
+ def get_gravatar_url(self, size=200, default="mm"):
+ """
+ Computes the gravatar url from an email address
+ """
+ params = urlencode({'d': default, 's': str(size)})
+ grvurl = "http://www.gravatar.com/avatar/%s?%s" % (self.email_hash,
+ params)
+ return grvurl
+
+ @property
+ def gravatar(self):
+ return self.get_gravatar_url()
+
+ @property
+ def gravatar_icon(self):
+ return self.get_gravatar_url(size=24)
+
+ @property
+ def full_name(self):
+ return self.user.get_full_name()
+
+ @property
+ def full_email(self):
+ email = "%s <%s>" % (self.full_name, self.user.email)
+ return email.strip()
+
+ def get_api_detail_url(self):
+ """
+ Returns the API detail endpoint for the object
+ """
+ return reverse('api:user-detail', args=(self.pk,))
+
+ def __str__(self):
+ return self.full_email
diff --git a/users/permissions.py b/users/permissions.py
new file mode 100644
index 0000000..3ec012a
--- /dev/null
+++ b/users/permissions.py
@@ -0,0 +1,47 @@
+# users.permissions
+# Permissions for Django Rest Framework and other permission classes.
+#
+# Author: Benjamin Bengfort
+# Created: Fri Oct 24 10:20:45 2014 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: permissions.py [] benjamin@bengfort.com $
+
+"""
+Permissions for Django Rest Framework and other permission classes.
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from rest_framework import permissions
+
+##########################################################################
+## Permissions
+##########################################################################
+
+class IsAuthorOrReadOnly(permissions.BasePermission):
+ """
+ Object-level permission to allow only owners of an object to edit.
+ Note, this permission assumes there is an `author` attribute on the
+ object that maps to an `auth.User` instance.
+ """
+
+ def has_object_permission(self, request, view, obj):
+ if request.method in permissions.SAFE_METHODS:
+ return True
+
+ return obj.author == request.user
+
+class IsAdminOrSelf(permissions.BasePermission):
+ """
+ Object-level permission to only allow modifications to a User object
+ if the request.user is an administrator or you are modifying your own
+ user object.
+ """
+
+ def has_object_permission(self, request, view, obj):
+ return request.user.is_staff or request.user == obj
diff --git a/users/serializers.py b/users/serializers.py
new file mode 100644
index 0000000..e7dff73
--- /dev/null
+++ b/users/serializers.py
@@ -0,0 +1,116 @@
+# users.serializers
+# Serializers for the members models
+#
+# Author: Benjamin Bengfort
+# Created: Sun May 18 07:57:36 2014 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: serializers.py [] benjamin@bengfort.com $
+
+"""
+Serializers for the members models
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from rest_framework import serializers
+from django.contrib.auth.models import User
+from users.models import Profile
+
+##########################################################################
+## Serializers
+##########################################################################
+
+
+class ProfileSerializer(serializers.ModelSerializer):
+ """
+ Serializes the Profile object to embed into the User JSON
+ """
+
+ gravatar = serializers.CharField(read_only=True)
+
+ class Meta:
+ model = Profile
+ fields = ('biography', 'gravatar', 'location', 'organization')
+
+
+class UserSerializer(serializers.HyperlinkedModelSerializer):
+ """
+ Serializes the User object for use in the API.
+ """
+
+ profile = ProfileSerializer(many=False, read_only=False)
+
+ class Meta:
+ model = User
+ fields = (
+ 'url', 'username', 'first_name',
+ 'last_name', 'email', 'profile'
+ )
+ extra_kwargs = {
+ 'url': {'view_name': 'api:user-detail'}
+ }
+
+ def create(self, validated_data):
+ """
+ Explicitly define create to also create the Profile object.
+ """
+ profile_data = validated_data.pop('profile')
+ user = User.objects.create(**validated_data)
+
+ for attr, value in profile_data.items():
+ setattr(user.profile, attr, value)
+
+ user.profile.save()
+ return user
+
+ def update(self, instance, validated_data):
+ """
+ Explicitly define update to also update the Profile object.
+ """
+ profile_data = validated_data.pop('profile')
+ profile = instance.profile
+
+ # Update the user instance
+ for attr, value in validated_data.items():
+ setattr(instance, attr, value)
+ instance.save()
+
+ # Update the profile instance
+ for attr, value in profile_data.items():
+ setattr(profile, attr, value)
+ profile.save()
+
+ return instance
+
+
+class SimpleUserSerializer(UserSerializer):
+
+ full_name = serializers.SerializerMethodField()
+
+ class Meta:
+ model = User
+ fields = (
+ 'url', 'username', 'full_name',
+ )
+ extra_kwargs = {
+ 'url': {'view_name': 'api:user-detail'}
+ }
+
+ def get_full_name(self, obj):
+ return obj.profile.full_name
+
+
+class PasswordSerializer(serializers.Serializer):
+
+ password = serializers.CharField(max_length=200)
+ repeated = serializers.CharField(max_length=200)
+
+ def validate(self, attrs):
+ if attrs['password'] != attrs['repeated']:
+ raise serializers.ValidationError("passwords do not match!")
+ return attrs
diff --git a/users/signals.py b/users/signals.py
new file mode 100644
index 0000000..d46547b
--- /dev/null
+++ b/users/signals.py
@@ -0,0 +1,60 @@
+# users.signals
+# Signals management for the Users app
+#
+# Author: Benjamin Bengfort
+# Created: Wed Mar 04 23:30:27 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: signals.py [] benjamin@bengfort.com $
+
+"""
+Signals management for the Users app
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+import hashlib
+
+from stream.signals import stream
+from django.dispatch import receiver
+from django.db.models.signals import post_save
+
+from users.models import Profile
+from django.contrib.auth.models import User
+
+##########################################################################
+## Signals
+##########################################################################
+
+@receiver(post_save, sender=User)
+def update_user_profile(sender, instance, created, **kwargs):
+ """
+ Creates a Profile object for the user if it doesn't exist, or updates
+ it with new information from the User (e.g. the gravatar).
+ """
+ ## Compute the email hash
+ digest = hashlib.md5(instance.email.lower().encode("utf-8")).hexdigest()
+
+ if created:
+ Profile.objects.create(user=instance, email_hash=digest)
+ else:
+ instance.profile.email_hash = digest
+ instance.profile.save()
+
+@receiver(post_save, sender=User)
+def send_joined_activity_signal(sender, instance, created, **kwargs):
+ """
+ Sends the "joined" activity to the stream on create
+ """
+ if created:
+ joined = {
+ 'sender': sender,
+ 'actor': instance,
+ 'verb': 'join',
+ 'timestamp': instance.date_joined,
+ }
+ stream.send(**joined)
diff --git a/users/tests.py b/users/tests.py
new file mode 100644
index 0000000..32c6e5d
--- /dev/null
+++ b/users/tests.py
@@ -0,0 +1,263 @@
+# users.tests
+# Tests for the users app
+#
+# Author: Benjamin Bengfort
+# Created: Thu Jan 22 16:47:20 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: tests.py [] benjamin@bengfort.com $
+
+"""
+Tests for the users app
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+import hashlib
+
+from users.models import Profile
+from stream.signals import stream
+from rest_framework import status
+from stream.models import StreamItem
+from django.test import TestCase, Client
+from rest_framework.test import APITestCase
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
+
+try:
+ from unittest.mock import MagicMock
+except ImportError:
+ from mock import MagicMock
+
+##########################################################################
+## User Fixture
+##########################################################################
+
+fixtures = {
+ 'user': {
+ 'username': 'jdoe',
+ 'first_name': 'John',
+ 'last_name': 'Doe',
+ 'email': 'jdoe@example.com',
+ 'password': 'supersecret',
+ },
+ 'api_user': {
+ 'username': 'starbucks',
+ 'first_name': 'Jane',
+ 'last_name': 'Windemere',
+ 'profile': {
+ 'biography': 'Originally from Seattle, now lives in Portland',
+ 'organization': 'SETI'
+ }
+ }
+}
+
+##########################################################################
+## Model Tests
+##########################################################################
+
+
+class UserModelTest(TestCase):
+
+ def test_user_create_send_stream(self):
+ """
+ Assert that when a user is created it sends the "join" stream signal
+ """
+ handler = MagicMock()
+ stream.connect(handler)
+ user = User.objects.create_user(username='bob',
+ email='bob@example.com',
+ password='secret')
+
+ # Ensure that the signal was sent once with required arguments
+ handler.assert_called_once_with(verb='join', sender=User, actor=user,
+ timestamp=user.date_joined,
+ signal=stream)
+
+ def test_user_joined_activity(self):
+ """
+ Assert that when a user joins, there is an activity stream item
+ """
+ user = User.objects.create_user(username='bob',
+ email='bob@example.com',
+ password='secret')
+ query = StreamItem.objects.filter(verb='join', actor=user)
+ self.assertEqual(query.count(), 1, "no stream item created!")
+
+
+class ProfileModelTest(TestCase):
+
+ def setUp(self):
+ self.user = User.objects.create_user(**fixtures['user'])
+
+ def test_profile_on_create(self):
+ """
+ Test the User post_save signal to create a profile
+ """
+
+ self.assertEqual(Profile.objects.count(), 1, "begin profile count mismatch (user mock has no profile?)")
+ u = User.objects.create_user(username="test", email="test@example.com", password="password")
+ self.assertEqual(Profile.objects.count(), 2, "additional profile object doesn't exist")
+ self.assertIsNotNone(u.profile)
+
+ def test_profile_email_hash_md5(self):
+ """
+ Ensure that the email_hash on a user is an MD5 digest
+ """
+
+ email = "Jane.Doe@gmail.com"
+ udigest = hashlib.md5(email.encode('utf-8')).hexdigest()
+ ldigest = hashlib.md5(email.lower().encode('utf-8')).hexdigest()
+
+ u = User.objects.create_user(username="test", email=email, password="password")
+ self.assertIsNotNone(u.profile, "user has no profile?")
+ self.assertIsNotNone(u.profile.email_hash, "user has no email hash?")
+
+ self.assertNotEqual(udigest, u.profile.email_hash, "email was not lower case before digest")
+ self.assertEqual(ldigest, u.profile.email_hash, "email not hashed correctly")
+
+ def test_profile_email_hash_create(self):
+ """
+ Email should be hashed on user create
+ """
+
+ digest = hashlib.md5(fixtures['user']['email'].encode('utf-8')).hexdigest()
+
+ self.assertIsNotNone(self.user.profile, "user has no profile?")
+ self.assertIsNotNone(self.user.profile.email_hash, "user has no email hash?")
+ self.assertEqual(digest, self.user.profile.email_hash, "email hash does not match expected")
+
+ def test_profile_email_hash_update(self):
+ """
+ Email should be hashed on user update
+ """
+
+ newemail = "john.doe@gmail.com"
+ digest = hashlib.md5(newemail.encode('utf-8')).hexdigest()
+
+ self.user.email = newemail
+ self.user.save()
+
+ self.assertEqual(digest, self.user.profile.email_hash, "email hash does not match expected")
+
+##########################################################################
+## View Tests
+##########################################################################
+
+
+class UserViewsTest(TestCase):
+
+ def setUp(self):
+ self.user = User.objects.create_user(**fixtures['user'])
+ self.client = Client()
+
+ def login(self):
+ credentials = {
+ 'username': fixtures['user']['username'],
+ 'password': fixtures['user']['password'],
+ }
+
+ return self.client.login(**credentials)
+
+ def logout(self):
+ return self.client.logout()
+
+ def test_profile_view_auth(self):
+ """
+ Assert that profile can only be viewed if logged in.
+ """
+ endpoint = reverse('profile')
+ loginurl = reverse('social:begin', args=('google-oauth2',))
+ params = "next=%s" % endpoint
+ expected = "%s?%s" % (loginurl, params)
+ response = self.client.get(endpoint)
+
+ self.assertRedirects(response, expected, fetch_redirect_response=False)
+
+ def test_profile_object(self):
+ """
+ Assert the profile gets the current user
+ """
+
+ endpoint = reverse('profile')
+
+ self.login()
+ response = self.client.get(endpoint)
+
+ self.assertEqual(self.user, response.context['user'])
+
+ def test_profile_template(self):
+ """
+ Check that the right template is being used
+ """
+ endpoint = reverse('profile')
+
+ self.login()
+ response = self.client.get(endpoint)
+
+ self.assertTemplateUsed(response, 'registration/profile.html')
+
+
+class UserAPITest(APITestCase):
+
+ def setUp(self):
+ self.user = User.objects.create_user(**fixtures['user'])
+ self.client.force_authenticate(user=self.user)
+
+ def test_user_create(self):
+ """
+ Check that a user can be created using the POST method
+
+ Required to test because the serializer overrides create.
+ NOTE: MUST POST IN JSON TO UPDATE/CREATE PROFILE
+ """
+ endpoint = reverse("api:user-list")
+ response = self.client.post(endpoint, data=fixtures['api_user'],
+ format='json')
+
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ # Check that a profile and user exist in database
+ user = User.objects.filter(username=fixtures['api_user']['username'])
+ profile = Profile.objects.filter(user=user)
+
+ self.assertEquals(len(user), 1)
+ self.assertEquals(len(profile), 1)
+
+ self.assertEqual(profile[0].organization, 'SETI')
+
+ def test_user_update(self):
+ """
+ Check that a user can be updated using a PUT method
+
+ Required to test because the serializer overrides update.
+ NOTE: MUST POST IN JSON TO UPDATE/CREATE PROFILE
+ """
+
+ endpoint = reverse("api:user-detail", kwargs={"pk": self.user.pk})
+
+ # Check that the user profile exists
+ response = self.client.get(endpoint)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # Update the profile
+ content = {
+ "username": self.user.username,
+ "first_name": self.user.first_name,
+ "last_name": self.user.last_name,
+ "profile": {
+ "biography": "This is a test bio.",
+ "organization": "NASA"
+ }
+ }
+
+ response = self.client.put(endpoint, content, format="json")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ # Fetch the profile again
+ user = User.objects.get(pk=self.user.pk)
+ self.assertEqual(user.profile.organization, "NASA")
diff --git a/users/views.py b/users/views.py
new file mode 100644
index 0000000..77f0de9
--- /dev/null
+++ b/users/views.py
@@ -0,0 +1,77 @@
+# users.views
+# Views for users and contributor management
+#
+# Author: Benjamin Bengfort
+# Created: Fri May 16 15:38:56 2014 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: views.py [] benjamin@bengfort.com $
+
+"""
+Views for users and contributor management
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from users.mixins import LoginRequired
+from users.permissions import IsAdminOrSelf
+from django.contrib.auth.models import User
+from django.views.generic import TemplateView
+
+from rest_framework import viewsets
+from rest_framework import status
+from rest_framework.response import Response
+from rest_framework.decorators import detail_route
+from users.serializers import UserSerializer, PasswordSerializer
+
+##########################################################################
+## Views
+##########################################################################
+
+
+class ProfileView(LoginRequired, TemplateView):
+ """
+ A simple template view to display a reviewer's profile including their
+ upvoting and down voting statistics.
+ """
+
+ template_name = "registration/profile.html"
+
+ def get_context_data(self, **kwargs):
+ """
+ Computes the gravatar from the user email and adds data to the
+ context to render the template.
+ """
+ context = super(ProfileView, self).get_context_data(**kwargs)
+ context['user'] = self.request.user
+
+ stream = self.request.user.activity_stream.all()[:10]
+ context['activity_stream'] = stream
+
+ return context
+
+##########################################################################
+## API HTTP/JSON Views
+##########################################################################
+
+
+class UserViewSet(viewsets.ModelViewSet):
+
+ queryset = User.objects.all()
+ serializer_class = UserSerializer
+
+ @detail_route(methods=['post'], permission_classes=[IsAdminOrSelf])
+ def set_password(self, request, pk=None):
+ user = self.get_object()
+ serializer = PasswordSerializer(data=request.data)
+ if serializer.is_valid():
+ user.set_password(serializer.data['password'])
+ user.save()
+ return Response({'status': 'password set'})
+ else:
+ return Response(serializer.errors,
+ status=status.HTTP_400_BAD_REQUEST)
diff --git a/voting/__init__.py b/voting/__init__.py
new file mode 100644
index 0000000..f66c2c8
--- /dev/null
+++ b/voting/__init__.py
@@ -0,0 +1,24 @@
+# voting
+# Handles the up and down voting for objects in the app
+#
+# Author: Benjamin Bengfort
+# Created: Wed Mar 04 23:33:26 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: __init__.py [] benjamin@bengfort.com $
+
+"""
+Handles the up and down voting for objects in the app
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+##########################################################################
+## Configuration
+##########################################################################
+
+default_app_config = 'voting.apps.VotingConfig'
diff --git a/voting/admin.py b/voting/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/voting/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/voting/apps.py b/voting/apps.py
new file mode 100644
index 0000000..f9ecf4f
--- /dev/null
+++ b/voting/apps.py
@@ -0,0 +1,31 @@
+# voting.apps
+# Describes the Voting application for Django
+#
+# Author: Benjamin Bengfort
+# Created: Wed Mar 04 23:34:16 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: apps.py [] benjamin@bengfort.com $
+
+"""
+Describes the Voting application for Django
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.apps import AppConfig
+
+##########################################################################
+## Voting Config
+##########################################################################
+
+class VotingConfig(AppConfig):
+ name = 'voting'
+ verbose_name = "Voting"
+
+ def ready(self):
+ import voting.signals
diff --git a/voting/managers.py b/voting/managers.py
new file mode 100644
index 0000000..f3405fd
--- /dev/null
+++ b/voting/managers.py
@@ -0,0 +1,57 @@
+# voting.managers
+# Custom manager model for voting objects
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jan 20 12:42:19 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: managers.py [] benjamin@bengfort.com $
+
+"""
+Custom manager model for voting objects
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.db import models
+from django.contrib.contenttypes.models import ContentType
+
+##########################################################################
+## Voting Manager
+##########################################################################
+
+class VotingManager(models.Manager):
+
+ def upvotes(self):
+ """
+ Return only the upvoted
+ """
+ return self.filter(vote=self.model.BALLOT.upvote)
+
+ def downvotes(self):
+ """
+ Return only the down votes
+ """
+ return self.filter(vote=self.model.BALLOT.downvote)
+
+ def punch_ballot(self, content=None, user=None, vote=0):
+ """
+ Essentially `update_or_create` with ContentType lookup
+ """
+ if content is None or user is None:
+ raise TypeError("content and user are required for punch ballot")
+
+ kwargs = {
+ 'content_type': ContentType.objects.get_for_model(content),
+ 'object_id': content.id,
+ 'user': user,
+ 'defaults': {
+ 'vote': vote,
+ }
+ }
+
+ return self.update_or_create(**kwargs)
diff --git a/voting/migrations/0001_initial.py b/voting/migrations/0001_initial.py
new file mode 100644
index 0000000..a1b758c
--- /dev/null
+++ b/voting/migrations/0001_initial.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-07-06 00:28
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import model_utils.fields
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Vote',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
+ ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
+ ('vote', models.SmallIntegerField(choices=[(-1, 'downvote'), (1, 'upvote'), (0, 'novote')], default=0)),
+ ('object_id', models.PositiveIntegerField()),
+ ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name_plural': 'votes',
+ 'get_latest_by': 'modified',
+ 'verbose_name': 'vote',
+ 'db_table': 'voting',
+ },
+ ),
+ migrations.AlterUniqueTogether(
+ name='vote',
+ unique_together=set([('object_id', 'user', 'content_type')]),
+ ),
+ ]
diff --git a/voting/migrations/__init__.py b/voting/migrations/__init__.py
new file mode 100644
index 0000000..c9e57a0
--- /dev/null
+++ b/voting/migrations/__init__.py
@@ -0,0 +1,18 @@
+# voting.migrations
+# Database migrations for the voting app
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jul 05 20:11:59 2016 -0400
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: __init__.py [] benjamin@bengfort.com $
+
+"""
+Database migrations for the voting app
+"""
+
+##########################################################################
+## Imports
+##########################################################################
diff --git a/voting/models.py b/voting/models.py
new file mode 100644
index 0000000..6a34e2a
--- /dev/null
+++ b/voting/models.py
@@ -0,0 +1,64 @@
+# voting.models
+# ContentTypes based generic models for voting on anything!
+#
+# Author: Benjamin Bengfort
+# Created: Thu Jan 15 16:02:31 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: models.py [] benjamin@bengfort.com $
+
+"""
+ContentTypes based generic models for voting on anything!
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.db import models
+from model_utils import Choices
+from model_utils.models import TimeStampedModel
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from voting.managers import VotingManager
+
+##########################################################################
+## Models
+##########################################################################
+
+class Vote(TimeStampedModel):
+ """
+ Generic vote object for up and down voting things
+ """
+
+ BALLOT = Choices((-1, 'downvote', 'downvote'), (1, 'upvote', 'upvote'), (0, 'novote', 'novote'))
+
+ # Data fields for the voting object
+ vote = models.SmallIntegerField( choices=BALLOT, default=BALLOT.novote )
+ user = models.ForeignKey( 'auth.User', related_name='votes' )
+
+ # Content types for a generic relationship (e.g. vote anything)
+ content_type = models.ForeignKey( ContentType )
+ object_id = models.PositiveIntegerField()
+ content_object = GenericForeignKey( 'content_type', 'object_id' )
+
+ # Set a custom manager for the Vote object
+ objects = VotingManager()
+
+ def __str__(self):
+ action = {
+ -1: "down voted",
+ 0: "no voted",
+ 1: "up voted",
+ }[self.vote]
+
+ return u"%s %s %s" % (unicode(self.user), action, unicode(self.content_object))
+
+ class Meta:
+ db_table = "voting"
+ get_latest_by = "modified"
+ verbose_name = "vote"
+ verbose_name_plural = "votes"
+ unique_together = ('object_id', 'user', 'content_type')
diff --git a/voting/serializers.py b/voting/serializers.py
new file mode 100644
index 0000000..54adcc0
--- /dev/null
+++ b/voting/serializers.py
@@ -0,0 +1,60 @@
+# voting.serializers
+# API Serializers for the voting module
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jan 27 08:38:52 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: serializers.py [] benjamin@bengfort.com $
+
+"""
+API Serializers for the voting module
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from rest_framework import serializers
+
+##########################################################################
+## Validators
+##########################################################################
+
+class InRange(object):
+ """
+ Validator that specifies a value must be in a particular range
+ """
+
+ def __init__(self, low, high):
+ self.low = low
+ self.high = high
+
+ def __call__(self, value):
+ if value > self.high or value < self.low:
+ raise serializers.ValidationError("value must be between %d and %d (inclusive)" % (self.low, self.high))
+
+##########################################################################
+## Serializers
+##########################################################################
+
+class VotingSerializer(serializers.Serializer):
+ """
+ Serializes incoming votes.
+
+ Note: There is no model associated with this serializer
+ """
+
+ vote = serializers.IntegerField(validators=[InRange(-1,1)])
+ display = serializers.SerializerMethodField('get_vote_display')
+
+ def get_vote_display(self, obj):
+ displays = {
+ -1: "downvote",
+ 0: "novote",
+ 1: "upvote",
+ }
+
+ return displays[obj['vote']]
diff --git a/voting/signals.py b/voting/signals.py
new file mode 100644
index 0000000..2a3b4c7
--- /dev/null
+++ b/voting/signals.py
@@ -0,0 +1,54 @@
+# voting.signals
+# Signals handling for the Voting app
+#
+# Author: Benjamin Bengfort
+# Created: Wed Mar 04 23:35:42 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: signals.py [] benjamin@bengfort.com $
+
+"""
+Signals handling for the Voting app
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from voting.models import Vote
+from stream.signals import stream
+from django.dispatch import receiver
+from django.db.models.signals import post_save
+
+##########################################################################
+## Signals
+##########################################################################
+
+@receiver(post_save, sender=Vote)
+def send_voted_activity_signal(sender, instance, created, **kwargs):
+ """
+ Sends the "voted" activity to the stream on up/down vote
+
+ Decisions:
+ 1. The vote object isn't included in the stream
+ 2. Activities are recorded even when votes are changed
+ 3. The target of the vote verb is the content_object
+ """
+ vote_verb = {
+ 1: 'upvote',
+ -1: 'downvote',
+ 0: None
+ }[instance.vote]
+
+ if vote_verb is None:
+ return
+
+ voted = {
+ 'sender': sender,
+ 'actor': instance.user,
+ 'verb': vote_verb,
+ 'target': instance.content_object,
+ }
+ stream.send(**voted)
diff --git a/voting/templatetags/__init__.py b/voting/templatetags/__init__.py
new file mode 100644
index 0000000..5387239
--- /dev/null
+++ b/voting/templatetags/__init__.py
@@ -0,0 +1,18 @@
+# voting.templatetags
+# Contextual template tags for use with things that get voted on
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jan 27 11:58:10 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: __init__.py [] benjamin@bengfort.com $
+
+"""
+Contextual template tags for use with things that get voted on
+"""
+
+##########################################################################
+## Imports
+##########################################################################
diff --git a/voting/templatetags/votable.py b/voting/templatetags/votable.py
new file mode 100644
index 0000000..cedaf54
--- /dev/null
+++ b/voting/templatetags/votable.py
@@ -0,0 +1,57 @@
+# voting.templatetags.votable
+# Tags for the Voting app
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jan 27 11:59:19 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: votable.py [] benjamin@bengfort.com $
+
+"""
+Tags for the Voting app
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django import template
+from voting.models import Vote
+from django.contrib.contenttypes.models import ContentType
+
+##########################################################################
+## Module constants
+##########################################################################
+
+register = template.Library()
+
+##########################################################################
+## Assignment Tags
+##########################################################################
+
+@register.assignment_tag(takes_context=True)
+def current_user_vote(context, content):
+ """
+ Returns the current user's vote for the given content:
+
+ -1 is a downvote
+ 0 is a novote
+ 1 is an upvote
+
+ Note that if the vote doesn't exist, a 0 is returned
+ """
+
+ kwargs = {
+ 'content_type': ContentType.objects.get_for_model(content),
+ 'object_id': content.id,
+ 'user': context['user'],
+ }
+
+ vote = Vote.objects.filter(**kwargs).first()
+
+ if vote is None:
+ return 0
+ else:
+ return vote.vote
diff --git a/voting/tests.py b/voting/tests.py
new file mode 100644
index 0000000..46a0075
--- /dev/null
+++ b/voting/tests.py
@@ -0,0 +1,187 @@
+# voting.tests
+# Tests for the voting module
+#
+# Author: Benjamin Bengfort
+# Created: Tue Jan 27 08:49:03 2015 -0500
+#
+# Copyright (C) 2016 District Data Labs
+# For license information, see LICENSE.txt
+#
+# ID: tests.py [] benjamin@bengfort.com $
+
+"""
+Tests for the voting module
+"""
+
+##########################################################################
+## Imports
+##########################################################################
+
+from django.test import TestCase
+from rest_framework.test import APITestCase
+from rest_framework import serializers
+
+from fugato.models import *
+from voting.models import *
+from voting.serializers import *
+from django.contrib.auth.models import User
+
+from stream.signals import stream
+from stream.models import StreamItem
+
+try:
+ from unittest.mock import MagicMock
+except ImportError:
+ from mock import MagicMock
+
+##########################################################################
+## Fixtures
+##########################################################################
+
+fixtures = {
+ 'user': {
+ 'username': 'jdoe',
+ 'first_name': 'John',
+ 'last_name': 'Doe',
+ 'email': 'jdoe@example.com',
+ 'password': 'supersecret',
+ },
+ 'question': {
+ 'text': 'Why did the chicken cross the road?',
+ 'author': None
+ }
+}
+
+##########################################################################
+## Serializer Tests
+##########################################################################
+
+class SerializerTests(APITestCase):
+ """
+ Test added functionality in voting serializers
+ """
+
+ def assertNotRaises(self, exc, fun, *args, **kwds):
+ try:
+ fun(*args, **kwds)
+ except exc:
+ self.fail("%s exception was raised", exc.__class__.__name__)
+
+ def test_in_range_validator(self):
+ """
+ Test the in-range validator
+ """
+
+ validator = InRange(-1, 1)
+
+ self.assertRaises(serializers.ValidationError, validator, -2)
+ self.assertRaises(serializers.ValidationError, validator, 2)
+ self.assertNotRaises(serializers.ValidationError, validator, -1)
+ self.assertNotRaises(serializers.ValidationError, validator, 1)
+ self.assertNotRaises(serializers.ValidationError, validator, 0)
+
+ def test_voting_serializer_vote_range(self):
+ """
+ Ensure that the vote in the serializer is in range
+ """
+
+ serializer = VotingSerializer(data={"vote":-1})
+ self.assertTrue(serializer.is_valid(), "downvote is not valid")
+
+ serializer = VotingSerializer(data={"vote":0})
+ self.assertTrue(serializer.is_valid(), "novote is not valid")
+
+ serializer = VotingSerializer(data={"vote":1})
+ self.assertTrue(serializer.is_valid(), "upvote is not valid")
+
+ serializer = VotingSerializer(data={"vote":10})
+ self.assertFalse(serializer.is_valid(), "bad vote is valid")
+
+##########################################################################
+## Model Tests
+##########################################################################
+
+class VotingModelTests(TestCase):
+ """
+ Test the Vote model/manager functionality
+ """
+
+ def setUp(self):
+ self.user = User.objects.create_user(**fixtures['user'])
+ fixtures['question']['author'] = self.user
+ self.question = Question.objects.create(**fixtures['question'])
+
+ def test_punch_ballot(self):
+ """
+ Test the punch ballot method of the Vote manager
+ """
+
+ # Ensure that there are no votes for the question to start
+ self.assertEqual(self.question.votes.count(), 0)
+ self.assertEqual(Vote.objects.upvotes().count(), 0)
+ self.assertEqual(Vote.objects.downvotes().count(), 0)
+
+ vote, created = Vote.objects.punch_ballot(self.question, self.user, 1)
+ self.assertTrue(created)
+ self.assertEqual(self.question.votes.count(), 1)
+ self.assertEqual(Vote.objects.upvotes().count(), 1)
+ self.assertEqual(Vote.objects.downvotes().count(), 0)
+
+ vote, created = Vote.objects.punch_ballot(self.question, self.user, -1)
+ self.assertFalse(created)
+ self.assertEqual(self.question.votes.count(), 1)
+ self.assertEqual(Vote.objects.upvotes().count(), 0)
+ self.assertEqual(Vote.objects.downvotes().count(), 1)
+
+ vote, created = Vote.objects.punch_ballot(self.question, self.user)
+ self.assertFalse(created)
+ self.assertEqual(self.question.votes.count(), 1)
+ self.assertEqual(Vote.objects.upvotes().count(), 0)
+ self.assertEqual(Vote.objects.downvotes().count(), 0)
+
+ self.assertEqual(Vote.objects.count(), 1)
+
+
+ def test_punch_ballot_kwargs(self):
+ """
+ Ensure that punch ballot requires content and user
+ """
+ self.assertRaises(TypeError, Vote.objects.punch_ballot, content=self.question)
+ self.assertRaises(TypeError, Vote.objects.punch_ballot, user=self.user)
+
+ def test_upvote_send_stream(self):
+ """
+ Assert that 'upvote' stream signal is sent
+ """
+ handler = MagicMock()
+ stream.connect(handler)
+ vote, created = Vote.objects.punch_ballot(self.question, self.user, 1)
+
+ # Ensure that the signal was sent once with required arguments
+ handler.assert_called_once_with(verb='upvote', sender=Vote,
+ actor=self.user, target=self.question, signal=stream)
+
+ def test_downvote_send_stream(self):
+ """
+ Assert that 'downvote' stream signal is sent
+ """
+ handler = MagicMock()
+ stream.connect(handler)
+ vote, created = Vote.objects.punch_ballot(self.question, self.user, -1)
+
+ # Ensure that the signal was sent once with required arguments
+ handler.assert_called_once_with(verb='downvote', sender=Vote,
+ actor=self.user, target=self.question, signal=stream)
+
+ def test_voted_activity(self):
+ """
+ Assert that when a ballot is punched, there is an activity stream item
+ """
+ vote, created = Vote.objects.punch_ballot(self.question, self.user, 1)
+ target_content_type = ContentType.objects.get_for_model(self.question)
+ target_object_id = self.question.id
+
+ query = StreamItem.objects.filter(verb='upvote', actor=self.user,
+ target_content_type=target_content_type, target_object_id=target_object_id)
+
+ self.assertEqual(query.count(), 1, "no stream item created!")
diff --git a/voting/views.py b/voting/views.py
new file mode 100644
index 0000000..91ea44a
--- /dev/null
+++ b/voting/views.py
@@ -0,0 +1,3 @@
+from django.shortcuts import render
+
+# Create your views here.