├── .gitignore ├── {{ cookiecutter.project_slug }} ├── {{ cookiecutter.jurisdiction_slug }}_app │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── admin.py │ ├── static │ │ ├── favicon.png │ │ ├── scss │ │ │ └── main.scss │ │ └── js │ │ │ └── main.js │ ├── templates │ │ ├── 500.html │ │ ├── 404.html │ │ ├── councilmatic_cms │ │ │ └── static_page.html │ │ ├── home_page.html │ │ └── base.html │ ├── apps.py │ ├── wsgi.py │ ├── search_indexes.py │ ├── views.py │ ├── urls.py │ ├── models.py │ └── settings.py ├── docker-entrypoint.sh ├── .gitignore ├── manage.py ├── requirements.txt ├── Dockerfile ├── package.json ├── .env.example ├── LICENSE ├── docker-compose.yml ├── webpack.config.js └── README.md ├── Dockerfile ├── cookiecutter.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ "$DJANGO_MANAGEPY_MIGRATE" = 'on' ]; then 5 | python manage.py migrate --noinput 6 | fi 7 | 8 | exec "$@" 9 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | # import your models 3 | 4 | # Register your models here. 5 | # admin.site.register(YourModel) -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/datamade/councilmatic-starter-template/HEAD/{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/static/favicon.png -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/templates/500.html: -------------------------------------------------------------------------------- 1 | {% raw %}{% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Server Error

5 |

Whoops! We couldn't process that request.

6 | {% endblock %}{% endraw %} -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/templates/404.html: -------------------------------------------------------------------------------- 1 | {% raw %}{% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Page Not Found

5 |

This is not the content you were looking for.

6 | {% endblock %}{% endraw %} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:slim 2 | 3 | RUN apt-get update && \ 4 | apt-get upgrade -y && \ 5 | apt-get install -y git 6 | 7 | RUN ["pip", "install", "--no-cache-dir", "setuptools", "cookiecutter"] 8 | 9 | RUN mkdir /cookiecutter 10 | WORKDIR /cookiecutter 11 | ENTRYPOINT ["cookiecutter"] 12 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | .DS_Store 3 | 4 | # Settings 5 | .env 6 | settings_deployment.py 7 | 8 | # Collected Static Files 9 | static/** 10 | **/static/images/ocd-person/*.jpg 11 | 12 | __pycache__ 13 | downloads 14 | webpack-stats.json 15 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class {{ cookiecutter.jurisdiction_camel_case }}CouncilmaticConfig(AppConfig): 5 | name = "{{ cookiecutter.jurisdiction_slug }}_app" 6 | verbose_name = "{{ cookiecutter.project_name }}" 7 | -------------------------------------------------------------------------------- /cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "Your City Councilmatic", 3 | "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '_') }}", 4 | "jurisdiction_name": "Your City", 5 | "jurisdiction_slug": "{{ cookiecutter.jurisdiction_name.lower().replace(' ', '_') }}", 6 | "jurisdiction_camel_case": "{{ cookiecutter.jurisdiction_name.title().replace(' ', '') }}" 7 | } 8 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault( 7 | "DJANGO_SETTINGS_MODULE", "{{ cookiecutter.jurisdiction_slug }}_app.settings" 8 | ) 9 | 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/templates/councilmatic_cms/static_page.html: -------------------------------------------------------------------------------- 1 | {% raw %}{% extends "councilmatic_cms/home_page.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 | {{ block.super }} 8 |
9 |
10 | {% endblock %}{% endraw %} 11 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for councilmatic 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.8/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "councilmatic.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/requirements.txt: -------------------------------------------------------------------------------- 1 | # Core Django and database 2 | Django<5 3 | psycopg2-binary>=2.9.5 4 | dj-database-url 5 | 6 | # Councilmatic core 7 | django-councilmatic[all] @ https://github.com/datamade/django-councilmatic/archive/refs/heads/5.x.zip 8 | pupa 9 | 10 | # Static files and frontend 11 | django-webpack-loader>=2.0.0 12 | whitenoise>=6.6.0 13 | 14 | # Development and debugging 15 | django-debug-toolbar>=4.2.0 16 | 17 | # Time zones 18 | pytz>=2023.3 19 | 20 | # Environment management 21 | python-dotenv>=1.0.0 22 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/search_indexes.py: -------------------------------------------------------------------------------- 1 | from councilmatic_search.search_indexes import BillIndex 2 | from haystack import indexes 3 | from .models import {{ cookiecutter.jurisdiction_camel_case }}Bill 4 | from django.conf import settings 5 | import pytz 6 | 7 | app_timezone = pytz.timezone(settings.TIME_ZONE) 8 | 9 | class {{ cookiecutter.jurisdiction_camel_case }}BillIndex(BillIndex, indexes.Indexable): 10 | 11 | def get_model(self): 12 | return {{ cookiecutter.jurisdiction_camel_case }}Bill 13 | 14 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-slim AS node 2 | 3 | COPY ./package.json package.json 4 | RUN npm install 5 | 6 | FROM python:3.12 AS app 7 | LABEL maintainer "DataMade " 8 | 9 | RUN apt-get update && \ 10 | apt-get install -y --no-install-recommends --purge postgresql-client gdal-bin && \ 11 | apt-get autoclean && \ 12 | rm -rf /var/lib/apt/lists/* && \ 13 | rm -rf /tmp/* 14 | 15 | RUN mkdir /app 16 | WORKDIR /app 17 | 18 | COPY ./requirements.txt /app/requirements.txt 19 | RUN pip install -r requirements.txt 20 | 21 | # Get NodeJS & npm 22 | COPY --from=node /usr/local/bin /usr/local/bin 23 | COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules 24 | 25 | # Get app dependencies 26 | COPY --from=node node_modules /app/node_modules 27 | 28 | COPY . /app 29 | ENV DJANGO_SECRET_KEY 'foobar' 30 | RUN python manage.py collectstatic --no-input 31 | 32 | ENTRYPOINT ["/app/docker-entrypoint.sh"] 33 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "{{ cookiecutter.project_slug }}", 3 | "version": "1.0.0", 4 | "description": "Frontend dependencies for {{ cookiecutter.project_name }}", 5 | "scripts": { 6 | "build": "webpack --mode=production", 7 | "serve": "webpack serve --config /app/webpack.config.js --mode=development --hot --open" 8 | }, 9 | "devDependencies": { 10 | "@babel/core": "^7.23.0", 11 | "@babel/preset-env": "^7.23.0", 12 | "babel-loader": "^9.1.0", 13 | "css-loader": "^6.8.0", 14 | "eslint": "^8.52.0", 15 | "mini-css-extract-plugin": "^2.7.0", 16 | "prettier": "^3.0.0", 17 | "sass": "^1.69.0", 18 | "sass-loader": "^13.3.0", 19 | "webpack": "^5.89.0", 20 | "webpack-cli": "^5.1.0", 21 | "webpack-bundle-tracker": "*", 22 | "webpack-dev-server": "*", 23 | "style-loader": "*", 24 | "postcss-loader": "*" 25 | }, 26 | "dependencies": { 27 | "bootstrap": "^5.3.0", 28 | "@popperjs/core": "^2.11.8" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/templates/home_page.html: -------------------------------------------------------------------------------- 1 | {% raw %}{% extends "base.html" %} 2 | 3 | {% block title %}Home{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

Hello, world! 👋

11 |

Welcome to your new Councilmatic instance.

12 |

Your database contains:

13 |
14 |
{{ bill_count }}
Bills
15 |
{{ event_count }}
Events
16 |
{{ person_count }}
People
17 |
18 |
19 |
20 |
21 |
22 | {% endblock %}{% endraw %} 23 | 24 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/.env.example: -------------------------------------------------------------------------------- 1 | # Councilmatic settings 2 | OCD_CITY_COUNCIL_NAME="{{ cookiecutter.jurisdiction_name }}" 3 | 4 | # Django settings 5 | DJANGO_SECRET_KEY=your-very-secret-key-here 6 | DEBUG=True 7 | DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 8 | 9 | # Database settings 10 | DATABASE_URL=postgis://postgres:postgres@postgres:5432/postgres 11 | POSTGRES_REQUIRE_SSL=False 12 | 13 | # Search settings 14 | SEARCH_URL=http://elasticsearch:9200 15 | 16 | # Static files and media (for production) 17 | # AWS_ACCESS_KEY_ID=your-aws-access-key 18 | # AWS_SECRET_ACCESS_KEY=your-aws-secret-key 19 | # AWS_STORAGE_BUCKET_NAME=your-bucket-name 20 | # AWS_S3_REGION_NAME=us-east-1 21 | 22 | # Search engine optimization 23 | ALLOW_CRAWL=False 24 | 25 | # Email settings (for notifications, if enabled) 26 | # EMAIL_HOST=smtp.gmail.com 27 | # EMAIL_PORT=587 28 | # EMAIL_HOST_USER=your-email@gmail.com 29 | # EMAIL_HOST_PASSWORD=your-app-password 30 | # EMAIL_USE_TLS=True 31 | 32 | # Analytics (optional) 33 | # GOOGLE_ANALYTICS_ID=GA-XXXXXXXX 34 | 35 | # Sentry error tracking (optional) 36 | # SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id 37 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 DataMade 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | from django.shortcuts import render 3 | from django.views.generic import TemplateView 4 | 5 | from councilmatic_core.models import Bill, Event, Person 6 | 7 | 8 | class IndexView(TemplateView): 9 | template_name = "home_page.html" 10 | 11 | def get_context_data(self, *args, **kwargs): 12 | context = super().get_context_data(*args, **kwargs) 13 | context.update({ 14 | "bill_count": Bill.objects.count(), 15 | "event_count": Event.objects.count(), 16 | "person_count": Person.objects.count(), 17 | }) 18 | return context 19 | 20 | 21 | def robots_txt(request): 22 | """Serve robots.txt file with crawling permissions based on environment.""" 23 | return render( 24 | request, 25 | "robots.txt", 26 | {"ALLOW_CRAWL": os.getenv("ALLOW_CRAWL", "False").lower() == "true"}, 27 | content_type="text/plain", 28 | ) 29 | 30 | 31 | def page_not_found(request, exception, template_name="404.html"): 32 | """Custom 404 error handler.""" 33 | return render(request, template_name, status=404) 34 | 35 | 36 | def server_error(request, template_name="500.html"): 37 | """Custom 500 error handler.""" 38 | return render(request, template_name, status=500) 39 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.urls import path, include 4 | from django.conf.urls.static import static 5 | 6 | from wagtail.admin import urls as wagtailadmin_urls 7 | from wagtail import urls as wagtail_urls 8 | from wagtail.documents import urls as wagtaildocs_urls 9 | 10 | from . import views 11 | 12 | urlpatterns = [ 13 | path("", views.IndexView.as_view(), name="index"), 14 | path("robots.txt", views.robots_txt, name="robots_txt"), 15 | path("admin/", admin.site.urls), 16 | path("", include("councilmatic_search.urls")), 17 | path("", include("councilmatic_cms.urls")), 18 | ] 19 | 20 | # Error handlers 21 | handler404 = "{{ cookiecutter.jurisdiction_slug }}_app.views.page_not_found" 22 | handler500 = "{{ cookiecutter.jurisdiction_slug }}_app.views.server_error" 23 | 24 | # Debug toolbar and static files for development 25 | if settings.DEBUG: 26 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 27 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) 28 | 29 | if "debug_toolbar" in settings.INSTALLED_APPS: 30 | import debug_toolbar 31 | 32 | urlpatterns = [ 33 | path("__debug__/", include(debug_toolbar.urls)), 34 | ] + urlpatterns 35 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from councilmatic_core.models import Bill, Event, Person, Organization 4 | from datetime import datetime 5 | import pytz 6 | 7 | app_timezone = pytz.timezone(settings.TIME_ZONE) 8 | 9 | 10 | class {{ cookiecutter.jurisdiction_camel_case }}Bill(Bill): 11 | """ 12 | Extend the base Bill model for city-specific functionality. 13 | Add any custom fields or methods specific to your city here. 14 | """ 15 | 16 | class Meta: 17 | proxy = True 18 | 19 | def __str__(self): 20 | return self.friendly_name or self.identifier 21 | 22 | 23 | class {{ cookiecutter.jurisdiction_camel_case }}Event(Event): 24 | """ 25 | Extend the base Event model for city-specific functionality. 26 | Add any custom fields or methods specific to your city here. 27 | """ 28 | 29 | class Meta: 30 | proxy = True 31 | 32 | 33 | class {{ cookiecutter.jurisdiction_camel_case }}Person(Person): 34 | """ 35 | Extend the base Person model for city-specific functionality. 36 | Add any custom fields or methods specific to your city here. 37 | """ 38 | 39 | class Meta: 40 | proxy = True 41 | 42 | 43 | class {{ cookiecutter.jurisdiction_camel_case }}Organization(Organization): 44 | """ 45 | Extend the base Organization model for city-specific functionality. 46 | Add any custom fields or methods specific to your city here. 47 | """ 48 | 49 | class Meta: 50 | proxy = True 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Councilmatic Starter Template 2 | 3 | This repo provides starter code and documentation for new Councilmatic instances, incorporating modern Django development patterns and tooling. 4 | 5 | ## About Councilmatic 6 | 7 | The [councilmatic family](https://www.councilmatic.org/) is a set of web apps for keeping tabs on city representatives and their legislative activity. 8 | 9 | Councilmatic started as a Code for America project by Mjumbe Poe, who designed the earliest version of a Councilmatic site – [Councilmatic Philadelphia](http://philly.councilmatic.org/). DataMade then implemented Councilmatic in New York City, Chicago, and Los Angeles. 10 | 11 | This template uses `django-councilmatic` – [a Django app](https://github.com/datamade/django-councilmatic) with base functionality common across all cities. 12 | 13 | ## Features 14 | 15 | - **Modern Django 4.2+** with updated patterns and security 16 | - **Docker-based development** environment 17 | - **Node.js + Webpack** for frontend asset building 18 | - **Bootstrap 5** with customizable SCSS 19 | - **PostgreSQL + PostGIS** for geospatial data 20 | - **Elasticserch** for full-text search 21 | - **Accessibility** features built-in 22 | 23 | ## Getting Started 24 | 25 | ### Prerequisites 26 | 27 | - [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) 28 | 29 | ### 1. Create Your Instance 30 | 31 | ```bash 32 | # Build cookiecutter container 33 | docker build github.com/datamade/councilmatic-starter-template#master -t cookiecutter:latest 34 | 35 | # Generate a new project 36 | docker run -it \ 37 | --mount type=bind,source=$(pwd),target=/cookiecutter \ 38 | cookiecutter gh:datamade/councilmatic-starter-template 39 | ``` 40 | 41 | ### 2. Get Customizing! 42 | 43 | Consult the README in your new project directory for next steps. 44 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/docker-compose.yml: -------------------------------------------------------------------------------- 1 | platform: linux/amd64 2 | 3 | services: 4 | webpack: 5 | container_name: {{ cookiecutter.jurisdiction_slug }}_webpack 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | target: node 10 | stdin_open: true 11 | ports: 12 | - 3000:3000 13 | volumes: 14 | - .:/app 15 | - {{ cookiecutter.jurisdiction_slug }}_node_modules:/app/node_modules 16 | command: npm run serve 17 | 18 | app: 19 | build: . 20 | container_name: {{ cookiecutter.jurisdiction_slug }}_councilmatic 21 | depends_on: 22 | postgres: 23 | condition: service_healthy 24 | elasticsearch: 25 | condition: service_started 26 | environment: 27 | DJANGO_MANAGEPY_MIGRATE: "on" 28 | volumes: 29 | - .:/app 30 | ports: 31 | - 8000:8000 32 | entrypoint: /app/docker-entrypoint.sh 33 | command: python manage.py runserver 0.0.0.0:8000 34 | 35 | postgres: 36 | container_name: {{ cookiecutter.jurisdiction_slug }}_postgres 37 | image: postgis/postgis:14-3.2 38 | platform: linux/amd64 39 | healthcheck: 40 | test: ["CMD-SHELL", "pg_isready -U postgres"] 41 | interval: 10s 42 | timeout: 5s 43 | retries: 5 44 | environment: 45 | POSTGRES_DB: postgres 46 | POSTGRES_PASSWORD: postgres 47 | volumes: 48 | - {{ cookiecutter.jurisdiction_slug }}_postgres_data:/var/lib/postgresql/data 49 | ports: 50 | - 32006:5432 51 | 52 | elasticsearch: 53 | image: elasticsearch:7.14.2 54 | container_name: {{ cookiecutter.jurisdiction_slug }}_elasticsearch 55 | ports: 56 | - 9200:9200 57 | environment: 58 | - discovery.type=single-node 59 | - logger.org.elasticsearch.discovery=DEBUG 60 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 61 | mem_limit: 1g 62 | volumes: 63 | - {{ cookiecutter.jurisdiction_slug }}_es_data:/usr/share/elasticsearch/data 64 | 65 | volumes: 66 | {{ cookiecutter.jurisdiction_slug }}_node_modules: 67 | {{ cookiecutter.jurisdiction_slug }}_postgres_data: 68 | {{ cookiecutter.jurisdiction_slug }}_es_data: 69 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const webpack = require("webpack") // eslint-disable-line no-unused-vars 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin") 4 | const BundleTracker = require("webpack-bundle-tracker") 5 | 6 | const config = { 7 | context: __dirname, 8 | entry: { 9 | main: "./{{ cookiecutter.jurisdiction_slug }}_app/static/js/main.js", 10 | styles: "./{{ cookiecutter.jurisdiction_slug }}_app/static/scss/main.scss" 11 | }, 12 | output: { 13 | path: path.resolve(__dirname, "assets/bundles/"), 14 | filename: "[name]-[hash].js", 15 | chunkFilename: "[name]-[hash].js", 16 | }, 17 | plugins: [ 18 | new MiniCssExtractPlugin({ 19 | filename: "[name].bundle.css" 20 | }), 21 | new BundleTracker({ 22 | path: __dirname, 23 | filename: "webpack-stats.json", 24 | }), 25 | ], 26 | devServer: { 27 | watchFiles: ["{{ cookiecutter.jurisdiction_slug }}_app/static/**/*.js"], 28 | host: "0.0.0.0", 29 | port: 3000, 30 | compress: false, 31 | allowedHosts: ["localhost"], 32 | }, 33 | watchOptions: { 34 | poll: 1000, 35 | }, 36 | resolve: { 37 | extensions: [".js", ".scss"], 38 | }, 39 | ignoreWarnings: [ 40 | { 41 | module: /sass-loader/, // A RegExp 42 | }, 43 | /warning from compiler/, 44 | () => true, 45 | ], 46 | module: { 47 | rules: [ 48 | { 49 | test: /\.(js)$/, 50 | exclude: /node_modules/, 51 | loader: "babel-loader", 52 | options: { 53 | presets: ["@babel/preset-env"], 54 | }, 55 | }, 56 | { 57 | test: /\.(scss)$/, 58 | use: [ 59 | MiniCssExtractPlugin.loader, 60 | 'css-loader', 61 | 'sass-loader' 62 | ], 63 | }, 64 | ], 65 | }, 66 | } 67 | 68 | module.exports = (env, argv) => { 69 | /* 70 | * /app/webpack-stats.json is the roadmap for the assorted chunks of JS 71 | * produced by Webpack. During local development, the Webpack server 72 | * serves our bundles. In production, Django should look in 73 | * /app/static/bundles for bundles. 74 | */ 75 | if (argv.mode === "development") { 76 | config.output.publicPath = "http://localhost:3000/static/bundles/" 77 | } 78 | 79 | if (argv.mode === "production") { 80 | config.output.publicPath = "/static/bundles/" 81 | } 82 | 83 | return config 84 | } 85 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/README.md: -------------------------------------------------------------------------------- 1 | # {{ cookiecutter.project_name}} 2 | 3 | ## Getting Started 4 | 5 | ### Prerequisites 6 | 7 | - [Docker](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) 8 | 9 | ### 1. Configure Your Instance 10 | 11 | ```bash 12 | cp .env.example .env 13 | ``` 14 | 15 | **`.env`** - Set your environment variables: 16 | - Generate a secure `DJANGO_SECRET_KEY` 17 | - Configure your database connection 18 | - Set up search engine URLs 19 | 20 | ### 2. Initial Setup 21 | 22 | ```bash 23 | docker compose build 24 | ``` 25 | 26 | ### 3. Create a Superuser 27 | 28 | ```bash 29 | docker compose run --rm app python manage.py createsuperuser 30 | ``` 31 | 32 | ### 4. Start Development Server 33 | 34 | ```bash 35 | docker compose up 36 | ``` 37 | 38 | Visit http://localhost:8000 to see your site! 39 | 40 | ## Data Import 41 | 42 | We recommend using `pupa` to scrape and import data into your database. Follow [its helpful documentation](https://open-civic-data.readthedocs.io/en/latest/scrape/new.html) to initialize and run your own scraper/s to populate your database. 43 | 44 | N.b., your app comes with `pupa` pre-installed! You can use your containers to run the commands specified in the `pupa` docs like this: 45 | 46 | ```bash 47 | # Initialize your scrapers 48 | docker compose run --rm app pupa init {{ cookiecutter.jurisdiction_slug }} 49 | 50 | # Run a scrape 51 | docker compose run --rm app pupa update {{ cookiecutter.jurisdiction_slug }} 52 | ``` 53 | 54 | ## Development Workflow 55 | 56 | ### Frontend Development 57 | 58 | The template uses Webpack to build frontend assets: 59 | 60 | - **SCSS files**: `{{ cookiecutter.jurisdiction_slug }}/static/scss/` 61 | - **JavaScript files**: `{{ cookiecutter.jurisdiction_slug }}/static/js/` 62 | 63 | Customize your city's branding by editing: 64 | - `{{ cookiecutter.jurisdiction_slug }}/static/scss/main.scss` - Colors, fonts, and styles 65 | - `{{ cookiecutter.jurisdiction_slug }}/static/js/main.js` - JavaScript functionality 66 | 67 | ## Customization 68 | 69 | ### Templates 70 | 71 | Councilmatic gives you the infrastructure. You create the interface! Build your site by 72 | adding templates to `{{ cookiecutter.jurisdiction_slug }}/templates/`. 73 | 74 | ### Models 75 | 76 | Extend core models in `{{ cookiecutter.jurisdiction_slug }}/models.py`: 77 | ```python 78 | class {{ cookiecutter.jurisdiction_camel_case }}Bill(Bill): 79 | # Add custom fields or methods 80 | custom_field = models.CharField(max_length=100, blank=True) 81 | 82 | class Meta: 83 | proxy = True 84 | ``` 85 | 86 | ## Getting Help 87 | 88 | - [Councilmatic documentation](https://www.councilmatic.org/) 89 | - [Django Councilmatic GitHub](https://github.com/datamade/django-councilmatic) 90 | - [Open Civic Data documentation](https://open-civic-data.readthedocs.io) 91 | 92 | ## License 93 | 94 | Copyright (c) 2025 Participatory Politics Foundation and DataMade. Released under the [MIT License](LICENSE). 95 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/templates/base.html: -------------------------------------------------------------------------------- 1 | {% raw %}{% load static %} 2 | {% load webpack_loader %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {% block title %}{% endblock %} | {% endraw %}{{ cookiecutter.project_name }}{% raw %} 11 | 12 | 13 | {% endraw %}{{ cookiecutter.project_name }}{% raw %} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {% render_bundle 'styles' 'css' %} 36 | 37 | {% block extra_css %}{% endblock %} 38 | 39 | 40 | 41 | 42 | Skip to main content 43 | 44 | 45 |
46 | {% block content %}{% endblock %} 47 |
48 | 49 | 50 | 73 | 74 | 75 | {% render_bundle 'main' 'js' %} 76 | {% block extra_js %}{% endblock %} 77 | 78 | {% endraw %} 79 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/static/scss/main.scss: -------------------------------------------------------------------------------- 1 | // Import Bootstrap functions and variables first 2 | @import "~bootstrap/scss/functions"; 3 | @import "~bootstrap/scss/variables"; 4 | 5 | // Customize Bootstrap variables here 6 | :root { 7 | --bs-primary: #0066cc; 8 | --bs-secondary: #6c757d; 9 | --bs-success: #198754; 10 | --bs-info: #0dcaf0; 11 | --bs-warning: #ffc107; 12 | --bs-danger: #dc3545; 13 | --bs-light: #f8f9fa; 14 | --bs-dark: #212529; 15 | } 16 | 17 | // Custom color scheme for your city 18 | $primary: #0066cc; // Customize this for your city's brand color 19 | $secondary: #6c757d; 20 | $success: #198754; 21 | $info: #0dcaf0; 22 | $warning: #ffc107; 23 | $danger: #dc3545; 24 | $light: #f8f9fa; 25 | $dark: #212529; 26 | 27 | // Font customizations 28 | $font-family-sans-serif: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 29 | 30 | // Import Bootstrap 31 | @import "~bootstrap/scss/bootstrap"; 32 | 33 | // Custom styles for Councilmatic 34 | body { 35 | font-family: $font-family-sans-serif; 36 | line-height: 1.6; 37 | } 38 | 39 | .navbar-brand { 40 | font-weight: 600; 41 | 42 | img { 43 | height: 40px; 44 | width: auto; 45 | } 46 | } 47 | 48 | // Skip link for accessibility 49 | .visually-hidden-focusable { 50 | position: absolute !important; 51 | width: 1px !important; 52 | height: 1px !important; 53 | padding: 0 !important; 54 | margin: -1px !important; 55 | overflow: hidden !important; 56 | clip: rect(0, 0, 0, 0) !important; 57 | white-space: nowrap !important; 58 | border: 0 !important; 59 | 60 | &:focus { 61 | position: absolute !important; 62 | top: 0; 63 | left: 0; 64 | width: auto !important; 65 | height: auto !important; 66 | padding: 0.5rem 1rem !important; 67 | margin: 0 !important; 68 | overflow: visible !important; 69 | clip: auto !important; 70 | white-space: normal !important; 71 | background-color: $primary; 72 | color: white; 73 | text-decoration: none; 74 | z-index: 1000; 75 | } 76 | } 77 | 78 | // Bill/legislation cards 79 | .bill-card { 80 | border-left: 4px solid $primary; 81 | transition: box-shadow 0.2s ease; 82 | 83 | &:hover { 84 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 85 | } 86 | } 87 | 88 | // Person cards 89 | .person-card { 90 | text-align: center; 91 | 92 | .person-image { 93 | width: 120px; 94 | height: 120px; 95 | border-radius: 50%; 96 | object-fit: cover; 97 | margin: 0 auto 1rem; 98 | } 99 | } 100 | 101 | // Event/meeting cards 102 | .event-card { 103 | border-left: 4px solid $info; 104 | 105 | .event-date { 106 | font-weight: 600; 107 | color: $primary; 108 | } 109 | } 110 | 111 | // Search results 112 | .search-results { 113 | .result-item { 114 | border-bottom: 1px solid $light; 115 | padding: 1rem 0; 116 | 117 | &:last-child { 118 | border-bottom: none; 119 | } 120 | } 121 | 122 | .result-title { 123 | font-weight: 600; 124 | margin-bottom: 0.5rem; 125 | } 126 | 127 | .result-meta { 128 | font-size: 0.9rem; 129 | color: $secondary; 130 | } 131 | } 132 | 133 | // Footer 134 | footer { 135 | margin-top: auto; 136 | 137 | a { 138 | color: rgba(255, 255, 255, 0.8); 139 | 140 | &:hover { 141 | color: white; 142 | } 143 | } 144 | } 145 | 146 | // Responsive utilities 147 | @media (max-width: 768px) { 148 | .navbar-nav { 149 | text-align: center; 150 | } 151 | 152 | .search-form { 153 | margin-top: 1rem; 154 | } 155 | } 156 | 157 | // Print styles 158 | @media print { 159 | .navbar, 160 | footer, 161 | .btn, 162 | .pagination { 163 | display: none !important; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/static/js/main.js: -------------------------------------------------------------------------------- 1 | // Import Bootstrap components 2 | import 'bootstrap'; 3 | 4 | // Main JavaScript for Your City Councilmatic 5 | 6 | document.addEventListener('DOMContentLoaded', function() { 7 | // Initialize tooltips 8 | const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); 9 | tooltipTriggerList.map(function (tooltipTriggerEl) { 10 | return new bootstrap.Tooltip(tooltipTriggerEl); 11 | }); 12 | 13 | // Initialize popovers 14 | const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')); 15 | popoverTriggerList.map(function (popoverTriggerEl) { 16 | return new bootstrap.Popover(popoverTriggerEl); 17 | }); 18 | 19 | // Auto-dismiss alerts after 5 seconds 20 | const alerts = document.querySelectorAll('.alert:not(.alert-permanent)'); 21 | alerts.forEach(function(alert) { 22 | setTimeout(function() { 23 | const bsAlert = new bootstrap.Alert(alert); 24 | bsAlert.close(); 25 | }, 5000); 26 | }); 27 | 28 | // Search form enhancements 29 | const searchForm = document.querySelector('form[role="search"]'); 30 | if (searchForm) { 31 | const searchInput = searchForm.querySelector('input[type="search"]'); 32 | 33 | // Clear search on escape key 34 | searchInput.addEventListener('keydown', function(e) { 35 | if (e.key === 'Escape') { 36 | this.value = ''; 37 | } 38 | }); 39 | } 40 | 41 | let anchorLinks = [] 42 | 43 | // Smooth scrolling for anchor links 44 | try { 45 | anchorLinks = document.querySelectorAll('a[href^="#"]'); 46 | } catch (err) { 47 | console.error(err); 48 | } finally { 49 | anchorLinks.forEach(function(link) { 50 | link.addEventListener('click', function(e) { 51 | const target = document.querySelector(this.getAttribute('href')); 52 | if (target) { 53 | e.preventDefault(); 54 | target.scrollIntoView({ 55 | behavior: 'smooth', 56 | block: 'start' 57 | }); 58 | } 59 | }); 60 | }); 61 | } 62 | 63 | // Lazy loading for images 64 | if ('IntersectionObserver' in window) { 65 | const imageObserver = new IntersectionObserver(function(entries, observer) { 66 | entries.forEach(function(entry) { 67 | if (entry.isIntersecting) { 68 | const img = entry.target; 69 | img.src = img.dataset.src; 70 | img.classList.remove('lazy'); 71 | imageObserver.unobserve(img); 72 | } 73 | }); 74 | }); 75 | 76 | const lazyImages = document.querySelectorAll('img[data-src]'); 77 | lazyImages.forEach(function(img) { 78 | imageObserver.observe(img); 79 | }); 80 | } 81 | 82 | // Print page functionality 83 | const printButtons = document.querySelectorAll('.btn-print'); 84 | printButtons.forEach(function(button) { 85 | button.addEventListener('click', function() { 86 | window.print(); 87 | }); 88 | }); 89 | 90 | // Back to top button 91 | const backToTopButton = document.querySelector('.back-to-top'); 92 | if (backToTopButton) { 93 | window.addEventListener('scroll', function() { 94 | if (window.pageYOffset > 300) { 95 | backToTopButton.style.display = 'block'; 96 | } else { 97 | backToTopButton.style.display = 'none'; 98 | } 99 | }); 100 | 101 | backToTopButton.addEventListener('click', function(e) { 102 | e.preventDefault(); 103 | window.scrollTo({ 104 | top: 0, 105 | behavior: 'smooth' 106 | }); 107 | }); 108 | } 109 | }); 110 | 111 | // Analytics tracking (customize as needed) 112 | function trackEvent(category, action, label = null) { 113 | if (typeof gtag !== 'undefined') { 114 | gtag('event', action, { 115 | event_category: category, 116 | event_label: label 117 | }); 118 | } 119 | } 120 | 121 | // Export for use in other modules 122 | window.CouncilmaticApp = { 123 | trackEvent: trackEvent 124 | }; 125 | -------------------------------------------------------------------------------- /{{ cookiecutter.project_slug }}/{{ cookiecutter.jurisdiction_slug }}_app/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import dj_database_url 3 | from pathlib import Path 4 | from dotenv import load_dotenv 5 | 6 | # Load environment variables from .env file 7 | load_dotenv() 8 | 9 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 10 | BASE_DIR = Path(__file__).resolve().parent.parent 11 | 12 | # SECURITY WARNING: keep the secret key used in production secret! 13 | SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "your-secret-key-here") 14 | 15 | # SECURITY WARNING: don't run with debug turned on in production! 16 | DEBUG = os.getenv("DEBUG", "False").lower() == "true" 17 | 18 | ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") 19 | 20 | # Application definition 21 | INSTALLED_APPS = [ 22 | "django.contrib.admin", 23 | "django.contrib.auth", 24 | "django.contrib.contenttypes", 25 | "django.contrib.sessions", 26 | "django.contrib.messages", 27 | "django.contrib.staticfiles", 28 | "django.contrib.gis", 29 | "django.contrib.humanize", 30 | "webpack_loader", 31 | # Core apps 32 | "opencivicdata.core", 33 | "opencivicdata.legislative", 34 | "councilmatic_core", 35 | "{{ cookiecutter.jurisdiction_slug }}_app", 36 | # Search layer - Remove if search not required 37 | "councilmatic_search", 38 | "haystack", 39 | # CMS layer - Remove if CMS not required 40 | "councilmatic_cms", 41 | "wagtail.contrib.forms", 42 | "wagtail.contrib.redirects", 43 | "wagtail.contrib.typed_table_block", 44 | "wagtail.embeds", 45 | "wagtail.sites", 46 | "wagtail.users", 47 | "wagtail.snippets", 48 | "wagtail.documents", 49 | "wagtail.images", 50 | "wagtail.search", 51 | "wagtail.admin", 52 | "wagtail", 53 | "modelcluster", 54 | "taggit", 55 | ] 56 | 57 | if DEBUG: 58 | INSTALLED_APPS.append("debug_toolbar") 59 | 60 | MIDDLEWARE = [ 61 | "django.middleware.security.SecurityMiddleware", 62 | "whitenoise.middleware.WhiteNoiseMiddleware", 63 | "django.contrib.sessions.middleware.SessionMiddleware", 64 | "django.middleware.cache.UpdateCacheMiddleware", 65 | "django.middleware.locale.LocaleMiddleware", 66 | "django.middleware.common.CommonMiddleware", 67 | "django.middleware.cache.FetchFromCacheMiddleware", 68 | "django.middleware.csrf.CsrfViewMiddleware", 69 | "django.contrib.auth.middleware.AuthenticationMiddleware", 70 | "django.contrib.messages.middleware.MessageMiddleware", 71 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 72 | ] 73 | 74 | if DEBUG: 75 | MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") 76 | 77 | ROOT_URLCONF = "{{ cookiecutter.jurisdiction_slug }}_app.urls" 78 | 79 | TEMPLATES = [ 80 | { 81 | "BACKEND": "django.template.backends.django.DjangoTemplates", 82 | "DIRS": [BASE_DIR / "{{ cookiecutter.jurisdiction_slug }}_app" / "templates"], 83 | "APP_DIRS": True, 84 | "OPTIONS": { 85 | "context_processors": [ 86 | "django.template.context_processors.debug", 87 | "django.template.context_processors.request", 88 | "django.contrib.auth.context_processors.auth", 89 | "django.contrib.messages.context_processors.messages", 90 | ], 91 | }, 92 | }, 93 | ] 94 | 95 | WSGI_APPLICATION = "{{ cookiecutter.jurisdiction_slug }}_app.wsgi.application" 96 | 97 | # Database 98 | DATABASES = { 99 | "default": dj_database_url.parse( 100 | os.getenv( 101 | "DATABASE_URL", 102 | "postgis://postgres:postgres@localhost:5432/{{ cookiecutter.jurisdiction_slug }}_councilmatic", 103 | ), 104 | conn_max_age=600, 105 | ssl_require=True if os.getenv("POSTGRES_REQUIRE_SSL") == "True" else False, 106 | engine="django.contrib.gis.db.backends.postgis", 107 | ) 108 | } 109 | 110 | # Caching 111 | cache_backend = "dummy.DummyCache" if DEBUG else "db.DatabaseCache" 112 | CACHES = { 113 | "default": { 114 | "BACKEND": f"django.core.cache.backends.{cache_backend}", 115 | "LOCATION": "councilmatic_cache_table" if not DEBUG else "", 116 | "TIMEOUT": 60 * 10, # 10 minutes 117 | "OPTIONS": { 118 | "MAX_ENTRIES": 1000, 119 | }, 120 | } 121 | } 122 | 123 | # Password validation 124 | AUTH_PASSWORD_VALIDATORS = [ 125 | { 126 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 127 | }, 128 | { 129 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 130 | }, 131 | { 132 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 133 | }, 134 | { 135 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 136 | }, 137 | ] 138 | 139 | # Internationalization 140 | LANGUAGE_CODE = "en-us" 141 | TIME_ZONE = "America/Chicago" # Update this for your city 142 | USE_I18N = True 143 | USE_TZ = True 144 | 145 | # Static files (CSS, JavaScript, Images) 146 | STATIC_URL = "/static/" 147 | STATIC_ROOT = BASE_DIR / "staticfiles" 148 | STATICFILES_DIRS = [ 149 | BASE_DIR / "{{ cookiecutter.jurisdiction_slug }}_app" / "static", 150 | ] 151 | 152 | # Media files 153 | MEDIA_URL = "/media/" 154 | MEDIA_ROOT = BASE_DIR / "media" 155 | 156 | # Default primary key field type 157 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 158 | 159 | # Security settings 160 | if not DEBUG: 161 | SECURE_SSL_REDIRECT = True 162 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 163 | SESSION_COOKIE_SECURE = True 164 | CSRF_COOKIE_SECURE = True 165 | SECURE_BROWSER_XSS_FILTER = True 166 | SECURE_CONTENT_TYPE_NOSNIFF = True 167 | 168 | # Debug toolbar settings 169 | if DEBUG: 170 | INTERNAL_IPS = [ 171 | "127.0.0.1", 172 | "localhost", 173 | "0.0.0.0", 174 | ] 175 | 176 | # Webpack loader settings 177 | WEBPACK_LOADER = { 178 | "DEFAULT": { 179 | "BUNDLE_DIR_NAME": "dist/", 180 | "STATS_FILE": BASE_DIR / "webpack-stats.json", 181 | } 182 | } 183 | 184 | # Haystack search settings 185 | HAYSTACK_CONNECTIONS = { 186 | "default": { 187 | "ENGINE": "haystack.backends.elasticsearch7_backend.Elasticsearch7SearchEngine", 188 | "URL": os.environ["SEARCH_URL"], 189 | "ADMIN_URL": os.environ["SEARCH_URL"], 190 | "INDEX_NAME": "{{ cookiecutter.jurisdiction_slug }}_councilmatic", 191 | "SILENTLY_FAIL": False, 192 | } 193 | } 194 | 195 | HAYSTACK_SIGNAL_PROCESSOR = "haystack.signals.RealtimeSignalProcessor" 196 | 197 | WAGTAIL_SITE_NAME = "{{ cookiecutter.project_name }}" 198 | 199 | OCD_CITY_COUNCIL_NAME = os.getenv("OCD_CITY_COUNCIL_NAME", WAGTAIL_SITE_NAME) 200 | --------------------------------------------------------------------------------