52 |
53 |
54 |
{% endraw %}{{ cookiecutter.project_name }}{% raw %}
55 |
Part of the Councilmatic family.
56 |
57 |
58 |
59 |
60 |
61 |
62 | © {% now "Y" %} {% endraw %}{{ cookiecutter.project_name }}{% raw %}. All rights reserved.
63 |
64 |
70 |
71 |
72 |
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 |
--------------------------------------------------------------------------------