diff --git a/.gitignore b/.gitignore index d5bdbd2..54142d6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ .env venv-civic-json-worker venv +.venv venv-cfapi .coverage +.DS_Store diff --git a/.travis.yml b/.travis.yml index 5637163..3197cb5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,17 @@ language: python -install: pip install -r requirements.txt --use-mirrors +install: pip install -r requirements.txt +sudo: false +services: + - postgresql before_script: - psql -c 'create database civic_json_worker_test;' -U postgres - psql -c 'create database peopledbtest;' -U postgres - psql -c '\i test/peopledbtest.pgsql' -U postgres script: -- python run_update_test.py -- green test/integration -vvv --run-coverage +- green test/ -vvv --run-coverage +- flake8 . --exit-zero notifications: webhooks: http://project-monitor.codeforamerica.org/projects/72d031cc-8f21-4968-8db6-ff7370f5e98b/status slack: cfa:IjK8dNdwBJHL0Xc9FqvROliV +addons: + postgresql: "9.4" diff --git a/Procfile b/Procfile index a8aaf1f..13f6651 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: newrelic-admin run-program gunicorn -w 8 app:app +web: newrelic-admin run-program gunicorn --workers 6 app:app diff --git a/README.md b/README.md index 7137104..4de317c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Code for America has developed this API to track all the activity across the civ The tools that the Brigades and other groups use to do their fine deeds are all different. The CFAPI does the difficult job of being able to track these activities no matter what tools an organization is using. The participants don't need to change their activities to be included. ### How it works -To get the information for the CfAPI, Code for America maintains a [list of civic tech organizations](https://docs.google.com/a/codeforamerica.org/spreadsheet/ccc?key=0ArHmv-6U1drqdGNCLWV5Q0d5YmllUzE5WGlUY3hhT2c&usp=drive_web#gid=0) and once an hour checks their activity on Meetup.com, their blog, and their GitHub projects. Other services and support for noncode projects are slowly being added. More technical details [below](https://github.com/codeforamerica/cfapi#installation). +To get the information for the CfAPI, Code for America maintains a [list of civic tech organizations](https://github.com/codeforamerica/brigade-information/blob/master/organizations.json) and once an hour checks their activity on Meetup.com, their blog, and their GitHub projects. Other services and support for noncode projects are slowly being added. More technical details [below](https://github.com/codeforamerica/cfapi#installation). ### Projects powered by the CFAPI * The Code for America Brigade website @@ -58,7 +58,6 @@ Response for `http://codeforamerica.org/api/organizations/Code-for-San-Francisco { api_url: "http://codeforamerica.org/api/projects/122", categories: null, - tags: null, code_url: "https://github.com/sfbrigade/localfreeweb.org", description: "Front end for the Local Free Web project", github_details: { ... }, @@ -69,9 +68,10 @@ Response for `http://codeforamerica.org/api/organizations/Code-for-San-Francisco name: "localfreeweb.org", organization: {}, organization_name: "Code for San Francisco", - tags: "digital access", + tags: ["digital access","bus stops"], type: null, - status: "Delivered" + status: "Official" + commit_status: "success" }, ... ], @@ -105,7 +105,7 @@ Response for `http://codeforamerica.org/api/organizations/Code-for-San-Francisco ### History The need for a way to show off good civic tech projects was apparent. Several Brigades had all started working on ways to track their projects. They were working separately on the same idea at the same time. The CFAPI is a generalization of the great work done by: -Open City Open City +Open City Open City Beta NYC Beta NYC @@ -122,45 +122,7 @@ We also want to add support for many more services to be included, such as event ### How to add your Brigade to the API -##### Brigade Information -The new site will be powered by this [Brigade Information](https://docs.google.com/spreadsheet/ccc?key=0ArHmv-6U1drqdGNCLWV5Q0d5YmllUzE5WGlUY3hhT2c&usp=sharing) Google Spreadsheet. This way you don't need yet another account for our Brigade site. Just keep your Brigade's info up to date and you're good. Email andrewh@codeforamerica.org if you want permission to add and edit groups. - -The columns are: -* Name -* Website -* Events Url - Point us to where ever you schedule your events. Only Meetup.com events are working right now. -* RSS - If you have a blog, point us to it. It's pretty smart and can find the feed on its own. To show off your Google Group discussions, use a link like `https://groups.google.com/forum/feed/code-for-san-francisco/msgs/rss.xml?num=15` -* Projects list URL - Can either be a GitHub organization url like `https://github.com/sfbrigade` or a link to a list of project URLs, described below. - - -##### Projects List -This projects list you point us to will need the following columns: -* `name` - filled in by GitHub if left blank -* `description` - filled in by GitHub if left blank -* `link_url` - filled in by GitHub if left blank -* `code_url` - Only GitHub links work for now. Others will be added as needed later. -* `tags` - Catch-all project tagging, terms separated by commas. "Education, digital literacy, iOS, Kansas City" -* `status` - Whatever status names you use, e.g., "Alpha, Beta, Launched, In Progress" - -An example: -``` -name, description, link_url, code_url, type, categories, tags, status -South Bend Voices, "A redeploy of CityVoice for South Bend, IN.", http://www.southbendvoices.com/, https://github.com/codeforamerica/cityvoice,,, "community engagement, housing, mapping, ruby" "In progress" -``` - -That projects list URL can be any flavor of csv. The easiest way is to make a Google Spreadsheet like [my example](https://docs.google.com/spreadsheet/ccc?key=0ArHmv-6U1drqdDBzNXpSZkVzRDJUQnpOS0RJM0FDWGc&usp=sharing) and then select **File > Publish it to the web**. - -If you are using the new Google Spreadsheets, add `/export?format=csv` to the end. -`https://docs.google.com/spreadsheets/d//export?format=csv` - -If you have the older Google Drive version change `?output=html` to `?output=csv`. -`https://docs.google.com/spreadsheet/pub?key=?output=csv` - -Put that in the Brigade Information sheet and you're done. - -The projects list URL can also be a JSON file, with a list of strings containing GitHub project URLs. - -Lastly, the projects list URL can be a GitHub organization URL, like http://github.com/codeforamerica. +Submit a Pull Request with your brigade's information to the [Brigade Information repository](https://github.com/codeforamerica/brigade-information). Instructions are included in that repo's [README](https://github.com/codeforamerica/brigade-information/blob/master/README.md). ### Civic.json To add extra data about your projects to the CfAPI, include a `civic.json` file in the top level of your repo. @@ -285,17 +247,27 @@ python -c 'from app import db; db.create_all()' createdb civic_json_worker_test python -c 'from app import db; db.create_all()' -creatdb peopledbtest +createdb peopledbtest psql peopledbtest < test/peopledbtest.pgsql ``` -`python run_update_test.py` to test the run_update process. +`green -vvv --run-coverage` to run everything at once. + +`green test/updater -vvv` to test the run_update process. + +`green test/updater -vvv --run-coverage` to test the run_update process with coverage. `green test/integration -vvv` to test the API. `green test/integration -vvv --run-coverage` to test the API with code coverage. -`green test/ run_update_test.py -vvv --run-coverage` to run everything at once. + +### Codestyle (PEP8 and co.) + +The project ships with flake8 to track style, perform a flake8 check by calling + +`flake8 . --exclude=migrations,test --ignore=E501,E711,E712` + diff --git a/app.py b/app.py index 5c907d4..ce0cc88 100644 --- a/app.py +++ b/app.py @@ -5,29 +5,31 @@ from __future__ import division from datetime import datetime, date -import json import os import time +import re from mimetypes import guess_type from os.path import join from math import ceil -from urllib import urlencode +from urllib import urlencode, unquote_plus from flask import Flask, make_response, request, jsonify, render_template import requests from flask.ext.heroku import Heroku -from flask.ext.sqlalchemy import SQLAlchemy -from sqlalchemy.ext.mutable import Mutable -from sqlalchemy.ext.compiler import compiles -from sqlalchemy import types, desc +from raven.contrib.flask import Sentry +from sqlalchemy import desc, or_ from sqlalchemy.sql.expression import func -from sqlalchemy.orm import backref, defer -from sqlalchemy import event, DDL +from sqlalchemy.orm import defer from dictalchemy import make_class_dictable -from dateutil.tz import tzoffset -from flask.ext.script import Manager, prompt_bool +from flask.ext.script import Manager, Server, prompt_bool from flask.ext.migrate import Migrate, MigrateCommand from werkzeug.contrib.fixers import ProxyFix +from models import initialize_database, Organization, Event, Issue, Project, Story, Label, Error, Attendance +from utils import raw_name + +# import logging +# logging.basicConfig() +# logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) # ------------------- # Init @@ -35,17 +37,27 @@ app = Flask(__name__) heroku = Heroku(app) -db = SQLAlchemy(app) +db = initialize_database(app) migrate = Migrate(app, db) manager = Manager(app) manager.add_command('db', MigrateCommand) +manager.add_command('runserver', Server(use_debugger=True)) + + +# Provide SENTRY_DSN environment variable to automatically report all +# exceptions to Sentry. +if 'SENTRY_DSN' in os.environ: + sentry = Sentry(dsn=os.environ['SENTRY_DSN']) + sentry.init_app(app) + @manager.command def dropdb(): if prompt_bool("Are you sure you want to lose all your data?"): db.drop_all() + @manager.command def createdb(): db.create_all() @@ -58,6 +70,7 @@ def createdb(): # Settings # ------------------- + def add_cors_header(response): response.headers['Access-Control-Allow-Origin'] = '*' response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type' @@ -67,543 +80,19 @@ def add_cors_header(response): app.after_request(add_cors_header) -# ------------------- -# Types -# ------------------- - -class JsonType(Mutable, types.TypeDecorator): - ''' JSON wrapper type for TEXT database storage. - - References: - http://stackoverflow.com/questions/4038314/sqlalchemy-json-as-blob-text - http://docs.sqlalchemy.org/en/rel_0_9/orm/extensions/mutable.html - ''' - impl = types.Unicode - - def process_bind_param(self, value, engine): - return unicode(json.dumps(value)) - - def process_result_value(self, value, engine): - if value: - return json.loads(value) - else: - # default can also be a list - return {} - -class TSVectorType(types.TypeDecorator): - ''' TSVECTOR wrapper type for database storage. - - References: - http://stackoverflow.com/questions/13837111/tsvector-in-sqlalchemy - ''' - impl = types.UnicodeText - -@compiles(TSVectorType, 'postgresql') -def compile_tsvector(element, compiler, **kw): - return 'tsvector' - - -# ------------------- -# Models -# ------------------- - -class Organization(db.Model): - ''' - Brigades and other civic tech organizations - ''' - # Columns - name = db.Column(db.Unicode(), primary_key=True) - website = db.Column(db.Unicode()) - events_url = db.Column(db.Unicode()) - rss = db.Column(db.Unicode()) - projects_list_url = db.Column(db.Unicode()) - type = db.Column(db.Unicode()) - city = db.Column(db.Unicode()) - latitude = db.Column(db.Float()) - longitude = db.Column(db.Float()) - last_updated = db.Column(db.Integer()) - started_on = db.Column(db.Unicode()) - keep = db.Column(db.Boolean()) - tsv_body = db.Column(TSVectorType()) - id = db.Column(db.Unicode()) - - # Relationships - # can contain events, stories, projects (these relationships are defined in the child objects) - - def __init__(self, name, website=None, events_url=None, - rss=None, projects_list_url=None, type=None, city=None, latitude=None, longitude=None, last_updated=time.time()): - self.name = name - self.website = website - self.events_url = events_url - self.rss = rss - self.projects_list_url = projects_list_url - self.type = type - self.city = city - self.latitude = latitude - self.longitude = longitude - self.keep = True - self.last_updated = last_updated - self.started_on = unicode(date.today()) - self.id = safe_name(raw_name(name)) - - def current_events(self): - ''' - Return the two soonest upcoming events - ''' - filter_old = Event.start_time_notz >= datetime.utcnow() - current_events = Event.query.filter_by(organization_name=self.name)\ - .filter(filter_old).order_by(Event.start_time_notz.asc()).limit(2).all() - current_events_json = [row.asdict() for row in current_events] - return current_events_json - - def current_projects(self): - ''' - Return the three most current projects - ''' - current_projects = Project.query.filter_by(organization_name=self.name).order_by(desc(Project.last_updated)).limit(3) - current_projects_json = [project.asdict(include_issues=False) for project in current_projects] - - return current_projects_json - - def current_stories(self): - ''' - Return the two most current stories - ''' - current_stories = Story.query.filter_by(organization_name=self.name).order_by(desc(Story.id)).limit(2).all() - current_stories_json = [row.asdict() for row in current_stories] - return current_stories_json - - def all_events(self): - ''' API link to all an orgs events - ''' - # Make a nice org name - organization_name = safe_name(self.name) - return '%s://%s/api/organizations/%s/events' % (request.scheme, request.host, organization_name) - - def upcoming_events(self): - ''' API link to an orgs upcoming events - ''' - # Make a nice org name - organization_name = safe_name(self.name) - return '%s://%s/api/organizations/%s/upcoming_events' % (request.scheme, request.host, organization_name) - - def past_events(self): - ''' API link to an orgs past events - ''' - # Make a nice org name - organization_name = safe_name(self.name) - return '%s://%s/api/organizations/%s/past_events' % (request.scheme, request.host, organization_name) - - def all_projects(self): - ''' API link to all an orgs projects - ''' - # Make a nice org name - organization_name = safe_name(self.name) - return '%s://%s/api/organizations/%s/projects' % (request.scheme, request.host, organization_name) - - def all_issues(self): - '''API link to all an orgs issues - ''' - # Make a nice org name - organization_name = safe_name(self.name) - return '%s://%s/api/organizations/%s/issues' % (request.scheme, request.host, organization_name) - - def all_stories(self): - ''' API link to all an orgs stories - ''' - # Make a nice org name - organization_name = safe_name(self.name) - return '%s://%s/api/organizations/%s/stories' % (request.scheme, request.host, organization_name) - - def all_attendance(self): - ''' API link to orgs attendance ''' - organization_name = safe_name(self.name) - return '%s://%s/api/organizations/%s/attendance' % (request.scheme, request.host, organization_name) - - def api_id(self): - ''' Return organization name made safe for use in a URL. - ''' - return safe_name(self.name) - - def api_url(self): - ''' API link to itself - ''' - return '%s://%s/api/organizations/%s' % (request.scheme, request.host, self.api_id()) - - def asdict(self, include_extras=False): - ''' Return Organization as a dictionary, with some properties tweaked. - - Optionally include linked projects, events, and stories. - ''' - organization_dict = db.Model.asdict(self) - - # remove fields that don't need to be public - del organization_dict['keep'] - del organization_dict['tsv_body'] - - for key in ('all_events', 'all_projects', 'all_stories', 'all_issues', - 'upcoming_events', 'past_events', 'api_url', 'all_attendance'): - organization_dict[key] = getattr(self, key)() - - if include_extras: - for key in ('current_events', 'current_projects', 'current_stories'): - organization_dict[key] = getattr(self, key)() - - return organization_dict - - -tbl = Organization.__table__ -# Index the tsvector column -db.Index('index_org_tsv_body', tbl.c.tsv_body, postgresql_using='gin') - -# Trigger to populate the search index column -trig_ddl = DDL(""" - CREATE TRIGGER tsvupdate_orgs_trigger BEFORE INSERT OR UPDATE ON organization FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(tsv_body, 'pg_catalog.english', name); -""") -# Initialize the trigger after table is created -event.listen(tbl, 'after_create', trig_ddl.execute_if(dialect='postgresql')) - - -class Story(db.Model): - ''' - Blog posts from a Brigade. - ''' - # Columns - id = db.Column(db.Integer(), primary_key=True) - title = db.Column(db.Unicode()) - link = db.Column(db.Unicode()) - type = db.Column(db.Unicode()) - keep = db.Column(db.Boolean()) - - # Relationships - # child - organization = db.relationship('Organization', single_parent=True, cascade='all, delete-orphan', backref=backref("stories", cascade="save-update, delete")) - organization_name = db.Column(db.Unicode(), db.ForeignKey('organization.name', ondelete='CASCADE'), nullable=False) - - def __init__(self, title=None, link=None, type=None, organization_name=None): - self.title = title - self.link = link - self.type = type - self.organization_name = organization_name - self.keep = True - - def api_url(self): - ''' API link to itself - ''' - return '%s://%s/api/stories/%s' % (request.scheme, request.host, str(self.id)) - - def asdict(self, include_organization=False): - ''' Return Story as a dictionary, with some properties tweaked. - - Optionally include linked organization. - ''' - story_dict = db.Model.asdict(self) - - # remove fields that don't need to be public - del story_dict['keep'] - - story_dict['api_url'] = self.api_url() - - if include_organization: - story_dict['organization'] = self.organization.asdict() - - return story_dict - -class Project(db.Model): - ''' - Civic tech projects on GitHub - ''' - # Columns - id = db.Column(db.Integer(), primary_key=True) - name = db.Column(db.Unicode()) - code_url = db.Column(db.Unicode()) - link_url = db.Column(db.Unicode()) - description = db.Column(db.Unicode()) - type = db.Column(db.Unicode()) - categories = db.Column(db.Unicode()) - tags = db.Column(db.Unicode()) - github_details = db.Column(JsonType()) - last_updated = db.Column(db.DateTime()) - last_updated_issues = db.Column(db.Unicode()) - last_updated_civic_json = db.Column(db.Unicode()) - last_updated_root_files = db.Column(db.Unicode()) - keep = db.Column(db.Boolean()) - tsv_body = db.Column(TSVectorType()) - status = db.Column(db.Unicode()) - - # Relationships - # child - organization = db.relationship('Organization', single_parent=True, cascade='all, delete-orphan', backref=backref("projects", cascade="save-update, delete")) - organization_name = db.Column(db.Unicode(), db.ForeignKey('organization.name', ondelete='CASCADE'), nullable=False) - - # can contain issues (this relationship is defined in the child object) - - def __init__(self, name, code_url=None, link_url=None, - description=None, type=None, categories=None, tags=None, - github_details=None, last_updated=None, last_updated_issues=None, - last_updated_civic_json=None, last_updated_root_files=None, organization_name=None, - keep=None, status=None): - self.name = name - self.code_url = code_url - self.link_url = link_url - self.description = description - self.type = type - self.categories = categories - self.tags = tags - self.github_details = github_details - self.last_updated = last_updated - self.last_updated_issues = last_updated_issues - self.last_updated_civic_json = last_updated_civic_json - self.last_updated_root_files = last_updated_root_files - self.organization_name = organization_name - self.keep = True - self.status = status - - def api_url(self): - ''' API link to itself - ''' - return '%s://%s/api/projects/%s' % (request.scheme, request.host, str(self.id)) - - def asdict(self, include_organization=False, include_issues=True): - ''' Return Project as a dictionary, with some properties tweaked. - - Optionally include linked organization. - ''' - project_dict = db.Model.asdict(self) - - # remove fields that don't need to be public - del project_dict['keep'] - del project_dict['tsv_body'] - del project_dict['last_updated_issues'] - del project_dict['last_updated_civic_json'] - del project_dict['last_updated_root_files'] - - project_dict['api_url'] = self.api_url() - - if include_organization: - project_dict['organization'] = self.organization.asdict() - - if include_issues: - project_dict['issues'] = [o.asdict() for o in db.session.query(Issue).filter(Issue.project_id == project_dict['id']).all()] - - return project_dict - -tbl = Project.__table__ -# Index the tsvector column -db.Index('index_project_tsv_body', tbl.c.tsv_body, postgresql_using='gin') - -# Trigger to populate the search index column -trig_ddl = DDL(""" - CREATE TRIGGER tsvupdate_projects_trigger BEFORE INSERT OR UPDATE ON project FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(tsv_body, 'pg_catalog.english', name, description, type, categories, tags, github_details, status); -""") -# Initialize the trigger after table is created -event.listen(tbl, 'after_create', trig_ddl.execute_if(dialect='postgresql')) - - -class Issue(db.Model): - ''' - Issues of Civic Tech Projects on Github - ''' - # Columns - id = db.Column(db.Integer(), primary_key=True) - title = db.Column(db.Unicode()) - html_url = db.Column(db.Unicode()) - body = db.Column(db.Unicode()) - keep = db.Column(db.Boolean()) - - # Relationships - # child - project = db.relationship('Project', single_parent=True, cascade='all, delete-orphan', backref=backref("issues", cascade="save-update, delete")) - project_id = db.Column(db.Integer(), db.ForeignKey('project.id', ondelete='CASCADE'), nullable=False, index=True) - - # can contain labels (this relationship is defined in the child object) - - def __init__(self, title, project_id=None, html_url=None, labels=None, body=None): - self.title = title - self.html_url = html_url - self.body = body - self.project_id = project_id - self.keep = True - - def api_url(self): - ''' API link to itself - ''' - return '%s://%s/api/issues/%s' % (request.scheme, request.host, str(self.id)) - - def asdict(self, include_project=False): - ''' - Return issue as a dictionary with some properties tweaked - ''' - issue_dict = db.Model.asdict(self) - - # TODO: Also paged_results assumes asdict takes this argument, should be checked and fixed later - if include_project: - issue_dict['project'] = db.session.query(Project).filter(Project.id == self.project_id).first().asdict() - del issue_dict['project']['issues'] - del issue_dict['project_id'] - - # remove fields that don't need to be public - del issue_dict['keep'] - - issue_dict['api_url'] = self.api_url() - issue_dict['labels'] = [l.asdict() for l in self.labels] - - return issue_dict - -class Label(db.Model): - ''' - Issue labels for projects on Github - ''' - # Columns - id = db.Column(db.Integer(), primary_key=True) - name = db.Column(db.Unicode()) - color = db.Column(db.Unicode()) - url = db.Column(db.Unicode()) - - # Relationships - # child - issue = db.relationship('Issue', single_parent=True, cascade='all, delete-orphan', backref=backref("labels", cascade="save-update, delete")) - issue_id = db.Column(db.Integer, db.ForeignKey('issue.id', ondelete='CASCADE'), nullable=False, index=True) - - def __init__(self, name, color, url, issue_id=None): - self.name = name - self.color = color - self.url = url - self.issue_id = issue_id - - def asdict(self): - ''' - Return label as a dictionary with some properties tweaked - ''' - label_dict = db.Model.asdict(self) - - # remove fields that don't need to be public - del label_dict['id'] - del label_dict['issue_id'] - - return label_dict - -class Event(db.Model): - ''' - Organizations events from Meetup - ''' - # Columns - id = db.Column(db.Integer(), primary_key=True) - name = db.Column(db.Unicode()) - description = db.Column(db.Unicode()) - event_url = db.Column(db.Unicode()) - location = db.Column(db.Unicode()) - created_at = db.Column(db.Unicode()) - start_time_notz = db.Column(db.DateTime(False)) - end_time_notz = db.Column(db.DateTime(False)) - utc_offset = db.Column(db.Integer()) - keep = db.Column(db.Boolean()) - - # Relationships - # child - organization = db.relationship('Organization', single_parent=True, cascade='all, delete-orphan', backref=backref("events", cascade="save-update, delete")) - organization_name = db.Column(db.Unicode(), db.ForeignKey('organization.name', ondelete='CASCADE'), nullable=False) - - def __init__(self, name, event_url, start_time_notz, created_at, utc_offset, - organization_name, location=None, end_time_notz=None, description=None): - self.name = name - self.description = description - self.location = location - self.event_url = event_url - self.start_time_notz = start_time_notz - self.utc_offset = utc_offset - self.end_time_notz = end_time_notz - self.organization_name = organization_name - self.created_at = created_at - self.keep = True - - def start_time(self): - ''' Get a string representation of the start time with UTC offset. - ''' - if self.start_time_notz is None: - return None - tz = tzoffset(None, self.utc_offset) - st = self.start_time_notz - dt = datetime(st.year, st.month, st.day, st.hour, st.minute, st.second, tzinfo=tz) - return dt.strftime('%Y-%m-%d %H:%M:%S %z') - - def end_time(self): - ''' Get a string representation of the end time with UTC offset. - ''' - if self.end_time_notz is None: - return None - tz = tzoffset(None, self.utc_offset) - et = self.end_time_notz - dt = datetime(et.year, et.month, et.day, et.hour, et.minute, et.second, tzinfo=tz) - return dt.strftime('%Y-%m-%d %H:%M:%S %z') - - def api_url(self): - ''' API link to itself - ''' - return '%s://%s/api/events/%s' % (request.scheme, request.host, str(self.id)) - - def asdict(self, include_organization=False): - ''' Return Event as a dictionary, with some properties tweaked. - - Optionally include linked organization. - ''' - event_dict = db.Model.asdict(self) - - # remove fields that don't need to be public - for key in ('keep', 'start_time_notz', 'end_time_notz', 'utc_offset'): - del event_dict[key] - - for key in ('start_time', 'end_time', 'api_url'): - event_dict[key] = getattr(self, key)() - - if include_organization: - event_dict['organization'] = self.organization.asdict() - - return event_dict - - -class Attendance(db.Model): - ''' Attendance at organization events - sourced from the peopledb - ''' - # Columns - organization_url = db.Column(db.Unicode(), primary_key=True) - total = db.Column(db.Integer()) - weekly = db.Column(JsonType()) - - # Relationship - organization = db.relationship('Organization', single_parent=True, cascade='all, delete-orphan', backref=backref("attendance", cascade="save-update, delete")) - organization_name = db.Column(db.Unicode(), db.ForeignKey('organization.name', ondelete='CASCADE'), nullable=False) - - def __init__(self, organization_url, organization_name, total, weekly): - self.organization_url = organization_url - self.organization_name = organization_name - self.total = total - self.weekly = weekly - -class Error(db.Model): - ''' - Errors from run_update.py - ''' - # Columns - id = db.Column(db.Integer(), primary_key=True) - error = db.Column(db.Unicode()) - time = db.Column(db.DateTime(False)) - # ------------------- # API # ------------------- -def page_info(query, page, limit): - ''' Return last page and offset for a query. +def page_info(total, page, limit): + ''' Return last page and offset for a query total. ''' - # Get a bunch of projects. - total = query.count() last = int(ceil(total / limit)) offset = (page - 1) * limit return last, offset + def pages_dict(page, last, querystring): ''' Return a dictionary of pages to return in API responses. ''' @@ -636,59 +125,77 @@ def pages_dict(page, last, querystring): return pages -def paged_results(query, page, per_page, querystring=''): - ''' + +def paged_results(query, include_args=None, page=1, per_page=10, querystring=''): + ''' Return a dict representing one page-worth of results ''' - total = query.count() - last, offset = page_info(query, page, per_page) - if(querystring.find("only_ids") != -1): - model_dicts = [o.id for o in query.limit(per_page).offset(offset)] + items = [item for item in query] + total = len(items) + + last, offset = page_info(total, page, per_page) + page_of_items = items[offset:offset + per_page] + if 'only_ids' in querystring: + model_dicts = [o.id for o in page_of_items] else: model_dicts = [] - for o in query.limit(per_page).offset(offset): - obj = o.asdict(True) + for o in page_of_items: + obj = o.asdict(**include_args) if include_args else o.asdict() model_dicts.append(obj) return dict(total=total, pages=pages_dict(page, last, querystring), objects=model_dicts) -def is_safe_name(name): - ''' Return True if the string is a safe name. - ''' - return raw_name(safe_name(name)) == name - -def safe_name(name): - ''' Return URL-safe organization name with spaces replaced by dashes. - - Slashes will be removed, which is incompatible with raw_name(). - ''' - return name.replace(' ', '-').replace('/', '-').replace('?', '-').replace('#', '-') - -def raw_name(name): - ''' Return raw organization name with dashes replaced by spaces. - - Also replace old-style underscores with spaces. - ''' - return name.replace('_', ' ').replace('-', ' ') def get_query_params(args): filters = {} for key, value in args.iteritems(): if 'page' not in key: - filters[key] = value + filters[key] = unquote_plus(value).encode('utf8') return filters, urlencode(filters) + +def format_ilike_term(term): + ''' Format the passed term for use in an ilike query. + ''' + # strip pattern-matching metacharacters from the term + stripped_term = re.sub(ur'\||_|%|\*|\+|\?|\{|\}|\(|\)|\[|\]', '', term) + return u'%{}%'.format(stripped_term) + + +def build_rsvps_response(events): + ''' Arrange and organize rsvps from a list of event objects ''' + rsvps = { + "total": 0, + "weekly": {} + } + for fetched_event in events: + event_dict = fetched_event.asdict() + if event_dict["rsvps"]: + # 2014-04-30 18:30:00 -0700 + # Just compare dates + event_date = event_dict["start_time"][:10] + event_date = datetime.strptime(event_date, "%Y-%m-%d") + if datetime.today() > event_date: + week = datetime.strftime(event_date, "%Y %W") + rsvps["total"] += event_dict["rsvps"] + if rsvps["weekly"].get(week): + rsvps["weekly"][week] += event_dict["rsvps"] + else: + rsvps["weekly"][week] = event_dict["rsvps"] + + return rsvps + + @app.route('/api/organizations') @app.route('/api/organizations/') def get_organizations(name=None): ''' Regular response option for organizations. ''' - filters = request.args filters, querystring = get_query_params(request.args) if name: # Get one named organization. - filter = Organization.name == raw_name(name) - org = db.session.query(Organization).filter(filter).first() + org_filter = Organization.name == raw_name(name) + org = db.session.query(Organization).filter(org_filter).first() if org: return jsonify(org.asdict(True)) else: @@ -700,18 +207,24 @@ def get_organizations(name=None): # Default ordering of results ordering = desc(Organization.last_updated) + if 'tags[]' in filters: + tags = request.args.getlist('tags[]') + query = query.filter('organization.tags ?& :tags').params(tags=tags) + del filters['tags[]'] + for attr, value in filters.iteritems(): if 'q' in attr: - query = query.filter("organization.tsv_body @@ plainto_tsquery('%s')" % value) - ordering = desc(func.ts_rank(Organization.tsv_body, func.plainto_tsquery('%s' % value))) + query = query.filter('organization.tsv_body @@ plainto_tsquery(:search_query)').params(search_query=value) + ordering = desc(func.ts_rank(Organization.tsv_body, func.plainto_tsquery(value))) else: - query = query.filter(getattr(Organization, attr).ilike('%%%s%%' % value)) + query = query.filter(getattr(Organization, attr).ilike(format_ilike_term(value))) query = query.order_by(ordering) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 10)), querystring) + response = paged_results(query=query, include_args=dict(include_extras=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 10)), querystring=querystring) return jsonify(response) + @app.route('/api/organizations.geojson') def get_organizations_geojson(): ''' GeoJSON response option for organizations. @@ -719,20 +232,25 @@ def get_organizations_geojson(): geojson = dict(type='FeatureCollection', features=[]) for org in db.session.query(Organization): - # The unique identifier of an organization. - id = org.api_id() - # Pick out all the properties that aren't part of the location. - props = org.asdict() + # geojson should only return orgs with location data + if org.latitude and org.longitude: + + # The unique identifier of an organization. + id = org.api_id() - # GeoJSON Point geometry, http://geojson.org/geojson-spec.html#point - geom = dict(type='Point', coordinates=[org.longitude, org.latitude]) + # Pick out all the properties that aren't part of the location. + props = org.asdict() - feature = dict(type='Feature', id=id, properties=props, geometry=geom) - geojson['features'].append(feature) + # GeoJSON Point geometry, http://geojson.org/geojson-spec.html#point + geom = dict(type='Point', coordinates=[org.longitude, org.latitude]) + + feature = dict(type='Feature', id=id, properties=props, geometry=geom) + geojson['features'].append(feature) return jsonify(geojson) + @app.route("/api/organizations//events") def get_orgs_events(organization_name): ''' @@ -746,9 +264,10 @@ def get_orgs_events(organization_name): # Get event objects query = Event.query.filter_by(organization_name=organization.name) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 25))) + response = paged_results(query=query, include_args=dict(include_organization=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 25))) return jsonify(response) + @app.route("/api/organizations//upcoming_events") def get_upcoming_events(organization_name): ''' @@ -759,10 +278,11 @@ def get_upcoming_events(organization_name): if not organization: return "Organization not found", 404 # Get upcoming event objects - query = Event.query.filter(Event.organization_name == organization.name, Event.start_time_notz >= datetime.utcnow()) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 25))) + query = Event.query.filter(Event.organization_name == organization.name, Event.end_time_notz >= datetime.utcnow()) + response = paged_results(query=query, include_args=dict(include_organization=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 25))) return jsonify(response) + @app.route("/api/organizations//past_events") def get_past_events(organization_name): ''' @@ -773,11 +293,25 @@ def get_past_events(organization_name): if not organization: return "Organization not found", 404 # Get past event objects - query = Event.query.filter(Event.organization_name == organization.name, Event.start_time_notz < datetime.utcnow()).\ - order_by(desc(Event.start_time_notz)) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 25))) + query = Event.query.filter(Event.organization_name == organization.name, Event.end_time_notz < datetime.utcnow()).\ + order_by(desc(Event.end_time_notz)) + response = paged_results(query=query, include_args=dict(include_organization=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 25))) return jsonify(response) + +@app.route("/api/organizations//events/rsvps") +def gather_orgs_rsvps(organization_name=None): + ''' Orgs rsvps summarized ''' + # Check org name + organization = Organization.query.filter_by(name=raw_name(organization_name)).first() + if not organization: + return "Organization not found", 404 + orgs_events = Event.query.filter(Event.organization_name == organization.name).all() + rsvps = build_rsvps_response(orgs_events) + + return jsonify(rsvps) + + @app.route("/api/organizations//stories") def get_orgs_stories(organization_name): ''' @@ -790,9 +324,10 @@ def get_orgs_stories(organization_name): # Get story objects query = Story.query.filter_by(organization_name=organization.name).order_by(desc(Story.id)) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 25))) + response = paged_results(query=query, include_args=dict(include_organization=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 25))) return jsonify(response) + @app.route("/api/organizations//projects") def get_orgs_projects(organization_name): ''' @@ -820,44 +355,44 @@ def get_orgs_projects(organization_name): if 'q' in attr: # Returns all results if the value is empty if value: - query = query.filter("project.tsv_body @@ plainto_tsquery('%s')" % value) - relevance_ordering_filter = func.ts_rank(Project.tsv_body, func.plainto_tsquery('%s' % value)) + query = query.filter('project.tsv_body @@ plainto_tsquery(:search_query)').params(search_query=value) + relevance_ordering_filter = func.ts_rank(Project.tsv_body, func.plainto_tsquery(value)) ordering_filter_name = 'relevance' elif 'only_ids' in attr: query = query.with_entities(Project.id) elif 'sort_by' in attr: - if(value == 'relevance'): + if value == 'relevance': ordering_filter_name = 'relevance' else: ordering_filter_name = 'last_updated' elif 'sort_dir' in attr: - if(value == 'asc'): + if value == 'asc': ordering_dir = 'asc' else: ordering_dir = 'desc' else: - query = query.filter(getattr(Project, attr).ilike('%%%s%%' % value)) + query = query.filter(getattr(Project, attr).ilike(format_ilike_term(value))) - if(ordering_filter_name == 'last_updated'): + if ordering_filter_name == 'last_updated': ordering_filter = last_updated_ordering_filter - elif(ordering_filter_name == 'relevance' and dir(relevance_ordering_filter) != dir(None)): + elif ordering_filter_name == 'relevance' and dir(relevance_ordering_filter) != dir(None): ordering_filter = relevance_ordering_filter - if(ordering_dir == 'desc'): + if ordering_dir == 'desc': ordering = ordering_filter.desc() else: ordering = ordering_filter.asc() query = query.order_by(ordering) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 10)), querystring) + response = paged_results(query=query, include_args=dict(include_organization=True, include_issues=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 10)), querystring=querystring) return jsonify(response) + @app.route("/api/organizations//issues") @app.route("/api/organizations//issues/labels/") def get_orgs_issues(organization_name, labels=None): ''' A clean url to get an organizations issues ''' - # Get one named organization. organization = Organization.query.filter_by(name=raw_name(organization_name)).first() if not organization: @@ -875,7 +410,7 @@ def get_orgs_issues(organization_name, labels=None): labels = [label.strip() for label in labels.split(',')] # Create the filter for each label - labels = [Label.name.ilike('%%%s%%' % label) for label in labels] + labels = [Label.name.ilike(format_ilike_term(label)) for label in labels] # Create the base query object by joining on Issue.labels query = query.join(Issue.labels) @@ -890,7 +425,8 @@ def get_orgs_issues(organization_name, labels=None): # Get all issues belonging to these projects query = Issue.query.filter(Issue.project_id.in_(project_ids)).order_by(func.random()) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 10))) + filters, querystring = get_query_params(request.args) + response = paged_results(query=query, include_args=dict(include_project=True, include_labels=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 10)), querystring=querystring) return jsonify(response) @@ -903,25 +439,29 @@ def get_orgs_attendance(organization_name): if not organization: return "Organization not found", 404 + attendance_response = { + "organization_name": organization.name, + "cfapi_url": organization.api_url(), + "total": 0, + "weekly": {} + } + # Get that organization's attendance attendance = Attendance.query.filter_by(organization_name=organization.name).first() - weekly = {} - for week in attendance.weekly.keys(): - if week in weekly.keys(): - weekly[week] += attendance.weekly[week] - else: - weekly[week] = attendance.weekly[week] - attendance.weekly = weekly + if attendance: + weekly = {} + for week in attendance.weekly.keys(): + if week in weekly.keys(): + weekly[week] += attendance.weekly[week] + else: + weekly[week] = attendance.weekly[week] + attendance.weekly = weekly - attendance_response = { - "organization_name" : attendance.organization_name, - "cfapi_url" : attendance.organization_url, - "total" : attendance.total, - "weekly" : attendance.weekly - } + attendance_response['total'] = attendance.total + attendance_response['weekly'] = attendance.weekly - return json.dumps(attendance_response) + return jsonify(attendance_response) def find(lst, key, value): @@ -945,14 +485,14 @@ def get_all_orgs_attendance(): else: weekly[week] = org_attendance.weekly[week] attendance_response = { - "organization_name" : org_attendance.organization_name, - "cfapi_url" : org_attendance.organization_url, - "total" : org_attendance.total, - "weekly" : weekly + "organization_name": org_attendance.organization_name, + "cfapi_url": org_attendance.organization_url, + "total": org_attendance.total, + "weekly": weekly } response.append(attendance_response) - return json.dumps(response) + return jsonify(dict(organizations=response, total=len(response))) @app.route("/api/attendance") @@ -970,8 +510,39 @@ def get_all_attendance(): weekly[week] = org_attendance.weekly[week] response = { - "total" : total, - "weekly" : weekly + "total": total, + "weekly": weekly + } + + return jsonify(response) + + +@app.route("/api/member_count") +def all_member_count(): + ''' The total Meetup.com member count ''' + member_count = 0 + orgs = Organization.query.all() + for org in orgs: + if org.member_count: + member_count += org.member_count + + return jsonify({"total": member_count}) + + +@app.route("/api/organizations/member_count") +def orgs_member_count(): + ''' The Meetup.com member count for each group ''' + total_member_count = 0 + orgs_members = {} + orgs = Organization.query.all() + for org in orgs: + if org.member_count: + total_member_count += org.member_count + orgs_members[org.id] = org.member_count + + response = { + "total": total_member_count, + "organizations": orgs_members } return jsonify(response) @@ -982,9 +553,7 @@ def get_all_attendance(): def get_projects(id=None): ''' Regular response option for projects. ''' - filters, querystring = get_query_params(request.args) - if id: # Get one named project. filter = Project.id == id @@ -1003,54 +572,66 @@ def get_projects(id=None): ordering_filter_name = 'last_updated' ordering_filter = last_updated_ordering_filter ordering_dir = 'desc' + include_issues = False ordering = None for attr, value in filters.iteritems(): if 'organization' in attr: org_attr = attr.split('_')[1] - query = query.join(Project.organization).filter(getattr(Organization, org_attr).ilike('%%%s%%' % value)) + # Support searching for multiple org_types + if "," in value: + values = [unicode(item) for item in value.split(",")] + # build a list of ilike queries to match on + ilike_values = [getattr(Organization, org_attr).ilike(format_ilike_term(value)) for value in values] + query = query.join(Project.organization).filter(or_(*ilike_values)) + else: + query = query.join(Project.organization).filter(getattr(Organization, org_attr).ilike(format_ilike_term(value))) elif 'q' in attr: # Returns all results if the value is empty if value: - query = query.filter("project.tsv_body @@ plainto_tsquery('%s')" % value) - relevance_ordering_filter = func.ts_rank(Project.tsv_body, func.plainto_tsquery('%s' % value)) + query = query.filter('project.tsv_body @@ plainto_tsquery(:search_query)').params(search_query=value) + relevance_ordering_filter = func.ts_rank(Project.tsv_body, func.plainto_tsquery(value)) ordering_filter_name = 'relevance' elif 'only_ids' in attr: query = query.with_entities(Project.id) elif 'sort_by' in attr: - if(value == 'relevance'): + if value == 'relevance': ordering_filter_name = 'relevance' else: ordering_filter_name = 'last_updated' elif 'sort_dir' in attr: - if(value == 'asc'): + if value == 'asc': ordering_dir = 'asc' else: ordering_dir = 'desc' + elif 'include_issues' in attr: + if value in ['True','true','t']: + include_issues = True + else: - query = query.filter(getattr(Project, attr).ilike('%%%s%%' % value)) + query = query.filter(getattr(Project, attr).ilike(format_ilike_term(value))) - if(ordering_filter_name == 'last_updated'): + if ordering_filter_name == 'last_updated': ordering_filter = last_updated_ordering_filter - elif(ordering_filter_name == 'relevance' and dir(relevance_ordering_filter) != dir(None)): + elif ordering_filter_name == 'relevance' and dir(relevance_ordering_filter) != dir(None): ordering_filter = relevance_ordering_filter - if(ordering_dir == 'desc'): + if ordering_dir == 'desc': ordering = ordering_filter.desc() else: ordering = ordering_filter.asc() query = query.order_by(ordering) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 10)), querystring) + response = paged_results(query=query, include_args=dict(include_organization=True, include_issues=include_issues), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 10)), querystring=querystring) return jsonify(response) + @app.route('/api/issues') @app.route('/api/issues/') -def get_issues(id=None): +@app.route('/api/projects//issues') +def get_issues(id=None, projectid=None): '''Regular response option for issues. ''' - - filters = request.args filters, querystring = get_query_params(request.args) if id: @@ -1063,49 +644,62 @@ def get_issues(id=None): # If no issue found return jsonify({"status": "Resource Not Found"}), 404 + # Get one project's issues + if projectid: + filter = Project.id == projectid + project = db.session.query(Project).filter(filter).first() + print project + filter = Issue.project_id == project.id + single_project_issues_query = db.session.query(Issue).filter(filter) + response = paged_results(query=single_project_issues_query, include_args=dict(include_project=False, include_labels=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 10)), querystring=querystring) + return jsonify(response) + # Get a bunch of issues query = db.session.query(Issue).order_by(func.random()) for attr, value in filters.iteritems(): if 'project' in attr: proj_attr = attr.split('_')[1] - query = query.join(Issue.project).filter(getattr(Project, proj_attr).ilike('%%%s%%' % value)) + query = query.join(Issue.project).filter(getattr(Project, proj_attr).ilike(format_ilike_term(value))) elif 'organization' in attr: org_attr = attr.split('_')[1] - query = query.join(Issue.project).join(Project.organization).filter(getattr(Organization, org_attr).ilike('%%%s%%' % value)) + query = query.join(Issue.project).join(Project.organization).filter(getattr(Organization, org_attr).ilike(format_ilike_term(value))) else: - query = query.filter(getattr(Issue, attr).ilike('%%%s%%' % value)) + query = query.filter(getattr(Issue, attr).ilike(format_ilike_term(value))) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 10)), querystring) + response = paged_results(query=query, include_args=dict(include_project=True, include_labels=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 10)), querystring=querystring) return jsonify(response) + @app.route('/api/issues/labels/') def get_issues_by_labels(labels): ''' A clean url to filter issues by a comma-separated list of labels ''' - # Create a labels list by comma separating the argument labels = [label.strip() for label in labels.split(',')] # Create the filter for each label - labels = [Label.name.ilike('%%%s%%' % label) for label in labels] + labels = [Label.name.ilike(format_ilike_term(label)) for label in labels] # Create the base query object by joining on Issue.labels base_query = db.session.query(Issue).join(Issue.labels) # Check for parameters - filters = request.args filters, querystring = get_query_params(request.args) for attr, value in filters.iteritems(): if 'project' in attr: proj_attr = attr.split('_')[1] - base_query = base_query.join(Issue.project).filter(getattr(Project, proj_attr).ilike('%%%s%%' % value)) + base_query = base_query.join(Issue.project).filter(getattr(Project, proj_attr).ilike(format_ilike_term(value))) elif 'organization' in attr: org_attr = attr.split('_')[1] - base_query = base_query.join(Issue.project).join(Project.organization).filter(getattr(Organization, org_attr).ilike('%%%s%%' % value)) + base_query = base_query.join(Issue.project).join(Project.organization).filter(getattr(Organization, org_attr).ilike(format_ilike_term(value))) else: - base_query = base_query.filter(getattr(Issue, attr).ilike('%%%s%%' % value)) + try: + filter_attr = getattr(Issue, attr) + base_query = base_query.filter(filter_attr.ilike(format_ilike_term(value))) + except AttributeError: + pass # Filter for issues with each individual label label_queries = [base_query.filter(L) for L in labels] @@ -1114,16 +708,16 @@ def get_issues_by_labels(labels): query = base_query.intersect(*label_queries).order_by(func.random()) # Return the paginated reponse - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 10))) + filters, querystring = get_query_params(request.args) + response = paged_results(query=query, include_args=dict(include_project=True, include_labels=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 10)), querystring=querystring) return jsonify(response) + @app.route('/api/events') @app.route('/api/events/') def get_events(id=None): ''' Regular response option for events. ''' - - filters = request.args filters, querystring = get_query_params(request.args) if id: @@ -1142,31 +736,31 @@ def get_events(id=None): for attr, value in filters.iteritems(): if 'organization' in attr: org_attr = attr.split('_')[1] - query = query.join(Event.organization).filter(getattr(Organization, org_attr).ilike('%%%s%%' % value)) + query = query.join(Event.organization).filter(getattr(Organization, org_attr).ilike(format_ilike_term(value))) else: - query = query.filter(getattr(Event, attr).ilike('%%%s%%' % value)) + query = query.filter(getattr(Event, attr).ilike(format_ilike_term(value))) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 25)), querystring) + response = paged_results(query=query, include_args=dict(include_organization=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 25)), querystring=querystring) return jsonify(response) + @app.route('/api/events/upcoming_events') def get_all_upcoming_events(): ''' Show all upcoming events. Return them in chronological order. ''' - filters = request.args filters, querystring = get_query_params(request.args) - query = db.session.query(Event).filter(Event.start_time_notz >= datetime.utcnow()).order_by(Event.start_time_notz) + query = db.session.query(Event).filter(Event.end_time_notz >= datetime.utcnow()).order_by(Event.end_time_notz) for attr, value in filters.iteritems(): if 'organization' in attr: org_attr = attr.split('_')[1] - query = query.join(Event.organization).filter(getattr(Organization, org_attr).ilike('%%%s%%' % value)) + query = query.join(Event.organization).filter(getattr(Organization, org_attr).ilike(format_ilike_term(value))) else: - query = query.filter(getattr(Event, attr).ilike('%%%s%%' % value)) + query = query.filter(getattr(Event, attr).ilike(format_ilike_term(value))) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 25))) + response = paged_results(query=query, include_args=dict(include_organization=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 25))) return jsonify(response) @@ -1175,28 +769,36 @@ def get_all_past_events(): ''' Show all past events. Return them in reverse chronological order. ''' - filters = request.args filters, querystring = get_query_params(request.args) - query = db.session.query(Event).filter(Event.start_time_notz <= datetime.utcnow()).order_by(desc(Event.start_time_notz)) + query = db.session.query(Event).filter(Event.end_time_notz <= datetime.utcnow()).order_by(desc(Event.end_time_notz)) for attr, value in filters.iteritems(): if 'organization' in attr: org_attr = attr.split('_')[1] - query = query.join(Event.organization).filter(getattr(Organization, org_attr).ilike('%%%s%%' % value)) + query = query.join(Event.organization).filter(getattr(Organization, org_attr).ilike(format_ilike_term(value))) else: - query = query.filter(getattr(Event, attr).ilike('%%%s%%' % value)) + query = query.filter(getattr(Event, attr).ilike(format_ilike_term(value))) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 25))) + response = paged_results(query=query, include_args=dict(include_organization=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 25))) return jsonify(response) + +@app.route("/api/events/rsvps") +def gather_all_rsvps(): + ''' All rsvps summarized ''' + events = Event.query.all() + rsvps = build_rsvps_response(events) + + return jsonify(rsvps) + + @app.route('/api/stories') @app.route('/api/stories/') def get_stories(id=None): ''' Regular response option for stories. ''' - filters = request.args filters, querystring = get_query_params(request.args) if id: @@ -1215,43 +817,42 @@ def get_stories(id=None): for attr, value in filters.iteritems(): if 'organization' in attr: org_attr = attr.split('_')[1] - query = query.join(Story.organization).filter(getattr(Organization, org_attr).ilike('%%%s%%' % value)) + query = query.join(Story.organization).filter(getattr(Organization, org_attr).ilike(format_ilike_term(value))) else: - query = query.filter(getattr(Story, attr).ilike('%%%s%%' % value)) + query = query.filter(getattr(Story, attr).ilike(format_ilike_term(value))) - response = paged_results(query, int(request.args.get('page', 1)), int(request.args.get('per_page', 25)), querystring) + response = paged_results(query=query, include_args=dict(include_organization=True), page=int(request.args.get('page', 1)), per_page=int(request.args.get('per_page', 25)), querystring=querystring) return jsonify(response) # ------------------- # Routes # ------------------- + @app.route('/api/.well-known/status') def well_known_status(): ''' Return status information for Engine Light. http://engine-light.codeforamerica.org ''' + GITHUB_AUTH = None if 'GITHUB_TOKEN' in os.environ: - github_auth = (os.environ['GITHUB_TOKEN'], '') - else: - github_auth = None + GITHUB_AUTH = (os.environ['GITHUB_TOKEN'], '') + MEETUP_KEY = None if 'MEETUP_KEY' in os.environ: - meetup_key = os.environ['MEETUP_KEY'] - else: - meetup_key = None + MEETUP_KEY = os.environ['MEETUP_KEY'] try: org = db.session.query(Organization).order_by(Organization.last_updated).limit(1).first() project = db.session.query(Project).limit(1).first() - rate_limit = requests.get('https://api.github.com/rate_limit', auth=github_auth) + rate_limit = requests.get('https://api.github.com/rate_limit', auth=GITHUB_AUTH) remaining_github = rate_limit.json()['resources']['core']['remaining'] recent_error = db.session.query(Error).order_by(desc(Error.time)).limit(1).first() meetup_status = "No Meetup key set" - if meetup_key: - meetup_url = 'https://api.meetup.com/status?format=json&key=' + meetup_key + if MEETUP_KEY: + meetup_url = 'https://api.meetup.com/status?format=json&key=' + MEETUP_KEY meetup_status = requests.get(meetup_url).json().get('status') time_since_updated = time.time() - getattr(org, 'last_updated', -1) @@ -1266,7 +867,8 @@ def well_known_status(): if recent_error.time.date() == date.today(): status = recent_error.error else: - status = 'ok' # is this really okay? + # is this really okay? + status = 'ok' elif time_since_updated > 16 * 60 * 60: status = 'Oldest organization (%s) updated more than 16 hours ago' % org.name @@ -1288,12 +890,14 @@ def well_known_status(): return jsonify(state) + @app.route("/") def index(): response = make_response('Look in /api', 302) response.headers['Location'] = '/api' return response + @app.route("/api") @app.route("/api/") def api_index(): @@ -1311,6 +915,7 @@ def api_index(): return render_template('index.html', api_base='%s://%s' % (request.scheme, request.host)) + @app.route("/api/static/") def api_static_file(path): local_path = join('static', path) @@ -1319,10 +924,12 @@ def api_static_file(path): response.headers['Content-Type'] = mime_type return response + @app.errorhandler(404) def page_not_found(error): return jsonify({"status": "Resource Not Found"}), 404 + @app.errorhandler(500) def internal_error(error): return jsonify({"status": "Resource Not Found"}), 500 diff --git a/feeds.py b/feeds.py index 0ede03c..c6166f5 100644 --- a/feeds.py +++ b/feeds.py @@ -47,7 +47,6 @@ def extract_feed_links(html, feed_links_attributes=FEED_LINKS_ATTRIBUTES): """ soup = BeautifulSoup(html) head = soup.find('head') - links = [] for attrs in feed_links_attributes: if head: for link in head.findAll('link', dict(attrs)): @@ -87,7 +86,7 @@ def get_first_working_feed_link(url): # parse the html extracted from the url, and get all the potiential # links from it then try them one by one for link in extract_feed_links(html): - if '://' not in link: # if we got a relative URL, make it absolute + if '://' not in link: # if we got a relative URL, make it absolute link = site_url + link feed = feedparser.parse(link) if not feed.get("bozo", 1): diff --git a/gunicorn.sh b/gunicorn.sh deleted file mode 100755 index cddf6ed..0000000 --- a/gunicorn.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -set -e -VENV=/home/crimeweather/sites/civic-json-app -LOGFILE=$VENV/run/gunicorn.log -LOGDIR=$(dirname $LOGFILE) -NUM_WORKERS=3 -# user/group to run as -USER=crimeweather -GROUP=crimeweather -cd $VENV/checkouts/civic-json-worker -source $VENV/bin/activate -source /home/crimeweather/.zshenv -test -d $LOGDIR || mkdir -p $LOGDIR -exec $VENV/bin/gunicorn -w $NUM_WORKERS --daemon --bind 127.0.0.1:6666 --user=$USER --group=$GROUP --log-level=info --log-file=$LOGFILE 2>>$LOGFILE app:app diff --git a/migrations/versions/1440cf6cc91c_add_attendance_table.py b/migrations/versions/1440cf6cc91c_add_attendance_table.py index 456be73..55cbbea 100644 --- a/migrations/versions/1440cf6cc91c_add_attendance_table.py +++ b/migrations/versions/1440cf6cc91c_add_attendance_table.py @@ -12,23 +12,19 @@ from alembic import op import sqlalchemy as sa -from app import JsonType +from models import JsonType def upgrade(): - ### commands auto generated by Alembic - please adjust! ### - op.create_table('attendance', - sa.Column('organization_url', sa.Unicode(), nullable=False), - sa.Column('total', sa.Integer(), nullable=True), - sa.Column('weekly', JsonType, nullable=True), - sa.Column('organization_name', sa.Unicode(), nullable=False), - sa.ForeignKeyConstraint(['organization_name'], ['organization.name'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('organization_url') + op.create_table( + 'attendance', + sa.Column('organization_url', sa.Unicode(), nullable=False), + sa.Column('total', sa.Integer(), nullable=True), + sa.Column('weekly', JsonType, nullable=True), + sa.Column('organization_name', sa.Unicode(), nullable=False), + sa.ForeignKeyConstraint(['organization_name'], ['organization.name'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('organization_url') ) - ### end Alembic commands ### - def downgrade(): - ### commands auto generated by Alembic - please adjust! ### op.drop_table('attendance') - ### end Alembic commands ### diff --git a/migrations/versions/15593ff6a15f_add_created_at_updated_at_column.py b/migrations/versions/15593ff6a15f_add_created_at_updated_at_column.py new file mode 100644 index 0000000..743b0a0 --- /dev/null +++ b/migrations/versions/15593ff6a15f_add_created_at_updated_at_column.py @@ -0,0 +1,23 @@ +"""Added created_at and updated_at columns to issues + +Revision ID: 15593ff6a15f +Revises: 219963bb18dc +Create Date: 2015-10-29 02:37:46.752752 + +""" + +# revision identifiers, used by Alembic. +revision = '15593ff6a15f' +down_revision = '219963bb18dc' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('issue', sa.Column('created_at', sa.DateTime(), nullable=True)) + op.add_column('issue', sa.Column('updated_at', sa.DateTime(), nullable=True)) + +def downgrade(): + op.drop_column('issue', 'updated_at') + op.drop_column('issue', 'created_at') diff --git a/migrations/versions/1afd54c77a32_add_previous_names.py b/migrations/versions/1afd54c77a32_add_previous_names.py new file mode 100644 index 0000000..2964288 --- /dev/null +++ b/migrations/versions/1afd54c77a32_add_previous_names.py @@ -0,0 +1,23 @@ +"""Add previous_names + +Revision ID: 1afd54c77a32 +Revises: 2864e71a466e +Create Date: 2020-08-12 15:13:09.682717 + +""" + +# revision identifiers, used by Alembic. +revision = '1afd54c77a32' +down_revision = '2864e71a466e' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +def upgrade(): + op.add_column('organization', sa.Column('previous_names', JSONB(), nullable=True)) + + +def downgrade(): + op.drop_column('organization', 'previous_names') diff --git a/migrations/versions/219963bb18dc_add_language_column.py b/migrations/versions/219963bb18dc_add_language_column.py new file mode 100644 index 0000000..2247053 --- /dev/null +++ b/migrations/versions/219963bb18dc_add_language_column.py @@ -0,0 +1,63 @@ +""" Add language column to projects + +Revision ID: 219963bb18dc +Revises: 4f685c062cff +Create Date: 2015-09-21 18:06:53.922781 + +""" + +# revision identifiers, used by Alembic. +revision = '219963bb18dc' +down_revision = '4f685c062cff' + +from alembic import op +import sqlalchemy as sa +from models import JsonType + + +def upgrade(): + op.add_column('project', sa.Column('languages', JsonType, nullable=True)) + + droptrigger = "DROP TRIGGER IF EXISTS tsvupdate_projects_trigger ON project" + droptriggerfunc = "DROP FUNCTION IF EXISTS project_search_trigger()" + createtriggerfunc = ''' + CREATE FUNCTION project_search_trigger() RETURNS trigger AS $$ + begin + new.tsv_body := + setweight(to_tsvector('pg_catalog.english', coalesce(new.status,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.tags,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.name,'')), 'B') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.description,'')), 'B') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.languages,'')), 'A'); + return new; + end + $$ LANGUAGE plpgsql; + ''' + createtrigger = "CREATE TRIGGER tsvupdate_projects_trigger BEFORE INSERT OR UPDATE ON project FOR EACH ROW EXECUTE PROCEDURE project_search_trigger();" + op.execute(droptrigger) + op.execute(droptriggerfunc) + op.execute(createtriggerfunc) + op.execute(createtrigger) + +def downgrade(): + droptrigger = "DROP TRIGGER IF EXISTS tsvupdate_projects_trigger ON project" + droptriggerfunc = "DROP FUNCTION IF EXISTS project_search_trigger()" + createtriggerfunc = ''' + CREATE FUNCTION project_search_trigger() RETURNS trigger AS $$ + begin + new.tsv_body := + setweight(to_tsvector('pg_catalog.english', coalesce(new.status,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.tags,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.name,'')), 'B') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.description,'')), 'B'); + return new; + end + $$ LANGUAGE plpgsql; + ''' + createtrigger = "CREATE TRIGGER tsvupdate_projects_trigger BEFORE INSERT OR UPDATE ON project FOR EACH ROW EXECUTE PROCEDURE project_search_trigger();" + op.execute(droptrigger) + op.execute(droptriggerfunc) + op.execute(createtriggerfunc) + op.execute(createtrigger) + + op.drop_column('project', 'languages') diff --git a/migrations/versions/2864e71a466e_add_logo_url_to_organization.py b/migrations/versions/2864e71a466e_add_logo_url_to_organization.py new file mode 100644 index 0000000..1230124 --- /dev/null +++ b/migrations/versions/2864e71a466e_add_logo_url_to_organization.py @@ -0,0 +1,26 @@ +"""Add Logo URL to Organization + +Revision ID: 2864e71a466e +Revises: a5abdf9487c +Create Date: 2018-01-25 15:59:31.973635 + +""" + +# revision identifiers, used by Alembic. +revision = '2864e71a466e' +down_revision = 'a5abdf9487c' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('organization', sa.Column('logo_url', sa.Unicode(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('organization', 'logo_url') + ### end Alembic commands ### diff --git a/migrations/versions/29ef29bfbe43_add_commit_status.py b/migrations/versions/29ef29bfbe43_add_commit_status.py new file mode 100644 index 0000000..69bf66a --- /dev/null +++ b/migrations/versions/29ef29bfbe43_add_commit_status.py @@ -0,0 +1,26 @@ +""" Add commit status + +Revision ID: 29ef29bfbe43 +Revises: 6cee2da7d1f +Create Date: 2016-01-31 22:49:31.651206 + +""" + +# revision identifiers, used by Alembic. +revision = '29ef29bfbe43' +down_revision = '6cee2da7d1f' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('project', sa.Column('commit_status', sa.Unicode(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('project', 'commit_status') + ### end Alembic commands ### diff --git a/migrations/versions/2e30a731a0e3_add_status_column_to_project_model_for_.py b/migrations/versions/2e30a731a0e3_add_status_column_to_project_model_for_.py index 7d724b9..150850a 100644 --- a/migrations/versions/2e30a731a0e3_add_status_column_to_project_model_for_.py +++ b/migrations/versions/2e30a731a0e3_add_status_column_to_project_model_for_.py @@ -1,14 +1,14 @@ """Add status column to Project model for Issue #133 Revision ID: 2e30a731a0e3 -Revises: Project +Revises: 457b2ba1dfb2 Create Date: 2015-03-09 18:00:01.083339 """ # revision identifiers, used by Alembic. revision = '2e30a731a0e3' -down_revision = None +down_revision = '457b2ba1dfb2' from alembic import op import sqlalchemy as sa diff --git a/migrations/versions/457b2ba1dfb2_initial_migration.py b/migrations/versions/457b2ba1dfb2_initial_migration.py new file mode 100644 index 0000000..2f493ec --- /dev/null +++ b/migrations/versions/457b2ba1dfb2_initial_migration.py @@ -0,0 +1,131 @@ +"""initial migration + +Revision ID: 457b2ba1dfb2 +Revises: None +Create Date: 2015-11-02 14:10:08.014600 + +""" + +# revision identifiers, used by Alembic. +revision = '457b2ba1dfb2' +down_revision = None + +from alembic import op +import sqlalchemy as sa +from models import JsonType, TSVectorType + +def upgrade(): + op.create_table( + 'organization', + sa.Column('name', sa.Unicode(), nullable=False), + sa.Column('website', sa.Unicode(), nullable=True), + sa.Column('events_url', sa.Unicode(), nullable=True), + sa.Column('rss', sa.Unicode(), nullable=True), + sa.Column('projects_list_url', sa.Unicode(), nullable=True), + sa.Column('type', sa.Unicode(), nullable=True), + sa.Column('city', sa.Unicode(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('last_updated', sa.Integer(), nullable=True), + sa.Column('started_on', sa.Unicode(), nullable=True), + sa.Column('keep', sa.Boolean(), nullable=True), + sa.Column('tsv_body', TSVectorType(), nullable=True), + sa.PrimaryKeyConstraint('name') + ) + op.create_index('index_org_tsv_body', 'organization', ['tsv_body'], unique=False, postgresql_using='gin') + + op.create_table( + 'error', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('error', sa.Unicode(), nullable=True), + sa.Column('time', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + op.create_table( + 'story', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.Unicode(), nullable=True), + sa.Column('link', sa.Unicode(), nullable=True), + sa.Column('type', sa.Unicode(), nullable=True), + sa.Column('keep', sa.Boolean(), nullable=True), + sa.Column('organization_name', sa.Unicode(), nullable=False), + sa.ForeignKeyConstraint(['organization_name'], ['organization.name'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + op.create_table( + 'project', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.Unicode(), nullable=True), + sa.Column('code_url', sa.Unicode(), nullable=True), + sa.Column('link_url', sa.Unicode(), nullable=True), + sa.Column('description', sa.Unicode(), nullable=True), + sa.Column('type', sa.Unicode(), nullable=True), + sa.Column('categories', sa.Unicode(), nullable=True), + sa.Column('github_details', JsonType(), nullable=True), + sa.Column('last_updated', sa.DateTime(), nullable=True), + sa.Column('last_updated_issues', sa.Unicode(), nullable=True), + sa.Column('keep', sa.Boolean(), nullable=True), + sa.Column('tsv_body', TSVectorType(), nullable=True), + sa.Column('organization_name', sa.Unicode(), nullable=False), + sa.ForeignKeyConstraint(['organization_name'], ['organization.name'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('index_project_tsv_body', 'project', ['tsv_body'], unique=False, postgresql_using='gin') + + op.create_table( + 'event', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.Unicode(), nullable=True), + sa.Column('description', sa.Unicode(), nullable=True), + sa.Column('event_url', sa.Unicode(), nullable=True), + sa.Column('location', sa.Unicode(), nullable=True), + sa.Column('created_at', sa.Unicode(), nullable=True), + sa.Column('start_time_notz', sa.DateTime(), nullable=True), + sa.Column('end_time_notz', sa.DateTime(), nullable=True), + sa.Column('utc_offset', sa.Integer(), nullable=True), + sa.Column('keep', sa.Boolean(), nullable=True), + sa.Column('organization_name', sa.Unicode(), nullable=False), + sa.ForeignKeyConstraint(['organization_name'], ['organization.name'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + op.create_table( + 'issue', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.Unicode(), nullable=True), + sa.Column('html_url', sa.Unicode(), nullable=True), + sa.Column('body', sa.Unicode(), nullable=True), + sa.Column('keep', sa.Boolean(), nullable=True), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_issue_project_id', 'issue', ['project_id'], unique=False) + + op.create_table( + 'label', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.Unicode(), nullable=True), + sa.Column('color', sa.Unicode(), nullable=True), + sa.Column('url', sa.Unicode(), nullable=True), + sa.Column('issue_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['issue_id'], ['issue.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_label_issue_id', 'label', ['issue_id'], unique=False) + + +def downgrade(): + op.drop_index('ix_label_issue_id', table_name='label') + op.drop_table('label') + op.drop_index('ix_issue_project_id', table_name='issue') + op.drop_table('issue') + op.drop_table('event') + op.drop_index('index_project_tsv_body', table_name='project') + op.drop_table('project') + op.drop_table('story') + op.drop_table('error') + op.drop_index('index_org_tsv_body', table_name='organization') + op.drop_table('organization') diff --git a/migrations/versions/4b2b7cde821f_add_org_name_to_search.py b/migrations/versions/4b2b7cde821f_add_org_name_to_search.py new file mode 100644 index 0000000..9a0b972 --- /dev/null +++ b/migrations/versions/4b2b7cde821f_add_org_name_to_search.py @@ -0,0 +1,60 @@ +""" Adds organization name to the project search tsv + +Revision ID: 4b2b7cde821f +Revises: 15593ff6a15f +Create Date: 2015-11-30 17:21:56.928359 + +""" + +# revision identifiers, used by Alembic. +revision = '4b2b7cde821f' +down_revision = '15593ff6a15f' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + droptrigger = "DROP TRIGGER IF EXISTS tsvupdate_projects_trigger ON project" + droptriggerfunc = "DROP FUNCTION IF EXISTS project_search_trigger()" + createtriggerfunc = ''' + CREATE FUNCTION project_search_trigger() RETURNS trigger AS $$ + begin + new.tsv_body := + setweight(to_tsvector('pg_catalog.english', coalesce(new.status,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.tags,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.name,'')), 'B') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.description,'')), 'B') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.languages,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.organization_name,'')), 'A'); + return new; + end + $$ LANGUAGE plpgsql; + ''' + createtrigger = "CREATE TRIGGER tsvupdate_projects_trigger BEFORE INSERT OR UPDATE ON project FOR EACH ROW EXECUTE PROCEDURE project_search_trigger();" + op.execute(droptrigger) + op.execute(droptriggerfunc) + op.execute(createtriggerfunc) + op.execute(createtrigger) + +def downgrade(): + droptrigger = "DROP TRIGGER IF EXISTS tsvupdate_projects_trigger ON project" + droptriggerfunc = "DROP FUNCTION IF EXISTS project_search_trigger()" + createtriggerfunc = ''' + CREATE FUNCTION project_search_trigger() RETURNS trigger AS $$ + begin + new.tsv_body := + setweight(to_tsvector('pg_catalog.english', coalesce(new.status,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.tags,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.name,'')), 'B') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.description,'')), 'B') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.languages,'')), 'A'); + return new; + end + $$ LANGUAGE plpgsql; + ''' + createtrigger = "CREATE TRIGGER tsvupdate_projects_trigger BEFORE INSERT OR UPDATE ON project FOR EACH ROW EXECUTE PROCEDURE project_search_trigger();" + op.execute(droptrigger) + op.execute(droptriggerfunc) + op.execute(createtriggerfunc) + op.execute(createtrigger) diff --git a/migrations/versions/4f685c062cff_improved_project_search.py b/migrations/versions/4f685c062cff_improved_project_search.py new file mode 100644 index 0000000..377ee65 --- /dev/null +++ b/migrations/versions/4f685c062cff_improved_project_search.py @@ -0,0 +1,43 @@ +""" Much improved project search. + +Revision ID: 4f685c062cff +Revises: 8081a5906af +Create Date: 2015-09-15 21:53:02.468239 + +""" + +# revision identifiers, used by Alembic. +revision = '4f685c062cff' +down_revision = '8081a5906af' + +from alembic import op + +def upgrade(): + droptrigger = "DROP TRIGGER IF EXISTS tsvupdate_projects_trigger ON project" + droptriggerfunc = "DROP FUNCTION IF EXISTS project_search_trigger()" + createtriggerfunc = ''' + CREATE FUNCTION project_search_trigger() RETURNS trigger AS $$ + begin + new.tsv_body := + setweight(to_tsvector('pg_catalog.english', coalesce(new.status,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.tags,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.name,'')), 'B') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.description,'')), 'B'); + return new; + end + $$ LANGUAGE plpgsql; + ''' + createtrigger = "CREATE TRIGGER tsvupdate_projects_trigger BEFORE INSERT OR UPDATE ON project FOR EACH ROW EXECUTE PROCEDURE project_search_trigger();" + op.execute(droptrigger) + op.execute(droptriggerfunc) + op.execute(createtriggerfunc) + op.execute(createtrigger) + + +def downgrade(): + droptrigger = "DROP TRIGGER IF EXISTS tsvupdate_projects_trigger ON project" + droptriggerfunc = "DROP FUNCTION IF EXISTS project_search_trigger()" + createtrigger = "CREATE TRIGGER tsvupdate_projects_trigger BEFORE INSERT OR UPDATE ON project FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(tsv_body, 'pg_catalog.english', name, description, type, categories, tags, github_details, status);" + op.execute(droptrigger) + op.execute(droptriggerfunc) + op.execute(createtrigger) diff --git a/migrations/versions/532c3f11a51b_add_org_id_column.py b/migrations/versions/532c3f11a51b_add_org_id_column.py index 67252e5..47128f8 100644 --- a/migrations/versions/532c3f11a51b_add_org_id_column.py +++ b/migrations/versions/532c3f11a51b_add_org_id_column.py @@ -15,12 +15,8 @@ def upgrade(): - ### commands auto generated by Alembic - please adjust! ### op.add_column('organization', sa.Column('id', sa.Unicode())) - ### end Alembic commands ### def downgrade(): - ### commands auto generated by Alembic - please adjust! ### op.drop_column('organization', 'id') - ### end Alembic commands ### diff --git a/migrations/versions/575d8824e34c_.py b/migrations/versions/575d8824e34c_.py new file mode 100644 index 0000000..22e1cd8 --- /dev/null +++ b/migrations/versions/575d8824e34c_.py @@ -0,0 +1,22 @@ +""" Add rsvps column to events + +Revision ID: 575d8824e34c +Revises: 1440cf6cc91c +Create Date: 2015-08-19 12:01:20.698048 + +""" + +# revision identifiers, used by Alembic. +revision = '575d8824e34c' +down_revision = '1440cf6cc91c' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('event', sa.Column('rsvps', sa.Integer(), nullable=True)) + + +def downgrade(): + op.drop_column('event', 'rsvps') diff --git a/migrations/versions/578ce9f8d1_add_tags_and_social_profiles.py b/migrations/versions/578ce9f8d1_add_tags_and_social_profiles.py new file mode 100644 index 0000000..12cd8ff --- /dev/null +++ b/migrations/versions/578ce9f8d1_add_tags_and_social_profiles.py @@ -0,0 +1,29 @@ +"""Add Tags and Social Profiles + +Revision ID: 578ce9f8d1 +Revises: 29ef29bfbe43 +Create Date: 2017-12-07 19:34:45.949358 + +""" + +# revision identifiers, used by Alembic. +revision = '578ce9f8d1' +down_revision = '29ef29bfbe43' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.types import Text + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('organization', sa.Column('social_profiles', postgresql.JSONB(astext_type=Text()), nullable=True)) + op.add_column('organization', sa.Column('tags', postgresql.JSONB(astext_type=Text()), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('organization', 'tags') + op.drop_column('organization', 'social_profiles') + ### end Alembic commands ### diff --git a/migrations/versions/6cee2da7d1f_change_tags_to_json.py b/migrations/versions/6cee2da7d1f_change_tags_to_json.py new file mode 100644 index 0000000..d675f90 --- /dev/null +++ b/migrations/versions/6cee2da7d1f_change_tags_to_json.py @@ -0,0 +1,25 @@ +""" Changes tags column type to json + +Revision ID: 6cee2da7d1f +Revises: 4b2b7cde821f +Create Date: 2015-12-01 11:13:34.873061 + +""" + +# revision identifiers, used by Alembic. +revision = '6cee2da7d1f' +down_revision = '4b2b7cde821f' + +from alembic import op +import sqlalchemy as sa +from models import JsonType + + +def upgrade(): + op.drop_column('project', 'tags') + op.add_column('project', sa.Column('tags', JsonType, nullable=True)) + + +def downgrade(): + op.drop_column('project', 'tags') + op.add_column('project', sa.Column('tags', sa.Unicode(), nullable=True)) diff --git a/migrations/versions/8081a5906af_.py b/migrations/versions/8081a5906af_.py new file mode 100644 index 0000000..c269f01 --- /dev/null +++ b/migrations/versions/8081a5906af_.py @@ -0,0 +1,22 @@ +"""empty message + +Revision ID: 8081a5906af +Revises: 575d8824e34c +Create Date: 2015-08-25 18:04:56.738898 + +""" + +# revision identifiers, used by Alembic. +revision = '8081a5906af' +down_revision = '575d8824e34c' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('organization', sa.Column('member_count', sa.Integer(), nullable=True)) + + +def downgrade(): + op.drop_column('organization', 'member_count') diff --git a/migrations/versions/a5abdf9487c_add_venue_to_event.py b/migrations/versions/a5abdf9487c_add_venue_to_event.py new file mode 100644 index 0000000..885e347 --- /dev/null +++ b/migrations/versions/a5abdf9487c_add_venue_to_event.py @@ -0,0 +1,25 @@ +from alembic import op +import sqlalchemy as sa + + +"""Add Venue To Event + +Revision ID: a5abdf9487c +Revises: 578ce9f8d1 +Create Date: 2018-01-12 16:43:53.741499 + +""" + +# revision identifiers, used by Alembic. +revision = 'a5abdf9487c' +down_revision = '578ce9f8d1' + + +def upgrade(): + op.add_column('event', sa.Column('lat', sa.types.DECIMAL(20, 17))) + op.add_column('event', sa.Column('lon', sa.types.DECIMAL(20, 17))) + + +def downgrade(): + op.drop_column('event', 'lat') + op.drop_column('event', 'lon') diff --git a/models.py b/models.py new file mode 100644 index 0000000..3cd9064 --- /dev/null +++ b/models.py @@ -0,0 +1,617 @@ +from __future__ import division + +from datetime import datetime, date +import json +import time + +from flask import request +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.mutable import Mutable +from sqlalchemy.ext.compiler import compiles +from sqlalchemy import types, desc +from sqlalchemy.orm import backref +from sqlalchemy import event, DDL, text +from dateutil.tz import tzoffset + +from flask.ext.sqlalchemy import SQLAlchemy +from utils import raw_name, safe_name, convert_datetime_to_iso_8601 + +db = SQLAlchemy() + +# ------------------- +# Initiation logic +# ------------------- + + +def initialize_database(app): + """ Takes an initalized flask application and binds a database context to allow query execution + """ + # see https://github.com/mitsuhiko/flask-sqlalchemy/issues/82 + db.app = app + db.init_app(app) + + return db + +# ------------------- +# Types +# ------------------- + + +class JsonType(Mutable, types.TypeDecorator): + ''' JSON wrapper type for TEXT database storage. + + References: + http://stackoverflow.com/questions/4038314/sqlalchemy-json-as-blob-text + http://docs.sqlalchemy.org/en/rel_0_9/orm/extensions/mutable.html + ''' + impl = types.Unicode + + def process_bind_param(self, value, engine): + return unicode(json.dumps(value)) + + def process_result_value(self, value, engine): + if value: + return json.loads(value) + else: + # default can also be a list + return {} + + +class TSVectorType(types.TypeDecorator): + ''' TSVECTOR wrapper type for database storage. + + References: + http://stackoverflow.com/questions/13837111/tsvector-in-sqlalchemy + ''' + impl = types.UnicodeText + + +@compiles(TSVectorType, 'postgresql') +def compile_tsvector(element, compiler, **kw): + return 'tsvector' + + +# ------------------- +# Models +# ------------------- + + +class Organization(db.Model): + ''' + Brigades and other civic tech organizations + ''' + # Columns + name = db.Column(db.Unicode(), primary_key=True) + website = db.Column(db.Unicode()) + events_url = db.Column(db.Unicode()) + rss = db.Column(db.Unicode()) + projects_list_url = db.Column(db.Unicode()) + tags = db.Column(JSONB()) + type = db.Column(db.Unicode()) + city = db.Column(db.Unicode()) + latitude = db.Column(db.Float()) + longitude = db.Column(db.Float()) + last_updated = db.Column(db.Integer()) + social_profiles = db.Column(JSONB()) + started_on = db.Column(db.Unicode()) + member_count = db.Column(db.Integer()) + keep = db.Column(db.Boolean()) + tsv_body = db.Column(TSVectorType()) + id = db.Column(db.Unicode()) + logo_url = db.Column(db.Unicode()) + previous_names = db.Column(JSONB()) + + # Relationships + # can contain events, stories, projects (these relationships are defined in the child objects) + + def __init__(self, name, **kwargs): + self.name = name + self.website = kwargs.get('website') + self.events_url = kwargs.get('events_url') + self.rss = kwargs.get('rss') + self.projects_list_url = kwargs.get('projects_list_url') + self.tags = kwargs.get('tags', []) + self.type = kwargs.get('type') + self.city = kwargs.get('city') + self.latitude = kwargs.get('latitude') + self.longitude = kwargs.get('longitude') + self.keep = True + self.last_updated = kwargs.get('last_updated', time.time()) + self.social_profiles = kwargs.get('social_profiles', {}) + self.started_on = unicode(date.today()) + self.id = safe_name(raw_name(name)) + self.members_count = kwargs.get('members_count') + self.logo_url = kwargs.get('logo_url') + self.previous_names = kwargs.get('previous_names') + + def current_events(self): + ''' + Return the next two upcoming events + ''' + event_not_ended = Event.end_time_notz - \ + (Event.utc_offset * text("interval '1 second'")) >= datetime.utcnow() + + current_events = Event.query.\ + filter_by(organization_name=self.name).\ + filter(event_not_ended).\ + order_by(Event.start_time_notz.asc()).\ + limit(2).\ + all() + + current_events_json = [row.asdict() for row in current_events] + return current_events_json + + def current_projects(self): + ''' + Return the three most current projects + ''' + current_projects = Project.query.filter_by(organization_name=self.name).order_by(desc(Project.last_updated)).limit(3) + current_projects_json = [project.asdict(include_issues=False) for project in current_projects] + + return current_projects_json + + def current_stories(self): + ''' + Return the two most current stories + ''' + current_stories = Story.query.filter_by(organization_name=self.name).order_by(desc(Story.id)).limit(2).all() + current_stories_json = [row.asdict() for row in current_stories] + return current_stories_json + + def all_events(self): + ''' API link to all an orgs events + ''' + # Make a nice org name + organization_name = safe_name(self.name) + return '%s://%s/api/organizations/%s/events' % (request.scheme, request.host, organization_name) + + def upcoming_events(self): + ''' API link to an orgs upcoming events + ''' + # Make a nice org name + organization_name = safe_name(self.name) + return '%s://%s/api/organizations/%s/upcoming_events' % (request.scheme, request.host, organization_name) + + def past_events(self): + ''' API link to an orgs past events + ''' + # Make a nice org name + organization_name = safe_name(self.name) + return '%s://%s/api/organizations/%s/past_events' % (request.scheme, request.host, organization_name) + + def all_projects(self): + ''' API link to all an orgs projects + ''' + # Make a nice org name + organization_name = safe_name(self.name) + return '%s://%s/api/organizations/%s/projects' % (request.scheme, request.host, organization_name) + + def all_issues(self): + '''API link to all an orgs issues + ''' + # Make a nice org name + organization_name = safe_name(self.name) + return '%s://%s/api/organizations/%s/issues' % (request.scheme, request.host, organization_name) + + def all_stories(self): + ''' API link to all an orgs stories + ''' + # Make a nice org name + organization_name = safe_name(self.name) + return '%s://%s/api/organizations/%s/stories' % (request.scheme, request.host, organization_name) + + def all_attendance(self): + ''' API link to orgs attendance ''' + organization_name = safe_name(self.name) + return '%s://%s/api/organizations/%s/attendance' % (request.scheme, request.host, organization_name) + + def api_id(self): + ''' Return organization name made safe for use in a URL. + ''' + return safe_name(self.name) + + def api_url(self): + ''' API link to itself + ''' + return '%s://%s/api/organizations/%s' % (request.scheme, request.host, self.api_id()) + + def asdict(self, include_extras=False): + ''' Return Organization as a dictionary, with some properties tweaked. + + Optionally include linked projects, events, and stories. + ''' + organization_dict = db.Model.asdict(self) + + # remove fields that don't need to be public + del organization_dict['keep'] + del organization_dict['tsv_body'] + + for key in ('all_events', 'all_projects', 'all_stories', 'all_issues', + 'upcoming_events', 'past_events', 'api_url', 'all_attendance'): + organization_dict[key] = getattr(self, key)() + + if include_extras: + for key in ('current_events', 'current_projects', 'current_stories'): + organization_dict[key] = getattr(self, key)() + + return organization_dict + + +tbl = Organization.__table__ +# Index the tsvector column +db.Index('index_org_tsv_body', tbl.c.tsv_body, postgresql_using='gin') + +# Trigger to populate the search index column +trig_ddl = DDL(""" + CREATE TRIGGER tsvupdate_orgs_trigger BEFORE INSERT OR UPDATE ON organization FOR EACH ROW EXECUTE PROCEDURE tsvector_update_trigger(tsv_body, 'pg_catalog.english', name); +""") +# Initialize the trigger after table is created +event.listen(tbl, 'after_create', trig_ddl.execute_if(dialect='postgresql')) + + +class Story(db.Model): + ''' + Blog posts from a Brigade. + ''' + # Columns + id = db.Column(db.Integer(), primary_key=True) + title = db.Column(db.Unicode()) + link = db.Column(db.Unicode()) + type = db.Column(db.Unicode()) + keep = db.Column(db.Boolean()) + + # Relationships + # child + organization = db.relationship('Organization', single_parent=True, cascade='all, delete-orphan', backref=backref("stories", cascade="save-update, delete")) + organization_name = db.Column(db.Unicode(), db.ForeignKey('organization.name', ondelete='CASCADE'), nullable=False) + + def __init__(self, title=None, link=None, type=None, organization_name=None): + self.title = title + self.link = link + self.type = type + self.organization_name = organization_name + self.keep = True + + def api_url(self): + ''' API link to itself + ''' + return '%s://%s/api/stories/%s' % (request.scheme, request.host, str(self.id)) + + def asdict(self, include_organization=False): + ''' Return Story as a dictionary, with some properties tweaked. + + Optionally include linked organization. + ''' + story_dict = db.Model.asdict(self) + + # remove fields that don't need to be public + del story_dict['keep'] + + story_dict['api_url'] = self.api_url() + + if include_organization: + story_dict['organization'] = self.organization.asdict() + + return story_dict + + +class Project(db.Model): + ''' + Civic tech projects on GitHub + ''' + # Columns + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.Unicode()) + code_url = db.Column(db.Unicode()) + link_url = db.Column(db.Unicode()) + description = db.Column(db.Unicode()) + type = db.Column(db.Unicode()) + categories = db.Column(db.Unicode()) + tags = db.Column(JsonType()) + github_details = db.Column(JsonType()) + last_updated = db.Column(db.DateTime()) + last_updated_issues = db.Column(db.Unicode()) + last_updated_civic_json = db.Column(db.Unicode()) + last_updated_root_files = db.Column(db.Unicode()) + keep = db.Column(db.Boolean()) + tsv_body = db.Column(TSVectorType()) + status = db.Column(db.Unicode()) + languages = db.Column(JsonType()) + commit_status = db.Column(db.Unicode()) + + # Relationships + # child + organization = db.relationship('Organization', single_parent=True, cascade='all, delete-orphan', backref=backref("projects", cascade="save-update, delete")) + organization_name = db.Column(db.Unicode(), db.ForeignKey('organization.name', ondelete='CASCADE'), nullable=False) + + # can contain issues (this relationship is defined in the child object) + + def __init__(self, name, code_url=None, link_url=None, + description=None, type=None, categories=None, tags=None, + github_details=None, last_updated=None, last_updated_issues=None, + last_updated_civic_json=None, last_updated_root_files=None, organization_name=None, + keep=None, status=None, languages=None, commit_status=None): + self.name = name + self.code_url = code_url + self.link_url = link_url + self.description = description + self.type = type + self.categories = categories + self.tags = tags + self.github_details = github_details + self.last_updated = last_updated + self.last_updated_issues = last_updated_issues + self.last_updated_civic_json = last_updated_civic_json + self.last_updated_root_files = last_updated_root_files + self.organization_name = organization_name + self.keep = True + self.status = status + self.languages = languages + self.commit_status = commit_status + + def api_url(self): + ''' API link to itself + ''' + return u'{}://{}/api/projects/{}'.format(request.scheme, request.host, str(self.id)) + + def asdict(self, include_organization=False, include_issues=False): + ''' Return Project as a dictionary, with some properties tweaked. + + Optionally include linked organization and issues. + ''' + project_dict = db.Model.asdict(self) + + # remove fields that don't need to be public + del project_dict['keep'] + del project_dict['tsv_body'] + del project_dict['last_updated_issues'] + del project_dict['last_updated_civic_json'] + del project_dict['last_updated_root_files'] + + project_dict['api_url'] = self.api_url() + + if include_organization: + project_dict['organization'] = self.organization.asdict() + + if include_issues: + project_dict['issues'] = [o.asdict(include_project=False, include_labels=True) for o in db.session.query(Issue).filter(Issue.project_id == project_dict['id']).all()] + else: + project_dict['issues'] = self.api_url() + "/issues" + + return project_dict + +tbl = Project.__table__ +# Index the tsvector column +db.Index('index_project_tsv_body', tbl.c.tsv_body, postgresql_using='gin') + +# Trigger to populate the search index column +trig_ddl = DDL(""" + DROP FUNCTION IF EXISTS project_search_trigger(); + CREATE FUNCTION project_search_trigger() RETURNS trigger AS $$ + begin + new.tsv_body := + setweight(to_tsvector('pg_catalog.english', coalesce(new.status,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.tags,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.name,'')), 'B') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.description,'')), 'B') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.languages,'')), 'A') || + setweight(to_tsvector('pg_catalog.english', coalesce(new.organization_name,'')), 'A'); + return new; + end + $$ LANGUAGE plpgsql; + + CREATE TRIGGER tsvupdate_projects_trigger BEFORE INSERT OR UPDATE ON project FOR EACH ROW EXECUTE PROCEDURE project_search_trigger(); +""") +# Initialize the trigger after table is created +event.listen(tbl, 'after_create', trig_ddl.execute_if(dialect='postgresql')) + + +class Issue(db.Model): + ''' + Issues of Civic Tech Projects on Github + ''' + # Columns + id = db.Column(db.Integer(), primary_key=True) + title = db.Column(db.Unicode()) + html_url = db.Column(db.Unicode()) + body = db.Column(db.Unicode()) + keep = db.Column(db.Boolean()) + created_at = db.Column(db.DateTime()) + updated_at = db.Column(db.DateTime()) + + # Relationships + # child + project = db.relationship('Project', single_parent=True, cascade='all, delete-orphan', backref=backref("issues", cascade="save-update, delete")) + project_id = db.Column(db.Integer(), db.ForeignKey('project.id', ondelete='CASCADE'), nullable=False, index=True) + + # can contain labels (this relationship is defined in the child object) + + def __init__(self, title, project_id=None, html_url=None, labels=None, body=None, created_at=None, updated_at=None): + self.title = title + self.html_url = html_url + self.body = body + self.project_id = project_id + self.created_at = created_at + self.updated_at = updated_at + self.keep = True + + def api_url(self): + ''' API link to itself + ''' + return '%s://%s/api/issues/%s' % (request.scheme, request.host, str(self.id)) + + def asdict(self, include_project=False, include_labels=True): + ''' + Return issue as a dictionary with some properties tweaked + ''' + issue_dict = db.Model.asdict(self) + + if include_project: + issue_dict['project'] = db.session.query(Project).filter(Project.id == self.project_id).first().asdict(include_organization=False, include_issues=False) + del issue_dict['project_id'] + + # remove fields that don't need to be public + del issue_dict['keep'] + + # manually convert dates to ISO 8601 + issue_dict['created_at'] = convert_datetime_to_iso_8601(issue_dict['created_at']) + issue_dict['updated_at'] = convert_datetime_to_iso_8601(issue_dict['updated_at']) + + # set the API URL + issue_dict['api_url'] = self.api_url() + + if include_labels: + issue_dict['labels'] = [l.asdict() for l in self.labels] + + return issue_dict + + +class Label(db.Model): + ''' + Issue labels for projects on Github + ''' + # Columns + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.Unicode()) + color = db.Column(db.Unicode()) + url = db.Column(db.Unicode()) + + # Relationships + # child + issue = db.relationship('Issue', single_parent=True, cascade='all, delete-orphan', backref=backref("labels", cascade="save-update, delete")) + issue_id = db.Column(db.Integer, db.ForeignKey('issue.id', ondelete='CASCADE'), nullable=False, index=True) + + def __init__(self, **kwargs): + self.name = kwargs['name'] + self.color = kwargs['color'] + self.url = kwargs['url'] + self.issue_id = kwargs.get('issue_id') + + def asdict(self): + ''' + Return label as a dictionary with some properties tweaked + ''' + label_dict = db.Model.asdict(self) + + # remove fields that don't need to be public + del label_dict['id'] + del label_dict['issue_id'] + + return label_dict + + +class Event(db.Model): + ''' + Organizations events from Meetup + ''' + # Columns + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.Unicode()) + description = db.Column(db.Unicode()) + event_url = db.Column(db.Unicode()) + location = db.Column(db.Unicode()) + lat = db.Column(db.DECIMAL(20, 17)) + lon = db.Column(db.DECIMAL(20, 17)) + created_at = db.Column(db.Unicode()) + start_time_notz = db.Column(db.DateTime(False)) + end_time_notz = db.Column(db.DateTime(False)) + utc_offset = db.Column(db.Integer()) + rsvps = db.Column(db.Integer()) + keep = db.Column(db.Boolean()) + + # Relationships + # child + organization = db.relationship('Organization', single_parent=True, cascade='all, delete-orphan', backref=backref("events", cascade="save-update, delete")) + organization_name = db.Column(db.Unicode(), db.ForeignKey('organization.name', ondelete='CASCADE'), nullable=False) + + def __init__(self, name, event_url, start_time_notz, created_at, utc_offset, + organization_name, location=None, end_time_notz=None, description=None, + rsvps=None, lat=None, lon=None): + self.name = name + self.description = description + self.location = location + self.lat = lat + self.lon = lon + self.event_url = event_url + self.start_time_notz = start_time_notz + self.utc_offset = utc_offset + self.end_time_notz = end_time_notz + self.organization_name = organization_name + self.created_at = created_at + self.rsvps = rsvps + self.keep = True + + def start_time(self): + ''' Get a string representation of the start time with UTC offset. + ''' + if self.start_time_notz is None: + return None + tz = tzoffset(None, self.utc_offset) + st = self.start_time_notz + dt = datetime(st.year, st.month, st.day, st.hour, st.minute, st.second, tzinfo=tz) + return dt.strftime('%Y-%m-%d %H:%M:%S %z') + + def end_time(self): + ''' Get a string representation of the end time with UTC offset. + ''' + if self.end_time_notz is None: + return None + tz = tzoffset(None, self.utc_offset) + et = self.end_time_notz + dt = datetime(et.year, et.month, et.day, et.hour, et.minute, et.second, tzinfo=tz) + return dt.strftime('%Y-%m-%d %H:%M:%S %z') + + def api_url(self): + ''' API link to itself + ''' + return '%s://%s/api/events/%s' % (request.scheme, request.host, str(self.id)) + + def asdict(self, include_organization=False): + ''' Return Event as a dictionary, with some properties tweaked. + + Optionally include linked organization. + ''' + event_dict = db.Model.asdict(self) + + # remove fields that don't need to be public + for key in ('keep', 'start_time_notz', 'end_time_notz', 'utc_offset'): + del event_dict[key] + + # add custom fields not in database + for key in ('start_time', 'end_time', 'api_url'): + event_dict[key] = getattr(self, key)() + + if include_organization: + event_dict['organization'] = self.organization.asdict(include_extras=False) + + return event_dict + + +class Attendance(db.Model): + ''' Attendance at organization events + sourced from the peopledb + ''' + # Columns + organization_url = db.Column(db.Unicode(), primary_key=True) + total = db.Column(db.Integer()) + weekly = db.Column(JsonType()) + + # Relationship + organization = db.relationship('Organization', single_parent=True, cascade='all, delete-orphan', backref=backref("attendance", cascade="save-update, delete")) + organization_name = db.Column(db.Unicode(), db.ForeignKey('organization.name', ondelete='CASCADE'), nullable=False) + + def __init__(self, organization_url, organization_name, total, weekly): + self.organization_url = organization_url + self.organization_name = organization_name + self.total = total + self.weekly = weekly + + +class Error(db.Model): + ''' + Errors from run_update.py + ''' + # Columns + id = db.Column(db.Integer(), primary_key=True) + error = db.Column(db.Unicode()) + time = db.Column(db.DateTime(False)) diff --git a/org_sources.csv b/org_sources.csv index 314ee96..3b629f2 100644 --- a/org_sources.csv +++ b/org_sources.csv @@ -1 +1 @@ -https://docs.google.com/spreadsheet/pub?key=0ArHmv-6U1drqdGNCLWV5Q0d5YmllUzE5WGlUY3hhT2c&output=csv \ No newline at end of file +https://raw.githubusercontent.com/codeforamerica/brigade-information/master/organizations.json diff --git a/requirements.txt b/requirements.txt index 58e9a37..f70350e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ coverage==3.7.1 dictalchemy==0.1.2.1 factory-boy==2.3.1 feedparser==5.1.3 +flake8==2.4.1 Flask==0.10.1 flask-heroku==0.1.8 Flask-Migrate==1.2.0 @@ -12,21 +13,26 @@ Flask-Restless==0.12.1 Flask-Script==2.0.5 Flask-SQLAlchemy==1.0 green==1.11.0 -gunicorn==0.17.4 +gunicorn==19.3.0 httmock==1.2.1 itsdangerous==0.23 Jinja2==2.7.2 Mako==1.0.0 MarkupSafe==0.18 +mccabe==0.3.1 mock==1.0.1 newrelic==2.40.0.34 -pep8==1.6.2 -psycopg2==2.5.2 +psycopg2==2.7.3.2 +pyOpenSSL==17.4.0 python-dateutil==2.2 python-termstyle==0.1.10 -requests==1.2.3 +requests[security]==2.9.0 six==1.5.2 -SQLAlchemy==0.9.3 +SQLAlchemy==1.1.15 Unidecode==0.4.14 Werkzeug==0.9.4 wheel==0.24.0 +cryptography==2.1.3 +freezegun==0.3.5 +raven[flask]==6.4.0 +simplejson==3.13.2 diff --git a/run_update.py b/run_update.py index e3887cd..18c0843 100644 --- a/run_update.py +++ b/run_update.py @@ -11,15 +11,18 @@ from argparse import ArgumentParser from time import time from re import match, sub -from psycopg2 import connect, extras -from requests import get, exceptions + from dateutil.tz import tzoffset import feedparser +import json +from raven import Client as SentryClient +from requests import get, exceptions -from feeds import get_first_working_feed_link -from app import db, Project, Organization, Story, Event, Error, Issue, Label, is_safe_name, safe_name, raw_name, Attendance +from app import db, Project, Organization, Story, Event, Error, Issue, Label, Attendance +from feeds import get_first_working_feed_link +from utils import is_safe_name, safe_name, raw_name # Logging Setup @@ -32,39 +35,89 @@ # import warnings # warnings.filterwarnings('error') +THREE_HOURS_IN_MS = 3 * 60 * 60 * 1000 + # org sources filenames ORG_SOURCES_FILENAME = 'org_sources.csv' TEST_ORG_SOURCES_FILENAME = 'test_org_sources.csv' # API URL templates -MEETUP_API_URL = "https://api.meetup.com/2/events?status=past,upcoming&format=json&group_urlname={group_urlname}&key={key}" +# TODO: use a Meetup client library with pagination +MEETUP_API_URL = "https://api.meetup.com/2/events?status=past,upcoming&format=json&group_urlname={group_urlname}&key={key}&desc=true&page=200" +MEETUP_COUNT_API_URL = "https://api.meetup.com/2/groups?group_urlname={group_urlname}&key={key}" +GITHUB_USER_API_URL = 'https://api.github.com/users/{username}' GITHUB_USER_REPOS_API_URL = 'https://api.github.com/users/{username}/repos' GITHUB_REPOS_API_URL = 'https://api.github.com/repos{repo_path}' GITHUB_ISSUES_API_URL = 'https://api.github.com/repos{repo_path}/issues' GITHUB_CONTENT_API_URL = 'https://api.github.com/repos{repo_path}/contents/{file_path}' +GITHUB_COMMIT_STATUS_URL = 'https://api.github.com/repos{repo_path}/commits/{default_branch}/status' +GITHUB_AUTH = None if 'GITHUB_TOKEN' in os.environ: - github_auth = (os.environ['GITHUB_TOKEN'], '') -else: - github_auth = None + GITHUB_AUTH = (os.environ['GITHUB_TOKEN'], '') +MEETUP_KEY = None if 'MEETUP_KEY' in os.environ: - meetup_key = os.environ['MEETUP_KEY'] -else: - meetup_key = None + MEETUP_KEY = os.environ['MEETUP_KEY'] + +SENTRY = None +if 'SENTRY_DSN' in os.environ: + SENTRY = SentryClient(os.environ['SENTRY_DSN']) + + +GITHUB_THROTTLING = False -github_throttling = False def get_github_api(url, headers=None): ''' Make authenticated GitHub requests. ''' - logging.info('Asking Github for {}{}'.format(url, ' ({})'.format(headers) if headers and headers != {} else '')) - - got = get(url, auth=github_auth, headers=headers) + global GITHUB_THROTTLING + got = get(url, auth=GITHUB_AUTH, headers=headers) + + limit_hit, remaining = get_hit_github_ratelimit(got.headers) + logging.info(u'-{}- Asked Github for {}{}'.format(remaining, url, u' ({})'.format(headers) if headers and headers != {} else u'')) + + # check for throttling + if got.status_code == 403 and limit_hit: + # we've been throttled + GITHUB_THROTTLING = True + + # log the error + logging.error(u"GitHub Rate Limit Remaining: {}".format(got.headers["X-Ratelimit-Remaining"])) + + # save the error in the db + error_dict = { + "error": u'IOError: We done got throttled by GitHub', + "time": datetime.now() + } + new_error = Error(**error_dict) + # commit the error + db.session.add(new_error) + db.session.commit() return got +def get_hit_github_ratelimit(headers): + ''' Return True if we've hit the GitHub rate limit, + False if we haven't or if we can't figure it out. + Also return the remaining requests reported by GitHub. + ''' + try: + remaining_str = headers['X-Ratelimit-Remaining'] + except KeyError: + # no header by that name + return False, 0 + + try: + remaining_int = int(remaining_str) + except ValueError: + # value can't be converted into an integer + return False, 0 + + # return True if we've hit the limit + return remaining_int <= 0, remaining_int + def format_date(time_in_milliseconds, utc_offset_msec): ''' Create a datetime object from a time in milliseconds from the epoch @@ -73,46 +126,84 @@ def format_date(time_in_milliseconds, utc_offset_msec): dt = datetime.fromtimestamp(time_in_milliseconds / 1000.0, tz) return datetime(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) + def format_location(venue): + if 'address_1' not in venue: + return venue['name'] + address = venue['address_1'] - if('address_2' in venue and venue['address_2'] != ''): + + if 'address_2' in venue and venue['address_2'] != '': address = address + ', ' + venue['address_2'] - if 'state' in venue: - return "{address}, {city}, {state}, {country}".format(address=address, city=venue['city'], state=venue['state'], country=venue['country']) - else: - return "{address}, {city}, {country}".format(address=address, city=venue['city'], country=venue['country']) + return u'{name}\n{address}'.format(name=venue['name'], address=address) + def get_meetup_events(organization, group_urlname): + ''' Get events associated with a group ''' - Get events associated with a group - ''' - meetup_url = MEETUP_API_URL.format(group_urlname=group_urlname, key=meetup_key) + events = [] + + if not MEETUP_KEY: + logging.error("No meetup.com key set.") + return events + + meetup_url = MEETUP_API_URL.format(group_urlname=group_urlname, key=MEETUP_KEY) got = get(meetup_url) if got.status_code in range(400, 499): - logging.error("%s's meetup page cannot be found" % organization.name) - return [] + logging.error(u"{}'s meetup page cannot be found".format(organization.name)) + return events else: try: results = got.json()['results'] - events = [] for event in results: - event = dict(organization_name=organization.name, - name=event['name'], - event_url=event['event_url'], - start_time_notz=format_date(event['time'], event['utc_offset']), - created_at=format_date(event['created'], event['utc_offset']), - utc_offset=event['utc_offset'] / 1000.0) + # "Scheduled event duration in milliseconds, if an end time is + # specified by the organizer. When not present, a default of 3 + # hours may be assumed by applications" + # see: https://www.meetup.com/meetup_api/docs/:urlname/events/#list + duration = event.get('duration', THREE_HOURS_IN_MS) + + eventdict = dict( + organization_name=organization.name, + name=event['name'], + event_url=event['event_url'], + start_time_notz=format_date(event['time'], event['utc_offset']), + end_time_notz=format_date(event['time'] + duration, event['utc_offset']), + created_at=format_date(event['created'], event['utc_offset']), + utc_offset=event['utc_offset'] / 1000.0, + rsvps=event['yes_rsvp_count'], + description=event.get('description') + ) # Some events don't have locations. if 'venue' in event: - event['location'] = format_location(event['venue']) + eventdict['location'] = format_location(event['venue']) + eventdict['lat'] = event['venue']['lat'] + eventdict['lon'] = event['venue']['lon'] - events.append(event) + events.append(eventdict) return events except (TypeError, ValueError): - return [] + return events + + +def get_meetup_count(organization, identifier): + ''' Get the count of meetup members + ''' + meetup_url = MEETUP_COUNT_API_URL.format(group_urlname=identifier, key=MEETUP_KEY) + got = get(meetup_url) + members = None + if got and got.status_code // 100 == 2: + try: + response = got.json() + if response: + if response["results"]: + members = response["results"][0]["members"] + except ValueError: # meetup API returned non-JSON response + return None + + return members def get_organizations(org_sources): @@ -121,11 +212,29 @@ def get_organizations(org_sources): organizations = [] with open(org_sources) as file: for org_source in file.read().splitlines(): - if 'docs.google.com' in org_source: + scheme, netloc, path, _, _, _ = urlparse(org_source) + is_json = os.path.splitext(path)[1] == '.json' + # if it's a local file... + if not scheme and not netloc: + if is_json: + organizations.extend(get_organizations_from_local_json(org_source)) + else: + organizations.extend(get_organizations_from_local_csv(org_source)) + elif is_json: + organizations.extend(get_organizations_from_json(org_source)) + elif 'docs.google.com' in org_source: organizations.extend(get_organizations_from_spreadsheet(org_source)) return organizations + +def get_organizations_from_json(org_source): + ''' Get a row for each organization from a remote JSON file. + ''' + got = get(org_source) + return got.json() + + def get_organizations_from_spreadsheet(org_source): ''' Get a row for each organization from the Brigade Info spreadsheet. @@ -138,13 +247,36 @@ def get_organizations_from_spreadsheet(org_source): # Use response.content to plain bytes, then decode everything. # organizations = list(DictReader(StringIO(got.content))) + return decode_organizations_list(organizations) + + +def get_organizations_from_local_csv(org_source): + ''' Get a row for each organization from a local CSV file. + Return a list of dictionaries, one for each row past the header. + ''' + organizations = list(DictReader(open(org_source, 'rb'))) + return decode_organizations_list(organizations) + + +def get_organizations_from_local_json(org_source): + ''' Get a row for each organization from a local JSON file. + Return a list of dictionaries, one for each row past the header. + ''' + with open(org_source, 'rb') as org_data: + organizations = json.load(org_data) + return organizations +def decode_organizations_list(organizations): + ''' + Decode keys and values in a list of organizations + ''' for (index, org) in enumerate(organizations): organizations[index] = dict([(k.decode('utf8'), v.decode('utf8')) for (k, v) in org.items()]) return organizations + def get_stories(organization): ''' Get two recent stories from an rss feed. ''' @@ -154,30 +286,32 @@ def get_stories(organization): else: rss = organization.website + stories = [] + # Extract a valid RSS feed from the URL try: url = get_first_working_feed_link(rss) # If no feed found then give up if not url: - url = None - return None + return stories + except (HTTPError, ValueError, URLError): - url = None - return None + return stories try: logging.info('Asking cyberspace for ' + url) d = feedparser.parse(get(url).text) + except (HTTPError, URLError, exceptions.SSLError): - url = None - return None + return stories # # Return dictionaries for the two most recent entries. # - return [dict(title=e.title, link=e.link, type=u'blog', organization_name=organization.name) - for e in d.entries[:2]] + stories = [dict(title=e.title, link=e.link, type=u'blog', organization_name=organization.name) for e in d.entries[:2]] + return stories + def get_adjoined_json_lists(response, headers=None): ''' Github uses the Link header (RFC 5988) to do pagination. @@ -187,12 +321,34 @@ def get_adjoined_json_lists(response, headers=None): ''' result = response.json() + status_code = response.status_code if type(result) is list: while 'next' in response.links: response = get_github_api(response.links['next']['url'], headers=headers) + status_code = response.status_code + # Consider any status other than 2xx an error + if not status_code // 100 == 2: + break result += response.json() - return result + return result, status_code + + +def parse_github_user(url): + ''' given a URL, returns the github username or None if it is not a Github URL ''' + _, host, path, _, _, _ = urlparse(url) + matched = match(r'(/orgs)?/(?P[^/]+)/?$', path) + if host in ('www.github.com', 'github.com') and matched: + return matched.group('name') + + +def is_official_brigade(org_info): + ''' + Given an entry in the org info source (e.g. brigade-information), returns + true if the org is an "official" CfA brigade + ''' + tags = org_info.get('tags', []) + return 'Code for America' in tags and 'Official' in tags def get_projects(organization): @@ -201,7 +357,6 @@ def get_projects(organization): Convert to a dict. TODO: Have this work for GDocs. ''' - # don't try to process an empty projects_list_url if not organization.projects_list_url: return [] @@ -209,19 +364,18 @@ def get_projects(organization): # If projects_list is a GitHub organization # Use the GitHub auth to request all the included repos. # Follow next page links - _, host, path, _, _, _ = urlparse(organization.projects_list_url) - matched = match(r'(/orgs)?/(?P[^/]+)/?$', path) - if host in ('www.github.com', 'github.com') and matched: - projects_url = GITHUB_USER_REPOS_API_URL.format(username=matched.group('name')) + github_username = parse_github_user(organization.projects_list_url) + if github_username: + projects_url = GITHUB_USER_REPOS_API_URL.format(username=github_username) try: - response = get_github_api(projects_url) + got = get_github_api(projects_url) # Consider any status other than 2xx an error - if not response.status_code // 100 == 2: + if not got.status_code // 100 == 2: return [] - projects = get_adjoined_json_lists(response) + projects, _ = get_adjoined_json_lists(got) except exceptions.RequestException: # Something has gone wrong, probably a bad URL or site is down. @@ -255,6 +409,10 @@ def get_projects(organization): # some values might be empty strings elif type(project_value) in (str, unicode) and unicode(project_value.decode('utf8')) == u'': project[project_key] = None + # we want tags to be a list with no whitespace + elif project_key == 'tags': + project_value = unicode(project_value.decode('utf8')) + project[project_key] = [tag.strip() for tag in project_value.split(',')] else: project[project_key] = unicode(project_value.decode('utf8')) @@ -299,13 +457,42 @@ def get_projects(organization): return projects + +def github_latest_update_time(github_details): + ''' + Use `pushed_at` date, if present, which is updated any time any + branch is pushed. This tends to be more similar to the first + date visible when users click through to the project -- the + last commit's date. If there is no pushed_at date then fall back to `updated_at` date. + + It's still not perfect, but this will be a quick improvement to avoid the + confusion of seeing a "last modified yesterday" project that actually + hasn't seen a commit in three years. + + (See issue #245 for some context, but we ripped it out) + ''' + import dateutil.parser + + datetime_format = '%a, %d %b %Y %H:%M:%S %Z' + + if 'pushed_at' in github_details: + update_time = github_details['pushed_at'] + elif 'updated_at' in github_details: + update_time = github_details['updated_at'] + else: + return datetime.now() + + return dateutil.parser.parse(update_time).strftime(datetime_format) + + def non_github_project_update_time(project): ''' If its a non-github project, we should check if any of the fields have been updated, such as the description. Set the last_updated timestamp. ''' - existing_project = db.session.query(Project).filter(Project.name == project['name']).first() + filters = [Project.name == project['name'], Project.organization_name == project['organization_name']] + existing_project = db.session.query(Project).filter(*filters).first() if existing_project: # project gets existing last_updated @@ -322,6 +509,17 @@ def non_github_project_update_time(project): return project + +def make_root_github_project_path(path): + ''' Strip anything extra off the end of a github path + ''' + path_split = path.split('/') + path = '/'.join(path_split[0:3]) + # some URLs have been passed to us with '.git' at the end + path = sub(ur'\.git$', '', path) + return path + + def update_project_info(project): ''' Update info from Github, if it's missing. @@ -345,21 +543,30 @@ def update_project_info(project): # Get the Github attributes if host == 'github.com': + path = sub(r"[\s\/]+?$", "", path) + # make sure we're working with the main github URL + path = make_root_github_project_path(path) repo_url = GITHUB_REPOS_API_URL.format(repo_path=path) - # If we've hit the GitHub rate limit, skip updating projects. - global github_throttling - if github_throttling: - return project - # find an existing project, filtering on code_url, organization_name, and project name (if we know it) existing_filter = [Project.code_url == project['code_url'], Project.organization_name == project['organization_name']] if 'name' in project and project['name']: existing_filter.append(Project.name == project['name']) + existing_project = db.session.query(Project).filter(*existing_filter).first() + + # if we're throttled, make sure an existing project is kept and return none + if GITHUB_THROTTLING: + if existing_project: + # :::here (project/true) + existing_project.keep = True + # commit the project + db.session.commit() + return None + + # keep track of org spreadsheet values spreadsheet_is_updated = False - existing_project = db.session.query(Project).filter(*existing_filter).first() if existing_project: # copy 'last_updated' values from the existing project to the project dict project['last_updated'] = existing_project.last_updated @@ -373,6 +580,7 @@ def update_project_info(project): existing_value = existing_project.__dict__[project_key] if check_value and check_value != existing_value: spreadsheet_is_updated = True + project[project_key] = check_value elif not check_value and existing_value: project[project_key] = existing_value @@ -390,55 +598,53 @@ def update_project_info(project): if got.status_code in range(400, 499): if got.status_code == 404: - logging.error(repo_url + ' doesn\'t exist.') - # If its a bad GitHub link, don't return it at all. + # It's a bad GitHub link + logging.error(u"{} doesn't exist.".format(repo_url)) + # If there's an existing project in the database, get rid of it + if existing_project: + # this is redundant, but let's make sure + # :::here (project/false) + existing_project.keep = False + db.session.commit() + # Take the project out of the loop by returning None return None + elif got.status_code == 403: - logging.error("GitHub Rate Limit Remaining: " + str(got.headers["x-ratelimit-remaining"])) - error_dict = { - "error": u'IOError: We done got throttled by GitHub', - "time": datetime.now() - } - new_error = Error(**error_dict) - db.session.add(new_error) - # commit the error - db.session.commit() - github_throttling = True - return project + # Throttled by GitHub + if existing_project: + # :::here (project/true) + existing_project.keep = True + # commit the project + db.session.commit() + return None else: raise IOError # If the project has not been modified... elif got.status_code == 304: - logging.info('Project {} has not been modified since last update'.format(repo_url)) - - # Populate values from the civic.json if it exists/is updated - project, civic_json_is_updated = update_project_from_civic_json(project_dict=project, force=spreadsheet_is_updated) + logging.info(u'Project {} has not been modified since last update'.format(repo_url)) # if values have changed, copy untouched values from the existing project object and return it - if spreadsheet_is_updated or civic_json_is_updated: - logging.info('Project %s has been modified via spreadsheet or civic.json.', repo_url) - project['last_updated'] = datetime.now().strftime("%a, %d %b %Y %H:%M:%S %Z") + if spreadsheet_is_updated: + logging.info('Project %s has been modified via spreadsheet.', repo_url) project['github_details'] = existing_project.github_details return project # nothing was updated, but make sure we keep the project # :::here (project/true) existing_project.keep = True - db.session.add(existing_project) # commit the project db.session.commit() return None - # Save last_updated time header for future requests - project['last_updated'] = got.headers['Last-Modified'] - + # the project has been modified all_github_attributes = got.json() github_details = {} for field in ('contributors_url', 'created_at', 'forks_count', 'homepage', - 'html_url', 'id', 'language', 'open_issues', 'pushed_at', - 'updated_at', 'watchers_count', 'name', 'description', 'stargazers_count'): + 'html_url', 'id', 'open_issues', 'pushed_at', + 'updated_at', 'watchers_count', 'name', 'description', + 'stargazers_count', 'subscribers_count'): github_details[field] = all_github_attributes[field] github_details['owner'] = dict() @@ -457,15 +663,24 @@ def update_project_info(project): if 'link_url' not in project or not project['link_url']: project['link_url'] = all_github_attributes['homepage'] + project['last_updated'] = github_latest_update_time(github_details) + + # Grab the list of project languages + got = get_github_api(all_github_attributes['languages_url']) + languages_json = got.json() + if got.status_code // 100 == 2 and languages_json.keys(): + project['languages'] = languages_json.keys() + else: + project['languages'] = None + # # Populate project contributors from github_details[contributors_url] # project['github_details']['contributors'] = [] got = get_github_api(all_github_attributes['contributors_url']) - - # Check if there are contributors try: - for contributor in got.json(): + contributors_json = got.json() + for contributor in contributors_json: # we don't want people without email addresses? if contributor['login'] == 'invalid-email-address': break @@ -487,7 +702,8 @@ def update_project_info(project): # got = get_github_api(all_github_attributes['url'] + '/stats/participation') try: - project['github_details']['participation'] = got.json()['all'] + participation_json = got.json() + project['github_details']['participation'] = participation_json['all'] except: project['github_details']['participation'] = [0] * 50 @@ -496,8 +712,16 @@ def update_project_info(project): # project, civic_json_is_updated = update_project_from_civic_json(project_dict=project, force=spreadsheet_is_updated) + # Get the lastest commit status + # First build up the url to use + if "default_branch" in all_github_attributes: + commit_status_url = GITHUB_COMMIT_STATUS_URL.format(repo_path=path, default_branch=all_github_attributes['default_branch']) + got = get_github_api(commit_status_url) + project["commit_status"] = got.json().get('state', None) + return project + def extract_tag_value(tag_candidate): ''' Extract the value of a tag from a string or object. tag_candidate must be in the form of either u'tag value' or {'tag': u'tag value'} @@ -516,6 +740,7 @@ def extract_tag_value(tag_candidate): return None + def get_tags_from_civic_json_object(tags_in): ''' Extract and return tags in the correct format from the passed object ''' @@ -525,10 +750,9 @@ def get_tags_from_civic_json_object(tags_in): # get the tags extracted = [extract_tag_value(item) for item in tags_in] - # strip None values - stripped = [item for item in extracted if item is not None] - # return as a string - return u','.join(stripped) if len(stripped) else None + # strip None values and return as a list + return [item for item in extracted if item is not None] + def update_project_from_civic_json(project_dict, force=False): ''' Update and return the passed project dict with values from civic.json @@ -554,6 +778,7 @@ def update_project_from_civic_json(project_dict, force=False): return project_dict, is_updated + def get_issues_for_project(project): ''' get the issues for a single project in dict format without touching the database (used for testing) @@ -565,86 +790,102 @@ def get_issues_for_project(project): # Get github issues api url _, host, path, _, _, _ = urlparse(project.code_url) + path = sub(r"[\s\/]+?$", "", path) + # make sure we're working with the main github URL + path = make_root_github_project_path(path) issues_url = GITHUB_ISSUES_API_URL.format(repo_path=path) # Ping github's api for project issues got = get_github_api(issues_url, headers={'If-None-Match': project.last_updated_issues}) + if got.status_code // 100 != 2: + return issues # Save each issue in response - responses = get_adjoined_json_lists(got, headers={'If-None-Match': project.last_updated_issues}) + responses, _ = get_adjoined_json_lists(got, headers={'If-None-Match': project.last_updated_issues}) for issue in responses: # Type check the issue, we are expecting a dictionary if isinstance(issue, dict): # Pull requests are returned along with issues. Skip them. if "/pull/" in issue['html_url']: continue - issue_dict = dict(title=issue['title'], html_url=issue['html_url'], - body=issue['body'], project_id=project.id, labels=issue['labels']) + + issue_dict = dict(project_id=project.id) + for field in ( + 'title', 'html_url', 'body', + 'labels', 'created_at', 'updated_at'): + issue_dict[field] = issue.get(field, None) + issues.append(issue_dict) else: logging.error('Issue for project %s is not a dictionary', project.name) return issues -def get_issues(org_name): - ''' - Get github issues associated to each Organization's Projects. + +def get_issues(project): + ''' Get github issues associated with the passed Project. ''' issues = [] - # Only grab this organization's projects - projects = db.session.query(Project).filter(Project.organization_name == org_name).all() + # don't try to parse an empty code_url + if not project.code_url: + return issues - # Populate issues for each project - for project in projects: - # Mark this project's issues for deletion - # :::here (issue/false) - db.session.execute(db.update(Issue, values={'keep': False}).where(Issue.project_id == project.id)) + # Mark this project's issues for deletion + # :::here (issue/false) + db.session.execute(db.update(Issue, values={'keep': False}).where(Issue.project_id == project.id)) - # don't try to parse an empty code_url - if not project.code_url: - continue + # Get github issues api url + _, host, path, _, _, _ = urlparse(project.code_url) - # Get github issues api url - _, host, path, _, _, _ = urlparse(project.code_url) + # Only check issues if its a github project + if host != 'github.com': + return issues - # Only check issues if its a github project - if host != 'github.com': - continue + path = sub(r"[\s\/]+?$", "", path) + # make sure we're working with the main github URL + path = make_root_github_project_path(path) + issues_url = GITHUB_ISSUES_API_URL.format(repo_path=path) + + # Ping github's api for project issues + # :TODO: non-github projects are hitting here and shouldn't be! + got = get_github_api(issues_url, headers={'If-None-Match': project.last_updated_issues}) + + # A 304 means that issues have not been modified since we last checked + if got.status_code == 304: + # :::here (issue/true) + db.session.execute(db.update(Issue, values={'keep': True}).where(Issue.project_id == project.id)) + logging.info('Issues %s have not changed since last update', issues_url) + + elif got.status_code not in range(400, 499): + # Update the project's last_updated_issue field + project.last_updated_issues = unicode(got.headers['ETag']) + db.session.add(project) + + # Get all the pages of issues + responses, _ = get_adjoined_json_lists(got) + + # Save each issue in response + for issue in responses: + # Type check the issue, we are expecting a dictionary + if isinstance(issue, dict): + # Pull requests are returned along with issues. Skip them. + if "/pull/" in issue['html_url']: + continue + + issue_dict = dict(project_id=project.id) + for field in ( + 'title', 'html_url', 'body', + 'labels', 'created_at', 'updated_at'): + issue_dict[field] = issue.get(field, None) + + issues.append(issue_dict) + else: + logging.error('Issue for project %s is not a dictionary', project.name) - issues_url = GITHUB_ISSUES_API_URL.format(repo_path=path) - - # Ping github's api for project issues - # :TODO: non-github projects are hitting here and shouldn't be! - got = get_github_api(issues_url, headers={'If-None-Match': project.last_updated_issues}) - - # Verify that content has not been modified since last run - if got.status_code == 304: - # :::here (issue/true) - db.session.execute(db.update(Issue, values={'keep': True}).where(Issue.project_id == project.id)) - logging.info('Issues %s have not changed since last update', issues_url) - - elif got.status_code not in range(400, 499): - # Update project's last_updated_issue field - project.last_updated_issues = unicode(got.headers['ETag']) - db.session.add(project) - - responses = get_adjoined_json_lists(got, headers={'If-None-Match': project.last_updated_issues}) - - # Save each issue in response - for issue in responses: - # Type check the issue, we are expecting a dictionary - if isinstance(issue, dict): - # Pull requests are returned along with issues. Skip them. - if "/pull/" in issue['html_url']: - continue - issue_dict = dict(title=issue['title'], html_url=issue['html_url'], - body=issue['body'], project_id=project.id, labels=issue['labels']) - issues.append(issue_dict) - else: - logging.error('Issue for project %s is not a dictionary', project.name) return issues + def get_root_directory_listing_for_project(project_dict, force=False): ''' Get a listing of the project's github repo root directory. Will return an empty list if the listing hasn't changed since the last time we asked @@ -657,6 +898,9 @@ def get_root_directory_listing_for_project(project_dict, force=False): # Get the API URL _, host, path, _, _, _ = urlparse(project_dict['code_url']) + path = sub(r"[\s\/]+?$", "", path) + # make sure we're working with the main github URL + path = make_root_github_project_path(path) directory_url = GITHUB_CONTENT_API_URL.format(repo_path=path, file_path='') # Request the directory listing @@ -667,20 +911,21 @@ def get_root_directory_listing_for_project(project_dict, force=False): # Verify that content has not been modified since last run if got.status_code == 304: - logging.info('root directory listing has not changed since last update for {}'.format(directory_url)) + logging.info(u'root directory listing has not changed since last update for {}'.format(directory_url)) elif got.status_code not in range(400, 499): - logging.info('root directory listing has changed for {}'.format(directory_url)) + logging.info(u'root directory listing has changed for {}'.format(directory_url)) # Update the project's last_updated_root_files field project_dict['last_updated_root_files'] = unicode(got.headers['ETag']) # get the contents of the file listing = got.json() else: - logging.info('NO root directory listing found for {}'.format(directory_url)) + logging.info(u'NO root directory listing found for {}'.format(directory_url)) return listing + def get_civic_json_exists_for_project(project_dict, force=False): ''' Return True if the passed project has a civic.json file in its root directory. ''' @@ -688,6 +933,7 @@ def get_civic_json_exists_for_project(project_dict, force=False): exists = 'civic.json' in [item['name'] for item in directory_listing] return exists + def get_civic_json_for_project(project_dict, force=False): ''' Get the contents of the civic.json at the project's github repo root, if it exists. ''' @@ -699,6 +945,9 @@ def get_civic_json_for_project(project_dict, force=False): # Get the API URL (if 'code_url' wasn't in project_dict, it would've been caught upstream) _, host, path, _, _, _ = urlparse(project_dict['code_url']) + path = sub(r"[\s\/]+?$", "", path) + # make sure we're working with the main github URL + path = make_root_github_project_path(path) civic_url = GITHUB_CONTENT_API_URL.format(repo_path=path, file_path='civic.json') # Request the contents of the civic.json file @@ -711,23 +960,24 @@ def get_civic_json_for_project(project_dict, force=False): # Verify that content has not been modified since last run if got.status_code == 304: - logging.info('Unchanged civic.json at {}'.format(civic_url)) + logging.info(u'Unchanged civic.json at {}'.format(civic_url)) elif got.status_code not in range(400, 499): - logging.info('New civic.json at {}'.format(civic_url)) + logging.info(u'New civic.json at {}'.format(civic_url)) # Update the project's last_updated_civic_json field project_dict['last_updated_civic_json'] = unicode(got.headers['ETag']) try: # get the contents of the file civic = got.json() except ValueError: - logging.error('Malformed civic.json at {}'.format(civic_url)) + logging.error(u'Malformed civic.json at {}'.format(civic_url)) else: - logging.info('No civic.json at {}'.format(civic_url)) + logging.info(u'No civic.json at {}'.format(civic_url)) return civic + def count_people_totals(all_projects): ''' Create a list of people details based on project details. @@ -773,19 +1023,23 @@ def count_people_totals(all_projects): return users -def save_organization_info(session, org_dict): + +def save_organization_info(session, org_info): ''' Save a dictionary of organization info to the datastore session. Return an app.Organization instance. ''' + # Set any empty strings in org_info to None + org_info = {key: None if not value else value for (key, value) in org_info.iteritems()} + # Select an existing organization by name. - filter = Organization.name == org_dict['name'] + filter = Organization.name == org_info['name'] existing_org = session.query(Organization).filter(filter).first() # :::here (organization/true) # If this is a new organization, save and return it. The keep parameter is True by default. if not existing_org: - new_organization = Organization(**org_dict) + new_organization = Organization(**org_info) session.add(new_organization) return new_organization @@ -799,17 +1053,18 @@ def save_organization_info(session, org_dict): existing_org.keep = True # Update existing organization details. - for (field, value) in org_dict.items(): + for (field, value) in org_info.items(): setattr(existing_org, field, value) return existing_org + def save_project_info(session, proj_dict): ''' Save a dictionary of project info to the datastore session. Return an app.Project instance. ''' - # Select the current project, filtering on name AND organization. + # Select the current project, filtering on name and organization. filter = Project.name == proj_dict['name'], Project.organization_name == proj_dict['organization_name'] existing_project = session.query(Project).filter(*filter).first() @@ -829,47 +1084,54 @@ def save_project_info(session, proj_dict): return existing_project -def save_issue(session, issue): - ''' - Save a dictionary of issue info to the datastore session. + +def save_issue_info(session, issue_dict): + ''' Save a dictionary of issue info to the datastore session. + Return an app.Issue instance ''' - # Select the current issue, filtering on title AND project_id. - filter = Issue.title == issue['title'], Issue.project_id == issue['project_id'] + # Select the current issue, filtering on html_url and project id. + filter = Issue.html_url == issue_dict['html_url'], Issue.project_id == issue_dict['project_id'] existing_issue = session.query(Issue).filter(*filter).first() - # If this is a new issue save it + # If this is a new issue save and return it. if not existing_issue: - new_issue = Issue(**issue) + new_issue = Issue(**issue_dict) session.add(new_issue) - else: - # Preserve the existing issue. - # :::here (issue/true) - existing_issue.keep = True - # Update existing issue details - existing_issue.title = issue['title'] - existing_issue.body = issue['body'] - existing_issue.html_url = issue['html_url'] - existing_issue.project_id = issue['project_id'] - -def save_labels(session, issue): - ''' - Save labels to issues + return new_issue + + # Preserve the existing issue. + # :::here (issue/true) + existing_issue.keep = True + + # Update existing issue details, skipping 'labels' + for (field, value) in issue_dict.items(): + if field != 'labels': + setattr(existing_issue, field, value) + + return existing_issue + + +def save_labels_info(session, issue_dict): + ''' Save labels to issues ''' - # Select the current issue, filtering on title AND project_id. - filter = Issue.title == issue['title'], Issue.project_id == issue['project_id'] + # Select the current issue, filtering on html_url and project id. + filter = Issue.html_url == issue_dict['html_url'], Issue.project_id == issue_dict['project_id'] existing_issue = session.query(Issue).filter(*filter).first() # Get list of existing and incoming label names (dupes will be filtered out in comparison process) existing_label_names = [label.name for label in existing_issue.labels] - incoming_label_names = [label['name'] for label in issue['labels']] + incoming_label_names = [label['name'] for label in issue_dict['labels']] # Add labels that are in the incoming list and not the existing list add_label_names = list(set(incoming_label_names) - set(existing_label_names)) - for label_dict in issue['labels']: + for label_dict in issue_dict['labels']: if label_dict['name'] in add_label_names: # add the issue id to the labels label_dict["issue_id"] = existing_issue.id + # remove id and default from some labels + label_dict.pop("default", None) + label_dict.pop("id", None) new_label = Label(**label_dict) session.add(new_label) @@ -878,14 +1140,14 @@ def save_labels(session, issue): for label_name in delete_label_names: session.query(Label).filter(Label.issue_id == existing_issue.id, Label.name == label_name).delete() + def save_event_info(session, event_dict): ''' Save a dictionary of event into to the datastore session then return that event instance ''' # Select the current event, filtering on event_url and organization name. - filter = Event.event_url == event_dict['event_url'], \ - Event.organization_name == event_dict['organization_name'] + filter = Event.event_url == event_dict['event_url'], Event.organization_name == event_dict['organization_name'] existing_event = session.query(Event).filter(*filter).first() # If this is a new event, save and return it. @@ -902,14 +1164,16 @@ def save_event_info(session, event_dict): for (field, value) in event_dict.items(): setattr(existing_event, field, value) + return existing_event + + def save_story_info(session, story_dict): ''' Save a dictionary of story into to the datastore session then return that story instance ''' # Select the current story, filtering on link and organization name. - filter = Story.organization_name == story_dict['organization_name'], \ - Story.link == story_dict['link'] + filter = Story.organization_name == story_dict['organization_name'], Story.link == story_dict['link'] existing_story = session.query(Story).filter(*filter).first() @@ -927,7 +1191,16 @@ def save_story_info(session, story_dict): for (field, value) in story_dict.items(): setattr(existing_story, field, value) + return existing_story + + def get_event_group_identifier(events_url): + ''' Extract a group identifier from a meetup.com event URL + ''' + if 'meetup.com' not in events_url: + logging.error("Only Meetup.com events work right now.") + return None + parse_result = urlparse(events_url) url_parts = parse_result.path.split('/') identifier = url_parts.pop() @@ -939,50 +1212,60 @@ def get_event_group_identifier(events_url): return None -def get_attendance(peopledb, organization_url, organization_name): - ''' Get the attendance of an org from the peopledb ''' - - # Total attendance - q = ''' SELECT COUNT(*) AS total FROM attendance - WHERE organization_url = %s ''' - peopledb.execute(q,(organization_url,)) - total = int(peopledb.fetchone()["total"]) - - # weekly attendance - q = ''' SELECT COUNT(*) AS total, - to_char(datetime, 'YYYY WW') AS week - FROM attendance - WHERE organization_url = %s - GROUP BY week ''' - peopledb.execute(q,(organization_url,)) - weekly = peopledb.fetchall() - weekly = { week["week"] : int(week["total"]) for week in weekly } - - attendance = { - "organization_name" : organization_name, - "organization_url" : organization_url, - "total" : total, - "weekly" : weekly - } - - return attendance - -def update_attendance(db, organization_name, attendance): - ''' Update exisiting attendance ''' + +def update_attendance(session, organization_name, attendance_dict): + ''' Update exisiting attendance + ''' + # Select the current attendance, filtering on organization filter = Attendance.organization_name == organization_name - existing_attendance = db.session.query(Attendance).filter(filter).first() - if existing_attendance: - existing_attendance.total = attendance["total"] - existing_attendance.weekly = attendance["weekly"] - db.session.add(existing_attendance) - else: - new_att = Attendance(**attendance) - db.session.add(new_att) - db.session.commit() + existing_attendance = session.query(Attendance).filter(filter).first() + + # if this is a new attendance, save and return it + if not existing_attendance: + new_attendance = Attendance(**attendance_dict) + session.add(new_attendance) + return new_attendance + + # Update existing attendance details + existing_attendance.total = attendance_dict["total"] + existing_attendance.weekly = attendance_dict["weekly"] + + return existing_attendance + + +def get_logo(org_info): + ''' + get an organization's logo, looking first at 'logo_url' in the JSON and + then Github (project lists url) + ''' + # allow specifying a logo_url in the json file + if 'logo_url' in org_info: + return org_info['logo_url'] + + if 'projects_list_url' not in org_info: + return None + + github_username = parse_github_user(org_info['projects_list_url']) + if github_username: + # NOTE: This uses the /users/:id API endpoint to handle both cases + # where the brigade's profile is a single user account or an + # organizational account. + request_url = GITHUB_USER_API_URL.format(username=github_username) + got = get_github_api(request_url) + if got.status_code == 404: + logger.error("Got 404 for GitHub username " + github_username) + return + + try: + github_response = got.json() + return github_response['avatar_url'] + except ValueError: + logger.error("Malformed GitHub JSON fetching organization URL for " + github_username) + return def main(org_name=None, org_sources=None): - ''' Run update over all organizations. Optionally, update just one. + ''' Update the API's database ''' # set org_sources org_sources = org_sources or ORG_SOURCES_FILENAME @@ -994,15 +1277,19 @@ def main(org_name=None, org_sources=None): orgs_info = get_organizations(org_sources) shuffle(orgs_info) + # Prioritize updating official CfA brigades' organizations first. + orgs_info.sort(cmp=lambda b1, b2: -1 if is_official_brigade(b1) else 0) + + # If an organization name was passed, filter. if org_name: orgs_info = [org for org in orgs_info if org['name'] == org_name] - # Iterate over organizations and projects, saving them to db.session. + # Retrieve and save all information about the organizations for org_info in orgs_info: if not is_safe_name(org_info['name']): error_dict = { - "error": unicode('ValueError: Bad organization name: "%s"' % org_info['name']), + "error": unicode('ValueError: Bad organization name: "{}"'.format(org_info['name'])), "time": datetime.now() } new_error = Error(**error_dict) @@ -1011,98 +1298,87 @@ def main(org_name=None, org_sources=None): db.session.commit() continue - try: - filter = Organization.name == org_info['name'] - existing_org = db.session.query(Organization).filter(filter).first() + # don't try to process orgs if we're throttled + if GITHUB_THROTTLING: organization_names.add(org_info['name']) + continue - # Mark everything associated with this organization for deletion at first. + try: + # Mark everything associated with this organization for deletion # :::here (event/false, story/false, project/false, organization/false) db.session.execute(db.update(Event, values={'keep': False}).where(Event.organization_name == org_info['name'])) db.session.execute(db.update(Story, values={'keep': False}).where(Story.organization_name == org_info['name'])) db.session.execute(db.update(Project, values={'keep': False}).where(Project.organization_name == org_info['name'])) db.session.execute(db.update(Organization, values={'keep': False}).where(Organization.name == org_info['name'])) - # commit the false keeps - db.session.commit() - # Empty lat longs are okay. - if 'latitude' in org_info: - if not org_info['latitude']: - org_info['latitude'] = None - if 'longitude' in org_info: - if not org_info['longitude']: - org_info['longitude'] = None + # ORGANIZATION INFO + # Save or update the organization + org_info.update({'logo_url': get_logo(org_info)}) organization = save_organization_info(db.session, org_info) - organization_names.add(organization.name) - # flush the organization - db.session.flush() + # commit the organization and the false keeps + db.session.commit() + + + # STORIES if organization.rss or organization.website: - logging.info("Gathering all of %s's stories." % organization.name) + logging.info(u"Gathering all of {}'s stories.".format(organization.name)) stories = get_stories(organization) - if stories: - for story_info in stories: - save_story_info(db.session, story_info) - # flush the stories - db.session.flush() + # build and commit stories + for story_info in stories: + save_story_info(db.session, story_info) + db.session.commit() + # PROJECTS, ISSUES and LABELS if organization.projects_list_url: - logging.info("Gathering all of %s's projects." % organization.name) + logging.info(u"Gathering all of {}'s projects.".format(organization.name)) projects = get_projects(organization) + # build and commit projects for proj_dict in projects: - save_project_info(db.session, proj_dict) - # flush the projects - db.session.flush() - + saved_project = save_project_info(db.session, proj_dict) + db.session.commit() + + logging.info(u'Gathering all issues for this {} project: {}.'.format(organization.name, saved_project.name)) + issues = get_issues(saved_project) + # build and commit issues and labels + for issue_dict in issues: + save_issue_info(db.session, issue_dict) + db.session.commit() + save_labels_info(db.session, issue_dict) + db.session.commit() + + # EVENTS if organization.events_url: - if not meetup_key: - logging.error("No Meetup.com key set.") - if 'meetup.com' not in organization.events_url: - logging.error("Only Meetup.com events work right now.") + logging.info(u"Gathering all of {}'s events.".format(organization.name)) + identifier = get_event_group_identifier(organization.events_url) + if identifier: + # build and commit events + for event in get_meetup_events(organization, identifier): + save_event_info(db.session, event) + db.session.commit() + + # Get and save the meetup.com member count for this organization + members = get_meetup_count(organization, identifier) + # Don't overwrite the old value if we got None back + if members: + organization.member_count = members + db.session.commit() + else: - logging.info("Gathering all of %s's events." % organization.name) - identifier = get_event_group_identifier(organization.events_url) - if identifier: - for event in get_meetup_events(organization, identifier): - save_event_info(db.session, event) - # flush the events - db.session.flush() - else: - logging.error("%s does not have a valid events url" % organization.name) - - # Get issues for all of the projects - logging.info("Gathering all of %s's open GitHub issues." % organization.name) - issues = get_issues(organization.name) - for issue in issues: - save_issue(db.session, issue) - - # flush the issues - db.session.flush() - for issue in issues: - save_labels(db.session, issue) - - # Get attendance data - with connect(os.environ["PEOPLEDB"]) as conn: - with conn.cursor(cursor_factory=extras.RealDictCursor) as peopledb: - cfapi_url = "https://www.codeforamerica.org/api/organizations/" - organization_url = cfapi_url + organization.api_id() - attendance = get_attendance(peopledb, organization_url, organization.name) - - if attendance: - update_attendance(db, organization.name, attendance) - - # commit everything - db.session.commit() + logging.error(u'{} does not have a valid events url'.format(organization.name)) # Remove everything marked for deletion. # :::here (event/delete, story/delete, project/delete, issue/delete, organization/delete) - db.session.query(Event).filter(Event.keep == False).delete() - db.session.query(Story).filter(Story.keep == False).delete() - db.session.query(Issue).filter(Issue.keep == False).delete() - db.session.query(Project).filter(Project.keep == False).delete() - db.session.query(Organization).filter(Organization.keep == False).delete() + num_events = db.session.query(Event).filter(Event.keep == False).delete() + num_stories = db.session.query(Story).filter(Story.keep == False).delete() + num_issues = db.session.query(Issue).filter(Issue.keep == False).delete() + num_projects = db.session.query(Project).filter(Project.keep == False).delete() + num_orgs = db.session.query(Organization).filter(Organization.keep == False).delete() + + logging.info(u'Deleted {} organizations, {} projects, {} issues, {} stories, {} events'.format(num_orgs, num_projects, num_issues, num_stories, num_events)) + # commit objects deleted for keep=False db.session.commit() @@ -1126,11 +1402,22 @@ def main(org_name=None, org_sources=None): # commit for deleting orphaned organizations db.session.commit() + parser = ArgumentParser(description='''Update database from CSV source URL.''') parser.add_argument('--name', dest='name', help='Single organization name to update.') -parser.add_argument('--test', action='store_const', dest='org_sources', const=TEST_ORG_SOURCES_FILENAME, help='Use the testing list of organizations.') +parser.add_argument('--sources', dest='sources', help='URL of an organization sources JSON file.') +parser.add_argument('--test', action='store_const', dest='test_sources', const=TEST_ORG_SOURCES_FILENAME, help='Use the testing list of organizations.') if __name__ == "__main__": args = parser.parse_args() org_name = args.name and args.name.decode('utf8') or '' - main(org_name=org_name, org_sources=args.org_sources) + org_sources = args.sources and args.sources.decode('utf8') or '' + if args.test_sources and not org_sources: + org_sources = args.test_sources + + try: + main(org_name=org_name, org_sources=org_sources) + except: + if SENTRY: + SENTRY.captureException() + raise diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..f27f1cc --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-2.7.15 diff --git a/scripts/civicjson_stats.py b/scripts/civicjson_stats.py index e42c341..0fac59c 100644 --- a/scripts/civicjson_stats.py +++ b/scripts/civicjson_stats.py @@ -1,34 +1,35 @@ from requests import get -from time import sleep import json -civicjson_urls = ["https://raw.githubusercontent.com/rasmi/my-neighborhood/master/civic.json", -"https://raw.githubusercontent.com/BetaNYC/civic.json/master/civic.json", -"https://raw.githubusercontent.com/ameensol/dataExplorer/master/civic.json", -"https://raw.githubusercontent.com/ameensol/dataExplorerAPI/master/civic.json", -"https://raw.githubusercontent.com/BetaNYC/betanyc-support-ribbon-css/master/civic.json", -"https://raw.githubusercontent.com/BetaNYC/NY-Waterways-GTFS-data/master/civic.json", -"https://raw.githubusercontent.com/rasmi/homeless-nyc/master/civic.json", -"https://raw.githubusercontent.com/MTA-Service-Alerts-beta-nyc/service-alerts/master/civic.json", -"https://raw.githubusercontent.com/seanluciotolentino/dangerous-intersections/master/civic.json", -"https://raw.githubusercontent.com/clhenrick/am-i-rent-stabilized/master/civic.json", -"https://raw.githubusercontent.com/josselinphilippe/bagitnyc/master/civic.json", -"https://raw.githubusercontent.com/hondacivicapps/hondacivicapps.github.io/master/civic.json", -"https://raw.githubusercontent.com/codefordc/guides/master/civic.json", -"https://raw.githubusercontent.com/DangerousRDNYC/DangerousRDNYC/master/civic.json", -"https://raw.githubusercontent.com/Emrals/Emrals-Android/master/civic.json", -"https://raw.githubusercontent.com/codefordc/dc-campaign-finance-watch/master/civic.json", -"https://raw.githubusercontent.com/rasmi/crime-nyc/master/civic.json", -"https://raw.githubusercontent.com/codefordc/open211/master/civic.json", -"https://raw.githubusercontent.com/codefordc/ancfinder/master/civic.json", -"https://raw.githubusercontent.com/codefordc/codefordc-2.0/master/civic.json", -"https://raw.githubusercontent.com/codefordc/districthousing/master/civic.json", -"https://raw.githubusercontent.com/childcaremap/NYCdaycare/master/civic.json", -"https://raw.githubusercontent.com/NYPDVisionZeroAccountability/compstat-vs-moving-violation-enforcement/master/civic.json", -"https://raw.githubusercontent.com/BetaNYC/Bike-Share-Data-Best-Practices/master/civic.json", -"https://raw.githubusercontent.com/BetaNYC/budgetBuddy/master/civic.json", -"https://raw.githubusercontent.com/talos/acris-bigquery/master/civic.json", -"https://raw.githubusercontent.com/camsys/onebusaway-nyc-atstop/master/civic.json"] +civicjson_urls = [ + "https://raw.githubusercontent.com/rasmi/my-neighborhood/master/civic.json", + "https://raw.githubusercontent.com/BetaNYC/civic.json/master/civic.json", + "https://raw.githubusercontent.com/ameensol/dataExplorer/master/civic.json", + "https://raw.githubusercontent.com/ameensol/dataExplorerAPI/master/civic.json", + "https://raw.githubusercontent.com/BetaNYC/betanyc-support-ribbon-css/master/civic.json", + "https://raw.githubusercontent.com/BetaNYC/NY-Waterways-GTFS-data/master/civic.json", + "https://raw.githubusercontent.com/rasmi/homeless-nyc/master/civic.json", + "https://raw.githubusercontent.com/MTA-Service-Alerts-beta-nyc/service-alerts/master/civic.json", + "https://raw.githubusercontent.com/seanluciotolentino/dangerous-intersections/master/civic.json", + "https://raw.githubusercontent.com/clhenrick/am-i-rent-stabilized/master/civic.json", + "https://raw.githubusercontent.com/josselinphilippe/bagitnyc/master/civic.json", + "https://raw.githubusercontent.com/hondacivicapps/hondacivicapps.github.io/master/civic.json", + "https://raw.githubusercontent.com/codefordc/guides/master/civic.json", + "https://raw.githubusercontent.com/DangerousRDNYC/DangerousRDNYC/master/civic.json", + "https://raw.githubusercontent.com/Emrals/Emrals-Android/master/civic.json", + "https://raw.githubusercontent.com/codefordc/dc-campaign-finance-watch/master/civic.json", + "https://raw.githubusercontent.com/rasmi/crime-nyc/master/civic.json", + "https://raw.githubusercontent.com/codefordc/open211/master/civic.json", + "https://raw.githubusercontent.com/codefordc/ancfinder/master/civic.json", + "https://raw.githubusercontent.com/codefordc/codefordc-2.0/master/civic.json", + "https://raw.githubusercontent.com/codefordc/districthousing/master/civic.json", + "https://raw.githubusercontent.com/childcaremap/NYCdaycare/master/civic.json", + "https://raw.githubusercontent.com/NYPDVisionZeroAccountability/compstat-vs-moving-violation-enforcement/master/civic.json", + "https://raw.githubusercontent.com/BetaNYC/Bike-Share-Data-Best-Practices/master/civic.json", + "https://raw.githubusercontent.com/BetaNYC/budgetBuddy/master/civic.json", + "https://raw.githubusercontent.com/talos/acris-bigquery/master/civic.json", + "https://raw.githubusercontent.com/camsys/onebusaway-nyc-atstop/master/civic.json" +] key_counts = { # key : count diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9cea7b6 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[flake8] +# Ignore: +# E501 = line too long +# E711 = comparison to None should be 'if cond is None: +# E712 = comparison to True should be ‘if cond is True:’ or ‘if cond:’ +ignore = E501,E711,E712 +exclude = migrations,test diff --git a/templates/index.html b/templates/index.html index 716e099..b226f0c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2,16 +2,6 @@ - - - - - - Civic Tech Movement API @@ -73,6 +63,9 @@

Url parameters

per_page (integer)
The number of results to return per page:

/api/organizations?per_page=5

+
tags(array of strings)
+
An array of tags to filter the results by. For example, to select only the Code for America Brigades:

+ /api/organizations?tags[]=Code%20for%20America&tags[]=Brigade

Organization properties
You can add any of the Organization properties as a parameter and the API will filter by organizations that have that property.
@@ -141,7 +134,7 @@

Retrieve a list of organizations, in GeoJSON format - for geographic applications.. + for geographic applications.

Endpoint

@@ -340,10 +333,11 @@

Sample Response

"github_details": { … }, "organization": { … }, "organization_name": "Code for America", - "tags": "community engagement, housing", + "tags": ["community engagement", "housing"], "type": "web service", "status": "In Progress", - "issues": [ … ] + "issues": [ … ], + "commit_status": "success" }, { "id": 2, @@ -376,10 +370,11 @@

Sample Response

"type": "Brigade, Code for All" }, "organization_name": "Philly", - "tags": "neighborhoods, news" + "tags": ["neighborhoods", "news"], "type": "", "status": "", - "issues": [ … ] + "issues": [ … ], + "commit_status": "success" }, { "id": 2, @@ -413,6 +408,8 @@

Numeric ID.
name
Name.
+
languages
+
A list of programming langauges used in the project
link_url
Homepage.
code_url
@@ -436,6 +433,8 @@

String with short description of project status.
issues
A list of project issues.
+
commit_status
+
The status of the latest commit. More info
@@ -453,7 +452,7 @@

Sample Response

"description": "A place-based call-in system for gathering and sharing community feedback", "organization": { … }, "organization_name": "Code for America", - "tags": "community engagement, housing", + "tags": ["community engagement", "housing"], "type": "web service", "github_details": { @@ -465,7 +464,6 @@

Sample Response

"forks_count": 18, "homepage": "http://www.cityvoiceapp.com/", "html_url": "https://github.com/codeforamerica/cityvoice", - "language": "Ruby", "name": "cityvoice", "open_issues": 38, "owner": { … }, @@ -493,6 +491,11 @@

Sample Response

"project_name": "cityvoice", }, { … } + ], + "languages" : [ + "Python", + "CSS", + "HTML" ] }
@@ -511,7 +514,7 @@

Endpoints

/api/events
/api/events/upcoming_events
- /api/events/past_events
+ /api/events/past_events
/api/organizations/{organization id}/events
/api/organizations/{organization id}/upcoming_events
/api/organizations/{organization id}/past_events @@ -537,9 +540,9 @@

Response Properties

Event filters

upcoming_events
-
Only returns events happening in the future.
+
Only returns events happening presently or in the future.
past_events
-
Only returns events that have already happened.
+
Only returns events that have already ended.

Sample Request

@@ -778,7 +781,7 @@

Response Properties

Issue filters

labels (comma separated)
-
Return issues that have atleast one of the given labels
+
Return issues that have at least one of the given labels

Sample Request

diff --git a/test/factories.py b/test/factories.py index 990eb03..88cb201 100644 --- a/test/factories.py +++ b/test/factories.py @@ -33,10 +33,12 @@ class ProjectFactory(SQLAlchemyModelFactory): description = u'This is a description' type = factory.LazyAttribute(lambda n: choice([u'web service', u'api', u'data standard'])) categories = factory.LazyAttribute(lambda n: choice([u'housing', u'community engagement', u'criminal justice', u'education'])) - tags = factory.LazyAttribute(lambda n: choice([u'civic', u'mapping'])) + tags = [u'what', u'ever', u'', u'†≈ç®åz¥≈†'] github_details = {'repo': u'git@github.com:codeforamerica/civic-project.git'} organization_name = factory.LazyAttribute(lambda e: OrganizationFactory().name) status = u'Project status' + languages = [u'Python', u'CSS'] + last_updated = factory.LazyAttribute(lambda o: datetime.utcnow()) class EventFactory(SQLAlchemyModelFactory): FACTORY_FOR = Event @@ -49,11 +51,16 @@ class EventFactory(SQLAlchemyModelFactory): location = u'155 9th St., San Francisco, CA' now = factory.LazyAttribute(lambda o: datetime.utcnow()) - start_time_notz = factory.LazyAttribute(lambda o: o.now + timedelta(hours=10)) - end_time_notz = factory.LazyAttribute(lambda o: o.now + timedelta(hours=12)) - utc_offset = -28800 + start_time_notz = factory.LazyAttribute(lambda o: o.now - timedelta(hours=10)) + end_time_notz = factory.LazyAttribute(lambda o: o.start_time_notz + timedelta(hours=3)) + utc_offset = -28800 # 8 hours created_at = factory.LazyAttribute(lambda o: o.now) organization_name = factory.LazyAttribute(lambda e: OrganizationFactory().name) + lat = 37.7749 + lon = -122.4194 + + rsvps = 1234 + class StoryFactory(SQLAlchemyModelFactory): FACTORY_FOR = Story @@ -71,6 +78,8 @@ class IssueFactory(SQLAlchemyModelFactory): title = factory.Sequence(lambda n: u'Civic Issue {0}'.format(n)) html_url = factory.Sequence(lambda n: u'http://www.github.com/codeforamerica/cfapi/issues/{0}'.format(n)) body = factory.Sequence(lambda n: u'Civic Issue blah blah blah {0}'.format(n)) + created_at = factory.LazyAttribute(lambda o: datetime.utcnow()) + updated_at = factory.LazyAttribute(lambda o: datetime.utcnow()) project_id = factory.LazyAttribute(lambda e: ProjectFactory().id) @@ -87,11 +96,11 @@ class AttendanceFactory(SQLAlchemyModelFactory): FACTORY_SESSION = db.session organization_name = factory.LazyAttribute(lambda e: OrganizationFactory().name) - organization_url = "https://www.codeforamerica.org/api/organizations/" + str(factory.LazyAttribute(lambda e: OrganizationFactory().name)).replace(" ","-") - total = randint(1,1000) + organization_url = "https://www.codeforamerica.org/api/organizations/" + str(factory.LazyAttribute(lambda e: OrganizationFactory().name)).replace(" ", "-") + total = randint(1, 1000) weekly = { - "2014 01" : randint(1,50), - "2014 02" : randint(1,50), - "2015 01" : randint(1,50), - "2015 02" : randint(1,50) + "2014 01": randint(1, 50), + "2014 02": randint(1, 50), + "2015 01": randint(1, 50), + "2015 02": randint(1, 50) } diff --git a/test/integration/test_attendance.py b/test/integration/test_attendance.py index f7f8d8d..b1827e8 100644 --- a/test/integration/test_attendance.py +++ b/test/integration/test_attendance.py @@ -8,12 +8,16 @@ class TestAttendance(IntegrationTest): def test_attendance(self): - cfsf = OrganizationFactory(name="Code for San Francisco") - url = "https://www.codeforamerica.org/api/organizations/Code-for-San-Francisco" - cfsf_att = AttendanceFactory(organization_name="Code for San Francisco", organization_url=url) - oakland = OrganizationFactory(name="Open Oakland") - url = "https://www.codeforamerica.org/api/organizations/Open-Oakland" - oakland_att = AttendanceFactory(organization_name="Open Oakland", organization_url=url) + cfsf = OrganizationFactory(name=u"Code for San Francisco") + url = u"https://www.codeforamerica.org/api/organizations/Code-for-San-Francisco" + cfsf_att = AttendanceFactory(organization_name=u"Code for San Francisco", organization_url=url) + oakland = OrganizationFactory(name=u"Open Oakland") + url = u"https://www.codeforamerica.org/api/organizations/Open-Oakland" + oakland_att = AttendanceFactory(organization_name=u"Open Oakland", organization_url=url) + db.session.add(cfsf) + db.session.add(cfsf_att) + db.session.add(oakland) + db.session.add(oakland_att) db.session.commit() response = self.app.get('/api/attendance') @@ -34,33 +38,31 @@ def test_attendance(self): weekly[week] += att.weekly[week] else: weekly[week] = att.weekly[week] - self.assertEqual(response["total"],total) - self.assertEqual(response["weekly"],weekly) - + self.assertEqual(response["total"], total) + self.assertEqual(response["weekly"], weekly) def test_orgs_attendance(self): - OrganizationFactory(name="Code for San Francisco") - url = "https://www.codeforamerica.org/api/organizations/Code-for-San-Francisco" - AttendanceFactory(organization_name="Code for San Francisco", organization_url=url) - OrganizationFactory(name="Open Oakland") - url = "https://www.codeforamerica.org/api/organizations/Open-Oakland" - AttendanceFactory(organization_name="Open Oakland", organization_url=url) + OrganizationFactory(name=u"Code for San Francisco") + url = u"https://www.codeforamerica.org/api/organizations/Code-for-San-Francisco" + AttendanceFactory(organization_name=u"Code for San Francisco", organization_url=url) + OrganizationFactory(name=u"Open Oakland") + url = u"https://www.codeforamerica.org/api/organizations/Open-Oakland" + AttendanceFactory(organization_name=u"Open Oakland", organization_url=url) db.session.commit() response = self.app.get('/api/organizations/attendance') self.assertEquals(response.status_code, 200) response = json.loads(response.data) - self.assertIsInstance(response, list) - self.assertTrue("organization_name" in response[0].keys()) - self.assertTrue("cfapi_url" in response[0].keys()) - self.assertTrue("total" in response[0].keys()) - self.assertTrue("weekly" in response[0].keys()) - + self.assertIsInstance(response, dict) + self.assertTrue("organization_name" in response['organizations'][0].keys()) + self.assertTrue("cfapi_url" in response['organizations'][0].keys()) + self.assertTrue("total" in response['organizations'][0].keys()) + self.assertTrue("weekly" in response['organizations'][0].keys()) def test_org_attendance(self): - OrganizationFactory(name="Code for San Francisco") - url = "https://www.codeforamerica.org/api/organizations/Code-for-San-Francisco" - AttendanceFactory(organization_name="Code for San Francisco", organization_url=url) + OrganizationFactory(name=u"Code for San Francisco") + url = u"https://www.codeforamerica.org/api/organizations/Code-for-San-Francisco" + AttendanceFactory(organization_name=u"Code for San Francisco", organization_url=url) db.session.commit() response = self.app.get('/api/organizations/Code-for-San-Francisco/attendance') @@ -71,4 +73,3 @@ def test_org_attendance(self): self.assertTrue("cfapi_url" in response.keys()) self.assertTrue("total" in response.keys()) self.assertTrue("weekly" in response.keys()) - diff --git a/test/integration/test_events.py b/test/integration/test_events.py index 0ceb1a2..b28aba7 100644 --- a/test/integration/test_events.py +++ b/test/integration/test_events.py @@ -156,6 +156,7 @@ def test_events(self): assert isinstance(response['objects'][0]['organization'], dict) assert isinstance(response['objects'][0]['organization_name'], unicode) assert isinstance(response['objects'][0]['start_time'], unicode) + assert isinstance(response['objects'][0]['rsvps'], int) def test_past_events(self): ''' @@ -218,3 +219,27 @@ def test_events_query_filter(self): response = json.loads(response.data) self.assertEqual(response['total'], 1) self.assertEqual(response['objects'][0]['name'], u'Awesome event') + + + def test_rsvp_routes(self): + org = OrganizationFactory(name=u"Code for San Francisco") + another_org = OrganizationFactory(type=u'Code for All') + awesome_event = EventFactory(name=u'Awesome event') + sad_event = EventFactory(name=u'Sad event', description=u'sad stuff will happen') + + awesome_event.organization = org + sad_event.organization = another_org + + db.session.commit() + + # Make sure total number rsvps is 2468 + response = self.app.get('/api/events/rsvps') + response = json.loads(response.data) + self.assertEqual(response["total"], 2468) + + # Make sure org number rsvps is 1234 + response = self.app.get('/api/organizations/Code-for-San-Francisco/events/rsvps') + response = json.loads(response.data) + self.assertEqual(response["total"], 1234) + + diff --git a/test/integration/test_issues.py b/test/integration/test_issues.py index bbccd65..303591c 100644 --- a/test/integration/test_issues.py +++ b/test/integration/test_issues.py @@ -18,7 +18,7 @@ def test_issues(self): project = ProjectFactory(organization_name=organization.name) db.session.add(project) db.session.commit() - issue = IssueFactory(project_id=project.id, title=u'TEST ISSUE', body=u'TEST ISSUE BODY') + issue = IssueFactory(project_id=project.id, title=u'TEST ISSUE', body=u'TEST ISSUE BODY', created_at="2013-06-06T00:12:30Z", updated_at="2014-02-21T20:43:16Z") db.session.add(issue) db.session.commit() @@ -29,6 +29,8 @@ def test_issues(self): self.assertEqual(response['total'], 1) self.assertEqual(response['objects'][0]['title'], u'TEST ISSUE') self.assertEqual(response['objects'][0]['body'], u'TEST ISSUE BODY') + self.assertEqual(response['objects'][0]['created_at'], u'2013-06-06T00:12:30Z') + self.assertEqual(response['objects'][0]['updated_at'], u'2014-02-21T20:43:16Z') # Check for linked issues in linked project self.assertTrue('project' in response['objects'][0]) @@ -42,7 +44,6 @@ def test_issues(self): self.assertEqual(response.status_code, 200) response = json.loads(response.data) self.assertTrue('project' in response) - self.assertTrue('issues' not in response['project']) def test_issues_with_labels(self): ''' diff --git a/test/integration/test_organizations.py b/test/integration/test_organizations.py index 403a2a0..d85eed7 100644 --- a/test/integration/test_organizations.py +++ b/test/integration/test_organizations.py @@ -1,3 +1,4 @@ +# -- coding: utf-8 -- import json from datetime import datetime, timedelta import time @@ -53,18 +54,33 @@ def test_orgs_projects_order(self): def test_current_events(self): """ - The three soonest upcoming events should be returned. + The two soonest upcoming events should be returned. If there are no events in the future, no events will be returned """ # Assuming today is Christmas... organization = OrganizationFactory(name=u'Collective of Ericas') db.session.flush() - # Create multiple events, some in the future, one in the past - EventFactory(organization_name=organization.name, name=u'Christmas Eve', start_time_notz=datetime.now() - timedelta(1)) - EventFactory(organization_name=organization.name, name=u'New Years', start_time_notz=datetime.now() + timedelta(7)) - EventFactory(organization_name=organization.name, name=u'MLK Day', start_time_notz=datetime.now() + timedelta(25)) - EventFactory(organization_name=organization.name, name=u'Cesar Chavez Day', start_time_notz=datetime.now() + timedelta(37)) + event_utc_offset = EventFactory.attributes()['utc_offset'] + now = datetime.utcnow() + now_notz = now + timedelta(seconds=event_utc_offset) + + # Create multiple events, some in the very near future, one in the very recent past + EventFactory(organization_name=organization.name, + name=u'Christmas Eve', + start_time_notz=now_notz - timedelta(hours=3), + end_time_notz=now_notz - timedelta(seconds=1)) + EventFactory(organization_name=organization.name, + name=u'New Years', + start_time_notz=now_notz - timedelta(hours=2), + end_time_notz=now_notz + timedelta(seconds=1)) + EventFactory(organization_name=organization.name, + name=u'MLK Day', + start_time_notz=now_notz + timedelta(days=7)) + EventFactory(organization_name=organization.name, + name=u'Cesar Chavez Day', + start_time_notz=now_notz + timedelta(days=30)) + db.session.commit() response = self.app.get('/api/organizations/Collective%20of%20Ericas') @@ -241,6 +257,25 @@ def test_org_search_existing_phrase(self): self.assertEqual(response['total'], 1) self.assertEqual(len(response['objects']), 1) + def test_org_search_escaped_phrase(self): + OrganizationFactory( + name=u'Cöde%%for \'Ameriça', + ) + db.session.commit() + response = self.app.get('/api/organizations?q=\'Ameriça') + response = json.loads(response.data) + assert isinstance(response['total'], int) + assert isinstance(response['objects'], list) + self.assertEqual(response['total'], 1) + self.assertEqual(len(response['objects']), 1) + + response = self.app.get('/api/organizations?q=Cöde%') + response = json.loads(response.data) + assert isinstance(response['total'], int) + assert isinstance(response['objects'], list) + self.assertEqual(response['total'], 1) + self.assertEqual(len(response['objects']), 1) + def test_org_search_existing_part_of_phrase(self): OrganizationFactory( name=u'Code for San Francisco', @@ -319,8 +354,8 @@ def test_organization_query_filter(self): ''' Test that organization query params work as expected. ''' - OrganizationFactory(name=u'Brigade Organization', type=u'Brigade') - OrganizationFactory(name=u'Bayamon Organization', type=u'Brigade', city=u'Bayamon, PR') + OrganizationFactory(name=u'Brigade Organization', type=u'Brigade', tags=['Brigade', 'Official']) + OrganizationFactory(name=u'Bayamon Organization', type=u'Brigade', city=u'Bayamon, PR', tags=['Brigade']) OrganizationFactory(name=u'Meetup Organization', type=u'Meetup') db.session.commit() @@ -343,6 +378,68 @@ def test_organization_query_filter(self): response = json.loads(response.data) self.assertEqual(response['total'], 0) + # Test tag-based filtering: + response = self.app.get('/api/organizations?tags[]=Brigade') + self.assertEqual(response.status_code, 200) + response = json.loads(response.data) + self.assertEqual(response['total'], 2) + self.assertEqual(response['objects'][0]['name'], u'Brigade Organization') + self.assertEqual(response['objects'][1]['name'], u'Bayamon Organization') + + response = self.app.get('/api/organizations?tags[]=Brigade&tags[]=Official') + self.assertEqual(response.status_code, 200) + response = json.loads(response.data) + self.assertEqual(response['total'], 1) + self.assertEqual(response['objects'][0]['name'], u'Brigade Organization') + + def test_organization_query_filter_with_unescaped_characters(self): + ''' Test that organization query params with unescaped characters work as expected. + ''' + OrganizationFactory(name=u'Code for Addis Ababa', type=u'Code for All', city=u'Addis Ababa') + OrganizationFactory(name=u'Code for Ponta Grossa', type=u'Code for All', city=u'Ponta Grossa, PR') + OrganizationFactory(name=u'USDS', type=u'Government', city=u'Washington, DC') + + db.session.commit() + + response = self.app.get('/api/organizations?type=Code%20for%20All') + self.assertEqual(response.status_code, 200) + response = json.loads(response.data) + self.assertEqual(response['total'], 2) + self.assertEqual(response['objects'][0]['name'], u'Code for Addis Ababa') + self.assertEqual(response['objects'][1]['name'], u'Code for Ponta Grossa') + + response = self.app.get('/api/organizations?type=Code+for+All') + self.assertEqual(response.status_code, 200) + response = json.loads(response.data) + self.assertEqual(response['total'], 2) + self.assertEqual(response['objects'][0]['name'], u'Code for Addis Ababa') + self.assertEqual(response['objects'][1]['name'], u'Code for Ponta Grossa') + + response = self.app.get('/api/organizations?type=Code%2Bfor%2BAll') + self.assertEqual(response.status_code, 200) + response = json.loads(response.data) + self.assertEqual(response['total'], 2) + self.assertEqual(response['objects'][0]['name'], u'Code for Addis Ababa') + self.assertEqual(response['objects'][1]['name'], u'Code for Ponta Grossa') + + response = self.app.get('/api/organizations?city=Ponta%20Grossa') + self.assertEqual(response.status_code, 200) + response = json.loads(response.data) + self.assertEqual(response['total'], 1) + self.assertEqual(response['objects'][0]['name'], u'Code for Ponta Grossa') + + response = self.app.get('/api/organizations?city=Addis+Ababa') + self.assertEqual(response.status_code, 200) + response = json.loads(response.data) + self.assertEqual(response['total'], 1) + self.assertEqual(response['objects'][0]['name'], u'Code for Addis Ababa') + + response = self.app.get('/api/organizations?city=Washington%2C%2BDC') + self.assertEqual(response.status_code, 200) + response = json.loads(response.data) + self.assertEqual(response['total'], 1) + self.assertEqual(response['objects'][0]['name'], u'USDS') + def test_organization_issues(self): ''' Test getting all of an organization's issues ''' @@ -552,5 +649,28 @@ def test_org_dont_show_issues(self): response = json.loads(response.data) for org in response['objects']: if org['current_projects']: - self.assertFalse('issues' in org['current_projects'][0]) + self.assertFalse(isinstance(org['current_projects'][0]["issues"], list)) break + + def test_geojson(self): + ''' Test that /organization.geojson works ''' + + organization = OrganizationFactory() + org2 = OrganizationFactory() + del org2.latitude + del org2.longitude + + db.session.flush() + + response = self.app.get('/api/organizations.geojson') + response = json.loads(response.data) + + # Test that features have expected attributes + org = response['features'][0] + self.assertTrue('geometry' in org.keys()) + self.assertTrue('coordinates' in org['geometry']) + self.assertTrue('properties' in org.keys()) + self.assertTrue('id' in org.keys()) + + # Test that only orgs with location data showed up + self.assertEqual(len(response['features']),1) diff --git a/test/integration/test_projects.py b/test/integration/test_projects.py index d10b494..9ac3e6b 100644 --- a/test/integration/test_projects.py +++ b/test/integration/test_projects.py @@ -1,3 +1,4 @@ +# -- coding: utf-8 -- import json from datetime import datetime, timedelta @@ -9,9 +10,9 @@ class TestProjects(IntegrationTest): def test_all_projects_order(self): - """ + ''' Test that projects gets returned in order of last_updated - """ + ''' ProjectFactory(name=u'Project 1', last_updated='Mon, 01 Jan 2010 00:00:00 GMT') ProjectFactory(name=u'Project 2', last_updated='Tue, 01 Jan 2011 00:00:00 GMT') ProjectFactory(name=u'Non Github Project', last_updated='Wed, 01 Jan 2013 00:00:00', github_details=None) @@ -37,7 +38,7 @@ def test_projects(self): assert isinstance(response['total'], int) assert isinstance(response['objects'], list) assert isinstance(response['objects'][0]['categories'], unicode) - assert isinstance(response['objects'][0]['tags'], unicode) + assert isinstance(response['objects'][0]['tags'], list) assert isinstance(response['objects'][0]['code_url'], unicode) assert isinstance(response['objects'][0]['description'], unicode) assert isinstance(response['objects'][0]['github_details'], dict) @@ -49,6 +50,7 @@ def test_projects(self): assert isinstance(response['objects'][0]['organization_name'], unicode) assert isinstance(response['objects'][0]['type'], unicode) assert isinstance(response['objects'][0]['status'], unicode) + assert isinstance(response['objects'][0]['languages'], list) def test_project_search_nonexisting_text(self): ''' Searching for non-existing text in the project and org/project @@ -93,6 +95,42 @@ def test_project_search_existing_text(self): self.assertEqual(org_project_response['total'], 1) self.assertEqual(len(org_project_response['objects']), 1) + def test_project_search_escaped_text(self): + ''' Searching for escaped text in the project and org/project endpoints + returns expected results + ''' + organization = OrganizationFactory(name=u"Code for San Francisco") + ProjectFactory(organization_name=organization.name, description=u'What\'s My \'District') + ProjectFactory(organization_name=organization.name, description=u'Cöde%%for%%Ameriça') + db.session.commit() + project_response = self.app.get('/api/projects?q=What\'s My \'District') + project_response = json.loads(project_response.data) + assert isinstance(project_response['total'], int) + assert isinstance(project_response['objects'], list) + self.assertEqual(project_response['total'], 1) + self.assertEqual(len(project_response['objects']), 1) + + org_project_response = self.app.get("/api/organizations/Code-for-San-Francisco/projects?q='District") + org_project_response = json.loads(org_project_response.data) + assert isinstance(org_project_response['total'], int) + assert isinstance(org_project_response['objects'], list) + self.assertEqual(org_project_response['total'], 1) + self.assertEqual(len(org_project_response['objects']), 1) + + project_response = self.app.get('/api/projects?q=%Ameriça') + project_response = json.loads(project_response.data) + assert isinstance(project_response['total'], int) + assert isinstance(project_response['objects'], list) + self.assertEqual(project_response['total'], 1) + self.assertEqual(len(project_response['objects']), 1) + + org_project_response = self.app.get("/api/organizations/Code-for-San-Francisco/projects?q=Cöde%") + org_project_response = json.loads(org_project_response.data) + assert isinstance(org_project_response['total'], int) + assert isinstance(org_project_response['objects'], list) + self.assertEqual(org_project_response['total'], 1) + self.assertEqual(len(org_project_response['objects']), 1) + def test_project_search_existing_phrase(self): ''' Searching for an existing phrase in the project and org/project endpoints returns expected results @@ -268,6 +306,23 @@ def test_project_search_order_by_last_updated_sort_asc(self): self.assertEqual(len(org_project_response["objects"]), 2) self.assertEqual(org_project_response['objects'][0]['description'], 'ruby ruby ruby ruby ruby') + def test_project_search_ranked_order(self): + ''' Search results from the project and org/project endpoints are returned + with correct ranking values + ''' + organization = OrganizationFactory(name=u"Code for San Francisco") + ProjectFactory(organization_name=organization.name, status=u'TEST', last_updated=datetime.now() - timedelta(10000)) + ProjectFactory(organization_name=organization.name, description=u'testing a new thing', last_updated=datetime.now() - timedelta(1)) + ProjectFactory(organization_name=organization.name, tags=[u'test,tags,what,ever'], last_updated=datetime.now() - timedelta(100)) + ProjectFactory(organization_name=organization.name, last_updated=datetime.now()) + db.session.commit() + project_response = self.app.get('/api/projects?q=TEST') + project_response = json.loads(project_response.data) + self.assertEqual(project_response['total'], 3) + self.assertEqual(project_response['objects'][0]['status'], u'TEST') + self.assertEqual(project_response['objects'][1]['tags'], [u'test,tags,what,ever']) + self.assertEqual(project_response['objects'][2]['description'], u'testing a new thing') + def test_project_return_only_ids(self): ''' Search results from the project and org/project endpoints are returned as only IDs if requested @@ -381,114 +436,140 @@ def test_project_search_includes_name(self): self.assertEqual(len(org_project_response['objects']), 1) self.assertEqual(org_project_response['objects'][0]['name'], 'My Cool Project') - def test_project_search_includes_type(self): - ''' The type field is included in search results from the project and org/project endpoints - ''' - organization = OrganizationFactory(name=u"Code for San Francisco") - ProjectFactory(organization_name=organization.name, type=u'mobile app') - ProjectFactory(organization_name=organization.name, type=u'data portal') - db.session.commit() - project_response = self.app.get('/api/projects?q=portal') - project_response = json.loads(project_response.data) - self.assertEqual(len(project_response['objects']), 1) - self.assertEqual(project_response['objects'][0]['type'], 'data portal') - - org_project_response = self.app.get('/api/organizations/Code-for-San-Francisco/projects?q=portal') - org_project_response = json.loads(org_project_response.data) - self.assertEqual(len(org_project_response['objects']), 1) - self.assertEqual(org_project_response['objects'][0]['type'], 'data portal') - - def test_project_search_includes_categories(self): - ''' The categories field is included in search results from the project and org/project endpoints - ''' - organization = OrganizationFactory(name=u"Code for San Francisco") - ProjectFactory(organization_name=organization.name, categories=u'project management, civic hacking') - ProjectFactory(organization_name=organization.name, categories=u'animal control, twitter') - db.session.commit() - project_response = self.app.get('/api/projects?q=control') - project_response = json.loads(project_response.data) - self.assertEqual(len(project_response['objects']), 1) - self.assertEqual(project_response['objects'][0]['categories'], 'animal control, twitter') - - org_project_response = self.app.get('/api/organizations/Code-for-San-Francisco/projects?q=control') - org_project_response = json.loads(org_project_response.data) - self.assertEqual(len(org_project_response['objects']), 1) - self.assertEqual(org_project_response['objects'][0]['categories'], 'animal control, twitter') - def test_project_search_includes_tags(self): - """ + ''' The tags field is included in search results from the project and org/project endpoints - """ + ''' organization = OrganizationFactory(name=u"Code for San Francisco") - ProjectFactory(organization_name=organization.name, tags=u'mapping, philly') - ProjectFactory(organization_name=organization.name, tags=u'food stamps, health') + ProjectFactory(organization_name=organization.name, tags=['mapping', 'philly']) + ProjectFactory(organization_name=organization.name, tags=['food stamps', 'health']) db.session.commit() project_response = self.app.get('/api/projects?q=stamps') project_response = json.loads(project_response.data) self.assertEqual(len(project_response['objects']), 1) - self.assertEqual(project_response['objects'][0]['tags'], 'food stamps, health') + self.assertEqual(project_response['objects'][0]['tags'], ['food stamps', 'health']) org_project_response = self.app.get('/api/organizations/Code-for-San-Francisco/projects?q=stamps') org_project_response = json.loads(org_project_response.data) self.assertEqual(len(org_project_response['objects']), 1) - self.assertEqual(org_project_response['objects'][0]['tags'], 'food stamps, health') + self.assertEqual(org_project_response['objects'][0]['tags'], ['food stamps', 'health']) - def test_project_search_includes_github_details(self): - ''' The github_details field is included in search results from the project and org/project endpoints + def test_project_search_includes_organization_name(self): + ''' + The organization name is included in the project search ''' organization = OrganizationFactory(name=u"Code for San Francisco") - ProjectFactory(organization_name=organization.name, github_details=json.dumps({'panic': 'disco'})) - ProjectFactory(organization_name=organization.name, github_details=json.dumps({'button': 'red'})) + ProjectFactory(organization_name=organization.name, name=u"Project One") + ProjectFactory(organization_name=organization.name, name=u"Project Two", description=u"America") + + organization = OrganizationFactory(name=u"Code for America") + ProjectFactory(organization_name=organization.name, name=u"Project Three") + ProjectFactory(organization_name=organization.name, name=u"Project Four", tags=u"San Francisco") db.session.commit() - project_response = self.app.get('/api/projects?q=disco') - project_response = json.loads(project_response.data) - self.assertEqual(len(project_response['objects']), 1) - self.assertEqual(project_response['objects'][0]['github_details'], '{"panic": "disco"}') - org_project_response = self.app.get('/api/organizations/Code-for-San-Francisco/projects?q=disco') - org_project_response = json.loads(org_project_response.data) - self.assertEqual(len(org_project_response['objects']), 1) - self.assertEqual(org_project_response['objects'][0]['github_details'], '{"panic": "disco"}') + # Test that org_name matches return before project name + project_response = self.app.get('/api/projects?q=Code+for+San+Francisco') + project_response = json.loads(project_response.data) + self.assertEqual(len(project_response['objects']), 3) + self.assertEqual(project_response['objects'][0]['name'], u'Project One') + self.assertEqual(project_response['objects'][1]['name'], u'Project Two') + self.assertEqual(project_response['objects'][2]['name'], u'Project Four') + self.assertTrue('San Francisco' in project_response['objects'][2]['tags']) + + # Test that org name matches return before project description + project_response = self.app.get('/api/projects?q=Code for America') + project_response = json.loads(project_response.data) + self.assertEqual(len(project_response['objects']), 3) + self.assertEqual(project_response['objects'][0]['name'], u'Project Three') + self.assertEqual(project_response['objects'][1]['name'], u'Project Four') + self.assertEqual(project_response['objects'][2]['name'], u'Project Two') + self.assertEqual(project_response['objects'][2]['description'], u'America') - def test_project_query_filter(self): + def test_project_organzation_type_filter(self): ''' - Test that project query params work as expected. + Test searching for projects from certain types of organizations. ''' - brigade = OrganizationFactory(name=u'Whatever', type=u'Brigade') - brigade_somewhere_far = OrganizationFactory(name=u'Brigade Organization', type=u'Brigade, Code for All') - web_project = ProjectFactory(name=u'Random Web App', type=u'web service') - other_web_project = ProjectFactory(name=u'Random Web App 2', type=u'web service', description=u'Another') - non_web_project = ProjectFactory(name=u'Random Other App', type=u'other service') - - web_project.organization = brigade - non_web_project.organization = brigade_somewhere_far - - db.session.add(web_project) - db.session.add(non_web_project) + brigade = OrganizationFactory(name=u'Brigade Org', type=u'Brigade, midwest') + code_for_all = OrganizationFactory(name=u'Code for All Org', type=u'Code for All') + gov_org = OrganizationFactory(name=u'Gov Org', type=u'Government') + + brigade_project = ProjectFactory(name=u'Today Brigade project', organization_name=brigade.name) + code_for_all_project = ProjectFactory(name=u'Yesterday Code for All project', organization_name=code_for_all.name, last_updated=datetime.now() - timedelta(days=1)) + gov_project = ProjectFactory(name=u'Two days ago Gov project', organization_name=gov_org.name, last_updated=datetime.now() - timedelta(days=2)) + brigade_project2 = ProjectFactory(name=u'Three days ago Brigade project', organization_name=brigade.name, last_updated=datetime.now() - timedelta(days=3)) + code_for_all_project2 = ProjectFactory(name=u'Four days ago Code for All project', organization_name=code_for_all.name, last_updated=datetime.now() - timedelta(days=4)) + gov_project2 = ProjectFactory(name=u'Five days ago Gov project', organization_name=gov_org.name, last_updated=datetime.now() - timedelta(days=5)) + + db.session.add(brigade_project) + db.session.add(code_for_all_project) + db.session.add(gov_project) + db.session.add(brigade_project2) + db.session.add(code_for_all_project2) + db.session.add(gov_project2) db.session.commit() - response = self.app.get('/api/projects?type=web%20service') + # Test they return in order of last_updated + response = self.app.get('/api/projects') self.assertEqual(response.status_code, 200) response = json.loads(response.data) - self.assertEqual(response['total'], 2) - self.assertEqual(response['objects'][0]['name'], u'Random Web App') - self.assertEqual(response['objects'][1]['name'], u'Random Web App 2') - - response = self.app.get('/api/projects?type=web%20service&description=Another') + self.assertEqual(response['total'], 6) + self.assertEqual(response['objects'][0]['name'], 'Today Brigade project') + self.assertEqual(response['objects'][1]['name'], 'Yesterday Code for All project') + self.assertEqual(response['objects'][2]['name'], 'Two days ago Gov project') + self.assertEqual(response['objects'][3]['name'], 'Three days ago Brigade project') + self.assertEqual(response['objects'][4]['name'], 'Four days ago Code for All project') + self.assertEqual(response['objects'][5]['name'], 'Five days ago Gov project') + + # Test they return in order of last_updated, no matter the search order + response = self.app.get('/api/projects?organization_type=Government,Code+for+All,Brigade') self.assertEqual(response.status_code, 200) response = json.loads(response.data) - self.assertEqual(response['total'], 1) - self.assertEqual(response['objects'][0]['name'], u'Random Web App 2') - - response = self.app.get('/api/projects?type=different%20service') + self.assertEqual(response['total'], 6) + self.assertEqual(response['objects'][0]['name'], 'Today Brigade project') + self.assertEqual(response['objects'][1]['name'], 'Yesterday Code for All project') + self.assertEqual(response['objects'][2]['name'], 'Two days ago Gov project') + self.assertEqual(response['objects'][3]['name'], 'Three days ago Brigade project') + self.assertEqual(response['objects'][4]['name'], 'Four days ago Code for All project') + self.assertEqual(response['objects'][5]['name'], 'Five days ago Gov project') + + response = self.app.get('/api/projects?organization_type=Brigade,Code+for+All') self.assertEqual(response.status_code, 200) response = json.loads(response.data) - self.assertEqual(response['total'], 0) + self.assertEqual(response['total'], 4) + self.assertEqual(response['objects'][0]['name'], 'Today Brigade project') + self.assertEqual(response['objects'][1]['name'], 'Yesterday Code for All project') + self.assertEqual(response['objects'][2]['name'], 'Three days ago Brigade project') + self.assertEqual(response['objects'][3]['name'], 'Four days ago Code for All project') + + # # Different order, same results + response = self.app.get('/api/projects?organization_type=Code+for+All,Brigade') + self.assertEqual(response.status_code, 200) + response = json.loads(response.data) + self.assertEqual(response['total'], 4) + self.assertEqual(response['objects'][0]['name'], 'Today Brigade project') + self.assertEqual(response['objects'][1]['name'], 'Yesterday Code for All project') + self.assertEqual(response['objects'][2]['name'], 'Three days ago Brigade project') + self.assertEqual(response['objects'][3]['name'], 'Four days ago Code for All project') - response = self.app.get('/api/projects?organization_type=Code+for+All') + response = self.app.get('/api/projects?organization_type=Code+for+All,Government') + self.assertEqual(response.status_code, 200) + response = json.loads(response.data) + self.assertEqual(response['total'], 4) + self.assertEqual(response['objects'][0]['name'], 'Yesterday Code for All project') + self.assertEqual(response['objects'][1]['name'], 'Two days ago Gov project') + self.assertEqual(response['objects'][2]['name'], 'Four days ago Code for All project') + self.assertEqual(response['objects'][3]['name'], 'Five days ago Gov project') + + # # Different order, same results + response = self.app.get('/api/projects?organization_type=Government,Code+for+All') self.assertEqual(response.status_code, 200) response = json.loads(response.data) - self.assertEqual(response['total'], 1) + self.assertEqual(response['total'], 4) + self.assertEqual(response['objects'][0]['name'], 'Yesterday Code for All project') + self.assertEqual(response['objects'][1]['name'], 'Two days ago Gov project') + self.assertEqual(response['objects'][2]['name'], 'Four days ago Code for All project') + self.assertEqual(response['objects'][3]['name'], 'Five days ago Gov project') + def test_project_cascading_deletes(self): ''' Test that issues get deleted when their parent @@ -523,6 +604,9 @@ def test_project_cascading_deletes(self): issue = IssueFactory(title=u'TEST ISSUE', project_id=project.id) another_issue = IssueFactory(title=u'ANOTHER TEST ISSUE', project_id=project.id) a_third_issue = IssueFactory(title=u'A THIRD TEST ISSUE', project_id=project.id) + db.session.add(issue) + db.session.add(another_issue) + db.session.add(a_third_issue) db.session.commit() # make sure the issues are in the db @@ -533,3 +617,23 @@ def test_project_cascading_deletes(self): db.session.commit() issues = db.session.query(Issue).all() self.assertFalse(len(issues)) + + def test_include_issues(self): + """ Test the include_issues flag """ + project = ProjectFactory() + db.session.commit() + IssueFactory(project_id=project.id) + db.session.commit() + + got = self.app.get("/api/projects?include_issues=True") + project = json.loads(got.data)['objects'][0] + self.assertTrue(isinstance(project['issues'], list)) + got = self.app.get("/api/projects?include_issues=False") + project = json.loads(got.data)['objects'][0] + self.assertFalse(isinstance(project['issues'], list)) + self.assertEqual("http://localhost/api/projects/1/issues", project["issues"]) + got = self.app.get("/api/projects") + project = json.loads(got.data)['objects'][0] + self.assertFalse(isinstance(project['issues'], list)) + self.assertEqual("http://localhost/api/projects/1/issues", project["issues"]) + diff --git a/test/peopledbtest.pgsql b/test/peopledbtest.pgsql index 1a958c2..aa2ac84 100644 --- a/test/peopledbtest.pgsql +++ b/test/peopledbtest.pgsql @@ -11,5 +11,5 @@ CREATE TABLE attendance event_name TEXT, -- Sometimes people answer informational questions when they check in. - extras JSON + extras TEXT ); \ No newline at end of file diff --git a/test/updater/__init__.py b/test/updater/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/run_update_test.py b/test/updater/test_run_update.py similarity index 71% rename from run_update_test.py rename to test/updater/test_run_update.py index 669d6c3..31a6ea9 100644 --- a/run_update_test.py +++ b/test/updater/test_run_update.py @@ -5,7 +5,6 @@ import datetime import logging import time -import json from re import match, search, sub from httmock import response, HTTMock @@ -13,6 +12,12 @@ from psycopg2 import connect, extras +from freezegun import freeze_time + +from csv import DictReader +from StringIO import StringIO +import json + root_logger = logging.getLogger() root_logger.disabled = True @@ -39,24 +44,13 @@ def setUp(self): self.db.create_all() import run_update - run_update.github_throttling = False + run_update.GITHUB_THROTTLING = False - # FAKE PEOPLEDB - with connect('postgres:///peopledbtest') as conn: - with conn.cursor() as db: - with open('test/peopledbtest-destroy.pgsql') as filename: - db.execute(filename.read()) - with open('test/peopledbtest.pgsql') as filename: - db.execute(filename.read()) def tearDown(self): self.db.session.close() self.db.drop_all() - with connect('postgres:///peopledbtest') as conn: - with conn.cursor() as db: - with open('test/peopledbtest-destroy.pgsql') as filename: - db.execute(filename.read()) def setup_mock_rss_response(self): ''' This overwrites urllib2.urlopen to return a mock response, which stops @@ -73,7 +67,7 @@ def setup_mock_rss_response(self): urllib2.urlopen.return_value.read = Mock(return_value=rss_content) return urllib2.urlopen - def get_raw_organization_list(self, count=3): + def get_csv_organization_list(self, count=3): if type(count) is not int: count = 3 # 'https://github.com/codeforamerica' and 'https://www.github.com/orgs/codeforamerica' are transformed @@ -81,24 +75,41 @@ def get_raw_organization_list(self, count=3): lines = [u'''name,website,events_url,rss,projects_list_url'''.encode('utf8'), u'''Cöde for Ameriça,http://codeforamerica.org,http://www.meetup.com/events/Code-For-Charlotte/,http://www.codeforamerica.org/blog/feed/,http://example.com/cfa-projects.csv'''.encode('utf8'), u'''Code for America (2),,,,https://github.com/codeforamerica'''.encode('utf8'), u'''Code for America (3),,http://www.meetup.com/events/Code-For-Rhode-Island/,http://www.codeforamerica.org/blog/another/feed/,https://www.github.com/orgs/codeforamerica'''.encode('utf8')] return '\n'.join(lines[0:count + 1]) + def get_json_organization_list(self, count=3): + ''' Get the json version of the organization list + ''' + raw_csv = self.get_csv_organization_list(count) + return json.dumps([item for item in DictReader(StringIO(raw_csv))]) + def response_content(self, url, request): # csv file of project descriptions if url.geturl() == 'http://example.com/cfa-projects.csv': - project_lines = ['''Name,description,link_url,code_url,type,categories,tags,status''', ''',,,https://github.com/codeforamerica/cityvoice,,,"safety, police, poverty",Shuttered''', ''',,,https://github.com/codeforamerica/bizfriendly-web,,,,'''] + project_lines = ['''Name,description,link_url,code_url,type,categories,tags,status''', ''',,,https://github.com/codeforamerica/cityvoice,,,"safety, police, poverty",Shuttered''', ''',,,https://github.com/codeforamerica/bizfriendly-web/,,,"what,ever,,†≈ç®åz¥≈†",'''] if self.results_state == 'before': return response(200, '''\n'''.join(project_lines[0:3]), {'content-type': 'text/csv; charset=UTF-8'}) elif self.results_state == 'after': return response(200, '''\n'''.join(project_lines[0:2]), {'content-type': 'text/csv; charset=UTF-8'}) + # json of user description + elif url.geturl() == 'https://api.github.com/users/codeforamerica': + return response(200, '''{ "login": "codeforamerica", "id": 337792, "avatar_url": "https://avatars2.githubusercontent.com/u/337792?v=4", "gravatar_id": "", "url": "https://api.github.com/users/codeforamerica", "html_url": "https://github.com/codeforamerica", "followers_url": "https://api.github.com/users/codeforamerica/followers", "following_url": "https://api.github.com/users/codeforamerica/following{/other_user}", "gists_url": "https://api.github.com/users/codeforamerica/gists{/gist_id}", "starred_url": "https://api.github.com/users/codeforamerica/starred{/owner}{/repo}", "subscriptions_url": "https://api.github.com/users/codeforamerica/subscriptions", "organizations_url": "https://api.github.com/users/codeforamerica/orgs", "repos_url": "https://api.github.com/users/codeforamerica/repos", "events_url": "https://api.github.com/users/codeforamerica/events{/privacy}", "received_events_url": "https://api.github.com/users/codeforamerica/received_events", "type": "Organization", "site_admin": false, "name": "Code for America", "company": null, "blog": "http://codeforamerica.org", "location": null, "email": "labs@codeforamerica.org", "hireable": null, "bio": null, "public_repos": 659, "public_gists": 0, "followers": 0, "following": 0, "created_at": "2010-07-19T19:41:04Z", "updated_at": "2017-09-05T10:22:41Z" }''') # json of project descriptions elif url.geturl() == 'https://api.github.com/users/codeforamerica/repos': - return response(200, '''[{ "id": 10515516, "name": "cityvoice", "owner": { "login": "codeforamerica", "avatar_url": "https://avatars.githubusercontent.com/u/337792", "html_url": "https://github.com/codeforamerica", "type": "Organization"}, "html_url": "https://github.com/codeforamerica/cityvoice", "description": "A place-based call-in system for gathering and sharing community feedback", "url": "https://api.github.com/repos/codeforamerica/cityvoice", "contributors_url": "https://api.github.com/repos/codeforamerica/cityvoice/contributors", "created_at": "2013-06-06T00:12:30Z", "updated_at": "2014-02-21T20:43:16Z", "pushed_at": "2014-02-21T20:43:16Z", "homepage": "http://www.cityvoiceapp.com/", "stargazers_count": 10, "watchers_count": 10, "language": "Ruby", "forks_count": 12, "open_issues": 37 }]''', headers=dict(Link='; rel="next", ; rel="last"')) + return response(200, '''[{ "id": 10515516, "name": "cityvoice", "owner": { "login": "codeforamerica", "avatar_url": "https://avatars.githubusercontent.com/u/337792", "html_url": "https://github.com/codeforamerica", "type": "Organization"}, "html_url": "https://github.com/codeforamerica/cityvoice", "description": "A place-based call-in system for gathering and sharing community feedback", "url": "https://api.github.com/repos/codeforamerica/cityvoice", "contributors_url": "https://api.github.com/repos/codeforamerica/cityvoice/contributors", "created_at": "2013-06-06T00:12:30Z", "updated_at": "2014-02-21T20:43:16Z", "pushed_at": "2014-02-21T20:43:16Z", "homepage": "http://www.cityvoiceapp.com/", "stargazers_count": 10, "watchers_count": 10, "language": "Ruby", "forks_count": 12, "open_issues": 37, "languages_url": "https://api.github.com/repos/codeforamerica/cityvoice/languages" }]''', headers=dict(Link='; rel="next", ; rel="last"')) - # csv file of organization descriptions + # mock of programming languages + elif url.geturl() == 'https://api.github.com/repos/codeforamerica/cityvoice/languages': + return response(200, ''' { "Ruby": 178825, "HTML": 80191, "JavaScript": 16028, "CSS": 8579, "Shell": 219 }''') + + # json file of organization descriptions # this catches the request for the URL contained in run_update.TEST_ORG_SOURCES_FILENAME + elif url.geturl() == 'https://raw.githubusercontent.com/codeforamerica/brigade-information/master/test/test_organizations.json': + return response(200, self.get_json_organization_list(self.organization_count)) + + # csv file of organization descriptions elif "docs.google.com" in url: - return response(200, self.get_raw_organization_list(self.organization_count)) + return response(200, self.get_csv_organization_list(self.organization_count)) # contents of civic.json file in root directory for cityvoice elif "cityvoice/contents/civic.json" in url.geturl(): @@ -118,11 +129,11 @@ def response_content(self, url, request): # json of project description (cityvoice) elif url.geturl() == 'https://api.github.com/repos/codeforamerica/cityvoice': - return response(200, '''{ "id": 10515516, "name": "cityvoice", "owner": { "login": "codeforamerica", "avatar_url": "https://avatars.githubusercontent.com/u/337792", "html_url": "https://github.com/codeforamerica", "type": "Organization"}, "html_url": "https://github.com/codeforamerica/cityvoice", "description": "A place-based call-in system for gathering and sharing community feedback", "url": "https://api.github.com/repos/codeforamerica/cityvoice", "contributors_url": "https://api.github.com/repos/codeforamerica/cityvoice/contributors", "created_at": "2013-06-06T00:12:30Z", "updated_at": "2014-02-21T20:43:16Z", "pushed_at": "2014-02-21T20:43:16Z", "homepage": "http://www.cityvoiceapp.com/", "stargazers_count": 10, "watchers_count": 10, "language": "Ruby", "forks_count": 12, "open_issues": 37 }''', {'last-modified': datetime.datetime.strptime('Fri, 15 Nov 2013 00:08:07 GMT', "%a, %d %b %Y %H:%M:%S GMT")}) + return response(200, '''{ "id": 10515516, "name": "cityvoice", "owner": { "login": "codeforamerica", "avatar_url": "https://avatars.githubusercontent.com/u/337792", "html_url": "https://github.com/codeforamerica", "type": "Organization"}, "html_url": "https://github.com/codeforamerica/cityvoice", "description": "A place-based call-in system for gathering and sharing community feedback", "url": "https://api.github.com/repos/codeforamerica/cityvoice", "contributors_url": "https://api.github.com/repos/codeforamerica/cityvoice/contributors", "created_at": "2013-06-06T00:12:30Z", "updated_at": "2014-02-21T20:43:16Z", "pushed_at": "2014-02-21T20:43:16Z", "homepage": "http://www.cityvoiceapp.com/", "stargazers_count": 10, "watchers_count": 10, "language": "Ruby", "languages_url": "https://api.github.com/repos/codeforamerica/cityvoice/languages", "forks_count": 12, "open_issues": 37, "subscribers_count": 40, "default_branch" : "master" }''', {'last-modified': datetime.datetime.strptime('Fri, 15 Nov 2013 00:08:07 GMT', "%a, %d %b %Y %H:%M:%S GMT")}) # json of project description (bizfriendly-web) elif url.geturl() == 'https://api.github.com/repos/codeforamerica/bizfriendly-web': - return response(200, ''' { "id": 11137392, "name": "bizfriendly-web", "owner": { "login": "codeforamerica", "avatar_url": "https://avatars.githubusercontent.com/u/337792?v=3", "html_url": "https://github.com/codeforamerica", "type": "Organization" }, "html_url": "https://github.com/codeforamerica/bizfriendly-web", "description": "An online service that teaches small business owners how to use the internet to better run their businesses.", "url": "https://api.github.com/repos/codeforamerica/bizfriendly-web", "contributors_url": "https://api.github.com/repos/codeforamerica/bizfriendly-web/contributors", "created_at": "2013-07-02T23:14:10Z", "updated_at": "2014-11-02T18:55:33Z", "pushed_at": "2014-10-14T21:55:04Z", "homepage": "http://bizfriend.ly", "stargazers_count": 17, "watchers_count": 17, "language": "JavaScript", "forks_count": 21, "open_issues": 31 } ''', {'last-modified': datetime.datetime.strptime('Fri, 15 Nov 2013 00:08:07 GMT', "%a, %d %b %Y %H:%M:%S GMT")}) + return response(200, ''' { "id": 11137392, "name": "bizfriendly-web", "owner": { "login": "codeforamerica", "avatar_url": "https://avatars.githubusercontent.com/u/337792?v=3", "html_url": "https://github.com/codeforamerica", "type": "Organization" }, "html_url": "https://github.com/codeforamerica/bizfriendly-web", "description": "An online service that teaches small business owners how to use the internet to better run their businesses.", "url": "https://api.github.com/repos/codeforamerica/bizfriendly-web", "contributors_url": "https://api.github.com/repos/codeforamerica/bizfriendly-web/contributors", "created_at": "2013-07-02T23:14:10Z", "updated_at": "2014-11-02T18:55:33Z", "pushed_at": "2014-10-14T21:55:04Z", "homepage": "http://bizfriend.ly", "stargazers_count": 17, "watchers_count": 17, "language": "JavaScript", "languages_url": "https://api.github.com/repos/codeforamerica/cityvoice/languages", "forks_count": 21, "open_issues": 31, "subscribers_count": 44 } ''', {'last-modified': datetime.datetime.strptime('Fri, 15 Nov 2013 00:08:07 GMT', "%a, %d %b %Y %H:%M:%S GMT")}) # json of project contributors (cityvoice) elif url.geturl() == 'https://api.github.com/repos/codeforamerica/cityvoice/contributors' or url.geturl() == 'https://api.github.com/repos/codeforamerica/bizfriendly-web/contributors': @@ -135,8 +146,8 @@ def response_content(self, url, request): # json of project issues (cityvoice, bizfriendly-web) elif url.geturl() == 'https://api.github.com/repos/codeforamerica/cityvoice/issues' or url.geturl() == 'https://api.github.com/repos/codeforamerica/bizfriendly-web/issues': # build issues dynamically based on results_state value - issue_lines = ['''{"html_url": "https://github.com/codeforamerica/cityvoice/issue/210","title": "Important cityvoice issue", "labels": [ xxx ], "body" : "WHATEVER"}''', '''{"html_url": "https://github.com/codeforamerica/cityvoice/issue/211","title": "More important cityvoice issue", "labels": [ xxx ], "body" : "WHATEVER"}'''] - label_lines = ['''{ "color" : "84b6eb", "name" : "enhancement", "url": "https://api.github.com/repos/codeforamerica/cityvoice/labels/enhancement"}''', '''{ "color" : "84b6eb", "name" : "question", "url": "https://api.github.com/repos/codeforamerica/cityvoice/labels/question"}'''] + issue_lines = ['''{"html_url": "https://github.com/codeforamerica/cityvoice/issue/210","title": "Important cityvoice issue", "labels": [ xxx ],"created_at": "2015-09-16T05:45:20Z", "updated_at": "2015-10-22T17:26:02Z", "body" : "WHATEVER"}''', '''{"html_url": "https://github.com/codeforamerica/cityvoice/issue/211","title": "More important cityvoice issue", "labels": [ xxx ], "created_at" : "2015-10-26T01:13:03Z", "updated_at" : "2015-10-26T18:06:54Z", "body" : "WHATEVER"}'''] + label_lines = ['''{ "color" : "84b6eb", "name" : "enhancement", "url": "https://api.github.com/repos/codeforamerica/cityvoice/labels/enhancement", "node_id": "AAAAAA="}''', '''{ "color" : "84b6eb", "name" : "question", "url": "https://api.github.com/repos/codeforamerica/cityvoice/labels/question", "node_id": "BBBBBBB="}'''] issue_lines_before = [sub('xxx', ','.join(label_lines[0:2]), issue_lines[0]), sub('xxx', ','.join(label_lines[0:2]), issue_lines[1])] issue_lines_after = [sub('xxx', ','.join(label_lines[0:1]), issue_lines[0])] response_etag = {'ETag': '8456bc53d4cf6b78779ded3408886f82'} @@ -154,8 +165,16 @@ def response_content(self, url, request): elif url.geturl() == 'https://api.github.com/user/337792/repos?page=2': return response(200, '''[ ]''', headers=dict(Link='; rel="prev", ; rel="first"')) + # mock commit status + elif url.geturl() == 'https://api.github.com/repos/codeforamerica/cityvoice/commits/master/status': + return response(200, '''{ "state" : "success" } ''') + + # elif meetup member count + elif 'https://api.meetup.com/2/groups?group_urlname=' in url.geturl(): + return response(200, ''' { "results" : [ { "members" : 100 } ] } ''') + # json of meetup events - elif 'meetup.com' in url.geturl() and 'Code-For-Charlotte' in url.geturl(): + elif 'https://api.meetup.com/2/events?status=past,upcoming&format=json&group_urlname=' in url.geturl() and 'Code-For-Charlotte' in url.geturl(): events_filename = 'meetup_events.json' if self.results_state == 'after': events_filename = 'meetup_events_fewer.json' @@ -166,7 +185,7 @@ def response_content(self, url, request): return response(200, events_content) # json of alternate meetup events - elif 'meetup.com' in url.geturl() and 'Code-For-Rhode-Island' in url.geturl(): + elif 'https://api.meetup.com/2/events?status=past,upcoming&format=json&group_urlname=' in url.geturl() and 'Code-For-Rhode-Island' in url.geturl(): events_file = open('meetup_events_another.json') events_content = events_file.read() events_file.close() @@ -232,6 +251,7 @@ def overwrite_response_content(url, request): project = self.db.session.query(Project).filter(filter).first() self.assertIsNotNone(project) self.assertEqual(project.name, u'bizfriendly-web') + self.assertEqual(project.tags, [u'what', u'ever', u'', u'†≈ç®åz¥≈†']) # check for the one project status filter = [Project.organization_name == u'Cöde for Ameriça', Project.name == u'cityvoice'] @@ -264,16 +284,40 @@ def test_main_with_good_new_data(self): old_project = ProjectFactory(name=u'Old Project', organization_name=u'Old Organization') old_event = EventFactory(name=u'Old Event', organization_name=u'Old Organization') old_issue = IssueFactory(title=u'Old Issue', project_id=1) - self.db.session.flush() + self.db.session.add(old_organization) + self.db.session.add(old_project) + self.db.session.add(old_event) + self.db.session.add(old_issue) + self.db.session.commit() + + from app import Organization, Project, Event, Issue + # make sure old org is there + filter = Organization.name == u'Old Organization' + organization = self.db.session.query(Organization).filter(filter).first() + self.assertIsNotNone(organization) + + # make sure old project is there + filter = Project.name == u'Old Project' + project = self.db.session.query(Project).filter(filter).first() + self.assertIsNotNone(project) + + # make sure the old issue is there + filter = Issue.title == u'Old Issue' + issue = self.db.session.query(Issue).filter(filter).first() + self.assertIsNotNone(issue) + + # make sure old event is there + filter = Event.name == u'Old Event' + event = self.db.session.query(Event).filter(filter).first() + self.assertIsNotNone(event) + + # + # run update with HTTMock(self.response_content): import run_update run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) - self.db.session.flush() - - from app import Organization, Project, Event, Issue - # make sure old org is no longer there filter = Organization.name == u'Old Organization' organization = self.db.session.query(Organization).filter(filter).first() @@ -294,17 +338,18 @@ def test_main_with_good_new_data(self): event = self.db.session.query(Event).filter(filter).first() self.assertIsNone(event) - # check for the one organization + # + # check for one organization filter = Organization.name == u'Cöde for Ameriça' organization = self.db.session.query(Organization).filter(filter).first() self.assertEqual(organization.name, u'Cöde for Ameriça') - # check for the one project + # check for one project filter = Project.name == u'bizfriendly-web' project = self.db.session.query(Project).filter(filter).first() self.assertEqual(project.name, u'bizfriendly-web') - # check for the one issue + # check for one issue filter = Issue.title == u'Important cityvoice issue' issue = self.db.session.query(Issue).filter(filter).first() self.assertEqual(issue.title, u'Important cityvoice issue') @@ -319,7 +364,8 @@ def test_main_with_good_new_data(self): # Thu, 16 Jan 2014 19:00:00 -05:00 self.assertEqual(first_event.utc_offset, -5 * 3600) self.assertEqual(first_event.start_time_notz, datetime.datetime(2014, 1, 16, 19, 0, 0)) - self.assertEqual(first_event.name,u'Organizational meeting') + self.assertEqual(first_event.end_time_notz, datetime.datetime(2014, 1, 16, 22, 0, 0)) + self.assertEqual(first_event.name, u'Organizational meeting') second_event = events.pop(0) # Thu, 20 Feb 2014 18:30:00 -05:00 @@ -367,7 +413,7 @@ def overwrite_response_content(url, request): with HTTMock(self.response_content): with HTTMock(overwrite_response_content): import run_update - self.assertFalse(run_update.github_throttling) + self.assertFalse(run_update.GITHUB_THROTTLING) with self.assertRaises(IOError): run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) @@ -377,8 +423,8 @@ def test_main_with_weird_organization_name(self): self.setup_mock_rss_response() def overwrite_response_content(url, request): - if "docs.google.com" in url: - return response(200, '''name\nCode_for-America''') + if url.geturl() == 'https://raw.githubusercontent.com/codeforamerica/brigade-information/master/test/test_organizations.json': + return response(200, '''[{"name": "Code_for-America"}]''', {'content-type': 'text/csv; charset=UTF-8'}) with HTTMock(self.response_content): with HTTMock(overwrite_response_content): @@ -402,7 +448,7 @@ def test_main_with_bad_organization_name(self): self.setup_mock_rss_response() def overwrite_response_content(url, request): - return response(200, '''name\nCode#America\nCode?America\nCode/America\nCode for America''') + return response(200, '''[{"name": "Code#America"}, {"name": "Code?America"}, {"name": "Code/America"}, {"name": "Code for America"}]''', {'content-type': 'text/csv; charset=UTF-8'}) with HTTMock(self.response_content): with HTTMock(overwrite_response_content): @@ -425,8 +471,8 @@ def test_main_with_bad_events_url(self): self.setup_mock_rss_response() def overwrite_response_content(url, request): - if "docs.google.com" in url: - return response(200, '''name,events_url\nCode for America,http://www.meetup.com/events/foo-%%%''') + if url.geturl() == 'https://raw.githubusercontent.com/codeforamerica/brigade-information/master/test/test_organizations.json': + return response(200, '''[{"name": "Code for America", "events_url": "http://www.meetup.com/events/foo-%%%"}]''', {'content-type': 'text/csv; charset=UTF-8'}) logging.error = Mock() @@ -450,9 +496,8 @@ def test_main_with_non_existant_meetup(self): self.setup_mock_rss_response() def overwrite_response_content(url, request): - if "docs.google.com" in url: - return response(200, '''name,events_url\nCode for America,http://www.meetup.com/events/Code-For-Charlotte''') - + if url.geturl() == 'https://raw.githubusercontent.com/codeforamerica/brigade-information/master/test/test_organizations.json': + return response(200, '''[{"name": "Code for America", "events_url": "http://www.meetup.com/events/Code-For-Charlotte"}]''', {'content-type': 'text/csv; charset=UTF-8'}) if 'api.meetup.com' in url: return response(404, '''Not Found!''') @@ -497,7 +542,7 @@ def test_github_throttling(self): def overwrite_response_content(url, request): if url.netloc == 'api.github.com': - return response(403, "", {"x-ratelimit-remaining": 0}) + return response(403, "", {"X-Ratelimit-Remaining": '0'}) with HTTMock(self.response_content): with HTTMock(overwrite_response_content): @@ -513,6 +558,29 @@ def overwrite_response_content(url, request): error = self.db.session.query(Error).first() self.assertEqual(error.error, "IOError: We done got throttled by GitHub") + def test_unthrottled_forbidden(self): + ''' A 403 response that's not due to GitHub throttling doesn't generate an error. + ''' + self.setup_mock_rss_response() + + def overwrite_response_content(url, request): + if url.netloc == 'api.github.com': + return response(403, "", {"X-Ratelimit-Remaining": '3388'}) + + with HTTMock(self.response_content): + with HTTMock(overwrite_response_content): + import run_update + run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + + from app import Project + projects = self.db.session.query(Project).all() + for project in projects: + self.assertIsNone(project.github_details) + + from app import Error + error = self.db.session.query(Error).first() + self.assertIsNone(error) + def test_csv_sniffer(self): ''' Testing weird csv dialects we've encountered @@ -595,6 +663,79 @@ def updated_status(url, request): self.assertEqual(projects[0]['status'], "active") self.assertEqual(projects[0]['last_updated'], datetime.datetime.now().strftime("%a, %d %b %Y %H:%M:%S %Z")) + def test_non_github_projects_same_name(self): + ''' Test that non github projects with same name but different groups dont overlap + ''' + self.setup_mock_rss_response() + + from test.factories import OrganizationFactory + philly = OrganizationFactory(name=u'Code for Philly', projects_list_url=u'http://codeforphilly.org/projects.csv') + philly2 = OrganizationFactory(name=u'Philly2', projects_list_url=u'http://codeforphilly.org/projects.csv') + + # Get a Philly project into the db + with HTTMock(self.response_content): + import run_update + + # mock the time + freezer = freeze_time("2012-01-14 12:00:01") + freezer.start() + + projects = run_update.get_projects(philly) + for proj_info in projects: + run_update.save_project_info(self.db.session, proj_info) + self.db.session.flush() + + projects = run_update.get_projects(philly2) + for proj_info in projects: + run_update.save_project_info(self.db.session, proj_info) + self.db.session.flush() + + from app import Project + projects = self.db.session.query(Project).all() + self.assertEqual(projects[0].last_updated, datetime.datetime.now().strftime("%a, %d %b %Y %H:%M:%S %Z")) + self.assertEqual(projects[1].last_updated, datetime.datetime.now().strftime("%a, %d %b %Y %H:%M:%S %Z")) + + freezer.stop() + freezer = freeze_time("2012-01-14 12:00:02") + freezer.start() + + projects = run_update.get_projects(philly) + for proj_info in projects: + run_update.save_project_info(self.db.session, proj_info) + self.db.session.flush() + + projects = run_update.get_projects(philly2) + for proj_info in projects: + run_update.save_project_info(self.db.session, proj_info) + self.db.session.flush() + + projects = self.db.session.query(Project).all() + from datetime import timedelta + one_second_ago = datetime.datetime.now() - timedelta(seconds=1) + self.assertEqual(projects[0].last_updated, one_second_ago.strftime("%a, %d %b %Y %H:%M:%S %Z")) + self.assertEqual(projects[1].last_updated, one_second_ago.strftime("%a, %d %b %Y %H:%M:%S %Z")) + + freezer.stop() + + def test_github_latest_update_time(self): + import run_update + import dateutil.parser + # Test that latest date is given + pushed_at_time = u'2015-10-02T15:43:20Z' + updated_at_time = u'2015-10-02T15:43:22Z' + github_details = {'pushed_at': pushed_at_time, 'updated_at': updated_at_time} + self.assertEqual(run_update.github_latest_update_time(github_details), dateutil.parser.parse(pushed_at_time).strftime('%a, %d %b %Y %H:%M:%S %Z')) + + # Test handling of missing data + github_details = {'updated_at': updated_at_time} + self.assertEqual(run_update.github_latest_update_time(github_details), dateutil.parser.parse(updated_at_time).strftime('%a, %d %b %Y %H:%M:%S %Z')) + + github_details = {'pushed_at': pushed_at_time} + self.assertEqual(run_update.github_latest_update_time(github_details), dateutil.parser.parse(pushed_at_time).strftime('%a, %d %b %Y %H:%M:%S %Z')) + + github_details = {} + self.assertIsNotNone(run_update.github_latest_update_time(github_details)) + def test_utf8_noncode_projects(self): ''' Test that utf8 project descriptions match exisiting projects. ''' @@ -603,7 +744,8 @@ def test_utf8_noncode_projects(self): from test.factories import OrganizationFactory, ProjectFactory philly = OrganizationFactory(name=u'Code for Philly', projects_list_url=u'http://codeforphilly.org/projects.csv') - old_project = ProjectFactory(name=u'Philly Map of Shame', organization_name=u'Code for Philly', description=u'PHL Map of Shame is a citizen-led project to map the impact of the School Reform Commission\u2019s \u201cdoomsday budget\u201d on students and parents. We will visualize complaints filed with the Pennsylvania Department of Education.', categories=u'Education, CivicEngagement', tags=u'philly, mapping', type=None, link_url=u'http://phillymapofshame.org', code_url=None, status=u'In Progress') + old_project = ProjectFactory(name=u'Philly Map of Shame', organization_name=u'Code for Philly', description=u'PHL Map of Shame is a citizen-led project to map the impact of the School Reform Commission\u2019s \u201cdoomsday budget\u201d on students and parents. We will visualize complaints filed with the Pennsylvania Department of Education.', categories=u'Education, CivicEngagement', tags=[u'philly', u'mapping'], type=None, link_url=u'http://phillymapofshame.org', code_url=None, status=u'In Progress') + old_project.last_updated = "2000-01-01" self.db.session.flush() def overwrite_response_content(url, request): @@ -615,7 +757,7 @@ def overwrite_response_content(url, request): import run_update projects = run_update.get_projects(philly) # If the two descriptions are equal, it won't update last_updated - assert projects[0]['last_updated'] == None + self.assertEqual(projects[0]['last_updated'], "2000-01-01") def test_issue_paging(self): ''' test that issues are following page links ''' @@ -624,23 +766,23 @@ def test_issue_paging(self): from test.factories import OrganizationFactory, ProjectFactory organization = OrganizationFactory(name=u'Code for America', projects_list_url=u'http://codeforamerica.org/projects.csv') - project = ProjectFactory(organization_name=u'Code for America',code_url=u'https://github.com/TESTORG/TESTPROJECT') - self.db.session.flush() + project = ProjectFactory(organization_name=organization.name, code_url=u'https://github.com/TESTORG/TESTPROJECT') + self.db.session.commit() def overwrite_response_content(url, request): if url.geturl() == 'https://api.github.com/repos/TESTORG/TESTPROJECT/issues': - content = '''[{"number": 2,"title": "TEST TITLE 2","body": "TEST BODY 2","labels": [], "html_url":""}]''' + content = '''[{"number": 2,"title": "TEST TITLE 2", "created_at":"2015-10-26T18:00:00Z", "updated_at":"2015-10-26T18:06:54Z", "body": "TEST BODY 2","labels": [], "html_url":""}]''' headers = {"Link": '"; rel="next"', 'ETag': '8456bc53d4cf6b78779ded3408886f82'} return response(200, content, headers) elif url.geturl() == 'https://api.github.com/repos/TESTORG/TESTPROJECT/issues?page=2': - content = '''[{"number": 2,"title": "TEST TITLE 2","body": "TEST BODY 2","labels": [], "html_url":""}]''' + content = '''[{"number": 2,"title": "TEST TITLE 2", "created_at":"2015-10-26T18:00:00Z", "updated_at":"2015-10-26T18:06:54Z","body": "TEST BODY 2","labels": [], "html_url":""}]''' return response(200, content) with HTTMock(self.response_content): with HTTMock(overwrite_response_content): import run_update - issues = run_update.get_issues(organization.name) + issues = run_update.get_issues(project) assert (len(issues) == 2) def test_project_list_without_all_columns(self): @@ -671,11 +813,11 @@ def test_new_value_in_csv_project_list(self): from app import Project import run_update - org_csv = '''name,website,events_url,rss,projects_list_url\nOrganization Name,,,,http://organization.org/projects.csv''' + org_json = '''[{"name": "Organization Name", "website": "", "events_url": "", "rss": "", "projects_list_url": "http://organization.org/projects.csv"}]''' def status_one_response_content(url, request): - if "docs.google.com" in url.geturl(): - return response(200, org_csv, {'content-type': 'text/csv; charset=UTF-8'}) + if url.geturl() == 'https://raw.githubusercontent.com/codeforamerica/brigade-information/master/test/test_organizations.json': + return response(200, org_json, {'content-type': 'text/csv; charset=UTF-8'}) # return an empty civic.json so the value of status there won't overwrite the one from the spreadsheet elif "/contents/civic.json" in url.geturl(): return response(200, '''{}''', {'Etag': '8456bc53d4cf6b78779ded3408886f82'}) @@ -702,8 +844,8 @@ def status_one_response_content(url, request): cv_headers_dict = got.headers def status_two_response_content(url, request): - if "docs.google.com" in url.geturl(): - return response(200, org_csv, {'content-type': 'text/csv; charset=UTF-8'}) + if url.geturl() == 'https://raw.githubusercontent.com/codeforamerica/brigade-information/master/test/test_organizations.json': + return response(200, org_json, {'content-type': 'text/csv; charset=UTF-8'}) # return an empty civic.json so the value of status there won't overwrite the one from the spreadsheet elif "/contents/civic.json" in url.geturl(): return response(200, '''{}''', {'Etag': '8456bc53d4cf6b78779ded3408886f82'}) @@ -945,6 +1087,10 @@ def check_database_against_input(self): for event_dict in check_events[organization.name]: event = self.db.session.query(Event).filter(Event.event_url == event_dict['event_url'], Event.organization_name == event_dict['organization_name']).first() self.assertIsNotNone(event) + self.assertIsNotNone(event.location) + self.assertIsNotNone(event.lat) + self.assertIsNotNone(event.lon) + self.assertIsNotNone(event.description) self.assertTrue(event.keep) # get the matching STORIES for this organization from the database @@ -1070,6 +1216,9 @@ def test_empty_project_values_set_null(self): def overwrite_response_content(url, request): if "cityvoice/contents/civic.json" in url.geturl(): return response(200, '''{"status": "", "tags": ["", "", ""]}''', {'Etag': '8456bc53d4cf6b78779ded3408886f82'}) + if url.geturl() == 'http://example.com/cfa-projects.csv': + project_lines = ['''Name,description,link_url,code_url,type,categories,tags,status''', ''',,,https://github.com/codeforamerica/cityvoice,,,"safety, police, poverty",Shuttered''', ''',,,https://github.com/codeforamerica/bizfriendly-web/,,,"",'''] + return response(200, '''\n'''.join(project_lines), {'content-type': 'text/csv; charset=UTF-8'}) with HTTMock(self.response_content): with HTTMock(overwrite_response_content): @@ -1084,6 +1233,7 @@ def overwrite_response_content(url, request): self.assertEqual(project.status, None) self.assertEqual(project.tags, None) + # and in the saved project you know doesn't have status & tags set because they're # missing from civic.json filter = [Project.organization_name == u'Code for America (3)', Project.name == u'cityvoice'] @@ -1128,10 +1278,10 @@ def test_bad_events_json(self): self.setup_mock_rss_response() def overwrite_response_content(url, request): - if 'meetup.com' in url.geturl() and 'Code-For-Charlotte' in url.geturl(): + if 'https://api.meetup.com/2/events?status=past,upcoming&format=json&group_urlname=' in url.geturl() and 'Code-For-Charlotte' in url.geturl(): return response(200, 'no json object can be decoded from me') - elif 'meetup.com' in url.geturl() and 'Code-For-Rhode-Island' in url.geturl(): + elif 'https://api.meetup.com/2/events?status=past,upcoming&format=json&group_urlname=' in url.geturl() and 'Code-For-Rhode-Island' in url.geturl(): return response(200, None) with HTTMock(self.response_content): @@ -1143,6 +1293,80 @@ def overwrite_response_content(url, request): from app import Event self.assertEqual(self.db.session.query(Event).count(), 0) + def test_secondary_github_urls_handled_correctly(self): + ''' Projects with secondary GitHub URLs as their main URL are handled correctly. + ''' + self.setup_mock_rss_response() + + from app import Project + import run_update + + # alter responses to return only one organization, with one project that + # has a 2nd-level GitHub URL (with /issues at the end) + def overwrite_response_content(url, request): + if url.geturl() == 'https://raw.githubusercontent.com/codeforamerica/brigade-information/master/test/test_organizations.json': + return response(200, '''[{"name": "Cöde for Ameriça", "website": "http://codeforamerica.org", "events_url": "http://www.meetup.com/events/Code-For-Charlotte/", "rss": "http://www.codeforamerica.org/blog/feed/", "projects_list_url": "http://example.com/cfa-projects.csv"}]''', {'content-type': 'text/csv; charset=UTF-8'}) + elif url.geturl() == 'http://example.com/cfa-projects.csv': + project_lines = ['''Name,description,link_url,code_url,type,categories,tags,status'''.encode('utf8'), ''',,,https://github.com/codeforamerica/cityvoice/issues,,,"safety, police, poverty",Shuttered'''.encode('utf8')] + return response(200, '''\n'''.join(project_lines), {'content-type': 'text/csv; charset=UTF-8'}) + + # run a standard run_update + with HTTMock(self.response_content): + with HTTMock(overwrite_response_content): + run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + + check_project = self.db.session.query(Project).first() + # the project exists + self.assertIsNotNone(check_project) + self.assertIsNotNone(check_project.id) + # the project has issues + self.assertTrue(hasattr(check_project, 'issues')) + self.assertTrue(len(check_project.issues) > 0) + # the project has status & tags from civic.json + self.assertTrue(check_project.status is not None) + self.assertTrue(type(check_project.status) is unicode) + self.assertTrue(len(check_project.status) > 0) + self.assertTrue(check_project.tags is not None) + self.assertTrue(type(check_project.tags) is list) + self.assertTrue(len(check_project.tags) > 0) + + def test_git_extension_stripped_from_git_url(self): + ''' A .git extension is stripped from a project's GitHub URL + ''' + self.setup_mock_rss_response() + + from app import Project + import run_update + + # alter responses to return only one organization, with one project that + # has a GitHub URL with .git at the end + def overwrite_response_content(url, request): + if url.geturl() == 'https://raw.githubusercontent.com/codeforamerica/brigade-information/master/test/test_organizations.json': + return response(200, '''[{"name": "Cöde for Ameriça", "website": "http://codeforamerica.org", "events_url": "http://www.meetup.com/events/Code-For-Charlotte/", "rss": "http://www.codeforamerica.org/blog/feed/", "projects_list_url": "http://example.com/cfa-projects.csv"}]''', {'content-type': 'text/csv; charset=UTF-8'}) + elif url.geturl() == 'http://example.com/cfa-projects.csv': + project_lines = ['''Name,description,link_url,code_url,type,categories,tags,status'''.encode('utf8'), ''',,,https://github.com/codeforamerica/cityvoice.git,,,"safety, police, poverty",Shuttered'''.encode('utf8')] + return response(200, '''\n'''.join(project_lines), {'content-type': 'text/csv; charset=UTF-8'}) + + # run a standard run_update + with HTTMock(self.response_content): + with HTTMock(overwrite_response_content): + run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + + check_project = self.db.session.query(Project).first() + # the project exists + self.assertIsNotNone(check_project) + self.assertIsNotNone(check_project.id) + # the project has issues + self.assertTrue(hasattr(check_project, 'issues')) + self.assertTrue(len(check_project.issues) > 0) + # the project has status & tags from civic.json + self.assertTrue(check_project.status is not None) + self.assertTrue(type(check_project.status) is unicode) + self.assertTrue(len(check_project.status) > 0) + self.assertTrue(check_project.tags is not None) + self.assertTrue(type(check_project.tags) is list) + self.assertTrue(len(check_project.tags) > 0) + def test_unmodified_projects_stay_in_database(self): ''' Verify that unmodified projects are not deleted from the database ''' @@ -1205,69 +1429,7 @@ def test_values_set_from_civic_json(self): project = self.db.session.query(Project).first() self.assertIsNotNone(project) self.assertEqual(project.status, u'Beta') - self.assertEqual(project.tags, u'mapping,transportation,community organizing') - - def test_new_values_in_civic_json(self): - ''' A value that has changed in civic.json should be saved, even if the - related GitHub project reports that it hasn't been updated - ''' - self.setup_mock_rss_response() - - from app import Project - import run_update - - org_csv = '''name,website,events_url,rss,projects_list_url\nOrganization Name,,,,http://example.com/cfa-projects.csv''' - - # set results_state to 'after' so we'll only get one project - self.results_state = 'after' - - def status_one_response_content(url, request): - if "docs.google.com" in url.geturl(): - return response(200, org_csv, {'content-type': 'text/csv; charset=UTF-8'}) - - with HTTMock(self.response_content): - with HTTMock(status_one_response_content): - run_update.main(org_name=u"Organization Name", org_sources=run_update.TEST_ORG_SOURCES_FILENAME) - - project_v1 = self.db.session.query(Project).first() - # the project status was correctly set - self.assertEqual(project_v1.status, u'Beta') - # the project tags were correctly set - self.assertEqual(project_v1.tags, u'mapping,transportation,community organizing') - v1_github_details = project_v1.github_details - - # save the default github response so we can send it with a 304 status below - cv_body_text = None - cv_headers_dict = None - with HTTMock(self.response_content): - from requests import get - got = get('https://api.github.com/repos/codeforamerica/cityvoice') - cv_body_text = str(got.text) - cv_headers_dict = got.headers - - def status_two_response_content(url, request): - if "docs.google.com" in url.geturl(): - return response(200, org_csv, {'content-type': 'text/csv; charset=UTF-8'}) - # return a civic.json with a new status value - elif "/contents/civic.json" in url.geturl(): - return response(200, '''{"status": "Cromulent", "tags": ["community organizing", "safety and justice"]}''', {'Etag': '8456bc53d4cf6b78779ded3408886f82'}) - # return a 304 (not modified) instead of a 200 for the project - elif url.geturl() == 'https://api.github.com/repos/codeforamerica/cityvoice': - return response(304, cv_body_text, cv_headers_dict) - - with HTTMock(self.response_content): - with HTTMock(status_two_response_content): - run_update.main(org_name=u"Organization Name", org_sources=run_update.TEST_ORG_SOURCES_FILENAME) - - project_v2 = self.db.session.query(Project).first() - # the new project status was correctly set - self.assertEqual(project_v2.status, u'Cromulent') - # the new tags were correctly set - self.assertEqual(project_v2.tags, u'community organizing,safety and justice') - # the untouched details from the GitHub project weren't changed - self.assertEqual(project_v2.github_details, v1_github_details) - - self.results_state = 'before' + self.assertEqual(project.tags, [u'mapping', u'transportation', u'community organizing']) def test_unicode_values_in_civic_json(self): ''' Unicode values in the civic.json file are handled correctly @@ -1290,10 +1452,10 @@ def unicode_response_content(url, request): project = self.db.session.query(Project).first() self.assertIsNotNone(project) self.assertEqual(project.status, u'汉语 漢語') - self.assertEqual(project.tags, u'한국어 조선말,ру́сский язы́к,†≈ç®åz¥≈†') + self.assertEqual(project.tags, [u'한국어 조선말', u'ру́сский язы́к', u'†≈ç®åz¥≈†']) # testing for the roman text representations as well, just for reference self.assertEqual(project.status, u'\u6c49\u8bed \u6f22\u8a9e') - self.assertEqual(project.tags, u'\ud55c\uad6d\uc5b4 \uc870\uc120\ub9d0,\u0440\u0443\u0301\u0441\u0441\u043a\u0438\u0439 \u044f\u0437\u044b\u0301\u043a,\u2020\u2248\xe7\xae\xe5z\xa5\u2248\u2020') + self.assertEqual(project.tags, [u'\ud55c\uad6d\uc5b4 \uc870\uc120\ub9d0', u'\u0440\u0443\u0301\u0441\u0441\u043a\u0438\u0439 \u044f\u0437\u044b\u0301\u043a', u'\u2020\u2248\xe7\xae\xe5z\xa5\u2248\u2020']) def test_alt_tag_format_in_civic_json(self): ''' Tags represented as objects rather than strings are read correctly. @@ -1312,17 +1474,17 @@ def unicode_response_content(url, request): with HTTMock(unicode_response_content): run_update.main(org_name=u"C\xf6de for Ameri\xe7a", org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + # check a project for the status and tags from the mock civic.json project = self.db.session.query(Project).first() self.assertIsNotNone(project) self.assertEqual(project.status, u'Cromulent') - self.assertEqual(project.tags, u'economic development,twitter,người máy,python') + self.assertEqual(project.tags, [u'economic development',u'twitter',u'người máy',u'python']) # testing for the roman text representations as well, just for reference - self.assertEqual(project.tags, u'economic development,twitter,ng\u01b0\u1eddi m\xe1y,python') + self.assertEqual(project.tags, [u'economic development',u'twitter',u'ng\u01b0\u1eddi m\xe1y',u'python']) - def test_civic_json_values_preferred(self): - ''' Values set in civic.json are preferred over values set in spreadsheets, - even after multiple updates. + def test_spreadsheet_values_preferred(self): + ''' Values set in spreadsheet are preferred over values set in civic.json ''' self.setup_mock_rss_response() @@ -1340,7 +1502,7 @@ def test_civic_json_values_preferred(self): project = self.db.session.query(Project).first() self.assertIsNotNone(project) self.assertEqual(project.status, u'Beta') - self.assertEqual(project.tags, u'mapping,transportation,community organizing') + self.assertEqual(project.tags, [u'mapping',u'transportation',u'community organizing']) # respond to requests for project, root file listing, and civic.json with 304s # only if a 'If-None-Match' or 'If-Modified-Since' header is passed @@ -1363,61 +1525,151 @@ def files_not_updated(url, request): # check a project for the status and tags from the mock civic.json project = self.db.session.query(Project).first() self.assertIsNotNone(project) - self.assertEqual(project.status, u'Beta') - self.assertEqual(project.tags, u'mapping,transportation,community organizing') + self.assertEqual(project.status, u'Shuttered') + self.assertEqual(project.tags, [u'safety', u'police', u'poverty']) self.results_state = 'before' - def test_attendance(self): - ''' Test gathering attendance from the peopledb ''' - # Mock attendance data - cfsf_url = "https://www.codeforamerica.org/api/organizations/Code-for-San-Francisco" - cfsf_name = "Code for San Francisco" - oakland_url = "https://www.codeforamerica.org/api/organizations/Open-Oakland" - oakland_name = "Open Oakland" - cfsf_checkin1 = datetime.datetime.strptime("2015-01-01","%Y-%m-%d") - cfsf_checkin2 = datetime.datetime.strptime("2015-01-08","%Y-%m-%d") - oakland_checkin1 = datetime.datetime.strptime("2015-01-16","%Y-%m-%d") - oakland_checkin2 = datetime.datetime.strptime("2015-01-24","%Y-%m-%d") - - # Access the peopledb - PEOPLEDB = 'postgres:///peopledbtest' - - with connect(PEOPLEDB) as conn: - with conn.cursor() as db: - # Put some fake attendance data in it - q = '''INSERT INTO attendance - ( datetime, organization_url) - VALUES ( %s, %s )''' - db.execute(q, (cfsf_checkin1, cfsf_url)) - db.execute(q, (cfsf_checkin2, cfsf_url)) - db.execute(q, (oakland_checkin1, oakland_url)) - db.execute(q, (oakland_checkin2, oakland_url)) - - # Call a function to pull data out of it - with connect(PEOPLEDB) as conn: - with conn.cursor(cursor_factory=extras.RealDictCursor) as peopledb: + def test_meetup_count(self): + ''' Test getting membership count from Meetup + ''' + from test.factories import OrganizationFactory + org = OrganizationFactory(name="TEST ORG") + with HTTMock(self.response_content): + import run_update + org.member_count = run_update.get_meetup_count(organization=org, identifier="TEST-MEETUP") + + self.assertEqual(org.member_count, 100) + + + def test_meetup_count_with_empty_response(self): + from test.factories import OrganizationFactory + org = OrganizationFactory(name="TEST ORG") + response = { + "status_code": 200, + "content": "application/json;charset=utf-8" + } + with HTTMock(lambda _url, _request: response): + import run_update + org.member_count = run_update.get_meetup_count(organization=org, identifier="TEST-MEETUP") + + self.assertEqual(org.member_count, None) + + + def test_languages(self): + ''' Test pulling languages from Github ''' + from app import Project + + # Test that languages are returned as list + with HTTMock(self.response_content): + import run_update + run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + project = self.db.session.query(Project).first() + self.assertEqual(["Shell", "HTML", "Ruby", "JavaScript", "CSS"], project.languages) + + # Test that null languages are handled + with HTTMock(self.response_content): + + def overwrite_response(url, request): + # mock of programming languages + if url.geturl() == 'https://api.github.com/repos/codeforamerica/cityvoice/languages': + return response(200, ''' { } ''') + + with HTTMock(overwrite_response): + run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + project = self.db.session.query(Project).first() + self.assertTrue(isinstance(project.languages, type(None))) + + def test_two_issues_with_the_same_name(self): + ''' Two issues with the same name but different html_urls should be saved as separate issues. + ''' + from app import Project, Issue + import run_update + self.setup_mock_rss_response() + + same_title = u'Same-Titled Cityvoice Issue' + + def overwrite_response_content(url, request): + response_etag = {'ETag': '8456bc53d4cf6b78779ded3408886f82'} + if url.geturl() == 'https://api.github.com/repos/codeforamerica/cityvoice/issues': + return response(200, '''[{{"html_url": "https://github.com/codeforamerica/cityvoice/issue/210","title": "{issue_title}", "labels": [],"created_at": "2015-09-16T05:45:20Z", "updated_at": "2015-10-22T17:26:02Z", "body" : "WHATEVER"}}, {{"html_url": "https://github.com/codeforamerica/cityvoice/issue/211","title": "{issue_title}", "labels": [], "created_at" : "2015-10-26T01:13:03Z", "updated_at" : "2015-10-26T18:06:54Z", "body" : "WHATEVER"}}]'''.format(issue_title=same_title), response_etag) + + # run a standard run_update + with HTTMock(self.response_content): + with HTTMock(overwrite_response_content): + run_update.main(org_name=u"Cöde for Ameriça", org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + + # check the cityvoice project + filter = Project.name == u'cityvoice' + project = self.db.session.query(Project).filter(filter).first() + self.assertIsNotNone(project) + self.assertEqual(project.name, u'cityvoice') + project_id = project.id + + # and check the issues + filter = Issue.title == same_title + issues = self.db.session.query(Issue).filter(filter).all() + self.assertIsNotNone(issues) + self.assertEqual(2, len(issues)) + self.assertNotEqual(issues[0].html_url, issues[1].html_url) + for check_issue in issues: + self.assertEqual(check_issue.title, same_title) + self.assertEqual(check_issue.project_id, project_id) + + def test_404ing_project_deleted(self): + ''' A project that once existed but is now returning a 404 is deleted from the database. + ''' + from app import Project + self.setup_mock_rss_response() + + # run a vanilla update + with HTTMock(self.response_content): + import run_update + run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + + filter = Project.name == u'cityvoice' + projects = self.db.session.query(Project).filter(filter).all() + self.assertEqual(len(projects), 3) + + def overwrite_response_content(url, request): + if 'https://api.github.com/repos/codeforamerica/cityvoice' in url.geturl(): + return response(404, '''{"message": "Not Found", "documentation_url": "https://developer.github.com/v3"}''', {'ETag': '8456bc53d4cf6b78779ded3408886f82'}) + + logging.error = Mock() + + # run a new update + with HTTMock(self.response_content): + with HTTMock(overwrite_response_content): import run_update - from app import Attendance, Organization - from test.factories import OrganizationFactory - cfsf = OrganizationFactory(name='Code for San Francisco') - oakland = OrganizationFactory(name='Open Oakland') - - cfsf_attendance = run_update.get_attendance(peopledb, cfsf_url, cfsf.name) - self.assertEqual(cfsf_attendance["organization_name"], "Code for San Francisco") - self.assertTrue("2015 01" in cfsf_attendance["weekly"].keys()) - - oakland_attendance = run_update.get_attendance(peopledb, oakland_url, oakland.name) - self.assertEqual(oakland_attendance["organization_name"], "Open Oakland") - self.assertTrue("2015 03" in oakland_attendance["weekly"].keys()) - - run_update.update_attendance(self.db, cfsf.name, cfsf_attendance) - run_update.update_attendance(self.db, oakland.name, oakland_attendance) - attendance = self.db.session.query(Attendance).all() - self.assertEqual(attendance[0].organization_name, "Code for San Francisco") - self.assertEqual(attendance[1].organization_name, "Open Oakland") - self.assertTrue("2015 03" in attendance[1].weekly.keys()) + run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + + logging.error.assert_called_with('https://api.github.com/repos/codeforamerica/cityvoice doesn\'t exist.') + filter = Project.name == u'cityvoice' + projects = self.db.session.query(Project).filter(filter).all() + self.assertEqual(len(projects), 0) + + def test_commit_status(self): + """ Test grabbing the last commit status """ + with HTTMock(self.response_content): + import run_update + run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + + from app import Project + filter = Project.name == u'cityvoice' + cityvoice = self.db.session.query(Project).filter(filter).first() + self.assertEqual("success", cityvoice.commit_status) + + def test_logo_fetching(self): + """ Test grabbing the organization logo """ + with HTTMock(self.response_content): + import run_update + run_update.main(org_sources=run_update.TEST_ORG_SOURCES_FILENAME) + + from app import Organization + filter = Organization.name == u'Code for America (2)' + cfa = self.db.session.query(Organization).filter(filter).first() + self.assertEqual("https://avatars2.githubusercontent.com/u/337792?v=4", cfa.logo_url) if __name__ == '__main__': diff --git a/test_org_sources.csv b/test_org_sources.csv index 953513e..10bbef1 100644 --- a/test_org_sources.csv +++ b/test_org_sources.csv @@ -1 +1 @@ -https://docs.google.com/spreadsheet/pub?key=0ArHmv-6U1drqdEVkTUtZNVlYRE5ndERLLTFDb2RqQlE&output=csv +https://raw.githubusercontent.com/codeforamerica/brigade-information/master/test/test_organizations.json diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..65c4307 --- /dev/null +++ b/utils.py @@ -0,0 +1,38 @@ +from datetime import datetime + + +def is_safe_name(name): + ''' Return True if the string is a safe name. + ''' + return raw_name(safe_name(name)) == name + + +def safe_name(name): + ''' Return URL-safe organization name with spaces replaced by dashes. + + Slashes will be removed, which is incompatible with raw_name(). + ''' + return name.replace(' ', '-').replace('/', '-').replace('?', '-').replace('#', '-') + + +def raw_name(name): + ''' Return raw organization name with dashes replaced by spaces. + + Also replace old-style underscores with spaces. + ''' + return name.replace('_', ' ').replace('-', ' ') + + +def convert_datetime_to_iso_8601(dt): + ''' Convert the passed datetime object to ISO 8601 format + ''' + if not dt or type(dt) is not datetime: + return None + + iso_string = unicode(dt.isoformat()) + + # add a 'Z' (representing the UTC time zone) to the end if there's no explicit time zone set + if not dt.tzinfo: + iso_string = u'{}Z'.format(iso_string.rstrip(u'Z')) + + return iso_string