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 %} +
+ + +
+ {% 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 %} + + 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 %} +
+ +
+ + +
+
Question History
+
    +
  • Question history has not been implemented yet.
  • +
+
+ + +
+ + +
+

{{ question.text }}

+

+ asked on {{ question.created|date }} by {{ question.author.get_full_name }} +

+ + +
+ {% current_user_vote question as voted %} + + + + + +
+ {% if question.author == request.user %} + {% if question.details %} +

Edit Details

+ {% else %} +

Add Details

+ {% endif %} + {% endif %} +
+
+
+ + +
+ {% if question.details %} + {{ question.details_rendered|safe }} + {% endif %} +
+ + +
+ +
+ + +
+ {% with count=question.answers.count %} +

{{ count }} ANSWER{{ count|pluralize:"S" }}

+

Answer Question

+ {% endwith %} +
+
+
+ + +
+ {% 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 %} + + + + +
+
+ {% empty %} +
+

Be the first to answer this question!

+
+ {% endfor %} +
+ + +
+
+

Compose an answer

+
+
+ + Edit Answer in Markdown + +
+
+ + +
+
+
+
+
+ +
+
+ + +
+
Related Questions
+
    + {% for related in question.related.all %} +
  • {{ related }}
  • + {% empty %} +
  • Related question discovery has not been implemented yet.
  • + {% 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 %} + +{% 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 %} + + {% endif %} + + {% if messages %} + {% for message in messages %} + {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %} + + {% endif %} + {% endfor %} + {% endif %} + + {% if next %} + {% if user.is_authenticated %} + + {% else %} + + {% endif %} + {% endif %} + {% endblock %} + + {% block login-form %} +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + + {% csrf_token %} +
+
+
+
+
+ {% endblock %} + + {% endblock %} +
+ + + +
+ {% endblock %} + +
+
+
+ + + + + +{% 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 %} + +{% 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 %} +
+
+
+
+ +
+
+
+
+
+
+ + +
+ Enter a new password for your account. +
+
+
+ + +
+ Enter the same password as above, for verification. +
+
+ + {% 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 %} +
+
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ + {% 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 %} + +
+ +
+ + +
+ + +
+ Gravatar + +
+ +

{{ user.profile.full_name }}

+

{{ user.username }}

+ + +
+
    + + {% if user.profile.location %} +
  • + + {{ user.profile.location }} +
  • + {% endif %} + + {% if user.profile.organization %} +
  • + + {{ user.profile.organization }} +
  • + {% endif %} + + {% if user.email %} +
  • + + {{ user.email }} +
  • + {% endif %} + +
  • + + Joined on {{ user.date_joined|date }} +
  • + +
+ + +
+ + +
+ + +
+ + +
+ + + + + + + + + +
+
+ + +
+
+
+ +
    + {% 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 + Minimum Entropy beta + +{% endblock %} + +{% block userlinks %} +{% if user.is_authenticated %} + +{% 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

    +
    +
    + + +
    +
    + + +
    + + {% 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. +

    + + + + +
    + +

    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: + +