diff --git a/.gitignore b/.gitignore index 1d59fb6..5c05d4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__/ db.sqlite3 +/staticfiles/ diff --git a/.travis.yml b/.travis.yml index abbeb93..02cb7de 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,5 @@ language: python +python: + - 3.6 script: flake8 diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..511c858 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn ballot_api.wsgi --log-file - diff --git a/README.md b/README.md index fefe7eb..1c8a0e5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ California. ## Prequisites +- Python 3 +- [PostgreSQL](https://www.postgresql.org/) + ## Setup diff --git a/ballot/admin.py b/ballot/admin.py index daa3a78..fb5b5c0 100644 --- a/ballot/admin.py +++ b/ballot/admin.py @@ -14,11 +14,7 @@ class PersonAdmin(admin.ModelAdmin): exclude = ('filer',) -class BallotApiAdminSite(admin.AdminSite): - site_header = 'Open Disclosure Ballot API Admin' - - -api_admin = BallotApiAdminSite(name='ballotapi-admin') -api_admin.register(Election) -api_admin.register(Person, PersonAdmin) -api_admin.register(State) +admin.site.site_header = 'Open Disclosure Ballot API Admin' +admin.site.register(Election) +admin.site.register(Person, PersonAdmin) +admin.site.register(State) diff --git a/ballot_api/settings/__init__.py b/ballot_api/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ballot_api/settings.py b/ballot_api/settings/common.py similarity index 77% rename from ballot_api/settings.py rename to ballot_api/settings/common.py index 84d0c51..7ab1295 100644 --- a/ballot_api/settings.py +++ b/ballot_api/settings/common.py @@ -1,6 +1,9 @@ +import logging import os -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +logging.basicConfig(level=logging.INFO) + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) SECRET_KEY = 'y%72v=z^op&=n4$ye^u$*n0!a=v*-qs_&)#rs*wf+m3up85)(#' @@ -8,6 +11,15 @@ ALLOWED_HOSTS = [] +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'ballot_api', + 'HOST': '127.0.0.1', + 'PORT': '5432', + } +} + INSTALLED_APPS = ( 'flat', 'django.contrib.admin', @@ -18,6 +30,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'ballot', + 'gsheets', 'rest_framework_swagger', ) @@ -31,6 +44,7 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', ) ROOT_URLCONF = 'ballot_api.urls' @@ -53,13 +67,6 @@ WSGI_APPLICATION = 'ballot_api.wsgi.application' -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), - } -} - LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' @@ -71,3 +78,5 @@ USE_TZ = True STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' diff --git a/ballot_api/settings/development.py b/ballot_api/settings/development.py new file mode 100644 index 0000000..0f5b26b --- /dev/null +++ b/ballot_api/settings/development.py @@ -0,0 +1,7 @@ +# flake8: noqa +from .common import * + +try: + from local_settings import * +except ImportError: + pass diff --git a/ballot_api/settings/heroku.py b/ballot_api/settings/heroku.py new file mode 100644 index 0000000..6c4a277 --- /dev/null +++ b/ballot_api/settings/heroku.py @@ -0,0 +1,20 @@ +# flake8: noqa + +import os + +import dj_database_url + +from .common import * + +DEBUG = False + +ALLOWED_HOSTS = [ + 'caciviclab-ballot-api.herokuapp.com', +] + +DATABASES['default'] = dj_database_url.config() + +SECRET_KEY = os.environ.get('SECRET_KEY', None) + +if not SECRET_KEY: + raise Exception('SECRET_KEY environment variable must be set for heroku configuration.') diff --git a/ballot_api/urls.py b/ballot_api/urls.py index 3fa1967..0819057 100644 --- a/ballot_api/urls.py +++ b/ballot_api/urls.py @@ -1,14 +1,14 @@ from django.conf.urls import url, include +from django.contrib import admin from rest_framework import routers from rest_framework_swagger.views import get_swagger_view -from ballot.admin import api_admin -from ballot.views import CandidateViewSet, ElectionViewSet +import ballot.views as ballot_views +import gsheets.views as gsheets_views - -router = routers.DefaultRouter() -router.register(r'candidates', CandidateViewSet) -router.register(r'elections', ElectionViewSet) +router = routers.SimpleRouter() +router.register(r'elections', ballot_views.ElectionViewSet) +router.register(r'candidates', gsheets_views.CandidateViewSet) schema_view = get_swagger_view(title='Open Disclosure Ballot API') @@ -16,5 +16,5 @@ url(r'^api/', include(router.urls)), url(r'^$', schema_view), url(r'^admin/doc/', include('django.contrib.admindocs.urls')), - url(r'^admin/', api_admin.urls), + url(r'^admin/', include(admin.site.urls)), ] diff --git a/ballot_api/wsgi.py b/ballot_api/wsgi.py index 2b89bb0..644933c 100644 --- a/ballot_api/wsgi.py +++ b/ballot_api/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ballot_api.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ballot_api.settings.heroku") application = get_wsgi_application() diff --git a/gsheets/__init__.py b/gsheets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gsheets/admin.py b/gsheets/admin.py new file mode 100644 index 0000000..a329755 --- /dev/null +++ b/gsheets/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from .models import Candidate, CandidateAlias, Committee, Referendum, ReferendumMapping + + +class CandidateAliasInline(admin.TabularInline): + model = CandidateAlias + + +class CandidateAdmin(admin.ModelAdmin): + inlines = (CandidateAliasInline,) + + +admin.site.register(Candidate, CandidateAdmin) +admin.site.register(CandidateAlias) +admin.site.register(Referendum) +admin.site.register(ReferendumMapping) +admin.site.register(Committee) diff --git a/gsheets/management/__init__.py b/gsheets/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gsheets/management/commands/__init__.py b/gsheets/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gsheets/management/commands/gsheets_import.py b/gsheets/management/commands/gsheets_import.py new file mode 100644 index 0000000..1a6b433 --- /dev/null +++ b/gsheets/management/commands/gsheets_import.py @@ -0,0 +1,93 @@ +import csv +import io +import logging +import re +import tempfile +import urllib.request + +from django.core.management.base import BaseCommand + +from gsheets import parsers + + +CANDIDATES_SHEET = 'https://docs.google.com/spreadsheets/d/1272oaLyQhKwQa6RicA5tBso6wFruum-mgrNm3O3VogI/pub?gid=0&single=true&output=csv' # noqa +REFERENDUMS_SHEET = 'https://docs.google.com/spreadsheets/d/1272oaLyQhKwQa6RicA5tBso6wFruum-mgrNm3O3VogI/pub?gid=1693935349&single=true&output=csv' # noqa +REFERENDUM_NAME_TO_NUMBER_SHEET = 'https://docs.google.com/spreadsheets/d/1272oaLyQhKwQa6RicA5tBso6wFruum-mgrNm3O3VogI/pub?gid=896561174&single=true&output=csv' # noqa +COMMITTEES_SHEET = 'https://docs.google.com/spreadsheets/d/1272oaLyQhKwQa6RicA5tBso6wFruum-mgrNm3O3VogI/pub?gid=1995437960&single=true&output=csv' # noqa + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Import data from Google Sheets' + + def add_arguments(self, parser): + parser.add_argument( + '--force', + action='store_true', + default=False, + help="Import the entity even if it already exists.") + + def format_csv_fields(self, row): + """Lowercases the keys of row and removes any special characters""" + + # Lowercase the keys + model = ((k.lower(), v) for k, v in row.items()) + + # Replace spaces with underscore + model = ((re.sub(r'\s+', '_', k), v) for k, v in model) + + # Strip other characters + model = ((re.sub(r'[^a-z_]', '', k), v) for k, v in model) + + return dict(model) + + def handle(self, *args, **options): + self.fetch_and_parse_from_url(CANDIDATES_SHEET, parsers.CandidateParser(), **options) + self.fetch_and_parse_from_url(COMMITTEES_SHEET, parsers.CommitteeParser(), **options) + self.fetch_and_parse_from_url(REFERENDUMS_SHEET, parsers.ReferendumParser(), **options) + self.fetch_and_parse_from_url(REFERENDUM_NAME_TO_NUMBER_SHEET, parsers.ReferendumMappingParser(), **options) + + def fetch_and_parse_from_url(self, url, parser, force=False, **options): + with urllib.request.urlopen(url) as request: + with tempfile.TemporaryFile() as csvfile: + csvfile.write(request.read()) + csvfile.seek(0) + reader = csv.DictReader(io.TextIOWrapper(csvfile, encoding='utf-8')) + + total = 0 + imported = 0 + import_errors = 0 + for row in reader: + total += 1 + + row = self.format_csv_fields(row) + logger.debug(row) + + exists = parser.exists_in_db(row) + if exists and not force: + # Skip this row + continue + + id = row.get(parser.key) + + try: + data = parser.parse(row) + logger.debug(data) + model, created = parser.commit(data) + except Exception as err: + import_errors += 1 + logger.error('%s "%s" could not be parsed: parse_errors=%s row=%s', + parser.name, id, err, row) + logger.exception(err) + continue + + imported += 1 + if created: + logger.info('Created %s "%s"', parser.name, id) + else: + logger.info('Updated %s "%s"', parser.name, id) + + logger.info('Import %s data complete: total=%s imported=%s errors=%s', + parser.name, total, imported, import_errors) diff --git a/gsheets/migrations/0001_initial.py b/gsheets/migrations/0001_initial.py new file mode 100644 index 0000000..ce48ce8 --- /dev/null +++ b/gsheets/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Candidate', + fields=[ + ('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)), + ('fppc', models.IntegerField(blank=True, unique=True, null=True)), + ('committee_name', models.CharField(blank=True, max_length=120)), + ('candidate', models.CharField(max_length=30)), + ('office', models.CharField(blank=True, help_text='Office the candidate is running for.', max_length=30)), + ('incumbent', models.BooleanField(default=False)), + ('accepted_expenditure_ceiling', models.BooleanField(default=False)), + ('website', models.URLField(blank=True, null=True)), + ('twitter', models.URLField(blank=True, null=True)), + ('party_affiliation', models.CharField(blank=True, max_length=15)), + ('occupation', models.CharField(blank=True, max_length=80)), + ('bio', models.TextField(blank=True)), + ('photo', models.URLField(blank=True, null=True)), + ('votersedge', models.URLField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='Committee', + fields=[ + ('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)), + ('filer_id', models.CharField(max_length=15)), + ('filer_naml', models.CharField(blank=True, max_length=200)), + ('committee_type', models.CharField(choices=[('RCP', 'RCP'), ('BMC', 'BMC')], blank=True, max_length=3)), + ('description', models.CharField(blank=True, max_length=40)), + ('ballot_measure', models.CharField(blank=True, max_length=3)), + ('support_or_oppose', models.CharField(choices=[('S', 'Support'), ('O', 'Oppose')], blank=True, max_length=1)), + ('website', models.URLField(blank=True, null=True)), + ('twitter', models.URLField(blank=True, null=True)), + ('facebook', models.URLField(blank=True, null=True)), + ('netfilelocalid', models.CharField(blank=True, max_length=15)), + ], + ), + migrations.CreateModel( + name='Referendum', + fields=[ + ('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)), + ('measure_number', models.CharField(max_length=5)), + ('short_title', models.CharField(blank=True, max_length=30)), + ('full_title', models.TextField(blank=True)), + ('summary', models.TextField()), + ('votersedge', models.URLField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='ReferendumMapping', + fields=[ + ('id', models.AutoField(primary_key=True, verbose_name='ID', auto_created=True, serialize=False)), + ('measure_name', models.CharField(max_length=30)), + ('measure_number', models.CharField(max_length=3)), + ], + ), + ] diff --git a/gsheets/migrations/0002_auto_20170205_0455.py b/gsheets/migrations/0002_auto_20170205_0455.py new file mode 100644 index 0000000..aace0a2 --- /dev/null +++ b/gsheets/migrations/0002_auto_20170205_0455.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('gsheets', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CandidateAlias', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)), + ('candidate_alias', models.CharField(max_length=30)), + ('candidate', models.ForeignKey(to='gsheets.Candidate', related_name='aliases')), + ], + ), + migrations.AlterField( + model_name='referendum', + name='short_title', + field=models.CharField(max_length=200, blank=True), + ), + migrations.AlterField( + model_name='referendummapping', + name='measure_name', + field=models.CharField(max_length=200), + ), + ] diff --git a/gsheets/migrations/0003_auto_20170205_0802.py b/gsheets/migrations/0003_auto_20170205_0802.py new file mode 100644 index 0000000..423cc3b --- /dev/null +++ b/gsheets/migrations/0003_auto_20170205_0802.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('gsheets', '0002_auto_20170205_0455'), + ] + + operations = [ + migrations.AlterModelOptions( + name='candidatealias', + options={'verbose_name_plural': 'candidate aliases'}, + ), + migrations.AlterField( + model_name='candidate', + name='candidate', + field=models.CharField(max_length=30, help_text="The candidate's full name."), + ), + migrations.AlterField( + model_name='candidate', + name='party_affiliation', + field=models.CharField(max_length=1, choices=[('D', 'Democrat'), ('R', 'Republican'), ('I', 'Independent'), ('O', 'Other')], blank=True), + ), + ] diff --git a/gsheets/migrations/0004_auto_20170329_0408.py b/gsheets/migrations/0004_auto_20170329_0408.py new file mode 100644 index 0000000..a1d8366 --- /dev/null +++ b/gsheets/migrations/0004_auto_20170329_0408.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('gsheets', '0003_auto_20170205_0802'), + ] + + operations = [ + migrations.AlterField( + model_name='referendummapping', + name='measure_number', + field=models.CharField(max_length=10), + ), + ] diff --git a/gsheets/migrations/__init__.py b/gsheets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gsheets/models.py b/gsheets/models.py new file mode 100644 index 0000000..728cb95 --- /dev/null +++ b/gsheets/models.py @@ -0,0 +1,88 @@ +from django.db import models + + +class CandidateAlias(models.Model): + """An alternative name for the candidate which may occur in filings.""" + + candidate_alias = models.CharField(max_length=30) + candidate = models.ForeignKey('Candidate', on_delete=models.CASCADE, related_name='aliases') + + def __str__(self): + return self.candidate_alias + + class Meta: + verbose_name_plural = 'candidate aliases' + + +class Candidate(models.Model): + """A person running for office in an election.""" + + PARTY_AFFILIATION = [ + ('D', 'Democrat'), + ('R', 'Republican'), + ('I', 'Independent'), + ('O', 'Other'), + ] + + fppc = models.IntegerField(blank=True, null=True, unique=True) + committee_name = models.CharField(blank=True, max_length=120) + candidate = models.CharField(max_length=30, help_text='The candidate\'s full name.') + office = models.CharField(blank=True, max_length=30, help_text='Office the candidate is running for.') + incumbent = models.BooleanField(default=False) + accepted_expenditure_ceiling = models.BooleanField(default=False) + website = models.URLField(blank=True, null=True) + twitter = models.URLField(blank=True, null=True) + party_affiliation = models.CharField(blank=True, max_length=1, choices=PARTY_AFFILIATION) + occupation = models.CharField(blank=True, max_length=80) + bio = models.TextField(blank=True) + # TODO use ImageField to support photo uploads + photo = models.URLField(blank=True, null=True) + votersedge = models.URLField(blank=True, null=True) + + def __str__(self): + return self.candidate + + +class Referendum(models.Model): + measure_number = models.CharField(max_length=5) + short_title = models.CharField(blank=True, max_length=200) + full_title = models.TextField(blank=True) + summary = models.TextField() + votersedge = models.URLField(blank=True, null=True) + + def __str__(self): + return "Referendum %s %s" % (self.measure_number, self.short_title) + + +class Committee(models.Model): + COMMITTEE_TYPES = [ + ('RCP', 'RCP'), + ('BMC', 'BMC'), + ] + + SUPPORT_OPPOSE = [ + ('S', 'Support'), + ('O', 'Oppose'), + ] + + filer_id = models.CharField(max_length=15) + filer_naml = models.CharField(blank=True, max_length=200) + committee_type = models.CharField(blank=True, max_length=3, choices=COMMITTEE_TYPES) + description = models.CharField(blank=True, max_length=40) + ballot_measure = models.CharField(blank=True, max_length=3) + support_or_oppose = models.CharField(blank=True, max_length=1, choices=SUPPORT_OPPOSE) + website = models.URLField(blank=True, null=True) + twitter = models.URLField(blank=True, null=True) + facebook = models.URLField(blank=True, null=True) + netfilelocalid = models.CharField(blank=True, max_length=15) + + def __str__(self): + return self.filer_naml + + +class ReferendumMapping(models.Model): + measure_name = models.CharField(max_length=200) + measure_number = models.CharField(max_length=10) + + def __str__(self): + return '%s: %s' % (self.measure_number, self.measure_name) diff --git a/gsheets/parsers.py b/gsheets/parsers.py new file mode 100644 index 0000000..59bf3c9 --- /dev/null +++ b/gsheets/parsers.py @@ -0,0 +1,162 @@ +from django.core.exceptions import ObjectDoesNotExist + +from .models import Candidate, CandidateAlias, Committee, Referendum, ReferendumMapping +from .serializers import CandidateSerializer, CommitteeSerializer, ReferendumSerializer, ReferendumMappingSerializer + + +def get_or_none(key, row): + value = row.get(key, None) + # Convert falsy values to None + if not value: + value = None + + return value + + +class Parser(object): + @property + def name(self): + return self.model.__name__ + + def filter_fields(self, row): + field_names = self.get_field_names() + return dict((k, v) for k, v in row.items() if k in field_names) + + def parse(self, row): + return self.parse_fields(self.filter_fields(row)) + + def parse_fields(self, row): + """Subclasses should override this method to transform the row to appropriate types""" + + return row + + def get_field_names(self): + return [field.name for field in self.model._meta.get_fields()] + + def exists_in_db(self, row): + """Determines if this row exists in the database already.""" + + id = row.get(self.key, None) + if not id: + return False + + kwargs = {self.key: id} + exists = True + try: + self.model.objects.get(**kwargs) + except ObjectDoesNotExist: + exists = False + + return exists + + def to_serializer(self, row): + return self.__class__.serializer(data=row) + + def commit(self, data): + id = data.get(self.key) + kwargs = { + self.key: id, + "defaults": data + } + + return self.model.objects.update_or_create(**kwargs) + + +class CandidateParser(Parser): + model = Candidate + key = 'candidate' + serializer = CandidateSerializer + + def parse_party_affiliation(self, row): + party_affiliation = row.get('party_affiliation', None) + if not party_affiliation: + return 'O' + + # Take only the first character + party_affiliation = party_affiliation[0] + if party_affiliation not in [k for k, _ in Candidate.PARTY_AFFILIATION]: + return 'O' + + return party_affiliation + + def parse(self, row): + """Parses individual fields in the row that are acceptable to the model.""" + + data = self.filter_fields(row) + + # We'll handle aliases below, don't pass them to the eventual model + del data['aliases'] + + # Convert empty strings to None + fppc = get_or_none('fppc', row) + + # Convert empty strings to bool + accepted_expenditure_ceiling = bool(row.get('accepted_expenditure_ceiling', False)) + + # Party affiliation + party_affiliation = self.parse_party_affiliation(row) + + # Convert twitter @handle to URL + twitter = row.get('twitter', None) + if twitter: + # Drop the first char (@) + twitter = 'https://twitter.com/%s' % twitter[1:] + + data.update( + fppc=fppc, + accepted_expenditure_ceiling=accepted_expenditure_ceiling, + party_affiliation=party_affiliation, + twitter=twitter) + + candidate, created = self.commit(data) + + # Parse aliases + alias_parser = CandidateAliasParser() + candidate_aliases = row.get('aliases', '').split(',') + for candidate_alias in candidate_aliases: + if not candidate_alias: + continue + + alias = alias_parser.parse(dict(candidate_alias=candidate_alias, candidate=candidate)) + alias_parser.commit(alias) + + return data + + +class CandidateAliasParser(Parser): + model = CandidateAlias + key = 'candidate_alias' + + def parse(self, row): + return row + + +class CommitteeParser(Parser): + model = Committee + key = 'filer_id' + serializer = CommitteeSerializer + + def parse_fields(self, row): + twitter = row.get('twitter', None) + if twitter: + # Drop the first char (@) + twitter = 'https://twitter.com/%s' % twitter[1:] + + parsed = dict(row) + parsed.update( + twitter=twitter, + ) + + return parsed + + +class ReferendumParser(Parser): + model = Referendum + key = 'measure_number' + serializer = ReferendumSerializer + + +class ReferendumMappingParser(Parser): + model = ReferendumMapping + key = 'measure_number' + serializer = ReferendumMappingSerializer diff --git a/gsheets/serializers.py b/gsheets/serializers.py new file mode 100644 index 0000000..11562ce --- /dev/null +++ b/gsheets/serializers.py @@ -0,0 +1,31 @@ +from rest_framework import serializers +from .models import Candidate, Committee, Referendum, ReferendumMapping + + +class CandidateSerializer(serializers.ModelSerializer): + fppc = serializers.IntegerField(allow_null=True) + + class Meta: + model = Candidate + fields = '__all__' + + +class CommitteeSerializer(serializers.ModelSerializer): + + class Meta: + model = Committee + fields = '__all__' + + +class ReferendumSerializer(serializers.ModelSerializer): + + class Meta: + model = Referendum + fields = '__all__' + + +class ReferendumMappingSerializer(serializers.ModelSerializer): + + class Meta: + model = ReferendumMapping + fields = '__all__' diff --git a/gsheets/views.py b/gsheets/views.py new file mode 100644 index 0000000..f63d8c5 --- /dev/null +++ b/gsheets/views.py @@ -0,0 +1,11 @@ +from rest_framework import viewsets +from .models import Candidate +from .serializers import CandidateSerializer + + +class CandidateViewSet(viewsets.ReadOnlyModelViewSet): + """ + API endpoint that allows Candidates to be viewed. + """ + queryset = Candidate.objects.all() + serializer_class = CandidateSerializer diff --git a/manage.py b/manage.py index eca1dce..c45d528 100644 --- a/manage.py +++ b/manage.py @@ -3,7 +3,7 @@ import sys if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ballot_api.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ballot_api.settings.development") try: from django.core.management import execute_from_command_line except ImportError: diff --git a/requirements.txt b/requirements.txt index db5325b..64ffaa7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,22 @@ coreapi==2.1.1 +dj-database-url==0.4.2 Django==1.11 django-flat-theme==1.1.4 django-rest-swagger==2.1.1 djangorestframework==3.5.3 docutils==0.13.1 flake8==3.2.1 +gunicorn==19.7.1 itypes==1.1.0 mccabe==0.5.3 olefile==0.44 openapi-codec==1.2.1 pep8==1.7.0 Pillow==4.0.0 +psycopg2==2.7.1 pycodestyle==2.2.0 pyflakes==1.3.0 requests==2.13.0 simplejson==3.10.0 uritemplate==3.0.0 +whitenoise==3.3.0 diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..3a799ff --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.6.0