├── pythonie ├── core │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0009_auto_20160703_0857.py │ │ ├── 0005_auto_20150726_1416.py │ │ ├── 0007_homepage_sponsors.py │ │ ├── 0003_media_in_simple_page.py │ │ ├── 0006_homepagesponsorrelationship.py │ │ ├── 0002_simplepage.py │ │ ├── 0004_auto_20150708_1416.py │ │ ├── 0008_auto_20150821_2230.py │ │ └── 0001_initial.py │ ├── static │ │ ├── css │ │ │ ├── pythonie.scss │ │ │ ├── tweet.css │ │ │ ├── jquery.smartmenus.bootstrap.css │ │ │ ├── tito.css │ │ │ ├── bootstrap-glyphicons.css │ │ │ └── style.css │ │ ├── js │ │ │ ├── pythonie.js │ │ │ ├── npm.js │ │ │ ├── hallo-custombuttons.js │ │ │ ├── theme-toggle.js │ │ │ └── jquery.smartmenus.bootstrap.js │ │ ├── icons │ │ │ └── favicon.ico │ │ ├── img │ │ │ ├── pythonie.png │ │ │ ├── twitter-little-bird.png │ │ │ └── twitter-little-bird-button.png │ │ └── fonts │ │ │ ├── FontAwesome.otf │ │ │ ├── fontawesome-webfont.eot │ │ │ ├── fontawesome-webfont.ttf │ │ │ ├── fontawesome-webfont.woff │ │ │ ├── fontawesome-webfont.woff2 │ │ │ ├── glyphicons-halflings-regular.eot │ │ │ ├── glyphicons-halflings-regular.ttf │ │ │ ├── glyphicons-halflings-regular.woff │ │ │ └── glyphicons-halflings-regular.woff2 │ ├── templatetags │ │ ├── __init__.py │ │ └── core_tags.py │ ├── templates │ │ ├── core │ │ │ ├── segment.html │ │ │ ├── speakers.html │ │ │ ├── sponsor.html │ │ │ ├── simple_page.html │ │ │ ├── home_page.html │ │ │ └── meetup.html │ │ ├── 404.html │ │ ├── footer.html │ │ ├── 500.html │ │ ├── header.html │ │ ├── navbar.html │ │ ├── navbar_tree.html │ │ └── base.html │ ├── admin.py │ ├── wagtail_hooks.py │ └── models.py ├── pythonie │ ├── __init__.py │ ├── settings │ │ ├── __init__.py │ │ ├── configure.py │ │ ├── patches.py │ │ ├── dev.py │ │ ├── tests.py │ │ ├── production.py │ │ └── base.py │ ├── wsgi.py │ └── urls.py ├── speakers │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── update-sessionize-json-stream.py │ │ │ └── import-sessionize.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0004_alter_session_room.py │ │ ├── 0001_initial.py │ │ ├── 0002_talkspage.py │ │ └── 0003_auto_20220929_1828.py │ ├── templatetags │ │ ├── __init__.py │ │ └── speaker_tags.py │ ├── templates │ │ └── speakers │ │ │ ├── speaker_picture.html │ │ │ ├── session.html │ │ │ ├── speaker.html │ │ │ ├── speakers_page.html │ │ │ └── talks_page.html │ ├── admin.py │ └── models.py ├── sponsors │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0004_auto_20150726_1412.py │ │ ├── 0002_restructure_sponsor.py │ │ ├── 0003_sponsorshiplevel.py │ │ └── 0001_initial.py │ ├── admin.py │ ├── test_sponsors.py │ └── models.py ├── meetups │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── updatemeetups.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0005_delete_meetupupdate.py │ │ ├── 0007_alter_meetup_options.py │ │ ├── 0003_remove_meetup_announced.py │ │ ├── 0004_auto_20150705_1641.py │ │ ├── 0006_auto_20160703_0857.py │ │ ├── 0002_add_meetup_sponsor_relationship.py │ │ └── 0001_initial.py │ ├── __init__.py │ ├── admin.py │ ├── models.py │ ├── utils.py │ ├── schema.py │ └── test_meetups.py ├── tox.ini ├── templates │ └── wagtailembeds │ │ └── embed_frontend.html ├── setup.py ├── .gitignore ├── manage.py ├── vagrant │ └── provision.sh └── Vagrantfile ├── runtime.txt ├── .tool-versions ├── .coveragerc ├── requirements.txt ├── Procfile ├── requirements ├── dev.in ├── production.in ├── production.txt ├── main.in ├── dev.txt └── main.txt ├── Dockerfile ├── .github └── workflows │ └── test.yml ├── .gitignore ├── docker-compose.yml ├── toast.yml ├── .claude └── commands │ └── commit.md ├── Taskfile.yaml ├── README.md └── CLAUDE.md /pythonie/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/pythonie/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/speakers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/sponsors/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.13.9 2 | -------------------------------------------------------------------------------- /pythonie/core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/core/static/css/pythonie.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/core/static/js/pythonie.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/core/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/pythonie/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/meetups/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/meetups/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/speakers/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/speakers/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/speakers/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/sponsors/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | python 3.13.9 2 | task 3.37.2 3 | -------------------------------------------------------------------------------- /pythonie/meetups/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/speakers/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythonie/meetups/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "diarmuid" 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = pythonie 4 | 5 | -------------------------------------------------------------------------------- /pythonie/tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 80 3 | exclude = *migrations* 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/main.txt 2 | -r requirements/production.txt 3 | -------------------------------------------------------------------------------- /pythonie/core/static/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PythonIreland/website/HEAD/pythonie/core/static/icons/favicon.ico -------------------------------------------------------------------------------- /pythonie/core/static/img/pythonie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PythonIreland/website/HEAD/pythonie/core/static/img/pythonie.png -------------------------------------------------------------------------------- /pythonie/templates/wagtailembeds/embed_frontend.html: -------------------------------------------------------------------------------- 1 |
9 | Python Ireland
10 | | Speaker | 20 |||
|---|---|---|
| {% speaker_picture speaker %} | 26 |27 | {{ speaker.name }} 28 | | 29 |30 | {{ speaker.biography|safe }} 31 | | 32 |
| Session | 22 |When | 23 |Duration | 24 |
|---|---|---|
| 30 | {{ session.name }} 31 | | 32 |{{ session.scheduled_at }} | 33 |{{ session.duration }} | 34 |
When: {{ meetup.time|timezone:"Europe/Dublin" }} IST
9 | {% if meetup.venue %} 10 |Where: {{ meetup.venue.name }}
11 | {% endif %} 12 |{{ meetup.rsvps }} people are attending. Click for more information and to 13 | RSVP
14 | 15 | {% if meetup.sponsors.count %} 16 |").attr({ 35 | "class" : 'tm-click-to-tweet', 36 | }).append(link); 37 | 38 | var tweet = $("").attr({"class" : 'tm-ctt-tip'}).append( 39 | link.clone().attr({"class": 'tm-ctt-btn'}).text('Click to tweet')); 40 | 41 | elem.append(tweet); 42 | var node = lastSelection.createContextualFragment($('
').append(elem).html()); 43 | 44 | lastSelection.deleteContents(); 45 | lastSelection.insertNode(node); 46 | 47 | return widget.options.editable.element.trigger('change'); 48 | }); 49 | } 50 | }); 51 | })(jQuery); 52 | 53 | }).call(this); 54 | -------------------------------------------------------------------------------- /pythonie/pythonie/settings/production.py: -------------------------------------------------------------------------------- 1 | from pythonie.settings.configure import configure_redis 2 | 3 | from .base import * # flake8: noqa 4 | 5 | # Disable debug mode 6 | DEBUG = False 7 | 8 | # AWS S3 Storage Configuration 9 | AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") 10 | AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY") 11 | AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME") 12 | AWS_S3_CUSTOM_DOMAIN = "s3.python.ie" 13 | AWS_HOST = "s3-eu-west-1.amazonaws.com" 14 | AWS_DEFAULT_ACL = "public-read" 15 | 16 | # Use S3 for media files (Django 5.1+ STORAGES API) 17 | STORAGES = { 18 | "default": { 19 | "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", 20 | }, 21 | "staticfiles": { 22 | "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", 23 | }, 24 | } 25 | 26 | MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/" 27 | 28 | # Compress static files offline 29 | # http://django-compressor.readthedocs.org/en/latest/settings/#django.conf.settings.COMPRESS_OFFLINE 30 | 31 | COMPRESS_OFFLINE = False 32 | CSRF_TRUSTED_ORIGINS = ["https://python.ie"] 33 | 34 | REDIS_URL = os.environ.get("REDISCLOUD_URL") 35 | REDIS = configure_redis(REDIS_URL) 36 | 37 | # Send notification emails as a background task using Celery, 38 | # to prevent this from blocking web server threads 39 | # (requires the django-celery package): 40 | # http://celery.readthedocs.org/en/latest/configuration.html 41 | 42 | # import djcelery 43 | # 44 | # djcelery.setup_loader() 45 | # 46 | # CELERY_SEND_TASK_ERROR_EMAILS = True 47 | # BROKER_URL = 'redis://' 48 | 49 | 50 | # Use Redis as the cache backend for extra performance 51 | # (requires the django-redis-cache package): 52 | # http://wagtail.readthedocs.org/en/latest/howto/performance.html#cache 53 | 54 | # CACHES = { 55 | # 'default': { 56 | # 'BACKEND': 'redis_cache.cache.RedisCache', 57 | # 'LOCATION': '127.0.0.1:6379', 58 | # 'KEY_PREFIX': 'pythonie', 59 | # 'OPTIONS': { 60 | # 'CLIENT_CLASS': 'redis_cache.client.DefaultClient', 61 | # } 62 | # } 63 | # } 64 | 65 | 66 | try: 67 | from .local import * # noqa 68 | except ImportError: 69 | pass 70 | -------------------------------------------------------------------------------- /pythonie/meetups/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | 5 | from meetups import models, schema 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | def get_content(url, params=None): 11 | response = requests.get(url, params=params, timeout=3) 12 | log.info("Response from meetups request: {0}".format(response)) 13 | if not response: 14 | return [] 15 | log.debug("Retrieved {} from {}".format(response.json(), url)) 16 | return response.json() 17 | 18 | 19 | def update(): 20 | """Contacts meetup.com API to retrieve meetup data""" 21 | log.info("Updating meetups") 22 | meetup_data = get_content( 23 | "https://api.meetup.com/2/events.html", 24 | params={"group_urlname": "pythonireland", "text_format": "html", "time": ",3m"}, 25 | ) 26 | if not meetup_data: 27 | log.warn("No meetup data returned from API") 28 | return 29 | log.info(meetup_data) 30 | meetups = schema.Meetups() 31 | meetups = meetups.deserialize(meetup_data) 32 | log.info(meetups) 33 | for result in meetups.get("results"): 34 | # Check if the meetup exists 35 | meetup = ( 36 | models.Meetup.objects.filter(id=result["id"]).first() or models.Meetup() 37 | ) 38 | meetup.rsvps = result.get("yes_rsvp_count") 39 | meetup.maybe_rsvps = result.get("maybe_rsvp_count") 40 | meetup.waitlist_count = result.get("waitlist_count") 41 | 42 | if result["updated"] <= meetup.updated: 43 | log.info("Existing meetup:{!r} RSVPs updated".format(meetup)) 44 | meetup.save() 45 | continue 46 | 47 | meetup.id = result.get("id") 48 | meetup.visibility = result.get("visibility") 49 | meetup.created = result.get("created") 50 | meetup.name = result.get("name") 51 | meetup.description = result.get("description") 52 | meetup.event_url = result.get("event_url") 53 | meetup.time = result.get("time") 54 | meetup.updated = result.get("updated") 55 | meetup.status = result.get("status") 56 | meetup.visibility = result.get("visibility") 57 | meetup.save() 58 | log.info("Existing meetup:{!r} fully updated".format(meetup)) 59 | -------------------------------------------------------------------------------- /pythonie/core/migrations/0008_auto_20150821_2230.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import wagtail.blocks 5 | import wagtail.fields 6 | import wagtail.embeds.blocks 7 | import wagtail.images.blocks 8 | from django.db import migrations, models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | dependencies = [ 14 | ("core", "0007_homepage_sponsors"), 15 | ] 16 | 17 | operations = [ 18 | migrations.AlterField( 19 | model_name="homepage", 20 | name="body", 21 | field=wagtail.fields.StreamField( 22 | ( 23 | ( 24 | "heading", 25 | wagtail.blocks.CharBlock( 26 | icon="home", classname="full title" 27 | ), 28 | ), 29 | ("paragraph", wagtail.blocks.RichTextBlock(icon="edit")), 30 | ("video", wagtail.embeds.blocks.EmbedBlock(icon="media")), 31 | ("image", wagtail.images.blocks.ImageChooserBlock(icon="image")), 32 | ("slide", wagtail.embeds.blocks.EmbedBlock(icon="media")), 33 | ("html", wagtail.blocks.RawHTMLBlock(icon="code")), 34 | ) 35 | ), 36 | preserve_default=True, 37 | ), 38 | migrations.AlterField( 39 | model_name="simplepage", 40 | name="body", 41 | field=wagtail.fields.StreamField( 42 | ( 43 | ( 44 | "heading", 45 | wagtail.blocks.CharBlock( 46 | icon="home", classname="full title" 47 | ), 48 | ), 49 | ("paragraph", wagtail.blocks.RichTextBlock(icon="edit")), 50 | ("video", wagtail.embeds.blocks.EmbedBlock(icon="media")), 51 | ("image", wagtail.images.blocks.ImageChooserBlock(icon="image")), 52 | ("slide", wagtail.embeds.blocks.EmbedBlock(icon="media")), 53 | ("html", wagtail.blocks.RawHTMLBlock(icon="code")), 54 | ) 55 | ), 56 | preserve_default=True, 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /pythonie/core/static/js/theme-toggle.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | var storageKey = 'pythonie-theme'; 3 | var className = 'dark'; 4 | var root = document.documentElement; 5 | var toggleButton; 6 | 7 | function getStoredTheme() { 8 | try { 9 | return window.localStorage ? localStorage.getItem(storageKey) : null; 10 | } catch (e) { 11 | return null; 12 | } 13 | } 14 | 15 | function setStoredTheme(theme) { 16 | try { 17 | if (window.localStorage) { 18 | localStorage.setItem(storageKey, theme); 19 | } 20 | } catch (e) { 21 | /* ignore storage errors */ 22 | } 23 | } 24 | 25 | function prefersDark() { 26 | return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 27 | } 28 | 29 | function getActiveTheme() { 30 | return getStoredTheme() || (prefersDark() ? 'dark' : 'light'); 31 | } 32 | 33 | function applyTheme(theme) { 34 | if (theme === 'dark') { 35 | root.classList.add(className); 36 | } else { 37 | root.classList.remove(className); 38 | theme = 'light'; 39 | } 40 | root.dataset.theme = theme; 41 | updateToggle(theme); 42 | } 43 | 44 | function updateToggle(theme) { 45 | if (!toggleButton) return; 46 | var isDark = theme === 'dark'; 47 | toggleButton.setAttribute('aria-pressed', isDark); 48 | toggleButton.classList.toggle('is-dark', isDark); 49 | var label = toggleButton.querySelector('[data-theme-label]'); 50 | if (label) label.textContent = isDark ? 'Light' : 'Dark'; 51 | } 52 | 53 | function handleToggle() { 54 | var isDark = root.classList.contains(className); 55 | var nextTheme = isDark ? 'light' : 'dark'; 56 | applyTheme(nextTheme); 57 | setStoredTheme(nextTheme); 58 | } 59 | 60 | function initThemeToggle() { 61 | toggleButton = document.querySelector('[data-theme-toggle]'); 62 | if (!toggleButton) { 63 | return; 64 | } 65 | toggleButton.addEventListener('click', handleToggle); 66 | applyTheme(getActiveTheme()); 67 | } 68 | 69 | document.addEventListener('DOMContentLoaded', initThemeToggle); 70 | })(); 71 | -------------------------------------------------------------------------------- /pythonie/speakers/templates/speakers/speakers_page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load wagtailcore_tags %} 4 | {% load static core_tags speaker_tags %} 5 | 6 | {% block body_class %}template-{{ self.get_verbose_name|slugify }}{% endblock %} 7 | 8 | {% block content %} 9 |10 |51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /pythonie/speakers/templates/speakers/talks_page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load wagtailcore_tags %} 4 | {% load static core_tags speaker_tags %} 5 | 6 | {% block body_class %}template-{{ self.get_verbose_name|slugify }}{% endblock %} 7 | 8 | {% block content %} 9 |11 |50 |12 |49 |13 |48 |14 | 15 | 16 |47 |17 |23 | 24 |18 | 19 | PyCon Ireland 2022 Speakers 20 | 21 |
22 |25 | 26 | {% for speaker in self.speakers %} 27 |
46 |28 | 42 | {% endfor %} 43 | 44 | 45 |{% speaker_picture speaker %} 29 |30 | {{ speaker.name }} 31 | 32 |33 | 41 |34 | {% for session in speaker.sessions.all %} 35 |
40 |- 36 | {{ session.name }} 37 |
38 | {% endfor %} 39 |10 |60 | {% endblock %} 61 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --output-file requirements/dev.txt requirements/dev.in 3 | asgiref==3.11.0 4 | # via 5 | # -c requirements/main.txt 6 | # django 7 | boolean-py==5.0 8 | # via license-expression 9 | cachecontrol==0.14.4 10 | # via pip-audit 11 | certifi==2025.11.12 12 | # via 13 | # -c requirements/main.txt 14 | # requests 15 | charset-normalizer==3.4.4 16 | # via 17 | # -c requirements/main.txt 18 | # requests 19 | coverage==7.12.0 20 | # via -r requirements/dev.in 21 | cyclonedx-python-lib==9.1.0 22 | # via pip-audit 23 | defusedxml==0.7.1 24 | # via 25 | # -c requirements/main.txt 26 | # py-serializable 27 | django==5.2.8 28 | # via 29 | # -c requirements/main.txt 30 | # django-debug-toolbar 31 | # model-mommy 32 | django-debug-toolbar==6.1.0 33 | # via -r requirements/dev.in 34 | fakeredis==2.32.1 35 | # via -r requirements/dev.in 36 | filelock==3.20.0 37 | # via cachecontrol 38 | idna==3.11 39 | # via 40 | # -c requirements/main.txt 41 | # requests 42 | isort==7.0.0 43 | # via -r requirements/dev.in 44 | license-expression==30.4.4 45 | # via cyclonedx-python-lib 46 | markdown-it-py==4.0.0 47 | # via rich 48 | mdurl==0.1.2 49 | # via markdown-it-py 50 | model-mommy==2.0.0 51 | # via -r requirements/dev.in 52 | msgpack==1.1.2 53 | # via cachecontrol 54 | packageurl-python==0.17.6 55 | # via cyclonedx-python-lib 56 | packaging==25.0 57 | # via 58 | # -c requirements/main.txt 59 | # pip-audit 60 | # pip-requirements-parser 61 | # pipdeptree 62 | pip==25.3 63 | # via 64 | # pip-api 65 | # pipdeptree 66 | pip-api==0.0.34 67 | # via pip-audit 68 | pip-audit==2.9.0 69 | # via -r requirements/dev.in 70 | pip-requirements-parser==32.0.1 71 | # via pip-audit 72 | pipdeptree==2.30.0 73 | # via -r requirements/dev.in 74 | platformdirs==4.5.0 75 | # via pip-audit 76 | py-serializable==2.1.0 77 | # via cyclonedx-python-lib 78 | pygments==2.19.2 79 | # via rich 80 | pyparsing==3.2.5 81 | # via pip-requirements-parser 82 | redis==7.1.0 83 | # via 84 | # -c requirements/main.txt 85 | # fakeredis 86 | requests==2.32.5 87 | # via 88 | # -c requirements/main.txt 89 | # cachecontrol 90 | # pip-audit 91 | rich==14.2.0 92 | # via pip-audit 93 | ruff==0.14.7 94 | # via -r requirements/dev.in 95 | sortedcontainers==2.4.0 96 | # via 97 | # cyclonedx-python-lib 98 | # fakeredis 99 | sqlparse==0.5.4 100 | # via 101 | # -c requirements/main.txt 102 | # django 103 | # django-debug-toolbar 104 | toml==0.10.2 105 | # via pip-audit 106 | urllib3==2.5.0 107 | # via 108 | # -c requirements/main.txt 109 | # requests 110 | uv==0.9.13 111 | # via -r requirements/dev.in 112 | -------------------------------------------------------------------------------- /toast.yml: -------------------------------------------------------------------------------- 1 | image: python.ie/website-dev 2 | tasks: 3 | deps:compute: 4 | description: Compute the dependencies 5 | cache: false 6 | mount_paths: 7 | - .:/app 8 | location: /app 9 | command: | 10 | python -m uv pip compile --output-file requirements/main.txt requirements/main.in 11 | python -m uv pip compile --output-file requirements/dev.txt requirements/dev.in 12 | python -m uv pip compile --output-file requirements/production.txt requirements/production.in 13 | 14 | deps:outdated: 15 | description: Show the outdated dependencies 16 | cache: false 17 | command: python -m pip list -o 18 | 19 | deps:upgrade: 20 | description: Upgrade the dependencies 21 | cache: false 22 | mount_paths: 23 | - .:/app 24 | location: /app 25 | command: | 26 | python -m uv pip compile --upgrade --output-file requirements/main.txt requirements/main.in 27 | python -m uv pip compile --upgrade --output-file requirements/dev.txt requirements/dev.in 28 | python -m uv pip compile --upgrade --output-file requirements/production.txt requirements/production.in 29 | 30 | deps:upgrade:django: 31 | description: Upgrade Django to the latest compatible version 32 | cache: false 33 | mount_paths: 34 | - .:/app 35 | location: /app 36 | command: | 37 | python -m uv pip compile --upgrade-package django --output-file requirements/main.txt requirements/main.in 38 | python -m uv pip compile --upgrade-package django --output-file requirements/dev.txt requirements/dev.in 39 | python -m uv pip compile --upgrade-package django --output-file requirements/production.txt requirements/production.in 40 | 41 | deps:upgrade:wagtail: 42 | description: Upgrade Wagtail to the latest compatible version 43 | cache: false 44 | mount_paths: 45 | - .:/app 46 | location: /app 47 | command: | 48 | python -m uv pip compile --upgrade-package wagtail --output-file requirements/main.txt requirements/main.in 49 | python -m uv pip compile --upgrade-package wagtail --output-file requirements/dev.txt requirements/dev.in 50 | python -m uv pip compile --upgrade-package wagtail --output-file requirements/production.txt requirements/production.in 51 | 52 | deps:security: 53 | description: Check dependencies for known security vulnerabilities 54 | cache: false 55 | command: pip-audit 56 | 57 | deps:tree: 58 | description: Show the dependencies tree 59 | cache: false 60 | command: pipdeptree 61 | 62 | code:format: 63 | description: Format the code 64 | cache: false 65 | mount_paths: 66 | - .:/app 67 | location: /app 68 | command: | 69 | python -m ruff format pythonie 70 | 71 | code:lint: 72 | description: Lint the code and fix issues 73 | cache: false 74 | mount_paths: 75 | - .:/app 76 | location: /app 77 | command: | 78 | python -m ruff check --fix pythonie 79 | 80 | code:check: 81 | description: Check code formatting and linting without changes 82 | cache: false 83 | mount_paths: 84 | - .:/app 85 | location: /app 86 | command: | 87 | python -m ruff format --check pythonie 88 | python -m ruff check pythonie 89 | 90 | fish: 91 | description: Run a shell 92 | cache: false 93 | mount_paths: 94 | - .:/app 95 | location: /app 96 | command: /usr/bin/fish 97 | -------------------------------------------------------------------------------- /pythonie/meetups/schema.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import colander 4 | from delorean import Delorean 5 | 6 | 7 | def int_to_datetime(value): 8 | time = datetime.utcfromtimestamp(value / 1000) 9 | return Delorean(time, timezone="UTC").shift("Europe/Dublin").datetime 10 | 11 | 12 | class Meetups(colander.MappingSchema): 13 | class meta(colander.MappingSchema): 14 | lat = colander.SchemaNode(colander.String()) 15 | lon = colander.SchemaNode(colander.String()) 16 | updated = colander.SchemaNode(colander.DateTime()) 17 | title = colander.SchemaNode(colander.String()) 18 | description = colander.SchemaNode(colander.String()) 19 | count = colander.SchemaNode(colander.Integer()) 20 | link = colander.SchemaNode(colander.String()) 21 | total_count = colander.SchemaNode(colander.Integer()) 22 | url = colander.SchemaNode(colander.String()) 23 | 24 | @colander.instantiate(missing=[]) 25 | class results(colander.SequenceSchema): 26 | @colander.instantiate() 27 | class result(colander.MappingSchema): 28 | id = colander.SchemaNode(colander.String()) 29 | event_url = colander.SchemaNode(colander.String()) 30 | status = colander.SchemaNode(colander.String()) 31 | 32 | created = colander.SchemaNode(colander.Integer(), preparer=int_to_datetime) 33 | updated = colander.SchemaNode(colander.Integer(), preparer=int_to_datetime) 34 | 35 | time = colander.SchemaNode(colander.Integer(), preparer=int_to_datetime) 36 | utc_offset = colander.SchemaNode(colander.Integer()) 37 | 38 | @colander.instantiate() 39 | class group(colander.MappingSchema): 40 | who = colander.SchemaNode(colander.String()) 41 | join_mode = colander.SchemaNode(colander.String()) 42 | id = colander.SchemaNode(colander.Integer()) 43 | name = colander.SchemaNode(colander.String()) 44 | urlname = colander.SchemaNode(colander.String()) 45 | group_lon = colander.SchemaNode(colander.Float()) 46 | group_lat = colander.SchemaNode(colander.Float()) 47 | created = colander.SchemaNode(colander.Integer()) 48 | 49 | @colander.instantiate(missing=colander.drop) 50 | class venue(colander.MappingSchema): 51 | # city = colander.SchemaNode(colander.String()) 52 | lon = colander.SchemaNode(colander.Float()) 53 | repinned = colander.SchemaNode(colander.Boolean()) 54 | lat = colander.SchemaNode(colander.Float()) 55 | id = colander.SchemaNode(colander.Float()) 56 | name = colander.SchemaNode(colander.String()) 57 | # address_1 = colander.SchemaNode(colander.String()) 58 | # country = colander.SchemaNode(colander.String()) 59 | 60 | name = colander.SchemaNode(colander.String()) 61 | headcount = colander.SchemaNode(colander.Integer()) 62 | description = colander.SchemaNode(colander.String(), missing="") 63 | visibility = colander.SchemaNode(colander.String()) 64 | duration = colander.SchemaNode(colander.Integer(), missing=colander.drop) 65 | 66 | maybe_rsvp_count = colander.SchemaNode(colander.Integer()) 67 | yes_rsvp_count = colander.SchemaNode(colander.Integer()) 68 | rsvp_limit = colander.SchemaNode(colander.Integer(), missing=colander.drop) 69 | waitlist_count = colander.SchemaNode(colander.Integer()) 70 | -------------------------------------------------------------------------------- /pythonie/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import django.utils.timezone 5 | import modelcluster.fields 6 | import wagtail.fields 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ("wagtailcore", "0013_update_golive_expire_help_text"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="HomePage", 19 | fields=[ 20 | ( 21 | "page_ptr", 22 | models.OneToOneField( 23 | auto_created=True, 24 | serialize=False, 25 | to="wagtailcore.Page", 26 | primary_key=True, 27 | parent_link=True, 28 | on_delete=models.CASCADE, 29 | ), 30 | ), 31 | ], 32 | options={ 33 | "abstract": False, 34 | }, 35 | bases=("wagtailcore.page",), 36 | ), 37 | migrations.CreateModel( 38 | name="HomePageSegment", 39 | fields=[ 40 | ( 41 | "id", 42 | models.AutoField( 43 | auto_created=True, 44 | serialize=False, 45 | verbose_name="ID", 46 | primary_key=True, 47 | ), 48 | ), 49 | ( 50 | "sort_order", 51 | models.IntegerField(blank=True, null=True, editable=False), 52 | ), 53 | ( 54 | "homepage", 55 | modelcluster.fields.ParentalKey( 56 | to="core.HomePage", related_name="homepage_segments" 57 | ), 58 | ), 59 | ], 60 | options={ 61 | "verbose_name": "Homepage Segment", 62 | "verbose_name_plural": "Homepage Segments", 63 | }, 64 | bases=(models.Model,), 65 | ), 66 | migrations.CreateModel( 67 | name="PageSegment", 68 | fields=[ 69 | ( 70 | "id", 71 | models.AutoField( 72 | auto_created=True, 73 | serialize=False, 74 | verbose_name="ID", 75 | primary_key=True, 76 | ), 77 | ), 78 | ("title", models.CharField(max_length=255)), 79 | ("body", wagtail.fields.RichTextField()), 80 | ( 81 | "location", 82 | models.CharField( 83 | default="main", 84 | max_length=5, 85 | choices=[ 86 | ("main", "Main section"), 87 | ("right", "Right side"), 88 | ("left", "Left side"), 89 | ], 90 | ), 91 | ), 92 | ], 93 | options={}, 94 | bases=(models.Model,), 95 | ), 96 | migrations.AddField( 97 | model_name="homepagesegment", 98 | name="segment", 99 | field=models.ForeignKey( 100 | to="core.PageSegment", 101 | related_name="homepage_segments", 102 | on_delete=models.CASCADE, 103 | ), 104 | preserve_default=True, 105 | ), 106 | ] 107 | -------------------------------------------------------------------------------- /pythonie/speakers/models.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import requests 5 | from django.conf import settings 6 | from django.db import models 7 | from wagtail.admin.panels import FieldPanel 8 | from wagtail.models import Page 9 | 10 | log = logging.getLogger("speakers") 11 | 12 | 13 | class Room(models.Model): 14 | name = models.CharField(max_length=255, blank=False) 15 | 16 | def __str__(self): 17 | return self.name 18 | 19 | class Meta: 20 | ordering = ["name"] 21 | 22 | 23 | class Speaker(Page): 24 | name = models.CharField(max_length=255, blank=False) 25 | email = models.CharField(max_length=255, blank=False) 26 | external_id = models.CharField(max_length=255, unique=True, blank=True) 27 | picture_url = models.CharField(max_length=255, blank=True) 28 | biography = models.TextField() 29 | created_at = models.DateTimeField(auto_now_add=True) 30 | updated_at = models.DateTimeField(auto_now=True) 31 | 32 | content_panels = Page.content_panels + [ 33 | FieldPanel("name"), 34 | FieldPanel("email"), 35 | FieldPanel("external_id"), 36 | FieldPanel("biography"), 37 | FieldPanel("picture_url"), 38 | ] 39 | 40 | def __str__(self): 41 | return self.name 42 | 43 | class Meta: 44 | ordering = ["name"] 45 | 46 | 47 | class SpeakersPage(Page): 48 | subpage_types = ["Speaker"] 49 | 50 | @property 51 | def speakers(self): 52 | return Speaker.objects.order_by("name").all() 53 | 54 | 55 | # In this context, a session is a link to a future proposal (talk) 56 | class Session(Page): 57 | class StateChoices(models.TextChoices): 58 | DRAFT = "draft", "Draft" 59 | ACCEPTED = "accepted", "Accepted" 60 | CONFIRMED = "confirmed", "Confirmed" 61 | REFUSED = "refused", "Refused" 62 | CANCELLED = "cancelled", "Cancelled" 63 | 64 | class TypeChoices(models.TextChoices): 65 | TALK = "talk", "Talk" 66 | WORKSHOP = "workshop", "Workshop" 67 | 68 | name = models.CharField(max_length=255, blank=False, db_index=True) 69 | description = models.TextField() 70 | scheduled_at = models.DateTimeField() 71 | duration = models.IntegerField(default=30) 72 | created_at = models.DateTimeField(auto_now_add=True) 73 | updated_at = models.DateTimeField(auto_now=True) 74 | external_id = models.CharField(max_length=255, unique=True) 75 | type = models.CharField( 76 | max_length=16, 77 | blank=False, 78 | choices=TypeChoices.choices, 79 | default=TypeChoices.TALK, 80 | db_index=True, 81 | ) 82 | 83 | state = models.CharField( 84 | max_length=16, 85 | blank=False, 86 | choices=StateChoices.choices, 87 | default=StateChoices.DRAFT, 88 | db_index=True, 89 | ) 90 | room = models.ForeignKey(Room, on_delete=models.PROTECT) 91 | # speaker = models.ForeignKey(Speaker, on_delete=models.CASCADE) 92 | speakers = models.ManyToManyField(Speaker, related_name="sessions") 93 | 94 | @property 95 | def speaker_names(self): 96 | return ", ".join(speaker.name for speaker in self.speakers.all()) 97 | 98 | content_panels = Page.content_panels + [ 99 | FieldPanel("name"), 100 | FieldPanel("description"), 101 | FieldPanel("scheduled_at"), 102 | FieldPanel("duration"), 103 | FieldPanel("type"), 104 | FieldPanel("state"), 105 | ] 106 | 107 | def is_confirmed(self) -> bool: 108 | return self.state == "confirmed" 109 | 110 | def __str__(self): 111 | return self.name 112 | 113 | class Meta: 114 | ordering = [ 115 | "name", 116 | ] 117 | 118 | 119 | class TalksPage(Page): 120 | subpage_types = ["Session"] 121 | 122 | @property 123 | def sessions(self): 124 | return Session.objects.order_by("scheduled_at", "room__name").all() 125 | -------------------------------------------------------------------------------- /pythonie/core/static/js/jquery.smartmenus.bootstrap.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * SmartMenus jQuery Plugin Bootstrap Addon - v0.2.0 - June 1, 2015 3 | * http://www.smartmenus.org/ 4 | * 5 | * Copyright 2015 Vasil Dinkov, Vadikom Web Ltd. 6 | * http://vadikom.com 7 | * 8 | * Licensed MIT 9 | */ 10 | 11 | (function($) { 12 | 13 | // init ondomready 14 | $(function() { 15 | 16 | // init all navbars that don't have the "data-sm-skip" attribute set 17 | var $navbars = $('ul.navbar-nav:not([data-sm-skip])'); 18 | $navbars.each(function() { 19 | var $this = $(this); 20 | $this.addClass('sm').smartmenus({ 21 | 22 | // these are some good default options that should work for all 23 | // you can, of course, tweak these as you like 24 | subMenusSubOffsetX: 2, 25 | subMenusSubOffsetY: -6, 26 | subIndicators: false, 27 | collapsibleShowFunction: null, 28 | collapsibleHideFunction: null, 29 | rightToLeftSubMenus: $this.hasClass('navbar-right'), 30 | bottomToTopSubMenus: $this.closest('.navbar').hasClass('navbar-fixed-bottom') 31 | }) 32 | .bind({ 33 | // set/unset proper Bootstrap classes for some menu elements 34 | 'show.smapi': function(e, menu) { 35 | var $menu = $(menu), 36 | $scrollArrows = $menu.dataSM('scroll-arrows'); 37 | if ($scrollArrows) { 38 | // they inherit border-color from body, so we can use its background-color too 39 | $scrollArrows.css('background-color', $(document.body).css('background-color')); 40 | } 41 | $menu.parent().addClass('open'); 42 | }, 43 | 'hide.smapi': function(e, menu) { 44 | $(menu).parent().removeClass('open'); 45 | } 46 | }) 47 | // set Bootstrap's "active" class to SmartMenus "current" items (should someone decide to enable markCurrentItem: true) 48 | .find('a.current').parent().addClass('active'); 49 | 50 | // keep Bootstrap's default behavior for parent items when the "data-sm-skip-collapsible-behavior" attribute is set to the ul.navbar-nav 51 | // i.e. use the whole item area just as a sub menu toggle and don't customize the carets 52 | var obj = $this.data('smartmenus'); 53 | if ($this.is('[data-sm-skip-collapsible-behavior]')) { 54 | $this.bind({ 55 | // click the parent item to toggle the sub menus (and reset deeper levels and other branches on click) 56 | 'click.smapi': function(e, item) { 57 | if (obj.isCollapsible()) { 58 | var $item = $(item), 59 | $sub = $item.parent().dataSM('sub'); 60 | if ($sub && $sub.dataSM('shown-before') && $sub.is(':visible')) { 61 | obj.itemActivate($item); 62 | obj.menuHide($sub); 63 | return false; 64 | } 65 | } 66 | } 67 | }); 68 | } 69 | 70 | var $carets = $this.find('.caret'); 71 | 72 | // onresize detect when the navbar becomes collapsible and add it the "sm-collapsible" class 73 | var winW; 74 | function winResize() { 75 | var newW = obj.getViewportWidth(); 76 | if (newW != winW) { 77 | if (obj.isCollapsible()) { 78 | $this.addClass('sm-collapsible'); 79 | // set "navbar-toggle" class to carets (so they look like a button) if the "data-sm-skip-collapsible-behavior" attribute is not set to the ul.navbar-nav 80 | if (!$this.is('[data-sm-skip-collapsible-behavior]')) { 81 | $carets.addClass('navbar-toggle sub-arrow'); 82 | } 83 | } else { 84 | $this.removeClass('sm-collapsible'); 85 | if (!$this.is('[data-sm-skip-collapsible-behavior]')) { 86 | $carets.removeClass('navbar-toggle sub-arrow'); 87 | } 88 | } 89 | winW = newW; 90 | } 91 | }; 92 | winResize(); 93 | $(window).bind('resize.smartmenus' + obj.rootId, winResize); 94 | }); 95 | 96 | }); 97 | 98 | // fix collapsible menu detection for Bootstrap 3 99 | $.SmartMenus.prototype.isCollapsible = function() { 100 | return this.$firstLink.parent().css('float') != 'left'; 101 | }; 102 | 103 | })(jQuery); -------------------------------------------------------------------------------- /pythonie/core/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load compress static wagtailuserbar %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |11 |59 |12 |58 |13 |57 |14 | 15 |56 |16 |23 |17 | PyCon 18 | Ireland 19 | 2022 20 | Talks 21 |
22 |24 | 25 |
55 |26 | 32 | 33 | 34 | 35 | {% for session in self.sessions %} 36 |Title 27 |Speaker(s) 28 |When 29 |Duration 30 |Where 31 |37 | 52 | {% endfor %} 53 | 54 |38 | {{ session.title }} 39 | 40 |41 | 48 |42 | {% for speaker in session.speakers.all %} 43 |
47 |- {% speaker_picture speaker 40 %} {{ speaker.name }} 44 |
45 | {% endfor %} 46 |{{ session.scheduled_at }} 49 |{{ session.duration }} 50 |{{ session.room }} 51 |15 | {% block title %}{% if self.seo_title %}{{ self.seo_title }}{% else %}{{ self.title }}{% endif %}{% endblock %} 16 | {% block title_suffix %}{% endblock %} 17 | 18 | 19 | 20 | 42 | 43 | 44 | 45 | 46 | 47 | {% compress css %} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {% endcompress %} 58 | 59 | 60 | 61 | {% block extra_css %} 62 | {% endblock %} 63 | 64 | 65 | 66 | 67 | 68 | {% wagtailuserbar %} 69 | 70 | {% include "header.html" %} 71 | {% include "navbar.html" %} 72 | 73 | {% block content %}{% endblock %} 74 | 75 | {% include "footer.html" %} 76 | 77 | {% compress js %} 78 | 79 | {% endcompress %} 80 | 81 | {% block basejs %} 82 | 83 | 84 | 85 | 86 | 87 | 97 | {% endblock %} 98 | {% block extra_js %} 99 | 100 | {% endblock %} 101 | 102 | 103 | -------------------------------------------------------------------------------- /pythonie/core/static/css/tweet.css: -------------------------------------------------------------------------------- 1 | .tm-tweet-clear { 2 | zoom: 1; 3 | } 4 | .tm-tweet-clear:after { 5 | display: block; 6 | visibility: hidden; 7 | height: 0; 8 | clear: both; 9 | content: "."; 10 | } 11 | .tm-click-to-tweet { 12 | display: block; 13 | background-color: #fff; 14 | margin: 0; 15 | padding: 0; 16 | position: relative; 17 | border: 1px solid #dddddd; 18 | -moz-border-radius: 4px; 19 | border-radius: 4px; 20 | padding: 15px 30px; 21 | margin: 15px 0px; 22 | zoom: 1; 23 | } 24 | .tm-click-to-tweet .clearfix { 25 | zoom: 1; 26 | } 27 | .tm-click-to-tweet .clearfix:after { 28 | display: block; 29 | visibility: hidden; 30 | height: 0; 31 | clear: both; 32 | content: "."; 33 | } 34 | .tm-click-to-tweet .clear { 35 | clear: both; 36 | } 37 | .tm-click-to-tweet .f-left { 38 | float: left; 39 | display: inline-block; 40 | position: relative; 41 | } 42 | .tm-click-to-tweet .f-right { 43 | float: right; 44 | display: inline-block; 45 | position: relative; 46 | } 47 | .tm-click-to-tweet .list-reset { 48 | list-style: none; 49 | margin: 0; 50 | padding: 0; 51 | } 52 | .tm-click-to-tweet .list-reset li { 53 | list-style: none; 54 | margin: 0; 55 | padding: 0; 56 | } 57 | .tm-click-to-tweet .list-float { 58 | zoom: 1; 59 | } 60 | .tm-click-to-tweet .list-float:after { 61 | display: block; 62 | visibility: hidden; 63 | height: 0; 64 | clear: both; 65 | content: "."; 66 | } 67 | .tm-click-to-tweet .list-float li { 68 | float: left; 69 | display: inline-block; 70 | } 71 | .tm-click-to-tweet .kill-box-shadow { 72 | box-shadow: none; 73 | -webkit-box-shadow: none; 74 | -moz-box-shadow: none; 75 | } 76 | .tm-click-to-tweet .alignright { 77 | float: right; 78 | margin-bottom: 10px; 79 | margin-left: 10px; 80 | text-align: right; 81 | } 82 | .tm-click-to-tweet .alignleft { 83 | float: left; 84 | margin-bottom: 10px; 85 | margin-right: 10px; 86 | text-align: right; 87 | } 88 | .tm-click-to-tweet:after { 89 | content: "."; 90 | display: block; 91 | clear: both; 92 | visibility: hidden; 93 | line-height: 0; 94 | height: 0; 95 | } 96 | .tm-click-to-tweet .tm-ctt-reset { 97 | margin: 0; 98 | padding: 0; 99 | position: relative; 100 | } 101 | .tm-click-to-tweet:after { 102 | display: block; 103 | visibility: hidden; 104 | height: 0; 105 | clear: both; 106 | content: "."; 107 | } 108 | .tm-click-to-tweet a { 109 | text-decoration: none; 110 | text-transform: none; 111 | } 112 | .tm-click-to-tweet a:hover { 113 | text-decoration: none; 114 | } 115 | .tm-click-to-tweet .tm-ctt-text { 116 | margin: 0; 117 | padding: 0; 118 | position: relative; 119 | margin-bottom: 10px; 120 | word-wrap: break-word; 121 | } 122 | .tm-click-to-tweet .tm-ctt-text a { 123 | margin: 0; 124 | padding: 0; 125 | position: relative; 126 | color: #999999; 127 | font-size: 24px; 128 | line-height: 140%; 129 | text-transform: none; 130 | letter-spacing: 0.05em; 131 | font-weight: 100; 132 | text-decoration: none; 133 | text-transform: none; 134 | } 135 | .tm-click-to-tweet .tm-ctt-text a:hover { 136 | text-decoration: none; 137 | color: #666666; 138 | } 139 | .tm-click-to-tweet a.tm-ctt-btn { 140 | margin: 0; 141 | padding: 0; 142 | position: relative; 143 | display: block; 144 | text-transform: uppercase; 145 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 146 | font-size: 12px; 147 | font-weight: bold; 148 | line-height: 100%; 149 | color: #999999; 150 | float: right; 151 | padding-right: 24px; 152 | text-decoration: none; 153 | background: transparent url(../img/twitter-little-bird.png) no-repeat right top; 154 | } 155 | .tm-click-to-tweet a.tm-ctt-btn:hover { 156 | text-decoration: none; 157 | color: #666666; 158 | text-transform: uppercase; 159 | } 160 | .tm-click-to-tweet .tm-powered-by { 161 | font-size: 10px; 162 | color: #999999; 163 | } 164 | .tm-click-to-tweet .tm-powered-by a { 165 | font-size: 10px; 166 | color: #999999 !important; 167 | } 168 | .tm-click-to-tweet .tm-powered-by a:hover { 169 | color: #999999 !important; 170 | text-decoration: underline !important; 171 | } 172 | -------------------------------------------------------------------------------- /pythonie/core/static/css/jquery.smartmenus.bootstrap.css: -------------------------------------------------------------------------------- 1 | /* 2 | You probably do not need to edit this at all. 3 | 4 | Add some SmartMenus required styles not covered in Bootstrap 3's default CSS. 5 | These are theme independent and should work with any Bootstrap 3 theme mod. 6 | */ 7 | /* sub menus arrows on desktop */ 8 | .navbar-nav:not(.sm-collapsible) ul .caret { 9 | position: absolute; 10 | right: 0; 11 | margin-top: 6px; 12 | margin-right: 15px; 13 | border-top: 4px solid transparent; 14 | border-bottom: 4px solid transparent; 15 | border-left: 4px dashed; 16 | } 17 | .navbar-nav:not(.sm-collapsible) ul a.has-submenu { 18 | padding-right: 30px; 19 | } 20 | /* make sub menu arrows look like +/- buttons in collapsible mode */ 21 | .navbar-nav.sm-collapsible .caret, .navbar-nav.sm-collapsible ul .caret { 22 | position: absolute; 23 | right: 0; 24 | margin: -3px 15px 0 0; 25 | padding: 0; 26 | width: 32px; 27 | height: 26px; 28 | line-height: 24px; 29 | text-align: center; 30 | border-width: 1px; 31 | border-style: solid; 32 | } 33 | .navbar-nav.sm-collapsible .caret:before { 34 | content: '+'; 35 | font-family: monospace; 36 | font-weight: bold; 37 | } 38 | .navbar-nav.sm-collapsible .open > a > .caret:before { 39 | content: '-'; 40 | } 41 | .navbar-nav.sm-collapsible a.has-submenu { 42 | padding-right: 50px; 43 | } 44 | /* revert to Bootstrap's default carets in collapsible mode when the "data-sm-skip-collapsible-behavior" attribute is set to the ul.navbar-nav */ 45 | .navbar-nav.sm-collapsible[data-sm-skip-collapsible-behavior] .caret, .navbar-nav.sm-collapsible[data-sm-skip-collapsible-behavior] ul .caret { 46 | position: static; 47 | margin: 0 0 0 2px; 48 | padding: 0; 49 | width: 0; 50 | height: 0; 51 | border-top: 4px dashed; 52 | border-right: 4px solid transparent; 53 | border-bottom: 0; 54 | border-left: 4px solid transparent; 55 | } 56 | .navbar-nav.sm-collapsible[data-sm-skip-collapsible-behavior] .caret:before { 57 | content: '' !important; 58 | } 59 | .navbar-nav.sm-collapsible[data-sm-skip-collapsible-behavior] a.has-submenu { 60 | padding-right: 15px; 61 | } 62 | /* scrolling arrows for tall menus */ 63 | .navbar-nav span.scroll-up, .navbar-nav span.scroll-down { 64 | position: absolute; 65 | display: none; 66 | visibility: hidden; 67 | height: 20px; 68 | overflow: hidden; 69 | text-align: center; 70 | } 71 | .navbar-nav span.scroll-up-arrow, .navbar-nav span.scroll-down-arrow { 72 | position: absolute; 73 | top: -2px; 74 | left: 50%; 75 | margin-left: -8px; 76 | width: 0; 77 | height: 0; 78 | overflow: hidden; 79 | border-top: 7px dashed transparent; 80 | border-right: 7px dashed transparent; 81 | border-bottom: 7px solid; 82 | border-left: 7px dashed transparent; 83 | } 84 | .navbar-nav span.scroll-down-arrow { 85 | top: 6px; 86 | border-top: 7px solid; 87 | border-right: 7px dashed transparent; 88 | border-bottom: 7px dashed transparent; 89 | border-left: 7px dashed transparent; 90 | } 91 | /* add more indentation for 2+ level sub in collapsible mode - Bootstrap normally supports just 1 level sub menus */ 92 | .navbar-nav.sm-collapsible ul .dropdown-menu > li > a, 93 | .navbar-nav.sm-collapsible ul .dropdown-menu .dropdown-header { 94 | padding-left: 35px; 95 | } 96 | .navbar-nav.sm-collapsible ul ul .dropdown-menu > li > a, 97 | .navbar-nav.sm-collapsible ul ul .dropdown-menu .dropdown-header { 98 | padding-left: 45px; 99 | } 100 | .navbar-nav.sm-collapsible ul ul ul .dropdown-menu > li > a, 101 | .navbar-nav.sm-collapsible ul ul ul .dropdown-menu .dropdown-header { 102 | padding-left: 55px; 103 | } 104 | .navbar-nav.sm-collapsible ul ul ul ul .dropdown-menu > li > a, 105 | .navbar-nav.sm-collapsible ul ul ul ul .dropdown-menu .dropdown-header { 106 | padding-left: 65px; 107 | } 108 | /* fix SmartMenus sub menus auto width (subMenusMinWidth and subMenusMaxWidth options) */ 109 | .navbar-nav .dropdown-menu > li > a { 110 | white-space: normal; 111 | } 112 | .navbar-nav ul.sm-nowrap > li > a { 113 | white-space: nowrap; 114 | } 115 | .navbar-nav.sm-collapsible ul.sm-nowrap > li > a { 116 | white-space: normal; 117 | } 118 | /* fix .navbar-right subs alignment */ 119 | .navbar-right ul.dropdown-menu { 120 | left: 0; 121 | right: auto; 122 | } -------------------------------------------------------------------------------- /requirements/main.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --output-file requirements/main.txt requirements/main.in 3 | annotated-types==0.7.0 4 | # via pydantic 5 | anyascii==0.3.3 6 | # via wagtail 7 | asgiref==3.11.0 8 | # via django 9 | babel==2.17.0 10 | # via delorean 11 | beautifulsoup4==4.14.2 12 | # via wagtail 13 | boto3==1.41.5 14 | # via -r requirements/main.in 15 | botocore==1.41.5 16 | # via 17 | # boto3 18 | # s3transfer 19 | certifi==2025.11.12 20 | # via requests 21 | charset-normalizer==3.4.4 22 | # via requests 23 | colander==2.0 24 | # via -r requirements/main.in 25 | defusedxml==0.7.1 26 | # via 27 | # -r requirements/main.in 28 | # willow 29 | delorean==1.0.0 30 | # via -r requirements/main.in 31 | dj-database-url==3.0.1 32 | # via -r requirements/main.in 33 | dj-static==0.0.6 34 | # via -r requirements/main.in 35 | django==5.2.8 36 | # via 37 | # -r requirements/main.in 38 | # dj-database-url 39 | # django-appconf 40 | # django-compressor 41 | # django-extensions 42 | # django-filter 43 | # django-modelcluster 44 | # django-permissionedforms 45 | # django-storages 46 | # django-stubs-ext 47 | # django-taggit 48 | # django-tasks 49 | # django-treebeard 50 | # djangorestframework 51 | # laces 52 | # modelsearch 53 | # wagtail 54 | django-appconf==1.2.0 55 | # via django-compressor 56 | django-compressor==4.6.0 57 | # via 58 | # -r requirements/main.in 59 | # django-libsass 60 | django-extensions==4.1 61 | # via -r requirements/main.in 62 | django-filter==25.2 63 | # via wagtail 64 | django-libsass==0.9 65 | # via -r requirements/main.in 66 | django-modelcluster==6.4 67 | # via 68 | # -r requirements/main.in 69 | # wagtail 70 | django-permissionedforms==0.1 71 | # via wagtail 72 | django-storages==1.14.6 73 | # via -r requirements/main.in 74 | django-stubs-ext==5.2.7 75 | # via django-tasks 76 | django-taggit==6.1.0 77 | # via 78 | # -r requirements/main.in 79 | # wagtail 80 | django-tasks==0.9.0 81 | # via 82 | # modelsearch 83 | # wagtail 84 | django-treebeard==4.7.1 85 | # via wagtail 86 | djangorestframework==3.16.1 87 | # via wagtail 88 | draftjs-exporter==5.1.0 89 | # via wagtail 90 | et-xmlfile==2.0.0 91 | # via openpyxl 92 | filetype==1.2.0 93 | # via willow 94 | gunicorn==23.0.0 95 | # via -r requirements/main.in 96 | humanize==4.14.0 97 | # via delorean 98 | idna==3.11 99 | # via requests 100 | iso8601==2.1.0 101 | # via colander 102 | jmespath==1.0.1 103 | # via 104 | # boto3 105 | # botocore 106 | laces==0.1.2 107 | # via wagtail 108 | libsass==0.23.0 109 | # via django-libsass 110 | modelsearch==1.1.1 111 | # via wagtail 112 | numpy==2.3.5 113 | # via pandas 114 | openpyxl==3.1.5 115 | # via wagtail 116 | packaging==25.0 117 | # via gunicorn 118 | pandas==2.3.3 119 | # via -r requirements/main.in 120 | pillow==12.0.0 121 | # via 122 | # pillow-heif 123 | # wagtail 124 | pillow-heif==1.1.1 125 | # via willow 126 | pydantic==2.12.5 127 | # via -r requirements/main.in 128 | pydantic-core==2.41.5 129 | # via pydantic 130 | python-dateutil==2.9.0.post0 131 | # via 132 | # -r requirements/main.in 133 | # botocore 134 | # delorean 135 | # pandas 136 | pytz==2025.2 137 | # via 138 | # -r requirements/main.in 139 | # delorean 140 | # pandas 141 | rcssmin==1.2.2 142 | # via django-compressor 143 | redis==7.1.0 144 | # via -r requirements/main.in 145 | requests==2.32.5 146 | # via 147 | # -r requirements/main.in 148 | # wagtail 149 | rjsmin==1.2.5 150 | # via django-compressor 151 | s3transfer==0.15.0 152 | # via boto3 153 | six==1.17.0 154 | # via python-dateutil 155 | soupsieve==2.8 156 | # via beautifulsoup4 157 | sqlparse==0.5.4 158 | # via django 159 | static3==0.7.0 160 | # via dj-static 161 | telepath==0.3.1 162 | # via wagtail 163 | translationstring==1.4 164 | # via colander 165 | typing-extensions==4.15.0 166 | # via 167 | # beautifulsoup4 168 | # django-stubs-ext 169 | # django-tasks 170 | # pydantic 171 | # pydantic-core 172 | # typing-inspection 173 | typing-inspection==0.4.2 174 | # via pydantic 175 | tzdata==2025.2 176 | # via pandas 177 | tzlocal==5.3.1 178 | # via delorean 179 | urllib3==2.5.0 180 | # via 181 | # botocore 182 | # requests 183 | wagtail==7.2.1 184 | # via -r requirements/main.in 185 | whitenoise==6.11.0 186 | # via -r requirements/main.in 187 | willow==1.12.0 188 | # via 189 | # -r requirements/main.in 190 | # wagtail 191 | -------------------------------------------------------------------------------- /.claude/commands/commit.md: -------------------------------------------------------------------------------- 1 | # Claude Command: Commit 2 | 3 | This command helps you create well-formatted commits with conventional commit messages and emoji. 4 | 5 | ## Usage 6 | 7 | To create a commit, just type: 8 | ``` 9 | /commit 10 | ``` 11 | 12 | ## What This Command Does 13 | 14 | 1. Checks which files are staged with `git status` 15 | 2. If no files are staged, automatically adds all modified and new files with `git add` 16 | 3. Performs a `git diff` to understand what changes are being committed 17 | 4. Analyzes the diff to determine if multiple distinct logical changes are present 18 | 5. If multiple distinct changes are detected, suggests breaking the commit into multiple smaller commits 19 | 6. For each commit (or the single commit if not split), creates a commit message using emoji conventional commit format 20 | 21 | ## Best Practices for Commits 22 | 23 | - **Atomic commits**: Each commit should contain related changes that serve a single purpose 24 | - **Split large changes**: If changes touch multiple concerns, split them into separate commits 25 | - **Conventional commit format**: Use the format `emoji: ` 26 | - **Present tense, imperative mood**: Write commit messages as commands (e.g., "add feature" not "added feature") 27 | - **Concise first line**: Keep the first line under 72 characters 28 | - **No Claude attribution**: NEVER mention Claude or Claude Code in commit messages 29 | 30 | ## Commit Types and Emojis 31 | 32 | Use ONE emoji per commit based on the primary type of change: 33 | 34 | - ✨ `feat`: New feature or functionality 35 | - 🐛 `fix`: Bug fix (non-critical) 36 | - 🚑️ `fix`: Critical hotfix 37 | - 📝 `docs`: Documentation changes 38 | - 🎨 `style`: Code structure/formatting improvements 39 | - ♻️ `refactor`: Code refactoring (no behavior change) 40 | - 🚚 `refactor`: Move or rename files/resources 41 | - ⚡️ `perf`: Performance improvements 42 | - ✅ `test`: Add or update tests 43 | - 🔧 `chore`: Configuration, tooling, maintenance 44 | - 🔥 `chore`: Remove code or files 45 | - 📦️ `chore`: Update dependencies or packages 46 | - ➕ `chore`: Add a dependency 47 | - ➖ `chore`: Remove a dependency 48 | - 🚀 `ci`: CI/CD changes 49 | - 💚 `fix`: Fix CI build 50 | - 🔒️ `fix`: Security fixes 51 | - ♿️ `feat`: Accessibility improvements 52 | - 🗃️ `chore`: Database migrations or schema changes 53 | - 🌐 `feat`: Internationalization/localization changes 54 | 55 | ## Guidelines for Splitting Commits 56 | 57 | When analyzing the diff, consider splitting commits based on these criteria: 58 | 59 | 1. **Different concerns**: Changes to unrelated parts of the codebase 60 | 2. **Different types of changes**: Mixing features, fixes, refactoring, etc. 61 | 3. **File patterns**: Changes to different types of files (e.g., source code vs documentation) 62 | 4. **Logical grouping**: Changes that would be easier to understand or review separately 63 | 5. **Size**: Very large changes that would be clearer if broken down 64 | 65 | ## Examples 66 | 67 | **Good commit messages for this Django/Wagtail project:** 68 | - ✨ feat: add speaker bio field to Speaker model 69 | - ✨ feat: implement new StreamField block for video embeds 70 | - 🐛 fix: correct sponsor logo display on homepage 71 | - 🐛 fix: resolve meetup sync timezone issue 72 | - 📝 docs: update CLAUDE.md with new task commands 73 | - ♻️ refactor: simplify SpeakersPage queryset logic 74 | - ♻️ refactor: extract common page mixins to core app 75 | - 🎨 style: improve Wagtail admin panel layout 76 | - 🔥 chore: remove deprecated Meetup API v2 code 77 | - 📦️ chore: update Wagtail to 6.2.x 78 | - 📦️ chore: upgrade Django to 5.0.14 79 | - ➕ chore: add django-extensions for development 80 | - ➖ chore: remove unused celery dependency 81 | - 🚀 ci: update Heroku deployment configuration 82 | - 💚 fix: resolve failing Docker build 83 | - 🔒️ fix: patch Django security vulnerability 84 | - ♿️ feat: improve navigation accessibility for screen readers 85 | - 🗃️ chore: add migration for new Session fields 86 | - 🌐 feat: add French translation for sponsor pages 87 | 88 | **Example of splitting commits:** 89 | 90 | If you modify both a Wagtail page model AND update a management command, split into: 91 | 1. ✨ feat: add session_type field to Session model 92 | 2. ♻️ refactor: update import-sessionize command to handle new field 93 | 94 | If you fix multiple unrelated issues, split into: 95 | 1. 🐛 fix: correct speaker ordering on TalksPage 96 | 2. 🐛 fix: resolve Redis connection timeout in dev settings 97 | 3. 🗃️ chore: add missing migration for sponsors app 98 | 99 | ## Important Notes 100 | 101 | - If specific files are already staged, the command will only commit those files 102 | - If no files are staged, it will automatically stage all modified and new files 103 | - The commit message will be constructed based on the changes detected 104 | - Before committing, the command will review the diff to identify if multiple commits would be more appropriate 105 | - If suggesting multiple commits, it will help you stage and commit the changes separately 106 | - **CRITICAL**: Never add "Generated with Claude Code" or similar attributions to commits 107 | -------------------------------------------------------------------------------- /pythonie/core/static/css/tito.css: -------------------------------------------------------------------------------- 1 | 2 | * { 3 | box-sizing: border-box 4 | } 5 | 6 | .tito-wrapper { 7 | border: 1px solid #ccc; 8 | border-radius: 3px; 9 | background: #f1f1f1; 10 | font-family: "bree-serif", sans-serif; 11 | font-size: 16px; 12 | color: #333; 13 | margin: 20px auto; 14 | max-width: 900px; 15 | padding: 10px 10px 0 10px; 16 | width: 100% 17 | } 18 | 19 | .tito-ticket-list { 20 | display: table; 21 | list-style-type: none; 22 | margin: 0 0 20px 0; 23 | padding: 0; 24 | width: 100% 25 | } 26 | 27 | #tito-previous-releases, .tito-ticket-list.tito-ticket-waitlist { 28 | margin: 0 29 | } 30 | 31 | .tito-ticket { 32 | display: table-row 33 | } 34 | 35 | .tito-ticket-name-wrapper { 36 | border-bottom: 1px solid #ccc; 37 | display: table-cell; 38 | vertical-align: middle 39 | } 40 | 41 | .tito-ticket-name .label.label-default { 42 | border: 1px solid #333; 43 | border-radius: 2px; 44 | color: #333; 45 | font-size: 10px; 46 | font-weight: bold; 47 | margin-left: 5px; 48 | padding: 2px 5px; 49 | position: relative; 50 | top: -1px; 51 | text-transform: uppercase 52 | } 53 | 54 | .tito-ticket-name-wrapper .tito-tickets-remaining { 55 | background: #ddd; 56 | border: 1px solid rgba(51, 51, 51, 0.2); 57 | color: #333; 58 | font-size: 10px; 59 | padding: 2px 5px; 60 | position: relative; 61 | top: -3px; 62 | margin-left: 10px; 63 | white-space: nowrap 64 | } 65 | 66 | .tito-ticket-description { 67 | margin-top: 0.2rem; 68 | font-size: 0.7rem; 69 | } 70 | 71 | .tito-ticket-price-quantity-wrapper, .tito-ticket-status, .tito-ticket-status-sold-out { 72 | border-bottom: 1px solid #ccc; 73 | display: table-cell; 74 | text-align: right; 75 | vertical-align: middle 76 | } 77 | 78 | .tito-ticket-price, .tito-ticket-quantity { 79 | display: inline-block 80 | } 81 | 82 | .tito-ticket-quantity-field { 83 | background-color: #fff; 84 | border: 1px solid #ccc; 85 | border-radius: 4px; 86 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); 87 | display: inline-block; 88 | font-size: 14px; 89 | height: 34px; 90 | line-height: 1.42857143; 91 | margin: 5px 0; 92 | padding: 6px 12px; 93 | text-align: center; 94 | width: 45px 95 | } 96 | 97 | .tito-ticket-donation-field { 98 | background-color: #fff; 99 | border: 1px solid #ccc; 100 | border-radius: 4px; 101 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); 102 | display: inline-block; 103 | font-size: 14px; 104 | height: 34px; 105 | line-height: 1.42857143; 106 | margin: 5px 0; 107 | padding: 6px 12px; 108 | text-align: center; 109 | width: 85px; 110 | margin-left: 6px 111 | } 112 | 113 | .tito-ticket-price span { 114 | display: block 115 | } 116 | 117 | .tito-ticket-vat { 118 | float: right; 119 | font-size: 10px; 120 | padding-bottom: 5px 121 | } 122 | 123 | .btn.btn-default, .tito-ticket-status span { 124 | background: #ccc; 125 | border-radius: 4px; 126 | color: #333; 127 | display: inline-block; 128 | font-size: 14px; 129 | height: 34px; 130 | line-height: 1.42857143; 131 | margin: 5px 0; 132 | padding: 6px 12px; 133 | text-align: center; 134 | text-decoration: none; 135 | width: 100px 136 | } 137 | 138 | .btn.btn-default.btn-waitlist { 139 | background: #fff; 140 | border: 1px solid #ccc; 141 | color: #333; 142 | width: 200px 143 | } 144 | 145 | .btn.btn-default.btn-waitlist:hover { 146 | border: 1px solid #333 147 | } 148 | 149 | .tito-discount-code { 150 | display: table-row 151 | } 152 | 153 | .tito-discount-code-label { 154 | display: none 155 | } 156 | 157 | .btn.btn-default.tito-discount-apply-button { 158 | display: none 159 | } 160 | 161 | .tito-discount-code-field { 162 | background-color: #fff; 163 | border: 1px solid #ccc; 164 | border-radius: 4px; 165 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); 166 | display: inline-block; 167 | font-size: 14px; 168 | height: 34px; 169 | line-height: 1.42857143; 170 | margin: 5px 0; 171 | padding: 6px 12px 172 | } 173 | 174 | .tito-discount-code-show { 175 | display: none 176 | } 177 | 178 | .tito-submit-wrapper { 179 | text-align: right 180 | } 181 | 182 | .tito-submit { 183 | background: #fff; 184 | border: 1px solid #ccc; 185 | border-radius: 4px; 186 | color: #333; 187 | cursor: pointer; 188 | font-size: 14px; 189 | height: 34px; 190 | line-height: 1.42857143; 191 | margin: 0; 192 | padding: 6px 12px; 193 | text-align: center; 194 | width: 100px 195 | } 196 | 197 | .tito-submit:hover { 198 | border-color: #333 199 | } 200 | 201 | .tito-ticket.tito-locked-ticket > div { 202 | padding: 10px 0 203 | } 204 | 205 | .tito-ticket.tito-locked-ticket label, .tito-ticket.tito-locked-ticket span { 206 | opacity: .5 207 | } 208 | 209 | .locked-tickets-message p { 210 | font-size: 14px; 211 | line-height: 1.3; 212 | opacity: .5 213 | } 214 | 215 | .tito-badge-link { 216 | font-size: 12px; 217 | display: inline-block; 218 | margin-bottom: 5px; 219 | text-align: center; 220 | width: 100% 221 | } 222 | -------------------------------------------------------------------------------- /pythonie/speakers/migrations/0003_auto_20220929_1828.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.15 on 2022-09-29 18:28 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 | ("wagtailcore", "0066_collection_management_permissions"), 11 | ("speakers", "0002_talkspage"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Room", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=255)), 28 | ], 29 | options={ 30 | "ordering": ["name"], 31 | }, 32 | ), 33 | migrations.CreateModel( 34 | name="Speaker", 35 | fields=[ 36 | ( 37 | "page_ptr", 38 | models.OneToOneField( 39 | auto_created=True, 40 | on_delete=django.db.models.deletion.CASCADE, 41 | parent_link=True, 42 | primary_key=True, 43 | serialize=False, 44 | to="wagtailcore.page", 45 | ), 46 | ), 47 | ("name", models.CharField(max_length=255)), 48 | ("email", models.CharField(max_length=255)), 49 | ( 50 | "external_id", 51 | models.CharField(blank=True, max_length=255, unique=True), 52 | ), 53 | ("picture_url", models.CharField(blank=True, max_length=255)), 54 | ("biography", models.TextField()), 55 | ("created_at", models.DateTimeField(auto_now_add=True)), 56 | ("updated_at", models.DateTimeField(auto_now=True)), 57 | ], 58 | options={ 59 | "ordering": ["name"], 60 | }, 61 | bases=("wagtailcore.page",), 62 | ), 63 | migrations.RemoveField( 64 | model_name="speakerspage", 65 | name="api_url", 66 | ), 67 | migrations.RemoveField( 68 | model_name="talkspage", 69 | name="api_url", 70 | ), 71 | migrations.CreateModel( 72 | name="Session", 73 | fields=[ 74 | ( 75 | "page_ptr", 76 | models.OneToOneField( 77 | auto_created=True, 78 | on_delete=django.db.models.deletion.CASCADE, 79 | parent_link=True, 80 | primary_key=True, 81 | serialize=False, 82 | to="wagtailcore.page", 83 | ), 84 | ), 85 | ("name", models.CharField(db_index=True, max_length=255)), 86 | ("description", models.TextField()), 87 | ("scheduled_at", models.DateTimeField()), 88 | ("duration", models.IntegerField(default=30)), 89 | ("created_at", models.DateTimeField(auto_now_add=True)), 90 | ("updated_at", models.DateTimeField(auto_now=True)), 91 | ("external_id", models.CharField(max_length=255, unique=True)), 92 | ( 93 | "type", 94 | models.CharField( 95 | choices=[("talk", "Talk"), ("workshop", "Workshop")], 96 | db_index=True, 97 | default="talk", 98 | max_length=16, 99 | ), 100 | ), 101 | ( 102 | "state", 103 | models.CharField( 104 | choices=[ 105 | ("draft", "Draft"), 106 | ("accepted", "Accepted"), 107 | ("confirmed", "Confirmed"), 108 | ("refused", "Refused"), 109 | ("cancelled", "Cancelled"), 110 | ], 111 | db_index=True, 112 | default="draft", 113 | max_length=16, 114 | ), 115 | ), 116 | ( 117 | "room", 118 | models.ForeignKey( 119 | on_delete=django.db.models.deletion.CASCADE, to="speakers.room" 120 | ), 121 | ), 122 | ( 123 | "speakers", 124 | models.ManyToManyField( 125 | related_name="sessions", to="speakers.Speaker" 126 | ), 127 | ), 128 | ], 129 | options={ 130 | "ordering": ["name"], 131 | }, 132 | bases=("wagtailcore.page",), 133 | ), 134 | ] 135 | -------------------------------------------------------------------------------- /pythonie/speakers/management/commands/update-sessionize-json-stream.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pydantic 4 | import requests 5 | from django.core.management import BaseCommand, CommandParser 6 | from wagtail.models import Page 7 | 8 | from speakers.models import Speaker, Session, Room 9 | 10 | 11 | class SessionModel(pydantic.BaseModel): 12 | id: str 13 | title: str 14 | description: str 15 | startsAt: datetime.datetime 16 | endsAt: datetime.datetime 17 | speakers: list[pydantic.UUID4] 18 | roomId: int 19 | 20 | @property 21 | def duration(self) -> int: 22 | return int((self.endsAt - self.startsAt).seconds / 60) 23 | 24 | 25 | class SpeakerModel(pydantic.BaseModel): 26 | id: pydantic.UUID4 27 | firstName: str 28 | lastName: str 29 | bio: str | None 30 | tagLine: str 31 | profilePicture: pydantic.HttpUrl | None 32 | sessions: list[int] 33 | fullName: str 34 | # email: str 35 | 36 | # @property 37 | # def fullName(self): 38 | # return f'{self.firstName} {self.lastName}' 39 | 40 | 41 | class RoomModel(pydantic.BaseModel): 42 | id: int 43 | name: str 44 | 45 | 46 | class SessionizeModel(pydantic.BaseModel): 47 | sessions: list[SessionModel] 48 | speakers: list[SpeakerModel] 49 | rooms: list[RoomModel] 50 | 51 | 52 | class Command(BaseCommand): 53 | def handle(self, *args, **kwargs): 54 | response = requests.get("https://sessionize.com/api/v2/z66z4kb6/view/All") 55 | sessionize: SessionizeModel = SessionizeModel.parse_obj(response.json()) 56 | 57 | rooms = {} 58 | 59 | for incoming_room in sessionize.rooms: 60 | incoming_room: RoomModel 61 | rooms[incoming_room.id] = self.save_room(incoming_room) 62 | 63 | parent_page: Page = Page.objects.get(id=144).specific 64 | for speaker in sessionize.speakers: 65 | speaker: SpeakerModel 66 | self.save_speaker(parent_page, speaker) 67 | 68 | parent_page: Page = Page.objects.get(id=145).specific 69 | for session in sessionize.sessions: 70 | session: SessionModel 71 | self.save_session(parent_page, rooms[session.roomId], session) 72 | 73 | def save_speaker(self, parent_page: Page, incoming_speaker: SpeakerModel) -> None: 74 | print(f"{incoming_speaker.id} {incoming_speaker.fullName}") 75 | try: 76 | speaker: Speaker = Speaker.objects.get(external_id=incoming_speaker.id) 77 | speaker.name = incoming_speaker.fullName 78 | speaker.email = f"{incoming_speaker.id}@sessionize.com" 79 | speaker.biography = incoming_speaker.bio or "No biography available." 80 | speaker.picture_url = incoming_speaker.profilePicture or "" 81 | speaker.title = incoming_speaker.fullName 82 | except Speaker.DoesNotExist: 83 | speaker: Speaker = Speaker( 84 | external_id=incoming_speaker.id, 85 | name=incoming_speaker.fullName, 86 | email=f"{incoming_speaker.id}@sessionize.com", 87 | biography=incoming_speaker.bio or "No biography available", 88 | picture_url=incoming_speaker.profilePicture or "", 89 | title=incoming_speaker.fullName, 90 | ) 91 | 92 | parent_page.add_child(instance=speaker) 93 | 94 | speaker.save() 95 | speaker.save_revision().publish() 96 | 97 | def save_session( 98 | self, 99 | parent_page: Page, 100 | room: Room, 101 | incoming_session: SessionModel, 102 | ) -> None: 103 | print(f"{incoming_session.id} {incoming_session.title}") 104 | created: bool = False 105 | 106 | try: 107 | session: Session = Session.objects.get(external_id=incoming_session.id) 108 | session.name = incoming_session.title 109 | session.description = incoming_session.description 110 | session.scheduled_at = incoming_session.startsAt 111 | session.duration = incoming_session.duration 112 | session.title = incoming_session.title 113 | session.room = room 114 | except Session.DoesNotExist: 115 | session: Session = Session( 116 | external_id=incoming_session.id, 117 | name=incoming_session.title, 118 | description=incoming_session.description, 119 | scheduled_at=incoming_session.startsAt, 120 | duration=incoming_session.duration, 121 | title=incoming_session.title, 122 | room=room, 123 | ) 124 | created = True 125 | 126 | parent_page.add_child(instance=session) 127 | 128 | speakers = Speaker.objects.filter(external_id__in=incoming_session.speakers) 129 | for speaker in speakers: 130 | session.speakers.add(speaker) 131 | 132 | session.save() 133 | session.save_revision().publish() 134 | print( 135 | f'{incoming_session.id} {incoming_session.title} {created and "CREATED" or "UPDATED"}' 136 | ) 137 | 138 | def save_room(self, incoming_room: RoomModel) -> Room: 139 | room, created = Room.objects.get_or_create(name=incoming_room.name) 140 | return room 141 | -------------------------------------------------------------------------------- /pythonie/core/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | 5 | from django.db import models 6 | from modelcluster.fields import ParentalKey 7 | from wagtail.admin.panels import FieldPanel, InlinePanel 8 | from wagtail import blocks 9 | from wagtail.blocks import RawHTMLBlock 10 | from wagtail.fields import RichTextField, StreamField 11 | from wagtail.models import Orderable, Page 12 | from wagtail.embeds.blocks import EmbedBlock 13 | from wagtail.images.blocks import ImageChooserBlock 14 | from wagtail.snippets.models import register_snippet 15 | 16 | from sponsors.models import Sponsor, SponsorshipLevel 17 | 18 | log = logging.getLogger("pythonie") 19 | 20 | 21 | class MeetupMixin(models.Model): 22 | show_meetups = models.BooleanField(default=False) 23 | 24 | settings_panels = [ 25 | FieldPanel("show_meetups"), 26 | ] 27 | 28 | class Meta: 29 | abstract = True 30 | 31 | 32 | class SponsorMixin(models.Model): 33 | show_sponsors = models.BooleanField(default=False) 34 | 35 | settings_panels = [ 36 | FieldPanel("show_sponsors"), 37 | ] 38 | 39 | class Meta: 40 | abstract = True 41 | 42 | 43 | @register_snippet 44 | class PageSegment(models.Model): 45 | """This is a fixed text content""" 46 | 47 | title = models.CharField(max_length=255) 48 | body = RichTextField() 49 | location = models.CharField( 50 | max_length=5, 51 | choices=( 52 | ("main", "Main section"), 53 | ("right", "Right side"), 54 | ("left", "Left side"), 55 | ), 56 | default="main", 57 | ) 58 | 59 | panels = [ 60 | FieldPanel("title", classname="full title"), 61 | FieldPanel("body", classname="full"), 62 | FieldPanel("location", classname="select"), 63 | ] 64 | 65 | def __str__(self): 66 | return "{!s} on {!s}".format(self.title, self.homepage_segments.first()) 67 | 68 | 69 | class HomePageSegment(Orderable, models.Model): 70 | """Pivot table to associate a HomePage to Segment snippets""" 71 | 72 | homepage = ParentalKey( 73 | "HomePage", related_name="homepage_segments", on_delete=models.CASCADE 74 | ) 75 | segment = models.ForeignKey( 76 | "PageSegment", related_name="homepage_segments", on_delete=models.CASCADE 77 | ) 78 | 79 | class Meta: 80 | verbose_name = "Homepage Segment" 81 | verbose_name_plural = "Homepage Segments" 82 | 83 | panels = [ 84 | FieldPanel("segment"), 85 | ] 86 | 87 | def __str__(self): 88 | return "{!s} Segment".format(self.homepage) 89 | 90 | 91 | class HomePageSponsorRelationship(models.Model): 92 | """Qualify how sponsor helped content described in HomePage 93 | Pivot table for Sponsor M<-->M HomePage 94 | """ 95 | 96 | sponsor = models.ForeignKey(Sponsor, on_delete=models.CASCADE) 97 | homepage = models.ForeignKey("HomePage", on_delete=models.CASCADE) 98 | level = models.ForeignKey(SponsorshipLevel, on_delete=models.CASCADE) 99 | 100 | def __repr__(self): 101 | return "{} {} {}".format( 102 | self.sponsor.name, self.homepage.title, self.level.name 103 | ) 104 | 105 | 106 | class HomePage(Page, MeetupMixin, SponsorMixin): 107 | exclude_fields_in_copy = ['sponsors'] 108 | subpage_types = [ 109 | "HomePage", 110 | "SimplePage", 111 | "speakers.SpeakersPage", 112 | "speakers.TalksPage", 113 | ] 114 | 115 | body = StreamField( 116 | [ 117 | ("heading", blocks.CharBlock(icon="home", classname="full title")), 118 | ("paragraph", blocks.RichTextBlock(icon="edit")), 119 | ("video", EmbedBlock(icon="media")), 120 | ("image", ImageChooserBlock(icon="image")), 121 | ("slide", EmbedBlock(icon="media")), 122 | ("html", RawHTMLBlock(icon="code")), 123 | ], 124 | use_json_field=True, 125 | ) 126 | 127 | sponsors = models.ManyToManyField( 128 | Sponsor, through=HomePageSponsorRelationship, blank=True 129 | ) 130 | 131 | def __str__(self): 132 | return self.title 133 | 134 | content_panels = Page.content_panels + [ 135 | FieldPanel("body"), 136 | ] 137 | 138 | settings_panels = ( 139 | Page.settings_panels 140 | + MeetupMixin.settings_panels 141 | + SponsorMixin.settings_panels 142 | + [InlinePanel("homepage_segments", label="Homepage Segment")] 143 | ) 144 | 145 | 146 | class SimplePage(Page, MeetupMixin, SponsorMixin): 147 | """ 148 | allowed url to embed listed in 149 | lib/python3.4/site-packages/wagtail/wagtailembeds/oembed_providers.py 150 | """ 151 | 152 | body = StreamField( 153 | [ 154 | ("heading", blocks.CharBlock(icon="home", classname="full title")), 155 | ("paragraph", blocks.RichTextBlock(icon="edit")), 156 | ("video", EmbedBlock(icon="media")), 157 | ("image", ImageChooserBlock(icon="image")), 158 | ("slide", EmbedBlock(icon="media")), 159 | ("html", RawHTMLBlock(icon="code")), 160 | ], 161 | use_json_field=True, 162 | ) 163 | 164 | content_panels = Page.content_panels + [ 165 | FieldPanel("body"), 166 | ] 167 | 168 | settings_panels = ( 169 | Page.settings_panels 170 | + MeetupMixin.settings_panels 171 | + SponsorMixin.settings_panels 172 | ) 173 | 174 | 175 | -------------------------------------------------------------------------------- /pythonie/speakers/management/commands/import-sessionize.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import logging 3 | 4 | import numpy as np 5 | import pandas as pd 6 | from django.core.management.base import BaseCommand, CommandParser 7 | from django.utils.text import slugify 8 | from wagtail.models import Page 9 | 10 | from speakers.models import Speaker, Room, Session 11 | 12 | log = logging.getLogger("import-sessionize") 13 | 14 | 15 | class SessionHeaders(str, enum.Enum): 16 | Id = "Session Id" 17 | Title = "Title" 18 | Description = "Description" 19 | OwnerInformed = "Owner Informed" 20 | OwnerConfirmed = "Owner Confirmed" 21 | Room = "Room" 22 | ScheduledAt = "Scheduled At" 23 | ScheduledDuration = "Scheduled Duration" 24 | SpeakerIds = "Speaker Ids" 25 | 26 | 27 | class SpeakerHeaders(str, enum.Enum): 28 | Id = "Speaker Id" 29 | FirstName = "FirstName" 30 | LastName = "LastName" 31 | Email = "Email" 32 | TagLine = "TagLine" 33 | Bio = "Bio" 34 | ProfilePicture = "Profile Picture" 35 | Blog = "Blog" 36 | Twitter = "Twitter" 37 | LinkedIn = "LinkedIn" 38 | 39 | 40 | SESSION_HEADERS = [member.value for name, member in SessionHeaders.__members__.items()] 41 | print(f"{SESSION_HEADERS=}") 42 | SPEAKER_HEADERS = [member.value for name, member in SpeakerHeaders.__members__.items()] 43 | print(f"{SPEAKER_HEADERS=}") 44 | 45 | 46 | class Command(BaseCommand): 47 | help = "Import the sessionize record" 48 | 49 | def add_arguments(self, parser: CommandParser): 50 | parser.add_argument("--file", "-f", action="store", type=str) 51 | 52 | def handle(self, *args, **options): 53 | self.save_speakers(options) 54 | self.save_sessions(options) 55 | 56 | def save_speakers(self, options): 57 | df_accepted_speakers = pd.read_excel( 58 | options["file"], 59 | sheet_name="Accepted speakers", 60 | ) 61 | speakers = df_accepted_speakers[SPEAKER_HEADERS] 62 | parent_page = Page.objects.get(id=144).specific 63 | print(parent_page.title) 64 | for index, row in speakers.iterrows(): 65 | # print(index, row) 66 | name = f"{row[SpeakerHeaders.FirstName]} {row[SpeakerHeaders.LastName]}" 67 | print(f"{row[SpeakerHeaders.Id]=}") 68 | picture_url = row[SpeakerHeaders.ProfilePicture] 69 | if picture_url is np.nan: 70 | picture_url = "" 71 | try: 72 | speaker = Speaker.objects.get(external_id=row[SpeakerHeaders.Id]) 73 | speaker.name = name 74 | speaker.email = row[SpeakerHeaders.Email] 75 | speaker.biography = row[SpeakerHeaders.Bio] 76 | speaker.picture_url = picture_url 77 | speaker.title = name 78 | except Speaker.DoesNotExist: 79 | speaker = Speaker( 80 | external_id=row[SpeakerHeaders.Id], 81 | name=name, 82 | email=row[SpeakerHeaders.Email], 83 | biography=row[SpeakerHeaders.Bio], 84 | picture_url=picture_url, 85 | title=name, 86 | ) 87 | parent_page.add_child(instance=speaker) 88 | 89 | speaker.save() 90 | speaker.save_revision().publish() 91 | 92 | def save_sessions(self, options): 93 | df_accepted_session = pd.read_excel( 94 | options["file"], 95 | sheet_name="Accepted sessions", 96 | ) 97 | sessions = df_accepted_session[SESSION_HEADERS] 98 | parent_page = Page.objects.get(id=145).specific 99 | 100 | for index, row in sessions.iterrows(): 101 | 102 | if row[SessionHeaders.Room] is np.nan: 103 | continue 104 | 105 | room, created = Room.objects.get_or_create( 106 | name=row[SessionHeaders.Room], 107 | ) 108 | 109 | if row[SessionHeaders.ScheduledAt] is pd.NaT: 110 | continue 111 | 112 | state = Session.StateChoices.ACCEPTED 113 | if row[SessionHeaders.OwnerConfirmed] != "No": 114 | state = Session.StateChoices.CONFIRMED 115 | 116 | session_type = Session.TypeChoices.TALK 117 | if str(row[SessionHeaders.Title]).startswith("Workshop:"): 118 | session_type = Session.TypeChoices.WORKSHOP 119 | 120 | name = row[SessionHeaders.Title] 121 | 122 | print(f"{row[SessionHeaders.Id]=}") 123 | try: 124 | session = Session.objects.get(external_id=row[SessionHeaders.Id]) 125 | session.name = name 126 | session.description = row[SessionHeaders.Description] 127 | session.room = room 128 | session.scheduled_at = row[SessionHeaders.ScheduledAt] 129 | session.duration = row[SessionHeaders.ScheduledDuration] 130 | session.state = state 131 | session.type = session_type 132 | session.title = name 133 | except Session.DoesNotExist: 134 | session = Session( 135 | external_id=row[SessionHeaders.Id], 136 | name=name, 137 | description=row[SessionHeaders.Description], 138 | room=room, 139 | scheduled_at=row[SessionHeaders.ScheduledAt], 140 | duration=row[SessionHeaders.ScheduledDuration], 141 | state=state, 142 | type=session_type, 143 | title=name, 144 | ) 145 | parent_page.add_child(instance=session) 146 | 147 | session.save() 148 | session.save_revision().publish() 149 | 150 | session.speakers.all().delete() 151 | session.save() 152 | 153 | speaker_ids = [ 154 | speaker_id.strip() 155 | for speaker_id in row[SessionHeaders.SpeakerIds].split(",") 156 | ] 157 | for speaker in Speaker.objects.filter(external_id__in=speaker_ids): 158 | session.speakers.add(speaker) 159 | 160 | session.save() 161 | 162 | 163 | # print(f"{session_speakers=}") 164 | -------------------------------------------------------------------------------- /Taskfile.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # https://taskfile.dev 3 | 4 | version: '3' 5 | 6 | env: 7 | DOCKER_IMAGE: python.ie/website-dev 8 | PGDATABASE: pythonie 9 | PGPASSWORD: pythonie 10 | PGUSER: postgres 11 | PGHOST: 127.0.0.1 12 | HEROKU_APP: pythonie 13 | 14 | dotenv: ['production.env'] 15 | 16 | tasks: 17 | database:drop: 18 | desc: Drop the local PostgreSQL database 19 | cmds: 20 | - docker compose exec -u postgres postgres dropdb --if-exists $PGDATABASE 21 | 22 | database:pull: 23 | desc: Pull the database from Heroku and store it into a localhost PostgreSQL 24 | cmds: 25 | - heroku pg:pull $HEROKU_POSTGRESQL_IDENTIFIER $PGDATABASE 26 | 27 | database:push: 28 | desc: Push the database from local PostgreSQL to Heroku 29 | cmds: 30 | - heroku pg:push $PGDATABASE $HEROKU_POSTGRESQL_IDENTIFIER 31 | 32 | database:reset: 33 | desc: Reset the database with a fresh copy of the official database 34 | cmds: 35 | - task: database:drop 36 | - task: database:pull 37 | 38 | heroku:database:backups: 39 | desc: Show the backups of the database 40 | cmds: 41 | - heroku pg:backups 42 | 43 | heroku:database:run-backup: 44 | desc: Run a remote backup 45 | cmds: 46 | - heroku pg:backups:capture 47 | 48 | heroku:logs: 49 | desc: View application logs in real-time 50 | cmds: 51 | - heroku logs --tail 52 | 53 | heroku:restart: 54 | desc: Restart the Heroku application 55 | cmds: 56 | - heroku restart 57 | 58 | heroku:shell: 59 | desc: Run Django shell on Heroku 60 | cmds: 61 | - heroku run python pythonie/manage.py shell --settings=pythonie.settings.production 62 | 63 | heroku:bash: 64 | desc: Run bash shell on Heroku 65 | cmds: 66 | - heroku run bash 67 | 68 | heroku:migrate: 69 | desc: Run database migrations on Heroku 70 | cmds: 71 | - heroku run python pythonie/manage.py migrate --settings=pythonie.settings.production 72 | 73 | heroku:config: 74 | desc: Show Heroku environment variables 75 | cmds: 76 | - heroku config 77 | 78 | heroku:ps: 79 | desc: Show dyno status 80 | cmds: 81 | - heroku ps 82 | 83 | heroku:releases: 84 | desc: Show deployment history 85 | cmds: 86 | - heroku releases 87 | 88 | heroku:rollback: 89 | desc: Rollback to previous release 90 | cmds: 91 | - heroku rollback 92 | 93 | heroku:maintenance:on: 94 | desc: Enable maintenance mode 95 | cmds: 96 | - heroku maintenance:on 97 | 98 | heroku:maintenance:off: 99 | desc: Disable maintenance mode 100 | cmds: 101 | - heroku maintenance:off 102 | 103 | run:postgres: 104 | desc: Only run the PostgreSQL server, for example, if you want to restore the database from Heroku 105 | cmds: 106 | - docker compose run --remove-orphans --detach --service-ports postgres 107 | 108 | run: 109 | desc: Run a local version of PythonIE 110 | cmds: 111 | - docker compose run --rm --service-ports web python pythonie/manage.py runserver 0.0.0.0:8000 112 | 113 | django:shell-plus: 114 | desc: Run Django shell_plus 115 | cmds: 116 | - docker compose run --rm web python pythonie/manage.py shell_plus 117 | 118 | shell: 119 | desc: Run a shell 120 | cmds: 121 | - docker compose run --rm web /bin/bash 122 | 123 | django:make-migrations: 124 | desc: Make migrations 125 | cmds: 126 | - docker compose run --rm web python pythonie/manage.py makemigrations 127 | 128 | django:migrate: 129 | desc: Run database migrations 130 | cmds: 131 | - docker compose run --rm web python pythonie/manage.py migrate 132 | 133 | django:collect-static: 134 | desc: Collect static files 135 | cmds: 136 | - docker compose run --rm web python pythonie/manage.py collectstatic 137 | 138 | dependencies:compute: 139 | desc: Compute the dependencies 140 | cmds: 141 | - toast deps:compute 142 | 143 | dependencies:outdated: 144 | desc: List the outdated dependencies 145 | cmds: 146 | - toast deps:outdated 147 | 148 | dependencies:upgrade: 149 | desc: Upgrade the dependencies 150 | cmds: 151 | - toast deps:upgrade 152 | 153 | dependencies:upgrade:django: 154 | desc: Upgrade Django to the latest compatible version 155 | cmds: 156 | - toast deps:upgrade:django 157 | 158 | dependencies:upgrade:wagtail: 159 | desc: Upgrade Wagtail to the latest compatible version 160 | cmds: 161 | - toast deps:upgrade:wagtail 162 | 163 | dependencies:security: 164 | desc: Check dependencies for known security vulnerabilities 165 | cmds: 166 | - toast deps:security 167 | 168 | dependencies:tree: 169 | desc: Show the dependencies tree 170 | cmds: 171 | - toast deps:tree 172 | 173 | docker:build: 174 | desc: Build the docker image 175 | cmds: 176 | - docker build --no-cache -t $DOCKER_IMAGE . 177 | 178 | docker:run: 179 | desc: Run a shell into the docker container 180 | cmds: 181 | - docker compose run web /bin/bash 182 | 183 | stack:pull: 184 | desc: Pull the docker images for the stack 185 | cmds: 186 | - docker compose pull postgres minio mc 187 | 188 | pycon:import:sessionize: 189 | desc: Import the information from Sessionize 190 | cmds: 191 | - docker compose run web python pythonie/manage.py import-sessionize --file sessionize.xlsx 192 | 193 | pycon:import:sessionize:json: 194 | desc: Import the information from Sessionize 195 | cmds: 196 | - docker compose run web python pythonie/manage.py update-sessionize-json-stream 197 | 198 | code:format: 199 | desc: Format the code 200 | cmds: 201 | - toast code:format 202 | 203 | code:lint: 204 | desc: Lint the code and fix issues 205 | cmds: 206 | - toast code:lint 207 | 208 | code:check: 209 | desc: Check code formatting and linting without changes 210 | cmds: 211 | - toast code:check 212 | 213 | dependencies:upgrade:package: 214 | desc: "Upgrade a specific package (usage: task dependencies:upgrade:package PACKAGE=django)" 215 | cmds: 216 | - python -m uv pip compile --upgrade-package $PACKAGE --output-file requirements/main.txt requirements/main.in 217 | - python -m uv pip compile --upgrade-package $PACKAGE --output-file requirements/dev.txt requirements/dev.in 218 | - python -m uv pip compile --upgrade-package $PACKAGE --output-file requirements/production.txt requirements/production.in 219 | 220 | tests: 221 | desc: Run all tests 222 | cmds: 223 | - docker compose run --rm -e DJANGO_SETTINGS_MODULE=pythonie.settings.tests web python pythonie/manage.py test pythonie --verbosity=3 224 | -------------------------------------------------------------------------------- /pythonie/meetups/test_meetups.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest.mock import patch 3 | 4 | from django.test import TestCase 5 | from django.utils import timezone 6 | from meetups import utils 7 | from meetups.models import Meetup, next_n_months 8 | from model_mommy import mommy 9 | from pytz import UTC 10 | 11 | description = ( 12 | " We will be having a meetup in June. More details to follow." 13 | "
If you are interested in speaking, please submit your " 14 | 'details to\xa0' 15 | '' 16 | "http://bit.ly/pyie-cfp-2015.
Enquiries? Please " 17 | "contact contact@python.ie.
" 18 | ) 19 | 20 | 21 | class MeetupModelTests(TestCase): 22 | def test_create_meetup(self): 23 | now = timezone.now() 24 | meetup = mommy.make(Meetup, time=now, created=now, updated=now) 25 | self.assertIsNotNone(meetup.id) 26 | self.assertIsNotNone(meetup.name) 27 | self.assertIsNotNone(meetup.description) 28 | self.assertIsNotNone(meetup.event_url) 29 | self.assertIsNotNone(meetup.time) 30 | self.assertIsNotNone(meetup.created) 31 | self.assertIsNotNone(meetup.updated) 32 | self.assertIsNotNone(meetup.rsvps) 33 | self.assertIsNotNone(meetup.maybe_rsvps) 34 | self.assertIsNotNone(meetup.waitlist_count) 35 | self.assertIsNotNone(meetup.status) 36 | self.assertIsNotNone(meetup.visibility) 37 | 38 | def test_next_n_months(self): 39 | september = datetime(year=2015, month=9, day=1, hour=1, minute=00) 40 | expected = datetime(year=2015, month=12, day=1, hour=1, minute=00) 41 | actual = next_n_months(september, 3) 42 | self.assertEqual(expected, actual) 43 | november = datetime(year=2015, month=10, day=1, hour=1, minute=00) 44 | expected = datetime(year=2016, month=1, day=1, hour=1, minute=00) 45 | actual = next_n_months(november, 3) 46 | self.assertEqual(expected, actual) 47 | 48 | 49 | class UtilsTests(TestCase): 50 | def _first_result(self): 51 | return { 52 | "results": [ 53 | { 54 | "maybe_rsvp_count": 7, 55 | "id": "qwfbshytjbnb", 56 | "waitlist_count": 11, 57 | "yes_rsvp_count": 24, 58 | "utc_offset": 3600000, 59 | "visibility": "public", 60 | "announced": False, 61 | "updated": 1431467590000, 62 | "description": description, 63 | "name": "Python Ireland meetup", 64 | "event_url": ( 65 | "http://www.meetup.com/pythonireland/" "events/221078098/" 66 | ), 67 | "headcount": 0, 68 | "time": 1433957400000, 69 | "created": 1390942022000, 70 | "group": { 71 | "name": "Python Ireland", 72 | "id": 6943212, 73 | "who": "Pythonista", 74 | "join_mode": "open", 75 | "created": 1359572645000, 76 | "group_lon": -6.25, 77 | "group_lat": 53.33000183105469, 78 | "urlname": "pythonireland", 79 | }, 80 | "status": "upcoming", 81 | } 82 | ] 83 | } 84 | 85 | def _second_result(self): 86 | first = self._first_result().copy() 87 | first["results"][0].update({"updated": 1431467600000}) 88 | first["results"][0].update({"yes_rsvp_count": 125}) 89 | first["results"][0].update({"name": "New name"}) 90 | return first 91 | 92 | def _third_result(self): 93 | first = self._first_result().copy() 94 | first["results"][0].update({"id": "qwfbshytjbnc"}) 95 | first["results"][0].update({"name": "New entry"}) 96 | return first 97 | 98 | @patch("meetups.utils.get_content") 99 | def test_update_first_run(self, mock_get_content): 100 | mock_get_content.return_value = self._first_result() 101 | utils.update() 102 | meetups = Meetup.objects.all() 103 | self.assertEqual(len(meetups), 1) 104 | 105 | # Check all values are as expected 106 | meetup = meetups[0] 107 | expected_datetime = datetime( 108 | year=2015, month=6, day=10, hour=17, minute=30, tzinfo=UTC 109 | ) 110 | self.assertEqual(meetup.time, expected_datetime) 111 | expected_datetime = datetime( 112 | year=2015, month=5, day=12, hour=21, minute=53, second=10, tzinfo=UTC 113 | ) 114 | self.assertEqual(meetup.updated, expected_datetime) 115 | expected_datetime = datetime( 116 | year=2014, month=1, day=28, hour=20, minute=47, second=2, tzinfo=UTC 117 | ) 118 | self.assertEqual(meetup.created, expected_datetime) 119 | self.assertEqual(meetup.rsvps, 24) 120 | self.assertEqual(meetup.maybe_rsvps, 7) 121 | self.assertEqual(meetup.waitlist_count, 11) 122 | self.assertEqual(meetup.name, "Python Ireland meetup") 123 | self.assertEqual(meetup.description, description) 124 | self.assertEqual(meetup.status, "upcoming") 125 | self.assertEqual(meetup.visibility, "public") 126 | self.assertEqual( 127 | meetup.event_url, 128 | ("http://www.meetup.com/" "pythonireland/events/221078098/"), 129 | ) 130 | 131 | @patch("meetups.utils.get_content") 132 | def test_update_second_run(self, mock_get_content): 133 | mock_get_content.return_value = self._first_result() 134 | utils.update() 135 | meetups = Meetup.objects.all() 136 | expected_datetime = datetime( 137 | year=2015, month=5, day=12, hour=21, minute=53, second=10, tzinfo=UTC 138 | ) 139 | self.assertEqual(meetups[0].updated, expected_datetime) 140 | 141 | mock_get_content.return_value = self._second_result() 142 | 143 | utils.update() 144 | meetups = Meetup.objects.all() 145 | self.assertEqual(len(meetups), 1) 146 | 147 | expected_datetime = datetime( 148 | year=2015, month=5, day=12, hour=21, minute=53, second=20, tzinfo=UTC 149 | ) 150 | self.assertEqual(meetups[0].updated, expected_datetime) 151 | self.assertEqual(meetups[0].name, "New name") 152 | 153 | self.assertEqual(meetups[0].rsvps, 125) 154 | 155 | @patch("meetups.utils.get_content") 156 | def test_update_second_run_add_one(self, mock_get_content): 157 | mock_get_content.return_value = self._first_result() 158 | utils.update() 159 | mock_get_content.return_value = self._second_result() 160 | utils.update() 161 | mock_get_content.return_value = self._third_result() 162 | utils.update() 163 | meetup_one = Meetup.objects.get(id="qwfbshytjbnb") 164 | meetup_two = Meetup.objects.get(id="qwfbshytjbnc") 165 | self.assertEqual(meetup_one.name, "New name") 166 | self.assertEqual(meetup_two.name, "New entry") 167 | -------------------------------------------------------------------------------- /pythonie/core/static/css/bootstrap-glyphicons.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:'Glyphicons Halflings';src:url('../fonts/glyphicons-halflings-regular.eot');src:url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'),url('../fonts/glyphicons-halflings-regular.woff') format('woff'),url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'),url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg')}.glyphicon:before{font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-plus:before{content:"\002b"}.glyphicon-minus:before{content:"\2212"}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-euro:before{content:"\20ac"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse:before{content:"\e159"}.glyphicon-collapse-top:before{content:"\e160"} 2 | /* This beautiful CSS-File has been crafted with LESS (lesscss.org) and compiled by simpLESS (wearekiss.com/simpless) */ 3 | -------------------------------------------------------------------------------- /pythonie/pythonie/settings/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for pythonie project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.7/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.7/ref/settings/ 9 | """ 10 | import os 11 | from os.path import abspath, dirname, join 12 | 13 | import dj_database_url 14 | 15 | # Absolute filesystem path to the Django project directory: 16 | PROJECT_ROOT = dirname(dirname(dirname(abspath(__file__)))) 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") 23 | 24 | ALLOWED_HOSTS = [ 25 | ".herokuapp.com", 26 | ".python.ie", 27 | ".pycon.ie", 28 | "next.python.ie", 29 | "127.0.0.1", 30 | "localhost", 31 | ] 32 | 33 | # Base URL to use when referring to full URLs within the Wagtail admin backend- 34 | # e.g. in notification emails. Don't include '/admin' or a trailing slash 35 | BASE_URL = "https://python.ie" 36 | 37 | # Application definition 38 | 39 | INSTALLED_APPS = ( 40 | "django.contrib.admin", 41 | "django.contrib.auth", 42 | "django.contrib.contenttypes", 43 | "django.contrib.sessions", 44 | "django.contrib.messages", 45 | "django.contrib.staticfiles", 46 | "compressor", 47 | "taggit", 48 | "modelcluster", 49 | "wagtail", 50 | "wagtail.admin", 51 | "wagtail.documents", 52 | "wagtail.snippets", 53 | "wagtail.users", 54 | "wagtail.sites", 55 | "wagtail.images", 56 | "wagtail.embeds", 57 | "wagtail.search", 58 | "wagtail.contrib.redirects", 59 | "wagtail.contrib.forms", 60 | "wagtail.contrib.styleguide", 61 | "core", 62 | "storages", 63 | "meetups", 64 | "sponsors", 65 | "speakers", 66 | # 'debug_toolbar', 67 | "django_extensions", 68 | ) 69 | 70 | MIDDLEWARE = ( 71 | "django.contrib.sessions.middleware.SessionMiddleware", 72 | "django.middleware.common.CommonMiddleware", 73 | "django.middleware.csrf.CsrfViewMiddleware", 74 | "django.contrib.auth.middleware.AuthenticationMiddleware", 75 | "django.contrib.messages.middleware.MessageMiddleware", 76 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 77 | "whitenoise.middleware.WhiteNoiseMiddleware", 78 | "wagtail.contrib.redirects.middleware.RedirectMiddleware", 79 | ) 80 | 81 | LOGGING = { 82 | "version": 1, 83 | "disable_existing_loggers": False, 84 | "formatters": { 85 | "verbose": { 86 | "format": ( 87 | "%(levelname)s %(asctime)s %(module)s:%(lineno)s " 88 | "%(funcName)s %(message)s" 89 | ) 90 | }, 91 | "simple": {"format": "%(levelname)s %(message)s"}, 92 | }, 93 | "handlers": { 94 | "console": { 95 | "class": "logging.StreamHandler", 96 | "formatter": "verbose", 97 | }, 98 | }, 99 | "loggers": { 100 | "django": { 101 | "handlers": ["console"], 102 | "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), 103 | "propagate": True, 104 | }, 105 | "pythonie": { 106 | "handlers": ["console"], 107 | "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), 108 | "propagate": True, 109 | }, 110 | "meetups": { 111 | "handlers": ["console"], 112 | "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), 113 | "propagate": True, 114 | }, 115 | "sponsors": { 116 | "handlers": ["console"], 117 | "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), 118 | "propagate": True, 119 | }, 120 | "speakers": { 121 | "handlers": ["console"], 122 | "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), 123 | "propagate": True, 124 | }, 125 | "core": { 126 | "handlers": ["console"], 127 | "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), 128 | "propagate": True, 129 | }, 130 | }, 131 | } 132 | 133 | ROOT_URLCONF = "pythonie.urls" 134 | WSGI_APPLICATION = "pythonie.wsgi.application" 135 | 136 | MEETUP_KEY = os.getenv("MEETUP_KEY") 137 | 138 | # Database 139 | # https://docs.djangoproject.com/en/1.7/ref/settings/#databases 140 | 141 | 142 | DATABASES = {"default": dj_database_url.config()} 143 | 144 | # Internationalization 145 | # https://docs.djangoproject.com/en/1.7/topics/i18n/ 146 | 147 | LANGUAGE_CODE = "en-gb" 148 | TIME_ZONE = "UTC" 149 | USE_I18N = True 150 | USE_L10N = True 151 | USE_TZ = True 152 | 153 | # Static files (CSS, JavaScript, Images) 154 | # https://docs.djangoproject.com/en/1.7/howto/static-files/ 155 | 156 | STATIC_ROOT = join(PROJECT_ROOT, "static") 157 | STATIC_URL = "/static/" 158 | 159 | STORAGES = { 160 | "default": { 161 | "BACKEND": "django.core.files.storage.FileSystemStorage", 162 | }, 163 | "staticfiles": { 164 | "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", 165 | }, 166 | } 167 | 168 | STATICFILES_FINDERS = ( 169 | "django.contrib.staticfiles.finders.FileSystemFinder", 170 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 171 | "compressor.finders.CompressorFinder", 172 | ) 173 | 174 | MEDIA_ROOT = join(PROJECT_ROOT, "media") 175 | MEDIA_URL = "/media/" 176 | 177 | # Django compressor settings 178 | # http://django-compressor.readthedocs.org/en/latest/settings/ 179 | 180 | COMPRESS_PRECOMPILERS = (("text/x-scss", "django_libsass.SassCompiler"),) 181 | 182 | # # Template configuration 183 | # TEMPLATE_CONTEXT_PROCESSORS = global_settings.TEMPLATE_CONTEXT_PROCESSORS + ( 184 | # 'django.core.context_processors.request', 185 | # ) 186 | # 187 | # TEMPLATE_LOADERS = ( 188 | # 'django.template.loaders.filesystem.Loader', 189 | # 'django.template.loaders.app_directories.Loader', 190 | # ) 191 | # TEMPLATE_DIRS = ( 192 | # join(PROJECT_ROOT, 'templates'), 193 | # join(PROJECT_ROOT, 'templates/wagtailembeds'), 194 | # ) 195 | # log.debug("Template dirs: {0}".format(TEMPLATE_DIRS)) 196 | TEMPLATES = [ 197 | { 198 | "BACKEND": "django.template.backends.django.DjangoTemplates", 199 | "DIRS": [ 200 | # insert your TEMPLATE_DIRS here 201 | "templates", 202 | "templates/wagtailembeds", 203 | ], 204 | "APP_DIRS": True, 205 | "OPTIONS": { 206 | "context_processors": [ 207 | # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this 208 | # list if you haven't customized them: 209 | "django.contrib.auth.context_processors.auth", 210 | "django.template.context_processors.i18n", 211 | "django.template.context_processors.media", 212 | "django.template.context_processors.static", 213 | "django.template.context_processors.tz", 214 | "django.template.context_processors.request", 215 | "django.contrib.messages.context_processors.messages", 216 | ], 217 | }, 218 | }, 219 | ] 220 | 221 | # Wagtail settings 222 | 223 | WAGTAIL_SITE_NAME = "pythonie" 224 | 225 | # Use Elasticsearch as the search backend for extra performance and better 226 | # search results: 227 | # http://wagtail.readthedocs.org/en/latest/howto/performance.html#search 228 | # http://wagtail.readthedocs.org/en/latest/core_components/\ 229 | # search/backends.html#elasticsearch-backend 230 | # 231 | # WAGTAILSEARCH_BACKENDS = { 232 | # 'default': { 233 | # 'BACKEND': ('wagtail.search.backends.elasticsearch.' 234 | # 'ElasticSearch'), 235 | # 'INDEX': 'pythonie', 236 | # }, 237 | # } 238 | 239 | 240 | # Whether to use face/feature detection to improve image cropping - 241 | # requires OpenCV 242 | WAGTAILIMAGES_FEATURE_DETECTION_ENABLED = False 243 | 244 | MEETUPS_LAST_CHECKED = "meetups_last_checked" 245 | 246 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 247 | -------------------------------------------------------------------------------- /pythonie/core/static/css/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-color: #eaeff2; 3 | --text-color: #111827; 4 | --muted-color: #475569; 5 | --link-color: #0b61d8; 6 | --link-hover-color: #0a58ca; 7 | --card-bg: #ffffff; 8 | --border-color: #d0d7de; 9 | --nav-bg: #ffffff; 10 | --nav-hover-bg: #e5e7eb; 11 | --nav-text: #111827; 12 | --footer-bg: #ffffff; 13 | --button-bg: #e5e7eb; 14 | --button-text: #111827; 15 | --button-border: #cbd5e1; 16 | --focus-ring: #2563eb; 17 | --brand-primary: #336600; 18 | --brand-secondary: #339900; 19 | --heading-color: #111827; 20 | } 21 | 22 | html.dark { 23 | --bg-color: #222426; /* page background */ 24 | --text-color: #bdbcb2; /* requested body text */ 25 | --muted-color: #cbd5e1; /* muted */ 26 | --link-color: #facc15; /* emphasized links */ 27 | --link-hover-color: #c3c3bc; /* neutral hover to avoid yellow tint */ 28 | --card-bg: #1e2021; /* content boxes */ 29 | --border-color: #374151; /* subtle mid gray */ 30 | --nav-bg: #1c1e1f; /* navbar background */ 31 | --nav-hover-bg: #111827; 32 | --nav-text: #bdbcb2; 33 | --footer-bg: #111827; 34 | --button-bg: #1f2937; 35 | --button-text: #e5e7eb; 36 | --button-border: #374151; 37 | --focus-ring: #76b3fa; 38 | --brand-primary: #336600; /* match original logo green */ 39 | --brand-secondary: #339900; /* supporting tint */ 40 | --heading-color: #c3c3bc; 41 | } 42 | 43 | body { 44 | background: var(--bg-color); 45 | color: var(--text-color); 46 | font-size: 16px; 47 | font-family: "bree-serif", serif; 48 | font-style: normal; 49 | font-weight: 300; /* Bree Serif: 700 (bold), 400 (regular), 300 (light), 200 (thin); */ 50 | } 51 | 52 | body, p, li { 53 | color: var(--text-color); 54 | } 55 | 56 | small, 57 | .text-muted { 58 | color: var(--muted-color); 59 | } 60 | 61 | a { 62 | color: var(--link-color); 63 | text-decoration: none; 64 | } 65 | 66 | a:hover, 67 | a:focus { 68 | color: var(--link-hover-color); 69 | text-decoration: underline; 70 | } 71 | 72 | a:focus-visible, 73 | button:focus-visible { 74 | outline: 2px solid var(--focus-ring); 75 | outline-offset: 2px; 76 | } 77 | 78 | .wf-loading { 79 | visibility: hidden; 80 | } 81 | 82 | /* HEADER */ 83 | h2, h3, h4, h5, h6 { 84 | font-family: "bree", sans-serif; 85 | font-style: normal; 86 | font-weight: 400; /* Bree: 700 (bold), 400 (regular), 300 (light), 200 (thin) */ 87 | color: var(--heading-color, var(--text-color)); 88 | } 89 | 90 | h1 { 91 | font-family: "bree", sans-serif; 92 | font-style: normal; 93 | font-weight: 700; 94 | color: var(--heading-color, var(--text-color)); 95 | } 96 | 97 | .list-group img { 98 | width: 100%; 99 | max-width: 183px; 100 | } 101 | 102 | #header > a { 103 | text-decoration: none; 104 | } 105 | 106 | #header h1 { 107 | font-size: 32pt; 108 | text-align: left; 109 | word-wrap: break-word !important; 110 | white-space: normal; 111 | color: #ffffff; 112 | } 113 | 114 | #header h1 #pyhead { 115 | color: var(--brand-primary); 116 | } 117 | 118 | #header h1 #iehead { 119 | color: var(--brand-secondary); 120 | } 121 | 122 | #header h1 #yearhead { 123 | color: var(--brand-secondary); 124 | } 125 | 126 | #header .well { 127 | margin-top: 15px; 128 | } 129 | 130 | #header img.logo { 131 | height: 50px; 132 | } 133 | 134 | /* END HEADER */ 135 | .sponsor-list .sponsor img { 136 | max-width: 140px; 137 | max-height: 100px; 138 | } 139 | 140 | .sponsor-list .sponsor { 141 | padding-bottom: 0.5em 142 | } 143 | 144 | .sponsor-list { 145 | list-style: none; 146 | padding-left: 0em; 147 | } 148 | 149 | .sponsor .logo { 150 | float: left; 151 | margin-right: 0.5em 152 | } 153 | 154 | .blog .blog-post-link { 155 | font-family: "bree", sans-serif; 156 | font-style: normal; 157 | font-weight: 400; 158 | color: var(--text-color); 159 | } 160 | 161 | .thumbnail { 162 | padding: 4px 0; 163 | } 164 | 165 | .navbar-default { 166 | background-color: var(--nav-bg); 167 | border-color: var(--border-color); 168 | } 169 | 170 | .navbar-default .navbar-brand { 171 | color: var(--nav-text); 172 | } 173 | 174 | .navbar-default .navbar-brand:hover, 175 | .navbar-default .navbar-brand:focus { 176 | color: var(--link-hover-color); 177 | } 178 | 179 | .navbar-default .navbar-nav > li > a { 180 | color: var(--nav-text); 181 | } 182 | 183 | .navbar-default .navbar-nav > li > a:hover, 184 | .navbar-default .navbar-nav > li > a:focus { 185 | color: var(--link-hover-color); 186 | background-color: var(--nav-hover-bg); 187 | } 188 | 189 | /* Override Bootstrap's .open styles for dark theme support */ 190 | .navbar-default .navbar-nav > .open > a, 191 | .navbar-default .navbar-nav > .open > a:hover, 192 | .navbar-default .navbar-nav > .open > a:focus { 193 | color: var(--link-hover-color); 194 | background-color: var(--nav-hover-bg); 195 | } 196 | 197 | .navbar-default .navbar-toggle .icon-bar { 198 | background-color: var(--nav-text); 199 | } 200 | 201 | .well { 202 | background-color: var(--card-bg); 203 | border-color: var(--border-color); 204 | color: var(--text-color); 205 | } 206 | 207 | footer .well { 208 | background-color: var(--footer-bg); 209 | } 210 | 211 | .panel, 212 | .panel-default, 213 | .panel-body { 214 | background-color: var(--card-bg); 215 | border-color: var(--border-color); 216 | color: var(--text-color); 217 | } 218 | 219 | .panel-heading { 220 | background-color: var(--nav-bg); 221 | color: var(--text-color); 222 | } 223 | 224 | .list-group-item { 225 | background-color: var(--card-bg); 226 | border-color: var(--border-color); 227 | color: var(--text-color); 228 | } 229 | 230 | .header-bar { 231 | position: relative; 232 | } 233 | 234 | .header-brand { 235 | text-decoration: none; 236 | color: inherit; 237 | } 238 | 239 | .header-actions { 240 | position: absolute; 241 | top: 50%; 242 | right: 0; 243 | transform: translateY(-50%); 244 | } 245 | 246 | .theme-toggle { 247 | display: inline-flex; 248 | align-items: center; 249 | gap: 8px; 250 | background: transparent; 251 | border: none; 252 | padding: 4px 0; 253 | color: var(--text-color); 254 | font-size: 14px; 255 | cursor: pointer; 256 | } 257 | 258 | .theme-toggle:hover { 259 | color: var(--link-hover-color); 260 | } 261 | 262 | .theme-toggle-switch { 263 | position: relative; 264 | width: 48px; 265 | height: 24px; 266 | border-radius: 12px; 267 | background: var(--button-bg); 268 | border: 2px solid var(--button-border); 269 | transition: background 0.2s, border-color 0.2s; 270 | } 271 | 272 | .theme-toggle-switch::before { 273 | content: ''; 274 | position: absolute; 275 | width: 18px; 276 | height: 18px; 277 | border-radius: 50%; 278 | background: var(--button-text); 279 | top: 1px; 280 | left: 1px; 281 | transition: transform 0.2s; 282 | } 283 | 284 | .theme-toggle.is-dark .theme-toggle-switch { 285 | background: var(--nav-hover-bg); 286 | border-color: var(--border-color); 287 | } 288 | 289 | .theme-toggle.is-dark .theme-toggle-switch::before { 290 | transform: translateX(24px); 291 | } 292 | 293 | .theme-toggle-label { 294 | font-weight: 500; 295 | } 296 | 297 | /* Dropdown menu - minimal dark theme support */ 298 | .navbar-default .dropdown-menu { 299 | background-color: var(--nav-bg); 300 | border-color: var(--border-color); 301 | } 302 | 303 | .navbar-default .dropdown-menu > li > a { 304 | color: var(--nav-text); 305 | } 306 | 307 | .navbar-default .dropdown-menu > li > a:hover, 308 | .navbar-default .dropdown-menu > li > a:focus { 309 | background-color: var(--nav-hover-bg); 310 | color: var(--link-hover-color); 311 | } 312 | 313 | .navbar-default .dropdown-menu .divider { 314 | background-color: var(--border-color); 315 | } 316 | 317 | /* Ensure dropdowns work on mobile */ 318 | @media (max-width: 767px) { 319 | .navbar-default .navbar-nav .open .dropdown-menu > li > a { 320 | color: var(--nav-text); 321 | padding: 10px 15px 10px 25px; 322 | } 323 | 324 | .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover, 325 | .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus { 326 | background-color: var(--nav-hover-bg); 327 | color: var(--link-hover-color); 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Ireland Website 2 | 3 | Website for Python Ireland (python.ie / pycon.ie) community, built with Django 5.2 and Wagtail CMS 7.2. Manages meetups, sponsors, speakers, and conference sessions. 4 | 5 | ## Prerequisites 6 | 7 | - Python 3.13 (see `.tool-versions`) 8 | - Docker & Docker Compose (for containerized development - recommended) 9 | - [Task](https://taskfile.dev/) (optional but recommended) 10 | - Redis (only for local non-Docker development) 11 | 12 | ## Quick Start (Docker - Recommended) 13 | 14 | 1. Build the Docker image: 15 | ```bash 16 | task docker:build 17 | # or: make docker-build 18 | ``` 19 | 20 | 2. Start supporting services: 21 | ```bash 22 | docker compose up -d postgres redis minio 23 | ``` 24 | 25 | 3. Run database migrations: 26 | ```bash 27 | task django:migrate 28 | ``` 29 | 30 | 4. Create a superuser: 31 | ```bash 32 | docker compose run --rm web python pythonie/manage.py createsuperuser --settings=pythonie.settings.dev 33 | ``` 34 | 35 | 5. Start the development server: 36 | ```bash 37 | task run 38 | # or: docker compose run --rm --service-ports web python pythonie/manage.py runserver 0.0.0.0:8000 39 | ``` 40 | 41 | 6. Visit http://127.0.0.1:8000/ in your browser 42 | 7. Access Wagtail admin at http://127.0.0.1:8000/admin/ 43 | 44 | ## Local Setup (Without Docker) 45 | 46 | If you prefer to develop without Docker: 47 | 48 | 1. Fork the repository into your own personal GitHub account 49 | 2. Clone your fork: `git clone git@github.com:YourGitHubName/website.git` 50 | 3. Ensure you are running Python 3.13: `python -V` should output `Python 3.13.x` 51 | 4. Create a virtualenv: `python3 -m venv pythonie-venv` 52 | 5. Activate the virtualenv: `source pythonie-venv/bin/activate` 53 | 6. Install dependencies: `pip install -r requirements.txt` (or `uv pip install -r requirements.txt`) 54 | 7. Set up the database: `python pythonie/manage.py migrate --settings=pythonie.settings.dev` 55 | 8. Create a superuser: `python pythonie/manage.py createsuperuser --settings=pythonie.settings.dev` 56 | 9. Install and run Redis server locally: `redis-server` 57 | 10. Set Redis environment variable: `export REDISCLOUD_URL=127.0.0.1:6379` 58 | 11. Run the server: `python pythonie/manage.py runserver --settings=pythonie.settings.dev` 59 | 12. Visit http://127.0.0.1:8000/admin/ to log in 60 | 61 | ## Project Structure 62 | 63 | ``` 64 | pythonie/ 65 | ├── core/ # Base Wagtail pages (HomePage, SimplePage) and mixins 66 | ├── meetups/ # Meetup.com integration and event management 67 | ├── sponsors/ # Sponsor management with sponsorship levels 68 | ├── speakers/ # Conference speakers and sessions (Sessionize integration) 69 | └── pythonie/ 70 | ├── settings/ # Environment-specific settings (base, dev, tests, production) 71 | ├── urls.py # URL configuration 72 | └── wsgi.py # WSGI application 73 | ``` 74 | 75 | ## Common Commands 76 | 77 | ### Using Task (Recommended) 78 | 79 | ```bash 80 | # Development 81 | task run # Run development server 82 | task shell # Open bash shell in container 83 | task django:shell-plus # Django shell with models pre-loaded 84 | 85 | # Database 86 | task django:migrate # Run migrations 87 | task django:make-migrations # Create new migrations 88 | task django:collect-static # Collect static files 89 | 90 | # Testing 91 | task tests # Run test suite 92 | make docker-tests # Alternative test command 93 | 94 | # Code Quality 95 | task code:format # Format code with ruff 96 | task code:lint # Lint and fix issues 97 | task code:check # Check without changes 98 | 99 | # Dependencies 100 | task dependencies:compute # Recompile dependency files 101 | task dependencies:outdated # List outdated packages 102 | task dependencies:upgrade # Upgrade all dependencies 103 | task dependencies:upgrade:package PACKAGE=django # Upgrade specific package 104 | task dependencies:security # Check for security vulnerabilities 105 | task dependencies:tree # Show dependencies tree 106 | 107 | # Database Operations (Heroku) 108 | task database:pull # Pull production database to local 109 | task database:push # Push local database to production 110 | task database:reset # Reset local DB with production copy 111 | task heroku:database:backups # View Heroku backups 112 | task heroku:database:run-backup # Create a new backup 113 | 114 | # Heroku Management 115 | task heroku:logs # View logs in real-time 116 | task heroku:restart # Restart the application 117 | task heroku:shell # Django shell on Heroku 118 | task heroku:bash # Bash shell on Heroku 119 | task heroku:migrate # Run migrations on Heroku 120 | task heroku:config # Show environment variables 121 | task heroku:ps # Show dyno status 122 | task heroku:releases # Show deployment history 123 | task heroku:rollback # Rollback to previous release 124 | task heroku:maintenance:on # Enable maintenance mode 125 | task heroku:maintenance:off # Disable maintenance mode 126 | 127 | # Conference Management 128 | task pycon:import:sessionize # Import from Sessionize Excel 129 | task pycon:import:sessionize:json # Update from Sessionize JSON stream 130 | ``` 131 | 132 | ### Direct Django Commands 133 | 134 | ```bash 135 | # Always specify --settings=pythonie.settings.dev (or tests, production, etc.) 136 | 137 | # Run server 138 | python pythonie/manage.py runserver --settings=pythonie.settings.dev 139 | 140 | # Database 141 | python pythonie/manage.py migrate --settings=pythonie.settings.dev 142 | python pythonie/manage.py makemigrations --settings=pythonie.settings.dev 143 | 144 | # Create superuser 145 | python pythonie/manage.py createsuperuser --settings=pythonie.settings.dev 146 | 147 | # Django shell 148 | python pythonie/manage.py shell_plus --settings=pythonie.settings.dev 149 | ``` 150 | 151 | ## Running Tests 152 | 153 | ```bash 154 | # Using Task (Docker) 155 | task tests 156 | 157 | # Using Make (Docker) 158 | make docker-tests 159 | 160 | # Local (all tests) 161 | python pythonie/manage.py test pythonie --settings=pythonie.settings.tests --verbosity=2 162 | 163 | # Run specific test module 164 | python pythonie/manage.py test pythonie.meetups.test_meetups --settings=pythonie.settings.tests 165 | 166 | # Verbose output 167 | python pythonie/manage.py test pythonie --settings=pythonie.settings.tests --verbosity=3 168 | ``` 169 | 170 | ## Code Quality 171 | 172 | ```bash 173 | # Format code with ruff 174 | task code:format 175 | 176 | # Lint and fix issues 177 | task code:lint 178 | 179 | # Check without changes 180 | task code:check 181 | ``` 182 | 183 | ## Environment Variables 184 | 185 | For Docker development, create/edit `development.env`: 186 | 187 | ```bash 188 | DJANGO_SETTINGS_MODULE=pythonie.settings.dev 189 | PGDATABASE=pythonie 190 | PGUSER=postgres 191 | PGPASSWORD=pythonie 192 | PGHOST=postgres 193 | REDISCLOUD_URL=redis://redis:6379 # Optional, for Redis integration 194 | MEETUP_KEY=your_meetup_api_key # Optional, for Meetup.com sync 195 | ``` 196 | 197 | For local development without Docker: 198 | ```bash 199 | export REDISCLOUD_URL=127.0.0.1:6379 200 | export MEETUP_KEY=your_meetup_api_key # Get from https://secure.meetup.com/meetup_api/key/ 201 | ``` 202 | 203 | ## Deployment 204 | 205 | The project is deployed on Heroku. Use Task commands for database operations: 206 | 207 | ```bash 208 | # View backups 209 | task heroku:database:backups 210 | 211 | # Create a new backup 212 | task heroku:database:run-backup 213 | 214 | # Pull production data to local (for testing/debugging) 215 | task database:pull 216 | 217 | # Push local data to production (use with caution!) 218 | task database:push 219 | ``` 220 | 221 | ## Development Tools 222 | 223 | This project uses several tools to streamline development: 224 | 225 | - **[Task](https://taskfile.dev/)**: Task runner for common workflows. See `Taskfile.yaml` for all available tasks. 226 | - **[Toast](https://github.com/stepchowfun/toast)**: Containerized automation for dependency management. See `toast.yml`. 227 | - **[asdf](https://asdf-vm.com/)**: Tool version manager for consistent Python versions. See `.tool-versions`. 228 | - **[uv](https://github.com/astral-sh/uv)**: Fast Python package manager for dependency installation. 229 | 230 | ## Troubleshooting 231 | 232 | ### Redis Connection Errors 233 | - **Docker**: Redis should work automatically via `docker compose` 234 | - **Local**: Ensure Redis is running (`redis-server`) and set `REDISCLOUD_URL=127.0.0.1:6379` 235 | 236 | ### Database Issues 237 | - Reset with production data: `task database:reset` 238 | - Check PostgreSQL is running: `docker compose ps postgres` 239 | - Verify environment variables in `development.env` 240 | 241 | ### Permission Errors (Docker) 242 | - Check file ownership in mounted volumes 243 | - May need to run: `sudo chown -R $USER:$USER .` 244 | 245 | ### Migration Conflicts 246 | - Pull latest production data: `task database:pull` 247 | - Or create fresh migrations: `task django:make-migrations` 248 | 249 | ### Import Errors or Module Not Found 250 | - Rebuild Docker image: `task docker:build` 251 | - Reinstall dependencies: `pip install -r requirements.txt` 252 | 253 | ## Contributing 254 | 255 | 1. Fork the repository into your own GitHub account 256 | 2. Create a feature branch: `git checkout -b feature/my-new-feature` 257 | 3. Make your changes and test thoroughly 258 | 4. Format your code: `task code:format` 259 | 5. Run tests: `task tests` 260 | 6. Commit your changes with clear messages 261 | 7. Push to your fork and create a Pull Request 262 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is the Python Ireland (python.ie / pycon.ie) website, built with Django 5.2 and Wagtail CMS 7.2. It manages content for the Python Ireland community including meetups, sponsors, speakers, and PyCon talks/sessions. 8 | 9 | ### Python Version 10 | 11 | This project requires **Python 3.13**. All code must be compatible with Python 3.13. When developing locally without Docker, ensure you are using Python 3.13.x. 12 | 13 | ## Architecture 14 | 15 | ### Django Apps Structure 16 | 17 | The project follows a modular Django app structure within the `pythonie/` directory: 18 | 19 | - **core**: Base Wagtail pages (HomePage, SimplePage) with StreamField content blocks. Implements PageSegment snippets and mixins (MeetupMixin, SponsorMixin) for common functionality. 20 | - **meetups**: Manages Meetup.com integration for Python Ireland meetups. Includes a management command `updatemeetups` to sync with Meetup API. 21 | - **sponsors**: Sponsor management with SponsorshipLevel relationships. 22 | - **speakers**: Speaker and Session (talk/workshop) management for conferences. Includes Sessionize integration via management commands (`import-sessionize`, `update-sessionize-json-stream`). 23 | 24 | ### Settings Configuration 25 | 26 | Multiple settings files in `pythonie/pythonie/settings/`: 27 | - `base.py`: Shared settings, uses `dj_database_url` for database configuration 28 | - `dev.py`: SQLite database, local Redis, DEBUG=True 29 | - `tests.py`: Test-specific settings with SQLite and mock Redis 30 | - `production.py`: Heroku production settings 31 | - `pgdev.py`: PostgreSQL development settings 32 | 33 | Always specify settings module: `--settings=pythonie.settings.dev` (or `tests`, `production`, etc.) 34 | 35 | ### Database 36 | 37 | - Development (default): SQLite at `pythonie/db.sqlite3` 38 | - Docker/Tests: PostgreSQL 17 in docker-compose 39 | - Production: PostgreSQL 17 on Heroku via `DATABASE_URL` environment variable 40 | 41 | **Important**: When using Heroku CLI tools locally (e.g., `task database:reset`), ensure you have PostgreSQL 17 installed locally. This simplifies database reset operations and ensures compatibility with production. 42 | 43 | ### Key Dependencies 44 | 45 | - Django ~5.2.0 46 | - Wagtail ~7.2.0 (CMS framework) 47 | - Redis (caching, configured via `REDISCLOUD_URL`) 48 | - WhiteNoise (static file serving) 49 | - boto3/django-storages (S3 integration) 50 | - Delorean/python-dateutil (date handling) 51 | 52 | ## Common Commands 53 | 54 | ### Local Development (without Docker) 55 | 56 | ```bash 57 | # Setup 58 | python3 -m venv pythonie-venv 59 | source pythonie-venv/bin/activate 60 | pip install -r requirements.txt 61 | 62 | # Database 63 | python pythonie/manage.py migrate --settings=pythonie.settings.dev 64 | python pythonie/manage.py createsuperuser --settings=pythonie.settings.dev 65 | 66 | # Run server 67 | python pythonie/manage.py runserver --settings=pythonie.settings.dev 68 | 69 | # Access admin at http://127.0.0.1:8000/admin/ 70 | ``` 71 | 72 | ### Docker Development (preferred) 73 | 74 | Uses Task for most operations. Requires docker-compose with services: web, postgres, redis, minio. 75 | 76 | ```bash 77 | # Build docker image 78 | task docker:build 79 | # or: make docker-build 80 | 81 | # Run development server 82 | task run 83 | # or: docker compose run --rm --service-ports web python pythonie/manage.py runserver 0.0.0.0:8000 84 | 85 | # Shell access 86 | task shell 87 | # or: docker compose run --rm web /bin/bash 88 | 89 | # Django shell 90 | task django:shell-plus 91 | 92 | # Database migrations 93 | task django:make-migrations 94 | task django:migrate 95 | ``` 96 | 97 | ### Testing 98 | 99 | ```bash 100 | # Run all tests (local) 101 | python pythonie/manage.py test pythonie --settings=pythonie.settings.tests --verbosity=2 102 | 103 | # Run all tests (docker) 104 | make docker-tests 105 | # or: task tests 106 | 107 | # Run single test 108 | python pythonie/manage.py test pythonie.meetups.test_meetups --settings=pythonie.settings.tests 109 | ``` 110 | 111 | ### Code Quality 112 | 113 | ```bash 114 | # Format code with ruff 115 | task code:format 116 | # or: toast code:format 117 | # or: python -m ruff format pythonie 118 | 119 | # Lint code and fix issues 120 | task code:lint 121 | # or: toast code:lint 122 | # or: python -m ruff check --fix pythonie 123 | 124 | # Check code formatting and linting without changes 125 | task code:check 126 | # or: toast code:check 127 | ``` 128 | 129 | ### Dependency Management 130 | 131 | Uses `uv` for fast Python package management. Dependencies are defined in `.in` files and compiled to `.txt` files: 132 | 133 | ```bash 134 | # Recompile all dependencies 135 | task dependencies:compute 136 | # or: toast deps:compute 137 | 138 | # Check outdated packages 139 | task dependencies:outdated 140 | 141 | # Upgrade all dependencies 142 | task dependencies:upgrade 143 | 144 | # Upgrade only Wagtail 145 | task dependencies:upgrade:wagtail 146 | 147 | # Upgrade specific package 148 | task dependencies:upgrade:package PACKAGE=django 149 | 150 | # Check for security vulnerabilities 151 | task dependencies:security 152 | 153 | # Show dependencies tree 154 | task dependencies:tree 155 | ``` 156 | 157 | ### Database Operations (Heroku) 158 | 159 | ```bash 160 | # Pull production database to local 161 | task database:pull 162 | 163 | # Push local database to production 164 | task database:push 165 | 166 | # Reset local with fresh production copy 167 | task database:reset 168 | 169 | # View Heroku backups 170 | task heroku:database:backups 171 | 172 | # Create new backup 173 | task heroku:database:run-backup 174 | ``` 175 | 176 | ### Heroku Management 177 | 178 | ```bash 179 | # View logs in real-time 180 | task heroku:logs 181 | 182 | # Restart the application 183 | task heroku:restart 184 | 185 | # Django shell on Heroku 186 | task heroku:shell 187 | 188 | # Bash shell on Heroku 189 | task heroku:bash 190 | 191 | # Run migrations on Heroku 192 | task heroku:migrate 193 | 194 | # Show environment variables 195 | task heroku:config 196 | 197 | # Show dyno status 198 | task heroku:ps 199 | 200 | # Show deployment history 201 | task heroku:releases 202 | 203 | # Rollback to previous release 204 | task heroku:rollback 205 | 206 | # Maintenance mode 207 | task heroku:maintenance:on 208 | task heroku:maintenance:off 209 | ``` 210 | 211 | ### Conference Management 212 | 213 | ```bash 214 | # Import speakers/sessions from Sessionize 215 | task pycon:import:sessionize 216 | # or: docker compose run web python pythonie/manage.py import-sessionize --file sessionize.xlsx 217 | 218 | # Update from Sessionize JSON stream 219 | task pycon:import:sessionize:json 220 | ``` 221 | 222 | ## Important Implementation Notes 223 | 224 | ### Wagtail Page Models 225 | 226 | All page types inherit from `wagtail.models.Page`. The page tree structure: 227 | - HomePage (root, can have child HomePage or SimplePage) 228 | - SimplePage 229 | - SpeakersPage → Speaker pages 230 | - TalksPage → Session pages 231 | 232 | Pages use StreamFields for flexible content blocks (heading, paragraph, video, image, slide, html). 233 | 234 | ### Settings Module Requirement 235 | 236 | Django commands MUST include `--settings=pythonie.settings.`. The default in `manage.py` is `pythonie.settings` which won't work without proper environment setup. 237 | 238 | ### Redis Configuration 239 | 240 | Development expects Redis at `127.0.0.1:6379` or via `REDISCLOUD_URL` environment variable. Configure via `pythonie.settings.configure.configure_redis()`. 241 | 242 | ### Environment Variables 243 | 244 | Key variables (see `development.env` / `production.env`): 245 | - `DJANGO_SECRET_KEY`: Required for production 246 | - `DJANGO_SETTINGS_MODULE`: Settings module path 247 | - `DATABASE_URL`: PostgreSQL connection (Heroku format) 248 | - `REDISCLOUD_URL`: Redis connection 249 | - `MEETUP_KEY`: Meetup.com API key 250 | - `PGDATABASE`, `PGUSER`, `PGPASSWORD`, `PGHOST`: PostgreSQL credentials for Docker 251 | - `HEROKU_APP`: Heroku application name (set in Taskfile.yaml) 252 | 253 | ### Static Files 254 | 255 | Static files collected via WhiteNoise with `CompressedManifestStaticFilesStorage`. SCSS compiled via django-compressor and django-libsass. 256 | 257 | ### Testing Strategy 258 | 259 | Tests use `pythonie.settings.tests` which configures SQLite and mock Redis. Run with `--verbosity=2` or `--verbosity=3` for detailed output. 260 | 261 | ### Deployment 262 | 263 | The project is hosted on Heroku. 264 | 265 | ### Upgrading Wagtail 266 | 267 | After upgrading Wagtail to a new version, you must: 268 | 269 | 1. Rebuild the Docker image: `task docker:build` 270 | 2. Run migrations: `task django:migrate` 271 | 3. Clear and recollect static files: `docker compose run --rm web python pythonie/manage.py collectstatic --clear --noinput` 272 | 4. **Clear your browser cache** (Ctrl+Shift+R or Cmd+Shift+R) - this is critical as Wagtail admin JavaScript files are cached and stale cache can cause the admin sidebar menu to disappear with errors like `wagtailConfig is undefined`. 273 | 274 | ### Storage Configuration (Django 5.1+) 275 | 276 | Django 5.1 removed `DEFAULT_FILE_STORAGE` and `STATICFILES_STORAGE` settings. The project now uses the `STORAGES` API: 277 | 278 | - **base.py**: Defines default storage (FileSystemStorage) and staticfiles (WhiteNoise) 279 | - **production.py**: Overrides default storage to use S3 (`storages.backends.s3boto3.S3Boto3Storage`) 280 | 281 | If you see 404 errors for images after upgrading Django, ensure `STORAGES` is properly configured instead of the deprecated settings. 282 | 283 | ### Documentation Language 284 | 285 | All documentation and code comments must be written in English to ensure all contributors can collaborate effectively. 286 | 287 | ### Git Commits 288 | 289 | When creating git commits, do not include any mention of Claude, Claude Code, or AI assistance in commit messages. Commit messages should focus solely on describing the changes made, without attribution to the tool used to make them. --------------------------------------------------------------------------------