From d011c913bb81498587442ec4408c3de3ff1e036a Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Sun, 17 Jul 2016 19:13:28 -0400 Subject: [PATCH 01/15] moving members app from Trinket --- Makefile | 2 +- members/__init__.py | 24 ++ members/admin.py | 65 +++++ members/apps.py | 31 +++ members/migrations/0001_initial.py | 42 ++++ members/migrations/__init__.py | 18 ++ members/models.py | 96 +++++++ members/permissions.py | 47 ++++ members/serializers.py | 118 +++++++++ members/signals.py | 45 ++++ members/tests.py | 238 ++++++++++++++++++ members/urls.py | 30 +++ members/views.py | 94 +++++++ partisan/assets/css/profile.css | 128 ++++++++++ partisan/assets/css/style.css | 53 ++++ partisan/assets/favicon.png | Bin 0 -> 843 bytes partisan/assets/humans.txt | 13 + partisan/assets/img/logo.png | Bin 0 -> 49705 bytes partisan/assets/img/long-logo.png | Bin 0 -> 16593 bytes partisan/assets/js/main.js | 47 ++++ partisan/assets/js/profile.js | 118 +++++++++ partisan/assets/js/utils.js | 95 +++++++ partisan/assets/robots.txt | 2 + partisan/settings/base.py | 57 +++-- partisan/settings/testing.py | 8 +- partisan/templates/admin/login.html | 1 + partisan/templates/base.html | 56 +++++ partisan/templates/components/analytics.html | 11 + partisan/templates/components/footer.html | 29 +++ .../components/modals/edit-profile-modal.html | 73 ++++++ .../modals/not-implemented-yet.html | 19 ++ .../components/modals/set-password-modal.html | 30 +++ partisan/templates/components/navbar.html | 80 ++++++ partisan/templates/members/profile.html | 200 +++++++++++++++ partisan/templates/page.html | 27 ++ .../templates/registration/logged_out.html | 8 + partisan/templates/registration/login.html | 161 ++++++++++++ .../registration/password_reset_complete.html | 8 + .../registration/password_reset_confirm.html | 57 +++++ .../registration/password_reset_done.html | 31 +++ .../registration/password_reset_form.html | 47 ++++ partisan/templates/rest_framework/api.html | 78 ++++++ partisan/templates/site/home.html | 16 ++ partisan/templates/site/legal/legal-page.html | 84 +++++++ partisan/templates/site/legal/privacy.html | 51 ++++ partisan/templates/site/legal/terms.html | 122 +++++++++ partisan/urls.py | 63 ++++- partisan/utils.py | 94 +++++++ partisan/views.py | 56 +++++ requirements.txt | 8 + 50 files changed, 2755 insertions(+), 26 deletions(-) create mode 100644 members/__init__.py create mode 100644 members/admin.py create mode 100644 members/apps.py create mode 100644 members/migrations/0001_initial.py create mode 100644 members/migrations/__init__.py create mode 100644 members/models.py create mode 100644 members/permissions.py create mode 100644 members/serializers.py create mode 100644 members/signals.py create mode 100644 members/tests.py create mode 100644 members/urls.py create mode 100644 members/views.py create mode 100644 partisan/assets/css/profile.css create mode 100644 partisan/assets/css/style.css create mode 100644 partisan/assets/favicon.png create mode 100644 partisan/assets/humans.txt create mode 100644 partisan/assets/img/logo.png create mode 100644 partisan/assets/img/long-logo.png create mode 100644 partisan/assets/js/main.js create mode 100644 partisan/assets/js/profile.js create mode 100644 partisan/assets/js/utils.js create mode 100644 partisan/assets/robots.txt create mode 100644 partisan/templates/admin/login.html create mode 100644 partisan/templates/base.html create mode 100644 partisan/templates/components/analytics.html create mode 100644 partisan/templates/components/footer.html create mode 100644 partisan/templates/components/modals/edit-profile-modal.html create mode 100644 partisan/templates/components/modals/not-implemented-yet.html create mode 100644 partisan/templates/components/modals/set-password-modal.html create mode 100644 partisan/templates/components/navbar.html create mode 100644 partisan/templates/members/profile.html create mode 100644 partisan/templates/page.html create mode 100644 partisan/templates/registration/logged_out.html create mode 100644 partisan/templates/registration/login.html create mode 100644 partisan/templates/registration/password_reset_complete.html create mode 100644 partisan/templates/registration/password_reset_confirm.html create mode 100644 partisan/templates/registration/password_reset_done.html create mode 100644 partisan/templates/registration/password_reset_form.html create mode 100644 partisan/templates/rest_framework/api.html create mode 100644 partisan/templates/site/home.html create mode 100644 partisan/templates/site/legal/legal-page.html create mode 100644 partisan/templates/site/legal/privacy.html create mode 100644 partisan/templates/site/legal/terms.html create mode 100644 partisan/utils.py create mode 100644 partisan/views.py 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/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..c193caa --- /dev/null +++ b/members/admin.py @@ -0,0 +1,65 @@ +# members.admin +# Administrative interface for members in Trinket. +# +# 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 Trinket. +""" + +########################################################################## +## 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/__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..aea845d --- /dev/null +++ b/members/views.py @@ -0,0 +1,94 @@ +# 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/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..b5d8904 --- /dev/null +++ b/partisan/assets/css/style.css @@ -0,0 +1,53 @@ +/* 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; +} diff --git a/partisan/assets/favicon.png b/partisan/assets/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..aceffda81a3fb27fec11fa635edee8e08024cd71 GIT binary patch literal 843 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzmUKs7M+S!VC(K#9UIO`&C9V-A z!TD(=<%vb942~)JNvR5+xryniL8*x;m4zo$Z5SAs?s~d7hE&{2`t$$4{b2{D28N_0 zi3A1#2j+JS4GdBR>9@!{0ebx zDttn}S&lsS;AI3F!0?nQsGzQHpOt{Tt8g~!qmrPrOo9R~!}07*E-& zUjwOdak`Vv{4qUeS~EkyAHIM(Apw6?RTc)Ojuiq1hK7L*LFx@R9-WzK%>Q8pv+AqM z4tLl>w$G9Pc|MRYASft`o$Fuq_jkSxpKKdL_yz7WJ+fHvBVA>ynnRYZKsMvi{X$04 z3Jn}?jf)m7lD#nTl7o%p%6~JMjzl@gFdXT0FfDYbk?wG9oHmcKYc1QOq$DSxcRyG; z#Kq0~2=u3|Lrls4f3+2j{7>welGrrZHr{9cD6H_0b80@*BbNnro=%`JQDZVqRT0=< z^W($AK$DLK3hP);GCwtAcsW17Y=u`E!^H~$SxQfUads$D=+w!RhDCXIs^DS`HXxhaOkEE7b8{sS(tDC*3fE zvtwt&4B?KsjXyXA;$5!sU->Spv5lqU@`qLiV8lNtY~Y@7t3xe~f5|?Z%1=*b_w@7_ zO*qT9iMzwHQDTbi_eCr3Gc0lF+|D=MmVvQlz0oh`+_Zy?7am!AgTe~DWM4f=H@yg literal 0 HcmV?d00001 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/logo.png b/partisan/assets/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f95fdbf2803e9f1b1528f6811d12e75b6449bce4 GIT binary patch literal 49705 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

k4a$N0Mcz8ybs0NNMiItsnWh_Cqj-=+|-u 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
8aqJPBhvjk==WgrHhic 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 JN=&?u{RjElON9Uc literal 0 HcmV?d00001 diff --git a/partisan/assets/img/long-logo.png b/partisan/assets/img/long-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..39932540d2a0ae204483ae680a779bd36f96001e GIT binary patch literal 16593 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 + * 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..31f8a73 --- /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..4c6739d --- /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..3d97796 --- /dev/null +++ b/partisan/templates/components/navbar.html @@ -0,0 +1,80 @@ +{% load gravatar %} + diff --git a/partisan/templates/members/profile.html b/partisan/templates/members/profile.html new file mode 100644 index 0000000..11bdc42 --- /dev/null +++ b/partisan/templates/members/profile.html @@ -0,0 +1,200 @@ +{% 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 %} +
+ + +
+
+

+ + Starred Datasets +

+
+ {% if member.starred_datasets.count == 0 %} +
+

Star some datasets to pin them here.

+
+ {% endif %} + +
+ +
+ +
+
+ +
    +
  • No activities recorded yet
  • +
+
+
+
+ +
+
+
+{% 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..f2eb7db --- /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..dbff529 --- /dev/null +++ b/partisan/templates/rest_framework/api.html @@ -0,0 +1,78 @@ +{% extends "rest_framework/base.html" %} +{% load staticfiles %} +{% load gravatar %} + +{% block title %}Tinket API{% endblock %} + +{% block bootstrap_theme %} + + + +{% endblock %} + +{% block branding %} + + DDL Trinket 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..d4bf119 --- /dev/null +++ b/partisan/templates/site/home.html @@ -0,0 +1,16 @@ +{% extends 'page.html' %} + +{% block content %} + +
    + +
    + +
    +
    + +{% 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..532d24b --- /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/urls.py b/partisan/urls.py index 74f89ee..0ed3855 100644 --- a/partisan/urls.py +++ b/partisan/urls.py @@ -1,21 +1,78 @@ -"""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 [] 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 * + +########################################################################## +## Endpoint Discovery +########################################################################## + +## API +router = routers.DefaultRouter() +router.register(r'status', HeartbeatViewSet, "status") +router.register(r'users', UserViewSet) + + +########################################################################## +## 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('members.urls', namespace='member')), ] diff --git a/partisan/utils.py b/partisan/utils.py new file mode 100644 index 0000000..f72816f --- /dev/null +++ b/partisan/utils.py @@ -0,0 +1,94 @@ +# 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. + """ + return base64.b64encode(hashlib.sha256e(normalize(text)).digest()) + + +def htmlize(text): + """ + This helper method renders Markdown then uses Bleach to sanitize it as + well as convert all links to actual links. + """ + text = bleach.clean(text, strip=True) # Clean the text by stripping bad HTML tags + text = markdown(text) # Convert the markdown to HTML + text = bleach.linkify(text) # Add links from the text and add nofollow to existing links + + return text + + +########################################################################## +## Memoization +########################################################################## + + +def memoized(fget): + """ + Return a property attribute for new-style classes that only calls its + getter on the first access. The result is stored and on subsequent + accesses is returned, preventing the need to call the getter any more. + https://github.com/estebistec/python-memoized-property + """ + attr_name = '_{0}'.format(fget.__name__) + + @wraps(fget) + def fget_memoized(self): + if not hasattr(self, attr_name): + setattr(self, attr_name, fget(self)) + return getattr(self, attr_name) + + return property(fget_memoized) diff --git a/partisan/views.py b/partisan/views.py new file mode 100644 index 0000000..89db586 --- /dev/null +++ b/partisan/views.py @@ -0,0 +1,56 @@ +# 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 rest_framework import viewsets +from rest_framework.response import Response + +########################################################################## +## Views +########################################################################## + + +class HomePageView(TemplateView): + + template_name = "site/home.html" + + def get_context_data(self, **kwargs): + context = super(HomePageView, self).get_context_data(**kwargs) + return context + +########################################################################## +## API Views for this application +########################################################################## + + +class HeartbeatViewSet(viewsets.ViewSet): + """ + Endpoint for heartbeat checking, including the status and version. + """ + + 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/requirements.txt b/requirements.txt index ac4aff5..1d2f9a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,12 @@ ## 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 whitenoise==3.2 gunicorn==19.6.0 @@ -24,6 +27,11 @@ 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 + ## Testing nose==1.3.7 coverage==4.1 From c470179ad0b3c6ce3c01825ab2f420d837b367a0 Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Sun, 17 Jul 2016 19:21:13 -0400 Subject: [PATCH 02/15] templates cleanup --- members/admin.py | 4 +-- .../modals/not-implemented-yet.html | 2 +- partisan/templates/components/navbar.html | 5 --- partisan/templates/members/profile.html | 35 ------------------- partisan/templates/registration/login.html | 4 +-- partisan/templates/rest_framework/api.html | 7 +--- partisan/templates/site/legal/legal-page.html | 2 +- 7 files changed, 7 insertions(+), 52 deletions(-) diff --git a/members/admin.py b/members/admin.py index c193caa..96c4457 100644 --- a/members/admin.py +++ b/members/admin.py @@ -1,5 +1,5 @@ # members.admin -# Administrative interface for members in Trinket. +# Administrative interface for members in Partisan Discourse. # # Author: Benjamin Bengfort # Created: Sat Aug 22 09:24:11 2015 -0500 @@ -10,7 +10,7 @@ # ID: admin.py [] benjamin@bengfort.com $ """ -Administrative interface for members in Trinket. +Administrative interface for members in Partisan Discourse. """ ########################################################################## diff --git a/partisan/templates/components/modals/not-implemented-yet.html b/partisan/templates/components/modals/not-implemented-yet.html index 4c6739d..1c85f55 100644 --- a/partisan/templates/components/modals/not-implemented-yet.html +++ b/partisan/templates/components/modals/not-implemented-yet.html @@ -9,7 +9,7 @@ - -
    -
    -

    - - Starred Datasets -

    -
    - {% if member.starred_datasets.count == 0 %} -
    -

    Star some datasets to pin them here.

    -
    - {% endif %} - -
    - -
    -
    - -
      -
    • No activities recorded yet
    • -
    -
    -
    diff --git a/partisan/templates/registration/login.html b/partisan/templates/registration/login.html index f2eb7db..95c57d8 100644 --- a/partisan/templates/registration/login.html +++ b/partisan/templates/registration/login.html @@ -47,7 +47,7 @@

    {% block login-title %}Enter Access Controlled Area{% en {% else %} {% endif %} {% endif %} @@ -140,7 +140,7 @@

    {% block login-title %}Enter Access Controlled Area{% en @@ -13,4 +37,87 @@ {% block javascripts %} {{ block.super }} + {% endblock %} diff --git a/partisan/views.py b/partisan/views.py index 15db93d..29583b0 100644 --- a/partisan/views.py +++ b/partisan/views.py @@ -21,6 +21,7 @@ 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 @@ -31,7 +32,7 @@ ########################################################################## -class HomePageView(TemplateView): +class HomePageView(LoginRequiredMixin, TemplateView): template_name = "site/home.html" From f8db1744e93df2e290693a81ad5a0fc56c802436 Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Mon, 18 Jul 2016 13:29:10 -0400 Subject: [PATCH 09/15] document detail and parse page --- corpus/migrations/0004_auto_20160718_1223.py | 20 ++++ corpus/migrations/0005_auto_20160718_1247.py | 21 ++++ corpus/models.py | 12 +- corpus/nlp.py | 84 ++++++++++++++ corpus/serializers.py | 3 +- corpus/signals.py | 20 ++++ corpus/urls.py | 29 +++++ corpus/views.py | 6 + partisan/assets/js/fetch.js | 90 +++++++++++++++ partisan/templates/components/url_form.html | 18 +++ partisan/templates/corpus/document.html | 58 ++++++++++ partisan/templates/corpus/preprocessed.html | 12 ++ partisan/templates/site/home.html | 112 ++----------------- partisan/urls.py | 1 + partisan/utils.py | 4 +- requirements.txt | 9 ++ 16 files changed, 393 insertions(+), 106 deletions(-) create mode 100644 corpus/migrations/0004_auto_20160718_1223.py create mode 100644 corpus/migrations/0005_auto_20160718_1247.py create mode 100644 corpus/nlp.py create mode 100644 corpus/urls.py create mode 100644 partisan/assets/js/fetch.js create mode 100644 partisan/templates/components/url_form.html create mode 100644 partisan/templates/corpus/document.html create mode 100644 partisan/templates/corpus/preprocessed.html diff --git a/corpus/migrations/0004_auto_20160718_1223.py b/corpus/migrations/0004_auto_20160718_1223.py new file mode 100644 index 0000000..54245a0 --- /dev/null +++ b/corpus/migrations/0004_auto_20160718_1223.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-18 16:23 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('corpus', '0003_auto_20160718_1024'), + ] + + operations = [ + migrations.AlterField( + model_name='document', + name='signature', + field=models.CharField(blank=True, default=None, editable=False, max_length=44, null=True), + ), + ] diff --git a/corpus/migrations/0005_auto_20160718_1247.py b/corpus/migrations/0005_auto_20160718_1247.py new file mode 100644 index 0000000..5d91a1e --- /dev/null +++ b/corpus/migrations/0005_auto_20160718_1247.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-18 16:47 +from __future__ import unicode_literals + +from django.db import migrations +import picklefield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('corpus', '0004_auto_20160718_1223'), + ] + + operations = [ + migrations.AlterField( + model_name='document', + name='content', + field=picklefield.fields.PickledObjectField(blank=True, default=None, editable=False, null=True), + ), + ] diff --git a/corpus/models.py b/corpus/models.py index 07deb9e..d3148ec 100644 --- a/corpus/models.py +++ b/corpus/models.py @@ -20,7 +20,9 @@ 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 ########################################################################## @@ -36,8 +38,8 @@ class Document(TimeStampedModel): 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 = models.TextField(**nullable) # The preprocessed NLP content in a parsable text representation - signature = models.CharField(max_length=28, editable=False, **nullable) # A base64 encoded hash of the content + 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 @@ -51,6 +53,12 @@ class Meta: 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 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 index a197524..2d3752c 100644 --- a/corpus/serializers.py +++ b/corpus/serializers.py @@ -30,12 +30,13 @@ 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', 'title', 'long_url', 'short_url', + 'url', 'detail', 'title', 'long_url', 'short_url', 'signature', 'n_words', 'n_vocab', 'labels', ) read_only_fields = ( diff --git a/corpus/signals.py b/corpus/signals.py index 2cdb322..87ae63c 100644 --- a/corpus/signals.py +++ b/corpus/signals.py @@ -17,6 +17,7 @@ ## Imports ########################################################################## +import bs4 import requests from django.dispatch import receiver @@ -26,6 +27,8 @@ 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 @@ -58,3 +61,20 @@ def fetch_document_on_create(sender, instance, *args, **kwargs): # 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/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 index c6672fd..aaa443f 100644 --- a/corpus/views.py +++ b/corpus/views.py @@ -17,6 +17,8 @@ ## Imports ########################################################################## +from django.views.generic import DetailView + from rest_framework import status from rest_framework import viewsets from rest_framework.response import Response @@ -32,6 +34,10 @@ ## Views ########################################################################## +class DocumentDetail(DetailView): + + model = Document + template_name = 'corpus/document.html' ########################################################################## ## API HTTP/JSON Views diff --git a/partisan/assets/js/fetch.js b/partisan/assets/js/fetch.js new file mode 100644 index 0000000..912c0b3 --- /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) { + toggleURLForm(); + 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/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..26a02db --- /dev/null +++ b/partisan/templates/corpus/document.html @@ -0,0 +1,58 @@ +{% extends 'page.html' %} +{% load staticfiles %} + +{% block stylesheets %} + {{ block.super }} + +{% endblock %} + +{% block content %} + +
    + +
    + + {% include 'components/url_form.html' %} + +
    +

    {{ document.title }}

    + +
    + +
    +
    + {% 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/site/home.html b/partisan/templates/site/home.html index 6264aaa..630577f 100644 --- a/partisan/templates/site/home.html +++ b/partisan/templates/site/home.html @@ -1,4 +1,5 @@ {% extends 'page.html' %} +{% load staticfiles %} {% block content %} @@ -6,28 +7,17 @@
    -
    -
    -
    -
    - - - - - - - - -
    - -
    - {% csrf_token %} -
    + {% include 'components/url_form.html' %} + + +
    +

    +
      -

      Documents Fetched and Parsed

      -
        +
        +
        @@ -37,87 +27,5 @@

        Documents Fetched and Parsed

        {% block javascripts %} {{ block.super }} - + {% endblock %} diff --git a/partisan/urls.py b/partisan/urls.py index 90e9b82..467815f 100644 --- a/partisan/urls.py +++ b/partisan/urls.py @@ -75,5 +75,6 @@ # 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 index 0a73fe9..9ba6615 100644 --- a/partisan/utils.py +++ b/partisan/utils.py @@ -57,7 +57,9 @@ def signature(text): completely lowercase. These signatures will help us discover textual similarities between questions. """ - return base64.b64encode(hashlib.sha256e(normalize(text)).digest()) + text = normalize(text).encode('utf-8') + sign = base64.b64encode(hashlib.sha256(text).digest()) + return sign.decode('utf-8') def htmlize(text): diff --git a/requirements.txt b/requirements.txt index 1d2f9a5..43193d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ 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 @@ -32,6 +33,14 @@ 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 From 2e4607aa6ff9d7836b3f73f7614056af1a0a9d94 Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Mon, 18 Jul 2016 13:30:57 -0400 Subject: [PATCH 10/15] cleanup the home page --- partisan/assets/js/fetch.js | 2 +- partisan/templates/site/home.html | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/partisan/assets/js/fetch.js b/partisan/assets/js/fetch.js index 912c0b3..c130184 100644 --- a/partisan/assets/js/fetch.js +++ b/partisan/assets/js/fetch.js @@ -41,7 +41,7 @@ "data": JSON.stringify(data), "contentType": "application/json" }).done(function(data) { - toggleURLForm(); + if (data.detail) { window.location = data.detail; } else { diff --git a/partisan/templates/site/home.html b/partisan/templates/site/home.html index 630577f..3c0d9b1 100644 --- a/partisan/templates/site/home.html +++ b/partisan/templates/site/home.html @@ -15,11 +15,6 @@

          -
          -
          -
          -
          - From d954ed587d0151429dfaeb1818a900ab5c0de338 Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Mon, 18 Jul 2016 13:31:55 -0400 Subject: [PATCH 11/15] cleaned up migrations --- corpus/migrations/0001_initial.py | 20 +++++++++---- corpus/migrations/0002_auto_20160718_0831.py | 30 -------------------- corpus/migrations/0003_auto_20160718_1024.py | 19 ------------- corpus/migrations/0004_auto_20160718_1223.py | 20 ------------- corpus/migrations/0005_auto_20160718_1247.py | 21 -------------- 5 files changed, 14 insertions(+), 96 deletions(-) delete mode 100644 corpus/migrations/0002_auto_20160718_0831.py delete mode 100644 corpus/migrations/0003_auto_20160718_1024.py delete mode 100644 corpus/migrations/0004_auto_20160718_1223.py delete mode 100644 corpus/migrations/0005_auto_20160718_1247.py diff --git a/corpus/migrations/0001_initial.py b/corpus/migrations/0001_initial.py index dee6033..86b818c 100644 --- a/corpus/migrations/0001_initial.py +++ b/corpus/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-07-18 01:20 +# Generated by Django 1.9.7 on 2016-07-18 17:31 from __future__ import unicode_literals import autoslug.fields @@ -8,6 +8,7 @@ import django.db.models.deletion import django.utils.timezone import model_utils.fields +import picklefield.fields class Migration(migrations.Migration): @@ -27,8 +28,8 @@ class Migration(migrations.Migration): ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), ], options={ - 'get_latest_by': 'created', 'db_table': 'annotations', + 'get_latest_by': 'created', }, ), migrations.CreateModel( @@ -37,16 +38,19 @@ class Migration(migrations.Migration): ('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', models.TextField(blank=True, default=None, null=True)), - ('signature', models.CharField(blank=True, default=None, editable=False, max_length=28, 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={ - 'get_latest_by': 'created', 'db_table': 'documents', + 'get_latest_by': 'created', }, ), migrations.CreateModel( @@ -61,8 +65,8 @@ class Migration(migrations.Migration): ('parent', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='corpus.Label')), ], options={ - 'get_latest_by': 'created', 'db_table': 'labels', + 'get_latest_by': 'created', }, ), migrations.AddField( @@ -84,4 +88,8 @@ class Migration(migrations.Migration): name='document', unique_together=set([('long_url', 'short_url')]), ), + migrations.AlterUniqueTogether( + name='annotation', + unique_together=set([('document', 'user')]), + ), ] diff --git a/corpus/migrations/0002_auto_20160718_0831.py b/corpus/migrations/0002_auto_20160718_0831.py deleted file mode 100644 index e21e125..0000000 --- a/corpus/migrations/0002_auto_20160718_0831.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-07-18 12:31 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('corpus', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='document', - name='n_vocab', - field=models.SmallIntegerField(blank=True, default=None, null=True), - ), - migrations.AddField( - model_name='document', - name='n_words', - field=models.SmallIntegerField(blank=True, default=None, null=True), - ), - migrations.AddField( - model_name='document', - name='title', - field=models.CharField(blank=True, default=None, max_length=255, null=True), - ), - ] diff --git a/corpus/migrations/0003_auto_20160718_1024.py b/corpus/migrations/0003_auto_20160718_1024.py deleted file mode 100644 index b66d1d1..0000000 --- a/corpus/migrations/0003_auto_20160718_1024.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-07-18 14:24 -from __future__ import unicode_literals - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('corpus', '0002_auto_20160718_0831'), - ] - - operations = [ - migrations.AlterUniqueTogether( - name='annotation', - unique_together=set([('document', 'user')]), - ), - ] diff --git a/corpus/migrations/0004_auto_20160718_1223.py b/corpus/migrations/0004_auto_20160718_1223.py deleted file mode 100644 index 54245a0..0000000 --- a/corpus/migrations/0004_auto_20160718_1223.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-07-18 16:23 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('corpus', '0003_auto_20160718_1024'), - ] - - operations = [ - migrations.AlterField( - model_name='document', - name='signature', - field=models.CharField(blank=True, default=None, editable=False, max_length=44, null=True), - ), - ] diff --git a/corpus/migrations/0005_auto_20160718_1247.py b/corpus/migrations/0005_auto_20160718_1247.py deleted file mode 100644 index 5d91a1e..0000000 --- a/corpus/migrations/0005_auto_20160718_1247.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.7 on 2016-07-18 16:47 -from __future__ import unicode_literals - -from django.db import migrations -import picklefield.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('corpus', '0004_auto_20160718_1223'), - ] - - operations = [ - migrations.AlterField( - model_name='document', - name='content', - field=picklefield.fields.PickledObjectField(blank=True, default=None, editable=False, null=True), - ), - ] From 06d21165f03c054a143746feec0dd042e7c45d6e Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Mon, 18 Jul 2016 22:28:42 -0400 Subject: [PATCH 12/15] annotate documents --- corpus/serializers.py | 67 ++++++++++++- corpus/views.py | 45 ++++++++- partisan/assets/css/style.css | 25 +++++ partisan/assets/img/icons/democratic-icon.png | Bin 0 -> 402 bytes .../img/icons/democratic-white-icon.png | Bin 0 -> 528 bytes partisan/assets/img/icons/republican-icon.png | Bin 0 -> 324 bytes .../img/icons/republican-white-icon.png | Bin 0 -> 518 bytes partisan/assets/js/annotate.js | 88 ++++++++++++++++++ partisan/templates/corpus/document.html | 47 +++++++++- 9 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 partisan/assets/img/icons/democratic-icon.png create mode 100644 partisan/assets/img/icons/democratic-white-icon.png create mode 100644 partisan/assets/img/icons/republican-icon.png create mode 100644 partisan/assets/img/icons/republican-white-icon.png create mode 100644 partisan/assets/js/annotate.js diff --git a/corpus/serializers.py b/corpus/serializers.py index 2d3752c..2291abc 100644 --- a/corpus/serializers.py +++ b/corpus/serializers.py @@ -18,8 +18,8 @@ ########################################################################## from rest_framework import serializers -from corpus.models import Document, Annotation -from django.core.validators import URLValidator +from corpus.models import Document, Annotation, Label + ########################################################################## ## Document Serializer @@ -46,3 +46,66 @@ class Meta: '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/views.py b/corpus/views.py index aaa443f..6ea4b62 100644 --- a/corpus/views.py +++ b/corpus/views.py @@ -22,11 +22,13 @@ 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 +from corpus.models import Document, Annotation, Label from corpus.serializers import DocumentSerializer +from corpus.serializers import AnnotationSerializer from corpus.exceptions import CorpusException @@ -38,6 +40,25 @@ 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 @@ -83,3 +104,25 @@ def perform_create(self, serializer): 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/partisan/assets/css/style.css b/partisan/assets/css/style.css index b5d8904..39d86a5 100644 --- a/partisan/assets/css/style.css +++ b/partisan/assets/css/style.css @@ -51,3 +51,28 @@ div.header-buttons { .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'); +} diff --git a/partisan/assets/img/icons/democratic-icon.png b/partisan/assets/img/icons/democratic-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ea94379312e9f26731f9a23df202a6e70929a3ff GIT binary patch literal 402 zcmV;D0d4+?P) zu2g_3BQr%r19N>Yu!`@@Jn1#7L0ZD@AAsv}%v!(%X7PoCo|<)?34E=i_KxyrX6|(x w5iwo>wNU|Hbe`_Lrsh%Sxo+wJ@!#M12Rr|0z6zgktN;K207*qoM6N<$g2KP7C;$Ke literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..25df29fc5aeeba58f9ce668a14a9d6ad354100b7 GIT binary patch literal 528 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6SkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+uenMVO6iP5s=4O z;1O92wCOqsGdgL^t^f+Mmw5WRvOi!KW)?D=FLEvbD0I)$#W6(VeCx!GUd)anuIEpQ z-D1t2y@rK*W8Bd#P4XKOGAHs2NONl|_R1^PHO`&j)h)nr#EV;dtBCL1Lm7t0m3E)p zwd0o@PrG%y>n(KcVFY@Q>vlpinR(g8$%zH2 Ydih1^v)|cB0TnTLy85}Sb4q9e03=1Z7ytkO literal 0 HcmV?d00001 diff --git a/partisan/assets/img/icons/republican-icon.png b/partisan/assets/img/icons/republican-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1568aed1d44b5e77ad1b2a2d4f0cab38d32a13c0 GIT binary patch literal 324 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6n2Mb|LpV4>-?)JUISV`@iy0Ug z&wwza&sM`#Ktah8*NBqf{Irtt#G+J&^73-M%)IR4nt8U{ zI-MO?x2|}1ca!nM1SUUK{duKdwut_^#dUJ-Pob2lx>FbYYRF;xkf@O2SK?ieUwx-f zpzkgB%+Du-%r8fVwH7Q<%gOw)iYeReUd?V5%HN}hbpv(&|}o&9rGUN!C_vx1Mx R44`)yJYD@<);T3K0RZsuetrM| literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..47dd0035b8985c7172e3aad13842e69ce4957ee2 GIT binary patch literal 518 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!60wlNoGJgf6SkfJR9T^xl_H+M9WCijSl0AZa z85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YDR+uenMVO6iP5s=4O z;1O92wCOqsGdgL^t^f+Mmw5WRvOi!KW)@~zzMFdqQ0R)Mi(`n!`K1@nYq>B=w0*ok zv+oaQV3^sugKoj=ob;NOu9;HAx^32`IZ21zP9=Fop7Oh?sm8f{$;z20mz?{lGe@M< z z#pJQ(vLt&YGiMG@lQl0im$-dt&5TXFwpf+b^NRNW-4~rhg82<|&ffpBw;}eeNX%`G z#h%MEc5H9A(d17j;gODh8-Z381K1B0b& zxc;MP$jwj5OsmAL;mXHN&Oi;4ARB`7(@M${i&7cN%ggmL^RkPR6AM!H@{7`Ezq647 PDq`?-^>bP0l+XkK^lG`U literal 0 HcmV?d00001 diff --git a/partisan/assets/js/annotate.js b/partisan/assets/js/annotate.js new file mode 100644 index 0000000..fc640ea --- /dev/null +++ b/partisan/assets/js/annotate.js @@ -0,0 +1,88 @@ +/* + * 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 toggle 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-danger'); + + } else { + // This is not the newly selected button + btn.data('selected', false); + btn.removeClass('btn-danger'); + 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/templates/corpus/document.html b/partisan/templates/corpus/document.html index 26a02db..c4a2bf3 100644 --- a/partisan/templates/corpus/document.html +++ b/partisan/templates/corpus/document.html @@ -11,6 +11,14 @@ span.preprocessed-sentence:hover { color: #008cba; } + + #documentContent { + margin-top: 15px; + } + + #documentMeta { + margin-top: 8px; + } {% endblock %} @@ -19,11 +27,19 @@ + {% endblock %} @@ -55,4 +95,5 @@

          {{ document.title }}

          {% block javascripts %} {{ block.super }} + {% endblock %} From 154532ea52d97ae3828acecfc1d76bf1baabf914 Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Mon, 18 Jul 2016 23:05:59 -0400 Subject: [PATCH 13/15] recent activity and redblue colors --- corpus/models.py | 2 +- partisan/assets/css/style.css | 8 +++++ partisan/assets/img/democratic.png | Bin 0 -> 2616 bytes partisan/assets/img/republican.png | Bin 0 -> 2377 bytes partisan/assets/js/annotate.js | 7 ++-- partisan/settings/base.py | 2 +- partisan/templates/corpus/document.html | 26 ++++++++++++++- partisan/templates/site/home.html | 41 ++++++++++++++++++++---- partisan/views.py | 6 ++++ 9 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 partisan/assets/img/democratic.png create mode 100644 partisan/assets/img/republican.png diff --git a/corpus/models.py b/corpus/models.py index d3148ec..5b5ddc6 100644 --- a/corpus/models.py +++ b/corpus/models.py @@ -106,7 +106,7 @@ class Meta: def __str__(self): if self.label: return "{} added label {} to \"{}\" on {}".format( - self.user, self.label, self.document, self.updated + self.user, self.label, self.document, self.modified ) return "{} added document \"{}\" on {}".format( diff --git a/partisan/assets/css/style.css b/partisan/assets/css/style.css index 39d86a5..a4b14a0 100644 --- a/partisan/assets/css/style.css +++ b/partisan/assets/css/style.css @@ -76,3 +76,11 @@ div.header-buttons { .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/img/democratic.png b/partisan/assets/img/democratic.png new file mode 100644 index 0000000000000000000000000000000000000000..e152df0841077e66a5de95f7b20b70e902f37b8b GIT binary patch literal 2616 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 bBF{GWTxI!uSogV(-4BbEnVo5!iAU@|s@X0T literal 0 HcmV?d00001 diff --git a/partisan/assets/js/annotate.js b/partisan/assets/js/annotate.js index fc640ea..944f8ef 100644 --- a/partisan/assets/js/annotate.js +++ b/partisan/assets/js/annotate.js @@ -50,7 +50,7 @@ // On successful post of the annotation reset the buttons. var labelSlug = data.label - console.log("Setting toggle to", labelSlug); + 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) { @@ -62,12 +62,13 @@ btn.data('selected', true); btn.removeClass('btn-default'); btn.find("i").addClass('icon-white'); - btn.addClass('btn-danger'); + btn.addClass('btn-' + labelSlug); } else { // This is not the newly selected button btn.data('selected', false); - btn.removeClass('btn-danger'); + btn.removeClass('btn-democratic'); + btn.removeClass('btn-republican'); btn.find("i").removeClass('icon-white'); btn.addClass('btn-default'); } diff --git a/partisan/settings/base.py b/partisan/settings/base.py index 9a3f9d3..61b4027 100644 --- a/partisan/settings/base.py +++ b/partisan/settings/base.py @@ -185,7 +185,7 @@ def environ_setting(name, default=None): GRAVATAR_DEFAULT_SIZE = 512 GRAVATAR_DEFAULT_IMAGE = 'identicon' GRAVATAR_DEFAULT_RATING = 'r' -GRAVATAR_ICON_SIZE = 30 +GRAVATAR_ICON_SIZE = 42 ########################################################################## ## MarkupField Configuration diff --git a/partisan/templates/corpus/document.html b/partisan/templates/corpus/document.html index c4a2bf3..0c3e92f 100644 --- a/partisan/templates/corpus/document.html +++ b/partisan/templates/corpus/document.html @@ -19,6 +19,30 @@ #documentMeta { margin-top: 8px; } + + .btn-republican:hover { + color: #ffffff; + background-color: #d32a0e; + border-color: #b1240c; + } + + .btn-republican { + color: #ffffff; + background-color: #f04124; + border-color: #ea2f10; + } + + .btn-democratic { + color: #ffffff; + background-color: #008cba; + border-color: #0079a1; + } + + .btn-democratic:hover { + color: #ffffff; + background-color: #006687; + border-color: #004b63; + } {% endblock %} @@ -61,7 +85,7 @@

          {{ document.title }}

          {% for label in labels %} {% if annotation.label == label %} -
          + +
          +
          +

          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 %} +
          +
          +
          + From a680d0c6d2eb9802c95101296d2088e4c637975a Mon Sep 17 00:00:00 2001 From: Benjamin Bengfort Date: Mon, 18 Jul 2016 23:30:49 -0400 Subject: [PATCH 15/15] version bump --- README.md | 14 ++++++++++++++ partisan/tests/test_init.py | 2 +- partisan/version.py | 4 ++-- 3 files changed, 17 insertions(+), 3 deletions(-) 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/partisan/tests/test_init.py b/partisan/tests/test_init.py index da3e7cc..323b710 100644 --- a/partisan/tests/test_init.py +++ b/partisan/tests/test_init.py @@ -23,7 +23,7 @@ ## Module variables ########################################################################## -EXPECTED_VERSION = "0.1" +EXPECTED_VERSION = "0.1b1" ########################################################################## ## Initialization Tests diff --git a/partisan/version.py b/partisan/version.py index 297fc3b..d79a239 100644 --- a/partisan/version.py +++ b/partisan/version.py @@ -21,8 +21,8 @@ 'major': 0, 'minor': 1, 'micro': 0, - 'releaselevel': 'final', - 'serial': 0, + 'releaselevel': 'beta', + 'serial': 1, }