diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index 7cf398b..277d9b9 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -65,12 +65,12 @@ When analyzing the diff, consider splitting commits based on these criteria: ## Examples **Good commit messages for this Django/Wagtail project:** -- ✨ feat: add speaker bio field to Speaker model +- ✨ feat: add dark mode toggle to website header - ✨ feat: implement new StreamField block for video embeds - πŸ› fix: correct sponsor logo display on homepage - πŸ› fix: resolve meetup sync timezone issue - πŸ“ docs: update CLAUDE.md with new task commands -- ♻️ refactor: simplify SpeakersPage queryset logic +- ♻️ refactor: simplify HomePage queryset logic - ♻️ refactor: extract common page mixins to core app - 🎨 style: improve Wagtail admin panel layout - πŸ”₯ chore: remove deprecated Meetup API v2 code @@ -82,17 +82,17 @@ When analyzing the diff, consider splitting commits based on these criteria: - πŸ’š fix: resolve failing Docker build - πŸ”’οΈ fix: patch Django security vulnerability - ♿️ feat: improve navigation accessibility for screen readers -- πŸ—ƒοΈ chore: add migration for new Session fields +- πŸ—ƒοΈ chore: add migration for new Meetup fields - 🌐 feat: add French translation for sponsor pages **Example of splitting commits:** If you modify both a Wagtail page model AND update a management command, split into: -1. ✨ feat: add session_type field to Session model -2. ♻️ refactor: update import-sessionize command to handle new field +1. ✨ feat: add url field to Sponsor model +2. ♻️ refactor: update sponsor display logic to include links If you fix multiple unrelated issues, split into: -1. πŸ› fix: correct speaker ordering on TalksPage +1. πŸ› fix: correct meetup ordering on HomePage 2. πŸ› fix: resolve Redis connection timeout in dev settings 3. πŸ—ƒοΈ chore: add missing migration for sponsors app diff --git a/CLAUDE.md b/CLAUDE.md index c1c696c..3b0dd5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -This is the Python Ireland (python.ie / pycon.ie) website, built with Django 6.0 and Wagtail CMS 7.2. It manages content for the Python Ireland community including meetups, sponsors, speakers, and PyCon talks/sessions. +This is the Python Ireland (python.ie / pycon.ie) website, built with Django 6.0 and Wagtail CMS 7.2. It manages content for the Python Ireland community including meetups and sponsors. ### Python Version @@ -19,7 +19,6 @@ The project follows a modular Django app structure within the `pythonie/` direct - **core**: Base Wagtail pages (HomePage, SimplePage) with StreamField content blocks. Implements PageSegment snippets and mixins (MeetupMixin, SponsorMixin) for common functionality. - **meetups**: Manages Meetup.com integration for Python Ireland meetups. Includes a management command `updatemeetups` to sync with Meetup API. - **sponsors**: Sponsor management with SponsorshipLevel relationships. -- **speakers**: Speaker and Session (talk/workshop) management for conferences. Includes Sessionize integration via management commands (`import-sessionize`, `update-sessionize-json-stream`). ### Settings Configuration @@ -208,17 +207,6 @@ task heroku:maintenance:on task heroku:maintenance:off ``` -### Conference Management - -```bash -# Import speakers/sessions from Sessionize -task pycon:import:sessionize -# or: docker compose run web python pythonie/manage.py import-sessionize --file sessionize.xlsx - -# Update from Sessionize JSON stream -task pycon:import:sessionize:json -``` - ## Important Implementation Notes ### Wagtail Page Models @@ -226,8 +214,6 @@ task pycon:import:sessionize:json All page types inherit from `wagtail.models.Page`. The page tree structure: - HomePage (root, can have child HomePage or SimplePage) - SimplePage -- SpeakersPage β†’ Speaker pages -- TalksPage β†’ Session pages Pages use StreamFields for flexible content blocks (heading, paragraph, video, image, slide, html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cd82ed..45c3b45 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,6 @@ website/ β”œβ”€β”€ pythonie/ # Main Django project β”‚ β”œβ”€β”€ core/ # Base pages and templates β”‚ β”œβ”€β”€ meetups/ # Meetup.com integration -β”‚ β”œβ”€β”€ speakers/ # Conference speakers/sessions β”‚ β”œβ”€β”€ sponsors/ # Sponsor management β”‚ └── pythonie/ # Django settings β”œβ”€β”€ requirements/ # Dependency files @@ -112,7 +111,7 @@ python pythonie/manage.py runserver --settings=pythonie.settings.dev Use descriptive branch names with prefixes: ``` -feature/add-speaker-profile-images +feature/add-dark-mode-toggle bugfix/fix-meetup-sync-error docs/update-readme refactor/simplify-sponsor-model @@ -144,7 +143,7 @@ refactor/simplify-sponsor-model 5. **Commit your changes** with clear messages: ```bash git add . - git commit -m "Add speaker profile image upload feature" + git commit -m "Add dark mode toggle feature" ``` 6. **Push and create a Pull Request**: @@ -244,7 +243,7 @@ task tests # or: python pythonie/manage.py test pythonie --settings=pythonie.settings.tests -v 2 # Specific app -python pythonie/manage.py test pythonie.speakers --settings=pythonie.settings.tests +python pythonie/manage.py test pythonie.meetups --settings=pythonie.settings.tests # Specific test file python pythonie/manage.py test pythonie.meetups.test_meetups --settings=pythonie.settings.tests @@ -261,24 +260,24 @@ Place tests in `test_*.py` files within each app: from django.test import TestCase from model_mommy import mommy -from pythonie.speakers.models import Speaker +from pythonie.sponsors.models import Sponsor -class SpeakerTestCase(TestCase): - """Tests for the Speaker model.""" +class SponsorTestCase(TestCase): + """Tests for the Sponsor model.""" def setUp(self): """Set up test fixtures.""" - self.speaker = mommy.make(Speaker, name="Test Speaker") + self.sponsor = mommy.make(Sponsor, name="Test Sponsor") - def test_speaker_str(self): + def test_sponsor_str(self): """Test string representation.""" - self.assertEqual(str(self.speaker), "Test Speaker") + self.assertEqual(str(self.sponsor), "Test Sponsor") - def test_speaker_creation(self): - """Test speaker can be created.""" - speaker = mommy.make(Speaker) - self.assertIsNotNone(speaker.id) + def test_sponsor_creation(self): + """Test sponsor can be created.""" + sponsor = mommy.make(Sponsor) + self.assertIsNotNone(sponsor.id) ``` ### Test Requirements @@ -294,7 +293,7 @@ class SpeakerTestCase(TestCase): ### Pull Request Guidelines 1. **Title**: Clear, descriptive title - - Good: "Add speaker profile image upload" + - Good: "Add sponsor logo upload feature" - Bad: "Fix stuff" or "Updates" 2. **Description**: Include: @@ -352,7 +351,7 @@ Types: Examples: ``` -feat: Add speaker bio character limit validation +feat: Add dark mode toggle to website header fix: Resolve meetup sync timezone issue diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e7f29e3..e53a6a1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -36,11 +36,11 @@ ### Project Statistics - **~2,749** lines of Python code -- **4** Django apps (core, meetups, speakers, sponsors) -- **11** models + 4 pivot tables -- **5** Wagtail page types -- **19** HTML templates -- **3** custom management commands +- **3** Django apps (core, meetups, sponsors) +- **7** models + 2 pivot tables +- **2** Wagtail page types +- **18** HTML templates +- **1** custom management command --- @@ -60,10 +60,6 @@ pythonie/ β”‚ β”œβ”€β”€ utils.py # Meetup.com API client β”‚ β”œβ”€β”€ schema.py # Colander validation β”‚ └── management/ # updatemeetups command -β”œβ”€β”€ speakers/ # Conference speakers/sessions management -β”‚ β”œβ”€β”€ models.py # Speaker, Session, Room, SpeakersPage, TalksPage -β”‚ β”œβ”€β”€ templatetags/ # speaker_picture tag -β”‚ └── management/ # import-sessionize, update-sessionize-json-stream β”œβ”€β”€ sponsors/ # Sponsor management β”‚ β”œβ”€β”€ models.py # Sponsor, SponsorshipLevel β”‚ └── admin.py # Admin customizations @@ -82,12 +78,8 @@ pythonie/ ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Meetup.com API │──┐ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ - β”œβ”€β”€> updatemeetups ──> Meetup model -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ Sessionize API │─── -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ └──> import commands ──> Speaker/Session models +β”‚ Meetup.com API │──> updatemeetups ──> Meetup model +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”œβ”€β”€> Wagtail Page Tree β”‚ @@ -129,31 +121,10 @@ task django:migrate # 5. Create a superuser task django:createsuperuser -# 6. Create required parent pages (IMPORTANT) -task django:shell-plus -# In the Python shell: -from pythonie.core.models import HomePage -from pythonie.speakers.models import SpeakersPage, TalksPage -from wagtail.models import Site, Page - -root = Page.objects.get(id=1) - -# Create SpeakersPage (note the displayed ID) -speakers_page = SpeakersPage(title="Speakers", slug="speakers") -root.add_child(instance=speakers_page) -speakers_page.save_revision().publish() -print(f"SpeakersPage ID: {speakers_page.id}") # Note this ID! - -# Create TalksPage (note the displayed ID) -talks_page = TalksPage(title="Talks", slug="talks") -root.add_child(instance=talks_page) -talks_page.save_revision().publish() -print(f"TalksPage ID: {talks_page.id}") # Note this ID! +# 6. Generate sample data (creates pages, meetups, sponsors) +docker compose run --rm web python pythonie/manage.py generate_sample_data --settings=pythonie.settings.dev -# 7. Update hardcoded IDs in import-sessionize.py if different from 144/145 -# See "Hardcoded IDs" section below - -# 8. Start the server +# 7. Start the server task run # 9. Access the admin @@ -205,9 +176,6 @@ MEETUP_KEY=your-meetup-api-key AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_STORAGE_BUCKET_NAME= - -# Sessionize (if different) -SESSIONIZE_API_URL=https://sessionize.com/api/v2/z66z4kb6/view/All ``` --- @@ -277,44 +245,6 @@ class Meetup(models.Model): **WARNING**: `id` is a CharField (Meetup.com ID), not an AutoField -#### Speakers App - -**Speaker** (`pythonie/speakers/models.py:27`) -```python -class Speaker(Page): - """Speaker profile (inherits from Page for its own URL) - - Fields: - - external_id: Sessionize UUID (unique, for idempotent import) - - picture_url: Photo URL (Robohash fallback if empty) - - biography: Full bio - - Required parent: SpeakersPage - """ - external_id = models.CharField(max_length=255, unique=True) -``` - -**Session** (`pythonie/speakers/models.py:79`) -```python -class Session(Page): - """Talk or workshop - - States: draft, accepted, confirmed, refused, cancelled - Types: talk, workshop - - Fields: - - scheduled_at: Session DateTime - - duration: Duration in minutes (default 30) - - room: ForeignKey to Room - - speakers: M2M to Speaker - - Required parent: TalksPage - """ - - def is_confirmed(self): - return self.state == Session.StateChoices.CONFIRMED -``` - #### Sponsors App **Sponsor** (`pythonie/sponsors/models.py:21`) @@ -363,15 +293,6 @@ class SponsorshipLevel(models.Model): {% child_pages page as children %} ``` -#### speaker_tags.py - -```python -{% load speaker_tags %} - -{# Display speaker photo or Robohash fallback #} -{% speaker_picture speaker size=100 %} -``` - ### Wagtail Hooks (`pythonie/core/wagtail_hooks.py`) ```python @@ -424,18 +345,6 @@ def enable_quotes(): β”‚ SimplePage β”‚ β”‚ Meetup β”‚ β”‚MeetupSponsor β”‚ β”‚(Wagtail Pageβ”‚ β”‚ (Snippet) β”‚ β”‚ Relationship β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - - -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ SpeakersPage β”‚ β”‚ TalksPage β”‚ -β”‚ (Wagtail Page) β”‚ β”‚ (Wagtail Page) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β”‚ - β–Ό β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Speaker β”œβ”€β”€β”€β”€β”€ Session β”œβ”€β”€β”€β”€β”€ Room β”‚ - β”‚ (Page) β”‚M2M β”‚ (Page) β”‚ FK β”‚ β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ### M2M Relations with Pivot Tables @@ -506,106 +415,6 @@ task meetup:update **Note**: Meetup.com API v2 is deprecated, migration to GraphQL API recommended -### 2. Sessionize Integration - -#### Method A: Excel Import - -**File**: `pythonie/speakers/management/commands/import-sessionize.py` - -**Prerequisites**: -- Export Excel from Sessionize (with sheets "Accepted speakers", "Accepted sessions") -- Existing parent pages: SpeakersPage (ID 144), TalksPage (ID 145) - -**Usage**: -```bash -# Download sessionize.xlsx from Sessionize -docker-compose run web python pythonie/manage.py import-sessionize --file sessionize.xlsx - -# Or via Task -task pycon:import:sessionize -``` - -**Required Excel columns**: - -Speakers: -- `Speaker Id` (Sessionize UUID) -- `Full Name` -- `Email Address` -- `Profile Picture` -- `Biography` - -Sessions: -- `Session Id` -- `Session Title` -- `Session Description` -- `Session Type` (talk/workshop) -- `Speaker Ids` (comma-separated UUIDs) -- `Scheduled At` -- `Room` -- `Duration` - -**Hardcoded IDs** (IMPORTANT): -```python -# Lines 50-52 -parent_page = Page.objects.get(id=144).specific # SpeakersPage -parent_page = Page.objects.get(id=145).specific # TalksPage -``` - -**If your IDs differ**, update these lines or create pages with these exact IDs. - -#### Method B: JSON Stream API (Recommended) - -**File**: `pythonie/speakers/management/commands/update-sessionize-json-stream.py` - -**Advantages**: -- Automated (no Excel file needed) -- Can be scheduled via cron -- Pydantic validation - -**Usage**: -```bash -docker-compose run web python pythonie/manage.py update-sessionize-json-stream - -# Or via Task -task pycon:import:sessionize:json -``` - -**API URL** (line 131): -```python -response = requests.get("https://sessionize.com/api/v2/z66z4kb6/view/All") -``` - -**To change URL** for another event: -1. Go to Sessionize > Event > API / Embed -2. Copy the "All Data (JSON)" URL -3. Update in the code OR create env variable `SESSIONIZE_API_URL` - -**Pydantic Models** (validation): -```python -class SessionizeSpeaker(BaseModel): - id: str - firstName: str - lastName: str - bio: str | None = None - profilePicture: str | None = None - sessions: list[int] - -class SessionizeSession(BaseModel): - id: int - title: str - description: str | None = None - startsAt: str | None = None - endsAt: str | None = None - roomId: int | None = None - speakers: list[str] -``` - -**Email Fallback**: -```python -# Sessionize API doesn't provide emails -email = f"{speaker.id}@sessionize.com" -``` - ### 3. AWS S3 (Production) **Configuration** (`pythonie/pythonie/settings/production.py`): @@ -710,13 +519,13 @@ python pythonie/manage.py migrate --settings=pythonie.settings.dev **Migrate specific app**: ```bash -python pythonie/manage.py migrate speakers --settings=pythonie.settings.dev +python pythonie/manage.py migrate meetups --settings=pythonie.settings.dev ``` **Rollback migration**: ```bash # Rollback to migration 0003 -python pythonie/manage.py migrate speakers 0003 --settings=pythonie.settings.dev +python pythonie/manage.py migrate meetups 0003 --settings=pythonie.settings.dev ``` ### Heroku Database Management @@ -799,9 +608,9 @@ homepage = HomePage.objects.first() homepage.sponsors.add(sponsor, through_defaults={"level": gold}) # Publish a page -from pythonie.speakers.models import Speaker -speaker = Speaker.objects.get(name="John Doe") -speaker.save_revision().publish() +from pythonie.core.models import SimplePage +page = SimplePage.objects.get(slug="about") +page.save_revision().publish() ``` --- @@ -814,7 +623,7 @@ speaker.save_revision().publish() - `pythonie/meetups/test_meetups.py` - `pythonie/sponsors/test_sponsors.py` -**Missing**: Core and Speakers apps lack tests (high priority) +**Missing**: Core app lacks tests (high priority) ### Running Tests @@ -895,38 +704,34 @@ LOGGING = { ```python from django.test import TestCase from model_mommy import mommy -from pythonie.speakers.models import Speaker, Session +from pythonie.meetups.models import Meetup -class SessionTestCase(TestCase): +class MeetupTestCase(TestCase): def setUp(self): - self.speaker = mommy.make(Speaker, name="Jane Doe") - self.session = mommy.make( - Session, - name="Test Talk", - state=Session.StateChoices.CONFIRMED + self.meetup = mommy.make( + Meetup, + name="Python Ireland Monthly Meetup", + id="test-meetup-1" ) - self.session.speakers.add(self.speaker) - def test_is_confirmed(self): - self.assertTrue(self.session.is_confirmed()) - - def test_speaker_names(self): - self.assertEqual(self.session.speaker_names, "Jane Doe") + def test_meetup_created(self): + self.assertIsNotNone(self.meetup) + self.assertEqual(self.meetup.name, "Python Ireland Monthly Meetup") ``` **View test example**: ```python from django.test import TestCase, Client +from pythonie.core.models import HomePage -class SpeakersPageTestCase(TestCase): +class HomePageTestCase(TestCase): def setUp(self): self.client = Client() # Setup page tree... - def test_speakers_page_loads(self): - response = self.client.get("/speakers/") + def test_home_page_loads(self): + response = self.client.get("/") self.assertEqual(response.status_code, 200) - self.assertContains(response, "Speakers") ``` --- @@ -1057,43 +862,6 @@ export DJANGO_SETTINGS_MODULE=pythonie.settings.dev python pythonie/manage.py runserver ``` -### Problem: "Page with id=144 does not exist" - -**Error**: -``` -DoesNotExist: Page matching query does not exist. -``` - -**Cause**: Sessionize import looks for SpeakersPage (id=144) and TalksPage (id=145) - -**Solution A - Create pages with these IDs**: -```python -# Django shell -from pythonie.speakers.models import SpeakersPage, TalksPage -from wagtail.models import Page - -root = Page.objects.get(id=1) - -# Delete existing pages if present -SpeakersPage.objects.all().delete() -TalksPage.objects.all().delete() - -# Creating with specific ID (tricky with Wagtail, better to recreate DB) -``` - -**Solution B - Modify import code**: -```python -# pythonie/speakers/management/commands/import-sessionize.py - -# Lines 50-52, replace: -parent_page = Page.objects.get(id=144).specific - -# With: -parent_page = SpeakersPage.objects.first() -if not parent_page: - raise CommandError("SpeakersPage not found. Create it first.") -``` - ### Problem: Redis Connection Error **Error**: @@ -1123,25 +891,6 @@ redis-server REDIS = None ``` -### Problem: Sessionize import fails silently - -**Cause**: Pandas doesn't parse Excel correctly - -**Debug**: -```python -# Open Django shell -task django:shell-plus - -import pandas as pd -df = pd.read_excel("sessionize.xlsx", sheet_name="Accepted speakers") -print(df.head()) -print(df.columns) - -# Check exact column names -``` - -**Solution**: Rename columns in Excel or modify mapping in `import-sessionize.py` - ### Problem: Meetup update returns nothing **Error**: `update()` returns 0 meetups @@ -1268,7 +1017,7 @@ task dependencies:security # Check for security vulnerabilities python pythonie/manage.py makemigrations # Per app -python pythonie/manage.py makemigrations speakers +python pythonie/manage.py makemigrations meetups python pythonie/manage.py makemigrations sponsors ``` @@ -1295,16 +1044,16 @@ Django~=5.0.14 wagtail>=6.2,<7.0 ``` -### 7. Use external_id for Imports +### 7. Use Unique Identifiers for Imports ```python # Creates duplicates - avoid -speaker = Speaker.objects.create(name=data["name"]) +sponsor = Sponsor.objects.create(name=data["name"]) -# Get or create with external_id - do this -speaker, created = Speaker.objects.get_or_create( - external_id=data["id"], - defaults={"name": data["name"], ...} +# Get or create with unique field - do this +sponsor, created = Sponsor.objects.get_or_create( + name=data["name"], + defaults={"url": data["url"], ...} ) ``` @@ -1324,23 +1073,23 @@ page.save_revision().publish() from django.db import transaction @transaction.atomic -def import_speakers(data): - for speaker_data in data: +def import_sponsors(data): + for sponsor_data in data: # If an error occurs, everything rolls back - speaker = create_speaker(speaker_data) + sponsor = create_sponsor(sponsor_data) ``` ### 10. Log Instead of Print ```python import logging -logger = logging.getLogger("pythonie.speakers") +logger = logging.getLogger("pythonie.sponsors") # Avoid -print(f"Created speaker: {speaker.name}") +print(f"Created sponsor: {sponsor.name}") # Do this -logger.info(f"Created speaker: {speaker.name}") +logger.info(f"Created sponsor: {sponsor.name}") ``` --- @@ -1377,8 +1126,6 @@ task dependencies:security # Check for security vulnerabilities task dependencies:tree # Show dependencies tree # Imports -task pycon:import:sessionize # Import Sessionize Excel -task pycon:import:sessionize:json # Import Sessionize JSON task meetup:update # Update meetups # Heroku @@ -1427,7 +1174,6 @@ pythonie/pythonie/wsgi.py # WSGI application - **Production**: https://python.ie - **Wagtail Docs**: https://docs.wagtail.org/ - **Django Docs**: https://docs.djangoproject.com/ -- **Sessionize API**: https://sessionize.com/playbook/api - **Meetup API**: https://www.meetup.com/api/ (deprecated, GraphQL recommended) --- diff --git a/MIGRATION_DEPLOYMENT.md b/MIGRATION_DEPLOYMENT.md new file mode 100644 index 0000000..b80cf15 --- /dev/null +++ b/MIGRATION_DEPLOYMENT.md @@ -0,0 +1,212 @@ +# Deployment Guide: Removing Speakers App + +This guide explains how to safely deploy PR #161 which removes the speakers app from the Python Ireland website. + +## Overview + +PR #161 removes the entire `speakers` Django app and all associated code. A database migration has been created to cleanly drop the speakers tables from production. + +## Pre-requisites + +βœ… All speaker/session records have been deleted from production database +βœ… No Wagtail pages of type SpeakersPage or TalksPage exist in production +βœ… Recent database backup exists + +## Deployment Steps + +### Step 1: Create a Backup + +**CRITICAL**: Always backup before running destructive migrations. + +```bash +# Create a fresh backup +task heroku:database:run-backup + +# Verify the backup was created +task heroku:database:backups +``` + +### Step 2: Merge the PR + +Merge PR #161 into master branch: + +```bash +# Via GitHub CLI +gh pr merge 161 --squash --delete-branch + +# Or via GitHub web interface +``` + +### Step 3: Deploy to Heroku + +The deployment will happen automatically if auto-deploy is configured, or manually: + +```bash +# Pull latest master +git checkout master +git pull origin master + +# Push to Heroku (if manual deployment) +git push heroku master +``` + +### Step 4: Run the Migration + +The migration will run automatically during Heroku release phase. If you need to run it manually: + +```bash +# Run migrations on Heroku +task heroku:migrate + +# Or directly: +heroku run python pythonie/manage.py migrate --settings=pythonie.settings.production +``` + +The migration `0010_remove_speakers_tables` will: +- Drop `speakers_session_speakers` (M2M table) +- Drop `speakers_session` +- Drop `speakers_speaker` +- Drop `speakers_room` +- Drop `speakers_talkspage` +- Drop `speakers_speakerspage` + +### Step 5: Verify Deployment + +```bash +# Check application logs +task heroku:logs + +# Verify app is running +heroku ps + +# Test the website +curl -I https://python.ie + +# Verify migration ran +heroku run python pythonie/manage.py showmigrations core --settings=pythonie.settings.production +``` + +You should see `[X] 0010_remove_speakers_tables` in the output. + +### Step 6: Verify Tables Were Dropped + +```bash +# Connect to production database +heroku pg:psql + +# Check for speakers tables (should return no rows) +\dt speakers_* + +# Exit psql +\q +``` + +## Rollback Procedure + +⚠️ **WARNING**: The migration is IRREVERSIBLE by design. + +If you need to rollback: + +1. **Restore from backup**: + ```bash + # List backups + heroku pg:backups + + # Restore from backup (replace b001 with your backup ID) + heroku pg:backups:restore b001 DATABASE_URL + ``` + +2. **Revert the code**: + ```bash + # Rollback Heroku release + task heroku:rollback + + # Or force push previous commit + git checkout + git push heroku HEAD:master --force + ``` + +## Troubleshooting + +### Migration fails with "table does not exist" + +This is OK if the tables were already manually dropped. The migration uses `DROP TABLE IF EXISTS` so it won't error. + +### Application won't start after deployment + +Check logs for the specific error: +```bash +task heroku:logs +``` + +Common issues: +- Missing environment variable +- Database connection issue +- Code import error + +### Need to verify migration status + +```bash +# Show all migrations and their status +heroku run python pythonie/manage.py showmigrations --settings=pythonie.settings.production +``` + +## Post-Deployment Cleanup + +After successful deployment, clean up your local environment: + +```bash +# Remove the worktree +cd /Users/stephane/src/PythonIreland/website +git worktree remove /Users/stephane/src/PythonIreland/pythonie-remove-speakers + +# Delete local branch +git branch -d remove-speakers + +# Verify worktrees +git worktree list +``` + +## Migration Details + +**File**: `pythonie/core/migrations/0010_remove_speakers_tables.py` + +**What it does**: +- Uses raw SQL to drop speakers tables +- Handles CASCADE for foreign key constraints +- Drops tables in correct dependency order +- Is irreversible (raises RuntimeError on reverse) + +**Why in core app**: +The speakers app has been completely removed from the codebase, so the migration must live in another app (core was chosen as it's the base app). + +## Notes + +- This migration does NOT delete data - data should be deleted before running the migration +- The migration is idempotent - safe to run multiple times +- All table drops use CASCADE to handle foreign keys +- The migration cannot be reversed - backup is essential + +## Success Criteria + +βœ… Application deploys successfully +βœ… No errors in Heroku logs +βœ… Website loads at https://python.ie +βœ… Migration 0010_remove_speakers_tables shows as applied +βœ… No speakers_* tables exist in database +βœ… Admin interface works correctly + +## Support + +If you encounter issues during deployment: + +1. Check Heroku logs: `task heroku:logs` +2. Verify migration status: `heroku run python pythonie/manage.py showmigrations` +3. Restore from backup if needed +4. Contact the development team + +--- + +**Created**: 2026-01-11 +**PR**: #161 +**Migration**: `core.0010_remove_speakers_tables` diff --git a/README.md b/README.md index 7aad389..d90b5b1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Python Ireland Website -Website for Python Ireland (python.ie / pycon.ie) community, built with Django 6.0 and Wagtail CMS 7.2. Manages meetups, sponsors, speakers, and conference sessions. +Website for Python Ireland (python.ie / pycon.ie) community, built with Django 6.0 and Wagtail CMS 7.2. Manages meetups and sponsors. ## Prerequisites @@ -73,7 +73,6 @@ pythonie/ β”œβ”€β”€ core/ # Base Wagtail pages (HomePage, SimplePage) and mixins β”œβ”€β”€ meetups/ # Meetup.com integration and event management β”œβ”€β”€ sponsors/ # Sponsor management with sponsorship levels -β”œβ”€β”€ speakers/ # Conference speakers and sessions (Sessionize integration) └── pythonie/ β”œβ”€β”€ settings/ # Environment-specific settings (base, dev, tests, production) β”œβ”€β”€ urls.py # URL configuration @@ -134,10 +133,6 @@ task heroku:releases # Show deployment history task heroku:rollback # Rollback to previous release task heroku:maintenance:on # Enable maintenance mode task heroku:maintenance:off # Disable maintenance mode - -# Conference Management -task pycon:import:sessionize # Import from Sessionize Excel -task pycon:import:sessionize:json # Update from Sessionize JSON stream ``` ### Direct Django Commands diff --git a/Taskfile.yaml b/Taskfile.yaml index 7f82d67..09a68eb 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -108,7 +108,7 @@ tasks: run: desc: Run a local version of PythonIE cmds: - - docker compose run --rm --service-ports web python pythonie/manage.py runserver 0.0.0.0:8000 + - docker compose run --remove-orphans --rm --service-ports web python pythonie/manage.py runserver 0.0.0.0:8000 django:shell-plus: desc: Run Django shell_plus @@ -130,6 +130,16 @@ tasks: cmds: - docker compose run --rm web python pythonie/manage.py migrate + django:migrate:postgres: + desc: Run database migrations on PostgreSQL (docker-compose) + cmds: + - docker compose run --rm web python pythonie/manage.py migrate --settings=pythonie.settings.pgdev + + django:showmigrations: + desc: Show migration status + cmds: + - docker compose run --rm web python pythonie/manage.py showmigrations --settings=pythonie.settings.pgdev + django:collect-static: desc: Collect static files cmds: @@ -183,17 +193,12 @@ tasks: stack:pull: desc: Pull the docker images for the stack cmds: - - docker compose pull postgres minio mc + - docker compose pull postgres minio mc redis - pycon:import:sessionize: - desc: Import the information from Sessionize + stack:services: + desc: List the services from the docker compose file cmds: - - docker compose run web python pythonie/manage.py import-sessionize --file sessionize.xlsx - - pycon:import:sessionize:json: - desc: Import the information from Sessionize - cmds: - - docker compose run web python pythonie/manage.py update-sessionize-json-stream + - docker compose config --services code:format: desc: Format the code @@ -221,3 +226,8 @@ tasks: desc: Run all tests cmds: - docker compose run --rm -e DJANGO_SETTINGS_MODULE=pythonie.settings.tests web python pythonie/manage.py test pythonie --verbosity=3 + + down: + desc: Stop the docker compose stack + cmds: + - docker compose down diff --git a/pythonie/core/management/commands/generate_sample_data.py b/pythonie/core/management/commands/generate_sample_data.py index afc46b5..905ad6e 100644 --- a/pythonie/core/management/commands/generate_sample_data.py +++ b/pythonie/core/management/commands/generate_sample_data.py @@ -64,7 +64,6 @@ def _create_home_page(self): def _create_navigation_pages(self, home): pycon = self._create_page(home, "PyCon 2025", "pycon-2025") self._create_page(pycon, "Schedule", "schedule") - self._create_page(pycon, "Speakers", "pycon-speakers") self._create_page(pycon, "Sponsors", "pycon-sponsors") self._create_page(pycon, "Venue", "venue") self._create_page(pycon, "Tickets", "tickets") diff --git a/pythonie/core/migrations/0010_remove_speakers_tables.py b/pythonie/core/migrations/0010_remove_speakers_tables.py new file mode 100644 index 0000000..1119cbe --- /dev/null +++ b/pythonie/core/migrations/0010_remove_speakers_tables.py @@ -0,0 +1,68 @@ +# Generated manually to remove speakers app tables + +from django.db import migrations + + +def drop_speakers_tables(apps, schema_editor): + """ + Drop all tables from the removed speakers app. + + This migration removes tables that were part of the speakers app which + has been completely removed from the codebase. The tables are dropped + in order to handle foreign key constraints properly. + """ + # We can't use apps.get_model() here since the speakers app is removed + # Instead, we use raw SQL to drop the tables + + # Determine if we're using PostgreSQL or SQLite + is_postgres = schema_editor.connection.vendor == "postgresql" + cascade = " CASCADE" if is_postgres else "" + + with schema_editor.connection.cursor() as cursor: + # For SQLite, we need to disable foreign keys temporarily + if not is_postgres: + cursor.execute("PRAGMA foreign_keys = OFF") + + # Drop tables in reverse dependency order + # First drop junction tables (M2M relationships) + cursor.execute(f"DROP TABLE IF EXISTS speakers_session_speakers{cascade}") + + # Then drop tables with foreign keys + cursor.execute(f"DROP TABLE IF EXISTS speakers_session{cascade}") + cursor.execute(f"DROP TABLE IF EXISTS speakers_speaker{cascade}") + + # Drop remaining tables + cursor.execute(f"DROP TABLE IF EXISTS speakers_room{cascade}") + cursor.execute(f"DROP TABLE IF EXISTS speakers_talkspage{cascade}") + cursor.execute(f"DROP TABLE IF EXISTS speakers_speakerspage{cascade}") + + # Re-enable foreign keys for SQLite + if not is_postgres: + cursor.execute("PRAGMA foreign_keys = ON") + + +def reverse_drop_speakers_tables(apps, schema_editor): + """ + This migration is irreversible. + + If you need to rollback, restore from a database backup taken before + running this migration. + """ + raise RuntimeError( + "This migration cannot be reversed. " + "The speakers app and its data have been permanently removed. " + "Restore from a backup if you need to recover." + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0009_auto_20160703_0857"), + ] + + operations = [ + migrations.RunPython( + drop_speakers_tables, + reverse_drop_speakers_tables, + ), + ] diff --git a/pythonie/core/models.py b/pythonie/core/models.py index 5259f94..77b92fe 100644 --- a/pythonie/core/models.py +++ b/pythonie/core/models.py @@ -107,8 +107,6 @@ class HomePage(Page, MeetupMixin, SponsorMixin): subpage_types = [ "HomePage", "SimplePage", - "speakers.SpeakersPage", - "speakers.TalksPage", ] body = StreamField( diff --git a/pythonie/core/templates/core/speakers.html b/pythonie/core/templates/core/speakers.html deleted file mode 100644 index c4a672c..0000000 --- a/pythonie/core/templates/core/speakers.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "base.html" %} - -{% block content %} - {% if speakers %}

Speakers

{% endif %} - {% for speaker in speakers %} -
{{ speaker.name }}
- {% endfor %} -{% endblock %} diff --git a/pythonie/pythonie/settings/base.py b/pythonie/pythonie/settings/base.py index 94454f8..27c66d2 100644 --- a/pythonie/pythonie/settings/base.py +++ b/pythonie/pythonie/settings/base.py @@ -15,6 +15,7 @@ # Absolute filesystem path to the Django project directory: PROJECT_ROOT = dirname(dirname(dirname(abspath(__file__)))) +print(f"{PROJECT_ROOT=}") # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.7/howto/deployment/checklist/ @@ -68,7 +69,6 @@ "storages", "meetups", "sponsors", - "speakers", # 'debug_toolbar', "django_extensions", ) @@ -123,11 +123,6 @@ "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), "propagate": True, }, - "speakers": { - "handlers": ["console"], - "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), - "propagate": True, - }, "core": { "handlers": ["console"], "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), diff --git a/pythonie/pythonie/settings/tests.py b/pythonie/pythonie/settings/tests.py index 537c79c..8579c94 100644 --- a/pythonie/pythonie/settings/tests.py +++ b/pythonie/pythonie/settings/tests.py @@ -46,10 +46,6 @@ "handlers": ["console"], "level": os.getenv("DJANGO_LOG_LEVEL", "ERROR"), }, - "speakers": { - "handlers": ["console"], - "level": os.getenv("DJANGO_LOG_LEVEL", "ERROR"), - }, "core": { "handlers": ["console"], "level": os.getenv("DJANGO_LOG_LEVEL", "ERROR"), diff --git a/pythonie/speakers/__init__.py b/pythonie/speakers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pythonie/speakers/admin.py b/pythonie/speakers/admin.py deleted file mode 100644 index d4a6d93..0000000 --- a/pythonie/speakers/admin.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.contrib import admin - -from speakers.models import Room, Session, Speaker - - -class RoomAdmin(admin.ModelAdmin): - list_display = ("name",) - - -admin.site.register(Room, RoomAdmin) - - -class SpeakerAdmin(admin.ModelAdmin): - list_display = ( - "name", - "email", - ) - - -admin.site.register(Speaker, SpeakerAdmin) - - -class SessionAdmin(admin.ModelAdmin): - list_display = ( - "name", - "speaker_names", - "room", - "type", - "state", - "scheduled_at", - ) - - list_filter = ("room", "state", "type") - - search_fields = ["name", "room", "type", "state"] - - @admin.display() - def speaker_names(self, obj): - return ", ".join(speaker.name for speaker in obj.speakers.all()) - - -admin.site.register(Session, SessionAdmin) diff --git a/pythonie/speakers/management/__init__.py b/pythonie/speakers/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pythonie/speakers/management/commands/__init__.py b/pythonie/speakers/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pythonie/speakers/management/commands/import-sessionize.py b/pythonie/speakers/management/commands/import-sessionize.py deleted file mode 100644 index a565e1f..0000000 --- a/pythonie/speakers/management/commands/import-sessionize.py +++ /dev/null @@ -1,161 +0,0 @@ -import enum -import logging - -import numpy as np -import pandas as pd -from django.core.management.base import BaseCommand, CommandParser -from wagtail.models import Page - -from speakers.models import Room, Session, Speaker - -log = logging.getLogger("import-sessionize") - - -class SessionHeaders(str, enum.Enum): - Id = "Session Id" - Title = "Title" - Description = "Description" - OwnerInformed = "Owner Informed" - OwnerConfirmed = "Owner Confirmed" - Room = "Room" - ScheduledAt = "Scheduled At" - ScheduledDuration = "Scheduled Duration" - SpeakerIds = "Speaker Ids" - - -class SpeakerHeaders(str, enum.Enum): - Id = "Speaker Id" - FirstName = "FirstName" - LastName = "LastName" - Email = "Email" - TagLine = "TagLine" - Bio = "Bio" - ProfilePicture = "Profile Picture" - Blog = "Blog" - Twitter = "Twitter" - LinkedIn = "LinkedIn" - - -SESSION_HEADERS = [member.value for name, member in SessionHeaders.__members__.items()] -print(f"{SESSION_HEADERS=}") -SPEAKER_HEADERS = [member.value for name, member in SpeakerHeaders.__members__.items()] -print(f"{SPEAKER_HEADERS=}") - - -class Command(BaseCommand): - help = "Import the sessionize record" - - def add_arguments(self, parser: CommandParser): - parser.add_argument("--file", "-f", action="store", type=str) - - def handle(self, *args, **options): - self.save_speakers(options) - self.save_sessions(options) - - def save_speakers(self, options): - df_accepted_speakers = pd.read_excel( - options["file"], - sheet_name="Accepted speakers", - ) - speakers = df_accepted_speakers[SPEAKER_HEADERS] - parent_page = Page.objects.get(id=144).specific - print(parent_page.title) - for index, row in speakers.iterrows(): - # print(index, row) - name = f"{row[SpeakerHeaders.FirstName]} {row[SpeakerHeaders.LastName]}" - print(f"{row[SpeakerHeaders.Id]=}") - picture_url = row[SpeakerHeaders.ProfilePicture] - if picture_url is np.nan: - picture_url = "" - try: - speaker = Speaker.objects.get(external_id=row[SpeakerHeaders.Id]) - speaker.name = name - speaker.email = row[SpeakerHeaders.Email] - speaker.biography = row[SpeakerHeaders.Bio] - speaker.picture_url = picture_url - speaker.title = name - except Speaker.DoesNotExist: - speaker = Speaker( - external_id=row[SpeakerHeaders.Id], - name=name, - email=row[SpeakerHeaders.Email], - biography=row[SpeakerHeaders.Bio], - picture_url=picture_url, - title=name, - ) - parent_page.add_child(instance=speaker) - - speaker.save() - speaker.save_revision().publish() - - def save_sessions(self, options): - df_accepted_session = pd.read_excel( - options["file"], - sheet_name="Accepted sessions", - ) - sessions = df_accepted_session[SESSION_HEADERS] - parent_page = Page.objects.get(id=145).specific - - for index, row in sessions.iterrows(): - if row[SessionHeaders.Room] is np.nan: - continue - - room, created = Room.objects.get_or_create( - name=row[SessionHeaders.Room], - ) - - if row[SessionHeaders.ScheduledAt] is pd.NaT: - continue - - state = Session.StateChoices.ACCEPTED - if row[SessionHeaders.OwnerConfirmed] != "No": - state = Session.StateChoices.CONFIRMED - - session_type = Session.TypeChoices.TALK - if str(row[SessionHeaders.Title]).startswith("Workshop:"): - session_type = Session.TypeChoices.WORKSHOP - - name = row[SessionHeaders.Title] - - print(f"{row[SessionHeaders.Id]=}") - try: - session = Session.objects.get(external_id=row[SessionHeaders.Id]) - session.name = name - session.description = row[SessionHeaders.Description] - session.room = room - session.scheduled_at = row[SessionHeaders.ScheduledAt] - session.duration = row[SessionHeaders.ScheduledDuration] - session.state = state - session.type = session_type - session.title = name - except Session.DoesNotExist: - session = Session( - external_id=row[SessionHeaders.Id], - name=name, - description=row[SessionHeaders.Description], - room=room, - scheduled_at=row[SessionHeaders.ScheduledAt], - duration=row[SessionHeaders.ScheduledDuration], - state=state, - type=session_type, - title=name, - ) - parent_page.add_child(instance=session) - - session.save() - session.save_revision().publish() - - session.speakers.all().delete() - session.save() - - speaker_ids = [ - speaker_id.strip() - for speaker_id in row[SessionHeaders.SpeakerIds].split(",") - ] - for speaker in Speaker.objects.filter(external_id__in=speaker_ids): - session.speakers.add(speaker) - - session.save() - - -# print(f"{session_speakers=}") diff --git a/pythonie/speakers/management/commands/update-sessionize-json-stream.py b/pythonie/speakers/management/commands/update-sessionize-json-stream.py deleted file mode 100644 index 7709fd4..0000000 --- a/pythonie/speakers/management/commands/update-sessionize-json-stream.py +++ /dev/null @@ -1,140 +0,0 @@ -import datetime - -import pydantic -import requests -from django.core.management import BaseCommand -from wagtail.models import Page - -from speakers.models import Room, Session, Speaker - - -class SessionModel(pydantic.BaseModel): - id: str - title: str - description: str - startsAt: datetime.datetime - endsAt: datetime.datetime - speakers: list[pydantic.UUID4] - roomId: int - - @property - def duration(self) -> int: - return int((self.endsAt - self.startsAt).seconds / 60) - - -class SpeakerModel(pydantic.BaseModel): - id: pydantic.UUID4 - firstName: str - lastName: str - bio: str | None - tagLine: str - profilePicture: pydantic.HttpUrl | None - sessions: list[int] - fullName: str - # email: str - - # @property - # def fullName(self): - # return f'{self.firstName} {self.lastName}' - - -class RoomModel(pydantic.BaseModel): - id: int - name: str - - -class SessionizeModel(pydantic.BaseModel): - sessions: list[SessionModel] - speakers: list[SpeakerModel] - rooms: list[RoomModel] - - -class Command(BaseCommand): - def handle(self, *args, **kwargs): - response = requests.get("https://sessionize.com/api/v2/z66z4kb6/view/All") - sessionize: SessionizeModel = SessionizeModel.parse_obj(response.json()) - - rooms = {} - - for incoming_room in sessionize.rooms: - incoming_room: RoomModel - rooms[incoming_room.id] = self.save_room(incoming_room) - - parent_page: Page = Page.objects.get(id=144).specific - for speaker in sessionize.speakers: - speaker: SpeakerModel - self.save_speaker(parent_page, speaker) - - parent_page: Page = Page.objects.get(id=145).specific - for session in sessionize.sessions: - session: SessionModel - self.save_session(parent_page, rooms[session.roomId], session) - - def save_speaker(self, parent_page: Page, incoming_speaker: SpeakerModel) -> None: - print(f"{incoming_speaker.id} {incoming_speaker.fullName}") - try: - speaker: Speaker = Speaker.objects.get(external_id=incoming_speaker.id) - speaker.name = incoming_speaker.fullName - speaker.email = f"{incoming_speaker.id}@sessionize.com" - speaker.biography = incoming_speaker.bio or "No biography available." - speaker.picture_url = incoming_speaker.profilePicture or "" - speaker.title = incoming_speaker.fullName - except Speaker.DoesNotExist: - speaker: Speaker = Speaker( - external_id=incoming_speaker.id, - name=incoming_speaker.fullName, - email=f"{incoming_speaker.id}@sessionize.com", - biography=incoming_speaker.bio or "No biography available", - picture_url=incoming_speaker.profilePicture or "", - title=incoming_speaker.fullName, - ) - - parent_page.add_child(instance=speaker) - - speaker.save() - speaker.save_revision().publish() - - def save_session( - self, - parent_page: Page, - room: Room, - incoming_session: SessionModel, - ) -> None: - print(f"{incoming_session.id} {incoming_session.title}") - created: bool = False - - try: - session: Session = Session.objects.get(external_id=incoming_session.id) - session.name = incoming_session.title - session.description = incoming_session.description - session.scheduled_at = incoming_session.startsAt - session.duration = incoming_session.duration - session.title = incoming_session.title - session.room = room - except Session.DoesNotExist: - session: Session = Session( - external_id=incoming_session.id, - name=incoming_session.title, - description=incoming_session.description, - scheduled_at=incoming_session.startsAt, - duration=incoming_session.duration, - title=incoming_session.title, - room=room, - ) - created = True - - parent_page.add_child(instance=session) - - speakers = Speaker.objects.filter(external_id__in=incoming_session.speakers) - for speaker in speakers: - session.speakers.add(speaker) - - session.save() - session.save_revision().publish() - print( - f"{incoming_session.id} {incoming_session.title} {created and 'CREATED' or 'UPDATED'}" - ) - - def save_room(self, incoming_room: RoomModel) -> Room: - room, created = Room.objects.get_or_create(name=incoming_room.name) - return room diff --git a/pythonie/speakers/migrations/0001_initial.py b/pythonie/speakers/migrations/0001_initial.py deleted file mode 100644 index 390a9d5..0000000 --- a/pythonie/speakers/migrations/0001_initial.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [("wagtailcore", "0016_change_page_url_path_to_text_field")] - - operations = [ - migrations.CreateModel( - name="SpeakersPage", - fields=[ - ( - "page_ptr", - models.OneToOneField( - auto_created=True, - to="wagtailcore.Page", - parent_link=True, - serialize=False, - primary_key=True, - on_delete=models.CASCADE, - ), - ), - ("api_url", models.CharField(max_length=255)), - ], - options={ - "abstract": False, - }, - bases=("wagtailcore.page",), - ), - ] diff --git a/pythonie/speakers/migrations/0002_talkspage.py b/pythonie/speakers/migrations/0002_talkspage.py deleted file mode 100644 index 5cfa177..0000000 --- a/pythonie/speakers/migrations/0002_talkspage.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("speakers", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="TalksPage", - fields=[ - ( - "page_ptr", - models.OneToOneField( - primary_key=True, - serialize=False, - parent_link=True, - auto_created=True, - to="wagtailcore.Page", - on_delete=models.CASCADE, - ), - ), - ("api_url", models.CharField(max_length=255)), - ], - options={ - "abstract": False, - }, - bases=("wagtailcore.page",), - ), - ] diff --git a/pythonie/speakers/migrations/0003_auto_20220929_1828.py b/pythonie/speakers/migrations/0003_auto_20220929_1828.py deleted file mode 100644 index acc8f0a..0000000 --- a/pythonie/speakers/migrations/0003_auto_20220929_1828.py +++ /dev/null @@ -1,133 +0,0 @@ -# Generated by Django 3.2.15 on 2022-09-29 18:28 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("wagtailcore", "0066_collection_management_permissions"), - ("speakers", "0002_talkspage"), - ] - - operations = [ - migrations.CreateModel( - name="Room", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("name", models.CharField(max_length=255)), - ], - options={ - "ordering": ["name"], - }, - ), - migrations.CreateModel( - name="Speaker", - fields=[ - ( - "page_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="wagtailcore.page", - ), - ), - ("name", models.CharField(max_length=255)), - ("email", models.CharField(max_length=255)), - ( - "external_id", - models.CharField(blank=True, max_length=255, unique=True), - ), - ("picture_url", models.CharField(blank=True, max_length=255)), - ("biography", models.TextField()), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ], - options={ - "ordering": ["name"], - }, - bases=("wagtailcore.page",), - ), - migrations.RemoveField( - model_name="speakerspage", - name="api_url", - ), - migrations.RemoveField( - model_name="talkspage", - name="api_url", - ), - migrations.CreateModel( - name="Session", - fields=[ - ( - "page_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="wagtailcore.page", - ), - ), - ("name", models.CharField(db_index=True, max_length=255)), - ("description", models.TextField()), - ("scheduled_at", models.DateTimeField()), - ("duration", models.IntegerField(default=30)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("external_id", models.CharField(max_length=255, unique=True)), - ( - "type", - models.CharField( - choices=[("talk", "Talk"), ("workshop", "Workshop")], - db_index=True, - default="talk", - max_length=16, - ), - ), - ( - "state", - models.CharField( - choices=[ - ("draft", "Draft"), - ("accepted", "Accepted"), - ("confirmed", "Confirmed"), - ("refused", "Refused"), - ("cancelled", "Cancelled"), - ], - db_index=True, - default="draft", - max_length=16, - ), - ), - ( - "room", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="speakers.room" - ), - ), - ( - "speakers", - models.ManyToManyField( - related_name="sessions", to="speakers.Speaker" - ), - ), - ], - options={ - "ordering": ["name"], - }, - bases=("wagtailcore.page",), - ), - ] diff --git a/pythonie/speakers/migrations/0004_alter_session_room.py b/pythonie/speakers/migrations/0004_alter_session_room.py deleted file mode 100644 index f273e3c..0000000 --- a/pythonie/speakers/migrations/0004_alter_session_room.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.0.14 on 2025-11-21 09:37 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("speakers", "0003_auto_20220929_1828"), - ] - - operations = [ - migrations.AlterField( - model_name="session", - name="room", - field=models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, to="speakers.room" - ), - ), - ] diff --git a/pythonie/speakers/migrations/__init__.py b/pythonie/speakers/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pythonie/speakers/models.py b/pythonie/speakers/models.py deleted file mode 100644 index 7e7376b..0000000 --- a/pythonie/speakers/models.py +++ /dev/null @@ -1,121 +0,0 @@ -import logging - -from django.db import models -from wagtail.admin.panels import FieldPanel -from wagtail.models import Page - -log = logging.getLogger("speakers") - - -class Room(models.Model): - name = models.CharField(max_length=255, blank=False) - - def __str__(self): - return self.name - - class Meta: - ordering = ["name"] - - -class Speaker(Page): - name = models.CharField(max_length=255, blank=False) - email = models.CharField(max_length=255, blank=False) - external_id = models.CharField(max_length=255, unique=True, blank=True) - picture_url = models.CharField(max_length=255, blank=True) - biography = models.TextField() - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - content_panels = Page.content_panels + [ - FieldPanel("name"), - FieldPanel("email"), - FieldPanel("external_id"), - FieldPanel("biography"), - FieldPanel("picture_url"), - ] - - def __str__(self): - return self.name - - class Meta: - ordering = ["name"] - - -class SpeakersPage(Page): - subpage_types = ["Speaker"] - - @property - def speakers(self): - return Speaker.objects.order_by("name").all() - - -# In this context, a session is a link to a future proposal (talk) -class Session(Page): - class StateChoices(models.TextChoices): - DRAFT = "draft", "Draft" - ACCEPTED = "accepted", "Accepted" - CONFIRMED = "confirmed", "Confirmed" - REFUSED = "refused", "Refused" - CANCELLED = "cancelled", "Cancelled" - - class TypeChoices(models.TextChoices): - TALK = "talk", "Talk" - WORKSHOP = "workshop", "Workshop" - - name = models.CharField(max_length=255, blank=False, db_index=True) - description = models.TextField() - scheduled_at = models.DateTimeField() - duration = models.IntegerField(default=30) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - external_id = models.CharField(max_length=255, unique=True) - type = models.CharField( - max_length=16, - blank=False, - choices=TypeChoices.choices, - default=TypeChoices.TALK, - db_index=True, - ) - - state = models.CharField( - max_length=16, - blank=False, - choices=StateChoices.choices, - default=StateChoices.DRAFT, - db_index=True, - ) - room = models.ForeignKey(Room, on_delete=models.PROTECT) - # speaker = models.ForeignKey(Speaker, on_delete=models.CASCADE) - speakers = models.ManyToManyField(Speaker, related_name="sessions") - - @property - def speaker_names(self): - return ", ".join(speaker.name for speaker in self.speakers.all()) - - content_panels = Page.content_panels + [ - FieldPanel("name"), - FieldPanel("description"), - FieldPanel("scheduled_at"), - FieldPanel("duration"), - FieldPanel("type"), - FieldPanel("state"), - ] - - def is_confirmed(self) -> bool: - return self.state == "confirmed" - - def __str__(self): - return self.name - - class Meta: - ordering = [ - "name", - ] - - -class TalksPage(Page): - subpage_types = ["Session"] - - @property - def sessions(self): - return Session.objects.order_by("scheduled_at", "room__name").all() diff --git a/pythonie/speakers/templates/speakers/session.html b/pythonie/speakers/templates/speakers/session.html deleted file mode 100644 index a643438..0000000 --- a/pythonie/speakers/templates/speakers/session.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "base.html" %} - -{% load wagtailcore_tags %} -{% load static core_tags speaker_tags %} - -{% block body_class %}template-{{ self.get_verbose_name|slugify }}{% endblock %} - -{% block content %} -
-
-
-
-
-

{{ self.name }}

- {{ self.biography }} - - - - - - - - {% for speaker in self.speakers.all %} - - - - - - {% endfor %} - -
Speaker
{% speaker_picture speaker %} - {{ speaker.name }} - - {{ speaker.biography|safe }} -
-
-
-
-
-
-{% endblock %} diff --git a/pythonie/speakers/templates/speakers/speaker.html b/pythonie/speakers/templates/speakers/speaker.html deleted file mode 100644 index ed06098..0000000 --- a/pythonie/speakers/templates/speakers/speaker.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "base.html" %} - -{% load wagtailcore_tags %} -{% load static core_tags speaker_tags %} - -{% block body_class %}template-{{ self.get_verbose_name|slugify }}{% endblock %} - -{% block content %} -
-
-
-
-
-

{{ self.name }}

- {% speaker_picture self %} -
- {{ self.biography }} - - - - - - - - - - {% for session in self.sessions.all %} - - - - - - {% endfor %} - -
SessionWhenDuration
- {{ session.name }} - {{ session.scheduled_at }}{{ session.duration }}
-
-
-
-
-
-{% endblock %} diff --git a/pythonie/speakers/templates/speakers/speaker_picture.html b/pythonie/speakers/templates/speakers/speaker_picture.html deleted file mode 100644 index 1e0e431..0000000 --- a/pythonie/speakers/templates/speakers/speaker_picture.html +++ /dev/null @@ -1 +0,0 @@ -{{ name }} \ No newline at end of file diff --git a/pythonie/speakers/templates/speakers/speakers_page.html b/pythonie/speakers/templates/speakers/speakers_page.html deleted file mode 100644 index 3ac78c7..0000000 --- a/pythonie/speakers/templates/speakers/speakers_page.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "base.html" %} - -{% load wagtailcore_tags %} -{% load static core_tags speaker_tags %} - -{% block body_class %}template-{{ self.get_verbose_name|slugify }}{% endblock %} - -{% block content %} -
-
-
-
-
- - -
-

- - PyCon Ireland 2022 Speakers - -

-
- - - - {% for speaker in self.speakers %} - - - - - - {% endfor %} - - -
{% speaker_picture speaker %} - {{ speaker.name }} - - -
-
-
-
-
-
-{% endblock %} diff --git a/pythonie/speakers/templates/speakers/talks_page.html b/pythonie/speakers/templates/speakers/talks_page.html deleted file mode 100644 index 1f8d519..0000000 --- a/pythonie/speakers/templates/speakers/talks_page.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "base.html" %} - -{% load wagtailcore_tags %} -{% load static core_tags speaker_tags %} - -{% block body_class %}template-{{ self.get_verbose_name|slugify }}{% endblock %} - -{% block content %} -
-
-
-
-
- -
-

- PyCon - Ireland - 2022 - Talks -

-
- - - - - - - - - - - - - {% for session in self.sessions %} - - - - - - - - {% endfor %} - -
TitleSpeaker(s)WhenDurationWhere
- {{ session.title }} - -
    - {% for speaker in session.speakers.all %} -
  • {% speaker_picture speaker 40 %} {{ speaker.name }} -
  • - {% endfor %} -
-
{{ session.scheduled_at }}{{ session.duration }}{{ session.room }}
-
-
-
-
-
-{% endblock %} diff --git a/pythonie/speakers/templatetags/__init__.py b/pythonie/speakers/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/pythonie/speakers/templatetags/speaker_tags.py b/pythonie/speakers/templatetags/speaker_tags.py deleted file mode 100644 index 6755e4e..0000000 --- a/pythonie/speakers/templatetags/speaker_tags.py +++ /dev/null @@ -1,13 +0,0 @@ -from django import template - -from speakers.models import Speaker - -register = template.Library() - - -@register.inclusion_tag("speakers/speaker_picture.html") -def speaker_picture(speaker: Speaker, size: int = 75): - picture_url = speaker.picture_url - if not picture_url: - picture_url = f"https://robohash.org/{speaker.name}" - return {"url": picture_url, "name": speaker.name, "size": size}