diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 569822798..e2ab2c390 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: name: Packaging strategy: matrix: - python-version: ["3.11"] + python-version: ["3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/strings.yml b/.github/workflows/strings.yml index cfc580b9a..cf75b54a1 100644 --- a/.github/workflows/strings.yml +++ b/.github/workflows/strings.yml @@ -24,10 +24,10 @@ jobs: name: Check gettext syntax steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - uses: actions/cache@v4 with: path: ~/.cache/pip @@ -49,10 +49,10 @@ jobs: name: Spellcheck steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - uses: actions/cache@v4 with: path: ~/.cache/pip diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 4ecec238f..1f74dfe7d 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -24,10 +24,10 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - uses: actions/cache@v4 with: path: ~/.cache/pip @@ -44,10 +44,10 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - uses: actions/cache@v4 with: path: ~/.cache/pip @@ -64,10 +64,10 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - name: Install Dependencies run: pip3 install licenseheaders - name: Run licenseheaders diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 68e8639fb..c1d228e28 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,13 +23,15 @@ jobs: name: Tests strategy: matrix: - python-version: ["3.10", "3.11", "3.13"] + python-version: ["3.11", "3.13", "3.14"] database: [sqlite, postgres] exclude: - database: sqlite python-version: "3.10" - database: sqlite python-version: "3.11" + - database: sqlite + python-version: "3.12" services: postgres: image: postgres:15 @@ -81,4 +83,4 @@ jobs: file: src/coverage.xml token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false - if: matrix.database == 'postgres' && matrix.python-version == '3.11' + if: matrix.database == 'postgres' && matrix.python-version == '3.13' diff --git a/pyproject.toml b/pyproject.toml index 37fa06eed..44901138b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "pretix" dynamic = ["version"] description = "Reinventing presales, one ticket at a time" readme = "README.rst" -requires-python = ">=3.10" +requires-python = ">=3.11" license = {file = "LICENSE"} keywords = ["tickets", "web", "shop", "ecommerce"] authors = [ @@ -19,10 +19,11 @@ classifiers = [ "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Environment :: Web Environment", "License :: OSI Approved :: GNU Affero General Public License v3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Framework :: Django :: 4.2", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Framework :: Django :: 5.2", ] dependencies = [ @@ -36,7 +37,7 @@ dependencies = [ "css-inline==0.20.*", "defusedcsv>=1.1.0", "dnspython==2.*", - "Django[argon2]==4.2.*,>=4.2.26", + "Django[argon2]==5.2.*", "django-bootstrap3==26.1", "django-compressor==4.6.0", "django-countries==8.2.*", diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index ca29fd10a..0dec88e90 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -45,7 +45,6 @@ import pycountry from django import forms from django.conf import settings from django.contrib import messages -from django.contrib.gis.geoip2 import GeoIP2 from django.core.exceptions import ValidationError from django.core.files.uploadedfile import SimpleUploadedFile from django.core.validators import ( @@ -102,6 +101,7 @@ from pretix.helpers.countries import ( from pretix.helpers.escapejson import escapejson_attr from pretix.helpers.http import get_client_ip from pretix.helpers.i18n import get_format_without_seconds +from pretix.helpers.security import get_geoip from pretix.presale.signals import question_form_fields logger = logging.getLogger(__name__) @@ -393,7 +393,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget): def guess_country_from_request(request, event): if settings.HAS_GEOIP: - g = GeoIP2() + g = get_geoip() try: res = g.country(get_client_ip(request)) if res['country_code'] and len(res['country_code']) == 2: diff --git a/src/pretix/base/management/commands/makemigrations.py b/src/pretix/base/management/commands/makemigrations.py index 7b88011fb..b5e7c781b 100644 --- a/src/pretix/base/management/commands/makemigrations.py +++ b/src/pretix/base/management/commands/makemigrations.py @@ -36,8 +36,9 @@ from django.core.management.commands.makemigrations import Command as Parent from ._migrations import monkeypatch_migrations -monkeypatch_migrations() - class Command(Parent): - pass + + def handle(self, *args, **kwargs): + monkeypatch_migrations() + return super().handle(*args, **kwargs) diff --git a/src/pretix/base/migrations/0234_total_ordering.py b/src/pretix/base/migrations/0234_total_ordering.py index dfbd53d53..9cd089813 100644 --- a/src/pretix/base/migrations/0234_total_ordering.py +++ b/src/pretix/base/migrations/0234_total_ordering.py @@ -41,16 +41,20 @@ class Migration(migrations.Migration): name='datetime', field=models.DateTimeField(), ), - migrations.AlterIndexTogether( - name='logentry', - index_together={('datetime', 'id')}, + migrations.AddIndex( + 'logentry', + models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b1fe5a_idx"), ), - migrations.AlterIndexTogether( - name='order', - index_together={('datetime', 'id'), ('last_modified', 'id')}, + migrations.AddIndex( + 'order', + models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"), ), - migrations.AlterIndexTogether( - name='transaction', - index_together={('datetime', 'id')}, + migrations.AddIndex( + 'order', + models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"), + ), + migrations.AddIndex( + 'transaction', + models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b20405_idx"), ), ] diff --git a/src/pretix/base/migrations/0236_reusable_media.py b/src/pretix/base/migrations/0236_reusable_media.py index 95eb93890..11c9a9df3 100644 --- a/src/pretix/base/migrations/0236_reusable_media.py +++ b/src/pretix/base/migrations/0236_reusable_media.py @@ -61,7 +61,10 @@ class Migration(migrations.Migration): options={ 'ordering': ('identifier', 'type', 'organizer'), 'unique_together': {('identifier', 'type', 'organizer')}, - 'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')}, + 'indexes': [ + models.Index(fields=('identifier', 'type', 'organizer'), name='reusable_medium_organizer_index'), + models.Index(fields=('updated', 'id'), name="pretixbase__updated_093277_idx") + ], }, bases=(models.Model, pretix.base.models.base.LoggingMixin), ), diff --git a/src/pretix/base/migrations/0246_bigint.py b/src/pretix/base/migrations/0246_bigint.py index ebfedcbad..625cf3c64 100644 --- a/src/pretix/base/migrations/0246_bigint.py +++ b/src/pretix/base/migrations/0246_bigint.py @@ -9,31 +9,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RenameIndex( - model_name="logentry", - new_name="pretixbase__datetim_b1fe5a_idx", - old_fields=("datetime", "id"), - ), - migrations.RenameIndex( - model_name="order", - new_name="pretixbase__datetim_66aff0_idx", - old_fields=("datetime", "id"), - ), - migrations.RenameIndex( - model_name="order", - new_name="pretixbase__last_mo_4ebf8b_idx", - old_fields=("last_modified", "id"), - ), - migrations.RenameIndex( - model_name="reusablemedium", - new_name="pretixbase__updated_093277_idx", - old_fields=("updated", "id"), - ), - migrations.RenameIndex( - model_name="transaction", - new_name="pretixbase__datetim_b20405_idx", - old_fields=("datetime", "id"), - ), migrations.AlterField( model_name="attendeeprofile", name="id", diff --git a/src/pretix/base/migrations/0260_alter_reusablemedium_index_together.py b/src/pretix/base/migrations/0260_alter_reusablemedium_index_together.py index 123fc37f7..9dbbd057c 100644 --- a/src/pretix/base/migrations/0260_alter_reusablemedium_index_together.py +++ b/src/pretix/base/migrations/0260_alter_reusablemedium_index_together.py @@ -1,6 +1,6 @@ # Generated by Django 4.2.10 on 2024-04-02 15:16 -from django.db import migrations +from django.db import migrations, models class Migration(migrations.Migration): @@ -10,8 +10,8 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AlterIndexTogether( - name="reusablemedium", - index_together=set(), + migrations.RemoveIndex( + "reusablemedium", + 'reusable_medium_organizer_index', ), ] diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index 43b5439ef..480564f2b 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -88,7 +88,7 @@ class LogEntry(models.Model): class Meta: ordering = ('-datetime', '-id') - indexes = [models.Index(fields=["datetime", "id"])] + indexes = [models.Index(fields=["datetime", "id"], name="pretixbase__datetim_b1fe5a_idx")] def display(self): from pretix.base.logentrytype_registry import log_entry_types diff --git a/src/pretix/base/models/media.py b/src/pretix/base/models/media.py index be8a0af9d..d14dbdcc1 100644 --- a/src/pretix/base/models/media.py +++ b/src/pretix/base/models/media.py @@ -122,7 +122,7 @@ class ReusableMedium(LoggedModel): class Meta: unique_together = (("identifier", "type", "organizer"),) indexes = [ - models.Index(fields=("updated", "id")), + models.Index(fields=("updated", "id"), name="pretixbase__updated_093277_idx"), ] ordering = "identifier", "type", "organizer" diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 466af8b0d..c38131f36 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -336,8 +336,8 @@ class Order(LockModel, LoggedModel): verbose_name_plural = _("Orders") ordering = ("-datetime", "-pk") indexes = [ - models.Index(fields=["datetime", "id"]), - models.Index(fields=["last_modified", "id"]), + models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"), + models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"), ] constraints = [ models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"), @@ -3080,7 +3080,7 @@ class Transaction(models.Model): class Meta: ordering = 'datetime', 'pk' indexes = [ - models.Index(fields=['datetime', 'id']) + models.Index(fields=['datetime', 'id'], name="pretixbase__datetim_b20405_idx") ] def save(self, *args, **kwargs): diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index a5682576e..108b24b0f 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -32,6 +32,7 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. +import logging import warnings from typing import Any, Callable, Generic, List, Tuple, TypeVar @@ -48,6 +49,8 @@ from .plugins import ( PLUGIN_LEVEL_ORGANIZER, ) +logger = logging.getLogger(__name__) + app_cache = {} T = TypeVar('T') @@ -60,23 +63,25 @@ def _populate_app_cache(): def get_defining_app(o): # If sentry packed this in a wrapper, unpack that - if "sentry" in o.__module__: + module = getattr(o, "__module__", None) + if module and "sentry" in module: o = o.__wrapped__ if hasattr(o, "__mocked_app"): return o.__mocked_app # Find the Django application this belongs to - searchpath = o.__module__ + searchpath = module or getattr(o.__class__, "__module__", None) or "" # Core modules are always active - if any(searchpath.startswith(cm) for cm in settings.CORE_MODULES): + if searchpath and any(searchpath.startswith(cm) for cm in settings.CORE_MODULES): return 'CORE' if not app_cache: _populate_app_cache() - while True: + app = None + while searchpath: app = app_cache.get(searchpath) if "." not in searchpath or app: break @@ -157,7 +162,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal): if not app_cache: _populate_app_cache() - for receiver in self._sorted_receivers(sender): + for receiver in self._live_receivers(sender)[0]: if self._is_receiver_active(sender, receiver): response = receiver(signal=self, sender=sender, **named) responses.append((receiver, response)) @@ -179,7 +184,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal): if not app_cache: _populate_app_cache() - for receiver in self._sorted_receivers(sender): + for receiver in self._live_receivers(sender)[0]: if self._is_receiver_active(sender, receiver): named[chain_kwarg_name] = response response = receiver(signal=self, sender=sender, **named) @@ -204,7 +209,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal): if not app_cache: _populate_app_cache() - for receiver in self._sorted_receivers(sender): + for receiver in self._live_receivers(sender)[0]: if self._is_receiver_active(sender, receiver): try: response = receiver(signal=self, sender=sender, **named) @@ -214,17 +219,35 @@ class PluginSignal(Generic[T], django.dispatch.Signal): responses.append((receiver, response)) return responses - def _sorted_receivers(self, sender): - orig_list = self._live_receivers(sender) + def asend(self, sender: T, **named): + raise NotImplementedError() # NOQA + + def asend_robust(self, sender: T, **named): + raise NotImplementedError() # NOQA + + def _live_receivers(self, sender): + orig_list, orig_async_list = super()._live_receivers(sender) + + if orig_async_list: + logger.error('Async receivers are not supported.') + raise NotImplementedError + + def _getattr_fallback_to_class(obj, key): + return getattr(obj, key, getattr(obj.__class__, key)) + + def _is_core_module(receiver): + m = _getattr_fallback_to_class(receiver, "__module__") + return any(m.startswith(c) for c in settings.CORE_MODULES) + sorted_list = sorted( orig_list, key=lambda receiver: ( - 0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1, - receiver.__module__, - receiver.__name__, + 0 if _is_core_module(receiver) else 1, + _getattr_fallback_to_class(receiver, "__module__"), + _getattr_fallback_to_class(receiver, "__name__"), ) ) - return sorted_list + return sorted_list, [] class EventPluginSignal(PluginSignal[Event]): @@ -300,23 +323,41 @@ class GlobalSignal(django.dispatch.Signal): if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS: return response - for receiver in self._live_receivers(sender): + for receiver in self._live_receivers(sender)[0]: named[chain_kwarg_name] = response response = receiver(signal=self, sender=sender, **named) return response + def asend(self, sender: T, **named): + raise NotImplementedError() # NOQA + + def asend_robust(self, sender: T, **named): + raise NotImplementedError() # NOQA + def _live_receivers(self, sender): # Ensure consistent sorting of receivers - orig_list = super()._live_receivers(sender) + orig_list, orig_async_list = super()._live_receivers(sender) + + if orig_async_list: + logger.error('Async receivers are not supported.') + raise NotImplementedError + + def _getattr_fallback_to_class(obj, key): + return getattr(obj, key, getattr(obj.__class__, key)) + + def _is_core_module(receiver): + m = _getattr_fallback_to_class(receiver, "__module__") + return any(m.startswith(c) for c in settings.CORE_MODULES) + sorted_list = sorted( orig_list, key=lambda receiver: ( - 0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1, - receiver.__module__, - receiver.__name__, + 0 if _is_core_module(receiver) else 1, + _getattr_fallback_to_class(receiver, "__module__"), + _getattr_fallback_to_class(receiver, "__name__"), ) ) - return sorted_list + return sorted_list, [] class DeprecatedSignal(GlobalSignal): diff --git a/src/pretix/helpers/security.py b/src/pretix/helpers/security.py index 6a67bbc69..d09fbe0f3 100644 --- a/src/pretix/helpers/security.py +++ b/src/pretix/helpers/security.py @@ -25,7 +25,7 @@ import time from django.conf import settings from django.contrib.auth import login as auth_login -from django.contrib.gis.geoip2 import GeoIP2 +from django.contrib.gis import geoip2 from django.core.cache import cache from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ @@ -63,14 +63,20 @@ def get_user_agent_hash(request): _geoip = None -def _get_country(request): +def get_geoip() -> geoip2.GeoIP2: + # See https://code.djangoproject.com/ticket/36988#ticket global _geoip - if not _geoip: - _geoip = GeoIP2() + geoip2.SUPPORTED_DATABASE_TYPES.add("Geoacumen-Country") + if not _geoip: + _geoip = geoip2.GeoIP2() + return _geoip + + +def _get_country(request): try: - res = _geoip.country(get_client_ip(request)) + res = get_geoip().country(get_client_ip(request)) except AddressNotFoundError: return None return res['country_code'] diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index 9de49b067..68c6ea4e6 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -49,7 +49,7 @@ from django.views.decorators.cache import cache_page from django.views.decorators.gzip import gzip_page from django.views.decorators.http import condition from django.views.i18n import ( - JavaScriptCatalog, get_formats, js_catalog_template, + JavaScriptCatalog, builtin_template_path, get_formats, ) from lxml import html @@ -170,7 +170,8 @@ def generate_widget_js(version, lang): 'September', 'October', 'November', 'December' ) catalog = dict((k, v) for k, v in catalog.items() if k.startswith('widget\u0004') or k in str_wl) - template = Engine().from_string(js_catalog_template) + with builtin_template_path("i18n_catalog.js").open(encoding="utf-8") as fh: + template = Engine().from_string(fh.read()) context = Context({ 'catalog_str': indent(json.dumps( catalog, sort_keys=True, indent=2)) if catalog else None, diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 508b185b3..21b54a833 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -534,6 +534,7 @@ X_FRAME_OPTIONS = 'DENY' # URL settings ROOT_URLCONF = 'pretix.multidomain.maindomain_urlconf' +FORMS_URLFIELD_ASSUME_HTTPS = True # transitional for django 6.0 WSGI_APPLICATION = 'pretix.wsgi.application' diff --git a/src/setup.cfg b/src/setup.cfg index e633a9958..41d65a4bc 100644 --- a/src/setup.cfg +++ b/src/setup.cfg @@ -23,9 +23,7 @@ filterwarnings = error ignore:.*invalid escape sequence.*: ignore:The 'warn' method is deprecated:DeprecationWarning - ignore::django.utils.deprecation.RemovedInDjango51Warning:django.core.files.storage - ignore:.*index_together.*:django.utils.deprecation.RemovedInDjango51Warning: - ignore:.*get_storage_class.*:django.utils.deprecation.RemovedInDjango51Warning:compressor + ignore::django.utils.deprecation.RemovedInDjango60Warning: ignore:.*This signal will soon be only available for plugins that declare to be organizer-level.*:DeprecationWarning: ignore::DeprecationWarning:mt940 ignore::DeprecationWarning:cbor2 diff --git a/src/tests/concurrency_tests/conftest.py b/src/tests/concurrency_tests/conftest.py index eea651711..cbe5cc0ab 100644 --- a/src/tests/concurrency_tests/conftest.py +++ b/src/tests/concurrency_tests/conftest.py @@ -19,14 +19,13 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import aiohttp import pytest import pytest_asyncio from django.utils.timezone import now from django_scopes import scopes_disabled -from pytz import UTC from pretix.base.models import ( Device, Event, Item, Organizer, Quota, SeatingPlan, @@ -53,7 +52,7 @@ def organizer(): def event(organizer): e = Event.objects.create( organizer=organizer, name='Dummy', slug='dummy', - date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC), + date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=timezone.utc), presale_end=now() + timedelta(days=300), plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf', is_public=True, live=True @@ -109,8 +108,8 @@ def customer(event, membership_type): def membership(event, membership_type, customer): return customer.memberships.create( membership_type=membership_type, - date_start=datetime(2017, 1, 1, 0, 0, tzinfo=UTC), - date_end=datetime(2099, 1, 1, 0, 0, tzinfo=UTC), + date_start=datetime(2017, 1, 1, 0, 0, tzinfo=timezone.utc), + date_end=datetime(2099, 1, 1, 0, 0, tzinfo=timezone.utc), )