From ea3813d2b210263bf870ca7b89dfe020de2697fd Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Mon, 16 Feb 2026 22:15:19 +0200 Subject: [PATCH 01/26] Add apt sources to fix dev environment --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 78499c1a..de283450 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,10 @@ FROM python:2.7 ENV PYTHONUNBUFFERED 1 COPY pkglist /tmp/ -RUN apt-get update \ +RUN sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list \ + && sed -i 's|security.debian.org|archive.debian.org|g' /etc/apt/sources.list \ + && sed -i '/stretch-updates/d' /etc/apt/sources.list \ + && apt-get update \ && apt-get install -y $(cat /tmp/pkglist) \ # cleaning up unused files && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ From 8fa60de626d1c3131b2b25af0c0f7fdcea9887e4 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 16:48:40 +0200 Subject: [PATCH 02/26] Update to ES7 --- docker-compose.yml | 5 +++-- requirements.txt | 4 ++-- writeit/settings.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 989ab502..53138102 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,11 +74,12 @@ services: - db-data:/var/lib/postgresql/data elasticsearch: - image: elasticsearch:1 + image: elasticsearch:7.17.27 volumes: - elasticsearch-data:/usr/share/elasticsearch/data environment: - - ES_MAX_MEM=1g + - discovery.type=single-node + - ES_JAVA_OPTS=-Xms512m -Xmx1g ports: - 9200 diff --git a/requirements.txt b/requirements.txt index 31647a23..1a79a4f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ django-dirtyfields==1.0 django-downloadview==1.6 django-extensions==1.6.7 django-formtools==1.0 -django-haystack==2.4.0 +django-haystack==3.2.1 django-jsonfield==1.4.1 django-markdown-deux==1.0.5 django-model-utils==3.1.2 @@ -34,7 +34,7 @@ django-pipeline==1.5.4 django-plugins==0.3.0 django-subdomains==2.0.4 django-tastypie==0.13.3 -elasticsearch==1.6.0 +elasticsearch==7.17.27 email-reply-parser==0.3.0 enum34==1.1.6 flufl.bounce==2.3 diff --git a/writeit/settings.py b/writeit/settings.py index 7ffcc6c7..44f5c3d6 100644 --- a/writeit/settings.py +++ b/writeit/settings.py @@ -243,7 +243,7 @@ HAYSTACK_CONNECTIONS = { "default": { - "ENGINE": "haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine", + "ENGINE": "haystack.backends.elasticsearch7_backend.Elasticsearch7SearchEngine", "URL": ELASTICSEARCH_URL, "PORT": urlparse(os.environ.get("ELASTICSEARCH_URL")).port, "INDEX_NAME": ELASTICSEARCH_INDEX, From 5d220527009d25011d4ee4f00377b6bc75d70a69 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 16:49:00 +0200 Subject: [PATCH 03/26] Update ES7 --- docker-compose.yml | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 53138102..e0a46f59 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,7 +74,7 @@ services: - db-data:/var/lib/postgresql/data elasticsearch: - image: elasticsearch:7.17.27 + image: elasticsearch:7.17.13 volumes: - elasticsearch-data:/usr/share/elasticsearch/data environment: diff --git a/requirements.txt b/requirements.txt index 1a79a4f8..22c28551 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,7 @@ django-pipeline==1.5.4 django-plugins==0.3.0 django-subdomains==2.0.4 django-tastypie==0.13.3 -elasticsearch==7.17.27 +elasticsearch==7.17.13 email-reply-parser==0.3.0 enum34==1.1.6 flufl.bounce==2.3 From 280c2416ab15ba9b5f7b97d9e3764fadd49ca419 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 18:25:20 +0200 Subject: [PATCH 04/26] Update to Py3 --- Dockerfile | 8 +-- docker-compose.yml | 2 +- pkglist | 4 +- requirements.txt | 133 ++++++++++++++++++++++----------------------- 4 files changed, 70 insertions(+), 77 deletions(-) diff --git a/Dockerfile b/Dockerfile index de283450..a31780af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,9 @@ -FROM python:2.7 +FROM python:3.9.22-bookworm ENV PYTHONUNBUFFERED 1 COPY pkglist /tmp/ -RUN sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list \ - && sed -i 's|security.debian.org|archive.debian.org|g' /etc/apt/sources.list \ - && sed -i '/stretch-updates/d' /etc/apt/sources.list \ - && apt-get update \ +RUN apt-get update \ && apt-get install -y $(cat /tmp/pkglist) \ # cleaning up unused files && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ @@ -15,6 +12,7 @@ RUN sed -i 's/deb.debian.org/archive.debian.org/g' /etc/apt/sources.list \ # Copy, then install requirements before copying rest for a requirements cache layer. COPY requirements.txt /tmp/ RUN cd /tmp \ + && pip install --upgrade pip setuptools wheel \ && pip install -r requirements.txt COPY . /app diff --git a/docker-compose.yml b/docker-compose.yml index e0a46f59..53138102 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,7 +74,7 @@ services: - db-data:/var/lib/postgresql/data elasticsearch: - image: elasticsearch:7.17.13 + image: elasticsearch:7.17.27 volumes: - elasticsearch-data:/usr/share/elasticsearch/data environment: diff --git a/pkglist b/pkglist index f2dbeb5a..1aac42db 100644 --- a/pkglist +++ b/pkglist @@ -4,6 +4,6 @@ git libffi-dev libpq-dev libssl-dev -python-dev -python-pip +python3-dev +python3-pip yui-compressor diff --git a/requirements.txt b/requirements.txt index 22c28551..bac299fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,86 +1,81 @@ -amqp==1.4.9 -anyjson==0.3.3 -asn1crypto==0.24.0 -backport-collections==0.1 -billiard==3.3.0.23 -celery==3.1.25 +amqp==5.1.1 +asn1crypto==1.5.1 +billiard==3.6.4.0 +celery==5.2.7 celery-haystack==0.10 -cffi==1.7.0 +cffi==1.15.1 codecov==2.1.13 -contextlib2==0.5.3 -cryptography==2.0.3 -django-environ==0.4.5 +cryptography==41.0.7 +django-environ==0.11.2 dj-static==0.0.6 -Django==1.8.18 +Django==3.2.25 django-admin-bootstrapped==2.3.6 -django-annoying==0.10.3 -django-appconf==1.0.2 -django-autoslug==1.9.3 -django-celery==3.2.1 -django-celery-transactions==0.1.3 -django-debug-toolbar==1.5 -django-dirtyfields==1.0 -django-downloadview==1.6 -django-extensions==1.6.7 -django-formtools==1.0 +django-annoying==0.10.6 +django-appconf==1.0.6 +django-autoslug==1.9.9 +django-celery-beat==2.5.0 +django-celery-results==2.5.1 +django-debug-toolbar==3.8.1 +django-dirtyfields==1.9.2 +django-downloadview==2.3.0 +django-extensions==3.2.3 +django-formtools==2.4.1 django-haystack==3.2.1 django-jsonfield==1.4.1 -django-markdown-deux==1.0.5 -django-model-utils==3.1.2 -django-nose==1.4.3 -django-object-actions==0.8.2 +django-markdown-deux==1.0.6 +django-model-utils==4.3.1 +django-nose==1.4.7 +django-object-actions==4.2.0 django-pagination==1.0.7 -django-pipeline==1.5.4 +django-pipeline==2.1.0 django-plugins==0.3.0 -django-subdomains==2.0.4 -django-tastypie==0.13.3 +django-subdomains==2.1.0 +django-tastypie==0.14.7 elasticsearch==7.17.13 -email-reply-parser==0.3.0 -enum34==1.1.6 -flufl.bounce==2.3 -futures==3.0.5 -gevent==1.4.0 -greenlet==1.1.2 -gunicorn==19.10.0 -idna==2.1 -ipaddress==1.0.16 +email-reply-parser==0.5.12 +flufl.bounce==4.0 +gevent==23.9.1 +greenlet==3.0.3 +gunicorn==21.2.0 +idna==3.6 Khayyam==3.0.15 -kombu==3.0.37 -libsass==0.11.1 -markdown2==2.3.1 -mock==1.0.1 +kombu==5.3.4 +libsass==0.22.0 +markdown2==2.4.12 +mock==5.1.0 multiple-django-popolo-sources==0.0.3 mysociety-django-popolo==0.0.8 -ndg-httpsclient==0.4.1 +ndg-httpsclient==0.4.4 nose==1.3.7 -oauthlib==2.0.0 +oauthlib==3.2.2 -e git+https://github.com/mysociety/popit-django.git@224831562b3a078279b14f9354fe1b23492c4cd8#egg=popit_django -prospector==1.2.0 -psycopg2==2.8.5 -pyasn1==0.1.9 -pycparser==2.14 -PyJWT==1.4.2 -pyOpenSSL==17.2.0 -python-dateutil==2.5.3 -python-mimeparse==1.5.2 -python-openid==2.2.5 -python-social-auth==0.2.21 -pytz==2016.4 -PyYAML==3.11 -requests==2.11.1 -requests-oauthlib==0.7.0 -sentry-sdk==0.14.3 -simplejson==3.8.2 -six==1.10.0 +prospector==1.10.3 +psycopg2==2.9.9 +pyasn1==0.5.1 +pycparser==2.21 +PyJWT==2.8.0 +pyOpenSSL==23.3.0 +python-dateutil==2.8.2 +python-mimeparse==1.6.0 +python3-openid==3.2.0 +social-auth-app-django==5.4.0 +social-auth-core==4.5.1 +pytz==2023.3.post1 +PyYAML==6.0.1 +requests==2.31.0 +requests-oauthlib==1.3.1 +sentry-sdk==1.39.2 +simplejson==3.19.2 +six==1.16.0 slumber==0.7.1 -sqlparse==0.3.0 -static==0.4 +sqlparse==0.4.4 static3==0.7.0 -transifex-client==0.12.1 -Unidecode==0.4.19 -urllib3==1.16 -vcrpy==1.4.0 -wrapt==1.10.8 -zope.interface==4.2.0 +transifex-client==0.14.4 +Unidecode==1.3.8 +urllib3==1.26.18 +vine==5.1.0 +vcrpy==5.1.0 +wrapt==1.16.0 +zope.interface==6.1 defusedxml==0.7.1 -lxml==5.0.2 \ No newline at end of file +lxml==5.0.2 From 56ed0e144227e1de01e9b78c4992bf93aecf6c58 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 18:44:46 +0200 Subject: [PATCH 05/26] Py3 fixes --- global_test_case.py | 2 +- instance/migrations/0004_add_django_popolo_people.py | 2 +- nuntium/forms.py | 2 +- nuntium/tests/answers_search_test.py | 2 +- nuntium/tests/messages_search_test.py | 2 +- nuntium/tests/writeitinstances_test.py | 2 +- nuntium/user_section/forms.py | 2 +- nuntium/user_section/tests/user_section_views_tests.py | 2 +- writeit/settings.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/global_test_case.py b/global_test_case.py index 41be9da2..1088c16b 100644 --- a/global_test_case.py +++ b/global_test_case.py @@ -72,7 +72,7 @@ def tearDown(self, *args, **kwargs): super(UsingDbMixin, self).tearDown(*args, **kwargs) -from urlparse import urlparse +from urllib.parse import urlparse def get_path_and_subdomain(path, **extra): diff --git a/instance/migrations/0004_add_django_popolo_people.py b/instance/migrations/0004_add_django_popolo_people.py index 83121022..132b8f32 100644 --- a/instance/migrations/0004_add_django_popolo_people.py +++ b/instance/migrations/0004_add_django_popolo_people.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import re -from urlparse import urlsplit, urlunsplit +from urllib.parse import urlsplit, urlunsplit from django.db import migrations from django.contrib.contenttypes.management import update_contenttypes diff --git a/nuntium/forms.py b/nuntium/forms.py index 8b9846d1..13562bb1 100644 --- a/nuntium/forms.py +++ b/nuntium/forms.py @@ -1,5 +1,5 @@ # coding=utf-8 -import urlparse +import urllib.parse as urlparse from django.forms import ModelForm, ModelMultipleChoiceField, SelectMultiple, URLField, Form, Textarea, TextInput, EmailInput from contactos.models import Contact diff --git a/nuntium/tests/answers_search_test.py b/nuntium/tests/answers_search_test.py index 03858812..deff1cf5 100644 --- a/nuntium/tests/answers_search_test.py +++ b/nuntium/tests/answers_search_test.py @@ -5,7 +5,7 @@ from ..models import Answer, Message from haystack import indexes import urllib -import urlparse +import urllib.parse as urlparse class AnswerIndexTestCase(TestCase): diff --git a/nuntium/tests/messages_search_test.py b/nuntium/tests/messages_search_test.py index a4e08ad2..2cf77668 100644 --- a/nuntium/tests/messages_search_test.py +++ b/nuntium/tests/messages_search_test.py @@ -14,7 +14,7 @@ from haystack.views import SearchView from popolo.models import Person import urllib -import urlparse +import urllib.parse as urlparse class MessagesSearchTestCase(TestCase): diff --git a/nuntium/tests/writeitinstances_test.py b/nuntium/tests/writeitinstances_test.py index 4dd1456a..3ef987ce 100644 --- a/nuntium/tests/writeitinstances_test.py +++ b/nuntium/tests/writeitinstances_test.py @@ -1,5 +1,5 @@ # coding=utf-8 -from urlparse import urlsplit, urlunsplit +from urllib.parse import urlsplit, urlunsplit from global_test_case import GlobalTestCase as TestCase, popit_load_data from subdomains.utils import reverse from instance.models import InstanceMembership, PopoloPerson, WriteItInstance diff --git a/nuntium/user_section/forms.py b/nuntium/user_section/forms.py index 9ced440a..09f7887b 100644 --- a/nuntium/user_section/forms.py +++ b/nuntium/user_section/forms.py @@ -1,5 +1,5 @@ # coding=utf-8 -from urlparse import urlparse +from urllib.parse import urlparse from django.conf import settings from django.core import validators diff --git a/nuntium/user_section/tests/user_section_views_tests.py b/nuntium/user_section/tests/user_section_views_tests.py index cc84a6d6..4960a09d 100644 --- a/nuntium/user_section/tests/user_section_views_tests.py +++ b/nuntium/user_section/tests/user_section_views_tests.py @@ -13,7 +13,7 @@ WriteItInstanceCreateForm, \ NewAnswerNotificationTemplateForm, ConfirmationTemplateForm from django.test.utils import override_settings -from urlparse import urlparse +from urllib.parse import urlparse import json from nuntium.user_section.views import WriteItInstanceCreateView diff --git a/writeit/settings.py b/writeit/settings.py index 44f5c3d6..a2c48803 100644 --- a/writeit/settings.py +++ b/writeit/settings.py @@ -9,7 +9,7 @@ from django.utils.translation import to_locale import environ -from urlparse import urlparse +from urllib.parse import urlparse env = environ.Env() From 6a43f7cf211c1d5fc0ab694b920d159c33548ffe Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 18:57:29 +0200 Subject: [PATCH 06/26] Py3 fix --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a31780af..adcc07da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,10 @@ COPY . /app WORKDIR /app -RUN python manage.py compilemessages +RUN DATABASE_URL=sqlite:///tmp/dummy.db \ + ELASTICSEARCH_URL=http://localhost:9200 \ + ELASTICSEARCH_INDEX=dummy \ + python manage.py compilemessages RUN addgroup --system django \ && adduser --system --ingroup django django \ From 3cdb3cc43ae1bb46156ff192466d10e57d833113 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 18:57:56 +0200 Subject: [PATCH 07/26] Adjust module references for Py3 --- writeit/settings.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/writeit/settings.py b/writeit/settings.py index a2c48803..b06f408b 100644 --- a/writeit/settings.py +++ b/writeit/settings.py @@ -157,8 +157,8 @@ "django.core.context_processors.static", "django.core.context_processors.tz", "django.contrib.messages.context_processors.messages", - "social.apps.django_app.context_processors.backends", - "social.apps.django_app.context_processors.login_redirect", + "social_django.context_processors.backends", + "social_django.context_processors.login_redirect", "writeit.context_processors.web_api_settings", "writeit.context_processors.google_analytics_settings", ) @@ -198,10 +198,11 @@ "django.contrib.sites", "django.contrib.messages", "django.contrib.staticfiles", - "social.apps.django_app.default", + "social_django", "annoying", "celery_haystack", - "djcelery", + "django_celery_beat", + "django_celery_results", "debug_toolbar", "instance", "nuntium", @@ -406,7 +407,7 @@ SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = env.str("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", None) AUTHENTICATION_BACKENDS = ( - "social.backends.google.GoogleOAuth2", + "social_core.backends.google.GoogleOAuth2", "django.contrib.auth.backends.ModelBackend", ) From c159696800bd81c51e2e8d0e897242cc143664f3 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 19:01:30 +0200 Subject: [PATCH 08/26] Py3 fixes --- .../instance_remove_extraneous_popolo_uri.py | 10 +++++----- instance/models.py | 4 ++-- mailit/__init__.py | 12 ++++++------ nuntium/api.py | 2 +- nuntium/tests/version_test.py | 8 ++++---- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/instance/management/commands/instance_remove_extraneous_popolo_uri.py b/instance/management/commands/instance_remove_extraneous_popolo_uri.py index 90481426..7f074279 100644 --- a/instance/management/commands/instance_remove_extraneous_popolo_uri.py +++ b/instance/management/commands/instance_remove_extraneous_popolo_uri.py @@ -19,7 +19,7 @@ def handle(self, *args, **options): if original_count == 0: msg = "There were no popolo_uri Identifier objects for person " \ "{person} with ID {person_id}" - print msg.format(person=person, person_id=person.id) + print(msg.format(person=person, person_id=person.id)) errors_found = True continue if original_count == 1: @@ -28,10 +28,10 @@ def handle(self, *args, **options): msg = "The only remaining popolo_uri Identifier for " \ "person {person} with ID {person_id} was a " \ "malformed legacy identifier: {bad_identifier}" - print msg.format( + print(msg.format( person=person, person_id=person.id, - bad_identifier=sole_identifier.identifier) + bad_identifier=sole_identifier.identifier)) errors_found = True continue # Otherwise we have more than one identifier. Delete any @@ -43,9 +43,9 @@ def handle(self, *args, **options): if len(unique_remaining) > 1: msg = "There were multiple conflicting IDs for person " \ "{person} with ID {person_id}" - print msg.format(person=person, person_id=person.id) + print(msg.format(person=person, person_id=person.id)) for non_unique_id in sorted(unique_remaining): - print " ", non_unique_id + print(" ", non_unique_id) errors_found = True continue # Now remove all but one of the identifiers: diff --git a/instance/models.py b/instance/models.py index a17332d6..0fb6f7cc 100644 --- a/instance/models.py +++ b/instance/models.py @@ -319,12 +319,12 @@ def relate_with_persons_from_popolo_json(self, popolo_source): Contact.objects.filter( writeitinstance=self, person=person).update(enabled=False) - except ConnectionError, e: + except ConnectionError as e: self.do_something_with_a_vanished_popit_api_instance(popolo_source) logger.exception("We could not connect with the URL") e.message = _('We could not connect with the URL') return (False, e) - except Exception, e: + except Exception as e: self.do_something_with_a_vanished_popit_api_instance(popolo_source) logger.exception("Unexpected error relating persons with popolo JSON") return (False, e) diff --git a/mailit/__init__.py b/mailit/__init__.py index bc0e5ec2..1d4bd2b0 100644 --- a/mailit/__init__.py +++ b/mailit/__init__.py @@ -75,11 +75,11 @@ def send(self, outbound_message): text_content = template.get_content_template().format(**context) html_content = template.content_html_template.format(**escape_dictionary_values(context)) subject = template.subject_template.format(**context) - except KeyError, error: + except KeyError as error: log = "Error with templates for instance %(instance)s and the error was '%(error)s'" log = log % { 'instance': writeitinstance.name, - 'error': error.__unicode__() + 'error': str(error) } mail_admins("Problem sending an email", log) logging.info(log) @@ -118,22 +118,22 @@ def send(self, outbound_message): 'to': outbound_message.contact.value, } logging.info(log) - except SMTPServerDisconnected, e: + except SMTPServerDisconnected as e: logging.warning(e) return False, False - except SMTPResponseException, e: + except SMTPResponseException as e: logging.warning(e) if e.smtp_code == 552: return False, False return False, True - except Exception, e: + except Exception as e: log = "Error with outbound id %(outbound_id)i, contact '%(contact)s' and message '%(message)s' and the error was '%(error)s'" log = log % { 'outbound_id': outbound_message.id, 'contact': outbound_message.contact.value, 'message': outbound_message.message, - 'error': e.__unicode__() + 'error': str(e) } mail_admins("Problem sending an email", log) logging.info(log) diff --git a/nuntium/api.py b/nuntium/api.py index 368eccd3..0d0b2e82 100644 --- a/nuntium/api.py +++ b/nuntium/api.py @@ -264,7 +264,7 @@ def hydrate(self, bundle): # Validating author_email try: validate_email(bundle.data['author_email']) - except ValidationError, e: + except ValidationError as e: raise ImmediateHttpResponse(response=HttpResponseBadRequest(e.__str__())) if bundle.data['persons'] == 'all': diff --git a/nuntium/tests/version_test.py b/nuntium/tests/version_test.py index b80771b6..32448d06 100644 --- a/nuntium/tests/version_test.py +++ b/nuntium/tests/version_test.py @@ -20,8 +20,8 @@ def test_check_version_output(self): self.assertEquals(response.status_code, 200) data = json.loads(response.content) - self.assertTrue(data.has_key('git_version')) - self.assertFalse(data.has_key('message_count')) + self.assertIn('git_version', data) + self.assertNotIn('message_count', data) def test_check_instance_version_output(self): url = reverse('instance_version', @@ -31,8 +31,8 @@ def test_check_instance_version_output(self): self.assertEquals(response.status_code, 200) data = json.loads(response.content) - self.assertTrue(data.has_key('git_version')) - self.assertTrue(data.has_key('message_count')) + self.assertIn('git_version', data) + self.assertIn('message_count', data) self.assertEquals(data['message_count'], 1) self.assertEquals(data['answer_count'], 1) From 125c84666cdf3b3f4e946a5f159c1add94406a9e Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 19:04:33 +0200 Subject: [PATCH 09/26] Py3 fix --- manage.py | 2 ++ writeit/compat.py | 5 +++++ writeit/wsgi.py | 1 + 3 files changed, 8 insertions(+) create mode 100644 writeit/compat.py diff --git a/manage.py b/manage.py index 119b4ac5..96d56a23 100755 --- a/manage.py +++ b/manage.py @@ -5,6 +5,8 @@ if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "writeit.settings") + import writeit.compat # noqa: F401 - patches django.utils.six for django-plugins + from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) diff --git a/writeit/compat.py b/writeit/compat.py new file mode 100644 index 00000000..f6c861ae --- /dev/null +++ b/writeit/compat.py @@ -0,0 +1,5 @@ +# Compatibility shim for django-plugins which imports django.utils.six +# (removed in Django 3.0). This must be imported before djangoplugins. +import django.utils +import six +django.utils.six = six diff --git a/writeit/wsgi.py b/writeit/wsgi.py index d523e4fb..3446ad14 100644 --- a/writeit/wsgi.py +++ b/writeit/wsgi.py @@ -14,6 +14,7 @@ """ import os +import writeit.compat # noqa: F401 - patches django.utils.six for django-plugins # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks # if running multiple sites in the same mod_wsgi process. To fix this, use From 90b9db92938032f62873bf6ca2690abd588d9b8d Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 19:39:12 +0200 Subject: [PATCH 10/26] Py3 compatability fixes --- writeit/compat.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/writeit/compat.py b/writeit/compat.py index f6c861ae..4c220f66 100644 --- a/writeit/compat.py +++ b/writeit/compat.py @@ -1,5 +1,18 @@ -# Compatibility shim for django-plugins which imports django.utils.six -# (removed in Django 3.0). This must be imported before djangoplugins. +# Compatibility shim for django-plugins which imports modules import django.utils +import django.utils.encoding +import django.conf.urls +import django.core.management.base import six django.utils.six = six + +django.utils.encoding.python_2_unicode_compatible = lambda cls: cls + +def _compat_patterns(prefix, *args): + if prefix: + raise Exception("Using a prefix with patterns() is not supported.") + return list(args) + +django.conf.urls.patterns = _compat_patterns + +django.core.management.base.NoArgsCommand = django.core.management.base.BaseCommand From d45f63b0da18ccbfe49dcac954fdfeb926e157ef Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 19:42:21 +0200 Subject: [PATCH 11/26] Lazy load mailit packages --- contactos/forms.py | 2 +- instance/models.py | 2 +- mailit/__init__.py | 144 +------------------------ mailit/channel.py | 142 ++++++++++++++++++++++++ nuntium/tests/instance_config_tests.py | 2 +- 5 files changed, 147 insertions(+), 145 deletions(-) create mode 100644 mailit/channel.py diff --git a/contactos/forms.py b/contactos/forms.py index 4733d2ce..47da67fb 100644 --- a/contactos/forms.py +++ b/contactos/forms.py @@ -1,7 +1,7 @@ from django.forms import ModelForm from django.forms.models import ModelChoiceField from contactos.models import Contact -from mailit import MailChannel +from mailit.channel import MailChannel class ContactUpdateForm(ModelForm): diff --git a/instance/models.py b/instance/models.py index 0fb6f7cc..b3da7ffb 100644 --- a/instance/models.py +++ b/instance/models.py @@ -25,7 +25,7 @@ from subdomains.utils import reverse from contactos.models import Contact -from mailit import MailChannel +from mailit.channel import MailChannel logger = logging.getLogger(__name__) diff --git a/mailit/__init__.py b/mailit/__init__.py index 1d4bd2b0..5e383175 100644 --- a/mailit/__init__.py +++ b/mailit/__init__.py @@ -1,142 +1,2 @@ -import itertools -import json -import logging -import textwrap - -from django.conf import settings -from django.core.mail import mail_admins -from django.core.mail.message import EmailMultiAlternatives -from django.utils.translation import override - -from smtplib import SMTPServerDisconnected, SMTPResponseException - -from nuntium.plugins import OutputPlugin -from contactos.models import ContactType -from writeit_utils import escape_dictionary_values - -logging.basicConfig(level=logging.INFO) - - -def process_content(content, indent=' ', width=66): - """Take the provided message content. Wrap it at 66 characters, and then - indent it by four spaces. - """ - return u'\n'.join([indent + y - for y - in itertools.chain(*[lines or [u""] for lines in [textwrap.wrap(x, width) for x in content.splitlines()]])] - ) - -def prepare_headers(): - # Hardcode sendgrid X-SMTPAPI header with a category to identify writinpublic - # mail for now. - xsmtpapi_dict = { - "category": "writeinpublic", - } - xsmtpapi_str = json.dumps(xsmtpapi_dict) - return { - "X-SMTPAPI": xsmtpapi_str, - } - -class MailChannel(OutputPlugin): - name = 'mail-channel' - title = 'Mail Channel' - - def get_contact_type(self): - contact_type, created = ContactType.objects.get_or_create(label_name="Electronic Mail", name="e-mail") - return contact_type - - contact_type = property(get_contact_type) - - def send(self, outbound_message): - # Here there should be somewhere the contacts - # Returns a tuple with the result_of_sending, fatal_error - # so False, True means that there was an error sending and you should not try again - try: - writeitinstance = outbound_message.message.writeitinstance - template = writeitinstance.mailit_template - except: - logging.exception('Error getting instance or template') - return False, False - - with override(writeitinstance.config.default_language): - author_name = outbound_message.message.author_name - context = { - 'subject': outbound_message.message.subject, - 'content': outbound_message.message.content, - 'content_indented': process_content(outbound_message.message.content), - 'person': outbound_message.contact.person.name, - 'author': author_name, - 'site_url': writeitinstance.get_absolute_url(), - 'message_url': outbound_message.message.get_absolute_url(), - 'site_name': writeitinstance.name, - 'owner_email': writeitinstance.owner.email, - } - try: - text_content = template.get_content_template().format(**context) - html_content = template.content_html_template.format(**escape_dictionary_values(context)) - subject = template.subject_template.format(**context) - except KeyError as error: - log = "Error with templates for instance %(instance)s and the error was '%(error)s'" - log = log % { - 'instance': writeitinstance.name, - 'error': str(error) - } - mail_admins("Problem sending an email", log) - logging.info(log) - return False, True - - if settings.SEND_ALL_EMAILS_FROM_DEFAULT_FROM_EMAIL: - from_email = author_name + " <" + settings.DEFAULT_FROM_EMAIL + ">" - else: - from_domain = writeitinstance.config.custom_from_domain or settings.DEFAULT_FROM_DOMAIN - from_email = ( - author_name + " <" + writeitinstance.slug + - "+" + outbound_message.outboundmessageidentifier.key + - '@' + from_domain + ">" - ) - - # There there should be a try and except looking - # for errors and stuff - try: - to_email = writeitinstance.owner.email if writeitinstance.config.testing_mode else outbound_message.contact.value - - msg = EmailMultiAlternatives( - subject, - text_content, - from_email, - [to_email], - connection=writeitinstance.config.get_mail_connection(), - headers=prepare_headers(), - ) - if html_content: - msg.attach_alternative(html_content, "text/html") - msg.send(fail_silently=False) - log = "Mail sent from %(from)s to %(to)s" - - log = log % { - 'from': from_email, - 'to': outbound_message.contact.value, - } - logging.info(log) - except SMTPServerDisconnected as e: - logging.warning(e) - return False, False - except SMTPResponseException as e: - logging.warning(e) - if e.smtp_code == 552: - return False, False - return False, True - - except Exception as e: - log = "Error with outbound id %(outbound_id)i, contact '%(contact)s' and message '%(message)s' and the error was '%(error)s'" - log = log % { - 'outbound_id': outbound_message.id, - 'contact': outbound_message.contact.value, - 'message': outbound_message.message, - 'error': str(e) - } - mail_admins("Problem sending an email", log) - logging.info(log) - return False, True - - return True, None +# MailChannel moved to mailit.channel to avoid importing models at app load time. +# Import it lazily where needed: from mailit.channel import MailChannel diff --git a/mailit/channel.py b/mailit/channel.py new file mode 100644 index 00000000..1d4bd2b0 --- /dev/null +++ b/mailit/channel.py @@ -0,0 +1,142 @@ +import itertools +import json +import logging +import textwrap + +from django.conf import settings +from django.core.mail import mail_admins +from django.core.mail.message import EmailMultiAlternatives +from django.utils.translation import override + +from smtplib import SMTPServerDisconnected, SMTPResponseException + +from nuntium.plugins import OutputPlugin +from contactos.models import ContactType +from writeit_utils import escape_dictionary_values + +logging.basicConfig(level=logging.INFO) + + +def process_content(content, indent=' ', width=66): + """Take the provided message content. Wrap it at 66 characters, and then + indent it by four spaces. + """ + return u'\n'.join([indent + y + for y + in itertools.chain(*[lines or [u""] for lines in [textwrap.wrap(x, width) for x in content.splitlines()]])] + ) + +def prepare_headers(): + # Hardcode sendgrid X-SMTPAPI header with a category to identify writinpublic + # mail for now. + xsmtpapi_dict = { + "category": "writeinpublic", + } + xsmtpapi_str = json.dumps(xsmtpapi_dict) + return { + "X-SMTPAPI": xsmtpapi_str, + } + +class MailChannel(OutputPlugin): + name = 'mail-channel' + title = 'Mail Channel' + + def get_contact_type(self): + contact_type, created = ContactType.objects.get_or_create(label_name="Electronic Mail", name="e-mail") + return contact_type + + contact_type = property(get_contact_type) + + def send(self, outbound_message): + # Here there should be somewhere the contacts + # Returns a tuple with the result_of_sending, fatal_error + # so False, True means that there was an error sending and you should not try again + try: + writeitinstance = outbound_message.message.writeitinstance + template = writeitinstance.mailit_template + except: + logging.exception('Error getting instance or template') + return False, False + + with override(writeitinstance.config.default_language): + author_name = outbound_message.message.author_name + context = { + 'subject': outbound_message.message.subject, + 'content': outbound_message.message.content, + 'content_indented': process_content(outbound_message.message.content), + 'person': outbound_message.contact.person.name, + 'author': author_name, + 'site_url': writeitinstance.get_absolute_url(), + 'message_url': outbound_message.message.get_absolute_url(), + 'site_name': writeitinstance.name, + 'owner_email': writeitinstance.owner.email, + } + try: + text_content = template.get_content_template().format(**context) + html_content = template.content_html_template.format(**escape_dictionary_values(context)) + subject = template.subject_template.format(**context) + except KeyError as error: + log = "Error with templates for instance %(instance)s and the error was '%(error)s'" + log = log % { + 'instance': writeitinstance.name, + 'error': str(error) + } + mail_admins("Problem sending an email", log) + logging.info(log) + return False, True + + if settings.SEND_ALL_EMAILS_FROM_DEFAULT_FROM_EMAIL: + from_email = author_name + " <" + settings.DEFAULT_FROM_EMAIL + ">" + else: + from_domain = writeitinstance.config.custom_from_domain or settings.DEFAULT_FROM_DOMAIN + from_email = ( + author_name + " <" + writeitinstance.slug + + "+" + outbound_message.outboundmessageidentifier.key + + '@' + from_domain + ">" + ) + + # There there should be a try and except looking + # for errors and stuff + try: + to_email = writeitinstance.owner.email if writeitinstance.config.testing_mode else outbound_message.contact.value + + msg = EmailMultiAlternatives( + subject, + text_content, + from_email, + [to_email], + connection=writeitinstance.config.get_mail_connection(), + headers=prepare_headers(), + ) + if html_content: + msg.attach_alternative(html_content, "text/html") + msg.send(fail_silently=False) + log = "Mail sent from %(from)s to %(to)s" + + log = log % { + 'from': from_email, + 'to': outbound_message.contact.value, + } + logging.info(log) + except SMTPServerDisconnected as e: + logging.warning(e) + return False, False + except SMTPResponseException as e: + logging.warning(e) + if e.smtp_code == 552: + return False, False + return False, True + + except Exception as e: + log = "Error with outbound id %(outbound_id)i, contact '%(contact)s' and message '%(message)s' and the error was '%(error)s'" + log = log % { + 'outbound_id': outbound_message.id, + 'contact': outbound_message.contact.value, + 'message': outbound_message.message, + 'error': str(e) + } + mail_admins("Problem sending an email", log) + logging.info(log) + return False, True + + return True, None diff --git a/nuntium/tests/instance_config_tests.py b/nuntium/tests/instance_config_tests.py index 8ca0d5f6..19e2284b 100644 --- a/nuntium/tests/instance_config_tests.py +++ b/nuntium/tests/instance_config_tests.py @@ -4,7 +4,7 @@ from popolo_sources.models import PopoloSource from nuntium.models import Message from django.contrib.auth.models import User -from mailit import MailChannel +from mailit.channel import MailChannel from contactos.models import Contact from django.core import mail From cbcda13193b78e24cf93e2c43aec29722b79d59f Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 19:50:07 +0200 Subject: [PATCH 12/26] Add setuptools --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index bac299fb..94ff2d53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ amqp==5.1.1 +setuptools>=69.0 asn1crypto==1.5.1 billiard==3.6.4.0 celery==5.2.7 From 14a09624f5021230df26c44ea75def8c9c314a7c Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 20:11:15 +0200 Subject: [PATCH 13/26] Add setuptools --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 94ff2d53..2602806b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ amqp==5.1.1 -setuptools>=69.0 +setuptools==69.5.1 asn1crypto==1.5.1 billiard==3.6.4.0 celery==5.2.7 From 7e07b30d2a57daff2a42823e558e427c92d30e53 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 20:27:44 +0200 Subject: [PATCH 14/26] Django 3 add on_delete params --- Dockerfile | 7 ++++--- contactos/models.py | 8 ++++---- instance/models.py | 10 +++++----- mailit/models.py | 8 ++++---- nuntium/models.py | 36 +++++++++++++++++++----------------- 5 files changed, 36 insertions(+), 33 deletions(-) diff --git a/Dockerfile b/Dockerfile index adcc07da..5d2fa294 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,11 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* # Copy, then install requirements before copying rest for a requirements cache layer. -COPY requirements.txt /tmp/ +COPY requirements.txt patch_packages.py /tmp/ RUN cd /tmp \ - && pip install --upgrade pip setuptools wheel \ - && pip install -r requirements.txt + && pip install --upgrade pip "setuptools<71" wheel \ + && pip install -r requirements.txt \ + && python /tmp/patch_packages.py COPY . /app diff --git a/contactos/models.py b/contactos/models.py index 042f6594..beab76b3 100644 --- a/contactos/models.py +++ b/contactos/models.py @@ -20,12 +20,12 @@ def __unicode__(self): class Contact(models.Model): """docstring for Contact""" - contact_type = models.ForeignKey('ContactType') - person = models.ForeignKey(Person) + contact_type = models.ForeignKey('ContactType', on_delete=models.CASCADE) + person = models.ForeignKey(Person, on_delete=models.CASCADE) value = models.CharField(max_length=512) is_bounced = models.BooleanField(default=False) - owner = models.ForeignKey(User, related_name="contacts", null=True) - writeitinstance = models.ForeignKey('instance.WriteItInstance', related_name="contacts", null=True) + owner = models.ForeignKey(User, related_name="contacts", null=True, on_delete=models.SET_NULL) + writeitinstance = models.ForeignKey('instance.WriteItInstance', related_name="contacts", null=True, on_delete=models.SET_NULL) enabled = models.BooleanField(default=True) def __unicode__(self): diff --git a/instance/models.py b/instance/models.py index b3da7ffb..2a9b055e 100644 --- a/instance/models.py +++ b/instance/models.py @@ -255,7 +255,7 @@ class WriteItInstance(models.Model): persons = models.ManyToManyField(PopoloPerson, related_name='writeit_instances', through='InstanceMembership') - owner = models.ForeignKey(User, related_name="writeitinstances") + owner = models.ForeignKey(User, related_name="writeitinstances", on_delete=models.CASCADE) def add_person(self, person): """Ensure there's exactly one link between the instance and a person""" @@ -384,8 +384,8 @@ def __unicode__(self): class InstanceMembership(models.Model): - person = models.ForeignKey(PopoloPerson) - writeitinstance = models.ForeignKey(WriteItInstance) + person = models.ForeignKey(PopoloPerson, on_delete=models.CASCADE) + writeitinstance = models.ForeignKey(WriteItInstance, on_delete=models.CASCADE) def new_write_it_instance(sender, instance, created, **kwargs): @@ -418,8 +418,8 @@ class WriteitInstancePopitInstanceRecord(models.Model): ("waiting", _("Waiting")), ("inprogress", _("In Progress")), ) - writeitinstance = models.ForeignKey(WriteItInstance) - popolo_source = models.ForeignKey(PopoloSource) + writeitinstance = models.ForeignKey(WriteItInstance, on_delete=models.CASCADE) + popolo_source = models.ForeignKey(PopoloSource, on_delete=models.CASCADE) periodicity = models.CharField( max_length="2", choices=PERIODICITY, diff --git a/mailit/models.py b/mailit/models.py index 3720a06f..857f094f 100644 --- a/mailit/models.py +++ b/mailit/models.py @@ -25,7 +25,7 @@ class MailItTemplate(models.Model): blank=True, help_text=_('You can use {subject}, {content}, {person}, {author}, {site_url}, {site_name}, and {owner_email}'), ) - writeitinstance = models.OneToOneField(WriteItInstance, related_name='mailit_template') + writeitinstance = models.OneToOneField(WriteItInstance, related_name='mailit_template', on_delete=models.CASCADE) def get_content_template(self): return self.content_template or default_content_template @@ -38,14 +38,14 @@ def new_write_it_instance(sender, instance, created, **kwargs): class BouncedMessageRecord(models.Model): - outbound_message = models.OneToOneField(OutboundMessage) + outbound_message = models.OneToOneField(OutboundMessage, on_delete=models.CASCADE) bounce_text = models.TextField() date = models.DateTimeField(auto_now=True) class RawIncomingEmail(models.Model): content = models.TextField() - writeitinstance = models.ForeignKey(WriteItInstance, related_name='raw_emails', null=True) - answer = models.OneToOneField(Answer, related_name='raw_email', null=True) + writeitinstance = models.ForeignKey(WriteItInstance, related_name='raw_emails', null=True, on_delete=models.SET_NULL) + answer = models.OneToOneField(Answer, related_name='raw_email', null=True, on_delete=models.SET_NULL) problem = models.BooleanField(default=False) message_id = models.CharField(max_length=2048, default="") diff --git a/nuntium/models.py b/nuntium/models.py index c3f7480a..31d0ee0d 100644 --- a/nuntium/models.py +++ b/nuntium/models.py @@ -49,7 +49,7 @@ def template_with_wrap(template, context): class MessageRecord(models.Model): status = models.CharField(max_length=255) datetime = models.DateField(default=now) - content_type = models.ForeignKey(ContentType) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField() content_object = GenericForeignKey('content_type', 'object_id') @@ -107,7 +107,7 @@ class Message(models.Model): author_email = models.EmailField() subject = models.CharField(max_length=255) content = models.TextField() - writeitinstance = models.ForeignKey(WriteItInstance) + writeitinstance = models.ForeignKey(WriteItInstance, on_delete=models.CASCADE) confirmated = models.BooleanField(default=False) slug = models.SlugField(max_length=255, unique=True) public = models.BooleanField(default=True) @@ -293,8 +293,8 @@ def slugify_message(sender, instance, **kwargs): class Answer(models.Model): content = models.TextField() content_html = models.TextField() - person = models.ForeignKey(PopoloPerson) - message = models.ForeignKey(Message, related_name='answers') + person = models.ForeignKey(PopoloPerson, on_delete=models.CASCADE) + message = models.ForeignKey(Message, related_name='answers', on_delete=models.CASCADE) created = models.DateTimeField(auto_now=True, null=True) def save(self, *args, **kwargs): @@ -385,7 +385,7 @@ def send_new_answer_payload(sender, instance, created, **kwargs): class AnswerAttachment(models.Model): - answer = models.ForeignKey(Answer, related_name="attachments") + answer = models.ForeignKey(Answer, related_name="attachments", on_delete=models.CASCADE) content = models.FileField(upload_to="attachments/%Y/%m/%d") name = models.CharField(max_length=512, default="") @@ -405,20 +405,20 @@ class AbstractOutboundMessage(models.Model): ("needmodera", _("Needs moderation")), ) - message = models.ForeignKey(Message) + message = models.ForeignKey(Message, on_delete=models.CASCADE) status = models.CharField( max_length="10", choices=STATUS_CHOICES, default="new", ) - site = models.ForeignKey(Site) + site = models.ForeignKey(Site, on_delete=models.CASCADE) class Meta: abstract = True class NoContactOM(AbstractOutboundMessage): - person = models.ForeignKey(PopoloPerson) + person = models.ForeignKey(PopoloPerson, on_delete=models.CASCADE) # This will happen everytime a contact is created @@ -459,7 +459,7 @@ class OutboundMessage(AbstractOutboundMessage): the one that will be tracked in order \ to know the actual status of the message""" - contact = models.ForeignKey(Contact) + contact = models.ForeignKey(Contact, on_delete=models.CASCADE) objects = OutboundMessageManager() @@ -519,7 +519,7 @@ def send(self): class OutboundMessageIdentifier(models.Model): - outbound_message = models.OneToOneField(OutboundMessage) + outbound_message = models.OneToOneField(OutboundMessage, on_delete=models.CASCADE) key = models.CharField(max_length=255) @classmethod @@ -553,8 +553,8 @@ def create_a_message_record(sender, instance, created, **kwargs): class OutboundMessagePluginRecord(models.Model): - outbound_message = models.ForeignKey(OutboundMessage) - plugin = models.ForeignKey(Plugin) + outbound_message = models.ForeignKey(OutboundMessage, on_delete=models.CASCADE) + plugin = models.ForeignKey(Plugin, on_delete=models.CASCADE) sent = models.BooleanField(default=False) number_of_attempts = models.PositiveIntegerField(default=0) try_again = models.BooleanField(default=True) @@ -565,7 +565,7 @@ class OutboundMessagePluginRecord(models.Model): class ConfirmationTemplate(models.Model): - writeitinstance = models.OneToOneField(WriteItInstance) + writeitinstance = models.OneToOneField(WriteItInstance, on_delete=models.CASCADE) content_html = models.TextField( blank=True, help_text=_('You can use {author_name}, {site_name}, {subject}, {content}, {recipients}, {confirmation_url}, and {message_url}'), @@ -588,7 +588,7 @@ def get_subject_template(self): class Confirmation(models.Model): - message = models.OneToOneField(Message) + message = models.OneToOneField(Message, on_delete=models.CASCADE) key = models.CharField(max_length=64, unique=True) created = models.DateField(default=now) confirmated_at = models.DateField(default=None, null=True) @@ -673,7 +673,7 @@ def send_confirmation_email(sender, instance, created, **kwargs): class Moderation(models.Model): - message = models.OneToOneField(Message, related_name='moderation') + message = models.OneToOneField(Message, related_name='moderation', on_delete=models.CASCADE) key = models.CharField(max_length=256) def save(self, *args, **kwargs): @@ -706,6 +706,7 @@ class AnswerWebHook(models.Model): writeitinstance = models.ForeignKey( WriteItInstance, related_name='answer_webhooks', + on_delete=models.CASCADE, ) def __unicode__(self): @@ -716,7 +717,7 @@ def __unicode__(self): class Subscriber(models.Model): - message = models.ForeignKey(Message, related_name='subscribers') + message = models.ForeignKey(Message, related_name='subscribers', on_delete=models.CASCADE) email = models.EmailField() @@ -728,6 +729,7 @@ class NewAnswerNotificationTemplate(models.Model): writeitinstance = models.OneToOneField( WriteItInstance, related_name='new_answer_notification_template', + on_delete=models.CASCADE, ) template_html = models.TextField( blank=True, @@ -754,7 +756,7 @@ def get_subject_template(self): class RateLimiter(models.Model): - writeitinstance = models.ForeignKey(WriteItInstance) + writeitinstance = models.ForeignKey(WriteItInstance, on_delete=models.CASCADE) email = models.EmailField() day = models.DateField() count = models.PositiveIntegerField(default=1) From f7e95e8d6c2b454e109482119d68653d6fea85f3 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 20:28:23 +0200 Subject: [PATCH 15/26] Patch on_delete --- patch_packages.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 patch_packages.py diff --git a/patch_packages.py b/patch_packages.py new file mode 100644 index 00000000..7b8f0eed --- /dev/null +++ b/patch_packages.py @@ -0,0 +1,33 @@ +"""Patch installed packages to add missing on_delete to ForeignKey/OneToOneField for Django 3 compat.""" +import re +import pathlib + +packages = ['djangoplugins', 'popolo', 'popolo_sources'] + +for pkg_name in packages: + try: + pkg = __import__(pkg_name) + except ImportError: + print(f"Skipping {pkg_name} (not installed)") + continue + + pkg_dir = pathlib.Path(pkg.__file__).parent + for py_file in pkg_dir.glob('**/*.py'): + text = py_file.read_text() + original = text + + # Match ForeignKey(...) and OneToOneField(...) without on_delete + for field_type in ['ForeignKey', 'OneToOneField']: + pattern = rf'(models\.{field_type}\([^)]*)\)' + def add_on_delete(m): + inner = m.group(1) + if 'on_delete' in inner: + return m.group(0) + if 'null=True' in inner: + return inner + ', on_delete=models.SET_NULL)' + return inner + ', on_delete=models.CASCADE)' + text = re.sub(pattern, add_on_delete, text) + + if text != original: + py_file.write_text(text) + print(f"Patched: {py_file}") From 9ddf25f49defc0eedd82fe0e61e7a45ae1423829 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 21:41:00 +0200 Subject: [PATCH 16/26] Django 3 patch --- patch_packages.py | 83 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 18 deletions(-) diff --git a/patch_packages.py b/patch_packages.py index 7b8f0eed..250c3ba2 100644 --- a/patch_packages.py +++ b/patch_packages.py @@ -1,8 +1,67 @@ -"""Patch installed packages to add missing on_delete to ForeignKey/OneToOneField for Django 3 compat.""" +"""Patch installed packages for Django 3 compatibility.""" import re import pathlib -packages = ['djangoplugins', 'popolo', 'popolo_sources'] +packages = ['djangoplugins', 'popolo', 'popolo_sources', 'subdomains'] + + +def find_matching_paren(text, start): + """Find the index of the closing paren matching the open paren at start.""" + depth = 0 + for i in range(start, len(text)): + if text[i] == '(': + depth += 1 + elif text[i] == ')': + depth -= 1 + if depth == 0: + return i + return -1 + + +def patch_on_delete(text): + """Add missing on_delete to ForeignKey/OneToOneField.""" + result = text + offset = 0 + for field_type in ['ForeignKey', 'OneToOneField']: + pattern = re.compile(rf'models\.{field_type}\(') + for m in pattern.finditer(text): + open_paren = m.end() - 1 + close_paren = find_matching_paren(text, open_paren) + if close_paren == -1: + continue + inner = text[open_paren + 1:close_paren] + if 'on_delete' in inner: + continue + if 'null=True' in inner: + insert = ', on_delete=models.SET_NULL' + else: + insert = ', on_delete=models.CASCADE' + pos = close_paren + offset + result = result[:pos] + insert + result[pos:] + offset += len(insert) + return result + + +def patch_django3_imports(text): + """Replace removed Django 3 imports with their Python 3 equivalents.""" + replacements = [ + # django.utils.six -> six + ('from django.utils.six import', 'from six import'), + ('from django.utils import six', 'import six'), + # django.utils.encoding + ('from django.utils.encoding import python_2_unicode_compatible', 'python_2_unicode_compatible = lambda cls: cls'), + # django.utils.translation + ('from django.utils.translation import ugettext_lazy as _', 'from django.utils.translation import gettext_lazy as _'), + ('from django.utils.translation import ugettext as _', 'from django.utils.translation import gettext as _'), + ('from django.utils.translation import ugettext_lazy', 'from django.utils.translation import gettext_lazy as ugettext_lazy'), + ('from django.utils.translation import ugettext', 'from django.utils.translation import gettext as ugettext'), + # django.core.urlresolvers -> django.urls + ('from django.core.urlresolvers import', 'from django.urls import'), + ] + for old, new in replacements: + text = text.replace(old, new) + return text + for pkg_name in packages: try: @@ -14,20 +73,8 @@ pkg_dir = pathlib.Path(pkg.__file__).parent for py_file in pkg_dir.glob('**/*.py'): text = py_file.read_text() - original = text - - # Match ForeignKey(...) and OneToOneField(...) without on_delete - for field_type in ['ForeignKey', 'OneToOneField']: - pattern = rf'(models\.{field_type}\([^)]*)\)' - def add_on_delete(m): - inner = m.group(1) - if 'on_delete' in inner: - return m.group(0) - if 'null=True' in inner: - return inner + ', on_delete=models.SET_NULL)' - return inner + ', on_delete=models.CASCADE)' - text = re.sub(pattern, add_on_delete, text) - - if text != original: - py_file.write_text(text) + patched = patch_on_delete(text) + patched = patch_django3_imports(patched) + if patched != text: + py_file.write_text(patched) print(f"Patched: {py_file}") From ff54e597b44a96ed59edd0a6288d6008167da8dc Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 21:44:32 +0200 Subject: [PATCH 17/26] Django 3 on_delete fix --- instance/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instance/models.py b/instance/models.py index 2a9b055e..1c70eff8 100644 --- a/instance/models.py +++ b/instance/models.py @@ -447,7 +447,7 @@ def set_status(self, status, explanation=''): class WriteItInstanceConfig(models.Model): - writeitinstance = AutoOneToOneField(WriteItInstance, related_name='config') + writeitinstance = AutoOneToOneField(WriteItInstance, related_name='config', on_delete=models.CASCADE) testing_mode = models.BooleanField(default=True) moderation_needed_in_all_messages = models.BooleanField( help_text=_("Every message is going to \ From c9689b638bd7ad4e04989d25dcd45a6c91b4fd25 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 21:50:32 +0200 Subject: [PATCH 18/26] Py3 fix --- patch_packages.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/patch_packages.py b/patch_packages.py index 250c3ba2..57802d10 100644 --- a/patch_packages.py +++ b/patch_packages.py @@ -2,7 +2,7 @@ import re import pathlib -packages = ['djangoplugins', 'popolo', 'popolo_sources', 'subdomains'] +packages = ['djangoplugins', 'popolo', 'popolo_sources', 'subdomains', 'popit'] def find_matching_paren(text, start): @@ -42,6 +42,12 @@ def patch_on_delete(text): return result +def patch_subfieldbase(text): + """Remove __metaclass__ = models.SubfieldBase (removed in Django 2.0).""" + text = re.sub(r'\s*__metaclass__\s*=\s*models\.SubfieldBase\n', '\n', text) + return text + + def patch_django3_imports(text): """Replace removed Django 3 imports with their Python 3 equivalents.""" replacements = [ @@ -74,6 +80,7 @@ def patch_django3_imports(text): for py_file in pkg_dir.glob('**/*.py'): text = py_file.read_text() patched = patch_on_delete(text) + patched = patch_subfieldbase(patched) patched = patch_django3_imports(patched) if patched != text: py_file.write_text(patched) From 1e6da6273717a33ee42ad87d2b3fa1eaa20f6023 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 21:54:21 +0200 Subject: [PATCH 19/26] Django3 fix --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 5d2fa294..9bd42ad9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ WORKDIR /app RUN DATABASE_URL=sqlite:///tmp/dummy.db \ ELASTICSEARCH_URL=http://localhost:9200 \ ELASTICSEARCH_INDEX=dummy \ + DJANGO_SECRET_KEY=dummy-build-key \ python manage.py compilemessages RUN addgroup --system django \ From 495fb978de54dca3ea477481215c8e1675e2e8d2 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 22:28:50 +0200 Subject: [PATCH 20/26] Py3 fix package references --- mailit/bin/__init__.py | 2 +- nuntium/user_section/tests/manually_create_answers_tests.py | 2 +- nuntium/user_section/tests/popit_instance_update_tests.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mailit/bin/__init__.py b/mailit/bin/__init__.py index 50ccd363..d13a5d56 100644 --- a/mailit/bin/__init__.py +++ b/mailit/bin/__init__.py @@ -1 +1 @@ -from handleemail import * # noqa +from .handleemail import * # noqa diff --git a/nuntium/user_section/tests/manually_create_answers_tests.py b/nuntium/user_section/tests/manually_create_answers_tests.py index 197765e3..1bb599a0 100644 --- a/nuntium/user_section/tests/manually_create_answers_tests.py +++ b/nuntium/user_section/tests/manually_create_answers_tests.py @@ -6,7 +6,7 @@ from django.utils.translation import ugettext as _ from ..forms import AnswerForm from popolo.models import Person -from user_section_views_tests import UserSectionTestCase +from .user_section_views_tests import UserSectionTestCase class ManuallyCreateAnswersTestCase(UserSectionTestCase): diff --git a/nuntium/user_section/tests/popit_instance_update_tests.py b/nuntium/user_section/tests/popit_instance_update_tests.py index 514cfb52..987b3cf3 100644 --- a/nuntium/user_section/tests/popit_instance_update_tests.py +++ b/nuntium/user_section/tests/popit_instance_update_tests.py @@ -9,7 +9,7 @@ from django.conf import settings from django.core.management import call_command from django.utils.unittest import skip -from user_section_views_tests import UserSectionTestCase +from .user_section_views_tests import UserSectionTestCase from django.utils.translation import ugettext as _ from nuntium.user_section.forms import RelatePopitInstanceWithWriteItInstance from nuntium.management.commands.back_fill_writeit_popit_records import WPBackfillRecords From 44617913ff68ee1913ff1025d8f0dd5005b78bf8 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 22:45:07 +0200 Subject: [PATCH 21/26] Fix package refs and URL patterns --- contactos/urls.py | 6 +++--- mailit/bin/froide_email_utils.py | 2 +- mailit/bin/handleemail.py | 2 +- mailit/urls.py | 6 +++--- nuntium/subdomain_urls.py | 19 ++++++++++--------- nuntium/urls.py | 6 +++--- nuntium/user_section/forms.py | 2 +- nuntium/user_section/urls.py | 6 +++--- writeit/urls.py | 25 +++++++++++-------------- 9 files changed, 36 insertions(+), 38 deletions(-) diff --git a/contactos/urls.py b/contactos/urls.py index a76b34db..ec61b12e 100644 --- a/contactos/urls.py +++ b/contactos/urls.py @@ -1,11 +1,11 @@ -from django.conf.urls import patterns, url +from django.conf.urls import url from contactos.views import ContactoUpdateView, ContactCreateView -urlpatterns = patterns('', +urlpatterns = [ url(r'^contacto/update/(?P[-\d]+)/?$', ContactoUpdateView.as_view(), name='contact_value_update'), url(r'^(?P[-\d]+)/(?P[-\d]+)/contacto/create/?$', ContactCreateView.as_view(), name='create-new-contact'), -) +] diff --git a/mailit/bin/froide_email_utils.py b/mailit/bin/froide_email_utils.py index 17ab484b..10f105e0 100644 --- a/mailit/bin/froide_email_utils.py +++ b/mailit/bin/froide_email_utils.py @@ -18,7 +18,7 @@ from email.Parser import Parser import re -from django.utils.six import BytesIO, text_type as str +from io import BytesIO class UnsupportedMailFormat(Exception): diff --git a/mailit/bin/handleemail.py b/mailit/bin/handleemail.py index 6f0bbfbe..79eb6606 100644 --- a/mailit/bin/handleemail.py +++ b/mailit/bin/handleemail.py @@ -5,7 +5,7 @@ from requests.auth import AuthBase import logging import sys -import config +from . import config import json from email_reply_parser import EmailReplyParser from flufl.bounce import all_failures, scan_message diff --git a/mailit/urls.py b/mailit/urls.py index 233d90d2..64820a21 100644 --- a/mailit/urls.py +++ b/mailit/urls.py @@ -1,9 +1,9 @@ -from django.conf.urls import patterns, url +from django.conf.urls import url from mailit.views import IncomingMail from django.views.decorators.csrf import csrf_exempt -urlpatterns = patterns('', +urlpatterns = [ url('^inbound/sendgrid/raw/$', csrf_exempt(IncomingMail.as_view()), name='sendgrid_inbound_raw'), -) +] diff --git a/nuntium/subdomain_urls.py b/nuntium/subdomain_urls.py index 52610b45..b4d23505 100644 --- a/nuntium/subdomain_urls.py +++ b/nuntium/subdomain_urls.py @@ -1,6 +1,8 @@ from django.conf import settings -from django.conf.urls import patterns, url, include +from django.conf.urls import url, include from django.conf.urls.i18n import i18n_patterns +from django.contrib.auth.views import LogoutView +from django.views.i18n import JavaScriptCatalog from django_downloadview import ObjectDownloadView @@ -63,7 +65,7 @@ # admin.autodiscover() download_attachment_view = ObjectDownloadView.as_view(model=AnswerAttachment, file_field="content") -managepatterns = patterns('', +managepatterns = [ url(r'^$', WriteItInstanceUpdateView.as_view(), name='writeitinstance_basic_update'), url(r'^settings/moderation/$', WriteItInstanceModerationView.as_view(), name='writeitinstance_moderation_update'), url(r'^moderationqueue/$', ModerationQueue.as_view(), name='writeitinstance_moderation_queue'), @@ -101,8 +103,7 @@ url(r'^moderation_accept/(?P[-\w]+)/?$', AcceptModerationView.as_view(), name='moderation_accept'), url(r'^moderation_reject/(?P[-\w]+)/?$', RejectModerationView.as_view(), name='moderation_rejected'), url(r'^welcome/$', WelcomeView.as_view(), name='welcome'), - -) +] js_info_dict = { 'packages': ('nuntium',), @@ -110,9 +111,9 @@ write_message_wizard = WriteMessageView.as_view(url_name='write_message_step') -urlpatterns = i18n_patterns('', +urlpatterns = i18n_patterns( url(r'^$', WriteItInstanceDetailView.as_view(), name='instance_detail'), - url(r'^jsi18n/$', 'django.views.i18n.javascript_catalog', js_info_dict), + url(r'^jsi18n/$', JavaScriptCatalog.as_view(**js_info_dict), name='javascript-catalog'), url(r'^write/sign/(?P[-\w]+)/$', ConfirmView.as_view(), name='confirm'), url(r'^write/sign/$', WriteSignView.as_view(), name='write_message_sign'), url(r'^write/(?P.+)/$', write_message_wizard, name='write_message_step'), @@ -130,7 +131,7 @@ url(r'^search/$', PerInstanceSearchView(), name='instance_search'), url(r'^attachment/(?P[-\d]+)/$', download_attachment_view, name='attachment'), url(r'^manage/', include(managepatterns)), - url(r'^accounts/logout/$', 'django.contrib.auth.views.logout', kwargs={'next_page': '/'}, name='logout'), + url(r'^accounts/logout/$', LogoutView.as_view(next_page='/'), name='logout'), url(r'^about/?$', AboutView.as_view(), name='about'), @@ -141,6 +142,6 @@ if settings.DEBUG: import debug_toolbar - urlpatterns += patterns('', + urlpatterns += [ url(r'^__debug__/', include(debug_toolbar.urls)), - ) + ] diff --git a/nuntium/urls.py b/nuntium/urls.py index 4c0d4fad..7e8848da 100644 --- a/nuntium/urls.py +++ b/nuntium/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import patterns, url +from django.conf.urls import url from django.views.generic.base import TemplateView from nuntium.views import ( @@ -13,7 +13,7 @@ ContactUsView, ) -urlpatterns = patterns('', +urlpatterns = [ # Examples: url(r'^$', HomeTemplateView.as_view(template_name='home.html'), name='home'), url(r'^instances/?$', WriteItInstanceListView.as_view(template_name='nuntium/template_list.html'), name='instance_list'), @@ -28,4 +28,4 @@ r"^robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain"), ), -) +] diff --git a/nuntium/user_section/forms.py b/nuntium/user_section/forms.py index 09f7887b..5652d6ed 100644 --- a/nuntium/user_section/forms.py +++ b/nuntium/user_section/forms.py @@ -232,7 +232,7 @@ class WriteItInstanceCreateForm(WriteItInstanceCreateFormPopitUrl): class Meta: model = WriteItInstance - fields = ('popit_url', 'slug') + fields = ('popit_url',) def __init__(self, *args, **kwargs): if 'owner' in kwargs: diff --git a/nuntium/user_section/urls.py b/nuntium/user_section/urls.py index 496fa0f8..d8934fec 100644 --- a/nuntium/user_section/urls.py +++ b/nuntium/user_section/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import patterns, url +from django.conf.urls import url from django.views.generic import TemplateView from .views import ( @@ -7,7 +7,7 @@ WriteItInstanceCreateView, ) -urlpatterns = patterns('', +urlpatterns = [ url(r'^accounts/profile/?$', UserAccountView.as_view(), name='account'), url(r'^accounts/your_instances/?$', YourInstancesView.as_view(), name='your-instances'), @@ -18,4 +18,4 @@ url(r'^docs/?$', TemplateView.as_view(template_name="nuntium/profiles/docs.html"), name='user_section_documentation'), -) +] diff --git a/writeit/urls.py b/writeit/urls.py index 77a7b9f2..97fa14a3 100644 --- a/writeit/urls.py +++ b/writeit/urls.py @@ -1,7 +1,8 @@ from django.conf import settings -from django.conf.urls import patterns, include, url +from django.conf.urls import include, url from django.conf.urls.i18n import i18n_patterns from django.contrib import admin +from django.contrib.auth.views import LogoutView from django.views.generic.base import TemplateView from tastypie.api import Api @@ -26,12 +27,11 @@ v1_api.register(HandleBouncesResource()) v1_api.register(PersonResource()) -urlpatterns = patterns( - "", - url(r"^admin/", include(admin.site.urls)), +urlpatterns = [ + url(r"^admin/", admin.site.urls), url(r"^/?$", RootRedirectView.as_view()), - (r"^api/", include(v1_api.urls)), - url(r"^social_auth/", include("social.apps.django_app.urls", namespace="social")), + url(r"^api/", include(v1_api.urls)), + url(r"^social_auth/", include("social_django.urls", namespace="social")), # TODO: These can probably be removed at some point. url(r"^contactos/", include("contactos.urls")), url(r"^mailit/", include("mailit.urls")), @@ -40,25 +40,22 @@ r"^robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain"), ), -) +] urlpatterns += i18n_patterns( - "", url(r"^", include("nuntium.urls")), url(r"^", include("nuntium.user_section.urls")), url( r"^accounts/logout/$", - "django.contrib.auth.views.logout", - kwargs={"next_page": "/"}, + LogoutView.as_view(next_page="/"), name="logout", ), - (r"accounts/", include("django.contrib.auth.urls")), + url(r"^accounts/", include("django.contrib.auth.urls")), ) if settings.DEBUG: import debug_toolbar - urlpatterns += patterns( - "", + urlpatterns += [ url(r"^__debug__/", include(debug_toolbar.urls)), - ) + ] From c75bf67ab307d7c40426f58f7a13c0ee7d7ee04b Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 22:49:10 +0200 Subject: [PATCH 22/26] Fix settings for Django 3 --- writeit/settings.py | 96 ++++++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/writeit/settings.py b/writeit/settings.py index b06f408b..8d37e55d 100644 --- a/writeit/settings.py +++ b/writeit/settings.py @@ -114,56 +114,63 @@ STATICFILES_STORAGE = ( "pipeline.storage.NonPackagingPipelineStorage" if TESTING - else "pipeline.storage.PipelineCachedStorage" + else "pipeline.storage.PipelineManifestStorage" ) -PIPELINE_CSS_COMPRESSOR = "pipeline.compressors.yui.YUICompressor" -PIPELINE_YUI_BINARY = "/usr/bin/env yui-compressor" -PIPELINE_COMPILERS = ("pipeline.compilers.sass.SASSCompiler",) -PIPELINE_SASS_BINARY = "/usr/bin/env sassc" # Libsass, via libsass-python -PIPELINE_CSS = { - "writeit-instance": { - "source_filenames": ("sass/instance.scss",), - "output_filename": "css/instance.css", - }, - "writeit-admin": { - "source_filenames": ("sass/admin.scss",), - "output_filename": "css/admin.css", - }, - "writeit-manager": { - "source_filenames": ("sass/manager.scss",), - "output_filename": "css/manager.css", - }, - "writeit-writeinpublic": { - "source_filenames": ("sass/writeinpublic.scss",), - "output_filename": "css/writeinpublic.css", +PIPELINE = { + "CSS_COMPRESSOR": "pipeline.compressors.yui.YUICompressor", + "YUI_BINARY": "/usr/bin/env yui-compressor", + "COMPILERS": ("pipeline.compilers.sass.SASSCompiler",), + "SASS_BINARY": "/usr/bin/env sassc", # Libsass, via libsass-python + "STYLESHEETS": { + "writeit-instance": { + "source_filenames": ("sass/instance.scss",), + "output_filename": "css/instance.css", + }, + "writeit-admin": { + "source_filenames": ("sass/admin.scss",), + "output_filename": "css/admin.css", + }, + "writeit-manager": { + "source_filenames": ("sass/manager.scss",), + "output_filename": "css/manager.css", + }, + "writeit-writeinpublic": { + "source_filenames": ("sass/writeinpublic.scss",), + "output_filename": "css/writeinpublic.css", + }, }, } -# List of callables that know how to import templates from various sources. -TEMPLATE_LOADERS = ( - "writeit.template_loaders.SubdomainFilesystemLoader", - "django.template.loaders.filesystem.Loader", - "django.template.loaders.app_directories.Loader", - # 'django.template.loaders.eggs.Loader', -) - -TEMPLATE_CONTEXT_PROCESSORS = ( - "django.contrib.auth.context_processors.auth", - "django.core.context_processors.debug", - "django.core.context_processors.i18n", - "django.core.context_processors.media", - "django.core.context_processors.request", - "django.core.context_processors.static", - "django.core.context_processors.tz", - "django.contrib.messages.context_processors.messages", - "social_django.context_processors.backends", - "social_django.context_processors.login_redirect", - "writeit.context_processors.web_api_settings", - "writeit.context_processors.google_analytics_settings", -) +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "templates")], + "OPTIONS": { + "loaders": [ + "writeit.template_loaders.SubdomainFilesystemLoader", + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.request", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + "social_django.context_processors.backends", + "social_django.context_processors.login_redirect", + "writeit.context_processors.web_api_settings", + "writeit.context_processors.google_analytics_settings", + ], + }, + }, +] -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( "debug_toolbar.middleware.DebugToolbarMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -189,7 +196,6 @@ # Python dotted path to the WSGI application used by Django's runserver. WSGI_APPLICATION = "writeit.wsgi.application" -TEMPLATE_DIRS = (os.path.join(BASE_DIR, "templates"),) INSTALLED_APPS = ( "django.contrib.auth", From 870010a326eb5dbcfe05184140c5699012c26565 Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 23:16:35 +0200 Subject: [PATCH 23/26] Py3 and Django 3 fixes --- contactos/migrations/0001_initial.py | 8 ++-- .../migrations/0002_contact_popolo_person.py | 2 +- .../migrations/0003_denull_popolo_people.py | 2 +- instance/migrations/0001_initial.py | 12 +++--- .../0003_add_parallel_popolo_data.py | 6 +-- .../0004_add_django_popolo_people.py | 2 +- .../migrations/0005_denull_popolo_source.py | 2 +- ...d_popoloperson_proxy_and_change_related.py | 2 +- instance/models.py | 4 +- mailit/migrations/0001_initial.py | 8 ++-- nuntium/migrations/0001_initial.py | 42 +++++++++---------- .../0003_add_parallel_popolo_data.py | 4 +- .../migrations/0003_denull_popolo_people.py | 4 +- .../0007_switch_fks_to_popoloperson.py | 4 +- nuntium/models.py | 2 +- nuntium/templatetags/nuntium_tags.py | 2 +- patch_packages.py | 40 +++++++++++++++--- writeit/compat.py | 5 ++- 18 files changed, 91 insertions(+), 60 deletions(-) diff --git a/contactos/migrations/0001_initial.py b/contactos/migrations/0001_initial.py index 36c3bfb6..755feee5 100644 --- a/contactos/migrations/0001_initial.py +++ b/contactos/migrations/0001_initial.py @@ -41,25 +41,25 @@ class Migration(migrations.Migration): migrations.AddField( model_name='contact', name='contact_type', - field=models.ForeignKey(to='contactos.ContactType'), + field=models.ForeignKey(to='contactos.ContactType', on_delete=models.CASCADE), preserve_default=True, ), migrations.AddField( model_name='contact', name='owner', - field=models.ForeignKey(related_name='contacts', to=settings.AUTH_USER_MODEL, null=True), + field=models.ForeignKey(related_name='contacts', to=settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL), preserve_default=True, ), migrations.AddField( model_name='contact', name='person', - field=models.ForeignKey(to='popit.Person'), + field=models.ForeignKey(to='popit.Person', on_delete=models.CASCADE), preserve_default=True, ), migrations.AddField( model_name='contact', name='writeitinstance', - field=models.ForeignKey(related_name='contacts', to='instance.WriteItInstance', null=True), + field=models.ForeignKey(related_name='contacts', to='instance.WriteItInstance', null=True, on_delete=models.SET_NULL), preserve_default=True, ), ] diff --git a/contactos/migrations/0002_contact_popolo_person.py b/contactos/migrations/0002_contact_popolo_person.py index fff5e800..9cb9dd1a 100644 --- a/contactos/migrations/0002_contact_popolo_person.py +++ b/contactos/migrations/0002_contact_popolo_person.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='contact', name='popolo_person', - field=models.ForeignKey(blank=True, to='popolo.Person', null=True), + field=models.ForeignKey(blank=True, to='popolo.Person', null=True, on_delete=models.SET_NULL), preserve_default=True, ), ] diff --git a/contactos/migrations/0003_denull_popolo_people.py b/contactos/migrations/0003_denull_popolo_people.py index d74b75b9..146b1d80 100644 --- a/contactos/migrations/0003_denull_popolo_people.py +++ b/contactos/migrations/0003_denull_popolo_people.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='contact', name='popolo_person', - field=models.ForeignKey(to='popolo.Person'), + field=models.ForeignKey(to='popolo.Person', on_delete=models.CASCADE), preserve_default=True, ), ] diff --git a/instance/migrations/0001_initial.py b/instance/migrations/0001_initial.py index b6a3f000..b58edc1e 100644 --- a/instance/migrations/0001_initial.py +++ b/instance/migrations/0001_initial.py @@ -19,7 +19,7 @@ class Migration(migrations.Migration): name='Membership', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('person', models.ForeignKey(to='popit.Person')), + ('person', models.ForeignKey(to='popit.Person', on_delete=models.CASCADE)), ], options={ }, @@ -32,7 +32,7 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=255)), ('description', models.CharField(max_length=512, blank=True)), ('slug', autoslug.fields.AutoSlugField(populate_from=b'name', unique=True, editable=False)), - ('owner', models.ForeignKey(related_name='writeitinstances', to=settings.AUTH_USER_MODEL)), + ('owner', models.ForeignKey(related_name='writeitinstances', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ('persons', models.ManyToManyField(related_name='writeit_instances', through='instance.Membership', to='popit.Person')), ], options={ @@ -59,7 +59,7 @@ class Migration(migrations.Migration): ('can_create_answer', models.BooleanField(default=False, help_text=b'Can create an answer using the WebUI')), ('maximum_recipients', models.PositiveIntegerField(default=5)), ('default_language', models.CharField(max_length=10, choices=[(b'ar', b'Arabic'), (b'cs', b'Czech'), (b'en', b'English'), (b'es', b'Spanish'), (b'fr', b'French'), (b'hu', b'Hungarian')])), - ('writeitinstance', annoying.fields.AutoOneToOneField(related_name='config', to='instance.WriteItInstance')), + ('writeitinstance', annoying.fields.AutoOneToOneField(related_name='config', to='instance.WriteItInstance', on_delete=models.CASCADE)), ], options={ }, @@ -74,8 +74,8 @@ class Migration(migrations.Migration): ('status_explanation', models.TextField(default=b'')), ('updated', models.DateTimeField(auto_now_add=True)), ('created', models.DateTimeField(auto_now_add=True)), - ('popitapiinstance', models.ForeignKey(to='popit.ApiInstance')), - ('writeitinstance', models.ForeignKey(to='instance.WriteItInstance')), + ('popitapiinstance', models.ForeignKey(to='popit.ApiInstance', on_delete=models.CASCADE)), + ('writeitinstance', models.ForeignKey(to='instance.WriteItInstance', on_delete=models.CASCADE)), ], options={ }, @@ -84,7 +84,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='membership', name='writeitinstance', - field=models.ForeignKey(to='instance.WriteItInstance'), + field=models.ForeignKey(to='instance.WriteItInstance', on_delete=models.CASCADE), preserve_default=True, ), ] diff --git a/instance/migrations/0003_add_parallel_popolo_data.py b/instance/migrations/0003_add_parallel_popolo_data.py index e5dab9f1..845fd28a 100644 --- a/instance/migrations/0003_add_parallel_popolo_data.py +++ b/instance/migrations/0003_add_parallel_popolo_data.py @@ -17,8 +17,8 @@ class Migration(migrations.Migration): name='InstanceMembership', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('person', models.ForeignKey(to='popolo.Person')), - ('writeitinstance', models.ForeignKey(to='instance.WriteItInstance')), + ('person', models.ForeignKey(to='popolo.Person', on_delete=models.CASCADE)), + ('writeitinstance', models.ForeignKey(to='instance.WriteItInstance', on_delete=models.CASCADE)), ], options={ }, @@ -33,7 +33,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='writeitinstancepopitinstancerecord', name='popolo_source', - field=models.ForeignKey(blank=True, to='popolo_sources.PopoloSource', null=True), + field=models.ForeignKey(blank=True, to='popolo_sources.PopoloSource', null=True, on_delete=models.SET_NULL), preserve_default=True, ), ] diff --git a/instance/migrations/0004_add_django_popolo_people.py b/instance/migrations/0004_add_django_popolo_people.py index 132b8f32..e688eb82 100644 --- a/instance/migrations/0004_add_django_popolo_people.py +++ b/instance/migrations/0004_add_django_popolo_people.py @@ -5,7 +5,7 @@ from urllib.parse import urlsplit, urlunsplit from django.db import migrations -from django.contrib.contenttypes.management import update_contenttypes +from django.contrib.contenttypes.management import create_contenttypes as update_contenttypes def is_proxy_url(url): diff --git a/instance/migrations/0005_denull_popolo_source.py b/instance/migrations/0005_denull_popolo_source.py index 35ed301e..8f3a4eaf 100644 --- a/instance/migrations/0005_denull_popolo_source.py +++ b/instance/migrations/0005_denull_popolo_source.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='writeitinstancepopitinstancerecord', name='popolo_source', - field=models.ForeignKey(to='popolo_sources.PopoloSource'), + field=models.ForeignKey(to='popolo_sources.PopoloSource', on_delete=models.CASCADE), preserve_default=True, ), ] diff --git a/instance/migrations/0010_add_popoloperson_proxy_and_change_related.py b/instance/migrations/0010_add_popoloperson_proxy_and_change_related.py index 7bbeb352..1cfd1b57 100644 --- a/instance/migrations/0010_add_popoloperson_proxy_and_change_related.py +++ b/instance/migrations/0010_add_popoloperson_proxy_and_change_related.py @@ -24,7 +24,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='instancemembership', name='person', - field=models.ForeignKey(to='instance.PopoloPerson'), + field=models.ForeignKey(to='instance.PopoloPerson', on_delete=models.CASCADE), preserve_default=True, ), migrations.AlterField( diff --git a/instance/models.py b/instance/models.py index 1c70eff8..371d4321 100644 --- a/instance/models.py +++ b/instance/models.py @@ -421,12 +421,12 @@ class WriteitInstancePopitInstanceRecord(models.Model): writeitinstance = models.ForeignKey(WriteItInstance, on_delete=models.CASCADE) popolo_source = models.ForeignKey(PopoloSource, on_delete=models.CASCADE) periodicity = models.CharField( - max_length="2", + max_length=2, choices=PERIODICITY, default='1W', ) status = models.CharField( - max_length="20", + max_length=20, choices=STATUS_CHOICES, default="nothing", ) diff --git a/mailit/migrations/0001_initial.py b/mailit/migrations/0001_initial.py index 7b2ca6dd..4e80f182 100644 --- a/mailit/migrations/0001_initial.py +++ b/mailit/migrations/0001_initial.py @@ -50,25 +50,25 @@ class Migration(migrations.Migration): migrations.AddField( model_name='rawincomingemail', name='answer', - field=models.OneToOneField(related_name='raw_email', null=True, to='nuntium.Answer'), + field=models.OneToOneField(related_name='raw_email', null=True, to='nuntium.Answer', on_delete=models.SET_NULL), preserve_default=True, ), migrations.AddField( model_name='rawincomingemail', name='writeitinstance', - field=models.ForeignKey(related_name='raw_emails', to='instance.WriteItInstance', null=True), + field=models.ForeignKey(related_name='raw_emails', to='instance.WriteItInstance', null=True, on_delete=models.SET_NULL), preserve_default=True, ), migrations.AddField( model_name='mailittemplate', name='writeitinstance', - field=models.OneToOneField(related_name='mailit_template', to='instance.WriteItInstance'), + field=models.OneToOneField(related_name='mailit_template', to='instance.WriteItInstance', on_delete=models.CASCADE), preserve_default=True, ), migrations.AddField( model_name='bouncedmessagerecord', name='outbound_message', - field=models.OneToOneField(to='nuntium.OutboundMessage'), + field=models.OneToOneField(to='nuntium.OutboundMessage', on_delete=models.CASCADE), preserve_default=True, ), ] diff --git a/nuntium/migrations/0001_initial.py b/nuntium/migrations/0001_initial.py index 748ce3c1..ee3d1833 100644 --- a/nuntium/migrations/0001_initial.py +++ b/nuntium/migrations/0001_initial.py @@ -36,7 +36,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('content', models.FileField(upload_to=b'attachments/%Y/%m/%d')), ('name', models.CharField(default=b'', max_length=512)), - ('answer', models.ForeignKey(related_name='attachments', to='nuntium.Answer')), + ('answer', models.ForeignKey(related_name='attachments', to='nuntium.Answer', on_delete=models.CASCADE)), ], options={ }, @@ -47,7 +47,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('url', models.URLField(max_length=255)), - ('writeitinstance', models.ForeignKey(related_name='answer_webhooks', to='instance.WriteItInstance')), + ('writeitinstance', models.ForeignKey(related_name='answer_webhooks', to='instance.WriteItInstance', on_delete=models.CASCADE)), ], options={ }, @@ -72,7 +72,7 @@ class Migration(migrations.Migration): ('content_html', models.TextField(help_text='You can use {author_name}, {site_name}, {subject}, {content}, {recipients}, {confirmation_url}, and {message_url}', blank=True)), ('content_text', models.TextField(help_text='You can use {author_name}, {site_name}, {subject}, {content}, {recipients}, {confirmation_url}, and {message_url}', blank=True)), ('subject', models.CharField(help_text='You can use {author_name}, {site_name}, {subject}, {content}, {recipients}, {confirmation_url}, and {message_url}', max_length=512, blank=True)), - ('writeitinstance', models.OneToOneField(to='instance.WriteItInstance')), + ('writeitinstance', models.OneToOneField(to='instance.WriteItInstance', on_delete=models.CASCADE)), ], options={ }, @@ -92,7 +92,7 @@ class Migration(migrations.Migration): ('moderated', models.NullBooleanField()), ('created', models.DateTimeField(auto_now_add=True, null=True)), ('updated', models.DateTimeField(auto_now=True, null=True)), - ('writeitinstance', models.ForeignKey(to='instance.WriteItInstance')), + ('writeitinstance', models.ForeignKey(to='instance.WriteItInstance', on_delete=models.CASCADE)), ], options={ 'ordering': ['-created'], @@ -106,7 +106,7 @@ class Migration(migrations.Migration): ('status', models.CharField(max_length=255)), ('datetime', models.DateField(default=datetime.datetime(2016, 7, 6, 11, 35, 48, 116612, tzinfo=utc))), ('object_id', models.PositiveIntegerField()), - ('content_type', models.ForeignKey(to='contenttypes.ContentType')), + ('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)), ], options={ }, @@ -117,7 +117,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('key', models.CharField(max_length=256)), - ('message', models.OneToOneField(related_name='moderation', to='nuntium.Message')), + ('message', models.OneToOneField(related_name='moderation', to='nuntium.Message', on_delete=models.CASCADE)), ], options={ }, @@ -130,7 +130,7 @@ class Migration(migrations.Migration): ('template_html', models.TextField(help_text='You can use {author_name}, {person}, {subject}, {content}, {message_url}, and {site_name}', blank=True)), ('template_text', models.TextField(help_text='You can use {author_name}, {person}, {subject}, {content}, {message_url}, and {site_name}', blank=True)), ('subject_template', models.CharField(help_text='You can use {author_name}, {person}, {subject}, {content}, {message_url}, and {site_name}', max_length=255, blank=True)), - ('writeitinstance', models.OneToOneField(related_name='new_answer_notification_template', to='instance.WriteItInstance')), + ('writeitinstance', models.OneToOneField(related_name='new_answer_notification_template', to='instance.WriteItInstance', on_delete=models.CASCADE)), ], options={ }, @@ -141,9 +141,9 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('status', models.CharField(default=b'new', max_length=b'10', choices=[(b'new', 'Newly created'), (b'ready', 'Ready to send'), (b'sent', 'Sent'), (b'error', 'Error sending it'), (b'needmodera', 'Needs moderation')])), - ('message', models.ForeignKey(to='nuntium.Message')), - ('person', models.ForeignKey(to='popit.Person')), - ('site', models.ForeignKey(to='sites.Site')), + ('message', models.ForeignKey(to='nuntium.Message', on_delete=models.CASCADE)), + ('person', models.ForeignKey(to='popit.Person', on_delete=models.CASCADE)), + ('site', models.ForeignKey(to='sites.Site', on_delete=models.CASCADE)), ], options={ 'abstract': False, @@ -155,9 +155,9 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('status', models.CharField(default=b'new', max_length=b'10', choices=[(b'new', 'Newly created'), (b'ready', 'Ready to send'), (b'sent', 'Sent'), (b'error', 'Error sending it'), (b'needmodera', 'Needs moderation')])), - ('contact', models.ForeignKey(to='contactos.Contact')), - ('message', models.ForeignKey(to='nuntium.Message')), - ('site', models.ForeignKey(to='sites.Site')), + ('contact', models.ForeignKey(to='contactos.Contact', on_delete=models.CASCADE)), + ('message', models.ForeignKey(to='nuntium.Message', on_delete=models.CASCADE)), + ('site', models.ForeignKey(to='sites.Site', on_delete=models.CASCADE)), ], options={ 'abstract': False, @@ -169,7 +169,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('key', models.CharField(max_length=255)), - ('outbound_message', models.OneToOneField(to='nuntium.OutboundMessage')), + ('outbound_message', models.OneToOneField(to='nuntium.OutboundMessage', on_delete=models.CASCADE)), ], options={ }, @@ -182,8 +182,8 @@ class Migration(migrations.Migration): ('sent', models.BooleanField(default=False)), ('number_of_attempts', models.PositiveIntegerField(default=0)), ('try_again', models.BooleanField(default=True)), - ('outbound_message', models.ForeignKey(to='nuntium.OutboundMessage')), - ('plugin', models.ForeignKey(to='djangoplugins.Plugin')), + ('outbound_message', models.ForeignKey(to='nuntium.OutboundMessage', on_delete=models.CASCADE)), + ('plugin', models.ForeignKey(to='djangoplugins.Plugin', on_delete=models.CASCADE)), ], options={ }, @@ -196,7 +196,7 @@ class Migration(migrations.Migration): ('email', models.EmailField(max_length=75)), ('day', models.DateField()), ('count', models.PositiveIntegerField(default=1)), - ('writeitinstance', models.ForeignKey(to='instance.WriteItInstance')), + ('writeitinstance', models.ForeignKey(to='instance.WriteItInstance', on_delete=models.CASCADE)), ], options={ }, @@ -207,7 +207,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('email', models.EmailField(max_length=75)), - ('message', models.ForeignKey(related_name='subscribers', to='nuntium.Message')), + ('message', models.ForeignKey(related_name='subscribers', to='nuntium.Message', on_delete=models.CASCADE)), ], options={ }, @@ -216,19 +216,19 @@ class Migration(migrations.Migration): migrations.AddField( model_name='confirmation', name='message', - field=models.OneToOneField(to='nuntium.Message'), + field=models.OneToOneField(to='nuntium.Message', on_delete=models.CASCADE), preserve_default=True, ), migrations.AddField( model_name='answer', name='message', - field=models.ForeignKey(related_name='answers', to='nuntium.Message'), + field=models.ForeignKey(related_name='answers', to='nuntium.Message', on_delete=models.CASCADE), preserve_default=True, ), migrations.AddField( model_name='answer', name='person', - field=models.ForeignKey(to='popit.Person'), + field=models.ForeignKey(to='popit.Person', on_delete=models.CASCADE), preserve_default=True, ), migrations.CreateModel( diff --git a/nuntium/migrations/0003_add_parallel_popolo_data.py b/nuntium/migrations/0003_add_parallel_popolo_data.py index bc954c1a..1a75f8a8 100644 --- a/nuntium/migrations/0003_add_parallel_popolo_data.py +++ b/nuntium/migrations/0003_add_parallel_popolo_data.py @@ -15,13 +15,13 @@ class Migration(migrations.Migration): migrations.AddField( model_name='answer', name='popolo_person', - field=models.ForeignKey(blank=True, to='popolo.Person', null=True), + field=models.ForeignKey(blank=True, to='popolo.Person', null=True, on_delete=models.SET_NULL), preserve_default=True, ), migrations.AddField( model_name='nocontactom', name='popolo_person', - field=models.ForeignKey(blank=True, to='popolo.Person', null=True), + field=models.ForeignKey(blank=True, to='popolo.Person', null=True, on_delete=models.SET_NULL), preserve_default=True, ), ] diff --git a/nuntium/migrations/0003_denull_popolo_people.py b/nuntium/migrations/0003_denull_popolo_people.py index be3395be..1eb2d104 100644 --- a/nuntium/migrations/0003_denull_popolo_people.py +++ b/nuntium/migrations/0003_denull_popolo_people.py @@ -15,13 +15,13 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='answer', name='popolo_person', - field=models.ForeignKey(to='popolo.Person'), + field=models.ForeignKey(to='popolo.Person', on_delete=models.CASCADE), preserve_default=True, ), migrations.AlterField( model_name='nocontactom', name='popolo_person', - field=models.ForeignKey(to='popolo.Person'), + field=models.ForeignKey(to='popolo.Person', on_delete=models.CASCADE), preserve_default=True, ), ] diff --git a/nuntium/migrations/0007_switch_fks_to_popoloperson.py b/nuntium/migrations/0007_switch_fks_to_popoloperson.py index dc91fe7b..4313b66a 100644 --- a/nuntium/migrations/0007_switch_fks_to_popoloperson.py +++ b/nuntium/migrations/0007_switch_fks_to_popoloperson.py @@ -17,13 +17,13 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='answer', name='person', - field=models.ForeignKey(to='instance.PopoloPerson'), + field=models.ForeignKey(to='instance.PopoloPerson', on_delete=models.CASCADE), preserve_default=True, ), migrations.AlterField( model_name='nocontactom', name='person', - field=models.ForeignKey(to='instance.PopoloPerson'), + field=models.ForeignKey(to='instance.PopoloPerson', on_delete=models.CASCADE), preserve_default=True, ), ] diff --git a/nuntium/models.py b/nuntium/models.py index 31d0ee0d..0fc1f456 100644 --- a/nuntium/models.py +++ b/nuntium/models.py @@ -407,7 +407,7 @@ class AbstractOutboundMessage(models.Model): message = models.ForeignKey(Message, on_delete=models.CASCADE) status = models.CharField( - max_length="10", + max_length=10, choices=STATUS_CHOICES, default="new", ) diff --git a/nuntium/templatetags/nuntium_tags.py b/nuntium/templatetags/nuntium_tags.py index cf6aceaa..71f3d408 100644 --- a/nuntium/templatetags/nuntium_tags.py +++ b/nuntium/templatetags/nuntium_tags.py @@ -51,7 +51,7 @@ def localize_datetime(dt, language_code=None): return dt -@register.assignment_tag(takes_context=True) +@register.simple_tag(takes_context=True) def assignment_url_with_subdomain(context, view, subdomain=UNSET, *args, **kwargs): return subdomainsurls(context, view, subdomain, *args, **kwargs) diff --git a/patch_packages.py b/patch_packages.py index 57802d10..9628a223 100644 --- a/patch_packages.py +++ b/patch_packages.py @@ -2,7 +2,7 @@ import re import pathlib -packages = ['djangoplugins', 'popolo', 'popolo_sources', 'subdomains', 'popit'] +packages = ['djangoplugins', 'popolo', 'popolo_sources', 'subdomains', 'popit', 'pagination'] def find_matching_paren(text, start): @@ -48,6 +48,19 @@ def patch_subfieldbase(text): return text +def patch_except_syntax(text): + """Fix Python 2 except syntax: 'except X, Y:' -> 'except (X, Y):' or 'except X as Y:'.""" + def replace_except(m): + first = m.group(1) + second = m.group(2) + if second[0].isupper(): + return f'except ({first}, {second}):' + else: + return f'except {first} as {second}:' + text = re.sub(r'except\s+(\w+),\s+(\w+):', replace_except, text) + return text + + def patch_django3_imports(text): """Replace removed Django 3 imports with their Python 3 equivalents.""" replacements = [ @@ -69,18 +82,33 @@ def patch_django3_imports(text): return text -for pkg_name in packages: +import site + +def find_package_dir(pkg_name): + """Find a package directory by name, even if it can't be imported.""" try: pkg = __import__(pkg_name) - except ImportError: - print(f"Skipping {pkg_name} (not installed)") - continue + return pathlib.Path(pkg.__file__).parent + except Exception: + pass + # Fallback: search site-packages directories + for sp in site.getsitepackages() + [site.getusersitepackages()]: + candidate = pathlib.Path(sp) / pkg_name + if candidate.is_dir(): + return candidate + return None + - pkg_dir = pathlib.Path(pkg.__file__).parent +for pkg_name in packages: + pkg_dir = find_package_dir(pkg_name) + if pkg_dir is None: + print(f"Skipping {pkg_name} (not found)") + continue for py_file in pkg_dir.glob('**/*.py'): text = py_file.read_text() patched = patch_on_delete(text) patched = patch_subfieldbase(patched) + patched = patch_except_syntax(patched) patched = patch_django3_imports(patched) if patched != text: py_file.write_text(patched) diff --git a/writeit/compat.py b/writeit/compat.py index 4c220f66..cf6b5e16 100644 --- a/writeit/compat.py +++ b/writeit/compat.py @@ -15,4 +15,7 @@ def _compat_patterns(prefix, *args): django.conf.urls.patterns = _compat_patterns -django.core.management.base.NoArgsCommand = django.core.management.base.BaseCommand +class _NoArgsCommand(django.core.management.base.BaseCommand): + option_list = () + +django.core.management.base.NoArgsCommand = _NoArgsCommand From b8ac3fb4cc00de49d9c3a9fa18af951c615643af Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Tue, 17 Feb 2026 23:25:08 +0200 Subject: [PATCH 24/26] Py3 fixes --- instance/migrations/0001_initial.py | 12 ++++++------ instance/migrations/0004_add_django_popolo_people.py | 12 ++++-------- .../migrations/0004_add_email_real_name_to_config.py | 2 +- .../0011_update_default_language_choice.py | 2 +- mailit/migrations/0001_initial.py | 4 ++-- nuntium/migrations/0001_initial.py | 8 ++++---- nuntium/migrations/0002_author_name_allow_blank.py | 2 +- 7 files changed, 19 insertions(+), 23 deletions(-) diff --git a/instance/migrations/0001_initial.py b/instance/migrations/0001_initial.py index b58edc1e..b277a7e7 100644 --- a/instance/migrations/0001_initial.py +++ b/instance/migrations/0001_initial.py @@ -31,7 +31,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(max_length=255)), ('description', models.CharField(max_length=512, blank=True)), - ('slug', autoslug.fields.AutoSlugField(populate_from=b'name', unique=True, editable=False)), + ('slug', autoslug.fields.AutoSlugField(populate_from='name', unique=True, editable=False)), ('owner', models.ForeignKey(related_name='writeitinstances', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ('persons', models.ManyToManyField(related_name='writeit_instances', through='instance.Membership', to='popit.Person')), ], @@ -56,9 +56,9 @@ class Migration(migrations.Migration): ('email_port', models.IntegerField(null=True, blank=True)), ('email_use_tls', models.NullBooleanField()), ('email_use_ssl', models.NullBooleanField()), - ('can_create_answer', models.BooleanField(default=False, help_text=b'Can create an answer using the WebUI')), + ('can_create_answer', models.BooleanField(default=False, help_text='Can create an answer using the WebUI')), ('maximum_recipients', models.PositiveIntegerField(default=5)), - ('default_language', models.CharField(max_length=10, choices=[(b'ar', b'Arabic'), (b'cs', b'Czech'), (b'en', b'English'), (b'es', b'Spanish'), (b'fr', b'French'), (b'hu', b'Hungarian')])), + ('default_language', models.CharField(max_length=10, choices=[('ar', 'Arabic'), ('cs', 'Czech'), ('en', 'English'), ('es', 'Spanish'), ('fr', 'French'), ('hu', 'Hungarian')])), ('writeitinstance', annoying.fields.AutoOneToOneField(related_name='config', to='instance.WriteItInstance', on_delete=models.CASCADE)), ], options={ @@ -69,9 +69,9 @@ class Migration(migrations.Migration): name='WriteitInstancePopitInstanceRecord', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('periodicity', models.CharField(default=b'1W', max_length=b'2', choices=[(b'--', b'Never'), (b'2D', b'Twice every Day'), (b'1D', b'Daily'), (b'1W', b'Weekly')])), - ('status', models.CharField(default=b'nothing', max_length=b'20', choices=[(b'nothing', 'Not doing anything now'), (b'error', 'Error'), (b'success', 'Success'), (b'waiting', 'Waiting'), (b'inprogress', 'In Progress')])), - ('status_explanation', models.TextField(default=b'')), + ('periodicity', models.CharField(default='1W', max_length=2, choices=[('--', 'Never'), ('2D', 'Twice every Day'), ('1D', 'Daily'), ('1W', 'Weekly')])), + ('status', models.CharField(default='nothing', max_length=20, choices=[('nothing', 'Not doing anything now'), ('error', 'Error'), ('success', 'Success'), ('waiting', 'Waiting'), ('inprogress', 'In Progress')])), + ('status_explanation', models.TextField(default='')), ('updated', models.DateTimeField(auto_now_add=True)), ('created', models.DateTimeField(auto_now_add=True)), ('popitapiinstance', models.ForeignKey(to='popit.ApiInstance', on_delete=models.CASCADE)), diff --git a/instance/migrations/0004_add_django_popolo_people.py b/instance/migrations/0004_add_django_popolo_people.py index e688eb82..74c18df6 100644 --- a/instance/migrations/0004_add_django_popolo_people.py +++ b/instance/migrations/0004_add_django_popolo_people.py @@ -5,7 +5,6 @@ from urllib.parse import urlsplit, urlunsplit from django.db import migrations -from django.contrib.contenttypes.management import create_contenttypes as update_contenttypes def is_proxy_url(url): @@ -38,14 +37,11 @@ def update_source_url(original_url): def forwards(apps, schema_editor): - # Make sure the content types for django-popolo exist, with a - # hacky workaround from: http://stackoverflow.com/a/35353170/223092 - popolo_app = apps.app_configs['popolo'] - popolo_app.models_module = popolo_app.models_module or True - update_contenttypes(popolo_app, verbosity=1, interactive=False) + # Make sure the content type for popolo Person exists ContentType = apps.get_model('contenttypes', 'ContentType') - person_content_type = ContentType.objects.get( - app_label='popolo', model='person') + person_content_type, _ = ContentType.objects.get_or_create( + app_label='popolo', model='person', + defaults={'app_label': 'popolo', 'model': 'person'}) # Create a PopoloSource for each old APIInstance ApiInstance = apps.get_model('popit', 'ApiInstance') PopoloSource = apps.get_model('popolo_sources', 'PopoloSource') diff --git a/instance/migrations/0004_add_email_real_name_to_config.py b/instance/migrations/0004_add_email_real_name_to_config.py index ea2f8b57..82a087bf 100644 --- a/instance/migrations/0004_add_email_real_name_to_config.py +++ b/instance/migrations/0004_add_email_real_name_to_config.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='writeitinstanceconfig', name='real_name_for_site_emails', - field=models.TextField(default=b'', help_text='The name that should appear in the From: line of emails sent from this site', blank=True), + field=models.TextField(default='', help_text='The name that should appear in the From: line of emails sent from this site', blank=True), preserve_default=True, ), ] diff --git a/instance/migrations/0011_update_default_language_choice.py b/instance/migrations/0011_update_default_language_choice.py index 077e0fb2..87f2d89b 100644 --- a/instance/migrations/0011_update_default_language_choice.py +++ b/instance/migrations/0011_update_default_language_choice.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='writeitinstanceconfig', name='default_language', - field=models.CharField(max_length=10, choices=[(b'ar', b'Arabic'), (b'cs', b'Czech'), (b'en', b'English'), (b'es', b'Spanish'), (b'fa', b'Persian'), (b'fr', b'French'), (b'hu', b'Hungarian')]), + field=models.CharField(max_length=10, choices=[('ar', 'Arabic'), ('cs', 'Czech'), ('en', 'English'), ('es', 'Spanish'), ('fa', 'Persian'), ('fr', 'French'), ('hu', 'Hungarian')]), preserve_default=True, ), ] diff --git a/mailit/migrations/0001_initial.py b/mailit/migrations/0001_initial.py index 4e80f182..035ce2ca 100644 --- a/mailit/migrations/0001_initial.py +++ b/mailit/migrations/0001_initial.py @@ -27,7 +27,7 @@ class Migration(migrations.Migration): name='MailItTemplate', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('subject_template', models.CharField(default=b'{subject}', help_text='You can use {subject}, {content}, {person}, {author}, {site_url}, {site_name}, and {owner_email}', max_length=255)), + ('subject_template', models.CharField(default='{subject}', help_text='You can use {subject}, {content}, {person}, {author}, {site_url}, {site_name}, and {owner_email}', max_length=255)), ('content_template', models.TextField(help_text='You can use {subject}, {content}, {person}, {author}, {site_url}, {site_name}, and {owner_email}', blank=True)), ('content_html_template', models.TextField(help_text='You can use {subject}, {content}, {person}, {author}, {site_url}, {site_name}, and {owner_email}', blank=True)), ], @@ -41,7 +41,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('content', models.TextField()), ('problem', models.BooleanField(default=False)), - ('message_id', models.CharField(default=b'', max_length=2048)), + ('message_id', models.CharField(default='', max_length=2048)), ], options={ }, diff --git a/nuntium/migrations/0001_initial.py b/nuntium/migrations/0001_initial.py index ee3d1833..47be8354 100644 --- a/nuntium/migrations/0001_initial.py +++ b/nuntium/migrations/0001_initial.py @@ -34,8 +34,8 @@ class Migration(migrations.Migration): name='AnswerAttachment', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('content', models.FileField(upload_to=b'attachments/%Y/%m/%d')), - ('name', models.CharField(default=b'', max_length=512)), + ('content', models.FileField(upload_to='attachments/%Y/%m/%d')), + ('name', models.CharField(default='', max_length=512)), ('answer', models.ForeignKey(related_name='attachments', to='nuntium.Answer', on_delete=models.CASCADE)), ], options={ @@ -140,7 +140,7 @@ class Migration(migrations.Migration): name='NoContactOM', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('status', models.CharField(default=b'new', max_length=b'10', choices=[(b'new', 'Newly created'), (b'ready', 'Ready to send'), (b'sent', 'Sent'), (b'error', 'Error sending it'), (b'needmodera', 'Needs moderation')])), + ('status', models.CharField(default='new', max_length=10, choices=[('new', 'Newly created'), ('ready', 'Ready to send'), ('sent', 'Sent'), ('error', 'Error sending it'), ('needmodera', 'Needs moderation')])), ('message', models.ForeignKey(to='nuntium.Message', on_delete=models.CASCADE)), ('person', models.ForeignKey(to='popit.Person', on_delete=models.CASCADE)), ('site', models.ForeignKey(to='sites.Site', on_delete=models.CASCADE)), @@ -154,7 +154,7 @@ class Migration(migrations.Migration): name='OutboundMessage', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('status', models.CharField(default=b'new', max_length=b'10', choices=[(b'new', 'Newly created'), (b'ready', 'Ready to send'), (b'sent', 'Sent'), (b'error', 'Error sending it'), (b'needmodera', 'Needs moderation')])), + ('status', models.CharField(default='new', max_length=10, choices=[('new', 'Newly created'), ('ready', 'Ready to send'), ('sent', 'Sent'), ('error', 'Error sending it'), ('needmodera', 'Needs moderation')])), ('contact', models.ForeignKey(to='contactos.Contact', on_delete=models.CASCADE)), ('message', models.ForeignKey(to='nuntium.Message', on_delete=models.CASCADE)), ('site', models.ForeignKey(to='sites.Site', on_delete=models.CASCADE)), diff --git a/nuntium/migrations/0002_author_name_allow_blank.py b/nuntium/migrations/0002_author_name_allow_blank.py index a1cdb263..16233d82 100644 --- a/nuntium/migrations/0002_author_name_allow_blank.py +++ b/nuntium/migrations/0002_author_name_allow_blank.py @@ -16,7 +16,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='message', name='author_name', - field=models.CharField(default=b'', max_length=512, blank=True), + field=models.CharField(default='', max_length=512, blank=True), preserve_default=True, ), ] From 30a209944dda0e0e2b796fafc3c2a476c299fe1b Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Wed, 18 Feb 2026 00:13:12 +0200 Subject: [PATCH 25/26] Py 3 and Django 3 fixes --- bin/wait-for-deps.sh | 2 ++ patch_packages.py | 28 +++++++++++++++++++++++- writeit/__init__.py | 15 +++++++++++++ writeit/celery.py | 1 + writeit/middleware.py | 50 ++++++++++++++++++++++++++++++++++++++++++- writeit/settings.py | 4 ++-- 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/bin/wait-for-deps.sh b/bin/wait-for-deps.sh index b23d3ca1..d7c825c3 100755 --- a/bin/wait-for-deps.sh +++ b/bin/wait-for-deps.sh @@ -6,4 +6,6 @@ set -o nounset source $(dirname $0)/wait-for-postgres.sh source $(dirname $0)/wait-for-elasticsearch.sh +python /app/patch_packages.py + exec "$@" diff --git a/patch_packages.py b/patch_packages.py index 9628a223..50a2dfe8 100644 --- a/patch_packages.py +++ b/patch_packages.py @@ -2,7 +2,7 @@ import re import pathlib -packages = ['djangoplugins', 'popolo', 'popolo_sources', 'subdomains', 'popit', 'pagination'] +packages = ['djangoplugins', 'popolo', 'popolo_sources', 'subdomains', 'popit', 'pagination', 'celery_haystack'] def find_matching_paren(text, start): @@ -61,6 +61,30 @@ def replace_except(m): return text +def patch_old_style_middleware(text): + """Convert old-style middleware classes to use MiddlewareMixin for Django 2+ compatibility.""" + if 'def process_request' not in text and 'def process_response' not in text: + return text + if 'MiddlewareMixin' in text: + return text + # Replace class Foo(object): with class Foo(MiddlewareMixin): + text = re.sub( + r'(class\s+\w+Middleware)\(object\)', + r'\1(MiddlewareMixin)', + text, + ) + # Add the import if we made a substitution + if 'MiddlewareMixin' in text and 'from django.utils.deprecation import MiddlewareMixin' not in text: + text = 'from django.utils.deprecation import MiddlewareMixin\n' + text + return text + + +def patch_celery5_imports(text): + """Fix celery imports removed in Celery 5.""" + text = text.replace('from celery.task import Task', 'from celery import Task') + return text + + def patch_django3_imports(text): """Replace removed Django 3 imports with their Python 3 equivalents.""" replacements = [ @@ -110,6 +134,8 @@ def find_package_dir(pkg_name): patched = patch_subfieldbase(patched) patched = patch_except_syntax(patched) patched = patch_django3_imports(patched) + patched = patch_celery5_imports(patched) + patched = patch_old_style_middleware(patched) if patched != text: py_file.write_text(patched) print(f"Patched: {py_file}") diff --git a/writeit/__init__.py b/writeit/__init__.py index e69de29b..c6ea3667 100644 --- a/writeit/__init__.py +++ b/writeit/__init__.py @@ -0,0 +1,15 @@ +# Monkey-patch removed Django APIs needed by old third-party packages +import django.conf.urls + +if not hasattr(django.conf.urls, 'patterns'): + def patterns(prefix, *args): + """Compatibility shim for django.conf.urls.patterns (removed in Django 2.0).""" + from django.urls import re_path + pattern_list = [] + for t in args: + if isinstance(t, (list, tuple)): + t = re_path(*t) + pattern_list.append(t) + return pattern_list + + django.conf.urls.patterns = patterns diff --git a/writeit/celery.py b/writeit/celery.py index c976f0ad..3630bd53 100644 --- a/writeit/celery.py +++ b/writeit/celery.py @@ -7,6 +7,7 @@ # set the default Django settings module for the 'celery' program. os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'writeit.settings') +import writeit.compat # noqa: F401 - patches removed Django APIs for old third-party packages from django.conf import settings # noqa app = Celery('writeit') diff --git a/writeit/middleware.py b/writeit/middleware.py index a293aa10..e4ac330c 100644 --- a/writeit/middleware.py +++ b/writeit/middleware.py @@ -1,6 +1,54 @@ +import re import threading -class SubdomainInThreadLocalStorageMiddleware(object): +from django.conf import settings +from django.utils.cache import patch_vary_headers +from django.utils.deprecation import MiddlewareMixin + +lower = lambda s: s.lower() if s else s + + +class SubdomainURLRoutingMiddleware(MiddlewareMixin): + """Django 3 compatible replacement for subdomains.middleware.SubdomainURLRoutingMiddleware.""" + + def get_domain_for_request(self, request): + return lower(settings.SESSION_COOKIE_DOMAIN) or lower(request.get_host()) + + def process_request(self, request): + domain = self.get_domain_for_request(request) + host = lower(request.get_host()) + pattern = r'^(?:(?P.*?)\.)?%s(?::.*)?$' % re.escape(domain) + matches = re.match(pattern, host) + if matches: + request.subdomain = matches.group('subdomain') + else: + request.subdomain = None + + urlconf = None + if hasattr(settings, 'SUBDOMAIN_URLCONFS'): + urlconf = settings.SUBDOMAIN_URLCONFS.get(request.subdomain) + if urlconf is not None: + request.urlconf = urlconf + + def process_response(self, request, response): + if getattr(settings, 'FORCE_VARY_ON_HOST', True): + patch_vary_headers(response, ('Host',)) + return response + + +class PaginationMiddleware(MiddlewareMixin): + """Django 3 compatible replacement for pagination.middleware.PaginationMiddleware.""" + + def process_request(self, request): + def get_page(suffix=''): + try: + return int(request.GET.get('page%s' % suffix, 1)) + except (ValueError, TypeError): + return 1 + request.page = get_page + + +class SubdomainInThreadLocalStorageMiddleware(MiddlewareMixin): tls = threading.local() diff --git a/writeit/settings.py b/writeit/settings.py index 8d37e55d..89085358 100644 --- a/writeit/settings.py +++ b/writeit/settings.py @@ -176,11 +176,11 @@ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", - "subdomains.middleware.SubdomainURLRoutingMiddleware", + "writeit.middleware.SubdomainURLRoutingMiddleware", "writeit.middleware.SubdomainInThreadLocalStorageMiddleware", "nuntium.middleware.InstanceLocaleMiddleware", "django.middleware.common.CommonMiddleware", - "pagination.middleware.PaginationMiddleware", + "writeit.middleware.PaginationMiddleware", # Uncomment the next line for simple clickjacking protection: # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) From 9f9be451a74a188395a0eab5f327db7effb20c6a Mon Sep 17 00:00:00 2001 From: michaelglenister Date: Wed, 18 Feb 2026 09:32:20 +0200 Subject: [PATCH 26/26] Fix celery for Django 3 --- global_test_case.py | 3 +-- writeit/settings.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/global_test_case.py b/global_test_case.py index 1088c16b..e0a93706 100644 --- a/global_test_case.py +++ b/global_test_case.py @@ -156,11 +156,10 @@ def setUp(self): super(SearchIndexTestCase, self).setUp() call_command('rebuild_index', verbosity=0, interactive=False) -from djcelery.contrib.test_runner import CeleryTestSuiteRunner from django_nose import NoseTestSuiteRunner -class WriteItTestRunner(CeleryTestSuiteRunner, NoseTestSuiteRunner): +class WriteItTestRunner(NoseTestSuiteRunner): def run_tests(self, test_labels, extra_tests=None, **kwargs): # don't show logging messages while testing diff --git a/writeit/settings.py b/writeit/settings.py index 89085358..eec84b0f 100644 --- a/writeit/settings.py +++ b/writeit/settings.py @@ -361,7 +361,7 @@ BROKER_URL = CELERY_BROKER_URL CELERY_ACCEPT_CONTENT = ["pickle"] CELERY_TASK_SERIALIZER = "pickle" -CELERY_RESULT_BACKEND = "djcelery.backends.database:DatabaseBackend" +CELERY_RESULT_BACKEND = "django-db" from celery.schedules import crontab