diff --git a/Makefile b/Makefile index 0623d80..2000273 100644 --- a/Makefile +++ b/Makefile @@ -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 := partisan +APPS := partisan members # Export targets not associated with files .PHONY: test showenv coverage bootstrap pip virtualenv clean virtual_env_set truncate diff --git a/README.md b/README.md index 97f3474..8f96ab7 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,20 @@ This small web application is intended to highlight how to operationalize machin The image used in this README, [Partisan Fail][partisan.jpg] by [David Colarusso](https://www.flickr.com/photos/dcolarusso/) is licensed under [CC BY-NC 2.0](https://creativecommons.org/licenses/by-nc/2.0/) +## Changelog + +The release versions that are deployed to the web servers 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. + +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 Beta 1 + +* **tag**: [v0.1b1](https://github.com/DistrictDataLabs/partisan-discourse/releases/tag/v0.1b1) +* **deployment**: Monday, July 18, 2016 +* **commit**: [see tag](#) + +This is the first beta release of the Political Discourse application. Right now this simple web application allows users to sign in, then add links to go fetch web content to the global corpus. These links are then preprocessed using NLP foo. Users can tag the documents as Republican or Democrat, allowing us to build a political classifier. + [travis_img]: https://travis-ci.org/DistrictDataLabs/partisan-discourse.svg [travis_href]: https://travis-ci.org/DistrictDataLabs/partisan-discourse diff --git a/corpus/__init__.py b/corpus/__init__.py new file mode 100644 index 0000000..75c0942 --- /dev/null +++ b/corpus/__init__.py @@ -0,0 +1,25 @@ +# corpus +# An application that allows the management of our classifier corpus. +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 19:29:55 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: __init__.py [] benjamin@bengfort.com $ + +""" +An application that allows the management of our classifier corpus. +""" + +########################################################################## +## Imports +########################################################################## + + +########################################################################## +## Configuration +########################################################################## + +default_app_config = 'corpus.apps.CorpusConfig' diff --git a/corpus/admin.py b/corpus/admin.py new file mode 100644 index 0000000..12c8a7b --- /dev/null +++ b/corpus/admin.py @@ -0,0 +1,29 @@ +# corpus.admin +# Register models with the Django Admin for the corpus app. +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 19:30:33 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: admin.py [] benjamin@bengfort.com $ + +""" +Register models with the Django Admin for the corpus app. +""" + +########################################################################## +## Imports +########################################################################## + +from django.contrib import admin +from corpus.models import Document, Annotation, Label + +########################################################################## +## Register Admin +########################################################################## + +admin.site.register(Label) +admin.site.register(Annotation) +admin.site.register(Document) diff --git a/corpus/apps.py b/corpus/apps.py new file mode 100644 index 0000000..cdb7d7b --- /dev/null +++ b/corpus/apps.py @@ -0,0 +1,33 @@ +# corpus.apps +# Application definition for the corpus app. +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 19:31:28 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: apps.py [] benjamin@bengfort.com $ + +""" +Application definition for the corpus app. +""" + +########################################################################## +## Imports +########################################################################## + +from django.apps import AppConfig + + +########################################################################## +## Corpus Config +########################################################################## + +class CorpusConfig(AppConfig): + + name = 'corpus' + verbose_name = "Corpora" + + def ready(self): + import corpus.signals diff --git a/corpus/bitly.py b/corpus/bitly.py new file mode 100644 index 0000000..501c73a --- /dev/null +++ b/corpus/bitly.py @@ -0,0 +1,58 @@ +# corpus.bitly +# Access the bit.ly url shortening service. +# +# Author: Benjamin Bengfort +# Created: Mon Jul 18 09:59:27 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: bitly.py [] benjamin@bengfort.com $ + +""" +Access the bit.ly url shortening service. +""" + +########################################################################## +## Imports +########################################################################## + +import requests + +from django.conf import settings +from urllib.parse import urljoin +from corpus.exceptions import BitlyAPIError + +########################################################################## +## Shorten function +########################################################################## + +def shorten(url, token=None): + """ + Shortens a URL using the bit.ly API. + """ + + # Get the bit.ly access token from settings + token = settings.BITLY_ACCESS_TOKEN or token + if not token: + raise BitlyAPIError( + "Cannot call shorten URL without a bit.ly access token" + ) + + # Compute and make the request to the API + endpoint = urljoin(settings.BITLY_API_ADDRESS, "v3/shorten") + params = { + "access_token": token, + "longUrl": url, + } + + # bit.ly tends not to send status code errors + response = requests.get(endpoint, params=params) + + # Parse and return the result + data = response.json() + if data['status_code'] != 200: + raise BitlyAPIError( + "Could not shorten link: {}".format(data['status_txt']) + ) + return data['data']['url'] diff --git a/corpus/exceptions.py b/corpus/exceptions.py new file mode 100644 index 0000000..07e8c7a --- /dev/null +++ b/corpus/exceptions.py @@ -0,0 +1,38 @@ +# corpus.exceptions +# Custom exceptions for corpus handling. +# +# Author: Benjamin Bengfort +# Created: Mon Jul 18 09:57:26 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: exceptions.py [] benjamin@bengfort.com $ + +""" +Custom exceptions for corpus handling. +""" + +########################################################################## +## Corpus Exceptions +########################################################################## + +class CorpusException(Exception): + """ + Something went wrong in the corpus app. + """ + pass + + +class BitlyAPIError(CorpusException): + """ + Something went wrong trying to shorten a url. + """ + pass + + +class FetchError(CorpusException): + """ + Something went wrong trying to fetch a url using requests. + """ + pass diff --git a/corpus/fixtures/labels.json b/corpus/fixtures/labels.json new file mode 100644 index 0000000..de6dfdb --- /dev/null +++ b/corpus/fixtures/labels.json @@ -0,0 +1,35 @@ +[ +{ + "model": "corpus.label", + "pk": 1, + "fields": { + "created": "2016-07-18T14:56:26.706Z", + "modified": "2016-07-18T14:56:26.708Z", + "name": "USA Political Parties", + "slug": "usa-political-parties", + "parent": null + } +}, +{ + "model": "corpus.label", + "pk": 2, + "fields": { + "created": "2016-07-18T14:57:33.059Z", + "modified": "2016-07-18T14:57:33.062Z", + "name": "Democratic", + "slug": "democratic", + "parent": 1 + } +}, +{ + "model": "corpus.label", + "pk": 3, + "fields": { + "created": "2016-07-18T14:57:59.531Z", + "modified": "2016-07-18T14:57:59.534Z", + "name": "Republican", + "slug": "republican", + "parent": 1 + } +} +] diff --git a/corpus/managers.py b/corpus/managers.py new file mode 100644 index 0000000..35d3508 --- /dev/null +++ b/corpus/managers.py @@ -0,0 +1,39 @@ +# corpus.managers +# Model managers for the corpus application. +# +# Author: Benjamin Bengfort +# Created: Mon Jul 18 23:09:19 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: managers.py [] benjamin@bengfort.com $ + +""" +Model managers for the corpus application. +""" + +########################################################################## +## Imports +########################################################################## + +from django.db import models + + +########################################################################## +## Annotation Manager +########################################################################## + +class AnnotationManager(models.Manager): + + def republican(self): + """ + Filters the annotations for only republican annotations. + """ + return self.filter(label__slug='republican') + + def democratic(self): + """ + Filters the annotations for only democratic annotations. + """ + return self.filter(label__slug='democratic') diff --git a/corpus/migrations/0001_initial.py b/corpus/migrations/0001_initial.py new file mode 100644 index 0000000..86b818c --- /dev/null +++ b/corpus/migrations/0001_initial.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-18 17:31 +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 +import picklefield.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Annotation', + 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')), + ], + options={ + 'db_table': 'annotations', + 'get_latest_by': 'created', + }, + ), + migrations.CreateModel( + name='Document', + 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')), + ('title', models.CharField(blank=True, default=None, max_length=255, null=True)), + ('long_url', models.URLField(max_length=2000, unique=True)), + ('short_url', models.URLField(blank=True, default=None, max_length=30, null=True)), + ('raw_html', models.TextField(blank=True, default=None, null=True)), + ('content', picklefield.fields.PickledObjectField(blank=True, default=None, editable=False, null=True)), + ('signature', models.CharField(blank=True, default=None, editable=False, max_length=44, null=True)), + ('n_words', models.SmallIntegerField(blank=True, default=None, null=True)), + ('n_vocab', models.SmallIntegerField(blank=True, default=None, null=True)), + ('users', models.ManyToManyField(related_name='documents', through='corpus.Annotation', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'documents', + 'get_latest_by': 'created', + }, + ), + migrations.CreateModel( + name='Label', + 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')), + ('name', models.CharField(max_length=64, unique=True)), + ('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='name', unique=True)), + ('documents', models.ManyToManyField(related_name='labels', through='corpus.Annotation', to='corpus.Document')), + ('parent', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='corpus.Label')), + ], + options={ + 'db_table': 'labels', + 'get_latest_by': 'created', + }, + ), + migrations.AddField( + model_name='annotation', + name='document', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='annotations', to='corpus.Document'), + ), + migrations.AddField( + model_name='annotation', + name='label', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='annotations', to='corpus.Label'), + ), + migrations.AddField( + model_name='annotation', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='annotations', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='document', + unique_together=set([('long_url', 'short_url')]), + ), + migrations.AlterUniqueTogether( + name='annotation', + unique_together=set([('document', 'user')]), + ), + ] diff --git a/corpus/migrations/__init__.py b/corpus/migrations/__init__.py new file mode 100644 index 0000000..3d9bab6 --- /dev/null +++ b/corpus/migrations/__init__.py @@ -0,0 +1,18 @@ +# corpus.migrations +# Database migrations for the corpus app. +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 19:29:21 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: __init__.py [] benjamin@bengfort.com $ + +""" +Database migrations for the corpus app. +""" + +########################################################################## +## Imports +########################################################################## diff --git a/corpus/models.py b/corpus/models.py new file mode 100644 index 0000000..4079b9f --- /dev/null +++ b/corpus/models.py @@ -0,0 +1,122 @@ +# corpus.models +# Database models for the corpus app +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 19:32:41 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: models.py [] benjamin@bengfort.com $ + +""" +Database models for the corpus app +""" + +########################################################################## +## Imports +########################################################################## + +from django.db import models +from autoslug import AutoSlugField +from partisan.utils import nullable +from django.core.urlresolvers import reverse +from model_utils.models import TimeStampedModel +from picklefield.fields import PickledObjectField +from corpus.managers import AnnotationManager + +########################################################################## +## Document Model +########################################################################## + +class Document(TimeStampedModel): + """ + Describes a document that is part of one or more corpora. + """ + + title = models.CharField(max_length=255, **nullable) # The title of the document, extracted from HTML + long_url = models.URLField(max_length=2000, unique=True) # The long url for the document + short_url = models.URLField(max_length=30, **nullable) # The bit.ly shortened url + raw_html = models.TextField(**nullable) # The html content fetched (hopefully) + content = PickledObjectField(**nullable) # The preprocessed NLP content in a parsable text representation + signature = models.CharField(max_length=44, editable=False, **nullable) # A base64 encoded hash of the content + n_words = models.SmallIntegerField(**nullable) # The word count of the document + n_vocab = models.SmallIntegerField(**nullable) # The size of the vocabulary used + + # Users are associated with documents by downloading and annotating them. + users = models.ManyToManyField( + 'auth.User', through='corpus.Annotation', related_name='documents' + ) + + class Meta: + db_table = "documents" + get_latest_by = "created" + unique_together = ("long_url", "short_url") + + def get_absolute_url(self): + """ + Returns the detail view url for the object + """ + return reverse('corpus:document-detail', args=(self.id,)) + + def __str__(self): + if self.title: return self.title + return self.short_url + + +########################################################################## +## Annotation +########################################################################## + +class Label(TimeStampedModel): + """ + A label that is associated with a document to classify it. + """ + + name = models.CharField(max_length=64, unique=True) # The name of the label + slug = AutoSlugField(populate_from='name', unique=True) # A unique slug of the label + parent = models.ForeignKey('self', **nullable) # If there is a label hierarchy + documents = models.ManyToManyField( + 'corpus.Document', through='corpus.Annotation', related_name='labels' + ) + + class Meta: + db_table = "labels" + get_latest_by = "created" + + def __str__(self): + return self.name + + +class Annotation(TimeStampedModel): + """ + A user description of a document, e.g. what label and a user-specific + association with the documentation for personalized corpus generation. + """ + + document = models.ForeignKey('corpus.Document', related_name='annotations') + user = models.ForeignKey('auth.User', related_name='annotations') + label = models.ForeignKey('corpus.Label', related_name='annotations', **nullable) + + objects = AnnotationManager() + + class Meta: + db_table = "annotations" + get_latest_by = "modified" + ordering = ['-modified'] + unique_together = ("document", "user") + + def __str__(self): + if self.label: + return "{} added label {} to \"{}\" on {}".format( + self.user, self.label, self.document, self.modified + ) + + return "{} added document \"{}\" on {}".format( + self.user, self.document, self.created + ) + + +########################################################################## +## Corpus Model +########################################################################## diff --git a/corpus/nlp.py b/corpus/nlp.py new file mode 100644 index 0000000..5e7163c --- /dev/null +++ b/corpus/nlp.py @@ -0,0 +1,84 @@ +# corpus.nlp +# Provides utilities for natural language processing +# +# Author: Benjamin Bengfort +# Created: Mon Jul 18 11:55:41 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: nlp.py [] benjamin@bengfort.com $ + +""" +Provides utilities for natural language processing +""" + +########################################################################## +## Imports +########################################################################## + +import bs4 +import nltk + +from collections import Counter +from readability.readability import Document + +########################################################################## +## Module Constants +########################################################################## + +# Tags to extract as paragraphs from the HTML text +TAGS = [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'h7', 'p', 'li' +] + + +########################################################################## +## Preprocessing Functions +########################################################################## + +def para_tokenize(html): + """ + Splits an HTML document into consistutent paragraphs. + """ + # Transform the document into a readability paper summary + summary = Document(html).summary() + + # Parse the HTML using BeautifulSoup + soup = bs4.BeautifulSoup(summary, 'lxml') + + # Extract the paragraph delimiting elements + for tag in soup.find_all(TAGS): + + # Get the HTML node text + text = tag.get_text() + if text: yield text + + +def preprocess(html): + """ + Returns a preprocessed document consisting of a list of paragraphs, which + is a list of sentences, which is a list of tuples, where each tuple is a + (token, part of speech) pair. + """ + return [ + [ + nltk.pos_tag(nltk.wordpunct_tokenize(sent)) + for sent in nltk.sent_tokenize(paragraph) + ] + for paragraph in para_tokenize(html) + ] + + +def word_vocab_count(text): + """ + Counts the number of words and vocabulary in preprocessed text. + """ + counts = Counter([ + word[0].lower() + for paragraph in text + for sentence in paragraph + for word in sentence + ]) + + return sum(counts.values()), len(counts) diff --git a/corpus/serializers.py b/corpus/serializers.py new file mode 100644 index 0000000..2291abc --- /dev/null +++ b/corpus/serializers.py @@ -0,0 +1,111 @@ +# corpus.serializers +# API serializers for corpus models and API interaction. +# +# Author: Benjamin Bengfort +# Created: Mon Jul 18 09:30:17 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: serializers.py [] benjamin@bengfort.com $ + +""" +API serializers for corpus models and API interaction. +""" + +########################################################################## +## Imports +########################################################################## + +from rest_framework import serializers +from corpus.models import Document, Annotation, Label + + +########################################################################## +## Document Serializer +########################################################################## + +class DocumentSerializer(serializers.HyperlinkedModelSerializer): + """ + Only allows the POST/PUT of a long_url and shows the document detail. + """ + + detail = serializers.URLField(source='get_absolute_url', read_only=True) + labels = serializers.StringRelatedField(many=True, read_only=True) + + class Meta: + model = Document + fields = ( + 'url', 'detail', 'title', 'long_url', 'short_url', + 'signature', 'n_words', 'n_vocab', 'labels', + ) + read_only_fields = ( + 'title', 'short_url', 'signature', 'n_words', 'n_vocab', + ) + extra_kwargs = { + 'long_url': {'validators': []}, + 'url': {'view_name': 'api:document-detail'}, + } + + +########################################################################## +## Annotation/Label Serializer +########################################################################## + +class CurrentDocumentDefault(object): + + def set_context(self, serializer_field): + self.document = serializer_field.context['document'] + + def __call__(self): + return self.document + + def __repr__(self): + return "{}()".format(self.__class__.__name__) + + +class AnnotationSerializer(serializers.ModelSerializer): + + # The user that is doing the annotation + user = serializers.HyperlinkedRelatedField( + default = serializers.CurrentUserDefault(), + read_only = True, + view_name = "api:user-detail", + ) + + # The document that the user is annotating + # Read only is true because the document is passed in at save. + document = serializers.HyperlinkedRelatedField( + view_name = "api:document-detail", + read_only = True, + default = CurrentDocumentDefault(), + ) + + # The label the user is assigning to the document + label = serializers.SlugRelatedField( + many = False, + slug_field = 'slug', + queryset = Label.objects.all(), + allow_null = True, + ) + + class Meta: + model = Annotation + fields = ('user', 'document', 'label') + + def create(self, validated_data): + """ + Most annotations have already been created (so usually only update is + needed), yet this serializer will not be instantiated with anything + but document/user pairs - so we should look up the instance on save. + """ + ModelClass = self.Meta.model + + try: + self.instance = ModelClass.objects.get( + user=validated_data['user'], + document=validated_data['document'], + ) + return self.update(self.instance, validated_data) + except ModelClass.DoesNotExist: + return super(AnnotationSerializer, self).create(validated_data) diff --git a/corpus/signals.py b/corpus/signals.py new file mode 100644 index 0000000..87ae63c --- /dev/null +++ b/corpus/signals.py @@ -0,0 +1,80 @@ +# corpus.signals +# Signals for model management in the corpus app. +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 21:05:28 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: signals.py [] benjamin@bengfort.com $ + +""" +Signals for model management in the corpus app. +""" + +########################################################################## +## Imports +########################################################################## + +import bs4 +import requests + +from django.dispatch import receiver +from django.db.models.signals import pre_save, post_save + +from corpus.bitly import shorten +from corpus.models import Document +from partisan.utils import signature +from corpus.exceptions import FetchError +from corpus.nlp import preprocess, word_vocab_count + + +########################################################################## +## Document Signals +########################################################################## + +@receiver(pre_save, sender=Document) +def fetch_document_on_create(sender, instance, *args, **kwargs): + """ + This is the workhorse of the document saving model. If the document is + created and doesn't have a short url, it will fetch a short url. If the + document is created and doesn't have html, it will fetch the html. + """ + + # Fetch the bit.ly URL if it doesn't already have one. + if not instance.short_url: + instance.short_url = shorten(instance.long_url) + + # If there is no raw_html, fetch it with the requests module. + if not instance.raw_html: + + try: + # Get the response and check if it exists + # Raise an exception on a bad status code + response = requests.get(instance.long_url) + response.raise_for_status() + except Exception as e: + raise FetchError( + "Could not fetch document: {}".format(e) + ) + + # Otherwise set the raw html on the instance + instance.raw_html = response.text + + # If there is no content, preprocess it + if not instance.content: + instance.content = preprocess(instance.raw_html) + words, vocab = word_vocab_count(instance.content) + instance.n_words = words + instance.n_vocab = vocab + + # If there is no title, parse it from the raw html. + if not instance.title: + soup = bs4.BeautifulSoup(instance.raw_html, 'lxml') + instance.title = soup.title.string + + # If there is no signature parse it from the raw_html + # TODO: Change the signature to operate off the preprocessed text. + if not instance.signature: + instance.signature = signature(instance.raw_html) diff --git a/corpus/tests.py b/corpus/tests.py new file mode 100644 index 0000000..d9da2cd --- /dev/null +++ b/corpus/tests.py @@ -0,0 +1,25 @@ +# corpus.tests +# Tests for the corpus app. +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 19:33:16 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: tests.py [] benjamin@bengfort.com $ + +""" +Tests for the corpus app. +""" + +########################################################################## +## Imports +########################################################################## + +from django.test import TestCase + + +########################################################################## +## Tests +########################################################################## diff --git a/corpus/urls.py b/corpus/urls.py new file mode 100644 index 0000000..b9b9483 --- /dev/null +++ b/corpus/urls.py @@ -0,0 +1,29 @@ +# corpus.urls +# URLs for routing the corpus app. +# +# Author: Benjamin Bengfort +# Created: Mon Jul 18 13:10:24 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: urls.py [] benjamin@bengfort.com $ + +""" +URLs for routing the corpus app. +""" + +########################################################################## +## Imports +########################################################################## + +from django.conf.urls import url +from corpus.views import * + +########################################################################## +## URL Patterns +########################################################################## + +urlpatterns = ( + url(r'^documents/(?P\d+)/$', DocumentDetail.as_view(), name='document-detail'), +) diff --git a/corpus/views.py b/corpus/views.py new file mode 100644 index 0000000..6ea4b62 --- /dev/null +++ b/corpus/views.py @@ -0,0 +1,128 @@ +# corpus.views +# Views for the corpus application +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 19:33:46 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: views.py [] benjamin@bengfort.com $ + +""" +Views for the corpus application +""" + +########################################################################## +## Imports +########################################################################## + +from django.views.generic import DetailView + +from rest_framework import status +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework.decorators import detail_route +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated + +from corpus.models import Document, Annotation, Label +from corpus.serializers import DocumentSerializer +from corpus.serializers import AnnotationSerializer +from corpus.exceptions import CorpusException + + +########################################################################## +## Views +########################################################################## + +class DocumentDetail(DetailView): + + model = Document + template_name = 'corpus/document.html' + context_object_name = 'document' + labels_parent_name = 'USA Political Parties' + + + def get_context_data(self, **kwargs): + context = super(DocumentDetail, self).get_context_data(**kwargs) + + # Add user-specific parameters + document = context['document'] + annotation = document.annotations.filter(user=self.request.user).first() + context['annotation'] = annotation + + # Add label-specific parameters + # TODO Do not hard code the parent into the class! + context['labels'] = Label.objects.filter( + parent__name = self.labels_parent_name + ) + + return context + +########################################################################## +## API HTTP/JSON Views +########################################################################## + +class DocumentViewSet(viewsets.ModelViewSet): + + queryset = Document.objects.all() + serializer_class = DocumentSerializer + permission_classes = [IsAuthenticated] + + def create(self, request, *args, **kwargs): + """ + Create both the document and the annotation (user-association). + """ + # Deserialize and validate the data from the user. + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Execute the document and annotation creation + self.perform_create(serializer) + + # Get the headers and return a response + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def perform_create(self, serializer): + """ + Excepts any thing that might happen in the signals and raises a + validation error in order to send back the right status code. + """ + try: + + # Create the document object + long_url = serializer.validated_data['long_url'] + document, _ = Document.objects.get_or_create(long_url=long_url) + serializer.instance = document + + # Create the annotation object + annotate, _ = Annotation.objects.get_or_create( + user = self.request.user, document = document + ) + + except CorpusException as e: + raise ValidationError(str(e)) + + @detail_route(methods=['post'], permission_classes=[IsAuthenticated]) + def annotate(self, request, pk=None): + """ + Allows the specification of an annotation (label) for the given + document. Note that a user can only have one label associated with + one document, for the time being. + """ + + # Get the document of the detail view and deserialize data + document = self.get_object() + serializer = AnnotationSerializer( + data=request.data, + context={'request': request, 'document': document} + ) + + # Validate the serializer and save the annotation + serializer.is_valid(raise_exception=True) + serializer.save() + + # Return the response + return Response(serializer.data) diff --git a/manage.py b/manage.py index dbd6fd3..2ef0b5d 100755 --- a/manage.py +++ b/manage.py @@ -8,7 +8,7 @@ # Copyright (C) 2016 District Data Labs # For license information, see LICENSE.txt # -# ID: manage.py [] benjamin@bengfort.com $ +# ID: manage.py [5277a6e] benjamin@bengfort.com $ """ Django default management commands, with some special sauce. diff --git a/members/__init__.py b/members/__init__.py new file mode 100644 index 0000000..4dbafa0 --- /dev/null +++ b/members/__init__.py @@ -0,0 +1,24 @@ +# members +# The members app manages information about our users (DDL members). +# +# Author: Benjamin Bengfort +# Created: Sat Aug 22 09:23:38 2015 -0500 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: __init__.py [] benjamin@bengfort.com $ + +""" +The members app manages information about our users (DDL members). +""" + +########################################################################## +## Imports +########################################################################## + +########################################################################## +## Configuration +########################################################################## + +default_app_config = 'members.apps.MembersConfig' diff --git a/members/admin.py b/members/admin.py new file mode 100644 index 0000000..96c4457 --- /dev/null +++ b/members/admin.py @@ -0,0 +1,65 @@ +# members.admin +# Administrative interface for members in Partisan Discourse. +# +# Author: Benjamin Bengfort +# Created: Sat Aug 22 09:24:11 2015 -0500 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: admin.py [] benjamin@bengfort.com $ + +""" +Administrative interface for members in Partisan Discourse. +""" + +########################################################################## +## Imports +########################################################################## + +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import User +from django.contrib.contenttypes.admin import GenericStackedInline + +from members.models import Profile + +########################################################################## +## Inline Adminstration +########################################################################## + + +class ProfileInline(admin.StackedInline): + """ + Inline administration descriptor for profile object + """ + + model = Profile + can_delete = False + verbose_name_plural = 'profile' + + +class UserAdmin(UserAdmin): + """ + Define new User admin + """ + + inlines = (ProfileInline,) + + +class ProfileAdmin(admin.ModelAdmin): + """ + Editing profiles without editing the user field. + """ + + readonly_fields = ('user', ) + fields = ('user', 'organization', 'location', 'biography') + + +########################################################################## +## Register Admin +########################################################################## + +admin.site.unregister(User) +admin.site.register(User, UserAdmin) +admin.site.register(Profile, ProfileAdmin) diff --git a/members/apps.py b/members/apps.py new file mode 100644 index 0000000..b6f47b5 --- /dev/null +++ b/members/apps.py @@ -0,0 +1,31 @@ +# members.apps +# Describes the Members application for Django +# +# Author: Benjamin Bengfort +# Created: Sat Aug 22 10:41:24 2015 -0500 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: apps.py [] benjamin@bengfort.com $ + +""" +Describes the Members application for Django +""" + +########################################################################## +## Imports +########################################################################## + +from django.apps import AppConfig + +########################################################################## +## Members Config +########################################################################## + +class MembersConfig(AppConfig): + name = 'members' + verbose_name = "Member Profiles" + + def ready(self): + import members.signals diff --git a/members/migrations/0001_initial.py b/members/migrations/0001_initial.py new file mode 100644 index 0000000..60b57b8 --- /dev/null +++ b/members/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-17 22:46 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import markupfield.fields +import model_utils.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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')), + ('email_hash', models.CharField(editable=False, max_length=32)), + ('organization', models.CharField(blank=True, default=None, max_length=255, null=True)), + ('location', models.CharField(blank=True, default=None, max_length=255, null=True)), + ('biography', markupfield.fields.MarkupField(blank=True, default=None, help_text='Edit in Markdown', null=True, rendered_field=True)), + ('twitter', models.CharField(blank=True, default=None, max_length=100, null=True)), + ('biography_markup_type', models.CharField(choices=[('', '--'), ('html', 'HTML'), ('plain', 'Plain'), ('markdown', 'Markdown')], default='markdown', editable=False, max_length=30)), + ('linkedin', models.URLField(blank=True, default=None, null=True)), + ('_biography_rendered', models.TextField(editable=False, null=True)), + ('user', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'member_profiles', + }, + ), + ] diff --git a/members/migrations/0002_auto_20160717_2120.py b/members/migrations/0002_auto_20160717_2120.py new file mode 100644 index 0000000..f39d123 --- /dev/null +++ b/members/migrations/0002_auto_20160717_2120.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-18 01:20 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('members', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='biography_markup_type', + field=models.CharField(choices=[('', '--'), ('markdown', 'markdown')], default='markdown', editable=False, max_length=30), + ), + ] diff --git a/members/migrations/__init__.py b/members/migrations/__init__.py new file mode 100644 index 0000000..d8bb0c7 --- /dev/null +++ b/members/migrations/__init__.py @@ -0,0 +1,18 @@ +# members.migrations +# Database migrations for the members app. +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 18:46:16 2016 -0400 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: __init__.py [] benjamin@bengfort.com $ + +""" +Database migrations for the members app. +""" + +########################################################################## +## Imports +########################################################################## diff --git a/members/models.py b/members/models.py new file mode 100644 index 0000000..8085cf8 --- /dev/null +++ b/members/models.py @@ -0,0 +1,96 @@ +# members.models +# Models that store information about faculty and students. +# +# Author: Benjamin Bengfort +# Created: Sat Aug 22 09:24:48 2015 -0500 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: models.py [] benjamin@bengfort.com $ + +""" +Models that store information about faculty and students. +""" + +########################################################################## +## Imports +########################################################################## + +import urllib.parse + +from django.db import models +from django.conf import settings +from partisan.utils import nullable +from markupfield.fields import MarkupField +from django.core.urlresolvers import reverse +from model_utils.models import TimeStampedModel +from django.contrib.contenttypes.models import ContentType + +########################################################################## +## User Profile Model for DDL Members +########################################################################## + +class Profile(TimeStampedModel): + """ + Stores extra information about a user or DDL member. + """ + + user = models.OneToOneField('auth.User', editable=False) + email_hash = models.CharField(max_length=32, editable=False) + organization = models.CharField(max_length=255, **nullable) + location = models.CharField(max_length=255, **nullable) + biography = MarkupField(markup_type='markdown', help_text='Edit in Markdown', **nullable) + twitter = models.CharField(max_length=100, **nullable) + linkedin = models.URLField(**nullable) + + class Meta: + db_table = 'member_profiles' + + @property + def full_name(self): + return self.user.get_full_name() + + @property + def full_email(self): + email = u"{} <{}>".format(self.full_name, self.user.email) + return email.strip() + + @property + def gravatar(self): + return self.get_gravatar_url() + + @property + def gravatar_icon(self): + return self.get_gravatar_url(size=settings.GRAVATAR_ICON_SIZE) + + @property + def gravatar_badge(self): + return self.get_gravatar_url(size=64) + + def get_gravatar_url(self, size=None, default=None): + """ + Comptues the gravatar url from an email address + """ + size = size or settings.GRAVATAR_DEFAULT_SIZE + default = default or settings.GRAVATAR_DEFAULT_IMAGE + params = urllib.parse.urlencode({'d': default, 's': str(size)}) + + return "http://www.gravatar.com/avatar/{}?{}".format( + self.email_hash, params + ) + + def get_api_detail_url(self): + """ + Returns the API detail endpoint for the object + """ + return reverse('api:user-detail', args=(self.user.pk,)) + + def get_absolute_url(self): + """ + Returns the detail view url for the object + """ + return reverse('member:detail', args=(self.user.username,)) + + def __str__(self): + return self.full_email diff --git a/members/permissions.py b/members/permissions.py new file mode 100644 index 0000000..173d324 --- /dev/null +++ b/members/permissions.py @@ -0,0 +1,47 @@ +# members.permissions +# Extra permissions for the Django Rest Framework views. +# +# Author: Benjamin Bengfort +# Created: Sun Aug 23 07:38:55 2015 -0500 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: permissions.py [] benjamin@bengfort.com $ + +""" +Extra permissions for the Django Rest Framework views. +""" + +########################################################################## +## Imports +########################################################################## + +from rest_framework import permissions + +########################################################################## +## Permissions +########################################################################## + +class IsAuthorOrReadOnly(permissions.BasePermission): + """ + Object-level permission to allow only owners of an object to edit. + Note, this permission assumes there is an `author` attribute on the + object that maps to an `auth.User` instance. + """ + + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return True + + return obj.author == request.user + +class IsAdminOrSelf(permissions.BasePermission): + """ + Object-level permission to only allow modifications to a User object + if the request.user is an administrator or you are modifying your own + user object. + """ + + def has_object_permission(self, request, view, obj): + return request.user.is_staff or request.user == obj diff --git a/members/serializers.py b/members/serializers.py new file mode 100644 index 0000000..0532e9e --- /dev/null +++ b/members/serializers.py @@ -0,0 +1,118 @@ +# members.serializers +# Serializers for the members models +# +# Author: Benjamin Bengfort +# Created: Sun Aug 23 07:32:51 2015 -0500 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: serializers.py [] benjamin@bengfort.com $ + +""" +Serializers for the members models +""" + +########################################################################## +## Imports +########################################################################## + +from rest_framework import serializers +from django.contrib.auth.models import User +from members.models import Profile + + +########################################################################## +## User and User Profile Serializers +########################################################################## + + +class ProfileSerializer(serializers.ModelSerializer): + """ + Serializes the Profile object to embed into the User JSON + """ + + gravatar = serializers.CharField(read_only=True) + + class Meta: + model = Profile + fields = ('biography', 'gravatar', 'location', 'organization', 'twitter', 'linkedin') + + +class UserSerializer(serializers.HyperlinkedModelSerializer): + """ + Serializes the User object for use in the API. + """ + + profile = ProfileSerializer(many=False, read_only=False) + + class Meta: + model = User + fields = ( + 'url', 'username', 'first_name', + 'last_name', 'email', 'profile' + ) + extra_kwargs = { + 'url': {'view_name': 'api:user-detail'} + } + + def create(self, validated_data): + """ + Explicitly define create to also create the Profile object. + """ + profile_data = validated_data.pop('profile') + user = User.objects.create(**validated_data) + + for attr, value in profile_data.items(): + setattr(user.profile, attr, value) + + user.profile.save() + return user + + def update(self, instance, validated_data): + """ + Explicitly define update to also update the Profile object. + """ + profile_data = validated_data.pop('profile') + profile = instance.profile + + # Update the user instance + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + # Update the profile instance + for attr, value in profile_data.items(): + setattr(profile, attr, value) + profile.save() + + return instance + + +class SimpleUserSerializer(UserSerializer): + + full_name = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ( + 'url', 'username', 'full_name', 'email', + ) + extra_kwargs = { + 'url': {'view_name': 'api:user-detail'} + } + + def get_full_name(self, obj): + return obj.profile.full_name + + +class PasswordSerializer(serializers.Serializer): + + password = serializers.CharField(max_length=200) + repeated = serializers.CharField(max_length=200) + + def validate(self, attrs): + if attrs['password'] != attrs['repeated']: + raise serializers.ValidationError("passwords do not match!") + return attrs + diff --git a/members/signals.py b/members/signals.py new file mode 100644 index 0000000..b43b620 --- /dev/null +++ b/members/signals.py @@ -0,0 +1,45 @@ +# members.signals +# Signals management for the Members app. +# +# Author: Benjamin Bengfort +# Created: Sat Aug 22 10:43:03 2015 -0500 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: signals.py [] benjamin@bengfort.com $ + +""" +Signals management for the Members app. +""" + +########################################################################## +## Imports +########################################################################## + +import hashlib + +from django.dispatch import receiver +from django.db.models.signals import post_save + +from members.models import Profile +from django.contrib.auth.models import User + +########################################################################## +## User Post-Save Signals +########################################################################## + +@receiver(post_save, sender=User) +def update_user_profile(sender, instance, created, **kwargs): + """ + Creates a Profile object for the user if it doesn't exist, or updates + it with new information from the User (e.g. the gravatar). + """ + ## Compute the email hash + digest = hashlib.md5(instance.email.lower().encode('utf-8')).hexdigest() + + if created: + Profile.objects.create(user=instance, email_hash=digest) + else: + instance.profile.email_hash = digest + instance.profile.save() diff --git a/members/tests.py b/members/tests.py new file mode 100644 index 0000000..025358b --- /dev/null +++ b/members/tests.py @@ -0,0 +1,238 @@ +# members.tests +# Testing for the members app. +# +# Author: Benjamin Bengfort +# Created: Sat Aug 22 09:25:12 2015 -0500 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: tests.py [] benjamin@bengfort.com $ + +""" +Testing for the members app. +""" + +########################################################################## +## Imports +########################################################################## + +import hashlib +import unittest + +from rest_framework import status +from members.models import Profile + +from django.test import TestCase, Client +from rest_framework.test import APITestCase +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse + +try: + from unittest.mock import MagicMock +except ImportError: + from mock import MagicMock + +########################################################################## +## User Fixture +########################################################################## + +fixtures = { + 'user': { + 'username': 'jdoe', + 'first_name': 'John', + 'last_name': 'Doe', + 'email': 'jdoe@example.com', + 'password': 'supersecret', + }, + 'api_user': { + 'username': 'starbucks', + 'first_name': 'Jane', + 'last_name': 'Windemere', + 'profile': { + 'biography': 'Originally from Seattle, now lives in Portland', + 'organization': 'SETI' + } + } +} + +########################################################################## +## Model Tests +########################################################################## + + +class ProfileModelTest(TestCase): + + def setUp(self): + self.user = User.objects.create_user(**fixtures['user']) + + def test_profile_on_create(self): + """ + Test the User post_save signal to create a profile + """ + + self.assertEqual(Profile.objects.count(), 1, "begin profile count mismatch (user mock has no profile?)") + u = User.objects.create_user(username="test", email="test@example.com", password="password") + self.assertEqual(Profile.objects.count(), 2, "additional profile object doesn't exist") + self.assertIsNotNone(u.profile) + + def test_profile_email_hash_md5(self): + """ + Ensure that the email_hash on a user is an MD5 digest + """ + + email = "Jane.Doe@gmail.com" + udigest = hashlib.md5(email.encode(encoding='utf-8')).hexdigest() + ldigest = hashlib.md5(email.lower().encode(encoding='utf-8')).hexdigest() + + u = User.objects.create_user(username="test", email=email, password="password") + self.assertIsNotNone(u.profile, "user has no profile?") + self.assertIsNotNone(u.profile.email_hash, "user has no email hash?") + + self.assertNotEqual(udigest, u.profile.email_hash, "email was not lower case before digest") + self.assertEqual(ldigest, u.profile.email_hash, "email not hashed correctly") + + def test_profile_email_hash_create(self): + """ + Email should be hashed on user create + """ + digest = hashlib.md5(fixtures['user']['email'].encode(encoding='utf-8')).hexdigest() + + self.assertIsNotNone(self.user.profile, "user has no profile?") + self.assertIsNotNone(self.user.profile.email_hash, "user has no email hash?") + self.assertEqual(digest, self.user.profile.email_hash, "email hash does not match expected") + + def test_profile_email_hash_update(self): + """ + Email should be hashed on user update + """ + + newemail = "john.doe@gmail.com" + digest = hashlib.md5(newemail.encode(encoding='utf-8')).hexdigest() + + self.user.email = newemail + self.user.save() + + self.assertEqual(digest, self.user.profile.email_hash, "email hash does not match expected") + + +########################################################################## +## View Tests +########################################################################## + + +class UserViewsTest(TestCase): + + def setUp(self): + self.user = User.objects.create_user(**fixtures['user']) + self.client = Client() + + def login(self): + credentials = { + 'username': fixtures['user']['username'], + 'password': fixtures['user']['password'], + } + + return self.client.login(**credentials) + + def logout(self): + return self.client.logout() + + @unittest.skip('not implemented yet') + def test_profile_view_auth(self): + """ + Assert that profile can only be viewed if logged in. + """ + endpoint = reverse('profile') + loginurl = reverse('social:begin', args=('google-oauth2',)) + params = "next=%s" % endpoint + expected = "%s?%s" % (loginurl, params) + response = self.client.get(endpoint) + + self.assertRedirects(response, expected, fetch_redirect_response=False) + + @unittest.skip('not implemented yet') + def test_profile_object(self): + """ + Assert the profile gets the current user + """ + + endpoint = reverse('profile') + + self.login() + response = self.client.get(endpoint) + + self.assertEqual(self.user, response.context['user']) + + @unittest.skip('not implemented yet') + def test_profile_template(self): + """ + Check that the right template is being used + """ + endpoint = reverse('profile') + + self.login() + response = self.client.get(endpoint) + + self.assertTemplateUsed(response, 'registration/profile.html') + + +class UserAPITest(APITestCase): + + def setUp(self): + self.user = User.objects.create_user(**fixtures['user']) + self.client.force_authenticate(user=self.user) + + @unittest.skip('not implemented yet') + def test_user_create(self): + """ + Check that a user can be created using the POST method + Required to test because the serializer overrides create. + NOTE: MUST POST IN JSON TO UPDATE/CREATE PROFILE + """ + endpoint = reverse("api:user-list") + response = self.client.post(endpoint, data=fixtures['api_user'], + format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # Check that a profile and user exist in database + user = User.objects.filter(username=fixtures['api_user']['username']) + profile = Profile.objects.filter(user=user) + + self.assertEquals(len(user), 1) + self.assertEquals(len(profile), 1) + + self.assertEqual(profile[0].organization, 'SETI') + + @unittest.skip('not implemented yet') + def test_user_update(self): + """ + Check that a user can be updated using a PUT method + Required to test because the serializer overrides update. + NOTE: MUST POST IN JSON TO UPDATE/CREATE PROFILE + """ + + endpoint = reverse("api:user-detail", kwargs={"pk": self.user.pk}) + + # Check that the user profile exists + response = self.client.get(endpoint) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Update the profile + content = { + "username": self.user.username, + "first_name": self.user.first_name, + "last_name": self.user.last_name, + "profile": { + "biography": "This is a test bio.", + "organization": "NASA" + } + } + + response = self.client.put(endpoint, content, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Fetch the profile again + user = User.objects.get(pk=self.user.pk) + self.assertEqual(user.profile.organization, "NASA") diff --git a/members/urls.py b/members/urls.py new file mode 100644 index 0000000..fb212a5 --- /dev/null +++ b/members/urls.py @@ -0,0 +1,30 @@ +# members.urls +# URLs for routing the members app. +# +# Author: Benjamin Bengfort +# Created: Fri Feb 12 23:30:10 2016 -0500 +# +# Copyright (C) 2016 District Data Labs +# For license information, see LICENSE.txt +# +# ID: urls.py [] benjamin@bengfort.com $ + +""" +URLs for routing the members app. +""" + +########################################################################## +## Imports +########################################################################## + +from django.conf.urls import url +from members.views import * + +########################################################################## +## URL Patterns +########################################################################## + +urlpatterns = ( + url(r'^members/$', MemberListView.as_view(), name='list'), + url(r'^(?P[\w-]+)/$', MemberView.as_view(), name='detail'), +) diff --git a/members/views.py b/members/views.py new file mode 100644 index 0000000..ed9bb72 --- /dev/null +++ b/members/views.py @@ -0,0 +1,96 @@ +# members.views +# Views for the members app. +# +# Author: Benjamin Bengfort +# Created: Sat Aug 22 09:25:37 2015 -0500 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: views.py [] benjamin@bengfort.com $ + +""" +Views for the members app. +""" + +########################################################################## +## Imports +########################################################################## + +from members.permissions import IsAdminOrSelf +from django.views.generic.detail import DetailView +from django.views.generic.list import ListView +from django.contrib.auth.models import User +from django.contrib.auth.mixins import LoginRequiredMixin + +from rest_framework import status +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework.decorators import detail_route +from members.serializers import UserSerializer, PasswordSerializer + + +########################################################################## +## Views +########################################################################## + +class MemberListView(LoginRequiredMixin, ListView): + """ + Listing and ordering of DDL members. + """ + + model = User + template_name = "members/list.html" + context_object_name = "member_list" + paginate_by = 50 + + def get_queryset(self): + queryset = super(MemberListView, self).get_queryset() + queryset = queryset.order_by('last_name') + return queryset + + def get_context_data(self, **kwargs): + context = super(MemberListView, self).get_context_data(**kwargs) + context['member_count'] = User.objects.count() + context['member_latest'] = User.objects.order_by('-date_joined')[0].date_joined + + return context + + +class MemberView(LoginRequiredMixin, DetailView): + """ + A detail view of a user and their DDL participation. This view is very + similar to a profile view except that it does not include the admin or + personal aspects of the profile. + + This view also serves as the member profile view for editing their own + user profile, data and information. + """ + + model = User + template_name = "members/profile.html" + context_object_name = 'member' + slug_field = "username" + + +########################################################################## +## API HTTP/JSON Views +########################################################################## + + +class UserViewSet(viewsets.ModelViewSet): + + queryset = User.objects.all() + serializer_class = UserSerializer + + @detail_route(methods=['post'], permission_classes=[IsAdminOrSelf]) + def set_password(self, request, pk=None): + user = self.get_object() + serializer = PasswordSerializer(data=request.data) + if serializer.is_valid(): + user.set_password(serializer.data['password']) + user.save() + return Response({'status': 'password set'}) + else: + return Response(serializer.errors, + status=status.HTTP_400_BAD_REQUEST) diff --git a/partisan/__init__.py b/partisan/__init__.py index 72e61fe..809c355 100644 --- a/partisan/__init__.py +++ b/partisan/__init__.py @@ -7,7 +7,7 @@ # Copyright (C) 2016 District Data Labs # For license information, see LICENSE.txt # -# ID: __init__.py [] benjamin@bengfort.com $ +# ID: __init__.py [5277a6e] benjamin@bengfort.com $ """ The project module for the Partisan Discourse web application. diff --git a/partisan/assets/css/profile.css b/partisan/assets/css/profile.css new file mode 100644 index 0000000..79866d2 --- /dev/null +++ b/partisan/assets/css/profile.css @@ -0,0 +1,128 @@ +/* Profile specific styles */ + +#profile-sidebar h2 { + font-size: 1.4em; + margin-bottom: 2px; +} + +#profile-sidebar h3 { + margin-top: 0; + font-weight: normal; + font-size: 1.3em; +} + +#profile-sidebar img { + width: 100%; +} + +#profile-sidebar ul { + font-size: 1.15em; +} + +#profile-sidebar ul li { + margin-bottom: 3px; + overflow-x: scroll; + font-size: 14px; +} + +#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; +} + +/* Number stats inline */ + +.number-stats li { + width: 31%; +} + +.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; +} + +.tab-pane { + margin-top: 10px; +} + +.stars-lead { + margin-bottom: 0; +} + +.starred-dataset-link { + font-size: 15px; + margin: 10.5px 0; + display: block; +} \ No newline at end of file diff --git a/partisan/assets/css/style.css b/partisan/assets/css/style.css new file mode 100644 index 0000000..a4b14a0 --- /dev/null +++ b/partisan/assets/css/style.css @@ -0,0 +1,86 @@ +/* Global Styles for Partisan Discourse */ + +html, +body { + height: 100%; +} + +body { + padding-top: 86px; +} + +/* Wrapper for page content to push down footer */ +#wrap { + min-height: 100%; + height: auto; + /* Negative indent footer by its height */ + margin: 0 auto -106px; + /* 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; + padding: 30px 15px; + font-size: 16px; +} + +#footer p { + margin: 0; +} + +.navbar .profile-image{ + margin: -10px 4px; + height: 24px; +} + +#footerStatus { + font-size: 12px; +} + +td.details-key { + font-weight: bold; +} + +div.header-buttons { + padding-top: 30px; +} + +.clickable-row { + cursor: pointer; +} + +/* Icons */ +.icon { + width: 16px; + height: 16px; + padding: 10px 14px 10px 8px; + background-repeat: no-repeat; + background-position: center; +} + +.icon-republican { + background-image: url('/assets/img/icons/republican-icon.png'); +} + +.icon-democratic { + background-image: url('/assets/img/icons/democratic-icon.png'); +} + +.icon-white.icon-republican { + background-image: url('/assets/img/icons/republican-white-icon.png'); +} + +.icon-white.icon-democratic { + background-image: url('/assets/img/icons/democratic-white-icon.png'); +} + +.text-republican { + color: #f04124; +} + +.text-democratic { + color: #008cba; +} diff --git a/partisan/assets/favicon.png b/partisan/assets/favicon.png new file mode 100644 index 0000000..aceffda Binary files /dev/null and b/partisan/assets/favicon.png differ diff --git a/partisan/assets/humans.txt b/partisan/assets/humans.txt new file mode 100644 index 0000000..42e94eb --- /dev/null +++ b/partisan/assets/humans.txt @@ -0,0 +1,13 @@ +/* TEAM */ + + Developer: Benjamin Bengfort + Contact: bbengfort [at] districtdatalabs.com + Twitter: @bbengfort + From: Washington, DC + +/* SITE */ + + Last update: 2016/07/17 + Language: English + Doctype: HTML5 + IDE: Github Atom.io Editor diff --git a/partisan/assets/img/democratic.png b/partisan/assets/img/democratic.png new file mode 100644 index 0000000..e152df0 Binary files /dev/null and b/partisan/assets/img/democratic.png differ diff --git a/partisan/assets/img/icons/democratic-icon.png b/partisan/assets/img/icons/democratic-icon.png new file mode 100644 index 0000000..ea94379 Binary files /dev/null and b/partisan/assets/img/icons/democratic-icon.png differ diff --git a/partisan/assets/img/icons/democratic-white-icon.png b/partisan/assets/img/icons/democratic-white-icon.png new file mode 100644 index 0000000..25df29f Binary files /dev/null and b/partisan/assets/img/icons/democratic-white-icon.png differ diff --git a/partisan/assets/img/icons/republican-icon.png b/partisan/assets/img/icons/republican-icon.png new file mode 100644 index 0000000..1568aed Binary files /dev/null and b/partisan/assets/img/icons/republican-icon.png differ diff --git a/partisan/assets/img/icons/republican-white-icon.png b/partisan/assets/img/icons/republican-white-icon.png new file mode 100644 index 0000000..47dd003 Binary files /dev/null and b/partisan/assets/img/icons/republican-white-icon.png differ diff --git a/partisan/assets/img/logo.png b/partisan/assets/img/logo.png new file mode 100644 index 0000000..f95fdbf Binary files /dev/null and b/partisan/assets/img/logo.png differ diff --git a/partisan/assets/img/long-logo.png b/partisan/assets/img/long-logo.png new file mode 100644 index 0000000..3993254 Binary files /dev/null and b/partisan/assets/img/long-logo.png differ diff --git a/partisan/assets/img/republican.png b/partisan/assets/img/republican.png new file mode 100644 index 0000000..af817c6 Binary files /dev/null and b/partisan/assets/img/republican.png differ diff --git a/partisan/assets/img/vote.png b/partisan/assets/img/vote.png new file mode 100644 index 0000000..271d18a Binary files /dev/null and b/partisan/assets/img/vote.png differ diff --git a/partisan/assets/js/annotate.js b/partisan/assets/js/annotate.js new file mode 100644 index 0000000..944f8ef --- /dev/null +++ b/partisan/assets/js/annotate.js @@ -0,0 +1,89 @@ +/* + * annotate.js + * Javascript for annotating documents. + * + * Author: Benjamin Bengfort + * Created: Mon Jul 18 22:25:07 2016 -0400 + * + * Dependencies: + * - jquery + */ + +(function($) { + $(document).ready(function() { + var annotateForm = $("#annotateForm"); + + annotateForm.find("button[type=submit]").click(function(e) { + // When the annotate button is clicked, set the val of the form. + var target = $(e.target); + + if (!target.data('selected')) { + // Label the annotation with the slug of the button + var label = target.data('label-slug'); + annotateForm.find("#label").val(label); + } else { + // Null the label on the annotation + annotateForm.find("#label").val(""); + } + + }); + + annotateForm.submit(function(e) { + e.preventDefault(); + + // Get the action and method from the form + var method = annotateForm.attr('method'); + var action = annotateForm.attr('action'); + + // Get the data from the form + var data = { + 'label': annotateForm.find('#label').val() + } + + // Now make the AJAX request to the endpoint + $.ajax({ + "url": action, + "method": method, + "data": JSON.stringify(data), + "contentType": "application/json" + }).done(function(data) { + + // On successful post of the annotation reset the buttons. + var labelSlug = data.label + console.log("Setting annotation to", labelSlug); + + // Go through each button and set the data as required. + $.each(annotateForm.find("button[type=submit]"), function(idx, btn) { + btn = $(btn); + + if (btn.data('label-slug') == labelSlug) { + // Ok this is the newly selected button + // Set the selected attribute to true and the class to primary. + btn.data('selected', true); + btn.removeClass('btn-default'); + btn.find("i").addClass('icon-white'); + btn.addClass('btn-' + labelSlug); + + } else { + // This is not the newly selected button + btn.data('selected', false); + btn.removeClass('btn-democratic'); + btn.removeClass('btn-republican'); + btn.find("i").removeClass('icon-white'); + btn.addClass('btn-default'); + } + + }); + + }).fail(function(xhr) { + data = xhr.responseJSON; + console.log(data) + + alert("There was a problem annotating this document!"); + }); + + return false; + }); + + }); +})(jQuery); diff --git a/partisan/assets/js/fetch.js b/partisan/assets/js/fetch.js new file mode 100644 index 0000000..c130184 --- /dev/null +++ b/partisan/assets/js/fetch.js @@ -0,0 +1,90 @@ +/* + * fetch.js + * Javascript for the web page fetch and lookup. + * + * Author: Benjamin Bengfort + * Created: Mon Jul 18 13:01:45 2016 -0400 + * + * Dependencies: + * - jquery + */ + +(function($) { + $(document).ready(function() { + $('[data-toggle="tooltip"]').tooltip() + + var urlForm = $("#urlForm"); + + // Handle urlForm submission + urlForm.submit(function(e) { + e.preventDefault(); + + // Remove form errors if any + removeErrors(urlForm); + + // Get form data + var data = { + 'long_url': $('#long_url').val() + } + + // POST the form to the endpoint + var endpoint = urlForm.attr('action'); + var method = urlForm.attr('method'); + + // Disable the form and show spinner + toggleURLForm(); + + + $.ajax({ + "url": endpoint, + "method": method, + "data": JSON.stringify(data), + "contentType": "application/json" + }).done(function(data) { + + if (data.detail) { + window.location = data.detail; + } else { + alert("No detail was provided for some reason?"); + } + + }).fail(function(xhr) { + data = xhr.responseJSON; + console.log(data) + + // Set the error for particular fields. + $.each(data, function(key, val) { + var field = $("#"+key); + if (field.length == 0) { + field = $("#long_url"); + } + + field.closest('.form-group').addClass("has-error"); + field.closest('.form-group').find('.help-block').text(val); + }); + + toggleURLForm(); + }); + + return false; + }); + + // Toggle Form Disabled + function toggleURLForm() { + $("#urlFormSpinner").toggleClass("hidden"); + $("#urlFormIcon").toggleClass("hidden"); + $('#long_url').prop('disabled', function(i, v) { return !v; }); + $('#submitUrlBtn').prop('disabled', function(i, v) { return !v; }); + } + + // Remove Errors Form + function removeErrors(form) { + $.each(form.find('.has-error'), function(idx, elem) { + elem = $(elem); + elem.removeClass("has-error"); + elem.find('.help-block').text(""); + }); + } + + }); +})(jQuery); diff --git a/partisan/assets/js/main.js b/partisan/assets/js/main.js new file mode 100644 index 0000000..36b2510 --- /dev/null +++ b/partisan/assets/js/main.js @@ -0,0 +1,47 @@ +/* + * main.js + * Main javascript function that should be run first thing in the Partisan App. + * + * Author: Benjamin Bengfort + * Created: Sun Aug 23 09:58:00 2015 -0500 + * + * Dependencies: + * - jquery + * - underscore + */ + +(function($) { + + // Do the CSRf AJAX Modification + var csrfToken = $('input[name="csrfmiddlewaretoken"]').val(); + $.ajaxSetup({headers: {"X-CSRFToken": csrfToken}}); + + // Update the status and the version from the API. + var statusURL = "/api/status/"; + $.get(statusURL) + .success(function(data) { + $("#footerVersion").text(data.version); + if (data.status == "ok") { + $("#footerStatus").addClass("text-success"); + } else { + $("#footerStatus").addClass("text-warning"); + } + }) + .fail(function() { + $("#footerVersion").text("X.X"); + $("#footerStatus").addClass("text-danger"); + }); + + // Add the hotkeys for easy management + $(document).ready(function() { + $(document).keyup(function(e) { + if (e.keyCode == 27) { + e.preventDefault(); + window.location = "/admin/"; + } + }); + }); + + console.log("Partisan Discourse App is started and ready"); + +})(jQuery); diff --git a/partisan/assets/js/profile.js b/partisan/assets/js/profile.js new file mode 100644 index 0000000..2c56af0 --- /dev/null +++ b/partisan/assets/js/profile.js @@ -0,0 +1,118 @@ +/* + * profile.js + * Javascript for the user profile editing and management. + * + * Author: Benjamin Bengfort + * Created: Sat Feb 13 14:45:34 2016 -0500 + * + * Dependencies: + * - jquery + * - partisan utils + */ + +(function($) { + $(document).ready(function() { + + // Configure the profile application + var csrfToken = $('input[name="csrfmiddlewaretoken"]').val(); + $.ajaxSetup({headers: {"X-CSRFToken": csrfToken}}); + console.log("Profile application ready"); + + var passwordForm = $("#setPasswordForm"); + var profileForm = $("#editProfileForm"); + var userDetail = profileForm.attr('action'); + + // When setPasswordModal is closed - reset the setPasswordForm + $('#setPasswordModal').on('hidden.bs.modal', function (e) { + passwordForm.removeClass('has-error'); + $('#passwordHelp').text(""); + $('#setPasswordForm')[0].reset(); + }); + + // Handle setPasswordForm submission + passwordForm.submit(function(e) { + e.preventDefault(); + // Get form data + var data = { + 'password': $('#txtPassword').val(), + 'repeated': $('#txtRepeated').val() + } + // Validate the data + if (data.password != data.repeated) { + passwordForm.addClass('has-error'); + $('#passwordHelp').text("passwords do not match!"); + return + } else if (data.password.length < 6) { + passwordForm.addClass('has-error'); + $('#passwordHelp').text("password must be at least 6 characters"); + return + } + // POST the change password data + var passwordEndpoint = passwordForm.attr('action'); + $.post(passwordEndpoint, data, function(result) { + $("#setPasswordModal").modal('hide'); + }); + return false; + }); + + // Handle the profile submission + profileForm.submit(function(e) { + e.preventDefault(); + // Get the form data + var data = utils.formData(profileForm); + + data.profile = { + "biography": data.biography, + "organization": data.organization, + "location": data.location, + "twitter": data.twitter, + "linkedin": data.linkedin + }; + + delete data.biography; + delete data.organization; + delete data.location; + delete data.twitter; + delete data.linkedin; + + $.ajax({ + "url": userDetail, + "method": "PUT", + "data": JSON.stringify(data), + "contentType": "application/json" + }).done(function(data) { + // Update DOM with data requested + $("#profileFullName").text(data.first_name + " " + data.last_name); + $("#profileUsername").text(data.username); + $("#profileEmail").text(data.email); + $("#profileOrganization").text(data.profile.organization); + $("#profileLocation").text(data.profile.location); + $("#profileTwitter").text("@" + data.profile.twitter); + $("#profileTwitterA").attr("href", "https://twitter.com/" + data.profile.twitter + "/"); + $("#profileLinkedIn").text(data.last_name + " Profile"); + $("#profileLinkedInA").attr("href", data.profile.linkedin); + $("#editProfileModal").modal("hide"); + }).fail(function(xhr) { + data = xhr.responseJSON; + // Set the error + $.each(data, function(key, val) { + var field = $("#"+key); + field.parent().addClass("has-error"); + field.parent().find('.help-block').text(val); + }); + }); + return false; + }); + + // Reset form on close + $("#editProfileModal").on("hide.bs.modal", function(e) { + resetEditProfileModal(); + }); + + // Helper function to reset edit profile modal + function resetEditProfileModal() { + profileForm.find('.form-group').removeClass("has-error"); + profileForm.find('.help-block').text(""); + } + }); +})(jQuery); diff --git a/partisan/assets/js/utils.js b/partisan/assets/js/utils.js new file mode 100644 index 0000000..7820143 --- /dev/null +++ b/partisan/assets/js/utils.js @@ -0,0 +1,95 @@ +/* + * utils.js + * Helper functions for global use in Partisan front-end apps + * + * Author: Benjamin Bengfort + * Created: Sun Aug 23 10:09:11 2015 -0500 + * + * Dependencies: + * - jquery + * - underscore + */ + +(function() { + + String.prototype.formalize = function () { + return this.replace(/^./, function (match) { + return match.toUpperCase(); + }); + }; + + Array.prototype.max = function() { + return Math.max.apply(null, this); + }; + + Array.prototype.min = function() { + return Math.min.apply(null, this); + }; + + Array.prototype.range = function() { + return [ + this.min(), this.max() + ] + } + + utils = { + /* + * Similar to humanize.intcomma - renders an integer with thousands- + * separated by commas. Pass in an integer, returns a string. + */ + intcomma: function(x) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); + }, + + /* + * Accepts an object and returns a GET query string (no ?) + */ + encodeQueryData: function(data) { + var ret = []; + for (var d in data) + ret.push(encodeURIComponent(d) + "=" + encodeURIComponent(data[d])); + return ret.join("&"); + }, + + /* + * Parses a string boolean and returns a bool type, especially Python bool str + */ + parseBool: function(str) { + if (typeof(str) === "boolean") { + return str + } + + return JSON.parse( + str.toLowerCase() + .replace('none', 'false') + .replace('no','false') + .replace('yes','true') + ); + }, + getParam: function (key) { + var value=RegExp(""+key+"[^&]+").exec(window.location.search); + return unescape(!!value ? value.toString().replace(/^[^=]+./,"") : ""); + }, + /* + * Pass in the selector for a form, this method uses jQuery's serializeArray + * method to map the data in the form to an object for json. + */ + formData: function(selector) { + return _.object(_.map($(selector).serializeArray(), function(obj) { + return [obj.name, obj.value]; + })); + }, + + cleanArray: function (actual){ + var newArray = new Array(); + for(var i = 0; i + + + + + + + {% block title %}Political Discourse{% endblock %} + + {% block meta %} + + + + {% endblock %} + + {% block link %} + + + {% endblock %} + + {% block stylesheets %} + + + + + {% endblock %} + + + + {% include 'components/navbar.html' %} + + {% block body %} + {% endblock %} + + {% block modals %} + + {% endblock %} + + {% block javascripts %} + + + + + + + + + {% endblock %} + + {% block analytics %} + {% include 'components/analytics.html' %} + {% endblock %} + + + diff --git a/partisan/templates/components/analytics.html b/partisan/templates/components/analytics.html new file mode 100644 index 0000000..8938be3 --- /dev/null +++ b/partisan/templates/components/analytics.html @@ -0,0 +1,11 @@ + + diff --git a/partisan/templates/components/footer.html b/partisan/templates/components/footer.html new file mode 100644 index 0000000..d754f68 --- /dev/null +++ b/partisan/templates/components/footer.html @@ -0,0 +1,29 @@ + diff --git a/partisan/templates/components/modals/edit-profile-modal.html b/partisan/templates/components/modals/edit-profile-modal.html new file mode 100644 index 0000000..957a689 --- /dev/null +++ b/partisan/templates/components/modals/edit-profile-modal.html @@ -0,0 +1,73 @@ + + diff --git a/partisan/templates/components/modals/not-implemented-yet.html b/partisan/templates/components/modals/not-implemented-yet.html new file mode 100644 index 0000000..1c85f55 --- /dev/null +++ b/partisan/templates/components/modals/not-implemented-yet.html @@ -0,0 +1,19 @@ + diff --git a/partisan/templates/components/modals/set-password-modal.html b/partisan/templates/components/modals/set-password-modal.html new file mode 100644 index 0000000..f079a04 --- /dev/null +++ b/partisan/templates/components/modals/set-password-modal.html @@ -0,0 +1,30 @@ + diff --git a/partisan/templates/components/navbar.html b/partisan/templates/components/navbar.html new file mode 100644 index 0000000..f653461 --- /dev/null +++ b/partisan/templates/components/navbar.html @@ -0,0 +1,75 @@ +{% load gravatar %} + diff --git a/partisan/templates/components/url_form.html b/partisan/templates/components/url_form.html new file mode 100644 index 0000000..2ae97c9 --- /dev/null +++ b/partisan/templates/components/url_form.html @@ -0,0 +1,18 @@ +
+
+
+
+ + + + + + + + +
+ +
+ {% csrf_token %} +
+
diff --git a/partisan/templates/corpus/document.html b/partisan/templates/corpus/document.html new file mode 100644 index 0000000..0c3e92f --- /dev/null +++ b/partisan/templates/corpus/document.html @@ -0,0 +1,123 @@ +{% extends 'page.html' %} +{% load staticfiles %} + +{% block stylesheets %} + {{ block.super }} + +{% endblock %} + +{% block content %} + +
+ +
+ {% include 'components/url_form.html' %} +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ +
+ {% for label in labels %} + {% if annotation.label == label %} + + {% endfor %} +
+ {% csrf_token %} +
+
+
+
+ +
+
+
+ {% with document.content as content %} + {% include 'corpus/preprocessed.html' %} + {% endwith %} +
+
+
+ + +
+ +{% endblock %} + +{% block javascripts %} + {{ block.super }} + + +{% endblock %} diff --git a/partisan/templates/corpus/preprocessed.html b/partisan/templates/corpus/preprocessed.html new file mode 100644 index 0000000..cd76cbb --- /dev/null +++ b/partisan/templates/corpus/preprocessed.html @@ -0,0 +1,12 @@ +{# A template for viewing preprocessed text #} +{% for paragraph in content %} +

+ {% for sentence in paragraph %} + + {% for token, tag in sentence %} + {{ token }} + {% endfor %} + + {% endfor %} +

+{% endfor %} diff --git a/partisan/templates/members/profile.html b/partisan/templates/members/profile.html new file mode 100644 index 0000000..e1dc918 --- /dev/null +++ b/partisan/templates/members/profile.html @@ -0,0 +1,201 @@ +{% extends 'page.html' %} +{% load staticfiles %} + +{% block stylesheets %} + {{ block.super }} + +{% endblock %} + +{% block content %} + +
+ +
+ + +
+ + +
+ Gravatar + +
+ +

{{ member.profile.full_name }}

+

{{ member.username }}

+ + +
+
    + {% if member.profile.location %} +
  • + + {{ member.profile.location }} +
  • + {% endif %} + {% if member.profile.organization %} +
  • + + {{ member.profile.organization }} +
  • + {% endif %} +
  • + + {% if member.profile.twitter %} + + @{{ member.profile.twitter }} + + {% else %} + Not Added + {% endif %} +
  • +
  • + + {% if member.profile.linkedin %} + + {{ member.last_name }} Profile + + {% else %} + Not Added + {% endif %} +
  • +
  • + + Joined on {{ member.date_joined|date }} +
  • +
+ + +
+ + +
+ + +
+ + +
+ + {% if user == member %} +
+ + + + + +
+ {% endif %} + + + +
+
+ + +
+ +
+ + +
+ {% if member.profile.biography.raw %} + {{ member.profile.biography }} + {% else %} +

No biography added quite yet.

+ {% endif %} +
+ + +
+
+

Your Recent Activity

+
+
+
    + {% for annotation in member.annotations.all|slice:":10" %} +
  • +
    + {% if annotation.label %} + {{ annotation.label }} + {% else %} + Annotate this document! + {% endif %} +
    +
    +
    + + {{ annotation.document.title }} + +
    +

    + {% if annotation.label %} + {{ annotation.user.username }} annotated this document “{{ annotation.label }}” on {{ annotation.modified|date }} + {% else %} + {{ annotation.user.username }} added this document on {{ annotation.modified|date }} + {% endif %} +

    +
    +
  • + {% endfor %} +
+
+
+ +
+ +
+ +
+
+
+{% endblock %} + +{% block modals %} + {{ block.super }} + {% if user == member %} + {% include 'components/modals/edit-profile-modal.html' %} + {% include 'components/modals/set-password-modal.html' %} + {% endif %} +{% endblock %} + +{% block javascripts %} + {{ block.super }} + + {% if user == member %} + + {% endif %} +{% endblock %} diff --git a/partisan/templates/page.html b/partisan/templates/page.html new file mode 100644 index 0000000..1df3cfb --- /dev/null +++ b/partisan/templates/page.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} + +{% block body %} + +
+ + {% block navbar %} + {# include 'components/navbar.html' #} + {% endblock %} + + +
+ {% block content %} + {% endblock %} +
+ +
+ + {% block footer %} + {% include 'components/footer.html' %} + {% endblock %} + + {% block modals %} + {{ block.super }} + {% include 'components/modals/not-implemented-yet.html' %} + {% endblock %} +{% endblock %} diff --git a/partisan/templates/registration/logged_out.html b/partisan/templates/registration/logged_out.html new file mode 100644 index 0000000..abeeaaa --- /dev/null +++ b/partisan/templates/registration/logged_out.html @@ -0,0 +1,8 @@ +{% extends 'registration/login.html' %} + +{% block login-alert %} + +{% endblock %} diff --git a/partisan/templates/registration/login.html b/partisan/templates/registration/login.html new file mode 100644 index 0000000..95c57d8 --- /dev/null +++ b/partisan/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/partisan/templates/registration/password_reset_complete.html b/partisan/templates/registration/password_reset_complete.html new file mode 100644 index 0000000..6d78da8 --- /dev/null +++ b/partisan/templates/registration/password_reset_complete.html @@ -0,0 +1,8 @@ +{% extends 'registration/login.html' %} + +{% block login-alert %} + +{% endblock %} diff --git a/partisan/templates/registration/password_reset_confirm.html b/partisan/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000..4245161 --- /dev/null +++ b/partisan/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/partisan/templates/registration/password_reset_done.html b/partisan/templates/registration/password_reset_done.html new file mode 100644 index 0000000..0ee4607 --- /dev/null +++ b/partisan/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/partisan/templates/registration/password_reset_form.html b/partisan/templates/registration/password_reset_form.html new file mode 100644 index 0000000..e5e94d3 --- /dev/null +++ b/partisan/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/partisan/templates/rest_framework/api.html b/partisan/templates/rest_framework/api.html new file mode 100644 index 0000000..5a8ceb0 --- /dev/null +++ b/partisan/templates/rest_framework/api.html @@ -0,0 +1,73 @@ +{% extends "rest_framework/base.html" %} +{% load staticfiles %} +{% load gravatar %} + +{% block title %}Partisan Discourse API{% endblock %} + +{% block bootstrap_theme %} + + + +{% endblock %} + +{% block branding %} + + DDL Partisan Discourse API + +{% endblock %} + +{% block userlinks %} +{% if user.is_authenticated %} + +{% else %} +
  • + + + Log In + +
  • +{% endif %} +{% endblock %} diff --git a/partisan/templates/site/home.html b/partisan/templates/site/home.html new file mode 100644 index 0000000..e3ee401 --- /dev/null +++ b/partisan/templates/site/home.html @@ -0,0 +1,53 @@ +{% extends 'page.html' %} +{% load staticfiles %} + +{% block content %} + +
    + +
    + {% include 'components/url_form.html' %} +
    + + +
    +
    +

    Recent Activity

    +
      + {% for annotation in annotations %} +
    • +
      + {% if annotation.label %} + {{ annotation.label }} + {% else %} + {{ annotation.user.profile.full_name }} + {% endif %} +
      +
      +
      + + {{ annotation.document.title }} + +
      +

      + {% if annotation.label %} + {{ annotation.user.username }} annotated this document “{{ annotation.label }}” on {{ annotation.modified|date }} + {% else %} + {{ annotation.user.username }} added this document on {{ annotation.modified|date }} + {% endif %} +

      +
      +
    • + {% endfor %} +
    +
    +
    + +
    + +{% endblock %} + +{% block javascripts %} + {{ block.super }} + +{% endblock %} diff --git a/partisan/templates/site/legal/legal-page.html b/partisan/templates/site/legal/legal-page.html new file mode 100644 index 0000000..c5faed6 --- /dev/null +++ b/partisan/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/partisan/templates/site/legal/privacy.html b/partisan/templates/site/legal/privacy.html new file mode 100644 index 0000000..20dd655 --- /dev/null +++ b/partisan/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/partisan/templates/site/legal/terms.html b/partisan/templates/site/legal/terms.html new file mode 100644 index 0000000..6f1dd30 --- /dev/null +++ b/partisan/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/partisan/tests/__init__.py b/partisan/tests/__init__.py index 2ffb1b5..a176f65 100644 --- a/partisan/tests/__init__.py +++ b/partisan/tests/__init__.py @@ -7,7 +7,7 @@ # Copyright (C) 2016 District Data Labs # For license information, see LICENSE.txt # -# ID: __init__.py [] benjamin@bengfort.com $ +# ID: __init__.py [80822db] benjamin@bengfort.com $ """ Tests for the complete partisan package. diff --git a/partisan/tests/test_init.py b/partisan/tests/test_init.py index 18946f9..323b710 100644 --- a/partisan/tests/test_init.py +++ b/partisan/tests/test_init.py @@ -7,7 +7,7 @@ # Copyright (C) 2016 District Data Labs # For license information, see LICENSE.txt # -# ID: test_init.py [] benjamin@bengfort.com $ +# ID: test_init.py [80822db] benjamin@bengfort.com $ """ Initialization tests for the Partisan Discourse project @@ -23,7 +23,7 @@ ## Module variables ########################################################################## -EXPECTED_VERSION = "0.1" +EXPECTED_VERSION = "0.1b1" ########################################################################## ## Initialization Tests diff --git a/partisan/urls.py b/partisan/urls.py index 74f89ee..467815f 100644 --- a/partisan/urls.py +++ b/partisan/urls.py @@ -1,21 +1,80 @@ -"""partisan URL Configuration +# partisan.urls +# Application url definition and routers. +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 18:33:45 2016 -0400 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: urls.py [5277a6e] benjamin@bengfort.com $ + +""" +Partisan Discourse URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/1.9/topics/http/urls/ + Examples: + Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') + Class-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') + Including another URLconf 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from django.conf.urls import url + +########################################################################## +## Imports +########################################################################## + from django.contrib import admin +from rest_framework import routers +from django.conf.urls import include, url + +from partisan.views import * +from members.views import * +from corpus.views import * + +########################################################################## +## Endpoint Discovery +########################################################################## + +## API +router = routers.DefaultRouter() +router.register(r'status', HeartbeatViewSet, "status") +router.register(r'users', UserViewSet) +router.register(r'documents', DocumentViewSet) + +########################################################################## +## URL Patterns +########################################################################## urlpatterns = [ - url(r'^admin/', admin.site.urls), + # Admin URLs + url(r'^grappelli/', include('grappelli.urls')), + url(r'^admin/', include(admin.site.urls)), + + # Application URLs + url(r'^$', HomePageView.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'), + + # Authentication URLs + url('', include('social.apps.django_app.urls', namespace='social')), + url('^accounts/', include('django.contrib.auth.urls')), + + ## REST API Urls + url(r'^api/', include(router.urls, namespace="api")), + + # Member, and Organization URLs + # !important: must be last and ordered specifically + url('', include('corpus.urls', namespace='corpus')), + url('', include('members.urls', namespace='member')), ] diff --git a/partisan/utils.py b/partisan/utils.py new file mode 100644 index 0000000..9ba6615 --- /dev/null +++ b/partisan/utils.py @@ -0,0 +1,97 @@ +# partisan.utils +# Project level utilities and helpers +# +# Author: Benjamin Bengfort +# Created: Thu Oct 08 22:26:18 2015 -0400 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: utils.py [] benjamin@bengfort.com $ + +""" +Project level utilities and helpers +""" + +########################################################################## +## Imports +########################################################################## + +import re +import base64 +import bleach +import hashlib + +from functools import wraps +from markdown import markdown + + +########################################################################## +## 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') + sign = base64.b64encode(hashlib.sha256(text).digest()) + return sign.decode('utf-8') + + +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) diff --git a/partisan/version.py b/partisan/version.py index bc0fe5d..d79a239 100644 --- a/partisan/version.py +++ b/partisan/version.py @@ -7,7 +7,7 @@ # Copyright (C) 2015 District Data Labs # For license information, see LICENSE.txt # -# ID: version.py [] benjamin@bengfort.com $ +# ID: version.py [80822db] benjamin@bengfort.com $ """ Helper module for managing versioning information @@ -21,8 +21,8 @@ 'major': 0, 'minor': 1, 'micro': 0, - 'releaselevel': 'final', - 'serial': 0, + 'releaselevel': 'beta', + 'serial': 1, } diff --git a/partisan/views.py b/partisan/views.py new file mode 100644 index 0000000..2a1f1cf --- /dev/null +++ b/partisan/views.py @@ -0,0 +1,66 @@ +# partisan.views +# Default application views for the system. +# +# Author: Benjamin Bengfort +# Created: Sun Jul 17 18:37:26 2016 -0400 +# +# Copyright (C) 2015 District Data Labs +# For license information, see LICENSE.txt +# +# ID: views.py [] benjamin@bengfort.com $ + +""" +Default application views for the system. +""" + +########################################################################## +## Imports +########################################################################## + +import partisan + +from datetime import datetime +from django.views.generic import TemplateView +from django.contrib.auth.mixins import LoginRequiredMixin + +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework.permissions import AllowAny + +from corpus.models import Annotation + +########################################################################## +## Views +########################################################################## + + +class HomePageView(LoginRequiredMixin, TemplateView): + + template_name = "site/home.html" + + def get_context_data(self, **kwargs): + context = super(HomePageView, self).get_context_data(**kwargs) + + # Return a list of the most recent documents + context['annotations'] = Annotation.objects.order_by('-modified')[:20] + + 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": partisan.get_version(), + "timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + }) diff --git a/partisan/wsgi.py b/partisan/wsgi.py index 08cdc70..fd39421 100644 --- a/partisan/wsgi.py +++ b/partisan/wsgi.py @@ -7,7 +7,7 @@ # Copyright (C) 2016 District Data Labs # For license information, see LICENSE.txt # -# ID: wsgi.py [] benjamin@bengfort.com $ +# ID: wsgi.py [5277a6e] benjamin@bengfort.com $ """ WSGI config for partisan project. diff --git a/requirements.txt b/requirements.txt index ac4aff5..43193d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,13 @@ ## Web Application Django==1.9.7 +djangorestframework==3.4.0 django-autoslug==1.9.3 django-dotenv==1.4.1 django-grappelli==2.8.1 +django-gravatar2==1.4.0 django-model-utils==2.5 +django-markupfield==1.4.0 +django-picklefield==0.3.2 whitenoise==3.2 gunicorn==19.6.0 @@ -24,6 +28,19 @@ defusedxml==0.4.1 six==1.10.0 python-dateutil==2.5.3 +## Markup Dependencies +Markdown==2.6.6 +bleach==1.4.3 +html5lib==0.9999999 + +## NLP and Parsing Dependencies +nltk==3.2.1 +beautifulsoup4==4.4.1 +readability-lxml==0.6.2 +lxml==3.6.0 +chardet==2.3.0 +cssselect==0.9.2 + ## Testing nose==1.3.7 coverage==4.1