├── .gitignore ├── LICENSE ├── README.md ├── meeting_guide ├── __init__.py ├── admin.py ├── apps.py ├── fixtures │ └── spec_meeting_types.json ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20190907_0756.py │ ├── 0003_location_postal_code.py │ ├── 0004_auto_20200110_1611.py │ ├── 0005_auto_20200113_1831.py │ ├── 0006_auto_20200313_1850.py │ ├── 0007_auto_20200317_1933.py │ ├── 0008_auto_20200321_2037.py │ ├── 0009_auto_20200321_2220.py │ ├── 0010_auto_20200322_1025.py │ ├── 0011_auto_20200404_1829.py │ ├── 0012_auto_20200405_1015.py │ ├── 0013_auto_20200722_1634.py │ ├── 0014_auto_20200726_0929.py │ ├── 0015_auto_20211002_1711.py │ ├── 0016_alter_group_gso_number_alter_meeting_area.py │ └── __init__.py ├── models.py ├── settings.py ├── static │ └── meeting_guide │ │ ├── app.js │ │ └── print.css ├── templates │ ├── meeting_guide │ │ ├── location_form.html │ │ ├── meetings_home.html │ │ └── tags │ │ │ └── meeting_guide.html │ └── wagtailadmin │ │ └── pages │ │ └── listing │ │ ├── _page_title_explore.html │ │ ├── _page_title_move.html │ │ └── _page_title_move_AFTER_WAGTAIL_PR_IN_CODERED.html ├── templatetags │ ├── __init__.py │ └── meeting_guide.py ├── urls.py ├── utils.py ├── validators.py ├── views.py └── wagtail_hooks.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # manage.py, always default to dev settings 2 | manage.py 3 | 4 | # Django media files 5 | media/* 6 | 7 | # Python bytecode: 8 | *.py[co] 9 | 10 | # Packaging files: 11 | *.egg* 12 | dist/* 13 | 14 | # Sphinx docs: 15 | build 16 | 17 | # SQLite3 database files: 18 | *.db 19 | 20 | # Logs: 21 | *.log 22 | 23 | # Eclipse 24 | .project 25 | 26 | # Linux Editors 27 | *~ 28 | \#*\# 29 | /.emacs.desktop 30 | /.emacs.desktop.lock 31 | .elc 32 | auto-save-list 33 | tramp 34 | .\#* 35 | *.swp 36 | *.swo 37 | 38 | # Mac 39 | .DS_Store 40 | ._* 41 | 42 | # Windows 43 | Thumbs.db 44 | Desktop.ini 45 | 46 | # Dev tools 47 | .idea 48 | .vagrant 49 | 50 | # Ignore local configurations 51 | wrds/settings/local.py 52 | db.sqlite3 53 | 54 | # Node modules 55 | node_modules/ 56 | 57 | # Bower components 58 | bower_components/ 59 | 60 | # Coverage 61 | htmlcov/ 62 | .coverage 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Timothy Allen and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wagtail Meeting Guide 2 | 3 | A Python package compatible with the [Meeting Guide App](https://www.aa.org/pages/en_US/meeting-guide) for [the Wagtail CMS](https://wagtail.io) on the [Django web framework](https://www.djangoproject.com). 4 | 5 | ## Pre-Requisites 6 | 7 | Using this package requires both the Wagtail CMS and Django. Wagtail and Django are fantastic for running your website, but require a developer. If you are new to Django, I would recommend going through both the [Django](https://docs.djangoproject.com/en/dev/intro/tutorial01/) and [Wagtail](http://docs.wagtail.io/en/latest/getting_started/tutorial.html) tutorials before trying to use this package. 8 | 9 | A Google Geocode API key and MapBox API key are also required. The Google API key is only used in the content management system (typically by administrators), so the free tier should be fine to use. 10 | 11 | ## Installation to Your Django Project 12 | 13 | * Install with the command `pip install wagtail-meeting-guide` 14 | * Add `meeting_guide`, `mptt`, and `wagtailgeowidget` to your `INSTALLED_APPS`. 15 | * Add the following settings: 16 | * `WAGTAIL_SITE_NAME` (MyCity Intergroup): the name of your website, typically the intergroup. 17 | * `BASE_URL` (https://AAMyCity.org): the base URL or the website. 18 | * Run migrations: `python manage.py migrate meeting_guide` 19 | * Load Meeting Guide's meeting types: `python manage.py loaddata spec_meeting_types.json` 20 | 21 | ## Configuration 22 | 23 | * Enter the Wagtail CMS, and go to `Settings`, `Meeting Types`. 24 | * Enter your Intergroup's code for each of the Meeting Guide Code Types 25 | * Go to `Regions` and enter your regions; regions can have a parent, so you can nest them. For example, you could have `Philadelphia County` as a region with no parent, and `Center City` as a sub-region with `Philadelphia County` as the parent. 26 | 27 | ## Including the Meeting Guide in Your Django Template 28 | 29 | The API end point for the locations and meetings you create in Wagtail has to be added to your site's root `urls.py`. Add a line like this: 30 | 31 | ```python 32 | urlpatterns = [ 33 | ... 34 | path("meetings/", include("meeting_guide.urls")), 35 | ... 36 | ] 37 | ``` 38 | 39 | You can include the Meeting Guide within any Django Template. Here is an example: 40 | 41 | ```django+html 42 | {% extends "base.html" %} 43 | 44 | {% load meeting_guide %} 45 | 46 | {% block content %} 47 | {% meeting_guide %} 48 | {% endblock content %} 49 | ``` 50 | 51 | > **Note:** `wagtail-meeting-guide` does not come with the template for `meeting_guide/location.html` (the template for the `Location` page). Use the code above in your own template as needed. 52 | 53 | ## More Settings 54 | 55 | You can modify the `MEETING_GUIDE` setting in Django's settings to change the defaults in [https://github.com/code4recovery/tsml-ui#advanced-customization](https://github.com/code4recovery/tsml-ui#advanced-customization). Here is an example settings block: 56 | 57 | ```python 58 | # Google Maps, Used by the Wagtail Content Management System 59 | GOOGLE_MAPS_V3_APIKEY = "FAKEoyCFYHEYHUoBLAHBLAHYbRqjBafhI3FAKE" 60 | GOOGLE_MAPS_API_BOUNDS = "39.732679,-77.821655|41.553879,-73.896790" 61 | GEO_WIDGET_DEFAULT_LOCATION = {"lat": 40.0024137, "lng": -75.258117} # The Philadelphia area 62 | GEO_WIDGET_ZOOM = 14 63 | 64 | # Key for MapBox, used by the front end served to users. 65 | MAPBOX_KEY = "YourMaxBoxKeyGoesHere" 66 | 67 | # Example of sending settings to tsml-ui 68 | MEETING_GUIDE = { 69 | "flags": ["X", "TC"], 70 | "show": { 71 | "listButtons": True, 72 | }, 73 | "map": { 74 | "key": MAPBOX_KEY, 75 | }, 76 | "strings": { 77 | "en": { 78 | "types": { 79 | "X": "Wheelchair", 80 | "TC": "Temp Closed", 81 | }, 82 | }, 83 | }, 84 | "feedback_emails": "manager@your-integroup.org", 85 | } 86 | ``` 87 | 88 | ## Downloading Meetings as a PDF 89 | 90 | To download the meeting list as a PDF, you must [have wkhtmltopdf installed on your system](https://wkhtmltopdf.org/). The end point for the download is `meeting-guide/download/`. 91 | 92 | You can change the print and style options in your Django settings. The options are a Python dictionary while the styles are a string containing CSS: 93 | 94 | ```python 95 | WAGTAIL_MEETING_GUIDE_PRINT_OPTIONS = { 96 | 'page-width': '100mm', 97 | 'page-height': '120mm', 98 | 'margin-top': '10mm', 99 | 'margin-right': '10mm', 100 | 'margin-bottom': '10mm', 101 | 'margin-left': '10mm', 102 | 'header-left': '[section]: [subsection]', 103 | 'encoding': "UTF-8", 104 | 'no-outline': None 105 | } 106 | 107 | WAGTAIL_MEETING_GUIDE_PRINT_STYLES = """ 108 | html, td { 109 | font-family: Arial, Helvetica, sans-serif; 110 | font-size: 9px; 111 | -webkit-text-size-adjust:100%; 112 | -ms-text-size-adjust:100%; 113 | color: red; 114 | } 115 | 116 | body { 117 | margin:0; 118 | } 119 | 120 | h1, .h1 { 121 | font-size: 24px; 122 | } 123 | """ 124 | ``` 125 | 126 | ## Release Notes 127 | 128 | https://github.com/code4recovery/wagtail-meeting-guide/releases/ 129 | 130 | ## Maintainer 131 | 132 | * Timothy Allen (https://github.com/FlipperPA/) 133 | -------------------------------------------------------------------------------- /meeting_guide/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4recovery/wagtail-meeting-guide/7d9e9a472ea7efd178e60789c72f5e8a8995094a/meeting_guide/__init__.py -------------------------------------------------------------------------------- /meeting_guide/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from mptt.admin import MPTTModelAdmin 3 | from .models import Region 4 | 5 | 6 | admin.site.register(Region, MPTTModelAdmin) 7 | -------------------------------------------------------------------------------- /meeting_guide/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MeetingsConfig(AppConfig): 5 | name = "meeting_guide" 6 | default_auto_field = "django.db.models.BigAutoField" 7 | -------------------------------------------------------------------------------- /meeting_guide/fixtures/spec_meeting_types.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "meeting_guide.meetingtype", 4 | "pk": 1, 5 | "fields": { 6 | "type_name": "11th Step Meditation", 7 | "intergroup_code": null, 8 | "spec_code": "11", 9 | "display_order": 100 10 | } 11 | }, 12 | { 13 | "model": "meeting_guide.meetingtype", 14 | "pk": 2, 15 | "fields": { 16 | "type_name": "12 Steps & 12 Traditions", 17 | "intergroup_code": null, 18 | "spec_code": "12x12", 19 | "display_order": 100 20 | } 21 | }, 22 | { 23 | "model": "meeting_guide.meetingtype", 24 | "pk": 3, 25 | "fields": { 26 | "type_name": "As Bill Sees It", 27 | "intergroup_code": null, 28 | "spec_code": "ASBI", 29 | "display_order": 100 30 | } 31 | }, 32 | { 33 | "model": "meeting_guide.meetingtype", 34 | "pk": 4, 35 | "fields": { 36 | "type_name": "Babysitting Available", 37 | "intergroup_code": null, 38 | "spec_code": "BA", 39 | "display_order": 100 40 | } 41 | }, 42 | { 43 | "model": "meeting_guide.meetingtype", 44 | "pk": 5, 45 | "fields": { 46 | "type_name": "Big Book", 47 | "intergroup_code": null, 48 | "spec_code": "B", 49 | "display_order": 100 50 | } 51 | }, 52 | { 53 | "model": "meeting_guide.meetingtype", 54 | "pk": 6, 55 | "fields": { 56 | "type_name": "Birthday", 57 | "intergroup_code": null, 58 | "spec_code": "H", 59 | "display_order": 100 60 | } 61 | }, 62 | { 63 | "model": "meeting_guide.meetingtype", 64 | "pk": 7, 65 | "fields": { 66 | "type_name": "Breakfast", 67 | "intergroup_code": null, 68 | "spec_code": "BRK", 69 | "display_order": 100 70 | } 71 | }, 72 | { 73 | "model": "meeting_guide.meetingtype", 74 | "pk": 8, 75 | "fields": { 76 | "type_name": "Candlelight", 77 | "intergroup_code": null, 78 | "spec_code": "AN", 79 | "display_order": 100 80 | } 81 | }, 82 | { 83 | "model": "meeting_guide.meetingtype", 84 | "pk": 9, 85 | "fields": { 86 | "type_name": "Child-Friendly", 87 | "intergroup_code": null, 88 | "spec_code": "CF", 89 | "display_order": 100 90 | } 91 | }, 92 | { 93 | "model": "meeting_guide.meetingtype", 94 | "pk": 10, 95 | "fields": { 96 | "type_name": "Closed", 97 | "intergroup_code": null, 98 | "spec_code": "C", 99 | "display_order": 50 100 | } 101 | }, 102 | { 103 | "model": "meeting_guide.meetingtype", 104 | "pk": 11, 105 | "fields": { 106 | "type_name": "Concurrent with Al-Anon", 107 | "intergroup_code": null, 108 | "spec_code": "AL-AN", 109 | "display_order": 100 110 | } 111 | }, 112 | { 113 | "model": "meeting_guide.meetingtype", 114 | "pk": 12, 115 | "fields": { 116 | "type_name": "Concurrent with Alateen", 117 | "intergroup_code": null, 118 | "spec_code": "AL", 119 | "display_order": 100 120 | } 121 | }, 122 | { 123 | "model": "meeting_guide.meetingtype", 124 | "pk": 13, 125 | "fields": { 126 | "type_name": "Cross Talk Permitted", 127 | "intergroup_code": null, 128 | "spec_code": "XT", 129 | "display_order": 100 130 | } 131 | }, 132 | { 133 | "model": "meeting_guide.meetingtype", 134 | "pk": 14, 135 | "fields": { 136 | "type_name": "Daily Reflections", 137 | "intergroup_code": null, 138 | "spec_code": "DR", 139 | "display_order": 100 140 | } 141 | }, 142 | { 143 | "model": "meeting_guide.meetingtype", 144 | "pk": 15, 145 | "fields": { 146 | "type_name": "Digital Basket", 147 | "intergroup_code": null, 148 | "spec_code": "DB", 149 | "display_order": 100 150 | } 151 | }, 152 | { 153 | "model": "meeting_guide.meetingtype", 154 | "pk": 16, 155 | "fields": { 156 | "type_name": "Discussion", 157 | "intergroup_code": null, 158 | "spec_code": "D", 159 | "display_order": 100 160 | } 161 | }, 162 | { 163 | "model": "meeting_guide.meetingtype", 164 | "pk": 17, 165 | "fields": { 166 | "type_name": "Dual Diagnosis", 167 | "intergroup_code": null, 168 | "spec_code": "DD", 169 | "display_order": 100 170 | } 171 | }, 172 | { 173 | "model": "meeting_guide.meetingtype", 174 | "pk": 18, 175 | "fields": { 176 | "type_name": "English", 177 | "intergroup_code": null, 178 | "spec_code": "EN", 179 | "display_order": 100 180 | } 181 | }, 182 | { 183 | "model": "meeting_guide.meetingtype", 184 | "pk": 19, 185 | "fields": { 186 | "type_name": "Fragrance Free", 187 | "intergroup_code": null, 188 | "spec_code": "FF", 189 | "display_order": 100 190 | } 191 | }, 192 | { 193 | "model": "meeting_guide.meetingtype", 194 | "pk": 20, 195 | "fields": { 196 | "type_name": "French", 197 | "intergroup_code": null, 198 | "spec_code": "FR", 199 | "display_order": 100 200 | } 201 | }, 202 | { 203 | "model": "meeting_guide.meetingtype", 204 | "pk": 21, 205 | "fields": { 206 | "type_name": "Gay", 207 | "intergroup_code": null, 208 | "spec_code": "G", 209 | "display_order": 100 210 | } 211 | }, 212 | { 213 | "model": "meeting_guide.meetingtype", 214 | "pk": 22, 215 | "fields": { 216 | "type_name": "Grapevine", 217 | "intergroup_code": null, 218 | "spec_code": "GR", 219 | "display_order": 100 220 | } 221 | }, 222 | { 223 | "model": "meeting_guide.meetingtype", 224 | "pk": 23, 225 | "fields": { 226 | "type_name": "Indigenous", 227 | "intergroup_code": null, 228 | "spec_code": "NDG", 229 | "display_order": 100 230 | } 231 | }, 232 | { 233 | "model": "meeting_guide.meetingtype", 234 | "pk": 24, 235 | "fields": { 236 | "type_name": "Italian", 237 | "intergroup_code": null, 238 | "spec_code": "ITA", 239 | "display_order": 100 240 | } 241 | }, 242 | { 243 | "model": "meeting_guide.meetingtype", 244 | "pk": 25, 245 | "fields": { 246 | "type_name": "Japanese", 247 | "intergroup_code": null, 248 | "spec_code": "JA", 249 | "display_order": 100 250 | } 251 | }, 252 | { 253 | "model": "meeting_guide.meetingtype", 254 | "pk": 26, 255 | "fields": { 256 | "type_name": "Korean", 257 | "intergroup_code": null, 258 | "spec_code": "KOR", 259 | "display_order": 100 260 | } 261 | }, 262 | { 263 | "model": "meeting_guide.meetingtype", 264 | "pk": 27, 265 | "fields": { 266 | "type_name": "Lesbian", 267 | "intergroup_code": null, 268 | "spec_code": "L", 269 | "display_order": 100 270 | } 271 | }, 272 | { 273 | "model": "meeting_guide.meetingtype", 274 | "pk": 28, 275 | "fields": { 276 | "type_name": "Literature", 277 | "intergroup_code": null, 278 | "spec_code": "LIT", 279 | "display_order": 100 280 | } 281 | }, 282 | { 283 | "model": "meeting_guide.meetingtype", 284 | "pk": 29, 285 | "fields": { 286 | "type_name": "Living Sober", 287 | "intergroup_code": null, 288 | "spec_code": "LS", 289 | "display_order": 100 290 | } 291 | }, 292 | { 293 | "model": "meeting_guide.meetingtype", 294 | "pk": 30, 295 | "fields": { 296 | "type_name": "LGBTQ", 297 | "intergroup_code": null, 298 | "spec_code": "LGBTQ", 299 | "display_order": 100 300 | } 301 | }, 302 | { 303 | "model": "meeting_guide.meetingtype", 304 | "pk": 31, 305 | "fields": { 306 | "type_name": "Meditation", 307 | "intergroup_code": null, 308 | "spec_code": "MED", 309 | "display_order": 100 310 | } 311 | }, 312 | { 313 | "model": "meeting_guide.meetingtype", 314 | "pk": 32, 315 | "fields": { 316 | "type_name": "Men", 317 | "intergroup_code": null, 318 | "spec_code": "M", 319 | "display_order": 100 320 | } 321 | }, 322 | { 323 | "model": "meeting_guide.meetingtype", 324 | "pk": 33, 325 | "fields": { 326 | "type_name": "Native American", 327 | "intergroup_code": null, 328 | "spec_code": "N", 329 | "display_order": 100 330 | } 331 | }, 332 | { 333 | "model": "meeting_guide.meetingtype", 334 | "pk": 34, 335 | "fields": { 336 | "type_name": "Newcomer", 337 | "intergroup_code": null, 338 | "spec_code": "BE", 339 | "display_order": 100 340 | } 341 | }, 342 | { 343 | "model": "meeting_guide.meetingtype", 344 | "pk": 35, 345 | "fields": { 346 | "type_name": "Non-Smoking", 347 | "intergroup_code": null, 348 | "spec_code": "NS", 349 | "display_order": 100 350 | } 351 | }, 352 | { 353 | "model": "meeting_guide.meetingtype", 354 | "pk": 36, 355 | "fields": { 356 | "type_name": "Open", 357 | "intergroup_code": null, 358 | "spec_code": "O", 359 | "display_order": 50 360 | } 361 | }, 362 | { 363 | "model": "meeting_guide.meetingtype", 364 | "pk": 37, 365 | "fields": { 366 | "type_name": "People of Color", 367 | "intergroup_code": null, 368 | "spec_code": "POC", 369 | "display_order": 100 370 | } 371 | }, 372 | { 373 | "model": "meeting_guide.meetingtype", 374 | "pk": 38, 375 | "fields": { 376 | "type_name": "Polish", 377 | "intergroup_code": null, 378 | "spec_code": "POL", 379 | "display_order": 100 380 | } 381 | }, 382 | { 383 | "model": "meeting_guide.meetingtype", 384 | "pk": 39, 385 | "fields": { 386 | "type_name": "Portuguese", 387 | "intergroup_code": null, 388 | "spec_code": "POR", 389 | "display_order": 100 390 | } 391 | }, 392 | { 393 | "model": "meeting_guide.meetingtype", 394 | "pk": 40, 395 | "fields": { 396 | "type_name": "Professionals", 397 | "intergroup_code": null, 398 | "spec_code": "P", 399 | "display_order": 100 400 | } 401 | }, 402 | { 403 | "model": "meeting_guide.meetingtype", 404 | "pk": 41, 405 | "fields": { 406 | "type_name": "Punjabi", 407 | "intergroup_code": null, 408 | "spec_code": "PUN", 409 | "display_order": 100 410 | } 411 | }, 412 | { 413 | "model": "meeting_guide.meetingtype", 414 | "pk": 42, 415 | "fields": { 416 | "type_name": "Russian", 417 | "intergroup_code": null, 418 | "spec_code": "RUS", 419 | "display_order": 100 420 | } 421 | }, 422 | { 423 | "model": "meeting_guide.meetingtype", 424 | "pk": 43, 425 | "fields": { 426 | "type_name": "Secular", 427 | "intergroup_code": null, 428 | "spec_code": "A", 429 | "display_order": 100 430 | } 431 | }, 432 | { 433 | "model": "meeting_guide.meetingtype", 434 | "pk": 44, 435 | "fields": { 436 | "type_name": "Sign Language", 437 | "intergroup_code": null, 438 | "spec_code": "ASL", 439 | "display_order": 100 440 | } 441 | }, 442 | { 443 | "model": "meeting_guide.meetingtype", 444 | "pk": 45, 445 | "fields": { 446 | "type_name": "Smoking Permitted", 447 | "intergroup_code": null, 448 | "spec_code": "SM", 449 | "display_order": 100 450 | } 451 | }, 452 | { 453 | "model": "meeting_guide.meetingtype", 454 | "pk": 46, 455 | "fields": { 456 | "type_name": "Spanish", 457 | "intergroup_code": null, 458 | "spec_code": "S", 459 | "display_order": 100 460 | } 461 | }, 462 | { 463 | "model": "meeting_guide.meetingtype", 464 | "pk": 47, 465 | "fields": { 466 | "type_name": "Speaker", 467 | "intergroup_code": null, 468 | "spec_code": "SP", 469 | "display_order": 100 470 | } 471 | }, 472 | { 473 | "model": "meeting_guide.meetingtype", 474 | "pk": 48, 475 | "fields": { 476 | "type_name": "Step Meeting", 477 | "intergroup_code": null, 478 | "spec_code": "ST", 479 | "display_order": 100 480 | } 481 | }, 482 | { 483 | "model": "meeting_guide.meetingtype", 484 | "pk": 49, 485 | "fields": { 486 | "type_name": "Tradition Study", 487 | "intergroup_code": null, 488 | "spec_code": "TR", 489 | "display_order": 100 490 | } 491 | }, 492 | { 493 | "model": "meeting_guide.meetingtype", 494 | "pk": 50, 495 | "fields": { 496 | "type_name": "Transgender", 497 | "intergroup_code": null, 498 | "spec_code": "T", 499 | "display_order": 100 500 | } 501 | }, 502 | { 503 | "model": "meeting_guide.meetingtype", 504 | "pk": 51, 505 | "fields": { 506 | "type_name": "Wheelchair Access", 507 | "intergroup_code": null, 508 | "spec_code": "X", 509 | "display_order": 100 510 | } 511 | }, 512 | { 513 | "model": "meeting_guide.meetingtype", 514 | "pk": 52, 515 | "fields": { 516 | "type_name": "Wheelchair-Accessible Bathroom", 517 | "intergroup_code": null, 518 | "spec_code": "XB", 519 | "display_order": 100 520 | } 521 | }, 522 | { 523 | "model": "meeting_guide.meetingtype", 524 | "pk": 53, 525 | "fields": { 526 | "type_name": "Women", 527 | "intergroup_code": null, 528 | "spec_code": "W", 529 | "display_order": 100 530 | } 531 | }, 532 | { 533 | "model": "meeting_guide.meetingtype", 534 | "pk": 54, 535 | "fields": { 536 | "type_name": "Young People", 537 | "intergroup_code": null, 538 | "spec_code": "Y", 539 | "display_order": 100 540 | } 541 | }, 542 | { 543 | "model": "meeting_guide.meetingtype", 544 | "pk": 55, 545 | "fields": { 546 | "type_name": "Temporarily Closed", 547 | "intergroup_code": null, 548 | "spec_code": "TC", 549 | "display_order": 100 550 | } 551 | }, 552 | { 553 | "model": "meeting_guide.meetingtype", 554 | "pk": 56, 555 | "fields": { 556 | "type_name": "Online", 557 | "intergroup_code": null, 558 | "spec_code": "ONL", 559 | "display_order": 100 560 | } 561 | } 562 | ] 563 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-04-17 13:30 2 | 3 | import datetime 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import modelcluster.fields 7 | import mptt.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ("wagtailcore", "0041_group_collection_permissions_verbose_name_plural") 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="Group", 21 | fields=[ 22 | ( 23 | "id", 24 | models.AutoField( 25 | auto_created=True, 26 | primary_key=True, 27 | serialize=False, 28 | verbose_name="ID", 29 | ), 30 | ), 31 | ("name", models.CharField(max_length=255)), 32 | ("gso_number", models.CharField(blank=True, max_length=10, null=True)), 33 | ("district", models.CharField(blank=True, max_length=10, null=True)), 34 | ("area", models.CharField(blank=True, max_length=10, null=True)), 35 | ( 36 | "status", 37 | models.SmallIntegerField( 38 | choices=[(0, "Inactive"), (1, "Active")], default=1 39 | ), 40 | ), 41 | ("founded", models.DateField(blank=True, null=True)), 42 | ("history", models.TextField(blank=True, null=True)), 43 | ], 44 | options={"ordering": ["name"]}, 45 | ), 46 | migrations.CreateModel( 47 | name="GroupContribution", 48 | fields=[ 49 | ( 50 | "id", 51 | models.AutoField( 52 | auto_created=True, 53 | primary_key=True, 54 | serialize=False, 55 | verbose_name="ID", 56 | ), 57 | ), 58 | ("date", models.DateField(default=datetime.date.today)), 59 | ("amount", models.DecimalField(decimal_places=2, max_digits=20)), 60 | ], 61 | options={"ordering": ["group__name", "-date"]}, 62 | ), 63 | migrations.CreateModel( 64 | name="Location", 65 | fields=[ 66 | ( 67 | "page_ptr", 68 | models.OneToOneField( 69 | auto_created=True, 70 | on_delete=django.db.models.deletion.CASCADE, 71 | parent_link=True, 72 | primary_key=True, 73 | serialize=False, 74 | to="wagtailcore.Page", 75 | ), 76 | ), 77 | ( 78 | "formatted_address", 79 | models.CharField( 80 | blank=True, 81 | max_length=255, 82 | null=True, 83 | verbose_name="Full Address", 84 | ), 85 | ), 86 | ( 87 | "lat_lng", 88 | models.CharField( 89 | blank=True, 90 | max_length=255, 91 | null=True, 92 | verbose_name="Latitude/Longitude", 93 | ), 94 | ), 95 | ], 96 | bases=("wagtailcore.page",), 97 | ), 98 | migrations.CreateModel( 99 | name="Meeting", 100 | fields=[ 101 | ( 102 | "page_ptr", 103 | models.OneToOneField( 104 | auto_created=True, 105 | on_delete=django.db.models.deletion.CASCADE, 106 | parent_link=True, 107 | primary_key=True, 108 | serialize=False, 109 | to="wagtailcore.Page", 110 | ), 111 | ), 112 | ("start_time", models.TimeField(null=True)), 113 | ("end_time", models.TimeField(null=True)), 114 | ( 115 | "day_of_week", 116 | models.SmallIntegerField( 117 | choices=[ 118 | (0, "Sunday"), 119 | (1, "Monday"), 120 | (2, "Tuesday"), 121 | (3, "Wednesday"), 122 | (4, "Thursday"), 123 | (5, "Friday"), 124 | (6, "Saturday"), 125 | ], 126 | default=0, 127 | ), 128 | ), 129 | ( 130 | "status", 131 | models.SmallIntegerField( 132 | choices=[(0, "Inactive"), (1, "Active")], default=1 133 | ), 134 | ), 135 | ( 136 | "meeting_details", 137 | models.TextField( 138 | blank=True, 139 | help_text="Additional details about the meeting.", 140 | null=True, 141 | ), 142 | ), 143 | ( 144 | "location_details", 145 | models.TextField( 146 | blank=True, 147 | help_text="How to find the meeting at the location, I.e.: 'In the basement', 'In the rear building.'", 148 | null=True, 149 | ), 150 | ), 151 | ], 152 | bases=("wagtailcore.page",), 153 | ), 154 | migrations.CreateModel( 155 | name="MeetingType", 156 | fields=[ 157 | ( 158 | "id", 159 | models.AutoField( 160 | auto_created=True, 161 | primary_key=True, 162 | serialize=False, 163 | verbose_name="ID", 164 | ), 165 | ), 166 | ("type_name", models.CharField(max_length=191)), 167 | ( 168 | "intergroup_code", 169 | models.CharField(blank=True, max_length=5, null=True), 170 | ), 171 | ( 172 | "meeting_guide_code", 173 | models.CharField(blank=True, max_length=5, null=True), 174 | ), 175 | ], 176 | options={"ordering": ["type_name"]}, 177 | ), 178 | migrations.CreateModel( 179 | name="Region", 180 | fields=[ 181 | ( 182 | "id", 183 | models.AutoField( 184 | auto_created=True, 185 | primary_key=True, 186 | serialize=False, 187 | verbose_name="ID", 188 | ), 189 | ), 190 | ("name", models.CharField(max_length=255)), 191 | ("lft", models.PositiveIntegerField(editable=False)), 192 | ("rght", models.PositiveIntegerField(editable=False)), 193 | ("tree_id", models.PositiveIntegerField(db_index=True, editable=False)), 194 | ("level", models.PositiveIntegerField(editable=False)), 195 | ( 196 | "parent", 197 | mptt.fields.TreeForeignKey( 198 | blank=True, 199 | null=True, 200 | on_delete=django.db.models.deletion.CASCADE, 201 | related_name="children", 202 | to="meeting_guide.Region", 203 | ), 204 | ), 205 | ], 206 | options={"abstract": False}, 207 | ), 208 | migrations.AddIndex( 209 | model_name="meetingtype", 210 | index=models.Index( 211 | fields=["type_name"], name="meeting_gui_type_na_8f3ae8_idx" 212 | ), 213 | ), 214 | migrations.AddIndex( 215 | model_name="meetingtype", 216 | index=models.Index( 217 | fields=["intergroup_code"], name="meeting_gui_intergr_c031b7_idx" 218 | ), 219 | ), 220 | migrations.AddIndex( 221 | model_name="meetingtype", 222 | index=models.Index( 223 | fields=["meeting_guide_code"], name="meeting_gui_meeting_e22f98_idx" 224 | ), 225 | ), 226 | migrations.AddField( 227 | model_name="meeting", 228 | name="group", 229 | field=models.ForeignKey( 230 | blank=True, 231 | null=True, 232 | on_delete=django.db.models.deletion.SET_NULL, 233 | related_name="meetings", 234 | to="meeting_guide.Group", 235 | ), 236 | ), 237 | migrations.AddField( 238 | model_name="meeting", 239 | name="meeting_location", 240 | field=models.ForeignKey( 241 | null=True, 242 | on_delete=django.db.models.deletion.SET_NULL, 243 | related_name="meetings", 244 | to="meeting_guide.Location", 245 | ), 246 | ), 247 | migrations.AddField( 248 | model_name="meeting", 249 | name="types", 250 | field=modelcluster.fields.ParentalManyToManyField( 251 | limit_choices_to={"intergroup_code__isnull": False}, 252 | related_name="meetings", 253 | to="meeting_guide.MeetingType", 254 | ), 255 | ), 256 | migrations.AddField( 257 | model_name="location", 258 | name="region", 259 | field=models.ForeignKey( 260 | null=True, 261 | on_delete=django.db.models.deletion.SET_NULL, 262 | related_name="locations", 263 | to="meeting_guide.Region", 264 | ), 265 | ), 266 | migrations.AddField( 267 | model_name="groupcontribution", 268 | name="group", 269 | field=models.ForeignKey( 270 | on_delete=django.db.models.deletion.CASCADE, to="meeting_guide.Group" 271 | ), 272 | ), 273 | migrations.AddIndex( 274 | model_name="meeting", 275 | index=models.Index( 276 | fields=["meeting_location"], name="meeting_gui_meeting_792a4f_idx" 277 | ), 278 | ), 279 | migrations.AddIndex( 280 | model_name="meeting", 281 | index=models.Index( 282 | fields=["day_of_week"], name="meeting_gui_day_of__e95721_idx" 283 | ), 284 | ), 285 | migrations.AddIndex( 286 | model_name="location", 287 | index=models.Index( 288 | fields=["region"], name="meeting_gui_region__d7d310_idx" 289 | ), 290 | ), 291 | migrations.AddIndex( 292 | model_name="location", 293 | index=models.Index( 294 | fields=["formatted_address"], name="meeting_gui_formatt_18f95a_idx" 295 | ), 296 | ), 297 | ] 298 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0002_auto_20190907_0756.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-07 11:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("meeting_guide", "0001_initial")] 9 | 10 | operations = [ 11 | migrations.AlterModelOptions( 12 | name="meetingtype", options={"ordering": ["display_order", "type_name"]} 13 | ), 14 | migrations.AddField( 15 | model_name="meetingtype", 16 | name="display_order", 17 | field=models.PositiveSmallIntegerField(default=100), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0003_location_postal_code.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2019-11-18 19:31 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("meeting_guide", "0002_auto_20190907_0756")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="location", 13 | name="postal_code", 14 | field=models.CharField( 15 | blank=True, max_length=12, verbose_name="Postal Code" 16 | ), 17 | ) 18 | ] 19 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0004_auto_20200110_1611.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.7 on 2020-01-10 21:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('meeting_guide', '0003_location_postal_code'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='meeting', 15 | old_name='meeting_details', 16 | new_name='details', 17 | ), 18 | migrations.RemoveField( 19 | model_name='group', 20 | name='area', 21 | ), 22 | migrations.RemoveField( 23 | model_name='group', 24 | name='district', 25 | ), 26 | migrations.RemoveField( 27 | model_name='meeting', 28 | name='location_details', 29 | ), 30 | migrations.AddField( 31 | model_name='location', 32 | name='details', 33 | field=models.TextField(blank=True, help_text="Details specific to the location, not the meeting. For example, 'Located in shopping center behind the bank.'", null=True), 34 | ), 35 | migrations.AddField( 36 | model_name='meeting', 37 | name='area', 38 | field=models.CharField(blank=True, max_length=10), 39 | ), 40 | migrations.AddField( 41 | model_name='meeting', 42 | name='district', 43 | field=models.CharField(blank=True, max_length=10), 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0005_auto_20200113_1831.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.9 on 2020-01-13 23:31 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('meeting_guide', '0004_auto_20200110_1611'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='location', 16 | name='region', 17 | field=models.ForeignKey(default=1, limit_choices_to={'parent__isnull': False}, on_delete=django.db.models.deletion.PROTECT, related_name='locations', to='meeting_guide.Region'), 18 | preserve_default=False, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0006_auto_20200313_1850.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.11 on 2020-03-13 22:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('meeting_guide', '0005_auto_20200113_1831'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='meeting', 15 | name='status', 16 | field=models.SmallIntegerField(choices=[(0, 'Inactive'), (1, 'Active'), (2, 'Suspended')], default=1), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0007_auto_20200317_1933.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.11 on 2020-03-17 23:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('meeting_guide', '0006_auto_20200313_1850'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='meeting', 15 | name='payment_paypal', 16 | field=models.EmailField(blank=True, default='', max_length=254, verbose_name='PayPal account'), 17 | ), 18 | migrations.AddField( 19 | model_name='meeting', 20 | name='payment_venmo', 21 | field=models.CharField(blank=True, default='', max_length=100, verbose_name='Venmo account'), 22 | ), 23 | migrations.AddField( 24 | model_name='meeting', 25 | name='video_conference_dial_in', 26 | field=models.CharField(blank=True, default='', max_length=255), 27 | ), 28 | migrations.AddField( 29 | model_name='meeting', 30 | name='video_conference_url', 31 | field=models.URLField(blank=True, default='', verbose_name='Video conference URL'), 32 | ), 33 | migrations.AlterField( 34 | model_name='meeting', 35 | name='status', 36 | field=models.SmallIntegerField(choices=[(1, 'Active'), (3, 'Online Only'), (2, 'Suspended'), (0, 'Inactive Permanently')], default=1), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0008_auto_20200321_2037.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2020-03-22 00:37 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('meeting_guide', '0007_auto_20200317_1933'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='meeting', 15 | old_name='payment_paypal', 16 | new_name='paypal', 17 | ), 18 | migrations.RenameField( 19 | model_name='meeting', 20 | old_name='payment_venmo', 21 | new_name='venmo', 22 | ), 23 | migrations.RenameField( 24 | model_name='meeting', 25 | old_name='video_conference_dial_in', 26 | new_name='video_conference_phone', 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0009_auto_20200321_2220.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2020-03-22 02:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('meeting_guide', '0008_auto_20200321_2037'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='meeting', 15 | name='status', 16 | field=models.SmallIntegerField(choices=[(1, 'Active'), (0, 'Inactive Permanently')], default=1), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0010_auto_20200322_1025.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.11 on 2020-03-22 14:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('meeting_guide', '0009_auto_20200321_2220'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='meeting', 15 | name='paypal', 16 | field=models.URLField(blank=True, default='', help_text='Example: https://paypal.me/sepia-mygroup', verbose_name='PayPal URL'), 17 | ), 18 | migrations.AlterField( 19 | model_name='meeting', 20 | name='venmo', 21 | field=models.URLField(blank=True, default='', help_text='Example: https://venmo.com/sepia-mygroup', verbose_name='Venmo URL'), 22 | ), 23 | migrations.AlterField( 24 | model_name='meeting', 25 | name='video_conference_phone', 26 | field=models.CharField(blank=True, default='', help_text='Example: 215-555-1212 Code: 123 456 789', max_length=255), 27 | ), 28 | migrations.AlterField( 29 | model_name='meeting', 30 | name='video_conference_url', 31 | field=models.URLField(blank=True, default='', help_text='Example: https://zoom.com/j/123456789', verbose_name='Video conference URL'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0011_auto_20200404_1829.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2020-04-04 22:29 2 | 3 | from django.db import migrations, models 4 | import meeting_guide.validators 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('meeting_guide', '0010_auto_20200322_1025'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='meeting', 16 | name='venmo', 17 | field=models.TextField(blank=True, default='', help_text='Example: @aa-tbc', max_length=17, validators=[meeting_guide.validators.VenmoUsernameValidator()], verbose_name='Venmo Account'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0012_auto_20200405_1015.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.12 on 2020-04-05 14:15 2 | 3 | from django.db import migrations, models 4 | import meeting_guide.validators 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('meeting_guide', '0011_auto_20200404_1829'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='meeting', 16 | name='venmo', 17 | field=models.TextField(blank=True, default='', help_text='Example: @aa-tbc', max_length=31, validators=[meeting_guide.validators.VenmoUsernameValidator()], verbose_name='Venmo Account'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0013_auto_20200722_1634.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-22 20:34 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import meeting_guide.validators 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('meeting_guide', '0012_auto_20200405_1015'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveIndex( 16 | model_name='meetingtype', 17 | name='meeting_gui_meeting_e22f98_idx', 18 | ), 19 | migrations.RenameField( 20 | model_name='meeting', 21 | old_name='video_conference_url', 22 | new_name='conference_url', 23 | ), 24 | migrations.RenameField( 25 | model_name='meetingtype', 26 | old_name='meeting_guide_code', 27 | new_name='spec_code', 28 | ), 29 | migrations.RenameField( 30 | model_name='meeting', 31 | old_name='video_conference_phone', 32 | new_name='conference_phone', 33 | ), 34 | migrations.AddField( 35 | model_name='meeting', 36 | name='cashapp', 37 | field=models.TextField(blank=True, default='', help_text='Example: $aa-mygroup', max_length=31, validators=[meeting_guide.validators.CashAppUsernameValidator()], verbose_name='CashApp Account'), 38 | ), 39 | migrations.AlterField( 40 | model_name='meeting', 41 | name='conference_phone', 42 | field=models.CharField(blank=True, default='', help_text=('Enter a valid conference phone number. The three groups of numbers in this example are a Zoom phone number, meeting code, and password: +19294362866,,2151234215#,,#,,12341234#',), max_length=255, validators=[meeting_guide.validators.ConferencePhoneValidator()]), 43 | ), 44 | migrations.AlterField( 45 | model_name='meeting', 46 | name='paypal', 47 | field=models.TextField(blank=True, default='', help_text='Example: aamygroup', max_length=255, validators=[meeting_guide.validators.PayPalUsernameValidator(), django.core.validators.MinLengthValidator(8)], verbose_name='PayPal Username'), 48 | ), 49 | migrations.AlterField( 50 | model_name='meeting', 51 | name='venmo', 52 | field=models.TextField(blank=True, default='', help_text='Example: @aa-mygroup', max_length=31, validators=[meeting_guide.validators.VenmoUsernameValidator()], verbose_name='Venmo Account'), 53 | ), 54 | migrations.AddIndex( 55 | model_name='meetingtype', 56 | index=models.Index(fields=['spec_code'], name='meeting_gui_spec_co_dfdefe_idx'), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0014_auto_20200726_0929.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-26 13:29 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import meeting_guide.validators 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('meeting_guide', '0013_auto_20200722_1634'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='meeting', 17 | name='conference_phone', 18 | field=models.CharField(blank=True, default='', help_text='Enter a valid conference phone number. The three groups of numbers in this example are a Zoom phone number, meeting code, and password: +19294362866,,2151234215#,,#,,12341234#', max_length=255, validators=[meeting_guide.validators.ConferencePhoneValidator()]), 19 | ), 20 | migrations.AlterField( 21 | model_name='meeting', 22 | name='conference_url', 23 | field=models.URLField(blank=True, default='', help_text='Example: https://zoom.com/j/123456789?pwd=ExzUZMeT091pRU0Omc2QWjErUUUpxS1B', verbose_name='Conference URL'), 24 | ), 25 | migrations.AlterField( 26 | model_name='meeting', 27 | name='paypal', 28 | field=models.TextField(blank=True, default='', help_text='Example: aamygroup', max_length=255, validators=[meeting_guide.validators.PayPalUsernameValidator(), django.core.validators.MinLengthValidator(3)], verbose_name='PayPal Username'), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0015_auto_20211002_1711.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.7 on 2021-10-02 21:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('meeting_guide', '0014_auto_20200726_0929'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='group', 15 | name='id', 16 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 17 | ), 18 | migrations.AlterField( 19 | model_name='groupcontribution', 20 | name='id', 21 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 22 | ), 23 | migrations.AlterField( 24 | model_name='meetingtype', 25 | name='id', 26 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 27 | ), 28 | migrations.AlterField( 29 | model_name='region', 30 | name='id', 31 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /meeting_guide/migrations/0016_alter_group_gso_number_alter_meeting_area.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-10-03 14:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("meeting_guide", "0015_auto_20211002_1711"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="group", 14 | name="gso_number", 15 | field=models.CharField( 16 | blank=True, 17 | help_text="General Service Office (GSO) number, if applicable.", 18 | max_length=10, 19 | null=True, 20 | verbose_name="GSO Number", 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name="meeting", 25 | name="area", 26 | field=models.CharField( 27 | blank=True, 28 | help_text="Every A.A. area has a number. Find yours at aa.org", 29 | max_length=10, 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /meeting_guide/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4recovery/wagtail-meeting-guide/7d9e9a472ea7efd178e60789c72f5e8a8995094a/meeting_guide/migrations/__init__.py -------------------------------------------------------------------------------- /meeting_guide/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.core.validators import MinLengthValidator 4 | from django.db import models 5 | from django.forms import CheckboxSelectMultiple 6 | from django.utils.functional import cached_property 7 | from django.utils.html import mark_safe 8 | 9 | from modelcluster.fields import ParentalManyToManyField 10 | from mptt.models import MPTTModel, TreeForeignKey 11 | from wagtail.models import Page 12 | from wagtail.admin.panels import FieldPanel, MultiFieldPanel, FieldRowPanel 13 | from wagtail.search import index 14 | from wagtail.search.index import SearchField 15 | from wagtailgeowidget.panels import GoogleMapsPanel 16 | from wagtailgeowidget.helpers import geosgeometry_str_to_struct 17 | 18 | from .validators import ( 19 | CashAppUsernameValidator, 20 | ConferencePhoneValidator, 21 | PayPalUsernameValidator, 22 | VenmoUsernameValidator, 23 | ) 24 | 25 | 26 | class Region(MPTTModel): 27 | """ 28 | Tree of regions and sub-regions. 29 | """ 30 | 31 | name = models.CharField(max_length=255) 32 | parent = TreeForeignKey( 33 | "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children" 34 | ) 35 | 36 | def __str__(self): 37 | ancestors = self.get_ancestors(include_self=True).values_list("name", flat=True) 38 | return " > ".join(ancestors) 39 | 40 | class MPTTMeta: 41 | order_insertion_by = ["name"] 42 | 43 | 44 | class Group(models.Model): 45 | """ 46 | Model for storing group data. 47 | """ 48 | 49 | STATUS_CHOICES = ((0, "Inactive"), (1, "Active")) 50 | 51 | name = models.CharField(max_length=255) 52 | gso_number = models.CharField( 53 | max_length=10, 54 | null=True, 55 | blank=True, 56 | help_text='General Service Office (GSO) number, if applicable.', 57 | verbose_name='GSO Number', 58 | ) 59 | status = models.SmallIntegerField(default=1, choices=STATUS_CHOICES) 60 | founded = models.DateField(null=True, blank=True) 61 | history = models.TextField(null=True, blank=True) 62 | 63 | class Meta: 64 | ordering = ["name"] 65 | 66 | def __str__(self): 67 | return "{0}".format(self.name) 68 | 69 | 70 | class GroupContribution(models.Model): 71 | """ 72 | For keeping track of contributions from groups. 73 | """ 74 | 75 | group = models.ForeignKey(Group, on_delete=models.CASCADE) 76 | date = models.DateField(default=datetime.date.today) 77 | amount = models.DecimalField(max_digits=20, decimal_places=2) 78 | 79 | class Meta: 80 | ordering = ["group__name", "-date"] 81 | 82 | def __str__(self): 83 | return "{0} ({1}: ${2})".format(self.group, self.date, self.amount) 84 | 85 | 86 | class Location(Page): 87 | """ 88 | Geocoded location details. 89 | """ 90 | 91 | region = models.ForeignKey( 92 | Region, 93 | related_name="locations", 94 | on_delete=models.PROTECT, 95 | limit_choices_to={"parent__isnull": False}, 96 | ) 97 | formatted_address = models.CharField( 98 | "Full Address", max_length=255, blank=True, null=True 99 | ) 100 | lat_lng = models.CharField( 101 | "Latitude/Longitude", max_length=255, blank=True, null=True 102 | ) 103 | postal_code = models.CharField("Postal Code", max_length=12, blank=True) 104 | details = models.TextField( 105 | null=True, 106 | blank=True, 107 | help_text="Details specific to the location, not the meeting. For example, " 108 | "'Located in shopping center behind the bank.'", 109 | ) 110 | 111 | @cached_property 112 | def point(self): 113 | return geosgeometry_str_to_struct(self.lat_lng) 114 | 115 | @property 116 | def lat(self): 117 | return self.point["y"] 118 | 119 | @property 120 | def lng(self): 121 | return self.point["x"] 122 | 123 | content_panels = Page.content_panels + [ 124 | FieldPanel("region"), 125 | FieldPanel("postal_code"), 126 | FieldPanel("details"), 127 | MultiFieldPanel( 128 | [ 129 | FieldPanel("formatted_address"), 130 | GoogleMapsPanel("lat_lng", address_field="formatted_address"), 131 | ], 132 | "Geocoded Address", 133 | ), 134 | ] 135 | 136 | search_fields = Page.search_fields + [ 137 | SearchField("region", partial_match=True), 138 | SearchField("formatted_address", partial_match=True), 139 | ] 140 | 141 | subpage_types = ["Meeting"] 142 | 143 | class Meta: 144 | indexes = [ 145 | models.Index(fields=["region"]), 146 | models.Index(fields=["formatted_address"]), 147 | ] 148 | 149 | def __str__(self): 150 | return "{0}: {1}".format(self.region, self.title) 151 | 152 | 153 | class MeetingType(models.Model): 154 | """ 155 | Model for storing different types of meetings. Initially populating with data from: 156 | https://github.com/code4recovery/spec 157 | """ 158 | 159 | type_name = models.CharField(max_length=191) 160 | intergroup_code = models.CharField(max_length=5, null=True, blank=True) 161 | spec_code = models.CharField(max_length=5, null=True, blank=True) 162 | display_order = models.PositiveSmallIntegerField(default=100) 163 | 164 | class Meta: 165 | ordering = ["display_order", "type_name"] 166 | indexes = [ 167 | models.Index(fields=["type_name"]), 168 | models.Index(fields=["intergroup_code"]), 169 | models.Index(fields=["spec_code"]), 170 | ] 171 | 172 | def __str__(self): 173 | return "{0} ({1} / {2})".format( 174 | self.type_name, self.intergroup_code, self.spec_code 175 | ) 176 | 177 | 178 | class Meeting(Page): 179 | """ 180 | Model for storing meeting data. 181 | """ 182 | 183 | SUNDAY = 0 184 | MONDAY = 1 185 | TUESDAY = 2 186 | WEDNESDAY = 3 187 | THURSDAY = 4 188 | FRIDAY = 5 189 | SATURDAY = 6 190 | DAY_OF_WEEK = ( 191 | (SUNDAY, "Sunday"), 192 | (MONDAY, "Monday"), 193 | (TUESDAY, "Tuesday"), 194 | (WEDNESDAY, "Wednesday"), 195 | (THURSDAY, "Thursday"), 196 | (FRIDAY, "Friday"), 197 | (SATURDAY, "Saturday"), 198 | ) 199 | 200 | INACTIVE = 0 201 | ACTIVE = 1 202 | STATUS_CHOICES = ( 203 | (ACTIVE, "Active"), 204 | (INACTIVE, "Inactive Permanently"), 205 | ) 206 | 207 | group = models.ForeignKey( 208 | Group, null=True, blank=True, on_delete=models.SET_NULL, related_name="meetings" 209 | ) 210 | meeting_location = models.ForeignKey( 211 | Location, related_name="meetings", null=True, on_delete=models.SET_NULL 212 | ) 213 | start_time = models.TimeField(null=True) 214 | end_time = models.TimeField(null=True) 215 | day_of_week = models.SmallIntegerField(default=0, choices=DAY_OF_WEEK) 216 | status = models.SmallIntegerField(default=1, choices=STATUS_CHOICES) 217 | details = models.TextField( 218 | null=True, blank=True, help_text="Additional details about the meeting." 219 | ) 220 | area = models.CharField( 221 | max_length=10, 222 | blank=True, 223 | help_text=mark_safe("Every A.A. area has a number. Find yours at aa.org") 224 | ) 225 | district = models.CharField(max_length=10, blank=True) 226 | types = ParentalManyToManyField( 227 | MeetingType, 228 | related_name="meetings", 229 | limit_choices_to={"intergroup_code__isnull": False}, 230 | ) 231 | conference_url = models.URLField( 232 | blank=True, 233 | verbose_name="Conference URL", 234 | default="", 235 | help_text="Example: " \ 236 | "https://zoom.com/j/123456789?pwd=ExzUZMeT091pRU0Omc2QWjErUUUpxS1B", 237 | ) 238 | conference_phone = models.CharField( 239 | max_length=255, 240 | blank=True, 241 | default="", 242 | validators=[ConferencePhoneValidator()], 243 | help_text="Enter a valid conference phone number. The three groups of " \ 244 | "numbers in this example are a Zoom phone number, meeting code, and " \ 245 | "password: +19294362866,,2151234215#,,#,,12341234#", 246 | ) 247 | venmo = models.TextField( 248 | max_length=31, # Venmo's max username length is 31 chars with the "@" prefix 249 | validators=[VenmoUsernameValidator()], 250 | blank=True, 251 | verbose_name="Venmo Account", 252 | default="", 253 | help_text="Example: @aa-mygroup", 254 | ) 255 | paypal = models.TextField( 256 | blank=True, 257 | verbose_name="PayPal Username", 258 | default="", 259 | max_length=255, 260 | validators=[PayPalUsernameValidator(), MinLengthValidator(3)], 261 | help_text="Example: aamygroup", 262 | ) 263 | cashapp = models.TextField( 264 | max_length=31, # Venmo's max username length is 31 chars with the "@" prefix 265 | validators=[CashAppUsernameValidator()], 266 | blank=True, 267 | verbose_name="CashApp Account", 268 | default="", 269 | help_text="Example: $aa-mygroup", 270 | ) 271 | 272 | @property 273 | def day_sort_order(self): 274 | """ 275 | Returns 0 for today's day of the week, up to 6 for yesterday's day of the 276 | week rather than Sunday - Saturday. 277 | """ 278 | day_sort_order = self.day_of_week - datetime.datetime.today().weekday() - 1 279 | if day_sort_order < 0: 280 | day_sort_order += 7 281 | 282 | return day_sort_order 283 | 284 | content_panels = Page.content_panels + [ 285 | FieldRowPanel( 286 | [ 287 | FieldPanel("day_of_week"), 288 | FieldPanel("start_time"), 289 | FieldPanel("end_time"), 290 | ], 291 | ), 292 | FieldRowPanel( 293 | [ 294 | FieldPanel("group"), 295 | FieldPanel("status"), 296 | ], 297 | ), 298 | FieldRowPanel( 299 | [ 300 | FieldPanel("area"), 301 | FieldPanel("district"), 302 | ], 303 | ), 304 | FieldRowPanel( 305 | [ 306 | FieldPanel("venmo"), 307 | FieldPanel("paypal"), 308 | ], 309 | ), 310 | FieldRowPanel( 311 | [ 312 | FieldPanel("conference_url"), 313 | FieldPanel("conference_phone"), 314 | ], 315 | ), 316 | FieldPanel("types", widget=CheckboxSelectMultiple), 317 | FieldPanel("details"), 318 | ] 319 | 320 | search_fields = Page.search_fields + [ 321 | SearchField("group", partial_match=True), 322 | SearchField("meeting_location", partial_match=True), 323 | ] 324 | 325 | parent_page_types = ["Location"] 326 | 327 | class Meta: 328 | indexes = [ 329 | models.Index(fields=["meeting_location"]), 330 | models.Index(fields=["day_of_week"]), 331 | ] 332 | 333 | def save(self, *args, **kwargs): 334 | """ 335 | Associate the meeting with the Location parent and save. Then 336 | automatically assign the ONLINE meeting type if the field is 337 | populated. 338 | """ 339 | # Associate with the parent meeting location, and save in case this 340 | # is new, before we change meeting types. 341 | self.meeting_location = Location.objects.get(pk=self.get_parent().id) 342 | 343 | # Automagically add or remove the online meeting type. 344 | online_meeting_type = MeetingType.objects.get(spec_code="ONL") 345 | if self.conference_url: 346 | self.types.add(online_meeting_type) 347 | else: 348 | self.types.remove(online_meeting_type) 349 | 350 | super().save(*args, **kwargs) 351 | 352 | def __str__(self): 353 | return "{0} ({1}): {2} @ {3}".format( 354 | self.title, self.group, self.day_of_week, self.start_time 355 | ) 356 | -------------------------------------------------------------------------------- /meeting_guide/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def get_meeting_guide_settings(): 5 | """ 6 | Define default settings and allow them to be overridden. 7 | """ 8 | meeting_guide_settings = { 9 | "timezone": "America/New_York", 10 | "show": { 11 | "listButtons": True, 12 | }, 13 | "map": { 14 | "key": "", 15 | }, 16 | } 17 | 18 | meeting_guide_settings.update(settings.MEETING_GUIDE) 19 | 20 | return meeting_guide_settings 21 | 22 | 23 | def get_print_styles(): 24 | """ 25 | Default options for PDF styling. 26 | """ 27 | 28 | return getattr( 29 | settings, 30 | "WAGTAIL_MEETING_GUIDE_PRINT_STYLES", 31 | """ 32 | html, td { 33 | font-family: Arial, Helvetica, sans-serif; 34 | font-size: 11pt; 35 | -webkit-text-size-adjust: 100%; 36 | -ms-text-size-adjust: 100%; 37 | } 38 | 39 | table { 40 | width: 100%; 41 | } 42 | 43 | body { 44 | margin:0; 45 | } 46 | 47 | .region { 48 | page-break-inside: avoid; 49 | } 50 | 51 | .page-break { 52 | page-break-after: always; 53 | } 54 | 55 | h1, .h1 { 56 | font-size: 1px; 57 | } 58 | 59 | h2, .h2 { 60 | font-size: 1px; 61 | } 62 | 63 | h3, .h3 { 64 | font-size: 16pt; 65 | display: inline-block; 66 | } 67 | 68 | h4, .h4 { 69 | font-size: 12pt; 70 | } 71 | 72 | h5, .h5 { 73 | font-size: 11pt; 74 | } 75 | 76 | h6, .h6 { 77 | font-size: 9pt; 78 | } 79 | """, 80 | ) 81 | -------------------------------------------------------------------------------- /meeting_guide/static/meeting_guide/print.css: -------------------------------------------------------------------------------- 1 | html, td { 2 | font-family: "Arial Narrow", Arial, Helvetica, sans-serif; 3 | font-size: 13pt; 4 | -webkit-text-size-adjust: 100%; 5 | -ms-text-size-adjust: 100%; 6 | } 7 | 8 | table { 9 | width: 100%; 10 | } 11 | 12 | body { 13 | margin: 0; 14 | } 15 | 16 | @page { 17 | size: Letter; 18 | margin: 20mm 7mm 7mm 7mm; 19 | @top-center { 20 | content: string(header) ": " string(subheader) " (Page " counter(page) " of " counter(pages) ")"; 21 | font-family: Arial, Helvetica, sans-serif; 22 | font-size: 22pt; 23 | text-transform: uppercase; 24 | } 25 | } 26 | 27 | .region { 28 | page-break-inside: avoid; 29 | } 30 | 31 | .page-break { 32 | page-break-after: always; 33 | } 34 | 35 | .text-center { 36 | text-align: center; 37 | } 38 | 39 | header { 40 | width: 0; 41 | height: 0; 42 | visibility: hidden; 43 | string-set: header content(); 44 | } 45 | 46 | subheader { 47 | font-family: Arial, Helvetica, sans-serif; 48 | font-size: 20pt; 49 | text-align: center; 50 | text-transform: uppercase; 51 | padding-top: 5px; 52 | string-set: subheader content(); 53 | } 54 | 55 | h1, .h1 { 56 | font-family: Arial, Helvetica, sans-serif; 57 | font-size: 24pt; 58 | } 59 | 60 | h2, .h2 { 61 | font-family: Arial, Helvetica, sans-serif; 62 | font-size: 20pt; 63 | } 64 | 65 | h3, .h3 { 66 | font-family: Arial, Helvetica, sans-serif; 67 | font-size: 14pt; 68 | padding: 3px 0 0 0; 69 | margin: 0; 70 | text-decoration: underline; 71 | text-transform: uppercase; 72 | } 73 | 74 | h4, .h4 { 75 | font-size: 12pt; 76 | } 77 | 78 | h5, .h5 { 79 | font-size: 11pt; 80 | } 81 | 82 | h6, .h6 { 83 | font-size: 9pt; 84 | } 85 | 86 | .large-text { 87 | font-family: Arial, Helvetica, sans-serif; 88 | font-size: 26pt; 89 | } 90 | 91 | .medium-text { 92 | font-family: Arial, Helvetica, sans-serif; 93 | font-size: 18pt; 94 | } 95 | -------------------------------------------------------------------------------- /meeting_guide/templates/meeting_guide/location_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load bootstrap4 %} 3 | 4 | {% block content %} 5 | {% bootstrap_form form %} 6 | {% endblock content %} 7 | -------------------------------------------------------------------------------- /meeting_guide/templates/meeting_guide/meetings_home.html: -------------------------------------------------------------------------------- 1 | {% load meeting_guide %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | Meeting List 9 | 10 | 11 | 12 | 13 | {% meeting_guide %} 14 | 15 | 16 | -------------------------------------------------------------------------------- /meeting_guide/templates/meeting_guide/tags/meeting_guide.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 |
9 | 12 | {% comment %} 13 | Let's keep a local copy of the JavaScript here so we can control 14 | when upgrades occur and test. 15 | 16 | Source is at https://react.meetingguide.org/app.js 17 | {% endcomment %} 18 | 19 | -------------------------------------------------------------------------------- /meeting_guide/templates/wagtailadmin/pages/listing/_page_title_explore.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/pages/listing/_page_title_explore.html" %} 2 | 3 | {% block pages_listing_title_extra %} 4 | {% if page.day_of_week >= 0 and page.day_of_week <= 6 %} 5 | ({{ page.get_day_of_week_display }} {{ page.start_time }} - {{ page.end_time }}) 6 | {% endif %} 7 | {% if page.formatted_address %} 8 |
{{ page.formatted_address }} 9 | {% endif %} 10 | {% endblock pages_listing_title_extra %} 11 | -------------------------------------------------------------------------------- /meeting_guide/templates/wagtailadmin/pages/listing/_page_title_move.html: -------------------------------------------------------------------------------- 1 | {% comment %} 2 | The title field for a page in the page listing, when in 'move' mode. 3 | 4 | Expects a variable 'page', the page instance. 5 | {% endcomment %} 6 | 7 |
8 | {% if page.can_choose %} 9 | {{ page.specific.get_admin_display_title }}{% if page.specific.formatted_address %} ({{ page.specific.formatted_address }}){% endif %} 10 | {% else %} 11 | {{ page.get_admin_display_title }}{% if page.specific.formatted_address %} ({{ page.specific.formatted_address }}){% endif %} 12 | {% endif %} 13 | 14 | {% include "wagtailadmin/pages/listing/_privacy_indicator.html" with page=page %} 15 | {% include "wagtailadmin/pages/listing/_locked_indicator.html" with page=page %} 16 |
17 | -------------------------------------------------------------------------------- /meeting_guide/templates/wagtailadmin/pages/listing/_page_title_move_AFTER_WAGTAIL_PR_IN_CODERED.html: -------------------------------------------------------------------------------- 1 | {% extends "wagtailadmin/pages/listing/_page_title_move.html" %} 2 | 3 | {% block pages_listing_link_title_extra %}{% if page.specific.formatted_address %} ({{ page.specific.formatted_address }}){% endif %}{% endblock pages_listing_link_title_extra %} 4 | {% block pages_listing_title_extra %}{% if page.specific.formatted_address %} ({{ page.specific.formatted_address }}){% endif %}{% endblock pages_listing_title_extra %} 5 | -------------------------------------------------------------------------------- /meeting_guide/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code4recovery/wagtail-meeting-guide/7d9e9a472ea7efd178e60789c72f5e8a8995094a/meeting_guide/templatetags/__init__.py -------------------------------------------------------------------------------- /meeting_guide/templatetags/meeting_guide.py: -------------------------------------------------------------------------------- 1 | from json import dumps as json_dumps 2 | 3 | from django import template 4 | 5 | from meeting_guide.settings import get_meeting_guide_settings 6 | 7 | register = template.Library() 8 | 9 | 10 | @register.inclusion_tag("meeting_guide/tags/meeting_guide.html", takes_context=True) 11 | def meeting_guide(context): 12 | """ 13 | Display the ReactJS drive Meeting Guide list. 14 | """ 15 | settings = get_meeting_guide_settings() 16 | json_meeting_guide_settings = json_dumps(settings) 17 | return { 18 | "meeting_guide_settings": json_meeting_guide_settings, 19 | "mapbox_key": settings["map"]["key"], 20 | "timezone": settings["timezone"], 21 | } 22 | -------------------------------------------------------------------------------- /meeting_guide/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import MeetingsHomeView, MeetingsAPIView 4 | 5 | app_name = "meeting-guide" 6 | 7 | urlpatterns = [ 8 | path("", MeetingsHomeView.as_view(), name="home"), 9 | path("api/", MeetingsAPIView.as_view(), name="api"), 10 | ] 11 | -------------------------------------------------------------------------------- /meeting_guide/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | import requests 5 | 6 | from django.core.files import File 7 | from django.conf import settings 8 | 9 | from meeting_guide.models import Region 10 | 11 | 12 | def get_geocode_address(full_address): 13 | """ 14 | Given a full address, get the Google address information. Use a local cache. 15 | 16 | Returns `None` if Google doesn't return an address. 17 | """ 18 | 19 | address_components = {} 20 | address_components["problem"] = "OK" 21 | 22 | cache_filename = ( 23 | "meeting_guide_cache/" 24 | + re.sub("[^0-9a-zA-Z]+", "", full_address.lower().lstrip(" ")) 25 | + ".json" 26 | ) 27 | 28 | address_components["cache_status"] = "MISS" 29 | 30 | if os.path.isfile(cache_filename): 31 | # grab the json from the cache 32 | with open(cache_filename, "r") as f: 33 | django_file = File(f) 34 | cached_json = django_file.read() 35 | 36 | try: 37 | # JSON is valid 38 | address_data = json.loads(cached_json) 39 | address_components["cache_status"] = "HIT" 40 | except json.JSONDecodeError: 41 | # Invalid JSON, delete the file, get on next run 42 | os.remove(cache_filename) 43 | address_components["cache_status"] = "INVALID" 44 | 45 | if address_components["cache_status"] != "HIT": 46 | payload = { 47 | "bounds": settings.GOOGLE_MAPS_API_BOUNDS, 48 | "key": settings.GOOGLE_MAPS_V3_APIKEY, 49 | "address": full_address.lstrip(" ").replace("'", ""), 50 | } 51 | # payload = {'key': API_KEY, 'address': full_address.lstrip(' ')} 52 | api_request_url = "https://maps.googleapis.com/maps/api/geocode/json" 53 | 54 | # Send the request to Google Maps 55 | r = requests.get(api_request_url, params=payload) 56 | address_data = r.json() 57 | 58 | # Write cache file if status == OK 59 | if address_data["status"] == "OK": 60 | with open(cache_filename, "w") as f: 61 | django_file = File(f) 62 | json.dump(address_data, django_file, indent=4, separators=(",", ": ")) 63 | 64 | # We have the data in 'address_data', let's do something with 65 | # each address and the associated meeting information. 66 | # Test to see if LOCATION address exists. If so, return the id from MySQL 67 | if address_data["status"] == "ZERO_RESULTS": 68 | address_components[ 69 | "problem" 70 | ] = 'Google returned "ZERO_RESULTS" for address {0}:\n{1}\n\n.'.format( 71 | full_address, address_components 72 | ) 73 | elif address_data["status"] == "OVER_QUERY_LIMIT": 74 | address_components[ 75 | "problem" 76 | ] = 'Google returned "OVER_QUERY_LIMIT"; have we hit the API too much?' 77 | else: 78 | address_components["formatted_address"] = address_data["results"][0][ 79 | "formatted_address" 80 | ] 81 | 82 | for address_component in address_data["results"][0]["address_components"]: 83 | for address_component_type in address_component["types"]: 84 | address_components[address_component_type] = address_component[ 85 | "short_name" 86 | ] 87 | 88 | address_components["lat"] = address_data["results"][0]["geometry"]["location"][ 89 | "lat" 90 | ] 91 | address_components["lng"] = address_data["results"][0]["geometry"]["location"][ 92 | "lng" 93 | ] 94 | 95 | if ( 96 | "street_number" not in address_components 97 | or "route" not in address_components 98 | ): 99 | address_components[ 100 | "problem" 101 | ] = 'Google did not return "street_number" or "route" for address {0}:\n{1}\n\n.'.format( 102 | full_address, address_components 103 | ) 104 | else: 105 | address_components["full_address"] = ( 106 | address_components["street_number"] + " " + address_components["route"] 107 | ) 108 | 109 | # 110 | # This address is missing an administrative_area_level_2 reported here: 111 | # https://code.google.com/p/gmaps-api-issues/issues/detail?id=11492&thanks=11492&ts=1487542697 112 | # 113 | if "administrative_area_level_2" in address_components: 114 | address_components["region"] = address_components[ 115 | "administrative_area_level_2" 116 | ] 117 | else: 118 | address_components[ 119 | "problem" 120 | ] = 'Google did not return "administrative_area_level_2" (county / parish) for address {0}:\n{1}\n\n.'.format( 121 | full_address, address_components 122 | ) 123 | address_components["region"] = "" 124 | 125 | # Subregion: most granular to least granular 126 | if "neighborhood" in address_components: 127 | address_components["subregion"] = address_components["neighborhood"] 128 | elif "sublocality" in address_components: 129 | address_components["subregion"] = address_components["sublocality"] 130 | elif "locality" in address_components: 131 | address_components["subregion"] = address_components["locality"] 132 | elif "administrative_area_level_3" in address_components: 133 | address_components["subregion"] = address_components[ 134 | "administrative_area_level_3" 135 | ] 136 | elif "city" in address_components: 137 | address_components["subregion"] = address_components["city"] 138 | else: 139 | address_components["subregion"] = "" 140 | address_components[ 141 | "problem" 142 | ] = 'Google did not return "neighborhood", "locality", "sublocality", "city", or "administrative_area_level_3" for subregion field for address {0}:\n{1}\n\n.'.format( 143 | full_address, address_components 144 | ) 145 | 146 | # City: least granular below the county level 147 | if "city" in address_components: 148 | address_components["city"] = address_components["city"] 149 | elif "administrative_area_level_3" in address_components: 150 | address_components["city"] = address_components[ 151 | "administrative_area_level_3" 152 | ] 153 | elif "locality" in address_components: 154 | address_components["city"] = address_components["locality"] 155 | elif "sublocality" in address_components: 156 | address_components["city"] = address_components["sublocality"] 157 | elif "neighborhood" in address_components: 158 | address_components["city"] = address_components["neighborhood"] 159 | else: 160 | address_components["city"] = "" 161 | address_components[ 162 | "problem" 163 | ] = 'Google did not return "neighborhood", "locality", "city", or "administrative_area_level_3" for city field for address {0}:\n{1}\n\n.'.format( 164 | full_address, address_components 165 | ) 166 | 167 | return address_components 168 | 169 | 170 | def build_tree(regions): 171 | """ Build up our regions recursively """ 172 | items = [] 173 | for r in regions: 174 | item = {"label": r.name, "value": r.id, "children": []} 175 | 176 | if r.children.count(): 177 | item["children"] = build_tree(r.children.all()) 178 | 179 | items.append(item) 180 | 181 | return items 182 | 183 | 184 | def get_region_tree(): 185 | """ 186 | Generate deeply nested region data for use by react-dropdown-tree-select 187 | This returns a nested structure of lists and dicts of regions with their 188 | names, ids, and children. 189 | """ 190 | top_regions = Region.objects.filter(parent__isnull=True).prefetch_related( 191 | "children" 192 | ) 193 | return build_tree(top_regions) 194 | -------------------------------------------------------------------------------- /meeting_guide/validators.py: -------------------------------------------------------------------------------- 1 | from django.core import validators 2 | from django.utils.deconstruct import deconstructible 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | @deconstructible 7 | class CashAppUsernameValidator(validators.RegexValidator): 8 | """ 9 | Validator for CashApp usernames (from Square). 10 | - Must start with "$" 11 | - Only ASCII alphanumeric characters, hyphens, and underscores are supported. 12 | """ 13 | regex = r"^\$[a-zA-Z0-9_-]+$" 14 | message = _( 15 | "Enter a valid CashApp username. Must start with '$', and can contain " 16 | "letters, numbers, '-', and '_'." 17 | ) 18 | flags = 0 19 | 20 | 21 | @deconstructible 22 | class ConferencePhoneValidator(validators.RegexValidator): 23 | """ 24 | Only allow valid characters for conference phone numbers. 25 | - Only numeric, "+", ",", and "#" characters are allowed. 26 | """ 27 | regex = r'^[0-9+,#]+$' 28 | message = _( 29 | "Enter a valid conference phone number. The three groups of numbers in this " 30 | "example are a Zoom phone number, meeting code, and password: " 31 | "+19294362866,,2151234215#,,#,,12341234#" 32 | ) 33 | flags = 0 34 | 35 | 36 | @deconstructible 37 | class PayPalUsernameValidator(validators.RegexValidator): 38 | """ 39 | Validator for PayPal usernames. 40 | - 8-16 characters 41 | - Only ASCII alphanumeric characters are supported. 42 | """ 43 | regex = r'^[a-zA-Z0-9]+$' 44 | message = _( 45 | "Enter a valid PayPal username for https://paypal.me/. Only letters and " 46 | "numbers are valid." 47 | ) 48 | flags = 0 49 | 50 | 51 | @deconstructible 52 | class VenmoUsernameValidator(validators.RegexValidator): 53 | """ 54 | Validator for Venmo usernames. 55 | - Must start with "@" 56 | - Only ASCII alphanumeric characters, hyphens, and underscores are supported. 57 | """ 58 | regex = r"^@[a-zA-Z0-9_-]+$" 59 | message = _( 60 | "Enter a valid Venmo username. Must start with '@', and can contain letters, " 61 | "numbers, '-', and '_'." 62 | ) 63 | flags = 0 64 | -------------------------------------------------------------------------------- /meeting_guide/views.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import re 4 | 5 | from django.conf import settings 6 | from django.http import HttpResponse 7 | from django.utils.decorators import method_decorator 8 | from django.views.decorators.cache import cache_page 9 | from django.views.decorators.gzip import gzip_page 10 | from django.views.generic import TemplateView 11 | 12 | from .models import Meeting, MeetingType, Region 13 | from .settings import get_meeting_guide_settings 14 | 15 | 16 | @method_decorator( 17 | cache_page(3600 * 24 * 7, key_prefix="wagtail_meeting_guide_api_cache"), 18 | name='dispatch', 19 | ) 20 | class MeetingsBaseView(TemplateView): 21 | DAY_OF_WEEK = ( 22 | (0, "Sunday"), 23 | (1, "Monday"), 24 | (2, "Tuesday"), 25 | (3, "Wednesday"), 26 | (4, "Thursday"), 27 | (5, "Friday"), 28 | (6, "Saturday"), 29 | ) 30 | 31 | def get_meetings(self): 32 | return ( 33 | Meeting.objects.live().filter( 34 | status=Meeting.ACTIVE, 35 | ).select_related("meeting_location", "group").prefetch_related( 36 | "meeting_location__region", 37 | # 'types', # ParentalManyToManyField instead of ManyToManyField causes Django to throw up on this 38 | ).order_by("day_of_week", "start_time") 39 | ) 40 | 41 | 42 | class MeetingsHomeView(TemplateView): 43 | """ 44 | List all meetings in the Meeting Guide ReactJS plugin. 45 | """ 46 | 47 | template_name = "meeting_guide/meetings_home.html" 48 | 49 | def get_context_data(self, **kwargs): 50 | context = super().get_context_data(**kwargs) 51 | settings = get_meeting_guide_settings() 52 | context["settings"] = json.dumps(settings) 53 | context["mapbox_key"] = settings["map"]["key"] 54 | context["timezone"] = settings["timezone"] 55 | 56 | return context 57 | 58 | 59 | @method_decorator(gzip_page, name="dispatch") 60 | class MeetingsAPIView(MeetingsBaseView): 61 | """ 62 | Return a JSON response of the meeting list. 63 | """ 64 | 65 | def get(self, request, *args, **kwargs): 66 | meetings = self.get_meetings() 67 | meetings_dict = [] 68 | 69 | # Eager load all regions to reference below. 70 | regions = Region.objects.all().prefetch_related("children") 71 | 72 | for meeting in meetings: 73 | meeting_types = list( 74 | meeting.types.values_list("spec_code", flat=True) 75 | ) 76 | 77 | group_info = "" 78 | if len(meeting.district): 79 | location = f"{meeting.meeting_location.title} (D{meeting.district})" 80 | group_info = f"D{meeting.district}" 81 | else: 82 | location = meeting.meeting_location.title 83 | 84 | gso_number = getattr(meeting.group, "gso_number", None) 85 | if gso_number and len(gso_number): 86 | group_info += f" / GSO #{gso_number}" 87 | 88 | region_ancestors = list( 89 | regions.get(id=meeting.meeting_location.region.id) 90 | .get_ancestors(include_self=True) 91 | .values_list("name", flat=True) 92 | ) 93 | 94 | notes = meeting.details 95 | 96 | meeting_dict = { 97 | "name": meeting.title, 98 | "slug": meeting.slug, 99 | "notes": notes, 100 | "updated": f"{meeting.last_published_at if meeting.last_published_at else datetime.datetime.now():%Y-%m-%d %H:%M:%S}", 101 | "url": f"{settings.BASE_URL}/meetings/?meeting={meeting.slug}", 102 | "day": meeting.day_of_week, 103 | "time": f"{meeting.start_time:%H:%M}", 104 | "end_time": f"{meeting.end_time:%H:%M}", 105 | "conference_url": meeting.conference_url, 106 | "conference_phone": meeting.conference_phone, 107 | "types": meeting_types, 108 | "location": location, 109 | "formatted_address": meeting.meeting_location.formatted_address, 110 | "latitude": meeting.meeting_location.lat, 111 | "longitude": meeting.meeting_location.lng, 112 | "regions": region_ancestors, 113 | "group": group_info, 114 | } 115 | 116 | if len(meeting.paypal): 117 | meeting_dict["paypal"] = meeting.paypal 118 | 119 | if len(meeting.venmo): 120 | meeting_dict["venmo"] = meeting.venmo 121 | 122 | if "feedback_url" in settings.MEETING_GUIDE: 123 | meeting_dict["feedback_url"] = settings.MEETING_GUIDE["feedback_url"] 124 | 125 | meetings_dict.append(meeting_dict) 126 | 127 | return HttpResponse(json.dumps(meetings_dict), content_type="application/json") 128 | -------------------------------------------------------------------------------- /meeting_guide/wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache 2 | from django_filters import ModelChoiceFilter 3 | 4 | from wagtail.admin.filters import WagtailFilterSet 5 | from wagtail.signals import page_published 6 | from wagtail.snippets.models import register_snippet 7 | from wagtail.snippets.views.snippets import SnippetViewSet, SnippetViewSetGroup 8 | 9 | from .models import Group, GroupContribution, MeetingType, Region, Location, Meeting 10 | 11 | 12 | def receiver(sender, **kwargs): 13 | """ 14 | Clear the API cache whenever a Location or Meeting is published. 15 | """ 16 | cache.delete("wagtail_meeting_guide_api_cache") 17 | 18 | 19 | # Register the signal receive for Location and Meeting publishes. 20 | page_published.connect(receiver, sender=Location) 21 | page_published.connect(receiver, sender=Meeting) 22 | 23 | 24 | class RegionFilter(WagtailFilterSet): 25 | parent = ModelChoiceFilter( 26 | queryset=Region.objects.filter(parent__isnull=True).order_by("name"), 27 | label='Select Region' 28 | ) 29 | 30 | class Meta: 31 | model = Region 32 | fields = [] 33 | 34 | 35 | class MeetingTypeAdmin(SnippetViewSet): 36 | model = MeetingType 37 | menu_label = "Meeting Types" 38 | menu_icon = "folder-open-1" 39 | add_to_settings_menu = True 40 | list_display = ( 41 | "type_name", 42 | "intergroup_code", 43 | "spec_code", 44 | "display_order", 45 | ) 46 | ordering = ("display_order", "type_name") 47 | search_fields = ("type_name",) 48 | 49 | 50 | class RegionAdmin(SnippetViewSet): 51 | model = Region 52 | menu_icon = "doc-full-inverse" 53 | empty_value_display = "-----" 54 | list_display = ("parent", "name") 55 | ordering = ("parent", "name") 56 | filterset_class = RegionFilter 57 | 58 | 59 | class GroupAdmin(SnippetViewSet): 60 | model = Group 61 | menu_label = "Groups" 62 | menu_icon = "folder-open-inverse" 63 | add_to_settings_menu = False 64 | list_display = ("name", "gso_number") 65 | search_fields = ("name",) 66 | 67 | 68 | class GroupContributionAdmin(SnippetViewSet): 69 | model = GroupContribution 70 | menu_label = "Contributions" 71 | menu_icon = "folder-open-inverse" 72 | add_to_settings_menu = False 73 | list_display = ("group", "date", "amount") 74 | list_filter = ("group",) 75 | search_fields = ("group",) 76 | 77 | 78 | class MeetingGuideAdminGroup(SnippetViewSetGroup): 79 | menu_label = "Meeting Guide" 80 | menu_icon = "calendar-alt" 81 | menu_order = 1000 82 | items = (MeetingTypeAdmin, RegionAdmin, GroupAdmin, GroupContributionAdmin) 83 | 84 | 85 | register_snippet(MeetingGuideAdminGroup) 86 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("README.md") as f: 4 | long_description = f.read() 5 | 6 | setup( 7 | name="wagtail-meeting_guide", 8 | description=( 9 | "Meeting Guide compatible Python package for Django's Wagtail CMS: meetings, " 10 | "locations, and API.", 11 | ), 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | author="Tim Allen", 15 | author_email="flipper@peregrinesalon.com", 16 | url="https://github.com/meeting-guide/wagtail-meeting-guide", 17 | include_package_data=True, 18 | packages=find_packages(), 19 | zip_safe=False, 20 | setup_requires=["setuptools_scm"], 21 | use_scm_version=True, 22 | install_requires=[ 23 | "wagtailgeowidget>6", 24 | "django-mptt", 25 | ], 26 | classifiers=[ 27 | "Development Status :: 5 - Production/Stable", 28 | "Environment :: Web Environment", 29 | "Intended Audience :: Developers", 30 | "License :: OSI Approved :: BSD License", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3.7", 34 | "Programming Language :: Python :: 3.8", 35 | "Programming Language :: Python :: 3.9", 36 | "Programming Language :: Python :: 3.10", 37 | "Programming Language :: Python :: 3.11", 38 | "Programming Language :: Python :: 3 :: Only", 39 | "Framework :: Django", 40 | "Framework :: Django :: 2.0", 41 | "Framework :: Django :: 3.0", 42 | "Framework :: Wagtail", 43 | "Framework :: Wagtail :: 2", 44 | "Topic :: Internet :: WWW/HTTP", 45 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 46 | ], 47 | ) 48 | --------------------------------------------------------------------------------