├── crm ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── make_superuser.py │ │ └── dedupe.py ├── migrations │ ├── __init__.py │ ├── 0016_merge_20190508_0013.py │ ├── 0017_remove_person_address.py │ ├── 0013_remove_turfmembership_is_captain.py │ ├── 0011_person_phone.py │ ├── 0015_auto_20190507_2326.py │ ├── 0010_auto_20180820_0223.py │ ├── 0018_auto_20190510_2349.py │ ├── 0006_auto_20180802_0712.py │ ├── 0012_person_location.py │ ├── 0009_auto_20180807_0304.py │ ├── 0012_person_is_captain.py │ ├── 0014_auto_20190507_2318.py │ ├── 0008_auto_20180806_0008.py │ ├── 0002_auto_20180729_2011_squashed_0005_auto_20180729_2125.py │ ├── 0001_initial.py │ ├── 0007_auto_20180805_2335.py │ ├── 0013_convert_to_geocodable.py │ └── 0003_auto_20180802_0200_squashed_0005_auto_20180802_0319.py ├── templates │ ├── index.html │ ├── robots.txt │ ├── email.eml │ ├── action.html │ ├── form.html │ └── report.html ├── apps.py ├── urls.py ├── pipeline.py ├── context.py ├── views.py └── exporting.py ├── events ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── list_members.py │ │ └── leaderboard.py ├── migrations │ ├── __init__.py │ ├── 0006_remove_event_address.py │ ├── 0005_auto_20190510_2349.py │ ├── 0003_auto_20190510_2346.py │ ├── 0002_auto_20180824_2228.py │ ├── 0004_event_location.py │ └── 0001_initial.py ├── apps.py ├── api_views.py ├── tests.py ├── admin.py ├── serializers.py └── models.py ├── sync ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── export.py │ │ └── import.py ├── migrations │ ├── __init__.py │ ├── 0004_auto_20190731_1553.py │ ├── 0003_synctarget_lastrun.py │ ├── 0002_auto_20190730_1850.py │ └── 0001_initial.py ├── tests.py ├── views.py ├── apps.py ├── templates │ └── admin │ │ └── sync │ │ ├── add.html │ │ └── change_form.html ├── forms.py └── models.py ├── donations ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_auto_20181021_0226.py │ └── 0001_initial.py ├── tests.py ├── apps.py ├── models.py ├── admin.py └── importing.py ├── filtering ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── query.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── apps.py └── admin.py ├── geocodable ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── resolve_all.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── views.py ├── apps.py ├── admin.py ├── api_views.py ├── serializers.py └── api.py ├── onboarding ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── new_neighbors.py │ │ └── onboard.py ├── migrations │ ├── __init__.py │ ├── 0008_remove_signup_deleted.py │ ├── 0006_auto_20180814_1829.py │ ├── 0011_auto_20181023_0619.py │ ├── 0002_newneighbornotificationtarget_last_notified.py │ ├── 0013_auto_20190402_2358.py │ ├── 0010_auto_20181023_0617.py │ ├── 0014_auto_20190507_2318.py │ ├── 0004_auto_20180810_0755.py │ ├── 0003_newneighbornotificationtarget_state.py │ ├── 0005_auto_20180810_0756.py │ ├── 0001_initial.py │ ├── 0012_auto_20181107_2352.py │ ├── 0007_auto_20181016_0515.py │ └── 0009_auto_20181021_2234.py ├── apps.py ├── templates │ └── new-neighbors-notification.eml ├── api_views.py ├── serializers.py ├── jobs.py ├── admin.py └── models.py ├── organizer ├── __init__.py ├── wsgi.py ├── pipeline.py ├── viewsets.py ├── exporting.py └── importing.py ├── assets ├── .gitignore ├── img │ ├── bg.png │ ├── logo.png │ ├── logo-wide.png │ ├── unfurl-bg.png │ ├── logo-square.png │ └── symbol.svg ├── js │ ├── style-bootstrap.js │ ├── store │ │ ├── history.js │ │ ├── provider.js │ │ ├── index.js │ │ ├── store.js │ │ ├── filter.js │ │ ├── select.js │ │ └── model.test.js │ ├── selectors │ │ ├── auth.js │ │ ├── index.js │ │ ├── geocache.js │ │ ├── people.js │ │ └── events.test.js │ ├── setupTests.js │ ├── components │ │ ├── ImportDialog.test.js │ │ ├── App.test.js │ │ ├── MapIndex.test.js │ │ ├── MapIndex.scss │ │ ├── chrome │ │ │ ├── Logo.js │ │ │ ├── LoadingSpinner.js │ │ │ ├── BusyIndicator.js │ │ │ ├── BusyIndicator.test.js │ │ │ ├── LoginButtons.test.js │ │ │ ├── OrganizerAppBar.test.js │ │ │ ├── OrganizerBottomNav.test.js │ │ │ ├── LoginButtons.js │ │ │ └── OrganizerBottomNav.js │ │ ├── PeopleIndex.test.js │ │ ├── Gravatar.js │ │ ├── mapping │ │ │ ├── LocalMap.js │ │ │ ├── MarkerMap.js │ │ │ ├── BaseMap.js │ │ │ └── LocatorControl.js │ │ ├── UserAvatar.js │ │ ├── people-browser │ │ │ ├── BooleanSelect.js │ │ │ ├── index.test.js │ │ │ └── Search.js │ │ ├── MaterialFormSwitch.test.js │ │ ├── MaterialFormSwitch.js │ │ ├── MaterialFormText.test.js │ │ ├── AppRoutes.test.js │ │ ├── MaterialFormSelect.test.js │ │ ├── events │ │ │ ├── NoEvents.js │ │ │ └── NoLocation.js │ │ ├── activities │ │ │ └── checkin │ │ │ │ ├── Skeleton.js │ │ │ │ └── SignupForm.js │ │ ├── MaterialFormSelect.js │ │ ├── MaterialFormText.js │ │ ├── MaterialFormModelSelect.js │ │ ├── AppRoutes.js │ │ ├── ErrorWrapper.js │ │ ├── App.js │ │ └── MapIndex.js │ ├── Django.js │ ├── reducers │ │ ├── index.js │ │ ├── filters.js │ │ ├── auth.js │ │ ├── geocache.js │ │ ├── model.js │ │ ├── select.js │ │ └── model.test.js │ ├── Breakpoint.js │ ├── actions │ │ ├── index.js │ │ ├── index.test.js │ │ └── geocache.js │ ├── index.test.js │ ├── index.js │ └── lib │ │ └── filter-ast.js ├── manifest.json ├── scss │ ├── app.scss │ ├── spinner.scss │ └── form-view.scss └── index.html ├── __mocks__ ├── styleMock.js ├── fileMock.js ├── raven-js.js └── history.js ├── Procfile ├── .coveragerc ├── .gitignore ├── Dockerfile ├── pytest.ini ├── docs ├── usage.rst ├── import-export.rst ├── events.rst ├── broadcasts.rst ├── maintenance.rst ├── crm.rst ├── installation.rst ├── integrations.rst └── development.rst ├── static └── app.css ├── docker-compose.yml ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── manage.py ├── webpack.prod.js ├── webpack.dev.js ├── .eslintrc.js ├── README.md ├── index.rst ├── Pipfile └── app.json /crm/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /events/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sync/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /donations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /filtering/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /geocodable/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /onboarding/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /organizer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crm/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crm/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sync/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sync/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/.gitignore: -------------------------------------------------------------------------------- 1 | bundles 2 | -------------------------------------------------------------------------------- /donations/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /events/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /events/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /filtering/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /filtering/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /geocodable/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /geocodable/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /onboarding/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /onboarding/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crm/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /events/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sync/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /filtering/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /geocodable/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /onboarding/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = "test-stub" 2 | -------------------------------------------------------------------------------- /crm/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | -------------------------------------------------------------------------------- /crm/templates/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: * 3 | -------------------------------------------------------------------------------- /assets/img/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdfischer/organizer/HEAD/assets/img/bg.png -------------------------------------------------------------------------------- /assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdfischer/organizer/HEAD/assets/img/logo.png -------------------------------------------------------------------------------- /crm/templates/email.eml: -------------------------------------------------------------------------------- 1 | {{body}} 2 | -------- 3 | This email was sent with EBF Organizer. 4 | -------------------------------------------------------------------------------- /assets/js/style-bootstrap.js: -------------------------------------------------------------------------------- 1 | import { install } from '@material-ui/styles' 2 | 3 | install() 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn organizer.wsgi --preload --log-file - 2 | worker: python manage.py rqworker 3 | -------------------------------------------------------------------------------- /assets/img/logo-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdfischer/organizer/HEAD/assets/img/logo-wide.png -------------------------------------------------------------------------------- /assets/img/unfurl-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdfischer/organizer/HEAD/assets/img/unfurl-bg.png -------------------------------------------------------------------------------- /assets/img/logo-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tdfischer/organizer/HEAD/assets/img/logo-square.png -------------------------------------------------------------------------------- /__mocks__/raven-js.js: -------------------------------------------------------------------------------- 1 | module.exports = jest.genMockFromModule('raven-js') 2 | module.exports.config = jest.fn(() => ({install:jest.fn()})) 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | */apps.py 4 | */management/commands/* 5 | manage.py 6 | conf.py 7 | organizer/wsgi.py 8 | organizer/settings.py 9 | -------------------------------------------------------------------------------- /assets/js/store/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history' 2 | 3 | export const history = createBrowserHistory() 4 | export default history 5 | -------------------------------------------------------------------------------- /sync/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.test import TestCase 5 | 6 | # Create your tests here. 7 | -------------------------------------------------------------------------------- /donations/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.test import TestCase 5 | 6 | # Create your tests here. 7 | -------------------------------------------------------------------------------- /sync/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.shortcuts import render 5 | 6 | # Create your views here. 7 | -------------------------------------------------------------------------------- /assets/js/selectors/auth.js: -------------------------------------------------------------------------------- 1 | export const getCurrentUser = state => state.getIn(['auth', 'user'], {}) 2 | export const getLoggedIn = state => !!getCurrentUser(state).id 3 | -------------------------------------------------------------------------------- /geocodable/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.shortcuts import render 5 | 6 | # Create your views here. 7 | -------------------------------------------------------------------------------- /crm/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class CrmConfig(AppConfig): 8 | name = 'crm' 9 | -------------------------------------------------------------------------------- /sync/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class SyncConfig(AppConfig): 8 | name = 'sync' 9 | -------------------------------------------------------------------------------- /events/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class EventsConfig(AppConfig): 8 | name = 'events' 9 | -------------------------------------------------------------------------------- /donations/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class DonationsConfig(AppConfig): 8 | name = 'donations' 9 | -------------------------------------------------------------------------------- /filtering/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class FilteringConfig(AppConfig): 8 | name = 'filtering' 9 | -------------------------------------------------------------------------------- /geocodable/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class GeocodableConfig(AppConfig): 8 | name = 'geocodable' 9 | -------------------------------------------------------------------------------- /onboarding/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class OnboardingConfig(AppConfig): 8 | name = 'onboarding' 9 | -------------------------------------------------------------------------------- /assets/js/setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import geolocate from 'mock-geolocation' 4 | 5 | geolocate.use() 6 | configure({ adapter: new Adapter() }); 7 | -------------------------------------------------------------------------------- /__mocks__/history.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | createBrowserHistory: () => { 3 | return { 4 | location: {}, 5 | listen: jest.fn(), 6 | action: "", 7 | push: jest.fn() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /crm/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from . import views 3 | 4 | urlpatterns = [ 5 | url(r'^service-worker.js', views.service_worker), 6 | url(r'^robots.txt', views.robots), 7 | url(r'^', views.index), 8 | ] 9 | -------------------------------------------------------------------------------- /assets/js/components/ImportDialog.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import ImportDialog from './ImportDialog' 4 | 5 | it('should render defaults safely', () => { 6 | shallow() 7 | }) 8 | 9 | -------------------------------------------------------------------------------- /geocodable/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.contrib import admin 5 | from . import models 6 | 7 | admin.site.register(models.Location) 8 | admin.site.register(models.LocationAlias) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | db.sqlite3 3 | *.swp 4 | dump.rdb 5 | node_modules 6 | coverage 7 | .env 8 | webpack-stats.json 9 | yarn-error.log 10 | test-results 11 | .coverage 12 | .hypothesis 13 | .pytest_cache 14 | htmlcov 15 | prof 16 | stats.json 17 | -------------------------------------------------------------------------------- /onboarding/management/commands/new_neighbors.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from onboarding import jobs 3 | 4 | class Command(BaseCommand): 5 | 6 | def handle(self, *args, **options): 7 | jobs.processAllTargets() 8 | -------------------------------------------------------------------------------- /assets/js/components/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { App } from './App' 3 | import { mount } from 'enzyme' 4 | 5 | it('should safely render a default state', () => { 6 | const wrapper = mount() 7 | expect(wrapper.find('.the-app')).toHaveLength(1) 8 | }) 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nikolaik/python-nodejs:python2.7-nodejs8 2 | ENV PYTHONUNBUFFERED 1 3 | RUN pip install pipenv 4 | RUN mkdir /code 5 | WORKDIR /code 6 | COPY package.json ./ 7 | COPY Pipfile Pipfile.lock ./ 8 | RUN pipenv -v --two --where sync 9 | RUN npm install 10 | COPY . /code/ 11 | -------------------------------------------------------------------------------- /assets/js/components/MapIndex.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { MapIndex } from './MapIndex' 3 | import { shallow } from 'enzyme' 4 | import Immutable from 'immutable' 5 | 6 | it('should render default state', () => { 7 | shallow() 8 | }) 9 | -------------------------------------------------------------------------------- /assets/js/components/MapIndex.scss: -------------------------------------------------------------------------------- 1 | .membership-map { 2 | overflow: hidden; 3 | width: 100%; 4 | height: 100%; 5 | flex: auto; 6 | display: flex; 7 | flex-flow: column; 8 | 9 | .leaflet-container { 10 | position: relative; 11 | width: inherit; 12 | flex: auto; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /assets/js/Django.js: -------------------------------------------------------------------------------- 1 | const getCookie = name => { 2 | const cookie = document.cookie 3 | .split(';') 4 | .find(cookie => cookie.trim().startsWith(name + '=')) || '' 5 | return decodeURIComponent(cookie.trim().substring(name.length + 1)) 6 | } 7 | 8 | export var csrftoken = getCookie('csrftoken') 9 | -------------------------------------------------------------------------------- /sync/templates/admin/sync/add.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% block content %} 3 |

Available backends:

4 |
    5 | {% for backend in backends %} 6 |
  • {{backend}}
  • 7 | {% endfor %} 8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /assets/js/components/chrome/Logo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import EBFESymbol from 'svg-react-loader!../../../img/symbol.svg' 3 | 4 | const Logo = props => ( 5 | (window.ORG_METADATA || {}).logo_url ? : 6 | ) 7 | 8 | export default Logo 9 | -------------------------------------------------------------------------------- /assets/js/components/PeopleIndex.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import { PeopleIndex } from './PeopleIndex' 4 | import Immutable from 'immutable' 5 | 6 | it('should render default state', () => { 7 | shallow() 8 | }) 9 | -------------------------------------------------------------------------------- /crm/pipeline.py: -------------------------------------------------------------------------------- 1 | from models import Person 2 | 3 | def ensure_person_for_email(user, details, *args, **kwargs): 4 | person, _ = Person.objects.update_or_create( 5 | email=user.email, 6 | defaults=dict( 7 | name = details.get('name') 8 | ) 9 | ) 10 | return {'person': person} 11 | -------------------------------------------------------------------------------- /assets/js/components/Gravatar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import gravatar from 'gravatar' 3 | import Avatar from '@material-ui/core/Avatar' 4 | 5 | export const Gravatar = props => ( 6 | 9 | ) 10 | 11 | export default Gravatar 12 | -------------------------------------------------------------------------------- /assets/js/selectors/index.js: -------------------------------------------------------------------------------- 1 | export const Geocache = require('./geocache') 2 | export const Auth = require('./auth') 3 | 4 | export const getSaving = state => state.getIn(['model', 'saving']) 5 | export const getLoading = state => state.getIn(['model', 'loading']) 6 | 7 | export const getActivityCount = state => getSaving(state) + getLoading(state) 8 | -------------------------------------------------------------------------------- /sync/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from . import models 3 | 4 | class ImportSourceForm(forms.ModelForm): 5 | backend = forms.CharField(disabled=True) 6 | class Meta: 7 | model = models.ImportSource 8 | fields = ['name', 'enabled', 'backend'] 9 | 10 | class DefaultImportSourceConfigForm(forms.Form): 11 | pass 12 | -------------------------------------------------------------------------------- /crm/templates/action.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block title %}{{action.name}}{% endblock %} 3 | {% block subtitle %}{{action.date}}{% endblock %} 4 | 5 | {% block contents %} 6 |

Forms

7 | 8 | {% for form in action.forms %} 9 | 10 | 11 | 12 | {% endfor %} 13 |
{{form.title}}
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /assets/js/components/mapping/LocalMap.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import BaseMap from './BaseMap' 3 | import LocatorControl from './LocatorControl' 4 | 5 | const LocalMap = props => { 6 | return ( 7 | 8 | 9 | {props.children} 10 | 11 | ) 12 | } 13 | 14 | export default LocalMap 15 | -------------------------------------------------------------------------------- /assets/js/selectors/geocache.js: -------------------------------------------------------------------------------- 1 | import { point } from '@turf/helpers' 2 | 3 | export const getCurrentLocation = state => state.getIn(['geocache', 'currentLocation']) ? point(state.getIn(['geocache', 'currentLocation'])) : null 4 | export const getAccuracy = state => state.getIn(['geocache', 'accuracy']) 5 | 6 | export const getLocationStatus = state => state.getIn(['geocache', 'status']) 7 | -------------------------------------------------------------------------------- /geocodable/management/commands/resolve_all.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from geocodable import models 4 | 5 | class Command(BaseCommand): 6 | def handle(self, *args, **options): 7 | for alias in models.LocationAlias.objects.all(): 8 | print alias.location.fullName 9 | alias.location = None 10 | alias.save() 11 | -------------------------------------------------------------------------------- /assets/js/components/UserAvatar.js: -------------------------------------------------------------------------------- 1 | import Gravatar from './Gravatar' 2 | import { connect } from 'react-redux' 3 | 4 | import { getCurrentUser } from '../selectors/auth' 5 | 6 | const mapStateToProps = state => { 7 | return { 8 | email: getCurrentUser(state).email 9 | } 10 | } 11 | 12 | export const UserAvatar = connect(mapStateToProps)(Gravatar) 13 | 14 | export default UserAvatar 15 | -------------------------------------------------------------------------------- /assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "EBF Organizer", 3 | "name": "East Bay Forward Organizer", 4 | "display": "standalone", 5 | "background_color": "#d7df23", 6 | "theme_color": "#d7df23", 7 | "icons": [ 8 | { 9 | "src": "/static/img/logo-square.png", 10 | "type": "image/png", 11 | "sizes": "801x801" 12 | } 13 | ], 14 | "start_url": "/organize/" 15 | } 16 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --forked 3 | DJANGO_SETTINGS_MODULE = organizer.settings 4 | python_files = tests.py test_*.py *_tests.py 5 | norecursedirs = assets node_modules .git 6 | markers = 7 | mock_redis: mark test to run with a mocked redis connection 8 | mock_geocoder: mark test to run with a mocked geocoder adaptor 9 | skip_auth: mark test to run without API authentication 10 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | At its core, Organizer tries to be a useful CRM for grassroots organizations 5 | that hate using CRMs. No CRM is perfect, and no CRM will fit your organization 6 | exactly right. But Organizer does have one advantage: it is AGPLv3. 7 | 8 | .. toctree:: 9 | :maxdepth: 3 10 | :caption: Features 11 | 12 | crm 13 | events 14 | import-export 15 | broadcasts 16 | -------------------------------------------------------------------------------- /assets/js/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux-immutable' 2 | import model from './model' 3 | import geocache from './geocache' 4 | import selections from './select' 5 | import auth from './auth' 6 | import filters from './filters' 7 | 8 | const organizerApp = combineReducers({ 9 | auth, 10 | model, 11 | selections, 12 | filters, 13 | geocache 14 | }) 15 | 16 | export default organizerApp 17 | -------------------------------------------------------------------------------- /assets/js/store/provider.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Provider } from 'react-redux' 3 | import { store } from './store' 4 | 5 | export const withProvider = Component => { 6 | return function wrapped(props) { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | } 14 | 15 | export default withProvider 16 | -------------------------------------------------------------------------------- /crm/context.py: -------------------------------------------------------------------------------- 1 | from . import serializers 2 | from django.conf import settings 3 | import json 4 | 5 | def add_user_data(request): 6 | user_serializer = serializers.UserSerializer(request.user, 7 | context={'request': request}) 8 | return { 9 | 'user_data': json.dumps(user_serializer.data) 10 | } 11 | 12 | def add_settings(request): 13 | return { 14 | 'settings': settings 15 | } 16 | -------------------------------------------------------------------------------- /onboarding/templates/new-neighbors-notification.eml: -------------------------------------------------------------------------------- 1 | You have some new neighbors! 2 | 3 | {% for newbie in newbies %}* {{newbie.current_turf}} {{newbie.name}} <{{newbie.email}}> 4 | {% endfor %} 5 | 6 | Why not send them an e-mail and say hello? 7 | 8 | ---- 9 | You are receiving this e-mail because EBFE Organizer is configured to notify 10 | this address about people nearby interested in organizing with East Bay for 11 | Everyone. 12 | -------------------------------------------------------------------------------- /sync/templates/admin/sync/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls %} 3 | 4 | {% block field_sets %} 5 |
6 | 7 | {{base_form.as_table}} 8 |
9 |
10 |
11 |

{{backend_name}} Options

12 | 13 | {{options_form.as_table}} 14 |
15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /crm/migrations/0016_merge_20190508_0013.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-08 00:13 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0015_auto_20190507_2326'), 12 | ('crm', '0013_convert_to_geocodable'), 13 | ] 14 | 15 | operations = [ 16 | ] 17 | -------------------------------------------------------------------------------- /assets/js/components/mapping/MarkerMap.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import BaseMap from './BaseMap' 3 | import { Marker } from 'react-leaflet' 4 | 5 | const MarkerMap = props => ( 6 | 7 | 8 | 9 | ) 10 | 11 | export default MarkerMap 12 | -------------------------------------------------------------------------------- /geocodable/api_views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import IsAuthenticated 2 | from . import models, serializers 3 | from organizer.viewsets import IntrospectiveViewSet 4 | 5 | class LocationViewSet(IntrospectiveViewSet): 6 | permission_classes = (IsAuthenticated,) 7 | queryset = models.Location.objects.all() 8 | serializer_class = serializers.LocationSerializer 9 | 10 | views = { 11 | 'locations': LocationViewSet 12 | } 13 | -------------------------------------------------------------------------------- /onboarding/api_views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | from rest_framework.permissions import AllowAny 3 | from crm.api_views import IntrospectiveViewSet 4 | from . import models, serializers 5 | 6 | class SignupViewSet(IntrospectiveViewSet): 7 | permission_classes = (AllowAny,) 8 | queryset = models.Signup.objects.all() 9 | serializer_class = serializers.SignupSerializer 10 | 11 | views = { 12 | 'signups': SignupViewSet 13 | } 14 | -------------------------------------------------------------------------------- /docs/import-export.rst: -------------------------------------------------------------------------------- 1 | .. _import-export: 2 | 3 | Import and Export 4 | ================= 5 | 6 | Organizer's :ref:`administration-interface` includes buttons for importing and 7 | exporting lists of data in CSV and other formats. Use the filters to narrow your 8 | list, then export it. 9 | 10 | Other services are processed by running the import or export sub-commands of 11 | ./manage.py. See :ref:`integrations` for a list of import and export plugins. 12 | -------------------------------------------------------------------------------- /assets/js/reducers/filters.js: -------------------------------------------------------------------------------- 1 | import * as Filter from '../store/filter' 2 | import Immutable from 'immutable' 3 | 4 | export default function(state = Immutable.Map(), action = {}) { 5 | switch (action.type) { 6 | case Filter.SET_FILTER: 7 | return state.setIn(['filters', action.key], action.filter) 8 | default: 9 | return Immutable.Map({ 10 | filters: Immutable.Map(), 11 | }).merge(state) 12 | } 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/js/components/chrome/LoadingSpinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import CircularProgress from '@material-ui/core/CircularProgress' 3 | import Grid from '@material-ui/core/Grid' 4 | 5 | export const LoadingSpinner = _props => ( 6 | 7 | 8 | 9 | ) 10 | 11 | export default LoadingSpinner 12 | -------------------------------------------------------------------------------- /events/api_views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets 2 | from rest_framework.permissions import IsAuthenticatedOrReadOnly 3 | from crm.api_views import IntrospectiveViewSet 4 | from . import models, serializers 5 | 6 | class EventViewSet(IntrospectiveViewSet): 7 | permission_classes = (IsAuthenticatedOrReadOnly,) 8 | queryset = models.Event.objects.all() 9 | serializer_class = serializers.EventSerializer 10 | 11 | views = { 12 | 'events': EventViewSet 13 | } 14 | -------------------------------------------------------------------------------- /assets/js/Breakpoint.js: -------------------------------------------------------------------------------- 1 | export default class Breakpoint { 2 | constructor(breakpoints) { 3 | this.breakpoints = breakpoints 4 | } 5 | 6 | get(value) { 7 | return this.breakpoints.filter(breakpoint => 8 | value <= breakpoint[0] || breakpoint[0] == undefined 9 | )[0] 10 | } 11 | 12 | getPoint(value) { 13 | return this.get(value)[0] 14 | } 15 | 16 | getValue(value) { 17 | return this.get(value)[1] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /assets/js/actions/index.js: -------------------------------------------------------------------------------- 1 | import * as _geocache from './geocache' 2 | export const Geocache = _geocache 3 | 4 | export const RECEIVE_USER = 'RECEIVE_USER' 5 | 6 | export const logout = () => { 7 | return dispatch => { 8 | return fetch('/api/users/logout/').then(() => 9 | Promise.resolve(dispatch(receiveUser({}))) 10 | ) 11 | } 12 | } 13 | 14 | export const receiveUser = (u) => { 15 | return { 16 | type: RECEIVE_USER, 17 | user: u 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /assets/js/selectors/people.js: -------------------------------------------------------------------------------- 1 | import { getCurrentUser } from './auth' 2 | import { createSelector } from 'reselect' 3 | import { Model } from '../store' 4 | 5 | const People = new Model('people') 6 | 7 | export const getPeople = state => ( 8 | People.immutableSelect(state) 9 | ) 10 | 11 | export const getCurrentPerson = createSelector( 12 | getCurrentUser, 13 | getPeople, 14 | (currentUser, allPeople) => ( 15 | currentUser ? allPeople.get(currentUser.email) : undefined 16 | ) 17 | ) 18 | -------------------------------------------------------------------------------- /crm/migrations/0017_remove_person_address.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-08 00:23 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0016_merge_20190508_0013'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='person', 17 | name='address', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /assets/js/components/people-browser/BooleanSelect.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MenuItem from '@material-ui/core/MenuItem' 3 | 4 | import MaterialFormSelect from '../MaterialFormSelect' 5 | 6 | export const BooleanSelect = props => { 7 | return ( 8 | 9 | Contains 10 | Is exactly 11 | 12 | ) 13 | } 14 | 15 | export default BooleanSelect 16 | -------------------------------------------------------------------------------- /docs/events.rst: -------------------------------------------------------------------------------- 1 | .. _events: 2 | 3 | Events 4 | ====== 5 | 6 | Organizer's user facing UI allows for members to check-in to events, just like 7 | paper and pen sign-in sheets but without the need to type them all up later on. 8 | 9 | Events must have an address and a time in order that they show up for members. 10 | Members can check-in to an event if they've enabled geolocation on their device 11 | and are within 1/4mi of the event's location. 12 | 13 | Event attendance can be later viewed through the :ref:`CRM` 14 | -------------------------------------------------------------------------------- /events/migrations/0006_remove_event_address.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-10 23:54 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('events', '0005_auto_20190510_2349'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='event', 17 | name='address', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /events/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.test import TestCase 5 | 6 | from . import importing 7 | from mock import patch 8 | 9 | def testGoogleEventImports(): 10 | with patch('events.importing.GoogleCalendarImporter.get_google_events') as get_google_events: 11 | get_google_events.return_value = iter([]) 12 | importer = importing.GoogleCalendarImporter({}) 13 | importer.init() 14 | for page in importer: 15 | pass 16 | -------------------------------------------------------------------------------- /sync/migrations/0004_auto_20190731_1553.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-07-31 15:53 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('sync', '0003_synctarget_lastrun'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameModel( 16 | old_name='SyncTarget', 17 | new_name='ImportSource', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /assets/js/store/index.js: -------------------------------------------------------------------------------- 1 | import { store } from './store' 2 | export { store } from './store' 3 | export { default as Model, withModelData } from './model' 4 | export { default as Selectable } from './select' 5 | export { default as Filterable } from './filter' 6 | export { default as history } from './history' 7 | export { default as withProvider } from './provider' 8 | 9 | if (module.hot) { 10 | module.hot.accept('../reducers', () => { 11 | store.replaceReducer(require('../reducers').default) 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /crm/management/commands/make_superuser.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.core.management.base import BaseCommand, CommandError 3 | 4 | class Command(BaseCommand): 5 | def add_arguments(self, parser): 6 | parser.add_argument('email') 7 | 8 | def handle(self, *args, **options): 9 | user = User.objects.get(email=options['email']) 10 | user.is_superuser = True 11 | user.is_staff = True 12 | user.save() 13 | print "Updated %s to superuser"%(user) 14 | -------------------------------------------------------------------------------- /onboarding/migrations/0008_remove_signup_deleted.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-10-16 21:36 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('onboarding', '0007_auto_20181016_0515'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='signup', 17 | name='deleted', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /onboarding/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers, relations 2 | from crm.serializers import AddressSerializer, PersonSerializer 3 | from . import models 4 | from events.models import Event 5 | 6 | class SignupSerializer(serializers.HyperlinkedModelSerializer): 7 | event = serializers.SlugRelatedField( 8 | queryset=Event.objects.all(), slug_field='id', required=False) 9 | class Meta: 10 | model = models.Signup 11 | fields = ('id', 'email', 'url', 'approved', 'created', 'event') 12 | -------------------------------------------------------------------------------- /crm/migrations/0013_remove_turfmembership_is_captain.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-07 22:49 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0012_person_is_captain'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='turfmembership', 17 | name='is_captain', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /assets/js/components/chrome/BusyIndicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import CircularProgress from '@material-ui/core/CircularProgress' 4 | import { getActivityCount } from '../../selectors' 5 | 6 | const mapStateToProps = state => { 7 | return { 8 | queueSize: getActivityCount(state) 9 | } 10 | } 11 | 12 | export const BusyIndicator = props => ( 13 | (props.queueSize > 0) ? : null 14 | ) 15 | 16 | export default connect(mapStateToProps)(BusyIndicator) 17 | -------------------------------------------------------------------------------- /donations/migrations/0002_auto_20181021_0226.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-10-21 02:26 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('donations', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='donation', 17 | name='value', 18 | field=models.IntegerField(), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /donations/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models 5 | from crm.models import Person 6 | 7 | class Donation(models.Model): 8 | person = models.ForeignKey(Person, related_name='donations') 9 | timestamp = models.DateTimeField() 10 | value = models.IntegerField() 11 | transaction_id = models.CharField(max_length=200, blank=True) 12 | recurring = models.BooleanField() 13 | 14 | def __unicode__(self): 15 | return "${0} from {1}".format(self.value / 100.0, self.person) 16 | -------------------------------------------------------------------------------- /organizer/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for organizer project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | from raven.contrib.django.raven_compat.middleware.wsgi import Sentry 14 | 15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "organizer.settings") 16 | 17 | application = get_wsgi_application() 18 | application = Sentry(application) 19 | -------------------------------------------------------------------------------- /sync/migrations/0003_synctarget_lastrun.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-07-31 15:31 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('sync', '0002_auto_20190730_1850'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='synctarget', 17 | name='lastRun', 18 | field=models.DateTimeField(blank=True, null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /crm/migrations/0011_person_phone.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-09-11 23:59 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0010_auto_20180820_0223'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='person', 17 | name='phone', 18 | field=models.CharField(blank=True, default=None, max_length=200, null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /crm/migrations/0015_auto_20190507_2326.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-07 23:26 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0014_auto_20190507_2318'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='person', 17 | name='state', 18 | ), 19 | migrations.DeleteModel( 20 | name='PersonState', 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /crm/migrations/0010_auto_20180820_0223.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-20 02:23 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0009_auto_20180807_0304'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='person', 17 | name='name', 18 | field=models.CharField(blank=True, default='', max_length=200, null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /onboarding/migrations/0006_auto_20180814_1829.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-14 18:29 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('onboarding', '0005_auto_20180810_0756'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='newneighbornotificationtarget', 17 | name='turfs', 18 | field=models.ManyToManyField(to='crm.Turf'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /onboarding/migrations/0011_auto_20181023_0619.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-10-23 06:19 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('onboarding', '0010_auto_20181023_0617'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='onboardingcomponent', 17 | name='configuration', 18 | field=models.TextField(blank=True, default=''), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /assets/js/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | import * as Actions from '../actions' 3 | 4 | export default function(state = Immutable.Map(), action={}) { 5 | switch (action.type) { 6 | case Actions.RECEIVE_USER: 7 | return state.merge({ 8 | user: action.user 9 | }) 10 | default: { 11 | const hasInline = (typeof window.CURRENT_USER != 'undefined') 12 | const defaultUser = hasInline ? window.CURRENT_USER : {} 13 | return Immutable.Map({ 14 | user: defaultUser, 15 | }).merge(state) 16 | } 17 | } 18 | } 19 | 20 | 21 | -------------------------------------------------------------------------------- /onboarding/migrations/0002_newneighbornotificationtarget_last_notified.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-02 03:20 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('onboarding', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='newneighbornotificationtarget', 17 | name='last_notified', 18 | field=models.DateField(null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /crm/migrations/0018_auto_20190510_2349.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-10 23:49 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0017_remove_person_address'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='person', 17 | name='lat', 18 | ), 19 | migrations.RemoveField( 20 | model_name='person', 21 | name='lng', 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /crm/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.shortcuts import render 5 | from . import models, serializers 6 | from django.views.decorators.clickjacking import xframe_options_exempt 7 | from django.http import HttpResponse 8 | import json 9 | 10 | def index(request, *args, **kwargs): 11 | return render(request, 'webpack-index.html') 12 | 13 | def service_worker(request, *args, **kwargs): 14 | return render(request, 'service-worker.js', content_type='text/javascript') 15 | 16 | def robots(request, *args, **kwargs): 17 | return render(request, 'robots.txt') 18 | -------------------------------------------------------------------------------- /events/migrations/0005_auto_20190510_2349.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-10 23:49 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('events', '0004_event_location'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='event', 17 | name='lat', 18 | ), 19 | migrations.RemoveField( 20 | model_name='event', 21 | name='lng', 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /static/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #888; 3 | font-color: #fff; 4 | } 5 | 6 | .logo a { 7 | margin: 2em 2em 2em 0; 8 | display: block; 9 | color: transparent; 10 | height: 200px; 11 | background: url(logo.png) center center no-repeat; 12 | background-size: contain; 13 | } 14 | 15 | header { 16 | background: url(bg.png) repeat; 17 | } 18 | 19 | .mastiff { 20 | padding: 3em; 21 | color: #000; 22 | } 23 | 24 | .mastiff h1, .mastiff h2 { 25 | background-color: #fff; 26 | padding: 0.25em; 27 | } 28 | 29 | .canvas { 30 | background-color: #fff; 31 | } 32 | 33 | footer { 34 | font-size: small; 35 | } 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | db: 5 | image: postgres 6 | ports: 7 | - "5432:5432" 8 | redis: 9 | image: redis 10 | app: 11 | build: . 12 | environment: 13 | DATABASE_URL: "postgres://postgres@db:5432/postgres" 14 | REDISTOGO_URL: "redis://redis:6379/0" 15 | ALLOWED_HOSTS: "*" 16 | DEBUG: 1 17 | SECRET_KEY: 'fyq9-o@ky!j*xx0y2mpyi92&wpbbjgo%z1@vrmzk18hpje+%x' 18 | command: pipenv run npm run start:docker 19 | volumes: 20 | - .:/code 21 | ports: 22 | - "8000:8000" 23 | - "8080:8080" 24 | depends_on: 25 | - db 26 | - redis 27 | -------------------------------------------------------------------------------- /geocodable/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from . import models 3 | 4 | class LocationSerializer(serializers.HyperlinkedModelSerializer): 5 | type = serializers.SerializerMethodField() 6 | 7 | def get_type(self, obj): 8 | if obj.type is None: 9 | return None 10 | return obj.type.value 11 | 12 | class Meta: 13 | model = models.Location 14 | fields = ('name', 'parent', 'id', 'fullName', 'type', 'lat', 'lng') 15 | 16 | class GeoSerializer(serializers.ModelSerializer): 17 | class Meta: 18 | model = models.LocationAlias 19 | fields = ('lat', 'lng', 'fullName') 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /assets/js/reducers/geocache.js: -------------------------------------------------------------------------------- 1 | import * as Actions from '../actions' 2 | import Immutable from 'immutable' 3 | 4 | export default function(state = Immutable.Map(), action = {}) { 5 | switch (action.type) { 6 | case Actions.Geocache.UPDATE_CURRENT_LOCATION: 7 | return state.set('accuracy', action.accuracy).set('currentLocation', action.geo) 8 | case Actions.Geocache.SET_LOCATION_STATUS: 9 | return state.set('status', action.status) 10 | default: 11 | return Immutable.Map({ 12 | currentLocation: undefined, 13 | status: undefined, 14 | accuracy: undefined 15 | }).merge(state) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /assets/js/components/chrome/BusyIndicator.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Raven from 'raven-js' 3 | import { shallow } from 'enzyme' 4 | import CircularProgress from '@material-ui/core/CircularProgress' 5 | 6 | import { BusyIndicator } from './BusyIndicator' 7 | 8 | it('should render nothing when there is nothing happening', () => { 9 | const wrapper = shallow() 10 | expect(wrapper.find(CircularProgress)).toHaveLength(0) 11 | }) 12 | 13 | it('should render something when there is nothing happening', () => { 14 | const wrapper = shallow() 15 | expect(wrapper.find(CircularProgress)).toHaveLength(1) 16 | }) 17 | -------------------------------------------------------------------------------- /assets/js/components/people-browser/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | 4 | import { Search } from './Search' 5 | import { BooleanFilter } from './BooleanFilter' 6 | import { BooleanSelect } from './BooleanSelect' 7 | 8 | describe('Search', () => { 9 | it('should render defaults safely', () => { 10 | shallow() 11 | }) 12 | }) 13 | 14 | describe('BooleanFilter', () => { 15 | it('should render defaults safely', () => { 16 | shallow() 17 | }) 18 | }) 19 | 20 | describe('BooleanSelect', () => { 21 | it('should render defaults safely', () => { 22 | shallow() 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /donations/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.contrib import admin 5 | from import_export.admin import ImportExportModelAdmin 6 | from organizer.admin import admin_site, OrganizerModelAdmin 7 | 8 | from . import models, importing 9 | 10 | class DonationAdmin(ImportExportModelAdmin, OrganizerModelAdmin): 11 | resource_class = importing.DonationResource 12 | 13 | list_display = ( 14 | 'value', 'person', 'timestamp', 'recurring' 15 | ) 16 | 17 | list_filter = ( 18 | 'person', 'recurring' 19 | ) 20 | 21 | admin.site.register(models.Donation, DonationAdmin) 22 | admin_site.register(models.Donation, DonationAdmin) 23 | -------------------------------------------------------------------------------- /onboarding/migrations/0013_auto_20190402_2358.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-04-02 23:58 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('onboarding', '0012_auto_20181107_2352'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='signup', 18 | name='event', 19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='signups', to='events.Event'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /assets/scss/app.scss: -------------------------------------------------------------------------------- 1 | .the-app { 2 | background-color: #fff; 3 | display: flex; 4 | flex-flow: column; 5 | max-height: 100%; 6 | height: inherit; 7 | } 8 | 9 | .viewport { 10 | display: flex; 11 | flex: auto; 12 | overflow-y: auto; 13 | } 14 | 15 | .viewport .scroll { 16 | display: block; 17 | flex: auto; 18 | width: 100%; 19 | } 20 | 21 | .bottom-nav { 22 | align-self: flex-end; 23 | flex: none; 24 | width: 100%; 25 | } 26 | 27 | body { 28 | display: flex; 29 | background-color: #fff; 30 | font-family: "Roboto"; 31 | height: inherit; 32 | flex-flow: column; 33 | margin: 0; 34 | } 35 | 36 | html { 37 | height: 100%; 38 | } 39 | 40 | #container { 41 | height: inherit; 42 | } 43 | -------------------------------------------------------------------------------- /onboarding/migrations/0010_auto_20181023_0617.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-10-23 06:17 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('onboarding', '0009_auto_20181021_2234'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='onboardingstatus', 18 | name='component', 19 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='onboarding.OnboardingComponent'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /assets/js/actions/index.test.js: -------------------------------------------------------------------------------- 1 | import { RECEIVE_USER, logout } from './' 2 | import fetchMock from 'fetch-mock' 3 | import configureMockStore from 'redux-mock-store' 4 | import thunk from 'redux-thunk' 5 | 6 | const mockStore = configureMockStore([thunk]) 7 | 8 | beforeEach(() => { 9 | fetchMock.restore() 10 | }) 11 | 12 | it('should fetch the logout endpoint when the logout action is dispatched', () => { 13 | const store = mockStore() 14 | fetchMock.mock('/api/users/logout/', {}) 15 | return store.dispatch(logout()) 16 | .then(() => { 17 | expect(store.getActions()).toHaveLength(1) 18 | expect(store.getActions()[0]).toEqual({type: RECEIVE_USER, user: {}}) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /crm/migrations/0006_auto_20180802_0712.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-02 07:12 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0005_auto_20180802_0319'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='person', 17 | name='lat', 18 | field=models.FloatField(null=True), 19 | ), 20 | migrations.AlterField( 21 | model_name='person', 22 | name='lng', 23 | field=models.FloatField(null=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /assets/js/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'enzyme' 3 | import App from './components/App' 4 | import { store } from './store' 5 | import { Provider } from 'react-redux' 6 | 7 | it('should render defaults safely', () => { 8 | // FIXME: "unknown tag" invariant?? 9 | render() 10 | }) 11 | 12 | it('should import index.js and boot up to an App', () => { 13 | jest.mock('raven-js') 14 | jest.mock('@material-ui/styles') 15 | require('@material-ui/styles').install = jest.fn() 16 | document.body.innerHTML = `
` 17 | 18 | require('./index') 19 | expect(require('@material-ui/styles').install).toHaveBeenCalled() 20 | }) 21 | -------------------------------------------------------------------------------- /assets/js/store/store.js: -------------------------------------------------------------------------------- 1 | import organizerApp from '../reducers' 2 | 3 | import { compose, createStore, applyMiddleware } from 'redux' 4 | import { getCurrentUser } from '../selectors/auth' 5 | import thunkMiddleware from 'redux-thunk' 6 | import createRavenMiddleware from 'raven-for-redux' 7 | import Raven from 'raven-js' 8 | import { connectRouter, routerMiddleware } from 'connected-react-router/immutable' 9 | 10 | const composer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose 11 | 12 | export const store = createStore( 13 | connectRouter(history)(organizerApp), 14 | composer(applyMiddleware(createRavenMiddleware(Raven, {getUserContext: getCurrentUser} ), thunkMiddleware, routerMiddleware(history))) 15 | ) 16 | export default store 17 | -------------------------------------------------------------------------------- /sync/migrations/0002_auto_20190730_1850.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-07-30 18:50 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('sync', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='synctarget', 17 | name='enabled', 18 | field=models.BooleanField(default=False), 19 | ), 20 | migrations.AlterField( 21 | model_name='synctarget', 22 | name='configuration', 23 | field=models.TextField(blank=True, null=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /crm/migrations/0012_person_location.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-04-22 20:54 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('crm', '0011_person_phone'), 13 | ('geocodable', '0001_initial'), 14 | ] 15 | 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name='person', 20 | name='location', 21 | field=models.ForeignKey(blank=True, null=True, 22 | on_delete=django.db.models.deletion.CASCADE, 23 | to='geocodable.LocationAlias'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /crm/migrations/0009_auto_20180807_0304.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-07 03:04 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0008_auto_20180806_0008'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='person', 17 | name='email', 18 | field=models.EmailField(db_index=True, max_length=200, unique=True), 19 | ), 20 | migrations.AlterField( 21 | model_name='personstate', 22 | name='name', 23 | field=models.CharField(max_length=200, unique=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /onboarding/migrations/0014_auto_20190507_2318.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-07 23:18 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('onboarding', '0013_auto_20190402_2358'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='newneighbornotificationtarget', 17 | name='states', 18 | ), 19 | migrations.RemoveField( 20 | model_name='newneighbornotificationtarget', 21 | name='turfs', 22 | ), 23 | migrations.DeleteModel( 24 | name='NewNeighborNotificationTarget', 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /assets/js/components/chrome/LoginButtons.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import LoginButtons from './LoginButtons' 3 | import { shallow } from 'enzyme' 4 | import Button from '@material-ui/core/Button' 5 | import fc from 'fast-check' 6 | 7 | it('should render an empty login splash', () => { 8 | const wrapper = shallow() 9 | expect(wrapper.find(Button)).toHaveLength(0) 10 | }) 11 | 12 | it('should render a list of login URLs', () => { 13 | fc.assert(fc.property(fc.array(fc.tuple(fc.string(), fc.string())), urls => { 14 | global.LOGIN_URLS = urls 15 | global.ORG_METADATA = {name: 'Name', shortname: 'Shortname'} 16 | const wrapper = shallow() 17 | expect(wrapper.find(Button)).toHaveLength(urls.length) 18 | })) 19 | }) 20 | -------------------------------------------------------------------------------- /assets/js/store/filter.js: -------------------------------------------------------------------------------- 1 | export const SET_FILTER = 'SET_FILTER' 2 | 3 | export const setFilter = (key, filter) => { 4 | return { 5 | type: SET_FILTER, 6 | key: key, 7 | filter: filter 8 | } 9 | } 10 | 11 | const isEqual = (a, b) => a == b 12 | 13 | export default class Filterable { 14 | constructor(key, matcher = isEqual) { 15 | this.key = key 16 | this.matcher = matcher 17 | } 18 | 19 | filtered(state, values) { 20 | const filterConfig = state.getIn(['filters', 'filters', this.key]) 21 | return values.filter(value => this.matcher(value, filterConfig)) 22 | } 23 | 24 | bindActionCreators(dispatch) { 25 | return { 26 | set: f => dispatch(setFilter(this.key, f)) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /sync/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-07-30 17:37 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='SyncTarget', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=255)), 21 | ('backend', models.CharField(max_length=255)), 22 | ('configuration', models.TextField()), 23 | ], 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /onboarding/migrations/0004_auto_20180810_0755.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-10 07:55 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0009_auto_20180807_0304'), 12 | ('onboarding', '0003_newneighbornotificationtarget_state'), 13 | ] 14 | 15 | operations = [ 16 | migrations.RemoveField( 17 | model_name='newneighbornotificationtarget', 18 | name='state', 19 | ), 20 | migrations.AddField( 21 | model_name='newneighbornotificationtarget', 22 | name='states', 23 | field=models.ManyToManyField(to='crm.PersonState'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /onboarding/migrations/0003_newneighbornotificationtarget_state.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-05 23:35 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('crm', '0007_auto_20180805_2335'), 13 | ('onboarding', '0002_newneighbornotificationtarget_last_notified'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name='newneighbornotificationtarget', 19 | name='state', 20 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='crm.PersonState'), 21 | preserve_default=False, 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /onboarding/migrations/0005_auto_20180810_0756.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-10 07:56 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('crm', '0009_auto_20180807_0304'), 12 | ('onboarding', '0004_auto_20180810_0755'), 13 | ] 14 | 15 | operations = [ 16 | migrations.RemoveField( 17 | model_name='newneighbornotificationtarget', 18 | name='turf', 19 | ), 20 | migrations.AddField( 21 | model_name='newneighbornotificationtarget', 22 | name='turfs', 23 | field=models.ManyToManyField(related_name='notification_targets', to='crm.Turf'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /assets/js/components/MaterialFormSwitch.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import { Form } from 'informed' 4 | import Switch from '@material-ui/core/Switch' 5 | import MenuItem from '@material-ui/core/MenuItem' 6 | 7 | import MaterialFormSwitch from './MaterialFormSwitch' 8 | 9 | it('should render defaults safely', () => { 10 | const onValueChange = jest.fn() 11 | const root = mount( 12 |
13 | 14 | 15 | ) 16 | root.find(Switch).prop('onChange')({target: {checked: true}}) 17 | expect(onValueChange).toHaveBeenCalledWith({select: true}) 18 | root.find(Switch).prop('onChange')({target: {checked: false}}) 19 | expect(onValueChange).toHaveBeenCalledWith({select: false}) 20 | }) 21 | 22 | -------------------------------------------------------------------------------- /assets/js/index.js: -------------------------------------------------------------------------------- 1 | import './style-bootstrap' 2 | 3 | import React from 'react' 4 | import ReactDOM from 'react-dom' 5 | import '../scss/app.scss' 6 | 7 | import App from './components/App' 8 | 9 | import Raven from 'raven-js' 10 | 11 | Raven.config(window.SENTRY_PUBLIC_DSN).install() 12 | window.onunhandledrejection = function(e) {Raven.captureException(e.reason)} 13 | 14 | if ('serviceWorker' in navigator) { 15 | window.addEventListener('load', () => { 16 | navigator.serviceWorker.register('/service-worker.js').then(registration => { 17 | console.log('SW registered: ' + registration) 18 | }).catch(registrationError => { 19 | console.log('SW registration failed: ' + registrationError) 20 | }) 21 | }) 22 | } 23 | 24 | ReactDOM.render( 25 | , 26 | document.getElementById('container') 27 | ) 28 | -------------------------------------------------------------------------------- /assets/js/components/MaterialFormSwitch.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { asField } from 'informed' 3 | import Switch from '@material-ui/core/Switch' 4 | 5 | const MaterialFormSwitch = asField(({fieldState, fieldApi, ...props}) => { 6 | const { 7 | value 8 | } = fieldState 9 | const { 10 | setValue, 11 | } = fieldApi 12 | const { 13 | onChange, 14 | forwardedRef, 15 | ...rest 16 | } = props 17 | return ( 18 | { 23 | setValue(e.target.checked) 24 | if (onChange) { 25 | onChange(e) 26 | } 27 | }} 28 | /> 29 | ) 30 | }) 31 | 32 | export default MaterialFormSwitch 33 | -------------------------------------------------------------------------------- /onboarding/management/commands/onboard.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from onboarding.models import runOnboarding 3 | from crm.models import Person 4 | import logging 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | class Command(BaseCommand): 9 | def add_arguments(self, parser): 10 | parser.add_argument('email', nargs='*', default=[]) 11 | parser.add_argument('--dry-run', default=False, action='store_true') 12 | 13 | def handle(*args, **options): 14 | if len(options['email']) == 0: 15 | people = Person.objects.all() 16 | else: 17 | people = Person.objects.filter(email__in=options['email']) 18 | 19 | for person in people: 20 | if not options['dry_run']: 21 | runOnboarding.delay(person) 22 | log.info("Queued onboarding check for %s", person) 23 | -------------------------------------------------------------------------------- /assets/js/selectors/events.test.js: -------------------------------------------------------------------------------- 1 | import { cookEventWithLocation } from './events' 2 | import moment from 'moment' 3 | import { point } from '@turf/helpers' 4 | 5 | const now = moment() 6 | 7 | it('should add required properties to the event', () => { 8 | const eventSkeleton = { 9 | geo: point([0, 0]), 10 | timestamp: now, 11 | end_timestamp: now, 12 | } 13 | const evt = cookEventWithLocation(point([0, 0]), 0, eventSkeleton, moment(), []) 14 | expect(evt).toMatchObject({ 15 | distance: expect.any(Number), 16 | relevance: expect.any(Number), 17 | walktime: expect.any(Number), 18 | checkIn: { 19 | isNearby: expect.any(Boolean), 20 | isInPast: expect.any(Boolean), 21 | hasNotStarted: expect.any(Boolean), 22 | canCheckIn: expect.any(Boolean), 23 | } 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /assets/js/components/MaterialFormText.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import { Form } from 'informed' 4 | import TextField from '@material-ui/core/TextField' 5 | import MenuItem from '@material-ui/core/MenuItem' 6 | 7 | import MaterialFormText from './MaterialFormText' 8 | 9 | it('should render defaults safely', () => { 10 | const onValueChange = jest.fn() 11 | const onChange = jest.fn() 12 | const root = mount( 13 |
14 | 15 | 16 | ) 17 | root.find(TextField).prop('onChange')({target: {value: "Text"}}) 18 | expect(onValueChange).toHaveBeenCalledWith({select: "Text"}) 19 | root.find(TextField).prop('onBlur')() 20 | expect(onChange.mock.calls[0][0]).toMatchObject({touched: {select: true}}) 21 | }) 22 | 23 | -------------------------------------------------------------------------------- /sync/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models 5 | from django.contrib.auth.models import User 6 | import json 7 | from organizer.importing import get_importer_class 8 | 9 | #FIXME: This only supports importing data, no exporting or full sync is implemented yet 10 | class ImportSource(models.Model): 11 | name = models.CharField(max_length=255) 12 | backend = models.CharField(max_length=255) 13 | enabled = models.BooleanField(default=False) 14 | configuration = models.TextField(blank=True, null=True) 15 | lastRun = models.DateTimeField(blank=True, null=True) 16 | 17 | def make_importer(self): 18 | importCls = get_importer_class(self.backend) 19 | return importCls(json.loads(self.configuration)) 20 | 21 | def __unicode__(self): 22 | return '%s: %s' % (self.name, self.backend) 23 | -------------------------------------------------------------------------------- /assets/js/components/AppRoutes.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { AppRoutes } from './AppRoutes' 3 | import CheckinActivity from './activities/checkin' 4 | import { withProvider } from '../store' 5 | import { mount } from 'enzyme' 6 | import fetchMock from 'fetch-mock' 7 | 8 | const MountableRoutes = withProvider(AppRoutes) 9 | 10 | beforeEach(() => { 11 | fetchMock.restore() 12 | }) 13 | 14 | it('should safely render a logged out state', () => { 15 | fetchMock.mock('begin:/api/events/', {}) 16 | const wrapper = mount() 17 | expect(wrapper.find(CheckinActivity)).toHaveLength(1) 18 | }) 19 | 20 | it('should safely render a logged in state', () => { 21 | fetchMock.mock('begin:/api/events/', {}) 22 | const wrapper = mount() 23 | expect(wrapper.find(CheckinActivity)).toHaveLength(1) 24 | }) 25 | 26 | -------------------------------------------------------------------------------- /onboarding/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-02 03:19 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('crm', '0005_auto_20180802_0319'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='NewNeighborNotificationTarget', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('email', models.CharField(max_length=200)), 23 | ('turf', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_targets', to='crm.Turf')), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /organizer/pipeline.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User, Group 2 | 3 | def sync_from_discourse_auth(user, details, **kwargs): 4 | for groupSlug in details.get('groups', []): 5 | groupSlug = 'discourse:' + groupSlug 6 | groupObj, _ = Group.objects.get_or_create(name=groupSlug) 7 | user.groups.add(groupObj) 8 | 9 | for group in user.groups.filter(name__startswith='discourse:'): 10 | _, groupName = group.name.split(':') 11 | if groupName not in details.get('groups', []): 12 | user.groups.remove(group) 13 | 14 | user.is_superuser = details.get('is_superuser', False) 15 | user.is_staff = details.get('is_staff', False) 16 | user.save() 17 | 18 | def sync_backend_group(user, backend, **kwargs): 19 | groupName = "social-auth:%s"%(backend.name) 20 | backend_group, _ = Group.objects.get_or_create(name=groupName) 21 | user.groups.add(backend_group) 22 | -------------------------------------------------------------------------------- /assets/js/components/MaterialFormSelect.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { mount } from 'enzyme' 3 | import { Form } from 'informed' 4 | import Select from '@material-ui/core/Select' 5 | import MenuItem from '@material-ui/core/MenuItem' 6 | 7 | import MaterialFormSelect from './MaterialFormSelect' 8 | 9 | it('should render defaults safely', () => { 10 | const onValueChange = jest.fn() 11 | const onChange = jest.fn() 12 | const root = mount( 13 |
14 | 15 | Item Number 1 16 | 17 |
18 | ) 19 | root.find(Select).prop('onChange')({target: 1}) 20 | expect(onValueChange).toHaveBeenCalled() 21 | root.find(Select).prop('onBlur')() 22 | expect(onChange.mock.calls[0][0]).toMatchObject({touched: {select: true}}) 23 | }) 24 | -------------------------------------------------------------------------------- /docs/broadcasts.rst: -------------------------------------------------------------------------------- 1 | .. _broadcasts: 2 | 3 | Broadcasts 4 | ========== 5 | 6 | Organizer has a limited neighborhood broadcast tool which sends e-mails to all 7 | members within a specific neighborhood or turf. It is available to neighborhood 8 | captains, which are defined through the Django administration UI (See 9 | :ref:`maintenance`). 10 | 11 | 12 | .. _new-neighbor-notifications: 13 | 14 | New Neighbor Notifications 15 | -------------------------- 16 | 17 | In addition to neighborhood-wide message broadcasts, Organizer can be configured 18 | to e-mail your neighborhood captains (or really, any specific e-mail address) 19 | with a list of new people for each neighborhood in the :ref:`CRM` at regular 20 | intervals. 21 | 22 | It is intended to be ran on a regular schedule, such as once a week or every 23 | afternoon. To run it: 24 | 25 | ./manage.py new_neighbors 26 | 27 | The notifications may be configured through the :ref:`administration-interface`. 28 | -------------------------------------------------------------------------------- /crm/templates/form.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% load static %} 3 | 4 | {% block extrahead %} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /crm/migrations/0012_person_is_captain.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-07 22:42 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | def copy_captain_flag(apps, schema_editor): 8 | Person = apps.get_model('crm', 'Person') 9 | TurfMembership = apps.get_model('crm', 'TurfMembership') 10 | 11 | for person in Person.objects.all(): 12 | if TurfMembership.objects.filter(is_captain=True, person=person).exists(): 13 | person.is_captain = True 14 | person.save() 15 | 16 | class Migration(migrations.Migration): 17 | 18 | dependencies = [ 19 | ('crm', '0011_person_phone'), 20 | ] 21 | 22 | operations = [ 23 | migrations.AddField( 24 | model_name='person', 25 | name='is_captain', 26 | field=models.BooleanField(default=False), 27 | ), 28 | migrations.RunPython(copy_captain_flag), 29 | ] 30 | -------------------------------------------------------------------------------- /assets/js/components/events/NoEvents.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import faCalendar from '@fortawesome/fontawesome-free-solid/faCalendar' 4 | import { library as faLibrary } from '@fortawesome/fontawesome' 5 | import { withStyles } from '@material-ui/styles' 6 | 7 | faLibrary.add(faCalendar) 8 | 9 | export const NoEvents = props => ( 10 | (props.show ? props.children : ( 11 |
12 | 13 |

No events.

14 |

Go make some trouble.

15 |
16 | )) 17 | ) 18 | 19 | NoEvents.defaultProps = { 20 | show: false 21 | } 22 | 23 | const styles = { 24 | root: { 25 | backgroundColor: '#ddd', 26 | color: '#aaa', 27 | textAlign: 'center', 28 | padding: '3rem' 29 | } 30 | } 31 | 32 | export default withStyles(styles)(NoEvents) 33 | -------------------------------------------------------------------------------- /assets/js/components/mapping/BaseMap.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import L from 'leaflet' 3 | 4 | import markerIcon from 'leaflet/dist/images/marker-icon.png' 5 | import markerRetinaIcon from 'leaflet/dist/images/marker-icon-2x.png' 6 | import markerShadow from 'leaflet/dist/images/marker-shadow.png' 7 | 8 | import { TileLayer, Map } from 'react-leaflet' 9 | 10 | delete L.Icon.Default.prototype._getIconUrl 11 | 12 | import 'leaflet/dist/leaflet.css' 13 | 14 | L.Icon.Default.mergeOptions({ 15 | iconRetinaUrl: markerRetinaIcon, 16 | iconUrl: markerIcon, 17 | shadowUrl: markerShadow 18 | }) 19 | 20 | const BaseMap = props => ( 21 | 22 | 25 | {props.children} 26 | 27 | ) 28 | 29 | BaseMap.defaultProps = { 30 | center: [0, 0], 31 | zoom: 17 32 | } 33 | 34 | export default BaseMap 35 | -------------------------------------------------------------------------------- /crm/migrations/0014_auto_20190507_2318.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-07 23:18 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('onboarding', '0014_auto_20190507_2318'), 12 | ('crm', '0013_remove_turfmembership_is_captain'), 13 | ] 14 | 15 | operations = [ 16 | migrations.RemoveField( 17 | model_name='turf', 18 | name='locality', 19 | ), 20 | migrations.RemoveField( 21 | model_name='turfmembership', 22 | name='person', 23 | ), 24 | migrations.RemoveField( 25 | model_name='turfmembership', 26 | name='turf', 27 | ), 28 | migrations.DeleteModel( 29 | name='Turf', 30 | ), 31 | migrations.DeleteModel( 32 | name='TurfMembership', 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | try: 7 | import dotenv 8 | dotenv.read_dotenv() 9 | except ImportError: 10 | pass 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "organizer.settings") 12 | try: 13 | from django.core.management import execute_from_command_line 14 | except ImportError: 15 | # The above import may fail for some other reason. Ensure that the 16 | # issue is really that Django is missing to avoid masking other 17 | # exceptions on Python 2. 18 | try: 19 | import django 20 | except ImportError: 21 | raise ImportError( 22 | "Couldn't import Django. Are you sure it's installed and " 23 | "available on your PYTHONPATH environment variable? Did you " 24 | "forget to activate a virtual environment?" 25 | ) 26 | raise 27 | execute_from_command_line(sys.argv) 28 | -------------------------------------------------------------------------------- /assets/js/components/events/NoLocation.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import faCompass from '@fortawesome/fontawesome-free-solid/faCompass' 4 | import { library as faLibrary } from '@fortawesome/fontawesome' 5 | import { withStyles } from '@material-ui/styles' 6 | import Button from '@material-ui/core/Button' 7 | 8 | faLibrary.add(faCompass) 9 | 10 | export const NoLocation = props => ( 11 |
12 | 13 |

{props.message}

14 | {props.onStartGeolocation ? () : null } 15 |
16 | ) 17 | 18 | const styles = { 19 | root: { 20 | backgroundColor: '#ddd', 21 | color: '#aaa', 22 | textAlign: 'center', 23 | padding: '3rem' 24 | } 25 | } 26 | 27 | export default withStyles(styles)(NoLocation) 28 | 29 | -------------------------------------------------------------------------------- /assets/js/components/activities/checkin/Skeleton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withStyles } from '@material-ui/styles' 3 | 4 | import Grid from '@material-ui/core/Grid' 5 | 6 | const carouselStyles = { 7 | card: { 8 | backgroundColor: '#eee', 9 | animationName: '$fade', 10 | animationDuration: '1s', 11 | animationIterationCount: 'infinite', 12 | animationDirection: 'alternate', 13 | animationTimingFunction: 'ease-out', 14 | height: '14rem', 15 | margin: '1rem' 16 | }, 17 | '@keyframes fade': { 18 | from: { 19 | backgroundColor: '#fafafa' 20 | }, 21 | to: { 22 | backgroundColor: '#ddd' 23 | } 24 | } 25 | } 26 | 27 | export const Skeleton = props => ( 28 | 29 | 30 | 31 | 32 | ) 33 | 34 | export default withStyles(carouselStyles)(Skeleton) 35 | -------------------------------------------------------------------------------- /crm/migrations/0008_auto_20180806_0008.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-06 00:08 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import taggit.managers 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('crm', '0007_auto_20180805_2335'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='person', 18 | name='lat', 19 | field=models.FloatField(blank=True, null=True), 20 | ), 21 | migrations.AlterField( 22 | model_name='person', 23 | name='lng', 24 | field=models.FloatField(blank=True, null=True), 25 | ), 26 | migrations.AlterField( 27 | model_name='person', 28 | name='tags', 29 | field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /donations/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-10-21 01:36 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('crm', '0011_person_phone'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Donation', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('timestamp', models.DateTimeField()), 23 | ('value', models.FloatField()), 24 | ('transaction_id', models.CharField(blank=True, max_length=200)), 25 | ('recurring', models.BooleanField()), 26 | ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='donations', to='crm.Person')), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /assets/js/reducers/model.js: -------------------------------------------------------------------------------- 1 | import * as Model from '../store/model' 2 | import Immutable from 'immutable' 3 | 4 | export default function(state = Immutable.Map(), action = {}) { 5 | if (!Immutable.isImmutable(state)) { 6 | state = Immutable.Map() 7 | } 8 | switch (action.type) { 9 | case Model.UPDATE_MODEL: 10 | return state.setIn(['models', action.name, action.id], action.data) 11 | case Model.SAVING_MODEL: 12 | return state.update('saving', i => i + 1) 13 | case Model.SAVED_MODEL: 14 | return state.update('saving', i => i - 1) 15 | case Model.REQUEST_MODELS: 16 | return state.update('loading', i => i + 1) 17 | case Model.RECEIVE_MODELS: 18 | return state.mergeIn(['models', action.name], Immutable.List(action.models).map(m => [m.id, m])) 19 | .update('loading', i => i - 1) 20 | default: 21 | return Immutable.Map({ 22 | loading: 0, 23 | saving: false, 24 | modified: false, 25 | models: Immutable.Map(), 26 | }).merge(state) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /assets/js/components/chrome/OrganizerAppBar.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Raven from 'raven-js' 3 | import { shallow } from 'enzyme' 4 | import MenuItem from '@material-ui/core/MenuItem' 5 | 6 | import { AppMenu } from './OrganizerAppBar' 7 | 8 | jest.mock('raven-js') 9 | 10 | it('should trigger a report dialog when the user clicks the report button', () => { 11 | const wrapper = shallow() 12 | const menuItems = wrapper.find(MenuItem) 13 | menuItems.last().dive().simulate('click') 14 | expect(Raven.captureMessage).toHaveBeenCalledWith("Manual report") 15 | expect(Raven.showReportDialog).toHaveBeenCalled() 16 | }) 17 | 18 | it('should log the user out when the user clicks the logout button', () => { 19 | const loggerOuter = jest.fn() 20 | const wrapper = shallow() 21 | const menuItems = wrapper.find(MenuItem) 22 | menuItems.first().dive().simulate('click') 23 | expect(loggerOuter).toHaveBeenCalled() 24 | }) 25 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | var config = require('./webpack.config') 2 | var merge = require('webpack-merge') 3 | var MiniCssExtractPlugin = require('mini-css-extract-plugin') 4 | var OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin') 5 | var UglifyJsPlugin = require('uglifyjs-webpack-plugin') 6 | 7 | module.exports = merge(config, { 8 | mode: 'production', 9 | plugins: [ 10 | new MiniCssExtractPlugin() 11 | ], 12 | devtool: 'source-map', 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.s?css$/, 17 | use: [ 18 | MiniCssExtractPlugin.loader, 19 | 'css-loader?importLoaders=1', 20 | 'sass-loader' 21 | ] 22 | }, 23 | ] 24 | }, 25 | optimization: { 26 | minimizer: [ 27 | new OptimizeCssAssetsPlugin(), 28 | new UglifyJsPlugin({ 29 | cache: true, 30 | parallel: true, 31 | sourceMap: true 32 | }) 33 | ] 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /assets/js/components/MaterialFormSelect.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Select from '@material-ui/core/Select' 3 | import { asField } from 'informed' 4 | 5 | const MaterialFormSelect = asField(({ fieldState, fieldApi, ...props }) => { 6 | const { 7 | value 8 | } = fieldState 9 | const { 10 | setValue, 11 | setTouched 12 | } = fieldApi 13 | const { 14 | onChange, 15 | onBlur, 16 | forwardedRef, 17 | ...rest 18 | } = props 19 | return ( 20 | 17 | Name 18 | Email 19 | {% for field in action.fields %} 20 | {{field.name}} 21 | {% endfor %} 22 | 23 | {% for signup in action.signups.all %} 24 | {% ifchanged signup.state_name %} 25 | 26 | {{signup.state_name}} 27 | 28 | {% endifchanged %} 29 | 30 | 31 | {{signup.activist.name}} 32 | {{signup.activist.email}} 33 | {% for field in signup.responses.all %} 34 | {{field.value}} 35 | {% endfor %} 36 | 37 | {% endfor %} 38 | 39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /events/migrations/0002_auto_20180824_2228.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-24 22:28 3 | from __future__ import unicode_literals 4 | 5 | import address.models 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('events', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name='event', 19 | name='end_timestamp', 20 | field=models.DateTimeField(default='2000-01-01 00:00'), 21 | preserve_default=False, 22 | ), 23 | migrations.AlterField( 24 | model_name='event', 25 | name='attendees', 26 | field=models.ManyToManyField(blank=True, related_name='events', to='crm.Person'), 27 | ), 28 | migrations.AlterField( 29 | model_name='event', 30 | name='location', 31 | field=address.models.AddressField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='address.Address'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /crm/migrations/0002_auto_20180729_2011_squashed_0005_auto_20180729_2125.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-07-29 21:41 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | replaces = [(b'crm', '0002_auto_20180729_2011'), (b'crm', '0003_person_neighborhood'), (b'crm', '0004_auto_20180729_2124'), (b'crm', '0005_auto_20180729_2125')] 11 | 12 | dependencies = [ 13 | ('crm', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name='person', 19 | name='lat', 20 | field=models.FloatField(default=0), 21 | preserve_default=False, 22 | ), 23 | migrations.AddField( 24 | model_name='person', 25 | name='lng', 26 | field=models.FloatField(default=0), 27 | preserve_default=False, 28 | ), 29 | migrations.AddField( 30 | model_name='person', 31 | name='neighborhood', 32 | field=models.CharField(max_length=200, null=True), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /filtering/management/commands/query.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from filtering.models import FilterNode 3 | from tablib import Dataset 4 | 5 | class Command(BaseCommand): 6 | def add_arguments(self, parser): 7 | parser.add_argument('query', nargs='?', default=None) 8 | parser.add_argument('--format', default="tsv") 9 | parser.add_argument('--column', action="append", default=[]) 10 | 11 | def handle(self, *args, **options): 12 | if options['query']: 13 | if len(options['column']) == 0: 14 | results = FilterNode.objects.get(name=options['query']).results.values() 15 | else: 16 | results = FilterNode.objects.get(name=options['query']).results.values(*options['column']) 17 | dataset = Dataset(headers=results[0].keys()) 18 | for result in results: 19 | dataset.append(result.values()) 20 | print dataset.export(options['format']) 21 | else: 22 | filters = FilterNode.objects.named() 23 | print "Available queries:" 24 | for filter in filters: 25 | print filter.name 26 | -------------------------------------------------------------------------------- /assets/js/components/MaterialFormText.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import TextField from '@material-ui/core/TextField' 3 | import { asField } from 'informed' 4 | 5 | const MaterialFormText = asField(({ fieldState, fieldApi, ...props }) => { 6 | const { 7 | value 8 | } = fieldState 9 | const { 10 | setValue, 11 | setTouched 12 | } = fieldApi 13 | const { 14 | onChange, 15 | onBlur, 16 | forwardedRef, 17 | ...rest 18 | } = props 19 | return ( 20 | { 25 | setValue(e.target.value) 26 | if (onChange) { 27 | onChange(e) 28 | } 29 | }} 30 | onBlur={e => { 31 | setTouched() 32 | if (onBlur) { 33 | onBlur(e) 34 | } 35 | }} 36 | error={!!fieldState.error} 37 | helperText={fieldState.error} 38 | /> 39 | ) 40 | }) 41 | 42 | export default MaterialFormText 43 | -------------------------------------------------------------------------------- /events/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.contrib import admin 5 | from . import models 6 | from organizer.admin import admin_site, OrganizerModelAdmin 7 | from rangefilter.filter import DateTimeRangeFilter 8 | 9 | class EventAdmin(OrganizerModelAdmin): 10 | search_fields = [ 11 | 'name', 'location__raw' 12 | ] 13 | 14 | list_display = [ 15 | 'name', 'timestamp', 'end_timestamp', 'attendee_count' 16 | ] 17 | 18 | list_filter = ( 19 | ('timestamp', DateTimeRangeFilter), 20 | ('end_timestamp', DateTimeRangeFilter) 21 | ) 22 | 23 | fieldsets = ( 24 | (None, { 25 | 'fields': ('name', ('timestamp', 'end_timestamp'), 26 | 'location', 'attendees') 27 | }), 28 | ('Advanced options', { 29 | 'classes': ('collapse',), 30 | 'fields': (('lat', 'lng'), 'instance_id', 'uid') 31 | }) 32 | ) 33 | 34 | filter_horizontal = ('attendees',) 35 | 36 | def attendee_count(self, obj): 37 | return obj.attendees.count() 38 | 39 | admin.site.register(models.Event, EventAdmin) 40 | admin_site.register(models.Event, EventAdmin) 41 | -------------------------------------------------------------------------------- /assets/js/reducers/select.js: -------------------------------------------------------------------------------- 1 | import * as Select from '../store/select' 2 | import Immutable from 'immutable' 3 | 4 | export default function selections(state = Immutable.Map(), action = {}) { 5 | switch (action.type) { 6 | case Select.SET_SELECTION: 7 | return state.setIn(['selections', action.key], Immutable.Set(action.selection)) 8 | case Select.ADD_SELECTION: 9 | return state.updateIn( 10 | ['selections', action.key], 11 | (currentSelection = Immutable.Set()) => currentSelection.add(action.item) 12 | ) 13 | case Select.REMOVE_SELECTION: 14 | return state.updateIn( 15 | ['selections', action.key], 16 | (currentSelection = Immutable.Set()) => currentSelection.delete(action.item) 17 | ) 18 | case Select.TOGGLE_SELECTION: { 19 | return state.updateIn( 20 | ['selections', action.key], 21 | (currentSelection = Immutable.Set()) => (currentSelection.contains(action.item) ? currentSelection.delete(action.item) : currentSelection.add(action.item)) 22 | ) 23 | } 24 | default: 25 | return Immutable.Map({ 26 | selections: Immutable.Map(), 27 | }).mergeDeep(state) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /assets/scss/spinner.scss: -------------------------------------------------------------------------------- 1 | .spinner { 2 | margin: 100px auto; 3 | width: 50px; 4 | height: 40px; 5 | text-align: center; 6 | font-size: 10px; 7 | } 8 | 9 | .spinner > div { 10 | background-color: #333; 11 | height: 100%; 12 | width: 6px; 13 | display: inline-block; 14 | margin: 1px; 15 | 16 | -webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out; 17 | animation: sk-stretchdelay 1.2s infinite ease-in-out; 18 | } 19 | 20 | .spinner .rect2 { 21 | -webkit-animation-delay: -1.1s; 22 | animation-delay: -1.1s; 23 | } 24 | 25 | .spinner .rect3 { 26 | -webkit-animation-delay: -1.0s; 27 | animation-delay: -1.0s; 28 | } 29 | 30 | .spinner .rect4 { 31 | -webkit-animation-delay: -0.9s; 32 | animation-delay: -0.9s; 33 | } 34 | 35 | .spinner .rect5 { 36 | -webkit-animation-delay: -0.8s; 37 | animation-delay: -0.8s; 38 | } 39 | 40 | @-webkit-keyframes sk-stretchdelay { 41 | 0%, 40%, 100% { -webkit-transform: scaleY(0.4) } 42 | 20% { -webkit-transform: scaleY(1.0) } 43 | } 44 | 45 | @keyframes sk-stretchdelay { 46 | 0%, 40%, 100% { 47 | transform: scaleY(0.4); 48 | -webkit-transform: scaleY(0.4); 49 | } 20% { 50 | transform: scaleY(1.0); 51 | -webkit-transform: scaleY(1.0); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /events/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers, relations 2 | from crm.serializers import AddressSerializer, PersonSerializer 3 | from . import models 4 | from crm.models import Person 5 | 6 | class EventSerializer(serializers.HyperlinkedModelSerializer): 7 | location = AddressSerializer(read_only=True) 8 | attendee_count = serializers.SerializerMethodField() 9 | user_has_checked_in = serializers.SerializerMethodField() 10 | 11 | def get_attendee_count(self, event): 12 | return event.signups.filter(approved=False).count() + event.attendees.count() 13 | 14 | def get_user_has_checked_in(self, event): 15 | user = self.context['request'].user 16 | if user is not None and not user.is_anonymous: 17 | email = user.email 18 | return event.signups.filter(email=email).exists() or event.attendees.filter(email=email).exists() 19 | else: 20 | return False 21 | 22 | class Meta: 23 | model = models.Event 24 | fields = ('id', 'url', 'timestamp', 'end_timestamp', 'uid', 'location', 'instance_id', 25 | 'attendee_count', 'user_has_checked_in', 'name', 'geo') 26 | extra_kwargs = { 27 | 'geo': {'read_only': True} 28 | } 29 | -------------------------------------------------------------------------------- /onboarding/migrations/0012_auto_20181107_2352.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-11-07 23:52 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('filtering', '0001_initial'), 13 | ('onboarding', '0011_auto_20181023_0619'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name='onboardingcomponent', 19 | name='filter', 20 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='filtering.FilterNode'), 21 | preserve_default=False, 22 | ), 23 | migrations.AddField( 24 | model_name='onboardingstatus', 25 | name='message', 26 | field=models.TextField(default=''), 27 | preserve_default=False, 28 | ), 29 | migrations.AlterField( 30 | model_name='onboardingstatus', 31 | name='person', 32 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='onboarding_statuses', to='crm.Person'), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /events/management/commands/list_members.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from events.models import Event 3 | from django.utils import timezone 4 | from crm.models import Person 5 | from datetime import timedelta 6 | from django.db.models import Sum 7 | 8 | class Command(BaseCommand): 9 | def handle(*args, **kwargs): 10 | oneYearAgo = timezone.now() + timedelta(days=-365) 11 | for person in Person.objects.all(): 12 | isVotingMember = person.events.filter(end_timestamp__gte=oneYearAgo).count() >= 3 13 | isPatron = person.donations.filter(timestamp__gte=oneYearAgo).count() >= 1 14 | isVolunteer = person.events.filter(end_timestamp__gte=oneYearAgo).count() >= 1 15 | personTag = None 16 | if isVotingMember: 17 | personTag = "Voting" 18 | elif isVolunteer: 19 | personTag = "Volunteer" 20 | elif isPatron: 21 | personTag = "Patron" 22 | 23 | if personTag is not None: 24 | print "[%s] %s <%s> - %s events, $%s"%(personTag, person.name, person.email, 25 | person.events.count(), 26 | person.donations.aggregate(total=Sum('value') / 100)['total']) 27 | -------------------------------------------------------------------------------- /events/migrations/0004_event_location.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-05-10 23:47 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | from geocodable.models import LocationAlias 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('geocodable', '0001_initial'), 14 | ('events', '0003_auto_20190510_2346'), 15 | ] 16 | 17 | def migrate_to_locations(apps, schema_editor): 18 | Event = apps.get_model('events', 'Event') 19 | db_alias = schema_editor.connection.alias 20 | allEvents = Event.objects.using(db_alias).all() 21 | for event in allEvents: 22 | if event.address is not None: 23 | event.location_id = LocationAlias.objects.get_or_create(raw=event.address.raw)[0].id 24 | event.save() 25 | 26 | operations = [ 27 | migrations.AddField( 28 | model_name='event', 29 | name='location', 30 | field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='geocodable.LocationAlias'), 31 | ), 32 | migrations.RunPython(migrate_to_locations), 33 | ] 34 | -------------------------------------------------------------------------------- /events/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models 5 | from django.utils import timezone 6 | from crm.models import Person 7 | from geocodable.models import LocationAlias 8 | import uuid 9 | 10 | class Event(models.Model): 11 | name = models.CharField(max_length=200) 12 | timestamp = models.DateTimeField() 13 | end_timestamp = models.DateTimeField() 14 | attendees = models.ManyToManyField(Person, related_name='events', blank=True) 15 | uid = models.CharField(max_length=200, blank=True) 16 | location = models.ForeignKey(LocationAlias, default=None, blank=True, 17 | null=True) 18 | instance_id = models.CharField(max_length=200, blank=True) 19 | 20 | @property 21 | def geo(self): 22 | return {'lat': self.lat, 'lng': self.lng} 23 | 24 | @property 25 | def lat(self): 26 | if self.location is not None: 27 | return self.location.lat 28 | else: 29 | return None 30 | 31 | @property 32 | def lng(self): 33 | if self.location is not None: 34 | return self.location.lng 35 | else: 36 | return None 37 | 38 | def __unicode__(self): 39 | return "%s (%s)"%(self.name, self.timestamp) 40 | -------------------------------------------------------------------------------- /crm/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-07-27 07:36 3 | from __future__ import unicode_literals 4 | 5 | import address.models 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import taggit.managers 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | ('address', '0001_initial'), 17 | ('taggit', '0002_auto_20150616_2121'), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='Person', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('name', models.CharField(max_length=200)), 26 | ('email', models.CharField(max_length=200)), 27 | ('created', models.DateTimeField(auto_now_add=True)), 28 | ('address', address.models.AddressField(blank=True, on_delete=django.db.models.deletion.CASCADE, to='address.Address')), 29 | ('tags', taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags')), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /assets/js/components/MaterialFormModelSelect.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import MenuItem from '@material-ui/core/MenuItem' 4 | import PropTypes from 'prop-types' 5 | 6 | import { Model, withModelData } from '../store' 7 | import MaterialFormSelect from './MaterialFormSelect' 8 | 9 | const mapStateToProps = (state, props) => { 10 | const model = new Model(props.model) 11 | return { 12 | rows: model.immutableSelect(state).sortBy(r => props.display ? props.display(r.name) : r.name) 13 | } 14 | } 15 | 16 | const MaterialFormModelSelect = ({display, value, dispatch: _dispatch, rows: _rows, ...props}) => ( 17 | 18 | {props.rows.map(row=> ({display(row)})).toArray()} 19 | 20 | ) 21 | 22 | MaterialFormModelSelect.defaultProps = { 23 | display: d => d.name, 24 | value: d => d.name 25 | } 26 | 27 | MaterialFormModelSelect.propTypes = { 28 | display: PropTypes.func.isRequired, 29 | value: PropTypes.func.isRequired 30 | } 31 | 32 | const mapModelToFetch = props => { 33 | return { 34 | [props.model]: {} 35 | } 36 | } 37 | 38 | export default withModelData(mapModelToFetch)(connect(mapStateToProps)(MaterialFormModelSelect)) 39 | -------------------------------------------------------------------------------- /crm/migrations/0007_auto_20180805_2335.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-05 23:35 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | def create_default_state(apps, schema_editor): 9 | PersonState = apps.get_model('crm', 'PersonState') 10 | PersonState.objects.get_or_create(pk=1, defaults={'name': 'Default'}) 11 | 12 | class Migration(migrations.Migration): 13 | 14 | dependencies = [ 15 | ('crm', '0006_auto_20180802_0712'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='PersonState', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('name', models.CharField(max_length=200)), 24 | ('description', models.TextField(blank=True, default='')), 25 | ], 26 | ), 27 | migrations.RunPython(create_default_state), 28 | migrations.AddField( 29 | model_name='person', 30 | name='state', 31 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='crm.PersonState'), 32 | preserve_default=False, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /docs/maintenance.rst: -------------------------------------------------------------------------------- 1 | .. _maintenance: 2 | 3 | Maintenance 4 | =========== 5 | 6 | Organizer does keep to itself for the most part. A vanilla installation requires 7 | almost no touching after the initial configuration and setup. 8 | 9 | In addition to the import and export commands, there are a handful of useful 10 | commands you can run: 11 | 12 | * make_superuser - Makes a given email address a superuser. 13 | * dedupe - Allows you to dedupe your airtable. It is an interactive script that 14 | will prompt you to merge items before asking again to save changes. 15 | * list_members - Probably only useful for East Bay for Everyone. Lists everyone 16 | who has attended at least one event and thus a member. Tags people who have 17 | attended at least three events as a voting member. 18 | 19 | 20 | .. _administrators: 21 | 22 | Administrators 23 | -------------- 24 | 25 | Administrators are users with administration privileges. This may be granted 26 | automatically through the login process; See :ref:`integrations`. 27 | 28 | .. _administration-interface: 29 | 30 | Administration Interface 31 | ~~~~~~~~~~~~~~~~~~~~~~~~ 32 | 33 | Organizer is built on top of Django, which includes a delightful administration 34 | backend. You may access it via /superuser/; /admin/ is the reduced CRM-specific 35 | version accessable from the normal UI. 36 | -------------------------------------------------------------------------------- /assets/js/components/mapping/LocatorControl.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import LocateControl from 'leaflet.locatecontrol' 3 | import { MapControl } from 'react-leaflet' 4 | import { bindActionCreators } from 'redux' 5 | import { Geocache } from '../../actions' 6 | 7 | class AutoStartLocateControl extends LocateControl { 8 | constructor(props) { 9 | super(props) 10 | this.onLocationFound = props.onLocationFound 11 | } 12 | 13 | addTo(map) { 14 | super.addTo(map) 15 | map.on('locationfound', this.onLocationFound) 16 | this.start() 17 | } 18 | } 19 | 20 | export class LocatorControl extends MapControl { 21 | createLeafletElement(props) { 22 | var lc = new AutoStartLocateControl({ 23 | keepCurrentZoomLevel: true, 24 | locateOptions: { 25 | enableHighAccuracy: true, 26 | }, 27 | icon: 'fa fa-map-marker', 28 | onLocationFound: loc => props.updateCurrentLocation(loc.latlng) 29 | }) 30 | return lc 31 | } 32 | } 33 | 34 | const mapLocatorDispatchToProps = dispatch => { 35 | return bindActionCreators({ 36 | updateCurrentLocation: Geocache.updateCurrentLocation 37 | }, dispatch) 38 | } 39 | export default connect(() => ({}), mapLocatorDispatchToProps)(LocatorControl) 40 | -------------------------------------------------------------------------------- /assets/js/components/chrome/OrganizerBottomNav.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { OrganizerBottomNav } from './OrganizerBottomNav' 3 | import { mount, shallow } from 'enzyme' 4 | 5 | import BottomNavigationAction from '@material-ui/core/BottomNavigationAction' 6 | 7 | it('should safely render default state', () => { 8 | shallow() 9 | shallow() 10 | }) 11 | 12 | it('should navigate react-router when clicked', () => { 13 | const clickHandler = jest.fn() 14 | const wrapper = mount() 15 | wrapper.find('button').first().simulate('click') 16 | expect(clickHandler).toHaveBeenCalledTimes(1) 17 | }) 18 | 19 | it('should correctly render staff buttons', () => { 20 | const wrapper = shallow() 21 | const staffWrapper = shallow() 22 | 23 | expect(wrapper.find(BottomNavigationAction).find({value: '/captain'})).toHaveLength(0) 24 | expect(wrapper.find(BottomNavigationAction).find({value: '/people'})).toHaveLength(0) 25 | 26 | expect(staffWrapper.find(BottomNavigationAction).find({value: '/captain'})).toHaveLength(1) 27 | expect(staffWrapper.find(BottomNavigationAction).find({value: '/people'})).toHaveLength(1) 28 | }) 29 | -------------------------------------------------------------------------------- /crm/migrations/0013_convert_to_geocodable.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-04-22 23:55 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from geocodable.models import LocationAlias, Location 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('geocodable', '0001_initial'), 13 | ('crm', '0012_person_location'), 14 | ] 15 | def migrate_to_locations(apps, schema_editor): 16 | Person = apps.get_model('crm', 'Person') 17 | db_alias = schema_editor.connection.alias 18 | allPeople = Person.objects.using(db_alias).all() 19 | for person in allPeople: 20 | person.location_id = LocationAlias.objects.get_or_create(raw=person.address.raw)[0].id 21 | person.save() 22 | 23 | def migrate_from_locations(apps, schema_editor): 24 | Person = apps.get_model('crm', 'Person') 25 | db_alias = schema_editor.connection.alias 26 | allPeople = Person.objects.using(db_alias).all() 27 | for person in allPeople: 28 | if person.location.location is not None: 29 | person.address = person.location.location.fullName 30 | person.save() 31 | 32 | operations = [ 33 | migrations.RunPython(migrate_to_locations, migrate_from_locations), 34 | ] 35 | -------------------------------------------------------------------------------- /onboarding/migrations/0007_auto_20181016_0515.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-10-16 05:15 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('events', '0002_auto_20180824_2228'), 13 | ('onboarding', '0006_auto_20180814_1829'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Signup', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('email', models.CharField(max_length=200)), 22 | ('created', models.DateField(auto_now_add=True)), 23 | ('approved', models.BooleanField(default=False)), 24 | ('deleted', models.BooleanField(default=False)), 25 | ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='events.Event')), 26 | ], 27 | ), 28 | migrations.AlterField( 29 | model_name='newneighbornotificationtarget', 30 | name='turfs', 31 | field=models.ManyToManyField(related_name='notification_targets', to='crm.Turf'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | var config = require('./webpack.config') 2 | var merge = require('webpack-merge') 3 | var path = require('path') 4 | var webpack = require('webpack') 5 | 6 | module.exports = merge(config, { 7 | mode: 'development', 8 | entry: { 9 | webpack: [ 10 | 'react-hot-loader/patch', 11 | 'webpack-dev-server/client?http://localhost:8080', 12 | 'webpack/hot/only-dev-server' 13 | ] 14 | }, 15 | devServer: { 16 | hot: true, 17 | contentBase: path.resolve(__dirname, 'assets'), // eslint-disable-line no-undef 18 | publicPath: '/', 19 | headers: { 20 | 'Access-Control-Allow-Origin': '*', 21 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 22 | 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization' 23 | } 24 | }, 25 | output: { 26 | publicPath: 'http://localhost:8080/', 27 | filename: '[name].js' 28 | }, 29 | plugins: [ 30 | new webpack.HotModuleReplacementPlugin(), 31 | ], 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.s?css$/, 36 | use: [ 37 | 'style-loader', 38 | 'css-loader?importLoaders=1', 39 | 'sass-loader', 40 | ] 41 | }, 42 | ] 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /assets/js/components/AppRoutes.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, Switch } from 'react-router-dom' 3 | import { ConnectedRouter } from 'connected-react-router/immutable' 4 | import importedComponent from 'react-imported-component' 5 | import { history, withProvider } from '../store' 6 | import { getLoggedIn } from '../selectors/auth' 7 | import { connect } from 'react-redux' 8 | import Skeleton from './activities/checkin/Skeleton' 9 | 10 | const MapIndex = importedComponent(() => import(/* webpackChunkName:'map' */ './MapIndex')) 11 | const PeopleIndex = importedComponent(() => import(/* webpackChunkName:'people' */ './PeopleIndex')) 12 | const EventCheckin = importedComponent(() =>import(/* webpackChunkName:'checkin' */ './activities/checkin'), { 13 | LoadingComponent: Skeleton 14 | }) 15 | 16 | export const AppRoutes = props => ( 17 | props.logged_in ? ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ) : 26 | ) 27 | 28 | 29 | const mapStateToProps = state => { 30 | return { 31 | logged_in: getLoggedIn(state) 32 | } 33 | } 34 | 35 | export default withProvider(connect(mapStateToProps)(AppRoutes)) 36 | -------------------------------------------------------------------------------- /events/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-17 04:40 3 | from __future__ import unicode_literals 4 | 5 | import address.models 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ('address', '0002_auto_20160213_1726'), 16 | ('crm', '0009_auto_20180807_0304'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Event', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('name', models.CharField(max_length=200)), 25 | ('timestamp', models.DateTimeField()), 26 | ('uid', models.CharField(blank=True, max_length=200)), 27 | ('lat', models.FloatField(blank=True, null=True)), 28 | ('lng', models.FloatField(blank=True, null=True)), 29 | ('instance_id', models.CharField(blank=True, max_length=200)), 30 | ('attendees', models.ManyToManyField(related_name='events', to='crm.Person')), 31 | ('location', address.models.AddressField(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='address.Address')), 32 | ], 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /organizer/viewsets.py: -------------------------------------------------------------------------------- 1 | from rest_framework import viewsets, status 2 | from rest_framework.decorators import list_route, detail_route 3 | from django.db.models import Q 4 | 5 | class IntrospectiveViewSet(viewsets.ModelViewSet): 6 | @list_route(methods=['get']) 7 | def fields(self, request): 8 | fields = [] 9 | for fieldName, field in self.get_serializer().fields.iteritems(): 10 | fields.append({'label': field.label, 'key': fieldName}) 11 | return Response({'fields': fields}) 12 | 13 | def get_sorts(self): 14 | sortKeys = [] 15 | if 'sort' in self.request.query_params: 16 | sortKeys = [self.request.query_params.get('sort')] 17 | return sortKeys 18 | 19 | def get_filter(self): 20 | filterArg = Q() 21 | 22 | for param, value in self.request.query_params.iteritems(): 23 | if param == "sort": 24 | continue 25 | if param == "page": 26 | continue 27 | if param.endswith("__in"): 28 | filterArg &= Q(**{param: [value]}) 29 | else: 30 | filterArg &= Q(**{param: value}) 31 | 32 | return filterArg 33 | 34 | def get_queryset(self): 35 | results = super(IntrospectiveViewSet, 36 | self).get_queryset().filter(self.get_filter()) 37 | 38 | for sortKey in self.get_sorts(): 39 | results = results.order_by(sortKey) 40 | 41 | return results 42 | 43 | 44 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "settings": { 3 | "import/resolver": "webpack" 4 | }, 5 | "env": { 6 | "browser": true, 7 | "commonjs": true, 8 | "es6": true 9 | }, 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:react/recommended", 13 | "plugin:jest/recommended", 14 | "plugin:import/errors", 15 | "plugin:import/warnings" 16 | ], 17 | "parser": "babel-eslint", 18 | "parserOptions": { 19 | "ecmaFeatures": { 20 | "experimentalObjectRestSpread": true, 21 | "jsx": true 22 | }, 23 | "allowImportExportEverywhere": true, 24 | "sourceType": "module" 25 | }, 26 | "plugins": [ 27 | "react", 28 | "jest", 29 | "import" 30 | ], 31 | "rules": { 32 | "indent": [ 33 | "error", 34 | 4 35 | ], 36 | "linebreak-style": [ 37 | "error", 38 | "unix" 39 | ], 40 | "quotes": [ 41 | "error", 42 | "single" 43 | ], 44 | "semi": [ 45 | "error", 46 | "never" 47 | ], 48 | "no-unused-vars": [ 49 | 2, {"args": "all", "argsIgnorePattern": "^_"} 50 | ], 51 | "react/prop-types": "off", 52 | "no-debugger": "off", 53 | "no-console": "off", 54 | "import/no-named-as-default-member": "off", 55 | "import/no-named-as-default": "off" 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /events/management/commands/leaderboard.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from events.models import Event 3 | from crm.models import Person 4 | from django.db.models import Count, Q 5 | from django.utils import timezone 6 | from datetime import timedelta 7 | 8 | class Command(BaseCommand): 9 | def handle(*args, **kwargs): 10 | windowStart = timezone.now() - timedelta(days=30) 11 | within30Days = Q(timestamp__gte=windowStart) 12 | people = Person.objects.all() 13 | leaderboard = people.annotate( 14 | event_count = Count('events', filter=within30Days) 15 | ).order_by('-event_count')[0:15] 16 | print "Event leaderboard since", windowStart 17 | print "===========" 18 | for person in leaderboard: 19 | print "%s\t%s"%(person, person.event_count) 20 | friendOverlap = Person.objects.filter(events__in=person.events.all()).annotate( 21 | event_count = Count('events', filter=within30Days) 22 | ).order_by('-event_count') 23 | print "\tFriends with:" 24 | for friend in friendOverlap[0:5]: 25 | print "\t\t%s %s"%(friend, friend.event_count) 26 | 27 | events = Event.objects.filter(within30Days).annotate( 28 | attendee_count=Count('attendees') 29 | ).order_by('-attendee_count')[0:15] 30 | print "Most popular events" 31 | print "===========" 32 | for event in events: 33 | print "%s\t%s"%(event, event.attendees.count()) 34 | -------------------------------------------------------------------------------- /organizer/exporting.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from django.conf import settings 3 | 4 | exporterCache = None 5 | 6 | def get_exporter_class(name): 7 | global exporterCache 8 | if exporterCache is None: 9 | exporterCache = collect_importers() 10 | return exporterCache.get(name) 11 | 12 | def collect_exporters(): 13 | ret = {} 14 | for app in settings.INSTALLED_APPS: 15 | try: 16 | imported = import_module('.'.join((app, 'exporting'))) 17 | except ImportError, e: 18 | continue 19 | if hasattr(imported, 'exporters'): 20 | ret.update(imported.exporters) 21 | return ret 22 | 23 | class DatasetExporter(object): 24 | def export_page(self, page, dry_run=False): 25 | raise NotImplementedError() 26 | 27 | def init(self): 28 | pass 29 | 30 | def __iter__(self): 31 | self.init() 32 | self.__offset = 0 33 | return self 34 | 35 | def get_queryset(self): 36 | return self.Meta.resource().get_queryset() 37 | 38 | def next(self): 39 | if self.__offset >= len(self): 40 | raise StopIteration 41 | batchSize = getattr(self.Meta, 'page_size', 100) 42 | ret = self.get_queryset()[self.__offset:(self.__offset+batchSize)] 43 | self.__offset += batchSize 44 | return self.Meta.resource().export(ret) 45 | 46 | def __len__(self): 47 | return self.get_queryset().count() 48 | 49 | def __getitem__(self, key): 50 | return self.get_queryset()[key] 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Organizer 2 | 3 | [![CircleCI](https://circleci.com/gh/tdfischer/organizer.svg?style=svg)](https://circleci.com/gh/tdfischer/organizer) 4 | [![Maintainability](https://api.codeclimate.com/v1/badges/86f7c614494ac53194e2/maintainability)](https://codeclimate.com/github/tdfischer/organizer/maintainability) 5 | [![codecov](https://codecov.io/gh/tdfischer/organizer/branch/master/graph/badge.svg)](https://codecov.io/gh/tdfischer/organizer) 6 | 7 | 8 | I don't think I have any unique perspectives on organizing people. It is hard 9 | work and the tools out there are never the best. Organizer is the particular 10 | windmill I have decided to tilt at in response. 11 | 12 | It is a work in progress. Small bits of hacking thrown around a django core and 13 | postgres database. Expect bumps. Expect bugs. Expect my undying admiration and 14 | love for your patch submissions and pull requests. 15 | 16 | ## One-Click Heroku Deployment 17 | 18 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 19 | 20 | Organizer is built for Heroku. Click the above button to deploy an app, which 21 | will automatically include the required addons and worker processes. 22 | 23 | ## Documentation 24 | 25 | Documentation is available on https://organizer-crm.readthedocs.io/. 26 | 27 | [![Read The Docs](https://readthedocs.org/projects/organizer-crm/badge/?version=latest)](https://organizer-crm.readthedocs.io/en/latest/?badge=latest) 28 | 29 | ## Development 30 | 31 | Want to [hack on organizer](https://organizer-crm.readthedocs.io/en/latest/docs/development.html)? Pull requests welcome! 32 | -------------------------------------------------------------------------------- /docs/crm.rst: -------------------------------------------------------------------------------- 1 | .. _crm: 2 | 3 | Comrade Relationship Manager 4 | ============================ 5 | 6 | Your organization is vast, and has hundreds, even thousands of members. How do 7 | you find anyone in it? 8 | 9 | The *Comrade Relationship manager* (CRM for short) is a tool that tracks data 10 | about people, including: 11 | 12 | * Name 13 | * E-Mail 14 | * Phone number 15 | * Address 16 | * Neighborhood 17 | * Tags 18 | * Level of engagement 19 | * Event and meeting attendance 20 | 21 | You can access the CRM through the "People" tab on the mobile UI. Build filters 22 | over your data, add/remove tags, and copy lists of e-mails for easy pasting into 23 | your e-mail client. 24 | 25 | 26 | Importing Spreadsheets 27 | ---------------------- 28 | 29 | The CRM allows you to import an excel, google sheets, or other spreadsheet 30 | using copy-and-paste. Importing a spreadsheet will allow you to match columns to 31 | CRM fields. 32 | 33 | Tags can be added to people by add a column to your data that begins with 34 | "tag:". If the column is non-blank, the person will be given that tag. 35 | 36 | If an imported email already exists in the CRM, that person will be updated with 37 | the imported data instead of creating a duplicate. 38 | 39 | Administration UI 40 | ----------------- 41 | 42 | There is a more comprehensive UI for the CRM that allows you to create, update, 43 | delete all data in the system. It is only available to administrators through 44 | the upper left app menu. See the :ref:`maintenance` section for more information on 45 | :ref:`administrators` and the :ref:`administration-interface`. 46 | -------------------------------------------------------------------------------- /assets/js/reducers/model.test.js: -------------------------------------------------------------------------------- 1 | import modelReducer from './model' 2 | import Model from '../store/model' 3 | import Immutable from 'immutable' 4 | 5 | function updateModel(data) { 6 | return { 7 | type: 'UPDATE_MODEL', 8 | id: data.id, 9 | data: data, 10 | name: 'test' 11 | } 12 | } 13 | 14 | it('should append models to an empty store', () => { 15 | const testModel = new Model('test') 16 | var testRows = [] 17 | var state = undefined 18 | for(var i = 0; i < 10; i++) { 19 | const modelInstance = {id: i} 20 | testRows.push([i, modelInstance]) 21 | state = modelReducer(state, updateModel(modelInstance)) 22 | } 23 | expect(state).toEqual(Immutable.Map({ 24 | models: Immutable.Map({ 25 | test: Immutable.Map(testRows) 26 | }) 27 | })) 28 | }) 29 | 30 | it('should update models and maintain order', () => { 31 | const testModel = new Model('test') 32 | const updatedRows = [] 33 | var state = undefined 34 | for(var i = 0; i < 10; i++) { 35 | const modelInstance = {id: i} 36 | state = modelReducer(state, updateModel(modelInstance)) 37 | } 38 | 39 | for(var i = 10; i >= 0; i--) { 40 | const modelInstance = {id: i, updated: i % 3} 41 | updatedRows.push([i, modelInstance]) 42 | state = modelReducer(state, updateModel(modelInstance)) 43 | } 44 | 45 | expect(state).toEqual(Immutable.Map({ 46 | models: Immutable.Map({ 47 | test: Immutable.Map(Immutable.Map(updatedRows)) 48 | }) 49 | })) 50 | }) 51 | -------------------------------------------------------------------------------- /assets/js/store/select.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | 3 | export const SET_SELECTION = 'SET_SELECTION' 4 | export const ADD_SELECTION = 'ADD_SELECTION' 5 | export const REMOVE_SELECTION = 'REMOVE_SELECTION' 6 | export const TOGGLE_SELECTION = 'TOGGLE_SELECTION' 7 | 8 | export const setSelection = (key, selection) => { 9 | return { 10 | type: SET_SELECTION, 11 | key: key, 12 | selection: selection 13 | } 14 | } 15 | 16 | export const addSelection = (key, newItem) => { 17 | return { 18 | type: ADD_SELECTION, 19 | key: key, 20 | item: newItem 21 | } 22 | } 23 | 24 | export const removeSelection = (key, oldItem) => { 25 | return { 26 | type: REMOVE_SELECTION, 27 | key: key, 28 | item: oldItem 29 | } 30 | } 31 | 32 | export const toggleSelection = (key, item) => { 33 | return { 34 | type: TOGGLE_SELECTION, 35 | key: key, 36 | item: item 37 | } 38 | } 39 | 40 | export default class Selectable { 41 | constructor(key) { 42 | this.key = key 43 | } 44 | 45 | immutableSelected(state) { 46 | return state.getIn(['selections', 'selections', this.key], Immutable.Set()).toIndexedSeq() 47 | } 48 | 49 | bindActionCreators(dispatch) { 50 | return { 51 | add: id => dispatch(addSelection(this.key, id)), 52 | remove: id => dispatch(removeSelection(this.key, id)), 53 | set: selection => dispatch(setSelection(this.key, selection)), 54 | toggle: id => dispatch(toggleSelection(this.key, id)) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /index.rst: -------------------------------------------------------------------------------- 1 | Organizer 2 | ===================================== 3 | 4 | At its core, Organizer tries to be a useful CRM for grassroots organizations 5 | that hate using CRMs. No CRM is perfect, and no CRM will fit your organization 6 | exactly right. However, Organizer does have one advantage over other systems: it 7 | is Free/Libre Software you run yourself. This means you own your data, instead 8 | of handing it off to some other for-profit enterprise. 9 | 10 | .. toctree:: 11 | :maxdepth: 3 12 | :caption: Contents: 13 | 14 | docs/installation 15 | docs/usage 16 | docs/maintenance 17 | docs/integrations 18 | docs/development 19 | 20 | Features 21 | -------- 22 | 23 | * Your members activate their neighbors into taking action with neighborhood :ref:`broadcasts` 24 | * Neighborhood captains can identify reliable organizers, keep tabs on newbies 25 | near them with :ref:`new-neighbor-notifications`. 26 | * Build a list of activists, segment by neighborhood, city, level of engagement; 27 | tag them and share with your team, or come back to them later. Its all in the 28 | :ref:`CRM`. 29 | * Expansive import/export support; connect Organizer to your group's Google 30 | Calendar, Airtable, import/export spreadsheets, csv, and more. Check out the 31 | :ref:`import-export` section. 32 | * First class support for Heroku deployments, for when you'd rather be 33 | organizing for change than debugging unix permissions. 34 | * Super easy membership attendance tracking! Connect your login with Slack or 35 | Discourse and finally do away with (most of) the hassle of paper signup sheets. Read up on :ref:`events`! 36 | -------------------------------------------------------------------------------- /sync/management/commands/export.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | from crm.exporting import AirtableExporter 3 | from organizer.exporting import get_exporter_class, collect_exporters 4 | from tqdm import tqdm 5 | import logging 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | class Command(BaseCommand): 10 | def add_arguments(self, parser): 11 | parser.add_argument('destination', nargs='+') 12 | parser.add_argument('--debug', default=False, action='store_true') 13 | parser.add_argument('--dry-run', default=False, action='store_true') 14 | 15 | def handle(self, *args, **options): 16 | if options['debug']: 17 | logging.basicConfig(level=logging.DEBUG) 18 | 19 | dryRun = options['dry_run'] 20 | 21 | exporters = [] 22 | for dest in options['destination']: 23 | exporterCls = get_exporter_class(dest) 24 | if exporterCls is None: 25 | print "No such exporter:", dest 26 | print "Available exporters:", ', '.join(collect_exporters().keys()) 27 | return 28 | exporters.append((dest, exporterCls())) 29 | 30 | with tqdm(exporters, desc='destinations', unit=' destination') as expIt: 31 | for (exporterName, exporter) in expIt: 32 | exportedResource = exporter.Meta.resource() 33 | with tqdm(exporter, desc=exporterName, unit=' page') as it: 34 | log.debug('Exporting %s items', len(exporter)) 35 | for page in it: 36 | exporter.export_page(page, dry_run=dryRun) 37 | -------------------------------------------------------------------------------- /filtering/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.contrib import admin 5 | from django.utils.html import format_html, format_html_join 6 | from . import models 7 | 8 | class FilterNodeAdmin(admin.ModelAdmin): 9 | list_display = ( 10 | 'name', 'query', 'kind', 'annotations_used' 11 | ) 12 | 13 | list_filter = ( 14 | ('content_type', admin.RelatedOnlyFieldListFilter), 15 | ) 16 | 17 | def kind(self, obj): 18 | return str(obj.content_type) 19 | 20 | def annotations_used(self, obj): 21 | if not obj.children.all().exists(): 22 | return "None" 23 | return ', '.join((str(a) for a in obj.get_family())) 24 | 25 | def query(self, obj): 26 | return obj.as_string() 27 | 28 | readonly_fields = ['results'] 29 | 30 | def results(self, obj): 31 | res = None 32 | try: 33 | res = obj.results.values() 34 | except Exception, e: 35 | return format_html('{}', e) 36 | 37 | if not res.exists(): 38 | return format_html('No results') 39 | 40 | headers = ((header,) for header in res[0].keys()) 41 | rows = ( 42 | (format_html_join('\n\t\t', '{}', 43 | ((v,) for v in r.values()), 44 | ),) for r in res 45 | ) 46 | return format_html( 47 | '{}{}
', 48 | format_html_join('\n', '{}', headers), 49 | format_html_join('\n\t', '{}', rows) 50 | ) 51 | 52 | admin.site.register(models.FilterNode, FilterNodeAdmin) 53 | admin.site.register(models.Annotation) 54 | -------------------------------------------------------------------------------- /assets/js/components/chrome/LoginButtons.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Button from '@material-ui/core/Button' 3 | import Grid from '@material-ui/core/Grid' 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 5 | import { library as faLibrary } from '@fortawesome/fontawesome' 6 | import faDiscourse from '@fortawesome/fontawesome-free-brands/faDiscourse' 7 | import faSlack from '@fortawesome/fontawesome-free-brands/faSlack' 8 | import faExclamationTriangle from '@fortawesome/fontawesome-free-solid/faExclamationTriangle' 9 | 10 | faLibrary.add(faDiscourse, faSlack, faExclamationTriangle) 11 | 12 | function iconForName(name) { 13 | switch (name) { 14 | case 'local-dev': 15 | return ['fa', 'exclamation-triangle'] 16 | default: 17 | return ['fab', name] 18 | } 19 | } 20 | 21 | function styleForName(name) { 22 | switch (name) { 23 | case 'local-dev': 24 | return {style: {backgroundColor: '#f00'}} 25 | default: 26 | return {color: 'primary'} 27 | } 28 | } 29 | 30 | const LoginButtons = props => ( 31 | 32 | {Object.entries(window.LOGIN_URLS || []).map(([name, url]) => ( 33 | 41 | ))} 42 | 43 | ) 44 | 45 | LoginButtons.defaultProps = { 46 | label: true 47 | } 48 | 49 | export default LoginButtons 50 | -------------------------------------------------------------------------------- /onboarding/jobs.py: -------------------------------------------------------------------------------- 1 | import django_rq 2 | from django.template import loader 3 | from django.core.mail import EmailMessage 4 | from django.conf import settings 5 | import logging 6 | from django.utils import timezone 7 | from django.db.models.signals import post_save 8 | from django.dispatch import receiver 9 | import django_rq 10 | 11 | from . import models 12 | from crm.models import Person 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | @receiver(post_save, sender=Person) 17 | def queuePersonChange(sender, instance, **kwargs): 18 | if settings.AUTOMATIC_ONBOARDING: 19 | django_rq.enqueue(runOnboarding, instance) 20 | 21 | def runOnboarding(person): 22 | logger.info("Executing onboarding for %s", person) 23 | components = models.OnboardingComponent.objects.filter(enabled=True) 24 | 25 | for component in components: 26 | if not component.personHasBeenOnboarded(person): 27 | if component.filter.results.filter(pk=person.pk).exists(): 28 | logger.info("Onboarding %s to %s", person, component) 29 | result = None 30 | try: 31 | result = component.onboardPerson(person) 32 | except Exception, e: 33 | logger.error("Caught error while onboarding %s: %s", 34 | person, e) 35 | result = (False, str(e)) 36 | models.OnboardingStatus.objects.create( 37 | person = person, 38 | component = component, 39 | success = result[0], 40 | message = result[1] 41 | ) 42 | logger.info("Onboarded %s to %s: %r", person, component, 43 | result) 44 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [dev-packages] 7 | hypothesis = {version = "*",extras = ["django"]} 8 | django-dotenv = "*" 9 | pytest-cov = "*" 10 | pytest-django = "*" 11 | coverage = "*" 12 | pytest = "*" 13 | mock = "*" 14 | pytest-forked = "*" 15 | sphinx = "*" 16 | sphinx-autobuild = "*" 17 | pytest-test-groups = "*" 18 | 19 | [packages] 20 | appdirs = "==1.4.3" 21 | bleach = "==2.0.0" 22 | dj-database-url = "==0.4.2" 23 | django-anymail = "==1.2.1" 24 | django-markdownify = "==0.1.0" 25 | "enum34" = "==1.1.6" 26 | geopy = "==1.17.0" 27 | "html5lib" = "==0.999999999" 28 | pyparsing = "==2.2.0" 29 | pytz = "==2017.2" 30 | requests = "*" 31 | six = ">=1.10.0" 32 | webencodings = "==0.5.1" 33 | whitenoise = {version = "*",extras = ["brotli"]} 34 | gunicorn = "*" 35 | "mailchimp3" = "*" 36 | raven = "*" 37 | djangorestframework = "==3.6.3" 38 | django-webpack-loader = "*" 39 | django-rq = "==1.2.0" 40 | django-redis-cache = "==1.8.0" 41 | airtable-python-wrapper = "==0.11.0" 42 | social-auth-core = "*" 43 | social-auth-app-django = "*" 44 | django-taggit = "*" 45 | django-taggit-serializer = "*" 46 | Django = "==1.11.16" 47 | Faker = "==0.8.17" 48 | icalendar = "*" 49 | apiclient = "*" 50 | "httplib2" = "*" 51 | "oauth2client" = "*" 52 | google-api-python-client = "*" 53 | django-address = "*" 54 | django-import-export = "*" 55 | python-dateutil = "*" 56 | tqdm = "*" 57 | markdown = "==2.2" 58 | django-request-id = "*" 59 | stripe = "*" 60 | "psycopg2-binary" = "*" 61 | django-admin-rangefilter = "*" 62 | redis = "==2.10.6" 63 | rq = "==0.12.0" 64 | slackclient = "*" 65 | django-mptt = "*" 66 | django-enumfields = "*" 67 | birdisle = "*" 68 | django-taggit-helpers = "*" 69 | 70 | [requires] 71 | python_version = "2.7" 72 | -------------------------------------------------------------------------------- /crm/management/commands/dedupe.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.db import transaction 3 | from django.db.models import Count 4 | from django.db.models.functions import Lower 5 | import logging 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | from crm.models import Person, merge_models 10 | 11 | class Command(BaseCommand): 12 | def add_arguments(self, parser): 13 | parser.add_argument('email', default=[], nargs='*') 14 | parser.add_argument('--debug', default=False, action='store_true') 15 | parser.add_argument('--dry-run', default=False, action='store_true') 16 | 17 | def handle(self, *args, **options): 18 | if options['debug']: 19 | logging.basicConfig(level=logging.DEBUG) 20 | 21 | dryRun = options['dry_run'] 22 | 23 | allPeople = Person.objects.values(lower_email=Lower('email')) \ 24 | .annotate(Count('id')) \ 25 | .filter(id__count__gt=1) 26 | 27 | if len(options['email']) > 0: 28 | duplicates = allPeople.filter(lower_email__in=options['email']) 29 | else: 30 | duplicates = allPeople 31 | 32 | log.info("Duplicates", duplicates) 33 | relatedModels = [] 34 | for dupe in duplicates.values_list('lower_email'): 35 | matches = Person.objects.filter(email__iexact=dupe).order_by('created') 36 | first = matches[0] 37 | duplicates = matches[1:] 38 | merged, relations = merge_models(first, *duplicates) 39 | if not dryRun: 40 | with transaction.atomic(): 41 | for d in duplicates: 42 | d.delete() 43 | first.save() 44 | for r in relations: 45 | r.save() 46 | -------------------------------------------------------------------------------- /assets/js/components/people-browser/Search.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Grid from '@material-ui/core/Grid' 3 | import IconButton from '@material-ui/core/IconButton' 4 | import { Form, withFormApi } from 'informed' 5 | import faPlusSquare from '@fortawesome/fontawesome-free-solid/faPlusSquare' 6 | import { library as faLibrary } from '@fortawesome/fontawesome' 7 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 8 | import MaterialFormSwitch from '../MaterialFormSwitch' 9 | 10 | faLibrary.add(faPlusSquare) 11 | 12 | import BooleanFilter from './BooleanFilter' 13 | 14 | const AddButton = withFormApi(props => ( 15 | { 18 | const curFilter = props.formApi.getState().values.filter || [] 19 | props.formApi.setValues({...(props.formApi.getState().values), filter: [...curFilter, {}]}) 20 | }}> 21 | 22 | 23 | )) 24 | 25 | export const Search = props => { 26 | return ( 27 |
props.filter.set({op: values.op ? 'or' : 'and', children: values.filter})}> 28 | {({formApi}) => ( 29 | 30 | Match All Match Any 31 | {(formApi.getValue('filter') || []).map((_values, idx) => 32 | 33 | 34 | 35 | )} 36 | Add a filter 37 | 38 | )} 39 |
40 | ) 41 | } 42 | 43 | export default Search 44 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Organizer", 3 | "description": "Getting people to show up is hard.", 4 | "repository": "https://github.com/tdfischer/organizer", 5 | "success_url": "/", 6 | "keywords": ["organizing", "crm"], 7 | "buildpacks": [ 8 | {"url": "heroku/nodejs"}, 9 | {"url": "heroku/python"} 10 | ], 11 | "addons": [ 12 | "heroku-postgresql", "redistogo" 13 | ], 14 | "env": { 15 | "SECRET_KEY": { 16 | "description": "A secret key for verifying the integrity of signed cookies.", 17 | "generator": "secret", 18 | "required": true 19 | }, 20 | "SLACK_KEY": { 21 | "description": "Slack API key", 22 | "required": false 23 | }, 24 | "SLACK_SECRET": { 25 | "description": "Slack API secret", 26 | "required": false 27 | }, 28 | "SLACK_TEAM_ID": { 29 | "description": "Slack team ID", 30 | "required": false 31 | }, 32 | "AIRTABLE_API_KEY": { 33 | "description": "Airtable API key", 34 | "required": false 35 | }, 36 | "AIRTABLE_BASE_ID": { 37 | "description": "ID of Airtable base to use", 38 | "required": false 39 | }, 40 | "AIRTABLE_TABLE_NAME": { 41 | "description": "Name of table in Airtable to use", 42 | "required": false 43 | }, 44 | "DISCOURSE_BASE_URL": { 45 | "description": "URL of Discourse installation for SSO logins", 46 | "required": false 47 | }, 48 | "DISCOURSE_SSO_SECRET": { 49 | "description": "Secret for Discourse SSO logins", 50 | "required": false 51 | }, 52 | "MAILGUN_API_KEY": { 53 | "description": "Your API key from mailgun", 54 | "required": false 55 | }, 56 | "MAILGUN_DOMAIN": { 57 | "description": "Your domain from mailgun", 58 | "required": false 59 | }, 60 | "DEFAULT_FROM_EMAIL": { 61 | "description": "Who your emails will come from by default", 62 | "required": false 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /assets/js/components/activities/checkin/SignupForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { withState } from 'recompose' 3 | import { Form } from 'informed' 4 | import DialogContent from '@material-ui/core/DialogContent' 5 | import DialogTitle from '@material-ui/core/DialogTitle' 6 | import Grid from '@material-ui/core/Grid' 7 | import Button from '@material-ui/core/Button' 8 | import MaterialFormText from '../../MaterialFormText' 9 | 10 | export const SignupForm = props => { 11 | const orgName = (window.ORG_METADATA || {}).name 12 | return ( 13 |
props.onSubmit(values).then(() => props.setHasSaved(true))}> 14 | {props.hasSaved ? ( 15 | 16 | Thanks! 17 | 18 | An organizer will get in touch. 19 | 20 | 21 | ) : ( 22 | 23 | Join {orgName}! 24 | 25 | 26 | You're not logged in as a member, but that's okay! Enter your e-mail address to RSVP or sign in. 27 | (v && v.indexOf('@') != -1) ? null : 'Please enter your e-mail address.'}/> 28 | 29 | 30 | 31 | 32 | )} 33 |
34 | ) 35 | } 36 | 37 | 38 | export default withState('hasSaved', 'setHasSaved', false)(SignupForm) 39 | -------------------------------------------------------------------------------- /assets/img/symbol.svg: -------------------------------------------------------------------------------- 1 | 2 | 22 | -------------------------------------------------------------------------------- /onboarding/migrations/0009_auto_20181021_2234.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-10-21 22:34 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('crm', '0011_person_phone'), 13 | ('onboarding', '0008_remove_signup_deleted'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='OnboardingComponent', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('name', models.CharField(max_length=100)), 22 | ('enabled', models.BooleanField()), 23 | ('handler', models.CharField(max_length=200)), 24 | ('configuration', models.TextField()), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='OnboardingStatus', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('created', models.DateField(auto_now_add=True)), 32 | ('success', models.BooleanField()), 33 | ('component', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='onboarding.OnboardingComponent')), 34 | ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='crm.Person')), 35 | ], 36 | ), 37 | migrations.AddField( 38 | model_name='signup', 39 | name='address', 40 | field=models.CharField(blank=True, max_length=200, null=True), 41 | ), 42 | migrations.AddField( 43 | model_name='signup', 44 | name='phone', 45 | field=models.CharField(blank=True, max_length=200, null=True), 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /assets/js/components/ErrorWrapper.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Raven from 'raven-js' 3 | import { withStyles } from '@material-ui/styles' 4 | import importedComponent from 'react-imported-component' 5 | 6 | const Button = importedComponent(() => import('@material-ui/core/Button')) 7 | 8 | export class ErrorWrapper extends React.Component { 9 | constructor(props) { 10 | super(props) 11 | this.state = { error: null } 12 | } 13 | 14 | componentDidCatch(error, errorInfo) { 15 | Raven.captureException(error, { extra: errorInfo }) 16 | console.error(error) 17 | console.error(errorInfo) 18 | this.setState({error}) 19 | } 20 | 21 | render() { 22 | if (this.state.error) { 23 | return ( 24 |
Raven.lastEventId() && Raven.showReportDialog()}> 25 |
26 | 27 |

Organizer has crashed!

28 |

This is quite unfortunate.

29 |

{Raven.lastEventId() ? () : null}

30 | {Raven.lastEventId() ? 'This error has been automatically reported' : 'This error could not be automatically reported'} 31 |
{this.state.error.stack}
32 |
33 |
34 | ) 35 | } else { 36 | return this.props.children 37 | } 38 | } 39 | } 40 | 41 | const errorStyles = { 42 | root: { 43 | width: '80%', 44 | margin: 'auto', 45 | textAlign: 'center', 46 | paddingTop: '5rem' 47 | }, 48 | errorMessage: { 49 | overflow: 'auto', 50 | backgroundColor: '#ddd' 51 | } 52 | } 53 | 54 | export default withStyles(errorStyles)(ErrorWrapper) 55 | -------------------------------------------------------------------------------- /assets/scss/form-view.scss: -------------------------------------------------------------------------------- 1 | .the-form { 2 | background-color: lighten($secondary-color, 25%) !important; 3 | 4 | .columns { 5 | background-color: #fff; 6 | } 7 | 8 | .the-response { 9 | 10 | .doodle { 11 | margin-bottom: -1.5em; 12 | margin-top: -0.7em; 13 | } 14 | 15 | @include breakpoint(medium) { 16 | top: -9em; 17 | position: relative; 18 | } 19 | 20 | .name { 21 | font-size: large; 22 | } 23 | .date { 24 | font-size: x-large; 25 | } 26 | 27 | text-align: center; 28 | } 29 | 30 | .the-ask { 31 | padding-bottom: 1em; 32 | @include breakpoint(medium) { 33 | top: -9em; 34 | position: relative; 35 | } 36 | 37 | label { 38 | font-weight: bold; 39 | font-size: large; 40 | } 41 | 42 | .FormInput { 43 | padding: 0; 44 | } 45 | 46 | .required { 47 | color: #e00; 48 | } 49 | 50 | .body { 51 | font-size: small; 52 | } 53 | } 54 | 55 | h1 { 56 | border-bottom: 3px solid $primary-color; 57 | } 58 | 59 | .meta { 60 | margin-top: 2em; 61 | font-size: small; 62 | 63 | .name { 64 | font-size: large; 65 | } 66 | 67 | .until { 68 | font-weight: bold; 69 | } 70 | } 71 | } 72 | 73 | @keyframes doodle-spin { 74 | from { 75 | transform: rotate(0deg); 76 | } 77 | to { 78 | transform: rotate(360deg); 79 | } 80 | } 81 | 82 | .doodle { 83 | font-size: 6rem; 84 | position: relative; 85 | text-align: center; 86 | span { 87 | display: block; 88 | width: 7rem; 89 | height: 7rem; 90 | transform: rotate(22.5deg); 91 | background: rgba($primary-color, 0.75); 92 | box-shadow: 0px 0px 30px 5px rgba($primary-color, 0.25); 93 | 94 | &.spinner { 95 | animation-name: doodle-spin; 96 | animation-duration: 3s; 97 | animation-iteration-count: infinite; 98 | animation-timing-function: linear; 99 | } 100 | } 101 | p { 102 | position: relative; 103 | top: -2.15em; 104 | margin: 0; 105 | padding: 0; 106 | line-height: 1em; 107 | } 108 | } 109 | 110 | -------------------------------------------------------------------------------- /crm/exporting.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from airtable import Airtable 3 | import logging 4 | from mailchimp3 import MailChimp 5 | import hashlib 6 | 7 | from organizer.exporting import DatasetExporter 8 | from crm.importing import PersonResource 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | class MailchimpExporter(DatasetExporter): 13 | class Meta: 14 | resource = PersonResource 15 | 16 | def init(self): 17 | self.mailchimp = MailChimp(mc_api=settings.MAILCHIMP_SECRET_KEY) 18 | 19 | def export_page(self, page, dry_run=False): 20 | for row in page.dict: 21 | hasher = hashlib.md5() 22 | hasher.update(row['email'].lower()) 23 | hashedAddr = hasher.hexdigest() 24 | if not dry_run: 25 | self.mailchimp.lists.members.create_or_update( 26 | settings.MAILCHIMP_LIST_ID, 27 | hashedAddr, 28 | dict( 29 | email_address = row['email'], 30 | status_if_new = 'subscribed' 31 | ) 32 | ) 33 | 34 | class AirtableExporter(DatasetExporter): 35 | class Meta: 36 | resource = PersonResource 37 | 38 | def init(self): 39 | self.airtable = Airtable( 40 | settings.AIRTABLE_BASE_ID, 41 | settings.AIRTABLE_TABLE_NAME, 42 | api_key=settings.AIRTABLE_API_KEY) 43 | self.members = self.airtable.get_all() 44 | 45 | def export_page(self, page, dry_run=False): 46 | for row in page.dict: 47 | self.export_person(row, dry_run=dry_run) 48 | 49 | def export_person(self, row, dry_run): 50 | rowEmail = row['email'].strip().lower() 51 | for m in self.members: 52 | memberEmail = m['fields'].get(settings.AIRTABLE_EMAIL_COLUMN, '').strip().lower() 53 | if memberEmail == rowEmail: 54 | return 55 | log.info('Creating %s <%s>', row['name'], row['email']) 56 | if not dry_run: 57 | self.airtable.insert({ 58 | settings.AIRTABLE_NAME_COLUMN: row['name'], 59 | settings.AIRTABLE_EMAIL_COLUMN: row['email'], 60 | }) 61 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Organizer is best deployed as a Heroku app. 5 | 6 | Once your app is up and running, use ```herkou config:set VAR=VALUE ...``` to 7 | configure the following settings. 8 | 9 | Required settings 10 | ----------------- 11 | 12 | There is only one required setting that must be set for the app to run at all. 13 | 14 | * SECRET_KEY - A random key to securely verify cookies. 15 | 16 | Email 17 | ~~~~~ 18 | 19 | E-mails are sent using Mailgun. Support for other platforms can be added in the 20 | future; organizer uses Anymail, a django library with support for a broad list 21 | of mail services. Pull requests are welcome. 22 | 23 | * MAILGUN_API_KEY - Your API key from Mailgun. 24 | * MAILGUN_DOMAIN - Your mailgun domain 25 | * ANYMAIL_WEBHOOK_AUTHORIZATION - Magic token you set in Mailgun 26 | * DEFAULT_FROM_EMAIL - Who your emails will be coming from when sent through 27 | mailgun. 28 | 29 | 30 | Optional settings 31 | ----------------- 32 | 33 | These settings are not required in most circumstances, but exist nonetheless. 34 | 35 | * DEFAULT_PERSON_STATE - Set the 'default' state to be used when creating a 36 | person and none is specified. 37 | * REDISTOGO_URL - Heroku often sets this one automatically. The default is 38 | redis://localhost:6379/0 39 | 40 | Settings you should never use unless you know what you're doing 41 | --------------------------------------------------------------- 42 | 43 | Misuse of these settings come with terrible consequences. 44 | 45 | * DEBUG - Sets debug mode. Breaks all privacy guarantees and exposes all sorts 46 | of personally identifying information. May a million locusts fly from your 47 | eyes, etc. Useful for local development. 48 | * USE_REALLY_INSECURE_DEVELOPMENT_AUTHENTICATION_BACKEND - Does what it says on 49 | the tin. Organizer comes with a special local development backend for testing 50 | and development purposes. Only works in conjunction with setting DEBUG. 51 | Turning this on removes all layers of privacy and security, exposing the 52 | entire database to the public eye. Many people will hate you for a long time. 53 | May your children and your children's children etc be cursed with boils and 54 | so on. Also useful for local development. 55 | -------------------------------------------------------------------------------- /crm/migrations/0003_auto_20180802_0200_squashed_0005_auto_20180802_0319.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.1 on 2018-08-02 08:04 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | replaces = [(b'crm', '0003_auto_20180802_0200'), (b'crm', '0004_auto_20180802_0208'), (b'crm', '0005_auto_20180802_0319')] 12 | 13 | dependencies = [ 14 | ('address', '0001_initial'), 15 | ('crm', '0002_auto_20180729_2011_squashed_0005_auto_20180729_2125'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Turf', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('name', models.CharField(max_length=200)), 24 | ('locality', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='address.Locality')), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='TurfMembership', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('joined_on', models.DateField()), 32 | ('is_captain', models.BooleanField()), 33 | ], 34 | ), 35 | migrations.RemoveField( 36 | model_name='person', 37 | name='neighborhood', 38 | ), 39 | migrations.AddField( 40 | model_name='turfmembership', 41 | name='person', 42 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='turf_memberships', to='crm.Person'), 43 | ), 44 | migrations.AddField( 45 | model_name='turfmembership', 46 | name='turf', 47 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='crm.Turf'), 48 | ), 49 | migrations.AlterField( 50 | model_name='turfmembership', 51 | name='is_captain', 52 | field=models.BooleanField(default=False), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /geocodable/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2019-04-22 23:50 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import enumfields.fields 8 | import geocodable.models 9 | import mptt.fields 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Location', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('type', enumfields.fields.EnumField(blank=True, enum=geocodable.models.LocationType, max_length=32, null=True)), 25 | ('name', models.CharField(blank=True, max_length=200, null=True)), 26 | ('lat', models.FloatField(blank=True, null=True)), 27 | ('lng', models.FloatField(blank=True, null=True)), 28 | ('lft', models.PositiveIntegerField(db_index=True, editable=False)), 29 | ('rght', models.PositiveIntegerField(db_index=True, editable=False)), 30 | ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), 31 | ('level', models.PositiveIntegerField(db_index=True, editable=False)), 32 | ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='geocodable.Location')), 33 | ], 34 | options={ 35 | 'abstract': False, 36 | }, 37 | ), 38 | migrations.CreateModel( 39 | name='LocationAlias', 40 | fields=[ 41 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 42 | ('raw', models.CharField(blank=True, default='', max_length=200)), 43 | ('nonce', models.CharField(blank=True, default='', max_length=200)), 44 | ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='geocodable.Location')), 45 | ], 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /assets/js/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import importedComponent from 'react-imported-component' 3 | import { hot } from 'react-hot-loader' 4 | 5 | import { ThemeProvider, jssPreset, createGenerateClassName } from '@material-ui/styles' 6 | import JssProvider from 'react-jss/lib/JssProvider' 7 | import { create } from 'jss' 8 | import { library as faLibrary } from '@fortawesome/fontawesome' 9 | import faTimes from '@fortawesome/fontawesome-free-solid/faTimes' 10 | import AppBar from '@material-ui/core/AppBar' 11 | import { createMuiTheme } from '@material-ui/core/styles' 12 | import Toolbar from '@material-ui/core/Toolbar' 13 | import Typography from '@material-ui/core/Typography' 14 | import Logo from './chrome/Logo' 15 | import ErrorWrapper from './ErrorWrapper' 16 | 17 | faLibrary.add(faTimes) 18 | 19 | const theme = createMuiTheme({ 20 | palette: (window.ORG_METADATA || {}).palette 21 | }) 22 | 23 | const EmptyAppBar = _props => ( 24 | 25 | 26 | 27 | {(window.ORG_METADATA || {}).name} 28 | 29 | 30 | ) 31 | 32 | const AppRoutes = importedComponent(() => import('./AppRoutes')) 33 | const OrganizerAppBar = importedComponent(() => import('./chrome/OrganizerAppBar'), { 34 | LoadingComponent: EmptyAppBar 35 | }) 36 | const OrganizerBottomNav = importedComponent(() => import('./chrome/OrganizerBottomNav')) 37 | 38 | 39 | export const App = _props => ( 40 | 41 |
42 | 43 |
44 |
45 | 46 |
47 |
48 | 49 |
50 |
51 | ) 52 | 53 | const jss = create(jssPreset()) 54 | const generateClassName = createGenerateClassName() 55 | 56 | const RouterApp = (props) => ( 57 | 58 | ) 59 | 60 | export default hot(module)(RouterApp) 61 | -------------------------------------------------------------------------------- /docs/integrations.rst: -------------------------------------------------------------------------------- 1 | .. _integrations: 2 | 3 | Integrations 4 | ============ 5 | 6 | Organizer can be connected to many other services. 7 | 8 | Sentry 9 | ------ 10 | 11 | Organizer supports reporting crashes, errors, and in-app problem reports to 12 | Sentry.io. It is recommended that you connect it (its "free"!). When enabled, 13 | users gain a menu item which allows them to report problems to you. 14 | 15 | * SENTRY_DSN - If you use sentry.io, enter your DSN here to receive error logs. 16 | 17 | MailChimp 18 | --------- 19 | 20 | People can be imported from and exported to MailChimp. 21 | 22 | * MAILCHIMP_SECRET_KEY - Your secret API key 23 | * MAILCHIMP_LIST_ID - The list you will be synchronizing with. Future releases 24 | will support more lists without environment configuration. 25 | 26 | ./manage.py import mailchimp-people 27 | 28 | ./manage.py export mailchimp-people 29 | 30 | Google Maps 31 | ----------- 32 | 33 | Organizer uses Google Maps for normalizing and geolocating street addresses. 34 | 35 | * GOOGLE_MAPS_KEY 36 | 37 | Google Calendar 38 | --------------- 39 | 40 | Events can be imported from a Google Calendar calendar 41 | 42 | * GOOGLE_SERVICE_ACCOUNT_CREDENTIALS - A big JSON blob. Sorry. 43 | * GOOGLE_CALENDAR_IMPORT_ID - ID of the calendar you'll be importing from. 44 | 45 | ./manage.py import google-calendar-events 46 | 47 | Airtable 48 | -------- 49 | 50 | Organizer supports importing and exporting people through Airtable. 51 | 52 | * AIRTABLE_API_KEY 53 | * AIRTABLE_BASE_ID 54 | * AIRTABLE_TABLE_NAME 55 | 56 | ./manage.py import airtable-people 57 | ./manage.py export airtable-people 58 | ./manage.py export airtable-attendance 59 | 60 | Discourse 61 | --------- 62 | 63 | Login through an installation of Discourse with SSO authentication enabled 64 | 65 | * DISCOURSE_BASE_URL - Full url to the index, eg https://discuss.example.com/ 66 | * DISCOURSE_SSO_SECRET - Secret you configure in discourse's settings. 67 | 68 | If a user is staff or an admin on discourse, they will be one in Organizer as 69 | well. Users will also be placed in the same groups, though groups are currently 70 | unused. 71 | 72 | Slack 73 | ----- 74 | 75 | Enables logging in through Slack, optionally restricting it to a single team. 76 | 77 | * SLACK_KEY 78 | * SLACK_SECRET 79 | * SLACK_TEAM_ID - Leave this unset to allow any team to access. It is an error 80 | to not set this if slack API credentials are provided. 81 | -------------------------------------------------------------------------------- /assets/js/store/model.test.js: -------------------------------------------------------------------------------- 1 | import configureMockStore from 'redux-mock-store' 2 | import thunk from 'redux-thunk' 3 | import fetchMock from 'fetch-mock' 4 | 5 | import Model, { UPDATE_MODEL, REQUEST_MODELS, RECEIVE_MODELS, SAVING_MODEL, SAVED_MODEL } from './model' 6 | 7 | const mockStore = configureMockStore([thunk]) 8 | 9 | beforeEach(() => { 10 | fetchMock.restore() 11 | }) 12 | 13 | it('should bind action creators', () => { 14 | const func = jest.fn() 15 | const model = new Model('test') 16 | const boundCalls = model.bindActionCreators(func) 17 | Object.values(boundCalls).forEach(c => c()) 18 | expect(func).toHaveBeenCalledTimes(11) 19 | }) 20 | 21 | it('should update', () => { 22 | const store = mockStore() 23 | const model = new Model('test') 24 | return store.dispatch(model.update(1, {id: 1})) 25 | .then(() => { 26 | expect(store.getActions()[0]).toEqual({type: UPDATE_MODEL, id: 1, data: {id: 1}, name: 'test'}) 27 | }) 28 | }) 29 | 30 | it('should fetch one page', () => { 31 | const store = mockStore() 32 | const model = new Model('test') 33 | fetchMock.mock('/api/test/?', {results: []}) 34 | return store.dispatch(model.fetchAll()) 35 | .then(() => { 36 | expect(store.getActions()).toHaveLength(2) 37 | expect(store.getActions()[0]).toEqual({type: REQUEST_MODELS, name: 'test'}) 38 | expect(store.getActions()[1]).toEqual({type: RECEIVE_MODELS, models: [], name: 'test'}) 39 | }) 40 | }) 41 | 42 | it('should create data', () => { 43 | const store = mockStore() 44 | const model = new Model('test') 45 | fetchMock.mock('/api/test/', {id: 1}) 46 | return store.dispatch(model.create({})) 47 | .then((id) => { 48 | expect(store.getActions()[0]).toEqual({id: 0, type: SAVING_MODEL, name: 'test'}) 49 | expect(store.getActions()[1]).toEqual({data: {id: id}, id: id, type: UPDATE_MODEL, name: 'test'}) 50 | expect(store.getActions()[2]).toEqual({id: id, type: SAVED_MODEL, name: 'test'}) 51 | }) 52 | }) 53 | 54 | it('should decode geo data properly', () => { 55 | const model = new Model('test') 56 | expect(model.deserializeGeo({})).toBeUndefined() 57 | expect(model.deserializeGeo({lat: undefined, lng: 0})).toBeUndefined() 58 | expect(model.deserializeGeo({lat: 0, lng: undefined})).toBeUndefined() 59 | expect(model.deserializeGeo({lat: 0, lng: 0})).not.toBeUndefined() 60 | }) 61 | -------------------------------------------------------------------------------- /donations/importing.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from . import models 3 | from crm.models import Person 4 | from django.conf import settings 5 | from airtable import Airtable 6 | from import_export import resources, fields, widgets 7 | from organizer.importing import DatasetImporter 8 | import tablib 9 | import stripe 10 | from datetime import datetime 11 | 12 | class PersonEmailWidget(widgets.Widget): 13 | def clean(self, value, row, *args, **kwargs): 14 | if value is None: 15 | return None 16 | ret, _ = Person.objects.get_or_create(email=value) 17 | return ret 18 | 19 | def render(self, value, obj=None): 20 | if value is None: 21 | return None 22 | return value.email 23 | 24 | class DonationResource(resources.ModelResource): 25 | email = fields.Field(column_name='email', attribute='person', widget=PersonEmailWidget()) 26 | 27 | class Meta: 28 | model = models.Donation 29 | fields = ('transaction_id', 'email', 'value', 'timestamp', 'recurring') 30 | import_id_fields = ('transaction_id',) 31 | report_skipped = True 32 | skup_unchanged = True 33 | 34 | class DonorboxImporter(DatasetImporter): 35 | class Meta: 36 | resource = DonationResource() 37 | 38 | def init(self): 39 | self.__finished = False 40 | self.__offset = None 41 | stripe.api_key = settings.STRIPE_KEY 42 | 43 | def next_page(self): 44 | if self.__finished: 45 | raise StopIteration() 46 | 47 | HEADERS = ( 48 | 'transaction_id', 'email', 'value', 'timestamp', 'recurring' 49 | ) 50 | ret = tablib.Dataset(headers=HEADERS) 51 | page = stripe.Charge.list(limit=10, starting_after=self.__offset) 52 | for charge in page['data']: 53 | if '@' not in charge.metadata.get('donorbox_email', ''): 54 | continue 55 | chargeID = charge.id 56 | isRecurring = charge.metadata['donorbox_recurring_donation'] 57 | email = charge.metadata['donorbox_email'] 58 | ret.append((chargeID, email, charge.amount, 59 | datetime.utcfromtimestamp(charge.created), 60 | isRecurring)) 61 | self.__offset = chargeID 62 | if not page['has_more']: 63 | self.__finished = True 64 | return ret 65 | 66 | importers = { 67 | 'donorbox-donations': DonorboxImporter, 68 | } 69 | -------------------------------------------------------------------------------- /organizer/importing.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from importlib import import_module 3 | from import_export import widgets 4 | from geocodable.models import LocationAlias 5 | from django import forms 6 | 7 | class DatasetImporter(object): 8 | """Importing backend. Implement a subclass of this to add more importing 9 | backends. Must also include a Meta subclass that defines a ``resource`` 10 | attribute, the value of which is an instantiated importing resource 11 | (PersonResource, EventResource, etc). Internally, Organizer uses 12 | django-import-export to manage creating/updating records generated by an 13 | importer backend.""" 14 | def __init__(self, configuration): 15 | self.configuration = configuration 16 | 17 | def next(self): 18 | return self.next_page() 19 | 20 | def next_page(self): 21 | """The meat of your importer. Must return a ``tablib.Dataset`` for each 22 | page, and raise a StopIteration exception when out of pages.""" 23 | raise NotImplementedError() 24 | 25 | def __iter__(self): 26 | self.init() 27 | return self 28 | 29 | def init(self): 30 | """Utility method to perform initial setup of the import""" 31 | pass 32 | 33 | def options_form(self, *args, **kwargs): 34 | return forms.Form(*args, **kwargs) 35 | 36 | importerCache = None 37 | 38 | def get_importer_classes(): 39 | """Returns all discovered importers""" 40 | global importerCache 41 | if importerCache is None: 42 | importerCache = collect_importers() 43 | return importerCache 44 | 45 | def get_importer_class(name): 46 | return get_importer_classes().get(name) 47 | 48 | def collect_importers(): 49 | """Searches installed apps for a module named 'importers'""" 50 | ret = {} 51 | for app in settings.INSTALLED_APPS: 52 | try: 53 | imported = import_module('.'.join((app, 'importing'))) 54 | except ImportError, e: 55 | continue 56 | if hasattr(imported, 'importers'): 57 | ret.update(imported.importers) 58 | return ret 59 | 60 | class LocationAliasWidget(widgets.Widget): 61 | def clean(self, value, row, *args, **kwargs): 62 | if value is None: 63 | return None 64 | return LocationAlias.objects.fromRaw(value) 65 | 66 | def render(self, value, obj=None): 67 | if value is None: 68 | return None 69 | return value.raw 70 | -------------------------------------------------------------------------------- /assets/js/components/chrome/OrganizerBottomNav.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import BottomNavigation from '@material-ui/core/BottomNavigation' 3 | import BottomNavigationAction from '@material-ui/core/BottomNavigationAction' 4 | import Icon from '@material-ui/core/Icon' 5 | import { push } from 'connected-react-router' 6 | import { connect } from 'react-redux' 7 | import { bindActionCreators } from 'redux' 8 | import { getCurrentUser, getLoggedIn } from '../../selectors/auth' 9 | import PropTypes from 'prop-types' 10 | import { withProvider } from '../../store' 11 | 12 | import { library as faLibrary } from '@fortawesome/fontawesome' 13 | import faUsers from '@fortawesome/fontawesome-free-solid/faUsers' 14 | import faUserCircle from '@fortawesome/fontawesome-free-solid/faUserCircle' 15 | import faGlobe from '@fortawesome/fontawesome-free-solid/faGlobe' 16 | import faBullhorn from '@fortawesome/fontawesome-free-solid/faBullhorn' 17 | 18 | faLibrary.add(faUsers, faGlobe, faUserCircle, faBullhorn) 19 | 20 | const mapStateToProps = state => { 21 | return { 22 | path: state.getIn(['router', 'location', 'pathname']), 23 | currentUser: getCurrentUser(state), 24 | loggedIn: getLoggedIn(state), 25 | } 26 | } 27 | 28 | const mapDispatchToProps = dispatch => { 29 | return bindActionCreators({push}, dispatch) 30 | } 31 | 32 | const CaptainButtons = () => [ 33 | } label="People" />, 34 | } label="Broadcasts" /> 35 | ] 36 | 37 | export const OrganizerBottomNav = props => props.loggedIn ? ( 38 | props.push(value)} > 43 | } label="Me" /> 44 | } label="Map" /> 45 | {props.currentUser.is_staff ? CaptainButtons() : null } 46 | 47 | ) : null 48 | 49 | OrganizerBottomNav.propTypes = { 50 | currentUser: PropTypes.object, 51 | loggedIn: PropTypes.bool 52 | } 53 | 54 | OrganizerBottomNav.defaultProps = { 55 | currentUser: {}, 56 | loggedIn: false 57 | } 58 | 59 | export default withProvider(connect(mapStateToProps, mapDispatchToProps)(OrganizerBottomNav)) 60 | -------------------------------------------------------------------------------- /assets/js/components/MapIndex.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import L from 'leaflet' 3 | import { connect } from 'react-redux' 4 | import { Model, withModelData } from '../store' 5 | import HeatmapLayer from 'react-leaflet-heatmap-layer' 6 | import LocalMap from './mapping/LocalMap' 7 | import gravatar from 'gravatar' 8 | import importedComponent from 'react-imported-component' 9 | import { getCoord } from '@turf/invariant' 10 | import { getCurrentUser } from '../selectors/auth' 11 | 12 | import 'react-leaflet-markercluster/dist/styles.min.css' 13 | import './MapIndex.scss' 14 | 15 | const MarkerClusterGroup = importedComponent(() => import('react-leaflet-markercluster/src/react-leaflet-markercluster')) 16 | 17 | const People = new Model('people') 18 | 19 | export const MapIndex = props => { 20 | const markers = props.allPeople.map(person => { 21 | const position = getCoord(person.geo) 22 | return { 23 | position: position, 24 | popup: person.name, 25 | tooltip: person.name, 26 | options: { 27 | icon: L.icon({ 28 | iconUrl: gravatar.url(person.email, {s: 32, d: 'retro'}), 29 | iconAnchor: [16, 16] 30 | }) 31 | } 32 | } 33 | }).toArray() 34 | 35 | return ( 36 |
37 | 38 | p.position[0]} 40 | longitudeExtractor={(p) => p.position[1]} 41 | intensityExtractor={(_p) => 50} 42 | points={markers} /> 43 | 44 | 45 |
46 | ) 47 | } 48 | 49 | const mapStateToProps = (state) => { 50 | const currentUser = getCurrentUser(state) 51 | const allPeople = People.immutableSelect(state) 52 | .filter(person=> !!person.geo) 53 | .toList() 54 | return { 55 | allPeople, 56 | currentUser 57 | } 58 | } 59 | 60 | const mapDispatchToProps = (dispatch) => { 61 | return { 62 | people: People.bindActionCreators(dispatch) 63 | } 64 | } 65 | 66 | MapIndex.defaultProps = { 67 | allPeople: [], 68 | currentUser: {} 69 | } 70 | 71 | const mapModelToFetch = _props => { 72 | return { 73 | people: {} 74 | } 75 | } 76 | 77 | export default connect(mapStateToProps, mapDispatchToProps)(withModelData(mapModelToFetch)(MapIndex)) 78 | -------------------------------------------------------------------------------- /sync/management/commands/import.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | from django.conf import settings 3 | from importlib import import_module 4 | from tqdm import tqdm 5 | import sys 6 | from organizer.importing import get_importer_class, collect_importers 7 | from sync import models 8 | import logging 9 | from django.utils import timezone 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | class Command(BaseCommand): 14 | def add_arguments(self, parser): 15 | parser.add_argument('target', nargs='*') 16 | parser.add_argument('--debug', default=False, action='store_true') 17 | parser.add_argument('--dry-run', default=False, action='store_true') 18 | 19 | def handle(self, *args, **options): 20 | if options['debug']: 21 | logging.basicConfig(level=logging.DEBUG) 22 | 23 | 24 | dryRun = options['dry_run'] 25 | 26 | errors = [] 27 | totals = {} 28 | importers = [] 29 | if len(options['target']) == 0: 30 | for target in models.ImportSource.objects.filter(enabled=True): 31 | importers.append((target, target.make_importer())) 32 | else: 33 | for targetName in options['target']: 34 | target = models.ImportSource.objects.get(name=targetName) 35 | importers.append((target, target.make_importer())) 36 | with tqdm(importers, desc='sources', unit = ' source') as impIt: 37 | for (target, importer) in impIt: 38 | resource = importer.Meta.resource 39 | with tqdm(importer, desc=target.name, unit=' page') as it: 40 | sourceTotals = {} 41 | for dataPage in it: 42 | log.debug('Importing %s rows...', len(dataPage)) 43 | result = resource.import_data(dataPage, dry_run=dryRun, 44 | raise_errors=True) 45 | for (k, v) in result.totals.iteritems(): 46 | sourceTotals[k] = sourceTotals.get(k, 0) + v 47 | totals[k] = totals.get(k, 0) + v 48 | it.set_postfix(sourceTotals) 49 | impIt.set_postfix(totals) 50 | for err in result.row_errors(): 51 | errors.append(err) 52 | target.lastRun = timezone.now() 53 | target.save() 54 | for (row, error) in errors: 55 | log.error("Row %s: %s", row, error[0].error) 56 | -------------------------------------------------------------------------------- /assets/js/lib/filter-ast.js: -------------------------------------------------------------------------------- 1 | export const matchOrContains = (needle, haystack) => { 2 | if (typeof(haystack) == 'string') { 3 | return haystack.indexOf(needle) != -1 4 | } else if (haystack instanceof Array) { 5 | return haystack.find(stack => matchOrContains(needle, stack)) != undefined 6 | } else { 7 | return false 8 | } 9 | } 10 | 11 | export const isEqual = (needle, haystack) => { 12 | if (haystack instanceof Array) { 13 | return haystack.indexOf(needle) != -1 14 | } else if (typeof(haystack) == 'number') { 15 | return Number.parseFloat(needle) == haystack 16 | } else { 17 | return needle == haystack 18 | } 19 | } 20 | 21 | function floatCmp(func, value) { 22 | const asFloat = Number.parseFloat(value) 23 | return (row) => func(row, asFloat) 24 | } 25 | 26 | export const makeComparator = (obj) => { 27 | const {children, property, op, value} = obj || {} 28 | if (children == undefined) { 29 | if (property == undefined || op == undefined || value == undefined) { 30 | return () => true 31 | } 32 | switch (op) { 33 | case 'contains': 34 | return (row) => matchOrContains(value, row[property]) 35 | case 'is': 36 | return (row) => isEqual(value, row[property]) 37 | case 'gt': 38 | return floatCmp((row, asFloat) => row[property] > asFloat, value) 39 | case 'gte': 40 | return floatCmp((row, asFloat) => row[property] >= asFloat, value) 41 | case 'lt': 42 | return floatCmp((row, asFloat) => row[property] < asFloat, value) 43 | case 'lte': 44 | return floatCmp((row, asFloat) => row[property] <= asFloat, value) 45 | default: 46 | throw Error('Unknown operator ' + op) 47 | } 48 | } else { 49 | const comparators = children.map(makeComparator) 50 | if (op == 'and') { 51 | // Run through each comparator, returning the first one that is 52 | // false; if one such comparator exists (find doesn't return 53 | // undefined), fail the match 54 | return obj => (comparators.find(comparator => !comparator(obj)) == undefined) 55 | } else if (op == 'or') { 56 | // Run through each comparator, returning the first one that is 57 | // true; if no such comparator exists (find returns undefined), fail 58 | // the match 59 | return obj => (comparators.find(comparator => comparator(obj)) != undefined) 60 | } 61 | } 62 | } 63 | 64 | export const matchPattern = (obj, pattern) => { 65 | return makeComparator(pattern)(obj) 66 | } 67 | -------------------------------------------------------------------------------- /onboarding/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.contrib import admin 5 | from . import models 6 | from crm.models import Person 7 | from events.models import Event 8 | from organizer.admin import admin_site, OrganizerModelAdmin 9 | from import_export.admin import ImportExportModelAdmin 10 | from import_export import resources 11 | 12 | def signup_approver(modeladmin, request, queryset): 13 | for signup in queryset: 14 | person, _ = Person.objects.update_or_create(email=signup.email) 15 | if signup.event is not None: 16 | event = signup.event 17 | event.attendees.add(person) 18 | event.save() 19 | signup.approved = True 20 | signup.save() 21 | signup_approver.short_description = "Approve selected signups" 22 | 23 | class EventSignupResource(resources.ModelResource): 24 | class Meta: 25 | model = models.Signup 26 | fields = ('email', 'address', 'phone', 'event') 27 | import_id_fields = ('email', 'event') 28 | 29 | class SignupAdmin(ImportExportModelAdmin, OrganizerModelAdmin): 30 | resource_class = EventSignupResource 31 | 32 | actions = [ 33 | signup_approver 34 | ] 35 | 36 | list_display = [ 37 | 'email', 'created', 'event', 'approved' 38 | ] 39 | search_fields = [ 40 | 'email', 'event__name' 41 | ] 42 | list_filter = ('approved', ('event', admin.RelatedOnlyFieldListFilter)) 43 | raw_id_fields = ('event',) 44 | 45 | class StatusAdmin(admin.ModelAdmin): 46 | list_display = [ 47 | 'person', 'component', 'created', 'success' 48 | ] 49 | 50 | list_filter = ('component', 'success') 51 | readonly_fields = ('message','created') 52 | 53 | def disable_components(modeladmin, request, queryset): 54 | queryset.update(enabled=False) 55 | disable_components.short_description = "Disable selected components" 56 | 57 | def enable_components(modeladmin, request, queryset): 58 | queryset.update(enabled=False) 59 | enable_components.short_description = "Enable selected components" 60 | 61 | class ComponentAdmin(admin.ModelAdmin): 62 | list_display = [ 63 | 'name', 'handler', 'filter', 'enabled' 64 | ] 65 | 66 | actions = [ 67 | disable_components, 68 | enable_components 69 | ] 70 | 71 | admin.site.register(models.Signup, SignupAdmin) 72 | admin.site.register(models.OnboardingStatus, StatusAdmin) 73 | admin_site.register(models.OnboardingStatus, StatusAdmin) 74 | admin.site.register(models.OnboardingComponent, ComponentAdmin) 75 | admin_site.register(models.OnboardingComponent, ComponentAdmin) 76 | 77 | admin_site.register(models.Signup, SignupAdmin) 78 | -------------------------------------------------------------------------------- /geocodable/api.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.cache import caches 3 | from geopy.geocoders import GoogleV3 4 | from geopy.location import Location 5 | from geopy.exc import GeocoderQueryError 6 | import importlib 7 | import logging 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | class GeocodeAdaptor(object): 12 | def resolve(self, address): 13 | return self.geolocator.geocode(address, exactly_one=True) 14 | 15 | class DummyAdaptor(GeocodeAdaptor): 16 | def __init__(self): 17 | self.responses = {} 18 | 19 | def reset(self): 20 | logger.info("Resetting adaptor") 21 | self.responses = {} 22 | 23 | def set_response(self, query, response): 24 | logger.info("Setting %r to %r", query, response) 25 | self.responses[query] = response 26 | 27 | def resolve(self, address): 28 | if address not in self.responses: 29 | raise GeocoderQueryError 30 | else: 31 | return self.responses[address] 32 | 33 | class GoogleAdaptor(GeocodeAdaptor): 34 | def __init__(self): 35 | self.geolocator = GoogleV3(settings.GOOGLE_MAPS_KEY) 36 | 37 | def decode_response(response): 38 | values = {} 39 | for component in response.raw.get('address_components', []): 40 | values[component['types'][0]] = component['long_name'] 41 | return { 42 | 'raw': response.address, 43 | 'street_number': values.get('street_number', ''), 44 | 'route': values.get('route', ''), 45 | 'locality': values.get('locality', ''), 46 | 'postal_code': values.get('postal_code', ''), 47 | 'state': values.get('administrative_area_level_1', ''), 48 | 'country': values.get('country', ''), 49 | 'neighborhood': values.get('neighborhood', None), 50 | 'lat': response.latitude, 51 | 'lng': response.longitude, 52 | } 53 | 54 | def get_adaptor(): 55 | module, cls = settings.GEOCODE_ADAPTOR.rsplit('.', 1) 56 | Adaptor = getattr(importlib.import_module(module), cls) 57 | return Adaptor() 58 | 59 | def geocode(address): 60 | adaptor = get_adaptor() 61 | geocache = caches['default'] 62 | cachedAddr = geocache.get('geocache:' + address) 63 | if cachedAddr is None: 64 | logger.info("Cache miss for %s", address) 65 | try: 66 | cachedAddr = decode_response(adaptor.resolve(address)) 67 | except GeocoderQueryError: 68 | pass 69 | # FIXME: Log geocoder failure 70 | #logger.info("Geocode failure") 71 | #return None 72 | geocache.set('geocache:' + address, cachedAddr) 73 | logger.info('Geocoded %r to %r', address, cachedAddr) 74 | return cachedAddr 75 | -------------------------------------------------------------------------------- /onboarding/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models 5 | import importlib 6 | import json 7 | import django_rq 8 | import logging 9 | 10 | log = logging.getLogger(__name__) 11 | 12 | from crm.models import Person 13 | from filtering.models import FilterNode 14 | from events.models import Event 15 | 16 | class Signup(models.Model): 17 | email = models.CharField(max_length=200) 18 | address = models.CharField(max_length=200, blank=True, null=True) 19 | phone = models.CharField(max_length=200, blank=True, null=True) 20 | created = models.DateField(auto_now_add=True) 21 | approved = models.BooleanField(default=False) 22 | event = models.ForeignKey(Event, null=True, blank=True, related_name='signups') 23 | 24 | def __unicode__(self): 25 | return '%s: %s'%(self.email, self.event) 26 | 27 | class ComponentManager(models.Manager): 28 | def get_queryset(self): 29 | return super(ComponentManager, self).get_queryset().filter(enabled=True) 30 | 31 | class OnboardingComponent(models.Model): 32 | name = models.CharField(max_length=100) 33 | enabled = models.BooleanField() 34 | handler = models.CharField(max_length=200) 35 | configuration = models.TextField(default='', blank=True) 36 | filter = models.ForeignKey(FilterNode) 37 | 38 | def personHasBeenOnboarded(self, person): 39 | return self.statuses.filter(person=person, success=True).exists() 40 | 41 | def getComponentClass(self): 42 | module, cls = self.handler.rsplit('.', 1) 43 | return getattr(importlib.import_module(module), cls) 44 | 45 | def onboardPerson(self, person): 46 | Component = self.getComponentClass() 47 | config = {} 48 | if len(self.configuration) > 0: 49 | config = json.loads(self.configuration) 50 | instance = Component() 51 | return instance.handle(config, person) 52 | 53 | def __unicode__(self): 54 | return "{0} ({1})".format(self.name, self.handler) 55 | 56 | class StatusManager(models.Manager): 57 | def successful(self): 58 | return super(StatusManager, self).get_queryset().order_by('created').filter(success=True) 59 | 60 | class OnboardingStatus(models.Model): 61 | person = models.ForeignKey(Person, related_name='onboarding_statuses') 62 | created = models.DateField(auto_now_add=True) 63 | component = models.ForeignKey(OnboardingComponent, related_name='statuses') 64 | success = models.BooleanField() 65 | message = models.TextField() 66 | 67 | objects = StatusManager() 68 | 69 | def __unicode__(self): 70 | return "{0}: {1} - {2}".format(self.person, self.component, 71 | self.success) 72 | -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | Organizer is split into two parts. The backend is written for Python 2.x, using 5 | Django. The frontend is written for node 9.x using React, Redux, webpack, and 6 | friends; webpack-dev-server runs in development mode, and compiles to static 7 | files in production. 8 | 9 | Running Organizer locally is similar to most django and npm projects. Organizer 10 | suggests using ``pipenv`` to manage your virtualenv and dependencies. 11 | 12 | Install python dependencies: 13 | 14 | $ pipenv install 15 | 16 | Install nodejs dependencies: 17 | 18 | $ npm install 19 | 20 | Initialize the database: 21 | 22 | $ ./manage.py migrate 23 | 24 | Run the backend server, frontend server, and redis-server all at once: 25 | 26 | $ pipenv run npm start 27 | 28 | This performs the following: 29 | 30 | * Starts ``redis-server`` 31 | * Runs ``webpack-dev-server`` on port 8080. You should never have to point your 32 | browser at this directly. 33 | * Runs ``./manage.py runserver``, the django development server, on port 8000. 34 | This is where you send your browser. 35 | 36 | After starting the application, send your browser to the django server on 37 | http://localhost:8000/ 38 | 39 | Provided that you set both the ``DEBUG`` and 40 | ``USE_REALLY_INSECURE_DEVELOPMENT_AUTHENTICATION_BACKEND`` environment 41 | variables, organizer has an incredibly insecure authentication backend for 42 | development. It is an error to use LocalDevAuth without these variables set. 43 | 44 | Useful environment variables 45 | 46 | * ``DEBUG`` - You probably want this set. Any value will do, eg ``DEBUG=1``. 47 | * ``USE_REALLY_INSECURE_DEVELOPMENT_AUTHENTICATION_BACKEND`` - See above 48 | * ``DUMMY_GEOCODE_LAT``/``DUMMY_GEOCODE_LNG`` - Set these to influence the 49 | geolocation points generated by the Dummy geocode adaptor. Default is 50 | ``(0,0)`` 51 | * ``GEOCODE_ADAPTOR`` - Set this to a class path to use a specific geocode 52 | adaptor. The default is ``crm.geocache.DummyAdaptor`` with ``DEBUG`` set, 53 | ``crm.geocache.GoogleAdaptor`` otherwise. 54 | 55 | Useful commands 56 | --------------- 57 | 58 | * fake_data - Generates fake data. Default geolocation coordinates for created 59 | objects are within an arbitrary rectangle around Oakland, CA. Will create 60 | numerous fake people, events, and neighborhoods in one fake city with each 61 | run. Requires that you configure DEBUG to be set for it to run. 62 | 63 | REST API 64 | -------- 65 | 66 | Organizer provides a full REST API via /api/. Logged in administrators will be 67 | able to view the self-generated documentation. 68 | 69 | Contributions 70 | ------------- 71 | Pull requests welcome. All contributions retain their copyright, but must be 72 | licensed under the Affero General Public License v3. 73 | -------------------------------------------------------------------------------- /filtering/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.16 on 2018-11-07 23:52 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import enumfields.fields 8 | import filtering.models 9 | import mptt.fields 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | ('contenttypes', '0002_remove_content_type_name'), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name='Annotation', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('prop_name', models.CharField(max_length=200)), 26 | ('operator', enumfields.fields.EnumField(enum=filtering.models.AnnotationOperator, max_length=200)), 27 | ('field_name', models.CharField(max_length=200)), 28 | ], 29 | ), 30 | migrations.CreateModel( 31 | name='FilterNode', 32 | fields=[ 33 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 34 | ('name', models.CharField(blank=True, max_length=200, null=True)), 35 | ('prop_name', models.CharField(blank=True, max_length=200, null=True)), 36 | ('operator', enumfields.fields.EnumField(blank=True, enum=filtering.models.FilterOperator, max_length=200, null=True)), 37 | ('value', models.CharField(blank=True, max_length=200, null=True)), 38 | ('lft', models.PositiveIntegerField(db_index=True, editable=False)), 39 | ('rght', models.PositiveIntegerField(db_index=True, editable=False)), 40 | ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), 41 | ('level', models.PositiveIntegerField(db_index=True, editable=False)), 42 | ('annotations', models.ManyToManyField(blank=True, related_name='attachment', to='filtering.Annotation')), 43 | ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), 44 | ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='filtering.FilterNode')), 45 | ], 46 | options={ 47 | 'abstract': False, 48 | }, 49 | ), 50 | migrations.AddField( 51 | model_name='annotation', 52 | name='filter', 53 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='filtering.FilterNode'), 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /assets/js/actions/geocache.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { getLocationStatus } from '../selectors/geocache' 4 | 5 | export const UPDATE_CURRENT_LOCATION = 'UPDATE_CURRENT_LOCATION' 6 | export const SET_LOCATION_STATUS = 'SET_LOCATION_STATUS' 7 | 8 | export const STATUS_NULL = undefined 9 | export const STATUS_PERMITTED = 0 10 | export const STATUS_DENIED = 1 11 | export const STATUS_UNAVAILABLE = 2 12 | export const STATUS_TIMEOUT = 3 13 | 14 | export const updateCurrentLocationFromBrowserPosition = pos => { 15 | return updateCurrentLocation([pos.coords.latitude, pos.coords.longitude], pos.coords.accuracy) 16 | } 17 | 18 | export const setLocationStatus = status => { 19 | return { 20 | type: SET_LOCATION_STATUS, 21 | status: status 22 | } 23 | } 24 | 25 | export const updateCurrentLocation = (coords, accuracy=0) => { 26 | return dispatch => { 27 | dispatch({ 28 | type: UPDATE_CURRENT_LOCATION, 29 | accuracy: accuracy, 30 | geo: coords 31 | }) 32 | } 33 | } 34 | 35 | export const withCurrentLocation = WrappedComponent => { 36 | const mapStateToProps = state => { 37 | return { 38 | locationStatus: getLocationStatus(state) 39 | } 40 | } 41 | return connect(mapStateToProps)(class Locator extends React.PureComponent { 42 | constructor(props) { 43 | super(props) 44 | this.watchID = -1 45 | } 46 | 47 | componentDidMount() { 48 | if (this.props.locationStatus == STATUS_PERMITTED || this.props.locationStatus == undefined) { 49 | this.start() 50 | } 51 | } 52 | 53 | componentDidUpdate(oldProps) { 54 | if (this.props.locationStatus == STATUS_PERMITTED && this.props.locationStatus != oldProps.locationStatus) { 55 | this.start() 56 | } 57 | } 58 | 59 | start() { 60 | if (this.watchID == -1) { 61 | this.watchID = navigator.geolocation.watchPosition(this.updatePosition.bind(this), this.positionError.bind(this), {enableHighAccuracy: true}) 62 | } 63 | } 64 | 65 | componentWillUnmount() { 66 | this.stop() 67 | } 68 | 69 | stop() { 70 | navigator.geolocation.clearWatch(this.watchID) 71 | this.watchID = -1 72 | } 73 | 74 | updatePosition(position) { 75 | this.props.dispatch(updateCurrentLocationFromBrowserPosition(position)) 76 | this.props.dispatch(setLocationStatus(STATUS_PERMITTED)) 77 | } 78 | 79 | positionError(err) { 80 | this.props.dispatch(setLocationStatus(err.code)) 81 | } 82 | 83 | render() { 84 | return 85 | } 86 | }) 87 | } 88 | --------------------------------------------------------------------------------